Merge pull request 'Merge upstream' (#40) from develop into master
Some checks failed
Lint / pnpm_install (push) Successful in 1m39s
Test (production install and build) / production (22.11.0) (push) Successful in 1m5s
Publish Docker image / Build (push) Successful in 4m56s
Lint / lint (backend) (push) Successful in 2m17s
Lint / lint (frontend) (push) Successful in 2m0s
Lint / lint (frontend-embed) (push) Successful in 1m59s
Lint / lint (frontend-shared) (push) Successful in 1m56s
Lint / lint (misskey-bubble-game) (push) Successful in 2m6s
Test (backend) / unit (22.11.0) (push) Failing after 8m8s
Lint / lint (misskey-reversi) (push) Successful in 2m2s
Lint / lint (misskey-js) (push) Successful in 2m40s
Lint / lint (sw) (push) Successful in 2m0s
Lint / typecheck (misskey-js) (push) Successful in 1m27s
Lint / typecheck (sw) (push) Successful in 1m32s
Lint / typecheck (backend) (push) Successful in 2m18s

Reviewed-on: #40
This commit is contained in:
ゆめ 2024-11-21 11:40:02 -06:00
commit 7a0067460b
59 changed files with 1616 additions and 962 deletions

View file

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

View file

@ -1,3 +1,11 @@
## 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 ## 2024.11.0-yumechinokuni.5
- Upstream: 2024.11.0-alpha.2 タッグをマージする - Upstream: 2024.11.0-alpha.2 タッグをマージする
@ -58,6 +66,7 @@ PgroongaのCWサーチ (github.com/paricafe/misskey#d30db97b59d264450901c1dd8680
(Based on https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/663) (Based on https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/663)
- Enhance: サイドバーを簡単に展開・折りたたみできるように ( #14981 ) - Enhance: サイドバーを簡単に展開・折りたたみできるように ( #14981 )
- Enhance: リノートメニューに「リノートの詳細」を追加 - Enhance: リノートメニューに「リノートの詳細」を追加
- Enhance: 非ログイン状態でMisskeyを開いた際のパフォーマンスを向上
- Fix: 通知の範囲指定の設定項目が必要ない通知設定でも範囲指定の設定がでている問題を修正 - Fix: 通知の範囲指定の設定項目が必要ない通知設定でも範囲指定の設定がでている問題を修正
- Fix: Turnstileが失敗・期限切れした際にも成功扱いとなってしまう問題を修正 - Fix: Turnstileが失敗・期限切れした際にも成功扱いとなってしまう問題を修正
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/768) (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/768)
@ -92,6 +101,7 @@ PgroongaのCWサーチ (github.com/paricafe/misskey#d30db97b59d264450901c1dd8680
- Fix: FTT無効時にユーザーリストタイムラインが使用できない問題を修正 - Fix: FTT無効時にユーザーリストタイムラインが使用できない問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/709) (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/709)
- Fix: User Webhookテスト機能のMock Payloadを修正 - Fix: User Webhookテスト機能のMock Payloadを修正
- Fix: アカウント削除のモデレーションログが動作していないのを修正 (#14996)
### Misskey.js ### Misskey.js
- Fix: Stream初期化時、別途WebSocketを指定する場合の型定義を修正 - Fix: Stream初期化時、別途WebSocketを指定する場合の型定義を修正

View file

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

View file

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

View file

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

View file

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

View file

@ -1707,9 +1707,9 @@ _achievements:
description: "在元旦登入" description: "在元旦登入"
flavor: "今年也请对本服务器多多指教!" flavor: "今年也请对本服务器多多指教!"
_cookieClicked: _cookieClicked:
title: "点击饼干小游戏" title: "饼干点点乐"
description: "点击了饼干" description: "点击了饼干"
flavor: "用错软件了?" flavor: "穿越了?"
_brainDiver: _brainDiver:
title: "Brain Diver" title: "Brain Diver"
description: "发布了包含 Brain Diver 链接的帖子" description: "发布了包含 Brain Diver 链接的帖子"

View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2024.11.0-yumechinokuni.5", "version": "2024.11.0-yumechinokuni.6",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project and yumechi
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class IndexUserDeleted1732071810971 {
name = 'IndexUserDeleted1732071810971'
async up(queryRunner) {
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_199b79e682bdc5ba946f491686" ON "user" ("isDeleted")`);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_199b79e682bdc5ba946f491686"`);
}
}

View file

@ -36,7 +36,7 @@ import { GlobalEventService } from './GlobalEventService.js';
import { HashtagService } from './HashtagService.js'; import { HashtagService } from './HashtagService.js';
import { HttpRequestService } from './HttpRequestService.js'; import { HttpRequestService } from './HttpRequestService.js';
import { IdService } from './IdService.js'; import { IdService } from './IdService.js';
import { ImageProcessingService } from './ImageProcessingService.js'; import { __YUME_PRIVATE_ImageProcessingService } from './ImageProcessingService.js';
import { InstanceActorService } from './InstanceActorService.js'; import { InstanceActorService } from './InstanceActorService.js';
import { InternalStorageService } from './InternalStorageService.js'; import { InternalStorageService } from './InternalStorageService.js';
import { MetaService } from './MetaService.js'; import { MetaService } from './MetaService.js';
@ -67,7 +67,7 @@ import { UserMutingService } from './UserMutingService.js';
import { UserRenoteMutingService } from './UserRenoteMutingService.js'; import { UserRenoteMutingService } from './UserRenoteMutingService.js';
import { UserSuspendService } from './UserSuspendService.js'; import { UserSuspendService } from './UserSuspendService.js';
import { UserAuthService } from './UserAuthService.js'; import { UserAuthService } from './UserAuthService.js';
import { VideoProcessingService } from './VideoProcessingService.js'; import { __YUME_PRIVATE_VideoProcessingService } from './VideoProcessingService.js';
import { UserWebhookService } from './UserWebhookService.js'; import { UserWebhookService } from './UserWebhookService.js';
import { ProxyAccountService } from './ProxyAccountService.js'; import { ProxyAccountService } from './ProxyAccountService.js';
import { UtilityService } from './UtilityService.js'; import { UtilityService } from './UtilityService.js';
@ -179,7 +179,7 @@ const $GlobalEventService: Provider = { provide: 'GlobalEventService', useExisti
const $HashtagService: Provider = { provide: 'HashtagService', useExisting: HashtagService }; const $HashtagService: Provider = { provide: 'HashtagService', useExisting: HashtagService };
const $HttpRequestService: Provider = { provide: 'HttpRequestService', useExisting: HttpRequestService }; const $HttpRequestService: Provider = { provide: 'HttpRequestService', useExisting: HttpRequestService };
const $IdService: Provider = { provide: 'IdService', useExisting: IdService }; const $IdService: Provider = { provide: 'IdService', useExisting: IdService };
const $ImageProcessingService: Provider = { provide: 'ImageProcessingService', useExisting: ImageProcessingService }; const $ImageProcessingService: Provider = { provide: '__YUME_PRIVATE_ImageProcessingService', useExisting: __YUME_PRIVATE_ImageProcessingService };
const $InstanceActorService: Provider = { provide: 'InstanceActorService', useExisting: InstanceActorService }; const $InstanceActorService: Provider = { provide: 'InstanceActorService', useExisting: InstanceActorService };
const $InternalStorageService: Provider = { provide: 'InternalStorageService', useExisting: InternalStorageService }; const $InternalStorageService: Provider = { provide: 'InternalStorageService', useExisting: InternalStorageService };
const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaService }; const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaService };
@ -212,7 +212,7 @@ const $UserRenoteMutingService: Provider = { provide: 'UserRenoteMutingService',
const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService }; const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService };
const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService }; const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService };
const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService }; const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService };
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService }; const $VideoProcessingService: Provider = { provide: '__YUME_PRIVATE_VideoProcessingService', useExisting: __YUME_PRIVATE_VideoProcessingService };
const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService }; const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService };
const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService }; const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService };
const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService }; const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService };
@ -330,7 +330,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
HashtagService, HashtagService,
HttpRequestService, HttpRequestService,
IdService, IdService,
ImageProcessingService, __YUME_PRIVATE_ImageProcessingService,
InstanceActorService, InstanceActorService,
InternalStorageService, InternalStorageService,
MetaService, MetaService,
@ -363,7 +363,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
UserSearchService, UserSearchService,
UserSuspendService, UserSuspendService,
UserAuthService, UserAuthService,
VideoProcessingService, __YUME_PRIVATE_VideoProcessingService,
UserWebhookService, UserWebhookService,
SystemWebhookService, SystemWebhookService,
WebhookTestService, WebhookTestService,
@ -625,7 +625,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
HashtagService, HashtagService,
HttpRequestService, HttpRequestService,
IdService, IdService,
ImageProcessingService, __YUME_PRIVATE_ImageProcessingService,
InstanceActorService, InstanceActorService,
InternalStorageService, InternalStorageService,
MetaService, MetaService,
@ -658,7 +658,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
UserSearchService, UserSearchService,
UserSuspendService, UserSuspendService,
UserAuthService, UserAuthService,
VideoProcessingService, __YUME_PRIVATE_VideoProcessingService,
UserWebhookService, UserWebhookService,
SystemWebhookService, SystemWebhookService,
WebhookTestService, WebhookTestService,

View file

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

View file

@ -6,7 +6,6 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as stream from 'node:stream/promises'; import * as stream from 'node:stream/promises';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import ipaddr from 'ipaddr.js';
import chalk from 'chalk'; import chalk from 'chalk';
import got, * as Got from 'got'; import got, * as Got from 'got';
import { parse } from 'content-disposition'; import { parse } from 'content-disposition';
@ -61,7 +60,6 @@ export class DownloadService {
request: operationTimeout, // whole operation timeout request: operationTimeout, // whole operation timeout
}, },
agent: { agent: {
http: this.httpRequestService.httpAgent,
https: this.httpRequestService.httpsAgent, https: this.httpRequestService.httpsAgent,
}, },
http2: false, // default http2: false, // default
@ -70,13 +68,6 @@ export class DownloadService {
}, },
enableUnixSockets: false, enableUnixSockets: false,
}).on('response', (res: Got.Response) => { }).on('response', (res: Got.Response) => {
if ((process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') && !this.config.proxy && res.ip) {
if (this.isPrivateIp(res.ip)) {
this.logger.warn(`Blocked address: ${res.ip}`);
req.destroy();
}
}
const contentLength = res.headers['content-length']; const contentLength = res.headers['content-length'];
if (contentLength != null) { if (contentLength != null) {
const size = Number(contentLength); const size = Number(contentLength);
@ -139,18 +130,4 @@ export class DownloadService {
cleanup(); cleanup();
} }
} }
@bindThis
private isPrivateIp(ip: string): boolean {
const parsedIp = ipaddr.parse(ip);
for (const net of this.config.allowedPrivateNetworks ?? []) {
const cidr = ipaddr.parseCIDR(net);
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
return false;
}
}
return parsedIp.range() !== 'unicast';
}
} }

View file

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

View file

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

View file

@ -6,9 +6,10 @@
import * as http from 'node:http'; import * as http from 'node:http';
import * as https from 'node:https'; import * as https from 'node:https';
import * as net from 'node:net'; import * as net from 'node:net';
import ipaddr from 'ipaddr.js';
import CacheableLookup from 'cacheable-lookup'; import CacheableLookup from 'cacheable-lookup';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; import { HttpsProxyAgent } from 'hpagent';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
@ -18,30 +19,109 @@ import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/val
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js'; import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
import type { IObject } from '@/core/activitypub/type.js'; import type { IObject } from '@/core/activitypub/type.js';
import type { Response } from 'node-fetch'; import type { Response } from 'node-fetch';
import type { URL } from 'node:url'; import { URL } from 'node:url';
export type HttpRequestSendOptions = { export type HttpRequestSendOptions = {
throwErrorWhenResponseNotOk: boolean; throwErrorWhenResponseNotOk: boolean;
validators?: ((res: Response) => void)[]; validators?: ((res: Response) => void)[];
}; };
declare module 'node:http' {
interface Agent {
createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket;
}
}
class HttpRequestServiceAgent extends http.Agent {
constructor(
private config: Config,
options?: http.AgentOptions,
) {
super(options);
}
@bindThis
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
const socket = super.createConnection(options, callback)
.on('connect', () => {
const address = socket.remoteAddress;
if (process.env.NODE_ENV === 'production') {
if (address && ipaddr.isValid(address)) {
if (this.isPrivateIp(address)) {
socket.destroy(new Error(`Blocked address: ${address}`));
}
}
}
});
return socket;
};
@bindThis
private isPrivateIp(ip: string): boolean {
const parsedIp = ipaddr.parse(ip);
for (const net of this.config.allowedPrivateNetworks ?? []) {
const cidr = ipaddr.parseCIDR(net);
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
return false;
}
}
return parsedIp.range() !== 'unicast';
}
}
class HttpsRequestServiceAgent extends https.Agent {
constructor(
private config: Config,
options?: https.AgentOptions,
) {
super(options);
}
@bindThis
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
const socket = super.createConnection(options, callback)
.on('connect', () => {
const address = socket.remoteAddress;
if (process.env.NODE_ENV === 'production') {
if (address && ipaddr.isValid(address)) {
if (this.isPrivateIp(address)) {
socket.destroy(new Error(`Blocked address: ${address}`));
}
}
}
});
return socket;
};
@bindThis
private isPrivateIp(ip: string): boolean {
const parsedIp = ipaddr.parse(ip);
for (const net of this.config.allowedPrivateNetworks ?? []) {
const cidr = ipaddr.parseCIDR(net);
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
return false;
}
}
return parsedIp.range() !== 'unicast';
}
}
@Injectable() @Injectable()
export class HttpRequestService { export class HttpRequestService {
/** /**
* Get http non-proxy agent * Get https non-proxy agent (without local address filtering)
*/ */
private http: http.Agent; private httpsNative: https.Agent;
/** /**
* Get https non-proxy agent * Get https non-proxy agent
*/ */
private https: https.Agent; private https: https.Agent;
/**
* Get http proxy or non-proxy agent
*/
public httpAgent: http.Agent;
/** /**
* Get https proxy or non-proxy agent * Get https proxy or non-proxy agent
*/ */
@ -57,34 +137,20 @@ export class HttpRequestService {
lookup: false, // nativeのdns.lookupにfallbackしない lookup: false, // nativeのdns.lookupにfallbackしない
}); });
this.http = new http.Agent({ const agentOption = {
keepAlive: true, keepAlive: true,
keepAliveMsecs: 30 * 1000, keepAliveMsecs: 30 * 1000,
lookup: cache.lookup as unknown as net.LookupFunction, lookup: cache.lookup as unknown as net.LookupFunction,
localAddress: config.outgoingAddress, localAddress: config.outgoingAddress,
}); minVersion: 'TLSv1.2' as const,
};
this.https = new https.Agent({ this.httpsNative = new https.Agent(agentOption);
keepAlive: true,
keepAliveMsecs: 30 * 1000, this.https = new HttpsRequestServiceAgent(config, agentOption);
lookup: cache.lookup as unknown as net.LookupFunction,
localAddress: config.outgoingAddress,
});
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128); const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
this.httpAgent = config.proxy
? new HttpProxyAgent({
keepAlive: true,
keepAliveMsecs: 30 * 1000,
maxSockets,
maxFreeSockets: 256,
scheduling: 'lifo',
proxy: config.proxy,
localAddress: config.outgoingAddress,
})
: this.http;
this.httpsAgent = config.proxy this.httpsAgent = config.proxy
? new HttpsProxyAgent({ ? new HttpsProxyAgent({
keepAlive: true, keepAlive: true,
@ -104,16 +170,22 @@ export class HttpRequestService {
* @param bypassProxy Allways bypass proxy * @param bypassProxy Allways bypass proxy
*/ */
@bindThis @bindThis
public getAgentByUrl(url: URL, bypassProxy = false): http.Agent | https.Agent { public getAgentByUrl(url: URL, bypassProxy = false): https.Agent {
if (url.protocol !== 'https:') {
throw new Error('Invalid protocol');
}
if (url.port && url.port !== '443') {
throw new Error('Invalid port');
}
if (bypassProxy || (this.config.proxyBypassHosts ?? []).includes(url.hostname)) { if (bypassProxy || (this.config.proxyBypassHosts ?? []).includes(url.hostname)) {
return url.protocol === 'http:' ? this.http : this.https; return this.https;
} else { } else {
return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent; return this.httpsAgent;
} }
} }
@bindThis @bindThis
public async getActivityJson(url: string): Promise<IObject> { public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise<IObject> {
const res = await this.send(url, { const res = await this.send(url, {
method: 'GET', method: 'GET',
headers: { headers: {
@ -121,6 +193,7 @@ export class HttpRequestService {
}, },
timeout: 5000, timeout: 5000,
size: 1024 * 256, size: 1024 * 256,
isLocalAddressAllowed: isLocalAddressAllowed,
}, { }, {
throwErrorWhenResponseNotOk: true, throwErrorWhenResponseNotOk: true,
validators: [validateContentTypeSetAsActivityPub], validators: [validateContentTypeSetAsActivityPub],
@ -129,13 +202,13 @@ export class HttpRequestService {
const finalUrl = res.url; // redirects may have been involved const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject; const activity = await res.json() as IObject;
assertActivityMatchesUrls(activity, [url, finalUrl]); assertActivityMatchesUrls(activity, [finalUrl]);
return activity; return activity;
} }
@bindThis @bindThis
public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>): Promise<T> { public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>, isLocalAddressAllowed = false): Promise<T> {
const res = await this.send(url, { const res = await this.send(url, {
method: 'GET', method: 'GET',
headers: Object.assign({ headers: Object.assign({
@ -143,19 +216,21 @@ export class HttpRequestService {
}, headers ?? {}), }, headers ?? {}),
timeout: 5000, timeout: 5000,
size: 1024 * 256, size: 1024 * 256,
isLocalAddressAllowed: isLocalAddressAllowed,
}); });
return await res.json() as T; return await res.json() as T;
} }
@bindThis @bindThis
public async getHtml(url: string, accept = 'text/html, */*', headers?: Record<string, string>): Promise<string> { public async getHtml(url: string, accept = 'text/html, */*', headers?: Record<string, string>, isLocalAddressAllowed = false): Promise<string> {
const res = await this.send(url, { const res = await this.send(url, {
method: 'GET', method: 'GET',
headers: Object.assign({ headers: Object.assign({
Accept: accept, Accept: accept,
}, headers ?? {}), }, headers ?? {}),
timeout: 5000, timeout: 5000,
isLocalAddressAllowed: isLocalAddressAllowed,
}); });
return await res.text(); return await res.text();
@ -170,6 +245,7 @@ export class HttpRequestService {
headers?: Record<string, string>, headers?: Record<string, string>,
timeout?: number, timeout?: number,
size?: number, size?: number,
isLocalAddressAllowed?: boolean,
} = {}, } = {},
extra: HttpRequestSendOptions = { extra: HttpRequestSendOptions = {
throwErrorWhenResponseNotOk: true, throwErrorWhenResponseNotOk: true,
@ -183,6 +259,16 @@ export class HttpRequestService {
controller.abort(); controller.abort();
}, timeout); }, timeout);
const urlParsed = new URL(url);
if (urlParsed.protocol !== 'https:') {
throw new Error('Invalid protocol');
}
if (urlParsed.port && urlParsed.port !== '443') {
throw new Error('Invalid port');
}
const res = await fetch(url, { const res = await fetch(url, {
method: args.method ?? 'GET', method: args.method ?? 'GET',
headers: { headers: {
@ -191,7 +277,7 @@ export class HttpRequestService {
}, },
body: args.body, body: args.body,
size: args.size ?? 10 * 1024 * 1024, size: args.size ?? 10 * 1024 * 1024,
agent: (url) => this.getAgentByUrl(url), agent: (url) => this.getAgentByUrl(url, false),
signal: controller.signal, signal: controller.signal,
}); });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,6 +16,7 @@ import { MiLocalUser, MiRemoteUser } from '@/models/User.js';
import { getApId } from './type.js'; import { getApId } from './type.js';
import { ApPersonService } from './models/ApPersonService.js'; import { ApPersonService } from './models/ApPersonService.js';
import type { IObject } from './type.js'; import type { IObject } from './type.js';
import { toASCII } from 'node:punycode';
export type UriParseResult = { export type UriParseResult = {
/** wether the URI was generated by us */ /** wether the URI was generated by us */
@ -63,7 +64,9 @@ export class ApDbResolverService implements OnApplicationShutdown {
const separator = '/'; const separator = '/';
const uri = new URL(getApId(value)); const uri = new URL(getApId(value));
if (uri.origin !== this.config.url) return { local: false, uri: uri.href }; if (toASCII(uri.host) !== toASCII(this.config.host)) {
return { local: false, uri: uri.href };
}
const [, type, id, ...rest] = uri.pathname.split(separator); const [, type, id, ...rest] = uri.pathname.split(separator);
return { return {

View file

@ -29,7 +29,7 @@ import { bindThis } from '@/decorators.js';
import type { MiRemoteUser } from '@/models/User.js'; import type { MiRemoteUser } from '@/models/User.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { AbuseReportService } from '@/core/AbuseReportService.js'; import { AbuseReportService } from '@/core/AbuseReportService.js';
import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; import { getApHrefNullable, getApId, getApIds, getApType, isActor, isCollection, isCollectionOrOrderedCollection, isPost, isTombstone, validActor, validPost, yumeDowncastAccept, yumeDowncastAdd, yumeDowncastAnnounce, yumeDowncastBlock, yumeDowncastCreate, yumeDowncastDelete, yumeDowncastFlag, yumeDowncastFollow, yumeDowncastLike, yumeDowncastMove, yumeDowncastReject, yumeDowncastRemove, yumeDowncastUndo, yumeDowncastUpdate } from './type.js';
import { ApNoteService } from './models/ApNoteService.js'; import { ApNoteService } from './models/ApNoteService.js';
import { ApLoggerService } from './ApLoggerService.js'; import { ApLoggerService } from './ApLoggerService.js';
import { ApDbResolverService } from './ApDbResolverService.js'; import { ApDbResolverService } from './ApDbResolverService.js';
@ -138,52 +138,92 @@ export class ApInboxService {
public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> { public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> {
if (actor.isSuspended) return; if (actor.isSuspended) return;
if (isCreate(activity)) { const create = yumeDowncastCreate(activity);
if (create) {
mInboxReceived?.inc({ host: actor.host, type: 'create' }); mInboxReceived?.inc({ host: actor.host, type: 'create' });
return await this.create(actor, activity); return await this.create(actor, create);
} else if (isDelete(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'delete' });
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, activity);
} else if (isAccept(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'accept' });
return await this.accept(actor, activity);
} else if (isReject(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'reject' });
return await this.reject(actor, activity);
} else if (isAdd(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'add' });
return await this.add(actor, activity);
} else if (isRemove(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'remove' });
return await this.remove(actor, activity);
} else if (isAnnounce(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'announce' });
return await this.announce(actor, activity);
} else if (isLike(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'like' });
return await this.like(actor, activity);
} else if (isUndo(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'undo' });
return await this.undo(actor, activity);
} else if (isBlock(activity)) {
mInboxReceived?.inc({ host: actor.host, type: 'block' });
return await this.block(actor, activity);
} else if (isFlag(activity)) {
mInboxReceived?.inc({ host: actor.host, type: '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}`;
} }
const update = yumeDowncastUpdate(activity);
if (update) {
mInboxReceived?.inc({ host: actor.host, type: 'update' });
return await this.update(actor, update);
}
const del = yumeDowncastDelete(activity);
if (del) {
mInboxReceived?.inc({ host: actor.host, type: 'delete' });
return await this.delete(actor, del);
}
const follow = yumeDowncastFollow(activity);
if (follow) {
mInboxReceived?.inc({ host: actor.host, type: 'follow' });
return await this.follow(actor, follow);
}
const accept = yumeDowncastAccept(activity);
if (accept) {
mInboxReceived?.inc({ host: actor.host, type: 'accept' });
return await this.accept(actor, accept);
}
const reject = yumeDowncastReject(activity);
if (reject) {
mInboxReceived?.inc({ host: actor.host, type: 'reject' });
return await this.reject(actor, reject);
}
const add = yumeDowncastAdd(activity);
if (add) {
mInboxReceived?.inc({ host: actor.host, type: 'add' });
return await this.add(actor, add);
}
const remove = yumeDowncastRemove(activity);
if (remove) {
mInboxReceived?.inc({ host: actor.host, type: 'remove' });
return await this.remove(actor, remove);
}
const announce = yumeDowncastAnnounce(activity);
if (announce) {
mInboxReceived?.inc({ host: actor.host, type: 'announce' });
return await this.announce(actor, announce);
}
const like = yumeDowncastLike(activity);
if (like) {
mInboxReceived?.inc({ host: actor.host, type: 'like' });
return await this.like(actor, like);
}
const move = yumeDowncastMove(activity);
if (move) {
mInboxReceived?.inc({ host: actor.host, type: 'move' });
return await this.move(actor, move);
}
const undo = yumeDowncastUndo(activity);
if (undo) {
mInboxReceived?.inc({ host: actor.host, type: 'undo' });
return await this.undo(actor, undo);
}
const block = yumeDowncastBlock(activity);
if (block) {
mInboxReceived?.inc({ host: actor.host, type: 'block' });
return await this.block(actor, block);
}
const flag = yumeDowncastFlag(activity);
if (flag) {
mInboxReceived?.inc({ host: actor.host, type: 'flag' });
return await this.flag(actor, flag);
}
mInboxReceived?.inc({ host: actor.host, type: 'unknown' });
return `unrecognized activity type: ${activity.type}`;
} }
@bindThis @bindThis
@ -234,7 +274,8 @@ export class ApInboxService {
throw err; throw err;
}); });
if (isFollow(object)) return await this.acceptFollow(actor, object); const follow = yumeDowncastFollow(object);
if (follow) return await this.acceptFollow(actor, follow);
return `skip: Unknown Accept type: ${getApType(object)}`; return `skip: Unknown Accept type: ${getApType(object)}`;
} }
@ -446,7 +487,7 @@ export class ApInboxService {
const exist = await this.apNoteService.fetchNote(note); const exist = await this.apNoteService.fetchNote(note);
if (exist) return 'skip: note exists'; if (exist) return 'skip: note exists';
await this.apNoteService.createNote(note, resolver, silent); await this.apNoteService.createNote(note, actor, resolver, silent);
return 'ok'; return 'ok';
} catch (err) { } catch (err) {
if (err instanceof StatusError && !err.isRetryable) { if (err instanceof StatusError && !err.isRetryable) {
@ -509,19 +550,12 @@ export class ApInboxService {
return `skip: delete actor ${actor.uri} !== ${uri}`; return `skip: delete actor ${actor.uri} !== ${uri}`;
} }
const user = await this.usersRepository.findOneBy({ id: actor.id }); if (!(await this.usersRepository.update({ id: actor.id, isDeleted: false }, { isDeleted: true })).affected) {
if (user == null) {
return 'skip: actor not found';
} else if (user.isDeleted) {
return 'skip: already deleted'; return 'skip: already deleted';
} }
const job = await this.queueService.createDeleteAccountJob(actor); const job = await this.queueService.createDeleteAccountJob(actor);
await this.usersRepository.update(actor.id, {
isDeleted: true,
});
this.globalEventService.publishInternalEvent('remoteUserUpdated', { id: actor.id }); this.globalEventService.publishInternalEvent('remoteUserUpdated', { id: actor.id });
return `ok: queued ${job.name} ${job.id}`; return `ok: queued ${job.name} ${job.id}`;
@ -590,7 +624,8 @@ export class ApInboxService {
throw e; throw e;
}); });
if (isFollow(object)) return await this.rejectFollow(actor, object); const follow = yumeDowncastFollow(object);
if (follow) return await this.rejectFollow(actor, follow);
return `skip: Unknown Reject type: ${getApType(object)}`; return `skip: Unknown Reject type: ${getApType(object)}`;
} }
@ -657,11 +692,20 @@ export class ApInboxService {
}); });
// don't queue because the sender may attempt again when timeout // don't queue because the sender may attempt again when timeout
if (isFollow(object)) return await this.undoFollow(actor, object); const follow = yumeDowncastFollow(object);
if (isBlock(object)) return await this.undoBlock(actor, object); if (follow) return await this.undoFollow(actor, follow);
if (isLike(object)) return await this.undoLike(actor, object);
if (isAnnounce(object)) return await this.undoAnnounce(actor, object); const block = yumeDowncastBlock(object);
if (isAccept(object)) return await this.undoAccept(actor, object); if (block) return await this.undoBlock(actor, block);
const like = yumeDowncastLike(object);
if (like) return await this.undoLike(actor, like);
const announce = yumeDowncastAnnounce(object);
if (announce) return await this.undoAnnounce(actor, announce);
const accept = yumeDowncastAccept(object);
if (accept) return await this.undoAccept(actor, accept);
return `skip: unknown object type ${getApType(object)}`; return `skip: unknown object type ${getApType(object)}`;
} }
@ -791,7 +835,7 @@ export class ApInboxService {
await this.apPersonService.updatePerson(actor.uri, resolver, object); await this.apPersonService.updatePerson(actor.uri, resolver, object);
return 'ok: Person updated'; return 'ok: Person updated';
} else if (getApType(object) === 'Question') { } else if (getApType(object) === 'Question') {
await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err)); await this.apQuestionService.updateQuestion(object, actor, resolver).catch(err => console.error(err));
return 'ok: Question updated'; return 'ok: Question updated';
} else { } else {
return `skip: Unknown type: ${getApType(object)}`; return `skip: Unknown type: ${getApType(object)}`;

View file

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

View file

@ -11,11 +11,14 @@ import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import { UserKeypairService } from '@/core/UserKeypairService.js'; import { UserKeypairService } from '@/core/UserKeypairService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
import type { IObject } from './type.js';
type Request = { type Request = {
url: string; url: string;
@ -145,6 +148,7 @@ export class ApRequestService {
private userKeypairService: UserKeypairService, private userKeypairService: UserKeypairService,
private httpRequestService: HttpRequestService, private httpRequestService: HttpRequestService,
private loggerService: LoggerService, private loggerService: LoggerService,
private utilityService: UtilityService,
) { ) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる
@ -251,7 +255,11 @@ export class ApRequestService {
//#endregion //#endregion
validateContentTypeSetAsActivityPub(res); validateContentTypeSetAsActivityPub(res);
const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject;
return await res.json(); assertActivityMatchesUrls(activity, [url, finalUrl]);
return activity;
} }
} }

