diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index b6ebcf6ad3..5a397168de 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1 +1 @@ -FROM mcr.microsoft.com/devcontainers/javascript-node:0-18 +FROM mcr.microsoft.com/devcontainers/javascript-node:22 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c6a85f788a..1efe69ae25 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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", diff --git a/.devcontainer/init.sh b/.devcontainer/init.sh index 729e1a9d2d..bcad3e6d85 100755 --- a/.devcontainer/init.sh +++ b/.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 diff --git a/.dockerignore b/.dockerignore index 087e9861e4..c628b85ec6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/.github/ISSUE_TEMPLATE/01_bug-report.yml b/.github/ISSUE_TEMPLATE/01_bug-report.yml index ac2b39cc12..89b8e7959b 100644 --- a/.github/ISSUE_TEMPLATE/01_bug-report.yml +++ b/.github/ISSUE_TEMPLATE/01_bug-report.yml @@ -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 diff --git a/.github/workflows/api-misskey-js.yml b/.github/workflows/api-misskey-js.yml index 377946d587..620f1415bb 100644 --- a/.github/workflows/api-misskey-js.yml +++ b/.github/workflows/api-misskey-js.yml @@ -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 diff --git a/.github/workflows/check-misskey-js-version.yml b/.github/workflows/check-misskey-js-version.yml index 9f06a8ff2e..fef172b6c3 100644 --- a/.github/workflows/check-misskey-js-version.yml +++ b/.github/workflows/check-misskey-js-version.yml @@ -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 diff --git a/.github/workflows/docker-beta.yml b/.github/workflows/docker-beta.yml index c05a0f709b..2198ea7d5a 100644 --- a/.github/workflows/docker-beta.yml +++ b/.github/workflows/docker-beta.yml @@ -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 diff --git a/.github/workflows/docker-host.yml b/.github/workflows/docker-host.yml index 9476b9223d..80928ff02a 100644 --- a/.github/workflows/docker-host.yml +++ b/.github/workflows/docker-host.yml @@ -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 diff --git a/.github/workflows/docker-io.yml b/.github/workflows/docker-io.yml index bc0dacd765..578cb56d30 100644 --- a/.github/workflows/docker-io.yml +++ b/.github/workflows/docker-io.yml @@ -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 diff --git a/.github/workflows/dockle.yml b/.github/workflows/dockle.yml index 25146a768d..065db2221e 100644 --- a/.github/workflows/dockle.yml +++ b/.github/workflows/dockle.yml @@ -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: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b80a59a68b..086ea0312e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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' }} diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index adc47f6412..4d60cbc7d2 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -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 diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index 0eb3640445..2549e95cef 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -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 diff --git a/.github/workflows/test-misskey-js.yml b/.github/workflows/test-misskey-js.yml index 9fdaed3964..2edddca81b 100644 --- a/.github/workflows/test-misskey-js.yml +++ b/.github/workflows/test-misskey-js.yml @@ -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 diff --git a/.github/workflows/test-production.yml b/.github/workflows/test-production.yml index 21608d21b0..3206359fd2 100644 --- a/.github/workflows/test-production.yml +++ b/.github/workflows/test-production.yml @@ -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 diff --git a/.github/workflows/validate-api-json.yml b/.github/workflows/validate-api-json.yml index b1236fb384..485b350013 100644 --- a/.github/workflows/validate-api-json.yml +++ b/.github/workflows/validate-api-json.yml @@ -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 diff --git a/.gitignore b/.gitignore index c417e98651..d74a01cc25 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.node-version b/.node-version index 209e3ef4b6..2bd5a0a98a 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -20 +22 diff --git a/Dockerfile b/Dockerfile index 8bdc40865d..490fe02d93 100644 --- a/Dockerfile +++ b/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" ] diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index 88707fe111..7e3047050e 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -822,6 +822,7 @@ unmuteThread: "ارفع الكتم عن النقاش" continueThread: "اعرض بقية النقاش" deleteAccountConfirm: "سيحذف حسابك نهائيًا، أتريد المتابعة؟" incorrectPassword: "كلمة السر خاطئة." +authenticationFailed: "فشل التوثيق" voteConfirm: "متيقِّن من تصويتك لـ {choice}؟" hide: "إخفاء" welcomeBackWithName: "مرحبًا بك مجددًا {name}" diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index dc5d315aed..79fe3a63bb 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -815,6 +815,7 @@ unmuteThread: "থ্রেড আনমিউট করুন" continueThread: "আরো থ্রেড দেখুন" deleteAccountConfirm: "আপনার অ্যাকাউন্ট মুছে ফেলা হবে। ঠিক আছে?" incorrectPassword: "আপনার দেওয়া পাসওয়ার্ডটি ভুল।" +authenticationFailed: "প্রমাণীকরণ ব্যর্থ হয়েছে।" voteConfirm: "\"{choice}\" এ ভোট দিতে চান?" hide: "লুকান" useDrawerReactionPickerForMobile: "মোবাইলে রিঅ্যাকশন পিকারকে ড্রয়ারে প্রদর্শন করুন" diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index c006963406..447c44494a 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -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 " diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index cff533976e..993e06fd98 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -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í" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index e83d0d40b3..f2d0ad178c 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -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" diff --git a/locales/en-US.yml b/locales/en-US.yml index e83883d3c7..c202d53d18 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -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}" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index ffe6b239fa..ca2202b76a 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -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" diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index 9fb852ca20..d20fc7682d 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -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" diff --git a/locales/id-ID.yml b/locales/id-ID.yml index dd67e2d5aa..ac7128b516 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -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" diff --git a/locales/index.d.ts b/locales/index.d.ts index eba90728a1..045db3c84d 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -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": { /** * テキスト diff --git a/locales/it-IT.yml b/locales/it-IT.yml index af0dc9d05a..4590ec1a51 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -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" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 746854467d..7465bdbb08 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -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: "テキストエリア" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 121b12b1ce..06bd3e27af 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -896,6 +896,7 @@ followersVisibility: "フォロワーの公開範囲" continueThread: "さらにスレッドを見るで" deleteAccountConfirm: "アカウントを消すで?ええんか?" incorrectPassword: "パスワードがちゃうわ。" +authenticationFailed: "認証失敗したで。" voteConfirm: "「{choice}」に投票するんか?" hide: "隠す" useDrawerReactionPickerForMobile: "ケータイとかのときドロワーで表示するで" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index e65ddaea20..b25f4ec781 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -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}" diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index 2183aa3022..7dc38ac31f 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -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" diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index e00f5750dd..2cd62f1f92 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -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" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 66e032f16f..074a1ab95d 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -881,6 +881,7 @@ unmuteThread: "Отменить сокрытие цепочки" continueThread: "Показать следующие ответы" deleteAccountConfirm: "Учётная запись будет безвозвратно удалена. Подтверждаете?" incorrectPassword: "Пароль неверен." +authenticationFailed: "Аутентификация не удалась." voteConfirm: "Отдать голос за «{choice}»?" hide: "Спрятать" useDrawerReactionPickerForMobile: "Выдвижная палитра на мобильном устройстве" diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index 0978701e55..dbfa4e2e49 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -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" diff --git a/locales/sv-SE.yml b/locales/sv-SE.yml index 62bc71a13d..83c9278ab8 100644 --- a/locales/sv-SE.yml +++ b/locales/sv-SE.yml @@ -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" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index 37d6169d0e..0ecafb94e8 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -896,6 +896,7 @@ followersVisibility: "การมองเห็นผู้ที่กำล continueThread: "ดูความต่อเนื่องเธรด" deleteAccountConfirm: "การดำเนินการนี้จะลบบัญชีของคุณอย่างถาวรเลยนะ แน่ใจหรอดำเนินการ?" incorrectPassword: "รหัสผ่านไม่ถูกต้อง" +authenticationFailed: "การตรวจสอบตัวตนล้มเหลว" voteConfirm: "ต้องการโหวต “{choice}” ใช่ไหม?" hide: "ซ่อน" useDrawerReactionPickerForMobile: "แสดง ตัวจิ้มรีแอคชั่น เป็นแบบลิ้นชัก เมื่อใช้บนมือถือ" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 0ce5dc1202..02b6543cf4 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -822,6 +822,7 @@ unmuteThread: "Скасувати глушіння" continueThread: "Показати продовження треду" deleteAccountConfirm: "Це незворотно видалить ваш акаунт. Продовжити?" incorrectPassword: "Неправильний пароль." +authenticationFailed: "Аутентифікація не вдалася." voteConfirm: "Підтверджуєте свій голос за \"{choice}\"?" hide: "Сховати" welcomeBackWithName: "З поверненням, {name}!" diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index d9c21d29ad..8708678938 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -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" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 1364ae375c..5154507583 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -899,6 +899,7 @@ followersVisibility: "关注者的公开范围" continueThread: "查看更多帖子" deleteAccountConfirm: "将要删除账户。是否确认?" incorrectPassword: "密码错误" +authenticationFailed: "认证失败" voteConfirm: "确定投给 “{choice}” ?" hide: "隐藏" useDrawerReactionPickerForMobile: "在移动设备上使用抽屉显示" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 7ad9ca9099..a759f43200 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -896,6 +896,7 @@ followersVisibility: "追隨者的可見性" continueThread: "查看更多貼文" deleteAccountConfirm: "將要刪除帳戶。是否確定?" incorrectPassword: "密碼錯誤。" +authenticationFailed: "驗證失敗。" voteConfirm: "確定投給「{choice}」?" hide: "隱藏" useDrawerReactionPickerForMobile: "在移動設備上使用抽屜顯示" diff --git a/package.json b/package.json index cad3851fc8..30a5d5cce7 100644 --- a/package.json +++ b/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" diff --git a/packages/backend/.swcrc b/packages/backend/.swcrc index 0504a2d389..9593b0bf77 100644 --- a/packages/backend/.swcrc +++ b/packages/backend/.swcrc @@ -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 } diff --git a/packages/backend/jest.config.e2e.cjs b/packages/backend/jest.config.e2e.cjs index 4502da47df..e2ab0c687c 100644 --- a/packages/backend/jest.config.e2e.cjs +++ b/packages/backend/jest.config.e2e.cjs @@ -7,7 +7,7 @@ const base = require('./jest.config.cjs') module.exports = { ...base, - globalSetup: "/built-test/entry.js", + globalSetup: "/test-server/entry.mjs", setupFilesAfterEnv: ["/test/jest.setup.ts"], testMatch: [ "/test/e2e/**/*.ts", diff --git a/packages/backend/migration/1733563840208-page-visibility.js b/packages/backend/migration/1733563840208-page-visibility.js new file mode 100644 index 0000000000..fc4ad59d2a --- /dev/null +++ b/packages/backend/migration/1733563840208-page-visibility.js @@ -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`); + } +} diff --git a/packages/backend/migration/1735078824104-RoleAssignment-Memo.js b/packages/backend/migration/1735078824104-RoleAssignment-Memo.js new file mode 100644 index 0000000000..87599f09b6 --- /dev/null +++ b/packages/backend/migration/1735078824104-RoleAssignment-Memo.js @@ -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"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 75817d17de..3fcba116ee 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -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" } diff --git a/packages/backend/src/core/AiService.ts b/packages/backend/src/core/AiService.ts index 33b79be355..83a0cb5fa9 100644 --- a/packages/backend/src/core/AiService.ts +++ b/packages/backend/src/core/AiService.ts @@ -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 { + public async detectSensitive(path: string, mime: string): Promise { 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(); } diff --git a/packages/backend/src/core/AvatarDecorationService.ts b/packages/backend/src/core/AvatarDecorationService.ts index 21e31d79a4..fa3f63677e 100644 --- a/packages/backend/src/core/AvatarDecorationService.ts +++ b/packages/backend/src/core/AvatarDecorationService.ts @@ -29,7 +29,7 @@ export class AvatarDecorationService implements OnApplicationShutdown { private moderationLogService: ModerationLogService, private globalEventService: GlobalEventService, ) { - this.cache = new MemorySingleCache(1000 * 60 * 30); + this.cache = new MemorySingleCache(1000 * 60 * 30); // 30s this.redisForSub.on('message', this.onMessage); } diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index d008e7ec52..6725ebe75b 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -56,10 +56,10 @@ export class CacheService implements OnApplicationShutdown { ) { //this.onMessage = this.onMessage.bind(this); - this.userByIdCache = new MemoryKVCache(Infinity); - this.localUserByNativeTokenCache = new MemoryKVCache(Infinity); - this.localUserByIdCache = new MemoryKVCache(Infinity); - this.uriPersonCache = new MemoryKVCache(Infinity); + this.userByIdCache = new MemoryKVCache(1000 * 60 * 5); // 5m + this.localUserByNativeTokenCache = new MemoryKVCache(1000 * 60 * 5); // 5m + this.localUserByIdCache = new MemoryKVCache(1000 * 60 * 5); // 5m + this.uriPersonCache = new MemoryKVCache(1000 * 60 * 5); // 5m this.userProfileCache = new RedisKVCache(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); } diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index feca0fe5e8..27b85b10b9 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -24,7 +24,7 @@ const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/; @Injectable() export class CustomEmojiService implements OnApplicationShutdown { - private cache: MemoryKVCache; + private emojisCache: MemoryKVCache; public localEmojisCache: RedisSingleCache>; constructor( @@ -40,7 +40,7 @@ export class CustomEmojiService implements OnApplicationShutdown { private moderationLogService: ModerationLogService, private globalEventService: GlobalEventService, ) { - this.cache = new MemoryKVCache(1000 * 60 * 60 * 12); + this.emojisCache = new MemoryKVCache(1000 * 60 * 60 * 12); // 12h this.localEmojisCache = new RedisSingleCache>(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 { - 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 diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts index 21ae798f9f..7543534d93 100644 --- a/packages/backend/src/core/DownloadService.ts +++ b/packages/backend/src/core/DownloadService.ts @@ -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'; - } } diff --git a/packages/backend/src/core/EmailService.ts b/packages/backend/src/core/EmailService.ts index 1852536a8c..832c2d55ef 100644 --- a/packages/backend/src/core/EmailService.ts +++ b/packages/backend/src/core/EmailService.ts @@ -330,6 +330,7 @@ export class EmailService { Accept: 'application/json', Authorization: truemailAuthKey, }, + isLocalAddressAllowed: true, }); const json = (await res.json()) as { diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index 231e09af14..3d7f22f2d2 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -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 { + private async fetchDom(instance: MiInstance): Promise { 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 { + private async fetchFaviconUrl(instance: MiInstance, doc: Document | null): Promise { 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 | null): Promise { + private async fetchIconUrl(instance: MiInstance, doc: Document | null, manifest: Record | null): Promise { 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 | null): Promise { + private async getThemeColor(info: NodeInfo | null, doc: Document | null, manifest: Record | null): Promise { 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 | null): Promise { + private async getSiteName(info: NodeInfo | null, doc: Document | null, manifest: Record | null): Promise { 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 | null): Promise { + private async getDescription(info: NodeInfo | null, doc: Document | null, manifest: Record | null): Promise { if (info && info.metadata) { if (typeof info.metadata.nodeDescription === 'string') { return info.metadata.nodeDescription; diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts index 1c7d542a3c..576eb089c8 100644 --- a/packages/backend/src/core/FileInfoService.ts +++ b/packages/backend/src/core/FileInfoService.ts @@ -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 { 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); diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index 3eb2a8089a..e3640216f2 100644 --- a/packages/backend/src/core/HttpRequestService.ts +++ b/packages/backend/src/core/HttpRequestService.ts @@ -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 { + public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise { 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(url: string, accept = 'application/json, */*', headers?: Record): Promise { + public async getJson(url: string, accept = 'application/json, */*', headers?: Record, isLocalAddressAllowed = false): Promise { 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): Promise { + public async getHtml(url: string, accept = 'text/html, */*', headers?: Record, isLocalAddressAllowed = false): Promise { 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, 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, }); diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts index e9dc9b57af..91857dc683 100644 --- a/packages/backend/src/core/RelayService.ts +++ b/packages/backend/src/core/RelayService.ts @@ -35,7 +35,7 @@ export class RelayService { private createSystemUserService: CreateSystemUserService, private apRendererService: ApRendererService, ) { - this.relaysCache = new MemorySingleCache(1000 * 60 * 10); + this.relaysCache = new MemorySingleCache(1000 * 60 * 10); // 10m } @bindThis diff --git a/packages/backend/src/core/RemoteUserResolveService.ts b/packages/backend/src/core/RemoteUserResolveService.ts index 38f45a03a4..c7f5effd2e 100644 --- a/packages/backend/src/core/RemoteUserResolveService.ts +++ b/packages/backend/src/core/RemoteUserResolveService.ts @@ -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) { diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 45e8b641c7..1c205dca0b 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -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(1000 * 60 * 60 * 1); - this.roleAssignmentByUserIdCache = new MemoryKVCache(1000 * 60 * 60 * 1); + this.rolesCache = new MemorySingleCache(1000 * 60 * 60); // 1h + this.roleAssignmentByUserIdCache = new MemoryKVCache(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 { + public async assign(userId: MiUser['id'], roleId: MiRole['id'], memo: string | null = null, expiresAt: Date | null = null, moderator?: MiUser): Promise { 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, }); } } diff --git a/packages/backend/src/core/UserKeypairService.ts b/packages/backend/src/core/UserKeypairService.ts index 51ac99179a..92d61cd103 100644 --- a/packages/backend/src/core/UserKeypairService.ts +++ b/packages/backend/src/core/UserKeypairService.ts @@ -25,7 +25,7 @@ export class UserKeypairService implements OnApplicationShutdown { ) { this.cache = new RedisKVCache(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), diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index 3f6d63609c..7d3a74cf57 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -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; } } diff --git a/packages/backend/src/core/WebAuthnService.ts b/packages/backend/src/core/WebAuthnService.ts index d0b65bf649..1cabc34600 100644 --- a/packages/backend/src/core/WebAuthnService.ts +++ b/packages/backend/src/core/WebAuthnService.ts @@ -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, diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts index f6b70ead44..5c16744a77 100644 --- a/packages/backend/src/core/activitypub/ApDbResolverService.ts +++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts @@ -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(Infinity); - this.publicKeyByUserIdCache = new MemoryKVCache(Infinity); + this.publicKeyCache = new MemoryKVCache(1000 * 60 * 60 * 12); // 12h + this.publicKeyByUserIdCache = new MemoryKVCache(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 { diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index fd9c7c2ba0..4d03427529 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -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 { + public async performActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver, additionalCc?: MiLocalUser['id']): Promise { + 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 { - if (actor.isSuspended) return; + public async performOneActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver, additionalCc?: MiLocalUser['id']): Promise { + 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 { + private async accept(actor: MiRemoteUser, activity: IAccept, resolver?: Resolver): Promise { 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 { + private async add(actor: MiRemoteUser, activity: IAdd, resolver?: Resolver): Promise { 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 { + private async announce(actor: MiRemoteUser, activity: IAnnounce, resolver?: Resolver): Promise { 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 { + private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost, resolver?: Resolver): Promise { 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 { + private async create(actor: MiRemoteUser, activity: ICreate, resolver?: Resolver, additionalCc?: MiLocalUser['id']): Promise { 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 { 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 { + private async reject(actor: MiRemoteUser, activity: IReject, resolver?: Resolver): Promise { 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 { + private async remove(actor: MiRemoteUser, activity: IRemove, resolver?: Resolver): Promise { 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 { + private async undo(actor: MiRemoteUser, activity: IUndo, resolver?: Resolver): Promise { 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 { + private async update(actor: MiRemoteUser, activity: IUpdate, resolver?: Resolver, additionalCc?: MiLocalUser['id']): Promise { 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 { + private async move(actor: MiRemoteUser, activity: IMove, resolver?: Resolver): Promise { // 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'; } } diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index b008a3ec59..f59fabba3c 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -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 { + public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise { + 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; } } diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index fbe32ba180..000f9d59f2 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -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 { 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; } diff --git a/packages/backend/src/core/activitypub/misc/check-against-url.ts b/packages/backend/src/core/activitypub/misc/check-against-url.ts new file mode 100644 index 0000000000..78ba891a2e --- /dev/null +++ b/packages/backend/src/core/activitypub/misc/check-against-url.ts @@ -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})`); + } +} diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 3d72c502e5..3ce588e18c 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -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 { + public async createNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise { // 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(); } diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 430ec714a4..88c2dc5d2b 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -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 { 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 { + if (collection) { + const resolved = await resolver.resolveCollection(collection); + if (resolved.first || (resolved as ICollection).items || (resolved as IOrderedCollection).orderedItems) { + return true; + } + } + + return false; + } } diff --git a/packages/backend/src/core/activitypub/models/ApQuestionService.ts b/packages/backend/src/core/activitypub/models/ApQuestionService.ts index 4e3df00e0d..d34c1a76c9 100644 --- a/packages/backend/src/core/activitypub/models/ApQuestionService.ts +++ b/packages/backend/src/core/activitypub/models/ApQuestionService.ts @@ -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 { + public async updateQuestion(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver): Promise { 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; diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 09322888d5..16812b7a4d 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -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'; diff --git a/packages/backend/src/core/entities/PageEntityService.ts b/packages/backend/src/core/entities/PageEntityService.ts index 5239f31585..a3e8f4ec4b 100644 --- a/packages/backend/src/core/entities/PageEntityService.ts +++ b/packages/backend/src/core/entities/PageEntityService.ts @@ -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, }); } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 66ae69ed93..e9bf5a3977 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -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; diff --git a/packages/backend/src/decorators.ts b/packages/backend/src/decorators.ts index 21777657d1..b26c1115e3 100644 --- a/packages/backend/src/decorators.ts +++ b/packages/backend/src/decorators.ts @@ -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; } diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index 4d78095702..07d4be7f3f 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -7,23 +7,23 @@ import * as Redis from 'ioredis'; import { bindThis } from '@/decorators.js'; export class RedisKVCache { - private redisClient: Redis.Redis; - private name: string; - private lifetime: number; - private memoryCache: MemoryKVCache; - private fetcher: (key: string) => Promise; - private toRedisConverter: (value: T) => string; - private fromRedisConverter: (value: string) => T | undefined; + private readonly lifetime: number; + private readonly memoryCache: MemoryKVCache; + private readonly fetcher: (key: string) => Promise; + private readonly toRedisConverter: (value: T) => string; + private readonly fromRedisConverter: (value: string) => T | undefined; - constructor(redisClient: RedisKVCache['redisClient'], name: RedisKVCache['name'], opts: { - lifetime: RedisKVCache['lifetime']; - memoryCacheLifetime: number; - fetcher: RedisKVCache['fetcher']; - toRedisConverter: RedisKVCache['toRedisConverter']; - fromRedisConverter: RedisKVCache['fromRedisConverter']; - }) { - this.redisClient = redisClient; - this.name = name; + constructor( + private redisClient: Redis.Redis, + private name: string, + opts: { + lifetime: RedisKVCache['lifetime']; + memoryCacheLifetime: number; + fetcher: RedisKVCache['fetcher']; + toRedisConverter: RedisKVCache['toRedisConverter']; + fromRedisConverter: RedisKVCache['fromRedisConverter']; + }, + ) { this.lifetime = opts.lifetime; this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime); this.fetcher = opts.fetcher; @@ -55,10 +55,13 @@ export class RedisKVCache { 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 { /** * キャッシュがあればそれを返し、無ければ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 { @@ -80,14 +87,14 @@ export class RedisKVCache { // 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 { } export class RedisSingleCache { - private redisClient: Redis.Redis; - private name: string; - private lifetime: number; - private memoryCache: MemorySingleCache; - private fetcher: () => Promise; - private toRedisConverter: (value: T) => string; - private fromRedisConverter: (value: string) => T | undefined; + private readonly lifetime: number; + private readonly memoryCache: MemorySingleCache; + private readonly fetcher: () => Promise; + private readonly toRedisConverter: (value: T) => string; + private readonly fromRedisConverter: (value: string) => T | undefined; - constructor(redisClient: RedisSingleCache['redisClient'], name: RedisSingleCache['name'], opts: { - lifetime: RedisSingleCache['lifetime']; - memoryCacheLifetime: number; - fetcher: RedisSingleCache['fetcher']; - toRedisConverter: RedisSingleCache['toRedisConverter']; - fromRedisConverter: RedisSingleCache['fromRedisConverter']; - }) { - this.redisClient = redisClient; - this.name = name; + constructor( + private redisClient: Redis.Redis, + private name: string, + opts: { + lifetime: number; + memoryCacheLifetime: number; + fetcher: RedisSingleCache['fetcher']; + toRedisConverter: RedisSingleCache['toRedisConverter']; + fromRedisConverter: RedisSingleCache['fromRedisConverter']; + }, + ) { this.lifetime = opts.lifetime; this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime); this.fetcher = opts.fetcher; @@ -152,10 +159,13 @@ export class RedisSingleCache { 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 { /** * キャッシュがあればそれを返し、無ければ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 { @@ -177,14 +191,14 @@ export class RedisSingleCache { // 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 { * データを持つマップ * これを直接操作するべきではない */ - public cache: Map; - private lifetime: number; - private gcIntervalHandle: NodeJS.Timeout; + private readonly cache = new Map(); + private readonly gcIntervalHandle = setInterval(() => this.gc(), 1000 * 60 * 3); // 3m - constructor(lifetime: MemoryKVCache['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 { @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 { public dispose(): void { clearInterval(this.gcIntervalHandle); } + + public get entries() { + return this.cache.entries(); + } } export class MemorySingleCache { private cachedAt: number | null = null; private value: T | undefined; - private lifetime: number; - constructor(lifetime: MemorySingleCache['lifetime']) { - this.lifetime = lifetime; - } + constructor( + private lifetime: number, + ) {} @bindThis public set(value: T): void { diff --git a/packages/backend/src/misc/queues.ts b/packages/backend/src/misc/queues.ts index 6a6fb9ba61..cfc2941778 100644 --- a/packages/backend/src/misc/queues.ts +++ b/packages/backend/src/misc/queues.ts @@ -2,21 +2,21 @@ import { EventEmitter } from 'node:events'; import * as Bull from 'bullmq'; export class Queues { - public readonly queues: ReadonlyArray>; + public readonly queues: ReadonlyArray>; - constructor(queues: Bull.Queue[]) { + constructor(queues: Bull.Queue[]) { if (queues.length === 0) { throw new Error('queues cannot be empty.'); } this.queues = queues; } - getRandomQueue(): Bull.Queue { + get randomQueue(): Bull.Queue { return this.queues[Math.floor(Math.random() * this.queues.length)]; } add(name: NameType, data: DataType, opts?: Bull.JobsOptions): Promise> { - 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[]> { @@ -30,7 +30,7 @@ export class Queues[]> { - 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[]>))) .filter((value): value is PromiseFulfilledResult[]> => value.status === 'fulfilled') .flatMap(value => value.value); } @@ -46,7 +46,7 @@ export class Queues); } - once>(event: U, listener: Bull.QueueListener[U]): void { + once>>(event: U, listener: Bull.QueueListener>[U]): void { const e = new EventEmitter(); e.once(event, listener); @@ -62,7 +62,7 @@ export class Queues[]> { - 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[]>))) .filter((value): value is PromiseFulfilledResult[]> => value.status === 'fulfilled') .flatMap(value => value.value); } diff --git a/packages/backend/src/models/Page.ts b/packages/backend/src/models/Page.ts index 36acf42c6e..709875e683 100644 --- a/packages/backend/src/models/Page.ts +++ b/packages/backend/src/models/Page.ts @@ -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, diff --git a/packages/backend/src/models/RoleAssignment.ts b/packages/backend/src/models/RoleAssignment.ts index b74fd90b98..67792725d3 100644 --- a/packages/backend/src/models/RoleAssignment.ts +++ b/packages/backend/src/models/RoleAssignment.ts @@ -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; } diff --git a/packages/backend/src/models/json-schema/page.ts b/packages/backend/src/models/json-schema/page.ts index 8e61526d18..456facbf8b 100644 --- a/packages/backend/src/models/json-schema/page.ts +++ b/packages/backend/src/models/json-schema/page.ts @@ -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; diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index 5fed070929..6f0cd718dd 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -44,7 +44,7 @@ export class DeliverProcessorService { private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('deliver'); - this.suspendedHostsCache = new MemorySingleCache(1000 * 60 * 60); + this.suspendedHostsCache = new MemorySingleCache(1000 * 60 * 60); // 1h } @bindThis diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index 259fd06f62..70f3e30c5c 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -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; diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 8ba2daf79d..0a6dc63703 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -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; diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index cc49c5ffad..22d673cd24 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -133,7 +133,7 @@ export class NodeinfoServerService { return document; }; - const cache = new MemorySingleCache>>(1000 * 60 * 10); + const cache = new MemorySingleCache>>(1000 * 60 * 10); // 10m fastify.get(nodeinfo2_1path, async (request, reply) => { const base = await cache.fetch(() => nodeinfo2(21)); diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 08d7189784..f534a40bbd 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -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(); diff --git a/packages/backend/src/server/api/AuthenticateService.ts b/packages/backend/src/server/api/AuthenticateService.ts index ddef8db987..690ff2e022 100644 --- a/packages/backend/src/server/api/AuthenticateService.ts +++ b/packages/backend/src/server/api/AuthenticateService.ts @@ -37,7 +37,7 @@ export class AuthenticateService implements OnApplicationShutdown { private cacheService: CacheService, ) { - this.appCache = new MemoryKVCache(Infinity); + this.appCache = new MemoryKVCache(1000 * 60 * 60 * 24 * 7); // 1w } @bindThis diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 7b564affeb..924eac7a1f 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -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, diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index ed661a1fab..a56a2d85b6 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -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 { diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index a603a7077c..987228be66 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -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], diff --git a/packages/backend/src/server/api/endpoints/admin/roles/assign.ts b/packages/backend/src/server/api/endpoints/admin/roles/assign.ts index df5955efd0..b1f8a2ec6d 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/assign.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/assign.ts @@ -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 { // 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); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/roles/users.ts b/packages/backend/src/server/api/endpoints/admin/roles/users.ts index 45758d4f50..f126e56523 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/users.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/users.ts @@ -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 { // 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, }))); }); diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index f1818086ae..f2c40541f1 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -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 { // eslint- createdAt: this.idService.parse(a.id).date.toISOString(), expiresAt: a.expiresAt ? a.expiresAt.toISOString() : null, roleId: a.roleId, + memo: a.memo, })), }; }); diff --git a/packages/backend/src/server/api/endpoints/ap/get.ts b/packages/backend/src/server/api/endpoints/ap/get.ts index d8c55de7ec..14286bc23e 100644 --- a/packages/backend/src/server/api/endpoints/ap/get.ts +++ b/packages/backend/src/server/api/endpoints/ap/get.ts @@ -11,6 +11,7 @@ import { ApResolverService } from '@/core/activitypub/ApResolverService.js'; export const meta = { tags: ['federation'], + requireAdmin: true, requireCredential: true, kind: 'read:federation', diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index d3c40dba59..cc7e245156 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -122,6 +122,11 @@ export default class extends Endpoint { // 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 { // 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, ); } diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index fb3ea38f87..62d64a8ff7 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -559,7 +559,7 @@ export default class extends Endpoint { // 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}`; diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index 5460635e1d..43f9db2aa8 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -11,6 +11,8 @@ export const meta = { tags: ['meta'], requireCredential: false, + allowGet: true, + cacheSec: 60, res: { type: 'object', diff --git a/packages/backend/src/server/api/endpoints/pages/create.ts b/packages/backend/src/server/api/endpoints/pages/create.ts index ee6a1b92c7..305b620758 100644 --- a/packages/backend/src/server/api/endpoints/pages/create.ts +++ b/packages/backend/src/server/api/endpoints/pages/create.ts @@ -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 { // 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, diff --git a/packages/backend/src/server/api/endpoints/pages/show.ts b/packages/backend/src/server/api/endpoints/pages/show.ts index e08b832a3f..1bf6127532 100644 --- a/packages/backend/src/server/api/endpoints/pages/show.ts +++ b/packages/backend/src/server/api/endpoints/pages/show.ts @@ -78,6 +78,10 @@ export default class extends Endpoint { // 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); }); } diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts index a42f6adddd..4d053537b5 100644 --- a/packages/backend/src/server/api/endpoints/pages/update.ts +++ b/packages/backend/src/server/api/endpoints/pages/update.ts @@ -70,6 +70,7 @@ export const paramDef = { font: { type: 'string', enum: ['serif', 'sans-serif'] }, alignCenter: { type: 'boolean' }, hideTitleWhenPinned: { type: 'boolean' }, + visibility: { type: 'string', enum: ['public', 'private'] }, }, required: ['pageId', 'title', 'name', 'content', 'variables', 'script'], } as const; @@ -129,6 +130,8 @@ export default class extends Endpoint { // eslint- hideTitleWhenPinned: ps.hideTitleWhenPinned === undefined ? page.hideTitleWhenPinned : ps.hideTitleWhenPinned, // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing font: ps.font === undefined ? page.font : ps.font, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + visibility: ps.visibility === undefined ? page.visibility : ps.visibility, eyeCatchingImageId: ps.eyeCatchingImageId === null ? null : ps.eyeCatchingImageId === undefined diff --git a/packages/backend/src/server/api/endpoints/stats.ts b/packages/backend/src/server/api/endpoints/stats.ts index 1e6983177f..286a82cc52 100644 --- a/packages/backend/src/server/api/endpoints/stats.ts +++ b/packages/backend/src/server/api/endpoints/stats.ts @@ -12,6 +12,8 @@ import UsersChart from '@/core/chart/charts/users.js'; export const meta = { requireCredential: false, + allowGet: true, + cacheSec: 60, tags: ['meta'], diff --git a/packages/backend/src/server/api/endpoints/users/followers.ts b/packages/backend/src/server/api/endpoints/users/followers.ts index 7ce7734f53..a8b4319a61 100644 --- a/packages/backend/src/server/api/endpoints/users/followers.ts +++ b/packages/backend/src/server/api/endpoints/users/followers.ts @@ -11,6 +11,7 @@ import { QueryService } from '@/core/QueryService.js'; import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -81,6 +82,7 @@ export default class extends Endpoint { // eslint- private utilityService: UtilityService, private followingEntityService: FollowingEntityService, private queryService: QueryService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneBy(ps.userId != null @@ -93,23 +95,25 @@ export default class extends Endpoint { // eslint- const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - if (profile.followersVisibility === 'private') { - if (me == null || (me.id !== user.id)) { - throw new ApiError(meta.errors.forbidden); - } - } else if (profile.followersVisibility === 'followers') { - if (me == null) { - throw new ApiError(meta.errors.forbidden); - } else if (me.id !== user.id) { - const isFollowing = await this.followingsRepository.exists({ - where: { - followeeId: user.id, - followerId: me.id, - }, - }); - if (!isFollowing) { + if (profile.followersVisibility !== 'public' && !await this.roleService.isModerator(me)) { + if (profile.followersVisibility === 'private') { + if (me == null || (me.id !== user.id)) { throw new ApiError(meta.errors.forbidden); } + } else if (profile.followersVisibility === 'followers') { + if (me == null) { + throw new ApiError(meta.errors.forbidden); + } else if (me.id !== user.id) { + const isFollowing = await this.followingsRepository.exists({ + where: { + followeeId: user.id, + followerId: me.id, + }, + }); + if (!isFollowing) { + throw new ApiError(meta.errors.forbidden); + } + } } } diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts index 084d278095..ee2e17a0e6 100644 --- a/packages/backend/src/server/api/endpoints/users/following.ts +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -12,6 +12,7 @@ import { QueryService } from '@/core/QueryService.js'; import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -93,6 +94,7 @@ export default class extends Endpoint { // eslint- private utilityService: UtilityService, private followingEntityService: FollowingEntityService, private queryService: QueryService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneBy(ps.userId != null @@ -105,23 +107,25 @@ export default class extends Endpoint { // eslint- const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - if (profile.followingVisibility === 'private') { - if (me == null || (me.id !== user.id)) { - throw new ApiError(meta.errors.forbidden); - } - } else if (profile.followingVisibility === 'followers') { - if (me == null) { - throw new ApiError(meta.errors.forbidden); - } else if (me.id !== user.id) { - const isFollowing = await this.followingsRepository.exists({ - where: { - followeeId: user.id, - followerId: me.id, - }, - }); - if (!isFollowing) { + if (profile.followingVisibility !== 'public' && !await this.roleService.isModerator(me)) { + if (profile.followingVisibility === 'private') { + if (me == null || (me.id !== user.id)) { throw new ApiError(meta.errors.forbidden); } + } else if (profile.followingVisibility === 'followers') { + if (me == null) { + throw new ApiError(meta.errors.forbidden); + } else if (me.id !== user.id) { + const isFollowing = await this.followingsRepository.exists({ + where: { + followeeId: user.id, + followerId: me.id, + }, + }); + if (!isFollowing) { + throw new ApiError(meta.errors.forbidden); + } + } } } diff --git a/packages/backend/src/server/api/endpoints/users/get-security-info.ts b/packages/backend/src/server/api/endpoints/users/get-security-info.ts new file mode 100644 index 0000000000..e3fe2b6ffe --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/get-security-info.ts @@ -0,0 +1,72 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import bcrypt from 'bcryptjs'; +import ms from 'ms'; + +export const meta = { + tags: ['users'], + + requireCredential: false, + + limit: { + duration: ms('1hour'), + max: 30, + }, + + res: { + type: 'object', + properties: { + twoFactorEnabled: { type: 'boolean' }, + usePasswordLessLogin: { type: 'boolean' }, + securityKeys: { type: 'boolean' }, + }, + }, + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + email: { type: 'string' }, + password: { type: 'string' }, + }, + required: ['email', 'password'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.userSecurityKeysRepository) + private userSecurityKeysRepository: UserSecurityKeysRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const profile = await this.userProfilesRepository.findOneBy({ + email: ps.email, + emailVerified: true, + }); + + const passwordMatched = await bcrypt.compare(ps.password, profile?.password ?? ''); + if (!profile || !passwordMatched) { + return { + twoFactorEnabled: false, + usePasswordLessLogin: false, + securityKeys: false, + }; + } + + return { + twoFactorEnabled: profile.twoFactorEnabled, + usePasswordLessLogin: profile.usePasswordLessLogin, + securityKeys: profile.twoFactorEnabled + ? await this.userSecurityKeysRepository.countBy({ userId: profile.userId }).then(result => result >= 1) + : false, + }; + }); + } +} diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index 0ef12ae660..5c16cea607 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -15,6 +15,7 @@ class AntennaChannel extends Channel { public static readonly requireCredential = true as const; public static readonly kind = 'read:account'; private antennaId: string; + private idOnly: boolean; constructor( private noteEntityService: NoteEntityService, @@ -29,6 +30,7 @@ class AntennaChannel extends Channel { @bindThis public async init(params: any) { this.antennaId = params.antennaId as string; + this.idOnly = params.idOnly ?? false; // Subscribe stream this.subscriber.on(`antennaStream:${this.antennaId}`, this.onEvent); @@ -49,9 +51,13 @@ class AntennaChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - this.connection.cacheNote(note); - - this.send('note', note); + if (this.idOnly && ['public', 'home'].includes(note.visibility)) { + const idOnlyNote = { id: note.id }; + this.send('note', idOnlyNote); + } else { + this.connection.cacheNote(note); + this.send('note', note); + } } else { this.send(data.type, data.body); } diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index 98db02fd07..94ffcd5d62 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -15,6 +15,7 @@ class ChannelChannel extends Channel { public static readonly shouldShare = false; public static readonly requireCredential = false as const; private channelId: string; + private idOnly: boolean; constructor( private noteEntityService: NoteEntityService, @@ -29,6 +30,7 @@ class ChannelChannel extends Channel { @bindThis public async init(params: any) { this.channelId = params.channelId as string; + this.idOnly = params.idOnly ?? false; // Subscribe stream this.subscriber.on('notesStream', this.onNote); @@ -55,9 +57,13 @@ class ChannelChannel extends Channel { } } - this.connection.cacheNote(note); - - this.send('note', note); + if (this.idOnly && ['public', 'home'].includes(note.visibility)) { + const idOnlyNote = { id: note.id }; + this.send('note', idOnlyNote); + } else { + this.connection.cacheNote(note); + this.send('note', note); + } } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index fee5972e85..64f1941603 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -18,6 +18,7 @@ class GlobalTimelineChannel extends Channel { public static readonly requireCredential = false as const; private withRenotes: boolean; private withFiles: boolean; + private idOnly: boolean; constructor( private metaService: MetaService, @@ -38,6 +39,7 @@ class GlobalTimelineChannel extends Channel { this.withRenotes = params.withRenotes ?? true; this.withFiles = params.withFiles ?? false; + this.idOnly = params.idOnly ?? false; // Subscribe events this.subscriber.on('notesStream', this.onNote); @@ -85,9 +87,13 @@ class GlobalTimelineChannel extends Channel { } } - this.connection.cacheNote(note); - - this.send('note', note); + if (this.idOnly && ['public', 'home'].includes(note.visibility)) { + const idOnlyNote = { id: note.id }; + this.send('note', idOnlyNote); + } else { + this.connection.cacheNote(note); + this.send('note', note); + } } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index 68822aa5d4..3ee1935227 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -17,6 +17,7 @@ class HomeTimelineChannel extends Channel { public static readonly kind = 'read:account'; private withRenotes: boolean; private withFiles: boolean; + private idOnly: boolean; constructor( private noteEntityService: NoteEntityService, @@ -32,6 +33,7 @@ class HomeTimelineChannel extends Channel { public async init(params: any) { this.withRenotes = params.withRenotes ?? true; this.withFiles = params.withFiles ?? false; + this.idOnly = params.idOnly ?? false; this.subscriber.on('notesStream', this.onNote); } @@ -89,9 +91,13 @@ class HomeTimelineChannel extends Channel { } } - this.connection.cacheNote(note); - - this.send('note', note); + if (this.idOnly && ['public', 'home'].includes(note.visibility)) { + const idOnlyNote = { id: note.id }; + this.send('note', idOnlyNote); + } else { + this.connection.cacheNote(note); + this.send('note', note); + } } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 39b7b8b641..20337f85b9 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -20,6 +20,7 @@ class HybridTimelineChannel extends Channel { private withRenotes: boolean; private withReplies: boolean; private withFiles: boolean; + private idOnly: boolean; constructor( private metaService: MetaService, @@ -41,6 +42,7 @@ class HybridTimelineChannel extends Channel { this.withRenotes = params.withRenotes ?? true; this.withReplies = params.withReplies ?? false; this.withFiles = params.withFiles ?? false; + this.idOnly = params.idOnly ?? false; // Subscribe events this.subscriber.on('notesStream', this.onNote); @@ -103,9 +105,13 @@ class HybridTimelineChannel extends Channel { } } - this.connection.cacheNote(note); - - this.send('note', note); + if (this.idOnly && ['public', 'home'].includes(note.visibility)) { + const idOnlyNote = { id: note.id }; + this.send('note', idOnlyNote); + } else { + this.connection.cacheNote(note); + this.send('note', note); + } } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index c191be84ee..441b0132a5 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -19,6 +19,7 @@ class LocalTimelineChannel extends Channel { private withRenotes: boolean; private withReplies: boolean; private withFiles: boolean; + private idOnly: boolean; constructor( private metaService: MetaService, @@ -40,6 +41,7 @@ class LocalTimelineChannel extends Channel { this.withRenotes = params.withRenotes ?? true; this.withReplies = params.withReplies ?? false; this.withFiles = params.withFiles ?? false; + this.idOnly = params.idOnly ?? false; // Subscribe events this.subscriber.on('notesStream', this.onNote); @@ -88,9 +90,13 @@ class LocalTimelineChannel extends Channel { } } - this.connection.cacheNote(note); - - this.send('note', note); + if (this.idOnly && ['public', 'home'].includes(note.visibility)) { + const idOnlyNote = { id: note.id }; + this.send('note', idOnlyNote); + } else { + this.connection.cacheNote(note); + this.send('note', note); + } } @bindThis diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts index 6fabdcb75a..3fb8885717 100644 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -16,6 +16,7 @@ class RoleTimelineChannel extends Channel { public static readonly shouldShare = false; public static readonly requireCredential = false as const; private roleId: string; + private idOnly: boolean; constructor( private noteEntityService: NoteEntityService, @@ -31,6 +32,7 @@ class RoleTimelineChannel extends Channel { @bindThis public async init(params: any) { this.roleId = params.roleId as string; + this.idOnly = params.idOnly ?? false; this.subscriber.on(`roleTimelineStream:${this.roleId}`, this.onEvent); } @@ -71,9 +73,13 @@ class RoleTimelineChannel extends Channel { } } - this.connection.cacheNote(note); - - this.send('note', note); + if (this.idOnly && ['public', 'home'].includes(note.visibility)) { + const idOnlyNote = { id: note.id }; + this.send('note', idOnlyNote); + } else { + this.connection.cacheNote(note); + this.send('note', note); + } } else { this.send(data.type, data.body); } diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 297c253c4a..651ab05f34 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -21,6 +21,7 @@ class UserListChannel extends Channel { private listUsersClock: NodeJS.Timeout; private withFiles: boolean; private withRenotes: boolean; + private idOnly: boolean; constructor( private userListsRepository: UserListsRepository, @@ -40,6 +41,7 @@ class UserListChannel extends Channel { this.listId = params.listId as string; this.withFiles = params.withFiles ?? false; this.withRenotes = params.withRenotes ?? true; + this.idOnly = params.idOnly ?? false; // Check existence and owner const listExist = await this.userListsRepository.exists({ @@ -128,9 +130,13 @@ class UserListChannel extends Channel { } } - this.connection.cacheNote(note); - - this.send('note', note); + if (this.idOnly && ['public', 'home'].includes(note.visibility)) { + const idOnlyNote = { id: note.id }; + this.send('note', idOnlyNote); + } else { + this.connection.cacheNote(note); + this.send('note', note); + } } @bindThis diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index ac88c40844..beaabc610b 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -195,7 +195,7 @@ function getQueryMode(issuerUrl: string): oauth2orize.grant.Options['modes'] { parsed.searchParams.append(key, value as string); } - return (res as OAuthHttpResponse).redirect(parsed.toString()); + (res as OAuthHttpResponse).redirect(parsed.toString()); }, }; } diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index d5c3ef3b01..9c8ee94d6a 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -616,6 +616,81 @@ export class ClientServerService { } }); + fastify.get<{ Params: { note: string; } }>('/notes/:note.json', async (request, reply) => { + const note = await this.notesRepository.findOneBy({ + id: request.params.note, + visibility: In(['public', 'home']), + }); + + if (note) { + try { + const _note = await this.noteEntityService.pack(note, null); + reply.header('Content-Type', 'application/json; charset=utf-8'); + reply.header('Cache-Control', 'public, max-age=600'); + return reply.send(_note); + } catch (err) { + reply.header('Cache-Control', 'max-age=10, must-revalidate'); + if (err instanceof IdentifiableError) { + this.clientLoggerService.logger.error(`Internal error occurred in ${request.routeOptions.url}: ${err.message}`, { + path: request.routeOptions.url, + params: request.params, + query: request.query, + id: err.id, + error: { + message: err.message, + code: 'INTERNAL_ERROR', + stack: err.stack, + }, + }); + const httpStatusCode = err.id === '85ab9bd7-3a41-4530-959d-f07073900109' ? 403 : 500; + reply.code(httpStatusCode); + return reply.send({ + message: err.message, + code: 'INTERNAL_ERROR', + id: err.id, + kind: 'server', + httpStatusCode, + info: { + message: err.message, + code: err.name, + id: err.id, + }, + }); + } else { + const error = err as Error; + const errId = randomUUID(); + this.clientLoggerService.logger.error(`Internal error occurred in ${request.routeOptions.url}: ${error.message}`, { + path: request.routeOptions.url, + params: request.params, + query: request.query, + id: errId, + error: { + message: error.message, + code: error.name, + stack: error.stack, + }, + }); + reply.code(500); + return reply.send({ + message: 'Internal error occurred. Please contact us if the error persists.', + code: 'INTERNAL_ERROR', + id: 'b9f2a7f9-fe64-434b-9484-cb1f804d1a80', + kind: 'server', + httpStatusCode: 500, + info: { + message: error.message, + code: error.name, + id: errId, + }, + }); + } + } + } else { + reply.code(404); + return; + } + }); + // Page fastify.get<{ Params: { user: string; page: string; } }>('/@:user/pages/:page', async (request, reply) => { const { username, host } = Acct.parse(request.params.user); diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index 4d8f212d84..f2be28de08 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -164,6 +164,6 @@ export class UrlPreviewService { contentLengthRequired: meta.urlPreviewRequireContentLength, }); - return this.httpRequestService.getJson(`${proxy}?${queryStr}`); + return this.httpRequestService.getJson(`${proxy}?${queryStr}`, 'application/json, */*', undefined, true); } } diff --git a/packages/backend/src/server/web/bios.css b/packages/backend/src/server/web/bios.css index 91d1af10b4..983e751022 100644 --- a/packages/backend/src/server/web/bios.css +++ b/packages/backend/src/server/web/bios.css @@ -5,7 +5,7 @@ */ * { - font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; + font-family: 'Fira code', 'Fira Mono', Consolas, Menlo, Courier, monospace; } html { diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index 60f1e05e8a..c70e96edd6 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -32,20 +32,24 @@ renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.') } + if (localStorage.getItem('id') === null) { + localStorage.setItem('id', crypto.randomUUID().replaceAll('-', '')); + } + let id = localStorage.getItem('id'); + //#region Detect language & fetch translations - if (!localStorage.hasOwnProperty('locale')) { + if (!Object.hasOwn(localStorage, 'locale')) { let lang = localStorage.getItem('lang'); if (lang == null || lang.toString == null || lang.toString() === 'null') { lang = 'ja-JP'; } const metaRes = await window.fetch('/api/meta', { - method: 'POST', - body: JSON.stringify({}), + method: 'GET', credentials: 'omit', - cache: 'no-cache', headers: { 'Content-Type': 'application/json', + 'X-Client-Transaction-Id': `${id}-misskey-${crypto.randomUUID().replaceAll('-', '')}` }, }); if (metaRes.status !== 200) { diff --git a/packages/backend/src/server/web/cli.css b/packages/backend/src/server/web/cli.css index 4e6136d59c..b4ed61a744 100644 --- a/packages/backend/src/server/web/cli.css +++ b/packages/backend/src/server/web/cli.css @@ -5,7 +5,7 @@ */ * { - font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; + font-family: 'Fira code', 'Fira Mono', Consolas, Menlo, Courier, monospace; } html { diff --git a/packages/backend/src/server/web/flush.js b/packages/backend/src/server/web/flush.js index fca3092f04..60f4e0cf2f 100644 --- a/packages/backend/src/server/web/flush.js +++ b/packages/backend/src/server/web/flush.js @@ -39,7 +39,7 @@ message('Start flushing.'); console.error(e); setTimeout(() => { - location = '/'; + window.location = '/'; }, 10000) } })(); diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 9e595e83ef..e125b074ff 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -150,6 +150,7 @@ export type ModerationLogPayloads = { roleId: string; roleName: string; expiresAt: string | null; + memo: string | null; }; unassignRole: { userId: string; @@ -157,6 +158,7 @@ export type ModerationLogPayloads = { userHost: string | null; roleId: string; roleName: string; + memo: string | null; }; createRole: { roleId: string; diff --git a/packages/backend/test-server/.eslintrc.cjs b/packages/backend/test-server/.eslintrc.cjs deleted file mode 100644 index c261741a36..0000000000 --- a/packages/backend/test-server/.eslintrc.cjs +++ /dev/null @@ -1,32 +0,0 @@ -module.exports = { - parserOptions: { - tsconfigRootDir: __dirname, - project: ['./tsconfig.json'], - }, - extends: [ - '../../shared/.eslintrc.js', - ], - rules: { - 'import/order': ['warn', { - 'groups': ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'], - 'pathGroups': [ - { - 'pattern': '@/**', - 'group': 'external', - 'position': 'after' - } - ], - }], - 'no-restricted-globals': [ - 'error', - { - 'name': '__dirname', - 'message': 'Not in ESModule. Use `import.meta.url` instead.' - }, - { - 'name': '__filename', - 'message': 'Not in ESModule. Use `import.meta.url` instead.' - } - ] - }, -}; diff --git a/packages/backend/test-server/.swcrc b/packages/backend/test-server/.swcrc deleted file mode 100644 index e3d6935169..0000000000 --- a/packages/backend/test-server/.swcrc +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/swcrc", - "jsc": { - "parser": { - "syntax": "typescript", - "dynamicImport": true, - "decorators": true - }, - "transform": { - "legacyDecorator": true, - "decoratorMetadata": true - }, - "experimental": { - "keepImportAssertions": true - }, - "baseUrl": "../built", - "paths": { - "@/*": ["*"] - }, - "target": "es2022" - }, - "minify": false -} diff --git a/packages/backend/test-server/entry.ts b/packages/backend/test-server/entry.mjs similarity index 83% rename from packages/backend/test-server/entry.ts rename to packages/backend/test-server/entry.mjs index 866a7e1f5b..519429a5cd 100644 --- a/packages/backend/test-server/entry.ts +++ b/packages/backend/test-server/entry.mjs @@ -2,10 +2,10 @@ import { portToPid } from 'pid-port'; import fkill from 'fkill'; import Fastify from 'fastify'; import { NestFactory } from '@nestjs/core'; -import { MainModule } from '@/MainModule.js'; -import { ServerService } from '@/server/ServerService.js'; -import { loadConfig } from '@/config.js'; -import { NestLogger } from '@/NestLogger.js'; +import { MainModule } from '../built/MainModule.js'; +import { ServerService } from '../built/server/ServerService.js'; +import { loadConfig } from '../built/config.js'; +import { NestLogger } from '../built/NestLogger.js'; const config = loadConfig(); const originEnv = JSON.stringify(process.env); @@ -56,7 +56,7 @@ async function killTestServer() { async function startControllerEndpoints(port = config.port + 1000) { const fastify = Fastify(); - fastify.post<{ Body: { key?: string, value?: string } }>('/env', async (req, res) => { + fastify.post('/env', async (req, res) => { console.log(req.body); const key = req.body['key']; if (!key) { @@ -69,7 +69,7 @@ async function startControllerEndpoints(port = config.port + 1000) { res.code(200).send({ success: true }); }); - fastify.post<{ Body: { key?: string, value?: string } }>('/env-reset', async (req, res) => { + fastify.post('/env-reset', async (req, res) => { process.env = JSON.parse(originEnv); res.code(200).send({ success: true }); }); diff --git a/packages/backend/test-server/tsconfig.json b/packages/backend/test-server/tsconfig.json deleted file mode 100644 index 10313699c2..0000000000 --- a/packages/backend/test-server/tsconfig.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "noEmitOnError": true, - "noImplicitAny": true, - "noImplicitReturns": true, - "noUnusedParameters": false, - "noUnusedLocals": false, - "noFallthroughCasesInSwitch": true, - "declaration": false, - "sourceMap": true, - "target": "ES2022", - "module": "nodenext", - "moduleResolution": "nodenext", - "allowSyntheticDefaultImports": true, - "removeComments": false, - "noLib": false, - "strict": true, - "strictNullChecks": true, - "strictPropertyInitialization": false, - "skipLibCheck": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "resolveJsonModule": true, - "isolatedModules": true, - "rootDir": "../src", - "baseUrl": "./", - "paths": { - "@/*": ["../src/*"] - }, - "outDir": "../built-test", - "types": [ - "node" - ], - "typeRoots": [ - "../src/@types", - "../node_modules/@types", - "../node_modules" - ], - "lib": [ - "esnext" - ] - }, - "compileOnSave": false, - "include": [ - "./**/*.ts", - "../src/**/*.ts" - ], - "exclude": [ - "../src/**/*.test.ts" - ] -} diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts index bea57f36c5..f73fdded9b 100644 --- a/packages/backend/test/e2e/2fa.ts +++ b/packages/backend/test/e2e/2fa.ts @@ -18,7 +18,7 @@ import type { PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON, RegistrationResponseJSON, -} from '@simplewebauthn/types'; +} from '@simplewebauthn/server'; import type * as misskey from 'misskey-js'; describe('2要素認証', () => { diff --git a/packages/backend/test/e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts index 4851ed14be..3ce816f5fb 100644 --- a/packages/backend/test/e2e/fetch-resource.ts +++ b/packages/backend/test/e2e/fetch-resource.ts @@ -95,15 +95,14 @@ describe('Webリソース', () => { describe.each([ { path: '/', type: HTML }, { path: '/docs/ja-JP/about', type: HTML }, // "指定されたURLに該当するページはありませんでした。" - // fastify-static gives charset=UTF-8 instead of utf-8 and that's okay - { path: '/api-doc', type: 'text/html; charset=UTF-8' }, + { path: '/api-doc', type: 'text/html; charset=utf-8' }, { path: '/api.json', type: JSON_UTF8 }, { path: '/api-console', type: HTML }, { path: '/_info_card_', type: HTML }, { path: '/bios', type: HTML }, { path: '/cli', type: HTML }, { path: '/flush', type: HTML }, - { path: '/robots.txt', type: 'text/plain; charset=UTF-8' }, + { path: '/robots.txt', type: 'text/plain; charset=utf-8' }, { path: '/favicon.ico', type: 'image/vnd.microsoft.icon' }, { path: '/opensearch.xml', type: 'application/opensearchdescription+xml' }, { path: '/apple-touch-icon.png', type: 'image/png' }, diff --git a/packages/backend/test/tsconfig.json b/packages/backend/test/tsconfig.json index 2b562acda8..bbc189bcde 100644 --- a/packages/backend/test/tsconfig.json +++ b/packages/backend/test/tsconfig.json @@ -9,7 +9,7 @@ "noFallthroughCasesInSwitch": true, "declaration": false, "sourceMap": true, - "target": "ES2022", + "target": "es2022", "module": "nodenext", "moduleResolution": "nodenext", "allowSyntheticDefaultImports": true, @@ -28,8 +28,9 @@ "@/*": ["../src/*"] }, "typeRoots": [ + "../src/@types", "../node_modules/@types", - "../src/@types" + "../node_modules" ], "lib": [ "esnext" diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index e0b7621b28..d27211302a 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -239,7 +239,7 @@ describe('RoleService', () => { }, }, }); - await roleService.assign(user.id, role.id, new Date(Date.now() + (1000 * 60 * 60 * 24))); + await roleService.assign(user.id, role.id, 'test', new Date(Date.now() + (1000 * 60 * 60 * 24))); metaService.fetch.mockResolvedValue({ policies: { canManageCustomEmojis: false, diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index 7f5e06b52f..155ec671aa 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -20,7 +20,8 @@ import { CoreModule } from '@/core/CoreModule.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { LoggerService } from '@/core/LoggerService.js'; import type { IActor, IApDocument, ICollection, IObject, IPost } from '@/core/activitypub/type.js'; -import { MiMeta, MiNote } from '@/models/_.js'; +import { MiMeta, MiNote, UserProfilesRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { DownloadService } from '@/core/DownloadService.js'; import { MetaService } from '@/core/MetaService.js'; @@ -86,6 +87,7 @@ async function createRandomRemoteUser( } describe('ActivityPub', () => { + let userProfilesRepository: UserProfilesRepository; let imageService: ApImageService; let noteService: ApNoteService; let personService: ApPersonService; @@ -127,6 +129,8 @@ describe('ActivityPub', () => { await app.init(); app.enableShutdownHooks(); + userProfilesRepository = app.get(DI.userProfilesRepository); + noteService = app.get(ApNoteService); personService = app.get(ApPersonService); rendererService = app.get(ApRendererService); @@ -169,7 +173,7 @@ describe('ActivityPub', () => { resolver.register(actor.id, actor); resolver.register(post.id, post); - const note = await noteService.createNote(post.id, resolver, true); + const note = await noteService.createNote(post.id, undefined, resolver, true); assert.deepStrictEqual(note?.uri, post.id); assert.deepStrictEqual(note.visibility, 'public'); @@ -205,6 +209,53 @@ describe('ActivityPub', () => { }); }); + describe('Collection visibility', () => { + test('Public following/followers', async () => { + const actor = createRandomActor(); + actor.following = { + id: `${actor.id}/following`, + type: 'OrderedCollection', + totalItems: 0, + first: `${actor.id}/following?page=1`, + }; + actor.followers = `${actor.id}/followers`; + + resolver.register(actor.id, actor); + resolver.register(actor.followers, { + id: actor.followers, + type: 'OrderedCollection', + totalItems: 0, + first: `${actor.followers}?page=1`, + }); + + const user = await personService.createPerson(actor.id, resolver); + const userProfile = await userProfilesRepository.findOneByOrFail({ userId: user.id }); + + assert.deepStrictEqual(userProfile.followingVisibility, 'public'); + assert.deepStrictEqual(userProfile.followersVisibility, 'public'); + }); + + test('Private following/followers', async () => { + const actor = createRandomActor(); + actor.following = { + id: `${actor.id}/following`, + type: 'OrderedCollection', + totalItems: 0, + // first: … + }; + actor.followers = `${actor.id}/followers`; + + resolver.register(actor.id, actor); + //resolver.register(actor.followers, { … }); + + const user = await personService.createPerson(actor.id, resolver); + const userProfile = await userProfilesRepository.findOneByOrFail({ userId: user.id }); + + assert.deepStrictEqual(userProfile.followingVisibility, 'private'); + assert.deepStrictEqual(userProfile.followersVisibility, 'private'); + }); + }); + describe('Renderer', () => { test('Render an announce with visibility: followers', () => { rendererService.renderAnnounce('https://example.com/notes/00example', { @@ -282,7 +333,7 @@ describe('ActivityPub', () => { resolver.register(actor.featured, featured); resolver.register(firstNote.id, firstNote); - const note = await noteService.createNote(firstNote.id as string, resolver); + const note = await noteService.createNote(firstNote.id as string, undefined, resolver); assert.strictEqual(note?.uri, firstNote.id); }); }); diff --git a/packages/backend/test/unit/ap-request.ts b/packages/backend/test/unit/ap-request.ts index d3d39240dc..7b0c90d10a 100644 --- a/packages/backend/test/unit/ap-request.ts +++ b/packages/backend/test/unit/ap-request.ts @@ -9,7 +9,7 @@ import httpSignature from '@peertube/http-signature'; import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js'; -export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => { +const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => { return { scheme: 'Signature', params: { diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 9e99517f03..6ef69f4d3c 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -196,6 +196,7 @@ export const page = async (user: UserToken, page: Partial eyeCatchingImageId: null, font: 'sans-serif' as FIXME, hideTitleWhenPinned: false, + visibility: 'public', name: '1678594845072', script: '', summary: null, @@ -399,7 +400,7 @@ export const waitFire = async (user: UserToken if (timer) clearTimeout(timer); res(true); } - }, params); + }, { ...params, idOnly: false }); } catch (e) { rej(e); } diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index 2b15a5cc7a..ce2c5b8785 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -9,7 +9,7 @@ "noFallthroughCasesInSwitch": true, "declaration": false, "sourceMap": false, - "target": "ES2022", + "target": "es2022", "module": "nodenext", "moduleResolution": "nodenext", "allowSyntheticDefaultImports": true, diff --git a/packages/frontend/package.json b/packages/frontend/package.json index ac73590fa0..2842184f27 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -23,25 +23,25 @@ "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@misskey-dev/browser-image-resizer": "2024.1.0", "@rollup/plugin-json": "6.1.0", - "@rollup/plugin-replace": "6.0.1", - "@rollup/plugin-typescript": "12.1.1", - "@rollup/pluginutils": "5.1.3", + "@rollup/plugin-replace": "6.0.2", + "@rollup/plugin-typescript": "12.1.2", + "@rollup/pluginutils": "5.1.4", "@syuilo/aiscript": "0.19.0", - "@tabler/icons-webfont": "3.21.0", + "@tabler/icons-webfont": "3.26.0", "@twemoji/parser": "15.1.1", - "@vitejs/plugin-vue": "5.1.4", - "@vue/compiler-sfc": "3.5.12", - "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.9", + "@vitejs/plugin-vue": "5.2.1", + "@vue/compiler-sfc": "3.5.13", + "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.13", "astring": "1.9.0", "broadcast-channel": "7.0.0", "buraha": "0.0.1", "canvas-confetti": "1.9.3", - "chart.js": "4.4.6", + "chart.js": "4.4.7", "chartjs-adapter-date-fns": "3.0.0", "chartjs-chart-matrix": "2.0.1", "chartjs-plugin-gradient": "0.6.1", - "chartjs-plugin-zoom": "2.0.1", - "chromatic": "11.18.0", + "chartjs-plugin-zoom": "2.2.0", + "chromatic": "11.20.2", "compare-versions": "6.1.1", "cropperjs": "2.0.0-rc.0", "date-fns": "4.1.0", @@ -58,86 +58,88 @@ "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", "photoswipe": "5.4.4", - "punycode": "2.3.1", - "rollup": "4.24.4", - "sanitize-html": "2.13.1", - "sass": "1.80.6", - "shiki": "1.22.2", + "punycode.js": "2.3.1", + "rollup": "4.29.1", + "sanitize-html": "2.14.0", + "sass": "1.83.0", + "shiki": "1.24.4", "strict-event-emitter-types": "2.0.0", "textarea-caret": "3.1.0", - "three": "0.170.0", + "three": "0.171.0", "throttle-debounce": "5.0.2", "tinycolor2": "1.6.0", "tsc-alias": "1.8.10", "tsconfig-paths": "4.2.0", - "typescript": "5.6.3", - "uuid": "11.0.2", + "typescript": "5.7.2", + "uuid": "11.0.3", "v-code-diff": "1.13.1", - "vite": "5.4.10", - "vue": "3.5.12", + "vite": "6.0.5", + "vue": "3.5.13", "vue-gtag": "2.0.1", - "vuedraggable": "next" + "vuedraggable": "next", + "webgl-audiovisualizer": "github:tar-bin/webgl-audiovisualizer" }, "devDependencies": { "@misskey-dev/eslint-plugin": "1.0.0", - "@misskey-dev/summaly": "MisskeyIO/summaly#5.1.1", - "@storybook/addon-actions": "8.4.2", - "@storybook/addon-essentials": "8.4.2", - "@storybook/addon-interactions": "8.4.2", - "@storybook/addon-links": "8.4.2", - "@storybook/addon-mdx-gfm": "8.4.2", - "@storybook/addon-storysource": "8.4.2", - "@storybook/blocks": "8.4.2", - "@storybook/components": "8.4.2", - "@storybook/core-events": "8.4.2", - "@storybook/manager-api": "8.4.2", - "@storybook/preview-api": "8.4.2", - "@storybook/react": "8.4.2", - "@storybook/react-vite": "8.4.2", - "@storybook/test": "8.4.2", - "@storybook/theming": "8.4.2", - "@storybook/types": "8.4.2", - "@storybook/vue3": "8.4.2", - "@storybook/vue3-vite": "8.4.2", + "@misskey-dev/summaly": "github:MisskeyIO/summaly#5.1.3", + "@storybook/addon-actions": "8.4.7", + "@storybook/addon-essentials": "8.4.7", + "@storybook/addon-interactions": "8.4.7", + "@storybook/addon-links": "8.4.7", + "@storybook/addon-mdx-gfm": "8.4.7", + "@storybook/addon-storysource": "8.4.7", + "@storybook/blocks": "8.4.7", + "@storybook/components": "8.4.7", + "@storybook/core-events": "8.4.7", + "@storybook/manager-api": "8.4.7", + "@storybook/preview-api": "8.4.7", + "@storybook/react": "8.4.7", + "@storybook/react-vite": "8.4.7", + "@storybook/test": "8.4.7", + "@storybook/theming": "8.4.7", + "@storybook/types": "8.4.7", + "@storybook/vue3": "8.4.7", + "@storybook/vue3-vite": "8.4.7", "@testing-library/vue": "8.1.0", "@types/escape-regexp": "0.0.3", "@types/estree": "1.0.6", - "@types/matter-js": "0.19.7", + "@types/matter-js": "0.19.8", "@types/micromatch": "4.0.9", - "@types/node": "22.9.0", - "@types/punycode": "2.1.4", + "@types/node": "22.10.2", + "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/sanitize-html": "2.13.0", + "@types/three": "0.171.0", "@types/throttle-debounce": "5.0.2", "@types/tinycolor2": "1.4.6", "@types/ws": "8.5.13", "@typescript-eslint/eslint-plugin": "7.10.0", "@typescript-eslint/parser": "7.10.0", - "@vitest/coverage-v8": "2.1.4", - "@vue/runtime-core": "3.5.12", + "@vitest/coverage-v8": "2.1.8", + "@vue/runtime-core": "3.5.13", "acorn": "8.14.0", "cross-env": "7.0.3", - "cypress": "13.15.2", + "cypress": "13.17.0", "eslint": "8.57.1", "eslint-plugin-import": "2.31.0", - "eslint-plugin-vue": "9.30.0", + "eslint-plugin-vue": "9.32.0", "fast-glob": "3.3.2", - "happy-dom": "15.11.0", + "happy-dom": "15.11.7", "intersection-observer": "0.12.2", "micromatch": "4.0.8", - "msw": "2.6.2", + "msw": "2.7.0", "msw-storybook-addon": "2.0.4", - "nodemon": "3.1.7", - "prettier": "3.3.3", - "react": "18.3.1", - "react-dom": "18.3.1", - "start-server-and-test": "2.0.8", - "storybook": "8.4.2", + "nodemon": "3.1.9", + "prettier": "3.4.2", + "react": "19.0.0", + "react-dom": "19.0.0", + "start-server-and-test": "2.0.9", + "storybook": "8.4.7", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "vite-plugin-turbosnap": "1.0.3", - "vitest": "2.1.4", + "vitest": "2.1.8", "vitest-fetch-mock": "0.3.0", - "vue-component-type-helpers": "2.1.10", + "vue-component-type-helpers": "2.2.0", "vue-eslint-parser": "9.4.3", - "vue-tsc": "2.1.10" + "vue-tsc": "2.2.0" } } diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index a34a70df0e..e4f8b707b5 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -5,6 +5,7 @@ import { defineAsyncComponent, reactive, ref } from 'vue'; import * as Misskey from 'misskey-js'; +import { set as gtagSet, time as gtagTime } from 'vue-gtag'; import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; import { i18n } from '@/i18n.js'; import { miLocalStorage } from '@/local-storage.js'; @@ -14,7 +15,6 @@ import { apiUrl } from '@/config.js'; import { waiting, popup, popupMenu, success, alert } from '@/os.js'; import { generateClientTransactionId, misskeyApi } from '@/scripts/misskey-api.js'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload.js'; -import { set as gtagSet, time as gtagTime } from 'vue-gtag'; import { instance } from '@/instance.js'; // TODO: 他のタブと永続化されたstateを同期 diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 79ef7b3f2d..48a8e1679e 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -62,10 +62,6 @@ export async function common(createVue: () => App) { }); } - if (miLocalStorage.getItem('id') === null) { - miLocalStorage.setItem('id', crypto.randomUUID()); - } - let isClientUpdated = false; //#region クライアントが更新されたかチェック diff --git a/packages/frontend/src/components/MkAudioVisualizer.vue b/packages/frontend/src/components/MkAudioVisualizer.vue new file mode 100644 index 0000000000..35f2603b4b --- /dev/null +++ b/packages/frontend/src/components/MkAudioVisualizer.vue @@ -0,0 +1,219 @@ + + + + + diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue index 47cccd9b7c..d6d4a7875e 100644 --- a/packages/frontend/src/components/MkGalleryPostPreview.vue +++ b/packages/frontend/src/components/MkGalleryPostPreview.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only -->