Merge tag '13.14.1' into merge-upstream

This commit is contained in:
まっちゃとーにゅ
2023-07-23 03:08:40 +09:00
560 changed files with 12755 additions and 8764 deletions

View File

@@ -17,7 +17,7 @@
"paths": {
"@/*": ["*"]
},
"target": "es2021"
"target": "es2022"
},
"minify": false
}

View File

@@ -2,14 +2,7 @@ import Redis from 'ioredis';
import { loadConfig } from './built/config.js';
const config = loadConfig();
const redis = new Redis({
port: config.redis.port,
host: config.redis.host,
family: config.redis.family == null ? 0 : config.redis.family,
password: config.redis.pass,
keyPrefix: `${config.redis.prefix}:`,
db: config.redis.db ?? 0,
});
const redis = new Redis(config.redis);
redis.on('connect', () => redis.disconnect());
redis.on('error', (e) => {

View File

@@ -0,0 +1,9 @@
export class ad1677054292210 {
name = 'ad1677054292210';
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "ad" ADD "dayOfWeek" integer NOT NULL Default 0`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "ad" DROP COLUMN "dayOfWeek"`);
}
}

View File

@@ -0,0 +1,13 @@
export class AddMetaOptions1688280713783 {
name = 'AddMetaOptions1688280713783'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "enableServerMachineStats" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "meta" ADD "enableIdenticonGeneration" boolean NOT NULL DEFAULT true`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableIdenticonGeneration"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableServerMachineStats"`);
}
}

View File

@@ -0,0 +1,25 @@
export class RefactorInviteSystem1688720440658 {
name = 'RefactorInviteSystem1688720440658'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "expiresAt" TIMESTAMP WITH TIME ZONE`);
await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "usedAt" TIMESTAMP WITH TIME ZONE`);
await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "pendingUserId" character varying(32)`);
await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "createdById" character varying(32)`);
await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "usedById" character varying(32)`);
await queryRunner.query(`ALTER TABLE "registration_ticket" ADD CONSTRAINT "UQ_b6f93f2f30bdbb9a5ebdc7c7189" UNIQUE ("usedById")`);
await queryRunner.query(`ALTER TABLE "registration_ticket" ADD CONSTRAINT "FK_beba993576db0261a15364ea96e" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "registration_ticket" ADD CONSTRAINT "FK_b6f93f2f30bdbb9a5ebdc7c7189" FOREIGN KEY ("usedById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "registration_ticket" DROP CONSTRAINT "FK_b6f93f2f30bdbb9a5ebdc7c7189"`);
await queryRunner.query(`ALTER TABLE "registration_ticket" DROP CONSTRAINT "FK_beba993576db0261a15364ea96e"`);
await queryRunner.query(`ALTER TABLE "registration_ticket" DROP CONSTRAINT "UQ_b6f93f2f30bdbb9a5ebdc7c7189"`);
await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "usedById"`);
await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "createdById"`);
await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "pendingUserId"`);
await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "usedAt"`);
await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "expiresAt"`);
}
}

View File

@@ -0,0 +1,13 @@
export class AddIndexToRelations1688880985544 {
name = 'AddIndexToRelations1688880985544'
async up(queryRunner) {
await queryRunner.query(`CREATE INDEX "IDX_beba993576db0261a15364ea96" ON "registration_ticket" ("createdById") `);
await queryRunner.query(`CREATE INDEX "IDX_b6f93f2f30bdbb9a5ebdc7c718" ON "registration_ticket" ("usedById") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_b6f93f2f30bdbb9a5ebdc7c718"`);
await queryRunner.query(`DROP INDEX "public"."IDX_beba993576db0261a15364ea96"`);
}
}

View File

@@ -0,0 +1,11 @@
export class NsfwCache1689102832143 {
name = 'NsfwCache1689102832143'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "cacheRemoteSensitiveFiles" boolean NOT NULL DEFAULT true`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "cacheRemoteSensitiveFiles"`);
}
}

View File

@@ -3,6 +3,9 @@
"main": "./index.js",
"private": true,
"type": "module",
"engines": {
"node": ">=18.16.0"
},
"scripts": {
"start": "node ./built/index.js",
"start:test": "NODE_ENV=test node ./built/index.js",
@@ -51,63 +54,62 @@
"utf-8-validate": "^6.0.3"
},
"dependencies": {
"@aws-sdk/client-s3": "3.321.1",
"@aws-sdk/lib-storage": "3.321.1",
"@aws-sdk/node-http-handler": "3.321.1",
"@bull-board/api": "5.2.0",
"@bull-board/fastify": "5.2.0",
"@bull-board/ui": "5.2.0",
"@aws-sdk/client-s3": "3.367.0",
"@aws-sdk/lib-storage": "3.367.0",
"@aws-sdk/node-http-handler": "3.360.0",
"@bull-board/api": "5.6.1",
"@bull-board/fastify": "5.6.1",
"@bull-board/ui": "5.6.1",
"@discordapp/twemoji": "14.1.2",
"@fastify/accepts": "4.1.0",
"@fastify/accepts": "4.2.0",
"@fastify/cookie": "8.3.0",
"@fastify/cors": "8.3.0",
"@fastify/http-proxy": "9.1.0",
"@fastify/multipart": "7.6.0",
"@fastify/http-proxy": "9.2.1",
"@fastify/multipart": "7.7.1",
"@fastify/static": "6.10.2",
"@fastify/view": "7.4.1",
"@nestjs/common": "9.4.2",
"@nestjs/core": "9.4.2",
"@nestjs/testing": "9.4.2",
"@fastify/view": "8.0.0",
"@nestjs/common": "10.1.0",
"@nestjs/core": "10.1.0",
"@nestjs/testing": "10.1.0",
"@peertube/http-signature": "1.7.0",
"@sinonjs/fake-timers": "10.2.0",
"@sinonjs/fake-timers": "10.3.0",
"@swc/cli": "0.1.62",
"@swc/core": "1.3.61",
"@swc/core": "1.3.70",
"accepts": "1.3.8",
"ajv": "8.12.0",
"archiver": "5.3.1",
"async-mutex": "^0.4.0",
"autwh": "0.1.0",
"bcryptjs": "2.4.3",
"blurhash": "2.0.5",
"bullmq": "3.15.0",
"cacheable-lookup": "6.1.0",
"bullmq": "4.4.0",
"cacheable-lookup": "7.0.0",
"cbor": "9.0.0",
"chalk": "5.2.0",
"chalk-template": "0.4.0",
"chalk": "5.3.0",
"chalk-template": "1.1.0",
"chokidar": "3.5.3",
"cli-highlight": "2.1.11",
"color-convert": "2.0.1",
"content-disposition": "0.5.4",
"date-fns": "2.30.0",
"deep-email-validator": "0.1.21",
"escape-regexp": "0.0.1",
"fastify": "4.17.0",
"fastify": "4.20.0",
"feed": "4.2.2",
"file-type": "18.4.0",
"file-type": "18.5.0",
"fluent-ffmpeg": "2.1.2",
"form-data": "4.0.0",
"got": "12.6.0",
"happy-dom": "9.20.3",
"got": "13.0.0",
"happy-dom": "10.0.3",
"hpagent": "1.2.0",
"ioredis": "5.3.2",
"ip-cidr": "3.1.0",
"is-svg": "4.3.2",
"ipaddr.js": "2.1.0",
"is-svg": "5.0.0",
"js-yaml": "4.1.0",
"jsdom": "22.1.0",
"json5": "2.2.3",
"jsonld": "8.2.0",
"jsrsasign": "10.8.6",
"meilisearch": "0.32.5",
"meilisearch": "0.33.0",
"mfm-js": "0.23.3",
"mime-types": "2.1.35",
"misskey-js": "workspace:*",
@@ -118,10 +120,9 @@
"nsfwjs": "2.4.2",
"oauth": "0.10.0",
"os-utils": "0.0.14",
"otpauth": "9.1.2",
"otpauth": "9.1.3",
"parse5": "7.1.2",
"pg": "8.11.0",
"private-ip": "3.0.0",
"pg": "8.11.1",
"probe-image-size": "7.2.3",
"promise-limit": "2.7.0",
"pug": "3.0.2",
@@ -130,41 +131,36 @@
"qrcode": "1.5.3",
"random-seed": "0.3.0",
"ratelimiter": "3.4.1",
"re2": "1.19.0",
"re2": "1.19.1",
"redis-lock": "0.1.4",
"reflect-metadata": "0.1.13",
"rename": "1.0.4",
"rndstr": "1.0.0",
"rss-parser": "3.13.0",
"rxjs": "7.8.1",
"s-age": "1.1.2",
"sanitize-html": "2.10.0",
"seedrandom": "3.0.5",
"semver": "7.5.1",
"sharp": "0.32.1",
"sanitize-html": "2.11.0",
"semver": "7.5.4",
"sharp": "0.32.3",
"sharp-read-bmp": "github:misskey-dev/sharp-read-bmp",
"slacc": "0.0.9",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly",
"systeminformation": "5.17.16",
"systeminformation": "5.18.7",
"tinycolor2": "1.6.0",
"tmp": "0.2.1",
"tsc-alias": "1.8.6",
"tsc-alias": "1.8.7",
"tsconfig-paths": "4.2.0",
"twemoji-parser": "14.0.0",
"typeorm": "0.3.16",
"typescript": "5.1.3",
"typeorm": "0.3.17",
"typescript": "5.1.6",
"ulid": "2.3.0",
"unzipper": "0.10.14",
"uuid": "9.0.0",
"vary": "1.1.2",
"web-push": "3.6.1",
"web-push": "3.6.3",
"ws": "8.13.0",
"xev": "3.0.2"
},
"devDependencies": {
"@jest/globals": "29.5.0",
"@jest/globals": "29.6.1",
"@swc/jest": "0.2.26",
"@types/accepts": "1.3.5",
"@types/archiver": "5.3.2",
@@ -172,25 +168,24 @@
"@types/cbor": "6.0.0",
"@types/color-convert": "2.0.0",
"@types/content-disposition": "0.5.5",
"@types/escape-regexp": "0.0.1",
"@types/fluent-ffmpeg": "2.1.21",
"@types/jest": "29.5.2",
"@types/jest": "29.5.3",
"@types/js-yaml": "4.0.5",
"@types/jsdom": "21.1.1",
"@types/jsonld": "1.5.8",
"@types/jsonld": "1.5.9",
"@types/jsrsasign": "10.5.8",
"@types/mime-types": "2.1.1",
"@types/node": "20.2.5",
"@types/ms": "^0.7.31",
"@types/node": "20.4.2",
"@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.8",
"@types/oauth": "0.9.1",
"@types/pg": "8.10.1",
"@types/pg": "8.10.2",
"@types/pug": "2.0.6",
"@types/punycode": "2.1.0",
"@types/qrcode": "1.5.0",
"@types/qrcode": "1.5.1",
"@types/random-seed": "0.3.3",
"@types/ratelimiter": "3.4.4",
"@types/redis": "4.0.11",
"@types/rename": "1.0.4",
"@types/sanitize-html": "2.9.0",
"@types/semver": "7.5.0",
@@ -198,20 +193,17 @@
"@types/sinonjs__fake-timers": "8.1.2",
"@types/tinycolor2": "1.4.3",
"@types/tmp": "0.2.3",
"@types/unzipper": "0.10.6",
"@types/uuid": "9.0.1",
"@types/vary": "1.1.0",
"@types/web-push": "3.3.2",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "5.59.8",
"@typescript-eslint/parser": "5.59.8",
"aws-sdk-client-mock": "2.1.1",
"@types/ws": "8.5.5",
"@typescript-eslint/eslint-plugin": "5.61.0",
"@typescript-eslint/parser": "5.61.0",
"aws-sdk-client-mock": "3.0.0",
"cross-env": "7.0.3",
"eslint": "8.41.0",
"eslint": "8.45.0",
"eslint-plugin-import": "2.27.5",
"execa": "6.1.0",
"jest": "29.5.0",
"jest-mock": "29.5.0"
"execa": "7.1.1",
"jest": "29.6.1",
"jest-mock": "29.6.1"
}
}

View File

