Merge branch 'develop' into ssmucny-events
This commit is contained in:
commit
e97e620ffb
|
@ -133,16 +133,20 @@ id: 'aid'
|
|||
#clusterLimit: 1
|
||||
|
||||
# Job concurrency per worker
|
||||
# deliverJobConcurrency: 128
|
||||
# inboxJobConcurrency: 16
|
||||
#deliverJobConcurrency: 128
|
||||
#inboxJobConcurrency: 16
|
||||
#relashionshipJobConcurrency: 16
|
||||
# What's relashionshipJob?:
|
||||
# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations.
|
||||
|
||||
# Job rate limiter
|
||||
# deliverJobPerSec: 128
|
||||
# inboxJobPerSec: 16
|
||||
#deliverJobPerSec: 128
|
||||
#inboxJobPerSec: 16
|
||||
#relashionshipJobPerSec: 64
|
||||
|
||||
# Job attempts
|
||||
# deliverJobMaxAttempts: 12
|
||||
# inboxJobMaxAttempts: 8
|
||||
#deliverJobMaxAttempts: 12
|
||||
#inboxJobMaxAttempts: 8
|
||||
|
||||
# IP address family used for outgoing request (ipv4, ipv6 or dual)
|
||||
#outgoingAddressFamily: ipv4
|
||||
|
|
|
@ -17,7 +17,7 @@ jobs:
|
|||
submodules: true
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 7
|
||||
version: 8
|
||||
run_install: false
|
||||
- uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
|
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 7
|
||||
version: 8
|
||||
run_install: false
|
||||
- name: Use Node.js 18.x
|
||||
uses: actions/setup-node@v3.6.0
|
||||
|
|
|
@ -35,7 +35,7 @@ jobs:
|
|||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 7
|
||||
version: 8
|
||||
run_install: false
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3.6.0
|
||||
|
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 7
|
||||
version: 8
|
||||
run_install: false
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3.6.0
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
name: Test (production install and build)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
NODE_ENV: production
|
||||
|
||||
jobs:
|
||||
production:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3.3.0
|
||||
with:
|
||||
submodules: true
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3.6.0
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'pnpm'
|
||||
- run: corepack enable
|
||||
- run: pnpm i --frozen-lockfile
|
||||
- name: Check pnpm-lock.yaml
|
||||
run: git diff --exit-code pnpm-lock.yaml
|
||||
- name: Copy Configure
|
||||
run: cp .github/misskey/test.yml .config/default.yml
|
||||
- name: Build
|
||||
run: pnpm build
|
|
@ -6,5 +6,6 @@
|
|||
"files.associations": {
|
||||
"*.test.ts": "typescript"
|
||||
},
|
||||
"jest.jestCommandLine": "pnpm run jest",
|
||||
"jest.autoRun": "off"
|
||||
}
|
|
@ -24,9 +24,12 @@
|
|||
(自分自身に対してもメモを追加できます。)
|
||||
* ユーザーメニューから追加できます。
|
||||
(デスクトップ表示ではusernameの右側のボタンからも追加可能)
|
||||
- アカウントの引っ越し(フォロワー引き継ぎ)に対応
|
||||
* 一度引っ越したアカウントは利用に制限がかかります
|
||||
- ロールタイムラインをロールごとに表示するかどうかの選択できるようになりました。
|
||||
* デフォルトがオフになるので、ロールタイムラインを表示する場合はオンにしてください。
|
||||
- カスタム絵文字のライセンスを複数でセットできるようになりました。
|
||||
- 管理者が予約ユーザー名を設定できるようになりました。
|
||||
|
||||
### Client
|
||||
- 通知の表示をカスタマイズできるように
|
||||
|
@ -37,12 +40,16 @@
|
|||
- 1枚だけのメディアリストの画像のアスペクト比を画像に応じて縦長にするように
|
||||
- Fix: リアクションをホバーした時のユーザーリストで猫耳が切れてしまっていた問題を修正
|
||||
- 新しい実績を追加
|
||||
- Renoteしたユーザーの一覧を見れるように
|
||||
|
||||
### Server
|
||||
- 環境変数MISSKEY_CONFIG_YMLで設定ファイルをdefault.ymlから変更可能に
|
||||
- Fix: エクスポートデータの拡張子がunknownになる問題を修正
|
||||
- Fix: Content-Dispositionのパースでエラーが発生した場合にダウンロードが完了しない問題を修正
|
||||
- Fix: API: i/update avatarIdとbannerIdにnullを渡した時、画像がリセットされない問題を修正
|
||||
- Fix: 1:1ではない画像のリアクション通知バッジが左や上に寄ってしまっていたのを中央に来るように修正
|
||||
- Fix: .wav, .flacが再生できない問題を修正(新しくアップロードされたファイルのみ修正が適用されます)
|
||||
- Fix: メモリの使用量を`used - buffers - cached`ではなく`total - available`で求めるように(環境によって正常に計測できていなかったため)
|
||||
|
||||
## 13.11.3
|
||||
|
||||
|
|
|
@ -165,6 +165,11 @@ pnpm jest -- foo.ts
|
|||
### e2e tests
|
||||
TODO
|
||||
|
||||
## Environment Variable
|
||||
|
||||
- `MISSKEY_CONFIG_YML`: Specify the file path of config.yml instead of default.yml (e.g. `2nd.yml`).
|
||||
- `MISSKEY_WEBFINGER_USE_HTTP`: If it's set true, WebFinger requests will be http instead of https, useful for testing federation between servers in localhost. NEVER USE IN PRODUCTION.
|
||||
|
||||
## Continuous integration
|
||||
Misskey uses GitHub Actions for executing automated tests.
|
||||
Configuration files are located in [`/.github/workflows`](/.github/workflows).
|
||||
|
|
|
@ -703,6 +703,8 @@ contact: "連絡先"
|
|||
useSystemFont: "システムのデフォルトのフォントを使う"
|
||||
clips: "クリップ"
|
||||
experimentalFeatures: "実験的機能"
|
||||
experimental: "実験的"
|
||||
thisIsExperimentalFeature: "これは実験的な機能です。仕様が変更されたり、正常に動作しなかったりする可能性があります。"
|
||||
developer: "開発者"
|
||||
makeExplorable: "アカウントを見つけやすくする"
|
||||
makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らなくなります。"
|
||||
|
@ -943,6 +945,7 @@ didYouLikeMisskey: "Misskeyを気に入っていただけましたか?"
|
|||
pleaseDonate: "Misskeyは{host}が使用している無料のソフトウェアです。これからも開発を続けられるように、ぜひ寄付をお願いします!"
|
||||
roles: "ロール"
|
||||
role: "ロール"
|
||||
noRole: "ロールはありません"
|
||||
normalUser: "一般ユーザー"
|
||||
undefined: "未定義"
|
||||
assign: "アサイン"
|
||||
|
@ -1002,14 +1005,18 @@ noteIdOrUrl: "ノートIDまたはURL"
|
|||
video: "動画"
|
||||
videos: "動画"
|
||||
dataSaver: "データセーバー"
|
||||
accountMigration: "アカウントの引っ越し"
|
||||
accountMoved: "このユーザーは新しいアカウントに引っ越しました:"
|
||||
accountMigration: "アカウントの移行"
|
||||
accountMoved: "このユーザーは新しいアカウントに移行しました:"
|
||||
accountMovedShort: "このアカウントは移行されています"
|
||||
operationForbidden: "この操作はできません"
|
||||
forceShowAds: "常に広告を表示する"
|
||||
event: "イベント"
|
||||
events: "イベント"
|
||||
reverseChronological: "倒叙"
|
||||
addMemo: "メモを追加"
|
||||
editMemo: "メモを編集"
|
||||
reactionsList: "リアクション一覧"
|
||||
renotesList: "Renote一覧"
|
||||
notificationDisplay: "通知の表示"
|
||||
leftTop: "左上"
|
||||
rightTop: "右上"
|
||||
|
@ -1023,6 +1030,8 @@ serverRules: "サーバールール"
|
|||
pleaseConfirmBelowBeforeSignup: "このサーバーに登録する前に、以下を確認してください。"
|
||||
pleaseAgreeAllToContinue: "続けるには、全ての「同意する」にチェックが入っている必要があります。"
|
||||
continue: "続ける"
|
||||
preservedUsernames: "予約ユーザー名"
|
||||
preservedUsernamesDescription: "予約するユーザー名を改行で列挙します。ここで指定されたユーザー名はアカウント作成時に使えなくなりますが、管理者によるアカウント作成時はこの制限を受けません。また、既に存在するアカウントも影響を受けません。"
|
||||
|
||||
_serverRules:
|
||||
description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。"
|
||||
|
@ -1037,13 +1046,20 @@ _event:
|
|||
detailValue: "値"
|
||||
|
||||
_accountMigration:
|
||||
moveTo: "このアカウントを新しいアカウントに引っ越す"
|
||||
moveToLabel: "引っ越し先のアカウント:"
|
||||
moveAccountDescription: "この操作は取り消せません。まずは引っ越し先のアカウントでこのアカウントに対しエイリアスを作成したことを確認してください。エイリアス作成後、引っ越し先のアカウントをこのように入力してください:@person@instance.com"
|
||||
moveFrom: "別のアカウントからこのアカウントに引っ越す"
|
||||
moveFromLabel: "引っ越し元のアカウント:"
|
||||
moveFromDescription: "別のアカウントからこのアカウントにフォロワーを引き継いで引っ越したい場合、ここでエイリアスを作成しておく必要があります。必ず引っ越しを実行する前に作成してください!引っ越し元のアカウントをこのように入力してください:@person@instance.com"
|
||||
migrationConfirm: "本当にこのアカウントを {account} に引っ越しますか?一度引っ越しを行うと取り消せず、二度とこのアカウントを元の状態で使用できなくなります。\nまた、引っ越し先のアカウントでエイリアスを作成したことを確認してください。"
|
||||
moveFrom: "別のアカウントからこのアカウントに移行"
|
||||
moveFromSub: "別のアカウントへエイリアスを作成"
|
||||
moveFromLabel: "移行元のアカウント #{n}"
|
||||
moveFromDescription: "別のアカウントからこのアカウントに移行したい場合、ここでエイリアスを作成しておく必要があります。\n移行元のアカウントをこのように入力してください: @username@server.example.com\n削除するには、入力欄を空にして保存します(非推奨)。"
|
||||
moveTo: "このアカウントを新しいアカウントへ移行"
|
||||
moveToLabel: "移行先のアカウント:"
|
||||
moveCannotBeUndone: "アカウントを移行すると、取り消すことはできません。"
|
||||
moveAccountDescription: "新しいアカウントへ移行します。\n ・フォロワーが新しいアカウントを自動でフォローします\n ・このアカウントからのフォローは全て解除されます\n ・このアカウントではノートの作成などができなくなります\n\nフォロワーの移行は自動ですが、フォローの移行は手動で行う必要があります。移行前にこのアカウントでフォローエクスポートし、移行後すぐに移行先アカウントでインポートを行なってください。\nリスト・ミュート・ブロックについても同様ですので、手動で移行する必要があります。\n\n(この説明はこのサーバー(Misskey v13.12.0以降)の仕様です。Mastodonなどの他のActivityPubソフトウェアでは挙動が異なる場合があります。)"
|
||||
moveAccountHowTo: "アカウントの移行には、まずは移行先のアカウントでこのアカウントに対しエイリアスを作成します。\nエイリアス作成後、移行先のアカウントを次のように入力してください: @username@server.example.com"
|
||||
startMigration: "移行する"
|
||||
migrationConfirm: "本当にこのアカウントを {account} に移行しますか?一度移行すると取り消せず、二度とこのアカウントを元の状態で使用できなくなります。"
|
||||
movedAndCannotBeUndone: "\nアカウントは移行されています。\n移行を取り消すことはできません。"
|
||||
postMigrationNote: "このアカウントからのフォロー解除は移行操作から24時間後に実行されます。\nこのアカウントのフォロー・フォロワー数は0になっています。フォロワーの解除はされないため、あなたのフォロワーはこのアカウントのフォロワー向け投稿を引き続き閲覧できます。"
|
||||
movedTo: "移行先のアカウント:"
|
||||
|
||||
_achievements:
|
||||
earnedAt: "獲得日時"
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
export class MovedAt1682190963894 {
|
||||
name = 'MovedAt1682190963894'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "movedAt" TIMESTAMP WITH TIME ZONE`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user"."movedAt" IS 'When the user moved to another account'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`COMMENT ON COLUMN "user"."movedAt" IS 'When the user moved to another account'`);
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "movedAt"`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
export class PreservedUsernames1682754135458 {
|
||||
name = 'PreservedUsernames1682754135458'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "preservedUsernames" character varying(1024) array NOT NULL DEFAULT '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "preservedUsernames"`);
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import * as fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import * as yaml from 'js-yaml';
|
||||
|
||||
/**
|
||||
|
@ -84,8 +84,10 @@ export type Source = {
|
|||
|
||||
deliverJobConcurrency?: number;
|
||||
inboxJobConcurrency?: number;
|
||||
relashionshipJobConcurrency?: number;
|
||||
deliverJobPerSec?: number;
|
||||
inboxJobPerSec?: number;
|
||||
relashionshipJobPerSec?: number;
|
||||
deliverJobMaxAttempts?: number;
|
||||
inboxJobMaxAttempts?: number;
|
||||
|
||||
|
@ -132,10 +134,11 @@ const dir = `${_dirname}/../../../.config`;
|
|||
/**
|
||||
* Path of configuration file
|
||||
*/
|
||||
const path = process.env.NODE_ENV === 'test'
|
||||
? `${dir}/test.yml`
|
||||
: `${dir}/default.yml`;
|
||||
|
||||
const path = process.env.MISSKEY_CONFIG_YML
|
||||
? resolve(dir, process.env.MISSKEY_CONFIG_YML)
|
||||
: process.env.NODE_ENV === 'test'
|
||||
? resolve(dir, 'test.yml')
|
||||
: resolve(dir, 'default.yml');
|
||||
export function loadConfig() {
|
||||
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8'));
|
||||
const clientManifestExists = fs.existsSync(_dirname + '/../../../built/_vite_/manifest.json');
|
||||
|
|
|
@ -56,6 +56,11 @@ export const FILE_TYPE_BROWSERSAFE = [
|
|||
'audio/webm',
|
||||
|
||||
'audio/aac',
|
||||
|
||||
// see https://github.com/misskey-dev/misskey/pull/10686
|
||||
'audio/flac',
|
||||
'audio/wav',
|
||||
// backward compatibility
|
||||
'audio/x-flac',
|
||||
'audio/vnd.wave',
|
||||
];
|
||||
|
|
|
@ -1,55 +1,90 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { IsNull, In, MoreThan, Not } 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 type { Config } from '@/config.js';
|
||||
import type { LocalUser, RemoteUser } from '@/models/entities/User.js';
|
||||
import type { BlockingsRepository, FollowingsRepository, InstancesRepository, Muting, MutingsRepository, UserListJoiningsRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { RelationshipJobData, ThinUser } from '@/queue/types.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.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';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ProxyAccountService } from '@/core/ProxyAccountService.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import InstanceChart from '@/core/chart/charts/instance.js';
|
||||
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
|
||||
|
||||
@Injectable()
|
||||
export class AccountMoveService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private blockingsRepository: BlockingsRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
@Inject(DI.userListJoiningsRepository)
|
||||
private userListJoiningsRepository: UserListJoiningsRepository,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private apPersonService: ApPersonService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private accountUpdateService: AccountUpdateService,
|
||||
private proxyAccountService: ProxyAccountService,
|
||||
private perUserFollowingChart: PerUserFollowingChart,
|
||||
private federatedInstanceService: FederatedInstanceService,
|
||||
private instanceChart: InstanceChart,
|
||||
private metaService: MetaService,
|
||||
private relayService: RelayService,
|
||||
private cacheService: CacheService,
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a local account to a remote account.
|
||||
* Move a local account to a new 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');
|
||||
public async moveFromLocal(src: LocalUser, dst: LocalUser | RemoteUser): Promise<unknown> {
|
||||
const srcUri = this.userEntityService.getUserUri(src);
|
||||
const dstUri = this.userEntityService.getUserUri(dst);
|
||||
|
||||
// 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;
|
||||
const update = {} as Partial<LocalUser>;
|
||||
update.alsoKnownAs = src.alsoKnownAs?.includes(dstUri) ? src.alsoKnownAs : src.alsoKnownAs?.concat([dstUri]) ?? [dstUri];
|
||||
update.movedToUri = dstUri;
|
||||
update.movedAt = new Date();
|
||||
await this.usersRepository.update(src.id, update);
|
||||
Object.assign(src, update);
|
||||
|
||||
// Update cache
|
||||
this.cacheService.uriPersonCache.set(srcUri, src);
|
||||
|
||||
const srcPerson = await this.apRendererService.renderPerson(src);
|
||||
const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src));
|
||||
|
@ -64,51 +99,249 @@ export class AccountMoveService {
|
|||
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
|
||||
},
|
||||
// Unfollow after 24 hours
|
||||
const followings = await this.followingsRepository.findBy({
|
||||
followerId: src.id,
|
||||
});
|
||||
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 */
|
||||
}
|
||||
}
|
||||
this.queueService.createDelayedUnfollowJob(followings.map(following => ({
|
||||
from: { id: src.id },
|
||||
to: { id: following.followeeId },
|
||||
})), process.env.NODE_ENV === 'test' ? 10000 : 1000 * 60 * 60 * 24);
|
||||
|
||||
await this.postMoveProcess(src, dst);
|
||||
|
||||
return iObj;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async postMoveProcess(src: User, dst: User): Promise<void> {
|
||||
// Copy blockings and mutings, and update lists
|
||||
try {
|
||||
await Promise.all([
|
||||
this.copyBlocking(src, dst),
|
||||
this.copyMutings(src, dst),
|
||||
this.updateLists(src, dst),
|
||||
]);
|
||||
} catch {
|
||||
/* skip if any error happens */
|
||||
}
|
||||
|
||||
// follow the new account
|
||||
const proxy = await this.proxyAccountService.fetch();
|
||||
const followings = await this.followingsRepository.findBy({
|
||||
followeeId: src.id,
|
||||
followerHost: IsNull(), // follower is local
|
||||
followerId: proxy ? Not(proxy.id) : undefined,
|
||||
});
|
||||
const followJobs = followings.map(following => ({
|
||||
from: { id: following.followerId },
|
||||
to: { id: dst.id },
|
||||
})) as RelationshipJobData[];
|
||||
|
||||
// Decrease following count instead of unfollowing.
|
||||
try {
|
||||
await this.adjustFollowingCounts(followJobs.map(job => job.from.id), src);
|
||||
} catch {
|
||||
/* skip if any error happens */
|
||||
}
|
||||
|
||||
// Should be queued because this can cause a number of follow per one move.
|
||||
this.queueService.createFollowJob(followJobs);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async copyBlocking(src: ThinUser, dst: ThinUser): Promise<void> {
|
||||
// Followers shouldn't overlap with blockers, but the destination account, different from the blockee (i.e., old account), may have followed the local user before moving.
|
||||
// So block the destination account here.
|
||||
const srcBlockings = await this.blockingsRepository.findBy({ blockeeId: src.id });
|
||||
const dstBlockings = await this.blockingsRepository.findBy({ blockeeId: dst.id });
|
||||
const blockerIds = dstBlockings.map(blocking => blocking.blockerId);
|
||||
// reblock the destination account
|
||||
const blockJobs: RelationshipJobData[] = [];
|
||||
for (const blocking of srcBlockings) {
|
||||
if (blockerIds.includes(blocking.blockerId)) continue; // skip if already blocked
|
||||
blockJobs.push({ from: { id: blocking.blockerId }, to: { id: dst.id } });
|
||||
}
|
||||
// no need to unblock the old account because it may be still functional
|
||||
this.queueService.createBlockJob(blockJobs);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async copyMutings(src: ThinUser, dst: ThinUser): Promise<void> {
|
||||
// Insert new mutings with the same values except mutee
|
||||
const oldMutings = await this.mutingsRepository.findBy([
|
||||
{ muteeId: src.id, expiresAt: IsNull() },
|
||||
{ muteeId: src.id, expiresAt: MoreThan(new Date()) },
|
||||
]);
|
||||
if (oldMutings.length === 0) return;
|
||||
|
||||
// Check if the destination account is already indefinitely muted by the muter
|
||||
const existingMutingsMuterUserIds = await this.mutingsRepository.findBy(
|
||||
{ muteeId: dst.id, expiresAt: IsNull() },
|
||||
).then(mutings => mutings.map(muting => muting.muterId));
|
||||
|
||||
const newMutings: Map<string, { muterId: string; muteeId: string; createdAt: Date; expiresAt: Date | null; }> = new Map();
|
||||
|
||||
// 重複しないようにIDを生成
|
||||
const genId = (): string => {
|
||||
let id: string;
|
||||
do {
|
||||
id = this.idService.genId();
|
||||
} while (newMutings.has(id));
|
||||
return id;
|
||||
};
|
||||
for (const muting of oldMutings) {
|
||||
if (existingMutingsMuterUserIds.includes(muting.muterId)) continue; // skip if already muted indefinitely
|
||||
newMutings.set(genId(), {
|
||||
...muting,
|
||||
createdAt: new Date(),
|
||||
muteeId: dst.id,
|
||||
});
|
||||
}
|
||||
|
||||
const arrayToInsert = Array.from(newMutings.entries()).map(entry => ({ ...entry[1], id: entry[0] }));
|
||||
await this.mutingsRepository.insert(arrayToInsert);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an alias of an old remote account.
|
||||
* Update lists while moving accounts.
|
||||
* - No removal of the old account from the lists
|
||||
* - Users number limit is not checked
|
||||
*
|
||||
* The user's new profile will be published to the followers.
|
||||
* @param src ThinUser (old account)
|
||||
* @param dst User (new account)
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
@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,
|
||||
public async updateLists(src: ThinUser, dst: User): Promise<void> {
|
||||
// Return if there is no list to be updated.
|
||||
const oldJoinings = await this.userListJoiningsRepository.find({
|
||||
where: {
|
||||
userId: src.id,
|
||||
},
|
||||
});
|
||||
this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj);
|
||||
if (oldJoinings.length === 0) return;
|
||||
|
||||
if (me.isLocked === false) {
|
||||
await this.userFollowingService.acceptAllFollowRequests(me);
|
||||
const existingUserListIds = await this.userListJoiningsRepository.find({
|
||||
where: {
|
||||
userId: dst.id,
|
||||
},
|
||||
}).then(joinings => joinings.map(joining => joining.userListId));
|
||||
|
||||
const newJoinings: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map();
|
||||
|
||||
// 重複しないようにIDを生成
|
||||
const genId = (): string => {
|
||||
let id: string;
|
||||
do {
|
||||
id = this.idService.genId();
|
||||
} while (newJoinings.has(id));
|
||||
return id;
|
||||
};
|
||||
for (const joining of oldJoinings) {
|
||||
if (existingUserListIds.includes(joining.userListId)) continue; // skip if dst exists in this user's list
|
||||
newJoinings.set(genId(), {
|
||||
createdAt: new Date(),
|
||||
userId: dst.id,
|
||||
userListId: joining.userListId,
|
||||
});
|
||||
}
|
||||
|
||||
this.accountUpdateService.publishToFollowers(me.id);
|
||||
const arrayToInsert = Array.from(newJoinings.entries()).map(entry => ({ ...entry[1], id: entry[0] }));
|
||||
await this.userListJoiningsRepository.insert(arrayToInsert);
|
||||
|
||||
return iObj;
|
||||
// Have the proxy account follow the new account in the same way as UserListService.push
|
||||
if (this.userEntityService.isRemoteUser(dst)) {
|
||||
const proxy = await this.proxyAccountService.fetch();
|
||||
if (proxy) {
|
||||
this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: dst.id } }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async adjustFollowingCounts(localFollowerIds: string[], oldAccount: User): Promise<void> {
|
||||
if (localFollowerIds.length === 0) return;
|
||||
|
||||
// Set the old account's following and followers counts to 0.
|
||||
await this.usersRepository.update({ id: oldAccount.id }, { followersCount: 0, followingCount: 0 });
|
||||
|
||||
// Decrease following counts of local followers by 1.
|
||||
await this.usersRepository.decrement({ id: In(localFollowerIds) }, 'followingCount', 1);
|
||||
|
||||
// Decrease follower counts of local followees by 1.
|
||||
const oldFollowings = await this.followingsRepository.findBy({ followerId: oldAccount.id });
|
||||
if (oldFollowings.length > 0) {
|
||||
await this.usersRepository.decrement({ id: In(oldFollowings.map(following => following.followeeId)) }, 'followersCount', 1);
|
||||
}
|
||||
|
||||
// Update instance stats by decreasing remote followers count by the number of local followers who were following the old account.
|
||||
if (this.userEntityService.isRemoteUser(oldAccount)) {
|
||||
this.federatedInstanceService.fetch(oldAccount.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
|
||||
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// FIXME: expensive?
|
||||
for (const followerId of localFollowerIds) {
|
||||
this.perUserFollowingChart.update({ id: followerId, host: null }, oldAccount, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* dstユーザーのalsoKnownAsをfetchPersonしていき、本当にmovedToUrlをdstに指定するユーザーが存在するのかを調べる
|
||||
*
|
||||
* @param dst movedToUrlを指定するユーザー
|
||||
* @param check
|
||||
* @param instant checkがtrueであるユーザーが最初に見つかったら即座にreturnするかどうか
|
||||
* @returns Promise<LocalUser | RemoteUser | null>
|
||||
*/
|
||||
@bindThis
|
||||
public async validateAlsoKnownAs(
|
||||
dst: LocalUser | RemoteUser,
|
||||
check: (oldUser: LocalUser | RemoteUser | null, newUser: LocalUser | RemoteUser) => boolean | Promise<boolean> = () => true,
|
||||
instant = false,
|
||||
): Promise<LocalUser | RemoteUser | null> {
|
||||
let resultUser: LocalUser | RemoteUser | null = null;
|
||||
|
||||
if (this.userEntityService.isRemoteUser(dst)) {
|
||||
if ((new Date()).getTime() - (dst.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) {
|
||||
await this.apPersonService.updatePerson(dst.uri);
|
||||
}
|
||||
dst = await this.apPersonService.fetchPerson(dst.uri) ?? dst;
|
||||
}
|
||||
|
||||
if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) return null;
|
||||
|
||||
const dstUri = this.userEntityService.getUserUri(dst);
|
||||
|
||||
for (const srcUri of dst.alsoKnownAs) {
|
||||
try {
|
||||
let src = await this.apPersonService.fetchPerson(srcUri);
|
||||
if (!src) continue; // oldAccountを探してもこのサーバーに存在しない場合はフォロー関係もないということなのでスルー
|
||||
|
||||
if (this.userEntityService.isRemoteUser(dst)) {
|
||||
if ((new Date()).getTime() - (src.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) {
|
||||
await this.apPersonService.updatePerson(srcUri);
|
||||
}
|
||||
|
||||
src = await this.apPersonService.fetchPerson(srcUri) ?? src;
|
||||
}
|
||||
|
||||
if (src.movedToUri === dstUri) {
|
||||
if (await check(resultUser, src)) {
|
||||
resultUser = src;
|
||||
}
|
||||
if (instant && resultUser) return resultUser;
|
||||
}
|
||||
} catch {
|
||||
/* skip if any error happens */
|
||||
}
|
||||
}
|
||||
|
||||
return resultUser;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import * as stream from 'node:stream';
|
|||
import * as util from 'node:util';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { FSWatcher } from 'chokidar';
|
||||
import { fileTypeFromFile } from 'file-type';
|
||||
import * as fileType from 'file-type';
|
||||
import FFmpeg from 'fluent-ffmpeg';
|
||||
import isSvg from 'is-svg';
|
||||
import probeImageSize from 'probe-image-size';
|
||||
|
@ -301,6 +301,19 @@ export class FileInfoService {
|
|||
return fs.promises.access(path).then(() => true, () => false);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public fixMime(mime: string | fileType.MimeType): string {
|
||||
// see https://github.com/misskey-dev/misskey/pull/10686
|
||||
if (mime === "audio/x-flac") {
|
||||
return "audio/flac";
|
||||
}
|
||||
if (mime === "audio/vnd.wave") {
|
||||
return "audio/wav";
|
||||
}
|
||||
|
||||
return mime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect MIME Type and extension
|
||||
*/
|
||||
|
@ -308,14 +321,14 @@ export class FileInfoService {
|
|||
public async detectType(path: string): Promise<{
|
||||
mime: string;
|
||||
ext: string | null;
|
||||
}> {
|
||||
}> {
|
||||
// Check 0 byte
|
||||
const fileSize = await this.getFileSize(path);
|
||||
if (fileSize === 0) {
|
||||
return TYPE_OCTET_STREAM;
|
||||
}
|
||||
|
||||
const type = await fileTypeFromFile(path);
|
||||
const type = await fileType.fileTypeFromFile(path);
|
||||
|
||||
if (type) {
|
||||
// XMLはSVGかもしれない
|
||||
|
@ -324,7 +337,7 @@ export class FileInfoService {
|
|||
}
|
||||
|
||||
return {
|
||||
mime: type.mime,
|
||||
mime: this.fixMime(type.mime),
|
||||
ext: type.ext,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -78,7 +78,7 @@ const $db: Provider = {
|
|||
|
||||
const $relationship: Provider = {
|
||||
provide: 'queue:relationship',
|
||||
useFactory: (config: Config) => q(config, 'relationship'),
|
||||
useFactory: (config: Config) => q(config, 'relationship', config.relashionshipJobPerSec ?? 64),
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
||||
|
|
|
@ -258,6 +258,12 @@ export class QueueService {
|
|||
return this.relationshipQueue.addBulk(jobs);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createDelayedUnfollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string }[], delay: number) {
|
||||
const jobs = followings.map(rel => this.generateRelationshipJobData('unfollow', rel, { delay }));
|
||||
return this.relationshipQueue.addBulk(jobs);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createBlockJob(blockings: { from: ThinUser, to: ThinUser, silent?: boolean }[]) {
|
||||
const jobs = blockings.map(rel => this.generateRelationshipJobData('block', rel));
|
||||
|
@ -271,7 +277,7 @@ export class QueueService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData): {
|
||||
private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData, opts: Bull.JobOptions = {}): {
|
||||
name: string,
|
||||
data: RelationshipJobData,
|
||||
opts: Bull.JobOptions,
|
||||
|
@ -287,6 +293,7 @@ export class QueueService {
|
|||
opts: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
...opts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import chalk from 'chalk';
|
|||
import { IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UsersRepository } from '@/models/index.js';
|
||||
import type { RemoteUser, User } from '@/models/entities/User.js';
|
||||
import type { LocalUser, RemoteUser } from '@/models/entities/User.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
|
@ -33,7 +33,7 @@ export class RemoteUserResolveService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async resolveUser(username: string, host: string | null): Promise<User> {
|
||||
public async resolveUser(username: string, host: string | null): Promise<LocalUser | RemoteUser> {
|
||||
const usernameLower = username.toLowerCase();
|
||||
|
||||
if (host == null) {
|
||||
|
@ -44,7 +44,7 @@ export class RemoteUserResolveService {
|
|||
} else {
|
||||
return u;
|
||||
}
|
||||
});
|
||||
}) as LocalUser;
|
||||
}
|
||||
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
@ -57,7 +57,7 @@ export class RemoteUserResolveService {
|
|||
} else {
|
||||
return u;
|
||||
}
|
||||
});
|
||||
}) as LocalUser;
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ usernameLower, host }) as RemoteUser | null;
|
||||
|
@ -109,7 +109,7 @@ export class RemoteUserResolveService {
|
|||
if (u == null) {
|
||||
throw new Error('user not found');
|
||||
} else {
|
||||
return u;
|
||||
return u as LocalUser | RemoteUser;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -13,8 +13,9 @@ import { UsedUsername } from '@/models/entities/UsedUsername.js';
|
|||
import generateUserToken from '@/misc/generate-native-user-token.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import UsersChart from './chart/charts/users.js';
|
||||
import { UtilityService } from './UtilityService.js';
|
||||
import UsersChart from '@/core/chart/charts/users.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
|
||||
@Injectable()
|
||||
export class SignupService {
|
||||
|
@ -34,6 +35,7 @@ export class SignupService {
|
|||
private utilityService: UtilityService,
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private metaService: MetaService,
|
||||
private usersChart: UsersChart,
|
||||
) {
|
||||
}
|
||||
|
@ -44,6 +46,7 @@ export class SignupService {
|
|||
password?: string | null;
|
||||
passwordHash?: UserProfile['password'] | null;
|
||||
host?: string | null;
|
||||
ignorePreservedUsernames?: boolean;
|
||||
}) {
|
||||
const { username, password, passwordHash, host } = opts;
|
||||
let hash = passwordHash;
|
||||
|
@ -77,6 +80,14 @@ export class SignupService {
|
|||
throw new Error('USED_USERNAME');
|
||||
}
|
||||
|
||||
if (!opts.ignorePreservedUsernames) {
|
||||
const instance = await this.metaService.fetch(true);
|
||||
const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase());
|
||||
if (isPreserved) {
|
||||
throw new Error('USED_USERNAME');
|
||||
}
|
||||
}
|
||||
|
||||
const keyPair = await new Promise<string[]>((res, rej) =>
|
||||
generateKeyPair('rsa', {
|
||||
modulusLength: 4096,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Inject, Injectable, OnModuleInit, forwardRef } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
|
||||
import type { LocalUser, PartialLocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
|
||||
|
@ -22,6 +22,8 @@ import { MetaService } from '@/core/MetaService.js';
|
|||
import { CacheService } from '@/core/CacheService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import Logger from '../logger.js';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
|
||||
const logger = new Logger('following/create');
|
||||
|
||||
|
@ -73,6 +75,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
private federatedInstanceService: FederatedInstanceService,
|
||||
private webhookService: WebhookService,
|
||||
private apRendererService: ApRendererService,
|
||||
private accountMoveService: AccountMoveService,
|
||||
private perUserFollowingChart: PerUserFollowingChart,
|
||||
private instanceChart: InstanceChart,
|
||||
) {
|
||||
|
@ -87,7 +90,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
const [follower, followee] = await Promise.all([
|
||||
this.usersRepository.findOneByOrFail({ id: _follower.id }),
|
||||
this.usersRepository.findOneByOrFail({ id: _followee.id }),
|
||||
]);
|
||||
]) as [LocalUser | RemoteUser, LocalUser | RemoteUser];
|
||||
|
||||
// check blocking
|
||||
const [blocking, blocked] = await Promise.all([
|
||||
|
@ -137,6 +140,20 @@ export class UserFollowingService implements OnModuleInit {
|
|||
if (followed) autoAccept = true;
|
||||
}
|
||||
|
||||
// Automatically accept if the follower is an account who has moved and the locked followee had accepted the old account.
|
||||
if (followee.isLocked && !autoAccept) {
|
||||
autoAccept = !!(await this.accountMoveService.validateAlsoKnownAs(
|
||||
follower,
|
||||
(oldSrc, newSrc) => this.followingsRepository.exist({
|
||||
where: {
|
||||
followeeId: followee.id,
|
||||
followerId: newSrc.id,
|
||||
},
|
||||
}),
|
||||
true,
|
||||
));
|
||||
}
|
||||
|
||||
if (!autoAccept) {
|
||||
await this.createFollowRequest(follower, followee, requestId);
|
||||
return;
|
||||
|
@ -210,6 +227,13 @@ export class UserFollowingService implements OnModuleInit {
|
|||
|
||||
this.globalEventService.publishInternalEvent('follow', { followerId: follower.id, followeeId: followee.id });
|
||||
|
||||
const [followeeUser, followerUser] = await Promise.all([
|
||||
this.usersRepository.findOneByOrFail({ id: followee.id }),
|
||||
this.usersRepository.findOneByOrFail({ id: follower.id }),
|
||||
]);
|
||||
|
||||
// Neither followee nor follower has moved.
|
||||
if (!followeeUser.movedToUri && !followerUser.movedToUri) {
|
||||
//#region Increment counts
|
||||
await Promise.all([
|
||||
this.usersRepository.increment({ id: follower.id }, 'followingCount', 1),
|
||||
|
@ -236,6 +260,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
//#endregion
|
||||
|
||||
this.perUserFollowingChart.update(follower, followee, true);
|
||||
}
|
||||
|
||||
// Publish follow event
|
||||
if (this.userEntityService.isLocalUser(follower) && !silent) {
|
||||
|
@ -283,12 +308,18 @@ export class UserFollowingService implements OnModuleInit {
|
|||
},
|
||||
silent = false,
|
||||
): Promise<void> {
|
||||
const following = await this.followingsRepository.findOneBy({
|
||||
const following = await this.followingsRepository.findOne({
|
||||
relations: {
|
||||
follower: true,
|
||||
followee: true,
|
||||
},
|
||||
where: {
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
}
|
||||
});
|
||||
|
||||
if (following == null) {
|
||||
if (following === null || !following.follower || !following.followee) {
|
||||
logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした');
|
||||
return;
|
||||
}
|
||||
|
@ -297,7 +328,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
|
||||
this.cacheService.userFollowingsCache.refresh(follower.id);
|
||||
|
||||
this.decrementFollowing(follower, followee);
|
||||
this.decrementFollowing(following.follower, following.followee);
|
||||
|
||||
// Publish unfollow event
|
||||
if (!silent && this.userEntityService.isLocalUser(follower)) {
|
||||
|
@ -316,24 +347,26 @@ export class UserFollowingService implements OnModuleInit {
|
|||
}
|
||||
|
||||
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower as PartialLocalUser, followee as PartialRemoteUser), follower));
|
||||
this.queueService.deliver(follower, content, followee.inbox, false);
|
||||
}
|
||||
|
||||
if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) {
|
||||
// local user has null host
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee));
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower as PartialRemoteUser, followee as PartialLocalUser), followee));
|
||||
this.queueService.deliver(followee, content, follower.inbox, false);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async decrementFollowing(
|
||||
follower: { id: User['id']; host: User['host']; },
|
||||
followee: { id: User['id']; host: User['host']; },
|
||||
follower: User,
|
||||
followee: User,
|
||||
): Promise<void> {
|
||||
this.globalEventService.publishInternalEvent('unfollow', { followerId: follower.id, followeeId: followee.id });
|
||||
|
||||
// Neither followee nor follower has moved.
|
||||
if (!follower.movedToUri && !followee.movedToUri) {
|
||||
//#region Decrement following / followers counts
|
||||
await Promise.all([
|
||||
this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1),
|
||||
|
@ -360,6 +393,41 @@ export class UserFollowingService implements OnModuleInit {
|
|||
//#endregion
|
||||
|
||||
this.perUserFollowingChart.update(follower, followee, false);
|
||||
} else {
|
||||
// Adjust following/followers counts
|
||||
for (const user of [follower, followee]) {
|
||||
if (user.movedToUri) continue; // No need to update if the user has already moved.
|
||||
|
||||
const nonMovedFollowees = await this.followingsRepository.count({
|
||||
relations: {
|
||||
followee: true,
|
||||
},
|
||||
where: {
|
||||
followerId: user.id,
|
||||
followee: {
|
||||
movedToUri: IsNull(),
|
||||
}
|
||||
}
|
||||
});
|
||||
const nonMovedFollowers = await this.followingsRepository.count({
|
||||
relations: {
|
||||
follower: true,
|
||||
},
|
||||
where: {
|
||||
followeeId: user.id,
|
||||
follower: {
|
||||
movedToUri: IsNull(),
|
||||
}
|
||||
}
|
||||
});
|
||||
await this.usersRepository.update(
|
||||
{ id: user.id },
|
||||
{ followingCount: nonMovedFollowees, followersCount: nonMovedFollowers },
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: adjust charts
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -415,7 +483,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
}
|
||||
|
||||
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee, requestId ?? `${this.config.url}/follows/${followRequest.id}`));
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower as PartialLocalUser, followee as PartialRemoteUser, requestId ?? `${this.config.url}/follows/${followRequest.id}`));
|
||||
this.queueService.deliver(follower, content, followee.inbox, false);
|
||||
}
|
||||
}
|
||||
|
@ -430,7 +498,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
},
|
||||
): Promise<void> {
|
||||
if (this.userEntityService.isRemoteUser(followee)) {
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower as PartialLocalUser | PartialRemoteUser, followee as PartialRemoteUser), follower));
|
||||
|
||||
if (this.userEntityService.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので
|
||||
this.queueService.deliver(follower, content, followee.inbox, false);
|
||||
|
@ -475,7 +543,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
await this.insertFollowingDoc(followee, follower);
|
||||
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee));
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee as PartialLocalUser, request.requestId!), followee));
|
||||
this.queueService.deliver(followee, content, follower.inbox, false);
|
||||
}
|
||||
|
||||
|
@ -562,15 +630,22 @@ export class UserFollowingService implements OnModuleInit {
|
|||
*/
|
||||
@bindThis
|
||||
private async removeFollow(followee: Both, follower: Both): Promise<void> {
|
||||
const following = await this.followingsRepository.findOneBy({
|
||||
const following = await this.followingsRepository.findOne({
|
||||
relations: {
|
||||
followee: true,
|
||||
follower: true,
|
||||
},
|
||||
where: {
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
}
|
||||
});
|
||||
|
||||
if (!following) return;
|
||||
if (!following || !following.followee || !following.follower) return;
|
||||
|
||||
await this.followingsRepository.delete(following.id);
|
||||
this.decrementFollowing(follower, followee);
|
||||
|
||||
this.decrementFollowing(following.follower, following.followee);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -35,7 +35,7 @@ export class UserSuspendService {
|
|||
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
// 知り得る全SharedInboxにDelete配信
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user));
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
|
||||
|
||||
const queue: string[] = [];
|
||||
|
||||
|
@ -65,7 +65,7 @@ export class UserSuspendService {
|
|||
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
// 知り得る全SharedInboxにUndo Delete配信
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user), user));
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user));
|
||||
|
||||
const queue: string[] = [];
|
||||
|
||||
|
|
|
@ -43,7 +43,8 @@ export class WebfingerService {
|
|||
const m = query.match(/^([^@]+)@(.*)/);
|
||||
if (m) {
|
||||
const hostname = m[2];
|
||||
return `https://${hostname}/.well-known/webfinger?` + urlQuery({ resource: `acct:${query}` });
|
||||
const useHttp = process.env.MISSKEY_WEBFINGER_USE_HTTP && process.env.MISSKEY_WEBFINGER_USE_HTTP.toLowerCase() === 'true';
|
||||
return `http${useHttp ? '' : 's'}://${hostname}/.well-known/webfinger?${urlQuery({ resource: `acct:${query}` })}`;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid query (${query})`);
|
||||
|
|
|
@ -8,7 +8,7 @@ import type { UserPublickey } from '@/models/entities/UserPublickey.js';
|
|||
import { CacheService } from '@/core/CacheService.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RemoteUser, User } from '@/models/entities/User.js';
|
||||
import { LocalUser, RemoteUser } from '@/models/entities/User.js';
|
||||
import { getApId } from './type.js';
|
||||
import { ApPersonService } from './models/ApPersonService.js';
|
||||
import type { IObject } from './type.js';
|
||||
|
@ -101,7 +101,7 @@ export class ApDbResolverService {
|
|||
* AP Person => Misskey User in DB
|
||||
*/
|
||||
@bindThis
|
||||
public async getUserFromApId(value: string | IObject): Promise<User | null> {
|
||||
public async getUserFromApId(value: string | IObject): Promise<LocalUser | RemoteUser | null> {
|
||||
const parsed = this.parseUri(value);
|
||||
|
||||
if (parsed.local) {
|
||||
|
@ -109,11 +109,11 @@ export class ApDbResolverService {
|
|||
|
||||
return await this.cacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({
|
||||
id: parsed.id,
|
||||
}).then(x => x ?? undefined)) ?? null;
|
||||
}).then(x => x ?? undefined)) as LocalUser | undefined ?? null;
|
||||
} else {
|
||||
return await this.cacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({
|
||||
uri: parsed.uri,
|
||||
}));
|
||||
})) as RemoteUser | null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In, IsNull } from 'typeorm';
|
||||
import { In } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
|
@ -13,13 +13,15 @@ import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js';
|
|||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/index.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 { 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';
|
||||
|
@ -76,6 +78,8 @@ export class ApInboxService {
|
|||
private apNoteService: ApNoteService,
|
||||
private apPersonService: ApPersonService,
|
||||
private apQuestionService: ApQuestionService,
|
||||
private accountMoveService: AccountMoveService,
|
||||
private cacheService: CacheService,
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
this.logger = this.apLoggerService.logger;
|
||||
|
@ -140,7 +144,7 @@ export class ApInboxService {
|
|||
} else if (isFlag(activity)) {
|
||||
await this.flag(actor, activity);
|
||||
} else if (isMove(activity)) {
|
||||
//await this.move(actor, activity);
|
||||
await this.move(actor, activity);
|
||||
} else {
|
||||
this.logger.warn(`unrecognized activity type: ${activity.type}`);
|
||||
}
|
||||
|
@ -158,6 +162,7 @@ export class ApInboxService {
|
|||
return 'skip: フォローしようとしているユーザーはローカルユーザーではありません';
|
||||
}
|
||||
|
||||
// don't queue because the sender may attempt again when timeout
|
||||
await this.userFollowingService.follow(actor, followee, activity.id);
|
||||
return 'ok';
|
||||
}
|
||||
|
@ -596,6 +601,7 @@ export class ApInboxService {
|
|||
throw e;
|
||||
});
|
||||
|
||||
// don't queue because the sender may attempt again when timeout
|
||||
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);
|
||||
|
@ -736,53 +742,7 @@ export class ApInboxService {
|
|||
// 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
|
||||
},
|
||||
});
|
||||
for (const following of followings) {
|
||||
if (!following.follower) continue;
|
||||
try {
|
||||
await this.userFollowingService.follow(following.follower, new_acc);
|
||||
await this.userFollowingService.unfollow(following.follower, old_acc);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
return 'ok';
|
||||
return await this.apPersonService.updatePerson(actor.uri) ?? 'skip: nothing to do';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import { v4 as uuid } from 'uuid';
|
|||
import * as mfm from 'mfm-js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
|
||||
import type { PartialLocalUser, LocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js';
|
||||
import type { IMentionedRemoteUsers, Note } from '@/models/entities/Note.js';
|
||||
import type { Blocking } from '@/models/entities/Blocking.js';
|
||||
import type { Relay } from '@/models/entities/Relay.js';
|
||||
|
@ -69,7 +69,7 @@ export class ApRendererService {
|
|||
public renderAccept(object: any, user: { id: User['id']; host: null }): IAccept {
|
||||
return {
|
||||
type: 'Accept',
|
||||
actor: `${this.config.url}/users/${user.id}`,
|
||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||
object,
|
||||
};
|
||||
}
|
||||
|
@ -78,7 +78,7 @@ export class ApRendererService {
|
|||
public renderAdd(user: LocalUser, target: any, object: any): IAdd {
|
||||
return {
|
||||
type: 'Add',
|
||||
actor: `${this.config.url}/users/${user.id}`,
|
||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||
target,
|
||||
object,
|
||||
};
|
||||
|
@ -86,7 +86,7 @@ export class ApRendererService {
|
|||
|
||||
@bindThis
|
||||
public renderAnnounce(object: any, note: Note): IAnnounce {
|
||||
const attributedTo = `${this.config.url}/users/${note.userId}`;
|
||||
const attributedTo = this.userEntityService.genLocalUserUri(note.userId);
|
||||
|
||||
let to: string[] = [];
|
||||
let cc: string[] = [];
|
||||
|
@ -106,7 +106,7 @@ export class ApRendererService {
|
|||
|
||||
return {
|
||||
id: `${this.config.url}/notes/${note.id}/activity`,
|
||||
actor: `${this.config.url}/users/${note.userId}`,
|
||||
actor: this.userEntityService.genLocalUserUri(note.userId),
|
||||
type: 'Announce',
|
||||
published: note.createdAt.toISOString(),
|
||||
to,
|
||||
|
@ -129,7 +129,7 @@ export class ApRendererService {
|
|||
return {
|
||||
type: 'Block',
|
||||
id: `${this.config.url}/blocks/${block.id}`,
|
||||
actor: `${this.config.url}/users/${block.blockerId}`,
|
||||
actor: this.userEntityService.genLocalUserUri(block.blockerId),
|
||||
object: block.blockee.uri,
|
||||
};
|
||||
}
|
||||
|
@ -138,7 +138,7 @@ export class ApRendererService {
|
|||
public renderCreate(object: IObject, note: Note): ICreate {
|
||||
const activity = {
|
||||
id: `${this.config.url}/notes/${note.id}/activity`,
|
||||
actor: `${this.config.url}/users/${note.userId}`,
|
||||
actor: this.userEntityService.genLocalUserUri(note.userId),
|
||||
type: 'Create',
|
||||
published: note.createdAt.toISOString(),
|
||||
object,
|
||||
|
@ -154,7 +154,7 @@ export class ApRendererService {
|
|||
public renderDelete(object: IObject | string, user: { id: User['id']; host: null }): IDelete {
|
||||
return {
|
||||
type: 'Delete',
|
||||
actor: `${this.config.url}/users/${user.id}`,
|
||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||
object,
|
||||
published: new Date().toISOString(),
|
||||
};
|
||||
|
@ -191,7 +191,7 @@ export class ApRendererService {
|
|||
public renderFlag(user: LocalUser, object: IObject | string, content: string): IFlag {
|
||||
return {
|
||||
type: 'Flag',
|
||||
actor: `${this.config.url}/users/${user.id}`,
|
||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||
content,
|
||||
object,
|
||||
};
|
||||
|
@ -202,7 +202,7 @@ export class ApRendererService {
|
|||
return {
|
||||
id: `${this.config.url}/activities/follow-relay/${relay.id}`,
|
||||
type: 'Follow',
|
||||
actor: `${this.config.url}/users/${relayActor.id}`,
|
||||
actor: this.userEntityService.genLocalUserUri(relayActor.id),
|
||||
object: 'https://www.w3.org/ns/activitystreams#Public',
|
||||
};
|
||||
}
|
||||
|
@ -213,21 +213,21 @@ export class ApRendererService {
|
|||
*/
|
||||
@bindThis
|
||||
public async renderFollowUser(id: User['id']) {
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: id });
|
||||
return this.userEntityService.isLocalUser(user) ? `${this.config.url}/users/${user.id}` : user.uri;
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: id }) as PartialLocalUser | PartialRemoteUser;
|
||||
return this.userEntityService.getUserUri(user);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public renderFollow(
|
||||
follower: { id: User['id']; host: User['host']; uri: User['host'] },
|
||||
followee: { id: User['id']; host: User['host']; uri: User['host'] },
|
||||
follower: PartialLocalUser | PartialRemoteUser,
|
||||
followee: PartialLocalUser | PartialRemoteUser,
|
||||
requestId?: string,
|
||||
): IFollow {
|
||||
return {
|
||||
id: requestId ?? `${this.config.url}/follows/${follower.id}/${followee.id}`,
|
||||
type: 'Follow',
|
||||
actor: this.userEntityService.isLocalUser(follower) ? `${this.config.url}/users/${follower.id}` : follower.uri!,
|
||||
object: this.userEntityService.isLocalUser(followee) ? `${this.config.url}/users/${followee.id}` : followee.uri!,
|
||||
actor: this.userEntityService.getUserUri(follower)!,
|
||||
object: this.userEntityService.getUserUri(followee)!,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -255,7 +255,7 @@ export class ApRendererService {
|
|||
return {
|
||||
id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`,
|
||||
type: 'Key',
|
||||
owner: `${this.config.url}/users/${user.id}`,
|
||||
owner: this.userEntityService.genLocalUserUri(user.id),
|
||||
publicKeyPem: createPublicKey(key.publicKey).export({
|
||||
type: 'spki',
|
||||
format: 'pem',
|
||||
|
@ -287,21 +287,21 @@ export class ApRendererService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public renderMention(mention: User): IApMention {
|
||||
public renderMention(mention: PartialLocalUser | PartialRemoteUser): IApMention {
|
||||
return {
|
||||
type: 'Mention',
|
||||
href: this.userEntityService.isRemoteUser(mention) ? mention.uri! : `${this.config.url}/users/${(mention as LocalUser).id}`,
|
||||
href: this.userEntityService.getUserUri(mention)!,
|
||||
name: this.userEntityService.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as LocalUser).username}`,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public renderMove(
|
||||
src: { id: User['id']; host: User['host']; uri: User['host'] },
|
||||
dst: { id: User['id']; host: User['host']; uri: User['host'] },
|
||||
src: PartialLocalUser | PartialRemoteUser,
|
||||
dst: PartialLocalUser | PartialRemoteUser,
|
||||
): 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!;
|
||||
const actor = this.userEntityService.getUserUri(src)!;
|
||||
const target = this.userEntityService.getUserUri(dst)!;
|
||||
return {
|
||||
id: `${this.config.url}/moves/${src.id}/${dst.id}`,
|
||||
actor,
|
||||
|
@ -354,7 +354,7 @@ export class ApRendererService {
|
|||
}
|
||||
}
|
||||
|
||||
const attributedTo = `${this.config.url}/users/${note.userId}`;
|
||||
const attributedTo = this.userEntityService.genLocalUserUri(note.userId);
|
||||
|
||||
const mentions = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri);
|
||||
|
||||
|
@ -379,7 +379,7 @@ export class ApRendererService {
|
|||
}) : [];
|
||||
|
||||
const hashtagTags = (note.tags ?? []).map(tag => this.renderHashtag(tag));
|
||||
const mentionTags = mentionedUsers.map(u => this.renderMention(u));
|
||||
const mentionTags = mentionedUsers.map(u => this.renderMention(u as LocalUser | RemoteUser));
|
||||
|
||||
const files = await getPromisedFiles(note.fileIds);
|
||||
|
||||
|
@ -465,7 +465,7 @@ export class ApRendererService {
|
|||
|
||||
@bindThis
|
||||
public async renderPerson(user: LocalUser) {
|
||||
const id = `${this.config.url}/users/${user.id}`;
|
||||
const id = this.userEntityService.genLocalUserUri(user.id);
|
||||
const isSystem = !!user.username.match(/\./);
|
||||
|
||||
const [avatar, banner, profile] = await Promise.all([
|
||||
|
@ -553,7 +553,7 @@ export class ApRendererService {
|
|||
return {
|
||||
type: 'Question',
|
||||
id: `${this.config.url}/questions/${note.id}`,
|
||||
actor: `${this.config.url}/users/${user.id}`,
|
||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||
content: note.text ?? '',
|
||||
[poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({
|
||||
name: text,
|
||||
|
@ -570,7 +570,7 @@ export class ApRendererService {
|
|||
public renderReject(object: any, user: { id: User['id'] }): IReject {
|
||||
return {
|
||||
type: 'Reject',
|
||||
actor: `${this.config.url}/users/${user.id}`,
|
||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||
object,
|
||||
};
|
||||
}
|
||||
|
@ -579,7 +579,7 @@ export class ApRendererService {
|
|||
public renderRemove(user: { id: User['id'] }, target: any, object: any): IRemove {
|
||||
return {
|
||||
type: 'Remove',
|
||||
actor: `${this.config.url}/users/${user.id}`,
|
||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||
target,
|
||||
object,
|
||||
};
|
||||
|
@ -600,7 +600,7 @@ export class ApRendererService {
|
|||
return {
|
||||
type: 'Undo',
|
||||
...(id ? { id } : {}),
|
||||
actor: `${this.config.url}/users/${user.id}`,
|
||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||
object,
|
||||
published: new Date().toISOString(),
|
||||
};
|
||||
|
@ -610,7 +610,7 @@ export class ApRendererService {
|
|||
public renderUpdate(object: any, user: { id: User['id'] }): IUpdate {
|
||||
return {
|
||||
id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`,
|
||||
actor: `${this.config.url}/users/${user.id}`,
|
||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||
type: 'Update',
|
||||
to: ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
object,
|
||||
|
@ -622,14 +622,14 @@ export class ApRendererService {
|
|||
public renderVote(user: { id: User['id'] }, vote: PollVote, note: Note, poll: Poll, pollOwner: RemoteUser): ICreate {
|
||||
return {
|
||||
id: `${this.config.url}/users/${user.id}#votes/${vote.id}/activity`,
|
||||
actor: `${this.config.url}/users/${user.id}`,
|
||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||
type: 'Create',
|
||||
to: [pollOwner.uri],
|
||||
published: new Date().toISOString(),
|
||||
object: {
|
||||
id: `${this.config.url}/users/${user.id}#votes/${vote.id}`,
|
||||
type: 'Note',
|
||||
attributedTo: `${this.config.url}/users/${user.id}`,
|
||||
attributedTo: this.userEntityService.genLocalUserUri(user.id),
|
||||
to: [pollOwner.uri],
|
||||
inReplyTo: note.uri,
|
||||
name: poll.choices[vote.choice],
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { LocalUser } from '@/models/entities/User.js';
|
||||
import type { LocalUser, RemoteUser } from '@/models/entities/User.js';
|
||||
import { InstanceActorService } from '@/core/InstanceActorService.js';
|
||||
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
@ -151,7 +151,7 @@ export class Resolver {
|
|||
return Promise.all(
|
||||
[parsed.id, parsed.rest].map(id => this.usersRepository.findOneByOrFail({ id })),
|
||||
)
|
||||
.then(([follower, followee]) => this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee, url)));
|
||||
.then(([follower, followee]) => this.apRendererService.addContext(this.apRendererService.renderFollow(follower as LocalUser | RemoteUser, followee as LocalUser | RemoteUser, url)));
|
||||
default:
|
||||
throw new Error(`resolveLocal: type ${parsed.type} unhandled`);
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import type Logger from '@/logger.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { ApResolverService } from '../ApResolverService.js';
|
||||
import { ApLoggerService } from '../ApLoggerService.js';
|
||||
import { checkHttps } from '@/misc/check-https.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApImageService {
|
||||
|
@ -48,8 +49,8 @@ export class ApImageService {
|
|||
throw new Error('invalid image: url not privided');
|
||||
}
|
||||
|
||||
if (!image.url.startsWith('https://')) {
|
||||
throw new Error('invalid image: unexpected shcema of url: ' + image.url);
|
||||
if (!checkHttps(image.url)) {
|
||||
throw new Error('invalid image: unexpected schema of url: ' + image.url);
|
||||
}
|
||||
|
||||
this.logger.info(`Creating the Image: ${image.url}`);
|
||||
|
|
|
@ -33,6 +33,7 @@ import { ApEventService } from './ApEventService.js';
|
|||
import { ApImageService } from './ApImageService.js';
|
||||
import type { Resolver } from '../ApResolverService.js';
|
||||
import type { IObject, IPost } from '../type.js';
|
||||
import { checkHttps } from '@/misc/check-https.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApNoteService {
|
||||
|
@ -132,13 +133,13 @@ export class ApNoteService {
|
|||
|
||||
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
||||
|
||||
if (note.id && !note.id.startsWith('https://')) {
|
||||
if (note.id && !checkHttps(note.id)) {
|
||||
throw new Error('unexpected shcema of note.id: ' + note.id);
|
||||
}
|
||||
|
||||
const url = getOneApHrefNullable(note.url);
|
||||
|
||||
if (url && !url.startsWith('https://')) {
|
||||
if (url && !checkHttps(url)) {
|
||||
throw new Error('unexpected shcema of note url: ' + url);
|
||||
}
|
||||
|
||||
|
|
|
@ -3,9 +3,9 @@ import promiseLimit from 'promise-limit';
|
|||
import { DataSource } from 'typeorm';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { BlockingsRepository, MutingsRepository, FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { RemoteUser } from '@/models/entities/User.js';
|
||||
import type { LocalUser, RemoteUser } from '@/models/entities/User.js';
|
||||
import { User } from '@/models/entities/User.js';
|
||||
import { truncate } from '@/misc/truncate.js';
|
||||
import type { CacheService } from '@/core/CacheService.js';
|
||||
|
@ -42,6 +42,8 @@ import type { ApLoggerService } from '../ApLoggerService.js';
|
|||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
import type { ApImageService } from './ApImageService.js';
|
||||
import type { IActor, IObject } from '../type.js';
|
||||
import type { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
import { checkHttps } from '@/misc/check-https.js';
|
||||
|
||||
const nameLength = 128;
|
||||
const summaryLength = 2048;
|
||||
|
@ -66,6 +68,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
private usersChart: UsersChart;
|
||||
private instanceChart: InstanceChart;
|
||||
private apLoggerService: ApLoggerService;
|
||||
private accountMoveService: AccountMoveService;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
|
@ -131,9 +134,16 @@ export class ApPersonService implements OnModuleInit {
|
|||
this.usersChart = this.moduleRef.get('UsersChart');
|
||||
this.instanceChart = this.moduleRef.get('InstanceChart');
|
||||
this.apLoggerService = this.moduleRef.get('ApLoggerService');
|
||||
this.accountMoveService = this.moduleRef.get('AccountMoveService');
|
||||
this.logger = this.apLoggerService.logger;
|
||||
}
|
||||
|
||||
private punyHost(url: string): string {
|
||||
const urlObj = new URL(url);
|
||||
const host = `${this.utilityService.toPuny(urlObj.hostname)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`;
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and convert to actor object
|
||||
* @param x Fetched object
|
||||
|
@ -141,7 +151,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
*/
|
||||
@bindThis
|
||||
private validateActor(x: IObject, uri: string): IActor {
|
||||
const expectHost = this.utilityService.toPuny(new URL(uri).hostname);
|
||||
const expectHost = this.punyHost(uri);
|
||||
|
||||
if (x == null) {
|
||||
throw new Error('invalid Actor: object is null');
|
||||
|
@ -182,7 +192,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
x.summary = truncate(x.summary, summaryLength);
|
||||
}
|
||||
|
||||
const idHost = this.utilityService.toPuny(new URL(x.id!).hostname);
|
||||
const idHost = this.punyHost(x.id);
|
||||
if (idHost !== expectHost) {
|
||||
throw new Error('invalid Actor: id has different host');
|
||||
}
|
||||
|
@ -192,7 +202,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
throw new Error('invalid Actor: publicKey.id is not a string');
|
||||
}
|
||||
|
||||
const publicKeyIdHost = this.utilityService.toPuny(new URL(x.publicKey.id).hostname);
|
||||
const publicKeyIdHost = this.punyHost(x.publicKey.id);
|
||||
if (publicKeyIdHost !== expectHost) {
|
||||
throw new Error('invalid Actor: publicKey.id has different host');
|
||||
}
|
||||
|
@ -202,27 +212,27 @@ export class ApPersonService implements OnModuleInit {
|
|||
}
|
||||
|
||||
/**
|
||||
* Personをフェッチします。
|
||||
* uriからUser(Person)をフェッチします。
|
||||
*
|
||||
* Misskeyに対象のPersonが登録されていればそれを返します。
|
||||
* Misskeyに対象のPersonが登録されていればそれを返し、登録がなければnullを返します。
|
||||
*/
|
||||
@bindThis
|
||||
public async fetchPerson(uri: string, resolver?: Resolver): Promise<User | null> {
|
||||
public async fetchPerson(uri: string): Promise<LocalUser | RemoteUser | null> {
|
||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||
|
||||
const cached = this.cacheService.uriPersonCache.get(uri);
|
||||
const cached = this.cacheService.uriPersonCache.get(uri) as LocalUser | RemoteUser | null;
|
||||
if (cached) return cached;
|
||||
|
||||
// URIがこのサーバーを指しているならデータベースからフェッチ
|
||||
if (uri.startsWith(this.config.url + '/')) {
|
||||
if (uri.startsWith(`${this.config.url}/`)) {
|
||||
const id = uri.split('/').pop();
|
||||
const u = await this.usersRepository.findOneBy({ id });
|
||||
const u = await this.usersRepository.findOneBy({ id }) as LocalUser;
|
||||
if (u) this.cacheService.uriPersonCache.set(uri, u);
|
||||
return u;
|
||||
}
|
||||
|
||||
//#region このサーバーに既に登録されていたらそれを返す
|
||||
const exist = await this.usersRepository.findOneBy({ uri });
|
||||
const exist = await this.usersRepository.findOneBy({ uri }) as LocalUser | RemoteUser;
|
||||
|
||||
if (exist) {
|
||||
this.cacheService.uriPersonCache.set(uri, exist);
|
||||
|
@ -237,7 +247,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
* Personを作成します。
|
||||
*/
|
||||
@bindThis
|
||||
public async createPerson(uri: string, resolver?: Resolver): Promise<User> {
|
||||
public async createPerson(uri: string, resolver?: Resolver): Promise<RemoteUser> {
|
||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||
|
||||
if (uri.startsWith(this.config.url)) {
|
||||
|
@ -252,7 +262,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
|
||||
this.logger.info(`Creating the Person: ${person.id}`);
|
||||
|
||||
const host = this.utilityService.toPuny(new URL(object.id).hostname);
|
||||
const host = this.punyHost(object.id);
|
||||
|
||||
const { fields } = this.analyzeAttachments(person.attachment ?? []);
|
||||
|
||||
|
@ -264,8 +274,8 @@ export class ApPersonService implements OnModuleInit {
|
|||
|
||||
const url = getOneApHrefNullable(person.url);
|
||||
|
||||
if (url && !url.startsWith('https://')) {
|
||||
throw new Error('unexpected shcema of person url: ' + url);
|
||||
if (url && !checkHttps(url)) {
|
||||
throw new Error('unexpected schema of person url: ' + url);
|
||||
}
|
||||
|
||||
// Create user
|
||||
|
@ -282,6 +292,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
name: truncate(person.name, nameLength),
|
||||
isLocked: !!person.manuallyApprovesFollowers,
|
||||
movedToUri: person.movedTo,
|
||||
movedAt: person.movedTo ? new Date() : null,
|
||||
alsoKnownAs: person.alsoKnownAs,
|
||||
isExplorable: !!person.discoverable,
|
||||
username: person.preferredUsername,
|
||||
|
@ -404,23 +415,26 @@ export class ApPersonService implements OnModuleInit {
|
|||
/**
|
||||
* Personの情報を更新します。
|
||||
* Misskeyに対象のPersonが登録されていなければ無視します。
|
||||
* もしアカウントの移行が確認された場合、アカウント移行処理を行います。
|
||||
*
|
||||
* @param uri URI of Person
|
||||
* @param resolver Resolver
|
||||
* @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します)
|
||||
* @param movePreventUris ここに指定されたURIがPersonのmovedToに指定されていたり10回より多く回っている場合これ以上アカウント移行を行わない(無限ループ防止)
|
||||
*/
|
||||
@bindThis
|
||||
public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject): Promise<void> {
|
||||
public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise<string | void> {
|
||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||
|
||||
// URIがこのサーバーを指しているならスキップ
|
||||
if (uri.startsWith(this.config.url + '/')) {
|
||||
if (uri.startsWith(`${this.config.url}/`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
//#region このサーバーに既に登録されているか
|
||||
const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser;
|
||||
const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser | null;
|
||||
|
||||
if (exist == null) {
|
||||
if (exist === null) {
|
||||
return;
|
||||
}
|
||||
//#endregion
|
||||
|
@ -459,8 +473,8 @@ export class ApPersonService implements OnModuleInit {
|
|||
|
||||
const url = getOneApHrefNullable(person.url);
|
||||
|
||||
if (url && !url.startsWith('https://')) {
|
||||
throw new Error('unexpected shcema of person url: ' + url);
|
||||
if (url && !checkHttps(url)) {
|
||||
throw new Error('unexpected schema of person url: ' + url);
|
||||
}
|
||||
|
||||
const updates = {
|
||||
|
@ -478,7 +492,16 @@ export class ApPersonService implements OnModuleInit {
|
|||
movedToUri: person.movedTo ?? null,
|
||||
alsoKnownAs: person.alsoKnownAs ?? null,
|
||||
isExplorable: !!person.discoverable,
|
||||
} as Partial<User>;
|
||||
} as Partial<RemoteUser> & Pick<RemoteUser, 'isBot' | 'isCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
|
||||
|
||||
const moving =
|
||||
// 移行先がない→ある
|
||||
(!exist.movedToUri && updates.movedToUri) ||
|
||||
// 移行先がある→別のもの
|
||||
(exist.movedToUri !== updates.movedToUri && exist.movedToUri && updates.movedToUri);
|
||||
// 移行先がある→ない、ない→ないは無視
|
||||
|
||||
if (moving) updates.movedAt = new Date();
|
||||
|
||||
if (avatar) {
|
||||
updates.avatarId = avatar.id;
|
||||
|
@ -523,6 +546,31 @@ export class ApPersonService implements OnModuleInit {
|
|||
});
|
||||
|
||||
await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err));
|
||||
|
||||
const updated = { ...exist, ...updates };
|
||||
|
||||
this.cacheService.uriPersonCache.set(uri, updated);
|
||||
|
||||
// 移行処理を行う
|
||||
if (updated.movedAt && (
|
||||
// 初めて移行する場合はmovedAtがnullなので移行処理を許可
|
||||
exist.movedAt == null ||
|
||||
// 以前のmovingから14日以上経過した場合のみ移行処理を許可
|
||||
// (Mastodonのクールダウン期間は30日だが若干緩めに設定しておく)
|
||||
exist.movedAt.getTime() + 1000 * 60 * 60 * 24 * 14 < updated.movedAt.getTime()
|
||||
)) {
|
||||
this.logger.info(`Start to process Move of @${updated.username}@${updated.host} (${uri})`);
|
||||
return this.processRemoteMove(updated, movePreventUris)
|
||||
.then(result => {
|
||||
this.logger.info(`Processing Move Finished [${result}] @${updated.username}@${updated.host} (${uri})`);
|
||||
return result;
|
||||
})
|
||||
.catch(e => {
|
||||
this.logger.info(`Processing Move Failed @${updated.username}@${updated.host} (${uri})`, { stack: e });
|
||||
});
|
||||
}
|
||||
|
||||
return 'skip';
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -532,7 +580,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
* リモートサーバーからフェッチしてMisskeyに登録しそれを返します。
|
||||
*/
|
||||
@bindThis
|
||||
public async resolvePerson(uri: string, resolver?: Resolver): Promise<User> {
|
||||
public async resolvePerson(uri: string, resolver?: Resolver): Promise<LocalUser | RemoteUser> {
|
||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||
|
||||
//#region このサーバーに既に登録されていたらそれを返す
|
||||
|
@ -607,4 +655,53 @@ export class ApPersonService implements OnModuleInit {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* リモート由来のアカウント移行処理を行います
|
||||
* @param src 移行元アカウント(リモートかつupdatePerson後である必要がある、というかこれ自体がupdatePersonで呼ばれる前提)
|
||||
* @param movePreventUris ここに列挙されたURIにsrc.movedToUriが含まれる場合、移行処理はしない(無限ループ防止)
|
||||
*/
|
||||
@bindThis
|
||||
private async processRemoteMove(src: RemoteUser, movePreventUris: string[] = []): Promise<string> {
|
||||
if (!src.movedToUri) return 'skip: no movedToUri';
|
||||
if (src.uri === src.movedToUri) return 'skip: movedTo itself (src)'; // ???
|
||||
if (movePreventUris.length > 10) return 'skip: too many moves';
|
||||
|
||||
// まずサーバー内で検索して様子見
|
||||
let dst = await this.fetchPerson(src.movedToUri);
|
||||
|
||||
if (dst && this.userEntityService.isLocalUser(dst)) {
|
||||
// targetがローカルユーザーだった場合データベースから引っ張ってくる
|
||||
dst = await this.usersRepository.findOneByOrFail({ uri: src.movedToUri }) as LocalUser;
|
||||
} else if (dst) {
|
||||
if (movePreventUris.includes(src.movedToUri)) return 'skip: circular move';
|
||||
|
||||
// targetを見つけたことがあるならtargetをupdatePersonする
|
||||
await this.updatePerson(src.movedToUri, undefined, undefined, [...movePreventUris, src.uri]);
|
||||
dst = await this.fetchPerson(src.movedToUri) ?? dst;
|
||||
} else {
|
||||
if (src.movedToUri.startsWith(`${this.config.url}/`)) {
|
||||
// ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている
|
||||
return 'failed: movedTo is local but not found';
|
||||
}
|
||||
|
||||
// targetが知らない人だったらresolvePerson
|
||||
// (uriが存在しなかったり応答がなかったりする場合resolvePersonはthrow Errorする)
|
||||
dst = await this.resolvePerson(src.movedToUri);
|
||||
}
|
||||
|
||||
if (dst.movedToUri === dst.uri) return 'skip: movedTo itself (dst)'; // ???
|
||||
if (src.movedToUri !== dst.uri) return 'skip: missmatch uri'; // ???
|
||||
if (dst.movedToUri === src.uri) return 'skip: dst.movedToUri === src.uri';
|
||||
if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) {
|
||||
return 'skip: dst.alsoKnownAs is empty';
|
||||
}
|
||||
if (!dst.alsoKnownAs?.includes(src.uri)) {
|
||||
return 'skip: alsoKnownAs does not include from.uri';
|
||||
}
|
||||
|
||||
await this.accountMoveService.postMoveProcess(src, dst);
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,8 +9,7 @@ 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 type { Instance } from '@/models/entities/Instance.js';
|
||||
import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
|
||||
import type { LocalUser, PartialLocalUser, PartialRemoteUser, 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, UserMemoRepository } from '@/models/index.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
@ -35,13 +34,13 @@ type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends bo
|
|||
const ajv = new Ajv();
|
||||
|
||||
function isLocalUser(user: User): user is LocalUser;
|
||||
function isLocalUser<T extends { host: User['host'] }>(user: T): user is T & { host: null; };
|
||||
function isLocalUser<T extends { host: User['host'] }>(user: T): user is (T & { host: null; });
|
||||
function isLocalUser(user: User | { host: User['host'] }): boolean {
|
||||
return user.host == null;
|
||||
}
|
||||
|
||||
function isRemoteUser(user: User): user is RemoteUser;
|
||||
function isRemoteUser<T extends { host: User['host'] }>(user: T): user is T & { host: string; };
|
||||
function isRemoteUser<T extends { host: User['host'] }>(user: T): user is (T & { host: string; });
|
||||
function isRemoteUser(user: User | { host: User['host'] }): boolean {
|
||||
return !isLocalUser(user);
|
||||
}
|
||||
|
@ -280,6 +279,17 @@ export class UserEntityService implements OnModuleInit {
|
|||
return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getUserUri(user: LocalUser | PartialLocalUser | RemoteUser | PartialRemoteUser): string {
|
||||
return this.isRemoteUser(user)
|
||||
? user.uri : this.genLocalUserUri(user.id);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public genLocalUserUri(userId: string): string {
|
||||
return `${this.config.url}/users/${userId}`;
|
||||
}
|
||||
|
||||
public async pack<ExpectsMe extends boolean | null = null, D extends boolean = false>(
|
||||
src: User['id'] | User,
|
||||
me?: { id: User['id'] } | null | undefined,
|
||||
|
@ -369,8 +379,11 @@ 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,
|
||||
movedTo: user.movedToUri ? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null) : null,
|
||||
alsoKnownAs: user.alsoKnownAs
|
||||
? Promise.all(user.alsoKnownAs.map(uri => this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null)))
|
||||
.then(xs => xs.length === 0 ? null : xs.filter(x => x != null) as string[])
|
||||
: null,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
|
||||
lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null,
|
||||
|
|
|
@ -40,7 +40,7 @@ export class ServerStatsService implements OnApplicationShutdown {
|
|||
const stats = {
|
||||
cpu: roundCpu(cpu),
|
||||
mem: {
|
||||
used: round(memStats.used - memStats.buffers - memStats.cached),
|
||||
used: round(memStats.total - memStats.available),
|
||||
active: round(memStats.active),
|
||||
},
|
||||
net: {
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
export function checkHttps(url: string) {
|
||||
return url.startsWith('https://') ||
|
||||
(url.startsWith('http://') && process.env.NODE_ENV !== 'production');
|
||||
}
|
|
@ -412,4 +412,9 @@ export class Meta {
|
|||
default: '{}',
|
||||
})
|
||||
public serverRules: string[];
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024, array: true, default: '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }',
|
||||
})
|
||||
public preservedUsernames: string[];
|
||||
}
|
||||
|
|
|
@ -75,6 +75,12 @@ export class User {
|
|||
})
|
||||
public movedToUri: string | null;
|
||||
|
||||
@Column('timestamp with time zone', {
|
||||
nullable: true,
|
||||
comment: 'When the user moved to another account',
|
||||
})
|
||||
public movedAt: Date | null;
|
||||
|
||||
@Column('simple-array', {
|
||||
nullable: true,
|
||||
comment: 'URIs the user is known as too',
|
||||
|
@ -253,11 +259,23 @@ export type LocalUser = User & {
|
|||
uri: null;
|
||||
}
|
||||
|
||||
export type PartialLocalUser = Partial<User> & {
|
||||
id: User['id'];
|
||||
host: null;
|
||||
uri: null;
|
||||
}
|
||||
|
||||
export type RemoteUser = User & {
|
||||
host: string;
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export type PartialRemoteUser = Partial<User> & {
|
||||
id: User['id'];
|
||||
host: string;
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const;
|
||||
export const passwordSchema = { type: 'string', minLength: 1 } as const;
|
||||
export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
|
||||
|
|
|
@ -80,9 +80,14 @@ export const packedUserDetailedNotMeOnlySchema = {
|
|||
},
|
||||
alsoKnownAs: {
|
||||
type: 'array',
|
||||
format: 'uri',
|
||||
nullable: true,
|
||||
optional: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
format: 'id',
|
||||
nullable: false,
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
|
|
|
@ -17,7 +17,7 @@ export class RelationshipQueueProcessorsService {
|
|||
|
||||
@bindThis
|
||||
public start(q: Bull.Queue): void {
|
||||
const maxJobs = (this.config.deliverJobConcurrency ?? 128) / 4; // conservative?
|
||||
const maxJobs = this.config.relashionshipJobConcurrency ?? 16;
|
||||
q.process('follow', maxJobs, (job) => this.relationshipProcessorService.processFollow(job));
|
||||
q.process('unfollow', maxJobs, (job) => this.relationshipProcessorService.processUnfollow(job));
|
||||
q.process('block', maxJobs, (job) => this.relationshipProcessorService.processBlock(job));
|
||||
|
|
|
@ -10,6 +10,7 @@ import { QueueLoggerService } from '../QueueLoggerService.js';
|
|||
import { RelationshipJobData } from '../types.js';
|
||||
import type { UsersRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { LocalUser, RemoteUser } from '@/models/entities/User.js';
|
||||
|
||||
@Injectable()
|
||||
export class RelationshipProcessorService {
|
||||
|
@ -39,7 +40,7 @@ export class RelationshipProcessorService {
|
|||
const [follower, followee] = await Promise.all([
|
||||
this.usersRepository.findOneByOrFail({ id: job.data.from.id }),
|
||||
this.usersRepository.findOneByOrFail({ id: job.data.to.id }),
|
||||
]);
|
||||
]) as [LocalUser | RemoteUser, LocalUser | RemoteUser];
|
||||
await this.userFollowingService.unfollow(follower, followee, job.data.silent);
|
||||
return 'ok';
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import * as url from '@/misc/prelude/url.js';
|
|||
import type { Config } from '@/config.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import type { LocalUser, User } from '@/models/entities/User.js';
|
||||
import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
|
||||
import { UserKeypairService } from '@/core/UserKeypairService.js';
|
||||
import type { Following } from '@/models/entities/Following.js';
|
||||
import { countIf } from '@/misc/prelude/array.js';
|
||||
|
@ -630,7 +630,7 @@ export class ActivityPubServerService {
|
|||
id: request.params.followee,
|
||||
host: Not(IsNull()),
|
||||
}),
|
||||
]);
|
||||
]) as [LocalUser | RemoteUser | null, LocalUser | RemoteUser | null];
|
||||
|
||||
if (follower == null || followee == null) {
|
||||
reply.code(404);
|
||||
|
@ -665,7 +665,7 @@ export class ActivityPubServerService {
|
|||
id: followRequest.followeeId,
|
||||
host: Not(IsNull()),
|
||||
}),
|
||||
]);
|
||||
]) as [LocalUser | RemoteUser | null, LocalUser | RemoteUser | null];
|
||||
|
||||
if (follower == null || followee == null) {
|
||||
reply.code(404);
|
||||
|
|
|
@ -454,7 +454,8 @@ export class FileServerService {
|
|||
fileRole: 'original',
|
||||
file,
|
||||
filename: file.name,
|
||||
mime: file.type,
|
||||
// 古いファイルは修正前のmimeを持っているのでできるだけ修正してあげる
|
||||
mime: this.fileInfoService.fixMime(file.type),
|
||||
ext: null,
|
||||
path,
|
||||
};
|
||||
|
|
|
@ -8,6 +8,7 @@ import { escapeAttribute, escapeValue } from '@/misc/prelude/xml.js';
|
|||
import type { User } from '@/models/entities/User.js';
|
||||
import * as Acct from '@/misc/acct.js';
|
||||
import { NodeinfoServerService } from './NodeinfoServerService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import type { FindOptionsWhere } from 'typeorm';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||
|
@ -23,6 +24,7 @@ export class WellKnownServerService {
|
|||
private usersRepository: UsersRepository,
|
||||
|
||||
private nodeinfoServerService: NodeinfoServerService,
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
//this.createServer = this.createServer.bind(this);
|
||||
}
|
||||
|
@ -130,7 +132,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => {
|
|||
const self = {
|
||||
rel: 'self',
|
||||
type: 'application/activity+json',
|
||||
href: `${this.config.url}/users/${user.id}`,
|
||||
href: this.userEntityService.genLocalUserUri(user.id),
|
||||
};
|
||||
const profilePage = {
|
||||
rel: 'http://webfinger.net/rel/profile-page',
|
||||
|
|
|
@ -261,6 +261,17 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
if (ep.meta.prohibitMoved) {
|
||||
if (user?.movedToUri) {
|
||||
throw new ApiError({
|
||||
message: 'You have moved your account.',
|
||||
code: 'YOUR_ACCOUNT_MOVED',
|
||||
id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31',
|
||||
httpStatusCode: 403,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if ((ep.meta.requireModerator || ep.meta.requireAdmin) && !user!.isRoot) {
|
||||
const myRoles = await this.roleService.getUserRoles(user!.id);
|
||||
if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) {
|
||||
|
|
|
@ -223,7 +223,6 @@ 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';
|
||||
|
@ -561,7 +560,6 @@ const $i_unpin: Provider = { provide: 'ep:i/unpin', useClass: ep___i_unpin.defau
|
|||
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 };
|
||||
|
@ -903,7 +901,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$i_updateEmail,
|
||||
$i_update,
|
||||
$i_move,
|
||||
$i_knownAs,
|
||||
$i_webhooks_create,
|
||||
$i_webhooks_list,
|
||||
$i_webhooks_show,
|
||||
|
@ -1239,7 +1236,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$i_updateEmail,
|
||||
$i_update,
|
||||
$i_move,
|
||||
$i_knownAs,
|
||||
$i_webhooks_create,
|
||||
$i_webhooks_list,
|
||||
$i_webhooks_show,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import rndstr from 'rndstr';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
@ -15,7 +16,6 @@ import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { SigninService } from './SigninService.js';
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { IsNull } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class SignupApiService {
|
||||
|
@ -137,6 +137,11 @@ export class SignupApiService {
|
|||
throw new FastifyReplyError(400, 'USED_USERNAME');
|
||||
}
|
||||
|
||||
const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase());
|
||||
if (isPreserved) {
|
||||
throw new FastifyReplyError(400, 'USED_USERNAME');
|
||||
}
|
||||
|
||||
const code = rndstr('a-z0-9', 16);
|
||||
|
||||
// Generate hash of password
|
||||
|
|
|
@ -223,7 +223,6 @@ 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';
|
||||
|
@ -558,8 +557,7 @@ 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/move', ep___i_move],
|
||||
['i/webhooks/create', ep___i_webhooks_create],
|
||||
['i/webhooks/list', ep___i_webhooks_list],
|
||||
['i/webhooks/show', ep___i_webhooks_show],
|
||||
|
@ -706,6 +704,12 @@ export interface IEndpointMeta {
|
|||
|
||||
readonly requireRolePolicy?: keyof RolePolicies;
|
||||
|
||||
/**
|
||||
* 引っ越し済みのユーザーによるリクエストを禁止するか
|
||||
* 省略した場合は false として解釈されます。
|
||||
*/
|
||||
readonly prohibitMoved?: boolean;
|
||||
|
||||
/**
|
||||
* エンドポイントのリミテーションに関するやつ
|
||||
* 省略した場合はリミテーションは無いものとして解釈されます。
|
||||
|
|
|
@ -52,6 +52,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
const { account, secret } = await this.signupService.signup({
|
||||
username: ps.username,
|
||||
password: ps.password,
|
||||
ignorePreservedUsernames: true,
|
||||
});
|
||||
|
||||
const res = await this.userEntityService.pack(account, account, {
|
||||
|
|
|
@ -39,9 +39,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
const pairs = await Promise.all(followings.map(f => Promise.all([
|
||||
this.usersRepository.findOneByOrFail({ id: f.followerId }),
|
||||
this.usersRepository.findOneByOrFail({ id: f.followeeId }),
|
||||
])));
|
||||
]).then(([from, to]) => [{ id: from.id }, { id: to.id }])));
|
||||
|
||||
this.queueService.createUnfollowJob(pairs.map(p => ({ to: p[0], from: p[1], silent: true })));
|
||||
this.queueService.createUnfollowJob(pairs.map(p => ({ from: p[0], to: p[1], silent: true })));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -118,6 +118,14 @@ export const meta = {
|
|||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
preservedUsernames: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
hcaptchaSecretKey: {
|
||||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
|
@ -311,6 +319,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
hiddenTags: instance.hiddenTags,
|
||||
blockedHosts: instance.blockedHosts,
|
||||
sensitiveWords: instance.sensitiveWords,
|
||||
preservedUsernames: instance.preservedUsernames,
|
||||
hcaptchaSecretKey: instance.hcaptchaSecretKey,
|
||||
recaptchaSecretKey: instance.recaptchaSecretKey,
|
||||
turnstileSecretKey: instance.turnstileSecretKey,
|
||||
|
|
|
@ -95,6 +95,7 @@ export const paramDef = {
|
|||
enableChartsForRemoteUser: { type: 'boolean' },
|
||||
enableChartsForFederatedInstances: { type: 'boolean' },
|
||||
serverRules: { type: 'array', items: { type: 'string' } },
|
||||
preservedUsernames: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
@ -392,6 +393,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
set.serverRules = ps.serverRules;
|
||||
}
|
||||
|
||||
if (ps.preservedUsernames !== undefined) {
|
||||
set.preservedUsernames = ps.preservedUsernames;
|
||||
}
|
||||
|
||||
await this.metaService.update(set);
|
||||
this.moderationLogService.insertModerationLog(me, 'updateMeta');
|
||||
});
|
||||
|
|
|
@ -13,6 +13,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:account',
|
||||
|
||||
errors: {
|
||||
|
|
|
@ -11,6 +11,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:account',
|
||||
|
||||
errors: {
|
||||
|
|
|
@ -13,6 +13,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:channels',
|
||||
|
||||
limit: {
|
||||
|
|
|
@ -10,6 +10,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:channels',
|
||||
|
||||
errors: {
|
||||
|
|
|
@ -11,6 +11,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:channels',
|
||||
|
||||
errors: {
|
||||
|
|
|
@ -9,6 +9,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:channels',
|
||||
|
||||
errors: {
|
||||
|
|
|
@ -10,6 +10,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:channels',
|
||||
|
||||
errors: {
|
||||
|
|
|
@ -13,6 +13,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:account',
|
||||
|
||||
limit: {
|
||||
|
|
|
@ -12,6 +12,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:account',
|
||||
|
||||
res: {
|
||||
|
|
|
@ -10,6 +10,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:clip-favorite',
|
||||
|
||||
errors: {
|
||||
|
|
|
@ -10,6 +10,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:account',
|
||||
|
||||
errors: {
|
||||
|
|
|
@ -9,6 +9,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:clip-favorite',
|
||||
|
||||
errors: {
|
||||
|
|
|
@ -10,6 +10,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:account',
|
||||
|
||||
errors: {
|
||||
|
|
|
@ -15,6 +15,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
limit: {
|
||||
duration: ms('1hour'),
|
||||
max: 120,
|
||||
|
|
|
@ -19,6 +19,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:drive',
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -11,6 +11,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:flash',
|
||||
|
||||
limit: {
|
||||
|
|
|
@ -10,6 +10,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:flash-likes',
|
||||
|
||||
errors: {
|
||||
|
|
|
@ -9,6 +9,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:flash-likes',
|
||||
|
||||
errors: {
|
||||
|
|
|
@ -10,6 +10,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:flash',
|
||||
|
||||
limit: {
|
||||
|
|
|
@ -19,6 +19,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:following',
|
||||
|
||||
errors: {
|
||||
|
|
|
@ -13,6 +13,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:gallery',
|
||||
|
||||
limit: {
|
||||
|
|
|
@ -10,6 +10,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:gallery-likes',
|
||||
|
||||
errors: {
|
||||
|
|
|
@ -9,6 +9,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:gallery-likes',
|
||||
|
||||
errors: {
|
||||
|
|
|
@ -11,6 +11,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:gallery',
|
||||
|
||||
limit: {
|
||||
|
|
|
@ -4,6 +4,7 @@ import { AchievementService, ACHIEVEMENT_TYPES } from '@/core/AchievementService
|
|||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
prohibitMoved: true,
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import ms from 'ms';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
import type { DriveFilesRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
@ -9,6 +10,7 @@ import { ApiError } from '../../error.js';
|
|||
export const meta = {
|
||||
secure: true,
|
||||
requireCredential: true,
|
||||
prohibitMoved: true,
|
||||
|
||||
limit: {
|
||||
duration: ms('1hour'),
|
||||
|
@ -58,15 +60,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private queueService: QueueService,
|
||||
private accountMoveService: AccountMoveService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
|
||||
|
||||
if (file == null) throw new ApiError(meta.errors.noSuchFile);
|
||||
//if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType);
|
||||
if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile);
|
||||
if (file.size === 0) throw new ApiError(meta.errors.emptyFile);
|
||||
|
||||
const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
|
||||
me,
|
||||
(old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(),
|
||||
true
|
||||
);
|
||||
if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile);
|
||||
|
||||
this.queueService.createImportBlockingJob(me, file.id);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import ms from 'ms';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
import type { DriveFilesRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
@ -9,6 +10,7 @@ import { ApiError } from '../../error.js';
|
|||
export const meta = {
|
||||
secure: true,
|
||||
requireCredential: true,
|
||||
prohibitMoved: true,
|
||||
limit: {
|
||||
duration: ms('1hour'),
|
||||
max: 1,
|
||||
|
@ -57,15 +59,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private queueService: QueueService,
|
||||
private accountMoveService: AccountMoveService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
|
||||
|
||||
if (file == null) throw new ApiError(meta.errors.noSuchFile);
|
||||
//if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType);
|
||||
if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile);
|
||||
if (file.size === 0) throw new ApiError(meta.errors.emptyFile);
|
||||
|
||||
const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
|
||||
me,
|
||||
(old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(),
|
||||
true
|
||||
);
|
||||
if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile);
|
||||
|
||||
this.queueService.createImportFollowingJob(me, file.id);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import ms from 'ms';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
import type { DriveFilesRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
@ -9,6 +10,7 @@ import { ApiError } from '../../error.js';
|
|||
export const meta = {
|
||||
secure: true,
|
||||
requireCredential: true,
|
||||
prohibitMoved: true,
|
||||
|
||||
limit: {
|
||||
duration: ms('1hour'),
|
||||
|
@ -58,15 +60,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private queueService: QueueService,
|
||||
private accountMoveService: AccountMoveService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
|
||||
|
||||
if (file == null) throw new ApiError(meta.errors.noSuchFile);
|
||||
//if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType);
|
||||
if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile);
|
||||
if (file.size === 0) throw new ApiError(meta.errors.emptyFile);
|
||||
|
||||
const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
|
||||
me,
|
||||
(old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(),
|
||||
true
|
||||
);
|
||||
if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile);
|
||||
|
||||
this.queueService.createImportMutingJob(me, file.id);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import ms from 'ms';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
import type { DriveFilesRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
@ -9,6 +10,7 @@ import { ApiError } from '../../error.js';
|
|||
export const meta = {
|
||||
secure: true,
|
||||
requireCredential: true,
|
||||
prohibitMoved: true,
|
||||
limit: {
|
||||
duration: ms('1hour'),
|
||||
max: 1,
|
||||
|
@ -57,15 +59,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private queueService: QueueService,
|
||||
private accountMoveService: AccountMoveService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
|
||||
|
||||
if (file == null) throw new ApiError(meta.errors.noSuchFile);
|
||||
//if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType);
|
||||
if (file.size > 30000) throw new ApiError(meta.errors.tooBigFile);
|
||||
if (file.size === 0) throw new ApiError(meta.errors.emptyFile);
|
||||
|
||||
const checkMoving = await this.accountMoveService.validateAlsoKnownAs(
|
||||
me,
|
||||
(old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(),
|
||||
true
|
||||
);
|
||||
if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile);
|
||||
|
||||
this.queueService.createImportUserListsJob(me, file.id);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,92 +0,0 @@
|
|||
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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -7,40 +7,35 @@ import { DI } from '@/di-symbols.js';
|
|||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
import { LocalUser, RemoteUser } from '@/models/entities/User.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';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
|
||||
import * as Acct from '@/misc/acct.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['users'],
|
||||
|
||||
secure: true,
|
||||
requireCredential: true,
|
||||
prohibitMoved: 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: {
|
||||
destinationAccountForbids: {
|
||||
message:
|
||||
'Remote account doesn\'t have proper \'Known As\' alias. Did you remember to set it?',
|
||||
code: 'REMOTE_ACCOUNT_FORBIDS',
|
||||
'Destination account doesn\'t have proper \'Known As\' alias, or has already moved.',
|
||||
code: 'DESTINATION_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',
|
||||
|
@ -84,57 +79,52 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private remoteUserResolveService: RemoteUserResolveService,
|
||||
private apiLoggerService: ApiLoggerService,
|
||||
private accountMoveService: AccountMoveService,
|
||||
private getterService: GetterService,
|
||||
private apPersonService: ApPersonService,
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// check parameter
|
||||
if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchMoveTarget);
|
||||
if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchUser);
|
||||
// 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('@');
|
||||
const { username, host } = Acct.parse(ps.moveToAccount);
|
||||
// retrieve the destination account
|
||||
let moveTo = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => {
|
||||
let moveTo = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => {
|
||||
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`);
|
||||
throw new ApiError(meta.errors.noSuchMoveTarget);
|
||||
throw new ApiError(meta.errors.noSuchUser);
|
||||
});
|
||||
const remoteMoveTo = await this.getterService.getRemoteUser(moveTo.id);
|
||||
if (!remoteMoveTo.uri) throw new ApiError(meta.errors.uriNull);
|
||||
const destination = await this.getterService.getUser(moveTo.id) as LocalUser | RemoteUser;
|
||||
const newUri = this.userEntityService.getUserUri(destination);
|
||||
|
||||
// update local db
|
||||
await this.apPersonService.updatePerson(remoteMoveTo.uri);
|
||||
await this.apPersonService.updatePerson(newUri);
|
||||
// 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);
|
||||
moveTo = await this.apPersonService.resolvePerson(newUri);
|
||||
|
||||
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;
|
||||
});
|
||||
const fromUrl = this.userEntityService.genLocalUserUri(me.id);
|
||||
let allowed = false;
|
||||
if (moveTo.alsoKnownAs) {
|
||||
for (const knownAs of moveTo.alsoKnownAs) {
|
||||
if (knownAs.includes(fromUrl)) {
|
||||
allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// abort if unintended
|
||||
if (!(allowed && moveTo.uri && fromUrl)) throw new ApiError(meta.errors.remoteAccountForbids);
|
||||
if (!allowed || moveTo.movedToUri) throw new ApiError(meta.errors.destinationAccountForbids);
|
||||
|
||||
return await this.accountMoveService.moveToRemote(me, moveTo);
|
||||
return await this.accountMoveService.moveFromLocal(me, moveTo);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ export const meta = {
|
|||
tags: ['account', 'notes'],
|
||||
|
||||
requireCredential: true,
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:account',
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import * as mfm from 'mfm-js';
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
|
||||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||
import * as Acct from '@/misc/acct.js';
|
||||
import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/index.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { birthdaySchema, descriptionSchema, locationSchema, nameSchema } from '@/models/entities/User.js';
|
||||
|
@ -19,7 +20,10 @@ 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 { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
|
||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||
import { ApiLoggerService } from '../../ApiLoggerService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -71,6 +75,24 @@ export const meta = {
|
|||
code: 'TOO_MANY_MUTED_WORDS',
|
||||
id: '010665b1-a211-42d2-bc64-8f6609d79785',
|
||||
},
|
||||
|
||||
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',
|
||||
},
|
||||
|
||||
forbiddenToSetYourself: {
|
||||
message: 'You can\'t set yourself as your own alias.',
|
||||
code: 'FORBIDDEN_TO_SET_YOURSELF',
|
||||
id: '25c90186-4ab0-49c8-9bba-a1fa6c202ba4',
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
|
@ -129,6 +151,12 @@ export const paramDef = {
|
|||
emailNotificationTypes: { type: 'array', items: {
|
||||
type: 'string',
|
||||
} },
|
||||
alsoKnownAs: {
|
||||
type: 'array',
|
||||
maxItems: 10,
|
||||
uniqueItems: true,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
@ -153,6 +181,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
private globalEventService: GlobalEventService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private accountUpdateService: AccountUpdateService,
|
||||
private accountMoveService: AccountMoveService,
|
||||
private remoteUserResolveService: RemoteUserResolveService,
|
||||
private apiLoggerService: ApiLoggerService,
|
||||
private hashtagService: HashtagService,
|
||||
private roleService: RoleService,
|
||||
private cacheService: CacheService,
|
||||
|
@ -260,6 +291,38 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
});
|
||||
}
|
||||
|
||||
if (ps.alsoKnownAs) {
|
||||
if (_user.movedToUri) {
|
||||
throw new ApiError({
|
||||
message: 'You have moved your account.',
|
||||
code: 'YOUR_ACCOUNT_MOVED',
|
||||
id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31',
|
||||
httpStatusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
// Parse user's input into the old account
|
||||
const newAlsoKnownAs = new Set<string>();
|
||||
for (const line of ps.alsoKnownAs) {
|
||||
if (!line) throw new ApiError(meta.errors.noSuchUser);
|
||||
const { username, host } = Acct.parse(line);
|
||||
|
||||
// Retrieve the old account
|
||||
const knownAs = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => {
|
||||
this.apiLoggerService.logger.warn(`failed to resolve dstination user: ${e}`);
|
||||
throw new ApiError(meta.errors.noSuchUser);
|
||||
});
|
||||
if (knownAs.id === _user.id) throw new ApiError(meta.errors.forbiddenToSetYourself);
|
||||
|
||||
const toUrl = this.userEntityService.getUserUri(knownAs);
|
||||
if (!toUrl) throw new ApiError(meta.errors.uriNull);
|
||||
|
||||
newAlsoKnownAs.add(toUrl);
|
||||
}
|
||||
|
||||
updates.alsoKnownAs = newAlsoKnownAs.size > 0 ? Array.from(newAlsoKnownAs) : null;
|
||||
}
|
||||
|
||||
//#region emojis/tags
|
||||
|
||||
let emojis = [] as string[];
|
||||
|
@ -287,6 +350,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
//#endregion
|
||||
|
||||
if (Object.keys(updates).length > 0) await this.usersRepository.update(user.id, updates);
|
||||
if (Object.keys(updates).includes('alsoKnownAs')) {
|
||||
this.cacheService.uriPersonCache.set(this.userEntityService.genLocalUserUri(user.id), { ...user, ...updates });
|
||||
}
|
||||
if (Object.keys(profileUpdates).length > 0) await this.userProfilesRepository.update(user.id, profileUpdates);
|
||||
|
||||
const iObj = await this.userEntityService.pack<true, true>(user.id, user, {
|
||||
|
|
|
@ -11,6 +11,7 @@ export const meta = {
|
|||
tags: ['account'],
|
||||
|
||||
requireCredential: true,
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:mutes',
|
||||
|
||||
|
|
|
@ -18,6 +18,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
limit: {
|
||||
duration: ms('1hour'),
|
||||
max: 300,
|
||||
|
|
|
@ -12,6 +12,7 @@ export const meta = {
|
|||
tags: ['notes', 'favorites'],
|
||||
|
||||
requireCredential: true,
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:favorites',
|
||||
|
||||
|
|
|
@ -17,6 +17,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:votes',
|
||||
|
||||
errors: {
|
||||
|
|
|
@ -9,6 +9,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:reactions',
|
||||
|
||||
errors: {
|
||||
|
|
|
@ -13,6 +13,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:pages',
|
||||
|
||||
limit: {
|
||||
|
|
|
@ -10,6 +10,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:page-likes',
|
||||
|
||||
errors: {
|
||||
|
|
|
@ -9,6 +9,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:page-likes',
|
||||
|
||||
errors: {
|
||||
|
|
|
@ -11,6 +11,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:pages',
|
||||
|
||||
limit: {
|
||||
|
|
|
@ -13,6 +13,7 @@ export const meta = {
|
|||
tags: ['account'],
|
||||
|
||||
requireCredential: true,
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:mutes',
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import type { UsedUsernamesRepository, UsersRepository } from '@/models/index.js
|
|||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { localUsernameSchema } from '@/models/entities/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['users'],
|
||||
|
@ -39,9 +40,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
|
||||
@Inject(DI.usedUsernamesRepository)
|
||||
private usedUsernamesRepository: UsedUsernamesRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// Get exist
|
||||
const exist = await this.usersRepository.countBy({
|
||||
host: IsNull(),
|
||||
usernameLower: ps.username.toLowerCase(),
|
||||
|
@ -49,8 +51,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
|
||||
const exist2 = await this.usedUsernamesRepository.countBy({ username: ps.username.toLowerCase() });
|
||||
|
||||
const meta = await this.metaService.fetch();
|
||||
const isPreserved = meta.preservedUsernames.map(x => x.toLowerCase()).includes(ps.username.toLowerCase());
|
||||
|
||||
return {
|
||||
available: exist === 0 && exist2 === 0,
|
||||
available: exist === 0 && exist2 === 0 && !isPreserved,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
@ -13,6 +13,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:account',
|
||||
|
||||
description: 'Create a new list of users.',
|
||||
|
|
|
@ -12,6 +12,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:account',
|
||||
|
||||
description: 'Remove a user from a list.',
|
||||
|
|
|
@ -12,6 +12,8 @@ export const meta = {
|
|||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:account',
|
||||
|
||||
description: 'Add a user to an existing list.',
|
||||
|
|
|
@ -4,8 +4,9 @@ import * as assert from 'assert';
|
|||
// node-fetch only supports it's own Blob yet
|
||||
// https://github.com/node-fetch/node-fetch/pull/1664
|
||||
import { Blob } from 'node-fetch';
|
||||
import { startServer, signup, post, api, uploadFile, simpleGet } from '../utils.js';
|
||||
import { startServer, signup, post, api, uploadFile, simpleGet, initTestDb } from '../utils.js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
import { User } from '@/models/index.js';
|
||||
|
||||
describe('Endpoints', () => {
|
||||
let app: INestApplicationContext;
|
||||
|
@ -289,6 +290,16 @@ describe('Endpoints', () => {
|
|||
}, bob);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
const connection = await initTestDb(true);
|
||||
const Users = connection.getRepository(User);
|
||||
const newBob = await Users.findOneByOrFail({ id: bob.id });
|
||||
assert.strictEqual(newBob.followersCount, 0);
|
||||
assert.strictEqual(newBob.followingCount, 1);
|
||||
const newAlice = await Users.findOneByOrFail({ id: alice.id });
|
||||
assert.strictEqual(newAlice.followersCount, 1);
|
||||
assert.strictEqual(newAlice.followingCount, 0);
|
||||
connection.destroy();
|
||||
});
|
||||
|
||||
test('既にフォローしている場合は怒る', async () => {
|
||||
|
@ -341,6 +352,16 @@ describe('Endpoints', () => {
|
|||
}, bob);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
const connection = await initTestDb(true);
|
||||
const Users = connection.getRepository(User);
|
||||
const newBob = await Users.findOneByOrFail({ id: bob.id });
|
||||
assert.strictEqual(newBob.followersCount, 0);
|
||||
assert.strictEqual(newBob.followingCount, 0);
|
||||
const newAlice = await Users.findOneByOrFail({ id: alice.id });
|
||||
assert.strictEqual(newAlice.followersCount, 0);
|
||||
assert.strictEqual(newAlice.followingCount, 0);
|
||||
connection.destroy();
|
||||
});
|
||||
|
||||
test('フォローしていない場合は怒る', async () => {
|
||||
|
|
|
@ -0,0 +1,455 @@
|
|||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import rndstr from 'rndstr';
|
||||
import { loadConfig } from '@/config.js';
|
||||
import { User, UsersRepository } from '@/models/index.js';
|
||||
import { jobQueue } from '@/boot/common.js';
|
||||
import { uploadFile, signup, startServer, initTestDb, api, sleep, successfulApiCall } from '../utils.js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
|
||||
describe('Account Move', () => {
|
||||
let app: INestApplicationContext;
|
||||
let url: URL;
|
||||
|
||||
let root: any;
|
||||
let alice: any;
|
||||
let bob: any;
|
||||
let carol: any;
|
||||
let dave: any;
|
||||
let eve: any;
|
||||
let frank: any;
|
||||
|
||||
let Users: UsersRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await startServer();
|
||||
await jobQueue();
|
||||
const config = loadConfig();
|
||||
url = new URL(config.url);
|
||||
const connection = await initTestDb(false);
|
||||
root = await signup({ username: 'root' });
|
||||
alice = await signup({ username: 'alice' });
|
||||
bob = await signup({ username: 'bob' });
|
||||
carol = await signup({ username: 'carol' });
|
||||
dave = await signup({ username: 'dave' });
|
||||
eve = await signup({ username: 'eve' });
|
||||
frank = await signup({ username: 'frank' });
|
||||
Users = connection.getRepository(User);
|
||||
}, 1000 * 60 * 2);
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('Create Alias', () => {
|
||||
afterEach(async () => {
|
||||
await Users.update(bob.id, { alsoKnownAs: null });
|
||||
}, 1000 * 10);
|
||||
|
||||
test('Able to create an alias', async () => {
|
||||
const res = await api('/i/update', {
|
||||
alsoKnownAs: [`@alice@${url.hostname}`],
|
||||
}, bob);
|
||||
|
||||
const newBob = await Users.findOneByOrFail({ id: bob.id });
|
||||
assert.strictEqual(newBob.alsoKnownAs?.length, 1);
|
||||
assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`);
|
||||
assert.strictEqual(res.body.alsoKnownAs?.length, 1);
|
||||
assert.strictEqual(res.body.alsoKnownAs[0], alice.id);
|
||||
});
|
||||
|
||||
test('Able to create a local alias without hostname', async () => {
|
||||
await api('/i/update', {
|
||||
alsoKnownAs: ['@alice'],
|
||||
}, bob);
|
||||
|
||||
const newBob = await Users.findOneByOrFail({ id: bob.id });
|
||||
assert.strictEqual(newBob.alsoKnownAs?.length, 1);
|
||||
assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`);
|
||||
});
|
||||
|
||||
test('Able to create a local alias without @', async () => {
|
||||
await api('/i/update', {
|
||||
alsoKnownAs: ['alice'],
|
||||
}, bob);
|
||||
|
||||
const newBob = await Users.findOneByOrFail({ id: bob.id });
|
||||
assert.strictEqual(newBob.alsoKnownAs?.length, 1);
|
||||
assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`);
|
||||
});
|
||||
|
||||
test('Able to set remote user (but may fail)', async () => {
|
||||
const res = await api('/i/update', {
|
||||
alsoKnownAs: ['@syuilo@example.com'],
|
||||
}, bob);
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.strictEqual(res.body.error.code, 'NO_SUCH_USER');
|
||||
assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5');
|
||||
});
|
||||
|
||||
test('Unable to add duplicated aliases to alsoKnownAs', async () => {
|
||||
const res = await api('/i/update', {
|
||||
alsoKnownAs: [`@alice@${url.hostname}`, `@alice@${url.hostname}`],
|
||||
}, bob);
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.strictEqual(res.body.error.code, 'INVALID_PARAM');
|
||||
assert.strictEqual(res.body.error.id, '3d81ceae-475f-4600-b2a8-2bc116157532');
|
||||
});
|
||||
|
||||
test('Unable to add itself', async () => {
|
||||
const res = await api('/i/update', {
|
||||
alsoKnownAs: [`@bob@${url.hostname}`],
|
||||
}, bob);
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.strictEqual(res.body.error.code, 'FORBIDDEN_TO_SET_YOURSELF');
|
||||
assert.strictEqual(res.body.error.id, '25c90186-4ab0-49c8-9bba-a1fa6c202ba4');
|
||||
});
|
||||
|
||||
test('Unable to add a nonexisting local account to alsoKnownAs', async () => {
|
||||
const res1 = await api('/i/update', {
|
||||
alsoKnownAs: [`@nonexist@${url.hostname}`],
|
||||
}, bob);
|
||||
|
||||
assert.strictEqual(res1.status, 400);
|
||||
assert.strictEqual(res1.body.error.code, 'NO_SUCH_USER');
|
||||
assert.strictEqual(res1.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5');
|
||||
|
||||
const res2 = await api('/i/update', {
|
||||
alsoKnownAs: ['@alice', 'nonexist'],
|
||||
}, bob);
|
||||
|
||||
assert.strictEqual(res2.status, 400);
|
||||
assert.strictEqual(res2.body.error.code, 'NO_SUCH_USER');
|
||||
assert.strictEqual(res2.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5');
|
||||
});
|
||||
|
||||
test('Able to add two existing local account to alsoKnownAs', async () => {
|
||||
await api('/i/update', {
|
||||
alsoKnownAs: [`@alice@${url.hostname}`, `@carol@${url.hostname}`],
|
||||
}, bob);
|
||||
|
||||
const newBob = await Users.findOneByOrFail({ id: bob.id });
|
||||
assert.strictEqual(newBob.alsoKnownAs?.length, 2);
|
||||
assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`);
|
||||
assert.strictEqual(newBob.alsoKnownAs[1], `${url.origin}/users/${carol.id}`);
|
||||
});
|
||||
|
||||
test('Able to properly overwrite alsoKnownAs', async () => {
|
||||
await api('/i/update', {
|
||||
alsoKnownAs: [`@alice@${url.hostname}`],
|
||||
}, bob);
|
||||
await api('/i/update', {
|
||||
alsoKnownAs: [`@carol@${url.hostname}`, `@dave@${url.hostname}`],
|
||||
}, bob);
|
||||
|
||||
const newBob = await Users.findOneByOrFail({ id: bob.id });
|
||||
assert.strictEqual(newBob.alsoKnownAs?.length, 2);
|
||||
assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${carol.id}`);
|
||||
assert.strictEqual(newBob.alsoKnownAs[1], `${url.origin}/users/${dave.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Local to Local', () => {
|
||||
let antennaId = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
await api('/i/update', {
|
||||
alsoKnownAs: [`@alice@${url.hostname}`],
|
||||
}, root);
|
||||
const listRoot = await api('/users/lists/create', {
|
||||
name: rndstr('0-9a-z', 8),
|
||||
}, root);
|
||||
await api('/users/lists/push', {
|
||||
listId: listRoot.body.id,
|
||||
userId: alice.id,
|
||||
}, root);
|
||||
|
||||
await api('/following/create', {
|
||||
userId: root.id,
|
||||
}, alice);
|
||||
await api('/following/create', {
|
||||
userId: eve.id,
|
||||
}, alice);
|
||||
const antenna = await api('/antennas/create', {
|
||||
name: rndstr('0-9a-z', 8),
|
||||
src: 'home',
|
||||
keywords: [rndstr('0-9a-z', 8)],
|
||||
excludeKeywords: [],
|
||||
users: [],
|
||||
caseSensitive: false,
|
||||
withReplies: false,
|
||||
withFile: false,
|
||||
notify: false,
|
||||
}, alice);
|
||||
antennaId = antenna.body.id;
|
||||
|
||||
await api('/i/update', {
|
||||
alsoKnownAs: [`@alice@${url.hostname}`],
|
||||
}, bob);
|
||||
|
||||
await api('/following/create', {
|
||||
userId: alice.id,
|
||||
}, carol);
|
||||
|
||||
await api('/mute/create', {
|
||||
userId: alice.id,
|
||||
}, dave);
|
||||
await api('/blocking/create', {
|
||||
userId: alice.id,
|
||||
}, dave);
|
||||
await api('/following/create', {
|
||||
userId: eve.id,
|
||||
}, dave);
|
||||
|
||||
await api('/following/create', {
|
||||
userId: dave.id,
|
||||
}, eve);
|
||||
const listEve = await api('/users/lists/create', {
|
||||
name: rndstr('0-9a-z', 8),
|
||||
}, eve);
|
||||
await api('/users/lists/push', {
|
||||
listId: listEve.body.id,
|
||||
userId: bob.id,
|
||||
}, eve);
|
||||
|
||||
await api('/i/update', {
|
||||
isLocked: true,
|
||||
}, frank);
|
||||
await api('/following/create', {
|
||||
userId: frank.id,
|
||||
}, alice);
|
||||
await api('/following/requests/accept', {
|
||||
userId: alice.id,
|
||||
}, frank);
|
||||
}, 1000 * 10);
|
||||
|
||||
test('Prohibit the root account from moving', async () => {
|
||||
const res = await api('/i/move', {
|
||||
moveToAccount: `@bob@${url.hostname}`,
|
||||
}, root);
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.strictEqual(res.body.error.code, 'NOT_ROOT_FORBIDDEN');
|
||||
assert.strictEqual(res.body.error.id, '4362e8dc-731f-4ad8-a694-be2a88922a24');
|
||||
});
|
||||
|
||||
test('Unable to move to a nonexisting local account', async () => {
|
||||
const res = await api('/i/move', {
|
||||
moveToAccount: `@nonexist@${url.hostname}`,
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.strictEqual(res.body.error.code, 'NO_SUCH_USER');
|
||||
assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5');
|
||||
});
|
||||
|
||||
test('Unable to move if alsoKnownAs is invalid', async () => {
|
||||
const res = await api('/i/move', {
|
||||
moveToAccount: `@carol@${url.hostname}`,
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS');
|
||||
assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4');
|
||||
});
|
||||
|
||||
test('Relationships have been properly migrated', async () => {
|
||||
const move = await api('/i/move', {
|
||||
moveToAccount: `@bob@${url.hostname}`,
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(move.status, 200);
|
||||
|
||||
await sleep(1000 * 3); // wait for jobs to finish
|
||||
|
||||
// Unfollow delayed?
|
||||
const aliceFollowings = await api('/users/following', {
|
||||
userId: alice.id,
|
||||
}, alice);
|
||||
assert.strictEqual(aliceFollowings.status, 200);
|
||||
assert.strictEqual(aliceFollowings.body.length, 3);
|
||||
|
||||
const carolFollowings = await api('/users/following', {
|
||||
userId: carol.id,
|
||||
}, carol);
|
||||
assert.strictEqual(carolFollowings.status, 200);
|
||||
assert.strictEqual(carolFollowings.body.length, 2);
|
||||
assert.strictEqual(carolFollowings.body[0].followeeId, bob.id);
|
||||
assert.strictEqual(carolFollowings.body[1].followeeId, alice.id);
|
||||
|
||||
const blockings = await api('/blocking/list', {}, dave);
|
||||
assert.strictEqual(blockings.status, 200);
|
||||
assert.strictEqual(blockings.body.length, 2);
|
||||
assert.strictEqual(blockings.body[0].blockeeId, bob.id);
|
||||
assert.strictEqual(blockings.body[1].blockeeId, alice.id);
|
||||
|
||||
const mutings = await api('/mute/list', {}, dave);
|
||||
assert.strictEqual(mutings.status, 200);
|
||||
assert.strictEqual(mutings.body.length, 2);
|
||||
assert.strictEqual(mutings.body[0].muteeId, bob.id);
|
||||
assert.strictEqual(mutings.body[1].muteeId, alice.id);
|
||||
|
||||
const rootLists = await api('/users/lists/list', {}, root);
|
||||
assert.strictEqual(rootLists.status, 200);
|
||||
assert.strictEqual(rootLists.body[0].userIds.length, 2);
|
||||
assert.ok(rootLists.body[0].userIds.find((id: string) => id === bob.id));
|
||||
assert.ok(rootLists.body[0].userIds.find((id: string) => id === alice.id));
|
||||
|
||||
const eveLists = await api('/users/lists/list', {}, eve);
|
||||
assert.strictEqual(eveLists.status, 200);
|
||||
assert.strictEqual(eveLists.body[0].userIds.length, 1);
|
||||
assert.ok(eveLists.body[0].userIds.find((id: string) => id === bob.id));
|
||||
});
|
||||
|
||||
test('A locked account automatically accept the follow request if it had already accepted the old account.', async () => {
|
||||
await successfulApiCall({
|
||||
endpoint: '/following/create',
|
||||
parameters: {
|
||||
userId: frank.id,
|
||||
},
|
||||
user: bob,
|
||||
});
|
||||
const followers = await api('/users/followers', {
|
||||
userId: frank.id,
|
||||
}, frank);
|
||||
|
||||
assert.strictEqual(followers.status, 200);
|
||||
assert.strictEqual(followers.body.length, 2);
|
||||
assert.strictEqual(followers.body[0].followerId, bob.id);
|
||||
});
|
||||
|
||||
test('Unfollowed after 10 sec (24 hours in production).', async () => {
|
||||
await sleep(1000 * 8);
|
||||
|
||||
const following = await api('/users/following', {
|
||||
userId: alice.id,
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(following.status, 200);
|
||||
assert.strictEqual(following.body.length, 0);
|
||||
});
|
||||
|
||||
test('Unable to move if the destination account has already moved.', async () => {
|
||||
const res = await api('/i/move', {
|
||||
moveToAccount: `@alice@${url.hostname}`,
|
||||
}, bob);
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS');
|
||||
assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4');
|
||||
});
|
||||
|
||||
test('Follow and follower counts are properly adjusted', async () => {
|
||||
await api('/following/create', {
|
||||
userId: alice.id,
|
||||
}, eve);
|
||||
const newAlice = await Users.findOneByOrFail({ id: alice.id });
|
||||
const newCarol = await Users.findOneByOrFail({ id: carol.id });
|
||||
let newEve = await Users.findOneByOrFail({ id: eve.id });
|
||||
assert.strictEqual(newAlice.movedToUri, `${url.origin}/users/${bob.id}`);
|
||||
assert.strictEqual(newAlice.followingCount, 0);
|
||||
assert.strictEqual(newAlice.followersCount, 0);
|
||||
assert.strictEqual(newCarol.followingCount, 1);
|
||||
assert.strictEqual(newEve.followingCount, 1);
|
||||
assert.strictEqual(newEve.followersCount, 1);
|
||||
|
||||
await api('/following/delete', {
|
||||
userId: alice.id,
|
||||
}, eve);
|
||||
newEve = await Users.findOneByOrFail({ id: eve.id });
|
||||
assert.strictEqual(newEve.followingCount, 1);
|
||||
assert.strictEqual(newEve.followersCount, 1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
'/antennas/create',
|
||||
'/channels/create',
|
||||
'/channels/favorite',
|
||||
'/channels/follow',
|
||||
'/channels/unfavorite',
|
||||
'/channels/unfollow',
|
||||
'/clips/add-note',
|
||||
'/clips/create',
|
||||
'/clips/favorite',
|
||||
'/clips/remove-note',
|
||||
'/clips/unfavorite',
|
||||
'/clips/update',
|
||||
'/drive/files/upload-from-url',
|
||||
'/flash/create',
|
||||
'/flash/like',
|
||||
'/flash/unlike',
|
||||
'/flash/update',
|
||||
'/following/create',
|
||||
'/gallery/posts/create',
|
||||
'/gallery/posts/like',
|
||||
'/gallery/posts/unlike',
|
||||
'/gallery/posts/update',
|
||||
'/i/claim-achievement',
|
||||
'/i/move',
|
||||
'/i/import-blocking',
|
||||
'/i/import-following',
|
||||
'/i/import-muting',
|
||||
'/i/import-user-lists',
|
||||
'/i/pin',
|
||||
'/mute/create',
|
||||
'/notes/create',
|
||||
'/notes/favorites/create',
|
||||
'/notes/polls/vote',
|
||||
'/notes/reactions/create',
|
||||
'/pages/create',
|
||||
'/pages/like',
|
||||
'/pages/unlike',
|
||||
'/pages/update',
|
||||
'/renote-mute/create',
|
||||
'/users/lists/create',
|
||||
'/users/lists/pull',
|
||||
'/users/lists/push',
|
||||
])('Prohibit access after moving: %s', async (endpoint) => {
|
||||
const res = await api(endpoint, {}, alice);
|
||||
assert.strictEqual(res.status, 403);
|
||||
assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED');
|
||||
assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31');
|
||||
});
|
||||
|
||||
test('Prohibit access after moving: /antennas/update', async () => {
|
||||
const res = await api('/antennas/update', {
|
||||
antennaId,
|
||||
name: rndstr('0-9a-z', 8),
|
||||
src: 'users',
|
||||
keywords: [rndstr('0-9a-z', 8)],
|
||||
excludeKeywords: [],
|
||||
users: [eve.id],
|
||||
caseSensitive: false,
|
||||
withReplies: false,
|
||||
withFile: false,
|
||||
notify: false,
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 403);
|
||||
assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED');
|
||||
assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31');
|
||||
});
|
||||
|
||||
test('Prohibit access after moving: /drive/files/create', async () => {
|
||||
const res = await uploadFile(alice);
|
||||
|
||||
assert.strictEqual(res.status, 403);
|
||||
assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED');
|
||||
assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31');
|
||||
});
|
||||
|
||||
test('Prohibit updating alsoKnownAs after moving', async () => {
|
||||
const res = await api('/i/update', {
|
||||
alsoKnownAs: [`@eve@${url.hostname}`],
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 403);
|
||||
assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED');
|
||||
assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -83,7 +83,7 @@ describe('ユーザー', () => {
|
|||
...userLite(user),
|
||||
url: user.url,
|
||||
uri: user.uri,
|
||||
movedToUri: user.movedToUri,
|
||||
movedTo: user.movedTo,
|
||||
alsoKnownAs: user.alsoKnownAs,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
|
@ -348,7 +348,7 @@ describe('ユーザー', () => {
|
|||
// UserDetailedNotMeOnly
|
||||
assert.strictEqual(response.url, null);
|
||||
assert.strictEqual(response.uri, null);
|
||||
assert.strictEqual(response.movedToUri, null);
|
||||
assert.strictEqual(response.movedTo, null);
|
||||
assert.strictEqual(response.alsoKnownAs, null);
|
||||
assert.strictEqual(response.createdAt, new Date(response.createdAt).toISOString());
|
||||
assert.strictEqual(response.updatedAt, null);
|
||||
|
|
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue