Compare commits

...

27 commits

Author SHA1 Message Date
fly_mc
437dbbe75d Merge branch 'pari-dev' into pari 2024-11-21 23:38:29 +08:00
fly_mc
67d8393724 sw: lint 2024-11-21 23:06:10 +08:00
fly_mc
a0fe3feb48 backend: fix duplicated import 2024-11-21 22:47:28 +08:00
fly_mc
48683b5557 sw: use StorageAPI and better cache management 2024-11-21 22:31:52 +08:00
fly_mc
0c3e70b5c3 frontend: move showActionsOnlyHover to bottom 2024-11-21 22:10:34 +08:00
fly_mc
5a3f3862f8 frontend: removed renote counts auto increase 2024-11-21 22:03:49 +08:00
fly_mc
be80b377cc frontend: removed auto count+1 in MkPostForm 2024-11-21 22:01:42 +08:00
fly_mc
d194d4e247 Merge branch 'develop' into pari-dev 2024-11-21 21:57:03 +08:00
fly_mc
1372e005a5 Revert "do not use media proxy if emoji is local"
This reverts commit e2471b85dd.
2024-11-21 21:55:50 +08:00
github-actions[bot]
752606fe88 Bump version to 2024.11.0-beta.4 2024-11-21 08:21:54 +00:00
かっこかり
7f0ae038d4
Update CHANGELOG.md 2024-11-21 17:16:06 +09:00
syuilo
9871035597
Update CHANGELOG.md 2024-11-21 15:41:01 +09:00
github-actions[bot]
a21a2c52d7 Bump version to 2024.11.0-alpha.3 2024-11-21 06:27:16 +00:00
かっこかり
c1f19fad1e
fix(backend): fix apResolver (#15010)
* fix(backend): fix apResolver

* fix

* add comments

* tweak comment
2024-11-21 14:36:24 +09:00
fly_mc
e8d04d1029 Merge branch 'develop' into pari-dev 2024-11-21 12:32:48 +08:00
e2471b85dd do not use media proxy if emoji is local
Signed-off-by: eternal-flame-AD <yume@yumechi.jp>
2024-11-21 12:19:31 +08:00
かっこかり
3a6c2aa835
fix(backend): fix type error(s) in security fixes (#15009)
* Fix type error in security fixes

(cherry picked from commit fa3cf6c2996741e642955c5e2fca8ad785e83205)

* Fix error in test function calls

(cherry picked from commit 1758f29364eca3cbd13dbb5c84909c93712b3b3b)

* Fix style error

(cherry picked from commit 23c4aa25714af145098baa7edd74c1d217e51c1a)

* Fix another style error

(cherry picked from commit 36af07abe28bec670aaebf9f5af5694bb582c29a)

* Fix `.punyHost` misuse

(cherry picked from commit 6027b516e1c82324d55d6e54d0e17cbd816feb42)

* attempt to fix test: make yaml valid

---------

Co-authored-by: Julia Johannesen <julia@insertdomain.name>
2024-11-21 12:10:02 +09:00
かっこかり
53e827b18c
fix(backend): fix security patches (#15008) 2024-11-21 10:30:30 +09:00
syuilo
0f59adc436 fix ap/show 2024-11-21 09:25:18 +09:00
syuilo
9fdabe3666 fix(backend): use atomic command to improve security
Co-Authored-By: Acid Chicken <root@acid-chicken.com>
2024-11-21 09:22:15 +09:00
rectcoordsystem
090e9392cd
Merge commit from fork
* fix(backend): check target IP before sending HTTP request

* fix(backend): allow accessing private IP when testing

* Apply suggestions from code review

Co-authored-by: anatawa12 <anatawa12@icloud.com>

* fix(backend): lint and typecheck

* fix(backend): add isLocalAddressAllowed option to getAgentByUrl and send (HttpRequestService)

* fix(backend): allow fetchSummaryFromProxy, trueMail to access local addresses

---------

Co-authored-by: anatawa12 <anatawa12@icloud.com>
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2024-11-21 08:27:09 +09:00
Julia
b9cb949eb1
Merge commit from fork
* Fix poll update spoofing

* fix: Disallow negative poll counts

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2024-11-21 08:24:50 +09:00
Julia
5f675201f2
Merge commit from fork
* enhance: Add a few validation fixes from Sharkey

See the original MR on the GitLab instance:
https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/484

Co-Authored-By: Dakkar <dakkar@thenautilus.net>

* fix: primitive 2: acceptance of cross-origin alternate

Co-Authored-By: Laura Hausmann <laura@hausmann.dev>

* fix: primitive 3: validation of non-final url

* fix: primitive 4: missing same-origin identifier validation of collection-wrapped activities

* fix: primitives 5 & 8: reject activities with non
string identifiers

Co-Authored-By: Laura Hausmann <laura@hausmann.dev>

* fix: primitive 6: reject anonymous objects that were fetched by their id

* fix: primitives 9, 10 & 11: http signature validation
doesn't enforce required headers or specify auth header name

Co-Authored-By: Laura Hausmann <laura@hausmann.dev>

* fix: primitive 14: improper validation of outbox, followers, following & shared inbox collections

* fix: code style for primitive 14

* fix: primitive 15: improper same-origin validation for
note uri and url

Co-Authored-By: Laura Hausmann <laura@hausmann.dev>

* fix: primitive 16: improper same-origin validation for user uri and url

* fix: primitive 17: note same-origin identifier validation can be bypassed by wrapping the id in an array

* fix: code style for primitive 17

* fix: check attribution against actor in notes

While this isn't strictly required to fix the exploits at hand, this
mirrors the fix in `ApQuestionService` for GHSA-5h8r-gq97-xv69, as a
preemptive countermeasure.

* fix: primitive 18: `ap/get` bypasses access checks

One might argue that we could make this one actually preform access
checks against the returned activity object, but I feel like that's a
lot more work than just restricting it to administrators, since, to me
at least, it seems more like a debugging tool than anything else.

* fix: primitive 19 & 20: respect blocks and hide more

Ideally, the user property should also be hidden (as leaving it in leaks
information slightly), but given the schema of the note endpoint, I
don't think that would be possible without introducing some kind of
"ghost" user, who is attributed for posts by users who have you blocked.

* fix: primitives 21, 22, and 23: reuse resolver

This also increases the default `recursionLimit` for `Resolver`, as it
theoretically will go higher that it previously would and could possibly
fail on non-malicious collection activities.

* fix: primitives 25-33: proper local instance checks

* revert: fix: primitive 19 & 20

This reverts commit 465a9fe6591de90f78bd3d084e3c01e65dc3cf3c.

---------

Co-authored-by: Dakkar <dakkar@thenautilus.net>
Co-authored-by: Laura Hausmann <laura@hausmann.dev>
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2024-11-21 08:20:09 +09:00
syuilo
1c284c8154
New Crowdin updates (#15000)
* New translations ja-jp.yml (Catalan)

* New translations ja-jp.yml (English)

* New translations ja-jp.yml (Korean)

* New translations ja-jp.yml (Chinese Simplified)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (German)
2024-11-21 08:01:42 +09:00
Sayamame-beans
aa48a0e207
Fix: リノートミュートが新規投稿通知に対して作用していなかった問題を修正 (#15006)
* fix(backend): renoteMute doesn't work for note notification

* docs(changelog): update changelog
2024-11-21 08:00:50 +09:00
syuilo
f0c3a4cc0b
perf(frontend): reduce api requests for non-logged-in enviroment (#15001)
* wip

* Update CHANGELOG.md

* wip
2024-11-21 07:58:34 +09:00
fly_mc
7b622d797d frontend: add same router 2024-11-21 00:47:34 +08:00
42 changed files with 756 additions and 365 deletions

View file

@ -8,9 +8,9 @@
### General
- Feat: コンテンツの表示にログインを必須にできるように
- Feat: 過去のノートを非公開化/フォロワーのみ表示可能にできるように
- Fix: お知らせ作成時に画像URL入力欄を空欄に変更できないのを修正 ( #14976 )
- Enhance: 依存関係の更新
- Enhance: l10nの更新
- Fix: お知らせ作成時に画像URL入力欄を空欄に変更できないのを修正 ( #14976 )
### Client
- Enhance: Bull DashboardでRelationship Queueの状態も確認できるように
@ -30,6 +30,7 @@
(Based on https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/663)
- Enhance: サイドバーを簡単に展開・折りたたみできるように ( #14981 )
- Enhance: リノートメニューに「リノートの詳細」を追加
- Enhance: 非ログイン状態でMisskeyを開いた際のパフォーマンスを向上
- Fix: 通知の範囲指定の設定項目が必要ない通知設定でも範囲指定の設定がでている問題を修正
- Fix: Turnstileが失敗・期限切れした際にも成功扱いとなってしまう問題を修正
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/768)
@ -65,6 +66,8 @@
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/709)
- Fix: User Webhookテスト機能のMock Payloadを修正
- Fix: アカウント削除のモデレーションログが動作していないのを修正 (#14996)
- Fix: リノートミュートが新規投稿通知に対して作用していなかった問題を修正
- Fix: セキュリティに関する修正
### Misskey.js
- Fix: Stream初期化時、別途WebSocketを指定する場合の型定義を修正

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2024.11.0-pari.27",
"version": "2024.11.0-pari.28",
"codename": "nasubi",
"repository": {
"type": "git",

View file

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

View file

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

View file

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

View file

@ -565,15 +565,23 @@ export class NoteCreateService implements OnApplicationShutdown {
this.followingsRepository.findBy({
followeeId: user.id,
notify: 'normal',
}).then(followings => {
}).then(async followings => {
if (note.visibility !== 'specified') {
const isPureRenote = this.isRenote(data) && !this.isQuote(data) ? true : false;
for (const following of followings) {
// TODO: ワードミュート考慮
let isRenoteMuted = false;
if (isPureRenote) {
const userIdsWhoMeMutingRenotes = await this.cacheService.renoteMutingsCache.fetch(following.followerId);
isRenoteMuted = userIdsWhoMeMutingRenotes.has(user.id);
}
if (!isRenoteMuted) {
this.notificationService.createNotification(following.followerId, 'note', {
noteId: note.id,
}, user.id);
}
}
}
});
}

View file

@ -56,7 +56,7 @@ export class RemoteUserResolveService {
host = this.utilityService.toPuny(host);
if (this.config.host === host) {
if (host === this.utilityService.toPuny(this.config.host)) {
this.logger.info(`return local user: ${usernameLower}`);
return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => {
if (u == null) {

View file

@ -34,6 +34,11 @@ export class UtilityService {
return this.toPuny(this.config.host) === this.toPuny(host);
}
@bindThis
public isUriLocal(uri: string): boolean {
return this.punyHost(uri) === this.toPuny(this.config.host);
}
@bindThis
public isBlockedHost(blockedHosts: string[], host: string | null): boolean {
if (host == null) return false;
@ -117,6 +122,7 @@ export class UtilityService {
return host;
}
@bindThis
public isFederationAllowedHost(host: string): boolean {
if (this.meta.federation === 'none') return false;
if (this.meta.federation === 'specified' && !this.meta.federationHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`))) return false;

View file

@ -246,14 +246,12 @@ export class WebAuthnService {
@bindThis
public async verifyAuthentication(userId: MiUser['id'], response: AuthenticationResponseJSON): Promise<boolean> {
const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`);
const challenge = await this.redisClient.getdel(`webauthn:challenge:${userId}`);
if (!challenge) {
throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', 'challenge not found');
}
await this.redisClient.del(`webauthn:challenge:${userId}`);
const key = await this.userSecurityKeysRepository.findOneBy({
id: response.id,
userId: userId,

View file

@ -10,6 +10,7 @@ import type { Config } from '@/config.js';
import { MemoryKVCache } from '@/misc/cache.js';
import type { MiUserPublickey } from '@/models/UserPublickey.js';
import { CacheService } from '@/core/CacheService.js';
import { UtilityService } from '@/core/UtilityService.js';
import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js';
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
@ -55,6 +56,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
private cacheService: CacheService,
private apPersonService: ApPersonService,
private apLoggerService: ApLoggerService,
private utilityService: UtilityService,
) {
this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
@ -65,7 +67,9 @@ export class ApDbResolverService implements OnApplicationShutdown {
const separator = '/';
const uri = new URL(getApId(value));
if (uri.origin !== this.config.url) return { local: false, uri: uri.href };
if (this.utilityService.toPuny(uri.host) !== this.utilityService.toPuny(this.config.host)) {
return { local: false, uri: uri.href };
}
const [, type, id, ...rest] = uri.pathname.split(separator);
return {

View file

@ -91,15 +91,26 @@ export class ApInboxService {
}
@bindThis
public async performActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> {
public async performActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver): Promise<string | void> {
let result = undefined as string | void;
if (isCollectionOrOrderedCollection(activity)) {
const results = [] as [string, string | void][];
const resolver = this.apResolverService.createResolver();
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) {
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const items = toArray(isCollection(activity) ? activity.items : activity.orderedItems);
if (items.length >= resolver.getRecursionLimit()) {
throw new Error(`skipping activity: collection would surpass recursion limit: ${this.utilityService.extractDbHost(actor.uri)}`);
}
for (const item of items) {
const act = await resolver.resolve(item);
if (act.id == null || this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) {
this.logger.debug('skipping activity: activity id is null or mismatching');
continue;
}
try {
results.push([getApId(item), await this.performOneActivity(actor, act)]);
results.push([getApId(item), await this.performOneActivity(actor, act, resolver)]);
} catch (err) {
if (err instanceof Error || typeof err === 'string') {
this.logger.error(err);
@ -114,13 +125,14 @@ export class ApInboxService {
result = results.map(([id, reason]) => `${id}: ${reason}`).join('\n');
}
} else {
result = await this.performOneActivity(actor, activity);
result = await this.performOneActivity(actor, activity, resolver);
}
// ついでにリモートユーザーの情報が古かったら更新しておく
if (actor.uri) {
if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
setImmediate(() => {
// 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない
this.apPersonService.updatePerson(actor.uri);
});
}
@ -129,37 +141,37 @@ export class ApInboxService {
}
@bindThis
public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> {
public async performOneActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver): Promise<string | void> {
if (actor.isSuspended) return;
if (isCreate(activity)) {
return await this.create(actor, activity);
return await this.create(actor, activity, resolver);
} else if (isDelete(activity)) {
return await this.delete(actor, activity);
} else if (isUpdate(activity)) {
return await this.update(actor, activity);
return await this.update(actor, activity, resolver);
} else if (isFollow(activity)) {
return await this.follow(actor, activity);
} else if (isAccept(activity)) {
return await this.accept(actor, activity);
return await this.accept(actor, activity, resolver);
} else if (isReject(activity)) {
return await this.reject(actor, activity);
return await this.reject(actor, activity, resolver);
} else if (isAdd(activity)) {
return await this.add(actor, activity);
return await this.add(actor, activity, resolver);
} else if (isRemove(activity)) {
return await this.remove(actor, activity);
return await this.remove(actor, activity, resolver);
} else if (isAnnounce(activity)) {
return await this.announce(actor, activity);
return await this.announce(actor, activity, resolver);
} else if (isLike(activity)) {
return await this.like(actor, activity);
} else if (isUndo(activity)) {
return await this.undo(actor, activity);
return await this.undo(actor, activity, resolver);
} else if (isBlock(activity)) {
return await this.block(actor, activity);
} else if (isFlag(activity)) {
return await this.flag(actor, activity);
} else if (isMove(activity)) {
return await this.move(actor, activity);
return await this.move(actor, activity, resolver);
} else {
return `unrecognized activity type: ${activity.type}`;
}
@ -201,12 +213,13 @@ export class ApInboxService {
}
@bindThis
private async accept(actor: MiRemoteUser, activity: IAccept): Promise<string> {
private async accept(actor: MiRemoteUser, activity: IAccept, resolver?: Resolver): Promise<string> {
const uri = activity.id ?? activity;
this.logger.info(`Accept: ${uri}`);
const resolver = this.apResolverService.createResolver();
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(err => {
this.logger.error(`Resolution failed: ${err}`);
@ -243,7 +256,7 @@ export class ApInboxService {
}
@bindThis
private async add(actor: MiRemoteUser, activity: IAdd): Promise<string | void> {
private async add(actor: MiRemoteUser, activity: IAdd, resolver?: Resolver): Promise<string | void> {
if (actor.uri !== activity.actor) {
return 'invalid actor';
}
@ -254,7 +267,7 @@ export class ApInboxService {
if (activity.target === actor.featured) {
const object = fromTuple(activity.object);
const note = await this.apNoteService.resolveNote(object);
const note = await this.apNoteService.resolveNote(object, { resolver });
if (note == null) return 'note not found';
await this.notePiningService.addPinned(actor, note.id);
return;
@ -264,12 +277,13 @@ export class ApInboxService {
}
@bindThis
private async announce(actor: MiRemoteUser, activity: IAnnounce): Promise<string | void> {
private async announce(actor: MiRemoteUser, activity: IAnnounce, resolver?: Resolver): Promise<string | void> {
const uri = getApId(activity);
this.logger.info(`Announce: ${uri}`);
const resolver = this.apResolverService.createResolver();
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const activityObject = fromTuple(activity.object);
if (!activityObject) return 'skip: activity has no object property';
@ -287,7 +301,7 @@ export class ApInboxService {
}
@bindThis
private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost): Promise<string | void> {
private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost, resolver?: Resolver): Promise<string | void> {
const uri = getApId(activity);
if (actor.isSuspended) {
@ -309,7 +323,7 @@ export class ApInboxService {
// Announce対象をresolve
let renote;
try {
renote = await this.apNoteService.resolveNote(target);
renote = await this.apNoteService.resolveNote(target, { resolver });
if (renote == null) return 'announce target is null';
} catch (err) {
// 対象が4xxならスキップ
@ -328,7 +342,7 @@ export class ApInboxService {
this.logger.info(`Creating the (Re)Note: ${uri}`);
const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc);
const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc, resolver);
const createdAt = activity.published ? new Date(activity.published) : null;
if (createdAt && createdAt < this.idService.parse(renote.id).date) {
@ -366,7 +380,7 @@ export class ApInboxService {
}
@bindThis
private async create(actor: MiRemoteUser, activity: ICreate): Promise<string | void> {
private async create(actor: MiRemoteUser, activity: ICreate, resolver?: Resolver): Promise<string | void> {
const uri = getApId(activity);
this.logger.info(`Create: ${uri}`);
@ -392,7 +406,8 @@ export class ApInboxService {
activityObject.attributedTo = activity.actor;
}
const resolver = this.apResolverService.createResolver();
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const object = await resolver.resolve(activityObject).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
@ -419,6 +434,8 @@ export class ApInboxService {
if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) {
return 'skip: host in actor.uri !== note.id';
}
} else {
return 'skip: note.id is not a string';
}
}
@ -428,7 +445,7 @@ export class ApInboxService {
const exist = await this.apNoteService.fetchNote(note);
if (exist) return 'skip: note exists';
await this.apNoteService.createNote(note, resolver, silent);
await this.apNoteService.createNote(note, actor, resolver, silent);
return 'ok';
} catch (err) {
if (err instanceof StatusError && !err.isRetryable) {
@ -560,12 +577,13 @@ export class ApInboxService {
}
@bindThis
private async reject(actor: MiRemoteUser, activity: IReject): Promise<string> {
private async reject(actor: MiRemoteUser, activity: IReject, resolver?: Resolver): Promise<string> {
const uri = activity.id ?? activity;
this.logger.info(`Reject: ${uri}`);
const resolver = this.apResolverService.createResolver();
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
@ -602,7 +620,7 @@ export class ApInboxService {
}
@bindThis
private async remove(actor: MiRemoteUser, activity: IRemove): Promise<string | void> {
private async remove(actor: MiRemoteUser, activity: IRemove, resolver?: Resolver): Promise<string | void> {
if (actor.uri !== activity.actor) {
return 'invalid actor';
}
@ -613,7 +631,7 @@ export class ApInboxService {
if (activity.target === actor.featured) {
const activityObject = fromTuple(activity.object);
const note = await this.apNoteService.resolveNote(activityObject);
const note = await this.apNoteService.resolveNote(activityObject, { resolver });
if (note == null) return 'note not found';
await this.notePiningService.removePinned(actor, note.id);
return;
@ -623,7 +641,7 @@ export class ApInboxService {
}
@bindThis
private async undo(actor: MiRemoteUser, activity: IUndo): Promise<string> {
private async undo(actor: MiRemoteUser, activity: IUndo, resolver?: Resolver): Promise<string> {
if (actor.uri !== activity.actor) {
return 'invalid actor';
}
@ -632,7 +650,8 @@ export class ApInboxService {
this.logger.info(`Undo: ${uri}`);
const resolver = this.apResolverService.createResolver();
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
@ -756,14 +775,15 @@ export class ApInboxService {
}
@bindThis
private async update(actor: MiRemoteUser, activity: IUpdate): Promise<string> {
private async update(actor: MiRemoteUser, activity: IUpdate, resolver?: Resolver): Promise<string> {
if (actor.uri !== activity.actor) {
return 'skip: invalid actor';
}
this.logger.debug('Update');
const resolver = this.apResolverService.createResolver();
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
@ -774,7 +794,7 @@ export class ApInboxService {
await this.apPersonService.updatePerson(actor.uri, resolver, object);
return 'ok: Person updated';
} else if (getApType(object) === 'Question') {
await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err));
await this.apQuestionService.updateQuestion(object, actor, resolver).catch(err => console.error(err));
return 'ok: Question updated';
} else if (getApType(object) === 'Note') {
await this.apNoteService.updateNote(object, resolver).catch(err => console.error(err));
@ -785,11 +805,11 @@ export class ApInboxService {
}
@bindThis
private async move(actor: MiRemoteUser, activity: IMove): Promise<string> {
private async move(actor: MiRemoteUser, activity: IMove, resolver?: Resolver): Promise<string> {
// fetch the new and old accounts
const targetUri = getApHrefNullable(activity.target);
if (!targetUri) return 'skip: invalid activity target';
return await this.apPersonService.updatePerson(actor.uri) ?? 'skip: nothing to do';
return await this.apPersonService.updatePerson(actor.uri, resolver) ?? 'skip: nothing to do';
}
}

View file

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

View file

@ -43,7 +43,7 @@ export class Resolver {
private apRendererService: ApRendererService,
private apDbResolverService: ApDbResolverService,
private loggerService: LoggerService,
private recursionLimit = 100,
private recursionLimit = 256,
) {
this.history = new Set();
this.logger = this.loggerService.getLogger('ap-resolve');
@ -54,6 +54,11 @@ export class Resolver {
return Array.from(this.history);
}
@bindThis
public getRecursionLimit(): number {
return this.recursionLimit;
}
@bindThis
public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> {
const collection = typeof value === 'string'
@ -122,7 +127,11 @@ export class Resolver {
// `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 && (this.utilityService.punyHost(object.id) !== this.utilityService.punyHost(value))) {
if (object.id == null) {
throw new Error('invalid AP object: missing id');
}
if (this.utilityService.punyHost(object.id) !== this.utilityService.punyHost(value)) {
throw new Error(`invalid AP object ${value}: id ${object.id} has different host`);
}

View file

@ -4,24 +4,16 @@
*/
import type { IObject } from '../type.js';
function getHrefFrom(one: IObject|string): string | undefined {
if (typeof(one) === 'string') return one;
return one.href;
}
export function assertActivityMatchesUrls(activity: IObject, urls: string[]) {
const idOk = activity.id !== undefined && urls.includes(activity.id);
if (idOk) return;
const url = activity.url;
if (url) {
// `activity.url` can be an `ApObject = IObject | string | (IObject
// | string)[]`, we have to look inside it
const activityUrls = Array.isArray(url) ? url.map(getHrefFrom) : [getHrefFrom(url)];
const goodUrl = activityUrls.find(u => u && urls.includes(u));
// technically `activity.url` could be an `ApObject = IObject |
// string | (IObject | string)[]`, but if it's a complicated thing
// and the `activity.id` doesn't match, I think we're fine
// rejecting the activity
const urlOk = typeof(activity.url) === 'string' && urls.includes(activity.url);
if (goodUrl) return;
if (!idOk && !urlOk) {
throw new Error(`bad Activity: neither id(${activity?.id}) nor url(${activity?.url}) match location(${urls})`);
}
throw new Error(`bad Activity: neither id(${activity?.id}) nor url(${JSON.stringify(activity?.url)}) match location(${urls})`);
}

View file

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

View file

@ -154,12 +154,25 @@ export class ApPersonService implements OnModuleInit {
throw new Error('invalid Actor: inbox has different host');
}
const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined);
if (sharedInboxObject != null) {
const sharedInbox = getApId(sharedInboxObject);
if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHost(sharedInbox) === expectHost)) {
throw new Error('invalid Actor: wrong shared inbox');
}
}
for (const collection of ['outbox', 'followers', 'following'] as (keyof IActor)[]) {
const collectionUri = (x as IActor)[collection];
const xCollection = (x as IActor)[collection];
if (xCollection != null) {
const collectionUri = getApId(xCollection);
if (typeof collectionUri === 'string' && collectionUri.length > 0) {
if (this.utilityService.punyHost(collectionUri) !== expectHost) {
throw new Error(`invalid Actor: ${collection} has different host`);
}
} else if (collectionUri != null) {
throw new Error(`invalid Actor: wrong ${collection}`);
}
}
}
@ -287,7 +300,8 @@ export class ApPersonService implements OnModuleInit {
public async createPerson(uri: string, resolver?: Resolver): Promise<MiRemoteUser> {
if (typeof uri !== 'string') throw new Error('uri is not string');
if (uri.startsWith(this.config.url)) {
const host = this.utilityService.punyHost(uri);
if (host === this.utilityService.toPuny(this.config.host)) {
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
}
@ -301,8 +315,6 @@ export class ApPersonService implements OnModuleInit {
this.logger.info(`Creating the Person: ${person.id}`);
const host = this.utilityService.punyHost(object.id);
const fields = this.analyzeAttachments(person.attachment ?? []);
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
@ -328,10 +340,20 @@ export class ApPersonService implements OnModuleInit {
const url = getOneApHrefNullable(person.url);
if (url && !checkHttps(url)) {
if (person.id == null) {
throw new Error('Refusing to create person without id');
}
if (url != null) {
if (!checkHttps(url)) {
throw new Error('unexpected schema of person url: ' + url);
}
if (this.utilityService.punyHost(url) !== this.utilityService.punyHost(person.id)) {
throw new Error(`person url <> uri host mismatch: ${url} <> ${person.id}`);
}
}
// Create user
let user: MiRemoteUser | null = null;
@ -481,7 +503,7 @@ export class ApPersonService implements OnModuleInit {
if (typeof uri !== 'string') throw new Error('uri is not string');
// URIがこのサーバーを指しているならスキップ
if (uri.startsWith(`${this.config.url}/`)) return;
if (this.utilityService.isUriLocal(uri)) return;
//#region このサーバーに既に登録されているか
const exist = await this.fetchPerson(uri) as MiRemoteUser | null;
@ -530,10 +552,20 @@ export class ApPersonService implements OnModuleInit {
const url = getOneApHrefNullable(person.url);
if (url && !checkHttps(url)) {
if (person.id == null) {
throw new Error('Refusing to update person without id');
}
if (url != null) {
if (!checkHttps(url)) {
throw new Error('unexpected schema of person url: ' + url);
}
if (this.utilityService.punyHost(url) !== this.utilityService.punyHost(person.id)) {
throw new Error(`person url <> uri host mismatch: ${url} <> ${person.id}`);
}
}
const updates = {
lastFetchedAt: new Date(),
inbox: person.inbox,
@ -744,7 +776,7 @@ export class ApPersonService implements OnModuleInit {
await this.updatePerson(src.movedToUri, undefined, undefined, [...movePreventUris, src.uri]);
dst = await this.fetchPerson(src.movedToUri) ?? dst;
} else {
if (src.movedToUri.startsWith(`${this.config.url}/`)) {
if (this.utilityService.isUriLocal(src.movedToUri)) {
// ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている
return 'failed: movedTo is local but not found';
}

View file

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

View file

@ -190,6 +190,8 @@ export class InboxProcessorService implements OnApplicationShutdown {
if (signerHost !== activityIdHost) {
throw new Bull.UnrecoverableError(`skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`);
}
} else {
throw new Bull.UnrecoverableError('skip: activity id is not a string');
}
this.apRequestChart.inbox();

View file

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

View file

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

View file

@ -137,10 +137,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (local != null) return local;
}
// 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない
return await this.mergePack(
me,
isActor(object) ? await this.apPersonService.createPerson(getApId(object)) : null,
isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, true) : null,
isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, undefined, true) : null,
);
}

View file

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

View file

@ -145,6 +145,6 @@ export class UrlPreviewService {
contentLengthRequired: meta.urlPreviewRequireContentLength,
});
return this.httpRequestService.getJson<SummalyResult>(`${proxy}?${queryStr}`);
return this.httpRequestService.getJson<SummalyResult>(`${proxy}?${queryStr}`, 'application/json, */*', undefined, true);
}
}

View file

@ -74,6 +74,9 @@ html
script(type='application/json' id='misskey_meta' data-generated-at=now)
!= metaJson
script(type='application/json' id='misskey_clientCtx' data-generated-at=now)
!= clientCtx
script
include ../boot.js

View file

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

View file

@ -176,7 +176,7 @@ describe('ActivityPub', () => {
resolver.register(actor.id, actor);
resolver.register(post.id, post);
const note = await noteService.createNote(post.id, resolver, true);
const note = await noteService.createNote(post.id, undefined, resolver, true);
assert.deepStrictEqual(note?.uri, post.id);
assert.deepStrictEqual(note.visibility, 'public');
@ -336,7 +336,7 @@ describe('ActivityPub', () => {
resolver.register(actor.featured, featured);
resolver.register(firstNote.id, firstNote);
const note = await noteService.createNote(firstNote.id as string, resolver);
const note = await noteService.createNote(firstNote.id as string, undefined, resolver);
assert.strictEqual(note?.uri, firstNote.id);
});
});

View file

@ -779,8 +779,8 @@ function emitUpdReaction(emoji: string, delta: number) {
.footer {
visibility: hidden;
position: absolute;
top: 0px;
right: 0px;
bottom: 1px;
right: 1px;
padding: 0 4px;
margin-bottom: 0 !important;
background: var(--MI_THEME-popup);

View file

@ -916,12 +916,6 @@ async function post(ev?: MouseEvent) {
}
nextTick(() => {
deleteDraft();
if (props.reply) {
props.reply.repliesCount = (props.reply.repliesCount || 0) + 1;
}
if (props.quote) {
props.renote.renoteCount = (props.renote.renoteCount || 0) + 1;
}
emit('posted');
if (postAccount.value != null ? postAccount.value.id : null) {
postAccount.value = null;

View file

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

View file

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

View file

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

View file

@ -25,6 +25,10 @@ export function setupRouter(app: App, routerFactory: ((path: string) => IRouter)
window.history.pushState({ key: ctx.key }, '', ctx.path);
});
mainRouter.addListener('same', () => {
window.scroll({ top: 0, behavior: 'smooth' });
});
mainRouter.addListener('replace', ctx => {
window.history.replaceState({ key: ctx.key }, '', ctx.path);
});

View file

@ -576,7 +576,6 @@ export function getRenoteMenu(props: {
channelId: appearNote.channelId,
}).then(() => {
os.toast(i18n.ts.renoted);
appearNote.renoteCount = (appearNote.renoteCount || 0) + 1;
});
}
},
@ -625,7 +624,6 @@ export function getRenoteMenu(props: {
renoteId: appearNote.id,
}).then(() => {
os.toast(i18n.ts.renoted);
appearNote.renoteCount = (appearNote.renoteCount || 0) + 1;
});
}
},
@ -667,7 +665,6 @@ export function getRenoteMenu(props: {
channelId: channel.id,
}).then(() => {
os.toast(i18n.tsx.renotedToX({ name: channel.name }));
appearNote.renoteCount = (appearNote.renoteCount || 0) + 1;
});
}
},

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",
"name": "misskey-js",
"version": "2024.11.0-alpha.2",
"version": "2024.11.0-beta.4",
"description": "Misskey SDK for JavaScript",
"license": "MIT",
"main": "./built/index.js",

View file

@ -14,24 +14,124 @@ import * as swos from '@/scripts/operations.js';
const STATIC_CACHE_NAME = `misskey-static-${_VERSION_}`;
const PATHS_TO_CACHE = ['/assets/', '/static-assets/', '/emoji/', '/twemoji/', '/fluent-emoji/', '/vite/'];
const STORAGE_QUOTA = 2 * 1024 * 1024 * 1024; // 2GB in bytes
const EMOJI_PATH = '/emoji/';
async function requestStorageQuota() {
try {
if (!('storage' in navigator)) {
throw new Error('Storage API not supported');
}
if ('persist' in navigator) {
const isPersisted = await navigator.storage.persist();
console.log(`Persisted storage granted: ${isPersisted}`);
}
if ('estimate' in navigator.storage) {
const estimate = await navigator.storage.estimate();
const currentQuota = estimate.quota || 0;
const currentUsage = estimate.usage || 0;
console.log(`Current storage: ${currentUsage} of ${currentQuota} bytes used`);
if ('requestQuota' in navigator.storage) {
try {
const grantedQuota = await navigator.storage.requestQuota(STORAGE_QUOTA);
console.log(`Granted quota: ${grantedQuota} bytes`);
} catch (quotaError) {
console.warn('Failed to request additional quota:', quotaError);
}
}
return {
quota: currentQuota,
usage: currentUsage,
};
} else {
console.warn('Storage estimate API not supported');
return {
quota: 0,
usage: 0,
};
}
} catch (error) {
console.error('Failed to request storage quota:', error);
return {
quota: 0,
usage: 0,
};
}
}
async function manageStorageSpace(newRequestSize = 0) {
try {
if (!('storage' in navigator)) {
console.warn('Storage API not supported');
return;
}
const estimate = await navigator.storage.estimate();
const currentUsage = estimate.usage || 0;
const currentQuota = estimate.quota || STORAGE_QUOTA;
if (currentUsage + newRequestSize > currentQuota) {
console.log(`Storage space needed. Current usage: ${currentUsage}, Need: ${newRequestSize}, Quota: ${currentQuota}`);
const cache = await caches.open(STATIC_CACHE_NAME);
const keys = await cache.keys();
const emojiKeys = keys.filter(request => request.url.includes(EMOJI_PATH));
console.log(`Found ${emojiKeys.length} emoji caches to manage`);
if (emojiKeys.length > 0) {
for (const key of emojiKeys) {
await cache.delete(key);
console.log(`Deleted cache for: ${key.url}`);
const newEstimate = await navigator.storage.estimate();
const newUsage = newEstimate.usage || 0;
if (newUsage + newRequestSize <= currentQuota) {
console.log(`Sufficient space cleared. New usage: ${newUsage}`);
break;
}
}
} else {
console.warn('No emoji caches available for cleanup');
}
}
} catch (error) {
console.error('Failed to manage storage space:', error);
}
}
async function cacheWithFallback(cache, paths) {
for (const path of paths) {
try {
await cache.add(new Request(path, { credentials: 'same-origin' }));
} catch (error) {}
const response = await fetch(new Request(path, { credentials: 'same-origin' }));
const blob = await response.clone().blob();
await manageStorageSpace(blob.size);
await cache.put(new Request(path, { credentials: 'same-origin' }), response);
} catch (error) {
console.error(`Failed to cache ${path}:`, error);
}
}
}
globalThis.addEventListener('install', (ev) => {
ev.waitUntil((async () => {
await requestStorageQuota();
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(
caches.keys()
.then(cacheNames => Promise.all(
@ -51,28 +151,32 @@ async function offlineContentHTML() {
reload: i18n.ts?.reload ?? 'Reload',
};
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(!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(!0)}</script></body></html>`;
}
globalThis.addEventListener('fetch', ev => {
globalThis.addEventListener('fetch', (ev) => {
const shouldCache = PATHS_TO_CACHE.some(path => ev.request.url.includes(path));
if (shouldCache) {
ev.respondWith(
caches.match(ev.request)
.then(response => {
.then(async (response) => {
if (response) return response;
return fetch(ev.request).then(response => {
if (!response || response.status !== 200 || response.type !== 'basic') return response;
const responseToCache = response.clone();
caches.open(STATIC_CACHE_NAME)
.then(cache => {
cache.put(ev.request, responseToCache);
});
return response;
});
})
const fetchResponse = await fetch(ev.request);
if (!fetchResponse || fetchResponse.status !== 200 || fetchResponse.type !== 'basic') {
return fetchResponse;
}
const blob = await fetchResponse.clone().blob();
await manageStorageSpace(blob.size);
const responseToCache = fetchResponse.clone();
const cache = await caches.open(STATIC_CACHE_NAME);
await cache.put(ev.request, responseToCache);
return fetchResponse;
}),
);
return;
}
@ -82,27 +186,27 @@ globalThis.addEventListener('fetch', ev => {
isHTMLRequest = true;
} else if (ev.request.headers.get('accept')?.includes('/html')) {
isHTMLRequest = true;
} else if (ev.request.url.endsWith('/')) {
} else if (ev.request.url.endsWith('index.html')) {
isHTMLRequest = true;
}
if (!isHTMLRequest) return;
if (isHTMLRequest) {
ev.respondWith(
fetch(ev.request)
.catch(async () => {
const html = await offlineContentHTML();
return new Response(html, {
status: 200,
headers: {
'content-type': 'text/html',
},
caches.match(ev.request)
.then(async (response) => {
if (response) return response;
const offlineHTML = await offlineContentHTML();
return new Response(offlineHTML, {
headers: { 'Content-Type': 'text/html' },
});
}),
);
}
});
globalThis.addEventListener('push', ev => {
ev.waitUntil(globalThis.clients.matchAll({
globalThis.addEventListener('push', (ev) => {
ev.waitUntil(
globalThis.clients.matchAll({
includeUncontrolled: true,
type: 'window',
}).then(async () => {
@ -114,15 +218,20 @@ globalThis.addEventListener('push', ev => {
if (Date.now() - data.dateTime > 1000 * 60 * 60 * 24) break;
return createNotification(data);
case 'readAllNotifications':
await globalThis.registration.getNotifications()
.then(notifications => notifications.forEach(n => n.tag !== 'read_notification' && n.close()));
.then(notifications => {
notifications.forEach(n => {
if (n.tag !== 'read_notification') n.close();
});
});
break;
}
await createEmptyNotification();
return;
}));
}),
);
});
globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEventMap['notificationclick']) => {
@ -140,69 +249,72 @@ globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEv
case 'notification':
switch (action) {
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;
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;
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;
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;
case 'accept':
switch (data.body.type) {
case 'receiveFollowRequest':
if (data.body.type === 'receiveFollowRequest') {
await swos.api('following/requests/accept', loginId, { userId: data.body.userId });
break;
}
break;
case 'reject':
switch (data.body.type) {
case 'receiveFollowRequest':
if (data.body.type === 'receiveFollowRequest') {
await swos.api('following/requests/reject', loginId, { userId: data.body.userId });
break;
}
break;
case 'showFollowRequests':
client = await swos.openClient('push', '/my/follow-requests', loginId);
break;
case 'edited':
if ('note' in data.body) client = await swos.openPost({ reply: data.body.note }, loginId);
break;
default:
switch (data.body.type) {
case 'receiveFollowRequest':
if (data.body.type === 'receiveFollowRequest') {
client = await swos.openClient('push', '/my/follow-requests', loginId);
break;
case 'reaction':
} else if (data.body.type === 'reaction') {
client = await swos.openNote(data.body.note.id, loginId);
break;
default:
if ('note' in data.body) {
} else if ('note' in data.body) {
client = await swos.openNote(data.body.note.id, loginId);
} else if ('user' in data.body) {
client = await swos.openUser(Misskey.acct.toString(data.body.user), loginId);
}
break;
}
}
break;
case 'unreadAntennaNote':
client = await swos.openAntenna(data.body.antenna.id, loginId);
break;
default:
switch (action) {
case 'markAllAsRead':
await globalThis.registration.getNotifications()
.then(notifications => notifications.forEach(n => n.tag !== 'read_notification' && n.close()));
await get<Pick<Misskey.entities.SignupResponse, 'id' | 'token'>[]>('accounts').then(accounts => {
.then(notifications => notifications.forEach(n => {
if (n.tag !== 'read_notification') n.close();
}));
await get<Pick<Misskey.entities.SignupResponse, 'id' | 'token'>[]>('accounts')
.then(accounts => {
return Promise.all((accounts ?? []).map(async account => {
await swos.sendMarkAllAsRead(account.id);
}));
});
break;
case 'settings':
client = await swos.openClient('push', '/settings/notifications', loginId);
break;
@ -212,6 +324,7 @@ globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEv
if (client) {
client.focus();
}
if (data.type === 'notification') {
await swos.sendMarkAllAsRead(loginId);
}
@ -227,7 +340,6 @@ globalThis.addEventListener('notificationclose', (ev: ServiceWorkerGlobalScopeEv
if (data.type === 'notification') {
await swos.sendMarkAllAsRead(data.userId);
}
return;
})());
});