@@ -41,14 +41,7 @@ const $meilisearch: Provider = {
const $redis: Provider = {
provide: DI.redis,
useFactory: (config: Config) => {
return new Redis.Redis({
port: config.redis.port,
host: config.redis.host,
family: config.redis.family == null ? 0 : config.redis.family,
password: config.redis.pass,
keyPrefix: `${config.redis.prefix}:`,
db: config.redis.db ?? 0,
});
return new Redis.Redis(config.redis);
},
inject: [DI.config],
};
@@ -56,14 +49,7 @@ const $redis: Provider = {
const $redisForPub: Provider = {
provide: DI.redisForPub,
useFactory: (config: Config) => {
const redis = new Redis.Redis({
port: config.redisForPubsub.port,
host: config.redisForPubsub.host,
family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family,
password: config.redisForPubsub.pass,
keyPrefix: `${config.redisForPubsub.prefix}:`,
db: config.redisForPubsub.db ?? 0,
});
const redis = new Redis.Redis(config.redisForPubsub);
return redis;
},
inject: [DI.config],
@@ -72,14 +58,7 @@ const $redisForPub: Provider = {
const $redisForSub: Provider = {
provide: DI.redisForSub,
useFactory: (config: Config) => {
const redis = new Redis.Redis({
port: config.redisForPubsub.port,
host: config.redisForPubsub.host,
family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family,
password: config.redisForPubsub.pass,
keyPrefix: `${config.redisForPubsub.prefix}:`,
db: config.redisForPubsub.db ?? 0,
});
const redis = new Redis.Redis(config.redisForPubsub);
redis.subscribe(config.host);
return redis;
},

View File

@@ -31,7 +31,7 @@ function greet() {
console.log(themeColor(' | |_|___ ___| |_ ___ _ _ '));
console.log(themeColor(' | | | | |_ -|_ -| \'_| -_| | |'));
console.log(themeColor(' |_|_|_|_|___|___|_,_|___|_ |'));
console.log(' ' + chalk.gray(v) + themeColor(' |___|\n'.substr(v.length)));
console.log(' ' + chalk.gray(v) + themeColor(' |___|\n'.substring(v.length)));
//#endregion
console.log(' Misskey is an open-source decentralized microblogging platform.');
@@ -78,7 +78,7 @@ export async function masterMain() {
await spawnWorkers(config.clusterLimit);
}
bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`, null, true);
bootLogger.succ(config.socket ? `Now listening on socket ${config.socket} on ${config.url}` : `Now listening on port ${config.port} on ${config.url}`, null, true);
}
function showEnvironment(): void {
@@ -96,12 +96,6 @@ function showNodejsVersion(): void {
const nodejsLogger = bootLogger.createSubLogger('nodejs');
nodejsLogger.info(`Version ${process.version} detected.`);
const minVersion = fs.readFileSync(`${_dirname}/../../../../.node-version`, 'utf-8').trim();
if (semver.lt(process.version, minVersion)) {
nodejsLogger.error(`At least Node.js ${minVersion} required!`);
process.exit(1);
}
}
function loadConfigBoot(): Config {

View File

@@ -6,6 +6,16 @@ import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import * as yaml from 'js-yaml';
import type { RedisOptions } from 'ioredis';
type RedisOptionsSource = Partial<RedisOptions> & {
host: string;
port: number;
family?: number;
pass: string;
db?: number;
prefix?: string;
};
/**
* ユーザーが設定する必要のある情報
@@ -14,7 +24,9 @@ export type Source = {
repository_url?: string;
feedback_url?: string;
url: string;
port: number;
port?: number;
socket?: string;
chmodSocket?: string;
disableHsts?: boolean;
db: {
host: string;
@@ -33,30 +45,9 @@ export type Source = {
user: string;
pass: string;
}[];
redis: {
host: string;
port: number;
family?: number;
pass: string;
db?: number;
prefix?: string;
};
redisForPubsub?: {
host: string;
port: number;
family?: number;
pass: string;
db?: number;
prefix?: string;
};
redisForJobQueue?: {
host: string;
port: number;
family?: number;
pass: string;
db?: number;
prefix?: string;
};
redis: RedisOptionsSource;
redisForPubsub?: RedisOptionsSource;
redisForJobQueue?: RedisOptionsSource;
meilisearch?: {
host: string;
port: string;
@@ -119,8 +110,9 @@ export type Mixin = {
mediaProxy: string;
externalMediaProxyEnabled: boolean;
videoThumbnailGenerator: string | null;
redisForPubsub: NonNullable<Source['redisForPubsub']>;
redisForJobQueue: NonNullable<Source['redisForJobQueue']>;
redis: RedisOptions & RedisOptionsSource;
redisForPubsub: RedisOptions & RedisOptionsSource;
redisForJobQueue: RedisOptions & RedisOptionsSource;
};
export type Config = Source & Mixin;
@@ -182,9 +174,9 @@ export function loadConfig() {
config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator
: null;
if (!config.redis.prefix) config.redis.prefix = mixin.host;
if (config.redisForPubsub == null) config.redisForPubsub = config.redis;
if (config.redisForJobQueue == null) config.redisForJobQueue = config.redis;
mixin.redis = convertRedisOptions(config.redis, mixin.host);
mixin.redisForPubsub = config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, mixin.host) : mixin.redis;
mixin.redisForJobQueue = config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, mixin.host) : mixin.redis;
return Object.assign(config, mixin);
}
@@ -196,3 +188,14 @@ function tryCreateUrl(url: string) {
throw new Error(`url="${url}" is not a valid URL.`);
}
}
function convertRedisOptions(options: RedisOptionsSource, host: string): RedisOptions & RedisOptionsSource {
return {
...options,
password: options.pass,
prefix: options.prefix ?? host,
family: options.family == null ? 0 : options.family,
keyPrefix: `${options.prefix ?? host}:`,
db: options.db ?? 0,
};
}

View File

@@ -4,10 +4,9 @@ import { IsNull, In, MoreThan, Not } from 'typeorm';
import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { LocalUser, RemoteUser } from '@/models/entities/User.js';
import type { LocalUser, RemoteUser, User } 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';
@@ -295,7 +294,7 @@ export class AccountMoveService {
* dstユーザーのalsoKnownAsをfetchPersonしていき、本当にmovedToUrlをdstに指定するユーザーが存在するのかを調べる
*
* @param dst movedToUrlを指定するユーザー
* @param check
* @param check
* @param instant checkがtrueであるユーザーが最初に見つかったら即座にreturnするかどうか
* @returns Promise<LocalUser | RemoteUser | null>
*/

View File

@@ -99,7 +99,7 @@ export class AntennaService implements OnApplicationShutdown {
'MAXLEN', '~', '200',
'*',
'note', note.id);
this.globalEventService.publishAntennaStream(antenna.id, 'note', note);
}
@@ -112,16 +112,16 @@ export class AntennaService implements OnApplicationShutdown {
public async checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }): Promise<boolean> {
if (note.visibility === 'specified') return false;
if (note.visibility === 'followers') return false;
if (!antenna.withReplies && note.replyId != null) return false;
if (antenna.src === 'home') {
// TODO
} else if (antenna.src === 'list') {
const listUsers = (await this.userListJoiningsRepository.findBy({
userListId: antenna.userListId!,
})).map(x => x.userId);
if (!listUsers.includes(note.userId)) return false;
} else if (antenna.src === 'users') {
const accts = antenna.users.map(x => {
@@ -130,32 +130,32 @@ export class AntennaService implements OnApplicationShutdown {
});
if (!accts.includes(this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false;
}
const keywords = antenna.keywords
// Clean up
.map(xs => xs.filter(x => x !== ''))
.filter(xs => xs.length > 0);
if (keywords.length > 0) {
if (note.text == null && note.cw == null) return false;
const _text = (note.text ?? '') + '\n' + (note.cw ?? '');
const matched = keywords.some(and =>
and.every(keyword =>
antenna.caseSensitive
? _text.includes(keyword)
: _text.toLowerCase().includes(keyword.toLowerCase()),
));
if (!matched) return false;
}
const excludeKeywords = antenna.excludeKeywords
// Clean up
.map(xs => xs.filter(x => x !== ''))
.filter(xs => xs.length > 0);
if (excludeKeywords.length > 0) {
if (note.text == null && note.cw == null) return false;
@@ -167,16 +167,16 @@ export class AntennaService implements OnApplicationShutdown {
? _text.includes(keyword)
: _text.toLowerCase().includes(keyword.toLowerCase()),
));
if (matched) return false;
}
if (antenna.withFile) {
if (note.fileIds && note.fileIds.length === 0) return false;
}
// TODO: eval expression
return true;
}
@@ -188,7 +188,7 @@ export class AntennaService implements OnApplicationShutdown {
});
this.antennasFetched = true;
}
return this.antennas;
}

View File

@@ -32,11 +32,6 @@ export class AppLockService {
return this.lock(`ap-object:${uri}`, timeout);
}
@bindThis
public getFetchInstanceMetadataLock(host: string, timeout = 30 * 1000): Promise<() => void> {
return this.lock(`instance:${host}`, timeout);
}
@bindThis
public getChartInsertLock(lockKey: string, timeout = 30 * 1000): Promise<() => void> {
return this.lock(`chart-insert:${lockKey}`, timeout);

View File

@@ -11,10 +11,10 @@ import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
export class CacheService implements OnApplicationShutdown {
public userByIdCache: MemoryKVCache<User>;
public localUserByNativeTokenCache: MemoryKVCache<LocalUser | null>;
public userByIdCache: MemoryKVCache<User, User | string>;
public localUserByNativeTokenCache: MemoryKVCache<LocalUser | null, string | null>;
public localUserByIdCache: MemoryKVCache<LocalUser>;
public uriPersonCache: MemoryKVCache<User | null>;
public uriPersonCache: MemoryKVCache<User | null, string | null>;
public userProfileCache: RedisKVCache<UserProfile>;
public userMutingsCache: RedisKVCache<Set<string>>;
public userBlockingCache: RedisKVCache<Set<string>>;
@@ -55,10 +55,41 @@ export class CacheService implements OnApplicationShutdown {
) {
//this.onMessage = this.onMessage.bind(this);
this.userByIdCache = new MemoryKVCache<User>(Infinity);
this.localUserByNativeTokenCache = new MemoryKVCache<LocalUser | null>(Infinity);
this.localUserByIdCache = new MemoryKVCache<LocalUser>(Infinity);
this.uriPersonCache = new MemoryKVCache<User | null>(Infinity);
const localUserByIdCache = new MemoryKVCache<LocalUser>(1000 * 60 * 60 * 6 /* 6h */);
this.localUserByIdCache = localUserByIdCache;
// ローカルユーザーならlocalUserByIdCacheにデータを追加し、こちらにはid(文字列)だけを追加する
const userByIdCache = new MemoryKVCache<User, User | string>(1000 * 60 * 60 * 6 /* 6h */, {
toMapConverter: user => {
if (user.host === null) {
localUserByIdCache.set(user.id, user as LocalUser);
return user.id;
}
return user;
},
fromMapConverter: userOrId => typeof userOrId === 'string' ? localUserByIdCache.get(userOrId) : userOrId,
});
this.userByIdCache = userByIdCache;
this.localUserByNativeTokenCache = new MemoryKVCache<LocalUser | null, string | null>(Infinity, {
toMapConverter: user => {
if (user === null) return null;
localUserByIdCache.set(user.id, user);
return user.id;
},
fromMapConverter: id => id === null ? null : localUserByIdCache.get(id),
});
this.uriPersonCache = new MemoryKVCache<User | null, string | null>(Infinity, {
toMapConverter: user => {
if (user === null) return null;
userByIdCache.set(user.id, user);
return user.id;
},
fromMapConverter: id => id === null ? null : userByIdCache.get(id),
});
this.userProfileCache = new RedisKVCache<UserProfile>(this.redisClient, 'userProfile', {
lifetime: 1000 * 60 * 30, // 30m
@@ -131,7 +162,7 @@ export class CacheService implements OnApplicationShutdown {
const user = await this.usersRepository.findOneByOrFail({ id: body.id });
this.userByIdCache.set(user.id, user);
for (const [k, v] of this.uriPersonCache.cache.entries()) {
if (v.value?.id === user.id) {
if (v.value === user.id) {
this.uriPersonCache.set(k, user);
}
}

View File

@@ -20,7 +20,7 @@ export class CaptchaService {
secret,
response,
});
const res = await this.httpRequestService.send(url, {
method: 'POST',
body: params.toString(),
@@ -28,14 +28,14 @@ export class CaptchaService {
'Content-Type': 'application/x-www-form-urlencoded',
},
}, { throwErrorWhenResponseNotOk: false });
if (!res.ok) {
throw new Error(`${res.status}`);
}
return await res.json() as CaptchaResponse;
}
}
@bindThis
public async verifyRecaptcha(secret: string, response: string | null | undefined): Promise<void> {
if (response == null) {
@@ -73,7 +73,7 @@ export class CaptchaService {
if (response == null) {
throw new Error('turnstile-failed: no response provided');
}
const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => {
throw new Error(`turnstile-request-failed: ${err}`);
});

View File

@@ -81,6 +81,7 @@ import { GalleryLikeEntityService } from './entities/GalleryLikeEntityService.js
import { GalleryPostEntityService } from './entities/GalleryPostEntityService.js';
import { HashtagEntityService } from './entities/HashtagEntityService.js';
import { InstanceEntityService } from './entities/InstanceEntityService.js';
import { InviteCodeEntityService } from './entities/InviteCodeEntityService.js';
import { ModerationLogEntityService } from './entities/ModerationLogEntityService.js';
import { MutingEntityService } from './entities/MutingEntityService.js';
import { RenoteMutingEntityService } from './entities/RenoteMutingEntityService.js';
@@ -205,6 +206,7 @@ const $GalleryLikeEntityService: Provider = { provide: 'GalleryLikeEntityService
const $GalleryPostEntityService: Provider = { provide: 'GalleryPostEntityService', useExisting: GalleryPostEntityService };
const $HashtagEntityService: Provider = { provide: 'HashtagEntityService', useExisting: HashtagEntityService };
const $InstanceEntityService: Provider = { provide: 'InstanceEntityService', useExisting: InstanceEntityService };
const $InviteCodeEntityService: Provider = { provide: 'InviteCodeEntityService', useExisting: InviteCodeEntityService };
const $ModerationLogEntityService: Provider = { provide: 'ModerationLogEntityService', useExisting: ModerationLogEntityService };
const $MutingEntityService: Provider = { provide: 'MutingEntityService', useExisting: MutingEntityService };
const $RenoteMutingEntityService: Provider = { provide: 'RenoteMutingEntityService', useExisting: RenoteMutingEntityService };
@@ -329,6 +331,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
GalleryPostEntityService,
HashtagEntityService,
InstanceEntityService,
InviteCodeEntityService,
ModerationLogEntityService,
MutingEntityService,
RenoteMutingEntityService,
@@ -448,6 +451,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$GalleryPostEntityService,
$HashtagEntityService,
$InstanceEntityService,
$InviteCodeEntityService,
$ModerationLogEntityService,
$MutingEntityService,
$RenoteMutingEntityService,
@@ -567,6 +571,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
GalleryPostEntityService,
HashtagEntityService,
InstanceEntityService,
InviteCodeEntityService,
ModerationLogEntityService,
MutingEntityService,
RenoteMutingEntityService,
@@ -685,6 +690,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$GalleryPostEntityService,
$HashtagEntityService,
$InstanceEntityService,
$InviteCodeEntityService,
$ModerationLogEntityService,
$MutingEntityService,
$RenoteMutingEntityService,

View File

@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs';
import { v4 as uuid } from 'uuid';
import { IsNull, DataSource } from 'typeorm';
import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
import { User } from '@/models/entities/User.js';
@@ -24,28 +24,28 @@ export class CreateSystemUserService {
@bindThis
public async createSystemUser(username: string): Promise<User> {
const password = uuid();
const password = randomUUID();
// Generate hash of password
const salt = await bcrypt.genSalt(8);
const hash = await bcrypt.hash(password, salt);
// Generate secret
const secret = generateNativeUserToken();
const keyPair = await genRsaKeyPair(4096);
const keyPair = await genRsaKeyPair();
let account!: User;
// Start transaction
await this.db.transaction(async transactionalEntityManager => {
const exist = await transactionalEntityManager.findOneBy(User, {
usernameLower: username.toLowerCase(),
host: IsNull(),
});
if (exist) throw new Error('the user is already exists');
account = await transactionalEntityManager.insert(User, {
id: this.idService.genId(),
createdAt: new Date(),
@@ -58,25 +58,25 @@ export class CreateSystemUserService {
isExplorable: false,
isBot: true,
}).then(x => transactionalEntityManager.findOneByOrFail(User, x.identifiers[0]));
await transactionalEntityManager.insert(UserKeypair, {
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey,
userId: account.id,
});
await transactionalEntityManager.insert(UserProfile, {
userId: account.id,
autoAcceptFollowed: false,
password: hash,
});
await transactionalEntityManager.insert(UsedUsername, {
createdAt: new Date(),
username: username.toLowerCase(),
});
});
return account;
}
}

View File

@@ -140,7 +140,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
this.globalEventService.publishBroadcastStream('emojiAdded', {
emoji: updated,
});
});
}
}
@@ -194,7 +194,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
}
this.localEmojisCache.refresh();
this.globalEventService.publishBroadcastStream('emojiUpdated', {
emojis: await this.emojiEntityService.packDetailedMany(ids),
});
@@ -215,7 +215,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
emojis: await this.emojiEntityService.packDetailedMany(ids),
});
}
@bindThis
public async setLicenseBulk(ids: Emoji['id'][], license: string | null) {
await this.emojisRepository.update({

View File

@@ -28,11 +28,11 @@ export class DeleteAccountService {
// 物理削除する前にDelete activityを送信する
await this.userSuspendService.doPostSuspend(user).catch(e => {});
this.queueService.createDeleteAccountJob(user, {
soft: false,
});
await this.usersRepository.update(user.id, {
isDeleted: true,
});

View File

@@ -2,8 +2,7 @@ import * as fs from 'node:fs';
import * as stream from 'node:stream';
import * as util from 'node:util';
import { Inject, Injectable } from '@nestjs/common';
import IPCIDR from 'ip-cidr';
import PrivateIp from 'private-ip';
import ipaddr from 'ipaddr.js';
import chalk from 'chalk';
import got, * as Got from 'got';
import { parse } from 'content-disposition';
@@ -123,15 +122,15 @@ export class DownloadService {
public async downloadTextFile(url: string): Promise<string> {
// Create temp file
const [path, cleanup] = await createTemp();
this.logger.info(`text file: Temp file is ${path}`);
try {
// write content at URL to temp file
await this.downloadUrl(url, path);
const text = await util.promisify(fs.readFile)(path, 'utf8');
return text;
} finally {
cleanup();
@@ -140,13 +139,14 @@ export class DownloadService {
@bindThis
private isPrivateIp(ip: string): boolean {
const parsedIp = ipaddr.parse(ip);
for (const net of this.config.allowedPrivateNetworks ?? []) {
const cidr = new IPCIDR(net);
if (cidr.contains(ip)) {
if (parsedIp.match(ipaddr.parseCIDR(net))) {
return false;
}
}
return PrivateIp(ip) ?? false;
return parsedIp.range() !== 'unicast';
}
}

View File

@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
import { Inject, Injectable } from '@nestjs/common';
import { v4 as uuid } from 'uuid';
import sharp from 'sharp';
import { sharpBmp } from 'sharp-read-bmp';
import { IsNull } from 'typeorm';
@@ -162,7 +162,7 @@ export class DriveService {
?? `${ meta.objectStorageUseSSL ? 'https' : 'http' }://${ meta.objectStorageEndpoint }${ meta.objectStoragePort ? `:${meta.objectStoragePort}` : '' }/${ meta.objectStorageBucket }`;
// for original
const key = `${meta.objectStoragePrefix}/${uuid()}${ext}`;
const key = `${meta.objectStoragePrefix}/${randomUUID()}${ext}`;
const url = `${ baseUrl }/${ key }`;
// for alts
@@ -179,7 +179,7 @@ export class DriveService {
];
if (alts.webpublic) {
webpublicKey = `${meta.objectStoragePrefix}/webpublic-${uuid()}.${alts.webpublic.ext}`;
webpublicKey = `${meta.objectStoragePrefix}/webpublic-${randomUUID()}.${alts.webpublic.ext}`;
webpublicUrl = `${ baseUrl }/${ webpublicKey }`;
this.registerLogger.info(`uploading webpublic: ${webpublicKey}`);
@@ -187,7 +187,7 @@ export class DriveService {
}
if (alts.thumbnail) {
thumbnailKey = `${meta.objectStoragePrefix}/thumbnail-${uuid()}.${alts.thumbnail.ext}`;
thumbnailKey = `${meta.objectStoragePrefix}/thumbnail-${randomUUID()}.${alts.thumbnail.ext}`;
thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`;
this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`);
@@ -212,9 +212,9 @@ export class DriveService {
return await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0]));
} else { // use internal storage
const accessKey = uuid();
const thumbnailAccessKey = 'thumbnail-' + uuid();
const webpublicAccessKey = 'webpublic-' + uuid();
const accessKey = randomUUID();
const thumbnailAccessKey = 'thumbnail-' + randomUUID();
const webpublicAccessKey = 'webpublic-' + randomUUID();
const url = this.internalStorageService.saveFromPath(accessKey, path);
@@ -584,9 +584,9 @@ export class DriveService {
if (isLink) {
file.url = url;
// ローカルプロキシ用
file.accessKey = uuid();
file.thumbnailAccessKey = 'thumbnail-' + uuid();
file.webpublicAccessKey = 'webpublic-' + uuid();
file.accessKey = randomUUID();
file.thumbnailAccessKey = 'thumbnail-' + randomUUID();
file.webpublicAccessKey = 'webpublic-' + randomUUID();
}
}
@@ -713,9 +713,9 @@ export class DriveService {
webpublicUrl: null,
storedInternal: false,
// ローカルプロキシ用
accessKey: uuid(),
thumbnailAccessKey: 'thumbnail-' + uuid(),
webpublicAccessKey: 'webpublic-' + uuid(),
accessKey: randomUUID(),
thumbnailAccessKey: 'thumbnail-' + randomUUID(),
webpublicAccessKey: 'webpublic-' + randomUUID(),
});
} else {
this.driveFilesRepository.delete(file.id);

View File

@@ -29,12 +29,12 @@ export class EmailService {
@bindThis
public async sendEmail(to: string, subject: string, html: string, text: string) {
const meta = await this.metaService.fetch(true);
const iconUrl = `${this.config.url}/static-assets/mi-white.png`;
const emailSettingUrl = `${this.config.url}/settings/email`;
const enableAuth = meta.smtpUser != null && meta.smtpUser !== '';
const transporter = nodemailer.createTransport({
host: meta.smtpHost,
port: meta.smtpPort,
@@ -46,7 +46,7 @@ export class EmailService {
pass: meta.smtpPass,
} : undefined,
} as any);
try {
// TODO: htmlサニタイズ
const info = await transporter.sendMail({
@@ -135,7 +135,7 @@ export class EmailService {
</body>
</html>`,
});
this.logger.info(`Message sent: ${info.messageId}`);
} catch (err) {
this.logger.error(err as Error);
@@ -149,12 +149,12 @@ export class EmailService {
reason: null | 'used' | 'format' | 'disposable' | 'mx' | 'smtp';
}> {
const meta = await this.metaService.fetch();
const exist = await this.userProfilesRepository.countBy({
emailVerified: true,
email: emailAddress,
});
const validated = meta.enableActiveEmailValidation ? await validateEmail({
email: emailAddress,
validateRegex: true,
@@ -163,9 +163,9 @@ export class EmailService {
validateDisposable: true, // 捨てアドかどうかチェック
validateSMTP: false, // 日本だと25ポートが殆どのプロバイダーで塞がれていてタイムアウトになるので
}) : { valid: true, reason: null };
const available = exist === 0 && validated.valid;
return {
available,
reason: available ? null :

View File

@@ -43,19 +43,19 @@ export class FederatedInstanceService implements OnApplicationShutdown {
@bindThis
public async fetch(host: string): Promise<Instance> {
host = this.utilityService.toPuny(host);
const cached = await this.federatedInstanceCache.get(host);
if (cached) return cached;
const index = await this.instancesRepository.findOneBy({ host });
if (index == null) {
const i = await this.instancesRepository.insert({
id: this.idService.genId(),
host,
firstRetrievedAt: new Date(),
}).then(x => this.instancesRepository.findOneByOrFail(x.identifiers[0]));
this.federatedInstanceCache.set(host, i);
return i;
} else {
@@ -74,7 +74,7 @@ export class FederatedInstanceService implements OnApplicationShutdown {
.then((response) => {
return response.raw[0];
});
this.federatedInstanceCache.set(result.host, result);
}

View File

@@ -2,9 +2,8 @@ import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import { JSDOM } from 'jsdom';
import tinycolor from 'tinycolor2';
import * as Redis from 'ioredis';
import type { Instance } from '@/models/entities/Instance.js';
import type { InstancesRepository } from '@/models/index.js';
import { AppLockService } from '@/core/AppLockService.js';
import type Logger from '@/logger.js';
import { DI } from '@/di-symbols.js';
import { LoggerService } from '@/core/LoggerService.js';
@@ -37,39 +36,49 @@ export class FetchInstanceMetadataService {
private logger: Logger;
constructor(
@Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository,
private appLockService: AppLockService,
private httpRequestService: HttpRequestService,
private loggerService: LoggerService,
private federatedInstanceService: FederatedInstanceService,
@Inject(DI.redis)
private redisClient: Redis.Redis,
) {
this.logger = this.loggerService.getLogger('metadata', 'cyan');
}
@bindThis
public async tryLock(host: string): Promise<boolean> {
const mutex = await this.redisClient.set(`fetchInstanceMetadata:mutex:${host}`, '1', 'GET');
return mutex !== '1';
}
@bindThis
public unlock(host: string): Promise<'OK'> {
return this.redisClient.set(`fetchInstanceMetadata:mutex:${host}`, '0');
}
@bindThis
public async fetchInstanceMetadata(instance: Instance, force = false): Promise<void> {
const unlock = await this.appLockService.getFetchInstanceMetadataLock(instance.host);
if (!force) {
const _instance = await this.instancesRepository.findOneBy({ host: instance.host });
const now = Date.now();
if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) {
unlock();
return;
}
}
this.logger.info(`Fetching metadata of ${instance.host} ...`);
const host = instance.host;
// Acquire mutex to ensure no parallel runs
if (!await this.tryLock(host)) return;
try {
if (!force) {
const _instance = await this.federatedInstanceService.fetch(host);
const now = Date.now();
if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) {
// unlock at the finally caluse
return;
}
}
this.logger.info(`Fetching metadata of ${instance.host} ...`);
const [info, dom, manifest] = await Promise.all([
this.fetchNodeinfo(instance).catch(() => null),
this.fetchDom(instance).catch(() => null),
this.fetchManifest(instance).catch(() => null),
]);
const [favicon, icon, themeColor, name, description] = await Promise.all([
this.fetchFaviconUrl(instance, dom).catch(() => null),
this.fetchIconUrl(instance, dom, manifest).catch(() => null),
@@ -77,13 +86,13 @@ export class FetchInstanceMetadataService {
this.getSiteName(info, dom, manifest).catch(() => null),
this.getDescription(info, dom, manifest).catch(() => null),
]);
this.logger.succ(`Successfuly fetched metadata of ${instance.host}`);
const updates = {
infoUpdatedAt: new Date(),
} as Record<string, any>;
if (info) {
updates.softwareName = typeof info.software?.name === 'string' ? info.software.name.toLowerCase() : '?';
updates.softwareVersion = info.software?.version;
@@ -91,27 +100,27 @@ export class FetchInstanceMetadataService {
updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name ?? null) : null : null;
updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email ?? null) : null : null;
}
if (name) updates.name = name;
if (description) updates.description = description;
if (icon || favicon) updates.iconUrl = icon ?? favicon;
if (icon || favicon) updates.iconUrl = (icon && !icon.includes('data:image/png;base64')) ? icon : favicon;
if (favicon) updates.faviconUrl = favicon;
if (themeColor) updates.themeColor = themeColor;
await this.federatedInstanceService.update(instance.id, updates);
this.logger.succ(`Successfuly updated metadata of ${instance.host}`);
} catch (e) {
this.logger.error(`Failed to update metadata of ${instance.host}: ${e}`);
} finally {
unlock();
await this.unlock(host);
}
}
@bindThis
private async fetchNodeinfo(instance: Instance): Promise<NodeInfo> {
this.logger.info(`Fetching nodeinfo of ${instance.host} ...`);
try {
const wellknown = await this.httpRequestService.getJson('https://' + instance.host + '/.well-known/nodeinfo')
.catch(err => {
@@ -121,33 +130,33 @@ export class FetchInstanceMetadataService {
throw err.statusCode ?? err.message;
}
}) as Record<string, unknown>;
if (wellknown.links == null || !Array.isArray(wellknown.links)) {
throw new Error('No wellknown links');
}
const links = wellknown.links as any[];
const lnik1_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/1.0');
const lnik2_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0');
const lnik2_1 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.1');
const link = lnik2_1 ?? lnik2_0 ?? lnik1_0;
if (link == null) {
throw new Error('No nodeinfo link provided');
}
const info = await this.httpRequestService.getJson(link.href)
.catch(err => {
throw err.statusCode ?? err.message;
});
this.logger.succ(`Successfuly fetched nodeinfo of ${instance.host}`);
return info as NodeInfo;
} catch (err) {
this.logger.error(`Failed to fetch nodeinfo of ${instance.host}: ${err}`);
throw err;
}
}
@@ -155,51 +164,51 @@ export class FetchInstanceMetadataService {
@bindThis
private async fetchDom(instance: Instance): Promise<DOMWindow['document']> {
this.logger.info(`Fetching HTML of ${instance.host} ...`);
const url = 'https://' + instance.host;
const html = await this.httpRequestService.getHtml(url);
const { window } = new JSDOM(html);
const doc = window.document;
return doc;
}
@bindThis
private async fetchManifest(instance: Instance): Promise<Record<string, unknown> | null> {
const url = 'https://' + instance.host;
const manifestUrl = url + '/manifest.json';
const manifest = await this.httpRequestService.getJson(manifestUrl) as Record<string, unknown>;
return manifest;
}
@bindThis
private async fetchFaviconUrl(instance: Instance, doc: DOMWindow['document'] | null): Promise<string | null> {
const url = 'https://' + instance.host;
if (doc) {
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
const href = Array.from(doc.getElementsByTagName('link')).reverse().find(link => link.relList.contains('icon'))?.href;
if (href) {
return (new URL(href, url)).href;
}
}
const faviconUrl = url + '/favicon.ico';
const favicon = await this.httpRequestService.send(faviconUrl, {
method: 'HEAD',
}, { throwErrorWhenResponseNotOk: false });
if (favicon.ok) {
return faviconUrl;
}
return null;
}
@@ -209,38 +218,38 @@ export class FetchInstanceMetadataService {
const url = 'https://' + instance.host;
return (new URL(manifest.icons[0].src, url)).href;
}
if (doc) {
const url = 'https://' + instance.host;
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
const links = Array.from(doc.getElementsByTagName('link')).reverse();
// https://github.com/misskey-dev/misskey/pull/8220/files/0ec4eba22a914e31b86874f12448f88b3e58dd5a#r796487559
const href =
const href =
[
links.find(link => link.relList.contains('apple-touch-icon-precomposed'))?.href,
links.find(link => link.relList.contains('apple-touch-icon'))?.href,
links.find(link => link.relList.contains('icon'))?.href,
]
.find(href => href);
if (href) {
return (new URL(href, url)).href;
}
}
return null;
}
@bindThis
private async getThemeColor(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color;
if (themeColor) {
const color = new tinycolor(themeColor);
if (color.isValid()) return color.toHexString();
}
return null;
}
@@ -253,19 +262,19 @@ export class FetchInstanceMetadataService {
return info.metadata.name;
}
}
if (doc) {
const og = doc.querySelector('meta[property="og:title"]')?.getAttribute('content');
if (og) {
return og;
}
}
if (manifest) {
return manifest.name ?? manifest.short_name;
}
return null;
}
@@ -278,23 +287,23 @@ export class FetchInstanceMetadataService {
return info.metadata.description;
}
}
if (doc) {
const meta = doc.querySelector('meta[name="description"]')?.getAttribute('content');
if (meta) {
return meta;
}
const og = doc.querySelector('meta[property="og:description"]')?.getAttribute('content');
if (og) {
return og;
}
}
if (manifest) {
return manifest.name ?? manifest.short_name;
}
return null;
}
}

View File

@@ -161,20 +161,20 @@ export class FileInfoService {
private async detectSensitivity(source: string, mime: string, sensitiveThreshold: number, sensitiveThresholdForPorn: number, analyzeVideo: boolean): Promise<[sensitive: boolean, porn: boolean]> {
let sensitive = false;
let porn = false;
function judgePrediction(result: readonly predictionType[]): [sensitive: boolean, porn: boolean] {
let sensitive = false;
let porn = false;
if ((result.find(x => x.className === 'Sexy')?.probability ?? 0) > sensitiveThreshold) sensitive = true;
if ((result.find(x => x.className === 'Hentai')?.probability ?? 0) > sensitiveThreshold) sensitive = true;
if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThreshold) sensitive = true;
if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThresholdForPorn) porn = true;
return [sensitive, porn];
}
if ([
'image/jpeg',
'image/png',
@@ -253,10 +253,10 @@ export class FileInfoService {
disposeOutDir();
}
}
return [sensitive, porn];
}
private async *asyncIterateFrames(cwd: string, command: FFmpeg.FfmpegCommand): AsyncGenerator<string, void> {
const watcher = new FSWatcher({
cwd,
@@ -295,7 +295,7 @@ export class FileInfoService {
}
}
}
@bindThis
private exists(path: string): Promise<boolean> {
return fs.promises.access(path).then(() => true, () => false);
@@ -304,11 +304,11 @@ export class FileInfoService {
@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/x-flac') {
return 'audio/flac';
}
if (mime === "audio/vnd.wave") {
return "audio/wav";
if (mime === 'audio/vnd.wave') {
return 'audio/wav';
}
return mime;
@@ -355,11 +355,12 @@ export class FileInfoService {
* Check the file is SVG or not
*/
@bindThis
public async checkSvg(path: string) {
public async checkSvg(path: string): Promise<boolean> {
try {
const size = await this.getFileSize(path);
if (size > 1 * 1024 * 1024) return false;
return isSvg(fs.readFileSync(path));
const buffer = await fs.promises.readFile(path);
return isSvg(buffer.toString());
} catch {
return false;
}

View File

@@ -20,7 +20,7 @@ import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js';
import { Role } from '@/models';
import { Role } from '@/models/index.js';
@Injectable()
export class GlobalEventService {

View File

@@ -1,5 +1,6 @@
import * as http from 'node:http';
import * as https from 'node:https';
import * as net from 'node:net';
import CacheableLookup from 'cacheable-lookup';
import fetch from 'node-fetch';
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
@@ -42,21 +43,21 @@ export class HttpRequestService {
errorTtl: 30, // 30secs
lookup: false, // nativeのdns.lookupにfallbackしない
});
this.http = new http.Agent({
keepAlive: true,
keepAliveMsecs: 30 * 1000,
lookup: cache.lookup,
} as http.AgentOptions);
lookup: cache.lookup as unknown as net.LookupFunction,
});
this.https = new https.Agent({
keepAlive: true,
keepAliveMsecs: 30 * 1000,
lookup: cache.lookup,
} as https.AgentOptions);
lookup: cache.lookup as unknown as net.LookupFunction,
});
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
this.httpAgent = config.proxy
? new HttpProxyAgent({
keepAlive: true,
@@ -144,7 +145,7 @@ export class HttpRequestService {
method: args.method ?? 'GET',
headers: {
'User-Agent': this.config.userAgent,
...(args.headers ?? {})
...(args.headers ?? {}),
},
body: args.body,
size: args.size ?? 10 * 1024 * 1024,

View File

@@ -5,7 +5,7 @@ import type { Config } from '@/config.js';
import { genAid, parseAid } from '@/misc/id/aid.js';
import { genMeid, parseMeid } from '@/misc/id/meid.js';
import { genMeidg, parseMeidg } from '@/misc/id/meidg.js';
import { genObjectId } from '@/misc/id/object-id.js';
import { genObjectId, parseObjectId } from '@/misc/id/object-id.js';
import { bindThis } from '@/decorators.js';
import { parseUlid } from '@/misc/id/ulid.js';
@@ -23,7 +23,7 @@ export class IdService {
@bindThis
public genId(date?: Date): string {
if (!date || (date > new Date())) date = new Date();
switch (this.method) {
case 'aid': return genAid(date);
case 'meid': return genMeid(date);
@@ -38,7 +38,7 @@ export class IdService {
public parse(id: string): { date: Date; } {
switch (this.method) {
case 'aid': return parseAid(id);
case 'objectid':
case 'objectid': return parseObjectId(id);
case 'meid': return parseMeid(id);
case 'meidg': return parseMeidg(id);
case 'ulid': return parseUlid(id);

View File

@@ -26,12 +26,12 @@ export class InstanceActorService {
public async getInstanceActor(): Promise<LocalUser> {
const cached = this.cache.get();
if (cached) return cached;
const user = await this.usersRepository.findOneBy({
host: IsNull(),
username: ACTOR_USERNAME,
}) as LocalUser | undefined;
if (user) {
this.cache.set(user);
return user;

View File

@@ -3,7 +3,7 @@ import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import type { KEYWORD } from 'color-convert/conversions';
import type { KEYWORD } from 'color-convert/conversions.js';
@Injectable()
export class LoggerService {

View File

@@ -56,7 +56,7 @@ export class MetaService implements OnApplicationShutdown {
@bindThis
public async fetch(noCache = false): Promise<Meta> {
if (!noCache && this.cache) return this.cache;
return await this.db.transaction(async transactionalEntityManager => {
// 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する
const metas = await transactionalEntityManager.find(Meta, {
@@ -64,9 +64,9 @@ export class MetaService implements OnApplicationShutdown {
id: 'DESC',
},
});
const meta = metas[0];
if (meta) {
this.cache = meta;
return meta;
@@ -81,7 +81,7 @@ export class MetaService implements OnApplicationShutdown {
['id'],
)
.then((x) => transactionalEntityManager.findOneByOrFail(Meta, x.identifiers[0]));
this.cache = saved;
return saved;
}

View File

@@ -27,29 +27,29 @@ export class MfmService {
public fromHtml(html: string, hashtagNames?: string[]): string {
// some AP servers like Pixelfed use br tags as well as newlines
html = html.replace(/<br\s?\/?>\r?\n/gi, '\n');
const dom = parse5.parseFragment(html);
let text = '';
for (const n of dom.childNodes) {
analyze(n);
}
return text.trim();
function getText(node: TreeAdapter.Node): string {
if (treeAdapter.isTextNode(node)) return node.value;
if (!treeAdapter.isElementNode(node)) return '';
if (node.nodeName === 'br') return '\n';
if (node.childNodes) {
return node.childNodes.map(n => getText(n)).join('');
}
return '';
}
function appendChildren(childNodes: TreeAdapter.ChildNode[]): void {
if (childNodes) {
for (const n of childNodes) {
@@ -57,35 +57,35 @@ export class MfmService {
}
}
}
function analyze(node: TreeAdapter.Node) {
if (treeAdapter.isTextNode(node)) {
text += node.value;
return;
}
// Skip comment or document type node
if (!treeAdapter.isElementNode(node)) return;
switch (node.nodeName) {
case 'br': {
text += '\n';
break;
}
case 'a':
{
const txt = getText(node);
const rel = node.attrs.find(x => x.name === 'rel');
const href = node.attrs.find(x => x.name === 'href');
// ハッシュタグ
if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) {
text += txt;
// メンション
} else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) {
const part = txt.split('@');
if (part.length === 2 && href) {
//#region ホスト名部分が省略されているので復元する
const acct = `${txt}@${(new URL(href.value)).hostname}`;
@@ -116,12 +116,12 @@ export class MfmService {
return `[${txt}](${href.value})`;
}
};
text += generateLink();
}
break;
}
case 'h1':
{
text += '【';
@@ -129,7 +129,7 @@ export class MfmService {
text += '】\n';
break;
}
case 'b':
case 'strong':
{
@@ -138,7 +138,7 @@ export class MfmService {
text += '**';
break;
}
case 'small':
{
text += '<small>';
@@ -146,7 +146,7 @@ export class MfmService {
text += '</small>';
break;
}
case 's':
case 'del':
{
@@ -155,7 +155,7 @@ export class MfmService {
text += '~~';
break;
}
case 'i':
case 'em':
{
@@ -164,7 +164,7 @@ export class MfmService {
text += '</i>';
break;
}
// block code (<pre><code>)
case 'pre': {
if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
@@ -176,7 +176,7 @@ export class MfmService {
}
break;
}
// inline code (<code>)
case 'code': {
text += '`';
@@ -184,7 +184,7 @@ export class MfmService {
text += '`';
break;
}
case 'blockquote': {
const t = getText(node);
if (t) {
@@ -193,7 +193,7 @@ export class MfmService {
}
break;
}
case 'p':
case 'h2':
case 'h3':
@@ -205,7 +205,7 @@ export class MfmService {
appendChildren(node.childNodes);
break;
}
// other block elements
case 'div':
case 'header':
@@ -219,7 +219,7 @@ export class MfmService {
appendChildren(node.childNodes);
break;
}
default: // includes inline elements
{
appendChildren(node.childNodes);
@@ -234,48 +234,48 @@ export class MfmService {
if (nodes == null) {
return null;
}
const { window } = new Window();
const doc = window.document;
function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
if (children) {
for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child);
}
}
const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any } = {
bold: (node) => {
const el = doc.createElement('b');
appendChildren(node.children, el);
return el;
},
small: (node) => {
const el = doc.createElement('small');
appendChildren(node.children, el);
return el;
},
strike: (node) => {
const el = doc.createElement('del');
appendChildren(node.children, el);
return el;
},
italic: (node) => {
const el = doc.createElement('i');
appendChildren(node.children, el);
return el;
},
fn: (node) => {
const el = doc.createElement('i');
appendChildren(node.children, el);
return el;
},
blockCode: (node) => {
const pre = doc.createElement('pre');
const inner = doc.createElement('code');
@@ -283,21 +283,21 @@ export class MfmService {
pre.appendChild(inner);
return pre;
},
center: (node) => {
const el = doc.createElement('div');
appendChildren(node.children, el);
return el;
},
emojiCode: (node) => {
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
},
unicodeEmoji: (node) => {
return doc.createTextNode(node.props.emoji);
},
hashtag: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
@@ -305,32 +305,32 @@ export class MfmService {
a.setAttribute('rel', 'tag');
return a;
},
inlineCode: (node) => {
const el = doc.createElement('code');
el.textContent = node.props.code;
return el;
},
mathInline: (node) => {
const el = doc.createElement('code');
el.textContent = node.props.formula;
return el;
},
mathBlock: (node) => {
const el = doc.createElement('code');
el.textContent = node.props.formula;
return el;
},
link: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', node.props.url);
appendChildren(node.children, a);
return a;
},
mention: (node) => {
const a = doc.createElement('a');
const { username, host, acct } = node.props;
@@ -340,47 +340,47 @@ export class MfmService {
a.textContent = acct;
return a;
},
quote: (node) => {
const el = doc.createElement('blockquote');
appendChildren(node.children, el);
return el;
},
text: (node) => {
const el = doc.createElement('span');
const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
el.appendChild(x === 'br' ? doc.createElement('br') : x);
}
return el;
},
url: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', node.props.url);
a.textContent = node.props.url;
return a;
},
search: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
a.textContent = node.props.content;
return a;
},
plain: (node) => {
const el = doc.createElement('span');
appendChildren(node.children, el);
return el;
},
};
appendChildren(nodes, doc.body);
return `<p>${doc.body.innerHTML}</p>`;
}
}
}

View File

@@ -570,12 +570,14 @@ export class NoteCreateService implements OnApplicationShutdown {
if (data.reply) {
// 通知
if (data.reply.userHost === null) {
const threadMuted = await this.noteThreadMutingsRepository.findOneBy({
userId: data.reply.userId,
threadId: data.reply.threadId ?? data.reply.id,
const isThreadMuted = await this.noteThreadMutingsRepository.exist({
where: {
userId: data.reply.userId,
threadId: data.reply.threadId ?? data.reply.id,
},
});
if (!threadMuted) {
if (!isThreadMuted) {
nm.push(data.reply.userId, 'reply');
this.globalEventService.publishMainStream(data.reply.userId, 'reply', noteObj);
@@ -672,7 +674,7 @@ export class NoteCreateService implements OnApplicationShutdown {
// Register to search database
this.index(note);
}
@bindThis
private isSensitive(note: Option, sensitiveWord: string[]): boolean {
if (sensitiveWord.length > 0) {
@@ -712,12 +714,14 @@ export class NoteCreateService implements OnApplicationShutdown {
@bindThis
private async createMentionedEvents(mentionedUsers: MinimumUser[], note: Note, nm: NotificationManager) {
for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) {
const threadMuted = await this.noteThreadMutingsRepository.findOneBy({
userId: u.id,
threadId: note.threadId ?? note.id,
const isThreadMuted = await this.noteThreadMutingsRepository.exist({
where: {
userId: u.id,
threadId: note.threadId ?? note.id,
},
});
if (threadMuted) {
if (isThreadMuted) {
continue;
}
@@ -758,7 +762,7 @@ export class NoteCreateService implements OnApplicationShutdown {
@bindThis
private index(note: Note) {
if (note.text == null && note.cw == null) return;
this.searchService.indexNote(note);
}

View File

@@ -17,6 +17,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import { SearchService } from '@/core/SearchService.js';
@Injectable()
export class NoteDeleteService {
@@ -41,11 +42,12 @@ export class NoteDeleteService {
private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService,
private metaService: MetaService,
private searchService: SearchService,
private notesChart: NotesChart,
private perUserNotesChart: PerUserNotesChart,
private instanceChart: InstanceChart,
) {}
/**
* 投稿を削除します。
* @param user 投稿者
@@ -53,6 +55,7 @@ export class NoteDeleteService {
*/
async delete(user: { id: User['id']; uri: User['uri']; host: User['host']; isBot: User['isBot']; }, note: Note, quiet = false) {
const deletedAt = new Date();
const cascadingNotes = await this.findCascadingNotes(note);
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
if (note.renoteId && (await this.noteEntityService.countSameRenotes(user.id, note.renoteId, note.id)) === 0) {
@@ -88,8 +91,8 @@ export class NoteDeleteService {
}
// also deliever delete activity to cascaded notes
const cascadingNotes = (await this.findCascadingNotes(note)).filter(note => !note.localOnly); // filter out local-only notes
for (const cascadingNote of cascadingNotes) {
const federatedLocalCascadingNotes = (cascadingNotes).filter(note => !note.localOnly && note.userHost == null); // filter out local-only notes
for (const cascadingNote of federatedLocalCascadingNotes) {
if (!cascadingNote.user) continue;
if (!this.userEntityService.isLocalUser(cascadingNote.user)) continue;
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${cascadingNote.id}`), cascadingNote.user));
@@ -114,6 +117,11 @@ export class NoteDeleteService {
}
}
for (const cascadingNote of cascadingNotes) {
this.searchService.unindexNote(cascadingNote);
}
this.searchService.unindexNote(note);
await this.notesRepository.delete({
id: note.id,
userId: user.id,
@@ -121,10 +129,8 @@ export class NoteDeleteService {
}
@bindThis
private async findCascadingNotes(note: Note) {
const cascadingNotes: Note[] = [];
const recursive = async (noteId: string) => {
private async findCascadingNotes(note: Note): Promise<Note[]> {
const recursive = async (noteId: string): Promise<Note[]> => {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.replyId = :noteId', { noteId })
.orWhere(new Brackets(q => {
@@ -133,14 +139,16 @@ export class NoteDeleteService {
}))
.leftJoinAndSelect('note.user', 'user');
const replies = await query.getMany();
for (const reply of replies) {
cascadingNotes.push(reply);
await recursive(reply.id);
}
};
await recursive(note.id);
return cascadingNotes.filter(note => note.userHost === null); // filter out non-local users
return [
replies,
...await Promise.all(replies.map(reply => recursive(reply.id))),
].flat();
};
const cascadingNotes: Note[] = await recursive(note.id);
return cascadingNotes;
}
@bindThis

View File

@@ -43,11 +43,13 @@ export class NoteReadService implements OnApplicationShutdown {
//#endregion
// スレッドミュート
const threadMute = await this.noteThreadMutingsRepository.findOneBy({
userId: userId,
threadId: note.threadId ?? note.id,
const isThreadMuted = await this.noteThreadMutingsRepository.exist({
where: {
userId: userId,
threadId: note.threadId ?? note.id,
},
});
if (threadMute) return;
if (isThreadMuted) return;
const unread = {
id: this.idService.genId(),
@@ -62,9 +64,9 @@ export class NoteReadService implements OnApplicationShutdown {
// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => {
const exist = await this.noteUnreadsRepository.findOneBy({ id: unread.id });
const exist = await this.noteUnreadsRepository.exist({ where: { id: unread.id } });
if (exist == null) return;
if (!exist) return;
if (params.isMentioned) {
this.globalEventService.publishMainStream(userId, 'unreadMention', note.id);
@@ -99,7 +101,7 @@ export class NoteReadService implements OnApplicationShutdown {
});
// TODO: ↓まとめてクエリしたい
this.noteUnreadsRepository.countBy({
userId: userId,
isMentioned: true,
@@ -109,7 +111,7 @@ export class NoteReadService implements OnApplicationShutdown {
this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions');
}
});
this.noteUnreadsRepository.countBy({
userId: userId,
isSpecified: true,

View File

@@ -46,7 +46,7 @@ export class NotificationService implements OnApplicationShutdown {
force = false,
) {
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`);
const latestNotificationIdsRes = await this.redisClient.xrevrange(
`notificationTimeline:${userId}`,
'+',

View File

@@ -39,12 +39,12 @@ export class PollService {
@bindThis
public async vote(user: User, note: Note, choice: number) {
const poll = await this.pollsRepository.findOneBy({ noteId: note.id });
if (poll == null) throw new Error('poll not found');
// Check whether is valid choice
if (poll.choices[choice] == null) throw new Error('invalid choice param');
// Check blocking
if (note.userId !== user.id) {
const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id);
@@ -52,13 +52,13 @@ export class PollService {
throw new Error('blocked');
}
}
// if already voted
const exist = await this.pollVotesRepository.findBy({
noteId: note.id,
userId: user.id,
});
if (poll.multiple) {
if (exist.some(x => x.choice === choice)) {
throw new Error('already voted');
@@ -66,7 +66,7 @@ export class PollService {
} else if (exist.length !== 0) {
throw new Error('already voted');
}
// Create vote
await this.pollVotesRepository.insert({
id: this.idService.genId(),
@@ -75,11 +75,11 @@ export class PollService {
userId: user.id,
choice: choice,
});
// Increment votes count
const index = choice + 1; // In SQL, array index is 1 based
await this.pollsRepository.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`);
this.globalEventService.publishNoteStream(note.id, 'pollVoted', {
choice: choice,
userId: user.id,
@@ -90,10 +90,10 @@ export class PollService {
public async deliverQuestionUpdate(noteId: Note['id']) {
const note = await this.notesRepository.findOneBy({ id: noteId });
if (note == null) throw new Error('note not found');
const user = await this.usersRepository.findOneBy({ id: note.userId });
if (user == null) throw new Error('note not found');
if (this.userEntityService.isLocalUser(user)) {
const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user));
this.apDeliverManagerService.deliverToFollowers(user, content);

View File

@@ -3,7 +3,7 @@ import push from 'web-push';
import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { Packed } from '@/misc/json-schema';
import type { Packed } from '@/misc/json-schema.js';
import { getNoteSummary } from '@/misc/get-note-summary.js';
import type { SwSubscription, SwSubscriptionsRepository } from '@/models/index.js';
import { MetaService } from '@/core/MetaService.js';
@@ -31,7 +31,7 @@ function truncateBody<T extends keyof PushNotificationsTypes>(type: T, body: Pus
...body.note,
// textをgetNoteSummaryしたものに置き換える
text: getNoteSummary(('type' in body && body.type === 'renote') ? body.note.renote as Packed<'Note'> : body.note),
cw: undefined,
reply: undefined,
renote: undefined,
@@ -69,16 +69,16 @@ export class PushNotificationService implements OnApplicationShutdown {
@bindThis
public async pushNotification<T extends keyof PushNotificationsTypes>(userId: string, type: T, body: PushNotificationsTypes[T]) {
const meta = await this.metaService.fetch();
if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return;
// アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録
push.setVapidDetails(this.config.url,
meta.swPublicKey,
meta.swPrivateKey);
const subscriptions = await this.subscriptionsCache.fetch(userId);
for (const subscription of subscriptions) {
if ([
'readAllNotifications',
@@ -103,7 +103,7 @@ export class PushNotificationService implements OnApplicationShutdown {
//swLogger.info(err.statusCode);
//swLogger.info(err.headers);
//swLogger.info(err.body);
if (err.statusCode === 410) {
this.swSubscriptionsRepository.delete({
userId: userId,

View File

@@ -60,8 +60,8 @@ export class QueryService {
q.orderBy(`${q.alias}.id`, 'DESC');
}
return q;
}
}
// ここでいうBlockedは被Blockedの意
@bindThis
public generateBlockedUserQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void {
@@ -109,18 +109,18 @@ export class QueryService {
q.andWhere('note.channelId IS NULL');
} else {
q.leftJoinAndSelect('note.channel', 'channel');
const channelFollowingQuery = this.channelFollowingsRepository.createQueryBuilder('channelFollowing')
.select('channelFollowing.followeeId')
.where('channelFollowing.followerId = :followerId', { followerId: me.id });
q.andWhere(new Brackets(qb => { qb
// チャンネルのノートではない
.where('note.channelId IS NULL')
// または自分がフォローしているチャンネルのノート
.orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`);
}));
q.setParameters(channelFollowingQuery.getParameters());
}
}
@@ -130,9 +130,9 @@ export class QueryService {
const mutedQuery = this.mutedNotesRepository.createQueryBuilder('muted')
.select('muted.noteId')
.where('muted.userId = :userId', { userId: me.id });
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
q.setParameters(mutedQuery.getParameters());
}
@@ -141,13 +141,13 @@ export class QueryService {
const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
.select('threadMuted.threadId')
.where('threadMuted.userId = :userId', { userId: me.id });
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
q.andWhere(new Brackets(qb => { qb
.where('note.threadId IS NULL')
.orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`);
}));
q.setParameters(mutedQuery.getParameters());
}
@@ -156,15 +156,15 @@ export class QueryService {
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
.select('muting.muteeId')
.where('muting.muterId = :muterId', { muterId: me.id });
if (exclude) {
mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id });
}
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
.select('user_profile.mutedInstances')
.where('user_profile.userId = :muterId', { muterId: me.id });
// 投稿の作者をミュートしていない かつ
// 投稿の返信先の作者をミュートしていない かつ
// 投稿の引用元の作者をミュートしていない
@@ -191,7 +191,7 @@ export class QueryService {
.where('note.renoteUserHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
}));
q.setParameters(mutingQuery.getParameters());
q.setParameters(mutingInstanceQuery.getParameters());
}
@@ -201,9 +201,9 @@ export class QueryService {
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
.select('muting.muteeId')
.where('muting.muterId = :muterId', { muterId: me.id });
q.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`);
q.setParameters(mutingQuery.getParameters());
}
@@ -245,7 +245,7 @@ export class QueryService {
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :meId');
q.andWhere(new Brackets(qb => { qb
// 公開投稿である
.where(new Brackets(qb => { qb
@@ -268,7 +268,7 @@ export class QueryService {
}));
}));
}));
q.setParameters({ meId: me.id });
}
}
@@ -278,10 +278,10 @@ export class QueryService {
const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting')
.select('renote_muting.muteeId')
.where('renote_muting.muterId = :muterId', { muterId: me.id });
q.andWhere(new Brackets(qb => {
qb
.where(new Brackets(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() })`);
@@ -289,7 +289,7 @@ export class QueryService {
.orWhere('note.renoteId IS NULL')
.orWhere('note.text IS NOT NULL');
}));
q.setParameters(mutingQuery.getParameters());
}
}

View File

@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { v4 as uuid } from 'uuid';
import type { IActivity } from '@/core/activitypub/type.js';
import type { DriveFile } from '@/models/entities/DriveFile.js';
import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js';
@@ -101,15 +101,14 @@ export class QueueService {
const opts = {
attempts: this.config.deliverJobMaxAttempts ?? 12,
timeout: 1 * 60 * 1000, // 1min
backoff: {
type: 'apBackoff',
type: 'custom',
},
removeOnComplete: true,
removeOnFail: true,
};
await this.deliverQueue.addBulk(Array.from(inboxes.entries()).map(d => ({
await this.deliverQueue.addBulk(Array.from(inboxes.entries(), d => ({
name: d[0],
data: {
user,
@@ -417,7 +416,7 @@ export class QueueService {
to: webhook.url,
secret: webhook.secret,
createdAt: Date.now(),
eventId: uuid(),
eventId: randomUUID(),
};
return this.webhookDeliverQueue.add(webhook.id, data, {
@@ -435,11 +434,11 @@ export class QueueService {
this.deliverQueue.once('cleaned', (jobs, status) => {
//deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
});
this.deliverQueue.clean(0, Infinity, 'delayed');
this.deliverQueue.clean(0, 0, 'delayed');
this.inboxQueue.once('cleaned', (jobs, status) => {
//inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
});
this.inboxQueue.clean(0, Infinity, 'delayed');
this.inboxQueue.clean(0, 0, 'delayed');
}
}

View File

@@ -39,9 +39,9 @@ export class RelayService {
host: IsNull(),
username: ACTOR_USERNAME,
});
if (user) return user as LocalUser;
const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME);
return created as LocalUser;
}
@@ -53,12 +53,12 @@ export class RelayService {
inbox,
status: 'requesting',
}).then(x => this.relaysRepository.findOneByOrFail(x.identifiers[0]));
const relayActor = await this.getRelayActor();
const follow = await this.apRendererService.renderFollowRelay(relay, relayActor);
const activity = this.apRendererService.addContext(follow);
this.queueService.deliver(relayActor, activity, relay.inbox, false);
return relay;
}
@@ -67,17 +67,17 @@ export class RelayService {
const relay = await this.relaysRepository.findOneBy({
inbox,
});
if (relay == null) {
throw new Error('relay not found');
}
const relayActor = await this.getRelayActor();
const follow = this.apRendererService.renderFollowRelay(relay, relayActor);
const undo = this.apRendererService.renderUndo(follow, relayActor);
const activity = this.apRendererService.addContext(undo);
this.queueService.deliver(relayActor, activity, relay.inbox, false);
await this.relaysRepository.delete(relay.id);
}
@@ -86,13 +86,13 @@ export class RelayService {
const relays = await this.relaysRepository.find();
return relays;
}
@bindThis
public async relayAccepted(id: string): Promise<string> {
const result = await this.relaysRepository.update(id, {
status: 'accepted',
});
return JSON.stringify(result);
}
@@ -101,24 +101,24 @@ export class RelayService {
const result = await this.relaysRepository.update(id, {
status: 'rejected',
});
return JSON.stringify(result);
}
@bindThis
public async deliverToRelays(user: { id: User['id']; host: null; }, activity: any): Promise<void> {
if (activity == null) return;
const relays = await this.relaysCache.fetch(() => this.relaysRepository.findBy({
status: 'accepted',
}));
if (relays.length === 0) return;
const copy = deepClone(activity);
if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public'];
const signed = await this.apRendererService.attachLdSignature(copy, user);
for (const relay of relays) {
this.queueService.deliver(user, signed, relay.inbox, false);
}

View File

@@ -8,8 +8,9 @@ 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';
import { WebfingerService } from '@/core/WebfingerService.js';
import { ILink, WebfingerService } from '@/core/WebfingerService.js';
import { RemoteLoggerService } from '@/core/RemoteLoggerService.js';
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { bindThis } from '@/decorators.js';
@@ -27,6 +28,7 @@ export class RemoteUserResolveService {
private utilityService: UtilityService,
private webfingerService: WebfingerService,
private remoteLoggerService: RemoteLoggerService,
private apDbResolverService: ApDbResolverService,
private apPersonService: ApPersonService,
) {
this.logger = this.remoteLoggerService.logger.createSubLogger('resolve-user');
@@ -35,7 +37,7 @@ export class RemoteUserResolveService {
@bindThis
public async resolveUser(username: string, host: string | null): Promise<LocalUser | RemoteUser> {
const usernameLower = username.toLowerCase();
if (host == null) {
this.logger.info(`return local user: ${usernameLower}`);
return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => {
@@ -46,9 +48,9 @@ export class RemoteUserResolveService {
}
}) as LocalUser;
}
host = this.utilityService.toPuny(host);
if (this.config.host === host) {
this.logger.info(`return local user: ${usernameLower}`);
return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => {
@@ -59,39 +61,55 @@ export class RemoteUserResolveService {
}
}) as LocalUser;
}
const user = await this.usersRepository.findOneBy({ usernameLower, host }) as RemoteUser | null;
const acctLower = `${usernameLower}@${host}`;
if (user == null) {
const self = await this.resolveSelf(acctLower);
if (self.href.startsWith(this.config.url)) {
const local = this.apDbResolverService.parseUri(self.href);
if (local.local && local.type === 'users') {
// the LR points to local
return (await this.apDbResolverService
.getUserFromApId(self.href)
.then((u) => {
if (u == null) {
throw new Error('local user not found');
} else {
return u;
}
})) as LocalUser;
}
}
this.logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`);
return await this.apPersonService.createPerson(self.href);
}
// ユーザー情報が古い場合は、WebFilgerからやりなおして返す
// ユーザー情報が古い場合は、WebFingerからやりなおして返す
if (user.lastFetchedAt == null || Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
// 繋がらないインスタンスに何回も試行するのを防ぐ, 後続の同様処理の連続試行を防ぐ ため 試行前にも更新する
await this.usersRepository.update(user.id, {
lastFetchedAt: new Date(),
});
this.logger.info(`try resync: ${acctLower}`);
const self = await this.resolveSelf(acctLower);
if (user.uri !== self.href) {
// if uri mismatch, Fix (user@host <=> AP's Person id(RemoteUser.uri)) mapping.
this.logger.info(`uri missmatch: ${acctLower}`);
this.logger.info(`recovery missmatch uri for (username=${username}, host=${host}) from ${user.uri} to ${self.href}`);
// validate uri
const uri = new URL(self.href);
if (uri.hostname !== host) {
throw new Error('Invalid uri');
}
await this.usersRepository.update({
usernameLower,
host: host,
@@ -101,9 +119,9 @@ export class RemoteUserResolveService {
} else {
this.logger.info(`uri is fine: ${acctLower}`);
}
await this.apPersonService.updatePerson(self.href);
this.logger.info(`return resynced remote user: ${acctLower}`);
return await this.usersRepository.findOneBy({ uri: self.href }).then(u => {
if (u == null) {
@@ -113,13 +131,13 @@ export class RemoteUserResolveService {
}
});
}
this.logger.info(`return existing remote user: ${acctLower}`);
return user;
}
@bindThis
private async resolveSelf(acctLower: string) {
private async resolveSelf(acctLower: string): Promise<ILink> {
this.logger.info(`WebFinger for ${chalk.yellow(acctLower)}`);
const finger = await this.webfingerService.webfinger(acctLower).catch(err => {
this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${ err.statusCode ?? err.message }`);

View File

@@ -13,7 +13,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { StreamMessages } from '@/server/api/stream/types.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { Packed } from '@/misc/json-schema';
import type { Packed } from '@/misc/json-schema.js';
import type { OnApplicationShutdown } from '@nestjs/common';
export type RolePolicies = {
@@ -21,6 +21,9 @@ export type RolePolicies = {
ltlAvailable: boolean;
canPublicNote: boolean;
canInvite: boolean;
inviteLimit: number;
inviteLimitCycle: number;
inviteExpirationTime: number;
canManageCustomEmojis: boolean;
canSearchNotes: boolean;
canHideAds: boolean;
@@ -42,6 +45,9 @@ export const DEFAULT_POLICIES: RolePolicies = {
ltlAvailable: true,
canPublicNote: true,
canInvite: false,
inviteLimit: 0,
inviteLimitCycle: 60 * 24 * 7,
inviteExpirationTime: 0,
canManageCustomEmojis: false,
canSearchNotes: false,
canHideAds: false,
@@ -214,14 +220,19 @@ export class RoleService implements OnApplicationShutdown {
}
@bindThis
public async getUserRoles(userId: User['id']) {
public async getUserAssigns(userId: User['id']) {
const now = Date.now();
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
// 期限切れのロールを除外
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
const assignedRoleIds = assigns.map(x => x.roleId);
return assigns;
}
@bindThis
public async getUserRoles(userId: User['id']) {
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
const assignedRoles = roles.filter(r => assignedRoleIds.includes(r.id));
const assigns = await this.getUserAssigns(userId);
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, r.condFormula));
return [...assignedRoles, ...matchedCondRoles];
@@ -277,6 +288,9 @@ export class RoleService implements OnApplicationShutdown {
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
canInvite: calc('canInvite', vs => vs.some(v => v === true)),
inviteLimit: calc('inviteLimit', vs => Math.max(...vs)),
inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)),
inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)),
canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)),
canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
@@ -392,7 +406,7 @@ export class RoleService implements OnApplicationShutdown {
@bindThis
public async unassign(userId: User['id'], roleId: Role['id']): Promise<void> {
const now = new Date();
const existing = await this.roleAssignmentsRepository.findOneBy({ roleId, userId });
if (existing == null) {
throw new RoleService.NotAssignedError();

View File

@@ -135,6 +135,15 @@ export class SearchService {
}
}
@bindThis
public async unindexNote(note: Note): Promise<void> {
if (!['home', 'public'].includes(note.visibility)) return;
if (this.meilisearch) {
this.meilisearchNoteIndex!.deleteDocument(note.id);
}
}
@bindThis
public async searchNote(q: string, me: User | null, opts: {
userId?: Note['userId'] | null;
@@ -194,7 +203,7 @@ export class SearchService {
if (me) this.queryService.generateMutedUserQuery(query, me);
if (me) this.queryService.generateBlockedUserQuery(query, me);
return await query.take(pagination.limit).getMany();
return await query.limit(pagination.limit).getMany();
}
}
}

View File

@@ -50,33 +50,33 @@ export class SignupService {
}) {
const { username, password, passwordHash, host } = opts;
let hash = passwordHash;
// Validate username
if (!this.userEntityService.validateLocalUsername(username)) {
throw new Error('INVALID_USERNAME');
}
if (password != null && passwordHash == null) {
// Validate password
if (!this.userEntityService.validatePassword(password)) {
throw new Error('INVALID_PASSWORD');
}
// Generate hash of password
const salt = await bcrypt.genSalt(8);
hash = await bcrypt.hash(password, salt);
}
// Generate secret
const secret = generateUserToken();
// Check username duplication
if (await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) {
if (await this.usersRepository.exist({ where: { usernameLower: username.toLowerCase(), host: IsNull() } })) {
throw new Error('DUPLICATED_USERNAME');
}
// Check deleted username duplication
if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) {
if (await this.usedUsernamesRepository.exist({ where: { username: username.toLowerCase() } })) {
throw new Error('USED_USERNAME');
}
@@ -92,7 +92,7 @@ export class SignupService {
const keyPair = await new Promise<string[]>((res, rej) =>
generateKeyPair('rsa', {
modulusLength: 4096,
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
@@ -106,18 +106,18 @@ export class SignupService {
}, (err, publicKey, privateKey) =>
err ? rej(err) : res([publicKey, privateKey]),
));
let account!: User;
// Start transaction
await this.db.transaction(async transactionalEntityManager => {
const exist = await transactionalEntityManager.findOneBy(User, {
usernameLower: username.toLowerCase(),
host: IsNull(),
});
if (exist) throw new Error(' the username is already used');
account = await transactionalEntityManager.save(new User({
id: this.idService.genId(),
createdAt: new Date(),
@@ -127,27 +127,27 @@ export class SignupService {
token: secret,
isRoot: isTheFirstUser,
}));
await transactionalEntityManager.save(new UserKeypair({
publicKey: keyPair[0],
privateKey: keyPair[1],
userId: account.id,
}));
await transactionalEntityManager.save(new UserProfile({
userId: account.id,
autoAcceptFollowed: true,
password: hash,
}));
await transactionalEntityManager.save(new UsedUsername({
createdAt: new Date(),
username: username.toLowerCase(),
}));
});
this.usersChart.update(account, true);
return { account, secret };
}
}

View File

@@ -69,7 +69,7 @@ function verifyCertificateChain(certificates: string[]) {
const certStruct = jsrsasign.ASN1HEX.getTLVbyList(certificate.hex!, 0, [0]);
if (certStruct == null) throw new Error('certStruct is null');
const algorithm = certificate.getSignatureAlgorithmField();
const signatureHex = certificate.getSignatureValueHex();
@@ -143,19 +143,19 @@ export class TwoFactorAuthenticationService {
if (clientData.type !== 'webauthn.get') {
throw new Error('type is not webauthn.get');
}
if (this.hash(clientData.challenge).toString('hex') !== challenge) {
throw new Error('challenge mismatch');
}
if (clientData.origin !== this.config.scheme + '://' + this.config.host) {
throw new Error('origin mismatch');
}
const verificationData = Buffer.concat(
[authenticatorData, this.hash(clientDataJSON)],
32 + authenticatorData.length,
);
return crypto
.createVerify('SHA256')
.update(verificationData)
@@ -168,7 +168,7 @@ export class TwoFactorAuthenticationService {
none: {
verify({ publicKey }: { publicKey: Map<number, Buffer> }) {
const negTwo = publicKey.get(-2);
if (!negTwo || negTwo.length !== 32) {
throw new Error('invalid or no -2 key given');
}
@@ -176,12 +176,12 @@ export class TwoFactorAuthenticationService {
if (!negThree || negThree.length !== 32) {
throw new Error('invalid or no -3 key given');
}
const publicKeyU2F = Buffer.concat(
[ECC_PRELUDE, negTwo, negThree],
1 + 32 + 32,
);
return {
publicKey: publicKeyU2F,
valid: true,
@@ -207,16 +207,16 @@ export class TwoFactorAuthenticationService {
if (attStmt.alg !== -7) {
throw new Error('alg mismatch');
}
const verificationData = Buffer.concat([
authenticatorData,
clientDataHash,
]);
const attCert: Buffer = attStmt.x5c[0];
const negTwo = publicKey.get(-2);
if (!negTwo || negTwo.length !== 32) {
throw new Error('invalid or no -2 key given');
}
@@ -224,23 +224,23 @@ export class TwoFactorAuthenticationService {
if (!negThree || negThree.length !== 32) {
throw new Error('invalid or no -3 key given');
}
const publicKeyData = Buffer.concat(
[ECC_PRELUDE, negTwo, negThree],
1 + 32 + 32,
);
if (!attCert.equals(publicKeyData)) {
throw new Error('public key mismatch');
}
const isValid = crypto
.createVerify('SHA256')
.update(verificationData)
.verify(PEMString(attCert), attStmt.sig);
// TODO: Check 'attestationChallenge' field in extension of cert matches hash(clientDataJSON)
return {
valid: isValid,
publicKey: publicKeyData,
@@ -267,43 +267,43 @@ export class TwoFactorAuthenticationService {
const verificationData = this.hash(
Buffer.concat([authenticatorData, clientDataHash]),
);
const jwsParts = attStmt.response.toString('utf-8').split('.');
const header = JSON.parse(base64URLDecode(jwsParts[0]).toString('utf-8'));
const response = JSON.parse(
base64URLDecode(jwsParts[1]).toString('utf-8'),
);
const signature = jwsParts[2];
if (!verificationData.equals(Buffer.from(response.nonce, 'base64'))) {
throw new Error('invalid nonce');
}
const certificateChain = header.x5c
.map((key: any) => PEMString(key))
.concat([GSR2]);
if (getCertSubject(certificateChain[0]).CN !== 'attest.android.com') {
throw new Error('invalid common name');
}
if (!verifyCertificateChain(certificateChain)) {
throw new Error('Invalid certificate chain!');
}
const signatureBase = Buffer.from(
jwsParts[0] + '.' + jwsParts[1],
'utf-8',
);
const valid = crypto
.createVerify('sha256')
.update(signatureBase)
.verify(certificateChain[0], base64URLDecode(signature));
const negTwo = publicKey.get(-2);
if (!negTwo || negTwo.length !== 32) {
throw new Error('invalid or no -2 key given');
}
@@ -311,7 +311,7 @@ export class TwoFactorAuthenticationService {
if (!negThree || negThree.length !== 32) {
throw new Error('invalid or no -3 key given');
}
const publicKeyData = Buffer.concat(
[ECC_PRELUDE, negTwo, negThree],
1 + 32 + 32,
@@ -342,17 +342,17 @@ export class TwoFactorAuthenticationService {
authenticatorData,
clientDataHash,
]);
if (attStmt.x5c) {
const attCert = attStmt.x5c[0];
const validSignature = crypto
.createVerify('SHA256')
.update(verificationData)
.verify(PEMString(attCert), attStmt.sig);
const negTwo = publicKey.get(-2);
if (!negTwo || negTwo.length !== 32) {
throw new Error('invalid or no -2 key given');
}
@@ -360,12 +360,12 @@ export class TwoFactorAuthenticationService {
if (!negThree || negThree.length !== 32) {
throw new Error('invalid or no -3 key given');
}
const publicKeyData = Buffer.concat(
[ECC_PRELUDE, negTwo, negThree],
1 + 32 + 32,
);
return {
valid: validSignature,
publicKey: publicKeyData,
@@ -375,12 +375,12 @@ export class TwoFactorAuthenticationService {
throw new Error('ECDAA-Verify is not supported');
} else {
if (attStmt.alg !== -7) throw new Error('alg mismatch');
throw new Error('self attestation is not supported');
}
},
},
'fido-u2f': {
verify({
attStmt,
@@ -401,13 +401,13 @@ export class TwoFactorAuthenticationService {
if (x5c.length !== 1) {
throw new Error('x5c length does not match expectation');
}
const attCert = x5c[0];
// TODO: make sure attCert is an Elliptic Curve (EC) public key over the P-256 curve
const negTwo: Buffer = publicKey.get(-2);
if (!negTwo || negTwo.length !== 32) {
throw new Error('invalid or no -2 key given');
}
@@ -415,12 +415,12 @@ export class TwoFactorAuthenticationService {
if (!negThree || negThree.length !== 32) {
throw new Error('invalid or no -3 key given');
}
const publicKeyU2F = Buffer.concat(
[ECC_PRELUDE, negTwo, negThree],
1 + 32 + 32,
);
const verificationData = Buffer.concat([
NULL_BYTE,
rpIdHash,
@@ -428,12 +428,12 @@ export class TwoFactorAuthenticationService {
credentialId,
publicKeyU2F,
]);
const validSignature = crypto
.createVerify('SHA256')
.update(verificationData)
.verify(PEMString(attCert), attStmt.sig);
return {
valid: validSignature,
publicKey: publicKeyU2F,

View File

@@ -1,5 +1,6 @@
import { Inject, Injectable, OnModuleInit, forwardRef } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { IsNull } from 'typeorm';
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';
@@ -21,9 +22,8 @@ import { UserBlockingService } from '@/core/UserBlockingService.js';
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';
import Logger from '../logger.js';
const logger = new Logger('following/create');
@@ -122,22 +122,26 @@ export class UserFollowingService implements OnModuleInit {
let autoAccept = false;
// 鍵アカウントであっても、既にフォローされていた場合はスルー
const following = await this.followingsRepository.findOneBy({
followerId: follower.id,
followeeId: followee.id,
const isFollowing = await this.followingsRepository.exist({
where: {
followerId: follower.id,
followeeId: followee.id,
},
});
if (following) {
if (isFollowing) {
autoAccept = true;
}
// フォローしているユーザーは自動承認オプション
if (!autoAccept && (this.userEntityService.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) {
const followed = await this.followingsRepository.findOneBy({
followerId: followee.id,
followeeId: follower.id,
const isFollowed = await this.followingsRepository.exist({
where: {
followerId: followee.id,
followeeId: follower.id,
},
});
if (followed) autoAccept = true;
if (isFollowed) autoAccept = true;
}
// Automatically accept if the follower is an account who has moved and the locked followee had accepted the old account.
@@ -206,12 +210,14 @@ export class UserFollowingService implements OnModuleInit {
this.cacheService.userFollowingsCache.refresh(follower.id);
const req = await this.followRequestsRepository.findOneBy({
followeeId: followee.id,
followerId: follower.id,
const requestExist = await this.followRequestsRepository.exist({
where: {
followeeId: followee.id,
followerId: follower.id,
},
});
if (req) {
if (requestExist) {
await this.followRequestsRepository.delete({
followeeId: followee.id,
followerId: follower.id,
@@ -316,7 +322,7 @@ export class UserFollowingService implements OnModuleInit {
where: {
followerId: follower.id,
followeeId: followee.id,
}
},
});
if (following === null || !following.follower || !following.followee) {
@@ -406,8 +412,8 @@ export class UserFollowingService implements OnModuleInit {
followerId: user.id,
followee: {
movedToUri: IsNull(),
}
}
},
},
});
const nonMovedFollowers = await this.followingsRepository.count({
relations: {
@@ -417,8 +423,8 @@ export class UserFollowingService implements OnModuleInit {
followeeId: user.id,
follower: {
movedToUri: IsNull(),
}
}
},
},
});
await this.usersRepository.update(
{ id: user.id },
@@ -505,12 +511,14 @@ export class UserFollowingService implements OnModuleInit {
}
}
const request = await this.followRequestsRepository.findOneBy({
followeeId: followee.id,
followerId: follower.id,
const requestExist = await this.followRequestsRepository.exist({
where: {
followeeId: followee.id,
followerId: follower.id,
},
});
if (request == null) {
if (!requestExist) {
throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found');
}
@@ -638,7 +646,7 @@ export class UserFollowingService implements OnModuleInit {
where: {
followeeId: followee.id,
followerId: follower.id,
}
},
});
if (!following || !following.followee || !following.follower) return;

View File

@@ -32,13 +32,13 @@ export class UserSuspendService {
@bindThis
public async doPostSuspend(user: { id: User['id']; host: User['host'] }): Promise<void> {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
const queue: string[] = [];
const followings = await this.followingsRepository.find({
where: [
{ followerSharedInbox: Not(IsNull()) },
@@ -46,13 +46,13 @@ export class UserSuspendService {
],
select: ['followerSharedInbox', 'followeeSharedInbox'],
});
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
for (const inbox of inboxes) {
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
}
for (const inbox of queue) {
this.queueService.deliver(user, content, inbox, true);
}
@@ -62,13 +62,13 @@ export class UserSuspendService {
@bindThis
public async doPostUnsuspend(user: User): Promise<void> {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false });
if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにUndo Delete配信
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user));
const queue: string[] = [];
const followings = await this.followingsRepository.find({
where: [
{ followerSharedInbox: Not(IsNull()) },
@@ -76,13 +76,13 @@ export class UserSuspendService {
],
select: ['followerSharedInbox', 'followeeSharedInbox'],
});
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
for (const inbox of inboxes) {
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
}
for (const inbox of queue) {
this.queueService.deliver(user as any, content, inbox, true);
}

View File

@@ -21,7 +21,7 @@ export class VideoProcessingService {
@bindThis
public async generateVideoThumbnail(source: string): Promise<IImage> {
const [dir, cleanup] = await createTempDir();
try {
await new Promise((res, rej) => {
FFmpeg({
@@ -52,7 +52,7 @@ export class VideoProcessingService {
query({
thumbnail: '1',
url,
})
}),
);
}
}

View File

@@ -6,12 +6,12 @@ import { query as urlQuery } from '@/misc/prelude/url.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js';
type ILink = {
export type ILink = {
href: string;
rel?: string;
};
type IWebFinger = {
export type IWebFinger = {
links: ILink[];
subject: string;
};

View File

@@ -31,7 +31,7 @@ export class WebhookService implements OnApplicationShutdown {
});
this.webhooksFetched = true;
}
return this.webhooks;
}

View File

@@ -16,6 +16,8 @@ type AudienceInfo = {
visibleUsers: User[],
};
type GroupedAudience = Record<'public' | 'followers' | 'other', string[]>;
@Injectable()
export class ApAudienceService {
constructor(
@@ -27,14 +29,14 @@ export class ApAudienceService {
public async parseAudience(actor: RemoteUser, to?: ApObject, cc?: ApObject, resolver?: Resolver): Promise<AudienceInfo> {
const toGroups = this.groupingAudience(getApIds(to), actor);
const ccGroups = this.groupingAudience(getApIds(cc), actor);
const others = unique(concat([toGroups.other, ccGroups.other]));
const limit = promiseLimit<User | null>(2);
const mentionedUsers = (await Promise.all(
others.map(id => limit(() => this.apPersonService.resolvePerson(id, resolver).catch(() => null))),
)).filter((x): x is User => x != null);
if (toGroups.public.length > 0) {
return {
visibility: 'public',
@@ -42,7 +44,7 @@ export class ApAudienceService {
visibleUsers: [],
};
}
if (ccGroups.public.length > 0) {
return {
visibility: 'home',
@@ -50,7 +52,7 @@ export class ApAudienceService {
visibleUsers: [],
};
}
if (toGroups.followers.length > 0) {
return {
visibility: 'followers',
@@ -58,22 +60,22 @@ export class ApAudienceService {
visibleUsers: [],
};
}
return {
visibility: 'specified',
mentionedUsers,
visibleUsers: mentionedUsers,
};
}
@bindThis
private groupingAudience(ids: string[], actor: RemoteUser) {
const groups = {
public: [] as string[],
followers: [] as string[],
other: [] as string[],
private groupingAudience(ids: string[], actor: RemoteUser): GroupedAudience {
const groups: GroupedAudience = {
public: [],
followers: [],
other: [],
};
for (const id of ids) {
if (this.isPublic(id)) {
groups.public.push(id);
@@ -83,25 +85,23 @@ export class ApAudienceService {
groups.other.push(id);
}
}
groups.other = unique(groups.other);
return groups;
}
@bindThis
private isPublic(id: string) {
private isPublic(id: string): boolean {
return [
'https://www.w3.org/ns/activitystreams#Public',
'as#Public',
'Public',
].includes(id);
}
@bindThis
private isFollowers(id: string, actor: RemoteUser) {
return (
id === (actor.followersUri ?? `${actor.uri}/followers`)
);
private isFollowers(id: string, actor: RemoteUser): boolean {
return id === (actor.followersUri ?? `${actor.uri}/followers`);
}
}

View File

@@ -1,5 +1,4 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import escapeRegexp from 'escape-regexp';
import { DI } from '@/di-symbols.js';
import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
@@ -56,25 +55,18 @@ export class ApDbResolverService implements OnApplicationShutdown {
@bindThis
public parseUri(value: string | IObject): UriParseResult {
const uri = getApId(value);
// the host part of a URL is case insensitive, so use the 'i' flag.
const localRegex = new RegExp('^' + escapeRegexp(this.config.url) + '/(\\w+)/(\\w+)(?:\/(.+))?', 'i');
const matchLocal = uri.match(localRegex);
if (matchLocal) {
return {
local: true,
type: matchLocal[1],
id: matchLocal[2],
rest: matchLocal[3],
};
} else {
return {
local: false,
uri,
};
}
const separator = '/';
const uri = new URL(getApId(value));
if (uri.origin !== this.config.url) return { local: false, uri: uri.href };
const [, type, id, ...rest] = uri.pathname.split(separator);
return {
local: true,
type,
id,
rest: rest.length === 0 ? undefined : rest.join(separator),
};
}
/**
@@ -107,13 +99,15 @@ export class ApDbResolverService implements OnApplicationShutdown {
if (parsed.local) {
if (parsed.type !== 'users') return null;
return await this.cacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({
id: parsed.id,
}).then(x => x ?? undefined)) as LocalUser | undefined ?? null;
return await this.cacheService.userByIdCache.fetchMaybe(
parsed.id,
() => this.usersRepository.findOneBy({ id: parsed.id }).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;
return await this.cacheService.uriPersonCache.fetch(
parsed.uri,
() => this.usersRepository.findOneBy({ uri: parsed.uri }),
) as RemoteUser | null;
}
}
@@ -129,7 +123,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
const key = await this.userPublickeysRepository.findOneBy({
keyId,
});
if (key == null) return null;
return key;
@@ -153,9 +147,11 @@ export class ApDbResolverService implements OnApplicationShutdown {
} | null> {
const user = await this.apPersonService.resolvePerson(uri) as RemoteUser;
if (user == null) return null;
const key = await this.publicKeyByUserIdCache.fetch(user.id, () => this.userPublickeysRepository.findOneBy({ userId: user.id }), v => v != null);
const key = await this.publicKeyByUserIdCache.fetch(
user.id,
() => this.userPublickeysRepository.findOneBy({ userId: user.id }),
v => v != null,
);
return {
user,

View File

@@ -7,6 +7,7 @@ import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
import { QueueService } from '@/core/QueueService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import type { IActivity } from '@/core/activitypub/type.js';
import { ThinUser } from '@/queue/types.js';
interface IRecipe {
@@ -22,12 +23,127 @@ interface IDirectRecipe extends IRecipe {
to: RemoteUser;
}
const isFollowers = (recipe: any): recipe is IFollowersRecipe =>
const isFollowers = (recipe: IRecipe): recipe is IFollowersRecipe =>
recipe.type === 'Followers';
const isDirect = (recipe: any): recipe is IDirectRecipe =>
const isDirect = (recipe: IRecipe): recipe is IDirectRecipe =>
recipe.type === 'Direct';
class DeliverManager {
private actor: ThinUser;
private activity: IActivity | null;
private recipes: IRecipe[] = [];
/**
* Constructor
* @param userEntityService
* @param followingsRepository
* @param queueService
* @param actor Actor
* @param activity Activity to deliver
*/
constructor(
private userEntityService: UserEntityService,
private followingsRepository: FollowingsRepository,
private queueService: QueueService,
actor: { id: User['id']; host: null; },
activity: IActivity | null,
) {
// 型で弾いてはいるが一応ローカルユーザーかチェック
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (actor.host != null) throw new Error('actor.host must be null');
// パフォーマンス向上のためキューに突っ込むのはidのみに絞る
this.actor = {
id: actor.id,
};
this.activity = activity;
}
/**
* Add recipe for followers deliver
*/
@bindThis
public addFollowersRecipe(): void {
const deliver: IFollowersRecipe = {
type: 'Followers',
};
this.addRecipe(deliver);
}
/**
* Add recipe for direct deliver
* @param to To
*/
@bindThis
public addDirectRecipe(to: RemoteUser): void {
const recipe: IDirectRecipe = {
type: 'Direct',
to,
};
this.addRecipe(recipe);
}
/**
* Add recipe
* @param recipe Recipe
*/
@bindThis
public addRecipe(recipe: IRecipe): void {
this.recipes.push(recipe);
}
/**
* Execute delivers
*/
@bindThis
public async execute(): Promise<void> {
// The value flags whether it is shared or not.
// key: inbox URL, value: whether it is sharedInbox
const inboxes = new Map<string, boolean>();
// build inbox list
// Process follower recipes first to avoid duplication when processing direct recipes later.
if (this.recipes.some(r => isFollowers(r))) {
// followers deliver
// TODO: SELECT DISTINCT ON ("followerSharedInbox") "followerSharedInbox" みたいな問い合わせにすればよりパフォーマンス向上できそう
// ただ、sharedInboxがnullなリモートユーザーも稀におり、その対応ができなさそう
const followers = await this.followingsRepository.find({
where: {
followeeId: this.actor.id,
followerHost: Not(IsNull()),
},
select: {
followerSharedInbox: true,
followerInbox: true,
},
});
for (const following of followers) {
const inbox = following.followerSharedInbox ?? following.followerInbox;
if (inbox === null) throw new Error('inbox is null');
inboxes.set(inbox, following.followerSharedInbox != null);
}
}
for (const recipe of this.recipes.filter(isDirect)) {
// check that shared inbox has not been added yet
if (recipe.to.sharedInbox !== null && inboxes.has(recipe.to.sharedInbox)) continue;
// check that they actually have an inbox
if (recipe.to.inbox === null) continue;
inboxes.set(recipe.to.inbox, false);
}
// deliver
this.queueService.deliverMany(this.actor, this.activity, inboxes);
}
}
@Injectable()
export class ApDeliverManagerService {
constructor(
@@ -47,11 +163,11 @@ export class ApDeliverManagerService {
/**
* Deliver activity to followers
* @param actor
* @param activity Activity
* @param from Followee
*/
@bindThis
public async deliverToFollowers(actor: { id: LocalUser['id']; host: null; }, activity: any) {
public async deliverToFollowers(actor: { id: LocalUser['id']; host: null; }, activity: IActivity): Promise<void> {
const manager = new DeliverManager(
this.userEntityService,
this.followingsRepository,
@@ -65,11 +181,12 @@ export class ApDeliverManagerService {
/**
* Deliver activity to user
* @param actor
* @param activity Activity
* @param to Target user
*/
@bindThis
public async deliverToUser(actor: { id: LocalUser['id']; host: null; }, activity: any, to: RemoteUser) {
public async deliverToUser(actor: { id: LocalUser['id']; host: null; }, activity: IActivity, to: RemoteUser): Promise<void> {
const manager = new DeliverManager(
this.userEntityService,
this.followingsRepository,
@@ -82,131 +199,14 @@ export class ApDeliverManagerService {
}
@bindThis
public createDeliverManager(actor: { id: User['id']; host: null; }, activity: any) {
public createDeliverManager(actor: { id: User['id']; host: null; }, activity: IActivity | null): DeliverManager {
return new DeliverManager(
this.userEntityService,
this.followingsRepository,
this.queueService,
actor,
actor,
activity,
);
}
}
class DeliverManager {
private actor: ThinUser;
private activity: any;
private recipes: IRecipe[] = [];
/**
* Constructor
* @param actor Actor
* @param activity Activity to deliver
*/
constructor(
private userEntityService: UserEntityService,
private followingsRepository: FollowingsRepository,
private queueService: QueueService,
actor: { id: User['id']; host: null; },
activity: any,
) {
// 型で弾いてはいるが一応ローカルユーザーかチェック
if (actor.host != null) throw new Error('actor.host must be null');
// パフォーマンス向上のためキューに突っ込むのはidのみに絞る
this.actor = {
id: actor.id,
};
this.activity = activity;
}
/**
* Add recipe for followers deliver
*/
@bindThis
public addFollowersRecipe() {
const deliver = {
type: 'Followers',
} as IFollowersRecipe;
this.addRecipe(deliver);
}
/**
* Add recipe for direct deliver
* @param to To
*/
@bindThis
public addDirectRecipe(to: RemoteUser) {
const recipe = {
type: 'Direct',
to,
} as IDirectRecipe;
this.addRecipe(recipe);
}
/**
* Add recipe
* @param recipe Recipe
*/
@bindThis
public addRecipe(recipe: IRecipe) {
this.recipes.push(recipe);
}
/**
* Execute delivers
*/
@bindThis
public async execute() {
// The value flags whether it is shared or not.
// key: inbox URL, value: whether it is sharedInbox
const inboxes = new Map<string, boolean>();
/*
build inbox list
Process follower recipes first to avoid duplication when processing
direct recipes later.
*/
if (this.recipes.some(r => isFollowers(r))) {
// followers deliver
// TODO: SELECT DISTINCT ON ("followerSharedInbox") "followerSharedInbox" みたいな問い合わせにすればよりパフォーマンス向上できそう
// ただ、sharedInboxがnullなリモートユーザーも稀におり、その対応ができなさそう
const followers = await this.followingsRepository.find({
where: {
followeeId: this.actor.id,
followerHost: Not(IsNull()),
},
select: {
followerSharedInbox: true,
followerInbox: true,
},
}) as {
followerSharedInbox: string | null;
followerInbox: string;
}[];
for (const following of followers) {
const inbox = following.followerSharedInbox ?? following.followerInbox;
inboxes.set(inbox, following.followerSharedInbox != null);
}
}
this.recipes.filter((recipe): recipe is IDirectRecipe =>
// followers recipes have already been processed
isDirect(recipe)
// check that shared inbox has not been added yet
&& !(recipe.to.sharedInbox && inboxes.has(recipe.to.sharedInbox))
// check that they actually have an inbox
&& recipe.to.inbox != null,
)
.forEach(recipe => inboxes.set(recipe.to.inbox!, false));
// deliver
this.queueService.deliverMany(this.actor, this.activity, inboxes);
}
}

View File

@@ -21,10 +21,10 @@ 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';
import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
import { ApNoteService } from './models/ApNoteService.js';
import { ApLoggerService } from './ApLoggerService.js';
import { ApDbResolverService } from './ApDbResolverService.js';
@@ -86,7 +86,7 @@ export class ApInboxService {
}
@bindThis
public async performActivity(actor: RemoteUser, activity: IObject) {
public async performActivity(actor: RemoteUser, activity: IObject): Promise<void> {
if (isCollectionOrOrderedCollection(activity)) {
const resolver = this.apResolverService.createResolver();
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) {
@@ -107,7 +107,7 @@ export class ApInboxService {
if (actor.uri) {
if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
setImmediate(() => {
this.apPersonService.updatePerson(actor.uri!);
this.apPersonService.updatePerson(actor.uri);
});
}
}
@@ -229,7 +229,7 @@ export class ApInboxService {
@bindThis
private async add(actor: RemoteUser, activity: IAdd): Promise<void> {
if ('actor' in activity && actor.uri !== activity.actor) {
if (actor.uri !== activity.actor) {
throw new Error('invalid actor');
}
@@ -273,7 +273,7 @@ export class ApInboxService {
const unlock = await this.appLockService.getApLock(uri);
try {
// 既に同じURIを持つものが登録されていないかチェック
// 既に同じURIを持つものが登録されていないかチェック
const exist = await this.apNoteService.fetchNote(uri);
if (exist) {
return;
@@ -292,7 +292,7 @@ export class ApInboxService {
return;
}
this.logger.warn(`Error in announce target ${targetUri} - ${err.statusCode ?? err}`);
this.logger.warn(`Error in announce target ${targetUri} - ${err.statusCode}`);
}
throw err;
}
@@ -409,7 +409,7 @@ export class ApInboxService {
@bindThis
private async delete(actor: RemoteUser, activity: IDelete): Promise<string> {
if ('actor' in activity && actor.uri !== activity.actor) {
if (actor.uri !== activity.actor) {
throw new Error('invalid actor');
}
@@ -420,7 +420,7 @@ export class ApInboxService {
// typeが不明だけど、どうせ消えてるのでremote resolveしない
formerType = undefined;
} else {
const object = activity.object as IObject;
const object = activity.object;
if (isTombstone(object)) {
formerType = toSingle(object.formerType);
} else {
@@ -503,7 +503,10 @@ export class ApInboxService {
// 対象ユーザーは一番最初のユーザー として あとはコメントとして格納する
const uris = getApIds(activity.object);
const userIds = uris.filter(uri => uri.startsWith(this.config.url + '/users/')).map(uri => uri.split('/').pop()!);
const userIds = uris
.filter(uri => uri.startsWith(this.config.url + '/users/'))
.map(uri => uri.split('/').at(-1))
.filter((userId): userId is string => userId !== undefined);
const users = await this.usersRepository.findBy({
id: In(userIds),
});
@@ -566,7 +569,7 @@ export class ApInboxService {
@bindThis
private async remove(actor: RemoteUser, activity: IRemove): Promise<void> {
if ('actor' in activity && actor.uri !== activity.actor) {
if (actor.uri !== activity.actor) {
throw new Error('invalid actor');
}
@@ -586,7 +589,7 @@ export class ApInboxService {
@bindThis
private async undo(actor: RemoteUser, activity: IUndo): Promise<string> {
if ('actor' in activity && actor.uri !== activity.actor) {
if (actor.uri !== activity.actor) {
throw new Error('invalid actor');
}
@@ -618,12 +621,14 @@ export class ApInboxService {
return 'skip: follower not found';
}
const following = await this.followingsRepository.findOneBy({
followerId: follower.id,
followeeId: actor.id,
const isFollowing = await this.followingsRepository.exist({
where: {
followerId: follower.id,
followeeId: actor.id,
},
});
if (following) {
if (isFollowing) {
await this.userFollowingService.unfollow(follower, actor);
return 'ok: unfollowed';
}
@@ -673,22 +678,26 @@ export class ApInboxService {
return 'skip: フォロー解除しようとしているユーザーはローカルユーザーではありません';
}
const req = await this.followRequestsRepository.findOneBy({
followerId: actor.id,
followeeId: followee.id,
const requestExist = await this.followRequestsRepository.exist({
where: {
followerId: actor.id,
followeeId: followee.id,
},
});
const following = await this.followingsRepository.findOneBy({
followerId: actor.id,
followeeId: followee.id,
const isFollowing = await this.followingsRepository.exist({
where: {
followerId: actor.id,
followeeId: followee.id,
},
});
if (req) {
if (requestExist) {
await this.userFollowingService.cancelFollowRequest(followee, actor);
return 'ok: follow request canceled';
}
if (following) {
if (isFollowing) {
await this.userFollowingService.unfollow(actor, followee);
return 'ok: unfollowed';
}
@@ -713,7 +722,7 @@ export class ApInboxService {
@bindThis
private async update(actor: RemoteUser, activity: IUpdate): Promise<string> {
if ('actor' in activity && actor.uri !== activity.actor) {
if (actor.uri !== activity.actor) {
return 'skip: invalid actor';
}
@@ -727,7 +736,7 @@ export class ApInboxService {
});
if (isActor(object)) {
await this.apPersonService.updatePerson(actor.uri!, resolver, object);
await this.apPersonService.updatePerson(actor.uri, resolver, object);
return 'ok: Person updated';
} else if (getApType(object) === 'Question') {
await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err));

View File

@@ -4,9 +4,9 @@ import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { MfmService } from '@/core/MfmService.js';
import type { Note } from '@/models/entities/Note.js';
import { bindThis } from '@/decorators.js';
import { extractApHashtagObjects } from './models/tag.js';
import type { IObject } from './type.js';
import { bindThis } from '@/decorators.js';
@Injectable()
export class ApMfmService {
@@ -19,15 +19,14 @@ export class ApMfmService {
}
@bindThis
public htmlToMfm(html: string, tag?: IObject | IObject[]) {
const hashtagNames = extractApHashtagObjects(tag).map(x => x.name).filter((x): x is string => x != null);
public htmlToMfm(html: string, tag?: IObject | IObject[]): string {
const hashtagNames = extractApHashtagObjects(tag).map(x => x.name);
return this.mfmService.fromHtml(html, hashtagNames);
}
@bindThis
public getNoteHtml(note: Note) {
public getNoteHtml(note: Note): string | null {
if (!note.text) return '';
return this.mfmService.toHtml(mfm.parse(note.text), JSON.parse(note.mentionedRemoteUsers));
}
}
}

View File

@@ -1,7 +1,6 @@
import { createPublicKey } from 'node:crypto';
import { createPublicKey, randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { In, IsNull } from 'typeorm';
import { v4 as uuid } from 'uuid';
import { In } from 'typeorm';
import * as mfm from 'mfm-js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@@ -26,7 +25,6 @@ import { isNotNull } from '@/misc/is-not-null.js';
import { LdSignatureService } from './LdSignatureService.js';
import { ApMfmService } from './ApMfmService.js';
import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js';
import type { IIdentifier } from './models/identifier.js';
@Injectable()
export class ApRendererService {
@@ -63,7 +61,7 @@ export class ApRendererService {
}
@bindThis
public renderAccept(object: any, user: { id: User['id']; host: null }): IAccept {
public renderAccept(object: string | IObject, user: { id: User['id']; host: null }): IAccept {
return {
type: 'Accept',
actor: this.userEntityService.genLocalUserUri(user.id),
@@ -72,7 +70,7 @@ export class ApRendererService {
}
@bindThis
public renderAdd(user: LocalUser, target: any, object: any): IAdd {
public renderAdd(user: LocalUser, target: string | IObject | undefined, object: string | IObject): IAdd {
return {
type: 'Add',
actor: this.userEntityService.genLocalUserUri(user.id),
@@ -82,7 +80,7 @@ export class ApRendererService {
}
@bindThis
public renderAnnounce(object: any, note: Note): IAnnounce {
public renderAnnounce(object: string | IObject, note: Note): IAnnounce {
const attributedTo = this.userEntityService.genLocalUserUri(note.userId);
let to: string[] = [];
@@ -133,13 +131,13 @@ export class ApRendererService {
@bindThis
public renderCreate(object: IObject, note: Note): ICreate {
const activity = {
const activity: ICreate = {
id: `${this.config.url}/notes/${note.id}/activity`,
actor: this.userEntityService.genLocalUserUri(note.userId),
type: 'Create',
published: note.createdAt.toISOString(),
object,
} as ICreate;
};
if (object.to) activity.to = object.to;
if (object.cc) activity.cc = object.cc;
@@ -209,7 +207,7 @@ export class ApRendererService {
* @param id Follower|Followee ID
*/
@bindThis
public async renderFollowUser(id: User['id']) {
public async renderFollowUser(id: User['id']): Promise<string> {
const user = await this.usersRepository.findOneByOrFail({ id: id }) as PartialLocalUser | PartialRemoteUser;
return this.userEntityService.getUserUri(user);
}
@@ -223,8 +221,8 @@ export class ApRendererService {
return {
id: requestId ?? `${this.config.url}/follows/${follower.id}/${followee.id}`,
type: 'Follow',
actor: this.userEntityService.getUserUri(follower)!,
object: this.userEntityService.getUserUri(followee)!,
actor: this.userEntityService.getUserUri(follower),
object: this.userEntityService.getUserUri(followee),
};
}
@@ -264,14 +262,14 @@ export class ApRendererService {
public async renderLike(noteReaction: NoteReaction, note: { uri: string | null }): Promise<ILike> {
const reaction = noteReaction.reaction;
const object = {
const object: ILike = {
type: 'Like',
id: `${this.config.url}/likes/${noteReaction.id}`,
actor: `${this.config.url}/users/${noteReaction.userId}`,
object: note.uri ? note.uri : `${this.config.url}/notes/${noteReaction.noteId}`,
content: reaction,
_misskey_reaction: reaction,
} as ILike;
};
if (reaction.startsWith(':')) {
const name = reaction.replaceAll(':', '');
@@ -287,7 +285,7 @@ export class ApRendererService {
public renderMention(mention: PartialLocalUser | PartialRemoteUser): IApMention {
return {
type: 'Mention',
href: this.userEntityService.getUserUri(mention)!,
href: this.userEntityService.getUserUri(mention),
name: this.userEntityService.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as LocalUser).username}`,
};
}
@@ -297,8 +295,8 @@ export class ApRendererService {
src: PartialLocalUser | PartialRemoteUser,
dst: PartialLocalUser | PartialRemoteUser,
): IMove {
const actor = this.userEntityService.getUserUri(src)!;
const target = this.userEntityService.getUserUri(dst)!;
const actor = this.userEntityService.getUserUri(src);
const target = this.userEntityService.getUserUri(dst);
return {
id: `${this.config.url}/moves/${src.id}/${dst.id}`,
actor,
@@ -310,10 +308,10 @@ export class ApRendererService {
@bindThis
public async renderNote(note: Note, dive = true): Promise<IPost> {
const getPromisedFiles = async (ids: string[]) => {
if (!ids || ids.length === 0) return [];
const getPromisedFiles = async (ids: string[]): Promise<DriveFile[]> => {
if (ids.length === 0) return [];
const items = await this.driveFilesRepository.findBy({ id: In(ids) });
return ids.map(id => items.find(item => item.id === id)).filter(item => item != null) as DriveFile[];
return ids.map(id => items.find(item => item.id === id)).filter((item): item is DriveFile => item != null);
};
let inReplyTo;
@@ -323,9 +321,9 @@ export class ApRendererService {
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
if (inReplyToNote != null) {
const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId });
const inReplyToUserExist = await this.usersRepository.exist({ where: { id: inReplyToNote.userId } });
if (inReplyToUser != null) {
if (inReplyToUserExist) {
if (inReplyToNote.uri) {
inReplyTo = inReplyToNote.uri;
} else {
@@ -375,7 +373,7 @@ export class ApRendererService {
id: In(note.mentions),
}) : [];
const hashtagTags = (note.tags ?? []).map(tag => this.renderHashtag(tag));
const hashtagTags = note.tags.map(tag => this.renderHashtag(tag));
const mentionTags = mentionedUsers.map(u => this.renderMention(u as LocalUser | RemoteUser));
const files = await getPromisedFiles(note.fileIds);
@@ -451,37 +449,26 @@ export class ApRendererService {
@bindThis
public async renderPerson(user: LocalUser) {
const id = this.userEntityService.genLocalUserUri(user.id);
const isSystem = !!user.username.match(/\./);
const isSystem = user.username.includes('.');
const [avatar, banner, profile] = await Promise.all([
user.avatarId ? this.driveFilesRepository.findOneBy({ id: user.avatarId }) : Promise.resolve(undefined),
user.bannerId ? this.driveFilesRepository.findOneBy({ id: user.bannerId }) : Promise.resolve(undefined),
user.avatarId ? this.driveFilesRepository.findOneBy({ id: user.avatarId }) : undefined,
user.bannerId ? this.driveFilesRepository.findOneBy({ id: user.bannerId }) : undefined,
this.userProfilesRepository.findOneByOrFail({ userId: user.id }),
]);
const attachment: {
const attachment = profile.fields.map(field => ({
type: 'PropertyValue',
name: string,
value: string,
identifier?: IIdentifier,
}[] = [];
if (profile.fields) {
for (const field of profile.fields) {
attachment.push({
type: 'PropertyValue',
name: field.name,
value: (field.value != null && field.value.match(/^https?:/))
? `<a href="${new URL(field.value).href}" rel="me nofollow noopener" target="_blank">${new URL(field.value).href}</a>`
: field.value,
});
}
}
name: field.name,
value: /^https?:/.test(field.value)
? `<a href="${new URL(field.value).href}" rel="me nofollow noopener" target="_blank">${new URL(field.value).href}</a>`
: field.value,
}));
const emojis = await this.getEmojis(user.emojis);
const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
const hashtagTags = (user.tags ?? []).map(tag => this.renderHashtag(tag));
const hashtagTags = user.tags.map(tag => this.renderHashtag(tag));
const tag = [
...apemojis,
@@ -490,7 +477,7 @@ export class ApRendererService {
const keypair = await this.userKeypairService.getUserKeypair(user.id);
const person = {
const person: any = {
type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person',
id,
inbox: `${id}/inbox`,
@@ -508,11 +495,11 @@ export class ApRendererService {
image: banner ? this.renderImage(banner) : null,
tag,
manuallyApprovesFollowers: user.isLocked,
discoverable: !!user.isExplorable,
discoverable: user.isExplorable,
publicKey: this.renderKey(user, keypair, '#main-key'),
isCat: user.isCat,
attachment: attachment.length ? attachment : undefined,
} as any;
};
if (user.movedToUri) {
person.movedTo = user.movedToUri;
@@ -552,7 +539,7 @@ export class ApRendererService {
}
@bindThis
public renderReject(object: any, user: { id: User['id'] }): IReject {
public renderReject(object: string | IObject, user: { id: User['id'] }): IReject {
return {
type: 'Reject',
actor: this.userEntityService.genLocalUserUri(user.id),
@@ -561,7 +548,7 @@ export class ApRendererService {
}
@bindThis
public renderRemove(user: { id: User['id'] }, target: any, object: any): IRemove {
public renderRemove(user: { id: User['id'] }, target: string | IObject | undefined, object: string | IObject): IRemove {
return {
type: 'Remove',
actor: this.userEntityService.genLocalUserUri(user.id),
@@ -579,8 +566,8 @@ export class ApRendererService {
}
@bindThis
public renderUndo(object: any, user: { id: User['id'] }): IUndo {
const id = typeof object.id === 'string' && object.id.startsWith(this.config.url) ? `${object.id}/undo` : undefined;
public renderUndo(object: string | IObject, user: { id: User['id'] }): IUndo {
const id = typeof object !== 'string' && typeof object.id === 'string' && object.id.startsWith(this.config.url) ? `${object.id}/undo` : undefined;
return {
type: 'Undo',
@@ -592,7 +579,7 @@ export class ApRendererService {
}
@bindThis
public renderUpdate(object: any, user: { id: User['id'] }): IUpdate {
public renderUpdate(object: string | IObject, user: { id: User['id'] }): IUpdate {
return {
id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`,
actor: this.userEntityService.genLocalUserUri(user.id),
@@ -625,7 +612,7 @@ export class ApRendererService {
@bindThis
public addContext<T extends IObject>(x: T): T & { '@context': any; id: string; } {
if (typeof x === 'object' && x.id == null) {
x.id = `${this.config.url}/${uuid()}`;
x.id = `${this.config.url}/${randomUUID()}`;
}
return Object.assign({
@@ -658,7 +645,7 @@ export class ApRendererService {
vcard: 'http://www.w3.org/2006/vcard/ns#',
},
],
}, x as T & { id: string; });
}, x as T & { id: string });
}
@bindThis
@@ -683,13 +670,13 @@ export class ApRendererService {
*/
@bindThis
public renderOrderedCollectionPage(id: string, totalItems: any, orderedItems: any, partOf: string, prev?: string, next?: string) {
const page = {
const page: any = {
id,
partOf,
type: 'OrderedCollectionPage',
totalItems,
orderedItems,
} as any;
};
if (prev) page.prev = prev;
if (next) page.next = next;
@@ -706,7 +693,7 @@ export class ApRendererService {
* @param orderedItems attached objects (optional)
*/
@bindThis
public renderOrderedCollection(id: string | null, totalItems: any, first?: string, last?: string, orderedItems?: IObject[]) {
public renderOrderedCollection(id: string, totalItems: number, first?: string, last?: string, orderedItems?: IObject[]) {
const page: any = {
id,
type: 'OrderedCollection',
@@ -722,7 +709,7 @@ export class ApRendererService {
@bindThis
private async getEmojis(names: string[]): Promise<Emoji[]> {
if (names == null || names.length === 0) return [];
if (names.length === 0) return [];
const allEmojis = await this.customEmojiService.localEmojisCache.fetch();
const emojis = names.map(name => allEmojis.get(name)).filter(isNotNull);

View File

@@ -140,7 +140,7 @@ export class ApRequestService {
}
@bindThis
public async signedPost(user: { id: User['id'] }, url: string, object: any) {
public async signedPost(user: { id: User['id'] }, url: string, object: unknown): Promise<void> {
const body = JSON.stringify(object);
const keypair = await this.userKeypairService.getUserKeypair(user.id);
@@ -169,7 +169,7 @@ export class ApRequestService {
* @param url URL to fetch
*/
@bindThis
public async signedGet(url: string, user: { id: User['id'] }) {
public async signedGet(url: string, user: { id: User['id'] }): Promise<unknown> {
const keypair = await this.userKeypairService.getUserKeypair(user.id);
const req = ApRequestCreator.createSignedGet({

View File

@@ -61,10 +61,6 @@ export class Resolver {
@bindThis
public async resolve(value: string | IObject): Promise<IObject> {
if (value == null) {
throw new Error('resolvee is null (or undefined)');
}
if (typeof value !== 'string') {
return value;
}
@@ -104,11 +100,11 @@ export class Resolver {
? await this.apRequestService.signedGet(value, this.user) as IObject
: await this.httpRequestService.getJson(value, 'application/activity+json, application/ld+json')) as IObject;
if (object == null || (
if (
Array.isArray(object['@context']) ?
!(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') :
object['@context'] !== 'https://www.w3.org/ns/activitystreams'
)) {
) {
throw new Error('invalid response');
}

View File

@@ -3,6 +3,8 @@ import { Injectable } from '@nestjs/common';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js';
import { CONTEXTS } from './misc/contexts.js';
import type { JsonLdDocument } from 'jsonld';
import type { JsonLd, RemoteDocument } from 'jsonld/jsonld-spec.js';
// RsaSignature2017 based from https://github.com/transmute-industries/RsaSignature2017
@@ -18,22 +20,21 @@ class LdSignature {
@bindThis
public async signRsaSignature2017(data: any, privateKey: string, creator: string, domain?: string, created?: Date): Promise<any> {
const options = {
type: 'RsaSignature2017',
creator,
domain,
nonce: crypto.randomBytes(16).toString('hex'),
created: (created ?? new Date()).toISOString(),
} as {
const options: {
type: string;
creator: string;
domain?: string;
nonce: string;
created: string;
} = {
type: 'RsaSignature2017',
creator,
nonce: crypto.randomBytes(16).toString('hex'),
created: (created ?? new Date()).toISOString(),
};
if (!domain) {
delete options.domain;
if (domain) {
options.domain = domain;
}
const toBeSigned = await this.createVerifyData(data, options);
@@ -62,7 +63,7 @@ class LdSignature {
}
@bindThis
public async createVerifyData(data: any, options: any) {
public async createVerifyData(data: any, options: any): Promise<string> {
const transformedOptions = {
...options,
'@context': 'https://w3id.org/identity/v1',
@@ -82,7 +83,7 @@ class LdSignature {
}
@bindThis
public async normalize(data: any) {
public async normalize(data: JsonLdDocument): Promise<string> {
const customLoader = this.getLoader();
// XXX: Importing jsonld dynamically since Jest frequently fails to import it statically
// https://github.com/misskey-dev/misskey/pull/9894#discussion_r1103753595
@@ -93,14 +94,14 @@ class LdSignature {
@bindThis
private getLoader() {
return async (url: string): Promise<any> => {
if (!url.match('^https?\:\/\/')) throw new Error(`Invalid URL ${url}`);
return async (url: string): Promise<RemoteDocument> => {
if (!/^https?:\/\//.test(url)) throw new Error(`Invalid URL ${url}`);
if (this.preLoad) {
if (url in CONTEXTS) {
if (this.debug) console.debug(`HIT: ${url}`);
return {
contextUrl: null,
contextUrl: undefined,
document: CONTEXTS[url],
documentUrl: url,
};
@@ -110,7 +111,7 @@ class LdSignature {
if (this.debug) console.debug(`MISS: ${url}`);
const document = await this.fetchDocument(url);
return {
contextUrl: null,
contextUrl: undefined,
document: document,
documentUrl: url,
};
@@ -118,13 +119,17 @@ class LdSignature {
}
@bindThis
private async fetchDocument(url: string) {
const json = await this.httpRequestService.send(url, {
headers: {
Accept: 'application/ld+json, application/json',
private async fetchDocument(url: string): Promise<JsonLd> {
const json = await this.httpRequestService.send(
url,
{
headers: {
Accept: 'application/ld+json, application/json',
},
timeout: this.loderTimeout,
},
timeout: this.loderTimeout,
}, { throwErrorWhenResponseNotOk: false }).then(res => {
{ throwErrorWhenResponseNotOk: false },
).then(res => {
if (!res.ok) {
throw new Error(`${res.status} ${res.statusText}`);
} else {
@@ -132,7 +137,7 @@ class LdSignature {
}
});
return json;
return json as JsonLd;
}
@bindThis

View File

@@ -1,3 +1,5 @@
import type { JsonLd } from 'jsonld/jsonld-spec.js';
/* eslint:disable:quotemark indent */
const id_v1 = {
'@context': {
@@ -86,7 +88,7 @@ const id_v1 = {
'accessControl': { '@id': 'perm:accessControl', '@type': '@id' },
'writePermission': { '@id': 'perm:writePermission', '@type': '@id' },
},
};
} satisfies JsonLd;
const security_v1 = {
'@context': {
@@ -137,7 +139,7 @@ const security_v1 = {
'signatureAlgorithm': 'sec:signingAlgorithm',
'signatureValue': 'sec:signatureValue',
},
};
} satisfies JsonLd;
const activitystreams = {
'@context': {
@@ -517,9 +519,9 @@ const activitystreams = {
'@type': '@id',
},
},
};
} satisfies JsonLd;
export const CONTEXTS: Record<string, unknown> = {
export const CONTEXTS: Record<string, JsonLd> = {
'https://w3id.org/identity/v1': id_v1,
'https://w3id.org/security/v1': security_v1,
'https://www.w3.org/ns/activitystreams': activitystreams,

View File

@@ -1,7 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { DriveFilesRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type { RemoteUser } from '@/models/entities/User.js';
import type { DriveFile } from '@/models/entities/DriveFile.js';
import { MetaService } from '@/core/MetaService.js';
@@ -10,18 +9,16 @@ import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js';
import { DriveService } from '@/core/DriveService.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { checkHttps } from '@/misc/check-https.js';
import { ApResolverService } from '../ApResolverService.js';
import { ApLoggerService } from '../ApLoggerService.js';
import { checkHttps } from '@/misc/check-https.js';
import type { IObject } from '../type.js';
@Injectable()
export class ApImageService {
private logger: Logger;
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
@@ -32,21 +29,25 @@ export class ApImageService {
) {
this.logger = this.apLoggerService.logger;
}
/**
* Imageを作成します。
*/
@bindThis
public async createImage(actor: RemoteUser, value: any): Promise<DriveFile> {
public async createImage(actor: RemoteUser, value: string | IObject): Promise<DriveFile> {
// 投稿者が凍結されていたらスキップ
if (actor.isSuspended) {
throw new Error('actor has been suspended');
}
const image = await this.apResolverService.createResolver().resolve(value) as any;
const image = await this.apResolverService.createResolver().resolve(value);
if (image.url == null) {
throw new Error('invalid image: url not privided');
throw new Error('invalid image: url not provided');
}
if (typeof image.url !== 'string') {
throw new Error('invalid image: unexpected type of url: ' + JSON.stringify(image.url, null, 2));
}
if (!checkHttps(image.url)) {
@@ -57,29 +58,24 @@ export class ApImageService {
const instance = await this.metaService.fetch();
let file = await this.driveService.uploadFromUrl({
// Cache if remote file cache is on AND either
// 1. remote sensitive file is also on
// 2. or the image is not sensitive
const shouldBeCached = instance.cacheRemoteFiles && (instance.cacheRemoteSensitiveFiles || !image.sensitive);
const file = await this.driveService.uploadFromUrl({
url: image.url,
user: actor,
uri: image.url,
sensitive: image.sensitive,
isLink: !instance.cacheRemoteFiles,
comment: truncate(image.name, DB_MAX_IMAGE_COMMENT_LENGTH),
isLink: !shouldBeCached,
comment: truncate(image.name ?? undefined, DB_MAX_IMAGE_COMMENT_LENGTH),
});
if (!file.isLink || file.url === image.url) return file;
if (file.isLink) {
// URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、
// URLを更新する
if (file.url !== image.url) {
await this.driveFilesRepository.update({ id: file.id }, {
url: image.url,
uri: image.url,
});
file = await this.driveFilesRepository.findOneByOrFail({ id: file.id });
}
}
return file;
// URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、URLを更新する
await this.driveFilesRepository.update({ id: file.id }, { url: image.url, uri: image.url });
return await this.driveFilesRepository.findOneByOrFail({ id: file.id });
}
/**
@@ -89,7 +85,7 @@ export class ApImageService {
* リモートサーバーからフェッチしてMisskeyに登録しそれを返します。
*/
@bindThis
public async resolveImage(actor: RemoteUser, value: any): Promise<DriveFile> {
public async resolveImage(actor: RemoteUser, value: string | IObject): Promise<DriveFile> {
// TODO
// リモートサーバーからフェッチしてきて登録

View File

@@ -22,17 +22,17 @@ export class ApMentionService {
}
@bindThis
public async extractApMentions(tags: IObject | IObject[] | null | undefined, resolver: Resolver) {
const hrefs = unique(this.extractApMentionObjects(tags).map(x => x.href as string));
public async extractApMentions(tags: IObject | IObject[] | null | undefined, resolver: Resolver): Promise<User[]> {
const hrefs = unique(this.extractApMentionObjects(tags).map(x => x.href));
const limit = promiseLimit<User | null>(2);
const mentionedUsers = (await Promise.all(
hrefs.map(x => limit(() => this.apPersonService.resolvePerson(x, resolver).catch(() => null))),
)).filter((x): x is User => x != null);
return mentionedUsers;
}
@bindThis
public extractApMentionObjects(tags: IObject | IObject[] | null | undefined): IApMention[] {
if (tags == null) return [];

View File

@@ -20,7 +20,6 @@ import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { checkHttps } from '@/misc/check-https.js';
import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import { ApLoggerService } from '../ApLoggerService.js';
import { ApMfmService } from '../ApMfmService.js';
import { ApDbResolverService } from '../ApDbResolverService.js';
@@ -55,7 +54,7 @@ export class ApNoteService {
// 循環参照のため / for circular dependency
@Inject(forwardRef(() => ApPersonService))
private apPersonService: ApPersonService,
private utilityService: UtilityService,
private apAudienceService: ApAudienceService,
private apMentionService: ApMentionService,
@@ -72,17 +71,13 @@ export class ApNoteService {
}
@bindThis
public validateNote(object: IObject, uri: string) {
public validateNote(object: IObject, uri: string): Error | null {
const expectHost = this.utilityService.extractDbHost(uri);
if (object == null) {
return new Error('invalid Note: object is null');
}
if (!validPost.includes(getApType(object))) {
return new Error(`invalid Note: invalid object type ${getApType(object)}`);
}
if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) {
return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`);
}
@@ -91,10 +86,10 @@ export class ApNoteService {
if (object.attributedTo && actualHost !== expectHost) {
return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`);
}
return null;
}
/**
* Noteをフェッチします。
*
@@ -104,31 +99,30 @@ export class ApNoteService {
public async fetchNote(object: string | IObject): Promise<Note | null> {
return await this.apDbResolverService.getNoteFromApId(object);
}
/**
* Noteを作成します。
*/
@bindThis
public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<Note | null> {
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(value);
const entryUri = getApId(value);
const err = this.validateNote(object, entryUri);
if (err) {
this.logger.error(`${err.message}`, {
resolver: {
history: resolver.getHistory(),
},
value: value,
object: object,
this.logger.error(err.message, {
resolver: { history: resolver.getHistory() },
value,
object,
});
throw new Error('invalid note');
}
const note = object as IPost;
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
if (note.id && !checkHttps(note.id)) {
@@ -140,21 +134,25 @@ export class ApNoteService {
if (url && !checkHttps(url)) {
throw new Error('unexpected shcema of note url: ' + url);
}
this.logger.info(`Creating the Note: ${note.id}`);
// 投稿者をフェッチ
const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo!), resolver) as RemoteUser;
if (note.attributedTo == null) {
throw new Error('invalid note.attributedTo: ' + note.attributedTo);
}
const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as RemoteUser;
// 投稿者が凍結されていたらスキップ
if (actor.isSuspended) {
throw new Error('actor has been suspended');
}
const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
let visibility = noteAudience.visibility;
const visibleUsers = noteAudience.visibleUsers;
// Audience (to, cc) が指定されてなかった場合
if (visibility === 'specified' && visibleUsers.length === 0) {
if (typeof value === 'string') { // 入力がstringならばresolverでGETが発生している
@@ -162,81 +160,71 @@ export class ApNoteService {
visibility = 'public';
}
}
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
const apHashtags = await extractApHashtags(note.tag);
const apHashtags = extractApHashtags(note.tag);
// 添付ファイル
// TODO: attachmentは必ずしもImageではない
// TODO: attachmentは必ずしも配列ではない
// Noteがsensitiveなら添付もsensitiveにする
const limit = promiseLimit(2);
note.attachment = Array.isArray(note.attachment) ? note.attachment : note.attachment ? [note.attachment] : [];
const files = note.attachment
.map(attach => attach.sensitive = note.sensitive)
? (await Promise.all(note.attachment.map(x => limit(() => this.apImageService.resolveImage(actor, x)) as Promise<DriveFile>)))
.filter(image => image != null)
: [];
const limit = promiseLimit<DriveFile>(2);
const files = (await Promise.all(toArray(note.attachment).map(attach => (
limit(() => this.apImageService.resolveImage(actor, {
...attach,
sensitive: note.sensitive, // Noteがsensitiveなら添付もsensitiveにする
}))
))));
// リプライ
const reply: Note | null = note.inReplyTo
? await this.resolveNote(note.inReplyTo, resolver).then(x => {
if (x == null) {
this.logger.warn('Specified inReplyTo, but not found');
throw new Error('inReplyTo not found');
} else {
? await this.resolveNote(note.inReplyTo, { resolver })
.then(x => {
if (x == null) {
this.logger.warn('Specified inReplyTo, but not found');
throw new Error('inReplyTo not found');
}
return x;
}
}).catch(async err => {
this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`);
throw err;
})
})
.catch(async err => {
this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`);
throw err;
})
: null;
// 引用
let quote: Note | undefined | null;
let quote: Note | undefined | null = null;
if (note._misskey_quote || note.quoteUrl) {
const tryResolveNote = async (uri: string): Promise<{
status: 'ok';
res: Note | null;
} | {
status: 'permerror' | 'temperror';
}> => {
if (typeof uri !== 'string' || !uri.match(/^https?:/)) return { status: 'permerror' };
const tryResolveNote = async (uri: string): Promise<
| { status: 'ok'; res: Note }
| { status: 'permerror' | 'temperror' }
> => {
if (!/^https?:/.test(uri)) return { status: 'permerror' };
try {
const res = await this.resolveNote(uri);
if (res) {
return {
status: 'ok',
res,
};
} else {
return {
status: 'permerror',
};
}
if (res == null) return { status: 'permerror' };
return { status: 'ok', res };
} catch (e) {
return {
status: (e instanceof StatusError && e.isClientError) ? 'permerror' : 'temperror',
};
}
};
const uris = unique([note._misskey_quote, note.quoteUrl].filter((x): x is string => typeof x === 'string'));
const results = await Promise.all(uris.map(uri => tryResolveNote(uri)));
quote = results.filter((x): x is { status: 'ok', res: Note | null } => x.status === 'ok').map(x => x.res).find(x => x);
const results = await Promise.all(uris.map(tryResolveNote));
quote = results.filter((x): x is { status: 'ok', res: Note } => x.status === 'ok').map(x => x.res).at(0);
if (!quote) {
if (results.some(x => x.status === 'temperror')) {
throw new Error('quote resolve failed');
}
}
}
const cw = note.summary === '' ? null : note.summary;
// テキストのパース
let text: string | null = null;
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
@@ -246,38 +234,38 @@ export class ApNoteService {
} else if (typeof note.content === 'string') {
text = this.apMfmService.htmlToMfm(note.content, note.tag);
}
// vote
if (reply && reply.hasPoll) {
const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id });
const tryCreateVote = async (name: string, index: number): Promise<null> => {
if (poll.expiresAt && Date.now() > new Date(poll.expiresAt).getTime()) {
this.logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`);
} else if (index >= 0) {
this.logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`);
await this.pollService.vote(actor, reply, index);
// リモートフォロワーにUpdate配信
this.pollService.deliverQuestionUpdate(reply.id);
}
return null;
};
if (note.name) {
return await tryCreateVote(note.name, poll.choices.findIndex(x => x === note.name));
}
}
const emojis = await this.extractEmojis(note.tag ?? [], actor.host).catch(e => {
this.logger.info(`extractEmojis: ${e}`);
return [] as Emoji[];
return [];
});
const apEmojis = emojis.map(emoji => emoji.name);
const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined);
return await this.noteCreateService.create(actor, {
createdAt: note.published ? new Date(note.published) : null,
files,
@@ -297,7 +285,7 @@ export class ApNoteService {
url: url,
}, silent);
}
/**
* Noteを解決します。
*
@@ -305,94 +293,91 @@ export class ApNoteService {
* リモートサーバーからフェッチしてMisskeyに登録しそれを返します。
*/
@bindThis
public async resolveNote(value: string | IObject, resolver?: Resolver): Promise<Note | null> {
const uri = typeof value === 'string' ? value : value.id;
if (uri == null) throw new Error('missing uri');
// ブロックしてたら中断
public async resolveNote(value: string | IObject, options: { sentFrom?: URL, resolver?: Resolver } = {}): Promise<Note | null> {
const uri = getApId(value);
// ブロックしていたら中断
const meta = await this.metaService.fetch();
if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) throw new StatusError('blocked host', 451);
if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) {
throw new StatusError('blocked host', 451);
}
const unlock = await this.appLockService.getApLock(uri);
try {
//#region このサーバーに既に登録されていたらそれを返す
const exist = await this.fetchNote(uri);
if (exist) {
return exist;
}
if (exist) return exist;
//#endregion
if (uri.startsWith(this.config.url)) {
throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note');
}
// リモートサーバーからフェッチしてきて登録
// ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにートが生成されるが
// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
return await this.createNote(uri, resolver, true);
const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri;
return await this.createNote(createFrom, options.resolver, true);
} finally {
unlock();
}
}
@bindThis
public async extractEmojis(tags: IObject | IObject[], host: string): Promise<Emoji[]> {
// eslint-disable-next-line no-param-reassign
host = this.utilityService.toPuny(host);
if (!tags) return [];
const eomjiTags = toArray(tags).filter(isEmoji);
const existingEmojis = await this.emojisRepository.findBy({
host,
name: In(eomjiTags.map(tag => tag.name!.replaceAll(':', ''))),
name: In(eomjiTags.map(tag => tag.name.replaceAll(':', ''))),
});
return await Promise.all(eomjiTags.map(async tag => {
const name = tag.name!.replaceAll(':', '');
const name = tag.name.replaceAll(':', '');
tag.icon = toSingle(tag.icon);
const exists = existingEmojis.find(x => x.name === name);
if (exists) {
if ((tag.updated != null && exists.updatedAt == null)
if ((exists.updatedAt == null)
|| (tag.id != null && exists.uri == null)
|| (tag.updated != null && exists.updatedAt != null && new Date(tag.updated) > exists.updatedAt)
|| (tag.icon!.url !== exists.originalUrl)
|| (new Date(tag.updated) > exists.updatedAt)
|| (tag.icon.url !== exists.originalUrl)
) {
await this.emojisRepository.update({
host,
name,
}, {
uri: tag.id,
originalUrl: tag.icon!.url,
publicUrl: tag.icon!.url,
originalUrl: tag.icon.url,
publicUrl: tag.icon.url,
updatedAt: new Date(),
});
return await this.emojisRepository.findOneBy({
host,
name,
}) as Emoji;
const emoji = await this.emojisRepository.findOneBy({ host, name });
if (emoji == null) throw new Error('emoji update failed');
return emoji;
}
return exists;
}
this.logger.info(`register emoji host=${host}, name=${name}`);
return await this.emojisRepository.insert({
id: this.idService.genId(),
host,
name,
uri: tag.id,
originalUrl: tag.icon!.url,
publicUrl: tag.icon!.url,
originalUrl: tag.icon.url,
publicUrl: tag.icon.url,
updatedAt: new Date(),
aliases: [],
} as Partial<Emoji>).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
}));
}
}

View File

@@ -3,7 +3,7 @@ import promiseLimit from 'promise-limit';
import { DataSource } from 'typeorm';
import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.js';
import type { BlockingsRepository, MutingsRepository, FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type { LocalUser, RemoteUser } from '@/models/entities/User.js';
import { User } from '@/models/entities/User.js';
@@ -15,7 +15,6 @@ import type Logger from '@/logger.js';
import type { Note } from '@/models/entities/Note.js';
import type { IdService } from '@/core/IdService.js';
import type { MfmService } from '@/core/MfmService.js';
import type { Emoji } from '@/models/entities/Emoji.js';
import { toArray } from '@/misc/prelude/array.js';
import type { GlobalEventService } from '@/core/GlobalEventService.js';
import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
@@ -48,6 +47,8 @@ import type { IActor, IObject } from '../type.js';
const nameLength = 128;
const summaryLength = 2048;
type Field = Record<'name' | 'value', string>;
@Injectable()
export class ApPersonService implements OnModuleInit {
private utilityService: UtilityService;
@@ -94,28 +95,10 @@ export class ApPersonService implements OnModuleInit {
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
//private utilityService: UtilityService,
//private userEntityService: UserEntityService,
//private idService: IdService,
//private globalEventService: GlobalEventService,
//private metaService: MetaService,
//private federatedInstanceService: FederatedInstanceService,
//private fetchInstanceMetadataService: FetchInstanceMetadataService,
//private cacheService: CacheService,
//private apResolverService: ApResolverService,
//private apNoteService: ApNoteService,
//private apImageService: ApImageService,
//private apMfmService: ApMfmService,
//private mfmService: MfmService,
//private hashtagService: HashtagService,
//private usersChart: UsersChart,
//private instanceChart: InstanceChart,
//private apLoggerService: ApLoggerService,
) {
}
onModuleInit() {
onModuleInit(): void {
this.utilityService = this.moduleRef.get('UtilityService');
this.userEntityService = this.moduleRef.get('UserEntityService');
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
@@ -153,10 +136,6 @@ export class ApPersonService implements OnModuleInit {
private validateActor(x: IObject, uri: string): IActor {
const expectHost = this.punyHost(uri);
if (x == null) {
throw new Error('invalid Actor: object is null');
}
if (!isActor(x)) {
throw new Error(`invalid Actor type '${x.type}'`);
}
@@ -218,21 +197,19 @@ export class ApPersonService implements OnModuleInit {
*/
@bindThis
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) as LocalUser | RemoteUser | null;
const cached = this.cacheService.uriPersonCache.get(uri) as LocalUser | RemoteUser | null | undefined;
if (cached) return cached;
// URIがこのサーバーを指しているならデータベースからフェッチ
if (uri.startsWith(`${this.config.url}/`)) {
const id = uri.split('/').pop();
const u = await this.usersRepository.findOneBy({ id }) as LocalUser;
const u = await this.usersRepository.findOneBy({ id }) as LocalUser | null;
if (u) this.cacheService.uriPersonCache.set(uri, u);
return u;
}
//#region このサーバーに既に登録されていたらそれを返す
const exist = await this.usersRepository.findOneBy({ uri }) as LocalUser | RemoteUser;
const exist = await this.usersRepository.findOneBy({ uri }) as LocalUser | RemoteUser | null;
if (exist) {
this.cacheService.uriPersonCache.set(uri, exist);
@@ -243,6 +220,23 @@ export class ApPersonService implements OnModuleInit {
return null;
}
private async resolveAvatarAndBanner(user: RemoteUser, icon: any, image: any): Promise<Pick<RemoteUser, 'avatarId' | 'bannerId' | 'avatarUrl' | 'bannerUrl' | 'avatarBlurhash' | 'bannerBlurhash'>> {
const [avatar, banner] = await Promise.all([icon, image].map(img => {
if (img == null) return null;
if (user == null) throw new Error('failed to create user: user is null');
return this.apImageService.resolveImage(user, img).catch(() => null);
}));
return {
avatarId: avatar?.id ?? null,
bannerId: banner?.id ?? null,
avatarUrl: avatar ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null,
bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null,
avatarBlurhash: avatar?.blurhash ?? null,
bannerBlurhash: banner?.blurhash ?? null,
};
}
/**
* Personを作成します。
*/
@@ -254,9 +248,11 @@ export class ApPersonService implements OnModuleInit {
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
}
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(uri) as any;
const object = await resolver.resolve(uri);
if (object.id == null) throw new Error('invalid object.id: ' + object.id);
const person = this.validateActor(object, uri);
@@ -264,9 +260,9 @@ export class ApPersonService implements OnModuleInit {
const host = this.punyHost(object.id);
const { fields } = this.analyzeAttachments(person.attachment ?? []);
const fields = this.analyzeAttachments(person.attachment ?? []);
const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32);
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
const isBot = getApType(object) === 'Service';
@@ -279,9 +275,19 @@ export class ApPersonService implements OnModuleInit {
}
// Create user
let user: RemoteUser;
let user: RemoteUser | null = null;
//#region カスタム絵文字取得
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host)
.then(_emojis => _emojis.map(emoji => emoji.name))
.catch(err => {
this.logger.error(`error occured while fetching user emojis`, { stack: err });
return [];
});
//#endregion
try {
// Start transaction
// Start transaction
await this.db.transaction(async transactionalEntityManager => {
user = await transactionalEntityManager.save(new User({
id: this.idService.genId(),
@@ -290,30 +296,31 @@ export class ApPersonService implements OnModuleInit {
createdAt: new Date(),
lastFetchedAt: new Date(),
name: truncate(person.name, nameLength),
isLocked: !!person.manuallyApprovesFollowers,
isLocked: person.manuallyApprovesFollowers,
movedToUri: person.movedTo,
movedAt: person.movedTo ? new Date() : null,
alsoKnownAs: person.alsoKnownAs,
isExplorable: !!person.discoverable,
isExplorable: person.discoverable,
username: person.preferredUsername,
usernameLower: person.preferredUsername!.toLowerCase(),
usernameLower: person.preferredUsername?.toLowerCase(),
host,
inbox: person.inbox,
sharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined),
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox,
followersUri: person.followers ? getApId(person.followers) : undefined,
featured: person.featured ? getApId(person.featured) : undefined,
uri: person.id,
tags,
isBot,
isCat: (person as any).isCat === true,
emojis,
})) as RemoteUser;
await transactionalEntityManager.save(new UserProfile({
userId: user.id,
description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null,
url: url,
url,
fields,
birthday: bday ? bday[0] : null,
birthday: bday?.[0] ?? null,
location: person['vcard:Address'] ?? null,
userHost: host,
}));
@@ -327,24 +334,24 @@ export class ApPersonService implements OnModuleInit {
}
});
} catch (e) {
// duplicate key error
// duplicate key error
if (isDuplicateKeyValueError(e)) {
// /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応
const u = await this.usersRepository.findOneBy({
uri: person.id,
});
// /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応
const u = await this.usersRepository.findOneBy({ uri: person.id });
if (u == null) throw new Error('already registered');
if (u) {
user = u as RemoteUser;
} else {
throw new Error('already registered');
}
user = u as RemoteUser;
} else {
this.logger.error(e instanceof Error ? e : new Error(e as string));
throw e;
}
}
if (user == null) throw new Error('failed to create user: user is null');
// Register to the cache
this.cacheService.uriPersonCache.set(user.uri, user);
// Register host
this.federatedInstanceService.fetch(host).then(async i => {
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
@@ -354,68 +361,34 @@ export class ApPersonService implements OnModuleInit {
}
});
this.usersChart.update(user!, true);
this.usersChart.update(user, true);
// ハッシュタグ更新
this.hashtagService.updateUsertags(user!, tags);
this.hashtagService.updateUsertags(user, tags);
//#region アバターとヘッダー画像をフェッチ
const [avatar, banner] = await Promise.all([
person.icon,
person.image,
].map(img =>
img == null
? Promise.resolve(null)
: this.apImageService.resolveImage(user!, img).catch(() => null),
));
try {
const updates = await this.resolveAvatarAndBanner(user, person.icon, person.image);
await this.usersRepository.update(user.id, updates);
user = { ...user, ...updates };
const avatarId = avatar ? avatar.id : null;
const bannerId = banner ? banner.id : null;
const avatarUrl = avatar ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null;
const bannerUrl = banner ? this.driveFileEntityService.getPublicUrl(banner) : null;
const avatarBlurhash = avatar ? avatar.blurhash : null;
const bannerBlurhash = banner ? banner.blurhash : null;
await this.usersRepository.update(user!.id, {
avatarId,
bannerId,
avatarUrl,
bannerUrl,
avatarBlurhash,
bannerBlurhash,
});
user!.avatarId = avatarId;
user!.bannerId = bannerId;
user!.avatarUrl = avatarUrl;
user!.bannerUrl = bannerUrl;
user!.avatarBlurhash = avatarBlurhash;
user!.bannerBlurhash = bannerBlurhash;
// Register to the cache
this.cacheService.uriPersonCache.set(user.uri, user);
} catch (err) {
this.logger.error('error occured while fetching user avatar/banner', { stack: err });
}
//#endregion
//#region カスタム絵文字取得
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => {
this.logger.info(`extractEmojis: ${err}`);
return [] as Emoji[];
});
await this.updateFeatured(user.id, resolver).catch(err => this.logger.error(err));
const emojiNames = emojis.map(emoji => emoji.name);
await this.usersRepository.update(user!.id, {
emojis: emojiNames,
});
//#endregion
await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err));
return user!;
return user;
}
/**
* Personの情報を更新します。
* Misskeyに対象のPersonが登録されていなければ無視します。
* もしアカウントの移行が確認された場合、アカウント移行処理を行います。
*
*
* @param uri URI of Person
* @param resolver Resolver
* @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します)
@@ -426,18 +399,14 @@ export class ApPersonService implements OnModuleInit {
if (typeof uri !== 'string') throw new Error('uri is not string');
// URIがこのサーバーを指しているならスキップ
if (uri.startsWith(`${this.config.url}/`)) {
return;
}
if (uri.startsWith(`${this.config.url}/`)) return;
//#region このサーバーに既に登録されているか
const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser | null;
if (exist === null) {
return;
}
const exist = await this.fetchPerson(uri) as RemoteUser | null;
if (exist === null) return;
//#endregion
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const object = hint ?? await resolver.resolve(uri);
@@ -446,27 +415,17 @@ export class ApPersonService implements OnModuleInit {
this.logger.info(`Updating the Person: ${person.id}`);
// アバターとヘッダー画像をフェッチ
const [avatar, banner] = await Promise.all([
person.icon,
person.image,
].map(img =>
img == null
? Promise.resolve(null)
: this.apImageService.resolveImage(exist, img).catch(() => null),
));
// カスタム絵文字取得
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], exist.host).catch(e => {
this.logger.info(`extractEmojis: ${e}`);
return [] as Emoji[];
return [];
});
const emojiNames = emojis.map(emoji => emoji.name);
const { fields } = this.analyzeAttachments(person.attachment ?? []);
const fields = this.analyzeAttachments(person.attachment ?? []);
const tags = extractApHashtags(person.tag).map(tag => normalizeForSearch(tag)).splice(0, 32);
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
@@ -479,7 +438,7 @@ export class ApPersonService implements OnModuleInit {
const updates = {
lastFetchedAt: new Date(),
inbox: person.inbox,
sharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined),
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox,
followersUri: person.followers ? getApId(person.followers) : undefined,
featured: person.featured,
emojis: emojiNames,
@@ -487,33 +446,33 @@ export class ApPersonService implements OnModuleInit {
tags,
isBot: getApType(object) === 'Service',
isCat: (person as any).isCat === true,
isLocked: !!person.manuallyApprovesFollowers,
isLocked: person.manuallyApprovesFollowers,
movedToUri: person.movedTo ?? null,
alsoKnownAs: person.alsoKnownAs ?? null,
isExplorable: !!person.discoverable,
isExplorable: person.discoverable,
...(await this.resolveAvatarAndBanner(exist, person.icon, person.image).catch(() => ({}))),
} as Partial<RemoteUser> & Pick<RemoteUser, 'isBot' | 'isCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
const moving =
const moving = ((): boolean => {
// 移行先がない→ある
(!exist.movedToUri && updates.movedToUri) ||
if (
exist.movedToUri === null &&
updates.movedToUri
) return true;
// 移行先がある→別のもの
(exist.movedToUri !== updates.movedToUri && exist.movedToUri && updates.movedToUri);
if (
exist.movedToUri !== null &&
updates.movedToUri !== null &&
exist.movedToUri !== updates.movedToUri
) return true;
// 移行先がある→ない、ない→ないは無視
return false;
})();
if (moving) updates.movedAt = new Date();
if (avatar) {
updates.avatarId = avatar.id;
updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
updates.avatarBlurhash = avatar.blurhash;
}
if (banner) {
updates.bannerId = banner.id;
updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
updates.bannerBlurhash = banner.blurhash;
}
// Update user
await this.usersRepository.update(exist.id, updates);
@@ -525,10 +484,10 @@ export class ApPersonService implements OnModuleInit {
}
await this.userProfilesRepository.update({ userId: exist.id }, {
url: url,
url,
fields,
description: person.summary ? this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag) : null,
birthday: bday ? bday[0] : null,
birthday: bday?.[0] ?? null,
location: person['vcard:Address'] ?? null,
});
@@ -538,11 +497,10 @@ export class ApPersonService implements OnModuleInit {
this.hashtagService.updateUsertags(exist, tags);
// 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする
await this.followingsRepository.update({
followerId: exist.id,
}, {
followerSharedInbox: person.sharedInbox ?? (person.endpoints ? person.endpoints.sharedInbox : undefined),
});
await this.followingsRepository.update(
{ followerId: exist.id },
{ followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox },
);
await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err));
@@ -580,27 +538,22 @@ export class ApPersonService implements OnModuleInit {
*/
@bindThis
public async resolvePerson(uri: string, resolver?: Resolver): Promise<LocalUser | RemoteUser> {
if (typeof uri !== 'string') throw new Error('uri is not string');
//#region このサーバーに既に登録されていたらそれを返す
const exist = await this.fetchPerson(uri);
if (exist) {
return exist;
}
if (exist) return exist;
//#endregion
// リモートサーバーからフェッチしてきて登録
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
return await this.createPerson(uri, resolver);
}
@bindThis
public analyzeAttachments(attachments: IObject | IObject[] | undefined) {
const fields: {
name: string,
value: string
}[] = [];
// TODO: `attachments`が`IObject`だった場合、返り値が`[]`になるようだが構わないのか?
public analyzeAttachments(attachments: IObject | IObject[] | undefined): Field[] {
const fields: Field[] = [];
if (Array.isArray(attachments)) {
for (const attachment of attachments.filter(isPropertyValue)) {
fields.push({
@@ -610,11 +563,11 @@ export class ApPersonService implements OnModuleInit {
}
}
return { fields };
return fields;
}
@bindThis
public async updateFeatured(userId: User['id'], resolver?: Resolver) {
public async updateFeatured(userId: User['id'], resolver?: Resolver): Promise<void> {
const user = await this.usersRepository.findOneByOrFail({ id: userId });
if (!this.userEntityService.isRemoteUser(user)) return;
if (!user.featured) return;
@@ -636,20 +589,23 @@ export class ApPersonService implements OnModuleInit {
const featuredNotes = await Promise.all(items
.filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも
.slice(0, 5)
.map(item => limit(() => this.apNoteService.resolveNote(item, _resolver))));
.map(item => limit(() => this.apNoteService.resolveNote(item, {
resolver: _resolver,
sentFrom: new URL(user.uri),
}))));
await this.db.transaction(async transactionalEntityManager => {
await transactionalEntityManager.delete(UserNotePining, { userId: user.id });
// とりあえずidを別の時間で生成して順番を維持
let td = 0;
for (const note of featuredNotes.filter(note => note != null)) {
for (const note of featuredNotes.filter((note): note is Note => note != null)) {
td -= 1000;
transactionalEntityManager.insert(UserNotePining, {
id: this.idService.genId(new Date(Date.now() + td)),
createdAt: new Date(),
userId: user.id,
noteId: note!.id,
noteId: note.id,
});
}
});
@@ -688,7 +644,7 @@ export class ApPersonService implements OnModuleInit {
// (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';

View File

@@ -4,12 +4,12 @@ import type { NotesRepository, PollsRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type { IPoll } from '@/models/entities/Poll.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { isQuestion } from '../type.js';
import { ApLoggerService } from '../ApLoggerService.js';
import { ApResolverService } from '../ApResolverService.js';
import type { Resolver } from '../ApResolverService.js';
import type { IObject, IQuestion } from '../type.js';
import { bindThis } from '@/decorators.js';
@Injectable()
export class ApQuestionService {
@@ -33,33 +33,25 @@ export class ApQuestionService {
@bindThis
public async extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise<IPoll> {
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const question = await resolver.resolve(source);
if (!isQuestion(question)) throw new Error('invalid type');
if (!isQuestion(question)) {
throw new Error('invalid type');
}
const multiple = question.oneOf === undefined;
if (multiple && question.anyOf === undefined) throw new Error('invalid question');
const multiple = !question.oneOf;
const expiresAt = question.endTime ? new Date(question.endTime) : question.closed ? new Date(question.closed) : null;
if (multiple && !question.anyOf) {
throw new Error('invalid question');
}
const choices = question[multiple ? 'anyOf' : 'oneOf']
?.map((x) => x.name)
.filter((x): x is string => typeof x === 'string')
?? [];
const choices = question[multiple ? 'anyOf' : 'oneOf']!
.map((x, i) => x.name!);
const votes = question[multiple ? 'anyOf' : 'oneOf']?.map((x) => x.replies?.totalItems ?? x._misskey_votes ?? 0);
const votes = question[multiple ? 'anyOf' : 'oneOf']!
.map((x, i) => x.replies && x.replies.totalItems || x._misskey_votes || 0);
return {
choices,
votes,
multiple,
expiresAt,
};
return { choices, votes, multiple, expiresAt };
}
/**
@@ -68,8 +60,9 @@ export class ApQuestionService {
* @returns true if updated
*/
@bindThis
public async updateQuestion(value: any, resolver?: Resolver) {
public async updateQuestion(value: string | IObject, resolver?: Resolver): Promise<boolean> {
const uri = typeof value === 'string' ? value : value.id;
if (uri == null) throw new Error('uri is null');
// URIがこのサーバーを指しているならスキップ
if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local');
@@ -83,6 +76,7 @@ export class ApQuestionService {
//#endregion
// resolve new Question object
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
const question = await resolver.resolve(value) as IQuestion;
this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);
@@ -90,12 +84,14 @@ export class ApQuestionService {
if (question.type !== 'Question') throw new Error('object is not a Question');
const apChoices = question.oneOf ?? question.anyOf;
if (apChoices == null) throw new Error('invalid apChoices: ' + apChoices);
let changed = false;
for (const choice of poll.choices) {
const oldCount = poll.votes[poll.choices.indexOf(choice)];
const newCount = apChoices!.filter(ap => ap.name === choice)[0].replies!.totalItems;
const newCount = apChoices.filter(ap => ap.name === choice).at(0)?.replies?.totalItems;
if (newCount == null) throw new Error('invalid newCount: ' + newCount);
if (oldCount !== newCount) {
changed = true;
@@ -103,9 +99,7 @@ export class ApQuestionService {
}
}
await this.pollsRepository.update({ noteId: note.id }, {
votes: poll.votes,
});
await this.pollsRepository.update({ noteId: note.id }, { votes: poll.votes });
return changed;
}

View File

@@ -2,7 +2,7 @@ import { toArray } from '@/misc/prelude/array.js';
import { isHashtag } from '../type.js';
import type { IObject, IApHashtag } from '../type.js';
export function extractApHashtags(tags: IObject | IObject[] | null | undefined) {
export function extractApHashtags(tags: IObject | IObject[] | null | undefined): string[] {
if (tags == null) return [];
const hashtags = extractApHashtagObjects(tags);

View File

@@ -194,7 +194,6 @@ export interface IApPropertyValue extends IObject {
}
export const isPropertyValue = (object: IObject): object is IApPropertyValue =>
object &&
getApType(object) === 'PropertyValue' &&
typeof object.name === 'string' &&
'value' in object &&

View File

@@ -254,7 +254,7 @@ export default abstract class Chart<T extends Schema> {
private convertRawRecord(x: RawRecord<T>): KVs<T> {
const kvs = {} as Record<string, number>;
for (const k of Object.keys(x).filter((k) => k.startsWith(COLUMN_PREFIX)) as (keyof Columns<T>)[]) {
kvs[(k as string).substr(COLUMN_PREFIX.length).split(COLUMN_DELIMITER).join('.')] = x[k] as unknown as number;
kvs[(k as string).substring(COLUMN_PREFIX.length).split(COLUMN_DELIMITER).join('.')] = x[k] as unknown as number;
}
return kvs as KVs<T>;
}
@@ -627,7 +627,7 @@ export default abstract class Chart<T extends Schema> {
}
// 要求された範囲の最も古い箇所に位置するログが存在しなかったら
} else if (!isTimeSame(new Date(logs[logs.length - 1].date * 1000), gt)) {
} else if (!isTimeSame(new Date(logs.at(-1)!.date * 1000), gt)) {
// 要求された範囲の最も古い箇所時点での最も新しいログを持ってきて末尾に追加する
// (隙間埋めできないため)
const outdatedLog = await repository.findOne({

View File

@@ -3,8 +3,8 @@ import { DI } from '@/di-symbols.js';
import type { AbuseUserReportsRepository } from '@/models/index.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { AbuseUserReport } from '@/models/entities/AbuseUserReport.js';
import { UserEntityService } from './UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { UserEntityService } from './UserEntityService.js';
@Injectable()
export class AbuseUserReportEntityService {

View File

@@ -4,8 +4,8 @@ import type { AuthSessionsRepository } from '@/models/index.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { AuthSession } from '@/models/entities/AuthSession.js';
import type { User } from '@/models/entities/User.js';
import { AppEntityService } from './AppEntityService.js';
import { bindThis } from '@/decorators.js';
import { AppEntityService } from './AppEntityService.js';
@Injectable()
export class AuthSessionEntityService {

View File

@@ -47,17 +47,26 @@ export class ChannelEntityService {
const banner = channel.bannerId ? await this.driveFilesRepository.findOneBy({ id: channel.bannerId }) : null;
const hasUnreadNote = meId ? (await this.noteUnreadsRepository.findOneBy({ noteChannelId: channel.id, userId: meId })) != null : undefined;
const hasUnreadNote = meId ? await this.noteUnreadsRepository.exist({
where: {
noteChannelId: channel.id,
userId: meId,
},
}) : undefined;
const following = meId ? await this.channelFollowingsRepository.findOneBy({
followerId: meId,
followeeId: channel.id,
}) : null;
const isFollowing = meId ? await this.channelFollowingsRepository.exist({
where: {
followerId: meId,
followeeId: channel.id,
},
}) : false;
const favorite = meId ? await this.channelFavoritesRepository.findOneBy({
userId: meId,
channelId: channel.id,
}) : null;
const isFavorited = meId ? await this.channelFavoritesRepository.exist({
where: {
userId: meId,
channelId: channel.id,
},
}) : false;
const pinnedNotes = channel.pinnedNoteIds.length > 0 ? await this.notesRepository.find({
where: {
@@ -80,8 +89,8 @@ export class ChannelEntityService {
notesCount: channel.notesCount,
...(me ? {
isFollowing: following != null,
isFavorited: favorite != null,
isFollowing,
isFavorited,
hasUnreadNote,
} : {}),

View File

@@ -39,7 +39,7 @@ export class ClipEntityService {
description: clip.description,
isPublic: clip.isPublic,
favoritedCount: await this.clipFavoritesRepository.countBy({ clipId: clip.id }),
isFavorited: meId ? await this.clipFavoritesRepository.findOneBy({ clipId: clip.id, userId: meId }).then(x => x != null) : undefined,
isFavorited: meId ? await this.clipFavoritesRepository.exist({ where: { clipId: clip.id, userId: meId } }) : undefined,
});
}

View File

@@ -47,7 +47,7 @@ export class DriveFileEntityService {
private videoProcessingService: VideoProcessingService,
) {
}
@bindThis
public validateFileName(name: string): boolean {
return (

View File

@@ -40,7 +40,7 @@ export class FlashEntityService {
summary: flash.summary,
script: flash.script,
likedCount: flash.likedCount,
isLiked: meId ? await this.flashLikesRepository.findOneBy({ flashId: flash.id, userId: meId }).then(x => x != null) : undefined,
isLiked: meId ? await this.flashLikesRepository.exist({ where: { flashId: flash.id, userId: meId } }) : undefined,
});
}

View File

@@ -4,8 +4,8 @@ import type { FollowRequestsRepository } from '@/models/index.js';
import type { } from '@/models/entities/Blocking.js';
import type { User } from '@/models/entities/User.js';
import type { FollowRequest } from '@/models/entities/FollowRequest.js';
import { UserEntityService } from './UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { UserEntityService } from './UserEntityService.js';
@Injectable()
export class FollowRequestEntityService {

View File

@@ -3,8 +3,8 @@ import { DI } from '@/di-symbols.js';
import type { GalleryLikesRepository } from '@/models/index.js';
import type { } from '@/models/entities/Blocking.js';
import type { GalleryLike } from '@/models/entities/GalleryLike.js';
import { GalleryPostEntityService } from './GalleryPostEntityService.js';
import { bindThis } from '@/decorators.js';
import { GalleryPostEntityService } from './GalleryPostEntityService.js';
@Injectable()
export class GalleryLikeEntityService {

View File

@@ -46,7 +46,7 @@ export class GalleryPostEntityService {
tags: post.tags.length > 0 ? post.tags : undefined,
isSensitive: post.isSensitive,
likedCount: post.likedCount,
isLiked: meId ? await this.galleryLikesRepository.findOneBy({ postId: post.id, userId: meId }).then(x => x != null) : undefined,
isLiked: meId ? await this.galleryLikesRepository.exist({ where: { postId: post.id, userId: meId } }) : undefined,
});
}

View File

@@ -0,0 +1,52 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { RegistrationTicketsRepository } from '@/models/index.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Packed } from '@/misc/json-schema.js';
import type { User } from '@/models/entities/User.js';
import type { RegistrationTicket } from '@/models/entities/RegistrationTicket.js';
import { bindThis } from '@/decorators.js';
import { UserEntityService } from './UserEntityService.js';
@Injectable()
export class InviteCodeEntityService {
constructor(
@Inject(DI.registrationTicketsRepository)
private registrationTicketsRepository: RegistrationTicketsRepository,
private userEntityService: UserEntityService,
) {
}
@bindThis
public async pack(
src: RegistrationTicket['id'] | RegistrationTicket,
me?: { id: User['id'] } | null | undefined,
): Promise<Packed<'InviteCode'>> {
const target = typeof src === 'object' ? src : await this.registrationTicketsRepository.findOneOrFail({
where: {
id: src,
},
relations: ['createdBy', 'usedBy'],
});
return await awaitAll({
id: target.id,
code: target.code,
expiresAt: target.expiresAt ? target.expiresAt.toISOString() : null,
createdAt: target.createdAt.toISOString(),
createdBy: target.createdBy ? await this.userEntityService.pack(target.createdBy, me) : null,
usedBy: target.usedBy ? await this.userEntityService.pack(target.usedBy, me) : null,
usedAt: target.usedAt ? target.usedAt.toISOString() : null,
used: !!target.usedAt,
});
}
@bindThis
public packMany(
targets: any[],
me: { id: User['id'] },
) {
return Promise.all(targets.map(x => this.pack(x, me)));
}
}

View File

@@ -4,8 +4,8 @@ import type { ModerationLogsRepository } from '@/models/index.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { } from '@/models/entities/Blocking.js';
import type { ModerationLog } from '@/models/entities/ModerationLog.js';
import { UserEntityService } from './UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { UserEntityService } from './UserEntityService.js';
@Injectable()
export class ModerationLogEntityService {

View File

@@ -24,7 +24,7 @@ export class NoteEntityService implements OnModuleInit {
private driveFileEntityService: DriveFileEntityService;
private customEmojiService: CustomEmojiService;
private reactionService: ReactionService;
constructor(
private moduleRef: ModuleRef,
@@ -68,7 +68,7 @@ export class NoteEntityService implements OnModuleInit {
this.customEmojiService = this.moduleRef.get('CustomEmojiService');
this.reactionService = this.moduleRef.get('ReactionService');
}
@bindThis
private async hideNote(packedNote: Packed<'Note'>, meId: User['id'] | null) {
// TODO: isVisibleForMe を使うようにしても良さそう(型違うけど)
@@ -106,16 +106,14 @@ export class NoteEntityService implements OnModuleInit {
hide = false;
} else {
// フォロワーかどうか
const following = await this.followingsRepository.findOneBy({
followeeId: packedNote.userId,
followerId: meId,
const isFollowing = await this.followingsRepository.exist({
where: {
followeeId: packedNote.userId,
followerId: meId,
},
});
if (following == null) {
hide = true;
} else {
hide = false;
}
hide = !isFollowing;
}
}
@@ -457,12 +455,12 @@ export class NoteEntityService implements OnModuleInit {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.userId = :userId', { userId })
.andWhere('note.renoteId = :renoteId', { renoteId });
// 指定した投稿を除く
if (excludeNoteId) {
query.andWhere('note.id != :excludeNoteId', { excludeNoteId });
}
return await query.getCount();
}
}
}

View File

@@ -4,8 +4,8 @@ import type { NoteFavoritesRepository } from '@/models/index.js';
import type { } from '@/models/entities/Blocking.js';
import type { User } from '@/models/entities/User.js';
import type { NoteFavorite } from '@/models/entities/NoteFavorite.js';
import { NoteEntityService } from './NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { NoteEntityService } from './NoteEntityService.js';
@Injectable()
export class NoteFavoriteEntityService {

View File

@@ -17,7 +17,7 @@ export class NoteReactionEntityService implements OnModuleInit {
private userEntityService: UserEntityService;
private noteEntityService: NoteEntityService;
private reactionService: ReactionService;
constructor(
private moduleRef: ModuleRef,

View File

@@ -59,7 +59,7 @@ export class NotificationEntityService implements OnModuleInit {
meId: User['id'],
// eslint-disable-next-line @typescript-eslint/ban-types
options: {
},
hint?: {
packedNotes: Map<Note['id'], Packed<'Note'>>;

View File

@@ -97,7 +97,7 @@ export class PageEntityService {
eyeCatchingImage: page.eyeCatchingImageId ? await this.driveFileEntityService.pack(page.eyeCatchingImageId) : null,
attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter((x): x is DriveFile => x != null)),
likedCount: page.likedCount,
isLiked: meId ? await this.pageLikesRepository.findOneBy({ pageId: page.id, userId: meId }).then(x => x != null) : undefined,
isLiked: meId ? await this.pageLikesRepository.exist({ where: { pageId: page.id, userId: meId } }) : undefined,
});
}

View File

@@ -4,8 +4,8 @@ import type { PageLikesRepository } from '@/models/index.js';
import type { } from '@/models/entities/Blocking.js';
import type { User } from '@/models/entities/User.js';
import type { PageLike } from '@/models/entities/PageLike.js';
import { PageEntityService } from './PageEntityService.js';
import { bindThis } from '@/decorators.js';
import { PageEntityService } from './PageEntityService.js';
@Injectable()
export class PageLikeEntityService {

View File

@@ -3,8 +3,8 @@ import { DI } from '@/di-symbols.js';
import type { SigninsRepository } from '@/models/index.js';
import type { } from '@/models/entities/Blocking.js';
import type { Signin } from '@/models/entities/Signin.js';
import { UserEntityService } from './UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { UserEntityService } from './UserEntityService.js';
@Injectable()
export class SigninEntityService {

View File

@@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { In, Not } from 'typeorm';
import * as Redis from 'ioredis';
import Ajv from 'ajv';
import _Ajv from 'ajv';
import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@@ -31,6 +31,7 @@ type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends bo
Packed<'UserDetailed'> :
Packed<'UserLite'>;
const Ajv = _Ajv.default;
const ajv = new Ajv();
function isLocalUser(user: User): user is LocalUser;
@@ -112,7 +113,7 @@ export class UserEntityService implements OnModuleInit {
@Inject(DI.pagesRepository)
private pagesRepository: PagesRepository,
@Inject(DI.userMemosRepository)
private userMemosRepository: UserMemoRepository,
@@ -229,12 +230,14 @@ export class UserEntityService implements OnModuleInit {
/*
const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId);
const unread = myAntennas.length > 0 ? await this.antennaNotesRepository.findOneBy({
antennaId: In(myAntennas.map(x => x.id)),
read: false,
}) : null;
const isUnread = (myAntennas.length > 0 ? await this.antennaNotesRepository.exist({
where: {
antennaId: In(myAntennas.map(x => x.id)),
read: false,
},
}) : false);
return unread != null;
return isUnread;
*/
return false; // TODO
}

View File

@@ -81,7 +81,7 @@ export class QueueStatsService implements OnApplicationShutdown {
this.intervalId = setInterval(tick, interval);
}
@bindThis
public dispose(): void {
clearInterval(this.intervalId);

View File

@@ -3,6 +3,7 @@ import si from 'systeminformation';
import Xev from 'xev';
import * as osUtils from 'os-utils';
import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
const ev = new Xev();
@@ -17,6 +18,7 @@ export class ServerStatsService implements OnApplicationShutdown {
private intervalId: NodeJS.Timer | null = null;
constructor(
private metaService: MetaService,
) {
}
@@ -24,7 +26,9 @@ export class ServerStatsService implements OnApplicationShutdown {
* Report server stats regularly
*/
@bindThis
public start(): void {
public async start(): Promise<void> {
if (!(await this.metaService.fetch(true)).enableServerMachineStats) return;
const log = [] as any[];
ev.on('requestServerStatsLog', x => {

View File

@@ -4,7 +4,7 @@ import { default as convertColor } from 'color-convert';
import { format as dateFormat } from 'date-fns';
import { bindThis } from '@/decorators.js';
import { envOption } from './env.js';
import type { KEYWORD } from 'color-convert/conversions';
import type { KEYWORD } from 'color-convert/conversions.js';
type Context = {
name: string;

View File

@@ -4,7 +4,7 @@ export type Acct = {
};
export function parse(acct: string): Acct {
if (acct.startsWith('@')) acct = acct.substr(1);
if (acct.startsWith('@')) acct = acct.substring(1);
const split = acct.split('@', 2);
return { username: split[0], host: split[1] ?? null };
}

View File

@@ -181,14 +181,28 @@ export class RedisSingleCache<T> {
// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする?
export class MemoryKVCache<T> {
public cache: Map<string, { date: number; value: T; }>;
function nothingToDo<T, V = T>(value: T): V {
return value as unknown as V;
}
export class MemoryKVCache<T, V = T> {
public cache: Map<string, { date: number; value: V; }>;
private lifetime: number;
private gcIntervalHandle: NodeJS.Timer;
private toMapConverter: (value: T) => V;
private fromMapConverter: (cached: V) => T | undefined;
constructor(lifetime: MemoryKVCache<never>['lifetime']) {
constructor(lifetime: MemoryKVCache<never>['lifetime'], options: {
toMapConverter: (value: T) => V;
fromMapConverter: (cached: V) => T | undefined;
} = {
toMapConverter: nothingToDo,
fromMapConverter: nothingToDo,
}) {
this.cache = new Map();
this.lifetime = lifetime;
this.toMapConverter = options.toMapConverter;
this.fromMapConverter = options.fromMapConverter;
this.gcIntervalHandle = setInterval(() => {
this.gc();
@@ -199,7 +213,7 @@ export class MemoryKVCache<T> {
public set(key: string, value: T): void {
this.cache.set(key, {
date: Date.now(),
value,
value: this.toMapConverter(value),
});
}
@@ -211,7 +225,7 @@ export class MemoryKVCache<T> {
this.cache.delete(key);
return undefined;
}
return cached.value;
return this.fromMapConverter(cached.value);
}
@bindThis
@@ -222,9 +236,10 @@ export class MemoryKVCache<T> {
/**
* キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
* fetcherの引数はcacheに保存されている値があれば渡されます
*/
@bindThis
public async fetch(key: string, fetcher: () => Promise<T>, validator?: (cachedValue: T) => boolean): Promise<T> {
public async fetch(key: string, fetcher: (value: V | undefined) => Promise<T>, validator?: (cachedValue: T) => boolean): Promise<T> {
const cachedValue = this.get(key);
if (cachedValue !== undefined) {
if (validator) {
@@ -239,7 +254,7 @@ export class MemoryKVCache<T> {
}
// Cache MISS
const value = await fetcher();
const value = await fetcher(this.cache.get(key)?.value);
this.set(key, value);
return value;
}
@@ -247,9 +262,10 @@ export class MemoryKVCache<T> {
/**
* キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します
* optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします
* fetcherの引数はcacheに保存されている値があれば渡されます
*/
@bindThis
public async fetchMaybe(key: string, fetcher: () => Promise<T | undefined>, validator?: (cachedValue: T) => boolean): Promise<T | undefined> {
public async fetchMaybe(key: string, fetcher: (value: V | undefined) => Promise<T | undefined>, validator?: (cachedValue: T) => boolean): Promise<T | undefined> {
const cachedValue = this.get(key);
if (cachedValue !== undefined) {
if (validator) {
@@ -264,7 +280,7 @@ export class MemoryKVCache<T> {
}
// Cache MISS
const value = await fetcher();
const value = await fetcher(this.cache.get(key)?.value);
if (value !== undefined) {
this.set(key, value);
}

View File

@@ -1,4 +1,4 @@
export function checkHttps(url: string) {
return url.startsWith('https://') ||
(url.startsWith('http://') && process.env.NODE_ENV !== 'production');
export function checkHttps(url: string): boolean {
return url.startsWith('https://') ||
(url.startsWith('http://') && process.env.NODE_ENV !== 'production');
}

View File

@@ -1,11 +1,11 @@
import { Writable, WritableOptions } from "node:stream";
import { Writable, WritableOptions } from 'node:stream';
export class DevNull extends Writable implements NodeJS.WritableStream {
constructor(opts?: WritableOptions) {
super(opts);
}
constructor(opts?: WritableOptions) {
super(opts);
}
_write (chunk: any, encoding: BufferEncoding, cb: (err?: Error | null) => void) {
setImmediate(cb);
}
_write (chunk: any, encoding: BufferEncoding, cb: (err?: Error | null) => void) {
setImmediate(cb);
}
}

View File

@@ -0,0 +1,20 @@
import { secureRndstr } from './secure-rndstr.js';
const CHARS = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; // [0-9A-Z] w/o [01IO] (32 patterns)
export function generateInviteCode(): string {
const code = secureRndstr(8, {
chars: CHARS,
});
const uniqueId = [];
let n = Math.floor(Date.now() / 1000 / 60);
while (true) {
uniqueId.push(CHARS[n % CHARS.length]);
const t = Math.floor(n / CHARS.length);
if (!t) break;
n = t;
}
return code + uniqueId.reverse().join('');
}

View File

@@ -1,3 +1,3 @@
import { secureRndstr } from '@/misc/secure-rndstr.js';
export default () => secureRndstr(16, true);
export default () => secureRndstr(16);

View File

@@ -1,6 +1,6 @@
import IPCIDR from 'ip-cidr';
export function getIpHash(ip: string) {
export function getIpHash(ip: string): string {
try {
// because a single person may control many IPv6 addresses,
// only a /64 subnet prefix of any IP will be taken into account.

View File

@@ -5,10 +5,10 @@ const CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
export const ulidRegExp = /^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$/;
export function parseUlid(id: string): { date: Date; } {
const timestamp = id.slice(0, 10);
let time = 0;
for (let i = 0; i < 10; i++) {
time = time * 32 + CHARS.indexOf(timestamp[i]);
}
return { date: new Date(time) };
const timestamp = id.slice(0, 10);
let time = 0;
for (let i = 0; i < 10; i++) {
time = time * 32 + CHARS.indexOf(timestamp[i]);
}
return { date: new Date(time) };
}

Some files were not shown because too many files have changed in this diff Show More