View file

@ -16,11 +16,13 @@ import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { isCollectionOrOrderedCollection } from './type.js'; import { isCollectionOrOrderedCollection, yumeNormalizeObject } from './type.js';
import { ApDbResolverService } from './ApDbResolverService.js'; import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js'; import { ApRendererService } from './ApRendererService.js';
import { ApRequestService } from './ApRequestService.js'; import { ApRequestService } from './ApRequestService.js';
import type { IObject, ICollection, IOrderedCollection } from './type.js'; import type { IObject, ICollection, IOrderedCollection, IUnsanitizedObject } from './type.js';
import { toASCII } from 'node:punycode';
import { yumeAssertAcceptableURL } from './misc/validator.js';
export class Resolver { export class Resolver {
private history: Set<string>; private history: Set<string>;
@ -53,6 +55,11 @@ export class Resolver {
return Array.from(this.history); return Array.from(this.history);
} }
@bindThis
public getRecursionLimit(): number {
return this.recursionLimit;
}
@bindThis @bindThis
public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> { public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> {
const collection = typeof value === 'string' const collection = typeof value === 'string'
@ -67,7 +74,7 @@ export class Resolver {
} }
@bindThis @bindThis
public async resolve(value: string | IObject): Promise<IObject> { public async resolveNotNormalized(value: string | IObject): Promise<IUnsanitizedObject> {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return value; return value;
} }
@ -103,8 +110,8 @@ export class Resolver {
} }
const object = (this.user const object = (this.user
? await this.apRequestService.signedGet(value, this.user) as IObject ? await this.apRequestService.signedGet(value, this.user) as IUnsanitizedObject
: await this.httpRequestService.getActivityJson(value)) as IObject; : await this.httpRequestService.getActivityJson(value)) as IUnsanitizedObject;
if ( if (
Array.isArray(object['@context']) ? Array.isArray(object['@context']) ?
@ -114,9 +121,31 @@ export class Resolver {
throw new Error('invalid response'); throw new Error('invalid response');
} }
// HttpRequestService / ApRequestService have already checked that
// `object.id` or `object.url` matches the URL used to fetch the
// object after redirects; here we double-check that no redirects
// bounced between hosts
if (object.id == null) {
throw new Error('invalid AP object: missing id');
}
const idURL = yumeAssertAcceptableURL(object.id);
const valueURL = yumeAssertAcceptableURL(value);
if (toASCII(idURL.host) !== toASCII(valueURL.host)) {
throw new Bull.UnrecoverableError(`invalid AP object ${value}: id ${object.id} has different host`);
}
return object; return object;
} }
@bindThis
public async resolve(value: string | IObject): Promise<IObject> {
const object = await this.resolveNotNormalized(value);
return yumeNormalizeObject(object);
}
@bindThis @bindThis
private resolveLocal(url: string): Promise<IObject> { private resolveLocal(url: string): Promise<IObject> {
const parsed = this.apDbResolverService.parseUri(url); const parsed = this.apDbResolverService.parseUri(url);

View file

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

View file

@ -36,6 +36,7 @@ import { ApQuestionService } from './ApQuestionService.js';
import { ApImageService } from './ApImageService.js'; import { ApImageService } from './ApImageService.js';
import type { Resolver } from '../ApResolverService.js'; import type { Resolver } from '../ApResolverService.js';
import type { IObject, IPost } from '../type.js'; import type { IObject, IPost } from '../type.js';
import { yumeAssertAcceptableURL } from '../misc/validator.js';
@Injectable() @Injectable()
export class ApNoteService { export class ApNoteService {
@ -77,7 +78,7 @@ export class ApNoteService {
} }
@bindThis @bindThis
public validateNote(object: IObject, uri: string): Error | null { public validateNote(object: IObject, uri: string, actor?: MiRemoteUser): Error | null {
const expectHost = this.utilityService.extractDbHost(uri); const expectHost = this.utilityService.extractDbHost(uri);
const apType = getApType(object); const apType = getApType(object);
@ -98,6 +99,14 @@ export class ApNoteService {
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed'); return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed');
} }
if (actor) {
const attribution = (object.attributedTo) ? getOneApId(object.attributedTo) : actor.uri;
if (attribution !== actor.uri) {
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attribution does not match the actor that send it. attribution: ${attribution}, actor: ${actor.uri}`);
}
}
return null; return null;
} }
@ -115,14 +124,14 @@ export class ApNoteService {
* Noteを作成します * Noteを作成します
*/ */
@bindThis @bindThis
public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<MiNote | null> { public async createNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise<MiNote | null> {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver(); if (resolver == null) resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(value); const object = await resolver.resolve(value);
const entryUri = getApId(value); const entryUri = getApId(value);
const err = this.validateNote(object, entryUri); const err = this.validateNote(object, entryUri, actor);
if (err) { if (err) {
this.logger.error(err.message, { this.logger.error(err.message, {
resolver: { history: resolver.getHistory() }, resolver: { history: resolver.getHistory() },
@ -136,14 +145,27 @@ export class ApNoteService {
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
if (note.id && !checkHttps(note.id)) { if (note.id == null) {
throw new Error('Refusing to create note without id');
}
if (!checkHttps(note.id)) {
throw new Error('unexpected schema of note.id: ' + note.id); throw new Error('unexpected schema of note.id: ' + note.id);
} }
const url = getOneApHrefNullable(note.url); const url = getOneApHrefNullable(note.url);
if (url && !checkHttps(url)) { if (url != null) {
throw new Error('unexpected schema of note url: ' + url); if (!checkHttps(url)) {
throw new Error('unexpected schema of note url: ' + url);
}
const actUrl = yumeAssertAcceptableURL(url);
const noteUrl = yumeAssertAcceptableURL(note.id);
if (noteUrl.host !== actUrl.host) {
throw new Error(`note url & uri host mismatch: note url: ${url}, note uri: ${note.id}`);
}
} }
this.logger.info(`Creating the Note: ${note.id}`); this.logger.info(`Creating the Note: ${note.id}`);
@ -156,8 +178,9 @@ export class ApNoteService {
const uri = getOneApId(note.attributedTo); const uri = getOneApId(note.attributedTo);
// ローカルで投稿者を検索し、もし凍結されていたらスキップ // ローカルで投稿者を検索し、もし凍結されていたらスキップ
const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser; // eslint-disable-next-line no-param-reassign
if (cachedActor && cachedActor.isSuspended) { actor ??= await this.apPersonService.fetchPerson(uri) as MiRemoteUser | undefined;
if (actor && actor.isSuspended) {
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended'); throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
} }
@ -189,7 +212,8 @@ export class ApNoteService {
} }
//#endregion //#endregion
const actor = cachedActor ?? await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser; // eslint-disable-next-line no-param-reassign
actor ??= await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
// 解決した投稿者が凍結されていたらスキップ // 解決した投稿者が凍結されていたらスキップ
if (actor.isSuspended) { if (actor.isSuspended) {
@ -348,15 +372,11 @@ export class ApNoteService {
if (exist) return exist; if (exist) return exist;
//#endregion //#endregion
if (uri.startsWith(this.config.url)) {
throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note');
}
// リモートサーバーからフェッチしてきて登録 // リモートサーバーからフェッチしてきて登録
// ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにートが生成されるが // ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにートが生成されるが
// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。 // 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri; const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri;
return await this.createNote(createFrom, options.resolver, true); return await this.createNote(createFrom, undefined, options.resolver, true);
} finally { } finally {
unlock(); unlock();
} }

View file

@ -277,16 +277,13 @@ export class ApPersonService implements OnModuleInit {
* Personを作成します * Personを作成します
*/ */
@bindThis @bindThis
public async createPerson(uri: string, resolver?: Resolver): Promise<MiRemoteUser> { public async createPerson(uri: string, resolver: Resolver): Promise<MiRemoteUser> {
if (typeof uri !== 'string') throw new Error('uri is not string'); if (typeof uri !== 'string') throw new Error('uri is not string');
if (uri.startsWith(this.config.url)) { if (uri.startsWith(this.config.url)) {
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user'); throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
} }
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(uri); const object = await resolver.resolve(uri);
if (object.id == null) throw new Error('invalid object.id: ' + object.id); if (object.id == null) throw new Error('invalid object.id: ' + object.id);
@ -557,7 +554,9 @@ export class ApPersonService implements OnModuleInit {
if (moving) updates.movedAt = new Date(); if (moving) updates.movedAt = new Date();
// Update user // Update user
await this.usersRepository.update(exist.id, updates); if (!(await this.usersRepository.update({ id: exist.id, isDeleted: false }, updates)).affected) {
return 'skip';
}
if (person.publicKey) { if (person.publicKey) {
await this.userPublickeysRepository.update({ userId: exist.id }, { await this.userPublickeysRepository.update({ userId: exist.id }, {

View file

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

View file

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

View file

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

View file

@ -10,46 +10,85 @@ import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/Blocking.js'; import type { } from '@/models/Blocking.js';
import type { MiEmoji } from '@/models/Emoji.js'; import type { MiEmoji } from '@/models/Emoji.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { In } from 'typeorm';
import type { Config } from '@/config.js';
@Injectable() @Injectable()
export class EmojiEntityService { export class EmojiEntityService {
constructor( constructor(
@Inject(DI.emojisRepository) @Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository, private emojisRepository: EmojisRepository,
@Inject(DI.config)
private config: Config,
) { ) {
} }
private stripProxyIfOrigin(url: string): string {
try {
const u = new URL(url);
let origin = u.origin;
if (u.origin === new URL(this.config.mediaProxy).origin) {
const innerUrl = u.searchParams.get('url');
if (innerUrl) {
origin = new URL(innerUrl).origin;
}
}
if (origin === u.origin) {
return url;
}
} catch (e) {
return url;
}
return url;
}
@bindThis
public packSimpleNoQuery(
emoji: MiEmoji,
): Packed<'EmojiSimple'> {
return {
aliases: emoji.aliases,
name: emoji.name,
category: emoji.category,
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url: this.stripProxyIfOrigin(emoji.publicUrl || emoji.originalUrl),
localOnly: emoji.localOnly ? true : undefined,
isSensitive: emoji.isSensitive ? true : undefined,
roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined,
};
}
@bindThis @bindThis
public async packSimple( public async packSimple(
src: MiEmoji['id'] | MiEmoji, src: MiEmoji['id'] | MiEmoji,
): Promise<Packed<'EmojiSimple'>> { ): Promise<Packed<'EmojiSimple'>> {
const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src }); const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src });
return { return this.packSimpleNoQuery(emoji);
aliases: emoji.aliases,
name: emoji.name,
category: emoji.category,
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url: emoji.publicUrl || emoji.originalUrl,
localOnly: emoji.localOnly ? true : undefined,
isSensitive: emoji.isSensitive ? true : undefined,
roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0 ? emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : undefined,
};
} }
@bindThis @bindThis
public packSimpleMany( public async packSimpleMany(
emojis: any[], emojis: MiEmoji['id'][] | MiEmoji[],
) { ): Promise<Packed<'EmojiSimple'>[]> {
return Promise.all(emojis.map(x => this.packSimple(x))); if (emojis.length === 0) {
return [];
}
if (typeof emojis[0] === 'string') {
const res = await this.emojisRepository.findBy({ id: In(emojis as MiEmoji['id'][]) });
return res.map(this.packSimpleNoQuery);
}
return (emojis as MiEmoji[]).map(this.packSimpleNoQuery);
} }
@bindThis @bindThis
public async packDetailed( public packDetailedNoQuery(
src: MiEmoji['id'] | MiEmoji, emoji: MiEmoji,
): Promise<Packed<'EmojiDetailed'>> { ): Packed<'EmojiDetailed'> {
const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src });
return { return {
id: emoji.id, id: emoji.id,
aliases: emoji.aliases, aliases: emoji.aliases,
@ -57,7 +96,7 @@ export class EmojiEntityService {
category: emoji.category, category: emoji.category,
host: emoji.host, host: emoji.host,
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ) // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url: emoji.publicUrl || emoji.originalUrl, url: this.stripProxyIfOrigin(emoji.publicUrl || emoji.originalUrl),
license: emoji.license, license: emoji.license,
isSensitive: emoji.isSensitive, isSensitive: emoji.isSensitive,
localOnly: emoji.localOnly, localOnly: emoji.localOnly,
@ -66,10 +105,28 @@ export class EmojiEntityService {
} }
@bindThis @bindThis
public packDetailedMany( public async packDetailed(
emojis: any[], src: MiEmoji['id'] | MiEmoji,
) { ): Promise<Packed<'EmojiDetailed'>> {
return Promise.all(emojis.map(x => this.packDetailed(x))); const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src });
return this.packDetailedNoQuery(emoji);
}
@bindThis
public async packDetailedMany(
emojis: MiEmoji['id'][] | MiEmoji[],
) : Promise<Packed<'EmojiDetailed'>[]> {
if (emojis.length === 0) {
return [];
}
if (typeof emojis[0] === 'string') {
const res = await this.emojisRepository.findBy({ id: In(emojis as MiEmoji['id'][]) });
return res.map(this.packDetailedNoQuery);
}
return (emojis as MiEmoji[]).map(this.packDetailedNoQuery);
} }
} }

View file

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

View file

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

View file

@ -255,6 +255,8 @@ export class InboxProcessorService implements OnApplicationShutdown {
incCounter(mIncomingApReject, 'host_signature_mismatch'); incCounter(mIncomingApReject, 'host_signature_mismatch');
throw new Bull.UnrecoverableError(`skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`); throw new Bull.UnrecoverableError(`skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`);
} }
} else {
throw new Bull.UnrecoverableError('skip: activity id is not a string');
} }
this.apRequestChart.inbox(); this.apRequestChart.inbox();
@ -307,7 +309,7 @@ export class InboxProcessorService implements OnApplicationShutdown {
} }
const end = +new Date(); const end = +new Date();
observeHistogram(mIncomingApProcessingTime, { success: 'false' }, (end - begin) / 1000); observeHistogram(mIncomingApProcessingTime, { success: 'false' }, (end - begin) / 1000);
incCounter(mincomingApProcessingError, { reason: 'unknown' }); incCounter(mincomingApProcessingError, {});
throw e; throw e;
} }
observeHistogram(mIncomingApProcessingTime, { success: 'true' }, (+new Date() - begin) / 1000); observeHistogram(mIncomingApProcessingTime, { success: 'true' }, (+new Date() - begin) / 1000);

View file

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

View file

@ -8,27 +8,19 @@ import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import rename from 'rename'; import rename from 'rename';
import sharp from 'sharp';
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type { MiDriveFile, DriveFilesRepository } from '@/models/_.js'; import type { MiDriveFile, DriveFilesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { createTemp } from '@/misc/create-temp.js';
import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
import { StatusError } from '@/misc/status-error.js'; import { StatusError } from '@/misc/status-error.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { DownloadService } from '@/core/DownloadService.js';
import { IImageStreamable, ImageProcessingService, webpDefault } from '@/core/ImageProcessingService.js';
import { VideoProcessingService } from '@/core/VideoProcessingService.js';
import { InternalStorageService } from '@/core/InternalStorageService.js';
import { contentDisposition } from '@/misc/content-disposition.js'; import { contentDisposition } from '@/misc/content-disposition.js';
import { FileInfoService } from '@/core/FileInfoService.js'; import { FileInfoService } from '@/core/FileInfoService.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { isMimeImage } from '@/misc/is-mime-image.js';
import { correctFilename } from '@/misc/correct-filename.js';
import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js'; import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
import { InternalStorageService } from '@/core/InternalStorageService.js';
const _filename = fileURLToPath(import.meta.url); const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename); const _dirname = dirname(_filename);
@ -46,11 +38,8 @@ export class FileServerService {
@Inject(DI.driveFilesRepository) @Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository, private driveFilesRepository: DriveFilesRepository,
private fileInfoService: FileInfoService,
private downloadService: DownloadService,
private imageProcessingService: ImageProcessingService,
private videoProcessingService: VideoProcessingService,
private internalStorageService: InternalStorageService, private internalStorageService: InternalStorageService,
private fileInfoService: FileInfoService,
private loggerService: LoggerService, private loggerService: LoggerService,
) { ) {
this.logger = this.loggerService.getLogger('server', 'gray'); this.logger = this.loggerService.getLogger('server', 'gray');
@ -134,165 +123,78 @@ export class FileServerService {
return; return;
} }
try { if (file.state === 'remote') {
if (file.state === 'remote') { const url = new URL(`${this.config.mediaProxy}/`);
let image: IImageStreamable | null = null;
if (file.fileRole === 'thumbnail') { url.searchParams.set('url', file.url);
if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) {
reply.header('Cache-Control', 'max-age=31536000, immutable');
const url = new URL(`${this.config.mediaProxy}/static.webp`); return await reply.redirect(url.toString(), 301);
url.searchParams.set('url', file.url); }
url.searchParams.set('static', '1');
file.cleanup(); if (file.fileRole !== 'original') {
return await reply.redirect(url.toString(), 301); const filename = rename(file.filename, {
} else if (file.mime.startsWith('video/')) { suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web',
const externalThumbnail = this.videoProcessingService.getExternalVideoThumbnailUrl(file.url); extname: file.ext ? `.${file.ext}` : '.unknown',
if (externalThumbnail) { }).toString();
file.cleanup();
return await reply.redirect(externalThumbnail, 301);
}
image = await this.videoProcessingService.generateVideoThumbnail(file.path); reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream');
} reply.header('Cache-Control', 'max-age=31536000, immutable');
reply.header('Content-Disposition', contentDisposition('inline', filename));
if (request.headers.range && file.file.size > 0) {
const range = request.headers.range as string;
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
if (end > file.file.size) {
end = file.file.size - 1;
} }
const chunksize = end - start + 1;
if (file.fileRole === 'webpublic') { const fileStream = fs.createReadStream(file.path, {
if (['image/svg+xml'].includes(file.mime)) { start,
reply.header('Cache-Control', 'max-age=31536000, immutable'); end,
});
const url = new URL(`${this.config.mediaProxy}/svg.webp`); reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
url.searchParams.set('url', file.url); reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', chunksize);
file.cleanup(); reply.code(206);
return await reply.redirect(url.toString(), 301); return fileStream;
}
}
if (!image) {
if (request.headers.range && file.file.size > 0) {
const range = request.headers.range as string;
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
if (end > file.file.size) {
end = file.file.size - 1;
}
const chunksize = end - start + 1;
image = {
data: fs.createReadStream(file.path, {
start,
end,
}),
ext: file.ext,
type: file.mime,
};
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', chunksize);
reply.code(206);
} else {
image = {
data: fs.createReadStream(file.path),
ext: file.ext,
type: file.mime,
};
}
}
if ('pipe' in image.data && typeof image.data.pipe === 'function') {
// image.dataがstreamなら、stream終了後にcleanup
image.data.on('end', file.cleanup);
image.data.on('close', file.cleanup);
} else {
// image.dataがstreamでないなら直ちにcleanup
file.cleanup();
}
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream');
reply.header('Content-Length', file.file.size);
reply.header('Cache-Control', 'max-age=31536000, immutable');
reply.header('Content-Disposition',
contentDisposition(
'inline',
correctFilename(file.filename, image.ext),
),
);
return image.data;
} }
if (file.fileRole !== 'original') { return fs.createReadStream(file.path);
const filename = rename(file.filename, { } else {
suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web', reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream');
extname: file.ext ? `.${file.ext}` : '.unknown', reply.header('Content-Length', file.file.size);
}).toString(); reply.header('Cache-Control', 'max-age=31536000, immutable');
reply.header('Content-Disposition', contentDisposition('inline', file.filename));
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream'); if (request.headers.range && file.file.size > 0) {
reply.header('Cache-Control', 'max-age=31536000, immutable'); const range = request.headers.range as string;
reply.header('Content-Disposition', contentDisposition('inline', filename)); const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
if (request.headers.range && file.file.size > 0) { let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
const range = request.headers.range as string; if (end > file.file.size) {
const parts = range.replace(/bytes=/, '').split('-'); end = file.file.size - 1;
const start = parseInt(parts[0], 10);
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
if (end > file.file.size) {
end = file.file.size - 1;
}
const chunksize = end - start + 1;
const fileStream = fs.createReadStream(file.path, {
start,
end,
});
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', chunksize);
reply.code(206);
return fileStream;
} }
const chunksize = end - start + 1;
return fs.createReadStream(file.path); const fileStream = fs.createReadStream(file.path, {
} else { start,
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream'); end,
reply.header('Content-Length', file.file.size); });
reply.header('Cache-Control', 'max-age=31536000, immutable'); reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
reply.header('Content-Disposition', contentDisposition('inline', file.filename)); reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', chunksize);
if (request.headers.range && file.file.size > 0) { reply.code(206);
const range = request.headers.range as string; return fileStream;
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
if (end > file.file.size) {
end = file.file.size - 1;
}
const chunksize = end - start + 1;
const fileStream = fs.createReadStream(file.path, {
start,
end,
});
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', chunksize);
reply.code(206);
return fileStream;
}
return fs.createReadStream(file.path);
} }
} catch (e) {
if ('cleanup' in file) file.cleanup(); return fs.createReadStream(file.path);
throw e;
} }
} }
@bindThis @bindThis
private async proxyHandler(request: FastifyRequest<{ Params: { url: string; }; Querystring: { url?: string; }; }>, reply: FastifyReply) { private async proxyHandler(request: FastifyRequest<{ Params: { url: string; }; Querystring: { url?: string; }; }>, reply: FastifyReply) {
let url = 'url' in request.query ? request.query.url : 'https://' + request.params.url; const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url;
if (typeof url !== 'string') { if (typeof url !== 'string') {
reply.code(400); reply.code(400);
@ -302,234 +204,56 @@ export class FileServerService {
// アバタークロップなど、どうしてもオリジンである必要がある場合 // アバタークロップなど、どうしてもオリジンである必要がある場合
const mustOrigin = 'origin' in request.query; const mustOrigin = 'origin' in request.query;
if (this.config.externalMediaProxyEnabled) { if (!this.config.mediaProxy) {
// 外部のメディアプロキシが有効なら、そちらにリダイレクト reply.code(501);
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,
);
}
} }
if (!request.headers['user-agent']) { const proxiedURL = new URL(`${this.config.mediaProxy}/?url=${encodeURIComponent(url)}`);
throw new StatusError('User-Agent is required', 400, 'User-Agent is required');
} else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) { for (const [key, value] of Object.entries(request.query)) {
throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive'); if (key.toLowerCase() === 'url') continue;
proxiedURL.searchParams.append(key, value);
} }
if (!request.headers['user-agent']) { if (!mustOrigin) {
throw new StatusError('User-Agent is required', 400, 'User-Agent is required'); return await reply.redirect(
} else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) { proxiedURL.toString(),
throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive'); 301,
}
// 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);
} }
return await this.downloadAndDetectTypeFromUrl(url); reply.header('Cache-Control', 'public, max-age=259200'); // 3 days
}
@bindThis if (!request.headers['user-agent']) {
private async downloadAndDetectTypeFromUrl(url: string): Promise< throw new StatusError('User-Agent is required', 400, 'User-Agent is required');
{ state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } } 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');
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 @bindThis
private async getFileFromKey(key: string): Promise< private async getFileFromKey(key: string): Promise<
{ state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; } { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; filename: string; url: string; }
| { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; mime: string; ext: string | null; path: string; } | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; mime: string; ext: string | null; path: string; }
| '404' | '404'
| '204' | '204'
@ -548,15 +272,10 @@ export class FileServerService {
if (!file.storedInternal) { if (!file.storedInternal) {
if (!(file.isLink && file.uri)) return '204'; if (!(file.isLink && file.uri)) return '204';
const result = await this.downloadAndDetectTypeFromUrl(file.uri); return { state: 'remote',
file.size = (await fs.promises.stat(result.path)).size; // DB file.sizeは正確とは限らないので fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original',
return { filename: file.name
...result, , url: file.uri };
url: file.uri,
fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original',
file,
filename: file.name,
};
} }
const path = this.internalStorageService.resolvePath(key); const path = this.internalStorageService.resolvePath(key);

View file

@ -33,7 +33,6 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import { makeHstsHook } from './hsts.js'; import { makeHstsHook } from './hsts.js';
import { generateCSP } from './csp.js'; import { generateCSP } from './csp.js';
import * as prom from 'prom-client';
import { sanitizeRequestURI } from '@/misc/log-sanitization.js'; import { sanitizeRequestURI } from '@/misc/log-sanitization.js';
import { metricCounter, metricGauge, metricHistogram, MetricsService } from './api/MetricsService.js'; import { metricCounter, metricGauge, metricHistogram, MetricsService } from './api/MetricsService.js';
@ -110,6 +109,11 @@ const mLastSuccessfulRequest = metricGauge({
labelNames: [], labelNames: [],
}); });
// This function is used to determine if a path is safe to redirect to.
function redirectSafePath(path: string): boolean {
return ['/files/', '/identicon/', '/proxy/', '/static-assets/', '/vite/', '/embed_vite/'].some(prefix => path.startsWith(prefix));
}
@Injectable() @Injectable()
export class ServerService implements OnApplicationShutdown { export class ServerService implements OnApplicationShutdown {
private logger: Logger; private logger: Logger;
@ -348,7 +352,7 @@ export class ServerService implements OnApplicationShutdown {
name: name, name: name,
}); });
reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); reply.header('Content-Security-Policy', 'default-src \'none\'');
if (emoji == null) { if (emoji == null) {
if ('fallback' in request.query) { if ('fallback' in request.query) {
@ -359,16 +363,26 @@ export class ServerService implements OnApplicationShutdown {
} }
} }
const dbUrl = emoji?.publicUrl || emoji?.originalUrl;
const dbUrlParsed = new URL(dbUrl);
const instanceUrl = new URL(this.config.url);
if (dbUrlParsed.origin === instanceUrl.origin) {
if (!redirectSafePath(dbUrlParsed.pathname)) {
return await reply.status(508);
}
return await reply.redirect(dbUrl, 301);
}
let url: URL; let url: URL;
if ('badge' in request.query) { if ('badge' in request.query) {
url = new URL(`${this.config.mediaProxy}/emoji.png`); url = new URL(`${this.config.mediaProxy}/emoji.png`);
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ) // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl); url.searchParams.set('url', dbUrl);
url.searchParams.set('badge', '1'); url.searchParams.set('badge', '1');
} else { } else {
url = new URL(`${this.config.mediaProxy}/emoji.webp`); url = new URL(`${this.config.mediaProxy}/emoji.webp`);
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ) // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl); url.searchParams.set('url', dbUrl);
url.searchParams.set('emoji', '1'); url.searchParams.set('emoji', '1');
if ('static' in request.query) url.searchParams.set('static', '1'); if ('static' in request.query) url.searchParams.set('static', '1');
} }
@ -392,6 +406,16 @@ export class ServerService implements OnApplicationShutdown {
reply.header('Cache-Control', 'public, max-age=86400'); reply.header('Cache-Control', 'public, max-age=86400');
if (user) { if (user) {
const dbUrl = user?.avatarUrl ?? this.userEntityService.getIdenticonUrl(user);
const dbUrlParsed = new URL(dbUrl);
const instanceUrl = new URL(this.config.url);
if (dbUrlParsed.origin === instanceUrl.origin) {
if (!redirectSafePath(dbUrlParsed.pathname)) {
return await reply.status(508);
}
return await reply.redirect(dbUrl, 301);
}
reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user)); reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user));
} else { } else {
reply.redirect('/static-assets/user-unknown.png'); reply.redirect('/static-assets/user-unknown.png');

View file

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

View file

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

View file

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

View file

@ -118,6 +118,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
])); ]));
if (local != null) return local; if (local != null) return local;
const host = this.utilityService.extractDbHost(uri);
// local object, not found in db? fail
if (this.utilityService.isSelfHost(host)) return null;
// リモートから一旦オブジェクトフェッチ // リモートから一旦オブジェクトフェッチ
const resolver = this.apResolverService.createResolver(); const resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(uri) as any; const object = await resolver.resolve(uri) as any;
@ -134,8 +139,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return await this.mergePack( return await this.mergePack(
me, me,
isActor(object) ? await this.apPersonService.createPerson(getApId(object)) : null, isActor(object) ? await this.apPersonService.createPerson(getApId(object), resolver) : null,
isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, true) : null, isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, resolver, true) : null,
); );
} }

View file

@ -96,7 +96,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// Check if the circular reference will occur // Check if the circular reference will occur
const checkCircle = async (folderId: string, limit: number = 32): Promise<boolean> => { const checkCircle = async (folderId: string, limit: number = 32): Promise<boolean> => {
if (limit <= 0) { if (limit <= 0) {
return false; return true;
} }
const folder2 = await this.driveFoldersRepository.findOneByOrFail({ const folder2 = await this.driveFoldersRepository.findOneByOrFail({
id: folderId, id: folderId,

View file

@ -50,18 +50,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private emojiEntityService: EmojiEntityService, private emojiEntityService: EmojiEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const emojis = await this.emojisRepository.find({ const emojis = await this.emojisRepository
where: { .createQueryBuilder()
host: IsNull(), .where({ host: IsNull() })
}, .orderBy('LOWER(category)', 'ASC')
order: { .addOrderBy('LOWER(name)', 'ASC')
category: 'ASC', .getMany();
name: 'ASC',
},
});
return { return {
emojis: await this.emojiEntityService.packSimpleMany(emojis), emojis: emojis.map(this.emojiEntityService.packSimpleNoQuery),
}; };
}); });
} }

View file

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

View file

@ -30,6 +30,7 @@ export function generateCSP(hashedMap: Map<string, CSPHashed>, options: {
[ [
'\'self\'', '\'self\'',
'data:', 'data:',
'blob:',
// 'https://avatars.githubusercontent.com', // uncomment this for contributor avatars to work // 'https://avatars.githubusercontent.com', // uncomment this for contributor avatars to work
options.mediaProxy options.mediaProxy
].filter(Boolean)], ].filter(Boolean)],
@ -49,6 +50,7 @@ export function generateCSP(hashedMap: Map<string, CSPHashed>, options: {
'\'wasm-unsafe-eval\'', '\'wasm-unsafe-eval\'',
...scripts ...scripts
]], ]],
['worker-src', ['\'self\'', options.mediaProxy].filter(Boolean)],
['object-src', ['\'none\'']], ['object-src', ['\'none\'']],
['base-uri', ['\'self\'']], ['base-uri', ['\'self\'']],
['form-action', ['\'self\'']], ['form-action', ['\'self\'']],

View file

@ -611,7 +611,7 @@ export class ClientServerService {
} }
}); });
//#region SSR (for crawlers) //#region SSR
// User // User
fastify.get<{ Params: { user: string; sub?: string; } }>('/@:user/:sub?', async (request, reply) => { fastify.get<{ Params: { user: string; sub?: string; } }>('/@:user/:sub?', async (request, reply) => {
const { username, host } = Acct.parse(request.params.user); const { username, host } = Acct.parse(request.params.user);
@ -636,11 +636,17 @@ export class ClientServerService {
reply.header('X-Robots-Tag', 'noimageai'); reply.header('X-Robots-Tag', 'noimageai');
reply.header('X-Robots-Tag', 'noai'); reply.header('X-Robots-Tag', 'noai');
} }
const _user = await this.userEntityService.pack(user);
return await reply.view('user', { return await reply.view('user', {
user, profile, me, user, profile, me,
avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
sub: request.params.sub, sub: request.params.sub,
...await this.generateCommonPugData(this.meta), ...await this.generateCommonPugData(this.meta),
clientCtx: htmlSafeJsonStringify({
user: _user,
}),
}); });
} else { } else {
// リモートユーザーなので // リモートユーザーなので
@ -693,6 +699,9 @@ export class ClientServerService {
// TODO: Let locale changeable by instance setting // TODO: Let locale changeable by instance setting
summary: getNoteSummary(_note), summary: getNoteSummary(_note),
...await this.generateCommonPugData(this.meta), ...await this.generateCommonPugData(this.meta),
clientCtx: htmlSafeJsonStringify({
note: _note,
}),
}); });
} else { } else {
return await renderBase(reply); return await renderBase(reply);
@ -781,6 +790,9 @@ export class ClientServerService {
profile, profile,
avatarUrl: _clip.user.avatarUrl, avatarUrl: _clip.user.avatarUrl,
...await this.generateCommonPugData(this.meta), ...await this.generateCommonPugData(this.meta),
clientCtx: htmlSafeJsonStringify({
clip: _clip,
}),
}); });
} else { } else {
return await renderBase(reply); return await renderBase(reply);

View file

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

View file

@ -73,6 +73,9 @@ html
script(type='application/json' id='misskey_meta' data-generated-at=now) script(type='application/json' id='misskey_meta' data-generated-at=now)
!= metaJson != metaJson
script(type='application/json' id='misskey_clientCtx' data-generated-at=now)
!= clientCtx
script(integrity=bootJS.integrity) !{bootJS.content} script(integrity=bootJS.integrity) !{bootJS.content}
body body

View file

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

View file

@ -25,17 +25,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject, ref } from 'vue'; import { computed, defineAsyncComponent, inject, ref } from 'vue';
import type { MenuItem } from '@/types/menu.js';
import { getProxiedImageUrl, getStaticImageUrl } from '@/scripts/media-proxy.js'; import { getProxiedImageUrl, getStaticImageUrl } from '@/scripts/media-proxy.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { customEmojisMap } from '@/custom-emojis.js'; import { customEmojisMap } from '@/custom-emojis.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApiGet } from '@/scripts/misskey-api.js'; import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import * as sound from '@/scripts/sound.js'; import * as sound from '@/scripts/sound.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue'; import MkCustomEmojiDetailedDialog from '@/components/MkCustomEmojiDetailedDialog.vue';
import type { MenuItem } from '@/types/menu.js'; import { $i } from '@/account.js';
const props = defineProps<{ const props = defineProps<{
name: string; name: string;
@ -125,9 +126,31 @@ 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); 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> </script>
<style lang="scss" module> <style lang="scss" module>

View file

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

View file

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

View file

@ -62,13 +62,16 @@ import { dateString } from '@/filters/date.js';
import MkClipPreview from '@/components/MkClipPreview.vue'; import MkClipPreview from '@/components/MkClipPreview.vue';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { pleaseLogin } from '@/scripts/please-login.js'; import { pleaseLogin } from '@/scripts/please-login.js';
import { getServerContext } from '@/server-context.js';
const CTX_NOTE = getServerContext('note');
const props = defineProps<{ const props = defineProps<{
noteId: string; noteId: string;
initialTab?: string; initialTab?: string;
}>(); }>();
const note = ref<null | Misskey.entities.Note>(); const note = ref<null | Misskey.entities.Note>(CTX_NOTE);
const clips = ref<Misskey.entities.Clip[]>(); const clips = ref<Misskey.entities.Clip[]>();
const showPrev = ref<'user' | 'channel' | false>(false); const showPrev = ref<'user' | 'channel' | false>(false);
const showNext = ref<'user' | 'channel' | false>(false); const showNext = ref<'user' | 'channel' | false>(false);
@ -116,6 +119,12 @@ function fetchNote() {
showPrev.value = false; showPrev.value = false;
showNext.value = false; showNext.value = false;
note.value = null; note.value = null;
if (CTX_NOTE && CTX_NOTE.id === props.noteId) {
note.value = CTX_NOTE;
return;
}
misskeyApi('notes/show', { misskeyApi('notes/show', {
noteId: props.noteId, noteId: props.noteId,
}).then(res => { }).then(res => {

View file

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

View file

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as Misskey from 'misskey-js';
import { $i } from '@/account.js';
const providedContextEl = document.getElementById('misskey_clientCtx');
export type ServerContext = {
clip?: Misskey.entities.Clip;
note?: Misskey.entities.Note;
user?: Misskey.entities.UserLite;
} | null;
export const serverContext: ServerContext = (providedContextEl && providedContextEl.textContent) ? JSON.parse(providedContextEl.textContent) : null;
export function getServerContext<K extends keyof NonNullable<ServerContext>>(entity: K): Required<Pick<NonNullable<ServerContext>, K>> | null {
// contextは非ログイン状態の情報しかないためログイン時は利用できない
if ($i) return null;
return serverContext ? (serverContext[entity] ?? null) : null;
}

View file

@ -1,7 +1,7 @@
{ {
"type": "module", "type": "module",
"name": "misskey-js", "name": "misskey-js",
"version": "2024.11.0-yumechinokuni.5", "version": "2024.11.0-yumechinokuni.6",
"description": "Misskey SDK for JavaScript", "description": "Misskey SDK for JavaScript",
"license": "MIT", "license": "MIT",
"main": "./built/index.js", "main": "./built/index.js",

View file

@ -12,209 +12,242 @@ import { createEmptyNotification, createNotification } from '@/scripts/create-no
import { swLang } from '@/scripts/lang.js'; import { swLang } from '@/scripts/lang.js';
import * as swos from '@/scripts/operations.js'; import * as swos from '@/scripts/operations.js';
globalThis.addEventListener('install', () => { const STATIC_CACHE_NAME = `misskey-static-${_VERSION_}`;
// ev.waitUntil(globalThis.skipWaiting()); const PATHS_TO_CACHE = ['/assets/', '/static-assets/', '/emoji/', '/twemoji/', '/fluent-emoji/', '/vite/'];
async function cacheWithFallback(cache, paths) {
for (const path of paths) {
try {
await cache.add(new Request(path, { credentials: 'same-origin' }));
} catch (error) {
// eslint-disable-next-line no-empty
}
}
}
globalThis.addEventListener('install', (ev) => {
ev.waitUntil((async () => {
const cache = await caches.open(STATIC_CACHE_NAME);
await cacheWithFallback(cache, PATHS_TO_CACHE);
await globalThis.skipWaiting();
})());
}); });
globalThis.addEventListener('activate', ev => { globalThis.addEventListener('activate', (ev) => {
ev.waitUntil( ev.waitUntil(
caches.keys() caches.keys()
.then(cacheNames => Promise.all( .then((cacheNames) => Promise.all(
cacheNames cacheNames
.filter((v) => v !== swLang.cacheName) .filter((v) => v !== STATIC_CACHE_NAME && v !== swLang.cacheName)
.map(name => caches.delete(name)), .map((name) => caches.delete(name)),
)) ))
.then(() => globalThis.clients.claim()), .then(() => globalThis.clients.claim()),
); );
}); });
async function offlineContentHTML() { async function offlineContentHTML() {
const i18n = await (swLang.i18n ?? swLang.fetchLocale()) as Partial<I18n<Locale>>; const i18n = await (swLang.i18n ?? swLang.fetchLocale()) as Partial<I18n<Locale>>;
const messages = { const messages = {
title: i18n.ts?._offlineScreen.title ?? 'Offline - Could not connect to server', title: i18n.ts?._offlineScreen.title ?? 'Offline - Could not connect to server',
header: i18n.ts?._offlineScreen.header ?? 'Could not connect to server', header: i18n.ts?._offlineScreen.header ?? 'Could not connect to server',
reload: i18n.ts?.reload ?? 'Reload', reload: i18n.ts?.reload ?? 'Reload',
}; };
return `<!DOCTYPE html><html lang="ja"><head><meta charset="UTF-8"><meta content="width=device-width,initial-scale=1"name="viewport"><title>${messages.title}</title><style>body{background-color:#0c1210;color:#dee7e4;font-family:Hiragino Maru Gothic Pro,BIZ UDGothic,Roboto,HelveticaNeue,Arial,sans-serif;line-height:1.35;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:24px;box-sizing:border-box}.icon{max-width:120px;width:100%;height:auto;margin-bottom:20px;}.message{text-align:center;font-size:20px;font-weight:700;margin-bottom:20px}.version{text-align:center;font-size:90%;margin-bottom:20px}button{padding:7px 14px;min-width:100px;font-weight:700;font-family:Hiragino Maru Gothic Pro,BIZ UDGothic,Roboto,HelveticaNeue,Arial,sans-serif;line-height:1.35;border-radius:99rem;background-color:#b4e900;color:#192320;border:none;cursor:pointer;-webkit-tap-highlight-color:transparent}button:hover{background-color:#c6ff03}</style></head><body><svg class="icon"fill="none"height="24"stroke="currentColor"stroke-linecap="round"stroke-linejoin="round"stroke-width="2"viewBox="0 0 24 24"width="24"xmlns="http://www.w3.org/2000/svg"><path d="M0 0h24v24H0z"fill="none"stroke="none"/><path d="M9.58 5.548c.24 -.11 .492 -.207 .752 -.286c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.913 0 3.464 1.56 3.464 3.486c0 .957 -.383 1.824 -1.003 2.454m-2.997 1.033h-11.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.13 -.582 .37 -1.128 .7 -1.62"/><path d="M3 3l18 18"/></svg><div class="message">${messages.header}</div><div class="version">v${_VERSION_}</div><button onclick="reloadPage()">${messages.reload}</button><script>function reloadPage(){location.reload(!0)}</script></body></html>`; return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta content="width=device-width,initial-scale=1" name="viewport"><title>${messages.title}</title><style>body{background-color:#0c1210;color:#dee7e4;font-family:Hiragino Maru Gothic Pro,BIZ UDGothic,Roboto,HelveticaNeue,Arial,sans-serif;line-height:1.35;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:24px;box-sizing:border-box}.icon{max-width:120px;width:100%;height:auto;margin-bottom:20px;}.message{text-align:center;font-size:20px;font-weight:700;margin-bottom:20px}.version{text-align:center;font-size:90%;margin-bottom:20px}button{padding:7px 14px;min-width:100px;font-weight:700;font-family:Hiragino Maru Gothic Pro,BIZ UDGothic,Roboto,HelveticaNeue,Arial,sans-serif;line-height:1.35;border-radius:99rem;background-color:#ff82ab;color:#192320;border:none;cursor:pointer;-webkit-tap-highlight-color:transparent}button:hover{background-color:#fac5eb}</style></head><body><svg class="icon" fill="none" height="24" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h24v24H0z" fill="none" stroke="none"/><path d="M9.58 5.548c.24 -.11 .492 -.207 .752 -.286c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.913 0 3.464 1.56 3.464 3.486c0 .957 -.383 1.824 -1.003 2.454m-2.997 1.033h-11.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.13 -.582 .37 -1.128 .7 -1.62"/><path d="M3 3l18 18"/></svg><div class="message">${messages.header}</div><div class="version">v${_VERSION_}</div><button onclick="reloadPage()">${messages.reload}</button><script>function reloadPage(){location.reload(true)}</script></body></html>`;
} }
globalThis.addEventListener('fetch', ev => { globalThis.addEventListener('fetch', (ev) => {
let isHTMLRequest = false; const shouldCache = PATHS_TO_CACHE.some((path) => ev.request.url.includes(path));
if (ev.request.headers.get('sec-fetch-dest') === 'document') {
isHTMLRequest = true;
} else if (ev.request.headers.get('accept')?.includes('/html')) {
isHTMLRequest = true;
} else if (ev.request.url.endsWith('/')) {
isHTMLRequest = true;
}
if (!isHTMLRequest) return; if (shouldCache) {
ev.respondWith( ev.respondWith(
fetch(ev.request) caches.match(ev.request)
.catch(async () => { .then((response) => {
const html = await offlineContentHTML(); if (response) return response;
return new Response(html, {
status: 200, return fetch(ev.request).then((response) => {
headers: { if (!response || response.status !== 200 || response.type !== 'basic') return response;
'content-type': 'text/html', const responseToCache = response.clone();
}, caches.open(STATIC_CACHE_NAME)
}); .then((cache) => {
}), cache.put(ev.request, responseToCache);
); });
return response;
});
}),
);
return;
}
let isHTMLRequest = false;
if (ev.request.headers.get('sec-fetch-dest') === 'document') {
isHTMLRequest = true;
} else if (ev.request.headers.get('accept')?.includes('/html')) {
isHTMLRequest = true;
} else if (ev.request.url.endsWith('/')) {
isHTMLRequest = true;
}
if (!isHTMLRequest) return;
ev.respondWith(
fetch(ev.request)
.catch(async () => {
const html = await offlineContentHTML();
return new Response(html, {
status: 200,
headers: {
'content-type': 'text/html',
},
});
}),
);
}); });
globalThis.addEventListener('push', ev => { globalThis.addEventListener('push', (ev) => {
// クライアント取得 ev.waitUntil(globalThis.clients.matchAll({
ev.waitUntil(globalThis.clients.matchAll({ includeUncontrolled: true,
includeUncontrolled: true, type: 'window',
type: 'window', }).then(async () => {
}).then(async () => { const data: PushNotificationDataMap[keyof PushNotificationDataMap] = ev.data?.json();
const data: PushNotificationDataMap[keyof PushNotificationDataMap] = ev.data?.json();
switch (data.type) { switch (data.type) {
// case 'driveFileCreated': case 'notification':
case 'notification': case 'unreadAntennaNote':
case 'unreadAntennaNote': if (Date.now() - data.dateTime > 1000 * 60 * 60 * 24) break;
// 1日以上経過している場合は無視
if (Date.now() - data.dateTime > 1000 * 60 * 60 * 24) break;
return createNotification(data); return createNotification(data);
case 'readAllNotifications': case 'readAllNotifications':
await globalThis.registration.getNotifications() await globalThis.registration.getNotifications()
.then(notifications => notifications.forEach(n => n.tag !== 'read_notification' && n.close())); .then((notifications) => notifications.forEach((n) => n.tag !== 'read_notification' && n.close()));
break; break;
} }
await createEmptyNotification(); await createEmptyNotification();
return; return;
})); }));
}); });
globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEventMap['notificationclick']) => { globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEventMap['notificationclick']) => {
ev.waitUntil((async (): Promise<void> => { ev.waitUntil((async (): Promise<void> => {
if (_DEV_) { if (_DEV_) {
console.log('notificationclick', ev.action, ev.notification.data); console.log('notificationclick', ev.action, ev.notification.data);
} }
const { action, notification } = ev; const { action, notification } = ev;
const data: PushNotificationDataMap[keyof PushNotificationDataMap] = notification.data ?? {}; const data: PushNotificationDataMap[keyof PushNotificationDataMap] = notification.data ?? {};
const { userId: loginId } = data; const { userId: loginId } = data;
let client: WindowClient | null = null; let client: WindowClient | null = null;
switch (data.type) { switch (data.type) {
case 'notification': case 'notification':
switch (action) { switch (action) {
case 'follow': case 'follow':
if ('userId' in data.body) await swos.api('following/create', loginId, { userId: data.body.userId }); if ('userId' in data.body) await swos.api('following/create', loginId, { userId: data.body.userId });
break; break;
case 'showUser': case 'showUser':
if ('user' in data.body) client = await swos.openUser(Misskey.acct.toString(data.body.user), loginId); if ('user' in data.body) client = await swos.openUser(Misskey.acct.toString(data.body.user), loginId);
break; break;
case 'reply': case 'reply':
if ('note' in data.body) client = await swos.openPost({ reply: data.body.note }, loginId); if ('note' in data.body) client = await swos.openPost({ reply: data.body.note }, loginId);
break; break;
case 'renote': case 'renote':
if ('note' in data.body) await swos.api('notes/create', loginId, { renoteId: data.body.note.id }); if ('note' in data.body) await swos.api('notes/create', loginId, { renoteId: data.body.note.id });
break; break;
case 'accept': case 'accept':
switch (data.body.type) { switch (data.body.type) {
case 'receiveFollowRequest': case 'receiveFollowRequest':
await swos.api('following/requests/accept', loginId, { userId: data.body.userId }); await swos.api('following/requests/accept', loginId, { userId: data.body.userId });
break; break;
} }
break; break;
case 'reject': case 'reject':
switch (data.body.type) { switch (data.body.type) {
case 'receiveFollowRequest': case 'receiveFollowRequest':
await swos.api('following/requests/reject', loginId, { userId: data.body.userId }); await swos.api('following/requests/reject', loginId, { userId: data.body.userId });
break; break;
} }
break; break;
case 'showFollowRequests': case 'showFollowRequests':
client = await swos.openClient('push', '/my/follow-requests', loginId); client = await swos.openClient('push', '/my/follow-requests', loginId);
break; break;
default: default:
switch (data.body.type) { switch (data.body.type) {
case 'receiveFollowRequest': case 'receiveFollowRequest':
client = await swos.openClient('push', '/my/follow-requests', loginId); client = await swos.openClient('push', '/my/follow-requests', loginId);
break; break;
case 'reaction': case 'reaction':
client = await swos.openNote(data.body.note.id, loginId); client = await swos.openNote(data.body.note.id, loginId);
break; break;
default: default:
if ('note' in data.body) { if ('note' in data.body) {
client = await swos.openNote(data.body.note.id, loginId); client = await swos.openNote(data.body.note.id, loginId);
} else if ('user' in data.body) { } else if ('user' in data.body) {
client = await swos.openUser(Misskey.acct.toString(data.body.user), loginId); client = await swos.openUser(Misskey.acct.toString(data.body.user), loginId);
} }
break; break;
} }
} }
break; break;
case 'unreadAntennaNote': case 'unreadAntennaNote':
client = await swos.openAntenna(data.body.antenna.id, loginId); client = await swos.openAntenna(data.body.antenna.id, loginId);
break; break;
default: default:
switch (action) { switch (action) {
case 'markAllAsRead': case 'markAllAsRead':
await globalThis.registration.getNotifications() await globalThis.registration.getNotifications()
.then(notifications => notifications.forEach(n => n.tag !== 'read_notification' && n.close())); .then((notifications) => notifications.forEach((n) => n.tag !== 'read_notification' && n.close()));
await get<Pick<Misskey.entities.SignupResponse, 'id' | 'token'>[]>('accounts').then(accounts => { await get<Pick<Misskey.entities.SignupResponse, 'id' | 'token'>[]>('accounts').then((accounts) => {
return Promise.all((accounts ?? []).map(async account => { return Promise.all((accounts ?? []).map(async (account) => {
await swos.sendMarkAllAsRead(account.id); await swos.sendMarkAllAsRead(account.id);
})); }));
}); });
break; break;
case 'settings': case 'settings':
client = await swos.openClient('push', '/settings/notifications', loginId); client = await swos.openClient('push', '/settings/notifications', loginId);
break; break;
} }
} }
if (client) { if (client) {
client.focus(); client.focus();
} }
if (data.type === 'notification') { if (data.type === 'notification') {
await swos.sendMarkAllAsRead(loginId); await swos.sendMarkAllAsRead(loginId);
} }
notification.close(); notification.close();
})()); })());
}); });
globalThis.addEventListener('notificationclose', (ev: ServiceWorkerGlobalScopeEventMap['notificationclose']) => { globalThis.addEventListener('notificationclose', (ev: ServiceWorkerGlobalScopeEventMap['notificationclose']) => {
const data: PushNotificationDataMap[keyof PushNotificationDataMap] = ev.notification.data; const data: PushNotificationDataMap[keyof PushNotificationDataMap] = ev.notification.data;
ev.waitUntil((async (): Promise<void> => { ev.waitUntil((async (): Promise<void> => {
if (data.type === 'notification') { if (data.type === 'notification') {
await swos.sendMarkAllAsRead(data.userId); await swos.sendMarkAllAsRead(data.userId);
} }
return; return;
})()); })());
}); });
globalThis.addEventListener('message', (ev: ServiceWorkerGlobalScopeEventMap['message']) => { globalThis.addEventListener('message', (ev: ServiceWorkerGlobalScopeEventMap['message']) => {
ev.waitUntil((async (): Promise<void> => { ev.waitUntil((async (): Promise<void> => {
switch (ev.data) { if (ev.data === 'clear') {
case 'clear': await caches.keys()
// Cache Storage全削除 .then((cacheNames) => Promise.all(
await caches.keys() cacheNames.map((name) => caches.delete(name)),
.then(cacheNames => Promise.all( ));
cacheNames.map(name => caches.delete(name)), return;
)); }
return; // TODO
}
if (typeof ev.data === 'object') { if (typeof ev.data === 'object') {
// E.g. '[object Array]' → 'array' const otype = Object.prototype.toString.call(ev.data).slice(8, -1).toLowerCase();
const otype = Object.prototype.toString.call(ev.data).slice(8, -1).toLowerCase();
if (otype === 'object') { if (otype === 'object') {
if (ev.data.msg === 'initialize') { if (ev.data.msg === 'initialize') {
swLang.setLang(ev.data.lang); swLang.setLang(ev.data.lang);
} }
} }
} }
})()); })());
}); });