mirror of
https://github.com/MisskeyIO/misskey
synced 2025-01-24 02:33:48 +09:00
Merge tag '2024.5.0-io.5' into bun
This commit is contained in:
commit
22fe01c172
@ -1 +1 @@
|
||||
FROM mcr.microsoft.com/devcontainers/javascript-node:0-18
|
||||
FROM mcr.microsoft.com/devcontainers/javascript-node:22
|
||||
|
@ -4,10 +4,10 @@
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspace",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "20"
|
||||
"ghcr.io/devcontainers/features/node:latest": {
|
||||
"version": "22"
|
||||
},
|
||||
"ghcr.io/devcontainers-contrib/features/corepack:1": {}
|
||||
"ghcr.io/devcontainers-contrib/features/pnpm:latest": {}
|
||||
},
|
||||
"forwardPorts": [3000],
|
||||
"postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh",
|
||||
|
@ -4,8 +4,6 @@ set -xe
|
||||
|
||||
sudo chown -R node /workspace
|
||||
git submodule update --init
|
||||
corepack install
|
||||
corepack enable
|
||||
pnpm config set store-dir /home/node/.local/share/pnpm/store
|
||||
pnpm install --frozen-lockfile
|
||||
cp .devcontainer/devcontainer.yml .config/default.yml
|
||||
|
@ -1,3 +1,5 @@
|
||||
**/.git
|
||||
|
||||
.autogen
|
||||
.github
|
||||
.travis
|
||||
@ -7,24 +9,15 @@ Dockerfile
|
||||
build/
|
||||
built/
|
||||
db/
|
||||
compose.yml
|
||||
docker-compose.yml
|
||||
node_modules/
|
||||
packages/*/node_modules
|
||||
redis/
|
||||
files/
|
||||
fluent-emojis/
|
||||
.pnp.*
|
||||
|
||||
# .yarn関連
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
.pnpm-store
|
||||
|
||||
.idea/
|
||||
packages/*/.vscode/
|
||||
packages/backend/test/compose.yml
|
||||
packages/backend/test/docker-compose.yml
|
||||
|
2
.github/ISSUE_TEMPLATE/01_bug-report.yml
vendored
2
.github/ISSUE_TEMPLATE/01_bug-report.yml
vendored
@ -75,7 +75,7 @@ body:
|
||||
Examples:
|
||||
* Installation Method or Hosting Service: docker compose, k8s/docker, systemd, "Misskey install shell script", development environment
|
||||
* Misskey: 13.x.x
|
||||
* Node: 20.x.x
|
||||
* Node: 22.x.x
|
||||
* PostgreSQL: 15.x.x
|
||||
* Redis: 7.x.x
|
||||
* OS and Architecture: Ubuntu 22.04.2 LTS aarch64
|
||||
|
10
.github/workflows/api-misskey-js.yml
vendored
10
.github/workflows/api-misskey-js.yml
vendored
@ -15,9 +15,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- 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
|
||||
uses: actions/setup-node@v4.1.0
|
||||
|
@ -22,7 +22,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.1
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Check version
|
||||
run: |
|
||||
if [ "$(jq -r '.version' package.json)" != "$(jq -r '.version' packages/misskey-js/package.json)" ]; then
|
||||
|
3
.github/workflows/docker-beta.yml
vendored
3
.github/workflows/docker-beta.yml
vendored
@ -14,6 +14,9 @@ jobs:
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
3
.github/workflows/docker-host.yml
vendored
3
.github/workflows/docker-host.yml
vendored
@ -16,6 +16,9 @@ jobs:
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
3
.github/workflows/docker-io.yml
vendored
3
.github/workflows/docker-io.yml
vendored
@ -16,6 +16,9 @@ jobs:
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
3
.github/workflows/dockle.yml
vendored
3
.github/workflows/dockle.yml
vendored
@ -15,6 +15,9 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Build an image from Dockerfile
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
|
9
.github/workflows/lint.yml
vendored
9
.github/workflows/lint.yml
vendored
@ -25,7 +25,7 @@ jobs:
|
||||
pnpm_install:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4.1.1
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
@ -36,7 +36,6 @@ jobs:
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
- run: corepack enable
|
||||
- run: pnpm i --frozen-lockfile
|
||||
|
||||
lint:
|
||||
@ -51,7 +50,7 @@ jobs:
|
||||
- sw
|
||||
- misskey-js
|
||||
steps:
|
||||
- uses: actions/checkout@v4.1.1
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
@ -62,7 +61,6 @@ jobs:
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
- run: corepack enable
|
||||
- run: pnpm i --frozen-lockfile
|
||||
- run: pnpm --filter ${{ matrix.workspace }} run eslint
|
||||
|
||||
@ -76,7 +74,7 @@ jobs:
|
||||
- backend
|
||||
- misskey-js
|
||||
steps:
|
||||
- uses: actions/checkout@v4.1.1
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
@ -87,7 +85,6 @@ jobs:
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
- run: corepack enable
|
||||
- run: pnpm i --frozen-lockfile
|
||||
- run: pnpm -r run build:tsc
|
||||
if: ${{ matrix.workspace == 'backend' }}
|
||||
|
18
.github/workflows/test-backend.yml
vendored
18
.github/workflows/test-backend.yml
vendored
@ -23,8 +23,8 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
bun-version: [1.1.26]
|
||||
node-version: [22.x]
|
||||
bun-version: [1.1.x]
|
||||
|
||||
services:
|
||||
postgres:
|
||||
@ -50,7 +50,7 @@ jobs:
|
||||
- 56312:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4.1.1
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
@ -69,7 +69,6 @@ jobs:
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ matrix.bun-version }}
|
||||
- run: corepack enable
|
||||
- run: pnpm i --frozen-lockfile
|
||||
- name: Check pnpm-lock.yaml
|
||||
run: git diff --exit-code pnpm-lock.yaml
|
||||
@ -80,7 +79,7 @@ jobs:
|
||||
- name: Test
|
||||
run: pnpm --filter backend test-and-coverage
|
||||
- name: Upload to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./packages/backend/coverage/coverage-final.json
|
||||
@ -90,8 +89,8 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
bun-version: [1.1.26]
|
||||
node-version: [22.x]
|
||||
bun-version: [1.1.x]
|
||||
|
||||
services:
|
||||
postgres:
|
||||
@ -117,7 +116,7 @@ jobs:
|
||||
- 56312:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4.1.1
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
@ -134,7 +133,6 @@ jobs:
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ matrix.bun-version }}
|
||||
- run: corepack enable
|
||||
- run: pnpm i --frozen-lockfile
|
||||
- name: Check pnpm-lock.yaml
|
||||
run: git diff --exit-code pnpm-lock.yaml
|
||||
@ -145,7 +143,7 @@ jobs:
|
||||
- name: Test
|
||||
run: pnpm --filter backend test-and-coverage:e2e
|
||||
- name: Upload to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./packages/backend/coverage/coverage-final.json
|
||||
|
7
.github/workflows/test-frontend.yml
vendored
7
.github/workflows/test-frontend.yml
vendored
@ -28,10 +28,10 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
node-version: [22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4.1.1
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
@ -44,7 +44,6 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'pnpm'
|
||||
- run: corepack enable
|
||||
- run: pnpm i --frozen-lockfile
|
||||
- name: Check pnpm-lock.yaml
|
||||
run: git diff --exit-code pnpm-lock.yaml
|
||||
@ -55,7 +54,7 @@ jobs:
|
||||
- name: Test
|
||||
run: pnpm --filter frontend test-and-coverage
|
||||
- name: Upload Coverage
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./packages/frontend/coverage/coverage-final.json
|
||||
|
14
.github/workflows/test-misskey-js.yml
vendored
14
.github/workflows/test-misskey-js.yml
vendored
@ -23,14 +23,20 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
node-version: [22.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- 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 }}
|
||||
uses: actions/setup-node@v4.1.0
|
||||
@ -53,7 +59,7 @@ jobs:
|
||||
CI: true
|
||||
|
||||
- name: Upload Coverage
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./packages/misskey-js/coverage/coverage-final.json
|
||||
|
5
.github/workflows/test-production.yml
vendored
5
.github/workflows/test-production.yml
vendored
@ -18,10 +18,10 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
node-version: [22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4.1.1
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
@ -34,7 +34,6 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'pnpm'
|
||||
- run: corepack enable
|
||||
- run: pnpm i --frozen-lockfile
|
||||
- name: Check pnpm-lock.yaml
|
||||
run: git diff --exit-code pnpm-lock.yaml
|
||||
|
7
.github/workflows/validate-api-json.yml
vendored
7
.github/workflows/validate-api-json.yml
vendored
@ -19,11 +19,11 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
bun-version: [1.1.26]
|
||||
node-version: [22.x]
|
||||
bun-version: [1.1.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4.1.1
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
@ -42,7 +42,6 @@ jobs:
|
||||
bun-version: ${{ matrix.bun-version }}
|
||||
- name: Install Redocly CLI
|
||||
run: npm i -g @redocly/cli
|
||||
- run: corepack enable
|
||||
- run: pnpm i --frozen-lockfile
|
||||
- name: Check pnpm-lock.yaml
|
||||
run: git diff --exit-code pnpm-lock.yaml
|
||||
|
14
.gitignore
vendored
14
.gitignore
vendored
@ -9,17 +9,6 @@
|
||||
node_modules
|
||||
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-store
|
||||
|
||||
@ -35,8 +24,11 @@ coverage
|
||||
!/.config/example.yml
|
||||
!/.config/docker_example.yml
|
||||
!/.config/docker_example.env
|
||||
compose.yml
|
||||
docker-compose.yml
|
||||
!/.devcontainer/compose.yml
|
||||
!/.devcontainer/docker-compose.yml
|
||||
!/packages/backend/test/compose.yml
|
||||
!/packages/backend/test/docker-compose.yml
|
||||
|
||||
# misskey
|
||||
|
@ -1 +1 @@
|
||||
20
|
||||
22
|
||||
|
26
Dockerfile
26
Dockerfile
@ -1,6 +1,7 @@
|
||||
# syntax = docker/dockerfile:1.4
|
||||
|
||||
ARG NODE_VERSION=20
|
||||
ARG NODE_VERSION=22
|
||||
ARG BUN_VERSION=1.1
|
||||
|
||||
# build assets & compile TypeScript
|
||||
|
||||
@ -14,11 +15,10 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
&& apt-get install -yqq --no-install-recommends \
|
||||
build-essential
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /misskey
|
||||
|
||||
COPY --link pnpm-lock.yaml ./
|
||||
RUN npm install -g pnpm
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
pnpm fetch --ignore-scripts
|
||||
|
||||
@ -36,11 +36,7 @@ RUN pnpm i --frozen-lockfile --aggregate-output --offline \
|
||||
|
||||
COPY --link . ./
|
||||
|
||||
ARG NODE_ENV=production
|
||||
|
||||
RUN git submodule update --init
|
||||
RUN pnpm build
|
||||
RUN rm -rf .git/
|
||||
RUN NODE_ENV=production pnpm build
|
||||
|
||||
# build native dependencies for target platform
|
||||
|
||||
@ -50,11 +46,10 @@ RUN apt-get update \
|
||||
&& apt-get install -yqq --no-install-recommends \
|
||||
build-essential
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /misskey
|
||||
|
||||
COPY --link pnpm-lock.yaml ./
|
||||
RUN npm install -g pnpm
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
pnpm fetch --ignore-scripts
|
||||
|
||||
@ -68,14 +63,14 @@ COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bu
|
||||
RUN pnpm i --frozen-lockfile --aggregate-output --offline \
|
||||
&& pnpm rebuild -r
|
||||
|
||||
FROM oven/bun:latest AS runner
|
||||
FROM --platform=$TARGETPLATFORM oven/bun:${BUN_VERSION} AS runner
|
||||
|
||||
ARG UID="991"
|
||||
ARG GID="991"
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ffmpeg tini curl libjemalloc-dev libjemalloc2 \
|
||||
curl ffmpeg libjemalloc-dev libjemalloc2 tini \
|
||||
&& ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so \
|
||||
&& groupadd -g "${GID}" misskey \
|
||||
&& useradd -l -u "${UID}" -g "${GID}" -m -d /misskey misskey \
|
||||
@ -84,9 +79,11 @@ RUN apt-get update \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists
|
||||
|
||||
USER 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/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
|
||||
@ -100,10 +97,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 . ./
|
||||
|
||||
USER misskey
|
||||
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 TF_CPP_MIN_LOG_LEVEL=2
|
||||
ENV NODE_ENV=production
|
||||
ENV COREPACK_ENABLE_NETWORK=0
|
||||
HEALTHCHECK --interval=5s --retries=20 CMD ["/bin/bash", "/misskey/healthcheck.sh"]
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD [ "bun", "run", "migrateandstart:docker" ]
|
||||
|
@ -822,6 +822,7 @@ unmuteThread: "ارفع الكتم عن النقاش"
|
||||
continueThread: "اعرض بقية النقاش"
|
||||
deleteAccountConfirm: "سيحذف حسابك نهائيًا، أتريد المتابعة؟"
|
||||
incorrectPassword: "كلمة السر خاطئة."
|
||||
authenticationFailed: "فشل التوثيق"
|
||||
voteConfirm: "متيقِّن من تصويتك لـ {choice}؟"
|
||||
hide: "إخفاء"
|
||||
welcomeBackWithName: "مرحبًا بك مجددًا {name}"
|
||||
|
@ -815,6 +815,7 @@ unmuteThread: "থ্রেড আনমিউট করুন"
|
||||
continueThread: "আরো থ্রেড দেখুন"
|
||||
deleteAccountConfirm: "আপনার অ্যাকাউন্ট মুছে ফেলা হবে। ঠিক আছে?"
|
||||
incorrectPassword: "আপনার দেওয়া পাসওয়ার্ডটি ভুল।"
|
||||
authenticationFailed: "প্রমাণীকরণ ব্যর্থ হয়েছে।"
|
||||
voteConfirm: "\"{choice}\" এ ভোট দিতে চান?"
|
||||
hide: "লুকান"
|
||||
useDrawerReactionPickerForMobile: "মোবাইলে রিঅ্যাকশন পিকারকে ড্রয়ারে প্রদর্শন করুন"
|
||||
|
@ -896,6 +896,7 @@ followersVisibility: "Visibilitat dels seguidors"
|
||||
continueThread: "Veure la continuació del fil"
|
||||
deleteAccountConfirm: "Això eliminarà el teu compte irreversiblement. Procedir?"
|
||||
incorrectPassword: "Contrasenya incorrecta."
|
||||
authenticationFailed: "Autenticació fallida."
|
||||
voteConfirm: "Confirma el teu vot \"{choice}\""
|
||||
hide: "Amagar"
|
||||
useDrawerReactionPickerForMobile: "Mostrar el selector de reaccions com un calaix al mòbil "
|
||||
|
@ -859,6 +859,7 @@ unmuteThread: "Zrušit ztlumení vlákna"
|
||||
continueThread: "Zobrazit pokračování vlákna"
|
||||
deleteAccountConfirm: "Tohle nenávratně smaže váš účet, chcete pokračovat?"
|
||||
incorrectPassword: "Nesprávné heslo."
|
||||
authenticationFailed: "Ověření selhalo."
|
||||
voteConfirm: "Potvrdit hlas pro \"{choice}\"?"
|
||||
hide: "Skrýt"
|
||||
useDrawerReactionPickerForMobile: "Zobrazit výběr reakcí jako šuplík na mobilním zařízení"
|
||||
|
@ -889,6 +889,7 @@ unmuteThread: "Threadstummschaltung aufheben"
|
||||
continueThread: "Weiteren Threadverlauf anzeigen"
|
||||
deleteAccountConfirm: "Dein Benutzerkonto wird unwiderruflich gelöscht. Trotzdem fortfahren?"
|
||||
incorrectPassword: "Falsches Passwort."
|
||||
authenticationFailed: "Authentifizierung fehlgeschlagen."
|
||||
voteConfirm: "Wirklich für „{choice}“ abstimmen?"
|
||||
hide: "Inhalt verbergen"
|
||||
useDrawerReactionPickerForMobile: "Auf mobilen Geräten ausfahrbare Reaktionsauswahl anzeigen"
|
||||
|
@ -919,6 +919,7 @@ continueThread: "View thread continuation"
|
||||
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?"
|
||||
incorrectPassword: "Incorrect password."
|
||||
authenticationFailed: "Authentication failed."
|
||||
voteConfirm: "Confirm your vote for \"{choice}\"?"
|
||||
hide: "Hide"
|
||||
useDrawerReactionPickerForMobile: "Display reaction picker as drawer on mobile"
|
||||
@ -1296,6 +1297,7 @@ yourNameContainsProhibitedWordsDescription: "If you wish to use this name, pleas
|
||||
thisContentsAreMarkedAsSigninRequiredByAuthor: "Set by the author to require login to view"
|
||||
lockdown: "Lockdown"
|
||||
pleaseSelectAccount: "Select an account"
|
||||
availableRoles: "Available roles"
|
||||
here: "here"
|
||||
mutualLink: "Mutual Link"
|
||||
saveThisFile: "Save this file to Drive"
|
||||
@ -1313,6 +1315,8 @@ pleaseConsentToTracking: "{host} may collect information that may include person
|
||||
consentEssential: "Allow Essential Items"
|
||||
consentAll: "Allow All Items"
|
||||
consentSelected: "Allow Selected Items"
|
||||
emailAddressLogin: "Login with email address"
|
||||
usernameLogin: "Login with username"
|
||||
|
||||
_bubbleGame:
|
||||
howToPlay: "How to play"
|
||||
@ -2433,6 +2437,9 @@ _pages:
|
||||
contentBlocks: "Content"
|
||||
inputBlocks: "Input"
|
||||
specialBlocks: "Special"
|
||||
visibility: "Visibility"
|
||||
public: "Public"
|
||||
private: "Private"
|
||||
blocks:
|
||||
text: "Text"
|
||||
textarea: "Text area"
|
||||
@ -2733,3 +2740,9 @@ _skebStatus:
|
||||
yenX: "JPY {x}"
|
||||
nWorks: "Delivered {n} works"
|
||||
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}"
|
||||
|
@ -896,6 +896,7 @@ followersVisibility: "Visibilidad de seguidores"
|
||||
continueThread: "Ver la continuación del hilo"
|
||||
deleteAccountConfirm: "La cuenta será borrada. ¿Está seguro?"
|
||||
incorrectPassword: "La contraseña es incorrecta"
|
||||
authenticationFailed: "La autenticación falló"
|
||||
voteConfirm: "¿Confirma su voto a {choice}?"
|
||||
hide: "Ocultar"
|
||||
useDrawerReactionPickerForMobile: "Mostrar panel de reacciones en móviles"
|
||||
|
@ -896,6 +896,7 @@ followersVisibility: "Visibilité des abonnés"
|
||||
continueThread: "Afficher la suite du fil"
|
||||
deleteAccountConfirm: "Votre compte sera supprimé. Êtes vous certain ?"
|
||||
incorrectPassword: "Le mot de passe est incorrect."
|
||||
authenticationFailed: "L'authentification a échoué."
|
||||
voteConfirm: "Confirmez-vous votre vote pour « {choice} » ?"
|
||||
hide: "Masquer"
|
||||
useDrawerReactionPickerForMobile: "Afficher le sélecteur de réactions en tant que panneau sur mobile"
|
||||
|
@ -896,6 +896,7 @@ followersVisibility: "Visibilitas pengikut"
|
||||
continueThread: "Lihat lanjutan thread"
|
||||
deleteAccountConfirm: "Akun akan dihapus. Apakah kamu yakin?"
|
||||
incorrectPassword: "Kata sandi salah."
|
||||
authenticationFailed: "Autentikasi gagal."
|
||||
voteConfirm: "Konfirmasi suara kamu untuk ({choice})?"
|
||||
hide: "Sembunyikan"
|
||||
useDrawerReactionPickerForMobile: "Tampilkan bilah reaksi sebagai laci di ponsel"
|
||||
|
24
locales/index.d.ts
vendored
24
locales/index.d.ts
vendored
@ -3703,6 +3703,10 @@ export interface Locale extends ILocale {
|
||||
* パスワードが間違っています。
|
||||
*/
|
||||
"incorrectPassword": string;
|
||||
/**
|
||||
* 認証に失敗しました。
|
||||
*/
|
||||
"authenticationFailed": string;
|
||||
/**
|
||||
* 「{choice}」に投票しますか?
|
||||
*/
|
||||
@ -5318,6 +5322,14 @@ export interface Locale extends ILocale {
|
||||
* 選択した項目のみ許可
|
||||
*/
|
||||
"consentSelected": string;
|
||||
/**
|
||||
* メールアドレスでログイン
|
||||
*/
|
||||
"emailAddressLogin": string;
|
||||
/**
|
||||
* ユーザー名でログイン
|
||||
*/
|
||||
"usernameLogin": string;
|
||||
"_bubbleGame": {
|
||||
/**
|
||||
* 遊び方
|
||||
@ -9497,6 +9509,18 @@ export interface Locale extends ILocale {
|
||||
* 特殊
|
||||
*/
|
||||
"specialBlocks": string;
|
||||
/**
|
||||
* 公開範囲
|
||||
*/
|
||||
"visibility": string;
|
||||
/**
|
||||
* 公開
|
||||
*/
|
||||
"public": string;
|
||||
/**
|
||||
* 非公開
|
||||
*/
|
||||
"private": string;
|
||||
"blocks": {
|
||||
/**
|
||||
* テキスト
|
||||
|
@ -896,6 +896,7 @@ followersVisibility: "Visibilità dei profili che ti seguono"
|
||||
continueThread: "Altre conversazioni"
|
||||
deleteAccountConfirm: "Così verrà eliminato il profilo. Vuoi procedere?"
|
||||
incorrectPassword: "La password è errata."
|
||||
authenticationFailed: "Autenticazione fallita"
|
||||
voteConfirm: "Votare per「{choice}」?"
|
||||
hide: "Nascondere"
|
||||
useDrawerReactionPickerForMobile: "Mostra sul drawer da dispositivo mobile"
|
||||
|
@ -921,6 +921,7 @@ continueThread: "さらにスレッドを見る"
|
||||
deleteAccountConfirm: "アカウントが削除されます。よろしいですか?"
|
||||
deleteAccountConfirmAndWarn: "アカウントが削除されます。\n削除リクエスト後に再ログインすると\nアカウントの削除が中断されてしまいますのでご注意ください。\nよろしいですか?"
|
||||
incorrectPassword: "パスワードが間違っています。"
|
||||
authenticationFailed: "認証に失敗しました。"
|
||||
voteConfirm: "「{choice}」に投票しますか?"
|
||||
hide: "隠す"
|
||||
useDrawerReactionPickerForMobile: "モバイルデバイスのときドロワーで表示"
|
||||
@ -1323,6 +1324,8 @@ pleaseConsentToTracking: "{host}は[プライバシーポリシー]({privacyPoli
|
||||
consentEssential: "必須項目のみ許可"
|
||||
consentAll: "全て許可"
|
||||
consentSelected: "選択した項目のみ許可"
|
||||
emailAddressLogin: "メールアドレスでログイン"
|
||||
usernameLogin: "ユーザー名でログイン"
|
||||
|
||||
_bubbleGame:
|
||||
howToPlay: "遊び方"
|
||||
@ -2495,6 +2498,9 @@ _pages:
|
||||
contentBlocks: "コンテンツ"
|
||||
inputBlocks: "入力"
|
||||
specialBlocks: "特殊"
|
||||
visibility: "公開範囲"
|
||||
public: "公開"
|
||||
private: "非公開"
|
||||
blocks:
|
||||
text: "テキスト"
|
||||
textarea: "テキストエリア"
|
||||
|
@ -896,6 +896,7 @@ followersVisibility: "フォロワーの公開範囲"
|
||||
continueThread: "さらにスレッドを見るで"
|
||||
deleteAccountConfirm: "アカウントを消すで?ええんか?"
|
||||
incorrectPassword: "パスワードがちゃうわ。"
|
||||
authenticationFailed: "認証失敗したで。"
|
||||
voteConfirm: "「{choice}」に投票するんか?"
|
||||
hide: "隠す"
|
||||
useDrawerReactionPickerForMobile: "ケータイとかのときドロワーで表示するで"
|
||||
|
@ -918,6 +918,7 @@ continueThread: "글타래 더 보기"
|
||||
deleteAccountConfirm: "계정이 삭제되고 되돌릴 수 없게 됩니다. 계속하시겠습니까?"
|
||||
deleteAccountConfirmAndWarn: "계정이 삭제됩니다.\n삭제 요청 후 다시 로그인하면 계정 삭제가 중단되어 버립니다.\n계속하시겠습니까?"
|
||||
incorrectPassword: "비밀번호가 올바르지 않습니다."
|
||||
authenticationFailed: "인증에 실패했습니다."
|
||||
voteConfirm: "\"{choice}\"에 투표하시겠습니까?"
|
||||
hide: "숨기기"
|
||||
useDrawerReactionPickerForMobile: "모바일에서 드로어 메뉴로 표시"
|
||||
@ -1293,6 +1294,7 @@ yourNameContainsProhibitedWords: "바꾸려는 이름에 금지된 키워드가
|
||||
yourNameContainsProhibitedWordsDescription: "이름에 금지된 키워드가 있습니다. 이름을 사용해야 하는 경우, 서버 관리자에 문의하세요."
|
||||
lockdown: "잠금"
|
||||
pleaseSelectAccount: "계정을 선택해 주세요"
|
||||
availableRoles: "사용 가능한 역할"
|
||||
here: "여기"
|
||||
mutualLink: "서로링크"
|
||||
saveThisFile: "이 파일을 드라이브에 저장"
|
||||
@ -1310,6 +1312,8 @@ pleaseConsentToTracking: "{host}는 [개인정보 처리방침]({privacyPolicyUr
|
||||
consentEssential: "필수 항목만 허용"
|
||||
consentAll: "모두 허용"
|
||||
consentSelected: "선택한 항목만 허용"
|
||||
emailAddressLogin: "이메일 주소로 로그인"
|
||||
usernameLogin: "사용자명으로 로그인"
|
||||
|
||||
_bubbleGame:
|
||||
howToPlay: "설명"
|
||||
@ -2418,6 +2422,9 @@ _pages:
|
||||
contentBlocks: "콘텐츠"
|
||||
inputBlocks: "입력"
|
||||
specialBlocks: "특수"
|
||||
visibility: "공개 범위"
|
||||
public: "공개"
|
||||
private: "비공개"
|
||||
blocks:
|
||||
text: "텍스트"
|
||||
textarea: "텍스트 영역"
|
||||
@ -2718,3 +2725,9 @@ _skebStatus:
|
||||
yenX: "JPY {x}"
|
||||
nWorks: "납품 실적 {n}건"
|
||||
nRequests: "거래 실적 {n}건"
|
||||
_selfXssPrevention:
|
||||
warning: "경고"
|
||||
title: "「이 화면에 무언가를 붙여넣으라는 메시지」는 모두 *사기*입니다."
|
||||
description1: "여기에 무언가를 붙여넣으면, 악의를 가진 사용자에게 계정을 탈취당하거나 개인정보를 훔쳐갈 수 있습니다."
|
||||
description2: "붙여넣으려는 것이 무엇인지 정확히 이해하지 못하면, %c지금 작업을 중단하고 이 창을 닫으십시오."
|
||||
description3: "자세한 내용은 여기를 확인하십시오. {link}"
|
||||
|
@ -894,6 +894,7 @@ followersVisibility: "Widoczność obserwujących"
|
||||
continueThread: "Pokaż kontynuację wątku"
|
||||
deleteAccountConfirm: "Spowoduje to nieodwracalne usunięcie Twojego konta. Kontynuować?"
|
||||
incorrectPassword: "Nieprawidłowe hasło."
|
||||
authenticationFailed: "Uwierzytelnienie nie powiodło się."
|
||||
voteConfirm: "Potwierdzić swój głos na \"{choice}\"?"
|
||||
hide: "Ukryj"
|
||||
useDrawerReactionPickerForMobile: "Wyświetlaj wybornik reakcji jako szufladę na urządzeniach mobilnych"
|
||||
|
@ -863,6 +863,7 @@ unmuteThread: "Desativar silêncio desta conversa"
|
||||
continueThread: "Ver mais desta conversa"
|
||||
deleteAccountConfirm: "Deseja realmente excluir a conta?"
|
||||
incorrectPassword: "Senha inválida."
|
||||
authenticationFailed: "Falha na autenticação."
|
||||
voteConfirm: "Deseja confirmar o seu voto em \"{choice}\"?"
|
||||
hide: "Ocultar"
|
||||
useDrawerReactionPickerForMobile: "Mostrar em formato de gaveta"
|
||||
|
@ -881,6 +881,7 @@ unmuteThread: "Отменить сокрытие цепочки"
|
||||
continueThread: "Показать следующие ответы"
|
||||
deleteAccountConfirm: "Учётная запись будет безвозвратно удалена. Подтверждаете?"
|
||||
incorrectPassword: "Пароль неверен."
|
||||
authenticationFailed: "Аутентификация не удалась."
|
||||
voteConfirm: "Отдать голос за «{choice}»?"
|
||||
hide: "Спрятать"
|
||||
useDrawerReactionPickerForMobile: "Выдвижная палитра на мобильном устройстве"
|
||||
|
@ -826,6 +826,7 @@ unmuteThread: "Zrušiť stíšenie vlákna"
|
||||
continueThread: "Zobraziť pokračovanie vlákna"
|
||||
deleteAccountConfirm: "Toto nezvrátiteľne vymaže váš účet. Pokračovať?"
|
||||
incorrectPassword: "Nesprávne heslo."
|
||||
authenticationFailed: "Overenie zlyhalo."
|
||||
voteConfirm: "Potvrdzujete svoj hlas za \"{choice}\"?"
|
||||
hide: "Skryť"
|
||||
useDrawerReactionPickerForMobile: "Zobraziť výber reakcií ako šuflík na mobile"
|
||||
|
@ -466,6 +466,7 @@ squareAvatars: "Visa fyrkantiga profilbilder"
|
||||
sent: "Skicka"
|
||||
misskeyUpdated: "Misskey har uppdaterats!"
|
||||
incorrectPassword: "Fel lösenord."
|
||||
authenticationFailed: "Autentisering misslyckades."
|
||||
welcomeBackWithName: "Välkommen tillbaka, {name}"
|
||||
clickToFinishEmailVerification: "Tryck på [{ok}] för att slutföra bekräftelsen på e-postadressen."
|
||||
searchByGoogle: "Sök"
|
||||
|
@ -896,6 +896,7 @@ followersVisibility: "การมองเห็นผู้ที่กำล
|
||||
continueThread: "ดูความต่อเนื่องเธรด"
|
||||
deleteAccountConfirm: "การดำเนินการนี้จะลบบัญชีของคุณอย่างถาวรเลยนะ แน่ใจหรอดำเนินการ?"
|
||||
incorrectPassword: "รหัสผ่านไม่ถูกต้อง"
|
||||
authenticationFailed: "การตรวจสอบตัวตนล้มเหลว"
|
||||
voteConfirm: "ต้องการโหวต “{choice}” ใช่ไหม?"
|
||||
hide: "ซ่อน"
|
||||
useDrawerReactionPickerForMobile: "แสดง ตัวจิ้มรีแอคชั่น เป็นแบบลิ้นชัก เมื่อใช้บนมือถือ"
|
||||
|
@ -822,6 +822,7 @@ unmuteThread: "Скасувати глушіння"
|
||||
continueThread: "Показати продовження треду"
|
||||
deleteAccountConfirm: "Це незворотно видалить ваш акаунт. Продовжити?"
|
||||
incorrectPassword: "Неправильний пароль."
|
||||
authenticationFailed: "Аутентифікація не вдалася."
|
||||
voteConfirm: "Підтверджуєте свій голос за \"{choice}\"?"
|
||||
hide: "Сховати"
|
||||
welcomeBackWithName: "З поверненням, {name}!"
|
||||
|
@ -876,6 +876,7 @@ followersVisibility: "Hiển thị người theo dõi"
|
||||
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?"
|
||||
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}\"?"
|
||||
hide: "Ẩn"
|
||||
useDrawerReactionPickerForMobile: "Hiện bộ chọn biểu cảm dạng xổ ra trên điện thoại"
|
||||
|
@ -899,6 +899,7 @@ followersVisibility: "关注者的公开范围"
|
||||
continueThread: "查看更多帖子"
|
||||
deleteAccountConfirm: "将要删除账户。是否确认?"
|
||||
incorrectPassword: "密码错误"
|
||||
authenticationFailed: "认证失败"
|
||||
voteConfirm: "确定投给 “{choice}” ?"
|
||||
hide: "隐藏"
|
||||
useDrawerReactionPickerForMobile: "在移动设备上使用抽屉显示"
|
||||
|
@ -896,6 +896,7 @@ followersVisibility: "追隨者的可見性"
|
||||
continueThread: "查看更多貼文"
|
||||
deleteAccountConfirm: "將要刪除帳戶。是否確定?"
|
||||
incorrectPassword: "密碼錯誤。"
|
||||
authenticationFailed: "驗證失敗。"
|
||||
voteConfirm: "確定投給「{choice}」?"
|
||||
hide: "隱藏"
|
||||
useDrawerReactionPickerForMobile: "在移動設備上使用抽屜顯示"
|
||||
|
28
package.json
28
package.json
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "2024.5.0-io.4d",
|
||||
"version": "2024.5.0-io.5",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MisskeyIO/misskey.git"
|
||||
},
|
||||
"packageManager": "pnpm@9.12.3",
|
||||
"packageManager": "pnpm@9.15.0",
|
||||
"workspaces": [
|
||||
"packages/frontend",
|
||||
"packages/backend",
|
||||
@ -48,12 +48,12 @@
|
||||
},
|
||||
"resolutions": {
|
||||
"@tensorflow/tfjs-core": "4.22.0",
|
||||
"axios": "1.7.7",
|
||||
"chokidar": "4.0.1",
|
||||
"cookie": "1.0.1",
|
||||
"axios": "1.7.9",
|
||||
"chokidar": "4.0.3",
|
||||
"cookie": "1.0.2",
|
||||
"cookie-signature": "1.2.2",
|
||||
"debug": "4.3.7",
|
||||
"esbuild": "0.24.0",
|
||||
"debug": "4.4.0",
|
||||
"esbuild": "0.24.2",
|
||||
"jpeg-js": "0.4.4",
|
||||
"lodash": "4.17.21",
|
||||
"sharp": "0.33.5",
|
||||
@ -62,21 +62,21 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"cssnano": "7.0.6",
|
||||
"execa": "9.5.1",
|
||||
"execa": "9.5.2",
|
||||
"js-yaml": "4.1.0",
|
||||
"postcss": "8.4.47",
|
||||
"terser": "5.36.0",
|
||||
"typescript": "5.6.3"
|
||||
"postcss": "8.4.49",
|
||||
"terser": "5.37.0",
|
||||
"typescript": "5.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.9.0",
|
||||
"@types/node": "22.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "7.10.0",
|
||||
"@typescript-eslint/parser": "7.10.0",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "13.15.2",
|
||||
"cypress": "13.17.0",
|
||||
"eslint": "8.57.1",
|
||||
"ncp": "2.0.0",
|
||||
"start-server-and-test": "2.0.8"
|
||||
"start-server-and-test": "2.0.9"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tensorflow/tfjs-core": "4.22.0"
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"$schema": "https://swc.rs/schema.json",
|
||||
"jsc": {
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
@ -17,7 +17,8 @@
|
||||
"paths": {
|
||||
"@/*": ["*"]
|
||||
},
|
||||
"target": "es2022"
|
||||
"target": "es2022",
|
||||
"keepClassNames": true
|
||||
},
|
||||
"minify": false
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ const base = require('./jest.config.cjs')
|
||||
|
||||
module.exports = {
|
||||
...base,
|
||||
globalSetup: "<rootDir>/built-test/entry.js",
|
||||
globalSetup: "<rootDir>/test-server/entry.mjs",
|
||||
setupFilesAfterEnv: ["<rootDir>/test/jest.setup.ts"],
|
||||
testMatch: [
|
||||
"<rootDir>/test/e2e/**/*.ts",
|
||||
|
19
packages/backend/migration/1733563840208-page-visibility.js
Normal file
19
packages/backend/migration/1733563840208-page-visibility.js
Normal file
@ -0,0 +1,19 @@
|
||||
export class PageVisibility1733563840208 {
|
||||
name = 'PageVisibility1733563840208'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TYPE "public"."page_visibility_enum" RENAME TO "page_visibility_enum_old"`);
|
||||
await queryRunner.query(`CREATE TYPE "public"."page_visibility_enum" AS ENUM('public', 'private')`);
|
||||
await queryRunner.query(`ALTER TABLE "page" ALTER COLUMN "visibility" TYPE "public"."page_visibility_enum" USING "visibility"::"text"::"public"."page_visibility_enum"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."page_visibility_enum_old"`);
|
||||
await queryRunner.query(`ALTER TABLE "page" ALTER COLUMN "visibility" SET DEFAULT 'public'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`CREATE TYPE "public"."page_visibility_enum_old" AS ENUM('followers', 'public', 'specified')`);
|
||||
await queryRunner.query(`ALTER TABLE "page" ALTER COLUMN "visibility" TYPE "public"."page_visibility_enum_old" USING "visibility"::"text"::"public"."page_visibility_enum_old"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."page_visibility_enum"`);
|
||||
await queryRunner.query(`ALTER TYPE "public"."page_visibility_enum_old" RENAME TO "page_visibility_enum"`);
|
||||
await queryRunner.query(`ALTER TABLE "page" ALTER COLUMN "visibility" DROP DEFAULT`);
|
||||
}
|
||||
}
|
@ -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"`);
|
||||
}
|
||||
}
|
@ -13,7 +13,6 @@
|
||||
"revert": "bun typeorm migration:revert -d ormconfig.js",
|
||||
"check:connect": "bun ./scripts/check_connect.js",
|
||||
"build": "swc src -d built -D --strip-leading-paths",
|
||||
"build:test": "swc test-server -d built-test -D --config-file test-server/.swcrc --strip-leading-paths",
|
||||
"watch:swc": "swc src -d built -D -w --strip-leading-paths",
|
||||
"build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
|
||||
"watch": "node ./scripts/watch.mjs",
|
||||
@ -23,9 +22,9 @@
|
||||
"eslint": "eslint --quiet \"src/**/*.ts\"",
|
||||
"lint": "bun typecheck && bun eslint",
|
||||
"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.unit.cjs",
|
||||
"jest:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.e2e.cjs",
|
||||
"jest:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve --no-experimental-require-module node_modules/jest/bin/jest.js --forceExit --config jest.config.e2e.cjs",
|
||||
"jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.unit.cjs",
|
||||
"jest-and-coverage:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.e2e.cjs",
|
||||
"jest-and-coverage:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve --no-experimental-require-module node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.e2e.cjs",
|
||||
"jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache",
|
||||
"test": "bun jest",
|
||||
"test:e2e": "pnpm build && bun build:test && bun jest:e2e",
|
||||
@ -34,16 +33,16 @@
|
||||
"generate-api-json": "pnpm build && bun run ./scripts/generate_api_json.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@swc/core-darwin-arm64": "1.9.1",
|
||||
"@swc/core-darwin-x64": "1.9.1",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.9.1",
|
||||
"@swc/core-linux-arm64-gnu": "1.9.1",
|
||||
"@swc/core-linux-arm64-musl": "1.9.1",
|
||||
"@swc/core-linux-x64-gnu": "1.9.1",
|
||||
"@swc/core-linux-x64-musl": "1.9.1",
|
||||
"@swc/core-win32-arm64-msvc": "1.9.1",
|
||||
"@swc/core-win32-ia32-msvc": "1.9.1",
|
||||
"@swc/core-win32-x64-msvc": "1.9.1",
|
||||
"@swc/core-darwin-arm64": "1.10.1",
|
||||
"@swc/core-darwin-x64": "1.10.1",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.10.1",
|
||||
"@swc/core-linux-arm64-gnu": "1.10.1",
|
||||
"@swc/core-linux-arm64-musl": "1.10.1",
|
||||
"@swc/core-linux-x64-gnu": "1.10.1",
|
||||
"@swc/core-linux-x64-musl": "1.10.1",
|
||||
"@swc/core-win32-arm64-msvc": "1.10.1",
|
||||
"@swc/core-win32-ia32-msvc": "1.10.1",
|
||||
"@swc/core-win32-x64-msvc": "1.10.1",
|
||||
"@tensorflow/tfjs": "4.22.0",
|
||||
"@tensorflow/tfjs-node": "4.22.0",
|
||||
"bufferutil": "4.0.8",
|
||||
@ -64,34 +63,34 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@authenio/samlify-node-xmllint": "2.0.0",
|
||||
"@aws-sdk/client-s3": "3.687.0",
|
||||
"@aws-sdk/lib-storage": "3.687.0",
|
||||
"@bull-board/api": "6.3.3",
|
||||
"@bull-board/fastify": "6.3.3",
|
||||
"@bull-board/ui": "6.3.3",
|
||||
"@aws-sdk/client-s3": "3.717.0",
|
||||
"@aws-sdk/lib-storage": "3.717.0",
|
||||
"@bull-board/api": "6.5.3",
|
||||
"@bull-board/fastify": "6.5.3",
|
||||
"@bull-board/ui": "6.5.3",
|
||||
"@discordapp/twemoji": "15.1.0",
|
||||
"@elastic/elasticsearch": "8.15.1",
|
||||
"@fastify/accepts": "5.0.1",
|
||||
"@elastic/elasticsearch": "8.17.0",
|
||||
"@fastify/accepts": "5.0.2",
|
||||
"@fastify/cookie": "11.0.1",
|
||||
"@fastify/cors": "10.0.1",
|
||||
"@fastify/express": "4.0.1",
|
||||
"@fastify/formbody": "8.0.1",
|
||||
"@fastify/http-proxy": "10.0.1",
|
||||
"@fastify/http-proxy": "11.0.0",
|
||||
"@fastify/multipart": "9.0.1",
|
||||
"@fastify/static": "8.0.2",
|
||||
"@fastify/static": "8.0.3",
|
||||
"@fastify/view": "10.0.1",
|
||||
"@misskey-dev/sharp-read-bmp": "1.2.0",
|
||||
"@misskey-dev/summaly": "MisskeyIO/summaly#5.1.1",
|
||||
"@napi-rs/canvas": "0.1.60",
|
||||
"@nestjs/common": "10.4.7",
|
||||
"@nestjs/core": "10.4.7",
|
||||
"@nestjs/testing": "10.4.7",
|
||||
"@misskey-dev/summaly": "github:MisskeyIO/summaly#5.1.3",
|
||||
"@napi-rs/canvas": "0.1.65",
|
||||
"@nestjs/common": "10.4.15",
|
||||
"@nestjs/core": "10.4.15",
|
||||
"@nestjs/testing": "10.4.15",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@simplewebauthn/server": "11.0.0",
|
||||
"@simplewebauthn/server": "13.0.0",
|
||||
"@sinonjs/fake-timers": "11.3.1",
|
||||
"@smithy/node-http-handler": "3.2.5",
|
||||
"@swc/cli": "0.5.0",
|
||||
"@swc/core": "1.9.1",
|
||||
"@smithy/node-http-handler": "3.3.3",
|
||||
"@swc/cli": "0.5.2",
|
||||
"@swc/core": "1.10.1",
|
||||
"@twemoji/parser": "15.1.1",
|
||||
"accepts": "1.3.8",
|
||||
"ajv": "8.17.1",
|
||||
@ -100,30 +99,30 @@
|
||||
"bcryptjs": "2.4.3",
|
||||
"blurhash": "2.0.5",
|
||||
"body-parser": "1.20.3",
|
||||
"bullmq": "5.25.2",
|
||||
"bullmq": "5.34.4",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"cbor": "10.0.3",
|
||||
"chalk": "5.3.0",
|
||||
"chalk": "5.4.1",
|
||||
"chalk-template": "1.1.0",
|
||||
"chokidar": "4.0.1",
|
||||
"chokidar": "4.0.3",
|
||||
"cli-highlight": "2.1.11",
|
||||
"color-convert": "2.0.1",
|
||||
"content-disposition": "0.5.4",
|
||||
"date-fns": "4.1.0",
|
||||
"deep-email-validator": "0.1.21",
|
||||
"fastify": "5.0.0",
|
||||
"fastify": "5.2.0",
|
||||
"fastify-http-errors-enhanced": "6.0.0",
|
||||
"fastify-raw-body": "5.0.0",
|
||||
"feed": "4.2.2",
|
||||
"file-type": "19.6.0",
|
||||
"fluent-ffmpeg": "2.1.3",
|
||||
"form-data": "4.0.1",
|
||||
"got": "14.4.4",
|
||||
"happy-dom": "15.11.0",
|
||||
"got": "14.4.5",
|
||||
"happy-dom": "15.11.7",
|
||||
"hpagent": "1.2.0",
|
||||
"htmlescape": "1.1.1",
|
||||
"http-link-header": "1.1.3",
|
||||
"ioredis": "5.4.1",
|
||||
"ioredis": "5.4.2",
|
||||
"ip-cidr": "4.0.2",
|
||||
"ipaddr.js": "2.2.0",
|
||||
"is-svg": "5.1.0",
|
||||
@ -131,35 +130,35 @@
|
||||
"js-yaml": "4.1.0",
|
||||
"jsdom": "25.0.1",
|
||||
"json5": "2.2.3",
|
||||
"jsonld": "8.3.2",
|
||||
"jsonld": "8.3.3",
|
||||
"jsrsasign": "11.1.0",
|
||||
"meilisearch": "0.45.0",
|
||||
"meilisearch": "0.47.0",
|
||||
"mfm-js": "0.24.0",
|
||||
"microformats-parser": "2.0.2",
|
||||
"mime-types": "2.1.35",
|
||||
"misskey-js": "workspace:*",
|
||||
"misskey-reversi": "workspace:*",
|
||||
"ms": "3.0.0-canary.1",
|
||||
"nanoid": "5.0.8",
|
||||
"nanoid": "5.0.9",
|
||||
"nested-property": "4.0.0",
|
||||
"node-fetch": "3.3.2",
|
||||
"node-forge": "1.3.1",
|
||||
"nodemailer": "6.9.16",
|
||||
"nsfwjs": "2.4.2",
|
||||
"nsfwjs": "4.2.0",
|
||||
"oauth": "0.10.0",
|
||||
"oauth2orize": "1.12.0",
|
||||
"oauth2orize-pkce": "0.1.2",
|
||||
"os-utils": "0.0.14",
|
||||
"otpauth": "9.3.4",
|
||||
"otpauth": "9.3.6",
|
||||
"parse5": "7.2.1",
|
||||
"pg": "8.13.1",
|
||||
"pino": "9.5.0",
|
||||
"pino-pretty": "12.0.0",
|
||||
"pino": "9.6.0",
|
||||
"pino-pretty": "13.0.0",
|
||||
"pkce-challenge": "4.1.0",
|
||||
"probe-image-size": "7.2.3",
|
||||
"promise-limit": "2.7.0",
|
||||
"pug": "3.0.3",
|
||||
"punycode": "2.3.1",
|
||||
"punycode.js": "2.3.1",
|
||||
"qrcode": "1.5.4",
|
||||
"random-seed": "0.3.0",
|
||||
"ratelimiter": "3.4.1",
|
||||
@ -170,19 +169,19 @@
|
||||
"rss-parser": "3.13.0",
|
||||
"rxjs": "7.8.1",
|
||||
"samlify": "2.8.11",
|
||||
"sanitize-html": "2.13.1",
|
||||
"secure-json-parse": "2.7.0",
|
||||
"sanitize-html": "2.14.0",
|
||||
"secure-json-parse": "3.0.1",
|
||||
"sharp": "0.33.5",
|
||||
"slacc": "0.0.10",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"systeminformation": "5.23.5",
|
||||
"systeminformation": "5.23.21",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tmp": "0.2.3",
|
||||
"tsc-alias": "1.8.10",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typeorm": "0.3.20",
|
||||
"typescript": "5.6.3",
|
||||
"typescript": "5.7.2",
|
||||
"ulid": "2.3.0",
|
||||
"vary": "1.1.2",
|
||||
"web-push": "3.6.7",
|
||||
@ -193,8 +192,7 @@
|
||||
"devDependencies": {
|
||||
"@jest/globals": "29.7.0",
|
||||
"@misskey-dev/eslint-plugin": "1.0.0",
|
||||
"@nestjs/platform-express": "10.4.7",
|
||||
"@simplewebauthn/types": "11.0.0",
|
||||
"@nestjs/platform-express": "10.4.15",
|
||||
"@swc/jest": "0.2.37",
|
||||
"@types/accepts": "1.3.7",
|
||||
"@types/archiver": "6.0.3",
|
||||
@ -203,24 +201,24 @@
|
||||
"@types/color-convert": "2.0.4",
|
||||
"@types/content-disposition": "0.5.8",
|
||||
"@types/fluent-ffmpeg": "2.1.27",
|
||||
"@types/htmlescape": "^1.1.3",
|
||||
"@types/htmlescape": "1.1.3",
|
||||
"@types/http-link-header": "1.0.7",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsdom": "21.1.7",
|
||||
"@types/jsonld": "1.5.15",
|
||||
"@types/jsrsasign": "10.5.14",
|
||||
"@types/jsrsasign": "10.5.15",
|
||||
"@types/mime-types": "2.1.4",
|
||||
"@types/ms": "0.7.34",
|
||||
"@types/node": "22.9.0",
|
||||
"@types/node": "22.10.2",
|
||||
"@types/node-forge": "1.3.11",
|
||||
"@types/nodemailer": "6.4.16",
|
||||
"@types/nodemailer": "6.4.17",
|
||||
"@types/oauth": "0.9.6",
|
||||
"@types/oauth2orize": "1.11.5",
|
||||
"@types/oauth2orize-pkce": "0.1.2",
|
||||
"@types/pg": "8.11.10",
|
||||
"@types/pug": "2.0.10",
|
||||
"@types/punycode": "2.1.4",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@types/random-seed": "0.3.5",
|
||||
"@types/ratelimiter": "3.4.6",
|
||||
@ -240,11 +238,11 @@
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"execa": "9.5.1",
|
||||
"fkill": "^9.0.0",
|
||||
"execa": "9.5.2",
|
||||
"fkill": "9.0.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-mock": "29.7.0",
|
||||
"nodemon": "3.1.7",
|
||||
"nodemon": "3.1.9",
|
||||
"pid-port": "1.0.0",
|
||||
"simple-oauth2": "5.1.0"
|
||||
}
|
||||
|
@ -3,13 +3,13 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as nsfw from 'nsfwjs';
|
||||
import si from 'systeminformation';
|
||||
import { Mutex } from 'async-mutex';
|
||||
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
@ -33,7 +33,7 @@ export class AiService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async detectSensitive(path: string): Promise<nsfw.predictionType[] | null> {
|
||||
public async detectSensitive(path: string, mime: string): Promise<nsfw.PredictionType[] | null> {
|
||||
try {
|
||||
if (isSupportedCpu === undefined) {
|
||||
const cpuFlags = await this.getCpuFlags();
|
||||
@ -55,11 +55,16 @@ export class AiService {
|
||||
});
|
||||
}
|
||||
|
||||
const buffer = await fs.promises.readFile(path);
|
||||
const image = await tf.node.decodeImage(buffer, 3) as any;
|
||||
const sharp = await sharpBmp(path, mime);
|
||||
const { data, info } = await sharp
|
||||
.resize(299, 299, { fit: 'inside' })
|
||||
.removeAlpha()
|
||||
.raw({ depth: 'uchar' })
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
const image = tf.tensor3d(data, [info.height, info.width, info.channels], 'bool');
|
||||
try {
|
||||
const predictions = await this.model.classify(image);
|
||||
return predictions;
|
||||
return await this.model.classify(image);
|
||||
} finally {
|
||||
image.dispose();
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ export class AvatarDecorationService implements OnApplicationShutdown {
|
||||
private moderationLogService: ModerationLogService,
|
||||
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);
|
||||
}
|
||||
|
@ -56,10 +56,10 @@ export class CacheService implements OnApplicationShutdown {
|
||||
) {
|
||||
//this.onMessage = this.onMessage.bind(this);
|
||||
|
||||
this.userByIdCache = new MemoryKVCache<MiUser>(Infinity);
|
||||
this.localUserByNativeTokenCache = new MemoryKVCache<MiLocalUser | null>(Infinity);
|
||||
this.localUserByIdCache = new MemoryKVCache<MiLocalUser>(Infinity);
|
||||
this.uriPersonCache = new MemoryKVCache<MiUser | null>(Infinity);
|
||||
this.userByIdCache = new MemoryKVCache<MiUser>(1000 * 60 * 5); // 5m
|
||||
this.localUserByNativeTokenCache = new MemoryKVCache<MiLocalUser | null>(1000 * 60 * 5); // 5m
|
||||
this.localUserByIdCache = new MemoryKVCache<MiLocalUser>(1000 * 60 * 5); // 5m
|
||||
this.uriPersonCache = new MemoryKVCache<MiUser | null>(1000 * 60 * 5); // 5m
|
||||
|
||||
this.userProfileCache = new RedisKVCache<MiUserProfile>(this.redisClient, 'userProfile', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
@ -135,14 +135,14 @@ export class CacheService implements OnApplicationShutdown {
|
||||
if (user == null) {
|
||||
this.userByIdCache.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) {
|
||||
this.uriPersonCache.delete(k);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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) {
|
||||
this.uriPersonCache.set(k, user);
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/;
|
||||
|
||||
@Injectable()
|
||||
export class CustomEmojiService implements OnApplicationShutdown {
|
||||
private cache: MemoryKVCache<MiEmoji | null>;
|
||||
private emojisCache: MemoryKVCache<MiEmoji | null>;
|
||||
public localEmojisCache: RedisSingleCache<Map<string, MiEmoji>>;
|
||||
|
||||
constructor(
|
||||
@ -40,7 +40,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||
private moderationLogService: ModerationLogService,
|
||||
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', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
@ -346,7 +346,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||
host,
|
||||
})) ?? null;
|
||||
|
||||
const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull);
|
||||
const emoji = await this.emojisCache.fetch(`${name} ${host}`, queryOrNull);
|
||||
|
||||
if (emoji == null) return null;
|
||||
return emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ)
|
||||
@ -372,7 +372,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||
*/
|
||||
@bindThis
|
||||
public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> {
|
||||
const notCachedEmojis = emojis.filter(emoji => this.cache.get(`${emoji.name} ${emoji.host}`) == null);
|
||||
const notCachedEmojis = emojis.filter(emoji => this.emojisCache.get(`${emoji.name} ${emoji.host}`) == null);
|
||||
const emojisQuery: any[] = [];
|
||||
const hosts = new Set(notCachedEmojis.map(e => e.host));
|
||||
for (const host of hosts) {
|
||||
@ -387,7 +387,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
||||
select: ['name', 'host', 'originalUrl', 'publicUrl'],
|
||||
}) : [];
|
||||
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
|
||||
public dispose(): void {
|
||||
this.cache.dispose();
|
||||
this.emojisCache.dispose();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -6,7 +6,6 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as stream from 'node:stream/promises';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
import chalk from 'chalk';
|
||||
import got, * as Got from 'got';
|
||||
import { parse } from 'content-disposition';
|
||||
@ -70,13 +69,6 @@ export class DownloadService {
|
||||
},
|
||||
enableUnixSockets: false,
|
||||
}).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'];
|
||||
if (contentLength != null) {
|
||||
const size = Number(contentLength);
|
||||
@ -139,18 +131,4 @@ export class DownloadService {
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
@ -330,6 +330,7 @@ export class EmailService {
|
||||
Accept: 'application/json',
|
||||
Authorization: truemailAuthKey,
|
||||
},
|
||||
isLocalAddressAllowed: true,
|
||||
});
|
||||
|
||||
const json = (await res.json()) as {
|
||||
|
@ -15,7 +15,6 @@ import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import type { DOMWindow } from 'jsdom';
|
||||
|
||||
type NodeInfo = {
|
||||
openRegistrations?: unknown;
|
||||
@ -170,7 +169,7 @@ export class FetchInstanceMetadataService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async fetchDom(instance: MiInstance): Promise<DOMWindow['document']> {
|
||||
private async fetchDom(instance: MiInstance): Promise<Document> {
|
||||
this.logger.info(`Fetching HTML of ${instance.host} ...`);
|
||||
|
||||
const url = 'https://' + instance.host;
|
||||
@ -178,9 +177,8 @@ export class FetchInstanceMetadataService {
|
||||
const html = await this.httpRequestService.getHtml(url);
|
||||
|
||||
const { window } = new JSDOM(html);
|
||||
const doc = window.document;
|
||||
|
||||
return doc;
|
||||
return window.document as Document;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@ -195,7 +193,7 @@ export class FetchInstanceMetadataService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async fetchFaviconUrl(instance: MiInstance, doc: DOMWindow['document'] | null): Promise<string | null> {
|
||||
private async fetchFaviconUrl(instance: MiInstance, doc: Document | null): Promise<string | null> {
|
||||
const url = 'https://' + instance.host;
|
||||
|
||||
if (doc) {
|
||||
@ -221,7 +219,7 @@ export class FetchInstanceMetadataService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async fetchIconUrl(instance: MiInstance, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
private async fetchIconUrl(instance: MiInstance, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) {
|
||||
const url = 'https://' + instance.host;
|
||||
return (new URL(manifest.icons[0].src, url)).href;
|
||||
@ -250,7 +248,7 @@ export class FetchInstanceMetadataService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async getThemeColor(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
private async getThemeColor(info: NodeInfo | null, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color;
|
||||
|
||||
if (themeColor) {
|
||||
@ -262,7 +260,7 @@ export class FetchInstanceMetadataService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async getSiteName(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
private async getSiteName(info: NodeInfo | null, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
if (info && info.metadata) {
|
||||
if (typeof info.metadata.nodeName === 'string') {
|
||||
return info.metadata.nodeName;
|
||||
@ -287,7 +285,7 @@ export class FetchInstanceMetadataService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async getDescription(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
private async getDescription(info: NodeInfo | null, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
if (info && info.metadata) {
|
||||
if (typeof info.metadata.nodeDescription === 'string') {
|
||||
return info.metadata.nodeDescription;
|
||||
|
@ -13,7 +13,7 @@ import * as fileType from 'file-type';
|
||||
import FFmpeg from 'fluent-ffmpeg';
|
||||
import isSvg from 'is-svg';
|
||||
import probeImageSize from 'probe-image-size';
|
||||
import { type predictionType } from 'nsfwjs';
|
||||
import { type PredictionType } from 'nsfwjs';
|
||||
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
|
||||
import { encode } from 'blurhash';
|
||||
import { createTempDir } from '@/misc/create-temp.js';
|
||||
@ -170,7 +170,7 @@ export class FileInfoService {
|
||||
let sensitive = false;
|
||||
let porn = false;
|
||||
|
||||
function judgePrediction(result: readonly predictionType[]): [sensitive: boolean, porn: boolean] {
|
||||
function judgePrediction(result: readonly PredictionType[]): [sensitive: boolean, porn: boolean] {
|
||||
let sensitive = false;
|
||||
let porn = false;
|
||||
|
||||
@ -188,7 +188,7 @@ export class FileInfoService {
|
||||
'image/png',
|
||||
'image/webp',
|
||||
].includes(mime)) {
|
||||
const result = await this.aiService.detectSensitive(source);
|
||||
const result = await this.aiService.detectSensitive(source, mime);
|
||||
if (result) {
|
||||
[sensitive, porn] = judgePrediction(result);
|
||||
}
|
||||
@ -247,7 +247,7 @@ export class FileInfoService {
|
||||
}
|
||||
targetIndex = nextIndex;
|
||||
nextIndex += index; // fibonacci sequence によってフレーム数制限を掛ける
|
||||
const result = await this.aiService.detectSensitive(path);
|
||||
const result = await this.aiService.detectSensitive(path, 'image/png');
|
||||
if (result) {
|
||||
results.push(judgePrediction(result));
|
||||
}
|
||||
@ -273,7 +273,9 @@ export class FileInfoService {
|
||||
watcher.close();
|
||||
});
|
||||
command.run();
|
||||
for (let i = 1; true; i++) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
|
||||
let i = 0;
|
||||
while (true) {
|
||||
i++;
|
||||
const current = `${i}.png`;
|
||||
const next = `${i + 1}.png`;
|
||||
const framePath = join(cwd, current);
|
||||
@ -455,9 +457,9 @@ export class FileInfoService {
|
||||
private async getBlurhash(path: string, type: string): Promise<string> {
|
||||
const sharp = await sharpBmp(path, type);
|
||||
const { data, info } = await sharp
|
||||
.raw()
|
||||
.ensureAlpha()
|
||||
.resize(64, 64, { fit: 'inside' })
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
return encode(new Uint8ClampedArray(data), info.width, info.height, 5, 5);
|
||||
|
@ -6,6 +6,7 @@
|
||||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
import * as net from 'node:net';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
import CacheableLookup from 'cacheable-lookup';
|
||||
import fetch from 'node-fetch';
|
||||
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
|
||||
@ -15,6 +16,7 @@ import type { Config } from '@/config.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
|
||||
import type { IObject } from '@/core/activitypub/type.js';
|
||||
import type { Response } from 'node-fetch';
|
||||
import type { URL } from 'node:url';
|
||||
@ -24,8 +26,102 @@ export type HttpRequestSendOptions = {
|
||||
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()
|
||||
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
|
||||
*/
|
||||
@ -56,19 +152,20 @@ export class HttpRequestService {
|
||||
lookup: false, // nativeのdns.lookupにfallbackしない
|
||||
});
|
||||
|
||||
this.http = new http.Agent({
|
||||
const agentOption = {
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30 * 1000,
|
||||
lookup: cache.lookup as unknown as net.LookupFunction,
|
||||
localAddress: config.outgoingAddress,
|
||||
});
|
||||
};
|
||||
|
||||
this.https = new https.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30 * 1000,
|
||||
lookup: cache.lookup as unknown as net.LookupFunction,
|
||||
localAddress: config.outgoingAddress,
|
||||
});
|
||||
this.httpNative = new http.Agent(agentOption);
|
||||
|
||||
this.httpsNative = new https.Agent(agentOption);
|
||||
|
||||
this.http = new HttpRequestServiceAgent(config, agentOption);
|
||||
|
||||
this.https = new HttpsRequestServiceAgent(config, agentOption);
|
||||
|
||||
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
|
||||
|
||||
@ -103,16 +200,22 @@ export class HttpRequestService {
|
||||
* @param bypassProxy Allways bypass proxy
|
||||
*/
|
||||
@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 (isLocalAddressAllowed) {
|
||||
return url.protocol === 'http:' ? this.httpNative : this.httpsNative;
|
||||
}
|
||||
return url.protocol === 'http:' ? this.http : this.https;
|
||||
} else {
|
||||
if (isLocalAddressAllowed && (!this.config.proxy)) {
|
||||
return url.protocol === 'http:' ? this.httpNative : this.httpsNative;
|
||||
}
|
||||
return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getActivityJson(url: string): Promise<IObject> {
|
||||
public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise<IObject> {
|
||||
const res = await this.send(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@ -120,16 +223,22 @@ export class HttpRequestService {
|
||||
},
|
||||
timeout: 5000,
|
||||
size: 1024 * 256,
|
||||
isLocalAddressAllowed: isLocalAddressAllowed,
|
||||
}, {
|
||||
throwErrorWhenResponseNotOk: true,
|
||||
validators: [validateContentTypeSetAsActivityPub],
|
||||
});
|
||||
|
||||
return await res.json() as IObject;
|
||||
const finalUrl = res.url; // redirects may have been involved
|
||||
const activity = await res.json() as IObject;
|
||||
|
||||
assertActivityMatchesUrls(activity, [finalUrl]);
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
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, {
|
||||
method: 'GET',
|
||||
headers: Object.assign({
|
||||
@ -137,19 +246,21 @@ export class HttpRequestService {
|
||||
}, headers ?? {}),
|
||||
timeout: 5000,
|
||||
size: 1024 * 256,
|
||||
isLocalAddressAllowed: isLocalAddressAllowed,
|
||||
});
|
||||
|
||||
return await res.json() as T;
|
||||
}
|
||||
|
||||
@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, {
|
||||
method: 'GET',
|
||||
headers: Object.assign({
|
||||
Accept: accept,
|
||||
}, headers ?? {}),
|
||||
timeout: 5000,
|
||||
isLocalAddressAllowed: isLocalAddressAllowed,
|
||||
});
|
||||
|
||||
return await res.text();
|
||||
@ -164,6 +275,7 @@ export class HttpRequestService {
|
||||
headers?: Record<string, string>,
|
||||
timeout?: number,
|
||||
size?: number,
|
||||
isLocalAddressAllowed?: boolean,
|
||||
} = {},
|
||||
extra: HttpRequestSendOptions = {
|
||||
throwErrorWhenResponseNotOk: true,
|
||||
@ -178,6 +290,7 @@ export class HttpRequestService {
|
||||
}, timeout);
|
||||
|
||||
const bearcaps = url.startsWith('bear:?') ? this.parseBearcaps(url) : undefined;
|
||||
const isLocalAddressAllowed = args.isLocalAddressAllowed ?? false;
|
||||
|
||||
const res = await fetch(bearcaps?.url ?? url, {
|
||||
method: args.method ?? 'GET',
|
||||
@ -188,7 +301,7 @@ export class HttpRequestService {
|
||||
},
|
||||
body: args.body,
|
||||
size: args.size ?? 10 * 1024 * 1024,
|
||||
agent: (url) => this.getAgentByUrl(url),
|
||||
agent: (url) => this.getAgentByUrl(url, false, isLocalAddressAllowed),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
|
@ -35,7 +35,7 @@ export class RelayService {
|
||||
private createSystemUserService: CreateSystemUserService,
|
||||
private apRendererService: ApRendererService,
|
||||
) {
|
||||
this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10);
|
||||
this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10); // 10m
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -56,7 +56,7 @@ export class RemoteUserResolveService {
|
||||
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
if (this.config.host === host) {
|
||||
if (host === this.utilityService.toPuny(this.config.host)) {
|
||||
this.logger.info(`return local user: ${usernameLower}`);
|
||||
return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => {
|
||||
if (u == null) {
|
||||
|
@ -149,10 +149,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
private moderationLogService: ModerationLogService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
) {
|
||||
//this.onMessage = this.onMessage.bind(this);
|
||||
|
||||
this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60 * 1);
|
||||
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 60 * 1);
|
||||
this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h
|
||||
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m
|
||||
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
@ -490,7 +488,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
}
|
||||
|
||||
@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 role = await this.rolesRepository.findOneByOrFail({ id: roleId });
|
||||
@ -514,6 +512,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
expiresAt: expiresAt,
|
||||
roleId: roleId,
|
||||
userId: userId,
|
||||
memo: memo,
|
||||
}).then(x => this.roleAssignmentsRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
this.globalEventService.publishInternalEvent('userRoleAssigned', created);
|
||||
@ -523,9 +522,10 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
roleId: roleId,
|
||||
});
|
||||
}
|
||||
} else if (existing.expiresAt !== expiresAt) {
|
||||
} else if (existing.expiresAt !== expiresAt || existing.memo !== memo) {
|
||||
await this.roleAssignmentsRepository.update(existing.id, {
|
||||
expiresAt: expiresAt,
|
||||
memo: memo,
|
||||
});
|
||||
} else {
|
||||
throw new IdentifiableError('67d8689c-25c6-435f-8ced-631e4b81fce1', 'User is already assigned to this role.');
|
||||
@ -544,6 +544,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
userUsername: user.username,
|
||||
userHost: user.host,
|
||||
expiresAt: expiresAt ? expiresAt.toISOString() : null,
|
||||
memo: memo,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -584,6 +585,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
userId: userId,
|
||||
userUsername: user.username,
|
||||
userHost: user.host,
|
||||
memo: existing.memo,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ export class UserKeypairService implements OnApplicationShutdown {
|
||||
) {
|
||||
this.cache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', {
|
||||
lifetime: 1000 * 60 * 60 * 24, // 24h
|
||||
memoryCacheLifetime: Infinity,
|
||||
memoryCacheLifetime: 1000 * 60 * 60, // 1h
|
||||
fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }),
|
||||
toRedisConverter: (value) => JSON.stringify(value),
|
||||
fromRedisConverter: (value) => JSON.parse(value),
|
||||
|
@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { URL } from 'node:url';
|
||||
import { toASCII } from 'punycode';
|
||||
import punycode from 'punycode.js';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import RE2 from 're2';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
@ -30,6 +30,11 @@ export class UtilityService {
|
||||
return this.toPuny(this.config.host) === this.toPuny(host);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isUriLocal(uri: string): boolean {
|
||||
return this.punyHost(uri) === this.toPuny(this.config.host);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isBlockedHost(blockedHosts: string[], host: string | null): boolean {
|
||||
if (host == null) return false;
|
||||
@ -90,17 +95,24 @@ export class UtilityService {
|
||||
@bindThis
|
||||
public extractDbHost(uri: string): string {
|
||||
const url = new URL(uri);
|
||||
return this.toPuny(url.hostname);
|
||||
return this.toPuny(url.host);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public toPuny(host: string): string {
|
||||
return toASCII(host.toLowerCase());
|
||||
return punycode.toASCII(host.toLowerCase());
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public toPunyNullable(host: string | null | undefined): string | null {
|
||||
if (host == null) return null;
|
||||
return toASCII(host.toLowerCase());
|
||||
return punycode.toASCII(host.toLowerCase());
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public punyHost(url: string): string {
|
||||
const urlObj = new URL(url);
|
||||
const host = `${this.toPuny(urlObj.hostname)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`;
|
||||
return host;
|
||||
}
|
||||
}
|
||||
|
@ -32,7 +32,7 @@ import type {
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from '@simplewebauthn/types';
|
||||
} from '@simplewebauthn/server';
|
||||
|
||||
@Injectable()
|
||||
export class WebAuthnService {
|
||||
@ -78,7 +78,6 @@ export class WebAuthnService {
|
||||
userID: isoUint8Array.fromUTF8String(userId),
|
||||
userName: userName,
|
||||
userDisplayName: userDisplayName,
|
||||
attestationType: 'indirect',
|
||||
excludeCredentials: keys.map(key => (<{ id: string; transports?: AuthenticatorTransportFuture[]; }>{
|
||||
id: key.id,
|
||||
transports: key.transports ?? undefined,
|
||||
|
@ -10,6 +10,7 @@ import type { Config } from '@/config.js';
|
||||
import { MemoryKVCache } from '@/misc/cache.js';
|
||||
import type { MiUserPublickey } from '@/models/UserPublickey.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||
@ -53,9 +54,10 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
||||
|
||||
private cacheService: CacheService,
|
||||
private apPersonService: ApPersonService,
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(Infinity);
|
||||
this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(Infinity);
|
||||
this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
|
||||
this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@ -63,7 +65,9 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
||||
const separator = '/';
|
||||
|
||||
const uri = new URL(getApId(value));
|
||||
if (uri.origin !== this.config.url) return { local: false, uri: uri.href };
|
||||
if (this.utilityService.toPuny(uri.host) !== this.utilityService.toPuny(this.config.host)) {
|
||||
return { local: false, uri: uri.href };
|
||||
}
|
||||
|
||||
const [, type, id, ...rest] = uri.pathname.split(separator);
|
||||
return {
|
||||
|
@ -29,6 +29,7 @@ import { bindThis } from '@/decorators.js';
|
||||
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.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 { ApNoteService } from './models/ApNoteService.js';
|
||||
import { ApLoggerService } from './ApLoggerService.js';
|
||||
@ -38,7 +39,7 @@ import { ApAudienceService } from './ApAudienceService.js';
|
||||
import { ApPersonService } from './models/ApPersonService.js';
|
||||
import { ApQuestionService } from './models/ApQuestionService.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()
|
||||
export class ApInboxService {
|
||||
@ -90,13 +91,26 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
@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)) {
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) {
|
||||
const results = [] as [string, string | void][];
|
||||
// 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.extractDbHost(actor.uri)}`);
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const act = await resolver.resolve(item);
|
||||
if (act.id == null || this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) {
|
||||
this.logger.warn('skipping activity: activity id is null or mismatching');
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await this.performOneActivity(actor, act, additionalCc);
|
||||
results.push([getApId(item), await this.performOneActivity(actor, act, resolver, additionalCc)]);
|
||||
} catch (err) {
|
||||
if (err instanceof Error || typeof err === 'string') {
|
||||
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 {
|
||||
await this.performOneActivity(actor, activity, additionalCc);
|
||||
result = await this.performOneActivity(actor, activity, resolver, additionalCc);
|
||||
}
|
||||
|
||||
// ついでにリモートユーザーの情報が古かったら更新しておく
|
||||
if (actor.uri) {
|
||||
if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
|
||||
setImmediate(() => {
|
||||
// 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない
|
||||
this.apPersonService.updatePerson(actor.uri);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async performOneActivity(actor: MiRemoteUser, activity: IObject, additionalCc?: MiLocalUser['id']): Promise<void> {
|
||||
if (actor.isSuspended) return;
|
||||
public async performOneActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver, additionalCc?: MiLocalUser['id']): Promise<string> {
|
||||
if (actor.isSuspended) return 'skip: actor is suspended';
|
||||
|
||||
if (isCreate(activity)) {
|
||||
await this.create(actor, activity, additionalCc);
|
||||
return await this.create(actor, activity, resolver, additionalCc);
|
||||
} else if (isDelete(activity)) {
|
||||
await this.delete(actor, activity);
|
||||
return await this.delete(actor, activity);
|
||||
} else if (isUpdate(activity)) {
|
||||
await this.update(actor, activity, additionalCc);
|
||||
return await this.update(actor, activity, resolver, additionalCc);
|
||||
} else if (isFollow(activity)) {
|
||||
await this.follow(actor, activity);
|
||||
return await this.follow(actor, activity);
|
||||
} else if (isAccept(activity)) {
|
||||
await this.accept(actor, activity);
|
||||
return await this.accept(actor, activity, resolver);
|
||||
} else if (isReject(activity)) {
|
||||
await this.reject(actor, activity);
|
||||
return await this.reject(actor, activity, resolver);
|
||||
} 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)) {
|
||||
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)) {
|
||||
await this.announce(actor, activity);
|
||||
return await this.announce(actor, activity, resolver);
|
||||
} else if (isLike(activity)) {
|
||||
await this.like(actor, activity);
|
||||
return await this.like(actor, activity);
|
||||
} else if (isUndo(activity)) {
|
||||
await this.undo(actor, activity);
|
||||
return await this.undo(actor, activity, resolver);
|
||||
} else if (isBlock(activity)) {
|
||||
await this.block(actor, activity);
|
||||
return await this.block(actor, activity);
|
||||
} else if (isFlag(activity)) {
|
||||
await this.flag(actor, activity);
|
||||
return await this.flag(actor, activity);
|
||||
} else if (isMove(activity)) {
|
||||
await this.move(actor, activity);
|
||||
return await this.move(actor, activity, resolver);
|
||||
} 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);
|
||||
|
||||
return await this.reactionService.create(actor, note, activity._misskey_reaction ?? activity.content ?? activity.name).catch(err => {
|
||||
if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') {
|
||||
try {
|
||||
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';
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}).then(() => 'ok');
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
|
||||
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 => {
|
||||
this.logger.error(`Resolution failed: ${err}`, { error: err });
|
||||
@ -206,7 +232,7 @@ export class ApInboxService {
|
||||
|
||||
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
|
||||
@ -234,47 +260,57 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
@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) {
|
||||
throw new Error('invalid actor');
|
||||
return 'skip: invalid actor';
|
||||
}
|
||||
|
||||
if (activity.target == null) {
|
||||
throw new Error('target is null');
|
||||
return 'skip: target is null';
|
||||
}
|
||||
|
||||
if (activity.target === actor.featured) {
|
||||
const note = await this.apNoteService.resolveNote(activity.object);
|
||||
if (note == null) throw new Error('note not found');
|
||||
const note = await this.apNoteService.resolveNote(activity.object, { resolver });
|
||||
if (note == null) return 'skip: note not found';
|
||||
await this.notePiningService.addPinned(actor, note.id);
|
||||
return;
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
throw new Error(`unknown target: ${activity.target}`);
|
||||
return `skip: unknown target ${activity.target}`;
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
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
|
||||
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);
|
||||
|
||||
if (actor.isSuspended) {
|
||||
return;
|
||||
return 'skip: actor is suspended';
|
||||
}
|
||||
|
||||
// アナウンス先をブロックしてたら中断
|
||||
const meta = await this.metaService.fetch();
|
||||
if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) return;
|
||||
if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) return 'skip: blocked host';
|
||||
|
||||
const unlock = await this.appLockService.getApLock(uri);
|
||||
|
||||
@ -282,40 +318,37 @@ export class ApInboxService {
|
||||
// 既に同じURIを持つものが登録されていないかチェック
|
||||
const exist = await this.apNoteService.fetchNote(uri);
|
||||
if (exist) {
|
||||
return;
|
||||
return 'skip: note exists';
|
||||
}
|
||||
|
||||
// Announce対象をresolve
|
||||
let renote;
|
||||
try {
|
||||
renote = await this.apNoteService.resolveNote(targetUri);
|
||||
if (renote == null) throw new Error('announce target is null');
|
||||
renote = await this.apNoteService.resolveNote(target, { resolver });
|
||||
if (renote == null) return 'skip: target note not found';
|
||||
} catch (err) {
|
||||
// 対象が4xxならスキップ
|
||||
if (err instanceof StatusError) {
|
||||
if (!err.isRetryable) {
|
||||
this.logger.warn(`Ignored announce target ${targetUri} - ${err.statusCode}`);
|
||||
return;
|
||||
return `skip: Ignored announce target ${target} - ${err.statusCode}`;
|
||||
}
|
||||
|
||||
this.logger.warn(`Error in announce target ${targetUri} - ${err.statusCode}`);
|
||||
this.logger.warn(`Error in announce target ${target} - ${err.statusCode}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!await this.noteEntityService.isVisibleForMe(renote, actor.id)) {
|
||||
this.logger.warn('skip: invalid actor for this activity');
|
||||
return;
|
||||
return 'skip: invalid actor for this activity';
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (createdAt && createdAt < this.idService.parse(renote.id).date) {
|
||||
this.logger.warn('skip: malformed createdAt');
|
||||
return;
|
||||
return 'skip: malformed createdAt';
|
||||
}
|
||||
|
||||
await this.noteCreateService.create(actor, {
|
||||
@ -328,6 +361,8 @@ export class ApInboxService {
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@ -349,11 +384,13 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
this.logger.info(`Create: ${uri}`);
|
||||
|
||||
if (!activity.object) return 'skip: activity has no object property';
|
||||
|
||||
// copy audiences between activity <=> object.
|
||||
if (typeof activity.object === 'object') {
|
||||
const to = unique(concat([toArray(activity.to), toArray(activity.object.to)]));
|
||||
@ -370,7 +407,8 @@ export class ApInboxService {
|
||||
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 => {
|
||||
this.logger.error(`Resolution failed: ${e}`, { error: e });
|
||||
@ -378,9 +416,9 @@ export class ApInboxService {
|
||||
});
|
||||
|
||||
if (isPost(object)) {
|
||||
await this.createNote(resolver, actor, object, false, activity, additionalCc);
|
||||
return await this.createNote(resolver, actor, object, false, activity, additionalCc);
|
||||
} else {
|
||||
this.logger.warn(`Unknown type: ${getApType(object)}`);
|
||||
return `skip: Unknown type ${getApType(object) ?? 'undefined'}}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -397,6 +435,8 @@ export class ApInboxService {
|
||||
if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(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';
|
||||
}
|
||||
|
||||
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)) {
|
||||
await this.noteCreateService.appendNoteVisibleUser(actor, createdNote, additionalCc);
|
||||
return 'ok: note visible user appended';
|
||||
@ -433,7 +473,7 @@ export class ApInboxService {
|
||||
@bindThis
|
||||
private async delete(actor: MiRemoteUser, activity: IDelete): Promise<string> {
|
||||
if (actor.uri !== activity.actor) {
|
||||
throw new Error('invalid actor');
|
||||
return 'skip: invalid actor';
|
||||
}
|
||||
|
||||
// 削除対象objectのtype
|
||||
@ -553,12 +593,13 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
@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;
|
||||
|
||||
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 => {
|
||||
this.logger.error(`Resolution failed: ${e}`, { error: e });
|
||||
@ -567,7 +608,7 @@ export class ApInboxService {
|
||||
|
||||
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
|
||||
@ -595,36 +636,37 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
@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) {
|
||||
throw new Error('invalid actor');
|
||||
return 'skip: invalid actor';
|
||||
}
|
||||
|
||||
if (activity.target == null) {
|
||||
throw new Error('target is null');
|
||||
return 'skip: target is null';
|
||||
}
|
||||
|
||||
if (activity.target === actor.featured) {
|
||||
const note = await this.apNoteService.resolveNote(activity.object);
|
||||
if (note == null) throw new Error('note not found');
|
||||
const note = await this.apNoteService.resolveNote(activity.object, { resolver });
|
||||
if (note == null) return 'skip: note not found';
|
||||
await this.notePiningService.removePinned(actor, note.id);
|
||||
return;
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
throw new Error(`unknown target: ${activity.target}`);
|
||||
return `skip: unknown target ${activity.target}`;
|
||||
}
|
||||
|
||||
@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) {
|
||||
throw new Error('invalid actor');
|
||||
return 'skip: invalid actor';
|
||||
}
|
||||
|
||||
const uri = activity.id ?? activity;
|
||||
|
||||
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 => {
|
||||
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 (isAccept(object)) return await this.undoAccept(actor, object);
|
||||
|
||||
return `skip: unknown object type ${getApType(object)}`;
|
||||
return `skip: unknown object type ${getApType(object) ?? 'undefined'}}`;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@ -748,14 +790,15 @@ export class ApInboxService {
|
||||
}
|
||||
|
||||
@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) {
|
||||
return 'skip: invalid actor';
|
||||
}
|
||||
|
||||
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 => {
|
||||
this.logger.error(`Resolution failed: ${e}`, { error: e });
|
||||
@ -766,7 +809,7 @@ export class ApInboxService {
|
||||
await this.apPersonService.updatePerson(actor.uri, resolver, object);
|
||||
return 'ok: Person updated';
|
||||
} 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';
|
||||
} else if (additionalCc && isPost(object)) {
|
||||
const uri = getApId(object);
|
||||
@ -790,16 +833,16 @@ export class ApInboxService {
|
||||
unlock();
|
||||
}
|
||||
} else {
|
||||
return `skip: Unknown type: ${getApType(object)}`;
|
||||
return `skip: Unknown type: ${getApType(object) ?? 'undefined'}}`;
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
const targetUri = getApHrefNullable(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';
|
||||
}
|
||||
}
|
||||
|
@ -6,15 +6,19 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { UserKeypairService } from '@/core/UserKeypairService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
|
||||
import type { IObject } from './type.js';
|
||||
|
||||
type Request = {
|
||||
url: string;
|
||||
@ -144,6 +148,7 @@ export class ApRequestService {
|
||||
private userKeypairService: UserKeypairService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private loggerService: LoggerService,
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('ap:request');
|
||||
}
|
||||
@ -179,7 +184,8 @@ export class ApRequestService {
|
||||
* @param url URL to fetch
|
||||
*/
|
||||
@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 req = ApRequestCreator.createSignedGet({
|
||||
@ -197,9 +203,39 @@ export class ApRequestService {
|
||||
headers: req.request.headers,
|
||||
}, {
|
||||
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.punyHost(url) === this.utilityService.punyHost(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;
|
||||
|
||||
assertActivityMatchesUrls(activity, [finalUrl]);
|
||||
|
||||
return activity;
|
||||
}
|
||||
}
|
||||
|
@ -42,7 +42,7 @@ export class Resolver {
|
||||
private apRendererService: ApRendererService,
|
||||
private apDbResolverService: ApDbResolverService,
|
||||
private loggerService: LoggerService,
|
||||
private recursionLimit = 100,
|
||||
private recursionLimit = 256,
|
||||
) {
|
||||
this.history = new Set();
|
||||
this.logger = this.loggerService.getLogger('ap:resolve');
|
||||
@ -53,6 +53,11 @@ export class Resolver {
|
||||
return Array.from(this.history);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getRecursionLimit(): number {
|
||||
return this.recursionLimit;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> {
|
||||
const collection = typeof value === 'string'
|
||||
@ -115,6 +120,18 @@ export class Resolver {
|
||||
throw new Error('invalid response');
|
||||
}
|
||||
|
||||
// HttpRequestService / ApRequestService have already checked that
|
||||
// `object.id` or `object.url` matches the URL used to fetch the
|
||||
// object after redirects; here we double-check that no redirects
|
||||
// bounced between hosts
|
||||
if (object.id == null) {
|
||||
throw new Error('invalid AP object: missing id');
|
||||
}
|
||||
|
||||
if (this.utilityService.punyHost(object.id) !== this.utilityService.punyHost(value)) {
|
||||
throw new Error(`invalid AP object ${value}: id ${object.id} has different host`);
|
||||
}
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,19 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: dakkar and sharkey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { IObject } from '../type.js';
|
||||
|
||||
export function assertActivityMatchesUrls(activity: IObject, urls: string[]) {
|
||||
const idOk = activity.id !== undefined && urls.includes(activity.id);
|
||||
|
||||
// technically `activity.url` could be an `ApObject = IObject |
|
||||
// string | (IObject | string)[]`, but if it's a complicated thing
|
||||
// and the `activity.id` doesn't match, I think we're fine
|
||||
// rejecting the activity
|
||||
const urlOk = typeof(activity.url) === 'string' && urls.includes(activity.url);
|
||||
|
||||
if (!idOk && !urlOk) {
|
||||
throw new Error(`bad Activity: neither id(${activity?.id}) nor url(${activity?.url}) match location(${urls})`);
|
||||
}
|
||||
}
|
@ -80,24 +80,33 @@ export class ApNoteService {
|
||||
}
|
||||
|
||||
@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 apType = getApType(object);
|
||||
|
||||
if (!validPost.includes(getApType(object))) {
|
||||
return new Error(`invalid Note: invalid object type ${getApType(object)}`);
|
||||
if (apType == null || !validPost.includes(apType)) {
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: invalid object type ${apType ?? 'undefined'}`);
|
||||
}
|
||||
|
||||
if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) {
|
||||
return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`);
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`);
|
||||
}
|
||||
|
||||
const actualHost = object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo));
|
||||
if (object.attributedTo && actualHost !== expectHost) {
|
||||
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 different host. expected: ${expectHost}, actual: ${actualHost}`);
|
||||
}
|
||||
|
||||
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) {
|
||||
const attribution = (object.attributedTo) ? getOneApId(object.attributedTo) : actor.uri;
|
||||
|
||||
if (attribution !== actor.uri) {
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attribution does not match the actor that send it. attribution: ${attribution}, actor: ${actor.uri}`);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@ -117,7 +126,7 @@ export class ApNoteService {
|
||||
* Noteを作成します。
|
||||
*/
|
||||
@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
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
|
||||
@ -126,7 +135,7 @@ export class ApNoteService {
|
||||
const object = await resolver.resolve(value);
|
||||
|
||||
const entryUri = getApId(value);
|
||||
const err = this.validateNote(object, entryUri);
|
||||
const err = this.validateNote(object, entryUri, actor);
|
||||
if (err) {
|
||||
this.logger.error(err.message, {
|
||||
resolver: { history: resolver.getHistory() },
|
||||
@ -141,14 +150,24 @@ export class ApNoteService {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const url = getOneApHrefNullable(note.url);
|
||||
|
||||
if (url && !checkHttps(url)) {
|
||||
throw new Error('unexpected schema of note url: ' + url);
|
||||
if (url != null) {
|
||||
if (!checkHttps(url)) {
|
||||
throw new Error('unexpected schema of note url: ' + url);
|
||||
}
|
||||
|
||||
if (this.utilityService.punyHost(url) !== this.utilityService.punyHost(note.id)) {
|
||||
throw new Error(`note url & uri host mismatch: note url: ${url}, note uri: ${note.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info(`Creating the Note: ${note.id}`);
|
||||
@ -161,9 +180,10 @@ export class ApNoteService {
|
||||
const uri = getOneApId(note.attributedTo);
|
||||
|
||||
// ローカルで投稿者を検索し、もし凍結されていたらスキップ
|
||||
const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser;
|
||||
if (cachedActor && cachedActor.isSuspended) {
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `User ${cachedActor.id} has been suspended.`);
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
actor ??= await this.apPersonService.fetchPerson(uri) as MiRemoteUser | undefined;
|
||||
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);
|
||||
@ -194,7 +214,8 @@ export class ApNoteService {
|
||||
}
|
||||
//#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) {
|
||||
@ -358,7 +379,7 @@ export class ApNoteService {
|
||||
if (exist) return exist;
|
||||
//#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');
|
||||
}
|
||||
|
||||
@ -366,7 +387,7 @@ export class ApNoteService {
|
||||
// ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが
|
||||
// 添付されてきたNote Objectは偽装されている可能性があるため、常に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 {
|
||||
unlock();
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ import type { ApResolverService, Resolver } from '../ApResolverService.js';
|
||||
import type { ApLoggerService } from '../ApLoggerService.js';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
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 summaryLength = 2048;
|
||||
@ -130,12 +130,6 @@ export class ApPersonService implements OnModuleInit {
|
||||
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
|
||||
* @param x Fetched object
|
||||
@ -143,7 +137,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
*/
|
||||
@bindThis
|
||||
private validateActor(x: IObject, uri: string): IActor {
|
||||
const expectHost = this.punyHost(uri);
|
||||
const expectHost = this.utilityService.punyHost(uri);
|
||||
|
||||
if (!isActor(x)) {
|
||||
throw new Error(`invalid Actor type '${x.type}'`);
|
||||
@ -157,18 +151,29 @@ export class ApPersonService implements OnModuleInit {
|
||||
throw new Error('invalid Actor: wrong inbox');
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(x.inbox);
|
||||
} catch {
|
||||
throw new Error('invalid Actor: wrong inbox');
|
||||
if (this.utilityService.punyHost(x.inbox) !== expectHost) {
|
||||
throw new Error('invalid Actor: inbox has different host');
|
||||
}
|
||||
|
||||
const sharedInbox = x.sharedInbox ?? x.endpoints?.sharedInbox;
|
||||
if (typeof sharedInbox === 'string') {
|
||||
try {
|
||||
new URL(sharedInbox);
|
||||
} catch {
|
||||
throw new Error('invalid Actor: wrong sharedInbox');
|
||||
const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined);
|
||||
if (sharedInboxObject != null) {
|
||||
const sharedInbox = getApId(sharedInboxObject);
|
||||
if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHost(sharedInbox) === expectHost)) {
|
||||
throw new Error('invalid Actor: wrong shared inbox');
|
||||
}
|
||||
}
|
||||
|
||||
for (const collection of ['outbox', 'followers', 'following'] as (keyof IActor)[]) {
|
||||
const xCollection = (x as IActor)[collection];
|
||||
if (xCollection != null) {
|
||||
const collectionUri = getApId(xCollection);
|
||||
if (typeof collectionUri === 'string' && collectionUri.length > 0) {
|
||||
if (this.utilityService.punyHost(collectionUri) !== expectHost) {
|
||||
throw new Error(`invalid Actor: ${collection} has different host`);
|
||||
}
|
||||
} else if (collectionUri != null) {
|
||||
throw new Error(`invalid Actor: wrong ${collection}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,7 +200,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
x.summary = truncate(x.summary, summaryLength);
|
||||
}
|
||||
|
||||
const idHost = this.punyHost(x.id);
|
||||
const idHost = this.utilityService.punyHost(x.id);
|
||||
if (idHost !== expectHost) {
|
||||
throw new Error('invalid Actor: id has different host');
|
||||
}
|
||||
@ -205,7 +210,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
throw new Error('invalid Actor: publicKey.id is not a string');
|
||||
}
|
||||
|
||||
const publicKeyIdHost = this.punyHost(x.publicKey.id);
|
||||
const publicKeyIdHost = this.utilityService.punyHost(x.publicKey.id);
|
||||
if (publicKeyIdHost !== expectHost) {
|
||||
throw new Error('invalid Actor: publicKey.id has different host');
|
||||
}
|
||||
@ -248,6 +253,12 @@ export class ApPersonService implements OnModuleInit {
|
||||
if (user == null) throw new Error('failed to create user: user is null');
|
||||
|
||||
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
|
||||
// explicitly-null set of values
|
||||
if ((img == null) || (typeof img === 'object' && img.url == null)) {
|
||||
@ -285,7 +296,8 @@ export class ApPersonService implements OnModuleInit {
|
||||
public async createPerson(uri: string, resolver?: Resolver): Promise<MiRemoteUser> {
|
||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||
|
||||
if (new URL(uri).origin === this.config.url) {
|
||||
const host = this.utilityService.punyHost(uri);
|
||||
if (host === this.utilityService.toPuny(this.config.host)) {
|
||||
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
|
||||
}
|
||||
|
||||
@ -299,20 +311,43 @@ export class ApPersonService implements OnModuleInit {
|
||||
|
||||
this.logger.info(`Creating the Person: ${person.id}`);
|
||||
|
||||
const host = this.punyHost(object.id);
|
||||
|
||||
const fields = this.analyzeAttachments(person.attachment ?? []);
|
||||
|
||||
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
|
||||
|
||||
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 url = getOneApHrefNullable(person.url);
|
||||
|
||||
if (url && !checkHttps(url)) {
|
||||
throw new Error('unexpected schema of person url: ' + url);
|
||||
if (person.id == null) {
|
||||
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.punyHost(url) !== this.utilityService.punyHost(person.id)) {
|
||||
throw new Error(`person url <> uri host mismatch: ${url} <> ${person.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create user
|
||||
@ -345,7 +380,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
usernameLower: person.preferredUsername?.toLowerCase(),
|
||||
host,
|
||||
inbox: person.inbox,
|
||||
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox,
|
||||
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
|
||||
followersUri: person.followers ? getApId(person.followers) : undefined,
|
||||
featured: person.featured ? getApId(person.featured) : undefined,
|
||||
uri: person.id,
|
||||
@ -368,6 +403,8 @@ export class ApPersonService implements OnModuleInit {
|
||||
description: _description,
|
||||
url,
|
||||
fields,
|
||||
followingVisibility,
|
||||
followersVisibility,
|
||||
birthday: bday?.[0] ?? null,
|
||||
location: person['vcard:Address'] ?? null,
|
||||
userHost: host,
|
||||
@ -447,7 +484,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||
|
||||
// URIがこのサーバーを指しているならスキップ
|
||||
if (new URL(uri).origin === this.config.url) return;
|
||||
if (this.utilityService.isUriLocal(uri)) return;
|
||||
|
||||
//#region このサーバーに既に登録されているか
|
||||
const exist = await this.fetchPerson(uri) as MiRemoteUser | null;
|
||||
@ -475,12 +512,39 @@ export class ApPersonService implements OnModuleInit {
|
||||
|
||||
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 url = getOneApHrefNullable(person.url);
|
||||
|
||||
if (url && !checkHttps(url)) {
|
||||
throw new Error('unexpected schema of person url: ' + url);
|
||||
if (person.id == null) {
|
||||
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.punyHost(url) !== this.utilityService.punyHost(person.id)) {
|
||||
throw new Error(`person url <> uri host mismatch: ${url} <> ${person.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const policy = await this.roleService.getUserPolicies(exist.id);
|
||||
@ -488,7 +552,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
const updates = {
|
||||
lastFetchedAt: new Date(),
|
||||
inbox: person.inbox,
|
||||
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox,
|
||||
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
|
||||
followersUri: person.followers ? getApId(person.followers) : undefined,
|
||||
featured: person.featured,
|
||||
emojis: emojiNames,
|
||||
@ -545,6 +609,8 @@ export class ApPersonService implements OnModuleInit {
|
||||
url,
|
||||
fields,
|
||||
description: _description,
|
||||
followingVisibility,
|
||||
followersVisibility,
|
||||
birthday: bday?.[0] ?? null,
|
||||
location: person['vcard:Address'] ?? null,
|
||||
});
|
||||
@ -557,7 +623,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
// 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする
|
||||
await this.followingsRepository.update(
|
||||
{ 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));
|
||||
@ -689,7 +755,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
await this.updatePerson(src.movedToUri, undefined, undefined, [...movePreventUris, src.uri]);
|
||||
dst = await this.fetchPerson(src.movedToUri) ?? dst;
|
||||
} else {
|
||||
if (new URL(src.movedToUri).origin === this.config.url) {
|
||||
if (this.utilityService.isUriLocal(src.movedToUri)) {
|
||||
// ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている
|
||||
return 'failed: movedTo is local but not found';
|
||||
}
|
||||
@ -715,4 +781,16 @@ export class ApPersonService implements OnModuleInit {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -5,17 +5,19 @@
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
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 { IPoll } from '@/models/Poll.js';
|
||||
import type { MiRemoteUser } from '@/models/User.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.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 { ApResolverService } from '../ApResolverService.js';
|
||||
import type { Resolver } from '../ApResolverService.js';
|
||||
import type { IObject, IQuestion } from '../type.js';
|
||||
import type { IObject } from '../type.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApQuestionService {
|
||||
@ -25,6 +27,9 @@ export class ApQuestionService {
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@ -33,6 +38,7 @@ export class ApQuestionService {
|
||||
|
||||
private apResolverService: ApResolverService,
|
||||
private apLoggerService: ApLoggerService,
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
this.logger = this.apLoggerService.logger;
|
||||
}
|
||||
@ -66,28 +72,39 @@ export class ApQuestionService {
|
||||
* @returns true if updated
|
||||
*/
|
||||
@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;
|
||||
if (uri == null) throw new Error('uri is null');
|
||||
|
||||
// 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 このサーバーに既に登録されているか
|
||||
const note = await this.notesRepository.findOneBy({ uri });
|
||||
if (note == null) throw new Error('Question is not registed');
|
||||
|
||||
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
|
||||
|
||||
// resolve new Question object
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
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)}`);
|
||||
|
||||
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;
|
||||
if (apChoices == null) throw new Error('invalid apChoices: ' + apChoices);
|
||||
@ -97,7 +114,7 @@ export class ApQuestionService {
|
||||
for (const choice of poll.choices) {
|
||||
const oldCount = poll.votes[poll.choices.indexOf(choice)];
|
||||
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) {
|
||||
changed = true;
|
||||
|
@ -60,11 +60,14 @@ export function getApId(value: string | IObject): string {
|
||||
|
||||
/**
|
||||
* 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 (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 {
|
||||
@ -97,19 +100,23 @@ export interface IActivity extends IObject {
|
||||
export interface ICollection extends IObject {
|
||||
type: 'Collection';
|
||||
totalItems: number;
|
||||
items: ApObject;
|
||||
first?: IObject | string;
|
||||
items?: ApObject;
|
||||
}
|
||||
|
||||
export interface IOrderedCollection extends IObject {
|
||||
type: 'OrderedCollection';
|
||||
totalItems: number;
|
||||
orderedItems: ApObject;
|
||||
first?: IObject | string;
|
||||
orderedItems?: ApObject;
|
||||
}
|
||||
|
||||
export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event'];
|
||||
|
||||
export const isPost = (object: IObject): object is IPost =>
|
||||
validPost.includes(getApType(object));
|
||||
export const isPost = (object: IObject): object is IPost => {
|
||||
const type = getApType(object);
|
||||
return type != null && validPost.includes(type);
|
||||
};
|
||||
|
||||
export interface IPost extends IObject {
|
||||
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 isActor = (object: IObject): object is IActor =>
|
||||
validActor.includes(getApType(object));
|
||||
export const isActor = (object: IObject): object is IActor => {
|
||||
const type = getApType(object);
|
||||
return type != null && validActor.includes(type);
|
||||
};
|
||||
|
||||
export interface IActor extends IObject {
|
||||
type: 'Person' | 'Service' | 'Organization' | 'Group' | 'Application';
|
||||
@ -240,12 +249,16 @@ export interface IKey extends IObject {
|
||||
publicKeyPem: string | Buffer;
|
||||
}
|
||||
|
||||
export const validDocumentTypes = ['Audio', 'Document', 'Image', 'Page', 'Video'];
|
||||
|
||||
export interface IApDocument extends IObject {
|
||||
type: 'Audio' | 'Document' | 'Image' | 'Page' | 'Video';
|
||||
}
|
||||
|
||||
export const isDocument = (object: IObject): object is IApDocument =>
|
||||
['Audio', 'Document', 'Image', 'Page', 'Video'].includes(getApType(object));
|
||||
export const isDocument = (object: IObject): object is IApDocument => {
|
||||
const type = getApType(object);
|
||||
return type != null && validDocumentTypes.includes(type);
|
||||
};
|
||||
|
||||
export interface IApImage extends IApDocument {
|
||||
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 isAdd = (object: IObject): object is IAdd => getApType(object) === 'Add';
|
||||
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 isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block';
|
||||
export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag';
|
||||
export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move';
|
||||
export const isNote = (object: IObject): object is IPost => getApType(object) === 'Note';
|
||||
|
@ -105,6 +105,7 @@ export class PageEntityService {
|
||||
attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter(isNotNull), me),
|
||||
likedCount: page.likedCount,
|
||||
isLiked: meId ? await this.pageLikesRepository.exists({ where: { pageId: page.id, userId: meId } }) : undefined,
|
||||
visibility: page.visibility,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -456,12 +456,12 @@ export class UserEntityService implements OnModuleInit {
|
||||
}
|
||||
|
||||
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 :
|
||||
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 :
|
||||
null;
|
||||
|
||||
|
@ -20,8 +20,7 @@ export function bindThis(target: any, key: string, descriptor: any) {
|
||||
return {
|
||||
configurable: true,
|
||||
get() {
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
if (this === target.prototype || this.hasOwnProperty(key) ||
|
||||
if (this === target.prototype || Object.hasOwn(this, key) ||
|
||||
typeof fn !== 'function') {
|
||||
return fn;
|
||||
}
|
||||
|
@ -7,23 +7,23 @@ import * as Redis from 'ioredis';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
export class RedisKVCache<T> {
|
||||
private redisClient: Redis.Redis;
|
||||
private name: string;
|
||||
private lifetime: number;
|
||||
private memoryCache: MemoryKVCache<T>;
|
||||
private fetcher: (key: string) => Promise<T>;
|
||||
private toRedisConverter: (value: T) => string;
|
||||
private fromRedisConverter: (value: string) => T | undefined;
|
||||
private readonly lifetime: number;
|
||||
private readonly memoryCache: MemoryKVCache<T>;
|
||||
private readonly fetcher: (key: string) => Promise<T>;
|
||||
private readonly toRedisConverter: (value: T) => string;
|
||||
private readonly fromRedisConverter: (value: string) => T | undefined;
|
||||
|
||||
constructor(redisClient: RedisKVCache<T>['redisClient'], name: RedisKVCache<T>['name'], opts: {
|
||||
lifetime: RedisKVCache<T>['lifetime'];
|
||||
memoryCacheLifetime: number;
|
||||
fetcher: RedisKVCache<T>['fetcher'];
|
||||
toRedisConverter: RedisKVCache<T>['toRedisConverter'];
|
||||
fromRedisConverter: RedisKVCache<T>['fromRedisConverter'];
|
||||
}) {
|
||||
this.redisClient = redisClient;
|
||||
this.name = name;
|
||||
constructor(
|
||||
private redisClient: Redis.Redis,
|
||||
private name: string,
|
||||
opts: {
|
||||
lifetime: RedisKVCache<T>['lifetime'];
|
||||
memoryCacheLifetime: number;
|
||||
fetcher: RedisKVCache<T>['fetcher'];
|
||||
toRedisConverter: RedisKVCache<T>['toRedisConverter'];
|
||||
fromRedisConverter: RedisKVCache<T>['fromRedisConverter'];
|
||||
},
|
||||
) {
|
||||
this.lifetime = opts.lifetime;
|
||||
this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime);
|
||||
this.fetcher = opts.fetcher;
|
||||
@ -55,10 +55,13 @@ export class RedisKVCache<T> {
|
||||
|
||||
const cached = await this.redisClient.get(`kvcache:${this.name}:${key}`);
|
||||
if (cached == null) return undefined;
|
||||
const parsed = this.fromRedisConverter(cached);
|
||||
if (parsed == null) return undefined;
|
||||
this.memoryCache.set(key, parsed);
|
||||
return parsed;
|
||||
|
||||
const value = this.fromRedisConverter(cached);
|
||||
if (value !== undefined) {
|
||||
this.memoryCache.set(key, value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@ -69,6 +72,10 @@ export class RedisKVCache<T> {
|
||||
|
||||
/**
|
||||
* キャッシュがあればそれを返し、無ければ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
|
||||
public async fetch(key: string): Promise<T> {
|
||||
@ -80,14 +87,14 @@ export class RedisKVCache<T> {
|
||||
|
||||
// Cache MISS
|
||||
const value = await this.fetcher(key);
|
||||
this.set(key, value);
|
||||
await this.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async refresh(key: string) {
|
||||
const value = await this.fetcher(key);
|
||||
this.set(key, value);
|
||||
await this.set(key, value);
|
||||
|
||||
// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする
|
||||
}
|
||||
@ -104,23 +111,23 @@ export class RedisKVCache<T> {
|
||||
}
|
||||
|
||||
export class RedisSingleCache<T> {
|
||||
private redisClient: Redis.Redis;
|
||||
private name: string;
|
||||
private lifetime: number;
|
||||
private memoryCache: MemorySingleCache<T>;
|
||||
private fetcher: () => Promise<T>;
|
||||
private toRedisConverter: (value: T) => string;
|
||||
private fromRedisConverter: (value: string) => T | undefined;
|
||||
private readonly lifetime: number;
|
||||
private readonly memoryCache: MemorySingleCache<T>;
|
||||
private readonly fetcher: () => Promise<T>;
|
||||
private readonly toRedisConverter: (value: T) => string;
|
||||
private readonly fromRedisConverter: (value: string) => T | undefined;
|
||||
|
||||
constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: {
|
||||
lifetime: RedisSingleCache<T>['lifetime'];
|
||||
memoryCacheLifetime: number;
|
||||
fetcher: RedisSingleCache<T>['fetcher'];
|
||||
toRedisConverter: RedisSingleCache<T>['toRedisConverter'];
|
||||
fromRedisConverter: RedisSingleCache<T>['fromRedisConverter'];
|
||||
}) {
|
||||
this.redisClient = redisClient;
|
||||
this.name = name;
|
||||
constructor(
|
||||
private redisClient: Redis.Redis,
|
||||
private name: string,
|
||||
opts: {
|
||||
lifetime: number;
|
||||
memoryCacheLifetime: number;
|
||||
fetcher: RedisSingleCache<T>['fetcher'];
|
||||
toRedisConverter: RedisSingleCache<T>['toRedisConverter'];
|
||||
fromRedisConverter: RedisSingleCache<T>['fromRedisConverter'];
|
||||
},
|
||||
) {
|
||||
this.lifetime = opts.lifetime;
|
||||
this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime);
|
||||
this.fetcher = opts.fetcher;
|
||||
@ -152,10 +159,13 @@ export class RedisSingleCache<T> {
|
||||
|
||||
const cached = await this.redisClient.get(`singlecache:${this.name}`);
|
||||
if (cached == null) return undefined;
|
||||
const parsed = this.fromRedisConverter(cached);
|
||||
if (parsed == null) return undefined;
|
||||
this.memoryCache.set(parsed);
|
||||
return parsed;
|
||||
|
||||
const value = this.fromRedisConverter(cached);
|
||||
if (value !== undefined) {
|
||||
this.memoryCache.set(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@ -166,6 +176,10 @@ export class RedisSingleCache<T> {
|
||||
|
||||
/**
|
||||
* キャッシュがあればそれを返し、無ければ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
|
||||
public async fetch(): Promise<T> {
|
||||
@ -177,14 +191,14 @@ export class RedisSingleCache<T> {
|
||||
|
||||
// Cache MISS
|
||||
const value = await this.fetcher();
|
||||
this.set(value);
|
||||
await this.set(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async refresh() {
|
||||
const value = await this.fetcher();
|
||||
this.set(value);
|
||||
await this.set(value);
|
||||
|
||||
// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする
|
||||
}
|
||||
@ -197,18 +211,12 @@ export class MemoryKVCache<T> {
|
||||
* データを持つマップ
|
||||
* これを直接操作するべきではない
|
||||
*/
|
||||
public cache: Map<string, { date: number; value: T; }>;
|
||||
private lifetime: number;
|
||||
private gcIntervalHandle: NodeJS.Timeout;
|
||||
private readonly cache = new Map<string, { date: number; value: T; }>();
|
||||
private readonly gcIntervalHandle = setInterval(() => this.gc(), 1000 * 60 * 3); // 3m
|
||||
|
||||
constructor(lifetime: MemoryKVCache<never>['lifetime']) {
|
||||
this.cache = new Map();
|
||||
this.lifetime = lifetime;
|
||||
|
||||
this.gcIntervalHandle = setInterval(() => {
|
||||
this.gc();
|
||||
}, 1000 * 60 * 3);
|
||||
}
|
||||
constructor(
|
||||
private readonly lifetime: number,
|
||||
) {}
|
||||
|
||||
@bindThis
|
||||
/**
|
||||
@ -293,10 +301,14 @@ export class MemoryKVCache<T> {
|
||||
@bindThis
|
||||
public gc(): void {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [key, { date }] of this.cache.entries()) {
|
||||
if ((now - date) > this.lifetime) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
// The map is ordered from oldest to youngest.
|
||||
// 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 {
|
||||
clearInterval(this.gcIntervalHandle);
|
||||
}
|
||||
|
||||
public get entries() {
|
||||
return this.cache.entries();
|
||||
}
|
||||
}
|
||||
|
||||
export class MemorySingleCache<T> {
|
||||
private cachedAt: number | null = null;
|
||||
private value: T | undefined;
|
||||
private lifetime: number;
|
||||
|
||||
constructor(lifetime: MemorySingleCache<never>['lifetime']) {
|
||||
this.lifetime = lifetime;
|
||||
}
|
||||
constructor(
|
||||
private lifetime: number,
|
||||
) {}
|
||||
|
||||
@bindThis
|
||||
public set(value: T): void {
|
||||
|
@ -2,21 +2,21 @@ import { EventEmitter } from 'node:events';
|
||||
import * as Bull from 'bullmq';
|
||||
|
||||
export class Queues<DataType = any, ResultType = any, NameType extends string = string> {
|
||||
public readonly queues: ReadonlyArray<Bull.Queue<DataType, ResultType, NameType>>;
|
||||
public readonly queues: ReadonlyArray<Bull.Queue<void, void, string, DataType, ResultType, NameType>>;
|
||||
|
||||
constructor(queues: Bull.Queue<DataType, ResultType, NameType>[]) {
|
||||
constructor(queues: Bull.Queue<void, void, string, DataType, ResultType, NameType>[]) {
|
||||
if (queues.length === 0) {
|
||||
throw new Error('queues cannot be empty.');
|
||||
}
|
||||
this.queues = queues;
|
||||
}
|
||||
|
||||
getRandomQueue(): Bull.Queue<DataType, ResultType, NameType> {
|
||||
get randomQueue(): Bull.Queue<void, void, string, DataType, ResultType, NameType> {
|
||||
return this.queues[Math.floor(Math.random() * this.queues.length)];
|
||||
}
|
||||
|
||||
add(name: NameType, data: DataType, opts?: Bull.JobsOptions): Promise<Bull.Job<DataType, ResultType, NameType>> {
|
||||
return this.getRandomQueue().add(name, data, opts);
|
||||
return this.randomQueue.add(name, data, opts);
|
||||
}
|
||||
|
||||
async addBulk(jobs: { name: NameType; data: DataType; opts?: Bull.BulkJobOptions }[]): Promise<Bull.Job<DataType, ResultType, NameType>[]> {
|
||||
@ -30,7 +30,7 @@ export class Queues<DataType = any, ResultType = any, NameType extends string =
|
||||
}
|
||||
|
||||
async getDelayed(start?: number, end?: number): Promise<Bull.Job<DataType, ResultType, NameType>[]> {
|
||||
return (await Promise.allSettled(this.queues.map(queue => queue.getDelayed(start, end))))
|
||||
return (await Promise.allSettled(this.queues.map(queue => queue.getDelayed(start, end) as Promise<Bull.Job<DataType, ResultType, NameType>[]>)))
|
||||
.filter((value): value is PromiseFulfilledResult<Bull.Job<DataType, ResultType, NameType>[]> => value.status === 'fulfilled')
|
||||
.flatMap(value => value.value);
|
||||
}
|
||||
@ -46,7 +46,7 @@ export class Queues<DataType = any, ResultType = any, NameType extends string =
|
||||
}, {} as Record<string, number>);
|
||||
}
|
||||
|
||||
once<U extends keyof Bull.QueueListener<DataType, ResultType, NameType>>(event: U, listener: Bull.QueueListener<DataType, ResultType, NameType>[U]): void {
|
||||
once<U extends keyof Bull.QueueListener<Bull.Job<DataType, ResultType, NameType>>>(event: U, listener: Bull.QueueListener<Bull.Job<DataType, ResultType, NameType>>[U]): void {
|
||||
const e = new EventEmitter();
|
||||
e.once(event, listener);
|
||||
|
||||
@ -62,7 +62,7 @@ export class Queues<DataType = any, ResultType = any, NameType extends string =
|
||||
}
|
||||
|
||||
async getJobs(types?: Bull.JobType[] | Bull.JobType, start?: number, end?: number, asc?: boolean): Promise<Bull.Job<DataType, ResultType, NameType>[]> {
|
||||
return (await Promise.allSettled(this.queues.map(queue => queue.getJobs(types, start, end, asc))))
|
||||
return (await Promise.allSettled(this.queues.map(queue => queue.getJobs(types, start, end, asc) as Promise<Bull.Job<DataType, ResultType, NameType>[]>)))
|
||||
.filter((value): value is PromiseFulfilledResult<Bull.Job<DataType, ResultType, NameType>[]> => value.status === 'fulfilled')
|
||||
.flatMap(value => value.value);
|
||||
}
|
||||
|
@ -99,18 +99,13 @@ export class MiPage {
|
||||
|
||||
/**
|
||||
* public ... 公開
|
||||
* followers ... フォロワーのみ
|
||||
* specified ... visibleUserIds で指定したユーザーのみ
|
||||
* private ... 非公開
|
||||
*/
|
||||
@Column('enum', { enum: ['public', 'followers', 'specified'] })
|
||||
public visibility: 'public' | 'followers' | 'specified';
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
array: true, default: '{}',
|
||||
@Column('enum', {
|
||||
enum: ['public', 'private'],
|
||||
default: 'public',
|
||||
})
|
||||
public visibleUserIds: MiUser['id'][];
|
||||
public visibility: 'public' | 'private';
|
||||
|
||||
@Column('integer', {
|
||||
default: 0,
|
||||
|
@ -52,4 +52,11 @@ export class MiRoleAssignment {
|
||||
nullable: true,
|
||||
})
|
||||
public expiresAt: Date | null;
|
||||
|
||||
@Column('varchar', {
|
||||
comment: 'memo for the role assignment',
|
||||
length: 256,
|
||||
nullable: true,
|
||||
})
|
||||
public memo: string | null;
|
||||
}
|
||||
|
@ -205,6 +205,11 @@ export const packedPageSchema = {
|
||||
type: 'boolean',
|
||||
optional: true, nullable: false,
|
||||
},
|
||||
visibility: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: ['public', 'private'],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
@ -44,7 +44,7 @@ export class DeliverProcessorService {
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
) {
|
||||
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
|
||||
|
@ -180,6 +180,8 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
||||
if (signerHost !== 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
|
||||
@ -198,7 +200,11 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
||||
|
||||
// アクティビティを処理
|
||||
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) {
|
||||
if (e instanceof IdentifiableError) {
|
||||
if ([
|
||||
@ -206,6 +212,7 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
||||
'689ee33f-f97c-479a-ac49-1b9f8140af99',
|
||||
'9f466dab-c856-48cd-9e65-ff90ff750580',
|
||||
'85ab9bd7-3a41-4530-959d-f07073900109',
|
||||
'd450b8a9-48e4-4dab-ae36-f4db763fda7c',
|
||||
].includes(e.id)) return e.message;
|
||||
}
|
||||
throw e;
|
||||
|
@ -105,7 +105,7 @@ export class ActivityPubServerService {
|
||||
let signature;
|
||||
|
||||
try {
|
||||
signature = httpSignature.parseRequest(request.raw, { 'headers': [] });
|
||||
signature = httpSignature.parseRequest(request.raw, { 'headers': ['(request-target)', 'host', 'date'], authorizationHeaderName: 'signature' });
|
||||
} catch (e) {
|
||||
reply.code(401);
|
||||
return;
|
||||
|
@ -133,7 +133,7 @@ export class NodeinfoServerService {
|
||||
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) => {
|
||||
const base = await cache.fetch(() => nodeinfo2(21));
|
||||
|
@ -271,7 +271,7 @@ export class ServerService implements OnApplicationShutdown {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
fastify.listen({ port: this.config.port, host: '0.0.0.0' });
|
||||
fastify.listen({ port: this.config.port, host: '::' });
|
||||
}
|
||||
|
||||
await fastify.ready();
|
||||
|
@ -37,7 +37,7 @@ export class AuthenticateService implements OnApplicationShutdown {
|
||||
|
||||
private cacheService: CacheService,
|
||||
) {
|
||||
this.appCache = new MemoryKVCache<MiApp>(Infinity);
|
||||
this.appCache = new MemoryKVCache<MiApp>(1000 * 60 * 60 * 24 * 7); // 1w
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -382,6 +382,7 @@ import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by
|
||||
import * as ep___users_search from './endpoints/users/search.js';
|
||||
import * as ep___users_show from './endpoints/users/show.js';
|
||||
import * as ep___users_stats from './endpoints/users/stats.js';
|
||||
import * as ep___users_get_security_info from './endpoints/users/get-security-info.js';
|
||||
import * as ep___users_achievements from './endpoints/users/achievements.js';
|
||||
import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
|
||||
import * as ep___fetchRss from './endpoints/fetch-rss.js';
|
||||
@ -751,6 +752,7 @@ const $users_following: Provider = { provide: 'ep:users/following', useClass: ep
|
||||
const $users_gallery_posts: Provider = { provide: 'ep:users/gallery/posts', useClass: ep___users_gallery_posts.default };
|
||||
const $users_getFollowingBirthdayUsers: Provider = { provide: 'ep:users/get-following-birthday-users', useClass: ep___users_getFollowingBirthdayUsers.default };
|
||||
const $users_getFrequentlyRepliedUsers: Provider = { provide: 'ep:users/get-frequently-replied-users', useClass: ep___users_getFrequentlyRepliedUsers.default };
|
||||
const $users_getSecurityInfo: Provider = { provide: 'ep:users/get-security-info', useClass: ep___users_get_security_info.default };
|
||||
const $users_getSkebStatus: Provider = { provide: 'ep:users/get-skeb-status', useClass: ep___users_getSkebStatus.default };
|
||||
const $users_featuredNotes: Provider = { provide: 'ep:users/featured-notes', useClass: ep___users_featuredNotes.default };
|
||||
const $users_lists_create: Provider = { provide: 'ep:users/lists/create', useClass: ep___users_lists_create.default };
|
||||
@ -1149,6 +1151,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
||||
$users_gallery_posts,
|
||||
$users_getFollowingBirthdayUsers,
|
||||
$users_getFrequentlyRepliedUsers,
|
||||
$users_getSecurityInfo,
|
||||
$users_getSkebStatus,
|
||||
$users_featuredNotes,
|
||||
$users_lists_create,
|
||||
@ -1539,6 +1542,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
||||
$users_gallery_posts,
|
||||
$users_getFollowingBirthdayUsers,
|
||||
$users_getFrequentlyRepliedUsers,
|
||||
$users_getSecurityInfo,
|
||||
$users_getSkebStatus,
|
||||
$users_featuredNotes,
|
||||
$users_lists_create,
|
||||
|
@ -3,11 +3,13 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type {
|
||||
MiUserProfile,
|
||||
SigninsRepository,
|
||||
UserProfilesRepository,
|
||||
UsersRepository,
|
||||
@ -25,9 +27,8 @@ import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { RateLimiterService } from './RateLimiterService.js';
|
||||
import { SigninService } from './SigninService.js';
|
||||
import type { AuthenticationResponseJSON } from '@simplewebauthn/types';
|
||||
import type { AuthenticationResponseJSON } from '@simplewebauthn/server';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
@Injectable()
|
||||
export class SigninApiService {
|
||||
@ -121,35 +122,47 @@ export class SigninApiService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch user
|
||||
const user = await this.usersRepository.findOneBy({
|
||||
usernameLower: username.toLowerCase(),
|
||||
host: IsNull(),
|
||||
}) as MiLocalUser;
|
||||
const loginWithEmail = username.includes('@');
|
||||
|
||||
if (user == null) {
|
||||
// Fetch user
|
||||
const profile = await this.userProfilesRepository.findOne({
|
||||
relations: ['user'],
|
||||
where: loginWithEmail ? {
|
||||
email: username,
|
||||
emailVerified: true,
|
||||
user: {
|
||||
host: IsNull(),
|
||||
}
|
||||
} : {
|
||||
user: {
|
||||
usernameLower: username.toLowerCase(),
|
||||
host: IsNull(),
|
||||
}
|
||||
}
|
||||
});
|
||||
const user = (profile?.user as MiLocalUser) ?? null;
|
||||
|
||||
if (!user || !profile) {
|
||||
logger.error('No such user.');
|
||||
return error(404, {
|
||||
id: '6cc579cc-885d-43d8-95c2-b8c7fc963280',
|
||||
return error(403, {
|
||||
id: loginWithEmail ? '932c904e-9460-45b7-9ce6-7ed33be7eb2c' : '6cc579cc-885d-43d8-95c2-b8c7fc963280',
|
||||
});
|
||||
}
|
||||
|
||||
if (user.isDeleted && user.isSuspended) {
|
||||
logger.error('No such user. (logical deletion)');
|
||||
return error(404, {
|
||||
id: '6cc579cc-885d-43d8-95c2-b8c7fc963280',
|
||||
return error(403, {
|
||||
id: loginWithEmail ? '932c904e-9460-45b7-9ce6-7ed33be7eb2c' : '6cc579cc-885d-43d8-95c2-b8c7fc963280',
|
||||
});
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
logger.error('User is suspended.');
|
||||
return error(403, {
|
||||
id: 'e03a5f46-d309-4865-9b69-56282d94e1eb',
|
||||
id: loginWithEmail ? '932c904e-9460-45b7-9ce6-7ed33be7eb2c' : 'e03a5f46-d309-4865-9b69-56282d94e1eb',
|
||||
});
|
||||
}
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
|
||||
// Compare password
|
||||
const same = await bcrypt.compare(password, profile.password!);
|
||||
|
||||
@ -169,27 +182,26 @@ export class SigninApiService {
|
||||
if (!profile.twoFactorEnabled) {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const meta = await this.metaService.fetch();
|
||||
if (meta.enableHcaptcha && meta.hcaptchaSecretKey) {
|
||||
await this.captchaService.verifyHcaptcha(meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
try {
|
||||
if (meta.enableHcaptcha && meta.hcaptchaSecretKey) {
|
||||
await this.captchaService.verifyHcaptcha(meta.hcaptchaSecretKey, body['hcaptcha-response']);
|
||||
}
|
||||
|
||||
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 => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
if (meta.enableMcaptcha && meta.mcaptchaSecretKey && meta.mcaptchaSitekey && meta.mcaptchaInstanceUrl) {
|
||||
await this.captchaService.verifyMcaptcha(meta.mcaptchaSecretKey, meta.mcaptchaSitekey, meta.mcaptchaInstanceUrl, body['m-captcha-response']);
|
||||
}
|
||||
|
||||
if (meta.enableRecaptcha && meta.recaptchaSecretKey) {
|
||||
await this.captchaService.verifyRecaptcha(meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
if (meta.enableRecaptcha && meta.recaptchaSecretKey) {
|
||||
await this.captchaService.verifyRecaptcha(meta.recaptchaSecretKey, body['g-recaptcha-response']);
|
||||
}
|
||||
|
||||
if (meta.enableTurnstile && meta.turnstileSecretKey) {
|
||||
await this.captchaService.verifyTurnstile(meta.turnstileSecretKey, body['turnstile-response']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
if (meta.enableTurnstile && meta.turnstileSecretKey) {
|
||||
await this.captchaService.verifyTurnstile(meta.turnstileSecretKey, body['turnstile-response']);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Invalid request: captcha verification failed: ${err}`);
|
||||
return await fail(403, {
|
||||
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -218,7 +230,7 @@ export class SigninApiService {
|
||||
} catch (e) {
|
||||
logger.error('Invalid request: Unable to authenticate with two-factor token.');
|
||||
return await fail(403, {
|
||||
id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f',
|
||||
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
|
||||
});
|
||||
}
|
||||
|
||||
@ -240,7 +252,7 @@ export class SigninApiService {
|
||||
} else {
|
||||
logger.error('Invalid request: Unable to authenticate with WebAuthn credential.');
|
||||
return await fail(403, {
|
||||
id: '93b86c4b-72f9-40eb-9815-798928603d1e',
|
||||
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
@ -381,6 +381,7 @@ import * as ep___users_reportAbuse from './endpoints/users/report-abuse.js';
|
||||
import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by-username-and-host.js';
|
||||
import * as ep___users_search from './endpoints/users/search.js';
|
||||
import * as ep___users_show from './endpoints/users/show.js';
|
||||
import * as ep___users_get_security_info from './endpoints/users/get-security-info.js';
|
||||
import * as ep___users_stats from './endpoints/users/stats.js';
|
||||
import * as ep___users_achievements from './endpoints/users/achievements.js';
|
||||
import * as ep___users_updateMemo from './endpoints/users/update-memo.js';
|
||||
@ -773,6 +774,7 @@ const eps = [
|
||||
['users/search-by-username-and-host', ep___users_searchByUsernameAndHost],
|
||||
['users/search', ep___users_search],
|
||||
['users/show', ep___users_show],
|
||||
['users/get-security-info', ep___users_get_security_info],
|
||||
['users/stats', ep___users_stats],
|
||||
['users/achievements', ep___users_achievements],
|
||||
['users/update-memo', ep___users_updateMemo],
|
||||
|
@ -49,6 +49,7 @@ export const paramDef = {
|
||||
properties: {
|
||||
roleId: { type: 'string', format: 'misskey:id' },
|
||||
userId: { type: 'string', format: 'misskey:id' },
|
||||
memo: { type: 'string' },
|
||||
expiresAt: {
|
||||
type: 'integer',
|
||||
nullable: true,
|
||||
@ -90,7 +91,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -36,6 +36,7 @@ export const meta = {
|
||||
id: { type: 'string', format: 'misskey:id' },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
user: { ref: 'UserDetailed' },
|
||||
memo: { type: 'string', nullable: true },
|
||||
expiresAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
},
|
||||
required: ['id', 'createdAt', 'user'],
|
||||
@ -93,6 +94,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
id: assign.id,
|
||||
createdAt: this.idService.parse(assign.id).date.toISOString(),
|
||||
user: await this.userEntityService.pack(assign.user!, me, { schema: 'UserDetailed' }),
|
||||
memo: assign.memo,
|
||||
expiresAt: assign.expiresAt?.toISOString() ?? null,
|
||||
})));
|
||||
});
|
||||
|
@ -177,6 +177,10 @@ export const meta = {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
memo: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -262,6 +266,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
createdAt: this.idService.parse(a.id).date.toISOString(),
|
||||
expiresAt: a.expiresAt ? a.expiresAt.toISOString() : null,
|
||||
roleId: a.roleId,
|
||||
memo: a.memo,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
@ -11,6 +11,7 @@ import { ApResolverService } from '@/core/activitypub/ApResolverService.js';
|
||||
export const meta = {
|
||||
tags: ['federation'],
|
||||
|
||||
requireAdmin: true,
|
||||
requireCredential: true,
|
||||
kind: 'read:federation',
|
||||
|
||||
|
@ -122,6 +122,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
]));
|
||||
if (local != null) return local;
|
||||
|
||||
const host = this.utilityService.extractDbHost(uri);
|
||||
|
||||
// local object, not found in db? fail
|
||||
if (this.utilityService.isSelfHost(host)) return null;
|
||||
|
||||
// リモートから一旦オブジェクトフェッチ
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
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;
|
||||
}
|
||||
|
||||
// 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない
|
||||
return await this.mergePack(
|
||||
me,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -559,7 +559,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
const html = await this.httpRequestService.getHtml(url);
|
||||
|
||||
const { window } = new JSDOM(html);
|
||||
const doc = window.document;
|
||||
const doc = window.document as Document;
|
||||
|
||||
const myLink = `${this.config.url}/@${user.username}`;
|
||||
|
||||
|
@ -11,6 +11,8 @@ export const meta = {
|
||||
tags: ['meta'],
|
||||
|
||||
requireCredential: false,
|
||||
allowGet: true,
|
||||
cacheSec: 60,
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
|
@ -65,6 +65,7 @@ export const paramDef = {
|
||||
font: { type: 'string', enum: ['serif', 'sans-serif'], default: 'sans-serif' },
|
||||
alignCenter: { type: 'boolean', default: false },
|
||||
hideTitleWhenPinned: { type: 'boolean', default: false },
|
||||
visibility: { type: 'string', enum: ['public', 'private'] },
|
||||
},
|
||||
required: ['title', 'name', 'content', 'variables', 'script'],
|
||||
} as const;
|
||||
@ -114,7 +115,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
script: ps.script,
|
||||
eyeCatchingImageId: eyeCatchingImage ? eyeCatchingImage.id : null,
|
||||
userId: me.id,
|
||||
visibility: 'public',
|
||||
visibility: ps.visibility,
|
||||
alignCenter: ps.alignCenter,
|
||||
hideTitleWhenPinned: ps.hideTitleWhenPinned,
|
||||
font: ps.font,
|
||||
|
@ -78,6 +78,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
throw new ApiError(meta.errors.noSuchPage);
|
||||
}
|
||||
|
||||
if (page.visibility === 'private' && (me == null || (page.userId !== me.id))) {
|
||||
throw new ApiError(meta.errors.noSuchPage);
|
||||
}
|
||||
|
||||
return await this.pageEntityService.pack(page, me);
|
||||
});
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user