Compare commits
50 Commits
13.11.0.be
...
13.11.0-be
Author | SHA1 | Date | |
---|---|---|---|
![]() |
e72d6c9f2c | ||
![]() |
e23cf77f86 | ||
![]() |
0f328e8472 | ||
![]() |
7636536973 | ||
![]() |
975e79fa3b | ||
![]() |
eefa06622b | ||
![]() |
9114c8cb8e | ||
![]() |
132feea1fa | ||
![]() |
33e9428510 | ||
![]() |
1aa8f09b95 | ||
![]() |
65584f21bb | ||
![]() |
b697f948df | ||
![]() |
551b00302b | ||
![]() |
05aa0fa11a | ||
![]() |
008e3fb37e | ||
![]() |
68e8892f61 | ||
![]() |
25ebb73756 | ||
![]() |
fa67fb42b1 | ||
![]() |
40b3041608 | ||
![]() |
e3b8e8746e | ||
![]() |
33bce49ee8 | ||
![]() |
44a4faebc0 | ||
![]() |
79f198e4f1 | ||
![]() |
eb30976ae6 | ||
![]() |
abda3b6c8c | ||
![]() |
191ed3c814 | ||
![]() |
7da2ca1862 | ||
![]() |
cb39db100a | ||
![]() |
e3f4c9bcf6 | ||
![]() |
ed4a100e96 | ||
![]() |
1377ea4178 | ||
![]() |
6e1ae7b242 | ||
![]() |
930724f9de | ||
![]() |
3bbeec70ec | ||
![]() |
69828e8dac | ||
![]() |
58c8d21348 | ||
![]() |
1e3fb5531b | ||
![]() |
239d3f2dbf | ||
![]() |
ff6d9d2860 | ||
![]() |
f4588f3907 | ||
![]() |
a561b83070 | ||
![]() |
88f22da052 | ||
![]() |
89c12948a1 | ||
![]() |
f8cdab6d02 | ||
![]() |
521eb95dbc | ||
![]() |
c29f21ac56 | ||
![]() |
de9d136a3f | ||
![]() |
a0d5e2469d | ||
![]() |
b38811af7c | ||
![]() |
bc5be83e29 |
@@ -51,6 +51,23 @@ db:
|
||||
#extra:
|
||||
# ssl: true
|
||||
|
||||
dbReplications: false
|
||||
|
||||
# You can configure any number of replicas here
|
||||
#dbSlaves:
|
||||
# -
|
||||
# host:
|
||||
# port:
|
||||
# db:
|
||||
# user:
|
||||
# pass:
|
||||
# -
|
||||
# host:
|
||||
# port:
|
||||
# db:
|
||||
# user:
|
||||
# pass:
|
||||
|
||||
# ┌─────────────────────┐
|
||||
#───┘ Redis configuration └─────────────────────────────────────
|
||||
|
||||
@@ -62,6 +79,22 @@ redis:
|
||||
#prefix: example-prefix
|
||||
#db: 1
|
||||
|
||||
#redisForPubsub:
|
||||
# host: redis
|
||||
# port: 6379
|
||||
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
|
||||
# #pass: example-pass
|
||||
# #prefix: example-prefix
|
||||
# #db: 1
|
||||
|
||||
#redisForJobQueue:
|
||||
# host: redis
|
||||
# port: 6379
|
||||
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
|
||||
# #pass: example-pass
|
||||
# #prefix: example-prefix
|
||||
# #db: 1
|
||||
|
||||
# ┌─────────────────────────────┐
|
||||
#───┘ Elasticsearch configuration └─────────────────────────────
|
||||
|
||||
|
@@ -51,6 +51,23 @@ db:
|
||||
#extra:
|
||||
# ssl: true
|
||||
|
||||
dbReplications: false
|
||||
|
||||
# You can configure any number of replicas here
|
||||
#dbSlaves:
|
||||
# -
|
||||
# host:
|
||||
# port:
|
||||
# db:
|
||||
# user:
|
||||
# pass:
|
||||
# -
|
||||
# host:
|
||||
# port:
|
||||
# db:
|
||||
# user:
|
||||
# pass:
|
||||
|
||||
# ┌─────────────────────┐
|
||||
#───┘ Redis configuration └─────────────────────────────────────
|
||||
|
||||
@@ -62,6 +79,22 @@ redis:
|
||||
#prefix: example-prefix
|
||||
#db: 1
|
||||
|
||||
#redisForPubsub:
|
||||
# host: localhost
|
||||
# port: 6379
|
||||
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
|
||||
# #pass: example-pass
|
||||
# #prefix: example-prefix
|
||||
# #db: 1
|
||||
|
||||
#redisForJobQueue:
|
||||
# host: localhost
|
||||
# port: 6379
|
||||
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
|
||||
# #pass: example-pass
|
||||
# #prefix: example-prefix
|
||||
# #db: 1
|
||||
|
||||
# ┌─────────────────────────────┐
|
||||
#───┘ Elasticsearch configuration └─────────────────────────────
|
||||
|
||||
|
@@ -51,6 +51,23 @@ db:
|
||||
#extra:
|
||||
# ssl: true
|
||||
|
||||
dbReplications: false
|
||||
|
||||
# You can configure any number of replicas here
|
||||
#dbSlaves:
|
||||
# -
|
||||
# host:
|
||||
# port:
|
||||
# db:
|
||||
# user:
|
||||
# pass:
|
||||
# -
|
||||
# host:
|
||||
# port:
|
||||
# db:
|
||||
# user:
|
||||
# pass:
|
||||
|
||||
# ┌─────────────────────┐
|
||||
#───┘ Redis configuration └─────────────────────────────────────
|
||||
|
||||
@@ -62,6 +79,22 @@ redis:
|
||||
#prefix: example-prefix
|
||||
#db: 1
|
||||
|
||||
#redisForPubsub:
|
||||
# host: redis
|
||||
# port: 6379
|
||||
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
|
||||
# #pass: example-pass
|
||||
# #prefix: example-prefix
|
||||
# #db: 1
|
||||
|
||||
#redisForJobQueue:
|
||||
# host: redis
|
||||
# port: 6379
|
||||
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
|
||||
# #pass: example-pass
|
||||
# #prefix: example-prefix
|
||||
# #db: 1
|
||||
|
||||
# ┌─────────────────────────────┐
|
||||
#───┘ Elasticsearch configuration └─────────────────────────────
|
||||
|
||||
|
44
.github/workflows/storybook.yml
vendored
44
.github/workflows/storybook.yml
vendored
@@ -2,10 +2,6 @@ name: Storybook
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- l10n_develop
|
||||
|
||||
@@ -13,6 +9,9 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=7168"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3.3.0
|
||||
with:
|
||||
@@ -36,21 +35,34 @@ jobs:
|
||||
run: pnpm --filter misskey-js build
|
||||
- name: Build storybook
|
||||
run: pnpm --filter frontend build-storybook
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=7168"
|
||||
- name: Publish to Chromatic
|
||||
id: chromatic
|
||||
uses: chromaui/action@v1
|
||||
with:
|
||||
exitOnceUploaded: true
|
||||
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
storybookBuildDir: storybook-static
|
||||
workingDir: packages/frontend
|
||||
- name: Compare on Chromatic
|
||||
if: github.event_name == 'pull_request_target'
|
||||
run: pnpm --filter frontend chromatic -d storybook-static --exit-once-uploaded --patch-build ${{ github.head_ref }}...${{ github.base_ref }}
|
||||
if: github.ref == 'refs/heads/master'
|
||||
run: pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static
|
||||
env:
|
||||
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
- name: Publish to Chromatic
|
||||
if: github.ref != 'refs/heads/master'
|
||||
id: chromatic
|
||||
run: |
|
||||
CHROMATIC_PARAMETER="$(node packages/frontend/.storybook/changes.js $(git diff-tree --no-commit-id --name-only -r ${{ github.event.before }} HEAD | xargs))"
|
||||
if [ "$CHROMATIC_PARAMETER" = " --skip" ]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static $(echo "$CHROMATIC_PARAMETER")
|
||||
env:
|
||||
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
- name: Notify that Chromatic will skip testing
|
||||
uses: actions/github-script@v6.4.0
|
||||
if: github.ref != 'refs/heads/master' && github.ref != 'refs/heads/develop' && steps.chromatic.outputs.skip == 'true'
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.rest.repos.createCommitComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.sha,
|
||||
body: 'Chromatic will skip testing but you may still have to [review the changes on Chromatic](https://www.chromatic.com/pullrequests?appId=6428f7d7b962f0b79f97d6e4).'
|
||||
})
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
|
12
CHANGELOG.md
12
CHANGELOG.md
@@ -5,7 +5,7 @@
|
||||
-
|
||||
|
||||
### Client
|
||||
-
|
||||
-
|
||||
|
||||
### Server
|
||||
-
|
||||
@@ -15,13 +15,16 @@
|
||||
## 13.x.x (unreleased)
|
||||
|
||||
### NOTE
|
||||
- Redis 7.xが必要です
|
||||
- このバージョンからRedis 7.xが必要です。
|
||||
- アップデートを行うと全ての通知はリセットされます。
|
||||
|
||||
### General
|
||||
- チャンネルをお気に入りに登録できるように
|
||||
- チャンネルにノートをピン留めできるように
|
||||
- アカウントの引っ越し(フォロワー引き継ぎ)に対応
|
||||
|
||||
### Client
|
||||
- 投稿フォームのデザインを改善
|
||||
- 検索ページでURLを入力した際に照会したときと同等の挙動をするように
|
||||
- ノートのリアクションを大きく表示するオプションを追加
|
||||
- ギャラリー一覧にメディア表示と同じように NSFW 設定を反映するように(ホバーで表示)
|
||||
@@ -33,8 +36,13 @@
|
||||
- 左耳は上からおよそ 10%, 左からおよそ 20% の位置で決定します
|
||||
- 右耳は上からおよそ 10%, 左からおよそ 80% の位置で決定します
|
||||
- 「UIのアニメーションを減らす」 (`reduceAnimation`) で猫耳を撫でられなくなります
|
||||
- Add Minimizing ("folding") of windows
|
||||
|
||||
### Server
|
||||
- PostgreSQLのレプリケーション対応
|
||||
- 設定ファイルの `dbReplications` および `dbSlaves` にて設定できます
|
||||
- イベント用Redisを別サーバーに分離できるように
|
||||
- ジョブキュー用Redisを別サーバーに分離できるように
|
||||
- サーバーの全体的なパフォーマンスを向上
|
||||
- ノート作成時のパフォーマンスを向上
|
||||
- アンテナのタイムライン取得時のパフォーマンスを向上
|
||||
|
@@ -72,16 +72,50 @@ db:
|
||||
#extra:
|
||||
# ssl: true
|
||||
|
||||
dbReplications: false
|
||||
|
||||
# You can configure any number of replicas here
|
||||
#dbSlaves:
|
||||
# -
|
||||
# host:
|
||||
# port:
|
||||
# db:
|
||||
# user:
|
||||
# pass:
|
||||
# -
|
||||
# host:
|
||||
# port:
|
||||
# db:
|
||||
# user:
|
||||
# pass:
|
||||
|
||||
# ┌─────────────────────┐
|
||||
#───┘ Redis configuration └─────────────────────────────────────
|
||||
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
#family: 0 # 0=Both, 4=IPv4, 6=IPv6
|
||||
#pass: example-pass
|
||||
#prefix: example-prefix
|
||||
#db: 1
|
||||
|
||||
#redisForPubsub:
|
||||
# host: localhost
|
||||
# port: 6379
|
||||
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
|
||||
# #pass: example-pass
|
||||
# #prefix: example-prefix
|
||||
# #db: 1
|
||||
|
||||
#redisForJobQueue:
|
||||
# host: localhost
|
||||
# port: 6379
|
||||
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
|
||||
# #pass: example-pass
|
||||
# #prefix: example-prefix
|
||||
# #db: 1
|
||||
|
||||
# ┌─────────────────────────────┐
|
||||
#───┘ Elasticsearch configuration └─────────────────────────────
|
||||
|
||||
|
@@ -988,6 +988,16 @@ enableChartsForFederatedInstances: "Generate remote instance data charts"
|
||||
showClipButtonInNoteFooter: "Add \"Clip\" to note action menu"
|
||||
largeNoteReactions: "Enlargen displayed reactions"
|
||||
noteIdOrUrl: "Note ID or URL"
|
||||
migration: "Migration"
|
||||
moveTo: "Move current account to new account"
|
||||
moveToLabel: "Account you're moving to:"
|
||||
moveAccountDescription: "This process is irreversible. Make sure you've set up an alias for this account on your new account before moving. Please enter the tag of the account formatted like @person@instance.com"
|
||||
moveFrom: "Move to this account from an older account"
|
||||
moveFromLabel: "Account you're moving from:"
|
||||
moveFromDescription: "This will set an alias of your old account so that you can move from that account to this current one. Do this BEFORE moving from your older account. Please enter the tag of the account formatted like @person@instance.com"
|
||||
migrationConfirm: "Are you absolutely sure you want to migrate your acccount to {account}? Once you do this, you won't be able to reverse it, and you won't be able to use your account normally again.\nAlso, please ensure that you've set this current account as the account you're moving from."
|
||||
accountMoved: "User has moved to a new account:"
|
||||
|
||||
_achievements:
|
||||
earnedAt: "Unlocked at"
|
||||
_types:
|
||||
@@ -1898,4 +1908,3 @@ _webhookSettings:
|
||||
renote: "When renoted"
|
||||
reaction: "When receiving a reaction"
|
||||
mention: "When being mentioned"
|
||||
|
||||
|
@@ -920,6 +920,7 @@ pushNotificationNotSupported: "ブラウザかサーバーがプッシュ通知
|
||||
sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を削除する"
|
||||
sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」という通知が一瞬表示されるようになります。端末の電池消費量が増加する可能性があります。"
|
||||
windowMaximize: "最大化"
|
||||
windowMinimize: "最小化"
|
||||
windowRestore: "元に戻す"
|
||||
caption: "キャプション"
|
||||
loggedInAsBot: "Botアカウントでログイン中"
|
||||
@@ -988,6 +989,17 @@ enableChartsForFederatedInstances: "リモートサーバーのチャートを
|
||||
showClipButtonInNoteFooter: "ノートのアクションにクリップを追加"
|
||||
largeNoteReactions: "ノートのリアクションを大きく表示"
|
||||
noteIdOrUrl: "ノートIDまたはURL"
|
||||
accountMigration: "アカウントの引っ越し"
|
||||
accountMoved: "このユーザーは新しいアカウントに引っ越しました:"
|
||||
|
||||
_accountMigration:
|
||||
moveTo: "このアカウントを新しいアカウントに引っ越す"
|
||||
moveToLabel: "引っ越し先のアカウント:"
|
||||
moveAccountDescription: "この操作は取り消せません。まずは引っ越し先のアカウントでこのアカウントに対しエイリアスを作成したことを確認してください。エイリアス作成後、引っ越し先のアカウントをこのように入力してください:@person@instance.com"
|
||||
moveFrom: "別のアカウントからこのアカウントに引っ越す"
|
||||
moveFromLabel: "引っ越し元のアカウント:"
|
||||
moveFromDescription: "別のアカウントからこのアカウントにフォロワーを引き継いで引っ越したい場合、ここでエイリアスを作成しておく必要があります。必ず引っ越しを実行する前に作成してください!引っ越し元のアカウントをこのように入力してください:@person@instance.com"
|
||||
migrationConfirm: "本当にこのアカウントを {account} に引っ越しますか?一度引っ越しを行うと取り消せず、二度とこのアカウントを元の状態で使用できなくなります。\nまた、引っ越し先のアカウントでエイリアスを作成したことを確認してください。"
|
||||
|
||||
_achievements:
|
||||
earnedAt: "獲得日時"
|
||||
@@ -1954,4 +1966,3 @@ _webhookSettings:
|
||||
renote: "Renoteされたとき"
|
||||
reaction: "リアクションがあったとき"
|
||||
mention: "メンションされたとき"
|
||||
|
||||
|
11
package.json
11
package.json
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "13.11.0.beta-3",
|
||||
"version": "13.11.0-beta.8",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/misskey-dev/misskey.git"
|
||||
},
|
||||
"packageManager": "pnpm@8.1.0",
|
||||
"packageManager": "pnpm@8.1.1",
|
||||
"workspaces": [
|
||||
"packages/frontend",
|
||||
"packages/backend",
|
||||
@@ -16,6 +16,7 @@
|
||||
"scripts": {
|
||||
"build-pre": "node ./scripts/build-pre.js",
|
||||
"build": "pnpm build-pre && pnpm -r build && pnpm gulp",
|
||||
"build-storybook": "pnpm --filter frontend build-storybook",
|
||||
"start": "pnpm check:connect && cd packages/backend && node ./built/boot/index.js",
|
||||
"start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/index.js",
|
||||
"init": "pnpm migrate",
|
||||
@@ -50,13 +51,13 @@
|
||||
"gulp-replace": "1.1.4",
|
||||
"gulp-terser": "2.1.0",
|
||||
"js-yaml": "4.1.0",
|
||||
"typescript": "5.0.2"
|
||||
"typescript": "5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/gulp": "4.0.10",
|
||||
"@types/gulp-rename": "2.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "5.57.0",
|
||||
"@typescript-eslint/parser": "5.57.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.57.1",
|
||||
"@typescript-eslint/parser": "5.57.1",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "12.9.0",
|
||||
"eslint": "8.37.0",
|
||||
|
@@ -1,8 +1,15 @@
|
||||
import Redis from 'ioredis';
|
||||
import { loadConfig } from './built/config.js';
|
||||
import { createRedisConnection } from './built/redis.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const redis = createRedisConnection(config);
|
||||
const redis = new Redis({
|
||||
port: config.redis.port,
|
||||
host: config.redis.host,
|
||||
family: config.redis.family == null ? 0 : config.redis.family,
|
||||
password: config.redis.pass,
|
||||
keyPrefix: `${config.redis.prefix}:`,
|
||||
db: config.redis.db ?? 0,
|
||||
});
|
||||
|
||||
redis.on('connect', () => redis.disconnect());
|
||||
redis.on('error', (e) => {
|
||||
|
@@ -0,0 +1,17 @@
|
||||
export class AvatarUrlAndBannerUrl1680775031481 {
|
||||
name = 'AvatarUrlAndBannerUrl1680775031481'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "avatarUrl" character varying(512)`);
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "bannerUrl" character varying(512)`);
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "avatarBlurhash" character varying(128)`);
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "bannerBlurhash" character varying(128)`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "bannerBlurhash"`);
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarBlurhash"`);
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "bannerUrl"`);
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarUrl"`);
|
||||
}
|
||||
}
|
17
packages/backend/migration/1680931179228-account-move.js
Normal file
17
packages/backend/migration/1680931179228-account-move.js
Normal file
@@ -0,0 +1,17 @@
|
||||
export class AccountMove1680931179228 {
|
||||
name = 'AccountMove1680931179228'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "movedToUri" character varying(512)`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user"."movedToUri" IS 'The URI of the new account of the User'`);
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "alsoKnownAs" text`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too'`);
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "alsoKnownAs"`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user"."movedToUri" IS 'The URI of the new account of the User'`);
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "movedToUri"`);
|
||||
}
|
||||
}
|
@@ -22,24 +22,24 @@
|
||||
"test-and-coverage": "pnpm jest-and-coverage"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@swc/core-android-arm64": "^1.3.11",
|
||||
"@swc/core-darwin-arm64": "^1.3.42",
|
||||
"@swc/core-darwin-x64": "^1.3.42",
|
||||
"@swc/core-linux-arm-gnueabihf": "^1.3.42",
|
||||
"@swc/core-linux-arm64-gnu": "^1.3.42",
|
||||
"@swc/core-linux-arm64-musl": "^1.3.42",
|
||||
"@swc/core-linux-x64-gnu": "^1.3.42",
|
||||
"@swc/core-linux-x64-musl": "^1.3.42",
|
||||
"@swc/core-win32-arm64-msvc": "^1.3.42",
|
||||
"@swc/core-win32-ia32-msvc": "^1.3.42",
|
||||
"@swc/core-win32-x64-msvc": "^1.3.42",
|
||||
"@swc/core-android-arm64": "1.3.11",
|
||||
"@swc/core-darwin-arm64": "1.3.46",
|
||||
"@swc/core-darwin-x64": "1.3.46",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.3.46",
|
||||
"@swc/core-linux-arm64-gnu": "1.3.46",
|
||||
"@swc/core-linux-arm64-musl": "1.3.46",
|
||||
"@swc/core-linux-x64-gnu": "1.3.46",
|
||||
"@swc/core-linux-x64-musl": "1.3.46",
|
||||
"@swc/core-win32-arm64-msvc": "1.3.46",
|
||||
"@swc/core-win32-ia32-msvc": "1.3.46",
|
||||
"@swc/core-win32-x64-msvc": "1.3.46",
|
||||
"@tensorflow/tfjs": "4.2.0",
|
||||
"@tensorflow/tfjs-node": "4.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.301.0",
|
||||
"@aws-sdk/lib-storage": "3.301.0",
|
||||
"@aws-sdk/node-http-handler": "3.296.0",
|
||||
"@aws-sdk/client-s3": "3.306.0",
|
||||
"@aws-sdk/lib-storage": "3.306.0",
|
||||
"@aws-sdk/node-http-handler": "3.306.0",
|
||||
"@bull-board/api": "5.0.0",
|
||||
"@bull-board/fastify": "5.0.0",
|
||||
"@bull-board/ui": "5.0.0",
|
||||
@@ -49,15 +49,15 @@
|
||||
"@fastify/cors": "8.2.1",
|
||||
"@fastify/http-proxy": "9.0.0",
|
||||
"@fastify/multipart": "7.5.0",
|
||||
"@fastify/static": "6.9.0",
|
||||
"@fastify/static": "6.10.0",
|
||||
"@fastify/view": "7.4.1",
|
||||
"@nestjs/common": "9.3.12",
|
||||
"@nestjs/core": "9.3.12",
|
||||
"@nestjs/testing": "9.3.12",
|
||||
"@nestjs/common": "9.4.0",
|
||||
"@nestjs/core": "9.4.0",
|
||||
"@nestjs/testing": "9.4.0",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sinonjs/fake-timers": "10.0.2",
|
||||
"@swc/cli": "0.1.62",
|
||||
"@swc/core": "1.3.42",
|
||||
"@swc/core": "1.3.46",
|
||||
"accepts": "1.3.8",
|
||||
"ajv": "8.12.0",
|
||||
"archiver": "5.3.1",
|
||||
@@ -136,8 +136,8 @@
|
||||
"tsc-alias": "1.8.5",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"twemoji-parser": "14.0.0",
|
||||
"typeorm": "0.3.11",
|
||||
"typescript": "5.0.2",
|
||||
"typeorm": "0.3.13",
|
||||
"typescript": "5.0.3",
|
||||
"ulid": "2.3.0",
|
||||
"unzipper": "0.10.11",
|
||||
"uuid": "9.0.0",
|
||||
@@ -190,8 +190,8 @@
|
||||
"@types/web-push": "3.3.2",
|
||||
"@types/websocket": "1.0.5",
|
||||
"@types/ws": "8.5.4",
|
||||
"@typescript-eslint/eslint-plugin": "5.57.0",
|
||||
"@typescript-eslint/parser": "5.57.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.57.1",
|
||||
"@typescript-eslint/parser": "5.57.1",
|
||||
"aws-sdk-client-mock": "^2.1.1",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "8.37.0",
|
||||
|
@@ -2,18 +2,15 @@ import { setTimeout } from 'node:timers/promises';
|
||||
import { Global, Inject, Module } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { createRedisConnection } from '@/redis.js';
|
||||
import { DI } from './di-symbols.js';
|
||||
import { loadConfig } from './config.js';
|
||||
import { createPostgresDataSource } from './postgres.js';
|
||||
import { RepositoryModule } from './models/RepositoryModule.js';
|
||||
import type { Provider, OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
const $config: Provider = {
|
||||
provide: DI.config,
|
||||
useValue: config,
|
||||
useValue: loadConfig(),
|
||||
};
|
||||
|
||||
const $db: Provider = {
|
||||
@@ -28,18 +25,31 @@ const $db: Provider = {
|
||||
const $redis: Provider = {
|
||||
provide: DI.redis,
|
||||
useFactory: (config) => {
|
||||
const redisClient = createRedisConnection(config);
|
||||
return redisClient;
|
||||
return new Redis({
|
||||
port: config.redis.port,
|
||||
host: config.redis.host,
|
||||
family: config.redis.family == null ? 0 : config.redis.family,
|
||||
password: config.redis.pass,
|
||||
keyPrefix: `${config.redis.prefix}:`,
|
||||
db: config.redis.db ?? 0,
|
||||
});
|
||||
},
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
||||
const $redisSubscriber: Provider = {
|
||||
provide: DI.redisSubscriber,
|
||||
const $redisForPubsub: Provider = {
|
||||
provide: DI.redisForPubsub,
|
||||
useFactory: (config) => {
|
||||
const redisSubscriber = createRedisConnection(config);
|
||||
redisSubscriber.subscribe(config.host);
|
||||
return redisSubscriber;
|
||||
const redis = new Redis({
|
||||
port: config.redisForPubsub.port,
|
||||
host: config.redisForPubsub.host,
|
||||
family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family,
|
||||
password: config.redisForPubsub.pass,
|
||||
keyPrefix: `${config.redisForPubsub.prefix}:`,
|
||||
db: config.redisForPubsub.db ?? 0,
|
||||
});
|
||||
redis.subscribe(config.host);
|
||||
return redis;
|
||||
},
|
||||
inject: [DI.config],
|
||||
};
|
||||
@@ -47,14 +57,14 @@ const $redisSubscriber: Provider = {
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [RepositoryModule],
|
||||
providers: [$config, $db, $redis, $redisSubscriber],
|
||||
exports: [$config, $db, $redis, $redisSubscriber, RepositoryModule],
|
||||
providers: [$config, $db, $redis, $redisForPubsub],
|
||||
exports: [$config, $db, $redis, $redisForPubsub, RepositoryModule],
|
||||
})
|
||||
export class GlobalModule implements OnApplicationShutdown {
|
||||
constructor(
|
||||
@Inject(DI.db) private db: DataSource,
|
||||
@Inject(DI.redis) private redisClient: Redis.Redis,
|
||||
@Inject(DI.redisSubscriber) private redisSubscriber: Redis.Redis,
|
||||
@Inject(DI.redisForPubsub) private redisForPubsub: Redis.Redis,
|
||||
) {}
|
||||
|
||||
async onApplicationShutdown(signal: string): Promise<void> {
|
||||
@@ -69,7 +79,7 @@ export class GlobalModule implements OnApplicationShutdown {
|
||||
await Promise.all([
|
||||
this.db.destroy(),
|
||||
this.redisClient.disconnect(),
|
||||
this.redisSubscriber.disconnect(),
|
||||
this.redisForPubsub.disconnect(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
@@ -25,6 +25,14 @@ export type Source = {
|
||||
disableCache?: boolean;
|
||||
extra?: { [x: string]: string };
|
||||
};
|
||||
dbReplications?: boolean;
|
||||
dbSlaves?: {
|
||||
host: string;
|
||||
port: number;
|
||||
db: string;
|
||||
user: string;
|
||||
pass: string;
|
||||
}[];
|
||||
redis: {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -33,6 +41,22 @@ export type Source = {
|
||||
db?: number;
|
||||
prefix?: string;
|
||||
};
|
||||
redisForPubsub?: {
|
||||
host: string;
|
||||
port: number;
|
||||
family?: number;
|
||||
pass: string;
|
||||
db?: number;
|
||||
prefix?: string;
|
||||
};
|
||||
redisForJobQueue?: {
|
||||
host: string;
|
||||
port: number;
|
||||
family?: number;
|
||||
pass: string;
|
||||
db?: number;
|
||||
prefix?: string;
|
||||
};
|
||||
elasticsearch: {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -91,6 +115,8 @@ export type Mixin = {
|
||||
mediaProxy: string;
|
||||
externalMediaProxyEnabled: boolean;
|
||||
videoThumbnailGenerator: string | null;
|
||||
redisForPubsub: NonNullable<Source['redisForPubsub']>;
|
||||
redisForJobQueue: NonNullable<Source['redisForJobQueue']>;
|
||||
};
|
||||
|
||||
export type Config = Source & Mixin;
|
||||
@@ -151,6 +177,8 @@ export function loadConfig() {
|
||||
: null;
|
||||
|
||||
if (!config.redis.prefix) config.redis.prefix = mixin.host;
|
||||
if (config.redisForPubsub == null) config.redisForPubsub = config.redis;
|
||||
if (config.redisForJobQueue == null) config.redisForJobQueue = config.redis;
|
||||
|
||||
return Object.assign(config, mixin);
|
||||
}
|
||||
|
114
packages/backend/src/core/AccountMoveService.ts
Normal file
114
packages/backend/src/core/AccountMoveService.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull } from 'typeorm';
|
||||
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { LocalUser } from '@/models/entities/User.js';
|
||||
import { User } from '@/models/entities/User.js';
|
||||
import type { FollowingsRepository, UsersRepository } from '@/models/index.js';
|
||||
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { AccountUpdateService } from '@/core/AccountUpdateService.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
|
||||
@Injectable()
|
||||
export class AccountMoveService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private accountUpdateService: AccountUpdateService,
|
||||
private relayService: RelayService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a local account to a remote account.
|
||||
*
|
||||
* After delivering Move activity, its local followers unfollow the old account and then follow the new one.
|
||||
*/
|
||||
@bindThis
|
||||
public async moveToRemote(src: LocalUser, dst: User): Promise<unknown> {
|
||||
// Make sure that the destination is a remote account.
|
||||
if (this.userEntityService.isLocalUser(dst)) throw new Error('move destiantion is not remote');
|
||||
if (!dst.uri) throw new Error('destination uri is empty');
|
||||
|
||||
// add movedToUri to indicate that the user has moved
|
||||
const update = {} as Partial<User>;
|
||||
update.alsoKnownAs = src.alsoKnownAs?.concat([dst.uri]) ?? [dst.uri];
|
||||
update.movedToUri = dst.uri;
|
||||
await this.usersRepository.update(src.id, update);
|
||||
|
||||
const srcPerson = await this.apRendererService.renderPerson(src);
|
||||
const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src));
|
||||
await this.apDeliverManagerService.deliverToFollowers(src, updateAct);
|
||||
this.relayService.deliverToRelays(src, updateAct);
|
||||
|
||||
// Deliver Move activity to the followers of the old account
|
||||
const moveAct = this.apRendererService.addContext(this.apRendererService.renderMove(src, dst));
|
||||
await this.apDeliverManagerService.deliverToFollowers(src, moveAct);
|
||||
|
||||
// Publish meUpdated event
|
||||
const iObj = await this.userEntityService.pack<true, true>(src.id, src, { detail: true, includeSecrets: true });
|
||||
this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj);
|
||||
|
||||
// follow the new account and unfollow the old one
|
||||
const followings = await this.followingsRepository.find({
|
||||
relations: {
|
||||
follower: true,
|
||||
},
|
||||
where: {
|
||||
followeeId: src.id,
|
||||
followerHost: IsNull(), // follower is local
|
||||
},
|
||||
});
|
||||
for (const following of followings) {
|
||||
if (!following.follower) continue;
|
||||
try {
|
||||
await this.userFollowingService.follow(following.follower, dst);
|
||||
await this.userFollowingService.unfollow(following.follower, src);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
return iObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an alias of an old remote account.
|
||||
*
|
||||
* The user's new profile will be published to the followers.
|
||||
*/
|
||||
@bindThis
|
||||
public async createAlias(me: LocalUser, updates: Partial<User>): Promise<unknown> {
|
||||
await this.usersRepository.update(me.id, updates);
|
||||
|
||||
// Publish meUpdated event
|
||||
const iObj = await this.userEntityService.pack<true, true>(me.id, me, {
|
||||
detail: true,
|
||||
includeSecrets: true,
|
||||
});
|
||||
this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj);
|
||||
|
||||
if (me.isLocked === false) {
|
||||
await this.userFollowingService.acceptAllFollowRequests(me);
|
||||
}
|
||||
|
||||
this.accountUpdateService.publishToFollowers(me.id);
|
||||
|
||||
return iObj;
|
||||
}
|
||||
}
|
@@ -29,7 +29,7 @@ export class AccountUpdateService {
|
||||
public async publishToFollowers(userId: User['id']) {
|
||||
const user = await this.usersRepository.findOneBy({ id: userId });
|
||||
if (user == null) throw new Error('user not found');
|
||||
|
||||
|
||||
// フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user));
|
||||
|
@@ -27,8 +27,8 @@ export class AntennaService implements OnApplicationShutdown {
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.redisSubscriber)
|
||||
private redisSubscriber: Redis.Redis,
|
||||
@Inject(DI.redisForPubsub)
|
||||
private redisForPubsub: Redis.Redis,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
@@ -52,12 +52,12 @@ export class AntennaService implements OnApplicationShutdown {
|
||||
this.antennasFetched = false;
|
||||
this.antennas = [];
|
||||
|
||||
this.redisSubscriber.on('message', this.onRedisMessage);
|
||||
this.redisForPubsub.on('message', this.onRedisMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
this.redisSubscriber.off('message', this.onRedisMessage);
|
||||
this.redisForPubsub.off('message', this.onRedisMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@@ -27,8 +27,8 @@ export class CacheService implements OnApplicationShutdown {
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.redisSubscriber)
|
||||
private redisSubscriber: Redis.Redis,
|
||||
@Inject(DI.redisForPubsub)
|
||||
private redisForPubsub: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
@@ -116,7 +116,7 @@ export class CacheService implements OnApplicationShutdown {
|
||||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
||||
});
|
||||
|
||||
this.redisSubscriber.on('message', this.onMessage);
|
||||
this.redisForPubsub.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -167,6 +167,6 @@ export class CacheService implements OnApplicationShutdown {
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
this.redisSubscriber.off('message', this.onMessage);
|
||||
this.redisForPubsub.off('message', this.onMessage);
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AccountMoveService } from './AccountMoveService.js';
|
||||
import { AccountUpdateService } from './AccountUpdateService.js';
|
||||
import { AiService } from './AiService.js';
|
||||
import { AntennaService } from './AntennaService.js';
|
||||
@@ -119,6 +120,7 @@ import type { Provider } from '@nestjs/common';
|
||||
|
||||
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
||||
const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService };
|
||||
const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService };
|
||||
const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService };
|
||||
const $AiService: Provider = { provide: 'AiService', useExisting: AiService };
|
||||
const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService };
|
||||
@@ -242,6 +244,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
],
|
||||
providers: [
|
||||
LoggerService,
|
||||
AccountMoveService,
|
||||
AccountUpdateService,
|
||||
AiService,
|
||||
AntennaService,
|
||||
@@ -359,6 +362,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
|
||||
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
||||
$LoggerService,
|
||||
$AccountMoveService,
|
||||
$AccountUpdateService,
|
||||
$AiService,
|
||||
$AntennaService,
|
||||
@@ -477,6 +481,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
exports: [
|
||||
QueueModule,
|
||||
LoggerService,
|
||||
AccountMoveService,
|
||||
AccountUpdateService,
|
||||
AiService,
|
||||
AntennaService,
|
||||
@@ -593,6 +598,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
|
||||
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
||||
$LoggerService,
|
||||
$AccountMoveService,
|
||||
$AccountUpdateService,
|
||||
$AiService,
|
||||
$AntennaService,
|
||||
|
@@ -1,7 +1,8 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import type { InstancesRepository } from '@/models/index.js';
|
||||
import type { Instance } from '@/models/entities/Instance.js';
|
||||
import { MemoryKVCache } from '@/misc/cache.js';
|
||||
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
@@ -9,23 +10,40 @@ import { bindThis } from '@/decorators.js';
|
||||
|
||||
@Injectable()
|
||||
export class FederatedInstanceService {
|
||||
private cache: MemoryKVCache<Instance>;
|
||||
public federatedInstanceCache: RedisKVCache<Instance | null>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
this.cache = new MemoryKVCache<Instance>(1000 * 60 * 60);
|
||||
this.federatedInstanceCache = new RedisKVCache<Instance | null>(this.redisClient, 'federatedInstance', {
|
||||
lifetime: 1000 * 60 * 60 * 24, // 24h
|
||||
memoryCacheLifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: (key) => this.instancesRepository.findOneBy({ host: key }),
|
||||
toRedisConverter: (value) => JSON.stringify(value),
|
||||
fromRedisConverter: (value) => {
|
||||
const parsed = JSON.parse(value);
|
||||
return {
|
||||
...parsed,
|
||||
firstRetrievedAt: new Date(parsed.firstRetrievedAt),
|
||||
latestRequestReceivedAt: parsed.latestRequestReceivedAt ? new Date(parsed.latestRequestReceivedAt) : null,
|
||||
infoUpdatedAt: parsed.infoUpdatedAt ? new Date(parsed.infoUpdatedAt) : null,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async fetch(host: string): Promise<Instance> {
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
const cached = this.cache.get(host);
|
||||
const cached = await this.federatedInstanceCache.get(host);
|
||||
if (cached) return cached;
|
||||
|
||||
const index = await this.instancesRepository.findOneBy({ host });
|
||||
@@ -37,10 +55,10 @@ export class FederatedInstanceService {
|
||||
firstRetrievedAt: new Date(),
|
||||
}).then(x => this.instancesRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
this.cache.set(host, i);
|
||||
this.federatedInstanceCache.set(host, i);
|
||||
return i;
|
||||
} else {
|
||||
this.cache.set(host, index);
|
||||
this.federatedInstanceCache.set(host, index);
|
||||
return index;
|
||||
}
|
||||
}
|
||||
@@ -49,10 +67,10 @@ export class FederatedInstanceService {
|
||||
public async updateCachePartial(host: string, data: Partial<Instance>): Promise<void> {
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
const cached = this.cache.get(host);
|
||||
const cached = await this.federatedInstanceCache.get(host);
|
||||
if (cached == null) return;
|
||||
|
||||
this.cache.set(host, {
|
||||
this.federatedInstanceCache.set(host, {
|
||||
...cached,
|
||||
...data,
|
||||
});
|
||||
|
@@ -14,8 +14,8 @@ export class MetaService implements OnApplicationShutdown {
|
||||
private intervalId: NodeJS.Timer;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redisSubscriber)
|
||||
private redisSubscriber: Redis.Redis,
|
||||
@Inject(DI.redisForPubsub)
|
||||
private redisForPubsub: Redis.Redis,
|
||||
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
@@ -33,7 +33,7 @@ export class MetaService implements OnApplicationShutdown {
|
||||
}, 1000 * 60 * 5);
|
||||
}
|
||||
|
||||
this.redisSubscriber.on('message', this.onMessage);
|
||||
this.redisForPubsub.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -122,6 +122,6 @@ export class MetaService implements OnApplicationShutdown {
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
clearInterval(this.intervalId);
|
||||
this.redisSubscriber.off('message', this.onMessage);
|
||||
this.redisForPubsub.off('message', this.onMessage);
|
||||
}
|
||||
}
|
||||
|
@@ -8,13 +8,13 @@ import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, End
|
||||
function q<T>(config: Config, name: string, limitPerSec = -1) {
|
||||
return new Bull<T>(name, {
|
||||
redis: {
|
||||
port: config.redis.port,
|
||||
host: config.redis.host,
|
||||
family: config.redis.family == null ? 0 : config.redis.family,
|
||||
password: config.redis.pass,
|
||||
db: config.redis.db ?? 0,
|
||||
port: config.redisForJobQueue.port,
|
||||
host: config.redisForJobQueue.host,
|
||||
family: config.redisForJobQueue.family == null ? 0 : config.redisForJobQueue.family,
|
||||
password: config.redisForJobQueue.pass,
|
||||
db: config.redisForJobQueue.db ?? 0,
|
||||
},
|
||||
prefix: config.redis.prefix ? `${config.redis.prefix}:queue` : 'queue',
|
||||
prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue` : 'queue',
|
||||
limiter: limitPerSec > 0 ? {
|
||||
max: limitPerSec,
|
||||
duration: 1000,
|
||||
|
@@ -64,8 +64,8 @@ export class RoleService implements OnApplicationShutdown {
|
||||
public static NotAssignedError = class extends Error {};
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redisSubscriber)
|
||||
private redisSubscriber: Redis.Redis,
|
||||
@Inject(DI.redisForPubsub)
|
||||
private redisForPubsub: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
@@ -84,10 +84,10 @@ export class RoleService implements OnApplicationShutdown {
|
||||
) {
|
||||
//this.onMessage = this.onMessage.bind(this);
|
||||
|
||||
this.rolesCache = new MemorySingleCache<Role[]>(Infinity);
|
||||
this.roleAssignmentByUserIdCache = new MemoryKVCache<RoleAssignment[]>(Infinity);
|
||||
this.rolesCache = new MemorySingleCache<Role[]>(1000 * 60 * 60 * 1);
|
||||
this.roleAssignmentByUserIdCache = new MemoryKVCache<RoleAssignment[]>(1000 * 60 * 60 * 1);
|
||||
|
||||
this.redisSubscriber.on('message', this.onMessage);
|
||||
this.redisForPubsub.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -400,6 +400,6 @@ export class RoleService implements OnApplicationShutdown {
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
this.redisSubscriber.off('message', this.onMessage);
|
||||
this.redisForPubsub.off('message', this.onMessage);
|
||||
}
|
||||
}
|
||||
|
@@ -13,14 +13,14 @@ export class WebhookService implements OnApplicationShutdown {
|
||||
private webhooks: Webhook[] = [];
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redisSubscriber)
|
||||
private redisSubscriber: Redis.Redis,
|
||||
@Inject(DI.redisForPubsub)
|
||||
private redisForPubsub: Redis.Redis,
|
||||
|
||||
@Inject(DI.webhooksRepository)
|
||||
private webhooksRepository: WebhooksRepository,
|
||||
) {
|
||||
//this.onMessage = this.onMessage.bind(this);
|
||||
this.redisSubscriber.on('message', this.onMessage);
|
||||
this.redisForPubsub.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
@@ -82,6 +82,6 @@ export class WebhookService implements OnApplicationShutdown {
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
this.redisSubscriber.off('message', this.onMessage);
|
||||
this.redisForPubsub.off('message', this.onMessage);
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { In, IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
@@ -22,7 +22,7 @@ import { QueueService } from '@/core/QueueService.js';
|
||||
import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/index.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { RemoteUser } from '@/models/entities/User.js';
|
||||
import { getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
|
||||
import { getApHrefNullable, getApId, getApIds, getApType, getOneApHrefNullable, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
|
||||
import { ApNoteService } from './models/ApNoteService.js';
|
||||
import { ApLoggerService } from './ApLoggerService.js';
|
||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||
@@ -31,7 +31,7 @@ import { ApAudienceService } from './ApAudienceService.js';
|
||||
import { ApPersonService } from './models/ApPersonService.js';
|
||||
import { ApQuestionService } from './models/ApQuestionService.js';
|
||||
import type { Resolver } from './ApResolverService.js';
|
||||
import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate } from './type.js';
|
||||
import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove } from './type.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApInboxService {
|
||||
@@ -80,7 +80,7 @@ export class ApInboxService {
|
||||
) {
|
||||
this.logger = this.apLoggerService.logger;
|
||||
}
|
||||
|
||||
|
||||
@bindThis
|
||||
public async performActivity(actor: RemoteUser, activity: IObject) {
|
||||
if (isCollectionOrOrderedCollection(activity)) {
|
||||
@@ -139,6 +139,8 @@ export class ApInboxService {
|
||||
await this.block(actor, activity);
|
||||
} else if (isFlag(activity)) {
|
||||
await this.flag(actor, activity);
|
||||
} else if (isMove(activity)) {
|
||||
await this.move(actor, activity);
|
||||
} else {
|
||||
this.logger.warn(`unrecognized activity type: ${activity.type}`);
|
||||
}
|
||||
@@ -147,15 +149,15 @@ export class ApInboxService {
|
||||
@bindThis
|
||||
private async follow(actor: RemoteUser, activity: IFollow): Promise<string> {
|
||||
const followee = await this.apDbResolverService.getUserFromApId(activity.object);
|
||||
|
||||
|
||||
if (followee == null) {
|
||||
return 'skip: followee not found';
|
||||
}
|
||||
|
||||
|
||||
if (followee.host != null) {
|
||||
return 'skip: フォローしようとしているユーザーはローカルユーザーではありません';
|
||||
}
|
||||
|
||||
|
||||
await this.userFollowingService.follow(actor, followee, activity.id);
|
||||
return 'ok';
|
||||
}
|
||||
@@ -183,16 +185,16 @@ export class ApInboxService {
|
||||
const uri = activity.id ?? activity;
|
||||
|
||||
this.logger.info(`Accept: ${uri}`);
|
||||
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(err => {
|
||||
this.logger.error(`Resolution failed: ${err}`);
|
||||
throw err;
|
||||
});
|
||||
|
||||
|
||||
if (isFollow(object)) return await this.acceptFollow(actor, object);
|
||||
|
||||
|
||||
return `skip: Unknown Accept type: ${getApType(object)}`;
|
||||
}
|
||||
|
||||
@@ -225,18 +227,18 @@ export class ApInboxService {
|
||||
if ('actor' in activity && actor.uri !== activity.actor) {
|
||||
throw new Error('invalid actor');
|
||||
}
|
||||
|
||||
|
||||
if (activity.target == null) {
|
||||
throw new Error('target is null');
|
||||
}
|
||||
|
||||
|
||||
if (activity.target === actor.featured) {
|
||||
const note = await this.apNoteService.resolveNote(activity.object);
|
||||
if (note == null) throw new Error('note not found');
|
||||
await this.notePiningService.addPinned(actor, note.id);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
throw new Error(`unknown target: ${activity.target}`);
|
||||
}
|
||||
|
||||
@@ -405,10 +407,10 @@ export class ApInboxService {
|
||||
if ('actor' in activity && actor.uri !== activity.actor) {
|
||||
throw new Error('invalid actor');
|
||||
}
|
||||
|
||||
|
||||
// 削除対象objectのtype
|
||||
let formerType: string | undefined;
|
||||
|
||||
|
||||
if (typeof activity.object === 'string') {
|
||||
// typeが不明だけど、どうせ消えてるのでremote resolveしない
|
||||
formerType = undefined;
|
||||
@@ -420,19 +422,19 @@ export class ApInboxService {
|
||||
formerType = toSingle(object.type);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const uri = getApId(activity.object);
|
||||
|
||||
|
||||
// type不明でもactorとobjectが同じならばそれはPersonに違いない
|
||||
if (!formerType && actor.uri === uri) {
|
||||
formerType = 'Person';
|
||||
}
|
||||
|
||||
|
||||
// それでもなかったらおそらくNote
|
||||
if (!formerType) {
|
||||
formerType = 'Note';
|
||||
}
|
||||
|
||||
|
||||
if (validPost.includes(formerType)) {
|
||||
return await this.deleteNote(actor, uri);
|
||||
} else if (validActor.includes(formerType)) {
|
||||
@@ -445,44 +447,44 @@ export class ApInboxService {
|
||||
@bindThis
|
||||
private async deleteActor(actor: RemoteUser, uri: string): Promise<string> {
|
||||
this.logger.info(`Deleting the Actor: ${uri}`);
|
||||
|
||||
|
||||
if (actor.uri !== uri) {
|
||||
return `skip: delete actor ${actor.uri} !== ${uri}`;
|
||||
}
|
||||
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: actor.id });
|
||||
if (user == null) {
|
||||
return 'skip: actor not found';
|
||||
} else if (user.isDeleted) {
|
||||
return 'skip: already deleted';
|
||||
}
|
||||
|
||||
|
||||
const job = await this.queueService.createDeleteAccountJob(actor);
|
||||
|
||||
|
||||
await this.usersRepository.update(actor.id, {
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
|
||||
return `ok: queued ${job.name} ${job.id}`;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async deleteNote(actor: RemoteUser, uri: string): Promise<string> {
|
||||
this.logger.info(`Deleting the Note: ${uri}`);
|
||||
|
||||
|
||||
const unlock = await this.appLockService.getApLock(uri);
|
||||
|
||||
|
||||
try {
|
||||
const note = await this.apDbResolverService.getNoteFromApId(uri);
|
||||
|
||||
|
||||
if (note == null) {
|
||||
return 'message not found';
|
||||
}
|
||||
|
||||
|
||||
if (note.userId !== actor.id) {
|
||||
return '投稿を削除しようとしているユーザーは投稿の作成者ではありません';
|
||||
}
|
||||
|
||||
|
||||
await this.noteDeleteService.delete(actor, note);
|
||||
return 'ok: note deleted';
|
||||
} finally {
|
||||
@@ -536,23 +538,23 @@ export class ApInboxService {
|
||||
@bindThis
|
||||
private async rejectFollow(actor: RemoteUser, activity: IFollow): Promise<string> {
|
||||
// ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある
|
||||
|
||||
|
||||
const follower = await this.apDbResolverService.getUserFromApId(activity.actor);
|
||||
|
||||
|
||||
if (follower == null) {
|
||||
return 'skip: follower not found';
|
||||
}
|
||||
|
||||
|
||||
if (!this.userEntityService.isLocalUser(follower)) {
|
||||
return 'skip: follower is not a local user';
|
||||
}
|
||||
|
||||
|
||||
// relay
|
||||
const match = activity.id?.match(/follow-relay\/(\w+)/);
|
||||
if (match) {
|
||||
return await this.relayService.relayRejected(match[1]);
|
||||
}
|
||||
|
||||
|
||||
await this.userFollowingService.remoteReject(actor, follower);
|
||||
return 'ok';
|
||||
}
|
||||
@@ -562,18 +564,18 @@ export class ApInboxService {
|
||||
if ('actor' in activity && actor.uri !== activity.actor) {
|
||||
throw new Error('invalid actor');
|
||||
}
|
||||
|
||||
|
||||
if (activity.target == null) {
|
||||
throw new Error('target is null');
|
||||
}
|
||||
|
||||
|
||||
if (activity.target === actor.featured) {
|
||||
const note = await this.apNoteService.resolveNote(activity.object);
|
||||
if (note == null) throw new Error('note not found');
|
||||
await this.notePiningService.removePinned(actor, note.id);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
throw new Error(`unknown target: ${activity.target}`);
|
||||
}
|
||||
|
||||
@@ -582,24 +584,24 @@ export class ApInboxService {
|
||||
if ('actor' in activity && actor.uri !== activity.actor) {
|
||||
throw new Error('invalid actor');
|
||||
}
|
||||
|
||||
|
||||
const uri = activity.id ?? activity;
|
||||
|
||||
|
||||
this.logger.info(`Undo: ${uri}`);
|
||||
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
throw e;
|
||||
});
|
||||
|
||||
|
||||
if (isFollow(object)) return await this.undoFollow(actor, object);
|
||||
if (isBlock(object)) return await this.undoBlock(actor, object);
|
||||
if (isLike(object)) return await this.undoLike(actor, object);
|
||||
if (isAnnounce(object)) return await this.undoAnnounce(actor, object);
|
||||
if (isAccept(object)) return await this.undoAccept(actor, object);
|
||||
|
||||
|
||||
return `skip: unknown object type ${getApType(object)}`;
|
||||
}
|
||||
|
||||
@@ -609,17 +611,17 @@ export class ApInboxService {
|
||||
if (follower == null) {
|
||||
return 'skip: follower not found';
|
||||
}
|
||||
|
||||
|
||||
const following = await this.followingsRepository.findOneBy({
|
||||
followerId: follower.id,
|
||||
followeeId: actor.id,
|
||||
});
|
||||
|
||||
|
||||
if (following) {
|
||||
await this.userFollowingService.unfollow(follower, actor);
|
||||
return 'ok: unfollowed';
|
||||
}
|
||||
|
||||
|
||||
return 'skip: フォローされていない';
|
||||
}
|
||||
|
||||
@@ -708,16 +710,16 @@ export class ApInboxService {
|
||||
if ('actor' in activity && actor.uri !== activity.actor) {
|
||||
return 'skip: invalid actor';
|
||||
}
|
||||
|
||||
|
||||
this.logger.debug('Update');
|
||||
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
throw e;
|
||||
});
|
||||
|
||||
|
||||
if (isActor(object)) {
|
||||
await this.apPersonService.updatePerson(actor.uri!, resolver, object);
|
||||
return 'ok: Person updated';
|
||||
@@ -728,4 +730,59 @@ export class ApInboxService {
|
||||
return `skip: Unknown type: ${getApType(object)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async move(actor: RemoteUser, activity: IMove): Promise<string> {
|
||||
// fetch the new and old accounts
|
||||
const targetUri = getApHrefNullable(activity.target);
|
||||
if (!targetUri) return 'skip: invalid activity target';
|
||||
let new_acc = await this.apPersonService.resolvePerson(targetUri);
|
||||
let old_acc = await this.apPersonService.resolvePerson(actor.uri);
|
||||
|
||||
// update them if they're remote
|
||||
if (new_acc.uri) await this.apPersonService.updatePerson(new_acc.uri);
|
||||
if (old_acc.uri) await this.apPersonService.updatePerson(old_acc.uri);
|
||||
|
||||
// retrieve updated users
|
||||
new_acc = await this.apPersonService.resolvePerson(targetUri);
|
||||
old_acc = await this.apPersonService.resolvePerson(actor.uri);
|
||||
|
||||
// check if alsoKnownAs of the new account is valid
|
||||
let isValidMove = true;
|
||||
if (old_acc.uri) {
|
||||
if (!new_acc.alsoKnownAs?.includes(old_acc.uri)) {
|
||||
isValidMove = false;
|
||||
}
|
||||
} else if (!new_acc.alsoKnownAs?.includes(old_acc.id)) {
|
||||
isValidMove = false;
|
||||
}
|
||||
if (!isValidMove) {
|
||||
return 'skip: accounts invalid';
|
||||
}
|
||||
|
||||
// add target uri to movedToUri in order to indicate that the user has moved
|
||||
await this.usersRepository.update(old_acc.id, { movedToUri: targetUri });
|
||||
|
||||
// follow the new account and unfollow the old one
|
||||
const followings = await this.followingsRepository.find({
|
||||
relations: {
|
||||
follower: true,
|
||||
},
|
||||
where: {
|
||||
followeeId: old_acc.id,
|
||||
followerHost: IsNull(), // follower is local
|
||||
}
|
||||
});
|
||||
followings.forEach(async (following) => {
|
||||
if (!following.follower) return;
|
||||
try {
|
||||
await this.userFollowingService.follow(following.follower, new_acc);
|
||||
await this.userFollowingService.unfollow(following.follower, old_acc);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
});
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
}
|
||||
|
@@ -25,7 +25,7 @@ import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { LdSignatureService } from './LdSignatureService.js';
|
||||
import { ApMfmService } from './ApMfmService.js';
|
||||
import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js';
|
||||
import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js';
|
||||
import type { IIdentifier } from './models/identifier.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -292,6 +292,22 @@ export class ApRendererService {
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public renderMove(
|
||||
src: { id: User['id']; host: User['host']; uri: User['host'] },
|
||||
dst: { id: User['id']; host: User['host']; uri: User['host'] },
|
||||
): IMove {
|
||||
const actor = this.userEntityService.isLocalUser(src) ? `${this.config.url}/users/${src.id}` : src.uri!;
|
||||
const target = this.userEntityService.isLocalUser(dst) ? `${this.config.url}/users/${dst.id}` : dst.uri!;
|
||||
return {
|
||||
id: `${this.config.url}/moves/${src.id}/${dst.id}`,
|
||||
actor,
|
||||
type: 'Move',
|
||||
object: actor,
|
||||
target,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async renderNote(note: Note, dive = true): Promise<IPost> {
|
||||
const getPromisedFiles = async (ids: string[]) => {
|
||||
@@ -498,6 +514,14 @@ export class ApRendererService {
|
||||
attachment: attachment.length ? attachment : undefined,
|
||||
} as any;
|
||||
|
||||
if (user.movedToUri) {
|
||||
person.movedTo = user.movedToUri;
|
||||
}
|
||||
|
||||
if (user.alsoKnownAs) {
|
||||
person.alsoKnownAs = user.alsoKnownAs;
|
||||
}
|
||||
|
||||
if (profile.birthday) {
|
||||
person['vcard:bday'] = profile.birthday;
|
||||
}
|
||||
|
@@ -31,6 +31,7 @@ import type { UtilityService } from '@/core/UtilityService.js';
|
||||
import type { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
|
||||
import { extractApHashtags } from './tag.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
@@ -49,6 +50,7 @@ const summaryLength = 2048;
|
||||
export class ApPersonService implements OnModuleInit {
|
||||
private utilityService: UtilityService;
|
||||
private userEntityService: UserEntityService;
|
||||
private driveFileEntityService: DriveFileEntityService;
|
||||
private idService: IdService;
|
||||
private globalEventService: GlobalEventService;
|
||||
private metaService: MetaService;
|
||||
@@ -113,6 +115,7 @@ export class ApPersonService implements OnModuleInit {
|
||||
onModuleInit() {
|
||||
this.utilityService = this.moduleRef.get('UtilityService');
|
||||
this.userEntityService = this.moduleRef.get('UserEntityService');
|
||||
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
|
||||
this.idService = this.moduleRef.get('IdService');
|
||||
this.globalEventService = this.moduleRef.get('GlobalEventService');
|
||||
this.metaService = this.moduleRef.get('MetaService');
|
||||
@@ -278,6 +281,8 @@ export class ApPersonService implements OnModuleInit {
|
||||
lastFetchedAt: new Date(),
|
||||
name: truncate(person.name, nameLength),
|
||||
isLocked: !!person.manuallyApprovesFollowers,
|
||||
movedToUri: person.movedTo,
|
||||
alsoKnownAs: person.alsoKnownAs,
|
||||
isExplorable: !!person.discoverable,
|
||||
username: person.preferredUsername,
|
||||
usernameLower: person.preferredUsername!.toLowerCase(),
|
||||
@@ -356,32 +361,44 @@ export class ApPersonService implements OnModuleInit {
|
||||
|
||||
const avatarId = avatar ? avatar.id : null;
|
||||
const bannerId = banner ? banner.id : null;
|
||||
const avatarUrl = avatar ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null;
|
||||
const bannerUrl = banner ? this.driveFileEntityService.getPublicUrl(banner) : null;
|
||||
const avatarBlurhash = avatar ? avatar.blurhash : null;
|
||||
const bannerBlurhash = banner ? banner.blurhash : null;
|
||||
|
||||
await this.usersRepository.update(user!.id, {
|
||||
avatarId,
|
||||
bannerId,
|
||||
avatarUrl,
|
||||
bannerUrl,
|
||||
avatarBlurhash,
|
||||
bannerBlurhash,
|
||||
});
|
||||
|
||||
user!.avatarId = avatarId;
|
||||
user!.bannerId = bannerId;
|
||||
//#endregion
|
||||
user!.avatarId = avatarId;
|
||||
user!.bannerId = bannerId;
|
||||
user!.avatarUrl = avatarUrl;
|
||||
user!.bannerUrl = bannerUrl;
|
||||
user!.avatarBlurhash = avatarBlurhash;
|
||||
user!.bannerBlurhash = bannerBlurhash;
|
||||
//#endregion
|
||||
|
||||
//#region カスタム絵文字取得
|
||||
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => {
|
||||
this.logger.info(`extractEmojis: ${err}`);
|
||||
return [] as Emoji[];
|
||||
});
|
||||
//#region カスタム絵文字取得
|
||||
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => {
|
||||
this.logger.info(`extractEmojis: ${err}`);
|
||||
return [] as Emoji[];
|
||||
});
|
||||
|
||||
const emojiNames = emojis.map(emoji => emoji.name);
|
||||
const emojiNames = emojis.map(emoji => emoji.name);
|
||||
|
||||
await this.usersRepository.update(user!.id, {
|
||||
emojis: emojiNames,
|
||||
});
|
||||
//#endregion
|
||||
await this.usersRepository.update(user!.id, {
|
||||
emojis: emojiNames,
|
||||
});
|
||||
//#endregion
|
||||
|
||||
await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err));
|
||||
await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err));
|
||||
|
||||
return user!;
|
||||
return user!;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -458,15 +475,21 @@ export class ApPersonService implements OnModuleInit {
|
||||
isBot: getApType(object) === 'Service',
|
||||
isCat: (person as any).isCat === true,
|
||||
isLocked: !!person.manuallyApprovesFollowers,
|
||||
movedToUri: person.movedTo ?? null,
|
||||
alsoKnownAs: person.alsoKnownAs ?? null,
|
||||
isExplorable: !!person.discoverable,
|
||||
} as Partial<User>;
|
||||
|
||||
if (avatar) {
|
||||
updates.avatarId = avatar.id;
|
||||
updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
|
||||
updates.avatarBlurhash = avatar.blurhash;
|
||||
}
|
||||
|
||||
if (banner) {
|
||||
updates.bannerId = banner.id;
|
||||
updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
|
||||
updates.bannerBlurhash = banner.blurhash;
|
||||
}
|
||||
|
||||
// Update user
|
||||
|
@@ -157,6 +157,8 @@ export interface IActor extends IObject {
|
||||
name?: string;
|
||||
preferredUsername?: string;
|
||||
manuallyApprovesFollowers?: boolean;
|
||||
movedTo?: string;
|
||||
alsoKnownAs?: string[];
|
||||
discoverable?: boolean;
|
||||
inbox: string;
|
||||
sharedInbox?: string; // 後方互換性のため
|
||||
@@ -300,6 +302,11 @@ export interface IFlag extends IActivity {
|
||||
type: 'Flag';
|
||||
}
|
||||
|
||||
export interface IMove extends IActivity {
|
||||
type: 'Move';
|
||||
target: IObject | string;
|
||||
}
|
||||
|
||||
export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create';
|
||||
export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete';
|
||||
export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update';
|
||||
@@ -314,3 +321,4 @@ export const isLike = (object: IObject): object is ILike => getApType(object) ==
|
||||
export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce';
|
||||
export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block';
|
||||
export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag';
|
||||
export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move';
|
||||
|
@@ -266,6 +266,7 @@ export class DriveFileEntityService {
|
||||
fileIds: DriveFile['id'][],
|
||||
options?: PackOptions,
|
||||
): Promise<Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>> {
|
||||
if (fileIds.length === 0) return new Map();
|
||||
const files = await this.driveFilesRepository.findBy({ id: In(fileIds) });
|
||||
const packedFiles = await this.packMany(files, options);
|
||||
const map = new Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>(packedFiles.map(f => [f.id, f]));
|
||||
@@ -280,6 +281,7 @@ export class DriveFileEntityService {
|
||||
fileIds: DriveFile['id'][],
|
||||
options?: PackOptions,
|
||||
): Promise<Packed<'DriveFile'>[]> {
|
||||
if (fileIds.length === 0) return [];
|
||||
const filesMap = await this.packManyByIdsMap(fileIds, options);
|
||||
return fileIds.map(id => filesMap.get(id)).filter(isNotNull);
|
||||
}
|
||||
|
@@ -183,6 +183,11 @@ export class NoteEntityService implements OnModuleInit {
|
||||
// 実装上抜けがあるだけかもしれないので、「ヒントに含まれてなかったら(=undefinedなら)return」のようにはしない
|
||||
}
|
||||
|
||||
// パフォーマンスのためノートが作成されてから1秒以上経っていない場合はリアクションを取得しない
|
||||
if (note.createdAt.getTime() + 1000 > Date.now()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const reaction = await this.noteReactionsRepository.findOneBy({
|
||||
userId: meId,
|
||||
noteId: note.id,
|
||||
@@ -283,7 +288,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||
}, options);
|
||||
|
||||
const meId = me ? me.id : null;
|
||||
const note = typeof src === 'object' ? src : await this.notesRepository.findOneByOrFail({ id: src });
|
||||
const note = typeof src === 'object' ? src : await this.notesRepository.findOneOrFail({ where: { id: src }, relations: ['user'] });
|
||||
const host = note.userHost;
|
||||
|
||||
let text = note.text;
|
||||
@@ -395,7 +400,8 @@ export class NoteEntityService implements OnModuleInit {
|
||||
const myReactionsMap = new Map<Note['id'], NoteReaction | null>();
|
||||
if (meId) {
|
||||
const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!);
|
||||
const targets = [...notes.map(n => n.id), ...renoteIds];
|
||||
// パフォーマンスのためノートが作成されてから1秒以上経っていない場合はリアクションを取得しない
|
||||
const targets = [...notes.filter(n => n.createdAt.getTime() + 1000 < Date.now()).map(n => n.id), ...renoteIds];
|
||||
const myReactions = await this.noteReactionsRepository.findBy({
|
||||
userId: meId,
|
||||
noteId: In(targets),
|
||||
@@ -409,7 +415,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||
await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes));
|
||||
// TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく
|
||||
const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(isNotNull);
|
||||
const packedFiles = await this.driveFileEntityService.packManyByIdsMap(fileIds);
|
||||
const packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map();
|
||||
|
||||
return await Promise.all(notes.map(n => this.pack(n, me, {
|
||||
...options,
|
||||
|
@@ -108,27 +108,30 @@ export class NotificationEntityService implements OnModuleInit {
|
||||
) {
|
||||
if (notifications.length === 0) return [];
|
||||
|
||||
const noteIds = notifications.map(x => x.noteId).filter(isNotNull);
|
||||
let validNotifications = notifications;
|
||||
|
||||
const noteIds = validNotifications.map(x => x.noteId).filter(isNotNull);
|
||||
const notes = noteIds.length > 0 ? await this.notesRepository.find({
|
||||
where: { id: In(noteIds) },
|
||||
relations: ['user', 'user.avatar', 'user.banner', 'reply', 'reply.user', 'reply.user.avatar', 'reply.user.banner', 'renote', 'renote.user', 'renote.user.avatar', 'renote.user.banner'],
|
||||
relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'],
|
||||
}) : [];
|
||||
const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, {
|
||||
detail: true,
|
||||
});
|
||||
const packedNotes = new Map(packedNotesArray.map(p => [p.id, p]));
|
||||
|
||||
const userIds = notifications.map(x => x.notifierId).filter(isNotNull);
|
||||
validNotifications = validNotifications.filter(x => x.noteId == null || packedNotes.has(x.noteId));
|
||||
|
||||
const userIds = validNotifications.map(x => x.notifierId).filter(isNotNull);
|
||||
const users = userIds.length > 0 ? await this.usersRepository.find({
|
||||
where: { id: In(userIds) },
|
||||
relations: ['avatar', 'banner'],
|
||||
}) : [];
|
||||
const packedUsersArray = await this.userEntityService.packMany(users, { id: meId }, {
|
||||
detail: false,
|
||||
});
|
||||
const packedUsers = new Map(packedUsersArray.map(p => [p.id, p]));
|
||||
|
||||
return await Promise.all(notifications.map(x => this.pack(x, meId, {}, {
|
||||
return await Promise.all(validNotifications.map(x => this.pack(x, meId, {}, {
|
||||
packedNotes,
|
||||
packedUsers,
|
||||
})));
|
||||
|
@@ -9,13 +9,14 @@ import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { Promiseable } from '@/misc/prelude/await-all.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
|
||||
import { MemoryKVCache } from '@/misc/cache.js';
|
||||
import type { Instance } from '@/models/entities/Instance.js';
|
||||
import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
|
||||
import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js';
|
||||
import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
import type { AntennaService } from '../AntennaService.js';
|
||||
import type { CustomEmojiService } from '../CustomEmojiService.js';
|
||||
@@ -25,7 +26,7 @@ import type { PageEntityService } from './PageEntityService.js';
|
||||
|
||||
type IsUserDetailed<Detailed extends boolean> = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>;
|
||||
type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends boolean> =
|
||||
Detailed extends true ?
|
||||
Detailed extends true ?
|
||||
ExpectsMe extends true ? Packed<'MeDetailed'> :
|
||||
ExpectsMe extends false ? Packed<'UserDetailedNotMe'> :
|
||||
Packed<'UserDetailed'> :
|
||||
@@ -47,13 +48,14 @@ function isRemoteUser(user: User | { host: User['host'] }): boolean {
|
||||
|
||||
@Injectable()
|
||||
export class UserEntityService implements OnModuleInit {
|
||||
private apPersonService: ApPersonService;
|
||||
private noteEntityService: NoteEntityService;
|
||||
private driveFileEntityService: DriveFileEntityService;
|
||||
private pageEntityService: PageEntityService;
|
||||
private customEmojiService: CustomEmojiService;
|
||||
private antennaService: AntennaService;
|
||||
private roleService: RoleService;
|
||||
private userInstanceCache: MemoryKVCache<Instance | null>;
|
||||
private federatedInstanceService: FederatedInstanceService;
|
||||
|
||||
constructor(
|
||||
private moduleRef: ModuleRef,
|
||||
@@ -119,16 +121,17 @@ export class UserEntityService implements OnModuleInit {
|
||||
//private antennaService: AntennaService,
|
||||
//private roleService: RoleService,
|
||||
) {
|
||||
this.userInstanceCache = new MemoryKVCache<Instance | null>(1000 * 60 * 60 * 3);
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
this.apPersonService = this.moduleRef.get('ApPersonService');
|
||||
this.noteEntityService = this.moduleRef.get('NoteEntityService');
|
||||
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
|
||||
this.pageEntityService = this.moduleRef.get('PageEntityService');
|
||||
this.customEmojiService = this.moduleRef.get('CustomEmojiService');
|
||||
this.antennaService = this.moduleRef.get('AntennaService');
|
||||
this.roleService = this.moduleRef.get('RoleService');
|
||||
this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService');
|
||||
}
|
||||
|
||||
//#region Validators
|
||||
@@ -237,7 +240,7 @@ export class UserEntityService implements OnModuleInit {
|
||||
@bindThis
|
||||
public async getHasUnreadNotification(userId: User['id']): Promise<boolean> {
|
||||
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`);
|
||||
|
||||
|
||||
const latestNotificationIdsRes = await this.redisClient.xrevrange(
|
||||
`notificationTimeline:${userId}`,
|
||||
'+',
|
||||
@@ -269,27 +272,6 @@ export class UserEntityService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getAvatarUrl(user: User): Promise<string> {
|
||||
if (user.avatar) {
|
||||
return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user);
|
||||
} else if (user.avatarId) {
|
||||
const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId });
|
||||
return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user);
|
||||
} else {
|
||||
return this.getIdenticonUrl(user);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getAvatarUrlSync(user: User): string {
|
||||
if (user.avatar) {
|
||||
return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user);
|
||||
} else {
|
||||
return this.getIdenticonUrl(user);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getIdenticonUrl(user: User): string {
|
||||
return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`;
|
||||
@@ -309,19 +291,23 @@ export class UserEntityService implements OnModuleInit {
|
||||
includeSecrets: false,
|
||||
}, options);
|
||||
|
||||
let user: User;
|
||||
const user = typeof src === 'object' ? src : await this.usersRepository.findOneByOrFail({ id: src });
|
||||
|
||||
if (typeof src === 'object') {
|
||||
user = src;
|
||||
if (src.avatar === undefined && src.avatarId) src.avatar = await this.driveFilesRepository.findOneBy({ id: src.avatarId }) ?? null;
|
||||
if (src.banner === undefined && src.bannerId) src.banner = await this.driveFilesRepository.findOneBy({ id: src.bannerId }) ?? null;
|
||||
} else {
|
||||
user = await this.usersRepository.findOneOrFail({
|
||||
where: { id: src },
|
||||
relations: {
|
||||
avatar: true,
|
||||
banner: true,
|
||||
},
|
||||
// migration
|
||||
if (user.avatarId != null && user.avatarUrl === null) {
|
||||
const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId });
|
||||
user.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
|
||||
this.usersRepository.update(user.id, {
|
||||
avatarUrl: user.avatarUrl,
|
||||
avatarBlurhash: avatar.blurhash,
|
||||
});
|
||||
}
|
||||
if (user.bannerId != null && user.bannerUrl === null) {
|
||||
const banner = await this.driveFilesRepository.findOneByOrFail({ id: user.bannerId });
|
||||
user.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
|
||||
this.usersRepository.update(user.id, {
|
||||
bannerUrl: user.bannerUrl,
|
||||
bannerBlurhash: banner.blurhash,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -356,14 +342,11 @@ export class UserEntityService implements OnModuleInit {
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
host: user.host,
|
||||
avatarUrl: this.getAvatarUrlSync(user),
|
||||
avatarBlurhash: user.avatar?.blurhash ?? null,
|
||||
avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user),
|
||||
avatarBlurhash: user.avatarBlurhash,
|
||||
isBot: user.isBot ?? falsy,
|
||||
isCat: user.isCat ?? falsy,
|
||||
instance: user.host ? this.userInstanceCache.fetch(user.host,
|
||||
() => this.instancesRepository.findOneBy({ host: user.host! }),
|
||||
v => v != null,
|
||||
).then(instance => instance ? {
|
||||
instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? {
|
||||
name: instance.name,
|
||||
softwareName: instance.softwareName,
|
||||
softwareVersion: instance.softwareVersion,
|
||||
@@ -383,11 +366,13 @@ export class UserEntityService implements OnModuleInit {
|
||||
...(opts.detail ? {
|
||||
url: profile!.url,
|
||||
uri: user.uri,
|
||||
movedToUri: user.movedToUri ? await this.apPersonService.resolvePerson(user.movedToUri) : null,
|
||||
alsoKnownAs: user.alsoKnownAs,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
|
||||
lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null,
|
||||
bannerUrl: user.banner ? this.driveFileEntityService.getPublicUrl(user.banner) : null,
|
||||
bannerBlurhash: user.banner?.blurhash ?? null,
|
||||
bannerUrl: user.bannerUrl,
|
||||
bannerBlurhash: user.bannerBlurhash,
|
||||
isLocked: user.isLocked,
|
||||
isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
|
||||
isSuspended: user.isSuspended ?? falsy,
|
||||
|
@@ -2,7 +2,7 @@ export const DI = {
|
||||
config: Symbol('config'),
|
||||
db: Symbol('db'),
|
||||
redis: Symbol('redis'),
|
||||
redisSubscriber: Symbol('redisSubscriber'),
|
||||
redisForPubsub: Symbol('redisForPubsub'),
|
||||
|
||||
//#region Repositories
|
||||
usersRepository: Symbol('usersRepository'),
|
||||
|
@@ -68,6 +68,19 @@ export class User {
|
||||
})
|
||||
public followingCount: number;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512,
|
||||
nullable: true,
|
||||
comment: 'The URI of the new account of the User',
|
||||
})
|
||||
public movedToUri: string | null;
|
||||
|
||||
@Column('simple-array', {
|
||||
nullable: true,
|
||||
comment: 'URIs the user is known as too',
|
||||
})
|
||||
public alsoKnownAs: string[] | null;
|
||||
|
||||
@Column('integer', {
|
||||
default: 0,
|
||||
comment: 'The count of notes.',
|
||||
@@ -100,6 +113,26 @@ export class User {
|
||||
@JoinColumn()
|
||||
public banner: DriveFile | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
})
|
||||
public avatarUrl: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 512, nullable: true,
|
||||
})
|
||||
public bannerUrl: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
})
|
||||
public avatarBlurhash: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128, nullable: true,
|
||||
})
|
||||
public bannerBlurhash: string | null;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
length: 128, array: true, default: '{}',
|
||||
|
@@ -72,6 +72,18 @@ export const packedUserDetailedNotMeOnlySchema = {
|
||||
format: 'uri',
|
||||
nullable: true, optional: false,
|
||||
},
|
||||
movedToUri: {
|
||||
type: 'string',
|
||||
format: 'uri',
|
||||
nullable: true,
|
||||
optional: false,
|
||||
},
|
||||
alsoKnownAs: {
|
||||
type: 'array',
|
||||
format: 'uri',
|
||||
nullable: true,
|
||||
optional: false,
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
|
@@ -200,6 +200,22 @@ export function createPostgresDataSource(config: Config) {
|
||||
statement_timeout: 1000 * 10,
|
||||
...config.db.extra,
|
||||
},
|
||||
replication: config.dbReplications ? {
|
||||
master: {
|
||||
host: config.db.host,
|
||||
port: config.db.port,
|
||||
username: config.db.user,
|
||||
password: config.db.pass,
|
||||
database: config.db.db,
|
||||
},
|
||||
slaves: config.dbSlaves!.map(rep => ({
|
||||
host: rep.host,
|
||||
port: rep.port,
|
||||
username: rep.user,
|
||||
password: rep.pass,
|
||||
database: rep.db,
|
||||
})),
|
||||
} : undefined,
|
||||
synchronize: process.env.NODE_ENV === 'test',
|
||||
dropSchema: process.env.NODE_ENV === 'test',
|
||||
cache: !config.db.disableCache && process.env.NODE_ENV !== 'test' ? { // dbをcloseしても何故かredisのコネクションが内部的に残り続けるようで、テストの際に支障が出るため無効にする(キャッシュも含めてテストしたいため本当は有効にしたいが...)
|
||||
|
@@ -1,13 +0,0 @@
|
||||
import Redis from 'ioredis';
|
||||
import { Config } from '@/config.js';
|
||||
|
||||
export function createRedisConnection(config: Config): Redis.Redis {
|
||||
return new Redis({
|
||||
port: config.redis.port,
|
||||
host: config.redis.host,
|
||||
family: config.redis.family == null ? 0 : config.redis.family,
|
||||
password: config.redis.pass,
|
||||
keyPrefix: `${config.redis.prefix}:`,
|
||||
db: config.redis.db ?? 0,
|
||||
});
|
||||
}
|
@@ -149,13 +149,12 @@ export class ServerService implements OnApplicationShutdown {
|
||||
host: (host == null) || (host === this.config.host) ? IsNull() : host,
|
||||
isSuspended: false,
|
||||
},
|
||||
relations: ['avatar'],
|
||||
});
|
||||
|
||||
reply.header('Cache-Control', 'public, max-age=86400');
|
||||
|
||||
if (user) {
|
||||
reply.redirect(this.userEntityService.getAvatarUrlSync(user));
|
||||
reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user));
|
||||
} else {
|
||||
reply.redirect('/static-assets/user-unknown.png');
|
||||
}
|
||||
|
@@ -220,6 +220,8 @@ import * as ep___i_signinHistory from './endpoints/i/signin-history.js';
|
||||
import * as ep___i_unpin from './endpoints/i/unpin.js';
|
||||
import * as ep___i_updateEmail from './endpoints/i/update-email.js';
|
||||
import * as ep___i_update from './endpoints/i/update.js';
|
||||
import * as ep___i_move from './endpoints/i/move.js';
|
||||
import * as ep___i_knownAs from './endpoints/i/known-as.js';
|
||||
import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js';
|
||||
import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
|
||||
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
|
||||
@@ -551,6 +553,8 @@ const $i_signinHistory: Provider = { provide: 'ep:i/signin-history', useClass: e
|
||||
const $i_unpin: Provider = { provide: 'ep:i/unpin', useClass: ep___i_unpin.default };
|
||||
const $i_updateEmail: Provider = { provide: 'ep:i/update-email', useClass: ep___i_updateEmail.default };
|
||||
const $i_update: Provider = { provide: 'ep:i/update', useClass: ep___i_update.default };
|
||||
const $i_move: Provider = { provide: 'ep:i/move', useClass: ep___i_move.default };
|
||||
const $i_knownAs: Provider = { provide: 'ep:i/known-as', useClass: ep___i_knownAs.default };
|
||||
const $i_webhooks_create: Provider = { provide: 'ep:i/webhooks/create', useClass: ep___i_webhooks_create.default };
|
||||
const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep___i_webhooks_list.default };
|
||||
const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default };
|
||||
@@ -886,6 +890,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||
$i_unpin,
|
||||
$i_updateEmail,
|
||||
$i_update,
|
||||
$i_move,
|
||||
$i_knownAs,
|
||||
$i_webhooks_create,
|
||||
$i_webhooks_list,
|
||||
$i_webhooks_show,
|
||||
@@ -1215,6 +1221,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
||||
$i_unpin,
|
||||
$i_updateEmail,
|
||||
$i_update,
|
||||
$i_move,
|
||||
$i_knownAs,
|
||||
$i_webhooks_create,
|
||||
$i_webhooks_list,
|
||||
$i_webhooks_show,
|
||||
|
@@ -22,8 +22,8 @@ export class StreamingApiServerService {
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.redisSubscriber)
|
||||
private redisSubscriber: Redis.Redis,
|
||||
@Inject(DI.redisForPubsub)
|
||||
private redisForPubsub: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
@@ -81,7 +81,7 @@ export class StreamingApiServerService {
|
||||
ev.emit(parsed.channel, parsed.message);
|
||||
}
|
||||
|
||||
this.redisSubscriber.on('message', onRedisMessage);
|
||||
this.redisForPubsub.on('message', onRedisMessage);
|
||||
|
||||
const main = new MainStreamConnection(
|
||||
this.channelsService,
|
||||
@@ -111,7 +111,7 @@ export class StreamingApiServerService {
|
||||
connection.once('close', () => {
|
||||
ev.removeAllListeners();
|
||||
main.dispose();
|
||||
this.redisSubscriber.off('message', onRedisMessage);
|
||||
this.redisForPubsub.off('message', onRedisMessage);
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
});
|
||||
|
||||
|
@@ -220,6 +220,8 @@ import * as ep___i_signinHistory from './endpoints/i/signin-history.js';
|
||||
import * as ep___i_unpin from './endpoints/i/unpin.js';
|
||||
import * as ep___i_updateEmail from './endpoints/i/update-email.js';
|
||||
import * as ep___i_update from './endpoints/i/update.js';
|
||||
import * as ep___i_move from './endpoints/i/move.js';
|
||||
import * as ep___i_knownAs from './endpoints/i/known-as.js';
|
||||
import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js';
|
||||
import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
|
||||
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
|
||||
@@ -549,6 +551,8 @@ const eps = [
|
||||
['i/unpin', ep___i_unpin],
|
||||
['i/update-email', ep___i_updateEmail],
|
||||
['i/update', ep___i_update],
|
||||
['i/move', ep___i_move],
|
||||
['i/known-as', ep___i_knownAs],
|
||||
['i/webhooks/create', ep___i_webhooks_create],
|
||||
['i/webhooks/list', ep___i_webhooks_list],
|
||||
['i/webhooks/show', ep___i_webhooks_show],
|
||||
|
@@ -95,16 +95,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
.leftJoinAndSelect('user.banner', 'banner')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
|
||||
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
|
||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateMutedUserQuery(query, me);
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { ChannelsRepository, NotesRepository } from '@/models/index.js';
|
||||
import type { ChannelsRepository, Note, NotesRepository } from '@/models/index.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
@@ -73,6 +73,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
}
|
||||
|
||||
let timeline: Note[] = [];
|
||||
|
||||
const noteIdsRes = await this.redisClient.xrevrange(
|
||||
`channelTimeline:${channel.id}`,
|
||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
|
||||
@@ -80,41 +82,52 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1
|
||||
|
||||
if (noteIdsRes.length === 0) {
|
||||
return [];
|
||||
//#region Construct query
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('note.channelId = :channelId', { channelId: channel.id })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQuery(query, me);
|
||||
this.queryService.generateMutedNoteQuery(query, me);
|
||||
this.queryService.generateBlockedUserQuery(query, me);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
timeline = await query.take(ps.limit).getMany();
|
||||
} else {
|
||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
|
||||
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
//#region Construct query
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQuery(query, me);
|
||||
this.queryService.generateMutedNoteQuery(query, me);
|
||||
this.queryService.generateBlockedUserQuery(query, me);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
timeline = await query.getMany();
|
||||
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
}
|
||||
|
||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
|
||||
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
//#region Construct query
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
.leftJoinAndSelect('user.banner', 'banner')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
|
||||
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
|
||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQuery(query, me);
|
||||
this.queryService.generateMutedNoteQuery(query, me);
|
||||
this.queryService.generateBlockedUserQuery(query, me);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const timeline = await query.getMany();
|
||||
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||
|
||||
if (me) this.activeUsersChart.read(me);
|
||||
|
||||
return await this.noteEntityService.packMany(timeline, me);
|
||||
|
@@ -75,16 +75,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
.innerJoin(this.clipNotesRepository.metadata.targetName, 'clipNote', 'clipNote.noteId = note.id')
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
.leftJoinAndSelect('user.banner', 'banner')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
|
||||
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
|
||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
|
||||
.andWhere('clipNote.clipId = :clipId', { clipId: clip.id });
|
||||
|
||||
if (me) {
|
||||
|
92
packages/backend/src/server/api/endpoints/i/known-as.ts
Normal file
92
packages/backend/src/server/api/endpoints/i/known-as.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import ms from 'ms';
|
||||
|
||||
import { User } from '@/models/entities/User.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
import { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { ApiLoggerService } from '@/server/api/ApiLoggerService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['users'],
|
||||
|
||||
secure: true,
|
||||
requireCredential: true,
|
||||
|
||||
limit: {
|
||||
duration: ms('1day'),
|
||||
max: 30,
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchUser: {
|
||||
message: 'No such user.',
|
||||
code: 'NO_SUCH_USER',
|
||||
id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5',
|
||||
},
|
||||
notRemote: {
|
||||
message: 'User is not remote. You can only migrate from other instances.',
|
||||
code: 'NOT_REMOTE',
|
||||
id: '4362f8dc-731f-4ad8-a694-be2a88922a24',
|
||||
},
|
||||
uriNull: {
|
||||
message: 'User ActivityPup URI is null.',
|
||||
code: 'URI_NULL',
|
||||
id: 'bf326f31-d430-4f97-9933-5d61e4d48a23',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
alsoKnownAs: { type: 'string' },
|
||||
},
|
||||
required: ['alsoKnownAs'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
private userEntityService: UserEntityService,
|
||||
private remoteUserResolveService: RemoteUserResolveService,
|
||||
private apiLoggerService: ApiLoggerService,
|
||||
private accountMoveService: AccountMoveService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// Check parameter
|
||||
if (!ps.alsoKnownAs) throw new ApiError(meta.errors.noSuchUser);
|
||||
|
||||
let unfiltered = ps.alsoKnownAs;
|
||||
const updates = {} as Partial<User>;
|
||||
|
||||
if (!unfiltered) {
|
||||
updates.alsoKnownAs = null;
|
||||
} else {
|
||||
// Parse user's input into the old account
|
||||
if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5);
|
||||
if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1);
|
||||
if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote);
|
||||
|
||||
const userAddress = unfiltered.split('@');
|
||||
// Retrieve the old account
|
||||
const knownAs = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => {
|
||||
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`);
|
||||
throw new ApiError(meta.errors.noSuchUser);
|
||||
});
|
||||
|
||||
const toUrl: string | null = knownAs.uri;
|
||||
if (!toUrl) throw new ApiError(meta.errors.uriNull);
|
||||
// Only allow moving from a remote account
|
||||
if (this.userEntityService.isLocalUser(knownAs)) throw new ApiError(meta.errors.notRemote);
|
||||
|
||||
updates.alsoKnownAs = updates.alsoKnownAs?.concat([toUrl]) ?? [toUrl];
|
||||
}
|
||||
|
||||
return await this.accountMoveService.createAlias(me, updates);
|
||||
});
|
||||
}
|
||||
}
|
140
packages/backend/src/server/api/endpoints/i/move.ts
Normal file
140
packages/backend/src/server/api/endpoints/i/move.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import ms from 'ms';
|
||||
|
||||
import type { Config } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
import { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { ApiLoggerService } from '@/server/api/ApiLoggerService.js';
|
||||
import { GetterService } from '@/server/api/GetterService.js';
|
||||
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['users'],
|
||||
|
||||
secure: true,
|
||||
requireCredential: true,
|
||||
limit: {
|
||||
duration: ms('1day'),
|
||||
max: 5,
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchMoveTarget: {
|
||||
message: 'No such move target.',
|
||||
code: 'NO_SUCH_MOVE_TARGET',
|
||||
id: 'b5c90186-4ab0-49c8-9bba-a1f76c202ba4',
|
||||
},
|
||||
remoteAccountForbids: {
|
||||
message:
|
||||
'Remote account doesn\'t have proper \'Known As\' alias. Did you remember to set it?',
|
||||
code: 'REMOTE_ACCOUNT_FORBIDS',
|
||||
id: 'b5c90186-4ab0-49c8-9bba-a1f766282ba4',
|
||||
},
|
||||
notRemote: {
|
||||
message: 'User is not remote. You can only migrate to other instances.',
|
||||
code: 'NOT_REMOTE',
|
||||
id: '4362f8dc-731f-4ad8-a694-be2a88922a24',
|
||||
},
|
||||
rootForbidden: {
|
||||
message: 'The root can\'t migrate.',
|
||||
code: 'NOT_ROOT_FORBIDDEN',
|
||||
id: '4362e8dc-731f-4ad8-a694-be2a88922a24',
|
||||
},
|
||||
noSuchUser: {
|
||||
message: 'No such user.',
|
||||
code: 'NO_SUCH_USER',
|
||||
id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5',
|
||||
},
|
||||
uriNull: {
|
||||
message: 'User ActivityPup URI is null.',
|
||||
code: 'URI_NULL',
|
||||
id: 'bf326f31-d430-4f97-9933-5d61e4d48a23',
|
||||
},
|
||||
localUriNull: {
|
||||
message: 'Local User ActivityPup URI is null.',
|
||||
code: 'URI_NULL',
|
||||
id: '95ba11b9-90e8-43a5-ba16-7acc1ab32e71',
|
||||
},
|
||||
alreadyMoved: {
|
||||
message: 'Account was already moved to another account.',
|
||||
code: 'ALREADY_MOVED',
|
||||
id: 'b234a14e-9ebe-4581-8000-074b3c215962',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
moveToAccount: { type: 'string' },
|
||||
},
|
||||
required: ['moveToAccount'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private remoteUserResolveService: RemoteUserResolveService,
|
||||
private apiLoggerService: ApiLoggerService,
|
||||
private accountMoveService: AccountMoveService,
|
||||
private getterService: GetterService,
|
||||
private apPersonService: ApPersonService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// check parameter
|
||||
if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchMoveTarget);
|
||||
// abort if user is the root
|
||||
if (me.isRoot) throw new ApiError(meta.errors.rootForbidden);
|
||||
// abort if user has already moved
|
||||
if (me.movedToUri) throw new ApiError(meta.errors.alreadyMoved);
|
||||
|
||||
let unfiltered = ps.moveToAccount;
|
||||
if (!unfiltered) throw new ApiError(meta.errors.noSuchMoveTarget);
|
||||
|
||||
// parse user's input into the destination account
|
||||
if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5);
|
||||
if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1);
|
||||
if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote);
|
||||
|
||||
const userAddress = unfiltered.split('@');
|
||||
// retrieve the destination account
|
||||
let moveTo = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => {
|
||||
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`);
|
||||
throw new ApiError(meta.errors.noSuchMoveTarget);
|
||||
});
|
||||
const remoteMoveTo = await this.getterService.getRemoteUser(moveTo.id);
|
||||
if (!remoteMoveTo.uri) throw new ApiError(meta.errors.uriNull);
|
||||
|
||||
// update local db
|
||||
await this.apPersonService.updatePerson(remoteMoveTo.uri);
|
||||
// retrieve updated user
|
||||
moveTo = await this.apPersonService.resolvePerson(remoteMoveTo.uri);
|
||||
// only allow moving to a remote account
|
||||
if (this.userEntityService.isLocalUser(moveTo)) throw new ApiError(meta.errors.notRemote);
|
||||
|
||||
let allowed = false;
|
||||
|
||||
const fromUrl = `${this.config.url}/users/${me.id}`;
|
||||
// make sure that the user has indicated the old account as an alias
|
||||
moveTo.alsoKnownAs?.forEach((elem) => {
|
||||
if (fromUrl.includes(elem)) allowed = true;
|
||||
});
|
||||
|
||||
// abort if unintended
|
||||
if (!(allowed && moveTo.uri && fromUrl)) throw new ApiError(meta.errors.remoteAccountForbids);
|
||||
|
||||
return await this.accountMoveService.moveToRemote(me, moveTo);
|
||||
});
|
||||
}
|
||||
}
|
@@ -19,6 +19,7 @@ import { HashtagService } from '@/core/HashtagService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -148,6 +149,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
private pagesRepository: PagesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private driveFileEntityService: DriveFileEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private accountUpdateService: AccountUpdateService,
|
||||
@@ -170,8 +172,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
if (ps.location !== undefined) profileUpdates.location = ps.location;
|
||||
if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday;
|
||||
if (ps.ffVisibility !== undefined) profileUpdates.ffVisibility = ps.ffVisibility;
|
||||
if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId;
|
||||
if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId;
|
||||
if (ps.mutedWords !== undefined) {
|
||||
// TODO: ちゃんと数える
|
||||
const length = JSON.stringify(ps.mutedWords).length;
|
||||
@@ -217,6 +217,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
|
||||
if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar);
|
||||
if (!avatar.type.startsWith('image/')) throw new ApiError(meta.errors.avatarNotAnImage);
|
||||
|
||||
updates.avatarId = avatar.id;
|
||||
updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
|
||||
updates.avatarBlurhash = avatar.blurhash;
|
||||
}
|
||||
|
||||
if (ps.bannerId) {
|
||||
@@ -224,6 +228,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
|
||||
if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner);
|
||||
if (!banner.type.startsWith('image/')) throw new ApiError(meta.errors.bannerNotAnImage);
|
||||
|
||||
updates.bannerId = banner.id;
|
||||
updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
|
||||
updates.bannerBlurhash = banner.blurhash;
|
||||
}
|
||||
|
||||
if (ps.pinnedPageId) {
|
||||
|
@@ -49,16 +49,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
.andWhere('note.visibility = \'public\'')
|
||||
.andWhere('note.localOnly = FALSE')
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
.leftJoinAndSelect('user.banner', 'banner')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
|
||||
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
|
||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
if (ps.local) {
|
||||
query.andWhere('note.userHost IS NULL');
|
||||
|
@@ -57,16 +57,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
}));
|
||||
}))
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
.leftJoinAndSelect('user.banner', 'banner')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
|
||||
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
|
||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
if (me) {
|
||||
|
@@ -53,16 +53,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
.andWhere('note.createdAt > :date', { date: new Date(Date.now() - day) })
|
||||
.andWhere('note.visibility = \'public\'')
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
.leftJoinAndSelect('user.banner', 'banner')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
|
||||
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
|
||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
if (ps.channelId) query.andWhere('note.channelId = :channelId', { channelId: ps.channelId });
|
||||
|
||||
|
@@ -73,16 +73,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
.andWhere('note.visibility = \'public\'')
|
||||
.andWhere('note.channelId IS NULL')
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
.leftJoinAndSelect('user.banner', 'banner')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
|
||||
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
|
||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateRepliesQuery(query, me);
|
||||
if (me) {
|
||||
|
@@ -91,16 +91,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
|
||||
}))
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
.leftJoinAndSelect('user.banner', 'banner')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
|
||||
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
|
||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
|
||||
.setParameters(followingQuery.getParameters());
|
||||
|
||||
this.queryService.generateChannelQuery(query, me);
|
||||
|
@@ -80,16 +80,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
|
||||
.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)')
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
.leftJoinAndSelect('user.banner', 'banner')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
|
||||
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
|
||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateChannelQuery(query, me);
|
||||
this.queryService.generateRepliesQuery(query, me);
|
||||
|
@@ -60,16 +60,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
.orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`);
|
||||
}))
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
.leftJoinAndSelect('user.banner', 'banner')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
|
||||
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
|
||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateMutedUserQuery(query, me);
|
||||
|
@@ -75,7 +75,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
order: {
|
||||
id: -1,
|
||||
},
|
||||
relations: ['user', 'user.avatar', 'user.banner', 'note'],
|
||||
relations: ['user', 'note'],
|
||||
});
|
||||
|
||||
return await Promise.all(reactions.map(reaction => this.noteReactionEntityService.pack(reaction, me)));
|
||||
|
@@ -4,8 +4,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import { GetterService } from '@/server/api/GetterService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
@@ -62,16 +62,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
.andWhere('note.renoteId = :renoteId', { renoteId: note.id })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
.leftJoinAndSelect('user.banner', 'banner')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
|
||||
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
|
||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
if (me) this.queryService.generateMutedUserQuery(query, me);
|
||||
|
@@ -46,16 +46,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
.andWhere('note.replyId = :replyId', { replyId: ps.noteId })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
.leftJoinAndSelect('user.banner', 'banner')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
|
||||
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
|
||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
if (me) this.queryService.generateMutedUserQuery(query, me);
|
||||
|
@@ -71,16 +71,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
.leftJoinAndSelect('user.banner', 'banner')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
|
||||
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
|
||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
if (me) this.queryService.generateMutedUserQuery(query, me);
|
||||
|
@@ -85,16 +85,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
query
|
||||
.andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
.leftJoinAndSelect('user.banner', 'banner')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
|
||||
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
|
||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
if (me) this.queryService.generateMutedUserQuery(query, me);
|
||||
|
@@ -70,16 +70,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
.leftJoinAndSelect('user.banner', 'banner')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
|
||||
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
|
||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
if (followees.length > 0) {
|
||||
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
|
||||
|
@@ -84,16 +84,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
.innerJoin(this.userListJoiningsRepository.metadata.targetName, 'userListJoining', 'userListJoining.userId = note.userId')
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
.leftJoinAndSelect('user.banner', 'banner')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
|
||||
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
|
||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
|
||||
.andWhere('userListJoining.userListId = :userListId', { userListId: list.id });
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
|
@@ -74,16 +74,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('note.userId = :userId', { userId: user.id })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
.leftJoinAndSelect('user.banner', 'banner')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
|
||||
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
|
||||
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
if (me) {
|
||||
|
@@ -419,7 +419,7 @@ export class ClientServerService {
|
||||
reply.header('Cache-Control', 'public, max-age=15');
|
||||
return await reply.view('user', {
|
||||
user, profile, me,
|
||||
avatarUrl: await this.userEntityService.getAvatarUrl(user),
|
||||
avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
|
||||
sub: request.params.sub,
|
||||
instanceName: meta.name ?? 'Misskey',
|
||||
icon: meta.iconUrl,
|
||||
@@ -464,7 +464,7 @@ export class ClientServerService {
|
||||
return await reply.view('note', {
|
||||
note: _note,
|
||||
profile,
|
||||
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: note.userId })),
|
||||
avatarUrl: _note.user.avatarUrl,
|
||||
// TODO: Let locale changeable by instance setting
|
||||
summary: getNoteSummary(_note),
|
||||
instanceName: meta.name ?? 'Misskey',
|
||||
@@ -503,7 +503,7 @@ export class ClientServerService {
|
||||
return await reply.view('page', {
|
||||
page: _page,
|
||||
profile,
|
||||
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: page.userId })),
|
||||
avatarUrl: _page.user.avatarUrl,
|
||||
instanceName: meta.name ?? 'Misskey',
|
||||
icon: meta.iconUrl,
|
||||
themeColor: meta.themeColor,
|
||||
@@ -527,7 +527,7 @@ export class ClientServerService {
|
||||
return await reply.view('flash', {
|
||||
flash: _flash,
|
||||
profile,
|
||||
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: flash.userId })),
|
||||
avatarUrl: _flash.user.avatarUrl,
|
||||
instanceName: meta.name ?? 'Misskey',
|
||||
icon: meta.iconUrl,
|
||||
themeColor: meta.themeColor,
|
||||
@@ -551,7 +551,7 @@ export class ClientServerService {
|
||||
return await reply.view('clip', {
|
||||
clip: _clip,
|
||||
profile,
|
||||
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: clip.userId })),
|
||||
avatarUrl: _clip.user.avatarUrl,
|
||||
instanceName: meta.name ?? 'Misskey',
|
||||
icon: meta.iconUrl,
|
||||
themeColor: meta.themeColor,
|
||||
@@ -573,7 +573,7 @@ export class ClientServerService {
|
||||
return await reply.view('gallery-post', {
|
||||
post: _post,
|
||||
profile,
|
||||
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: post.userId })),
|
||||
avatarUrl: _post.user.avatarUrl,
|
||||
instanceName: meta.name ?? 'Misskey',
|
||||
icon: meta.iconUrl,
|
||||
themeColor: meta.themeColor,
|
||||
|
@@ -58,7 +58,7 @@ export class FeedService {
|
||||
generator: 'Misskey',
|
||||
description: `${user.notesCount} Notes, ${profile.ffVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.ffVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`,
|
||||
link: author.link,
|
||||
image: await this.userEntityService.getAvatarUrl(user),
|
||||
image: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
|
||||
feedLinks: {
|
||||
json: `${author.link}.json`,
|
||||
atom: `${author.link}.atom`,
|
||||
|
@@ -172,6 +172,7 @@ describe('Streaming', () => {
|
||||
assert.strictEqual(fired, true);
|
||||
});
|
||||
|
||||
/* TODO
|
||||
test('リモートユーザーの投稿は流れない', async () => {
|
||||
const fired = await waitFire(
|
||||
ayano, 'localTimeline', // ayano:Local
|
||||
@@ -191,6 +192,7 @@ describe('Streaming', () => {
|
||||
|
||||
assert.strictEqual(fired, false);
|
||||
});
|
||||
*/
|
||||
|
||||
test('ホーム指定の投稿は流れない', async () => {
|
||||
const fired = await waitFire(
|
||||
@@ -244,6 +246,7 @@ describe('Streaming', () => {
|
||||
assert.strictEqual(fired, true);
|
||||
});
|
||||
|
||||
/* TODO
|
||||
test('フォローしているリモートユーザーの投稿が流れる', async () => {
|
||||
const fired = await waitFire(
|
||||
ayano, 'hybridTimeline', // ayano:Hybrid
|
||||
@@ -263,6 +266,7 @@ describe('Streaming', () => {
|
||||
|
||||
assert.strictEqual(fired, false);
|
||||
});
|
||||
*/
|
||||
|
||||
test('フォローしているユーザーのダイレクト投稿が流れる', async () => {
|
||||
const fired = await waitFire(
|
||||
@@ -316,6 +320,7 @@ describe('Streaming', () => {
|
||||
assert.strictEqual(fired, true);
|
||||
});
|
||||
|
||||
/* TODO
|
||||
test('フォローしていないリモートユーザーの投稿が流れる', async () => {
|
||||
const fired = await waitFire(
|
||||
ayano, 'globalTimeline', // ayano:Global
|
||||
@@ -325,6 +330,7 @@ describe('Streaming', () => {
|
||||
|
||||
assert.strictEqual(fired, true);
|
||||
});
|
||||
*/
|
||||
|
||||
test('ホーム投稿は流れない', async () => {
|
||||
const fired = await waitFire(
|
||||
|
6
packages/frontend/.storybook/.gitignore
vendored
6
packages/frontend/.storybook/.gitignore
vendored
@@ -1,9 +1,7 @@
|
||||
# (cd path/to/frontend; pnpm tsc -p .storybook)
|
||||
# (cd path/to/frontend; node .storybook/generate.js)
|
||||
/changes.js
|
||||
/generate.js
|
||||
# (cd path/to/frontend; node .storybook/preload-locale.js)
|
||||
/preload-locale.js
|
||||
/locale.ts
|
||||
# (cd path/to/frontend; node .storybook/preload-theme.js)
|
||||
/main.js
|
||||
/preload-theme.js
|
||||
/themes.ts
|
||||
|
80
packages/frontend/.storybook/changes.ts
Normal file
80
packages/frontend/.storybook/changes.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import micromatch from 'micromatch';
|
||||
import main from './main';
|
||||
|
||||
interface Stats {
|
||||
readonly modules: readonly {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly reasons: readonly {
|
||||
readonly moduleName: string;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
fs.readFile(
|
||||
path.resolve(__dirname, '../storybook-static/preview-stats.json')
|
||||
).then((buffer) => {
|
||||
const stats: Stats = JSON.parse(buffer.toString());
|
||||
const keys = new Set(stats.modules.map((stat) => stat.id));
|
||||
const map = new Map(
|
||||
Array.from(keys, (key) => [
|
||||
key,
|
||||
new Set(
|
||||
stats.modules
|
||||
.filter((stat) => stat.id === key)
|
||||
.flatMap((stat) => stat.reasons)
|
||||
.map((reason) => reason.moduleName)
|
||||
),
|
||||
])
|
||||
);
|
||||
const modules = new Set(
|
||||
process.argv
|
||||
.slice(2)
|
||||
.map((arg) =>
|
||||
path.relative(
|
||||
path.resolve(__dirname, '..'),
|
||||
path.resolve(__dirname, '../../..', arg)
|
||||
)
|
||||
)
|
||||
.map((path) => (path.startsWith('.') ? path : `./${path}`))
|
||||
);
|
||||
if (
|
||||
micromatch(Array.from(modules), [
|
||||
'../../assets/**',
|
||||
'../../fluent-emojis/**',
|
||||
'../../locales/**',
|
||||
'../../misskey-assets/**',
|
||||
'assets/**',
|
||||
'public/**',
|
||||
'../../pnpm-lock.yaml',
|
||||
]).length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
for (;;) {
|
||||
const oldSize = modules.size;
|
||||
for (const module of Array.from(modules)) {
|
||||
if (map.has(module)) {
|
||||
for (const dependency of Array.from(map.get(module)!)) {
|
||||
modules.add(dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (modules.size === oldSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const stories = micromatch(
|
||||
Array.from(modules),
|
||||
main.stories.map((story) => `./${path.relative('..', story)}`)
|
||||
);
|
||||
if (stories.length) {
|
||||
for (const story of stories) {
|
||||
process.stdout.write(` --only-story-files ${story}`);
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(` --skip`);
|
||||
}
|
||||
});
|
@@ -1,6 +1,7 @@
|
||||
import { resolve } from 'node:path';
|
||||
import type { StorybookConfig } from '@storybook/vue3-vite';
|
||||
import { mergeConfig } from 'vite';
|
||||
import turbosnap from 'vite-plugin-turbosnap';
|
||||
const config = {
|
||||
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
|
||||
addons: [
|
||||
@@ -20,8 +21,13 @@ const config = {
|
||||
core: {
|
||||
disableTelemetry: true,
|
||||
},
|
||||
async viteFinal(config, options) {
|
||||
async viteFinal(config) {
|
||||
return mergeConfig(config, {
|
||||
plugins: [
|
||||
turbosnap({
|
||||
rootDir: config.root ?? process.cwd(),
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
target: [
|
||||
'chrome108',
|
||||
|
@@ -18,5 +18,10 @@
|
||||
"jsx": "react",
|
||||
"jsxFactory": "h"
|
||||
},
|
||||
"files": ["./generate.tsx", "./preload-locale.ts", "./preload-theme.ts"]
|
||||
"files": [
|
||||
"./changes.ts",
|
||||
"./generate.tsx",
|
||||
"./preload-locale.ts",
|
||||
"./preload-theme.ts"
|
||||
]
|
||||
}
|
||||
|
@@ -58,13 +58,13 @@
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"syuilo-password-strength": "0.0.1",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.150.1",
|
||||
"three": "0.151.3",
|
||||
"throttle-debounce": "5.0.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tsc-alias": "1.8.5",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"twemoji-parser": "14.0.0",
|
||||
"typescript": "5.0.2",
|
||||
"typescript": "5.0.3",
|
||||
"uuid": "9.0.0",
|
||||
"vanilla-tilt": "1.8.0",
|
||||
"vite": "4.2.1",
|
||||
@@ -91,13 +91,14 @@
|
||||
"@storybook/types": "7.0.2",
|
||||
"@storybook/vue3": "7.0.2",
|
||||
"@storybook/vue3-vite": "7.0.2",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/vue": "^6.6.1",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/vue": "7.0.0",
|
||||
"@types/escape-regexp": "0.0.1",
|
||||
"@types/estree": "^1.0.0",
|
||||
"@types/estree": "1.0.0",
|
||||
"@types/gulp": "4.0.10",
|
||||
"@types/gulp-rename": "2.0.1",
|
||||
"@types/matter-js": "0.18.2",
|
||||
"@types/micromatch": "3.1.1",
|
||||
"@types/node": "18.15.11",
|
||||
"@types/punycode": "2.1.0",
|
||||
"@types/sanitize-html": "2.9.0",
|
||||
@@ -108,32 +109,34 @@
|
||||
"@types/uuid": "9.0.1",
|
||||
"@types/websocket": "1.0.5",
|
||||
"@types/ws": "8.5.4",
|
||||
"@typescript-eslint/eslint-plugin": "5.57.0",
|
||||
"@typescript-eslint/parser": "5.57.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.57.1",
|
||||
"@typescript-eslint/parser": "5.57.1",
|
||||
"@vitest/coverage-c8": "^0.29.8",
|
||||
"@vue/runtime-core": "3.2.47",
|
||||
"astring": "^1.8.4",
|
||||
"chokidar-cli": "^3.0.0",
|
||||
"chromatic": "^6.17.2",
|
||||
"astring": "1.8.4",
|
||||
"chokidar-cli": "3.0.0",
|
||||
"chromatic": "6.17.3",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "12.9.0",
|
||||
"eslint": "8.37.0",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-vue": "9.10.0",
|
||||
"fast-glob": "^3.2.12",
|
||||
"fast-glob": "3.2.12",
|
||||
"happy-dom": "8.9.0",
|
||||
"msw": "^1.1.0",
|
||||
"msw-storybook-addon": "^1.8.0",
|
||||
"prettier": "^2.8.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"micromatch": "3.1.10",
|
||||
"msw": "1.2.1",
|
||||
"msw-storybook-addon": "1.8.0",
|
||||
"prettier": "2.8.7",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"start-server-and-test": "2.0.0",
|
||||
"storybook": "7.0.2",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"summaly": "github:misskey-dev/summaly",
|
||||
"vitest": "^0.29.8",
|
||||
"vitest-fetch-mock": "^0.2.2",
|
||||
"vue-eslint-parser": "9.1.0",
|
||||
"vite-plugin-turbosnap": "^1.0.1",
|
||||
"vitest": "0.29.8",
|
||||
"vitest-fetch-mock": "0.2.2",
|
||||
"vue-eslint-parser": "9.1.1",
|
||||
"vue-tsc": "1.2.0"
|
||||
}
|
||||
}
|
||||
|
32
packages/frontend/src/components/MkAccountMoved.vue
Normal file
32
packages/frontend/src/components/MkAccountMoved.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<i class="ti ti-plane-departure" style="margin-right: 8px;"></i>
|
||||
{{ i18n.ts.accountMoved }}
|
||||
<MkMention :class="$style.link" :username="acct" :host="host ?? localHost"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MkMention from './MkMention.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { host as localHost } from '@/config';
|
||||
|
||||
defineProps<{
|
||||
acct: string;
|
||||
host: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
padding: 16px;
|
||||
font-size: 90%;
|
||||
background: var(--infoWarnBg);
|
||||
color: var(--error);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
@@ -17,8 +17,8 @@
|
||||
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown">
|
||||
<template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template>
|
||||
<template #caption>
|
||||
<span v-if="okButtonDisabled && disabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })" />
|
||||
<span v-else-if="okButtonDisabled && disabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })" />
|
||||
<span v-if="okButtonDisabled && disabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })"/>
|
||||
<span v-else-if="okButtonDisabled && disabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })"/>
|
||||
</template>
|
||||
</MkInput>
|
||||
<MkSelect v-if="select" v-model="selectedValue" autofocus>
|
||||
@@ -32,11 +32,11 @@
|
||||
</template>
|
||||
</MkSelect>
|
||||
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
|
||||
<MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
|
||||
<MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
|
||||
<MkButton v-if="showOkButton" inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
|
||||
<MkButton v-if="showCancelButton || input || select" inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
|
||||
</div>
|
||||
<div v-if="actions" :class="$style.buttons">
|
||||
<MkButton v-for="action in actions" :key="action.text" inline :primary="action.primary" :danger="action.danger" @click="() => { action.callback(); modal?.close(); }">{{ action.text }}</MkButton>
|
||||
<MkButton v-for="action in actions" :key="action.text" inline rounded :primary="action.primary" :danger="action.danger" @click="() => { action.callback(); modal?.close(); }">{{ action.text }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkModal>
|
||||
|
@@ -11,15 +11,21 @@
|
||||
<div :class="$style.body" class="_shadow" @mousedown="onBodyMousedown" @keydown="onKeydown">
|
||||
<div :class="[$style.header, { [$style.mini]: mini }]" @contextmenu.prevent.stop="onContextmenu">
|
||||
<span :class="$style.headerLeft">
|
||||
<button v-for="button in buttonsLeft" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button>
|
||||
<template v-if="!minimized">
|
||||
<button v-for="button in buttonsLeft" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button>
|
||||
</template>
|
||||
</span>
|
||||
<span :class="$style.headerTitle" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown">
|
||||
<slot name="header"></slot>
|
||||
</span>
|
||||
<span :class="$style.headerRight">
|
||||
<button v-for="button in buttonsRight" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button>
|
||||
<template v-if="!minimized">
|
||||
<button v-for="button in buttonsRight" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button>
|
||||
</template>
|
||||
<button v-if="canResize && minimized" v-tooltip="i18n.ts.windowRestore" class="_button" :class="$style.headerButton" @click="unMinimize()"><i class="ti ti-maximize"></i></button>
|
||||
<button v-else-if="canResize && !maximized" v-tooltip="i18n.ts.windowMinimize" class="_button" :class="$style.headerButton" @click="minimize()"><i class="ti ti-minimize"></i></button>
|
||||
<button v-if="canResize && maximized" v-tooltip="i18n.ts.windowRestore" class="_button" :class="$style.headerButton" @click="unMaximize()"><i class="ti ti-picture-in-picture"></i></button>
|
||||
<button v-else-if="canResize && !maximized" v-tooltip="i18n.ts.windowMaximize" class="_button" :class="$style.headerButton" @click="maximize()"><i class="ti ti-rectangle"></i></button>
|
||||
<button v-else-if="canResize && !maximized && !minimized" v-tooltip="i18n.ts.windowMaximize" class="_button" :class="$style.headerButton" @click="maximize()"><i class="ti ti-rectangle"></i></button>
|
||||
<button v-if="closeButton" v-tooltip="i18n.ts.close" class="_button" :class="$style.headerButton" @click="close()"><i class="ti ti-x"></i></button>
|
||||
</span>
|
||||
</div>
|
||||
@@ -27,7 +33,7 @@
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="canResize">
|
||||
<template v-if="canResize && !minimized">
|
||||
<div :class="$style.handleTop" @mousedown.prevent="onTopHandleMousedown"></div>
|
||||
<div :class="$style.handleRight" @mousedown.prevent="onRightHandleMousedown"></div>
|
||||
<div :class="$style.handleBottom" @mousedown.prevent="onBottomHandleMousedown"></div>
|
||||
@@ -100,10 +106,11 @@ let rootEl = $shallowRef<HTMLElement | null>();
|
||||
let showing = $ref(true);
|
||||
let beforeClickedAt = 0;
|
||||
let maximized = $ref(false);
|
||||
let unMaximizedTop = '';
|
||||
let unMaximizedLeft = '';
|
||||
let unMaximizedWidth = '';
|
||||
let unMaximizedHeight = '';
|
||||
let minimized = $ref(false);
|
||||
let unResizedTop = '';
|
||||
let unResizedLeft = '';
|
||||
let unResizedWidth = '';
|
||||
let unResizedHeight = '';
|
||||
|
||||
function close() {
|
||||
showing = false;
|
||||
@@ -132,10 +139,10 @@ function top() {
|
||||
|
||||
function maximize() {
|
||||
maximized = true;
|
||||
unMaximizedTop = rootEl.style.top;
|
||||
unMaximizedLeft = rootEl.style.left;
|
||||
unMaximizedWidth = rootEl.style.width;
|
||||
unMaximizedHeight = rootEl.style.height;
|
||||
unResizedTop = rootEl.style.top;
|
||||
unResizedLeft = rootEl.style.left;
|
||||
unResizedWidth = rootEl.style.width;
|
||||
unResizedHeight = rootEl.style.height;
|
||||
rootEl.style.top = '0';
|
||||
rootEl.style.left = '0';
|
||||
rootEl.style.width = '100%';
|
||||
@@ -144,10 +151,35 @@ function maximize() {
|
||||
|
||||
function unMaximize() {
|
||||
maximized = false;
|
||||
rootEl.style.top = unMaximizedTop;
|
||||
rootEl.style.left = unMaximizedLeft;
|
||||
rootEl.style.width = unMaximizedWidth;
|
||||
rootEl.style.height = unMaximizedHeight;
|
||||
rootEl.style.top = unResizedTop;
|
||||
rootEl.style.left = unResizedLeft;
|
||||
rootEl.style.width = unResizedWidth;
|
||||
rootEl.style.height = unResizedHeight;
|
||||
}
|
||||
|
||||
function minimize() {
|
||||
minimized = true;
|
||||
unResizedWidth = rootEl.style.width;
|
||||
unResizedHeight = rootEl.style.height;
|
||||
rootEl.style.width = minWidth + 'px';
|
||||
rootEl.style.height = props.mini ? '32px' : '39px';
|
||||
}
|
||||
|
||||
function unMinimize() {
|
||||
const main = rootEl;
|
||||
if (main == null) return;
|
||||
|
||||
minimized = false;
|
||||
rootEl.style.width = unResizedWidth;
|
||||
rootEl.style.height = unResizedHeight;
|
||||
const browserWidth = window.innerWidth;
|
||||
const browserHeight = window.innerHeight;
|
||||
const windowWidth = main.offsetWidth;
|
||||
const windowHeight = main.offsetHeight;
|
||||
|
||||
const position = main.getBoundingClientRect();
|
||||
if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px';
|
||||
if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px';
|
||||
}
|
||||
|
||||
function onBodyMousedown() {
|
||||
@@ -155,7 +187,11 @@ function onBodyMousedown() {
|
||||
}
|
||||
|
||||
function onDblClick() {
|
||||
maximize();
|
||||
if (minimized) {
|
||||
unMinimize();
|
||||
} else {
|
||||
maximize();
|
||||
}
|
||||
}
|
||||
|
||||
function onHeaderMousedown(evt: MouseEvent) {
|
||||
@@ -187,7 +223,7 @@ function onHeaderMousedown(evt: MouseEvent) {
|
||||
|
||||
const clickX = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientX : evt.clientX;
|
||||
const clickY = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientY : evt.clientY;
|
||||
const moveBaseX = beforeMaximized ? parseInt(unMaximizedWidth, 10) / 2 : clickX - position.left; // TODO: parseIntやめる
|
||||
const moveBaseX = beforeMaximized ? parseInt(unResizedWidth, 10) / 2 : clickX - position.left; // TODO: parseIntやめる
|
||||
const moveBaseY = beforeMaximized ? 20 : clickY - position.top;
|
||||
const browserWidth = window.innerWidth;
|
||||
const browserHeight = window.innerHeight;
|
||||
|
@@ -22,7 +22,7 @@ export const Default = {
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkA v-bind="props">Text</MkA>',
|
||||
template: '<MkA v-bind="props">Misskey</MkA>',
|
||||
};
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
|
@@ -4,12 +4,16 @@
|
||||
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
|
||||
<div v-if="user.isCat" :class="[$style.ears, { [$style.mask]: useBlurEffect }]">
|
||||
<div :class="$style.earLeft">
|
||||
<div v-if="useBlurEffect" :class="$style.layer">
|
||||
<div v-if="false" :class="$style.layer">
|
||||
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
|
||||
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
|
||||
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.earRight">
|
||||
<div v-if="useBlurEffect" :class="$style.layer">
|
||||
<div v-if="false" :class="$style.layer">
|
||||
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
|
||||
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
|
||||
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,11 +199,21 @@ watch(() => props.user.avatarBlurhash, () => {
|
||||
|
||||
> .plot {
|
||||
contain: strict;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
clip-path: path('M0 0H1V1H0z');
|
||||
transform: scale(32767);
|
||||
transform-origin: 0 0;
|
||||
opacity: 0.5;
|
||||
|
||||
&:first-child {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
opacity: calc(1 / 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,6 +235,14 @@ watch(() => props.user.avatarBlurhash, () => {
|
||||
|
||||
> .plot {
|
||||
background-position: 20% 10%; /* ~= 37.5deg */
|
||||
|
||||
&:first-child {
|
||||
background-position-x: 21%;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
background-position-y: 11%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,7 +263,16 @@ watch(() => props.user.avatarBlurhash, () => {
|
||||
-38.5857864376%); /* 40 - 2 * sqrt(2) */
|
||||
|
||||
> .plot {
|
||||
position: absolute;
|
||||
background-position: 80% 10%; /* ~= 37.5deg */
|
||||
|
||||
&:first-child {
|
||||
background-position-x: 79%;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
background-position-y: 11%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { waitFor } from '@storybook/testing-library';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkPageHeader from './MkPageHeader.vue';
|
||||
export const Empty = {
|
||||
@@ -22,16 +23,16 @@ export const Empty = {
|
||||
template: '<MkPageHeader v-bind="props" />',
|
||||
};
|
||||
},
|
||||
async play() {
|
||||
const wait = new Promise((resolve) => setTimeout(resolve, 800));
|
||||
await waitFor(async () => await wait);
|
||||
},
|
||||
args: {
|
||||
static: true,
|
||||
tabs: [],
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
chromatic: {
|
||||
/* This component has animations that are implemented with JavaScript. So it's unstable to take a snapshot. */
|
||||
disableSnapshot: true,
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkPageHeader>;
|
||||
export const OneTab = {
|
||||
|
@@ -217,6 +217,7 @@ const patrons = [
|
||||
'氷月氷華里',
|
||||
'Ebise Lutica',
|
||||
'巣黒るい@リスケモ男の娘VTuber!',
|
||||
'ふぇいぽむ',
|
||||
];
|
||||
|
||||
let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure'));
|
||||
|
@@ -130,11 +130,6 @@ const menuDef = computed(() => [{
|
||||
}, {
|
||||
title: i18n.ts.otherSettings,
|
||||
items: [{
|
||||
icon: 'ti ti-package',
|
||||
text: i18n.ts.importAndExport,
|
||||
to: '/settings/import-export',
|
||||
active: currentPage?.route.name === 'import-export',
|
||||
}, {
|
||||
icon: 'ti ti-badges',
|
||||
text: i18n.ts.roles,
|
||||
to: '/settings/roles',
|
||||
@@ -164,6 +159,16 @@ const menuDef = computed(() => [{
|
||||
text: 'Webhook',
|
||||
to: '/settings/webhook',
|
||||
active: currentPage?.route.name === 'webhook',
|
||||
}, {
|
||||
icon: 'ti ti-package',
|
||||
text: i18n.ts.importAndExport,
|
||||
to: '/settings/import-export',
|
||||
active: currentPage?.route.name === 'import-export',
|
||||
}, {
|
||||
icon: 'ti ti-plane',
|
||||
text: i18n.ts.accountMigration,
|
||||
to: '/settings/migration',
|
||||
active: currentPage?.route.name === 'migration',
|
||||
}, {
|
||||
icon: 'ti ti-dots',
|
||||
text: i18n.ts.other,
|
||||
@@ -231,7 +236,7 @@ onUnmounted(() => {
|
||||
});
|
||||
|
||||
watch(router.currentRef, (to) => {
|
||||
if (to.route.name === "settings" && to.child?.route.name == null && !narrow) {
|
||||
if (to.route.name === 'settings' && to.child?.route.name == null && !narrow) {
|
||||
router.replace('/settings/profile');
|
||||
}
|
||||
});
|
||||
|
73
packages/frontend/src/pages/settings/migration.vue
Normal file
73
packages/frontend/src/pages/settings/migration.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="_gaps_m">
|
||||
<FormSection first>
|
||||
<template #label>{{ i18n.ts._accountMigration.moveTo }}</template>
|
||||
<MkInput v-model="moveToAccount" manual-save>
|
||||
<template #prefix><i class="ti ti-plane-departure"></i></template>
|
||||
<template #label>{{ i18n.ts._accountMigration.moveToLabel }}</template>
|
||||
</MkInput>
|
||||
</FormSection>
|
||||
<FormInfo warn>{{ i18n.ts._accountMigration.moveAccountDescription }}</FormInfo>
|
||||
|
||||
<FormSection>
|
||||
<template #label>{{ i18n.ts._accountMigration.moveFrom }}</template>
|
||||
<MkInput v-model="accountAlias" manual-save>
|
||||
<template #prefix><i class="ti ti-plane-arrival"></i></template>
|
||||
<template #label>{{ i18n.ts._accountMigration.moveFromLabel }}</template>
|
||||
</MkInput>
|
||||
</FormSection>
|
||||
<FormInfo warn>{{ i18n.ts._accountMigration.moveFromDescription }}</FormInfo>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import FormSection from '@/components/form/section.vue';
|
||||
import FormInfo from '@/components/MkInfo.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
|
||||
const moveToAccount = ref('');
|
||||
const accountAlias = ref('');
|
||||
|
||||
async function move(): Promise<void> {
|
||||
const account = moveToAccount.value;
|
||||
const confirm = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.t('migrationConfirm', { account: account.toString() }),
|
||||
});
|
||||
if (confirm.canceled) return;
|
||||
os.apiWithDialog('i/move', {
|
||||
moveToAccount: account,
|
||||
});
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
const account = accountAlias.value;
|
||||
os.apiWithDialog('i/known-as', {
|
||||
alsoKnownAs: account,
|
||||
});
|
||||
}
|
||||
|
||||
watch(accountAlias, async () => {
|
||||
await save();
|
||||
});
|
||||
|
||||
watch(moveToAccount, async () => {
|
||||
await move();
|
||||
});
|
||||
|
||||
definePageMetadata({
|
||||
title: i18n.ts.accountMigration,
|
||||
icon: 'ti ti-plane',
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.description {
|
||||
font-size: .85em;
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
@@ -7,6 +7,7 @@
|
||||
<!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> -->
|
||||
|
||||
<div class="profile _gaps">
|
||||
<MkAccountMoved v-if="user.movedToUri" :host="user.movedToUri.host" :acct="user.movedToUri.username"/>
|
||||
<MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!" class="warn"/>
|
||||
|
||||
<div :key="user.id" class="main _panel">
|
||||
@@ -117,6 +118,7 @@ import calcAge from 's-age';
|
||||
import * as misskey from 'misskey-js';
|
||||
import MkNote from '@/components/MkNote.vue';
|
||||
import MkFollowButton from '@/components/MkFollowButton.vue';
|
||||
import MkAccountMoved from '@/components/MkAccountMoved.vue';
|
||||
import MkRemoteCaution from '@/components/MkRemoteCaution.vue';
|
||||
import MkOmit from '@/components/MkOmit.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
|
@@ -161,6 +161,10 @@ export const routes = [{
|
||||
path: '/preferences-backups',
|
||||
name: 'preferences-backups',
|
||||
component: page(() => import('./pages/settings/preferences-backups.vue')),
|
||||
}, {
|
||||
path: '/migration',
|
||||
name: 'migration',
|
||||
component: page(() => import('./pages/settings/migration.vue'))
|
||||
}, {
|
||||
path: '/custom-css',
|
||||
name: 'general',
|
||||
|
@@ -127,6 +127,7 @@ hr {
|
||||
}
|
||||
|
||||
.ti {
|
||||
width: 1.28em;
|
||||
vertical-align: -12%;
|
||||
line-height: 1em;
|
||||
|
||||
|
@@ -1353,6 +1353,14 @@ export type Endpoints = {
|
||||
req: TODO;
|
||||
res: TODO;
|
||||
};
|
||||
'i/move': {
|
||||
req: TODO;
|
||||
res: TODO;
|
||||
};
|
||||
'i/known-as': {
|
||||
req: TODO;
|
||||
res: TODO;
|
||||
};
|
||||
'i/notifications': {
|
||||
req: {
|
||||
limit?: number;
|
||||
@@ -2688,6 +2696,8 @@ type UserLite = {
|
||||
onlineStatus: 'online' | 'active' | 'offline' | 'unknown';
|
||||
avatarUrl: string;
|
||||
avatarBlurhash: string;
|
||||
alsoKnownAs: string[];
|
||||
movedToUri: any;
|
||||
emojis: {
|
||||
name: string;
|
||||
url: string;
|
||||
@@ -2709,7 +2719,7 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
|
||||
//
|
||||
// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
|
||||
// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
|
||||
// src/api.types.ts:594:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
|
||||
// src/api.types.ts:596:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
|
||||
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
|
@@ -24,23 +24,23 @@
|
||||
"@swc/jest": "0.2.24",
|
||||
"@types/jest": "29.5.0",
|
||||
"@types/node": "18.15.11",
|
||||
"@typescript-eslint/eslint-plugin": "5.57.0",
|
||||
"@typescript-eslint/parser": "5.57.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.57.1",
|
||||
"@typescript-eslint/parser": "5.57.1",
|
||||
"eslint": "8.37.0",
|
||||
"jest": "^29.5.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"jest": "29.5.0",
|
||||
"jest-fetch-mock": "3.0.3",
|
||||
"jest-websocket-mock": "2.4.0",
|
||||
"mock-socket": "9.2.1",
|
||||
"tsd": "0.28.1",
|
||||
"typescript": "5.0.2"
|
||||
"typescript": "5.0.3"
|
||||
},
|
||||
"files": [
|
||||
"built"
|
||||
],
|
||||
"dependencies": {
|
||||
"@swc/cli": "0.1.62",
|
||||
"@swc/core": "1.3.42",
|
||||
"@swc/core": "1.3.46",
|
||||
"eventemitter3": "5.0.0",
|
||||
"reconnecting-websocket": "^4.4.0"
|
||||
"reconnecting-websocket": "4.4.0"
|
||||
}
|
||||
}
|
||||
|
@@ -362,6 +362,8 @@ export type Endpoints = {
|
||||
'i/get-word-muted-notes-count': { req: TODO; res: TODO; };
|
||||
'i/import-following': { req: TODO; res: TODO; };
|
||||
'i/import-user-lists': { req: TODO; res: TODO; };
|
||||
'i/move': { req: TODO; res: TODO; };
|
||||
'i/known-as': { req: TODO; res: TODO; };
|
||||
'i/notifications': { req: {
|
||||
limit?: number;
|
||||
sinceId?: Notification['id'];
|
||||
|
@@ -14,6 +14,8 @@ export type UserLite = {
|
||||
onlineStatus: 'online' | 'active' | 'offline' | 'unknown';
|
||||
avatarUrl: string;
|
||||
avatarBlurhash: string;
|
||||
alsoKnownAs: string[];
|
||||
movedToUri: any;
|
||||
emojis: {
|
||||
name: string;
|
||||
url: string;
|
||||
|
@@ -14,10 +14,10 @@
|
||||
"misskey-js": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/parser": "5.57.0",
|
||||
"@typescript-eslint/parser": "5.57.1",
|
||||
"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67",
|
||||
"eslint": "8.37.0",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"typescript": "5.0.2"
|
||||
"typescript": "5.0.3"
|
||||
}
|
||||
}
|
||||
|
@@ -59,7 +59,7 @@ export async function findClient() {
|
||||
type: 'window',
|
||||
});
|
||||
for (const c of clients) {
|
||||
if (c.url.indexOf('?zen') < 0) return c;
|
||||
if (!new URL(c.url).searchParams.has('zen')) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
1686
pnpm-lock.yaml
generated
1686
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user