merge: Persisted instance blocks (!1068)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1068

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
Hazelnoot 2025-06-01 17:25:52 +00:00
commit e1504cfb88
56 changed files with 854 additions and 157 deletions

4
.gitignore vendored
View file

@ -87,3 +87,7 @@ vite.config.local-dev.ts.timestamp-*
# Sharkey # Sharkey
/packages/megalodon/lib /packages/megalodon/lib
# TypeScript
.tsbuildinfo
*.tsbuildinfo

View file

@ -2,7 +2,8 @@
"compilerOptions": { "compilerOptions": {
"lib": ["dom", "es5"], "lib": ["dom", "es5"],
"target": "es5", "target": "es5",
"types": ["cypress", "node"] "types": ["cypress", "node"],
"incremental": true
}, },
"include": ["./**/*.ts"] "include": ["./**/*.ts"]
} }

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class IndexIDXInstanceHostKey1748104955717 {
name = 'IndexIDXInstanceHostKey1748104955717'
async up(queryRunner) {
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_instance_host_key" ON "instance" (((lower(reverse("host")) || '.')::text) text_pattern_ops)`);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "IDX_instance_host_key"`);
}
}

View file

@ -0,0 +1,85 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {{ blockedHosts: string[], silencedHosts: string[], mediaSilencedHosts: string[], federationHosts: string[], bubbleInstances: string[] }} Meta
*/
/**
* @class
* @implements {MigrationInterface}
*/
export class AddInstanceBlockColumns1748105111513 {
name = 'AddInstanceBlockColumns1748105111513'
async up(queryRunner) {
// Schema migration
await queryRunner.query(`ALTER TABLE "instance" ADD "isBlocked" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`COMMENT ON COLUMN "instance"."isBlocked" IS 'True if this instance is blocked from federation.'`);
await queryRunner.query(`ALTER TABLE "instance" ADD "isAllowListed" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`COMMENT ON COLUMN "instance"."isAllowListed" IS 'True if this instance is allow-listed.'`);
await queryRunner.query(`ALTER TABLE "instance" ADD "isBubbled" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`COMMENT ON COLUMN "instance"."isBubbled" IS 'True if this instance is part of the local bubble.'`);
await queryRunner.query(`ALTER TABLE "instance" ADD "isSilenced" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`COMMENT ON COLUMN "instance"."isSilenced" IS 'True if this instance is silenced.'`);
await queryRunner.query(`ALTER TABLE "instance" ADD "isMediaSilenced" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`COMMENT ON COLUMN "instance"."isMediaSilenced" IS 'True if this instance is media-silenced.'`);
// Data migration
/** @type {Meta[]} */
const metas = await queryRunner.query(`SELECT "blockedHosts", "silencedHosts", "mediaSilencedHosts", "federationHosts", "bubbleInstances" FROM "meta"`);
if (metas.length > 0) {
/** @type {Meta} */
const meta = metas[0];
// Blocked hosts
if (meta.blockedHosts.length > 0) {
const patterns = buildPatterns(meta.blockedHosts);
await queryRunner.query(`UPDATE "instance" SET "isBlocked" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]);
}
// Silenced hosts
if (meta.silencedHosts.length > 0) {
const patterns = buildPatterns(meta.silencedHosts);
await queryRunner.query(`UPDATE "instance" SET "isSilenced" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]);
}
// Media silenced hosts
if (meta.mediaSilencedHosts.length > 0) {
const patterns = buildPatterns(meta.mediaSilencedHosts);
await queryRunner.query(`UPDATE "instance" SET "isMediaSilenced" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]);
}
// Allow-listed hosts
if (meta.federationHosts.length > 0) {
const patterns = buildPatterns(meta.federationHosts);
await queryRunner.query(`UPDATE "instance" SET "isAllowListed" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]);
}
// Bubbled hosts
if (meta.bubbleInstances.length > 0) {
const patterns = buildPatterns(meta.bubbleInstances);
await queryRunner.query(`UPDATE "instance" SET "isBubbled" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]);
}
}
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isMediaSilenced"`);
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isSilenced"`);
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isBubbled"`);
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isAllowListed"`);
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isBlocked"`);
}
}
/**
* @param {string[]} input
* @returns {string[]}
*/
function buildPatterns(input) {
return input.map(i => i.toLowerCase().split('').reverse().join('') + '.%');
}

View file

