Compare commits

..

1 commit

Author SHA1 Message Date
9f1d7907e4
fix(backend): Atomically mark remote account deletions
All checks were successful
Test (production install and build) / production (22.11.0) (pull_request) Successful in 1m1s
Test (backend) / unit (22.11.0) (pull_request) Successful in 8m28s
Lint / pnpm_install (push) Successful in 1m20s
Publish Docker image / Build (push) Successful in 4m31s
Test (backend) / e2e (22.11.0) (pull_request) Successful in 11m30s
Test (production install and build) / production (22.11.0) (push) Successful in 1m3s
Lint / lint (backend) (pull_request) Successful in 2m8s
Test (backend) / unit (22.11.0) (push) Successful in 7m50s
Lint / lint (frontend) (pull_request) Successful in 2m17s
Lint / lint (frontend-embed) (pull_request) Successful in 2m19s
Lint / lint (frontend-shared) (pull_request) Successful in 2m23s
Test (backend) / e2e (22.11.0) (push) Successful in 11m41s
Lint / lint (misskey-bubble-game) (pull_request) Successful in 2m36s
Lint / lint (misskey-js) (pull_request) Successful in 2m31s
Lint / lint (misskey-reversi) (pull_request) Successful in 2m41s
Lint / lint (sw) (pull_request) Successful in 2m41s
Lint / typecheck (backend) (pull_request) Successful in 2m12s
Lint / typecheck (misskey-js) (pull_request) Successful in 2m1s
Lint / typecheck (sw) (pull_request) Successful in 2m2s
Lint / lint (backend) (push) Successful in 2m32s
Lint / lint (frontend) (push) Successful in 2m53s
Lint / lint (frontend-embed) (push) Successful in 2m39s
Lint / lint (frontend-shared) (push) Successful in 2m41s
Lint / lint (misskey-bubble-game) (push) Successful in 2m41s
Lint / lint (misskey-js) (push) Successful in 2m39s
Lint / lint (misskey-reversi) (push) Successful in 2m41s
Lint / typecheck (backend) (push) Successful in 2m15s
Lint / lint (sw) (push) Successful in 2m36s
Lint / typecheck (misskey-js) (push) Successful in 1m40s
Lint / typecheck (sw) (push) Successful in 1m29s
Signed-off-by: eternal-flame-AD <yume@yumechi.jp>
2024-11-19 21:49:10 -06:00
117 changed files with 2812 additions and 2823 deletions

View file

@ -59,40 +59,40 @@ jobs:
- name: Test
run: pnpm --filter backend test-and-coverage
# e2e:
# runs-on: ubuntu-latest
#
# strategy:
# matrix:
# node-version: [22.11.0]
#
# services:
# postgres:
# image: l1drm/postgres-pgroonga:alpine-15-znver4
# env:
# POSTGRES_DB: test-misskey
# POSTGRES_HOST_AUTH_METHOD: trust
# redis:
# image: redis:7
#
# steps:
# - uses: actions/checkout@v4.1.1
# with:
# submodules: true
# - name: Install pnpm
# uses: pnpm/action-setup@v4
# - name: Use Node.js ${{ matrix.node-version }}
# uses: actions/setup-node@v4.0.4
# with:
# node-version: ${{ matrix.node-version }}
# cache: 'pnpm'
# - run: corepack enable
# - run: pnpm i --frozen-lockfile
# - name: Check pnpm-lock.yaml
# run: git diff --exit-code pnpm-lock.yaml
# - name: Copy Configure
# run: cp .forgejo/misskey/test.yml .config
# - name: Build
# run: pnpm build
# - name: Test
# run: pnpm --filter backend test-and-coverage:e2e
e2e:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22.11.0]
services:
postgres:
image: l1drm/postgres-pgroonga:alpine-15-znver4
env:
POSTGRES_DB: test-misskey
POSTGRES_HOST_AUTH_METHOD: trust
redis:
image: redis:7
steps:
- uses: actions/checkout@v4.1.1
with:
submodules: true
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- run: corepack enable
- run: pnpm i --frozen-lockfile
- name: Check pnpm-lock.yaml
run: git diff --exit-code pnpm-lock.yaml
- name: Copy Configure
run: cp .forgejo/misskey/test.yml .config
- name: Build
run: pnpm build
- name: Test
run: pnpm --filter backend test-and-coverage:e2e

View file

@ -0,0 +1,97 @@
name: 🐛 Bug Report
description: Create a report to help us improve
labels: ["⚠bug?"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting!
First, in order to avoid duplicate Issues, please search to see if the problem you found has already been reported.
Also, If you are NOT owner/admin of server, PLEASE DONT REPORT SERVER SPECIFIC ISSUES TO HERE! (e.g. feature XXX is not working in misskey.example) Please try with another misskey servers, and if your issue is only reproducible with specific server, contact your server's owner/admin first.
- type: textarea
attributes:
label: 💡 Summary
description: Tell us what the bug is
validations:
required: true
- type: textarea
attributes:
label: 🥰 Expected Behavior
description: Tell us what should happen
validations:
required: true
- type: textarea
attributes:
label: 🤬 Actual Behavior
description: |
Tell us what happens instead of the expected behavior.
Please include errors from the developer console and/or server log files if you have access to them.
validations:
required: true
- type: textarea
attributes:
label: 📝 Steps to Reproduce
placeholder: |
1.
2.
3.
validations:
required: false
- type: textarea
attributes:
label: 💻 Frontend Environment
description: |
Tell us where on the platform it happens
DO NOT WRITE "latest". Please provide the specific version.
Examples:
* Model and OS of the device(s): MacBook Pro (14inch, 2021), macOS Ventura 13.4
* Browser: Chrome 113.0.5672.126
* Server URL: misskey.example.com
* Misskey: 2024.x.x
value: |
* Model and OS of the device(s):
* Browser:
* Server URL:
* Misskey:
render: markdown
validations:
required: false
- type: textarea
attributes:
label: 🛰 Backend Environment (for server admin)
description: |
Tell us where on the platform it happens
DO NOT WRITE "latest". Please provide the specific version.
If you are using a managed service, put that after the version.
Examples:
* Installation Method or Hosting Service: docker compose, k8s/docker, systemd, "Misskey install shell script", development environment
* Misskey: 2024.x.x
* Node: 20.x.x
* PostgreSQL: 15.x.x
* Redis: 7.x.x
* OS and Architecture: Ubuntu 24.04.2 LTS aarch64
value: |
* Installation Method or Hosting Service:
* Misskey:
* Node:
* PostgreSQL:
* Redis:
* OS and Architecture:
render: markdown
validations:
required: false
- type: checkboxes
attributes:
label: Do you want to address this bug yourself?
options:
- label: Yes, I will patch the bug myself and send a pull request

View file

@ -0,0 +1,22 @@
name: ✨ Feature Request
description: Suggest an idea for this project
labels: ["✨Feature"]
body:
- type: textarea
attributes:
label: Summary
description: Tell us what the suggestion is
validations:
required: true
- type: textarea
attributes:
label: Purpose
description: Describe the specific problem or need you think this feature will solve, and who it will help.
validations:
required: true
- type: checkboxes
attributes:
label: Do you want to implement this feature yourself?
options:
- label: Yes, I will implement this by myself and send a pull request

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,8 @@
contact_links:
- name: 💬 Misskey official Discord
url: https://discord.gg/Wp8gVStHW3
about: Chat freely about Misskey
# 仮
- name: 💬 Start discussion
url: https://github.com/misskey-dev/misskey/discussions
about: The official forum to join conversation and ask question

23
.github/PULL_REQUEST_TEMPLATE/01_bug.md vendored Normal file
View file

@ -0,0 +1,23 @@
<!-- お読みください / README
PRありがとうございます PRを作成する前に、コントリビューションガイドをご確認ください:
Thank you for your PR! Before creating a PR, please check the contribution guide:
https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md
-->
## What
<!-- このPRで何をしたのか どう変わるのか? -->
<!-- What did you do with this PR? How will it change things? -->
## Why
<!-- なぜそうするのか? どういう意図なのか? 何が困っているのか? -->
<!-- Why do you do it? What are your intentions? What is the problem? -->
## Additional info (optional)
<!-- テスト観点など -->
<!-- Test perspective, etc -->
## Checklist
- [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md)
- [ ] Test working in a local environment
- [ ] (If needed) Update CHANGELOG.md
- [ ] (If possible) Add tests

View file

@ -0,0 +1,23 @@
<!-- お読みください / README
PRありがとうございます PRを作成する前に、コントリビューションガイドをご確認ください:
Thank you for your PR! Before creating a PR, please check the contribution guide:
https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md
-->
## What
<!-- このPRで何をしたのか どう変わるのか? -->
<!-- What did you do with this PR? How will it change things? -->
## Why
<!-- なぜそうするのか? どういう意図なのか? 何が困っているのか? -->
<!-- Why do you do it? What are your intentions? What is the problem? -->
## Additional info (optional)
<!-- テスト観点など -->
<!-- Test perspective, etc -->
## Checklist
- [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md)
- [ ] Test working in a local environment
- [ ] (If needed) Update CHANGELOG.md
- [ ] (If possible) Add tests

View file

@ -0,0 +1,20 @@
## Summary
This is a release PR.
For more information on the release instructions, please see:
https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md#release
## For reviewers
- CHANGELOGに抜け漏れは無いか
- バージョンの上げ方は適切か
- 他にこのリリースに含めなければならない変更は無いか
- 全体的な変更内容を俯瞰し問題は無いか
- レビューされていないコミットがある場合は、それが問題ないか
- 最終的な動作確認を行い問題は無いか
などを確認し、リリースする準備が整っていると思われる場合は approve してください。
## Checklist
- [ ] package.jsonのバージョンが正しく更新されている
- [ ] CHANGELOGが過不足無く更新されている
- [ ] CIが全て通っている

View file

@ -1,7 +1,7 @@
<!-- お読みください / README
PRありがとうございます PRを作成する前に、コントリビューションガイドをご確認ください:
Thank you for your PR! Before creating a PR, please check the contribution guide:
https://forge.yumechi.jp/yume/yumechi-no-kuni/src/branch/master/CONTRIBUTING.md
https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md
-->
## What
@ -17,7 +17,7 @@ https://forge.yumechi.jp/yume/yumechi-no-kuni/src/branch/master/CONTRIBUTING.md
<!-- Test perspective, etc -->
## Checklist
- [ ] Read the [contribution guide](https://forge.yumechi.jp/yume/yumechi-no-kuni/src/branch/master/CONTRIBUTING.md)
- [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md)
- [ ] Test working in a local environment
- [ ] (If needed) Add story of storybook
- [ ] (If needed) Update CHANGELOG.md

View file

@ -60,13 +60,13 @@ jobs:
### General
-
### Client
-
### Server
-
use_external_app_to_release: ${{ vars.USE_RELEASE_APP == 'true' }}
indent: ${{ vars.INDENT }}
secrets:
@ -86,7 +86,6 @@ jobs:
draft_prerelease_channel: alpha
ready_start_prerelease_channel: beta
prerelease_channel: ${{ inputs.start-rc && 'rc' || '' }}
reset_number_on_channel_change: true
secrets:
RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }}
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}

View file

@ -41,7 +41,6 @@ jobs:
indent: ${{ vars.INDENT }}
draft_prerelease_channel: alpha
ready_start_prerelease_channel: beta
reset_number_on_channel_change: true
secrets:
RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }}
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}

View file

@ -15,8 +15,6 @@ on:
jobs:
build:
# chromatic is not likely to be available for fork repositories, so we disable for fork repositories.
if: github.repository == 'misskey-dev/misskey'
runs-on: ubuntu-latest
env:

View file

@ -0,0 +1,6 @@
build:
misskey:
args:
- NODE_ENV=development
deploy:
- helm upgrade --install misskey chart --set image=${OKTETO_BUILD_MISSKEY_IMAGE} --set url="https://misskey-$(kubectl config view --minify -o jsonpath='{..namespace}').cloud.okteto.net" --set environment=development

View file

@ -1,44 +1,3 @@
## 2024.11.1
### General
-
### Client
- Enhance: PC画面でチャンネルが複数列で表示されるように
(Cherry-picked from https://github.com/Otaku-Social/maniakey/pull/13)
- Enhance: 照会に失敗した場合、その理由を表示するように
- Fix: 画面サイズが変わった際にナビゲーションバーが自動で折りたたまれない問題を修正
- Fix: サーバー情報メニューに区切り線が不足していたのを修正
- Fix: ノートがログインしているユーザーしか見れない場合にログインダイアログを閉じるとその後の動線がなくなる問題を修正
- Fix: 公開範囲がホームのノートの埋め込みウィジェットが読み込まれない問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/803)
- Fix: 絵文字管理画面で一部の絵文字が表示されない問題を修正
### Server
- Fix: ユーザーのプロフィール画面をアドレス入力などで直接表示した際に概要タブの描画に失敗する問題の修正( #15032 )
- Fix: 起動前の疎通チェックが機能しなくなっていた問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/737)
## 2024.11.0-yumechinokuni.8
- Frontend: SSRでユーザープロフィールが表示されない問題を修正
- Security: SSRプライバシー方面の改善
- Security: AP Payloadの検証を強化
## 2024.11.0-yumechinokuni.7
- Misskey Trademark内容をWebUIから削除
- Service Worker キャッシュが正しく動作しない問題を修正
## 2024.11.0-yumechinokuni.6
- Upstream: 2024.11.0-alpha.4 タッグをマージする
- Performance: EmojiのリクエストをProxyでキャッシュするように
- Performance: Service Workerのキャッシュを最適化
- Security: AP Payloadの検証を強化
- Security: Image/Video Processorはドライブ機能だけを使うように
## 2024.11.0-yumechinokuni.5
- Upstream: 2024.11.0-alpha.2 タッグをマージする
@ -77,9 +36,9 @@ PgroongaのCWサーチ (github.com/paricafe/misskey#d30db97b59d264450901c1dd8680
### General
- Feat: コンテンツの表示にログインを必須にできるように
- Feat: 過去のノートを非公開化/フォロワーのみ表示可能にできるように
- Fix: お知らせ作成時に画像URL入力欄を空欄に変更できないのを修正 ( #14976 )
- Enhance: 依存関係の更新
- Enhance: l10nの更新
- Fix: お知らせ作成時に画像URL入力欄を空欄に変更できないのを修正 ( #14976 )
### Client
- Enhance: Bull DashboardでRelationship Queueの状態も確認できるように
@ -99,14 +58,13 @@ PgroongaのCWサーチ (github.com/paricafe/misskey#d30db97b59d264450901c1dd8680
(Based on https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/663)
- Enhance: サイドバーを簡単に展開・折りたたみできるように ( #14981 )
- Enhance: リノートメニューに「リノートの詳細」を追加
- Enhance: 非ログイン状態でMisskeyを開いた際のパフォーマンスを向上
- Fix: 通知の範囲指定の設定項目が必要ない通知設定でも範囲指定の設定がでている問題を修正
- Fix: Turnstileが失敗・期限切れした際にも成功扱いとなってしまう問題を修正
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/768)
- Fix: デッキのタイムラインカラムで「センシティブなファイルを含むノートを表示」設定が使用できなかった問題を修正
- Fix: Encode RSS urls with escape sequences before fetching allowing query parameters to be used
- Fix: リンク切れを修正
- Fix: ノート投稿ボタンにホバー時のスタイルが適用されていないのを修正
= Fix: ノート投稿ボタンにホバー時のスタイルが適用されていないのを修正
(Cherry-picked from https://github.com/taiyme/misskey/pull/305)
- Fix: メールアドレス登録有効化時の「完了」ダイアログボックスの表示条件を修正
- Fix: 画面幅が狭い環境でデザインが崩れる問題を修正
@ -134,11 +92,6 @@ PgroongaのCWサーチ (github.com/paricafe/misskey#d30db97b59d264450901c1dd8680
- Fix: FTT無効時にユーザーリストタイムラインが使用できない問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/709)
- Fix: User Webhookテスト機能のMock Payloadを修正
- Fix: アカウント削除のモデレーションログが動作していないのを修正 (#14996)
- Fix: リノートミュートが新規投稿通知に対して作用していなかった問題を修正
- Fix: Inboxの処理で生じるエラーを誤ってActivityとして処理することがある問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/730)
- Fix: セキュリティに関する修正
### Misskey.js
- Fix: Stream初期化時、別途WebSocketを指定する場合の型定義を修正

View file

@ -1,31 +1,49 @@
# ゆめちのくに
<div align="center">
<a href="https://misskey-hub.net">
<img src="./assets/title_float.svg" alt="Misskey logo" style="border-radius:50%" width="300"/>
</a>
YumechiNoKuni is a fork of Misskey, with a focus on security, observability and reliability.
**🌎 **Misskey** is an open source, federated social media platform that's free forever! 🚀**
[mi.yumechi.jp](https://mi.yumechi.jp) is running this version.
[Learn more](https://misskey-hub.net/)
[Learn more about Misskey](https://misskey-hub.net/)
---
## Main differences
<a href="https://misskey-hub.net/servers/">
<img src="https://custom-icon-badges.herokuapp.com/badge/find_an-instance-acea31?logoColor=acea31&style=for-the-badge&logo=misskey&labelColor=363B40" alt="find an instance"/></a>
### Unique features
<a href="https://misskey-hub.net/docs/for-admin/install/guides/">
<img src="https://custom-icon-badges.herokuapp.com/badge/create_an-instance-FBD53C?logoColor=FBD53C&style=for-the-badge&logo=server&labelColor=363B40" alt="create an instance"/></a>
- Strict ActivityPub sanitization by whitelisting properties and normalizing all referential properties.
- Strict Content Security Policy.
- Require TLSv1.2+ over port 443 for all ActivityPub requests.
- Strongly-typed inbox filtering in Rust.
- Reduce needless retries by marking more errors as permanent.
- Detailed prometheus metrics for slow requests, DB queries, AP processing, failed auths, etc.
- Disable unauthenticated media processing and use custom AppArmored media proxy.
- Enable active users in nodeinfo back.
- Advertise Git information over nodeinfo for better observability and easy tracking of the actual code running.
- Logical replication for the database over mTLS.
- More atomic operations in API handlers.
<a href="./CONTRIBUTING.md">
<img src="https://custom-icon-badges.herokuapp.com/badge/become_a-contributor-A371F7?logoColor=A371F7&style=for-the-badge&logo=git-merge&labelColor=363B40" alt="become a contributor"/></a>
### Picked from github.com/paricafe/misskey
<a href="https://discord.gg/Wp8gVStHW3">
<img src="https://custom-icon-badges.herokuapp.com/badge/join_the-community-5865F2?logoColor=5865F2&style=for-the-badge&logo=discord&labelColor=363B40" alt="join the community"/></a>
- pgroonga full-text search (with modifications).
- Better Service Worker caching.
- Better hashtag statistics.
- Better handling of deep recursive AP objects.
<a href="https://www.patreon.com/syuilo">
<img src="https://custom-icon-badges.herokuapp.com/badge/become_a-patron-F96854?logoColor=F96854&style=for-the-badge&logo=patreon&labelColor=363B40" alt="become a patron"/></a>
</div>
## Thanks
<a href="https://sentry.io/"><img src="https://github.com/misskey-dev/misskey/assets/4439005/98576556-222f-467a-94be-e98dbda1d852" height="30" alt="Sentry" /></a>
Thanks to [Sentry](https://sentry.io/) for providing the error tracking platform that helps us catch unexpected errors.
<a href="https://www.chromatic.com/"><img src="https://user-images.githubusercontent.com/321738/84662277-e3db4f80-af1b-11ea-88f5-91d67a5e59f6.png" height="30" alt="Chromatic" /></a>
Thanks to [Chromatic](https://www.chromatic.com/) for providing the visual testing platform that helps us review UI changes and catch visual regressions.
<a href="https://about.codecov.io/for/open-source/"><img src="https://about.codecov.io/wp-content/themes/codecov/assets/brand/sentry-cobranding/logos/codecov-by-sentry-logo.svg" height="30" alt="Codecov" /></a>
Thanks to [Codecov](https://about.codecov.io/for/open-source/) for providing the code coverage platform that helps us improve our test coverage.
<a href="https://crowdin.com/"><img src="https://user-images.githubusercontent.com/20679825/230709597-1299a011-171a-4294-a91e-355a9b37c672.svg" height="30" alt="Crowdin" /></a>
Thanks to [Crowdin](https://crowdin.com/) for providing the localization platform that helps us translate Misskey into many languages.
<a href="https://hub.docker.com/"><img src="https://user-images.githubusercontent.com/20679825/230148221-f8e73a32-a49b-47c3-9029-9a15c3824f92.png" height="30" alt="Docker" /></a>
Thanks to [Docker](https://hub.docker.com/) for providing the container platform that helps us run Misskey in production.

View file

@ -1,12 +1,15 @@
# Reporting Security Issues
If you discover a security issue in this project, please use the `git blame` command to identify the source of the issue,
if it was introduced by this fork please contact me at secity<at>yumechi.jp.
If you discover a security issue in Misskey, please report it by **[this form](https://github.com/misskey-dev/misskey/security/advisories/new)**.
For upstream issues please report by **[this form](https://github.com/misskey-dev/misskey/security/advisories/new)**.
This will allow us to assess the risk, and make a fix available before we add a
bug report to the GitHub repository.
Thanks for helping make YumechiNoKuni safe for everyone.
Thanks for helping make Misskey safe for everyone.
## When create a patch
If you can also create a patch to fix the vulnerability, please send a diff file with the report.
If you can also create a patch to fix the vulnerability, please create a PR on the private fork.
> [!note]
> There is a GitHub bug that prevents merging if a PR not following the develop branch of upstream, so please keep follow the develop branch.

View file

@ -586,7 +586,6 @@ masterVolume: "Volum principal"
notUseSound: "Sense so"
useSoundOnlyWhenActive: "Reproduir sons només quan Misskey estigui actiu"
details: "Detalls"
renoteDetails: "Més informació sobre l'impuls "
chooseEmoji: "Tria un emoji"
unableToProcess: "L'operació no pot ser completada "
recentUsed: "Utilitzat recentment"

View file

@ -1242,7 +1242,6 @@ keepOriginalFilenameDescription: "Wenn diese Einstellung deaktiviert ist, wird d
noDescription: "Keine Beschreibung vorhanden"
tryAgain: "Bitte später erneut versuchen"
confirmWhenRevealingSensitiveMedia: "Das Anzeigen von sensiblen Medien bestätigen"
sensitiveMediaRevealConfirm: "Es könnte sich um sensible Medien handeln. Möchtest du sie anzeigen?"
createdLists: "Erstellte Listen"
createdAntennas: "Erstellte Antennen"
fromX: "Von {x}"
@ -1254,8 +1253,6 @@ thereAreNChanges: "Es gibt {n} Änderung(en)"
signinWithPasskey: "Mit Passkey anmelden"
passkeyVerificationFailed: "Die Passkey-Verifizierung ist fehlgeschlagen."
passkeyVerificationSucceededButPasswordlessLoginDisabled: "Die Verifizierung des Passkeys war erfolgreich, aber die passwortlose Anmeldung ist deaktiviert."
messageToFollower: "Nachricht an die Follower"
testCaptchaWarning: "Diese Funktion ist für CAPTCHA-Testzwecke gedacht.\n<strong>Nicht in einer Produktivumgebung verwenden.</strong>"
prohibitedWordsForNameOfUser: "Verbotene Begriffe für Benutzernamen"
prohibitedWordsForNameOfUserDescription: "Wenn eine Zeichenfolge aus dieser Liste im Namen eines Benutzers enthalten ist, wird der Benutzername abgelehnt. Benutzer mit Moderatorenrechten sind von dieser Einschränkung nicht betroffen."
yourNameContainsProhibitedWords: "Dein Name enthält einen verbotenen Begriff"
@ -1267,7 +1264,6 @@ _accountSettings:
requireSigninToViewContentsDescription1: "Erfordere eine Anmeldung, um alle Notizen und andere Inhalte anzuzeigen, die du erstellt hast. Dadurch wird verhindert, dass Crawler deine Informationen sammeln."
requireSigninToViewContentsDescription3: "Diese Einschränkungen gelten möglicherweise nicht für föderierte Inhalte von anderen Servern."
makeNotesFollowersOnlyBefore: "Macht frühere Notizen nur für Follower sichtbar"
makeNotesHiddenBefore: "Frühere Notizen privat machen"
mayNotEffectForFederatedNotes: "Dies hat möglicherweise keine Auswirkungen auf Notizen, die an andere Server föderiert werden."
_abuseUserReport:
forward: "Weiterleiten"
@ -1278,7 +1274,6 @@ _delivery:
stop: "Gesperrt"
_type:
none: "Wird veröffentlicht"
manuallySuspended: "Manuell gesperrt"
_bubbleGame:
howToPlay: "Wie man spielt"
hold: "Halten"

View file

@ -586,7 +586,6 @@ masterVolume: "Master volume"
notUseSound: "Disable sound"
useSoundOnlyWhenActive: "Output sounds only if Misskey is active."
details: "Details"
renoteDetails: "Renote details"
chooseEmoji: "Select an emoji"
unableToProcess: "The operation could not be completed"
recentUsed: "Recently used"

59
locales/index.d.ts vendored
View file

@ -10605,65 +10605,6 @@ export interface Locale extends ILocale {
*/
"sent": string;
};
"_remoteLookupErrors": {
"_federationNotAllowed": {
/**
*
*/
"title": string;
/**
*
*
*/
"description": string;
};
"_uriInvalid": {
/**
* URIが不正です
*/
"title": string;
/**
* URIに問題がありますURIに使用できない文字を入力していないか確認してください
*/
"description": string;
};
"_requestFailed": {
/**
*
*/
"title": string;
/**
* URIや存在しないURIを入力していないか確認してください
*/
"description": string;
};
"_responseInvalid": {
/**
*
*/
"title": string;
/**
*
*/
"description": string;
};
"_responseInvalidIdHostNotMatch": {
/**
* URIのドメインと最終的に得られたURIのドメインとが異なりますURIを使用して照会し直してください
*/
"description": string;
};
"_noSuchObject": {
/**
*
*/
"title": string;
/**
* URIをもう一度お確かめください
*/
"description": string;
};
};
}
declare const locales: {
[lang: string]: Locale;

View file

@ -2827,22 +2827,3 @@ _selfXssPrevention:
_followRequest:
recieved: "受け取った申請"
sent: "送った申請"
_remoteLookupErrors:
_federationNotAllowed:
title: "このサーバーとは通信できません"
description: "このサーバーとの通信が無効化されているか、このサーバーをブロックしている・ブロックされている可能性があります。\nサーバー管理者にお問い合わせください。"
_uriInvalid:
title: "URIが不正です"
description: "入力されたURIに問題があります。URIに使用できない文字を入力していないか確認してください。"
_requestFailed:
title: "リクエストに失敗しました"
description: "このサーバーとの通信に失敗しました。相手サーバーがダウンしている可能性があります。また、不正なURIや存在しないURIを入力していないか確認してください。"
_responseInvalid:
title: "レスポンスが不正です"
description: "このサーバーと通信することはできましたが、得られたデータが不正なものでした。"
_responseInvalidIdHostNotMatch:
description: "入力されたURIのドメインと最終的に得られたURIのドメインとが異なります。第三者のサーバーを介してリモートのコンテンツを照会している場合は、発信元のサーバーで取得できるURIを使用して照会し直してください。"
_noSuchObject:
title: "見つかりません"
description: "要求されたリソースは見つかりませんでした。URIをもう一度お確かめください。"

View file

@ -586,7 +586,6 @@ masterVolume: "마스터 볼륨"
notUseSound: "음소거 하기"
useSoundOnlyWhenActive: "Misskey를 활성화한 때에만 소리를 출력하기"
details: "자세히"
renoteDetails: "리노트 상세 내용"
chooseEmoji: "이모지 선택"
unableToProcess: "작업을 완료할 수 없습니다"
recentUsed: "최근 사용"
@ -1300,7 +1299,6 @@ thisContentsAreMarkedAsSigninRequiredByAuthor: "게시자에 의해 로그인해
lockdown: "잠금"
pleaseSelectAccount: "계정을 선택해주세요."
availableRoles: "사용 가능한 역할"
acknowledgeNotesAndEnable: "활성화 하기 전에 주의 사항을 확인했습니다."
_accountSettings:
requireSigninToViewContents: "콘텐츠 열람을 위해 로그인으 필수로 설정하기"
requireSigninToViewContentsDescription1: "자신이 작성한 모든 노트 등의 콘텐츠를 보기 위해 로그인을 필수로 설정합니다. 크롤러가 정보 수집하는 것을 방지하는 효과를 기대할 수 있습니다."
@ -1457,8 +1455,6 @@ _serverSettings:
reactionsBufferingDescription: "활성화 한 경우, 리액션 작성 퍼포먼스가 대폭 향상되어 DB의 부하를 줄일 수 있으나, Redis의 메모리 사용량이 많아집니다."
inquiryUrl: "문의처 URL"
inquiryUrlDescription: "서버 운영자에게 보내는 문의 양식의 URL이나 운영자의 연락처 등이 적힌 웹 페이지의 URL을 설정합니다."
openRegistration: "회원 가입을 활성화 하기"
openRegistrationWarning: "회원 가입을 개방하는 것은 리스크가 따릅니다. 서버를 항상 감시할 수 있고, 문제가 발생했을 때 바로 대응할 수 있는 상태에서만 활성화 하는 것을 권장합니다."
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "일정 기간동안 모더레이터의 활동이 감지되지 않는 경우, 스팸 방지를 위해 이 설정은 자동으로 꺼집니다."
_accountMigration:
moveFrom: "다른 계정에서 이 계정으로 이사"
@ -2741,6 +2737,3 @@ _selfXssPrevention:
description1: "여기에 무언가를 붙여넣으면 악의적인 사용자에게 계정을 탈취당하거나 개인정보를 도용당할 수 있습니다."
description2: "붙여 넣으려는 항목이 무엇인지 정확히 이해하지 못하는 경우, %c지금 바로 작업을 중단하고 이 창을 닫으십시오."
description3: "자세한 내용은 여기를 확인해 주세요. {link}"
_followRequest:
recieved: "받은 신청"
sent: "보낸 신청"

View file

@ -143,8 +143,8 @@ unmarkAsSensitive: "取消标记为敏感内容"
enterFileName: "输入文件名"
mute: "屏蔽"
unmute: "解除静音"
renoteMute: "隐藏转帖"
renoteUnmute: "解除隐藏转帖"
renoteMute: "屏蔽转帖"
renoteUnmute: "解除屏蔽转帖"
block: "拉黑"
unblock: "取消拉黑"
suspend: "冻结"
@ -213,7 +213,7 @@ charts: "图表"
perHour: "每小时"
perDay: "每天"
stopActivityDelivery: "停止发送活动"
blockThisInstance: "屏蔽此服务器"
blockThisInstance: "封锁此服务器"
silenceThisInstance: "静音此服务器"
mediaSilenceThisInstance: "隐藏此服务器的媒体文件"
operations: "操作"
@ -233,17 +233,17 @@ clearQueueConfirmTitle: "确定清除队列?"
clearQueueConfirmText: "未送达的帖子将不会被投递。 通常无需执行此操作。"
clearCachedFiles: "清除缓存"
clearCachedFilesConfirm: "确定要清除所有缓存的远程文件?"
blockedInstances: "被屏蔽的服务器"
blockedInstancesDescription: "设定要屏蔽的服务器,以换行分隔。被屏蔽的服务器将无法与本服务器进行交换通讯。子域名也同样会被屏蔽。"
blockedInstances: "被封锁的服务器"
blockedInstancesDescription: "设定要封锁的服务器,以换行分隔。被封锁的服务器将无法与本服务器进行交换通讯。子域名也同样会被封锁。"
silencedInstances: "被静音的服务器"
silencedInstancesDescription: "设置要静音的服务器,以换行分隔。被静音的服务器内所有的账户将默认处于「静音」状态,仅能发送关注请求,并且在未关注状态下无法提及本地账户。被阻止的实例不受影响。"
mediaSilencedInstances: "已隐藏媒体文件的服务器"
mediaSilencedInstancesDescription: "设置要隐藏媒体文件的服务器,以换行分隔。被设置为隐藏媒体文件服务器内所有账号的文件均按照「敏感内容」处理,且将无法使用自定义表情符号。被阻止的实例不受影响。"
federationAllowedHosts: "允许联合的服务器"
federationAllowedHostsDescription: "设定允许联合的服务器,以换行分隔。"
muteAndBlock: "隐藏和屏蔽"
mutedUsers: "已隐藏用户"
blockedUsers: "已屏蔽的用户"
muteAndBlock: "静音/拉黑"
mutedUsers: "已静音用户"
blockedUsers: "已拉黑的用户"
noUsers: "无用户"
editProfile: "编辑资料"
noteDeleteConfirm: "要删除该帖子吗?"
@ -683,11 +683,11 @@ emptyToDisableSmtpAuth: "用户名和密码留空可以禁用 SMTP 验证"
smtpSecure: "在 SMTP 连接中使用隐式 SSL / TLS"
smtpSecureInfo: "使用 STARTTLS 时关闭。"
testEmail: "邮件发送测试"
wordMute: "隐藏文字"
wordMute: "文字屏蔽"
hardWordMute: "屏蔽关键词"
regexpError: "正则表达式错误"
regexpErrorDescription: "{tab} 屏蔽文字的第 {line} 行的正则表达式有错误:"
instanceMute: "已隐藏的服务器"
instanceMute: "被屏蔽的服务器"
userSaysSomething: "{name} 说了什么,但是被屏蔽词过滤了"
makeActive: "启用"
display: "显示"
@ -915,8 +915,8 @@ manageAccounts: "管理账户"
makeReactionsPublic: "将回应设置为公开"
makeReactionsPublicDescription: "将您发表过的回应设置成公开可见。"
classic: "经典"
muteThread: "隐藏帖子列表"
unmuteThread: "取消隐藏帖子列表"
muteThread: "屏蔽帖子列表"
unmuteThread: "取消屏蔽帖子列表"
followingVisibility: "关注的人的公开范围"
followersVisibility: "关注者的公开范围"
continueThread: "查看更多帖子"
@ -939,7 +939,7 @@ searchByGoogle: "Google"
instanceDefaultLightTheme: "服务器默认浅色主题"
instanceDefaultDarkTheme: "服务器默认深色主题"
instanceDefaultThemeDescription: "以对象格式输入主题代码"
mutePeriod: "隐藏期限"
mutePeriod: "屏蔽期限"
period: "截止时间"
indefinitely: "永久"
tenMinutes: "10 分钟"
@ -1707,9 +1707,9 @@ _achievements:
description: "在元旦登入"
flavor: "今年也请对本服务器多多指教!"
_cookieClicked:
title: "饼干点点乐"
title: "点击饼干小游戏"
description: "点击了饼干"
flavor: "穿越了?"
flavor: "用错软件了?"
_brainDiver:
title: "Brain Diver"
description: "发布了包含 Brain Diver 链接的帖子"
@ -1779,7 +1779,7 @@ _role:
canUpdateBioMedia: "可以更新头像和横幅"
pinMax: "帖子置顶数量限制"
antennaMax: "可创建的最大天线数量"
wordMuteMax: "隐藏词的字数限制"
wordMuteMax: "屏蔽词的字数限制"
webhookMax: "Webhook 创建数量限制"
clipMax: "便签创建数量限制"
noteEachClipsMax: "单个便签内的贴文数量限制"
@ -1792,7 +1792,7 @@ _role:
canUseTranslator: "使用翻译功能"
avatarDecorationLimit: "可添加头像挂件的最大个数"
canImportAntennas: "允许导入天线"
canImportBlocking: "允许导入屏蔽列表"
canImportBlocking: "允许导入拉黑列表"
canImportFollowing: "允许导入关注列表"
canImportMuting: "允许导入屏蔽列表"
canImportUserLists: "允许导入用户列表"
@ -1942,14 +1942,14 @@ _menuDisplay:
top: "顶部"
hide: "隐藏"
_wordMute:
muteWords: "要隐藏的词"
muteWords: "禁用词"
muteWordsDescription: "AND 条件用空格分隔OR 条件用换行符分隔。"
muteWordsDescription2: "正则表达式用斜线包裹"
_instanceMute:
instanceMuteDescription: "隐藏服务器中的所有帖子和转帖,包括这些服务器上的用户回复。"
instanceMuteDescription: "屏蔽服务器中的所有帖子和转帖,包括这些服务器上的用户回复。"
instanceMuteDescription2: "一行一个"
title: "隐藏服务器已设置的帖子。"
heading: "已隐藏的服务器"
heading: "屏蔽服务器"
_theme:
explore: "寻找主题"
install: "安装主题"
@ -2089,8 +2089,8 @@ _2fa:
_permissions:
"read:account": "查看账户信息"
"write:account": "更改帐户信息"
"read:blocks": "查看屏蔽列表"
"write:blocks": "编辑屏蔽列表"
"read:blocks": "查看黑名单"
"write:blocks": "编辑黑名单"
"read:drive": "查看网盘"
"write:drive": "管理网盘文件"
"read:favorites": "查看收藏夹"
@ -2099,8 +2099,8 @@ _permissions:
"write:following": "关注/取消关注"
"read:messaging": "查看消息"
"write:messaging": "撰写或删除消息"
"read:mutes": "查看隐藏列表"
"write:mutes": "编辑隐藏列表"
"read:mutes": "查看屏蔽列表"
"write:mutes": "编辑屏蔽列表"
"write:notes": "撰写或删除帖子"
"read:notifications": "查看通知"
"write:notifications": "管理通知"
@ -2300,8 +2300,8 @@ _exportOrImport:
favoritedNotes: "收藏的帖子"
clips: "便签"
followingList: "关注中"
muteList: "隐藏"
blockingList: "屏蔽"
muteList: "屏蔽"
blockingList: "拉黑"
userLists: "列表"
excludeMutingUsers: "排除屏蔽用户"
excludeInactiveUsers: "排除不活跃用户"

View file

@ -586,7 +586,6 @@ masterVolume: "主音量"
notUseSound: "關閉音效"
useSoundOnlyWhenActive: "瀏覽器在前景運作時Misskey 才會發出音效"
details: "詳細資訊"
renoteDetails: "轉發貼文的細節"
chooseEmoji: "選擇您的表情符號"
unableToProcess: "操作無法完成"
recentUsed: "最近使用"

View file

@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2024.11.1-yumechinokuni.0",
"version": "2024.11.0-yumechinokuni.5",
"codename": "nasubi",
"repository": {
"type": "git",
@ -51,18 +51,18 @@
"lodash": "4.17.21"
},
"dependencies": {
"cross-env": "7.0.3",
"cssnano": "6.1.2",
"esbuild": "0.24.0",
"execa": "9.5.1",
"fast-glob": "3.3.2",
"glob": "11.0.0",
"ignore-walk": "6.0.5",
"js-yaml": "4.1.0",
"postcss": "8.4.49",
"tar": "6.2.1",
"terser": "5.36.0",
"typescript": "5.6.3"
"typescript": "5.6.3",
"esbuild": "0.24.0",
"glob": "11.0.0",
"cross-env": "7.0.3"
},
"devDependencies": {
"@misskey-dev/eslint-plugin": "2.0.3",
@ -74,5 +74,8 @@
"globals": "15.12.0",
"ncp": "2.0.0",
"start-server-and-test": "2.0.8"
},
"optionalDependencies": {
"@tensorflow/tfjs-core": "4.4.0"
}
}

View file

@ -48,6 +48,8 @@
"@swc/core-win32-arm64-msvc": "1.3.56",
"@swc/core-win32-ia32-msvc": "1.3.56",
"@swc/core-win32-x64-msvc": "1.3.56",
"@tensorflow/tfjs": "4.4.0",
"@tensorflow/tfjs-node": "4.4.0",
"bufferutil": "4.0.7",
"slacc-android-arm-eabi": "0.0.10",
"slacc-android-arm64": "0.0.10",
@ -144,6 +146,7 @@
"nested-property": "4.0.0",
"node-fetch": "3.3.2",
"nodemailer": "6.9.16",
"nsfwjs": "2.4.2",
"oauth": "0.10.0",
"oauth2orize": "1.12.0",
"oauth2orize-pkce": "0.1.2",

View file

@ -0,0 +1,72 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* 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 { bindThis } from '@/decorators.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const REQUIRED_CPU_FLAGS = ['avx2', 'fma'];
let isSupportedCpu: undefined | boolean = undefined;
@Injectable()
export class AiService {
private model: nsfw.NSFWJS;
private modelLoadMutex: Mutex = new Mutex();
constructor(
) {
}
@bindThis
public async detectSensitive(path: string): Promise<nsfw.predictionType[] | null> {
try {
if (isSupportedCpu === undefined) {
const cpuFlags = await this.getCpuFlags();
isSupportedCpu = REQUIRED_CPU_FLAGS.every(required => cpuFlags.includes(required));
}
if (!isSupportedCpu) {
console.error('This CPU cannot use TensorFlow.');
return null;
}
const tf = await import('@tensorflow/tfjs-node');
if (this.model == null) {
await this.modelLoadMutex.runExclusive(async () => {
if (this.model == null) {
this.model = await nsfw.load(`file://${_dirname}/../../nsfw-model/`, { size: 299 });
}
});
}
const buffer = await fs.promises.readFile(path);
const image = await tf.node.decodeImage(buffer, 3) as any;
try {
const predictions = await this.model.classify(image);
return predictions;
} finally {
image.dispose();
}
} catch (err) {
console.error(err);
return null;
}
}
@bindThis
private async getCpuFlags(): Promise<string[]> {
const str = await si.cpuFlags();
return str.split(/\s+/);
}
}

View file

@ -17,6 +17,7 @@ import { WebhookTestService } from '@/core/WebhookTestService.js';
import { FlashService } from '@/core/FlashService.js';
import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js';
import { AiService } from './AiService.js';
import { AnnouncementService } from './AnnouncementService.js';
import { AntennaService } from './AntennaService.js';
import { AppLockService } from './AppLockService.js';
@ -35,7 +36,7 @@ import { GlobalEventService } from './GlobalEventService.js';
import { HashtagService } from './HashtagService.js';
import { HttpRequestService } from './HttpRequestService.js';
import { IdService } from './IdService.js';
import { __YUME_PRIVATE_ImageProcessingService } from './ImageProcessingService.js';
import { ImageProcessingService } from './ImageProcessingService.js';
import { InstanceActorService } from './InstanceActorService.js';
import { InternalStorageService } from './InternalStorageService.js';
import { MetaService } from './MetaService.js';
@ -66,7 +67,7 @@ import { UserMutingService } from './UserMutingService.js';
import { UserRenoteMutingService } from './UserRenoteMutingService.js';
import { UserSuspendService } from './UserSuspendService.js';
import { UserAuthService } from './UserAuthService.js';
import { __YUME_PRIVATE_VideoProcessingService } from './VideoProcessingService.js';
import { VideoProcessingService } from './VideoProcessingService.js';
import { UserWebhookService } from './UserWebhookService.js';
import { ProxyAccountService } from './ProxyAccountService.js';
import { UtilityService } from './UtilityService.js';
@ -159,6 +160,7 @@ const $AbuseReportService: Provider = { provide: 'AbuseReportService', useExisti
const $AbuseReportNotificationService: Provider = { provide: 'AbuseReportNotificationService', useExisting: AbuseReportNotificationService };
const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService };
const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService };
const $AiService: Provider = { provide: 'AiService', useExisting: AiService };
const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExisting: AnnouncementService };
const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService };
const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService };
@ -177,7 +179,7 @@ const $GlobalEventService: Provider = { provide: 'GlobalEventService', useExisti
const $HashtagService: Provider = { provide: 'HashtagService', useExisting: HashtagService };
const $HttpRequestService: Provider = { provide: 'HttpRequestService', useExisting: HttpRequestService };
const $IdService: Provider = { provide: 'IdService', useExisting: IdService };
const $ImageProcessingService: Provider = { provide: '__YUME_PRIVATE_ImageProcessingService', useExisting: __YUME_PRIVATE_ImageProcessingService };
const $ImageProcessingService: Provider = { provide: 'ImageProcessingService', useExisting: ImageProcessingService };
const $InstanceActorService: Provider = { provide: 'InstanceActorService', useExisting: InstanceActorService };
const $InternalStorageService: Provider = { provide: 'InternalStorageService', useExisting: InternalStorageService };
const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaService };
@ -210,7 +212,7 @@ const $UserRenoteMutingService: Provider = { provide: 'UserRenoteMutingService',
const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService };
const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService };
const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService };
const $VideoProcessingService: Provider = { provide: '__YUME_PRIVATE_VideoProcessingService', useExisting: __YUME_PRIVATE_VideoProcessingService };
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService };
const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService };
const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService };
const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService };
@ -309,6 +311,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AbuseReportNotificationService,
AccountMoveService,
AccountUpdateService,
AiService,
AnnouncementService,
AntennaService,
AppLockService,
@ -327,7 +330,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
HashtagService,
HttpRequestService,
IdService,
__YUME_PRIVATE_ImageProcessingService,
ImageProcessingService,
InstanceActorService,
InternalStorageService,
MetaService,
@ -360,7 +363,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
UserSearchService,
UserSuspendService,
UserAuthService,
__YUME_PRIVATE_VideoProcessingService,
VideoProcessingService,
UserWebhookService,
SystemWebhookService,
WebhookTestService,
@ -455,6 +458,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AbuseReportNotificationService,
$AccountMoveService,
$AccountUpdateService,
$AiService,
$AnnouncementService,
$AntennaService,
$AppLockService,
@ -602,6 +606,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AbuseReportNotificationService,
AccountMoveService,
AccountUpdateService,
AiService,
AnnouncementService,
AntennaService,
AppLockService,
@ -620,7 +625,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
HashtagService,
HttpRequestService,
IdService,
__YUME_PRIVATE_ImageProcessingService,
ImageProcessingService,
InstanceActorService,
InternalStorageService,
MetaService,
@ -653,7 +658,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
UserSearchService,
UserSuspendService,
UserAuthService,
__YUME_PRIVATE_VideoProcessingService,
VideoProcessingService,
UserWebhookService,
SystemWebhookService,
WebhookTestService,
@ -747,6 +752,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AbuseReportNotificationService,
$AccountMoveService,
$AccountUpdateService,
$AiService,
$AnnouncementService,
$AntennaService,
$AppLockService,

View file

@ -47,10 +47,6 @@ export class DeleteAccountService {
});
}
if (!(await this.usersRepository.update({ id: user.id, isDeleted: false }, { isDeleted: true })).affected) {
return;
}
// 物理削除する前にDelete activityを送信する
if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信

View file

@ -6,6 +6,7 @@
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';
@ -44,14 +45,6 @@ export class DownloadService {
const maxSize = this.config.maxFileSize;
const urlObj = new URL(url);
if (urlObj.protocol && urlObj.protocol !== 'https:') {
throw new Error(`Unsupported protocol: ${urlObj.protocol}, only HTTPS is supported`);
}
urlObj.protocol = 'https:';
if (urlObj.port && urlObj.port !== '443') {
throw new Error(`Unsupported port: ${urlObj.port}, only 443 is supported`);
}
let filename = urlObj.pathname.split('/').pop() ?? 'untitled';
const req = got.stream(url, {
@ -68,6 +61,7 @@ export class DownloadService {
request: operationTimeout, // whole operation timeout
},
agent: {
http: this.httpRequestService.httpAgent,
https: this.httpRequestService.httpsAgent,
},
http2: false, // default
@ -76,6 +70,13 @@ 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);
@ -138,4 +139,18 @@ 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';
}
}

View file

@ -22,8 +22,8 @@ import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { contentDisposition } from '@/misc/content-disposition.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { __YUME_PRIVATE_VideoProcessingService } from '@/core/VideoProcessingService.js';
import { __YUME_PRIVATE_ImageProcessingService } from '@/core/ImageProcessingService.js';
import { VideoProcessingService } from '@/core/VideoProcessingService.js';
import { ImageProcessingService } from '@/core/ImageProcessingService.js';
import type { IImage } from '@/core/ImageProcessingService.js';
import { QueueService } from '@/core/QueueService.js';
import type { MiDriveFolder } from '@/models/DriveFolder.js';
@ -120,8 +120,8 @@ export class DriveService {
private downloadService: DownloadService,
private internalStorageService: InternalStorageService,
private s3Service: S3Service,
private privateImageProcessingService: __YUME_PRIVATE_ImageProcessingService,
private privateVideoProcessingService: __YUME_PRIVATE_VideoProcessingService,
private imageProcessingService: ImageProcessingService,
private videoProcessingService: VideoProcessingService,
private globalEventService: GlobalEventService,
private queueService: QueueService,
private roleService: RoleService,
@ -277,7 +277,7 @@ export class DriveService {
}
try {
const thumbnail = await this.privateVideoProcessingService.generateVideoThumbnail(path);
const thumbnail = await this.videoProcessingService.generateVideoThumbnail(path);
return {
webpublic: null,
thumbnail,
@ -331,9 +331,9 @@ export class DriveService {
try {
if (['image/jpeg', 'image/webp', 'image/avif'].includes(type)) {
webpublic = await this.privateImageProcessingService.convertSharpToWebp(img, 2048, 2048);
webpublic = await this.imageProcessingService.convertSharpToWebp(img, 2048, 2048);
} else if (['image/png', 'image/bmp', 'image/svg+xml'].includes(type)) {
webpublic = await this.privateImageProcessingService.convertSharpToPng(img, 2048, 2048);
webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048);
} else {
this.registerLogger.debug('web image not created (not an required image)');
}
@ -352,9 +352,9 @@ export class DriveService {
try {
if (isAnimated) {
thumbnail = await this.privateImageProcessingService.convertSharpToWebp(sharp(path, { animated: true }), 374, 317, { alphaQuality: 70 });
thumbnail = await this.imageProcessingService.convertSharpToWebp(sharp(path, { animated: true }), 374, 317, { alphaQuality: 70 });
} else {
thumbnail = await this.privateImageProcessingService.convertSharpToWebp(img, 498, 422);
thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 422);
}
} catch (err) {
this.registerLogger.warn('thumbnail not created (an error occurred)', err as Error);

View file

@ -313,7 +313,6 @@ export class EmailService {
Accept: 'application/json',
Authorization: truemailAuthKey,
},
isLocalAddressAllowed: true,
});
const json = (await res.json()) as {

View file

@ -13,8 +13,11 @@ 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 { sharpBmp } from '@misskey-dev/sharp-read-bmp';
import * as blurhash from 'blurhash';
import { createTempDir } from '@/misc/create-temp.js';
import { AiService } from '@/core/AiService.js';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
@ -50,6 +53,7 @@ export class FileInfoService {
private logger: Logger;
constructor(
private aiService: AiService,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('file-info');
@ -163,7 +167,102 @@ export class FileInfoService {
@bindThis
private async detectSensitivity(source: string, mime: string, sensitiveThreshold: number, sensitiveThresholdForPorn: number, analyzeVideo: boolean): Promise<[sensitive: boolean, porn: boolean]> {
return [false, false];
let sensitive = false;
let porn = false;
function judgePrediction(result: readonly predictionType[]): [sensitive: boolean, porn: boolean] {
let sensitive = false;
let porn = false;
if ((result.find(x => x.className === 'Sexy')?.probability ?? 0) > sensitiveThreshold) sensitive = true;
if ((result.find(x => x.className === 'Hentai')?.probability ?? 0) > sensitiveThreshold) sensitive = true;
if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThreshold) sensitive = true;
if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThresholdForPorn) porn = true;
return [sensitive, porn];
}
if ([
'image/jpeg',
'image/png',
'image/webp',
].includes(mime)) {
const result = await this.aiService.detectSensitive(source);
if (result) {
[sensitive, porn] = judgePrediction(result);
}
} else if (analyzeVideo && (mime === 'image/apng' || mime.startsWith('video/'))) {
const [outDir, disposeOutDir] = await createTempDir();
try {
const command = FFmpeg()
.input(source)
.inputOptions([
'-skip_frame', 'nokey', // 可能ならキーフレームのみを取得してほしいとする(そうなるとは限らない)
'-lowres', '3', // 元の画質でデコードする必要はないので 1/8 画質でデコードしてもよいとする(そうなるとは限らない)
])
.noAudio()
.videoFilters([
{
filter: 'select', // フレームのフィルタリング
options: {
e: 'eq(pict_type,PICT_TYPE_I)', // I-Frame のみをフィルタするVP9 とかはデコードしてみないとわからないっぽい)
},
},
{
filter: 'blackframe', // 暗いフレームの検出
options: {
amount: '0', // 暗さに関わらず全てのフレームで測定値を取る
},
},
{
filter: 'metadata',
options: {
mode: 'select', // フレーム選択モード
key: 'lavfi.blackframe.pblack', // フレームにおける暗部の百分率(前のフィルタからのメタデータを参照する)
value: '50',
function: 'less', // 50% 未満のフレームを選択する50% 以上暗部があるフレームだと誤検知を招くかもしれないので)
},
},
{
filter: 'scale',
options: {
w: 299,
h: 299,
},
},
])
.format('image2')
.output(join(outDir, '%d.png'))
.outputOptions(['-vsync', '0']); // 可変フレームレートにすることで穴埋めをさせない
const results: ReturnType<typeof judgePrediction>[] = [];
let frameIndex = 0;
let targetIndex = 0;
let nextIndex = 1;
for await (const path of this.asyncIterateFrames(outDir, command)) {
try {
const index = frameIndex++;
if (index !== targetIndex) {
continue;
}
targetIndex = nextIndex;
nextIndex += index; // fibonacci sequence によってフレーム数制限を掛ける
const result = await this.aiService.detectSensitive(path);
if (result) {
results.push(judgePrediction(result));
}
} finally {
fs.promises.unlink(path);
}
}
sensitive = results.filter(x => x[0]).length >= Math.ceil(results.length * sensitiveThreshold);
porn = results.filter(x => x[1]).length >= Math.ceil(results.length * sensitiveThresholdForPorn);
} finally {
disposeOutDir();
}
}
return [sensitive, porn];
}
private async *asyncIterateFrames(cwd: string, command: FFmpeg.FfmpegCommand): AsyncGenerator<string, void> {

View file

@ -6,10 +6,9 @@
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 { HttpsProxyAgent } from 'hpagent';
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@ -19,105 +18,30 @@ import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/val
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 { URL } from 'node:url';
import type { URL } from 'node:url';
export type HttpRequestSendOptions = {
throwErrorWhenResponseNotOk: boolean;
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
*/
private httpsNative: https.Agent;
private http: http.Agent;
/**
* Get https non-proxy agent
*/
private https: https.Agent;
/**
* Get http proxy or non-proxy agent
*/
public httpAgent: http.Agent;
/**
* Get https proxy or non-proxy agent
*/
@ -133,20 +57,34 @@ export class HttpRequestService {
lookup: false, // nativeのdns.lookupにfallbackしない
});
const agentOption = {
this.http = new http.Agent({
keepAlive: true,
keepAliveMsecs: 30 * 1000,
lookup: cache.lookup as unknown as net.LookupFunction,
localAddress: config.outgoingAddress,
minVersion: 'TLSv1.2' as const,
};
});
this.httpsNative = new https.Agent(agentOption);
this.https = new HttpsRequestServiceAgent(config, agentOption);
this.https = new https.Agent({
keepAlive: true,
keepAliveMsecs: 30 * 1000,
lookup: cache.lookup as unknown as net.LookupFunction,
localAddress: config.outgoingAddress,
});
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
this.httpAgent = config.proxy
? new HttpProxyAgent({
keepAlive: true,
keepAliveMsecs: 30 * 1000,
maxSockets,
maxFreeSockets: 256,
scheduling: 'lifo',
proxy: config.proxy,
localAddress: config.outgoingAddress,
})
: this.http;
this.httpsAgent = config.proxy
? new HttpsProxyAgent({
keepAlive: true,
@ -166,23 +104,16 @@ export class HttpRequestService {
* @param bypassProxy Allways bypass proxy
*/
@bindThis
public getAgentByUrl(url: URL, bypassProxy = false): https.Agent {
if (url.protocol && url.protocol !== 'https:') {
throw new Error('Invalid protocol');
}
url.protocol = 'https:';
if (url.port && url.port !== '443') {
throw new Error('Invalid port');
}
public getAgentByUrl(url: URL, bypassProxy = false): http.Agent | https.Agent {
if (bypassProxy || (this.config.proxyBypassHosts ?? []).includes(url.hostname)) {
return this.https;
return url.protocol === 'http:' ? this.http : this.https;
} else {
return this.httpsAgent;
return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent;
}
}
@bindThis
public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise<IObject> {
public async getActivityJson(url: string): Promise<IObject> {
const res = await this.send(url, {
method: 'GET',
headers: {
@ -190,7 +121,6 @@ export class HttpRequestService {
},
timeout: 5000,
size: 1024 * 256,
isLocalAddressAllowed: isLocalAddressAllowed,
}, {
throwErrorWhenResponseNotOk: true,
validators: [validateContentTypeSetAsActivityPub],
@ -199,13 +129,13 @@ export class HttpRequestService {
const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject;
assertActivityMatchesUrls(activity, [finalUrl]);
assertActivityMatchesUrls(activity, [url, finalUrl]);
return activity;
}
@bindThis
public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>, isLocalAddressAllowed = false): Promise<T> {
public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>): Promise<T> {
const res = await this.send(url, {
method: 'GET',
headers: Object.assign({
@ -213,21 +143,19 @@ export class HttpRequestService {
}, headers ?? {}),
timeout: 5000,
size: 1024 * 256,
isLocalAddressAllowed: isLocalAddressAllowed,
});
return await res.json() as T;
}
@bindThis
public async getHtml(url: string, accept = 'text/html, */*', headers?: Record<string, string>, isLocalAddressAllowed = false): Promise<string> {
public async getHtml(url: string, accept = 'text/html, */*', headers?: Record<string, string>): Promise<string> {
const res = await this.send(url, {
method: 'GET',
headers: Object.assign({
Accept: accept,
}, headers ?? {}),
timeout: 5000,
isLocalAddressAllowed: isLocalAddressAllowed,
});
return await res.text();
@ -242,7 +170,6 @@ export class HttpRequestService {
headers?: Record<string, string>,
timeout?: number,
size?: number,
isLocalAddressAllowed?: boolean,
} = {},
extra: HttpRequestSendOptions = {
throwErrorWhenResponseNotOk: true,
@ -256,16 +183,6 @@ export class HttpRequestService {
controller.abort();
}, timeout);
const urlParsed = new URL(url);
if (urlParsed.protocol !== 'https:') {
throw new Error('Invalid protocol');
}
if (urlParsed.port && urlParsed.port !== '443') {
throw new Error('Invalid port');
}
const res = await fetch(url, {
method: args.method ?? 'GET',
headers: {
@ -274,7 +191,7 @@ export class HttpRequestService {
},
body: args.body,
size: args.size ?? 10 * 1024 * 1024,
agent: (url) => this.getAgentByUrl(url, false),
agent: (url) => this.getAgentByUrl(url),
signal: controller.signal,
});

View file

@ -46,9 +46,7 @@ import { bindThis } from '@/decorators.js';
import { Readable } from 'node:stream';
@Injectable()
// Prevent accidental import by upstream merge
// eslint-disable-next-line
export class __YUME_PRIVATE_ImageProcessingService {
export class ImageProcessingService {
constructor(
) {
}

View file

@ -7,7 +7,6 @@ import { setImmediate } from 'node:timers/promises';
import * as mfm from 'mfm-js';
import { In, DataSource, IsNull, LessThan } from 'typeorm';
import * as Redis from 'ioredis';
import * as Bull from 'bullmq';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { extractMentions } from '@/misc/extract-mentions.js';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
@ -57,7 +56,6 @@ import { isReply } from '@/misc/is-reply.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
import { CacheService } from '@/core/CacheService.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -219,7 +217,6 @@ export class NoteCreateService implements OnApplicationShutdown {
private instanceChart: InstanceChart,
private utilityService: UtilityService,
private userBlockingService: UserBlockingService,
private cacheService: CacheService,
) {
this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount);
}
@ -294,7 +291,7 @@ export class NoteCreateService implements OnApplicationShutdown {
case 'followers':
// 他人のfollowers noteはreject
if (data.renote.userId !== user.id) {
throw new Bull.UnrecoverableError('Renote target is not public or home');
throw new Error('Renote target is not public or home');
}
// Renote対象がfollowersならfollowersにする
@ -302,7 +299,7 @@ export class NoteCreateService implements OnApplicationShutdown {
break;
case 'specified':
// specified / direct noteはreject
throw new Bull.UnrecoverableError('Renote target is not public or home');
throw new Error('Renote target is not public or home');
}
}
@ -546,21 +543,13 @@ export class NoteCreateService implements OnApplicationShutdown {
this.followingsRepository.findBy({
followeeId: user.id,
notify: 'normal',
}).then(async followings => {
}).then(followings => {
if (note.visibility !== 'specified') {
const isPureRenote = this.isRenote(data) && !this.isQuote(data) ? true : false;
for (const following of followings) {
// TODO: ワードミュート考慮
let isRenoteMuted = false;
if (isPureRenote) {
const userIdsWhoMeMutingRenotes = await this.cacheService.renoteMutingsCache.fetch(following.followerId);
isRenoteMuted = userIdsWhoMeMutingRenotes.has(user.id);
}
if (!isRenoteMuted) {
this.notificationService.createNotification(following.followerId, 'note', {
noteId: note.id,
}, user.id);
}
this.notificationService.createNotification(following.followerId, 'note', {
noteId: note.id,
}, user.id);
}
}
});

View file

@ -169,10 +169,6 @@ export class ReactionService {
}
}
if (/['\\]/.test(reaction)) {
throw new IdentifiableError('cf61d38c-598a-49e2-b75a-6c38671fcc43', 'Invalid reaction.');
}
const record: MiNoteReaction = {
id: this.idService.gen(),
noteId: note.id,

View file

@ -18,7 +18,6 @@ import { RemoteLoggerService } from '@/core/RemoteLoggerService.js';
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { bindThis } from '@/decorators.js';
import { ApResolverService } from './activitypub/ApResolverService.js';
@Injectable()
export class RemoteUserResolveService {
@ -36,7 +35,6 @@ export class RemoteUserResolveService {
private remoteLoggerService: RemoteLoggerService,
private apDbResolverService: ApDbResolverService,
private apPersonService: ApPersonService,
private apResolverService: ApResolverService,
) {
this.logger = this.remoteLoggerService.logger.createSubLogger('resolve-user');
}
@ -58,7 +56,7 @@ export class RemoteUserResolveService {
host = this.utilityService.toPuny(host);
if (host === this.utilityService.toPuny(this.config.host)) {
if (this.config.host === host) {
this.logger.info(`return local user: ${usernameLower}`);
return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => {
if (u == null) {
@ -93,7 +91,7 @@ export class RemoteUserResolveService {
}
this.logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`);
return await this.apPersonService.createPerson(self.href, this.apResolverService.createResolver());
return await this.apPersonService.createPerson(self.href);
}
// ユーザー情報が古い場合は、WebFingerからやりなおして返す

View file

@ -488,7 +488,6 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
return ids.length > 0
? await this.usersRepository.findBy({
id: In(ids),
isDeleted: false,
})
: [];
}

View file

@ -28,7 +28,7 @@ export class S3Service {
? `${meta.objectStorageUseSSL ? 'https' : 'http'}://${meta.objectStorageEndpoint}`
: `${meta.objectStorageUseSSL ? 'https' : 'http'}://example.net`; // dummy url to select http(s) agent
const agent = this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy, true);
const agent = this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy);
const handlerOption: NodeHttpHandlerOptions = {};
if (meta.objectStorageUseSSL) {
handlerOption.httpsAgent = agent as https.Agent;

View file

@ -34,11 +34,6 @@ 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;
@ -101,7 +96,7 @@ export class UtilityService {
@bindThis
public extractDbHost(uri: string): string {
const url = new URL(uri);
return this.toPuny(url.host);
return this.toPuny(url.hostname);
}
@bindThis
@ -115,13 +110,6 @@ export class UtilityService {
return 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;
}
@bindThis
public isFederationAllowedHost(host: string): boolean {
if (this.meta.federation === 'none') return false;

View file

@ -7,21 +7,19 @@ import { Inject, Injectable } from '@nestjs/common';
import FFmpeg from 'fluent-ffmpeg';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { __YUME_PRIVATE_ImageProcessingService } from '@/core/ImageProcessingService.js';
import { ImageProcessingService } from '@/core/ImageProcessingService.js';
import type { IImage } from '@/core/ImageProcessingService.js';
import { createTempDir } from '@/misc/create-temp.js';
import { bindThis } from '@/decorators.js';
import { appendQuery, query } from '@/misc/prelude/url.js';
@Injectable()
// Prevent accidental import by upstream merge
// eslint-disable-next-line
export class __YUME_PRIVATE_VideoProcessingService {
export class VideoProcessingService {
constructor(
@Inject(DI.config)
private config: Config,
private imageProcessingService: __YUME_PRIVATE_ImageProcessingService,
private imageProcessingService: ImageProcessingService,
) {
}

View file

@ -83,11 +83,7 @@ export class WebAuthnService {
}
@bindThis
public async verifyRegistration(
userId: MiUser['id'],
response: RegistrationResponseJSON,
twoFactorOnly: boolean = false,
): Promise<{
public async verifyRegistration(userId: MiUser['id'], response: RegistrationResponseJSON): Promise<{
credentialID: string;
credentialPublicKey: Uint8Array;
attestationObject: Uint8Array;
@ -115,7 +111,7 @@ export class WebAuthnService {
expectedChallenge: challenge,
expectedOrigin: relyingParty.origin,
expectedRPID: relyingParty.rpId,
requireUserVerification: !twoFactorOnly,
requireUserVerification: true,
});
} catch (error) {
console.error(error);
@ -193,12 +189,14 @@ export class WebAuthnService {
*/
@bindThis
public async verifySignInWithPasskeyAuthentication(context: string, response: AuthenticationResponseJSON): Promise<MiUser['id'] | null> {
const challenge = await this.redisClient.getdel(`webauthn:challenge:${context}`);
const challenge = await this.redisClient.get(`webauthn:challenge:${context}`);
if (!challenge) {
throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', `challenge '${context}' not found`);
}
await this.redisClient.del(`webauthn:challenge:${context}`);
const key = await this.userSecurityKeysRepository.findOneBy({
id: response.id,
});
@ -247,17 +245,15 @@ export class WebAuthnService {
}
@bindThis
public async verifyAuthentication(
userId: MiUser['id'],
response: AuthenticationResponseJSON,
twoFactorOnly: boolean = false,
): Promise<boolean> {
public async verifyAuthentication(userId: MiUser['id'], response: AuthenticationResponseJSON): Promise<boolean> {
const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`);
if (!challenge) {
throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', 'challenge not found');
}
await this.redisClient.del(`webauthn:challenge:${userId}`);
const key = await this.userSecurityKeysRepository.findOneBy({
id: response.id,
userId: userId,
@ -306,7 +302,7 @@ export class WebAuthnService {
counter: key.counter,
transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined,
},
requireUserVerification: !twoFactorOnly,
requireUserVerification: true,
});
} catch (error) {
console.error(error);

View file

@ -10,14 +10,12 @@ 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';
import { getApId } from './type.js';
import { ApPersonService } from './models/ApPersonService.js';
import type { IObject } from './type.js';
import { toASCII } from 'node:punycode';
export type UriParseResult = {
/** wether the URI was generated by us */
@ -55,7 +53,6 @@ export class ApDbResolverService implements OnApplicationShutdown {
private cacheService: CacheService,
private apPersonService: ApPersonService,
private utilityService: UtilityService,
) {
this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
@ -66,9 +63,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
const separator = '/';
const uri = new URL(getApId(value));
if (this.utilityService.toPuny(uri.host) !== this.utilityService.toPuny(this.config.host)) {
return { local: false, uri: uri.href };
}
if (uri.origin !== this.config.url) return { local: false, uri: uri.href };
const [, type, id, ...rest] = uri.pathname.split(separator);
return {

View file

@ -29,7 +29,7 @@ import { bindThis } from '@/decorators.js';
import type { MiRemoteUser } from '@/models/User.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { AbuseReportService } from '@/core/AbuseReportService.js';
import { getApHrefNullable, getApId, getApIds, getApType, isActor, isCollection, isCollectionOrOrderedCollection, isPost, isTombstone, validActor, validPost, yumeDowncastAccept, yumeDowncastAdd, yumeDowncastAnnounce, yumeDowncastBlock, yumeDowncastCreate, yumeDowncastDelete, yumeDowncastFlag, yumeDowncastFollow, yumeDowncastLike, yumeDowncastMove, yumeDowncastReject, yumeDowncastRemove, yumeDowncastUndo, yumeDowncastUpdate } from './type.js';
import { 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';
import { ApDbResolverService } from './ApDbResolverService.js';
@ -40,7 +40,6 @@ 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, IPost } from './type.js';
import { metricCounter } from '@/server/api/MetricsService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
const mInboxReceived = metricCounter({
name: 'misskey_ap_inbox_received_total',
@ -98,26 +97,15 @@ export class ApInboxService {
}
@bindThis
public async performActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver): Promise<string | void> {
public async performActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> {
let result = undefined as string | void;
if (isCollectionOrOrderedCollection(activity)) {
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 resolver = this.apResolverService.createResolver();
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) {
const act = await resolver.resolve(item);
if (act.id == null || this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) {
this.logger.debug('skipping activity: activity id is null or mismatching');
continue;
}
try {
results.push([getApId(item), await this.performOneActivity(actor, act, resolver)]);
results.push([getApId(item), await this.performOneActivity(actor, act)]);
} catch (err) {
if (err instanceof Error || typeof err === 'string') {
this.logger.error(err);
@ -132,14 +120,13 @@ export class ApInboxService {
result = results.map(([id, reason]) => `${id}: ${reason}`).join('\n');
}
} else {
result = await this.performOneActivity(actor, activity, resolver);
result = await this.performOneActivity(actor, activity);
}
// ついでにリモートユーザーの情報が古かったら更新しておく
if (actor.uri) {
if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
setImmediate(() => {
// 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない
this.apPersonService.updatePerson(actor.uri);
});
}
@ -148,95 +135,55 @@ export class ApInboxService {
}
@bindThis
public async performOneActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver): Promise<string | void> {
public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> {
if (actor.isSuspended) return;
const create = yumeDowncastCreate(activity);
if (create) {
if (isCreate(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'create' });
return await this.create(actor, create);
}
const update = yumeDowncastUpdate(activity);
if (update) {
mInboxReceived?.inc({ host: actor.host, type: 'update' });
return await this.update(actor, update);
}
const del = yumeDowncastDelete(activity);
if (del) {
return await this.create(actor, activity);
} else if (isDelete(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'delete' });
return await this.delete(actor, del);
}
const follow = yumeDowncastFollow(activity);
if (follow) {
return await this.delete(actor, activity);
} else if (isUpdate(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'update' });
return await this.update(actor, activity);
} else if (isFollow(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'follow' });
return await this.follow(actor, follow);
}
const accept = yumeDowncastAccept(activity);
if (accept) {
return await this.follow(actor, activity);
} else if (isAccept(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'accept' });
return await this.accept(actor, accept);
}
const reject = yumeDowncastReject(activity);
if (reject) {
return await this.accept(actor, activity);
} else if (isReject(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'reject' });
return await this.reject(actor, reject);
}
const add = yumeDowncastAdd(activity);
if (add) {
return await this.reject(actor, activity);
} else if (isAdd(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'add' });
return await this.add(actor, add);
}
const remove = yumeDowncastRemove(activity);
if (remove) {
return await this.add(actor, activity);
} else if (isRemove(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'remove' });
return await this.remove(actor, remove);
}
const announce = yumeDowncastAnnounce(activity);
if (announce) {
return await this.remove(actor, activity);
} else if (isAnnounce(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'announce' });
return await this.announce(actor, announce);
}
const like = yumeDowncastLike(activity);
if (like) {
return await this.announce(actor, activity);
} else if (isLike(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'like' });
return await this.like(actor, like);
}
const move = yumeDowncastMove(activity);
if (move) {
mInboxReceived?.inc({ host: actor.host, type: 'move' });
return await this.move(actor, move);
}
const undo = yumeDowncastUndo(activity);
if (undo) {
return await this.like(actor, activity);
} else if (isUndo(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'undo' });
return await this.undo(actor, undo);
}
const block = yumeDowncastBlock(activity);
if (block) {
return await this.undo(actor, activity);
} else if (isBlock(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'block' });
return await this.block(actor, block);
}
const flag = yumeDowncastFlag(activity);
if (flag) {
return await this.block(actor, activity);
} else if (isFlag(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'flag' });
return await this.flag(actor, flag);
return await this.flag(actor, activity);
} else if (isMove(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'move' });
return await this.move(actor, activity);
} else {
mInboxReceived?.inc({ host: actor.host, type: 'unknown' });
return `unrecognized activity type: ${activity.type}`;
}
mInboxReceived?.inc({ host: actor.host, type: 'unknown' });
return `unrecognized activity type: ${activity.type}`;
}
@bindThis
@ -265,34 +212,29 @@ export class ApInboxService {
await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null);
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 await this.reactionService.create(actor, note, activity._misskey_reaction ?? activity.content ?? activity.name).catch(err => {
if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') {
return 'skip: already reacted';
} else {
throw err;
}
}
}).then(() => 'ok');
}
@bindThis
private async accept(actor: MiRemoteUser, activity: IAccept, resolver?: Resolver): Promise<string> {
private async accept(actor: MiRemoteUser, activity: IAccept): Promise<string> {
const uri = activity.id ?? activity;
this.logger.info(`Accept: ${uri}`);
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(err => {
this.logger.error(`Resolution failed: ${err}`);
throw err;
});
const follow = yumeDowncastFollow(object);
if (follow) return await this.acceptFollow(actor, follow);
if (isFollow(object)) return await this.acceptFollow(actor, object);
return `skip: Unknown Accept type: ${getApType(object)}`;
}
@ -322,7 +264,7 @@ export class ApInboxService {
}
@bindThis
private async add(actor: MiRemoteUser, activity: IAdd, resolver?: Resolver): Promise<string | void> {
private async add(actor: MiRemoteUser, activity: IAdd): Promise<string | void> {
if (actor.uri !== activity.actor) {
return 'invalid actor';
}
@ -332,7 +274,7 @@ export class ApInboxService {
}
if (activity.target === actor.featured) {
const note = await this.apNoteService.resolveNote(activity.object, { resolver });
const note = await this.apNoteService.resolveNote(activity.object);
if (note == null) return 'note not found';
await this.notePiningService.addPinned(actor, note.id);
return;
@ -342,13 +284,12 @@ export class ApInboxService {
}
@bindThis
private async announce(actor: MiRemoteUser, activity: IAnnounce, resolver?: Resolver): Promise<string | void> {
private async announce(actor: MiRemoteUser, activity: IAnnounce): Promise<string | void> {
const uri = getApId(activity);
this.logger.info(`Announce: ${uri}`);
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const resolver = this.apResolverService.createResolver();
if (!activity.object) return 'skip: activity has no object property';
const targetUri = getApId(activity.object);
@ -356,7 +297,7 @@ export class ApInboxService {
const target = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
throw e;
return e;
});
if (isPost(target)) return await this.announceNote(actor, activity, target);
@ -365,7 +306,7 @@ export class ApInboxService {
}
@bindThis
private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost, resolver?: Resolver): Promise<string | void> {
private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost): Promise<string | void> {
const uri = getApId(activity);
if (actor.isSuspended) {
@ -387,7 +328,7 @@ export class ApInboxService {
// Announce対象をresolve
let renote;
try {
renote = await this.apNoteService.resolveNote(target, { resolver });
renote = await this.apNoteService.resolveNote(target);
if (renote == null) return 'announce target is null';
} catch (err) {
// 対象が4xxならスキップ
@ -406,7 +347,7 @@ export class ApInboxService {
this.logger.info(`Creating the (Re)Note: ${uri}`);
const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc, resolver);
const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc);
const createdAt = activity.published ? new Date(activity.published) : null;
if (createdAt && createdAt < this.idService.parse(renote.id).date) {
@ -444,7 +385,7 @@ export class ApInboxService {
}
@bindThis
private async create(actor: MiRemoteUser, activity: ICreate, resolver?: Resolver): Promise<string | void> {
private async create(actor: MiRemoteUser, activity: ICreate): Promise<string | void> {
const uri = getApId(activity);
this.logger.info(`Create: ${uri}`);
@ -469,8 +410,7 @@ export class ApInboxService {
activity.object.attributedTo = activity.actor;
}
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
@ -497,8 +437,6 @@ 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';
}
}
@ -508,7 +446,7 @@ export class ApInboxService {
const exist = await this.apNoteService.fetchNote(note);
if (exist) return 'skip: note exists';
await this.apNoteService.createNote(note, actor, resolver, silent);
await this.apNoteService.createNote(note, resolver, silent);
return 'ok';
} catch (err) {
if (err instanceof StatusError && !err.isRetryable) {
@ -633,21 +571,19 @@ export class ApInboxService {
}
@bindThis
private async reject(actor: MiRemoteUser, activity: IReject, resolver?: Resolver): Promise<string> {
private async reject(actor: MiRemoteUser, activity: IReject): Promise<string> {
const uri = activity.id ?? activity;
this.logger.info(`Reject: ${uri}`);
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
throw e;
});
const follow = yumeDowncastFollow(object);
if (follow) return await this.rejectFollow(actor, follow);
if (isFollow(object)) return await this.rejectFollow(actor, object);
return `skip: Unknown Reject type: ${getApType(object)}`;
}
@ -677,7 +613,7 @@ export class ApInboxService {
}
@bindThis
private async remove(actor: MiRemoteUser, activity: IRemove, resolver?: Resolver): Promise<string | void> {
private async remove(actor: MiRemoteUser, activity: IRemove): Promise<string | void> {
if (actor.uri !== activity.actor) {
return 'invalid actor';
}
@ -687,7 +623,7 @@ export class ApInboxService {
}
if (activity.target === actor.featured) {
const note = await this.apNoteService.resolveNote(activity.object, { resolver });
const note = await this.apNoteService.resolveNote(activity.object);
if (note == null) return 'note not found';
await this.notePiningService.removePinned(actor, note.id);
return;
@ -697,7 +633,7 @@ export class ApInboxService {
}
@bindThis
private async undo(actor: MiRemoteUser, activity: IUndo, resolver?: Resolver): Promise<string> {
private async undo(actor: MiRemoteUser, activity: IUndo): Promise<string> {
if (actor.uri !== activity.actor) {
return 'invalid actor';
}
@ -706,29 +642,19 @@ export class ApInboxService {
this.logger.info(`Undo: ${uri}`);
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
throw e;
return e;
});
// don't queue because the sender may attempt again when timeout
const follow = yumeDowncastFollow(object);
if (follow) return await this.undoFollow(actor, follow);
const block = yumeDowncastBlock(object);
if (block) return await this.undoBlock(actor, block);
const like = yumeDowncastLike(object);
if (like) return await this.undoLike(actor, like);
const announce = yumeDowncastAnnounce(object);
if (announce) return await this.undoAnnounce(actor, announce);
const accept = yumeDowncastAccept(object);
if (accept) return await this.undoAccept(actor, accept);
if (isFollow(object)) return await this.undoFollow(actor, object);
if (isBlock(object)) return await this.undoBlock(actor, object);
if (isLike(object)) return await this.undoLike(actor, object);
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)}`;
}
@ -840,15 +766,14 @@ export class ApInboxService {
}
@bindThis
private async update(actor: MiRemoteUser, activity: IUpdate, resolver?: Resolver): Promise<string> {
private async update(actor: MiRemoteUser, activity: IUpdate): Promise<string> {
if (actor.uri !== activity.actor) {
return 'skip: invalid actor';
}
this.logger.debug('Update');
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
@ -859,7 +784,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, actor, resolver).catch(err => console.error(err));
await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err));
return 'ok: Question updated';
} else {
return `skip: Unknown type: ${getApType(object)}`;
@ -867,11 +792,11 @@ export class ApInboxService {
}
@bindThis
private async move(actor: MiRemoteUser, activity: IMove, resolver?: Resolver): Promise<string> {
private async move(actor: MiRemoteUser, activity: IMove): Promise<string> {
// fetch the new and old accounts
const targetUri = getApHrefNullable(activity.target);
if (!targetUri) return 'skip: invalid activity target';
return await this.apPersonService.updatePerson(actor.uri, resolver) ?? 'skip: nothing to do';
return await this.apPersonService.updatePerson(actor.uri) ?? 'skip: nothing to do';
}
}

View file

@ -30,7 +30,7 @@ import { IdService } from '@/core/IdService.js';
import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.js';
import { markOutgoing, type IAccept, type IActivity, type IAdd, type IAnnounce, type IApDocument, type IApEmoji, type IApHashtag, type IApImage, type IApMention, type IBlock, type ICreate, type IDelete, type IFlag, type IFollow, type IKey, type ILike, type IMove, type IObject, type IPost, type IQuestion, type IReject, type IRemove, type ITombstone, type IUndo, type IUpdate } from './type.js';
import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js';
@Injectable()
export class ApRendererService {
@ -66,21 +66,21 @@ export class ApRendererService {
@bindThis
public renderAccept(object: string | IObject, user: { id: MiUser['id']; host: null }): IAccept {
return markOutgoing({
return {
type: 'Accept',
actor: this.userEntityService.genLocalUserUri(user.id),
object,
}, undefined);
};
}
@bindThis
public renderAdd(user: MiLocalUser, target: string | IObject | undefined, object: string | IObject): IAdd {
return markOutgoing({
return {
type: 'Add',
actor: this.userEntityService.genLocalUserUri(user.id),
target,
object,
}, undefined);
};
}
@bindThis
@ -103,7 +103,7 @@ export class ApRendererService {
throw new Error('renderAnnounce: cannot render non-public note');
}
return markOutgoing({
return {
id: `${this.config.url}/notes/${note.id}/activity`,
actor: this.userEntityService.genLocalUserUri(note.userId),
type: 'Announce',
@ -111,7 +111,7 @@ export class ApRendererService {
to,
cc,
object,
}, undefined);
};
}
/**
@ -125,23 +125,23 @@ export class ApRendererService {
throw new Error('renderBlock: missing blockee uri');
}
return markOutgoing({
return {
type: 'Block',
id: `${this.config.url}/blocks/${block.id}`,
actor: this.userEntityService.genLocalUserUri(block.blockerId),
object: block.blockee.uri,
}, undefined);
};
}
@bindThis
public renderCreate(object: IObject, note: MiNote): ICreate {
const activity: ICreate = markOutgoing({
const activity: ICreate = {
id: `${this.config.url}/notes/${note.id}/activity`,
actor: this.userEntityService.genLocalUserUri(note.userId),
type: 'Create',
published: this.idService.parse(note.id).date.toISOString(),
object,
}, undefined);
};
if (object.to) activity.to = object.to;
if (object.cc) activity.cc = object.cc;
@ -151,28 +151,28 @@ export class ApRendererService {
@bindThis
public renderDelete(object: IObject | string, user: { id: MiUser['id']; host: null }): IDelete {
return markOutgoing({
return {
type: 'Delete',
actor: this.userEntityService.genLocalUserUri(user.id),
object,
published: new Date().toISOString(),
}, undefined);
};
}
@bindThis
public renderDocument(file: MiDriveFile): IApDocument {
return markOutgoing({
return {
type: 'Document',
mediaType: file.webpublicType ?? file.type,
url: this.driveFileEntityService.getPublicUrl(file),
name: file.comment,
sensitive: file.isSensitive,
}, undefined);
};
}
@bindThis
public renderEmoji(emoji: MiEmoji): IApEmoji {
return markOutgoing( {
return {
id: `${this.config.url}/emojis/${emoji.name}`,
type: 'Emoji',
name: `:${emoji.name}:`,
@ -183,28 +183,28 @@ export class ApRendererService {
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url: emoji.publicUrl || emoji.originalUrl,
},
}, undefined);
};
}
// to anonymise reporters, the reporting actor must be a system user
@bindThis
public renderFlag(user: MiLocalUser, object: IObject | string, content: string): IFlag {
return markOutgoing({
return {
type: 'Flag',
actor: this.userEntityService.genLocalUserUri(user.id),
content,
object,
}, undefined);
};
}
@bindThis
public renderFollowRelay(relay: MiRelay, relayActor: MiLocalUser): IFollow {
return markOutgoing({
return {
id: `${this.config.url}/activities/follow-relay/${relay.id}`,
type: 'Follow',
actor: this.userEntityService.genLocalUserUri(relayActor.id),
object: 'https://www.w3.org/ns/activitystreams#Public',
}, undefined);
};
}
/**
@ -223,36 +223,36 @@ export class ApRendererService {
followee: MiPartialLocalUser | MiPartialRemoteUser,
requestId?: string,
): IFollow {
return markOutgoing({
return {
id: requestId ?? `${this.config.url}/follows/${follower.id}/${followee.id}`,
type: 'Follow',
actor: this.userEntityService.getUserUri(follower),
object: this.userEntityService.getUserUri(followee),
}, undefined);
};
}
@bindThis
public renderHashtag(tag: string): IApHashtag {
return markOutgoing({
return {
type: 'Hashtag',
href: `${this.config.url}/tags/${encodeURIComponent(tag)}`,
name: `#${tag}`,
}, undefined);
};
}
@bindThis
public renderImage(file: MiDriveFile): IApImage {
return markOutgoing({
return {
type: 'Image',
url: this.driveFileEntityService.getPublicUrl(file),
sensitive: file.isSensitive,
name: file.comment,
}, undefined);
};
}
@bindThis
public renderKey(user: MiLocalUser, key: MiUserKeypair, postfix?: string): IKey {
return markOutgoing({
return {
id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`,
type: 'Key',
owner: this.userEntityService.genLocalUserUri(user.id),
@ -260,21 +260,21 @@ export class ApRendererService {
type: 'spki',
format: 'pem',
}),
}, undefined);
};
}
@bindThis
public async renderLike(noteReaction: MiNoteReaction, note: { uri: string | null }): Promise<ILike> {
const reaction = noteReaction.reaction;
const object: ILike = markOutgoing({
const object: ILike = {
type: 'Like',
id: `${this.config.url}/likes/${noteReaction.id}`,
actor: `${this.config.url}/users/${noteReaction.userId}`,
object: note.uri ? note.uri : `${this.config.url}/notes/${noteReaction.noteId}`,
content: reaction,
_misskey_reaction: reaction,
}, undefined);
};
if (reaction.startsWith(':')) {
const name = reaction.replaceAll(':', '');
@ -288,11 +288,11 @@ export class ApRendererService {
@bindThis
public renderMention(mention: MiPartialLocalUser | MiPartialRemoteUser): IApMention {
return markOutgoing({
return {
type: 'Mention',
href: this.userEntityService.getUserUri(mention),
name: this.userEntityService.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as MiLocalUser).username}`,
}, undefined);
};
}
@bindThis
@ -302,13 +302,13 @@ export class ApRendererService {
): IMove {
const actor = this.userEntityService.getUserUri(src);
const target = this.userEntityService.getUserUri(dst);
return markOutgoing({
return {
id: `${this.config.url}/moves/${src.id}/${dst.id}`,
actor,
type: 'Move',
object: actor,
target,
}, undefined);
};
}
@bindThis
@ -422,7 +422,7 @@ export class ApRendererService {
})),
} as const : {};
return markOutgoing({
return {
id: `${this.config.url}/notes/${note.id}`,
type: 'Note',
attributedTo,
@ -445,7 +445,7 @@ export class ApRendererService {
sensitive: note.cw != null || files.some(file => file.isSensitive),
tag,
...asPoll,
}, undefined);
};
}
@bindThis
@ -529,7 +529,7 @@ export class ApRendererService {
@bindThis
public renderQuestion(user: { id: MiUser['id'] }, note: MiNote, poll: MiPoll): IQuestion {
return markOutgoing({
return {
type: 'Question',
id: `${this.config.url}/questions/${note.id}`,
actor: this.userEntityService.genLocalUserUri(user.id),
@ -542,78 +542,78 @@ export class ApRendererService {
totalItems: poll.votes[i],
},
})),
}, 'question');
};
}
@bindThis
public renderReject(object: string | IObject, user: { id: MiUser['id'] }): IReject {
return markOutgoing({
return {
type: 'Reject',
actor: this.userEntityService.genLocalUserUri(user.id),
object,
}, undefined);
};
}
@bindThis
public renderRemove(user: { id: MiUser['id'] }, target: string | IObject | undefined, object: string | IObject): IRemove {
return markOutgoing({
return {
type: 'Remove',
actor: this.userEntityService.genLocalUserUri(user.id),
target,
object,
}, undefined);
};
}
@bindThis
public renderTombstone(id: string): ITombstone {
return markOutgoing({
return {
id,
type: 'Tombstone',
}, undefined);
};
}
@bindThis
public renderUndo(object: string | IObject, user: { id: MiUser['id'] }): IUndo {
const id = typeof object !== 'string' && typeof object.id === 'string' && object.id.startsWith(this.config.url) ? `${object.id}/undo` : undefined;
return markOutgoing({
return {
type: 'Undo',
...(id ? { id } : {}),
actor: this.userEntityService.genLocalUserUri(user.id),
object,
published: new Date().toISOString(),
}, undefined);
};
}
@bindThis
public renderUpdate(object: string | IObject, user: { id: MiUser['id'] }): IUpdate {
return markOutgoing( {
return {
id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`,
actor: this.userEntityService.genLocalUserUri(user.id),
type: 'Update',
to: ['https://www.w3.org/ns/activitystreams#Public'],
object,
published: new Date().toISOString(),
}, undefined);
};
}
@bindThis
public renderVote(user: { id: MiUser['id'] }, vote: MiPollVote, note: MiNote, poll: MiPoll, pollOwner: MiRemoteUser): ICreate {
return markOutgoing({
return {
id: `${this.config.url}/users/${user.id}#votes/${vote.id}/activity`,
actor: this.userEntityService.genLocalUserUri(user.id),
type: 'Create',
to: [pollOwner.uri],
published: new Date().toISOString(),
object: markOutgoing({
object: {
id: `${this.config.url}/users/${user.id}#votes/${vote.id}`,
type: 'Note',
attributedTo: this.userEntityService.genLocalUserUri(user.id),
to: [pollOwner.uri],
inReplyTo: note.uri,
name: poll.choices[vote.choice],
}, undefined),
}, undefined);
},
};
}
@bindThis

View file

@ -11,14 +11,11 @@ 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;
@ -148,7 +145,6 @@ export class ApRequestService {
private userKeypairService: UserKeypairService,
private httpRequestService: HttpRequestService,
private loggerService: LoggerService,
private utilityService: UtilityService,
) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる
@ -242,7 +238,7 @@ export class ApRequestService {
const alternate = document.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)) {
if (href) {
return await this.signedGet(href, user, false);
}
}
@ -255,11 +251,7 @@ export class ApRequestService {
//#endregion
validateContentTypeSetAsActivityPub(res);
const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject;
assertActivityMatchesUrls(activity, [finalUrl]);
return activity;
return await res.json();
}
}

View file

@ -16,13 +16,11 @@ import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { isCollectionOrOrderedCollection, yumeNormalizeObject } from './type.js';
import { isCollectionOrOrderedCollection } from './type.js';
import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js';
import { ApRequestService } from './ApRequestService.js';
import type { IObject, ICollection, IOrderedCollection, IUnsanitizedObject } from './type.js';
import { yumeAssertAcceptableURL } from './misc/validator.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { IObject, ICollection, IOrderedCollection } from './type.js';
export class Resolver {
private history: Set<string>;
@ -44,7 +42,7 @@ export class Resolver {
private apRendererService: ApRendererService,
private apDbResolverService: ApDbResolverService,
private loggerService: LoggerService,
private recursionLimit = 256,
private recursionLimit = 100,
) {
this.history = new Set();
this.logger = this.loggerService.getLogger('ap-resolve');
@ -55,26 +53,21 @@ export class Resolver {
return Array.from(this.history);
}
@bindThis
public getRecursionLimit(): number {
return this.recursionLimit;
}
@bindThis
public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> {
const collection = typeof value === 'string'
? await this.resolve(value)
: yumeNormalizeObject(value);
: value;
if (isCollectionOrOrderedCollection(collection)) {
return collection;
} else {
throw new IdentifiableError('f100eccf-f347-43fb-9b45-96a0831fb635', `unrecognized collection type: ${collection.type}`);
throw new Error(`unrecognized collection type: ${collection.type}`);
}
}
@bindThis
private async resolveNotNormalized(value: string | IObject): Promise<IUnsanitizedObject> {
public async resolve(value: string | IObject): Promise<IObject> {
if (typeof value !== 'string') {
return value;
}
@ -83,11 +76,11 @@ export class Resolver {
// URLs with fragment parts cannot be resolved correctly because
// the fragment part does not get transmitted over HTTP(S).
// Avoid strange behaviour by not trying to resolve these at all.
throw new IdentifiableError('b94fd5b1-0e3b-4678-9df2-dad4cd515ab2', `cannot resolve URL with fragment: ${value}`);
throw new Error(`cannot resolve URL with fragment: ${value}`);
}
if (this.history.has(value)) {
throw new IdentifiableError('0dc86cf6-7cd6-4e56-b1e6-5903d62d7ea5', 'cannot resolve already resolved one');
throw new Error('cannot resolve already resolved one');
}
if (this.history.size > this.recursionLimit) {
@ -102,7 +95,7 @@ export class Resolver {
}
if (!this.utilityService.isFederationAllowedHost(host)) {
throw new IdentifiableError('09d79f9e-64f1-4316-9cfa-e75c4d091574', 'Instance is blocked');
throw new Error('Instance is blocked');
}
if (this.config.signToActivityPubGet && !this.user) {
@ -110,54 +103,24 @@ export class Resolver {
}
const object = (this.user
? await this.apRequestService.signedGet(value, this.user) as IUnsanitizedObject
: await this.httpRequestService.getActivityJson(value)) as IUnsanitizedObject;
? await this.apRequestService.signedGet(value, this.user) as IObject
: await this.httpRequestService.getActivityJson(value)) as IObject;
if (
Array.isArray(object['@context']) ?
!(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') :
object['@context'] !== 'https://www.w3.org/ns/activitystreams'
) {
throw new IdentifiableError('72180409-793c-4973-868e-5a118eb5519b', 'invalid response');
throw new Error('invalid response');
}
// HttpRequestService / ApRequestService have already checked that
// `object.id` or `object.url` matches the URL used to fetch the
// object after redirects; here we double-check that no redirects
// bounced between hosts
if (object.id == null) {
throw new IdentifiableError('ad2dc287-75c1-44c4-839d-3d2e64576675', 'invalid AP object: missing id');
}
if (this.utilityService.punyHost(object.id) !== this.utilityService.punyHost(value)) {
throw new IdentifiableError('fd93c2fa-69a8-440f-880b-bf178e0ec877', `invalid AP object ${value}: id ${object.id} has different host`);
}
// HttpRequestService / ApRequestService have already checked that
// `object.id` or `object.url` matches the URL used to fetch the
// object after redirects; here we double-check that no redirects
// bounced between hosts
if (object.id == null) {
throw new Error('invalid AP object: missing id');
}
yumeAssertAcceptableURL(object.id);
yumeAssertAcceptableURL(value);
return object;
}
@bindThis
public async resolve(value: string | IObject): Promise<IObject> {
const object = await this.resolveNotNormalized(value);
return yumeNormalizeObject(object);
}
@bindThis
private resolveLocal(url: string): Promise<IObject> {
const parsed = this.apDbResolverService.parseUri(url);
if (!parsed.local) throw new IdentifiableError('02b40cd0-fa92-4b0c-acc9-fb2ada952ab8', 'resolveLocal: not local');
if (!parsed.local) throw new Error('resolveLocal: not local');
switch (parsed.type) {
case 'notes':
@ -186,7 +149,7 @@ export class Resolver {
case 'follows':
return this.followRequestsRepository.findOneBy({ id: parsed.id })
.then(async followRequest => {
if (followRequest == null) throw new IdentifiableError('a9d946e5-d276-47f8-95fb-f04230289bb0', 'resolveLocal: invalid follow request ID');
if (followRequest == null) throw new Error('resolveLocal: invalid follow request ID');
const [follower, followee] = await Promise.all([
this.usersRepository.findOneBy({
id: followRequest.followerId,
@ -198,12 +161,12 @@ export class Resolver {
}),
]);
if (follower == null || followee == null) {
throw new IdentifiableError('06ae3170-1796-4d93-a697-2611ea6d83b6', 'resolveLocal: follower or followee does not exist');
throw new Error('resolveLocal: follower or followee does not exist');
}
return this.apRendererService.addContext(this.apRendererService.renderFollow(follower as MiLocalUser | MiRemoteUser, followee as MiLocalUser | MiRemoteUser, url));
});
default:
throw new IdentifiableError('7a5d2fc0-94bc-4db6-b8b8-1bf24a2e23d0', `resolveLocal: type ${parsed.type} unhandled`);
throw new Error(`resolveLocal: type ${parsed.type} unhandled`);
}
}
}

View file

@ -4,28 +4,6 @@
*/
import type { Response } from 'node-fetch';
import * as Bull from 'bullmq';
import { toASCII } from 'node:punycode';
export function yumeAssertAcceptableURL(url: string | URL): URL {
const urlParsed = url instanceof URL ? url : new URL(url);
if (urlParsed.search.length + urlParsed.pathname.length > 1024) {
throw new Bull.UnrecoverableError('URL is too long');
}
if (urlParsed.protocol !== 'https:') {
throw new Bull.UnrecoverableError('URL protocol is not https');
}
if (urlParsed.port && urlParsed.port !== '443') {
throw new Bull.UnrecoverableError('URL port is not 443');
}
urlParsed.hostname = toASCII(urlParsed.hostname);
return urlParsed;
}
export function validateContentTypeSetAsActivityPub(response: Response): void {
const contentType = (response.headers.get('content-type') ?? '').toLowerCase();

View file

@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as Bull from 'bullmq';
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
@ -37,7 +36,6 @@ import { ApQuestionService } from './ApQuestionService.js';
import { ApImageService } from './ApImageService.js';
import type { Resolver } from '../ApResolverService.js';
import type { IObject, IPost } from '../type.js';
import { yumeAssertAcceptableURL } from '../misc/validator.js';
@Injectable()
export class ApNoteService {
@ -79,7 +77,7 @@ export class ApNoteService {
}
@bindThis
public validateNote(object: IObject, uri: string, actor?: MiRemoteUser): Error | null {
public validateNote(object: IObject, uri: string): Error | null {
const expectHost = this.utilityService.extractDbHost(uri);
const apType = getApType(object);
@ -100,14 +98,6 @@ export class ApNoteService {
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;
}
@ -125,14 +115,14 @@ export class ApNoteService {
* Noteを作成します
*/
@bindThis
public async createNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise<MiNote | null> {
public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<MiNote | null> {
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(value);
const entryUri = getApId(value);
const err = this.validateNote(object, entryUri, actor);
const err = this.validateNote(object, entryUri);
if (err) {
this.logger.error(err.message, {
resolver: { history: resolver.getHistory() },
@ -146,27 +136,14 @@ export class ApNoteService {
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
if (note.id == null) {
throw new Error('Refusing to create note without id');
}
if (!checkHttps(note.id)) {
if (note.id && !checkHttps(note.id)) {
throw new Error('unexpected schema of note.id: ' + note.id);
}
const url = getOneApHrefNullable(note.url);
if (url != null) {
if (!checkHttps(url)) {
throw new Error('unexpected schema of note url: ' + url);
}
const actUrl = yumeAssertAcceptableURL(url);
const noteUrl = yumeAssertAcceptableURL(note.id);
if (noteUrl.host !== actUrl.host) {
throw new Bull.UnrecoverableError(`note url & uri host mismatch: note url: ${url}, note uri: ${note.id}`);
}
if (url && !checkHttps(url)) {
throw new Error('unexpected schema of note url: ' + url);
}
this.logger.info(`Creating the Note: ${note.id}`);
@ -179,9 +156,8 @@ export class ApNoteService {
const uri = getOneApId(note.attributedTo);
// ローカルで投稿者を検索し、もし凍結されていたらスキップ
// eslint-disable-next-line no-param-reassign
actor ??= await this.apPersonService.fetchPerson(uri) as MiRemoteUser | undefined;
if (actor && actor.isSuspended) {
const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser;
if (cachedActor && cachedActor.isSuspended) {
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
}
@ -213,8 +189,7 @@ export class ApNoteService {
}
//#endregion
// eslint-disable-next-line no-param-reassign
actor ??= await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
const actor = cachedActor ?? await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
// 解決した投稿者が凍結されていたらスキップ
if (actor.isSuspended) {
@ -373,7 +348,7 @@ export class ApNoteService {
if (exist) return exist;
//#endregion
if (this.utilityService.isUriLocal(uri)) {
if (uri.startsWith(this.config.url)) {
throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note');
}
@ -381,7 +356,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, undefined, options.resolver, true);
return await this.createNote(createFrom, options.resolver, true);
} finally {
unlock();
}

View file

@ -129,6 +129,12 @@ 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
@ -136,7 +142,7 @@ export class ApPersonService implements OnModuleInit {
*/
@bindThis
private validateActor(x: IObject, uri: string): IActor {
const expectHost = this.utilityService.punyHost(uri);
const expectHost = this.punyHost(uri);
if (!isActor(x)) {
throw new Error(`invalid Actor type '${x.type}'`);
@ -150,32 +156,6 @@ export class ApPersonService implements OnModuleInit {
throw new Error('invalid Actor: wrong inbox');
}
if (this.utilityService.punyHost(x.inbox) !== expectHost) {
throw new Error('invalid Actor: inbox has different host');
}
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}`);
}
}
}
if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) {
throw new Error('invalid Actor: wrong username');
}
@ -199,7 +179,7 @@ export class ApPersonService implements OnModuleInit {
x.summary = truncate(x.summary, summaryLength);
}
const idHost = this.utilityService.punyHost(x.id);
const idHost = this.punyHost(x.id);
if (idHost !== expectHost) {
throw new Error('invalid Actor: id has different host');
}
@ -209,7 +189,7 @@ export class ApPersonService implements OnModuleInit {
throw new Error('invalid Actor: publicKey.id is not a string');
}
const publicKeyIdHost = this.utilityService.punyHost(x.publicKey.id);
const publicKeyIdHost = this.punyHost(x.publicKey.id);
if (publicKeyIdHost !== expectHost) {
throw new Error('invalid Actor: publicKey.id has different host');
}
@ -300,13 +280,13 @@ export class ApPersonService implements OnModuleInit {
public async createPerson(uri: string, resolver?: Resolver): Promise<MiRemoteUser> {
if (typeof uri !== 'string') throw new Error('uri is not string');
if (resolver == null) resolver = this.apResolverService.createResolver();
const host = this.utilityService.punyHost(uri);
if (host === this.utilityService.toPuny(this.config.host)) {
if (uri.startsWith(this.config.url)) {
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
}
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(uri);
if (object.id == null) throw new Error('invalid object.id: ' + object.id);
@ -314,6 +294,8 @@ 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);
@ -339,18 +321,8 @@ export class ApPersonService implements OnModuleInit {
const url = getOneApHrefNullable(person.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}`);
}
if (url && !checkHttps(url)) {
throw new Error('unexpected schema of person url: ' + url);
}
// Create user
@ -493,7 +465,7 @@ export class ApPersonService implements OnModuleInit {
if (typeof uri !== 'string') throw new Error('uri is not string');
// URIがこのサーバーを指しているならスキップ
if (this.utilityService.isUriLocal(uri)) return;
if (uri.startsWith(`${this.config.url}/`)) return;
//#region このサーバーに既に登録されているか
const exist = await this.fetchPerson(uri) as MiRemoteUser | null;
@ -542,18 +514,8 @@ export class ApPersonService implements OnModuleInit {
const url = getOneApHrefNullable(person.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}`);
}
if (url && !checkHttps(url)) {
throw new Error('unexpected schema of person url: ' + url);
}
const updates = {
@ -595,9 +557,7 @@ export class ApPersonService implements OnModuleInit {
if (moving) updates.movedAt = new Date();
// Update user
if (!(await this.usersRepository.update({ id: exist.id, isDeleted: false }, updates)).affected) {
return 'skip';
}
await this.usersRepository.update({ id: exist.id, isDeleted: false }, updates);
if (person.publicKey) {
await this.userPublickeysRepository.update({ userId: exist.id }, {
@ -702,7 +662,7 @@ export class ApPersonService implements OnModuleInit {
@bindThis
public async updateFeatured(userId: MiUser['id'], resolver?: Resolver): Promise<void> {
const user = await this.usersRepository.findOneByOrFail({ id: userId });
const user = await this.usersRepository.findOneByOrFail({ id: userId, isDeleted: false });
if (!this.userEntityService.isRemoteUser(user)) return;
if (!user.featured) return;
@ -768,7 +728,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 (this.utilityService.isUriLocal(src.movedToUri)) {
if (src.movedToUri.startsWith(`${this.config.url}/`)) {
// ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている
return 'failed: movedTo is local but not found';
}

View file

@ -5,19 +5,16 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { UsersRepository, NotesRepository, PollsRepository } from '@/models/_.js';
import type { 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 { getOneApId, isQuestion } from '../type.js';
import { isQuestion } from '../type.js';
import { ApLoggerService } from '../ApLoggerService.js';
import { ApResolverService } from '../ApResolverService.js';
import type { Resolver } from '../ApResolverService.js';
import type { IObject } from '../type.js';
import { yumeAssertAcceptableURL } from '../misc/validator.js';
import { toASCII } from 'punycode';
import type { IObject, IQuestion } from '../type.js';
@Injectable()
export class ApQuestionService {
@ -27,9 +24,6 @@ export class ApQuestionService {
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@ -71,41 +65,28 @@ export class ApQuestionService {
* @returns true if updated
*/
@bindThis
public async updateQuestion(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver): Promise<boolean> {
const uriIn = typeof value === 'string' ? value : value.id;
if (uriIn == null) throw new Error('uri is null');
public async updateQuestion(value: string | IObject, resolver?: Resolver): Promise<boolean> {
const uri = typeof value === 'string' ? value : value.id;
if (uri == null) throw new Error('uri is null');
// URIがこのサーバーを指しているならスキップ
const uri = yumeAssertAcceptableURL(uriIn);
if (toASCII(this.config.host) === uri.host) throw new Error('uri points local');
if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local');
//#region このサーバーに既に登録されているか
const note = await this.notesRepository.findOneBy({ uri: uriIn });
const note = await this.notesRepository.findOneBy({ uri });
if (note == null) throw new Error('Question is not registered');
const poll = await this.pollsRepository.findOneBy({ noteId: note.id });
if (poll == null) throw new Error('Question is not registered');
const user = await this.usersRepository.findOneBy({ id: poll.userId });
if (user == null) throw new Error('Question is not registered');
//#endregion
// resolve new Question object
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const question = await resolver.resolve(value);
const question = await resolver.resolve(value) as IQuestion;
this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);
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');
}
if (question.type !== 'Question') throw new Error('object is not a Question');
const apChoices = question.oneOf ?? question.anyOf;
if (apChoices == null) throw new Error('invalid apChoices: ' + apChoices);
@ -115,7 +96,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 || !(Number.isInteger(newCount) && newCount >= 0)) throw new Error('invalid newCount: ' + newCount);
if (newCount == null) throw new Error('invalid newCount: ' + newCount);
if (oldCount !== newCount) {
changed = true;

View file

@ -3,45 +3,20 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { target } from "happy-dom/lib/PropertySymbol.js";
import { toASCII } from "node:punycode";
import * as bull from "bullmq";
export type Obj = { [x: string]: any };
export type ApObject = IObject | string | (IObject | string)[];
export interface MisskeyVendorKeys {
_misskey_summary: string;
_misskey_followedMessage: string | null;
_misskey_requireSigninToViewContents: boolean;
_misskey_makeNotesFollowersOnlyBefore: number | null;
_misskey_makeNotesHiddenBefore: number | null;
_misskey_quote: string;
_misskey_content: string;
_misskey_reaction: string;
_misskey_votes: number;
}
function extractMisskeyVendorKeys(object: IObject): Partial<MisskeyVendorKeys> {
return {
_misskey_summary: object._misskey_summary,
_misskey_followedMessage: object._misskey_followedMessage,
_misskey_requireSigninToViewContents: object._misskey_requireSigninToViewContents,
_misskey_makeNotesFollowersOnlyBefore: object._misskey_makeNotesFollowersOnlyBefore,
_misskey_makeNotesHiddenBefore: object._misskey_makeNotesHiddenBefore,
_misskey_quote: object._misskey_quote,
_misskey_content: object._misskey_content,
_misskey_reaction: object._misskey_reaction,
_misskey_votes: object._misskey_votes,
};
}
export interface IUnsanitizedObject extends Partial<MisskeyVendorKeys> {
export interface IObject {
'@context'?: string | string[] | Obj | Obj[];
type: string | string[];
id?: string;
name?: string | null;
summary?: string;
_misskey_summary?: string;
_misskey_followedMessage?: string | null;
_misskey_requireSigninToViewContents?: boolean;
_misskey_makeNotesFollowersOnlyBefore?: number | null;
_misskey_makeNotesHiddenBefore?: number | null;
published?: string;
cc?: ApObject;
to?: ApObject;
@ -59,80 +34,6 @@ export interface IUnsanitizedObject extends Partial<MisskeyVendorKeys> {
href?: string;
tag?: IObject | IObject[];
sensitive?: boolean;
visibility?: string;
mentionedUsers?: any[];
visibleUsers?: any[];
}
export interface IObject extends IUnsanitizedObject {
__yume_normalized_object: true | 'outgoing';
};
export interface YumeDowncastSanitizedBadge<L extends 'question' | undefined> {
__yume_normalized_badge: L | 'outgoing';
};
export function markOutgoing<T, L extends 'question' | undefined>(object: T, _badge: L): T & IObject & YumeDowncastSanitizedBadge<L> {
return object as T & IObject & YumeDowncastSanitizedBadge<L>;
}
export function yumeNormalizeURL(url: string): string {
const u = new URL(url);
u.host = toASCII(u.host);
if (u.protocol && u.protocol !== 'https:') {
throw new bull.UnrecoverableError('protocol is not https');
}
u.protocol = 'https:';
if (u.port && u.port !== '443') {
throw new bull.UnrecoverableError('port is not 443');
}
return u.toString();
}
export function yumeNormalizeRecursive<O extends IUnsanitizedObject | string | (IUnsanitizedObject | string)[]>(object: O, depth = 0):
IObject | string | (IObject | string)[] {
if (depth > 16) {
throw new bull.UnrecoverableError('recursion limit exceeded');
}
if (typeof object === 'string') {
return yumeNormalizeURL(object);
}
if (Array.isArray(object)) {
if (object.length > 64) {
throw new bull.UnrecoverableError('array length limit exceeded');
}
return object.flatMap((x) => yumeNormalizeRecursive(x, depth + (object.length + 3 / 4)));
}
return yumeNormalizeObject(object, depth + 1);
}
export function yumeNormalizeObject(object: IUnsanitizedObject, depth = 0): IObject {
if (object.cc) {
object.cc = yumeNormalizeRecursive(object.cc, depth + 1);
}
if (object.id) {
object.id = yumeNormalizeURL(object.id);
}
if (object.url) {
object.url = yumeNormalizeRecursive(object.url, depth + 1);
}
if (object.replies) {
object.replies.first = object.replies.first ?
typeof object.replies.first === 'string' ? yumeNormalizeURL(object.replies.first) : yumeNormalizeObject(object.replies.first, depth + 1) : undefined;
object.replies.items = object.replies.items ?
typeof object.replies.items === 'string' ? yumeNormalizeURL(object.replies.items) : yumeNormalizeRecursive(object.replies.items, depth + 1) : undefined;
}
if (object.inReplyTo) {
object.inReplyTo = yumeNormalizeRecursive(object.inReplyTo, depth + 1);
}
return object as IObject;
}
/**
@ -179,7 +80,7 @@ export function getOneApHrefNullable(value: ApObject | undefined): string | unde
}
export function getApHrefNullable(value: string | IObject | undefined): string | undefined {
if (typeof value === 'string') return value;
if (typeof value === 'string') return value;
if (typeof value?.href === 'string') return value.href;
return undefined;
}
@ -200,28 +101,6 @@ export interface IActivity extends IObject {
};
}
export interface SafeList {
id: string;
content: string | null;
tag: IObject | IObject[];
published: string;
visibility: string;
mentionedUsers: any[];
visibleUsers: any[];
}
function extractSafe(object: IObject): Partial<SafeList> {
return {
id: object.id,
content: object.content,
tag: object.tag,
published: object.published,
visibility: object.visibility,
mentionedUsers: object.mentionedUsers,
visibleUsers: object.visibleUsers,
};
}
export interface ICollection extends IObject {
type: 'Collection';
totalItems: number;
@ -243,7 +122,7 @@ export const isPost = (object: IObject): object is IPost => {
return type != null && validPost.includes(type);
};
export interface IPost extends IObject{
export interface IPost extends IObject {
type: 'Note' | 'Question' | 'Article' | 'Audio' | 'Document' | 'Image' | 'Page' | 'Video' | 'Event';
source?: {
content: string;
@ -254,7 +133,7 @@ export interface IPost extends IObject{
quoteUrl?: string;
}
export interface IUnsanitizedQuestion extends IObject {
export interface IQuestion extends IObject {
type: 'Note' | 'Question';
actor: string;
source?: {
@ -269,25 +148,7 @@ export interface IUnsanitizedQuestion extends IObject {
closed?: Date;
}
export interface IQuestion extends IUnsanitizedQuestion, YumeDowncastSanitizedBadge<'question'> {}
export function yumeSanitizeQuestion(object: IUnsanitizedQuestion): IQuestion {
return {
type: object.type,
actor: yumeNormalizeURL(object.actor),
source: object.source,
_misskey_quote: object._misskey_quote,
quoteUrl: object.quoteUrl ? yumeNormalizeURL(object.quoteUrl) : '',
oneOf: object.oneOf,
anyOf: object.anyOf,
endTime: object.endTime,
closed: object.closed,
__yume_normalized_object: true,
__yume_normalized_badge: 'question',
};
}
export const isQuestion = (object: IObject): object is IUnsanitizedQuestion =>
export const isQuestion = (object: IObject): object is IQuestion =>
getApType(object) === 'Note' || getApType(object) === 'Question';
interface IQuestionChoice {
@ -403,307 +264,88 @@ export const isDocument = (object: IObject): object is IApDocument => {
return type != null && validDocumentTypes.includes(type);
};
export interface IApImage extends IApDocument, Partial<SafeList> {
export interface IApImage extends IApDocument {
type: 'Image';
}
export interface ICreate extends IActivity, Partial<SafeList> {
export interface ICreate extends IActivity {
type: 'Create';
}
export interface IDelete extends IActivity, Partial<SafeList> {
export interface IDelete extends IActivity {
type: 'Delete';
}
export interface IUpdate extends IActivity, Partial<SafeList> {
export interface IUpdate extends IActivity {
type: 'Update';
}
export interface IRead extends IActivity, Partial<SafeList> {
export interface IRead extends IActivity {
type: 'Read';
}
export interface IUndo extends IActivity, Partial<SafeList> {
export interface IUndo extends IActivity {
type: 'Undo';
}
export interface IFollow extends IActivity, Partial<SafeList> {
export interface IFollow extends IActivity {
type: 'Follow';
}
export interface IAccept extends IActivity, Partial<SafeList> {
export interface IAccept extends IActivity {
type: 'Accept';
}
export interface IReject extends IActivity, Partial<SafeList> {
export interface IReject extends IActivity {
type: 'Reject';
}
export interface IAdd extends IActivity, Partial<SafeList> {
export interface IAdd extends IActivity {
type: 'Add';
}
export interface IRemove extends IActivity, Partial<SafeList> {
export interface IRemove extends IActivity {
type: 'Remove';
}
export interface ILike extends IActivity, Partial<SafeList> {
export interface ILike extends IActivity {
type: 'Like' | 'EmojiReaction' | 'EmojiReact';
_misskey_reaction?: string;
}
export interface IAnnounce extends IActivity, Partial<SafeList> {
export interface IAnnounce extends IActivity {
type: 'Announce';
}
export interface IBlock extends IActivity, Partial<SafeList> {
export interface IBlock extends IActivity {
type: 'Block';
}
export interface IFlag extends IActivity, Partial<SafeList> {
export interface IFlag extends IActivity {
type: 'Flag';
}
export interface IMove extends IActivity, Partial<SafeList> {
export interface IMove extends IActivity {
type: 'Move';
target: IObject | string;
}
export function yumeDowncastCreate(object: IObject): ICreate | null {
if (getApType(object) !== 'Create') return null;
const obj = object as ICreate;
if (!obj.actor || !obj.object) return null;
return {
...extractMisskeyVendorKeys(object),
...extractSafe(object),
type: 'Create',
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
__yume_normalized_object: true,
};
}
export function yumeDowncastDelete(object: IObject): IDelete | null {
if (getApType(object) !== 'Delete') return null;
const obj = object as IDelete;
if (!obj.actor || !obj.object) return null;
return {
...extractMisskeyVendorKeys(object),
...extractSafe(object),
type: 'Delete',
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
__yume_normalized_object: true,
};
}
export function yumeDowncastUpdate(object: IObject): IUpdate | null {
if (getApType(object) !== 'Update') return null;
const obj = object as IUpdate;
if (!obj.actor || !obj.object) return null;
return {
...extractMisskeyVendorKeys(object),
...extractSafe(object),
type: 'Update',
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
__yume_normalized_object: true,
};
}
export function yumeDowncastRead(object: IObject): IRead | null {
if (getApType(object) !== 'Read') return null;
const obj = object as IRead;
if (!obj.actor || !obj.object) return null;
return {
...extractMisskeyVendorKeys(object),
...extractSafe(object),
type: 'Read',
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
__yume_normalized_object: true,
};
}
export function yumeDowncastUndo(object: IObject): IUndo | null {
if (getApType(object) !== 'Undo') return null;
const obj = object as IUndo;
if (!obj.actor || !obj.object) return null;
return {
...extractMisskeyVendorKeys(object),
...extractSafe(object),
type: 'Undo',
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
__yume_normalized_object: true,
};
}
export function yumeDowncastFollow(object: IObject): IFollow | null {
if (getApType(object) !== 'Follow') return null;
const obj = object as IFollow;
if (!obj.actor || !obj.object) return null;
return {
...extractMisskeyVendorKeys(object),
...extractSafe(object),
type: 'Follow',
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
__yume_normalized_object: true,
};
}
export function yumeDowncastAccept(object: IObject): IAccept | null {
if (getApType(object) !== 'Accept') return null;
const obj = object as IAccept;
if (!obj.actor || !obj.object) return null;
return {
...extractMisskeyVendorKeys(object),
...extractSafe(object),
type: 'Accept',
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
__yume_normalized_object: true,
};
}
export function yumeDowncastReject(object: IObject): IReject | null {
if (getApType(object) !== 'Reject') return null;
const obj = object as IReject;
if (!obj.actor || !obj.object) return null;
return {
...extractMisskeyVendorKeys(object),
...extractSafe(object),
type: 'Reject',
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
__yume_normalized_object: true,
};
}
export function yumeDowncastAdd(object: IObject): IAdd | null {
if (getApType(object) !== 'Add') return null;
const obj = object as IAdd;
if (!obj.actor || !obj.object ) return null;
return {
...extractMisskeyVendorKeys(object),
...extractSafe(object),
type: 'Add',
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
__yume_normalized_object: true,
};
}
export function yumeDowncastRemove(object: IObject): IRemove | null {
if (getApType(object) !== 'Remove') return null;
const obj = object as IRemove;
if (!obj.actor || !obj.object) return null;
return {
...extractMisskeyVendorKeys(object),
...extractSafe(object),
type: 'Remove',
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
__yume_normalized_object: true,
};
}
export function yumeDowncastLike(object: IObject): ILike | null {
if (getApType(object) !== 'Like') return null;
const obj = object as ILike;
if (!obj.actor || !obj.object) return null;
return {
...extractMisskeyVendorKeys(object),
...extractSafe(object),
type: 'Like',
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
__yume_normalized_object: true,
};
}
export function yumeDowncastAnnounce(object: IObject): IAnnounce | null {
if (getApType(object) !== 'Announce') return null;
const obj = object as IAnnounce;
if (!obj.actor || !obj.object) return null;
return {
// ...extractMisskeyVendorKeys(object),
...extractSafe(object),
type: 'Announce',
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
__yume_normalized_object: true,
};
}
export function yumeDowncastBlock(object: IObject): IBlock | null {
if (getApType(object) !== 'Block') return null;
const obj = object as IBlock;
if (!obj.actor || !obj.object) return null;
return {
...extractMisskeyVendorKeys(object),
...extractSafe(object),
type: 'Block',
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
__yume_normalized_object: true,
};
}
export function yumeDowncastFlag(object: IObject): IFlag | null {
if (getApType(object) !== 'Flag') return null;
const obj = object as IFlag;
if (!obj.actor || !obj.object) return null;
return {
...extractMisskeyVendorKeys(object),
...extractSafe(object),
type: 'Flag',
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
target: obj.target ? (typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target)) : undefined,
__yume_normalized_object: true,
};
}
export function yumeDowncastMove(object: IObject): IMove | null {
if (getApType(object) !== 'Move') return null;
const obj = object as IMove;
if (!obj.actor || !obj.object || !obj.target) return null;
return {
...extractMisskeyVendorKeys(object),
...extractSafe(object),
type: 'Move',
actor: typeof obj.actor === 'string' ? yumeNormalizeURL(obj.actor) : yumeNormalizeObject(obj.actor),
object: typeof obj.object === 'string' ? yumeNormalizeURL(obj.object) : yumeNormalizeObject(obj.object),
target: typeof obj.target === 'string' ? yumeNormalizeURL(obj.target) : yumeNormalizeObject(obj.target),
__yume_normalized_object: true,
};
}
export function yumeDowncastMention(object: IObject): IApMention | null {
if (getApType(object) !== 'Mention') {
return null;
}
const href = getApHrefNullable(object);
return {
...object,
type: 'Mention',
href: href ? yumeNormalizeURL(href) : '',
name: object.name ?? '',
};
}
export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create';
export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete';
export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update';
export const isRead = (object: IObject): object is IRead => getApType(object) === 'Read';
export const isUndo = (object: IObject): object is IUndo => getApType(object) === 'Undo';
export const isFollow = (object: IObject): object is IFollow => getApType(object) === 'Follow';
export const isAccept = (object: IObject): object is IAccept => getApType(object) === 'Accept';
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 => {
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';

View file

@ -18,6 +18,7 @@ import { bindThis } from '@/decorators.js';
import { isMimeImage } from '@/misc/is-mime-image.js';
import { IdService } from '@/core/IdService.js';
import { UtilityService } from '../UtilityService.js';
import { VideoProcessingService } from '../VideoProcessingService.js';
import { UserEntityService } from './UserEntityService.js';
import { DriveFolderEntityService } from './DriveFolderEntityService.js';
@ -42,6 +43,7 @@ export class DriveFileEntityService {
private utilityService: UtilityService,
private driveFolderEntityService: DriveFolderEntityService,
private videoProcessingService: VideoProcessingService,
private idService: IdService,
) {
}
@ -84,7 +86,11 @@ export class DriveFileEntityService {
@bindThis
public getThumbnailUrl(file: MiDriveFile): string | null {
if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) {
if (file.type.startsWith('video')) {
if (file.thumbnailUrl) return file.thumbnailUrl;
return this.videoProcessingService.getExternalVideoThumbnailUrl(file.webpublicUrl ?? file.url);
} else if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) {
// 動画ではなくリモートかつメディアプロキシ
return this.getProxiedUrl(file.uri, 'static');
}

View file

@ -11,39 +11,15 @@ import type { } from '@/models/Blocking.js';
import type { MiEmoji } from '@/models/Emoji.js';
import { bindThis } from '@/decorators.js';
import { In } from 'typeorm';
import type { Config } from '@/config.js';
@Injectable()
export class EmojiEntityService {
constructor(
@Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository,
@Inject(DI.config)
private config: Config,
) {
}
private stripProxyIfOrigin(url: string): string {
try {
const u = new URL(url);
let origin = u.origin;
if (u.origin === new URL(this.config.mediaProxy).origin) {
const innerUrl = u.searchParams.get('url');
if (innerUrl) {
origin = new URL(innerUrl).origin;
}
}
if (origin === u.origin) {
return url;
}
} catch (e) {
return url;
}
return url;
}
@bindThis
public packSimpleNoQuery(
emoji: MiEmoji,
@ -53,7 +29,7 @@ export class EmojiEntityService {
name: emoji.name,
category: emoji.category,
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url: this.stripProxyIfOrigin(emoji.publicUrl || emoji.originalUrl),
url: emoji.publicUrl || emoji.originalUrl,
localOnly: emoji.localOnly ? true : undefined,
isSensitive: emoji.isSensitive ? true : undefined,
roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined,
@ -96,7 +72,7 @@ export class EmojiEntityService {
category: emoji.category,
host: emoji.host,
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url: this.stripProxyIfOrigin(emoji.publicUrl || emoji.originalUrl),
url: emoji.publicUrl || emoji.originalUrl,
license: emoji.license,
isSensitive: emoji.isSensitive,
localOnly: emoji.localOnly,

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne, ViewEntity } from 'typeorm';
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { id } from './util/id.js';
import { MiUser } from './User.js';
@ -98,4 +98,3 @@ export class MiFollowing {
public followeeSharedInbox: string | null;
//#endregion
}

View file

@ -4,9 +4,9 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, MoreThan, QueryFailedError, TypeORMError } from 'typeorm';
import { MoreThan } from 'typeorm';
import { DI } from '@/di-symbols.js';
import { MiUser, type DriveFilesRepository, type NotesRepository, type UserProfilesRepository, type UsersRepository } from '@/models/_.js';
import type { DriveFilesRepository, NotesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
import { DriveService } from '@/core/DriveService.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
@ -26,9 +26,6 @@ export class DeleteAccountProcessorService {
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.db)
private db: DataSource,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@ -55,14 +52,6 @@ export class DeleteAccountProcessorService {
return;
}
if (!user.isDeleted) {
this.logger.warn('User is not pre-marked as deleted, this is likely a bug');
if (process.env.NODE_ENV !== 'production') {
throw new Error('User is not pre-marked as deleted'); // make some noise to make sure tests fail
}
await this.usersRepository.update({ id: user.id }, { isDeleted: true });
}
{ // Delete notes
let cursor: MiNote['id'] | null = null;
@ -132,46 +121,13 @@ export class DeleteAccountProcessorService {
}
}
// Deadlockが発生した場合にリトライする
for (let remaining = 3; remaining > 0; remaining--) {
try {
// soft指定されている場合は物理削除しない
await this.db.transaction(async txn => {
// soft指定してもデータをすべで削除する
await txn.delete(MiUser, user.id);
if (job.data.soft) {
await txn.insert(MiUser, {
...user,
isRoot: false,
updatedAt: new Date(),
emojis: [],
hideOnlineStatus: true,
followersCount: 0,
followingCount: 0,
avatarUrl: null,
avatarId: null,
notesCount: 0,
inbox: null,
sharedInbox: null,
featured: null,
uri: null,
followersUri: null,
token: null,
isDeleted: true,
});
}
});
return 'Account deleted';
} catch (e) {
// 40P01 = deadlock_detected
// https://www.postgresql.org/docs/current/errcodes-appendix.html
if (remaining > 0 && e instanceof QueryFailedError && e.driverError.code === '40P01') {
this.logger.warn(`Deadlock occurred, retrying after 1s... [${remaining - 1} remaining]`);
await new Promise(resolve => setTimeout(resolve, 1000));
continue;
}
throw e;
}
// soft指定されている場合は物理削除しない
if (job.data.soft) {
// nop
} else {
await this.usersRepository.delete(job.data.user.id);
}
return 'Account deleted';
}
}

View file

@ -43,7 +43,7 @@ const mIncomingApProcessingTime = metricHistogram({
name: 'misskey_incoming_ap_processing_time',
help: 'Incoming AP processing time in seconds',
labelNames: ['incoming_host', 'incoming_type', 'success'],
buckets: [2, 10, 60, 300],
buckets: [1, 10, 60, 300, 1800],
});
const mIncomingApEvent = metricCounter({
@ -255,8 +255,6 @@ export class InboxProcessorService implements OnApplicationShutdown {
incCounter(mIncomingApReject, 'host_signature_mismatch');
throw new Bull.UnrecoverableError(`skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`);
}
} else {
throw new Bull.UnrecoverableError('skip: activity id is not a string');
}
this.apRequestChart.inbox();
@ -309,7 +307,7 @@ export class InboxProcessorService implements OnApplicationShutdown {
}
const end = +new Date();
observeHistogram(mIncomingApProcessingTime, { success: 'false' }, (end - begin) / 1000);
incCounter(mincomingApProcessingError, {});
incCounter(mincomingApProcessingError, { reason: 'unknown' });
throw e;
}
observeHistogram(mIncomingApProcessingTime, { success: 'true' }, (+new Date() - begin) / 1000);

View file

@ -105,7 +105,7 @@ export class ActivityPubServerService {
let signature;
try {
signature = httpSignature.parseRequest(request.raw, { 'headers': ['(request-target)', 'host', 'date'], authorizationHeaderName: 'signature' });
signature = httpSignature.parseRequest(request.raw, { 'headers': [] });
} catch (e) {
reply.code(401);
return;

View file

@ -8,19 +8,27 @@ import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import { Inject, Injectable } from '@nestjs/common';
import rename from 'rename';
import sharp from 'sharp';
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
import type { Config } from '@/config.js';
import type { MiDriveFile, DriveFilesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { createTemp } from '@/misc/create-temp.js';
import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
import { StatusError } from '@/misc/status-error.js';
import type Logger from '@/logger.js';
import { DownloadService } from '@/core/DownloadService.js';
import { IImageStreamable, ImageProcessingService, webpDefault } from '@/core/ImageProcessingService.js';
import { VideoProcessingService } from '@/core/VideoProcessingService.js';
import { InternalStorageService } from '@/core/InternalStorageService.js';
import { contentDisposition } from '@/misc/content-disposition.js';
import { FileInfoService } from '@/core/FileInfoService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js';
import { isMimeImage } from '@/misc/is-mime-image.js';
import { correctFilename } from '@/misc/correct-filename.js';
import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
import { InternalStorageService } from '@/core/InternalStorageService.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
@ -38,8 +46,11 @@ export class FileServerService {
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
private internalStorageService: InternalStorageService,
private fileInfoService: FileInfoService,
private downloadService: DownloadService,
private imageProcessingService: ImageProcessingService,
private videoProcessingService: VideoProcessingService,
private internalStorageService: InternalStorageService,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('server', 'gray');
@ -123,78 +134,165 @@ export class FileServerService {
return;
}
if (file.state === 'remote') {
const url = new URL(`${this.config.mediaProxy}/`);
try {
if (file.state === 'remote') {
let image: IImageStreamable | null = null;
url.searchParams.set('url', file.url);
if (file.fileRole === 'thumbnail') {
if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) {
reply.header('Cache-Control', 'max-age=31536000, immutable');
return await reply.redirect(url.toString(), 301);
}
const url = new URL(`${this.config.mediaProxy}/static.webp`);
url.searchParams.set('url', file.url);
url.searchParams.set('static', '1');
if (file.fileRole !== 'original') {
const filename = rename(file.filename, {
suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web',
extname: file.ext ? `.${file.ext}` : '.unknown',
}).toString();
file.cleanup();
return await reply.redirect(url.toString(), 301);
} else if (file.mime.startsWith('video/')) {
const externalThumbnail = this.videoProcessingService.getExternalVideoThumbnailUrl(file.url);
if (externalThumbnail) {
file.cleanup();
return await reply.redirect(externalThumbnail, 301);
}
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream');
reply.header('Cache-Control', 'max-age=31536000, immutable');
reply.header('Content-Disposition', contentDisposition('inline', filename));
if (request.headers.range && file.file.size > 0) {
const range = request.headers.range as string;
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
if (end > file.file.size) {
end = file.file.size - 1;
image = await this.videoProcessingService.generateVideoThumbnail(file.path);
}
}
const chunksize = end - start + 1;
const fileStream = fs.createReadStream(file.path, {
start,
end,
});
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', chunksize);
reply.code(206);
return fileStream;
if (file.fileRole === 'webpublic') {
if (['image/svg+xml'].includes(file.mime)) {
reply.header('Cache-Control', 'max-age=31536000, immutable');
const url = new URL(`${this.config.mediaProxy}/svg.webp`);
url.searchParams.set('url', file.url);
file.cleanup();
return await reply.redirect(url.toString(), 301);
}
}
if (!image) {
if (request.headers.range && file.file.size > 0) {
const range = request.headers.range as string;
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
if (end > file.file.size) {
end = file.file.size - 1;
}
const chunksize = end - start + 1;
image = {
data: fs.createReadStream(file.path, {
start,
end,
}),
ext: file.ext,
type: file.mime,
};
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', chunksize);
reply.code(206);
} else {
image = {
data: fs.createReadStream(file.path),
ext: file.ext,
type: file.mime,
};
}
}
if ('pipe' in image.data && typeof image.data.pipe === 'function') {
// image.dataがstreamなら、stream終了後にcleanup
image.data.on('end', file.cleanup);
image.data.on('close', file.cleanup);
} else {
// image.dataがstreamでないなら直ちにcleanup
file.cleanup();
}
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream');
reply.header('Content-Length', file.file.size);
reply.header('Cache-Control', 'max-age=31536000, immutable');
reply.header('Content-Disposition',
contentDisposition(
'inline',
correctFilename(file.filename, image.ext),
),
);
return image.data;
}
return fs.createReadStream(file.path);
} else {
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream');
reply.header('Content-Length', file.file.size);
reply.header('Cache-Control', 'max-age=31536000, immutable');
reply.header('Content-Disposition', contentDisposition('inline', file.filename));
if (file.fileRole !== 'original') {
const filename = rename(file.filename, {
suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web',
extname: file.ext ? `.${file.ext}` : '.unknown',
}).toString();
if (request.headers.range && file.file.size > 0) {
const range = request.headers.range as string;
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
if (end > file.file.size) {
end = file.file.size - 1;
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream');
reply.header('Cache-Control', 'max-age=31536000, immutable');
reply.header('Content-Disposition', contentDisposition('inline', filename));
if (request.headers.range && file.file.size > 0) {
const range = request.headers.range as string;
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
if (end > file.file.size) {
end = file.file.size - 1;
}
const chunksize = end - start + 1;
const fileStream = fs.createReadStream(file.path, {
start,
end,
});
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', chunksize);
reply.code(206);
return fileStream;
}
const chunksize = end - start + 1;
const fileStream = fs.createReadStream(file.path, {
start,
end,
});
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', chunksize);
reply.code(206);
return fileStream;
}
return fs.createReadStream(file.path);
return fs.createReadStream(file.path);
} else {
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream');
reply.header('Content-Length', file.file.size);
reply.header('Cache-Control', 'max-age=31536000, immutable');
reply.header('Content-Disposition', contentDisposition('inline', file.filename));
if (request.headers.range && file.file.size > 0) {
const range = request.headers.range as string;
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
if (end > file.file.size) {
end = file.file.size - 1;
}
const chunksize = end - start + 1;
const fileStream = fs.createReadStream(file.path, {
start,
end,
});
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', chunksize);
reply.code(206);
return fileStream;
}
return fs.createReadStream(file.path);
}
} catch (e) {
if ('cleanup' in file) file.cleanup();
throw e;
}
}
@bindThis
private async proxyHandler(request: FastifyRequest<{ Params: { url: string; }; Querystring: { url?: string; }; }>, reply: FastifyReply) {
const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url;
let url = 'url' in request.query ? request.query.url : 'https://' + request.params.url;
if (typeof url !== 'string') {
reply.code(400);
@ -204,56 +302,234 @@ export class FileServerService {
// アバタークロップなど、どうしてもオリジンである必要がある場合
const mustOrigin = 'origin' in request.query;
if (!this.config.mediaProxy) {
reply.code(501);
if (this.config.externalMediaProxyEnabled) {
// 外部のメディアプロキシが有効なら、そちらにリダイレクト
reply.header('Cache-Control', 'public, max-age=259200'); // 3 days
const externalURL = new URL(`${this.config.mediaProxy}/${request.params.url || ''}`);
for (const [key, value] of Object.entries(request.query)) {
externalURL.searchParams.append(key, value);
}
if (mustOrigin) {
url = `${this.config.mediaProxy}?url=${encodeURIComponent(url)}`;
} else {
return await reply.redirect(
externalURL.toString(),
301,
);
}
}
const proxiedURL = new URL(`${this.config.mediaProxy}/?url=${encodeURIComponent(url)}`);
for (const [key, value] of Object.entries(request.query)) {
if (key.toLowerCase() === 'url') continue;
proxiedURL.searchParams.append(key, value);
if (!request.headers['user-agent']) {
throw new StatusError('User-Agent is required', 400, 'User-Agent is required');
} else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) {
throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive');
}
if (!mustOrigin) {
return await reply.redirect(
proxiedURL.toString(),
301,
if (!request.headers['user-agent']) {
throw new StatusError('User-Agent is required', 400, 'User-Agent is required');
} else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) {
throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive');
}
// Create temp file
const file = await this.getStreamAndTypeFromUrl(url);
if (file === '404') {
reply.code(404);
reply.header('Cache-Control', 'max-age=86400');
return reply.sendFile('/dummy.png', assets);
}
if (file === '204') {
reply.code(204);
reply.header('Cache-Control', 'max-age=86400');
return;
}
try {
const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp');
const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image-with-bmp');
if (
'emoji' in request.query ||
'avatar' in request.query ||
'static' in request.query ||
'preview' in request.query ||
'badge' in request.query
) {
if (!isConvertibleImage) {
// 画像でないなら404でお茶を濁す
throw new StatusError('Unexpected mime', 404);
}
}
let image: IImageStreamable | null = null;
if ('emoji' in request.query || 'avatar' in request.query) {
if (!isAnimationConvertibleImage && !('static' in request.query)) {
image = {
data: fs.createReadStream(file.path),
ext: file.ext,
type: file.mime,
};
} else {
const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) }))
.resize({
height: 'emoji' in request.query ? 128 : 320,
withoutEnlargement: true,
})
.webp(webpDefault);
image = {
data,
ext: 'webp',
type: 'image/webp',
};
}
} else if ('static' in request.query) {
image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422);
} else if ('preview' in request.query) {
image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200);
} else if ('badge' in request.query) {
const mask = (await sharpBmp(file.path, file.mime))
.resize(96, 96, {
fit: 'contain',
position: 'centre',
withoutEnlargement: false,
})
.greyscale()
.normalise()
.linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast
.flatten({ background: '#000' })
.toColorspace('b-w');
const stats = await mask.clone().stats();
if (stats.entropy < 0.1) {
// エントロピーがあまりない場合は404にする
throw new StatusError('Skip to provide badge', 404);
}
const data = sharp({
create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
})
.pipelineColorspace('b-w')
.boolean(await mask.png().toBuffer(), 'eor');
image = {
data: await data.png().toBuffer(),
ext: 'png',
type: 'image/png',
};
} else if (file.mime === 'image/svg+xml') {
image = this.imageProcessingService.convertToWebpStream(file.path, 2048, 2048);
} else if (!file.mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(file.mime)) {
throw new StatusError('Rejected type', 403, 'Rejected type');
}
if (!image) {
if (request.headers.range && file.file && file.file.size > 0) {
const range = request.headers.range as string;
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
if (end > file.file.size) {
end = file.file.size - 1;
}
const chunksize = end - start + 1;
image = {
data: fs.createReadStream(file.path, {
start,
end,
}),
ext: file.ext,
type: file.mime,
};
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', chunksize);
reply.code(206);
} else {
image = {
data: fs.createReadStream(file.path),
ext: file.ext,
type: file.mime,
};
}
}
if ('cleanup' in file) {
if ('pipe' in image.data && typeof image.data.pipe === 'function') {
// image.dataがstreamなら、stream終了後にcleanup
image.data.on('end', file.cleanup);
image.data.on('close', file.cleanup);
} else {
// image.dataがstreamでないなら直ちにcleanup
file.cleanup();
}
}
reply.header('Content-Type', image.type);
reply.header('Cache-Control', 'max-age=31536000, immutable');
reply.header('Content-Disposition',
contentDisposition(
'inline',
correctFilename(file.filename, image.ext),
),
);
return image.data;
} catch (e) {
if ('cleanup' in file) file.cleanup();
throw e;
}
}
@bindThis
private async getStreamAndTypeFromUrl(url: string): Promise<
{ state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: MiDriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }
| { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; mime: string; ext: string | null; path: string; }
| '404'
| '204'
> {
if (url.startsWith(`${this.config.url}/files/`)) {
const key = url.replace(`${this.config.url}/files/`, '').split('/').shift();
if (!key) throw new StatusError('Invalid File Key', 400, 'Invalid File Key');
return await this.getFileFromKey(key);
}
reply.header('Cache-Control', 'public, max-age=259200'); // 3 days
return await this.downloadAndDetectTypeFromUrl(url);
}
if (!request.headers['user-agent']) {
throw new StatusError('User-Agent is required', 400, 'User-Agent is required');
} else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) {
throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive');
@bindThis
private async downloadAndDetectTypeFromUrl(url: string): Promise<
{ state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }
> {
const [path, cleanup] = await createTemp();
try {
const { filename } = await this.downloadService.downloadUrl(url, path);
const { mime, ext } = await this.fileInfoService.detectType(path);
return {
state: 'remote',
mime, ext,
path, cleanup,
filename,
};
} catch (e) {
cleanup();
throw e;
}
if (!request.headers['user-agent']) {
throw new StatusError('User-Agent is required', 400, 'User-Agent is required');
} else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) {
throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive');
}
// directly proxy request through
const res = await fetch(proxiedURL, {
headers: {
'X-Forwarded-For': request.headers['x-forwarded-for']?.at(0) ?? request.ip,
'User-Agent': request.headers['user-agent'],
},
});
reply.code(res.status);
for (const [key, value] of res.headers.entries()) {
reply.header(key, value);
}
reply.send(res.body);
}
@bindThis
private async getFileFromKey(key: string): Promise<
{ state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; filename: string; url: string; }
{ state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; }
| { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; mime: string; ext: string | null; path: string; }
| '404'
| '204'
@ -272,10 +548,15 @@ export class FileServerService {
if (!file.storedInternal) {
if (!(file.isLink && file.uri)) return '204';
return { state: 'remote',
fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original',
filename: file.name
, url: file.uri };
const result = await this.downloadAndDetectTypeFromUrl(file.uri);
file.size = (await fs.promises.stat(result.path)).size; // DB file.sizeは正確とは限らないので
return {
...result,
url: file.uri,
fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original',
file,
filename: file.name,
};
}
const path = this.internalStorageService.resolvePath(key);

View file

@ -33,6 +33,7 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import { makeHstsHook } from './hsts.js';
import { generateCSP } from './csp.js';
import * as prom from 'prom-client';
import { sanitizeRequestURI } from '@/misc/log-sanitization.js';
import { metricCounter, metricGauge, metricHistogram, MetricsService } from './api/MetricsService.js';
@ -57,7 +58,7 @@ function categorizeRequestPath(path: string): 'api' | 'health' | 'vite' | 'other
const mRequestTime = metricHistogram({
name: 'misskey_http_request_duration_seconds',
help: 'Duration of handling HTTP requests in seconds',
labelNames: ['cate', 'method', 'path'],
labelNames: ['host', 'cate', 'method', 'path'],
buckets: [0.001, 0.1, 0.5, 1, 2, 5],
});
@ -88,19 +89,19 @@ const mTooManyRequestsServed = metricCounter({
const mAggregateRequestsServed = metricCounter({
name: 'misskey_http_requests_served_total',
help: 'Total number of HTTP requests served including invalid requests',
labelNames: ['cate', 'status'],
labelNames: ['host', 'cate', 'status'],
});
const mRequestsServedByPath = metricCounter({
name: 'misskey_http_requests_served_by_path',
help: 'Total number of HTTP requests served',
labelNames: ['cate', 'method', 'path', 'status'],
labelNames: ['host', 'cate', 'method', 'path', 'status'],
});
const mFatalErrorCount = metricCounter({
name: 'misskey_fatal_http_errors_total',
help: 'Total number of HTTP errors that propagate to the top level',
labelNames: ['cate', 'method', 'path'],
labelNames: ['host', 'cate', 'method', 'path'],
});
const mLastSuccessfulRequest = metricGauge({
@ -109,11 +110,6 @@ const mLastSuccessfulRequest = metricGauge({
labelNames: [],
});
// This function is used to determine if a path is safe to redirect to.
function redirectSafePath(path: string): boolean {
return ['/files/', '/identicon/', '/proxy/', '/static-assets/', '/vite/', '/embed_vite/'].some(prefix => path.startsWith(prefix));
}
@Injectable()
export class ServerService implements OnApplicationShutdown {
private logger: Logger;
@ -172,6 +168,7 @@ export class ServerService implements OnApplicationShutdown {
const url = new URL(request.url, this.config.url);
const logPath = sanitizeRequestURI(url.pathname);
mFatalErrorCount?.inc({
host: request.hostname,
method: request.method,
path: logPath,
cate: categorizeRequestPath(logPath),
@ -186,6 +183,7 @@ export class ServerService implements OnApplicationShutdown {
const received = reply.getHeader('x-request-received') as string;
mAggregateRequestsServed?.inc({
host: request.hostname,
cate,
status: reply.statusCode,
});
@ -209,6 +207,7 @@ export class ServerService implements OnApplicationShutdown {
if (received) {
const duration = (+new Date()) - parseInt(received);
mRequestTime?.observe({
host: request.hostname,
method: request.method,
cate,
}, duration / 1000);
@ -231,6 +230,7 @@ export class ServerService implements OnApplicationShutdown {
const duration = (+new Date()) - parseInt(received);
mRequestTime?.observe({
host: request.hostname,
method: request.method,
cate,
path: logPath,
@ -249,6 +249,7 @@ export class ServerService implements OnApplicationShutdown {
}
mRequestsServedByPath?.inc({
host: request.hostname,
method: request.method,
path: logPath,
cate,
@ -273,17 +274,7 @@ export class ServerService implements OnApplicationShutdown {
// Other Security/Privacy Headers
fastify.addHook('onRequest', (_, reply, done) => {
reply.header('x-content-type-options', 'nosniff');
reply.header('permissions-policy',
[
'interest-cohort',
'encrypted-media',
'attribution-reporting',
'geolocation', 'microphone', 'camera',
'midi', 'payment', 'usb', 'serial',
'xr-spatial-tracking'
]
.map(feature => `${feature}=()`).join(', '));
reply.header('permissions-policy', 'interest-cohort=()'); // Disable FLoC
if (this.config.browserSandboxing.strictOriginReferrer) {
reply.header('referrer-policy', 'strict-origin');
}
@ -357,7 +348,7 @@ export class ServerService implements OnApplicationShutdown {
name: name,
});
reply.header('Content-Security-Policy', 'default-src \'none\'');
reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\'');
if (emoji == null) {
if ('fallback' in request.query) {
@ -368,26 +359,16 @@ export class ServerService implements OnApplicationShutdown {
}
}
const dbUrl = emoji?.publicUrl || emoji?.originalUrl;
const dbUrlParsed = new URL(dbUrl);
const instanceUrl = new URL(this.config.url);
if (dbUrlParsed.origin === instanceUrl.origin) {
if (!redirectSafePath(dbUrlParsed.pathname)) {
return await reply.status(508);
}
return await reply.redirect(dbUrl, 301);
}
let url: URL;
if ('badge' in request.query) {
url = new URL(`${this.config.mediaProxy}/emoji.png`);
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url.searchParams.set('url', dbUrl);
url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl);
url.searchParams.set('badge', '1');
} else {
url = new URL(`${this.config.mediaProxy}/emoji.webp`);
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url.searchParams.set('url', dbUrl);
url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl);
url.searchParams.set('emoji', '1');
if ('static' in request.query) url.searchParams.set('static', '1');
}
@ -411,16 +392,6 @@ export class ServerService implements OnApplicationShutdown {
reply.header('Cache-Control', 'public, max-age=86400');
if (user) {
const dbUrl = user?.avatarUrl ?? this.userEntityService.getIdenticonUrl(user);
const dbUrlParsed = new URL(dbUrl);
const instanceUrl = new URL(this.config.url);
if (dbUrlParsed.origin === instanceUrl.origin) {
if (!redirectSafePath(dbUrlParsed.pathname)) {
return await reply.status(508);
}
return await reply.redirect(dbUrl, 301);
}
reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user));
} else {
reply.redirect('/static-assets/user-unknown.png');

View file

@ -255,7 +255,7 @@ export class SigninApiService {
});
}
const authorized = await this.webAuthnService.verifyAuthentication(user.id, body.credential, !profile.usePasswordLessLogin);
const authorized = await this.webAuthnService.verifyAuthentication(user.id, body.credential);
if (authorized) {
return this.signinService.signin(request, reply, user);

View file

@ -46,7 +46,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new Error('cannot delete a root account');
}
await this.deleteAccoountService.deleteAccount(user, me);
await this.deleteAccoountService.deleteAccount(user);
});
}
}

View file

@ -33,13 +33,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private deleteAccountService: DeleteAccountService,
) {
super(meta, paramDef, async (ps, me) => {
super(meta, paramDef, async (ps) => {
const user = await this.usersRepository.findOneByOrFail({ id: ps.userId });
if (user.isDeleted) {
return;
}
await this.deleteAccountService.deleteAccount(user, me);
await this.deleteAccountService.deleteAccount(user);
});
}
}

View file

@ -11,7 +11,6 @@ import { ApResolverService } from '@/core/activitypub/ApResolverService.js';
export const meta = {
tags: ['federation'],
requireAdmin: true,
requireCredential: true,
kind: 'read:federation',

View file

@ -19,7 +19,6 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { ApiError } from '../../error.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
export const meta = {
tags: ['federation'],
@ -33,31 +32,6 @@ export const meta = {
},
errors: {
federationNotAllowed: {
message: 'Federation for this host is not allowed.',
code: 'FEDERATION_NOT_ALLOWED',
id: '974b799e-1a29-4889-b706-18d4dd93e266',
},
uriInvalid: {
message: 'URI is invalid.',
code: 'URI_INVALID',
id: '1a5eab56-e47b-48c2-8d5e-217b897d70db',
},
requestFailed: {
message: 'Request failed.',
code: 'REQUEST_FAILED',
id: '81b539cf-4f57-4b29-bc98-032c33c0792e',
},
responseInvalid: {
message: 'Response from remote server is invalid.',
code: 'RESPONSE_INVALID',
id: '70193c39-54f3-4813-82f0-70a680f7495b',
},
responseInvalidIdHostNotMatch: {
message: 'Requested URI and response URI host does not match.',
code: 'RESPONSE_INVALID_ID_HOST_NOT_MATCH',
id: 'a2c9c61a-cb72-43ab-a964-3ca5fddb410a',
},
noSuchObject: {
message: 'No such object.',
code: 'NO_SUCH_OBJECT',
@ -136,9 +110,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
*/
@bindThis
private async fetchAny(uri: string, me: MiLocalUser | null | undefined): Promise<SchemaType<typeof meta['res']> | null> {
if (!this.utilityService.isFederationAllowedUri(uri)) {
throw new ApiError(meta.errors.federationNotAllowed);
}
if (!this.utilityService.isFederationAllowedUri(uri)) return null;
let local = await this.mergePack(me, ...await Promise.all([
this.apDbResolverService.getUserFromApId(uri),
@ -146,47 +118,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
]));
if (local != null) return local;
const host = this.utilityService.extractDbHost(uri);
// local object, not found in db? fail
if (this.utilityService.isSelfHost(host)) return null;
// リモートから一旦オブジェクトフェッチ
const resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(uri).catch((err) => {
if (err instanceof IdentifiableError) {
switch (err.id) {
// resolve
case 'b94fd5b1-0e3b-4678-9df2-dad4cd515ab2':
throw new ApiError(meta.errors.uriInvalid);
case '0dc86cf6-7cd6-4e56-b1e6-5903d62d7ea5':
case 'd592da9f-822f-4d91-83d7-4ceefabcf3d2':
throw new ApiError(meta.errors.requestFailed);
case '09d79f9e-64f1-4316-9cfa-e75c4d091574':
throw new ApiError(meta.errors.federationNotAllowed);
case '72180409-793c-4973-868e-5a118eb5519b':
case 'ad2dc287-75c1-44c4-839d-3d2e64576675':
throw new ApiError(meta.errors.responseInvalid);
case 'fd93c2fa-69a8-440f-880b-bf178e0ec877':
throw new ApiError(meta.errors.responseInvalidIdHostNotMatch);
// resolveLocal
case '02b40cd0-fa92-4b0c-acc9-fb2ada952ab8':
throw new ApiError(meta.errors.uriInvalid);
case 'a9d946e5-d276-47f8-95fb-f04230289bb0':
case '06ae3170-1796-4d93-a697-2611ea6d83b6':
throw new ApiError(meta.errors.noSuchObject);
case '7a5d2fc0-94bc-4db6-b8b8-1bf24a2e23d0':
throw new ApiError(meta.errors.responseInvalid);
}
}
throw new ApiError(meta.errors.requestFailed);
});
if (object.id == null) {
throw new ApiError(meta.errors.responseInvalid);
}
const object = await resolver.resolve(uri) as any;
// /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する
// これはDBに存在する可能性があるため再度DB検索
@ -198,11 +132,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (local != null) return local;
}
// 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない
return await this.mergePack(
me,
isActor(object) ? await this.apPersonService.createPerson(getApId(object)) : null,
isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, undefined, true) : null,
isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, true) : null,
);
}

View file

@ -95,7 +95,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.twoFactorNotEnabled);
}
const keyInfo = await this.webAuthnService.verifyRegistration(me.id, ps.credential, !profile.usePasswordLessLogin);
const keyInfo = await this.webAuthnService.verifyRegistration(me.id, ps.credential);
const keyId = keyInfo.credentialID;
await this.userSecurityKeysRepository.insert({

View file

@ -106,7 +106,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
id: In(ps.userIds),
} : {
id: In(ps.userIds),
isDeleted: false,
isSuspended: false,
});

View file

@ -183,7 +183,7 @@ export function genOpenapiSpec(config: Config, includeSelfRef = false) {
},
...(endpoint.meta.limit ? {
'429': {
description: 'Too many requests',
description: 'To many requests',
content: {
'application/json': {
schema: {

View file

@ -7,6 +7,6 @@ export const commonPugFilters = {
throw new Error('Invalid mimeType');
}
const dataURI = `data:${options.mimeType};base64,${Buffer.from(data).toString('base64')}`;
return `<${options.tagName} src="${dataURI}"></${options.tagName}>`;
return `<${options.tagName} data="${dataURI}"></${options.tagName}>`;
}
} as const;

View file

@ -248,6 +248,16 @@ export class ClientServerService {
fastify.addHook('onRequest', makeHstsHook(host, preload));
}
// Other Security/Privacy Headers
fastify.addHook('onRequest', (_, reply, done) => {
reply.header('x-content-type-options', 'nosniff');
reply.header('permissions-policy', 'interest-cohort=()'); // Disable FLoC
if (this.config.browserSandboxing.strictOriginReferrer ?? true) {
reply.header('referrer-policy', 'strict-origin');
}
done();
});
// CSP
if (process.env.NODE_ENV === 'production') {
console.debug('cspPrerenderedContent', this.config.cspPrerenderedContent);
@ -493,7 +503,6 @@ export class ClientServerService {
// ServiceWorker
fastify.get('/sw.js', async (request, reply) => {
reply.header('content-security-policy', `default-src \'self'; connect-src \'self\'${ this.config.mediaProxy ? ` ${new URL(this.config.mediaProxy).origin}` : '' }`);
return await reply.sendFile('/sw.js', swAssets, {
maxAge: ms('10 minutes'),
});
@ -552,7 +561,6 @@ export class ClientServerService {
usernameLower: username.toLowerCase(),
host: host ?? IsNull(),
isSuspended: false,
requireSigninToViewContents: false,
});
return user && await this.feedService.packFeed(user);
@ -603,25 +611,16 @@ export class ClientServerService {
}
});
//#region SSR
//#region SSR (for crawlers)
// User
fastify.get<{ Params: { user: string; sub?: string; } }>('/@:user/:sub?', async (request, reply) => {
const { username, host } = Acct.parse(request.params.user);
if (host) {
return await renderBase(reply); // リモートユーザーのページはSSRしない (プライバシーの観点から)
}
const user = await this.usersRepository.findOneBy({
usernameLower: username.toLowerCase(),
host: host ?? IsNull(),
isSuspended: false,
});
if (user?.requireSigninToViewContents) {
return await renderBase(reply);
}
vary(reply.raw, 'Accept');
if (user != null) {
@ -637,19 +636,11 @@ export class ClientServerService {
reply.header('X-Robots-Tag', 'noimageai');
reply.header('X-Robots-Tag', 'noai');
}
const _user = await this.userEntityService.pack(user, null, {
schema: host ? 'UserLite' : 'UserDetailedNotMe' // リモートユーザーの場合は詳細情報を返さない
});
return await reply.view('user', {
user, profile, me,
avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
sub: request.params.sub,
...await this.generateCommonPugData(this.meta),
clientCtx: htmlSafeJsonStringify({
user: _user,
}),
});
} else {
// リモートユーザーなので
@ -663,7 +654,6 @@ export class ClientServerService {
id: request.params.user,
host: IsNull(),
isSuspended: false,
requireSigninToViewContents: false,
});
if (user == null) {
@ -703,9 +693,6 @@ export class ClientServerService {
// TODO: Let locale changeable by instance setting
summary: getNoteSummary(_note),
...await this.generateCommonPugData(this.meta),
clientCtx: htmlSafeJsonStringify({
note: _note,
}),
});
} else {
return await renderBase(reply);
@ -715,14 +702,9 @@ export class ClientServerService {
// Page
fastify.get<{ Params: { user: string; page: string; } }>('/@:user/pages/:page', async (request, reply) => {
const { username, host } = Acct.parse(request.params.user);
if (host) {
return await renderBase(reply); // リモートユーザーのページはSSRしない
}
const user = await this.usersRepository.findOneBy({
usernameLower: username.toLowerCase(),
host: IsNull(),
host: host ?? IsNull(),
});
if (user == null) return;
@ -799,9 +781,6 @@ export class ClientServerService {
profile,
avatarUrl: _clip.user.avatarUrl,
...await this.generateCommonPugData(this.meta),
clientCtx: htmlSafeJsonStringify({
clip: _clip,
}),
});
} else {
return await renderBase(reply);
@ -929,7 +908,7 @@ export class ClientServerService {
});
if (note == null) return;
if (['specified', 'followers'].includes(note.visibility)) return;
if (note.visibility !== 'public') return;
if (note.userHost != null) return;
const _note = await this.noteEntityService.pack(note, null, { detail: true });

View file

@ -118,6 +118,7 @@ export class UrlPreviewService {
private fetchSummary(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
const agent = this.config.proxy
? {
http: this.httpRequestService.httpAgent,
https: this.httpRequestService.httpsAgent,
}
: undefined;
@ -144,6 +145,6 @@ export class UrlPreviewService {
contentLengthRequired: meta.urlPreviewRequireContentLength,
});
return this.httpRequestService.getJson<SummalyResult>(`${proxy}?${queryStr}`, 'application/json, */*', undefined, true);
return this.httpRequestService.getJson<SummalyResult>(`${proxy}?${queryStr}`);
}
}

View file

@ -5,136 +5,6 @@
'use strict';
class Systemd {
constructor(version, cmdline) {
this.tty_dom = document.querySelector('#tty');
const welcome = document.createElement('div');
welcome.className = 'tty-line';
welcome.innerText = `YumechiNoKuni ${version} running in Web mode. (+mproxy, +metrics, +csp) cmdline: ${cmdline}`;
this.tty_dom.appendChild(welcome);
}
async start(id, promise) {
let state = { state: 'running' };
let persistentDom = null;
const started = Date.now();
const formatRunning = () => {
const shiftArray = (arr, n) => {
return arr.slice(n).concat(arr.slice(0, n));
};
const elapsed_secs = Math.floor((Date.now() - started) / 1000);
const stars = shiftArray([' ', '*', '*', '*', ' ', ' '], elapsed_secs % 6);
const spanStatus = document.createElement('span');
spanStatus.innerText = stars.join('');
spanStatus.className = 'tty-status-running';
const spanMessage = document.createElement('span');
spanMessage.innerText = `A start job is running for ${id} (${elapsed_secs}s / no limit)`;
const div = document.createElement('div');
div.className = 'tty-line';
div.innerHTML = '[';
div.appendChild(spanStatus);
div.innerHTML += '] ';
div.appendChild(spanMessage);
return div;
};
const formatDone = () => {
const elapsed_secs = (Date.now() - started) / 1000;
const spanStatus = document.createElement('span');
spanStatus.innerText = ' OK ';
spanStatus.className = 'tty-status-ok';
const spanMessage = document.createElement('span');
spanMessage.innerText = `Finished ${id} in ${elapsed_secs.toFixed(3)}s`;
const div = document.createElement('div');
div.className = 'tty-line';
div.innerHTML = '[';
div.appendChild(spanStatus);
div.innerHTML += '] ';
div.appendChild(spanMessage);
return div;
};
const formatFailed = (message) => {
const elapsed_secs = (Date.now() - started) / 1000;
const spanStatus = document.createElement('span');
spanStatus.innerText = 'FAILED';
spanStatus.className = 'tty-status-failed';
const spanMessage = document.createElement('span');
spanMessage.innerText = `Failed ${id} in ${elapsed_secs.toFixed(3)}s: ${message}`;
const div = document.createElement('div');
div.className = 'tty-line';
div.innerHTML = '[';
div.appendChild(spanStatus);
div.innerHTML += '] ';
div.appendChild(spanMessage);
return div;
};
const render = () => {
switch (state.state) {
case 'running':
if (persistentDom === null) {
persistentDom = formatRunning();
this.tty_dom.appendChild(persistentDom);
}
else {
persistentDom.innerHTML = formatRunning().innerHTML;
}
break;
case 'done':
if (persistentDom === null) {
persistentDom = formatDone();
this.tty_dom.appendChild(persistentDom);
}
else {
persistentDom.innerHTML = formatDone().innerHTML;
}
break;
case 'failed':
if (persistentDom === null) {
persistentDom = formatFailed(state.message);
this.tty_dom.appendChild(persistentDom);
}
else {
persistentDom.innerHTML = formatFailed(state.message).innerHTML;
}
break;
}
};
render();
const interval = setInterval(render, 500);
try {
let res = await promise;
state = { state: 'done' };
return res;
}
catch (e) {
if (e instanceof Error) {
state = { state: 'failed', message: e.message };
}
else {
state = { state: 'failed', message: 'Unknown error' };
}
throw e;
}
finally {
clearInterval(interval);
render();
}
}
async startSync(id, func) {
return this.start(id, (async () => {
return func();
})());
}
emergency_mode(code, details) {``
const divPrev = document.createElement('div');
divPrev.className = 'tty-line';
divPrev.innerText = 'Critical error occurred [' + code + '] : ' + details.message ? details.message : details;
this.tty_dom.appendChild(divPrev);
const div = document.createElement('div');
div.className = 'tty-line';
div.innerText = 'You are in emergency mode. Type Ctrl-Shift-I to view system logs. Clearing local storage by going to /flush and browser settings may help.';
this.tty_dom.appendChild(div);
}
}
// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので
(async () => {
window.onerror = (e) => {
@ -146,24 +16,10 @@ class Systemd {
renderError('SOMETHING_HAPPENED_IN_PROMISE', e);
};
const cmdline = new URLSearchParams(location.search).get('cmdline') || '';
const cmdlineArray = cmdline.split(',').map(x => x.trim());
if (cmdlineArray.includes('nosplash')) {
document.querySelector('#splashIcon').classList.add('hidden');
document.querySelector('#splashSpinner').classList.add('hidden');
}
const systemd = new Systemd(VERSION, cmdline);
if (cmdlineArray.includes('leak')) {
await systemd.start('Promise Leak Service', new Promise(() => { }));
}
let forceError = localStorage.getItem('forceError');
if (forceError != null) {
await systemd.startSync('Force Error Service', () => {
throw new Error('This error is forced by having forceError in local storage.');
});
renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.');
return;
}
//#region Detect language & fetch translations
@ -181,7 +37,7 @@ class Systemd {
}
}
const metaRes = await systemd.start('Fetch /api/meta',window.fetch('/api/meta', {
const metaRes = await window.fetch('/api/meta', {
method: 'POST',
body: JSON.stringify({}),
credentials: 'omit',
@ -189,12 +45,12 @@ class Systemd {
headers: {
'Content-Type': 'application/json',
},
}));
});
if (metaRes.status !== 200) {
renderError('META_FETCH');
return;
}
const meta = await systemd.start('Parse /api/meta', metaRes.json());
const meta = await metaRes.json();
const v = meta.version;
if (v == null) {
renderError('META_FETCH_V');
@ -207,7 +63,7 @@ class Systemd {
lang = 'en-US';
}
const localRes = await systemd.start('Fetch Locale files', window.fetch(`/assets/locales/${lang}.${v}.json`));
const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`);
if (localRes.status === 200) {
localStorage.setItem('lang', lang);
localStorage.setItem('locale', await localRes.text());
@ -221,25 +77,19 @@ class Systemd {
//#region Script
async function importAppScript() {
await systemd.start('Load App Script', import(`/vite/${CLIENT_ENTRY}`))
await import(`/vite/${CLIENT_ENTRY}`)
.catch(async e => {
console.error(e);
renderError('APP_IMPORT', e);
});
}
if (cmdlineArray.includes('fail')) {
await systemd.startSync('Force Error Service', () => {
throw new Error('This error is forced by having fail in command line.');
});
}
// タイミングによっては、この時点でDOMの構築が済んでいる場合とそうでない場合とがある
if (document.readyState !== 'loading') {
systemd.start('import App Script', importAppScript());
importAppScript();
} else {
window.addEventListener('DOMContentLoaded', () => {
systemd.start('import App Script', importAppScript());
importAppScript();
});
}
//#endregion
@ -247,21 +97,19 @@ class Systemd {
//#region Theme
const theme = localStorage.getItem('theme');
if (theme) {
await systemd.startSync('Apply theme', () => {
for (const [k, v] of Object.entries(JSON.parse(theme))) {
document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString());
for (const [k, v] of Object.entries(JSON.parse(theme))) {
document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString());
// HTMLの theme-color 適用
if (k === 'htmlThemeColor') {
for (const tag of document.head.children) {
if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
tag.setAttribute('content', v);
break;
}
// HTMLの theme-color 適用
if (k === 'htmlThemeColor') {
for (const tag of document.head.children) {
if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
tag.setAttribute('content', v);
break;
}
}
}
});
}
}
const colorScheme = localStorage.getItem('colorScheme');
if (colorScheme) {
@ -286,22 +134,181 @@ class Systemd {
const customCss = localStorage.getItem('customCss');
if (customCss && customCss.length > 0) {
await systemd.startSync('Apply custom CSS', () => {
const style = document.createElement('style');
style.innerHTML = customCss;
document.head.appendChild(style);
});
const style = document.createElement('style');
style.innerHTML = customCss;
document.head.appendChild(style);
}
async function addStyle(styleText) {
await systemd.startSync('Apply custom Style', () => {
let css = document.createElement('style');
css.appendChild(document.createTextNode(styleText));
document.head.appendChild(css);
});
let css = document.createElement('style');
css.appendChild(document.createTextNode(styleText));
document.head.appendChild(css);
}
async function renderError(code, details) {
systemd.emergency_mode(code, details);
// Cannot set property 'innerHTML' of null を回避
if (document.readyState === 'loading') {
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
}
let errorsElement = document.getElementById('errors');
if (!errorsElement) {
document.body.innerHTML = `
<svg class="icon-warning" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 9v2m0 4v.01"></path>
<path d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75"></path>
</svg>
<h1>Failed to load<br>読み込みに失敗しました</h1>
<button class="button-big" onclick="location.reload(true);">
<span class="button-label-big">Reload / リロード</span>
</button>
<p><b>The following actions may solve the problem. / 以下を行うと解決する可能性があります</b></p>
<p>Update your os and browser / ブラウザおよびOSを最新バージョンに更新する</p>
<p>Disable an adblocker / アドブロッカーを無効にする</p>
<p>Clear the browser cache / ブラウザのキャッシュをクリアする</p>
<p>&#40;Tor Browser&#41; Set dom.webaudio.enabled to true / dom.webaudio.enabledをtrueに設定する</p>
<details style="color: #86b300;">
<summary>Other options / その他のオプション</summary>
<a href="/flush">
<button class="button-small">
<span class="button-label-small">Clear preferences and cache</span>
</button>
</a>
<br>
<a href="/cli">
<button class="button-small">
<span class="button-label-small">Start the simple client</span>
</button>
</a>
<br>
<a href="/bios">
<button class="button-small">
<span class="button-label-small">Start the repair tool</span>
</button>
</a>
</details>
<br>
<div id="errors"></div>
`;
errorsElement = document.getElementById('errors');
}
const detailsElement = document.createElement('details');
detailsElement.id = 'errorInfo';
detailsElement.innerHTML = `
<br>
<summary>
<code>ERROR CODE: ${code}</code>
</summary>
<code>${details.toString()} ${JSON.stringify(details)}</code>`;
errorsElement.appendChild(detailsElement);
addStyle(`
* {
font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
}
#misskey_app,
#splash {
display: none !important;
}
body,
html {
background-color: #222;
color: #dfddcc;
justify-content: center;
margin: auto;
padding: 10px;
text-align: center;
}
button {
border-radius: 999px;
padding: 0px 12px 0px 12px;
border: none;
cursor: pointer;
margin-bottom: 12px;
}
.button-big {
background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0));
line-height: 50px;
}
.button-big:hover {
background: rgb(153, 204, 0);
}
.button-small {
background: #444;
line-height: 40px;
}
.button-small:hover {
background: #555;
}
.button-label-big {
color: #222;
font-weight: bold;
font-size: 1.2em;
padding: 12px;
}
.button-label-small {
color: rgb(153, 204, 0);
font-size: 16px;
padding: 12px;
}
a {
color: rgb(134, 179, 0);
text-decoration: none;
}
p,
li {
font-size: 16px;
}
.icon-warning {
color: #dec340;
height: 4rem;
padding-top: 2rem;
}
h1 {
font-size: 1.5em;
margin: 1em;
}
code {
font-family: Fira, FiraCode, monospace;
}
#errorInfo {
background: #333;
margin-bottom: 2rem;
padding: 0.5rem 1rem;
width: 40rem;
border-radius: 10px;
justify-content: center;
margin: auto;
}
#errorInfo summary {
cursor: pointer;
}
#errorInfo summary > * {
display: inline;
}
@media screen and (max-width: 500px) {
#errorInfo {
width: 50%;
}
}`);
}
})();

View file

@ -9,32 +9,6 @@ html {
color: var(--MI_THEME-fg);
}
.hidden {
display: none !important;
}
#tty {
z-index: 10001;
opacity: 1;
}
#tty > .tty-line {
font-family: 'Courier New', Courier, monospace !important;
display: block;
}
#tty > .tty-line .tty-status-ok {
color: green;
}
#tty > .tty-line .tty-status-failed {
color: darkred;
}
#tty > .tty-line .tty-status-running {
color: red;
}
#splash {
position: fixed;
z-index: 10000;
@ -68,7 +42,7 @@ html {
left: 0;
margin: auto;
display: inline-block;
width: 60px;
width: 28px;
height: 28px;
transform: translateY(70px);
color: var(--MI_THEME-accent);
@ -86,16 +60,6 @@ html {
stroke-linejoin: round;
stroke-miterlimit: 1.5;
}
#splashSpinner > .spinner.bg circle {
fill:none;
stroke:currentColor;
stroke-width:24px;
}
#splashSpinner > .spinner.fg path {
fill:none;
stroke:currentColor;
stroke-width:24px;
}
#splashSpinner > .spinner.bg {
opacity: 0.275;
}

View file

@ -64,7 +64,7 @@ html.embed.noborder #splash {
left: 0;
margin: auto;
display: inline-block;
width: 60px;
width: 28px;
height: 28px;
transform: translateY(70px);
color: var(--MI_THEME-accent);
@ -82,16 +82,6 @@ html.embed.noborder #splash {
stroke-linejoin: round;
stroke-miterlimit: 1.5;
}
#splashSpinner > .spinner.bg circle {
fill:none;
stroke:currentColor;
stroke-width:24px;
}
#splashSpinner > .spinner.fg path {
fill:none;
stroke:currentColor;
stroke-width:24px;
}
#splashSpinner > .spinner.bg {
opacity: 0.275;
}

View file

@ -1,151 +0,0 @@
export class Systemd {
private tty_dom: HTMLDivElement;
constructor() {
this.tty_dom = document.querySelector('#tty') as HTMLDivElement;
console.log('Systemd started');
}
async start<T>(id: string, promise: Promise<T>): Promise<T> {
let state: {
state: 'running'
} | {
state: 'done'
} | {
state: 'failed'
message: string
} = { state: 'running' };
let persistentDom : HTMLDivElement | null = null;
const started = Date.now();
const formatRunning = () => {
const shiftArray = <T>(arr: T[], n: number): T[] => {
return arr.slice(n).concat(arr.slice(0, n));
};
const elapsed_secs = Math.floor((Date.now() - started) / 1000);
const stars = shiftArray(['*', '*', '*', ' ', ' ', ' '], elapsed_secs % 6);
const spanStatus = document.createElement('span');
spanStatus.innerText = stars.join('');
spanStatus.className = 'tty-status-running';
const spanMessage = document.createElement('span');
spanMessage.innerText = `A start job is running for ${id} (${elapsed_secs}s / no limit)`;
const div = document.createElement('div');
div.className = 'tty-line';
div.innerHTML = '[';
div.appendChild(spanStatus);
div.innerHTML += '] ';
div.appendChild(spanMessage);
return div;
}
const formatDone = () => {
const elapsed_secs = Math.floor((Date.now() - started) / 1000);
const spanStatus = document.createElement('span');
spanStatus.innerText = ' OK ';
spanStatus.className = 'tty-status-ok';
const spanMessage = document.createElement('span');
spanMessage.innerText = `Finished ${id} in ${elapsed_secs}s`;
const div = document.createElement('div');
div.className = 'tty-line';
div.innerHTML = '[';
div.appendChild(spanStatus);
div.innerHTML += '] ';
div.appendChild(spanMessage);
return div;
}
const formatFailed = (message: string) => {
const elapsed_secs = Math.floor((Date.now() - started) / 1000);
const spanStatus = document.createElement('span');
spanStatus.innerText = 'FAILED';
spanStatus.className = 'tty-status-failed';
const spanMessage = document.createElement('span');
spanMessage.innerText = `Failed ${id} in ${elapsed_secs}s: ${message}`;
const div = document.createElement('div');
div.className = 'tty-line';
div.innerHTML = '[';
div.appendChild(spanStatus);
div.innerHTML += '] ';
div.appendChild(spanMessage);
return div;
}
const render = () => {
switch (state.state) {
case 'running':
if (persistentDom === null) {
persistentDom = formatRunning();
this.tty_dom.appendChild(persistentDom);
} else {
persistentDom.innerHTML = formatRunning().innerHTML;
}
break;
case 'done':
if (persistentDom === null) {
persistentDom = formatDone();
this.tty_dom.appendChild(persistentDom);
} else {
persistentDom.innerHTML = formatDone().innerHTML;
}
break;
case 'failed':
if (persistentDom === null) {
persistentDom = formatFailed(state.message);
this.tty_dom.appendChild(persistentDom);
} else {
persistentDom.innerHTML = formatFailed(state.message).innerHTML;
}
break;
}
};
render();
const interval = setInterval(render, 500);
try {
let res = await promise;
state = { state: 'done' };
return res;
} catch (e) {
if (e instanceof Error) {
state = { state: 'failed', message: e.message };
} else {
state = { state: 'failed', message: 'Unknown error' };
}
throw e;
} finally {
clearInterval(interval);
render();
}
}
async startSync<T>(id: string, func: () => T): Promise<T> {
return this.start(id, (async () => {
return func();
})());
}
public emergency_mode() {
const div = document.createElement('div');
div.className = 'tty-line';
div.innerText = 'You are in emergency mode. Type Ctrl-Shift-I to view logs.';
this.tty_dom.appendChild(div);
}
}

View file

@ -56,8 +56,18 @@ html(class='embed')
br
| Please turn on your JavaScript
div#splash
div#tty
img#splashIcon(src= icon || '/static-assets/splash.png')
div#splashSpinner
<span>Loading...</span>
:dataTag(tagName='img' mimeType='image/svg+xml')
<svg class="spinner bg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
:dataTag(tagName='img' mimeType='image/svg+xml')
<svg class="spinner fg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
block content

View file

@ -9,6 +9,17 @@ block loadClientEntry
doctype html
//
-
_____ _ _
| |_|___ ___| |_ ___ _ _
| | | | |_ -|_ -| '_| -_| | |
|_|_|_|_|___|___|_,_|___|_ |
|___|
Thank you for using Misskey!
If you are reading this message... how about joining the development?
https://github.com/misskey-dev/misskey
html
head
@ -61,10 +72,8 @@ html
script(type='application/json' id='misskey_meta' data-generated-at=now)
!= metaJson
script(type='application/json' id='misskey_clientCtx' data-generated-at=now)
!= clientCtx
script(integrity=bootJS.integrity) !{bootJS.content}
body
noscript: p
@ -72,11 +81,18 @@ html
br
| Please turn on your JavaScript
div#splash
div#tty
img#splashIcon(src= icon || '/static-assets/splash.png')
div#splashSpinner
<span>Loading...</span>
script(integrity=bootJS.integrity) !{bootJS.content}
:dataTag(tagName='img' mimeType='image/svg+xml')
<svg class="spinner bg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
:dataTag(tagName='img' mimeType='image/svg+xml')
<svg class="spinner fg" viewBox="0 0 152 152" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1,0,0,1,12,12)">
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:24px;"/>
</g>
</svg>
block content

View file

@ -1,6 +1,17 @@
doctype html
//
-
_____ _ _
| |_|___ ___| |_ ___ _ _
| | | | |_ -|_ -| '_| -_| | |
|_|_|_|_|___|___|_,_|___|_ |
|___|
Thank you for using Misskey!
If you are reading this message... how about joining the development?
https://github.com/misskey-dev/misskey
html
head

View file

@ -19,6 +19,7 @@ proxyBypassHosts:
- challenges.cloudflare.com
proxyRemoteFiles: true
signToActivityPubGet: true
allowedPrivateNetworks:
- 127.0.0.1/32
- 172.20.0.0/16
allowedPrivateNetworks: [
'127.0.0.1/32',
'172.20.0.0/16'
]

View file

@ -131,7 +131,11 @@ describe('Note', () => {
rejects(
async () => await bob.client.request('ap/show', { uri: `https://a.test/notes/${note.id}` }),
(err: any) => {
strictEqual(err.code, 'REQUEST_FAILED');
/**
* FIXME: this error is not handled
* @see https://github.com/misskey-dev/misskey/issues/12736
*/
strictEqual(err.code, 'INTERNAL_ERROR');
return true;
},
);

View file

@ -14,6 +14,7 @@ import { afterAll, beforeAll, describe, test } from '@jest/globals';
import { GlobalModule } from '@/GlobalModule.js';
import { FileInfo, FileInfoService } from '@/core/FileInfoService.js';
//import { DI } from '@/di-symbols.js';
import { AiService } from '@/core/AiService.js';
import { LoggerService } from '@/core/LoggerService.js';
import type { TestingModule } from '@nestjs/testing';
import type { MockFunctionMetadata } from 'jest-mock';
@ -43,6 +44,7 @@ describe('FileInfoService', () => {
GlobalModule,
],
providers: [
AiService,
LoggerService,
FileInfoService,
],

View file

@ -19,7 +19,7 @@ import { GlobalModule } from '@/GlobalModule.js';
import { CoreModule } from '@/core/CoreModule.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { yumeNormalizeObject, type IActor, type IApDocument, type ICollection, type IObject, type IPost } from '@/core/activitypub/type.js';
import type { IActor, IApDocument, ICollection, IObject, IPost } from '@/core/activitypub/type.js';
import { MiMeta, MiNote, UserProfilesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
@ -42,7 +42,6 @@ function createRandomActor({ actorHost = host } = {}): NonTransientIActor {
id: actorId,
type: 'Person',
preferredUsername,
__yume_normalized_object: true,
inbox: `${actorId}/inbox`,
outbox: `${actorId}/outbox`,
};
@ -56,7 +55,6 @@ function createRandomNote(actor: NonTransientIActor): NonTransientIPost {
id: noteId,
type: 'Note',
attributedTo: actor.id,
__yume_normalized_object: true,
content: 'test test foo',
};
}
@ -73,7 +71,6 @@ function createRandomFeaturedCollection(actor: NonTransientIActor, length: numbe
type: 'Collection',
id: actor.outbox as string,
totalItems: items.length,
__yume_normalized_object: true,
items,
};
}
@ -165,34 +162,6 @@ describe('ActivityPub', () => {
content: 'あ',
};
const punnyPost = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `https://あ.com/users/あ`,
type: 'Note',
attributedTo: actor.id,
to: 'https://www.w3.org/ns/activitystreams#Public',
content: 'あ',
};
test('punnyPost normalization', async () => {
const normalized = yumeNormalizeObject(punnyPost);
assert.strictEqual(normalized.id, 'https://xn--l8j.com/users/あ');
});
const portedHost = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `https://あ.com:12443/users/${secureRndstr(8)}`,
type: 'Note',
to: 'https://www.w3.org/ns/activitystreams#Public',
content: 'あ',
}
test('actor with port should be rejected', async () => {
assert.throws(() => {
yumeNormalizeObject(portedHost);
});
});
test('Minimum Actor', async () => {
resolver.register(actor.id, actor);
@ -207,7 +176,7 @@ describe('ActivityPub', () => {
resolver.register(actor.id, actor);
resolver.register(post.id, post);
const note = await noteService.createNote(post.id, undefined, resolver, true);
const note = await noteService.createNote(post.id, resolver, true);
assert.deepStrictEqual(note?.uri, post.id);
assert.deepStrictEqual(note.visibility, 'public');
@ -251,7 +220,6 @@ describe('ActivityPub', () => {
type: 'OrderedCollection',
totalItems: 0,
first: `${actor.id}/following?page=1`,
__yume_normalized_object: true,
};
actor.followers = `${actor.id}/followers`;
@ -261,7 +229,6 @@ describe('ActivityPub', () => {
type: 'OrderedCollection',
totalItems: 0,
first: `${actor.followers}?page=1`,
__yume_normalized_object: true,
});
const user = await personService.createPerson(actor.id, resolver);
@ -277,7 +244,6 @@ describe('ActivityPub', () => {
id: `${actor.id}/following`,
type: 'OrderedCollection',
totalItems: 0,
__yume_normalized_object: true,
// first: …
};
actor.followers = `${actor.id}/followers`;
@ -370,7 +336,7 @@ describe('ActivityPub', () => {
resolver.register(actor.featured, featured);
resolver.register(firstNote.id, firstNote);
const note = await noteService.createNote(firstNote.id as string, undefined, resolver);
const note = await noteService.createNote(firstNote.id as string, resolver);
assert.strictEqual(note?.uri, firstNote.id);
});
});
@ -382,7 +348,6 @@ describe('ActivityPub', () => {
mediaType: 'image/png',
url: 'http://host1.test/foo.png',
name: '',
__yume_normalized_object: true,
};
const driveFile = await imageService.createImage(
await createRandomRemoteUser(resolver, personService),
@ -396,7 +361,6 @@ describe('ActivityPub', () => {
url: 'http://host1.test/bar.png',
name: '',
sensitive: true,
__yume_normalized_object: true,
};
const sensitiveDriveFile = await imageService.createImage(
await createRandomRemoteUser(resolver, personService),
@ -413,7 +377,6 @@ describe('ActivityPub', () => {
mediaType: 'image/png',
url: 'http://host1.test/foo.png',
name: '',
__yume_normalized_object: true,
};
const driveFile = await imageService.createImage(
await createRandomRemoteUser(resolver, personService),
@ -427,7 +390,6 @@ describe('ActivityPub', () => {
url: 'http://host1.test/bar.png',
name: '',
sensitive: true,
__yume_normalized_object: true,
};
const sensitiveDriveFile = await imageService.createImage(
await createRandomRemoteUser(resolver, personService),
@ -444,7 +406,6 @@ describe('ActivityPub', () => {
mediaType: 'image/png',
url: 'http://host1.test/foo.png',
name: '',
__yume_normalized_object: true,
};
const driveFile = await imageService.createImage(
await createRandomRemoteUser(resolver, personService),
@ -458,7 +419,6 @@ describe('ActivityPub', () => {
url: 'http://host1.test/bar.png',
name: '',
sensitive: true,
__yume_normalized_object: true,
};
const sensitiveDriveFile = await imageService.createImage(
await createRandomRemoteUser(resolver, personService),
@ -471,7 +431,6 @@ describe('ActivityPub', () => {
const linkObject: IObject = {
type: 'Link',
href: 'https://example.com/',
__yume_normalized_object: true,
};
const driveFile = await imageService.createImage(
await createRandomRemoteUser(resolver, personService),

View file

@ -13,7 +13,7 @@
<head>
<meta charset="UTF-8" />
<title>[DEV] Loading...</title>
<!--
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/;
@ -25,7 +25,6 @@
connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com;
frame-src *;"
/>
-->
<meta property="og:site_name" content="[DEV BUILD] Misskey" />
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>

View file

@ -125,9 +125,7 @@ const bannerStyle = computed(() => {
position: absolute;
top: 16px;
left: 16px;
max-width: calc(100% - 32px);
padding: 12px 16px;
box-sizing: border-box;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 1.2em;

View file

@ -45,8 +45,7 @@ const queryingKey = ref(true);
async function queryKey() {
queryingKey.value = true;
await webAuthnRequest(props.credentialRequest)
.catch((e) => {
console.error(e);
.catch(() => {
return Promise.reject(null);
})
.then((credential) => {
@ -54,7 +53,7 @@ async function queryKey() {
})
.finally(() => {
queryingKey.value = false;
});
});
}
onMounted(() => {

View file

@ -41,6 +41,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch :modelValue="agreeTosAndPrivacyPolicy" style="margin-top: 16px;" @update:modelValue="updateAgreeTosAndPrivacyPolicy">{{ i18n.ts.agree }}</MkSwitch>
</MkFolder>
<MkFolder :defaultOpen="true">
<template #label>{{ i18n.ts.basicNotesBeforeCreateAccount }}</template>
<template #suffix><i v-if="agreeNote" class="ti ti-check" style="color: var(--MI_THEME-success)"></i></template>
<a href="https://misskey-hub.net/docs/for-users/onboarding/warning/" class="_link" target="_blank">{{ i18n.ts.basicNotesBeforeCreateAccount }} <i class="ti ti-external-link"></i></a>
<MkSwitch :modelValue="agreeNote" style="margin-top: 16px;" data-cy-signup-rules-notes-agree @update:modelValue="updateAgreeNote">{{ i18n.ts.agree }}</MkSwitch>
</MkFolder>
<div v-if="!agreed" style="text-align: center;">{{ i18n.ts.pleaseAgreeAllToContinue }}</div>
<div class="_buttonsCenter">
@ -68,9 +77,10 @@ const availablePrivacyPolicy = instance.privacyPolicyUrl != null && instance.pri
const agreeServerRules = ref(false);
const agreeTosAndPrivacyPolicy = ref(false);
const agreeNote = ref(false);
const agreed = computed(() => {
return (!availableServerRules || agreeServerRules.value) && ((!availableTos && !availablePrivacyPolicy) || agreeTosAndPrivacyPolicy.value);
return (!availableServerRules || agreeServerRules.value) && ((!availableTos && !availablePrivacyPolicy) || agreeTosAndPrivacyPolicy.value) && agreeNote.value;
});
const emit = defineEmits<{
@ -119,6 +129,20 @@ async function updateAgreeTosAndPrivacyPolicy(v: boolean) {
agreeTosAndPrivacyPolicy.value = false;
}
}
async function updateAgreeNote(v: boolean) {
if (v) {
const confirm = await os.confirm({
type: 'question',
title: i18n.ts.doYouAgree,
text: i18n.tsx.iHaveReadXCarefullyAndAgree({ x: i18n.ts.basicNotesBeforeCreateAccount }),
});
if (confirm.canceled) return;
agreeNote.value = true;
} else {
agreeNote.value = false;
}
}
</script>
<style lang="scss" module>

View file

@ -25,18 +25,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, inject, ref } from 'vue';
import type { MenuItem } from '@/types/menu.js';
import { computed, inject, ref } from 'vue';
import { getProxiedImageUrl, getStaticImageUrl } from '@/scripts/media-proxy.js';
import { defaultStore } from '@/store.js';
import { customEmojisMap } from '@/custom-emojis.js';
import * as os from '@/os.js';
import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
import { misskeyApiGet } from '@/scripts/misskey-api.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import * as sound from '@/scripts/sound.js';
import { i18n } from '@/i18n.js';
import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue';
import { $i } from '@/account.js';
import type { MenuItem } from '@/types/menu.js';
const props = defineProps<{
name: string;
@ -126,31 +125,9 @@ function onClick(ev: MouseEvent) {
},
});
if ($i?.isModerator ?? $i?.isAdmin) {
menuItems.push({
text: i18n.ts.edit,
icon: 'ti ti-pencil',
action: async () => {
await edit(props.name);
},
});
}
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
}
}
async function edit(name: string) {
const emoji = await misskeyApi('emoji', {
name: name,
});
const { dispose } = os.popup(defineAsyncComponent(() => import('@/pages/emoji-edit-dialog.vue')), {
emoji: emoji,
}, {
closed: () => dispose(),
});
}
</script>
<style lang="scss" module>

View file

@ -8,12 +8,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.container">
<svg :class="[$style.spinner, $style.bg]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1.125,0,0,1.125,12,12)">
<circle cx="64" cy="64" r="64" :class="[$style.bgcircle]"/>
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
</g>
</svg>
<svg :class="[$style.spinner, $style.fg, { [$style.static]: static }]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1.125,0,0,1.125,12,12)">
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" :class="[$style.fgpath]"/>
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
</g>
</svg>
</div>
@ -109,16 +109,4 @@ const props = withDefaults(defineProps<{
animation-play-state: paused;
}
}
.bgcircle {
fill:none;
stroke:currentColor;
stroke-width:21.33px;
}
.fgpath {
fill:none;
stroke:currentColor;
stroke-width:21.33px;
}
</style>

View file

@ -14,7 +14,6 @@
<meta charset="UTF-8" />
<title>[DEV] Loading...</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<!--
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/;
@ -26,7 +25,6 @@
connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com;
frame-src *;"
/>
-->
<meta property="og:site_name" content="[DEV BUILD] Misskey" />
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>

View file

@ -390,7 +390,6 @@ const patrons = [
'こまつぶり',
'まゆつな空高',
'asata',
'ruru',
];
const thereIsTreasure = ref($i && !claimedAchievements.includes('foundTreasure'));

View file

@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<FormLink to="/about-misskey">
<template #icon><i class="ti ti-info-circle"></i></template>
{{ i18n.ts.aboutMisskey }} (Upstream)
{{ i18n.ts.aboutMisskey }}
</FormLink>
<FormLink v-if="instance.repositoryUrl || instance.providesTarball" :to="instance.repositoryUrl || `/tarball/misskey-${version}.tar.gz`" external>
<template #icon><i class="ti ti-code"></i></template>

View file

@ -6,9 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="1200">
<MkSpacer :contentMax="700">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<div v-if="tab === 'search'" key="search" :class="$style.searchRoot">
<div v-if="tab === 'search'" key="search">
<div class="_gaps">
<MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search">
<template #prefix><i class="ti ti-search"></i></template>
@ -27,31 +27,23 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-if="tab === 'featured'" key="featured">
<MkPagination v-slot="{items}" :pagination="featuredPagination">
<div :class="$style.root">
<MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/>
</div>
<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
</MkPagination>
</div>
<div v-else-if="tab === 'favorites'" key="favorites">
<MkPagination v-slot="{items}" :pagination="favoritesPagination">
<div :class="$style.root">
<MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/>
</div>
<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
</MkPagination>
</div>
<div v-else-if="tab === 'following'" key="following">
<MkPagination v-slot="{items}" :pagination="followingPagination">
<div :class="$style.root">
<MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/>
</div>
<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
</MkPagination>
</div>
<div v-else-if="tab === 'owned'" key="owned">
<MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton>
<MkPagination v-slot="{items}" :pagination="ownedPagination">
<div :class="$style.root">
<MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/>
</div>
<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
</MkPagination>
</div>
</MkHorizontalSwipe>
@ -93,7 +85,6 @@ onMounted(() => {
const featuredPagination = {
endpoint: 'channels/featured' as const,
limit: 10,
noPaging: true,
};
const favoritesPagination = {
@ -166,17 +157,3 @@ definePageMetadata(() => ({
icon: 'ti ti-device-tv',
}));
</script>
<style lang="scss" module>
.searchRoot {
width: 100%;
max-width: 700px;
margin: 0 auto;
}
.root {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: var(--MI-margin);
}
</style>

View file

@ -33,29 +33,25 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, watch, provide, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { url } from '@@/js/config.js';
import type { MenuItem } from '@/types/menu.js';
import MkNotes from '@/components/MkNotes.vue';
import { $i } from '@/account.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { url } from '@@/js/config.js';
import MkButton from '@/components/MkButton.vue';
import { clipsCache } from '@/cache.js';
import { isSupportShare } from '@/scripts/navigator.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { genEmbedCode } from '@/scripts/get-embed-code.js';
import { assertServerContext, serverContext } from '@/server-context.js';
// context
const CTX_CLIP = !$i && assertServerContext(serverContext, 'clip') ? serverContext.clip : null;
import type { MenuItem } from '@/types/menu.js';
const props = defineProps<{
clipId: string,
}>();
const clip = ref<Misskey.entities.Clip | null>(CTX_CLIP);
const clip = ref<Misskey.entities.Clip | null>(null);
const favorited = ref(false);
const pagination = {
endpoint: 'clips/notes' as const,
@ -68,11 +64,6 @@ const pagination = {
const isOwned = computed<boolean | null>(() => $i && clip.value && ($i.id === clip.value.userId));
watch(() => props.clipId, async () => {
if (CTX_CLIP && CTX_CLIP.id === props.clipId) {
clip.value = CTX_CLIP;
return;
}
clip.value = await misskeyApi('clips/show', {
clipId: props.clipId,
});

View file

@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #default="{items}">
<div class="ldhfsamy">
<button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)">
<img :src="emoji.url" class="img" :alt="emoji.name"/>
<img :src="`/emoji/${emoji.name}.webp`" class="img" :alt="emoji.name"/>
<div class="body">
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.category }}</div>
@ -57,7 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #default="{items}">
<div class="ldhfsamy">
<div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)">
<img :src="getProxiedImageUrl(emoji.url, 'emoji')" class="img" :alt="emoji.name"/>
<img :src="`/emoji/${emoji.name}@${emoji.host}.webp`" class="img" :alt="emoji.name"/>
<div class="body">
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.host }}</div>
@ -83,7 +83,6 @@ import FormSplit from '@/components/form/split.vue';
import { selectFile } from '@/scripts/select-file.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { getProxiedImageUrl } from '@/scripts/media-proxy.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';

View file

@ -118,7 +118,7 @@ watch(roleIdsThatCanBeUsedThisEmojiAsReaction, async () => {
rolesThatCanBeUsedThisEmojiAsReaction.value = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.value.map((id) => misskeyApi('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null);
}, { immediate: true });
const imgUrl = computed(() => file.value ? file.value.url : props.emoji ? props.emoji.url : null);
const imgUrl = computed(() => file.value ? file.value.url : props.emoji ? `/emoji/${props.emoji.name}.webp` : null);
async function changeImage(ev: Event) {
file.value = await selectFile(ev.currentTarget ?? ev.target, null);

View file

@ -15,22 +15,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import * as Misskey from 'misskey-js';
import { defineAsyncComponent } from 'vue';
import type { MenuItem } from '@/types/menu.js';
import * as os from '@/os.js';
import { misskeyApiGet } from '@/scripts/misskey-api.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { i18n } from '@/i18n.js';
import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue';
import { $i } from '@/account.js';
const props = defineProps<{
emoji: Misskey.entities.EmojiSimple;
}>();
function menu(ev) {
const menuItems: MenuItem[] = [];
menuItems.push({
os.popupMenu([{
type: 'label',
text: ':' + props.emoji.name + ':',
}, {
@ -52,28 +48,8 @@ function menu(ev) {
closed: () => dispose(),
});
},
});
if ($i?.isModerator ?? $i?.isAdmin) {
menuItems.push({
text: i18n.ts.edit,
icon: 'ti ti-pencil',
action: () => {
edit(props.emoji);
},
});
}
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
}], ev.currentTarget ?? ev.target);
}
const edit = async (emoji) => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/pages/emoji-edit-dialog.vue')), {
emoji: emoji,
}, {
closed: () => dispose(),
});
};
</script>
<style lang="scss" module>

View file

@ -117,6 +117,5 @@ definePageMetadata(() => ({
border-radius: var(--MI-radius);
background-color: var(--MI_THEME-panel);
overflow-x: scroll;
white-space: nowrap;
}
</style>

View file

@ -50,7 +50,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, watch, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { host } from '@@/js/config.js';
import type { Paging } from '@/components/MkPagination.vue';
import MkNoteDetailed from '@/components/MkNoteDetailed.vue';
import MkNotes from '@/components/MkNotes.vue';
@ -63,18 +62,13 @@ import { dateString } from '@/filters/date.js';
import MkClipPreview from '@/components/MkClipPreview.vue';
import { defaultStore } from '@/store.js';
import { pleaseLogin } from '@/scripts/please-login.js';
import { serverContext, assertServerContext } from '@/server-context.js';
import { $i } from '@/account.js';
// context
const CTX_NOTE = !$i && assertServerContext(serverContext, 'note') ? serverContext.note : null;
const props = defineProps<{
noteId: string;
initialTab?: string;
}>();
const note = ref<null | Misskey.entities.Note>(CTX_NOTE);
const note = ref<null | Misskey.entities.Note>();
const clips = ref<Misskey.entities.Clip[]>();
const showPrev = ref<'user' | 'channel' | false>(false);
const showNext = ref<'user' | 'channel' | false>(false);
@ -122,12 +116,6 @@ function fetchNote() {
showPrev.value = false;
showNext.value = false;
note.value = null;
if (CTX_NOTE && CTX_NOTE.id === props.noteId) {
note.value = CTX_NOTE;
return;
}
misskeyApi('notes/show', {
noteId: props.noteId,
}).then(res => {
@ -143,12 +131,7 @@ function fetchNote() {
}).catch(err => {
if (err.id === '8e75455b-738c-471d-9f80-62693f33372e') {
pleaseLogin({
path: '/',
message: i18n.ts.thisContentsAreMarkedAsSigninRequiredByAuthor,
openOnRemote: {
type: 'lookup',
url: `https://${host}/notes/${props.noteId}`,
},
});
}
error.value = err;

View file

@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div><MkSparkle><Mfm :plain="true" :text="user.followedMessage" :author="user"/></MkSparkle></div>
</MkFukidashi>
</div>
<div v-if="user.roles && user.roles.length > 0" class="roles">
<div v-if="user.roles.length > 0" class="roles">
<span v-for="role in user.roles" :key="role.id" v-tooltip="role.description" class="role" :style="{ '--color': role.color }">
<MkA v-adaptive-bg :to="`/roles/${role.id}`">
<img v-if="role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="role.iconUrl"/>
@ -96,7 +96,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<dt class="name"><i class="ti ti-cake ti-fw"></i> {{ i18n.ts.birthday }}</dt>
<dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ i18n.tsx.yearsOld({ age }) }})</dd>
</dl>
<dl v-if="user.createdAt" class="field">
<dl class="field">
<dt class="name"><i class="ti ti-calendar ti-fw"></i> {{ i18n.ts.registeredDate }}</dt>
<dd class="value">{{ dateString(user.createdAt) }} (<MkTime :time="user.createdAt"/>)</dd>
</dl>

View file

@ -39,7 +39,6 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/account.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import { serverContext, assertServerContext } from '@/server-context.js';
const XHome = defineAsyncComponent(() => import('./home.vue'));
const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue'));
@ -53,9 +52,6 @@ const XFlashs = defineAsyncComponent(() => import('./flashs.vue'));
const XGallery = defineAsyncComponent(() => import('./gallery.vue'));
const XRaw = defineAsyncComponent(() => import('./raw.vue'));
// context
const CTX_USER = !$i && assertServerContext(serverContext, 'user') ? serverContext.user : null;
const props = withDefaults(defineProps<{
acct: string;
page?: string;
@ -65,24 +61,13 @@ const props = withDefaults(defineProps<{
const tab = ref(props.page);
const user = ref<null | Misskey.entities.UserDetailed>(CTX_USER);
const user = ref<null | Misskey.entities.UserDetailed>(null);
const error = ref<any>(null);
function fetchUser(): void {
if (props.acct == null) return;
const { username, host } = Misskey.acct.parse(props.acct);
if (CTX_USER && CTX_USER.username === username && CTX_USER.host === host) {
user.value = CTX_USER;
return;
}
user.value = null;
misskeyApi('users/show', {
username,
host,
}).then(u => {
misskeyApi('users/show', Misskey.acct.parse(props.acct)).then(u => {
user.value = u;
}).catch(err => {
error.value = err;

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