@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
*/
/**
* @class
* @implements {MigrationInterface}
*/
export class AddInstanceForeignKeys1748128176881 {
name = 'AddInstanceForeignKeys1748128176881'
async up(queryRunner) {
// Fix-up: Some older instances have users without a matching instance entry
await queryRunner.query(`
INSERT INTO "instance" ("id", "host", "firstRetrievedAt")
SELECT
MIN("id"),
"host",
COALESCE(MIN("lastFetchedAt"), CURRENT_TIMESTAMP)
FROM "user"
WHERE
"host" IS NOT NULL AND
NOT EXISTS (select 1 from "instance" where "instance"."host" = "user"."host")
GROUP BY "host"
`);
await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_user_host" FOREIGN KEY ("host") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_note_userHost" FOREIGN KEY ("userHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_note_replyUserHost" FOREIGN KEY ("replyUserHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_note_renoteUserHost" FOREIGN KEY ("renoteUserHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_note_renoteUserHost"`);
await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_note_replyUserHost"`);
await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_note_userHost"`);
await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_user_host"`);
}
}

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
*/
/**
* @class
* @implements {MigrationInterface}
*/
export class AddInstanceForeignKeysToFollowing1748137683887 {
name = 'AddInstanceForeignKeysToFollowing1748137683887'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "following" ADD CONSTRAINT "FK_following_followerHost" FOREIGN KEY ("followerHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "following" ADD CONSTRAINT "FK_following_followeeHost" FOREIGN KEY ("followeeHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "following" DROP CONSTRAINT "FK_following_followeeHost"`);
await queryRunner.query(`ALTER TABLE "following" DROP CONSTRAINT "FK_following_followerHost"`);
}
}

View file

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
*/
/**
* @class
* @implements {MigrationInterface}
*/
export class AnalyzeInstanceUserNoteFollowing1748191631151 {
name = 'AnalyzeInstanceUserNoteFollowing1748191631151'
async up(queryRunner) {
// Refresh statistics for tables impacted by new indexes.
// This helps the query planner to efficiently use them without waiting for the next full vacuum.
await queryRunner.query(`ANALYZE "instance", "user", "following", "note"`);
}
async down(queryRunner) {
}
}

View file

@ -10,6 +10,9 @@
"start": "node ./built/boot/entry.js", "start": "node ./built/boot/entry.js",
"start:test": "cross-env NODE_ENV=test node ./built/boot/entry.js", "start:test": "cross-env NODE_ENV=test node ./built/boot/entry.js",
"migrate": "pnpm typeorm migration:run -d ormconfig.js", "migrate": "pnpm typeorm migration:run -d ormconfig.js",
"migrate:revert": "pnpm typeorm migration:revert -d ormconfig.js",
"migrate:generate": "pnpm typeorm migration:generate -d ormconfig.js",
"migrate:create": "pnpm typeorm migration:create",
"revert": "pnpm typeorm migration:revert -d ormconfig.js", "revert": "pnpm typeorm migration:revert -d ormconfig.js",
"check:connect": "node ./scripts/check_connect.js", "check:connect": "node ./scripts/check_connect.js",
"build": "swc src -d built -D --strip-leading-paths", "build": "swc src -d built -D --strip-leading-paths",

View file

@ -136,10 +136,10 @@ export class FanoutTimelineEndpointService {
const parentFilter = filter; const parentFilter = filter;
filter = (note) => { filter = (note) => {
if (!ps.ignoreAuthorFromInstanceBlock) { if (!ps.ignoreAuthorFromInstanceBlock) {
if (this.utilityService.isBlockedHost(this.meta.blockedHosts, note.userHost)) return false; if (note.userInstance?.isBlocked) return false;
} }
if (note.userId !== note.renoteUserId && this.utilityService.isBlockedHost(this.meta.blockedHosts, note.renoteUserHost)) return false; if (note.userId !== note.renoteUserId && note.renoteUserInstance?.isBlocked) return false;
if (note.userId !== note.replyUserId && this.utilityService.isBlockedHost(this.meta.blockedHosts, note.replyUserHost)) return false; if (note.userId !== note.replyUserId && note.replyUserInstance?.isBlocked) return false;
return parentFilter(note); return parentFilter(note);
}; };
@ -194,7 +194,10 @@ export class FanoutTimelineEndpointService {
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel'); .leftJoinAndSelect('note.channel', 'channel')
.leftJoinAndSelect('note.userInstance', 'userInstance')
.leftJoinAndSelect('note.replyUserInstance', 'replyUserInstance')
.leftJoinAndSelect('note.renoteUserInstance', 'renoteUserInstance');
const notes = (await query.getMany()).filter(noteFilter); const notes = (await query.getMany()).filter(noteFilter);

View file

@ -5,23 +5,24 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import { QueryFailedError } from 'typeorm'; import type { InstancesRepository, MiMeta } from '@/models/_.js';
import type { InstancesRepository } from '@/models/_.js';
import type { MiInstance } from '@/models/Instance.js'; import type { MiInstance } from '@/models/Instance.js';
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import { MemoryKVCache } from '@/misc/cache.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { Serialized } from '@/types.js';
import { diffArrays, diffArraysSimple } from '@/misc/diff-arrays.js';
@Injectable() @Injectable()
export class FederatedInstanceService implements OnApplicationShutdown { export class FederatedInstanceService implements OnApplicationShutdown {
public federatedInstanceCache: RedisKVCache<MiInstance | null>; private readonly federatedInstanceCache: MemoryKVCache<MiInstance | null>;
constructor( constructor(
@Inject(DI.redis) @Inject(DI.redisForSub)
private redisClient: Redis.Redis, private redisForSub: Redis.Redis,
@Inject(DI.instancesRepository) @Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository, private instancesRepository: InstancesRepository,
@ -29,67 +30,46 @@ export class FederatedInstanceService implements OnApplicationShutdown {
private utilityService: UtilityService, private utilityService: UtilityService,
private idService: IdService, private idService: IdService,
) { ) {
this.federatedInstanceCache = new RedisKVCache<MiInstance | null>(this.redisClient, 'federatedInstance', { this.federatedInstanceCache = new MemoryKVCache(1000 * 60 * 3); // 3m
lifetime: 1000 * 60 * 30, // 30m this.redisForSub.on('message', this.onMessage);
memoryCacheLifetime: 1000 * 60 * 3, // 3m
fetcher: (key) => this.instancesRepository.findOneBy({ host: key }),
toRedisConverter: (value) => JSON.stringify(value),
fromRedisConverter: (value) => {
const parsed = JSON.parse(value);
if (parsed == null) return null;
return {
...parsed,
firstRetrievedAt: new Date(parsed.firstRetrievedAt),
latestRequestReceivedAt: parsed.latestRequestReceivedAt ? new Date(parsed.latestRequestReceivedAt) : null,
infoUpdatedAt: parsed.infoUpdatedAt ? new Date(parsed.infoUpdatedAt) : null,
notRespondingSince: parsed.notRespondingSince ? new Date(parsed.notRespondingSince) : null,
};
},
});
} }
@bindThis @bindThis
public async fetchOrRegister(host: string): Promise<MiInstance> { public async fetchOrRegister(host: string): Promise<MiInstance> {
host = this.utilityService.toPuny(host); host = this.utilityService.toPuny(host);
const cached = await this.federatedInstanceCache.get(host); const cached = this.federatedInstanceCache.get(host);
if (cached) return cached; if (cached) return cached;
const index = await this.instancesRepository.findOneBy({ host }); let index = await this.instancesRepository.findOneBy({ host });
if (index == null) { if (index == null) {
let i; await this.instancesRepository.createQueryBuilder('instance')
try { .insert()
i = await this.instancesRepository.insertOne({ .values({
id: this.idService.gen(), id: this.idService.gen(),
host, host,
firstRetrievedAt: new Date(), firstRetrievedAt: new Date(),
}); isBlocked: this.utilityService.isBlockedHost(host),
} catch (e: unknown) { isSilenced: this.utilityService.isSilencedHost(host),
if (e instanceof QueryFailedError) { isMediaSilenced: this.utilityService.isMediaSilencedHost(host),
if (isDuplicateKeyValueError(e)) { isAllowListed: this.utilityService.isAllowListedHost(host),
i = await this.instancesRepository.findOneBy({ host }); isBubbled: this.utilityService.isBubbledHost(host),
} })
.orIgnore()
.execute();
index = await this.instancesRepository.findOneByOrFail({ host });
} }
if (i == null) {
throw e;
}
}
this.federatedInstanceCache.set(host, i);
return i;
} else {
this.federatedInstanceCache.set(host, index); this.federatedInstanceCache.set(host, index);
return index; return index;
} }
}
@bindThis @bindThis
public async fetch(host: string): Promise<MiInstance | null> { public async fetch(host: string): Promise<MiInstance | null> {
host = this.utilityService.toPuny(host); host = this.utilityService.toPuny(host);
const cached = await this.federatedInstanceCache.get(host); const cached = this.federatedInstanceCache.get(host);
if (cached !== undefined) return cached; if (cached !== undefined) return cached;
const index = await this.instancesRepository.findOneBy({ host }); const index = await this.instancesRepository.findOneBy({ host });
@ -117,8 +97,35 @@ export class FederatedInstanceService implements OnApplicationShutdown {
this.federatedInstanceCache.set(result.host, result); this.federatedInstanceCache.set(result.host, result);
} }
private syncCache(before: Serialized<MiMeta | undefined>, after: Serialized<MiMeta>): void {
const changed =
diffArraysSimple(before?.blockedHosts, after.blockedHosts) ||
diffArraysSimple(before?.silencedHosts, after.silencedHosts) ||
diffArraysSimple(before?.mediaSilencedHosts, after.mediaSilencedHosts) ||
diffArraysSimple(before?.federationHosts, after.federationHosts) ||
diffArraysSimple(before?.bubbleInstances, after.bubbleInstances);
if (changed) {
// We have to clear the whole thing, otherwise subdomains won't be synced.
this.federatedInstanceCache.clear();
}
}
@bindThis
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
if (type === 'metaUpdated') {
this.syncCache(body.before, body.after);
}
}
}
@bindThis @bindThis
public dispose(): void { public dispose(): void {
this.redisForSub.off('message', this.onMessage);
this.federatedInstanceCache.dispose(); this.federatedInstanceCache.dispose();
} }

View file

@ -4,7 +4,7 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource, EntityManager } from 'typeorm';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { MiMeta } from '@/models/Meta.js'; import { MiMeta } from '@/models/Meta.js';
@ -12,6 +12,9 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { FeaturedService } from '@/core/FeaturedService.js'; import { FeaturedService } from '@/core/FeaturedService.js';
import { MiInstance } from '@/models/Instance.js';
import { diffArrays } from '@/misc/diff-arrays.js';
import type { MetasRepository } from '@/models/_.js';
import type { OnApplicationShutdown } from '@nestjs/common'; import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable() @Injectable()
@ -26,6 +29,9 @@ export class MetaService implements OnApplicationShutdown {
@Inject(DI.db) @Inject(DI.db)
private db: DataSource, private db: DataSource,
@Inject(DI.metasRepository)
private readonly metasRepository: MetasRepository,
private featuredService: FeaturedService, private featuredService: FeaturedService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
) { ) {
@ -67,35 +73,35 @@ export class MetaService implements OnApplicationShutdown {
public async fetch(noCache = false): Promise<MiMeta> { public async fetch(noCache = false): Promise<MiMeta> {
if (!noCache && this.cache) return this.cache; if (!noCache && this.cache) return this.cache;
return await this.db.transaction(async transactionalEntityManager => {
// 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する // 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する
const metas = await transactionalEntityManager.find(MiMeta, { let meta = await this.metasRepository.createQueryBuilder('meta')
order: { .select()
.orderBy({
id: 'DESC', id: 'DESC',
}, })
}); .limit(1)
.getOne();
const meta = metas[0]; if (!meta) {
await this.metasRepository.createQueryBuilder('meta')
.insert()
.values({
id: 'x',
})
.orIgnore()
.execute();
meta = await this.metasRepository.createQueryBuilder('meta')
.select()
.orderBy({
id: 'DESC',
})
.limit(1)
.getOneOrFail();
}
if (meta) {
this.cache = meta; this.cache = meta;
return meta; return meta;
} else {
// metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う
const saved = await transactionalEntityManager
.upsert(
MiMeta,
{
id: 'x',
},
['id'],
)
.then((x) => transactionalEntityManager.findOneByOrFail(MiMeta, x.identifiers[0]));
this.cache = saved;
return saved;
}
});
} }
@bindThis @bindThis
@ -103,7 +109,7 @@ export class MetaService implements OnApplicationShutdown {
let before: MiMeta | undefined; let before: MiMeta | undefined;
const updated = await this.db.transaction(async transactionalEntityManager => { const updated = await this.db.transaction(async transactionalEntityManager => {
const metas = await transactionalEntityManager.find(MiMeta, { const metas: (MiMeta | undefined)[] = await transactionalEntityManager.find(MiMeta, {
order: { order: {
id: 'DESC', id: 'DESC',
}, },
@ -126,6 +132,10 @@ export class MetaService implements OnApplicationShutdown {
}, },
}); });
// Propagate changes to blockedHosts, silencedHosts, mediaSilencedHosts, federationInstances, and bubbleInstances to the relevant instance rows
// Do this inside the transaction to avoid potential race condition (when an instance gets registered while we're updating).
await this.persistBlocks(transactionalEntityManager, before ?? {}, afters[0]);
return afters[0]; return afters[0];
}); });
@ -159,4 +169,49 @@ export class MetaService implements OnApplicationShutdown {
public onApplicationShutdown(signal?: string | undefined): void { public onApplicationShutdown(signal?: string | undefined): void {
this.dispose(); this.dispose();
} }
private async persistBlocks(tem: EntityManager, before: Partial<MiMeta>, after: Partial<MiMeta>): Promise<void> {
await this.persistBlock(tem, before.blockedHosts, after.blockedHosts, 'isBlocked');
await this.persistBlock(tem, before.silencedHosts, after.silencedHosts, 'isSilenced');
await this.persistBlock(tem, before.mediaSilencedHosts, after.mediaSilencedHosts, 'isMediaSilenced');
await this.persistBlock(tem, before.federationHosts, after.federationHosts, 'isAllowListed');
await this.persistBlock(tem, before.bubbleInstances, after.bubbleInstances, 'isBubbled');
}
private async persistBlock(tem: EntityManager, before: string[] | undefined, after: string[] | undefined, field: keyof MiInstance): Promise<void> {
const { added, removed } = diffArrays(before, after);
if (removed.length > 0) {
await this.updateInstancesByHost(tem, field, false, removed);
}
if (added.length > 0) {
await this.updateInstancesByHost(tem, field, true, added);
}
}
private async updateInstancesByHost(tem: EntityManager, field: keyof MiInstance, value: boolean, hosts: string[]): Promise<void> {
// Use non-array queries when possible, as they are indexed and can be much faster.
if (hosts.length === 1) {
const pattern = genHostPattern(hosts[0]);
await tem
.createQueryBuilder(MiInstance, 'instance')
.update()
.set({ [field]: value })
.where('(lower(reverse("host")) || \'.\') LIKE :pattern', { pattern })
.execute();
} else if (hosts.length > 1) {
const patterns = hosts.map(host => genHostPattern(host));
await tem
.createQueryBuilder(MiInstance, 'instance')
.update()
.set({ [field]: value })
.where('(lower(reverse("host")) || \'.\') LIKE ANY (:patterns)', { patterns })
.execute();
}
}
}
function genHostPattern(host: string): string {
return host.toLowerCase().split('').reverse().join('') + '.%';
} }

View file

@ -243,47 +243,40 @@ export class QueryService {
q.andWhere(new Brackets(qb => { q.andWhere(new Brackets(qb => {
qb qb
.where(new Brackets(qb => {
qb.where('note.renoteId IS NOT NULL');
qb.andWhere('note.text IS NULL');
qb.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`);
}))
.orWhere('note.renoteId IS NULL') .orWhere('note.renoteId IS NULL')
.orWhere('note.text IS NOT NULL'); .orWhere('note.text IS NOT NULL')
.orWhere('note.cw IS NOT NULL')
.orWhere('note.replyId IS NOT NULL')
.orWhere('note.fileIds != \'{}\'')
.orWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`);
})); }));
q.setParameters(mutingQuery.getParameters()); q.setParameters(mutingQuery.getParameters());
} }
@bindThis @bindThis
public generateBlockedHostQueryForNote(q: SelectQueryBuilder<any>, excludeAuthor?: boolean): void { public generateBlockedHostQueryForNote(q: SelectQueryBuilder<any>, excludeAuthor?: boolean, allowSilenced = true): void {
let nonBlockedHostQuery: (part: string) => string; function checkFor(key: 'user' | 'replyUser' | 'renoteUser') {
if (this.meta.blockedHosts.length === 0) { q.leftJoin(`note.${key}Instance`, `${key}Instance`);
nonBlockedHostQuery = () => '1=1'; q.andWhere(new Brackets(qb => {
} else { qb.orWhere(`note.${key}Id IS NULL`) // no corresponding user
nonBlockedHostQuery = (match: string) => `('.' || ${match}) NOT ILIKE ALL(select '%.' || x from (select unnest("blockedHosts") as x from "meta") t)`; .orWhere(`note.${key}Host IS NULL`) // local
.orWhere(`${key}Instance.isBlocked = false`); // not blocked
if (!allowSilenced) {
qb.orWhere(`${key}Instance.isSilenced = false`); // not silenced
} }
if (excludeAuthor) { if (excludeAuthor) {
const instanceSuspension = (user: string) => new Brackets(qb => qb qb.orWhere(`note.userId = note.${key}Id`); // author
.where(`note.${user}Id IS NULL`) // no corresponding user }
.orWhere(`note.userId = note.${user}Id`) }));
.orWhere(`note.${user}Host IS NULL`) // local }
.orWhere(nonBlockedHostQuery(`note.${user}Host`)));
q if (!excludeAuthor) {
.andWhere(instanceSuspension('replyUser')) checkFor('user');
.andWhere(instanceSuspension('renoteUser')); }
} else { checkFor('replyUser');
const instanceSuspension = (user: string) => new Brackets(qb => qb checkFor('renoteUser');
.where(`note.${user}Id IS NULL`) // no corresponding user
.orWhere(`note.${user}Host IS NULL`) // local
.orWhere(nonBlockedHostQuery(`note.${user}Host`)));
q
.andWhere(instanceSuspension('user'))
.andWhere(instanceSuspension('replyUser'))
.andWhere(instanceSuspension('renoteUser'));
}
} }
} }

View file

@ -587,6 +587,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
lastActiveDate: parsed.user1.lastActiveDate != null ? new Date(parsed.user1.lastActiveDate) : null, lastActiveDate: parsed.user1.lastActiveDate != null ? new Date(parsed.user1.lastActiveDate) : null,
lastFetchedAt: parsed.user1.lastFetchedAt != null ? new Date(parsed.user1.lastFetchedAt) : null, lastFetchedAt: parsed.user1.lastFetchedAt != null ? new Date(parsed.user1.lastFetchedAt) : null,
movedAt: parsed.user1.movedAt != null ? new Date(parsed.user1.movedAt) : null, movedAt: parsed.user1.movedAt != null ? new Date(parsed.user1.movedAt) : null,
instance: null,
} : null, } : null,
user2: parsed.user2 != null ? { user2: parsed.user2 != null ? {
...parsed.user2, ...parsed.user2,
@ -597,6 +598,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
lastActiveDate: parsed.user2.lastActiveDate != null ? new Date(parsed.user2.lastActiveDate) : null, lastActiveDate: parsed.user2.lastActiveDate != null ? new Date(parsed.user2.lastActiveDate) : null,
lastFetchedAt: parsed.user2.lastFetchedAt != null ? new Date(parsed.user2.lastFetchedAt) : null, lastFetchedAt: parsed.user2.lastFetchedAt != null ? new Date(parsed.user2.lastFetchedAt) : null,
movedAt: parsed.user2.movedAt != null ? new Date(parsed.user2.movedAt) : null, movedAt: parsed.user2.movedAt != null ? new Date(parsed.user2.movedAt) : null,
instance: null,
} : null, } : null,
}; };
} else { } else {

View file

@ -49,22 +49,49 @@ export class UtilityService {
return regexp.test(email); return regexp.test(email);
} }
public isBlockedHost(host: string | null): boolean;
public isBlockedHost(blockedHosts: string[], host: string | null): boolean;
@bindThis @bindThis
public isBlockedHost(blockedHosts: string[], host: string | null): boolean { public isBlockedHost(blockedHostsOrHost: string[] | string | null, host?: string | null): boolean {
const blockedHosts = Array.isArray(blockedHostsOrHost) ? blockedHostsOrHost : this.meta.blockedHosts;
host = Array.isArray(blockedHostsOrHost) ? host : blockedHostsOrHost;
if (host == null) return false; if (host == null) return false;
return blockedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); return blockedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
} }
public isSilencedHost(host: string | null): boolean;
public isSilencedHost(silencedHosts: string[], host: string | null): boolean;
@bindThis @bindThis
public isSilencedHost(silencedHosts: string[] | undefined, host: string | null): boolean { public isSilencedHost(silencedHostsOrHost: string[] | string | null, host?: string | null): boolean {
if (!silencedHosts || host == null) return false; const silencedHosts = Array.isArray(silencedHostsOrHost) ? silencedHostsOrHost : this.meta.silencedHosts;
host = Array.isArray(silencedHostsOrHost) ? host : silencedHostsOrHost;
if (host == null) return false;
return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
} }
public isMediaSilencedHost(host: string | null): boolean;
public isMediaSilencedHost(silencedHosts: string[], host: string | null): boolean;
@bindThis @bindThis
public isMediaSilencedHost(silencedHosts: string[] | undefined, host: string | null): boolean { public isMediaSilencedHost(mediaSilencedHostsOrHost: string[] | string | null, host?: string | null): boolean {
if (!silencedHosts || host == null) return false; const mediaSilencedHosts = Array.isArray(mediaSilencedHostsOrHost) ? mediaSilencedHostsOrHost : this.meta.mediaSilencedHosts;
return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`)); host = Array.isArray(mediaSilencedHostsOrHost) ? host : mediaSilencedHostsOrHost;
if (host == null) return false;
return mediaSilencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
}
@bindThis
public isAllowListedHost(host: string | null): boolean {
if (host == null) return false;
return this.meta.federationHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
}
@bindThis
public isBubbledHost(host: string | null): boolean {
if (host == null) return false;
return this.meta.bubbleInstances.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
} }
@bindThis @bindThis

View file

@ -63,6 +63,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
emojis: [], emojis: [],
score: 0, score: 0,
host: null, host: null,
instance: null,
inbox: null, inbox: null,
sharedInbox: null, sharedInbox: null,
featured: null, featured: null,
@ -114,10 +115,13 @@ function generateDummyNote(override?: Partial<MiNote>): MiNote {
channelId: null, channelId: null,
channel: null, channel: null,
userHost: null, userHost: null,
userInstance: null,
replyUserId: null, replyUserId: null,
replyUserHost: null, replyUserHost: null,
replyUserInstance: null,
renoteUserId: null, renoteUserId: null,
renoteUserHost: null, renoteUserHost: null,
renoteUserInstance: null,
updatedAt: null, updatedAt: null,
processErrors: [], processErrors: [],
...override, ...override,
@ -449,6 +453,7 @@ export class WebhookTestService {
isAdmin: false, isAdmin: false,
isModerator: false, isModerator: false,
isSystem: false, isSystem: false,
instance: undefined,
...override, ...override,
}; };
} }

View file

@ -398,6 +398,9 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
const followerscollection = await _resolver.resolveCollection(person.followers!).catch(() => { return null; }); const followerscollection = await _resolver.resolveCollection(person.followers!).catch(() => { return null; });
const followingcollection = await _resolver.resolveCollection(person.following!).catch(() => { return null; }); const followingcollection = await _resolver.resolveCollection(person.following!).catch(() => { return null; });
// Register the instance first, to avoid FK errors
await this.federatedInstanceService.fetchOrRegister(host);
try { try {
// Start transaction // Start transaction
await this.db.transaction(async transactionalEntityManager => { await this.db.transaction(async transactionalEntityManager => {

View file

@ -44,10 +44,6 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
} }
protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> { protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> {
const suspendedInstancesQuery = this.instancesRepository.createQueryBuilder('instance')
.select('instance.host')
.where('instance.suspensionState != \'none\'');
const pubsubSubQuery = this.followingsRepository.createQueryBuilder('f') const pubsubSubQuery = this.followingsRepository.createQueryBuilder('f')
.select('f.followerHost') .select('f.followerHost')
.where('f.followerHost IS NOT NULL'); .where('f.followerHost IS NOT NULL');
@ -64,22 +60,25 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
this.followingsRepository.createQueryBuilder('following') this.followingsRepository.createQueryBuilder('following')
.select('COUNT(DISTINCT following.followeeHost)') .select('COUNT(DISTINCT following.followeeHost)')
.where('following.followeeHost IS NOT NULL') .where('following.followeeHost IS NOT NULL')
.andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : '(\'.\' || following.followeeHost) NOT ILIKE ALL(select \'%.\' || x from (select unnest("blockedHosts") as x from "meta") t)') .innerJoin('following.followeeInstance', 'followeeInstance')
.andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) .andWhere('followeeInstance.suspensionState = \'none\'')
.andWhere('followeeInstance.isBlocked = false')
.getRawOne() .getRawOne()
.then(x => parseInt(x.count, 10)), .then(x => parseInt(x.count, 10)),
this.followingsRepository.createQueryBuilder('following') this.followingsRepository.createQueryBuilder('following')
.select('COUNT(DISTINCT following.followerHost)') .select('COUNT(DISTINCT following.followerHost)')
.where('following.followerHost IS NOT NULL') .where('following.followerHost IS NOT NULL')
.andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : '(\'.\' || following.followerHost) NOT ILIKE ALL(select \'%.\' || x from (select unnest("blockedHosts") as x from "meta") t)') .innerJoin('following.followerInstance', 'followerInstance')
.andWhere(`following.followerHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) .andWhere('followerInstance.isBlocked = false')
.andWhere('followerInstance.suspensionState = \'none\'')
.getRawOne() .getRawOne()
.then(x => parseInt(x.count, 10)), .then(x => parseInt(x.count, 10)),
this.followingsRepository.createQueryBuilder('following') this.followingsRepository.createQueryBuilder('following')
.select('COUNT(DISTINCT following.followeeHost)') .select('COUNT(DISTINCT following.followeeHost)')
.where('following.followeeHost IS NOT NULL') .where('following.followeeHost IS NOT NULL')
.andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : '(\'.\' || following.followeeHost) NOT ILIKE ALL(select \'%.\' || x from (select unnest("blockedHosts") as x from "meta") t)') .innerJoin('following.followeeInstance', 'followeeInstance')
.andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) .andWhere('followeeInstance.isBlocked = false')
.andWhere('followeeInstance.suspensionState = \'none\'')
.andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`) .andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`)
.setParameters(pubsubSubQuery.getParameters()) .setParameters(pubsubSubQuery.getParameters())
.getRawOne() .getRawOne()
@ -87,7 +86,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
this.instancesRepository.createQueryBuilder('instance') this.instancesRepository.createQueryBuilder('instance')
.select('COUNT(instance.id)') .select('COUNT(instance.id)')
.where(`instance.host IN (${ subInstancesQuery.getQuery() })`) .where(`instance.host IN (${ subInstancesQuery.getQuery() })`)
.andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : '(\'.\' || instance.host) NOT ILIKE ALL(select \'%.\' || x from (select unnest("blockedHosts") as x from "meta") t)') .andWhere('instance.isBlocked = false')
.andWhere('instance.suspensionState = \'none\'') .andWhere('instance.suspensionState = \'none\'')
.andWhere('instance.isNotResponding = false') .andWhere('instance.isNotResponding = false')
.getRawOne() .getRawOne()
@ -95,7 +94,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
this.instancesRepository.createQueryBuilder('instance') this.instancesRepository.createQueryBuilder('instance')
.select('COUNT(instance.id)') .select('COUNT(instance.id)')
.where(`instance.host IN (${ pubInstancesQuery.getQuery() })`) .where(`instance.host IN (${ pubInstancesQuery.getQuery() })`)
.andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : '(\'.\' || instance.host) NOT ILIKE ALL(select \'%.\' || x from (select unnest("blockedHosts") as x from "meta") t)') .andWhere('instance.isBlocked = false')
.andWhere('instance.suspensionState = \'none\'') .andWhere('instance.suspensionState = \'none\'')
.andWhere('instance.isNotResponding = false') .andWhere('instance.isNotResponding = false')
.getRawOne() .getRawOne()

View file

@ -43,7 +43,7 @@ export class InstanceEntityService {
isNotResponding: instance.isNotResponding, isNotResponding: instance.isNotResponding,
isSuspended: instance.suspensionState !== 'none', isSuspended: instance.suspensionState !== 'none',
suspensionState: instance.suspensionState, suspensionState: instance.suspensionState,
isBlocked: this.utilityService.isBlockedHost(this.meta.blockedHosts, instance.host), isBlocked: instance.isBlocked,
softwareName: instance.softwareName, softwareName: instance.softwareName,
softwareVersion: instance.softwareVersion, softwareVersion: instance.softwareVersion,
openRegistrations: instance.openRegistrations, openRegistrations: instance.openRegistrations,
@ -51,8 +51,8 @@ export class InstanceEntityService {
description: instance.description, description: instance.description,
maintainerName: instance.maintainerName, maintainerName: instance.maintainerName,
maintainerEmail: instance.maintainerEmail, maintainerEmail: instance.maintainerEmail,
isSilenced: this.utilityService.isSilencedHost(this.meta.silencedHosts, instance.host), isSilenced: instance.isSilenced,
isMediaSilenced: this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, instance.host), isMediaSilenced: instance.isMediaSilenced,
iconUrl: instance.iconUrl, iconUrl: instance.iconUrl,
faviconUrl: instance.faviconUrl, faviconUrl: instance.faviconUrl,
themeColor: instance.themeColor, themeColor: instance.themeColor,

View file

@ -609,7 +609,7 @@ export class UserEntityService implements OnModuleInit {
requireSigninToViewContents: user.requireSigninToViewContents === false ? undefined : true, requireSigninToViewContents: user.requireSigninToViewContents === false ? undefined : true,
makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore ?? undefined, makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore ?? undefined,
makeNotesHiddenBefore: user.makeNotesHiddenBefore ?? undefined, makeNotesHiddenBefore: user.makeNotesHiddenBefore ?? undefined,
instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? { instance: user.host ? this.federatedInstanceService.fetch(user.host).then(instance => instance ? {
name: instance.name, name: instance.name,
softwareName: instance.softwareName, softwareName: instance.softwareName,
softwareVersion: instance.softwareVersion, softwareVersion: instance.softwareVersion,

View file

@ -308,8 +308,17 @@ export class MemoryKVCache<T> {
} }
} }
/**
* Removes all entries from the cache, but does not dispose it.
*/
@bindThis
public clear(): void {
this.cache.clear();
}
@bindThis @bindThis
public dispose(): void { public dispose(): void {
this.clear();
clearInterval(this.gcIntervalHandle); clearInterval(this.gcIntervalHandle);
} }

View file

@ -0,0 +1,102 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface DiffResult<T> {
added: T[];
removed: T[];
}
/**
* Calculates the difference between two snapshots of data.
* Null, undefined, and empty arrays are supported, and duplicate values are ignored.
* Result sets are de-duplicated, and will be empty if no data was added or removed (respectively).
* The inputs are treated as un-ordered, so a re-ordering of the same data will NOT be considered a change.
* @param dataBefore Array containing data before the change
* @param dataAfter Array containing data after the change
*/
export function diffArrays<T>(dataBefore: T[] | null | undefined, dataAfter: T[] | null | undefined): DiffResult<T> {
const before = dataBefore ? new Set(dataBefore) : null;
const after = dataAfter ? new Set(dataAfter) : null;
// data before AND after => changed
if (before?.size && after?.size) {
const added: T[] = [];
const removed: T[] = [];
for (const host of before) {
// before and NOT after => removed
// delete operation removes duplicates to speed up the "after" loop
if (!after.delete(host)) {
removed.push(host);
}
}
for (const host of after) {
// after and NOT before => added
if (!before.has(host)) {
added.push(host);
}
}
return { added, removed };
}
// data ONLY before => all removed
if (before?.size) {
return { added: [], removed: Array.from(before) };
}
// data ONLY after => all added
if (after?.size) {
return { added: Array.from(after), removed: [] };
}
// data NEITHER before nor after => no change
return { added: [], removed: [] };
}
/**
* Checks for any difference between two snapshots of data.
* Null, undefined, and empty arrays are supported, and duplicate values are ignored.
* The inputs are treated as un-ordered, so a re-ordering of the same data will NOT be considered a change.
* @param dataBefore Array containing data before the change
* @param dataAfter Array containing data after the change
*/
export function diffArraysSimple<T>(dataBefore: T[] | null | undefined, dataAfter: T[] | null | undefined): boolean {
const before = dataBefore ? new Set(dataBefore) : null;
const after = dataAfter ? new Set(dataAfter) : null;
if (before?.size && after?.size) {
// different size => changed
if (before.size !== after.size) return true;
// removed => changed
for (const host of before) {
// delete operation removes duplicates to speed up the "after" loop
if (!after.delete(host)) {
return true;
}
}
// added => changed
for (const host of after) {
if (!before.has(host)) {
return true;
}
}
// identical values => no change
return false;
}
// before and NOT after => change
if (before?.size) return true;
// after and NOT before => change
if (after?.size) return true;
// NEITHER before nor after => no change
return false;
}

View file

@ -4,6 +4,7 @@
*/ */
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { MiInstance } from '@/models/Instance.js';
import { id } from './util/id.js'; import { id } from './util/id.js';
import { MiUser } from './User.js'; import { MiUser } from './User.js';
@ -66,6 +67,16 @@ export class MiFollowing {
}) })
public followerHost: string | null; public followerHost: string | null;
@ManyToOne(() => MiInstance, {
onDelete: 'CASCADE',
})
@JoinColumn({
name: 'followerHost',
foreignKeyConstraintName: 'FK_following_followerHost',
referencedColumnName: 'host',
})
public followerInstance: MiInstance | null;
@Column('varchar', { @Column('varchar', {
length: 512, nullable: true, length: 512, nullable: true,
comment: '[Denormalized]', comment: '[Denormalized]',
@ -85,6 +96,16 @@ export class MiFollowing {
}) })
public followeeHost: string | null; public followeeHost: string | null;
@ManyToOne(() => MiInstance, {
onDelete: 'CASCADE',
})
@JoinColumn({
name: 'followeeHost',
foreignKeyConstraintName: 'FK_following_followeeHost',
referencedColumnName: 'host',
})
public followeeInstance: MiInstance | null;
@Column('varchar', { @Column('varchar', {
length: 512, nullable: true, length: 512, nullable: true,
comment: '[Denormalized]', comment: '[Denormalized]',

View file

@ -6,6 +6,7 @@
import { Entity, PrimaryColumn, Index, Column } from 'typeorm'; import { Entity, PrimaryColumn, Index, Column } from 'typeorm';
import { id } from './util/id.js'; import { id } from './util/id.js';
@Index('IDX_instance_host_key', { synchronize: false })
@Entity('instance') @Entity('instance')
export class MiInstance { export class MiInstance {
@PrimaryColumn(id()) @PrimaryColumn(id())
@ -98,6 +99,56 @@ export class MiInstance {
}) })
public suspensionState: 'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding'; public suspensionState: 'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding';
/**
* True if this instance is blocked from federation.
*/
@Column('boolean', {
nullable: false,
default: false,
comment: 'True if this instance is blocked from federation.',
})
public isBlocked: boolean;
/**
* True if this instance is allow-listed.
*/
@Column('boolean', {
nullable: false,
default: false,
comment: 'True if this instance is allow-listed.',
})
public isAllowListed: boolean;
/**
* True if this instance is part of the local bubble.
*/
@Column('boolean', {
nullable: false,
default: false,
comment: 'True if this instance is part of the local bubble.',
})
public isBubbled: boolean;
/**
* True if this instance is silenced.
*/
@Column('boolean', {
nullable: false,
default: false,
comment: 'True if this instance is silenced.',
})
public isSilenced: boolean;
/**
* True if this instance is media-silenced.
*/
@Column('boolean', {
nullable: false,
default: false,
comment: 'True if this instance is media-silenced.',
})
public isMediaSilenced: boolean;
@Column('varchar', { @Column('varchar', {
length: 64, nullable: true, length: 64, nullable: true,
comment: 'The software of the Instance.', comment: 'The software of the Instance.',

View file

@ -5,6 +5,7 @@
import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
import { noteVisibilities } from '@/types.js'; import { noteVisibilities } from '@/types.js';
import { MiInstance } from '@/models/Instance.js';
import { id } from './util/id.js'; import { id } from './util/id.js';
import { MiUser } from './User.js'; import { MiUser } from './User.js';
import { MiChannel } from './Channel.js'; import { MiChannel } from './Channel.js';
@ -222,6 +223,16 @@ export class MiNote {
}) })
public userHost: string | null; public userHost: string | null;
@ManyToOne(() => MiInstance, {
onDelete: 'CASCADE',
})
@JoinColumn({
name: 'userHost',
foreignKeyConstraintName: 'FK_note_userHost',
referencedColumnName: 'host',
})
public userInstance: MiInstance | null;
@Column({ @Column({
...id(), ...id(),
nullable: true, nullable: true,
@ -235,6 +246,16 @@ export class MiNote {
}) })
public replyUserHost: string | null; public replyUserHost: string | null;
@ManyToOne(() => MiInstance, {
onDelete: 'CASCADE',
})
@JoinColumn({
name: 'replyUserHost',
foreignKeyConstraintName: 'FK_note_replyUserHost',
referencedColumnName: 'host',
})
public replyUserInstance: MiInstance | null;
@Column({ @Column({
...id(), ...id(),
nullable: true, nullable: true,
@ -247,6 +268,16 @@ export class MiNote {
comment: '[Denormalized]', comment: '[Denormalized]',
}) })
public renoteUserHost: string | null; public renoteUserHost: string | null;
@ManyToOne(() => MiInstance, {
onDelete: 'CASCADE',
})
@JoinColumn({
name: 'renoteUserHost',
foreignKeyConstraintName: 'FK_note_renoteUserHost',
referencedColumnName: 'host',
})
public renoteUserInstance: MiInstance | null;
//#endregion //#endregion
constructor(data: Partial<MiNote>) { constructor(data: Partial<MiNote>) {

View file

@ -3,8 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn, ManyToOne } from 'typeorm';
import { type UserUnsignedFetchOption, userUnsignedFetchOptions } from '@/const.js'; import { type UserUnsignedFetchOption, userUnsignedFetchOptions } from '@/const.js';
import { MiInstance } from '@/models/Instance.js';
import { id } from './util/id.js'; import { id } from './util/id.js';
import { MiDriveFile } from './DriveFile.js'; import { MiDriveFile } from './DriveFile.js';
@ -292,6 +293,16 @@ export class MiUser {
}) })
public host: string | null; public host: string | null;
@ManyToOne(() => MiInstance, {
onDelete: 'CASCADE',
})
@JoinColumn({
name: 'host',
foreignKeyConstraintName: 'FK_user_host',
referencedColumnName: 'host',
})
public instance: MiInstance | null;
@Column('varchar', { @Column('varchar', {
length: 512, nullable: true, length: 512, nullable: true,
comment: 'The inbox URL of the User. It will be null if the origin of the user is local.', comment: 'The inbox URL of the User. It will be null if the origin of the user is local.',

View file

@ -121,6 +121,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me); this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
const notes = await query.getMany(); const notes = await query.getMany();
if (sinceId != null && untilId == null) { if (sinceId != null && untilId == null) {

View file

@ -138,9 +138,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('note.channel', 'channel'); .leftJoinAndSelect('note.channel', 'channel');
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateVisibilityQuery(query, me);
if (me) { if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
} }
if (ps.withRenotes === false) { if (ps.withRenotes === false) {

View file

@ -92,10 +92,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.andWhere('clipNote.clipId = :clipId', { clipId: clip.id }); .andWhere('clipNote.clipId = :clipId', { clipId: clip.id });
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
if (me) {
this.queryService.generateVisibilityQuery(query, me); this.queryService.generateVisibilityQuery(query, me);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
} }
const notes = await query const notes = await query

View file

@ -85,7 +85,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.visibility = \'public\'') .andWhere('note.visibility = \'public\'')
.andWhere('note.channelId IS NULL') .andWhere('note.channelId IS NULL')
.andWhere('note.userHost IN (:...hosts)', { hosts: this.serverSettings.bubbleInstances }) .andWhere('note.userHost IS NULL')
.andWhere('userInstance.isBubbled = true') // This comes from generateVisibilityQuery below
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')

View file

@ -4,7 +4,7 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { IsNull, ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { SkLatestNote, MiFollowing } from '@/models/_.js'; import { SkLatestNote, MiFollowing } from '@/models/_.js';
import type { NotesRepository } from '@/models/_.js'; import type { NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
@ -130,7 +130,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel')
// Exclude channel notes
.andWhere({ channelId: IsNull() })
; ;
// Limit to files, if requested // Limit to files, if requested
@ -145,11 +147,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// Hide blocked users / instances // Hide blocked users / instances
query.andWhere('"user"."isSuspended" = false'); query.andWhere('"user"."isSuspended" = false');
query.andWhere('("replyUser" IS NULL OR "replyUser"."isSuspended" = false)');
query.andWhere('("renoteUser" IS NULL OR "renoteUser"."isSuspended" = false)');
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
// Respect blocks and mutes // Respect blocks, mutes, and privacy
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
@ -161,7 +162,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// Query and return the next page // Query and return the next page
const notes = await query.getMany(); const notes = await query.getMany();
return await this.noteEntityService.packMany(notes, me); return await this.noteEntityService.packMany(notes, me, { skipHide: true });
}); });
} }
} }

View file

@ -96,10 +96,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (!this.serverSettings.enableBotTrending) query.andWhere('user.isBot = FALSE'); if (!this.serverSettings.enableBotTrending) query.andWhere('user.isBot = FALSE');
this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query, undefined, false);
this.queryService.generateBlockedHostQueryForNote(query);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me); if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : {}; const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : {};
@ -160,7 +160,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (note.user?.isSuspended) return false; if (note.user?.isSuspended) return false;
if (note.userHost) { if (note.userHost) {
if (!this.utilityService.isFederationAllowedHost(note.userHost)) return false; if (!this.utilityService.isFederationAllowedHost(note.userHost)) return false;
if (this.utilityService.isSilencedHost(this.serverSettings.silencedHosts, note.userHost)) return false;
} }
return true; return true;
}); });

View file

@ -107,10 +107,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
const notes = await query.getMany(); const notes = await query.getMany();
notes.sort((a, b) => a.id > b.id ? -1 : 1); notes.sort((a, b) => a.id > b.id ? -1 : 1);

View file

@ -105,10 +105,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'), const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('reaction.userId = :userId', { userId: ps.userId }) .andWhere('reaction.userId = :userId', { userId: ps.userId })
.leftJoinAndSelect('reaction.note', 'note'); .innerJoinAndSelect('reaction.note', 'note');
this.queryService.generateVisibilityQuery(query, me); this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
const reactions = (await query const reactions = (await query
.limit(ps.limit) .limit(ps.limit)

View file

@ -3,7 +3,7 @@
/* Visit https://aka.ms/tsconfig to read more about this file */ /* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */ /* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */

View file

@ -23,6 +23,7 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"incremental": true,
"rootDir": "../src", "rootDir": "../src",
"baseUrl": "./", "baseUrl": "./",
"paths": { "paths": {

View file

@ -23,6 +23,7 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"incremental": true,
"baseUrl": "./", "baseUrl": "./",
"paths": { "paths": {
"@/*": ["../src/*"] "@/*": ["../src/*"]

View file

@ -11,6 +11,7 @@ import { GlobalModule } from '@/GlobalModule.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { CoreModule } from '@/core/CoreModule.js'; import { CoreModule } from '@/core/CoreModule.js';
import { MetasRepository } from '@/models/_.js';
import type { TestingModule } from '@nestjs/testing'; import type { TestingModule } from '@nestjs/testing';
import type { DataSource } from 'typeorm'; import type { DataSource } from 'typeorm';
@ -39,8 +40,8 @@ describe('MetaService', () => {
}); });
test('fetch (cache)', async () => { test('fetch (cache)', async () => {
const db = app.get<DataSource>(DI.db); const metasRepository = app.get<MetasRepository>(DI.metasRepository);
const spy = jest.spyOn(db, 'transaction'); const spy = jest.spyOn(metasRepository, 'createQueryBuilder');
const result = await metaService.fetch(); const result = await metaService.fetch();
@ -49,12 +50,12 @@ describe('MetaService', () => {
}); });
test('fetch (force)', async () => { test('fetch (force)', async () => {
const db = app.get<DataSource>(DI.db); const metasRepository = app.get<MetasRepository>(DI.metasRepository);
const spy = jest.spyOn(db, 'transaction'); const spy = jest.spyOn(metasRepository, 'createQueryBuilder');
const result = await metaService.fetch(true); const result = await metaService.fetch(true);
expect(result.id).toBe('x'); expect(result.id).toBe('x');
expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalled();
}); });
}); });

View file

@ -57,10 +57,13 @@ describe('NoteCreateService', () => {
channelId: null, channelId: null,
channel: null, channel: null,
userHost: null, userHost: null,
userInstance: null,
replyUserId: null, replyUserId: null,
replyUserHost: null, replyUserHost: null,
replyUserInstance: null,
renoteUserId: null, renoteUserId: null,
renoteUserHost: null, renoteUserHost: null,
renoteUserInstance: null,
processErrors: [], processErrors: [],
}; };

View file

@ -15,6 +15,7 @@ import type { MockFunctionMetadata } from 'jest-mock';
import { GlobalModule } from '@/GlobalModule.js'; import { GlobalModule } from '@/GlobalModule.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { import {
InstancesRepository,
MiMeta, MiMeta,
MiRole, MiRole,
MiRoleAssignment, MiRoleAssignment,
@ -39,6 +40,7 @@ const moduleMocker = new ModuleMocker(global);
describe('RoleService', () => { describe('RoleService', () => {
let app: TestingModule; let app: TestingModule;
let roleService: RoleService; let roleService: RoleService;
let instancesRepository: InstancesRepository;
let usersRepository: UsersRepository; let usersRepository: UsersRepository;
let rolesRepository: RolesRepository; let rolesRepository: RolesRepository;
let roleAssignmentsRepository: RoleAssignmentsRepository; let roleAssignmentsRepository: RoleAssignmentsRepository;
@ -47,6 +49,19 @@ describe('RoleService', () => {
let clock: lolex.InstalledClock; let clock: lolex.InstalledClock;
async function createUser(data: Partial<MiUser> = {}) { async function createUser(data: Partial<MiUser> = {}) {
if (data.host != null) {
await instancesRepository
.createQueryBuilder('instance')
.insert()
.values({
id: genAidx(Date.now()),
firstRetrievedAt: new Date(),
host: data.host,
})
.orIgnore()
.execute();
}
const un = secureRndstr(16); const un = secureRndstr(16);
const x = await usersRepository.insert({ const x = await usersRepository.insert({
id: genAidx(Date.now()), id: genAidx(Date.now()),
@ -145,6 +160,7 @@ describe('RoleService', () => {
app.enableShutdownHooks(); app.enableShutdownHooks();
roleService = app.get<RoleService>(RoleService); roleService = app.get<RoleService>(RoleService);
instancesRepository = app.get<InstancesRepository>(DI.instancesRepository);
usersRepository = app.get<UsersRepository>(DI.usersRepository); usersRepository = app.get<UsersRepository>(DI.usersRepository);
rolesRepository = app.get<RolesRepository>(DI.rolesRepository); rolesRepository = app.get<RolesRepository>(DI.rolesRepository);
roleAssignmentsRepository = app.get<RoleAssignmentsRepository>(DI.roleAssignmentsRepository); roleAssignmentsRepository = app.get<RoleAssignmentsRepository>(DI.roleAssignmentsRepository);

View file

@ -7,16 +7,18 @@ import { Test, TestingModule } from '@nestjs/testing';
import { describe, jest, test } from '@jest/globals'; import { describe, jest, test } from '@jest/globals';
import { In } from 'typeorm'; import { In } from 'typeorm';
import { UserSearchService } from '@/core/UserSearchService.js'; import { UserSearchService } from '@/core/UserSearchService.js';
import { FollowingsRepository, MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import { FollowingsRepository, InstancesRepository, MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { GlobalModule } from '@/GlobalModule.js'; import { GlobalModule } from '@/GlobalModule.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { genAidx } from '@/misc/id/aidx.js';
describe('UserSearchService', () => { describe('UserSearchService', () => {
let app: TestingModule; let app: TestingModule;
let service: UserSearchService; let service: UserSearchService;
let instancesRepository: InstancesRepository;
let usersRepository: UsersRepository; let usersRepository: UsersRepository;
let followingsRepository: FollowingsRepository; let followingsRepository: FollowingsRepository;
let idService: IdService; let idService: IdService;
@ -35,6 +37,19 @@ describe('UserSearchService', () => {
let bobby: MiUser; let bobby: MiUser;
async function createUser(data: Partial<MiUser> = {}) { async function createUser(data: Partial<MiUser> = {}) {
if (data.host != null) {
await instancesRepository
.createQueryBuilder('instance')
.insert()
.values({
id: genAidx(Date.now()),
firstRetrievedAt: new Date(),
host: data.host,
})
.orIgnore()
.execute();
}
const user = await usersRepository const user = await usersRepository
.insert({ .insert({
id: idService.gen(), id: idService.gen(),
@ -104,6 +119,7 @@ describe('UserSearchService', () => {
await app.init(); await app.init();
instancesRepository = app.get<InstancesRepository>(DI.instancesRepository);
usersRepository = app.get(DI.usersRepository); usersRepository = app.get(DI.usersRepository);
userProfilesRepository = app.get(DI.userProfilesRepository); userProfilesRepository = app.get(DI.userProfilesRepository);
followingsRepository = app.get(DI.followingsRepository); followingsRepository = app.get(DI.followingsRepository);

View file

@ -103,6 +103,25 @@ describe('ActivityPub', () => {
let config: Config; let config: Config;
const metaInitial = { const metaInitial = {
id: 'x',
name: 'Test Instance',
shortName: 'Test Instance',
description: 'Test Instance',
langs: [] as string[],
pinnedUsers: [] as string[],
hiddenTags: [] as string[],
prohibitedWordsForNameOfUser: [] as string[],
silencedHosts: [] as string[],
mediaSilencedHosts: [] as string[],
policies: {},
serverRules: [] as string[],
bannedEmailDomains: [] as string[],
preservedUsernames: [] as string[],
bubbleInstances: [] as string[],
trustedLinkUrlPatterns: [] as string[],
federation: 'all',
federationHosts: [] as string[],
allowUnsignedFetch: 'always',
cacheRemoteFiles: true, cacheRemoteFiles: true,
cacheRemoteSensitiveFiles: true, cacheRemoteSensitiveFiles: true,
enableFanoutTimeline: true, enableFanoutTimeline: true,

View file

@ -0,0 +1,91 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { diffArrays, diffArraysSimple } from '@/misc/diff-arrays.js';
describe(diffArrays, () => {
it('should return empty result when both inputs are null', () => {
const result = diffArrays(null, null);
expect(result.added).toHaveLength(0);
expect(result.removed).toHaveLength(0);
});
it('should return empty result when both inputs are empty', () => {
const result = diffArrays([], []);
expect(result.added).toHaveLength(0);
expect(result.removed).toHaveLength(0);
});
it('should remove before when after is empty', () => {
const result = diffArrays([1, 2, 3], []);
expect(result.added).toHaveLength(0);
expect(result.removed).toEqual([1, 2, 3]);
});
it('should deduplicate before when after is empty', () => {
const result = diffArrays([1, 1, 2, 2, 3], []);
expect(result.added).toHaveLength(0);
expect(result.removed).toEqual([1, 2, 3]);
});
it('should add after when before is empty', () => {
const result = diffArrays([], [1, 2, 3]);
expect(result.added).toEqual([1, 2, 3]);
expect(result.removed).toHaveLength(0);
});
it('should deduplicate after when before is empty', () => {
const result = diffArrays([], [1, 1, 2, 2, 3]);
expect(result.added).toEqual([1, 2, 3]);
expect(result.removed).toHaveLength(0);
});
it('should return diff when both have values', () => {
const result = diffArrays(
['a', 'b', 'c', 'd'],
['a', 'c', 'e', 'f'],
);
expect(result.added).toEqual(['e', 'f']);
expect(result.removed).toEqual(['b', 'd']);
});
});
describe(diffArraysSimple, () => {
it('should return false when both inputs are null', () => {
const result = diffArraysSimple(null, null);
expect(result).toBe(false);
});
it('should return false when both inputs are empty', () => {
const result = diffArraysSimple([], []);
expect(result).toBe(false);
});
it('should return true when before is populated and after is empty', () => {
const result = diffArraysSimple([1, 2, 3], []);
expect(result).toBe(true);
});
it('should return true when before is empty and after is populated', () => {
const result = diffArraysSimple([], [1, 2, 3]);
expect(result).toBe(true);
});
it('should return true when values have changed', () => {
const result = diffArraysSimple(
['a', 'a', 'b', 'c'],
['a', 'b', 'c', 'd'],
);
expect(result).toBe(true);
});
it('should return false when values have not changed', () => {
const result = diffArraysSimple(
['a', 'a', 'b', 'c'],
['a', 'b', 'c', 'c'],
);
expect(result).toBe(false);
});
});

View file

@ -40,10 +40,13 @@ const base: MiNote = {
channelId: null, channelId: null,
channel: null, channel: null,
userHost: null, userHost: null,
userInstance: null,
replyUserId: null, replyUserId: null,
replyUserHost: null, replyUserHost: null,
replyUserInstance: null,
renoteUserId: null, renoteUserId: null,
renoteUserHost: null, renoteUserHost: null,
renoteUserInstance: null,
processErrors: [], processErrors: [],
}; };

View file

@ -23,6 +23,7 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"incremental": true,
"rootDir": "./src", "rootDir": "./src",
"baseUrl": "./", "baseUrl": "./",
"paths": { "paths": {

View file

@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"lib": ["esnext", "webworker"], "lib": ["esnext", "webworker"],
"incremental": true
} }
} }

View file

@ -23,6 +23,7 @@
"useDefineForClassFields": true, "useDefineForClassFields": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"skipLibCheck": true, "skipLibCheck": true,
"incremental": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"], "@/*": ["./src/*"],

View file

@ -18,6 +18,7 @@
"esModuleInterop": true, "esModuleInterop": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"skipLibCheck": true, "skipLibCheck": true,
"incremental": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./*"], "@/*": ["./*"],

View file

@ -18,6 +18,7 @@
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"incremental": true,
"jsx": "react", "jsx": "react",
"jsxFactory": "h" "jsxFactory": "h"
}, },

View file

@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"lib": ["esnext", "webworker"], "lib": ["esnext", "webworker"],
"incremental": true
} }
} }

View file

@ -22,6 +22,7 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"incremental": true,
"baseUrl": "./", "baseUrl": "./",
"paths": { "paths": {
"@/*": ["../src/*"] "@/*": ["../src/*"]

View file

@ -23,6 +23,7 @@
"useDefineForClassFields": true, "useDefineForClassFields": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"skipLibCheck": true, "skipLibCheck": true,
"incremental": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"], "@/*": ["./src/*"],

View file

@ -16,6 +16,7 @@
"noImplicitReturns": true, "noImplicitReturns": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"incremental": true,
"typeRoots": [ "typeRoots": [
"./node_modules/@types" "./node_modules/@types"
], ],

View file

@ -8,6 +8,7 @@
"strictFunctionTypes": true, "strictFunctionTypes": true,
"strictNullChecks": true, "strictNullChecks": true,
"esModuleInterop": true, "esModuleInterop": true,
"incremental": true,
"lib": [ "lib": [
"esnext", "esnext",
] ]

View file

@ -17,6 +17,7 @@
"esModuleInterop": true, "esModuleInterop": true,
"exactOptionalPropertyTypes": true, "exactOptionalPropertyTypes": true,
"skipLibCheck": true, "skipLibCheck": true,
"incremental": true,
"typeRoots": [ "typeRoots": [
"./node_modules/@types" "./node_modules/@types"
], ],

View file

@ -16,6 +16,7 @@
"noImplicitReturns": true, "noImplicitReturns": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"incremental": true,
"typeRoots": [ "typeRoots": [
"./node_modules/@types" "./node_modules/@types"
], ],

View file

@ -20,6 +20,7 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"skipLibCheck": true, "skipLibCheck": true,
"incremental": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"], "@/*": ["./src/*"],