Merge branch 'develop' into img-max

This commit is contained in:
tamaina
2023-04-09 01:44:25 +00:00
149 changed files with 2651 additions and 1472 deletions

View File

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

View File

@@ -0,0 +1,17 @@
export class AvatarUrlAndBannerUrl1680775031481 {
name = 'AvatarUrlAndBannerUrl1680775031481'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ADD "avatarUrl" character varying(512)`);
await queryRunner.query(`ALTER TABLE "user" ADD "bannerUrl" character varying(512)`);
await queryRunner.query(`ALTER TABLE "user" ADD "avatarBlurhash" character varying(128)`);
await queryRunner.query(`ALTER TABLE "user" ADD "bannerBlurhash" character varying(128)`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "bannerBlurhash"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarBlurhash"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "bannerUrl"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarUrl"`);
}
}

View File

@@ -0,0 +1,17 @@
export class AccountMove1680931179228 {
name = 'AccountMove1680931179228'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ADD "movedToUri" character varying(512)`);
await queryRunner.query(`COMMENT ON COLUMN "user"."movedToUri" IS 'The URI of the new account of the User'`);
await queryRunner.query(`ALTER TABLE "user" ADD "alsoKnownAs" text`);
await queryRunner.query(`COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too'`);
}
async down(queryRunner) {
await queryRunner.query(`COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too'`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "alsoKnownAs"`);
await queryRunner.query(`COMMENT ON COLUMN "user"."movedToUri" IS 'The URI of the new account of the User'`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "movedToUri"`);
}
}

View File

@@ -22,24 +22,24 @@
"test-and-coverage": "pnpm jest-and-coverage"
},
"optionalDependencies": {
"@swc/core-android-arm64": "^1.3.11",
"@swc/core-darwin-arm64": "^1.3.42",
"@swc/core-darwin-x64": "^1.3.42",
"@swc/core-linux-arm-gnueabihf": "^1.3.42",
"@swc/core-linux-arm64-gnu": "^1.3.42",
"@swc/core-linux-arm64-musl": "^1.3.42",
"@swc/core-linux-x64-gnu": "^1.3.42",
"@swc/core-linux-x64-musl": "^1.3.42",
"@swc/core-win32-arm64-msvc": "^1.3.42",
"@swc/core-win32-ia32-msvc": "^1.3.42",
"@swc/core-win32-x64-msvc": "^1.3.42",
"@swc/core-android-arm64": "1.3.11",
"@swc/core-darwin-arm64": "1.3.46",
"@swc/core-darwin-x64": "1.3.46",
"@swc/core-linux-arm-gnueabihf": "1.3.46",
"@swc/core-linux-arm64-gnu": "1.3.46",
"@swc/core-linux-arm64-musl": "1.3.46",
"@swc/core-linux-x64-gnu": "1.3.46",
"@swc/core-linux-x64-musl": "1.3.46",
"@swc/core-win32-arm64-msvc": "1.3.46",
"@swc/core-win32-ia32-msvc": "1.3.46",
"@swc/core-win32-x64-msvc": "1.3.46",
"@tensorflow/tfjs": "4.2.0",
"@tensorflow/tfjs-node": "4.2.0"
},
"dependencies": {
"@aws-sdk/client-s3": "3.301.0",
"@aws-sdk/lib-storage": "3.301.0",
"@aws-sdk/node-http-handler": "3.296.0",
"@aws-sdk/client-s3": "3.306.0",
"@aws-sdk/lib-storage": "3.306.0",
"@aws-sdk/node-http-handler": "3.306.0",
"@bull-board/api": "5.0.0",
"@bull-board/fastify": "5.0.0",
"@bull-board/ui": "5.0.0",
@@ -49,15 +49,15 @@
"@fastify/cors": "8.2.1",
"@fastify/http-proxy": "9.0.0",
"@fastify/multipart": "7.5.0",
"@fastify/static": "6.9.0",
"@fastify/static": "6.10.0",
"@fastify/view": "7.4.1",
"@nestjs/common": "9.3.12",
"@nestjs/core": "9.3.12",
"@nestjs/testing": "9.3.12",
"@nestjs/common": "9.4.0",
"@nestjs/core": "9.4.0",
"@nestjs/testing": "9.4.0",
"@peertube/http-signature": "1.7.0",
"@sinonjs/fake-timers": "10.0.2",
"@swc/cli": "0.1.62",
"@swc/core": "1.3.42",
"@swc/core": "1.3.46",
"accepts": "1.3.8",
"ajv": "8.12.0",
"archiver": "5.3.1",
@@ -136,8 +136,8 @@
"tsc-alias": "1.8.5",
"tsconfig-paths": "4.2.0",
"twemoji-parser": "14.0.0",
"typeorm": "0.3.11",
"typescript": "5.0.2",
"typeorm": "0.3.13",
"typescript": "5.0.3",
"ulid": "2.3.0",
"unzipper": "0.10.11",
"uuid": "9.0.0",
@@ -190,8 +190,8 @@
"@types/web-push": "3.3.2",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "5.57.0",
"@typescript-eslint/parser": "5.57.0",
"@typescript-eslint/eslint-plugin": "5.57.1",
"@typescript-eslint/parser": "5.57.1",
"aws-sdk-client-mock": "^2.1.1",
"cross-env": "7.0.3",
"eslint": "8.37.0",

View File

@@ -2,18 +2,15 @@ import { setTimeout } from 'node:timers/promises';
import { Global, Inject, Module } from '@nestjs/common';
import Redis from 'ioredis';
import { DataSource } from 'typeorm';
import { createRedisConnection } from '@/redis.js';
import { DI } from './di-symbols.js';
import { loadConfig } from './config.js';
import { createPostgresDataSource } from './postgres.js';
import { RepositoryModule } from './models/RepositoryModule.js';
import type { Provider, OnApplicationShutdown } from '@nestjs/common';
const config = loadConfig();
const $config: Provider = {
provide: DI.config,
useValue: config,
useValue: loadConfig(),
};
const $db: Provider = {
@@ -28,18 +25,31 @@ const $db: Provider = {
const $redis: Provider = {
provide: DI.redis,
useFactory: (config) => {
const redisClient = createRedisConnection(config);
return redisClient;
return new Redis({
port: config.redis.port,
host: config.redis.host,
family: config.redis.family == null ? 0 : config.redis.family,
password: config.redis.pass,
keyPrefix: `${config.redis.prefix}:`,
db: config.redis.db ?? 0,
});
},
inject: [DI.config],
};
const $redisSubscriber: Provider = {
provide: DI.redisSubscriber,
const $redisForPubsub: Provider = {
provide: DI.redisForPubsub,
useFactory: (config) => {
const redisSubscriber = createRedisConnection(config);
redisSubscriber.subscribe(config.host);
return redisSubscriber;
const redis = new Redis({
port: config.redisForPubsub.port,
host: config.redisForPubsub.host,
family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family,
password: config.redisForPubsub.pass,
keyPrefix: `${config.redisForPubsub.prefix}:`,
db: config.redisForPubsub.db ?? 0,
});
redis.subscribe(config.host);
return redis;
},
inject: [DI.config],
};
@@ -47,14 +57,14 @@ const $redisSubscriber: Provider = {
@Global()
@Module({
imports: [RepositoryModule],
providers: [$config, $db, $redis, $redisSubscriber],
exports: [$config, $db, $redis, $redisSubscriber, RepositoryModule],
providers: [$config, $db, $redis, $redisForPubsub],
exports: [$config, $db, $redis, $redisForPubsub, RepositoryModule],
})
export class GlobalModule implements OnApplicationShutdown {
constructor(
@Inject(DI.db) private db: DataSource,
@Inject(DI.redis) private redisClient: Redis.Redis,
@Inject(DI.redisSubscriber) private redisSubscriber: Redis.Redis,
@Inject(DI.redisForPubsub) private redisForPubsub: Redis.Redis,
) {}
async onApplicationShutdown(signal: string): Promise<void> {
@@ -69,7 +79,7 @@ export class GlobalModule implements OnApplicationShutdown {
await Promise.all([
this.db.destroy(),
this.redisClient.disconnect(),
this.redisSubscriber.disconnect(),
this.redisForPubsub.disconnect(),
]);
}
}

View File

@@ -25,6 +25,14 @@ export type Source = {
disableCache?: boolean;
extra?: { [x: string]: string };
};
dbReplications?: boolean;
dbSlaves?: {
host: string;
port: number;
db: string;
user: string;
pass: string;
}[];
redis: {
host: string;
port: number;
@@ -33,6 +41,22 @@ export type Source = {
db?: number;
prefix?: string;
};
redisForPubsub?: {
host: string;
port: number;
family?: number;
pass: string;
db?: number;
prefix?: string;
};
redisForJobQueue?: {
host: string;
port: number;
family?: number;
pass: string;
db?: number;
prefix?: string;
};
elasticsearch: {
host: string;
port: number;
@@ -91,6 +115,8 @@ export type Mixin = {
mediaProxy: string;
externalMediaProxyEnabled: boolean;
videoThumbnailGenerator: string | null;
redisForPubsub: NonNullable<Source['redisForPubsub']>;
redisForJobQueue: NonNullable<Source['redisForJobQueue']>;
};
export type Config = Source & Mixin;
@@ -151,6 +177,8 @@ export function loadConfig() {
: null;
if (!config.redis.prefix) config.redis.prefix = mixin.host;
if (config.redisForPubsub == null) config.redisForPubsub = config.redis;
if (config.redisForJobQueue == null) config.redisForJobQueue = config.redis;
return Object.assign(config, mixin);
}

View File

@@ -0,0 +1,114 @@
import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm';
import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
import type { LocalUser } from '@/models/entities/User.js';
import { User } from '@/models/entities/User.js';
import type { FollowingsRepository, UsersRepository } from '@/models/index.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { AccountUpdateService } from '@/core/AccountUpdateService.js';
import { RelayService } from '@/core/RelayService.js';
@Injectable()
export class AccountMoveService {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private userEntityService: UserEntityService,
private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService,
private globalEventService: GlobalEventService,
private userFollowingService: UserFollowingService,
private accountUpdateService: AccountUpdateService,
private relayService: RelayService,
) {
}
/**
* Move a local account to a remote account.
*
* After delivering Move activity, its local followers unfollow the old account and then follow the new one.
*/
@bindThis
public async moveToRemote(src: LocalUser, dst: User): Promise<unknown> {
// Make sure that the destination is a remote account.
if (this.userEntityService.isLocalUser(dst)) throw new Error('move destiantion is not remote');
if (!dst.uri) throw new Error('destination uri is empty');
// add movedToUri to indicate that the user has moved
const update = {} as Partial<User>;
update.alsoKnownAs = src.alsoKnownAs?.concat([dst.uri]) ?? [dst.uri];
update.movedToUri = dst.uri;
await this.usersRepository.update(src.id, update);
const srcPerson = await this.apRendererService.renderPerson(src);
const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src));
await this.apDeliverManagerService.deliverToFollowers(src, updateAct);
this.relayService.deliverToRelays(src, updateAct);
// Deliver Move activity to the followers of the old account
const moveAct = this.apRendererService.addContext(this.apRendererService.renderMove(src, dst));
await this.apDeliverManagerService.deliverToFollowers(src, moveAct);
// Publish meUpdated event
const iObj = await this.userEntityService.pack<true, true>(src.id, src, { detail: true, includeSecrets: true });
this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj);
// follow the new account and unfollow the old one
const followings = await this.followingsRepository.find({
relations: {
follower: true,
},
where: {
followeeId: src.id,
followerHost: IsNull(), // follower is local
},
});
for (const following of followings) {
if (!following.follower) continue;
try {
await this.userFollowingService.follow(following.follower, dst);
await this.userFollowingService.unfollow(following.follower, src);
} catch {
/* empty */
}
}
return iObj;
}
/**
* Create an alias of an old remote account.
*
* The user's new profile will be published to the followers.
*/
@bindThis
public async createAlias(me: LocalUser, updates: Partial<User>): Promise<unknown> {
await this.usersRepository.update(me.id, updates);
// Publish meUpdated event
const iObj = await this.userEntityService.pack<true, true>(me.id, me, {
detail: true,
includeSecrets: true,
});
this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj);
if (me.isLocked === false) {
await this.userFollowingService.acceptAllFollowRequests(me);
}
this.accountUpdateService.publishToFollowers(me.id);
return iObj;
}
}

View File

@@ -29,7 +29,7 @@ export class AccountUpdateService {
public async publishToFollowers(userId: User['id']) {
const user = await this.usersRepository.findOneBy({ id: userId });
if (user == null) throw new Error('user not found');
// フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信
if (this.userEntityService.isLocalUser(user)) {
const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user));

View File

@@ -27,8 +27,8 @@ export class AntennaService implements OnApplicationShutdown {
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.redisSubscriber)
private redisSubscriber: Redis.Redis,
@Inject(DI.redisForPubsub)
private redisForPubsub: Redis.Redis,
@Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository,
@@ -52,12 +52,12 @@ export class AntennaService implements OnApplicationShutdown {
this.antennasFetched = false;
this.antennas = [];
this.redisSubscriber.on('message', this.onRedisMessage);
this.redisForPubsub.on('message', this.onRedisMessage);
}
@bindThis
public onApplicationShutdown(signal?: string | undefined) {
this.redisSubscriber.off('message', this.onRedisMessage);
this.redisForPubsub.off('message', this.onRedisMessage);
}
@bindThis

View File

@@ -27,8 +27,8 @@ export class CacheService implements OnApplicationShutdown {
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.redisSubscriber)
private redisSubscriber: Redis.Redis,
@Inject(DI.redisForPubsub)
private redisForPubsub: Redis.Redis,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -116,7 +116,7 @@ export class CacheService implements OnApplicationShutdown {
fromRedisConverter: (value) => new Set(JSON.parse(value)),
});
this.redisSubscriber.on('message', this.onMessage);
this.redisForPubsub.on('message', this.onMessage);
}
@bindThis
@@ -167,6 +167,6 @@ export class CacheService implements OnApplicationShutdown {
@bindThis
public onApplicationShutdown(signal?: string | undefined) {
this.redisSubscriber.off('message', this.onMessage);
this.redisForPubsub.off('message', this.onMessage);
}
}

View File

@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js';
import { AiService } from './AiService.js';
import { AntennaService } from './AntennaService.js';
@@ -119,6 +120,7 @@ import type { Provider } from '@nestjs/common';
//#region 文字列ベースでのinjection用(循環参照対応のため)
const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService };
const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService };
const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService };
const $AiService: Provider = { provide: 'AiService', useExisting: AiService };
const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService };
@@ -242,6 +244,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
],
providers: [
LoggerService,
AccountMoveService,
AccountUpdateService,
AiService,
AntennaService,
@@ -359,6 +362,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
//#region 文字列ベースでのinjection用(循環参照対応のため)
$LoggerService,
$AccountMoveService,
$AccountUpdateService,
$AiService,
$AntennaService,
@@ -477,6 +481,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
exports: [
QueueModule,
LoggerService,
AccountMoveService,
AccountUpdateService,
AiService,
AntennaService,
@@ -593,6 +598,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
//#region 文字列ベースでのinjection用(循環参照対応のため)
$LoggerService,
$AccountMoveService,
$AccountUpdateService,
$AiService,
$AntennaService,

View File

@@ -44,7 +44,11 @@ export class CustomEmojiService {
memoryCacheLifetime: 1000 * 60 * 3, // 3m
fetcher: () => this.emojisRepository.find({ where: { host: IsNull() } }).then(emojis => new Map(emojis.map(emoji => [emoji.name, emoji]))),
toRedisConverter: (value) => JSON.stringify(value.values()),
fromRedisConverter: (value) => new Map(JSON.parse(value).map((x: Emoji) => [x.name, x])), // TODO: Date型の変換
fromRedisConverter: (value) => {
// 原因不明だが配列以外が入ってくることがあるため
if (!Array.isArray(JSON.parse(value))) return undefined;
return new Map(JSON.parse(value).map((x: Emoji) => [x.name, x]));
}, // TODO: Date型の変換
});
}

View File

@@ -1,7 +1,8 @@
import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import type { InstancesRepository } from '@/models/index.js';
import type { Instance } from '@/models/entities/Instance.js';
import { MemoryKVCache } from '@/misc/cache.js';
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
import { UtilityService } from '@/core/UtilityService.js';
@@ -9,23 +10,41 @@ import { bindThis } from '@/decorators.js';
@Injectable()
export class FederatedInstanceService {
private cache: MemoryKVCache<Instance>;
public federatedInstanceCache: RedisKVCache<Instance | null>;
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository,
private utilityService: UtilityService,
private idService: IdService,
) {
this.cache = new MemoryKVCache<Instance>(1000 * 60 * 60);
this.federatedInstanceCache = new RedisKVCache<Instance | null>(this.redisClient, 'federatedInstance', {
lifetime: 1000 * 60 * 60 * 24, // 24h
memoryCacheLifetime: 1000 * 60 * 30, // 30m
fetcher: (key) => this.instancesRepository.findOneBy({ host: key }),
toRedisConverter: (value) => JSON.stringify(value),
fromRedisConverter: (value) => {
const parsed = JSON.parse(value);
if (parsed == null) return null;
return {
...parsed,
firstRetrievedAt: new Date(parsed.firstRetrievedAt),
latestRequestReceivedAt: parsed.latestRequestReceivedAt ? new Date(parsed.latestRequestReceivedAt) : null,
infoUpdatedAt: parsed.infoUpdatedAt ? new Date(parsed.infoUpdatedAt) : null,
};
},
});
}
@bindThis
public async fetch(host: string): Promise<Instance> {
host = this.utilityService.toPuny(host);
const cached = this.cache.get(host);
const cached = await this.federatedInstanceCache.get(host);
if (cached) return cached;
const index = await this.instancesRepository.findOneBy({ host });
@@ -37,10 +56,10 @@ export class FederatedInstanceService {
firstRetrievedAt: new Date(),
}).then(x => this.instancesRepository.findOneByOrFail(x.identifiers[0]));
this.cache.set(host, i);
this.federatedInstanceCache.set(host, i);
return i;
} else {
this.cache.set(host, index);
this.federatedInstanceCache.set(host, index);
return index;
}
}
@@ -49,10 +68,10 @@ export class FederatedInstanceService {
public async updateCachePartial(host: string, data: Partial<Instance>): Promise<void> {
host = this.utilityService.toPuny(host);
const cached = this.cache.get(host);
const cached = await this.federatedInstanceCache.get(host);
if (cached == null) return;
this.cache.set(host, {
this.federatedInstanceCache.set(host, {
...cached,
...data,
});

View File

@@ -3,10 +3,11 @@ import { ulid } from 'ulid';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { genAid, parseAid } from '@/misc/id/aid.js';
import { genMeid } from '@/misc/id/meid.js';
import { genMeidg } from '@/misc/id/meidg.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 { bindThis } from '@/decorators.js';
import { parseUlid } from '@/misc/id/ulid.js';
@Injectable()
export class IdService {
@@ -37,11 +38,10 @@ export class IdService {
public parse(id: string): { date: Date; } {
switch (this.method) {
case 'aid': return parseAid(id);
// TODO
//case 'meid':
//case 'meidg':
//case 'ulid':
//case 'objectid':
case 'objectid':
case 'meid': return parseMeid(id);
case 'meidg': return parseMeidg(id);
case 'ulid': return parseUlid(id);
default: throw new Error('unrecognized id generation method');
}
}

View File

@@ -14,8 +14,8 @@ export class MetaService implements OnApplicationShutdown {
private intervalId: NodeJS.Timer;
constructor(
@Inject(DI.redisSubscriber)
private redisSubscriber: Redis.Redis,
@Inject(DI.redisForPubsub)
private redisForPubsub: Redis.Redis,
@Inject(DI.db)
private db: DataSource,
@@ -33,7 +33,7 @@ export class MetaService implements OnApplicationShutdown {
}, 1000 * 60 * 5);
}
this.redisSubscriber.on('message', this.onMessage);
this.redisForPubsub.on('message', this.onMessage);
}
@bindThis
@@ -122,6 +122,6 @@ export class MetaService implements OnApplicationShutdown {
@bindThis
public onApplicationShutdown(signal?: string | undefined) {
clearInterval(this.intervalId);
this.redisSubscriber.off('message', this.onMessage);
this.redisForPubsub.off('message', this.onMessage);
}
}

View File

@@ -8,13 +8,13 @@ import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, End
function q<T>(config: Config, name: string, limitPerSec = -1) {
return new Bull<T>(name, {
redis: {
port: config.redis.port,
host: config.redis.host,
family: config.redis.family == null ? 0 : config.redis.family,
password: config.redis.pass,
db: config.redis.db ?? 0,
port: config.redisForJobQueue.port,
host: config.redisForJobQueue.host,
family: config.redisForJobQueue.family == null ? 0 : config.redisForJobQueue.family,
password: config.redisForJobQueue.pass,
db: config.redisForJobQueue.db ?? 0,
},
prefix: config.redis.prefix ? `${config.redis.prefix}:queue` : 'queue',
prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue` : 'queue',
limiter: limitPerSec > 0 ? {
max: limitPerSec,
duration: 1000,

View File

@@ -64,8 +64,8 @@ export class RoleService implements OnApplicationShutdown {
public static NotAssignedError = class extends Error {};
constructor(
@Inject(DI.redisSubscriber)
private redisSubscriber: Redis.Redis,
@Inject(DI.redisForPubsub)
private redisForPubsub: Redis.Redis,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -84,10 +84,10 @@ export class RoleService implements OnApplicationShutdown {
) {
//this.onMessage = this.onMessage.bind(this);
this.rolesCache = new MemorySingleCache<Role[]>(Infinity);
this.roleAssignmentByUserIdCache = new MemoryKVCache<RoleAssignment[]>(Infinity);
this.rolesCache = new MemorySingleCache<Role[]>(1000 * 60 * 60 * 1);
this.roleAssignmentByUserIdCache = new MemoryKVCache<RoleAssignment[]>(1000 * 60 * 60 * 1);
this.redisSubscriber.on('message', this.onMessage);
this.redisForPubsub.on('message', this.onMessage);
}
@bindThis
@@ -400,6 +400,6 @@ export class RoleService implements OnApplicationShutdown {
@bindThis
public onApplicationShutdown(signal?: string | undefined) {
this.redisSubscriber.off('message', this.onMessage);
this.redisForPubsub.off('message', this.onMessage);
}
}

View File

@@ -13,14 +13,14 @@ export class WebhookService implements OnApplicationShutdown {
private webhooks: Webhook[] = [];
constructor(
@Inject(DI.redisSubscriber)
private redisSubscriber: Redis.Redis,
@Inject(DI.redisForPubsub)
private redisForPubsub: Redis.Redis,
@Inject(DI.webhooksRepository)
private webhooksRepository: WebhooksRepository,
) {
//this.onMessage = this.onMessage.bind(this);
this.redisSubscriber.on('message', this.onMessage);
this.redisForPubsub.on('message', this.onMessage);
}
@bindThis
@@ -82,6 +82,6 @@ export class WebhookService implements OnApplicationShutdown {
@bindThis
public onApplicationShutdown(signal?: string | undefined) {
this.redisSubscriber.off('message', this.onMessage);
this.redisForPubsub.off('message', this.onMessage);
}
}

View File

@@ -186,7 +186,7 @@ class DeliverManager {
for (const following of followers) {
const inbox = following.followerSharedInbox ?? following.followerInbox;
inboxes.set(inbox, following.followerSharedInbox === null);
inboxes.set(inbox, following.followerSharedInbox != null);
}
}

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { In, IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
@@ -22,7 +22,7 @@ import { QueueService } from '@/core/QueueService.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/index.js';
import { bindThis } from '@/decorators.js';
import type { RemoteUser } from '@/models/entities/User.js';
import { getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
import { getApHrefNullable, getApId, getApIds, getApType, getOneApHrefNullable, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
import { ApNoteService } from './models/ApNoteService.js';
import { ApLoggerService } from './ApLoggerService.js';
import { ApDbResolverService } from './ApDbResolverService.js';
@@ -31,7 +31,7 @@ import { ApAudienceService } from './ApAudienceService.js';
import { ApPersonService } from './models/ApPersonService.js';
import { ApQuestionService } from './models/ApQuestionService.js';
import type { Resolver } from './ApResolverService.js';
import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate } from './type.js';
import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove } from './type.js';
@Injectable()
export class ApInboxService {
@@ -80,7 +80,7 @@ export class ApInboxService {
) {
this.logger = this.apLoggerService.logger;
}
@bindThis
public async performActivity(actor: RemoteUser, activity: IObject) {
if (isCollectionOrOrderedCollection(activity)) {
@@ -139,6 +139,8 @@ export class ApInboxService {
await this.block(actor, activity);
} else if (isFlag(activity)) {
await this.flag(actor, activity);
} else if (isMove(activity)) {
//await this.move(actor, activity);
} else {
this.logger.warn(`unrecognized activity type: ${activity.type}`);
}
@@ -147,15 +149,15 @@ export class ApInboxService {
@bindThis
private async follow(actor: RemoteUser, activity: IFollow): Promise<string> {
const followee = await this.apDbResolverService.getUserFromApId(activity.object);
if (followee == null) {
return 'skip: followee not found';
}
if (followee.host != null) {
return 'skip: フォローしようとしているユーザーはローカルユーザーではありません';
}
await this.userFollowingService.follow(actor, followee, activity.id);
return 'ok';
}
@@ -183,16 +185,16 @@ export class ApInboxService {
const uri = activity.id ?? activity;
this.logger.info(`Accept: ${uri}`);
const resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(err => {
this.logger.error(`Resolution failed: ${err}`);
throw err;
});
if (isFollow(object)) return await this.acceptFollow(actor, object);
return `skip: Unknown Accept type: ${getApType(object)}`;
}
@@ -225,18 +227,18 @@ export class ApInboxService {
if ('actor' in activity && actor.uri !== activity.actor) {
throw new Error('invalid actor');
}
if (activity.target == null) {
throw new Error('target is null');
}
if (activity.target === actor.featured) {
const note = await this.apNoteService.resolveNote(activity.object);
if (note == null) throw new Error('note not found');
await this.notePiningService.addPinned(actor, note.id);
return;
}
throw new Error(`unknown target: ${activity.target}`);
}
@@ -405,10 +407,10 @@ export class ApInboxService {
if ('actor' in activity && actor.uri !== activity.actor) {
throw new Error('invalid actor');
}
// 削除対象objectのtype
let formerType: string | undefined;
if (typeof activity.object === 'string') {
// typeが不明だけど、どうせ消えてるのでremote resolveしない
formerType = undefined;
@@ -420,19 +422,19 @@ export class ApInboxService {
formerType = toSingle(object.type);
}
}
const uri = getApId(activity.object);
// type不明でもactorとobjectが同じならばそれはPersonに違いない
if (!formerType && actor.uri === uri) {
formerType = 'Person';
}
// それでもなかったらおそらくNote
if (!formerType) {
formerType = 'Note';
}
if (validPost.includes(formerType)) {
return await this.deleteNote(actor, uri);
} else if (validActor.includes(formerType)) {
@@ -445,44 +447,44 @@ export class ApInboxService {
@bindThis
private async deleteActor(actor: RemoteUser, uri: string): Promise<string> {
this.logger.info(`Deleting the Actor: ${uri}`);
if (actor.uri !== uri) {
return `skip: delete actor ${actor.uri} !== ${uri}`;
}
const user = await this.usersRepository.findOneBy({ id: actor.id });
if (user == null) {
return 'skip: actor not found';
} else if (user.isDeleted) {
return 'skip: already deleted';
}
const job = await this.queueService.createDeleteAccountJob(actor);
await this.usersRepository.update(actor.id, {
isDeleted: true,
});
return `ok: queued ${job.name} ${job.id}`;
}
@bindThis
private async deleteNote(actor: RemoteUser, uri: string): Promise<string> {
this.logger.info(`Deleting the Note: ${uri}`);
const unlock = await this.appLockService.getApLock(uri);
try {
const note = await this.apDbResolverService.getNoteFromApId(uri);
if (note == null) {
return 'message not found';
}
if (note.userId !== actor.id) {
return '投稿を削除しようとしているユーザーは投稿の作成者ではありません';
}
await this.noteDeleteService.delete(actor, note);
return 'ok: note deleted';
} finally {
@@ -536,23 +538,23 @@ export class ApInboxService {
@bindThis
private async rejectFollow(actor: RemoteUser, activity: IFollow): Promise<string> {
// ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある
const follower = await this.apDbResolverService.getUserFromApId(activity.actor);
if (follower == null) {
return 'skip: follower not found';
}
if (!this.userEntityService.isLocalUser(follower)) {
return 'skip: follower is not a local user';
}
// relay
const match = activity.id?.match(/follow-relay\/(\w+)/);
if (match) {
return await this.relayService.relayRejected(match[1]);
}
await this.userFollowingService.remoteReject(actor, follower);
return 'ok';
}
@@ -562,18 +564,18 @@ export class ApInboxService {
if ('actor' in activity && actor.uri !== activity.actor) {
throw new Error('invalid actor');
}
if (activity.target == null) {
throw new Error('target is null');
}
if (activity.target === actor.featured) {
const note = await this.apNoteService.resolveNote(activity.object);
if (note == null) throw new Error('note not found');
await this.notePiningService.removePinned(actor, note.id);
return;
}
throw new Error(`unknown target: ${activity.target}`);
}
@@ -582,24 +584,24 @@ export class ApInboxService {
if ('actor' in activity && actor.uri !== activity.actor) {
throw new Error('invalid actor');
}
const uri = activity.id ?? activity;
this.logger.info(`Undo: ${uri}`);
const resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
throw e;
});
if (isFollow(object)) return await this.undoFollow(actor, object);
if (isBlock(object)) return await this.undoBlock(actor, object);
if (isLike(object)) return await this.undoLike(actor, object);
if (isAnnounce(object)) return await this.undoAnnounce(actor, object);
if (isAccept(object)) return await this.undoAccept(actor, object);
return `skip: unknown object type ${getApType(object)}`;
}
@@ -609,17 +611,17 @@ export class ApInboxService {
if (follower == null) {
return 'skip: follower not found';
}
const following = await this.followingsRepository.findOneBy({
followerId: follower.id,
followeeId: actor.id,
});
if (following) {
await this.userFollowingService.unfollow(follower, actor);
return 'ok: unfollowed';
}
return 'skip: フォローされていない';
}
@@ -708,16 +710,16 @@ export class ApInboxService {
if ('actor' in activity && actor.uri !== activity.actor) {
return 'skip: invalid actor';
}
this.logger.debug('Update');
const resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
throw e;
});
if (isActor(object)) {
await this.apPersonService.updatePerson(actor.uri!, resolver, object);
return 'ok: Person updated';
@@ -728,4 +730,59 @@ export class ApInboxService {
return `skip: Unknown type: ${getApType(object)}`;
}
}
@bindThis
private async move(actor: RemoteUser, activity: IMove): Promise<string> {
// fetch the new and old accounts
const targetUri = getApHrefNullable(activity.target);
if (!targetUri) return 'skip: invalid activity target';
let new_acc = await this.apPersonService.resolvePerson(targetUri);
let old_acc = await this.apPersonService.resolvePerson(actor.uri);
// update them if they're remote
if (new_acc.uri) await this.apPersonService.updatePerson(new_acc.uri);
if (old_acc.uri) await this.apPersonService.updatePerson(old_acc.uri);
// retrieve updated users
new_acc = await this.apPersonService.resolvePerson(targetUri);
old_acc = await this.apPersonService.resolvePerson(actor.uri);
// check if alsoKnownAs of the new account is valid
let isValidMove = true;
if (old_acc.uri) {
if (!new_acc.alsoKnownAs?.includes(old_acc.uri)) {
isValidMove = false;
}
} else if (!new_acc.alsoKnownAs?.includes(old_acc.id)) {
isValidMove = false;
}
if (!isValidMove) {
return 'skip: accounts invalid';
}
// add target uri to movedToUri in order to indicate that the user has moved
await this.usersRepository.update(old_acc.id, { movedToUri: targetUri });
// follow the new account and unfollow the old one
const followings = await this.followingsRepository.find({
relations: {
follower: true,
},
where: {
followeeId: old_acc.id,
followerHost: IsNull(), // follower is local
},
});
for (const following of followings) {
if (!following.follower) continue;
try {
await this.userFollowingService.follow(following.follower, new_acc);
await this.userFollowingService.unfollow(following.follower, old_acc);
} catch {
/* empty */
}
}
return 'ok';
}
}

View File

@@ -25,7 +25,7 @@ import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { isNotNull } from '@/misc/is-not-null.js';
import { LdSignatureService } from './LdSignatureService.js';
import { ApMfmService } from './ApMfmService.js';
import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js';
import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js';
import type { IIdentifier } from './models/identifier.js';
@Injectable()
@@ -292,6 +292,22 @@ export class ApRendererService {
};
}
@bindThis
public renderMove(
src: { id: User['id']; host: User['host']; uri: User['host'] },
dst: { id: User['id']; host: User['host']; uri: User['host'] },
): IMove {
const actor = this.userEntityService.isLocalUser(src) ? `${this.config.url}/users/${src.id}` : src.uri!;
const target = this.userEntityService.isLocalUser(dst) ? `${this.config.url}/users/${dst.id}` : dst.uri!;
return {
id: `${this.config.url}/moves/${src.id}/${dst.id}`,
actor,
type: 'Move',
object: actor,
target,
};
}
@bindThis
public async renderNote(note: Note, dive = true): Promise<IPost> {
const getPromisedFiles = async (ids: string[]) => {
@@ -498,6 +514,14 @@ export class ApRendererService {
attachment: attachment.length ? attachment : undefined,
} as any;
if (user.movedToUri) {
person.movedTo = user.movedToUri;
}
if (user.alsoKnownAs) {
person.alsoKnownAs = user.alsoKnownAs;
}
if (profile.birthday) {
person['vcard:bday'] = profile.birthday;
}

View File

@@ -31,6 +31,7 @@ import type { UtilityService } from '@/core/UtilityService.js';
import type { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
import { extractApHashtags } from './tag.js';
import type { OnModuleInit } from '@nestjs/common';
@@ -49,6 +50,7 @@ const summaryLength = 2048;
export class ApPersonService implements OnModuleInit {
private utilityService: UtilityService;
private userEntityService: UserEntityService;
private driveFileEntityService: DriveFileEntityService;
private idService: IdService;
private globalEventService: GlobalEventService;
private metaService: MetaService;
@@ -113,6 +115,7 @@ export class ApPersonService implements OnModuleInit {
onModuleInit() {
this.utilityService = this.moduleRef.get('UtilityService');
this.userEntityService = this.moduleRef.get('UserEntityService');
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
this.idService = this.moduleRef.get('IdService');
this.globalEventService = this.moduleRef.get('GlobalEventService');
this.metaService = this.moduleRef.get('MetaService');
@@ -278,6 +281,8 @@ export class ApPersonService implements OnModuleInit {
lastFetchedAt: new Date(),
name: truncate(person.name, nameLength),
isLocked: !!person.manuallyApprovesFollowers,
movedToUri: person.movedTo,
alsoKnownAs: person.alsoKnownAs,
isExplorable: !!person.discoverable,
username: person.preferredUsername,
usernameLower: person.preferredUsername!.toLowerCase(),
@@ -356,32 +361,44 @@ export class ApPersonService implements OnModuleInit {
const avatarId = avatar ? avatar.id : null;
const bannerId = banner ? banner.id : null;
const avatarUrl = avatar ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null;
const bannerUrl = banner ? this.driveFileEntityService.getPublicUrl(banner) : null;
const avatarBlurhash = avatar ? avatar.blurhash : null;
const bannerBlurhash = banner ? banner.blurhash : null;
await this.usersRepository.update(user!.id, {
avatarId,
bannerId,
avatarUrl,
bannerUrl,
avatarBlurhash,
bannerBlurhash,
});
user!.avatarId = avatarId;
user!.bannerId = bannerId;
//#endregion
user!.avatarId = avatarId;
user!.bannerId = bannerId;
user!.avatarUrl = avatarUrl;
user!.bannerUrl = bannerUrl;
user!.avatarBlurhash = avatarBlurhash;
user!.bannerBlurhash = bannerBlurhash;
//#endregion
//#region カスタム絵文字取得
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => {
this.logger.info(`extractEmojis: ${err}`);
return [] as Emoji[];
});
//#region カスタム絵文字取得
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => {
this.logger.info(`extractEmojis: ${err}`);
return [] as Emoji[];
});
const emojiNames = emojis.map(emoji => emoji.name);
const emojiNames = emojis.map(emoji => emoji.name);
await this.usersRepository.update(user!.id, {
emojis: emojiNames,
});
//#endregion
await this.usersRepository.update(user!.id, {
emojis: emojiNames,
});
//#endregion
await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err));
await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err));
return user!;
return user!;
}
/**
@@ -458,15 +475,21 @@ export class ApPersonService implements OnModuleInit {
isBot: getApType(object) === 'Service',
isCat: (person as any).isCat === true,
isLocked: !!person.manuallyApprovesFollowers,
movedToUri: person.movedTo ?? null,
alsoKnownAs: person.alsoKnownAs ?? null,
isExplorable: !!person.discoverable,
} as Partial<User>;
if (avatar) {
updates.avatarId = avatar.id;
updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
updates.avatarBlurhash = avatar.blurhash;
}
if (banner) {
updates.bannerId = banner.id;
updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
updates.bannerBlurhash = banner.blurhash;
}
// Update user

View File

@@ -157,6 +157,8 @@ export interface IActor extends IObject {
name?: string;
preferredUsername?: string;
manuallyApprovesFollowers?: boolean;
movedTo?: string;
alsoKnownAs?: string[];
discoverable?: boolean;
inbox: string;
sharedInbox?: string; // 後方互換性のため
@@ -300,6 +302,11 @@ export interface IFlag extends IActivity {
type: 'Flag';
}
export interface IMove extends IActivity {
type: 'Move';
target: IObject | string;
}
export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create';
export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete';
export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update';
@@ -314,3 +321,4 @@ export const isLike = (object: IObject): object is ILike => getApType(object) ==
export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce';
export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block';
export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag';
export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move';

View File

@@ -266,6 +266,7 @@ export class DriveFileEntityService {
fileIds: DriveFile['id'][],
options?: PackOptions,
): Promise<Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>> {
if (fileIds.length === 0) return new Map();
const files = await this.driveFilesRepository.findBy({ id: In(fileIds) });
const packedFiles = await this.packMany(files, options);
const map = new Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>(packedFiles.map(f => [f.id, f]));
@@ -280,6 +281,7 @@ export class DriveFileEntityService {
fileIds: DriveFile['id'][],
options?: PackOptions,
): Promise<Packed<'DriveFile'>[]> {
if (fileIds.length === 0) return [];
const filesMap = await this.packManyByIdsMap(fileIds, options);
return fileIds.map(id => filesMap.get(id)).filter(isNotNull);
}

View File

@@ -183,6 +183,11 @@ export class NoteEntityService implements OnModuleInit {
// 実装上抜けがあるだけかもしれないので、「ヒントに含まれてなかったら(=undefinedなら)return」のようにはしない
}
// パフォーマンスのためートが作成されてから1秒以上経っていない場合はリアクションを取得しない
if (note.createdAt.getTime() + 1000 > Date.now()) {
return undefined;
}
const reaction = await this.noteReactionsRepository.findOneBy({
userId: meId,
noteId: note.id,
@@ -283,7 +288,7 @@ export class NoteEntityService implements OnModuleInit {
}, options);
const meId = me ? me.id : null;
const note = typeof src === 'object' ? src : await this.notesRepository.findOneByOrFail({ id: src });
const note = typeof src === 'object' ? src : await this.notesRepository.findOneOrFail({ where: { id: src }, relations: ['user'] });
const host = note.userHost;
let text = note.text;
@@ -395,7 +400,8 @@ export class NoteEntityService implements OnModuleInit {
const myReactionsMap = new Map<Note['id'], NoteReaction | null>();
if (meId) {
const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!);
const targets = [...notes.map(n => n.id), ...renoteIds];
// パフォーマンスのためートが作成されてから1秒以上経っていない場合はリアクションを取得しない
const targets = [...notes.filter(n => n.createdAt.getTime() + 1000 < Date.now()).map(n => n.id), ...renoteIds];
const myReactions = await this.noteReactionsRepository.findBy({
userId: meId,
noteId: In(targets),
@@ -409,7 +415,7 @@ export class NoteEntityService implements OnModuleInit {
await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes));
// TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく
const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(isNotNull);
const packedFiles = await this.driveFileEntityService.packManyByIdsMap(fileIds);
const packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map();
return await Promise.all(notes.map(n => this.pack(n, me, {
...options,

View File

@@ -108,27 +108,30 @@ export class NotificationEntityService implements OnModuleInit {
) {
if (notifications.length === 0) return [];
const noteIds = notifications.map(x => x.noteId).filter(isNotNull);
let validNotifications = notifications;
const noteIds = validNotifications.map(x => x.noteId).filter(isNotNull);
const notes = noteIds.length > 0 ? await this.notesRepository.find({
where: { id: In(noteIds) },
relations: ['user', 'user.avatar', 'user.banner', 'reply', 'reply.user', 'reply.user.avatar', 'reply.user.banner', 'renote', 'renote.user', 'renote.user.avatar', 'renote.user.banner'],
relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'],
}) : [];
const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, {
detail: true,
});
const packedNotes = new Map(packedNotesArray.map(p => [p.id, p]));
const userIds = notifications.map(x => x.notifierId).filter(isNotNull);
validNotifications = validNotifications.filter(x => x.noteId == null || packedNotes.has(x.noteId));
const userIds = validNotifications.map(x => x.notifierId).filter(isNotNull);
const users = userIds.length > 0 ? await this.usersRepository.find({
where: { id: In(userIds) },
relations: ['avatar', 'banner'],
}) : [];
const packedUsersArray = await this.userEntityService.packMany(users, { id: meId }, {
detail: false,
});
const packedUsers = new Map(packedUsersArray.map(p => [p.id, p]));
return await Promise.all(notifications.map(x => this.pack(x, meId, {}, {
return await Promise.all(validNotifications.map(x => this.pack(x, meId, {}, {
packedNotes,
packedUsers,
})));

View File

@@ -9,13 +9,14 @@ import type { Packed } from '@/misc/json-schema.js';
import type { Promiseable } from '@/misc/prelude/await-all.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
import { MemoryKVCache } from '@/misc/cache.js';
import type { Instance } from '@/models/entities/Instance.js';
import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js';
import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import type { OnModuleInit } from '@nestjs/common';
import type { AntennaService } from '../AntennaService.js';
import type { CustomEmojiService } from '../CustomEmojiService.js';
@@ -25,7 +26,7 @@ import type { PageEntityService } from './PageEntityService.js';
type IsUserDetailed<Detailed extends boolean> = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>;
type IsMeAndIsUserDetailed<ExpectsMe extends boolean | null, Detailed extends boolean> =
Detailed extends true ?
Detailed extends true ?
ExpectsMe extends true ? Packed<'MeDetailed'> :
ExpectsMe extends false ? Packed<'UserDetailedNotMe'> :
Packed<'UserDetailed'> :
@@ -47,13 +48,14 @@ function isRemoteUser(user: User | { host: User['host'] }): boolean {
@Injectable()
export class UserEntityService implements OnModuleInit {
private apPersonService: ApPersonService;
private noteEntityService: NoteEntityService;
private driveFileEntityService: DriveFileEntityService;
private pageEntityService: PageEntityService;
private customEmojiService: CustomEmojiService;
private antennaService: AntennaService;
private roleService: RoleService;
private userInstanceCache: MemoryKVCache<Instance | null>;
private federatedInstanceService: FederatedInstanceService;
constructor(
private moduleRef: ModuleRef,
@@ -119,16 +121,17 @@ export class UserEntityService implements OnModuleInit {
//private antennaService: AntennaService,
//private roleService: RoleService,
) {
this.userInstanceCache = new MemoryKVCache<Instance | null>(1000 * 60 * 60 * 3);
}
onModuleInit() {
this.apPersonService = this.moduleRef.get('ApPersonService');
this.noteEntityService = this.moduleRef.get('NoteEntityService');
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
this.pageEntityService = this.moduleRef.get('PageEntityService');
this.customEmojiService = this.moduleRef.get('CustomEmojiService');
this.antennaService = this.moduleRef.get('AntennaService');
this.roleService = this.moduleRef.get('RoleService');
this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService');
}
//#region Validators
@@ -237,7 +240,7 @@ export class UserEntityService implements OnModuleInit {
@bindThis
public async getHasUnreadNotification(userId: User['id']): Promise<boolean> {
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`);
const latestNotificationIdsRes = await this.redisClient.xrevrange(
`notificationTimeline:${userId}`,
'+',
@@ -269,27 +272,6 @@ export class UserEntityService implements OnModuleInit {
);
}
@bindThis
public async getAvatarUrl(user: User): Promise<string> {
if (user.avatar) {
return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user);
} else if (user.avatarId) {
const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId });
return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user);
} else {
return this.getIdenticonUrl(user);
}
}
@bindThis
public getAvatarUrlSync(user: User): string {
if (user.avatar) {
return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user);
} else {
return this.getIdenticonUrl(user);
}
}
@bindThis
public getIdenticonUrl(user: User): string {
return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`;
@@ -309,19 +291,23 @@ export class UserEntityService implements OnModuleInit {
includeSecrets: false,
}, options);
let user: User;
const user = typeof src === 'object' ? src : await this.usersRepository.findOneByOrFail({ id: src });
if (typeof src === 'object') {
user = src;
if (src.avatar === undefined && src.avatarId) src.avatar = await this.driveFilesRepository.findOneBy({ id: src.avatarId }) ?? null;
if (src.banner === undefined && src.bannerId) src.banner = await this.driveFilesRepository.findOneBy({ id: src.bannerId }) ?? null;
} else {
user = await this.usersRepository.findOneOrFail({
where: { id: src },
relations: {
avatar: true,
banner: true,
},
// migration
if (user.avatarId != null && user.avatarUrl === null) {
const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId });
user.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
this.usersRepository.update(user.id, {
avatarUrl: user.avatarUrl,
avatarBlurhash: avatar.blurhash,
});
}
if (user.bannerId != null && user.bannerUrl === null) {
const banner = await this.driveFilesRepository.findOneByOrFail({ id: user.bannerId });
user.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
this.usersRepository.update(user.id, {
bannerUrl: user.bannerUrl,
bannerBlurhash: banner.blurhash,
});
}
@@ -356,14 +342,11 @@ export class UserEntityService implements OnModuleInit {
name: user.name,
username: user.username,
host: user.host,
avatarUrl: this.getAvatarUrlSync(user),
avatarBlurhash: user.avatar?.blurhash ?? null,
avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user),
avatarBlurhash: user.avatarBlurhash,
isBot: user.isBot ?? falsy,
isCat: user.isCat ?? falsy,
instance: user.host ? this.userInstanceCache.fetch(user.host,
() => this.instancesRepository.findOneBy({ host: user.host! }),
v => v != null,
).then(instance => instance ? {
instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? {
name: instance.name,
softwareName: instance.softwareName,
softwareVersion: instance.softwareVersion,
@@ -383,11 +366,13 @@ export class UserEntityService implements OnModuleInit {
...(opts.detail ? {
url: profile!.url,
uri: user.uri,
movedToUri: user.movedToUri ? await this.apPersonService.resolvePerson(user.movedToUri) : null,
alsoKnownAs: user.alsoKnownAs,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null,
bannerUrl: user.banner ? this.driveFileEntityService.getPublicUrl(user.banner) : null,
bannerBlurhash: user.banner?.blurhash ?? null,
bannerUrl: user.bannerUrl,
bannerBlurhash: user.bannerBlurhash,
isLocked: user.isLocked,
isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
isSuspended: user.isSuspended ?? falsy,

View File

@@ -2,7 +2,7 @@ export const DI = {
config: Symbol('config'),
db: Symbol('db'),
redis: Symbol('redis'),
redisSubscriber: Symbol('redisSubscriber'),
redisForPubsub: Symbol('redisForPubsub'),
//#region Repositories
usersRepository: Symbol('usersRepository'),

View File

@@ -8,7 +8,7 @@ export class RedisKVCache<T> {
private memoryCache: MemoryKVCache<T>;
private fetcher: (key: string) => Promise<T>;
private toRedisConverter: (value: T) => string;
private fromRedisConverter: (value: string) => T;
private fromRedisConverter: (value: string) => T | undefined; // undefined means no cache
constructor(redisClient: RedisKVCache<T>['redisClient'], name: RedisKVCache<T>['name'], opts: {
lifetime: RedisKVCache<T>['lifetime'];
@@ -92,7 +92,7 @@ export class RedisSingleCache<T> {
private memoryCache: MemorySingleCache<T>;
private fetcher: () => Promise<T>;
private toRedisConverter: (value: T) => string;
private fromRedisConverter: (value: string) => T;
private fromRedisConverter: (value: string) => T | undefined; // undefined means no cache
constructor(redisClient: RedisSingleCache<T>['redisClient'], name: RedisSingleCache<T>['name'], opts: {
lifetime: RedisSingleCache<T>['lifetime'];

View File

@@ -3,6 +3,8 @@
import * as crypto from 'node:crypto';
export const aidRegExp = /^[0-9a-z]{10}$/;
const TIME2000 = 946684800000;
let counter = crypto.randomBytes(2).readUInt16LE(0);

View File

@@ -1,5 +1,8 @@
const CHARS = '0123456789abcdef';
// same as object-id
export const meidRegExp = /^[0-9a-f]{24}$/;
function getTime(time: number) {
if (time < 0) time = 0;
if (time === 0) {
@@ -24,3 +27,9 @@ function getRandom() {
export function genMeid(date: Date): string {
return getTime(date.getTime()) + getRandom();
}
export function parseMeid(id: string): { date: Date; } {
return {
date: new Date(parseInt(id.slice(0, 12), 16) - 0x800000000000),
};
}

View File

@@ -3,6 +3,7 @@ const CHARS = '0123456789abcdef';
// 4bit Fixed hex value 'g'
// 44bit UNIX Time ms in Hex
// 48bit Random value in Hex
export const meidgRegExp = /^g[0-9a-f]{23}$/;
function getTime(time: number) {
if (time < 0) time = 0;
@@ -26,3 +27,9 @@ function getRandom() {
export function genMeidg(date: Date): string {
return 'g' + getTime(date.getTime()) + getRandom();
}
export function parseMeidg(id: string): { date: Date; } {
return {
date: new Date(parseInt(id.slice(1, 12), 16)),
};
}

View File

@@ -1,5 +1,8 @@
const CHARS = '0123456789abcdef';
// same as meid
export const objectIdRegExp = /^[0-9a-f]{24}$/;
function getTime(time: number) {
if (time < 0) time = 0;
if (time === 0) {
@@ -24,3 +27,9 @@ function getRandom() {
export function genObjectId(date: Date): string {
return getTime(date.getTime()) + getRandom();
}
export function parseObjectId(id: string): { date: Date; } {
return {
date: new Date(parseInt(id.slice(0, 8), 16) * 1000),
};
}

View File

@@ -0,0 +1,14 @@
// Crockford's Base32
// https://github.com/ulid/spec#encoding
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) };
}

View File

@@ -68,6 +68,19 @@ export class User {
})
public followingCount: number;
@Column('varchar', {
length: 512,
nullable: true,
comment: 'The URI of the new account of the User',
})
public movedToUri: string | null;
@Column('simple-array', {
nullable: true,
comment: 'URIs the user is known as too',
})
public alsoKnownAs: string[] | null;
@Column('integer', {
default: 0,
comment: 'The count of notes.',
@@ -100,6 +113,26 @@ export class User {
@JoinColumn()
public banner: DriveFile | null;
@Column('varchar', {
length: 512, nullable: true,
})
public avatarUrl: string | null;
@Column('varchar', {
length: 512, nullable: true,
})
public bannerUrl: string | null;
@Column('varchar', {
length: 128, nullable: true,
})
public avatarBlurhash: string | null;
@Column('varchar', {
length: 128, nullable: true,
})
public bannerBlurhash: string | null;
@Index()
@Column('varchar', {
length: 128, array: true, default: '{}',

View File

@@ -72,6 +72,18 @@ export const packedUserDetailedNotMeOnlySchema = {
format: 'uri',
nullable: true, optional: false,
},
movedToUri: {
type: 'string',
format: 'uri',
nullable: true,
optional: false,
},
alsoKnownAs: {
type: 'array',
format: 'uri',
nullable: true,
optional: false,
},
createdAt: {
type: 'string',
nullable: false, optional: false,

View File

@@ -200,6 +200,22 @@ export function createPostgresDataSource(config: Config) {
statement_timeout: 1000 * 10,
...config.db.extra,
},
replication: config.dbReplications ? {
master: {
host: config.db.host,
port: config.db.port,
username: config.db.user,
password: config.db.pass,
database: config.db.db,
},
slaves: config.dbSlaves!.map(rep => ({
host: rep.host,
port: rep.port,
username: rep.user,
password: rep.pass,
database: rep.db,
})),
} : undefined,
synchronize: process.env.NODE_ENV === 'test',
dropSchema: process.env.NODE_ENV === 'test',
cache: !config.db.disableCache && process.env.NODE_ENV !== 'test' ? { // dbをcloseしても何故かredisのコネクションが内部的に残り続けるようで、テストの際に支障が出るため無効にする(キャッシュも含めてテストしたいため本当は有効にしたいが...)

View File

@@ -1,13 +0,0 @@
import Redis from 'ioredis';
import { Config } from '@/config.js';
export function createRedisConnection(config: Config): Redis.Redis {
return new Redis({
port: config.redis.port,
host: config.redis.host,
family: config.redis.family == null ? 0 : config.redis.family,
password: config.redis.pass,
keyPrefix: `${config.redis.prefix}:`,
db: config.redis.db ?? 0,
});
}

View File

@@ -149,13 +149,12 @@ export class ServerService implements OnApplicationShutdown {
host: (host == null) || (host === this.config.host) ? IsNull() : host,
isSuspended: false,
},
relations: ['avatar'],
});
reply.header('Cache-Control', 'public, max-age=86400');
if (user) {
reply.redirect(this.userEntityService.getAvatarUrlSync(user));
reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user));
} else {
reply.redirect('/static-assets/user-unknown.png');
}

View File

@@ -220,6 +220,8 @@ import * as ep___i_signinHistory from './endpoints/i/signin-history.js';
import * as ep___i_unpin from './endpoints/i/unpin.js';
import * as ep___i_updateEmail from './endpoints/i/update-email.js';
import * as ep___i_update from './endpoints/i/update.js';
import * as ep___i_move from './endpoints/i/move.js';
import * as ep___i_knownAs from './endpoints/i/known-as.js';
import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js';
import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
@@ -551,6 +553,8 @@ const $i_signinHistory: Provider = { provide: 'ep:i/signin-history', useClass: e
const $i_unpin: Provider = { provide: 'ep:i/unpin', useClass: ep___i_unpin.default };
const $i_updateEmail: Provider = { provide: 'ep:i/update-email', useClass: ep___i_updateEmail.default };
const $i_update: Provider = { provide: 'ep:i/update', useClass: ep___i_update.default };
const $i_move: Provider = { provide: 'ep:i/move', useClass: ep___i_move.default };
const $i_knownAs: Provider = { provide: 'ep:i/known-as', useClass: ep___i_knownAs.default };
const $i_webhooks_create: Provider = { provide: 'ep:i/webhooks/create', useClass: ep___i_webhooks_create.default };
const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep___i_webhooks_list.default };
const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default };
@@ -886,6 +890,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_unpin,
$i_updateEmail,
$i_update,
$i_move,
$i_knownAs,
$i_webhooks_create,
$i_webhooks_list,
$i_webhooks_show,
@@ -1215,6 +1221,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_unpin,
$i_updateEmail,
$i_update,
$i_move,
$i_knownAs,
$i_webhooks_create,
$i_webhooks_list,
$i_webhooks_show,

View File

@@ -22,8 +22,8 @@ export class StreamingApiServerService {
@Inject(DI.config)
private config: Config,
@Inject(DI.redisSubscriber)
private redisSubscriber: Redis.Redis,
@Inject(DI.redisForPubsub)
private redisForPubsub: Redis.Redis,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -81,7 +81,7 @@ export class StreamingApiServerService {
ev.emit(parsed.channel, parsed.message);
}
this.redisSubscriber.on('message', onRedisMessage);
this.redisForPubsub.on('message', onRedisMessage);
const main = new MainStreamConnection(
this.channelsService,
@@ -111,7 +111,7 @@ export class StreamingApiServerService {
connection.once('close', () => {
ev.removeAllListeners();
main.dispose();
this.redisSubscriber.off('message', onRedisMessage);
this.redisForPubsub.off('message', onRedisMessage);
if (intervalId) clearInterval(intervalId);
});

View File

@@ -220,6 +220,8 @@ import * as ep___i_signinHistory from './endpoints/i/signin-history.js';
import * as ep___i_unpin from './endpoints/i/unpin.js';
import * as ep___i_updateEmail from './endpoints/i/update-email.js';
import * as ep___i_update from './endpoints/i/update.js';
import * as ep___i_move from './endpoints/i/move.js';
import * as ep___i_knownAs from './endpoints/i/known-as.js';
import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js';
import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
@@ -549,6 +551,8 @@ const eps = [
['i/unpin', ep___i_unpin],
['i/update-email', ep___i_updateEmail],
['i/update', ep___i_update],
//['i/move', ep___i_move],
//['i/known-as', ep___i_knownAs],
['i/webhooks/create', ep___i_webhooks_create],
['i/webhooks/list', ep___i_webhooks_list],
['i/webhooks/show', ep___i_webhooks_show],

View File

@@ -95,16 +95,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);

View File

@@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { ChannelsRepository, NotesRepository } from '@/models/index.js';
import type { ChannelsRepository, Note, NotesRepository } from '@/models/index.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
@@ -73,48 +73,67 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.noSuchChannel);
}
const noteIdsRes = await this.redisClient.xrevrange(
`channelTimeline:${channel.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
'-',
'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1
let timeline: Note[] = [];
if (noteIdsRes.length === 0) {
return [];
const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1
let noteIdsRes: [string, string[]][] = [];
if (!ps.sinceId && !ps.sinceDate) {
noteIdsRes = await this.redisClient.xrevrange(
`channelTimeline:${channel.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
'-',
'COUNT', limit);
}
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
// redis から取得していないとき・取得数が足りないとき
if (noteIdsRes.length < limit) {
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.channelId = :channelId', { channelId: channel.id })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
if (noteIds.length === 0) {
return [];
if (me) {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
}
//#endregion
timeline = await query.take(ps.limit).getMany();
} else {
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId);
if (noteIds.length === 0) {
return [];
}
//#region Construct query
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
if (me) {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
}
//#endregion
timeline = await query.getMany();
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
}
//#region Construct query
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
.leftJoinAndSelect('note.channel', 'channel');
if (me) {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
}
//#endregion
const timeline = await query.getMany();
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
if (me) this.activeUsersChart.read(me);
return await this.noteEntityService.packMany(timeline, me);

View File

@@ -75,16 +75,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.innerJoin(this.clipNotesRepository.metadata.targetName, 'clipNote', 'clipNote.noteId = note.id')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
.andWhere('clipNote.clipId = :clipId', { clipId: clip.id });
if (me) {

View File

@@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import ms from 'ms';
import { User } from '@/models/entities/User.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ApiError } from '@/server/api/error.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApiLoggerService } from '@/server/api/ApiLoggerService.js';
export const meta = {
tags: ['users'],
secure: true,
requireCredential: true,
limit: {
duration: ms('1day'),
max: 30,
},
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5',
},
notRemote: {
message: 'User is not remote. You can only migrate from other instances.',
code: 'NOT_REMOTE',
id: '4362f8dc-731f-4ad8-a694-be2a88922a24',
},
uriNull: {
message: 'User ActivityPup URI is null.',
code: 'URI_NULL',
id: 'bf326f31-d430-4f97-9933-5d61e4d48a23',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
alsoKnownAs: { type: 'string' },
},
required: ['alsoKnownAs'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
private userEntityService: UserEntityService,
private remoteUserResolveService: RemoteUserResolveService,
private apiLoggerService: ApiLoggerService,
private accountMoveService: AccountMoveService,
) {
super(meta, paramDef, async (ps, me) => {
// Check parameter
if (!ps.alsoKnownAs) throw new ApiError(meta.errors.noSuchUser);
let unfiltered = ps.alsoKnownAs;
const updates = {} as Partial<User>;
if (!unfiltered) {
updates.alsoKnownAs = null;
} else {
// Parse user's input into the old account
if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5);
if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1);
if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote);
const userAddress = unfiltered.split('@');
// Retrieve the old account
const knownAs = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => {
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`);
throw new ApiError(meta.errors.noSuchUser);
});
const toUrl: string | null = knownAs.uri;
if (!toUrl) throw new ApiError(meta.errors.uriNull);
// Only allow moving from a remote account
if (this.userEntityService.isLocalUser(knownAs)) throw new ApiError(meta.errors.notRemote);
updates.alsoKnownAs = updates.alsoKnownAs?.concat([toUrl]) ?? [toUrl];
}
return await this.accountMoveService.createAlias(me, updates);
});
}
}

View File

@@ -0,0 +1,140 @@
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ApiError } from '@/server/api/error.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApiLoggerService } from '@/server/api/ApiLoggerService.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
export const meta = {
tags: ['users'],
secure: true,
requireCredential: true,
limit: {
duration: ms('1day'),
max: 5,
},
errors: {
noSuchMoveTarget: {
message: 'No such move target.',
code: 'NO_SUCH_MOVE_TARGET',
id: 'b5c90186-4ab0-49c8-9bba-a1f76c202ba4',
},
remoteAccountForbids: {
message:
'Remote account doesn\'t have proper \'Known As\' alias. Did you remember to set it?',
code: 'REMOTE_ACCOUNT_FORBIDS',
id: 'b5c90186-4ab0-49c8-9bba-a1f766282ba4',
},
notRemote: {
message: 'User is not remote. You can only migrate to other instances.',
code: 'NOT_REMOTE',
id: '4362f8dc-731f-4ad8-a694-be2a88922a24',
},
rootForbidden: {
message: 'The root can\'t migrate.',
code: 'NOT_ROOT_FORBIDDEN',
id: '4362e8dc-731f-4ad8-a694-be2a88922a24',
},
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5',
},
uriNull: {
message: 'User ActivityPup URI is null.',
code: 'URI_NULL',
id: 'bf326f31-d430-4f97-9933-5d61e4d48a23',
},
localUriNull: {
message: 'Local User ActivityPup URI is null.',
code: 'URI_NULL',
id: '95ba11b9-90e8-43a5-ba16-7acc1ab32e71',
},
alreadyMoved: {
message: 'Account was already moved to another account.',
code: 'ALREADY_MOVED',
id: 'b234a14e-9ebe-4581-8000-074b3c215962',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
moveToAccount: { type: 'string' },
},
required: ['moveToAccount'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.config)
private config: Config,
private userEntityService: UserEntityService,
private remoteUserResolveService: RemoteUserResolveService,
private apiLoggerService: ApiLoggerService,
private accountMoveService: AccountMoveService,
private getterService: GetterService,
private apPersonService: ApPersonService,
) {
super(meta, paramDef, async (ps, me) => {
// check parameter
if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchMoveTarget);
// abort if user is the root
if (me.isRoot) throw new ApiError(meta.errors.rootForbidden);
// abort if user has already moved
if (me.movedToUri) throw new ApiError(meta.errors.alreadyMoved);
let unfiltered = ps.moveToAccount;
if (!unfiltered) throw new ApiError(meta.errors.noSuchMoveTarget);
// parse user's input into the destination account
if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5);
if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1);
if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote);
const userAddress = unfiltered.split('@');
// retrieve the destination account
let moveTo = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => {
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`);
throw new ApiError(meta.errors.noSuchMoveTarget);
});
const remoteMoveTo = await this.getterService.getRemoteUser(moveTo.id);
if (!remoteMoveTo.uri) throw new ApiError(meta.errors.uriNull);
// update local db
await this.apPersonService.updatePerson(remoteMoveTo.uri);
// retrieve updated user
moveTo = await this.apPersonService.resolvePerson(remoteMoveTo.uri);
// only allow moving to a remote account
if (this.userEntityService.isLocalUser(moveTo)) throw new ApiError(meta.errors.notRemote);
let allowed = false;
const fromUrl = `${this.config.url}/users/${me.id}`;
// make sure that the user has indicated the old account as an alias
moveTo.alsoKnownAs?.forEach((elem) => {
if (fromUrl.includes(elem)) allowed = true;
});
// abort if unintended
if (!(allowed && moveTo.uri && fromUrl)) throw new ApiError(meta.errors.remoteAccountForbids);
return await this.accountMoveService.moveToRemote(me, moveTo);
});
}
}

View File

@@ -19,6 +19,7 @@ import { HashtagService } from '@/core/HashtagService.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { CacheService } from '@/core/CacheService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import { ApiError } from '../../error.js';
export const meta = {
@@ -148,6 +149,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private pagesRepository: PagesRepository,
private userEntityService: UserEntityService,
private driveFileEntityService: DriveFileEntityService,
private globalEventService: GlobalEventService,
private userFollowingService: UserFollowingService,
private accountUpdateService: AccountUpdateService,
@@ -170,8 +172,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (ps.location !== undefined) profileUpdates.location = ps.location;
if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday;
if (ps.ffVisibility !== undefined) profileUpdates.ffVisibility = ps.ffVisibility;
if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId;
if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId;
if (ps.mutedWords !== undefined) {
// TODO: ちゃんと数える
const length = JSON.stringify(ps.mutedWords).length;
@@ -217,6 +217,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar);
if (!avatar.type.startsWith('image/')) throw new ApiError(meta.errors.avatarNotAnImage);
updates.avatarId = avatar.id;
updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar');
updates.avatarBlurhash = avatar.blurhash;
}
if (ps.bannerId) {
@@ -224,6 +228,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner);
if (!banner.type.startsWith('image/')) throw new ApiError(meta.errors.bannerNotAnImage);
updates.bannerId = banner.id;
updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner);
updates.bannerBlurhash = banner.blurhash;
}
if (ps.pinnedPageId) {

View File

@@ -49,16 +49,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.andWhere('note.visibility = \'public\'')
.andWhere('note.localOnly = FALSE')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
if (ps.local) {
query.andWhere('note.userHost IS NULL');

View File

@@ -57,16 +57,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
}));
}))
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
if (me) {

View File

@@ -53,16 +53,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.andWhere('note.createdAt > :date', { date: new Date(Date.now() - day) })
.andWhere('note.visibility = \'public\'')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
if (ps.channelId) query.andWhere('note.channelId = :channelId', { channelId: ps.channelId });

View File

@@ -73,16 +73,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.andWhere('note.visibility = \'public\'')
.andWhere('note.channelId IS NULL')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateRepliesQuery(query, me);
if (me) {

View File

@@ -91,16 +91,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
}))
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
.setParameters(followingQuery.getParameters());
this.queryService.generateChannelQuery(query, me);

View File

@@ -80,16 +80,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateChannelQuery(query, me);
this.queryService.generateRepliesQuery(query, me);

View File

@@ -60,16 +60,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`);
}))
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);

View File

@@ -75,7 +75,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
order: {
id: -1,
},
relations: ['user', 'user.avatar', 'user.banner', 'note'],
relations: ['user', 'note'],
});
return await Promise.all(reactions.map(reaction => this.noteReactionEntityService.pack(reaction, me)));

View File

@@ -4,8 +4,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['notes'],
@@ -62,16 +62,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere('note.renoteId = :renoteId', { renoteId: note.id })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
if (me) this.queryService.generateMutedUserQuery(query, me);

View File

@@ -46,16 +46,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere('note.replyId = :replyId', { replyId: ps.noteId })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
if (me) this.queryService.generateMutedUserQuery(query, me);

View File

@@ -71,16 +71,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
if (me) this.queryService.generateMutedUserQuery(query, me);

View File

@@ -85,16 +85,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
query
.andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
if (me) this.queryService.generateMutedUserQuery(query, me);

View File

@@ -70,16 +70,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
if (followees.length > 0) {
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];

View File

@@ -84,16 +84,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.innerJoin(this.userListJoiningsRepository.metadata.targetName, 'userListJoining', 'userListJoining.userId = note.userId')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner')
.andWhere('userListJoining.userListId = :userListId', { userListId: list.id });
this.queryService.generateVisibilityQuery(query, me);

View File

@@ -74,16 +74,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.userId = :userId', { userId: user.id })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
if (me) {

View File

@@ -419,7 +419,7 @@ export class ClientServerService {
reply.header('Cache-Control', 'public, max-age=15');
return await reply.view('user', {
user, profile, me,
avatarUrl: await this.userEntityService.getAvatarUrl(user),
avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
sub: request.params.sub,
instanceName: meta.name ?? 'Misskey',
icon: meta.iconUrl,
@@ -464,7 +464,7 @@ export class ClientServerService {
return await reply.view('note', {
note: _note,
profile,
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: note.userId })),
avatarUrl: _note.user.avatarUrl,
// TODO: Let locale changeable by instance setting
summary: getNoteSummary(_note),
instanceName: meta.name ?? 'Misskey',
@@ -503,7 +503,7 @@ export class ClientServerService {
return await reply.view('page', {
page: _page,
profile,
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: page.userId })),
avatarUrl: _page.user.avatarUrl,
instanceName: meta.name ?? 'Misskey',
icon: meta.iconUrl,
themeColor: meta.themeColor,
@@ -527,7 +527,7 @@ export class ClientServerService {
return await reply.view('flash', {
flash: _flash,
profile,
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: flash.userId })),
avatarUrl: _flash.user.avatarUrl,
instanceName: meta.name ?? 'Misskey',
icon: meta.iconUrl,
themeColor: meta.themeColor,
@@ -551,7 +551,7 @@ export class ClientServerService {
return await reply.view('clip', {
clip: _clip,
profile,
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: clip.userId })),
avatarUrl: _clip.user.avatarUrl,
instanceName: meta.name ?? 'Misskey',
icon: meta.iconUrl,
themeColor: meta.themeColor,
@@ -573,7 +573,7 @@ export class ClientServerService {
return await reply.view('gallery-post', {
post: _post,
profile,
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: post.userId })),
avatarUrl: _post.user.avatarUrl,
instanceName: meta.name ?? 'Misskey',
icon: meta.iconUrl,
themeColor: meta.themeColor,

View File

@@ -58,7 +58,7 @@ export class FeedService {
generator: 'Misskey',
description: `${user.notesCount} Notes, ${profile.ffVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.ffVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`,
link: author.link,
image: await this.userEntityService.getAvatarUrl(user),
image: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user),
feedLinks: {
json: `${author.link}.json`,
atom: `${author.link}.atom`,

View File

@@ -2,7 +2,7 @@ version: "3"
services:
redistest:
image: redis:6
image: redis:7
ports:
- "127.0.0.1:56312:6379"

View File

@@ -172,6 +172,7 @@ describe('Streaming', () => {
assert.strictEqual(fired, true);
});
/* TODO
test('リモートユーザーの投稿は流れない', async () => {
const fired = await waitFire(
ayano, 'localTimeline', // ayano:Local
@@ -191,6 +192,7 @@ describe('Streaming', () => {
assert.strictEqual(fired, false);
});
*/
test('ホーム指定の投稿は流れない', async () => {
const fired = await waitFire(
@@ -244,6 +246,7 @@ describe('Streaming', () => {
assert.strictEqual(fired, true);
});
/* TODO
test('フォローしているリモートユーザーの投稿が流れる', async () => {
const fired = await waitFire(
ayano, 'hybridTimeline', // ayano:Hybrid
@@ -263,6 +266,7 @@ describe('Streaming', () => {
assert.strictEqual(fired, false);
});
*/
test('フォローしているユーザーのダイレクト投稿が流れる', async () => {
const fired = await waitFire(
@@ -316,6 +320,7 @@ describe('Streaming', () => {
assert.strictEqual(fired, true);
});
/* TODO
test('フォローしていないリモートユーザーの投稿が流れる', async () => {
const fired = await waitFire(
ayano, 'globalTimeline', // ayano:Global
@@ -325,6 +330,7 @@ describe('Streaming', () => {
assert.strictEqual(fired, true);
});
*/
test('ホーム投稿は流れない', async () => {
const fired = await waitFire(
@@ -385,6 +391,8 @@ describe('Streaming', () => {
});
});
// XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac"
/*
describe('Hashtag Timeline', () => {
test('指定したハッシュタグの投稿が流れる', () => new Promise<void>(async done => {
const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => {
@@ -404,45 +412,43 @@ describe('Streaming', () => {
});
}));
// XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac"
test('指定したハッシュタグの投稿が流れる (AND)', () => new Promise<void>(async done => {
let fooCount = 0;
let barCount = 0;
let fooBarCount = 0;
// test('指定したハッシュタグの投稿が流れる (AND)', () => new Promise<void>(async done => {
// let fooCount = 0;
// let barCount = 0;
// let fooBarCount = 0;
const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => {
if (type === 'note') {
if (body.text === '#foo') fooCount++;
if (body.text === '#bar') barCount++;
if (body.text === '#foo #bar') fooBarCount++;
}
}, {
q: [
['foo', 'bar'],
],
});
// const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => {
// if (type === 'note') {
// if (body.text === '#foo') fooCount++;
// if (body.text === '#bar') barCount++;
// if (body.text === '#foo #bar') fooBarCount++;
// }
// }, {
// q: [
// ['foo', 'bar'],
// ],
// });
post(chitose, {
text: '#foo',
});
// post(chitose, {
// text: '#foo',
// });
post(chitose, {
text: '#bar',
});
// post(chitose, {
// text: '#bar',
// });
post(chitose, {
text: '#foo #bar',
});
// post(chitose, {
// text: '#foo #bar',
// });
// setTimeout(() => {
// assert.strictEqual(fooCount, 0);
// assert.strictEqual(barCount, 0);
// assert.strictEqual(fooBarCount, 1);
// ws.close();
// done();
// }, 3000);
// }));
setTimeout(() => {
assert.strictEqual(fooCount, 0);
assert.strictEqual(barCount, 0);
assert.strictEqual(fooBarCount, 1);
ws.close();
done();
}, 3000);
}));
test('指定したハッシュタグの投稿が流れる (OR)', () => new Promise<void>(async done => {
let fooCount = 0;
@@ -543,5 +549,6 @@ describe('Streaming', () => {
}, 3000);
}));
});
*/
});
});

View File

@@ -0,0 +1,44 @@
import { aidRegExp, genAid, parseAid } from '@/misc/id/aid.js';
import { genMeid, meidRegExp, parseMeid } from '@/misc/id/meid.js';
import { genMeidg, meidgRegExp, parseMeidg } from '@/misc/id/meidg.js';
import { genObjectId, objectIdRegExp, parseObjectId } from '@/misc/id/object-id.js';
import { ulidRegExp, parseUlid } from '@/misc/id/ulid.js';
import { ulid } from 'ulid';
import { describe, test, expect } from '@jest/globals';
describe('misc:id', () => {
test('aid', () => {
const date = new Date();
const gotAid = genAid(date);
expect(gotAid).toMatch(aidRegExp);
expect(parseAid(gotAid).date.getTime()).toBe(date.getTime());
});
test('meid', () => {
const date = new Date();
const gotMeid = genMeid(date);
expect(gotMeid).toMatch(meidRegExp);
expect(parseMeid(gotMeid).date.getTime()).toBe(date.getTime());
});
test('meidg', () => {
const date = new Date();
const gotMeidg = genMeidg(date);
expect(gotMeidg).toMatch(meidgRegExp);
expect(parseMeidg(gotMeidg).date.getTime()).toBe(date.getTime());
});
test('objectid', () => {
const date = new Date();
const gotObjectId = genObjectId(date);
expect(gotObjectId).toMatch(objectIdRegExp);
expect(Math.floor(parseObjectId(gotObjectId).date.getTime() / 1000)).toBe(Math.floor(date.getTime() / 1000));
});
test('ulid', () => {
const date = new Date();
const gotUlid = ulid(date.getTime());
expect(gotUlid).toMatch(ulidRegExp);
expect(parseUlid(gotUlid).date.getTime()).toBe(date.getTime());
});
});

View File

@@ -1,9 +1,7 @@
# (cd path/to/frontend; pnpm tsc -p .storybook)
# (cd path/to/frontend; node .storybook/generate.js)
/changes.js
/generate.js
# (cd path/to/frontend; node .storybook/preload-locale.js)
/preload-locale.js
/locale.ts
# (cd path/to/frontend; node .storybook/preload-theme.js)
/main.js
/preload-theme.js
/themes.ts

View File

@@ -0,0 +1,80 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import micromatch from 'micromatch';
import main from './main';
interface Stats {
readonly modules: readonly {
readonly id: string;
readonly name: string;
readonly reasons: readonly {
readonly moduleName: string;
}[];
}[];
}
fs.readFile(
path.resolve(__dirname, '../storybook-static/preview-stats.json')
).then((buffer) => {
const stats: Stats = JSON.parse(buffer.toString());
const keys = new Set(stats.modules.map((stat) => stat.id));
const map = new Map(
Array.from(keys, (key) => [
key,
new Set(
stats.modules
.filter((stat) => stat.id === key)
.flatMap((stat) => stat.reasons)
.map((reason) => reason.moduleName)
),
])
);
const modules = new Set(
process.argv
.slice(2)
.map((arg) =>
path.relative(
path.resolve(__dirname, '..'),
path.resolve(__dirname, '../../..', arg)
)
)
.map((path) => (path.startsWith('.') ? path : `./${path}`))
);
if (
micromatch(Array.from(modules), [
'../../assets/**',
'../../fluent-emojis/**',
'../../locales/**',
'../../misskey-assets/**',
'assets/**',
'public/**',
'../../pnpm-lock.yaml',
]).length
) {
return;
}
for (;;) {
const oldSize = modules.size;
for (const module of Array.from(modules)) {
if (map.has(module)) {
for (const dependency of Array.from(map.get(module)!)) {
modules.add(dependency);
}
}
}
if (modules.size === oldSize) {
break;
}
}
const stories = micromatch(
Array.from(modules),
main.stories.map((story) => `./${path.relative('..', story)}`)
);
if (stories.length) {
for (const story of stories) {
process.stdout.write(` --only-story-files ${story}`);
}
} else {
process.stdout.write(` --skip`);
}
});

View File

@@ -1,6 +1,7 @@
import { resolve } from 'node:path';
import type { StorybookConfig } from '@storybook/vue3-vite';
import { mergeConfig } from 'vite';
import turbosnap from 'vite-plugin-turbosnap';
const config = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
@@ -20,8 +21,13 @@ const config = {
core: {
disableTelemetry: true,
},
async viteFinal(config, options) {
async viteFinal(config) {
return mergeConfig(config, {
plugins: [
turbosnap({
rootDir: config.root ?? process.cwd(),
}),
],
build: {
target: [
'chrome108',

View File

@@ -1,4 +1,10 @@
<link rel="stylesheet" href="https://unpkg.com/@tabler/icons-webfont@2.12.0/tabler-icons.min.css">
<link rel="stylesheet" href="https://unpkg.com/@fontsource/m-plus-rounded-1c/index.css">
<style>
html {
font-family: 'Hiragino Maru Gothic Pro', 'BIZ UDGothic', Roboto, HelveticaNeue, Arial, 'M PLUS Rounded 1c', sans-serif;
}
</style>
<script>
window.global = window;
</script>

View File

@@ -18,5 +18,10 @@
"jsx": "react",
"jsxFactory": "h"
},
"files": ["./generate.tsx", "./preload-locale.ts", "./preload-theme.ts"]
"files": [
"./changes.ts",
"./generate.tsx",
"./preload-locale.ts",
"./preload-theme.ts"
]
}

View File

@@ -58,13 +58,13 @@
"strict-event-emitter-types": "2.0.0",
"syuilo-password-strength": "0.0.1",
"textarea-caret": "3.1.0",
"three": "0.150.1",
"three": "0.151.3",
"throttle-debounce": "5.0.0",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.5",
"tsconfig-paths": "4.2.0",
"twemoji-parser": "14.0.0",
"typescript": "5.0.2",
"typescript": "5.0.3",
"uuid": "9.0.0",
"vanilla-tilt": "1.8.0",
"vite": "4.2.1",
@@ -91,13 +91,14 @@
"@storybook/types": "7.0.2",
"@storybook/vue3": "7.0.2",
"@storybook/vue3-vite": "7.0.2",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/vue": "^6.6.1",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/vue": "7.0.0",
"@types/escape-regexp": "0.0.1",
"@types/estree": "^1.0.0",
"@types/estree": "1.0.0",
"@types/gulp": "4.0.10",
"@types/gulp-rename": "2.0.1",
"@types/matter-js": "0.18.2",
"@types/micromatch": "3.1.1",
"@types/node": "18.15.11",
"@types/punycode": "2.1.0",
"@types/sanitize-html": "2.9.0",
@@ -108,32 +109,34 @@
"@types/uuid": "9.0.1",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "5.57.0",
"@typescript-eslint/parser": "5.57.0",
"@typescript-eslint/eslint-plugin": "5.57.1",
"@typescript-eslint/parser": "5.57.1",
"@vitest/coverage-c8": "^0.29.8",
"@vue/runtime-core": "3.2.47",
"astring": "^1.8.4",
"chokidar-cli": "^3.0.0",
"chromatic": "^6.17.2",
"astring": "1.8.4",
"chokidar-cli": "3.0.0",
"chromatic": "6.17.3",
"cross-env": "7.0.3",
"cypress": "12.9.0",
"eslint": "8.37.0",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-vue": "9.10.0",
"fast-glob": "^3.2.12",
"fast-glob": "3.2.12",
"happy-dom": "8.9.0",
"msw": "^1.1.0",
"msw-storybook-addon": "^1.8.0",
"prettier": "^2.8.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"micromatch": "3.1.10",
"msw": "1.2.1",
"msw-storybook-addon": "1.8.0",
"prettier": "2.8.7",
"react": "18.2.0",
"react-dom": "18.2.0",
"start-server-and-test": "2.0.0",
"storybook": "7.0.2",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"summaly": "github:misskey-dev/summaly",
"vitest": "^0.29.8",
"vitest-fetch-mock": "^0.2.2",
"vue-eslint-parser": "9.1.0",
"vite-plugin-turbosnap": "^1.0.1",
"vitest": "0.29.8",
"vitest-fetch-mock": "0.2.2",
"vue-eslint-parser": "9.1.1",
"vue-tsc": "1.2.0"
}
}

View File

@@ -0,0 +1,32 @@
<template>
<div :class="$style.root">
<i class="ti ti-plane-departure" style="margin-right: 8px;"></i>
{{ i18n.ts.accountMoved }}
<MkMention :class="$style.link" :username="acct" :host="host ?? localHost"/>
</div>
</template>
<script lang="ts" setup>
import MkMention from './MkMention.vue';
import { i18n } from '@/i18n';
import { host as localHost } from '@/config';
defineProps<{
acct: string;
host: string;
}>();
</script>
<style lang="scss" module>
.root {
padding: 16px;
font-size: 90%;
background: var(--infoWarnBg);
color: var(--error);
border-radius: var(--radius);
}
.link {
margin-left: 4px;
}
</style>

View File

@@ -17,8 +17,8 @@
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown">
<template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template>
<template #caption>
<span v-if="okButtonDisabled && disabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })" />
<span v-else-if="okButtonDisabled && disabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })" />
<span v-if="okButtonDisabled && disabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })"/>
<span v-else-if="okButtonDisabled && disabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })"/>
</template>
</MkInput>
<MkSelect v-if="select" v-model="selectedValue" autofocus>
@@ -32,11 +32,11 @@
</template>
</MkSelect>
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
<MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
<MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
<MkButton v-if="showOkButton" inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
<MkButton v-if="showCancelButton || input || select" inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
</div>
<div v-if="actions" :class="$style.buttons">
<MkButton v-for="action in actions" :key="action.text" inline :primary="action.primary" :danger="action.danger" @click="() => { action.callback(); modal?.close(); }">{{ action.text }}</MkButton>
<MkButton v-for="action in actions" :key="action.text" inline rounded :primary="action.primary" :danger="action.danger" @click="() => { action.callback(); modal?.close(); }">{{ action.text }}</MkButton>
</div>
</div>
</MkModal>

View File

@@ -439,6 +439,7 @@ defineExpose({
&.asDrawer {
width: 100% !important;
padding: 12px 0 max(env(safe-area-inset-bottom, 0px), 12px) 0;
> .emojis {
::v-deep(section) {

View File

@@ -11,15 +11,21 @@
<div :class="$style.body" class="_shadow" @mousedown="onBodyMousedown" @keydown="onKeydown">
<div :class="[$style.header, { [$style.mini]: mini }]" @contextmenu.prevent.stop="onContextmenu">
<span :class="$style.headerLeft">
<button v-for="button in buttonsLeft" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button>
<template v-if="!minimized">
<button v-for="button in buttonsLeft" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button>
</template>
</span>
<span :class="$style.headerTitle" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown">
<slot name="header"></slot>
</span>
<span :class="$style.headerRight">
<button v-for="button in buttonsRight" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button>
<template v-if="!minimized">
<button v-for="button in buttonsRight" v-tooltip="button.title" class="_button" :class="[$style.headerButton, { [$style.highlighted]: button.highlighted }]" @click="button.onClick"><i :class="button.icon"></i></button>
</template>
<button v-if="canResize && minimized" v-tooltip="i18n.ts.windowRestore" class="_button" :class="$style.headerButton" @click="unMinimize()"><i class="ti ti-maximize"></i></button>
<button v-else-if="canResize && !maximized" v-tooltip="i18n.ts.windowMinimize" class="_button" :class="$style.headerButton" @click="minimize()"><i class="ti ti-minimize"></i></button>
<button v-if="canResize && maximized" v-tooltip="i18n.ts.windowRestore" class="_button" :class="$style.headerButton" @click="unMaximize()"><i class="ti ti-picture-in-picture"></i></button>
<button v-else-if="canResize && !maximized" v-tooltip="i18n.ts.windowMaximize" class="_button" :class="$style.headerButton" @click="maximize()"><i class="ti ti-rectangle"></i></button>
<button v-else-if="canResize && !maximized && !minimized" v-tooltip="i18n.ts.windowMaximize" class="_button" :class="$style.headerButton" @click="maximize()"><i class="ti ti-rectangle"></i></button>
<button v-if="closeButton" v-tooltip="i18n.ts.close" class="_button" :class="$style.headerButton" @click="close()"><i class="ti ti-x"></i></button>
</span>
</div>
@@ -27,7 +33,7 @@
<slot></slot>
</div>
</div>
<template v-if="canResize">
<template v-if="canResize && !minimized">
<div :class="$style.handleTop" @mousedown.prevent="onTopHandleMousedown"></div>
<div :class="$style.handleRight" @mousedown.prevent="onRightHandleMousedown"></div>
<div :class="$style.handleBottom" @mousedown.prevent="onBottomHandleMousedown"></div>
@@ -100,10 +106,11 @@ let rootEl = $shallowRef<HTMLElement | null>();
let showing = $ref(true);
let beforeClickedAt = 0;
let maximized = $ref(false);
let unMaximizedTop = '';
let unMaximizedLeft = '';
let unMaximizedWidth = '';
let unMaximizedHeight = '';
let minimized = $ref(false);
let unResizedTop = '';
let unResizedLeft = '';
let unResizedWidth = '';
let unResizedHeight = '';
function close() {
showing = false;
@@ -132,10 +139,10 @@ function top() {
function maximize() {
maximized = true;
unMaximizedTop = rootEl.style.top;
unMaximizedLeft = rootEl.style.left;
unMaximizedWidth = rootEl.style.width;
unMaximizedHeight = rootEl.style.height;
unResizedTop = rootEl.style.top;
unResizedLeft = rootEl.style.left;
unResizedWidth = rootEl.style.width;
unResizedHeight = rootEl.style.height;
rootEl.style.top = '0';
rootEl.style.left = '0';
rootEl.style.width = '100%';
@@ -144,10 +151,35 @@ function maximize() {
function unMaximize() {
maximized = false;
rootEl.style.top = unMaximizedTop;
rootEl.style.left = unMaximizedLeft;
rootEl.style.width = unMaximizedWidth;
rootEl.style.height = unMaximizedHeight;
rootEl.style.top = unResizedTop;
rootEl.style.left = unResizedLeft;
rootEl.style.width = unResizedWidth;
rootEl.style.height = unResizedHeight;
}
function minimize() {
minimized = true;
unResizedWidth = rootEl.style.width;
unResizedHeight = rootEl.style.height;
rootEl.style.width = minWidth + 'px';
rootEl.style.height = props.mini ? '32px' : '39px';
}
function unMinimize() {
const main = rootEl;
if (main == null) return;
minimized = false;
rootEl.style.width = unResizedWidth;
rootEl.style.height = unResizedHeight;
const browserWidth = window.innerWidth;
const browserHeight = window.innerHeight;
const windowWidth = main.offsetWidth;
const windowHeight = main.offsetHeight;
const position = main.getBoundingClientRect();
if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px';
if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px';
}
function onBodyMousedown() {
@@ -155,7 +187,11 @@ function onBodyMousedown() {
}
function onDblClick() {
maximize();
if (minimized) {
unMinimize();
} else {
maximize();
}
}
function onHeaderMousedown(evt: MouseEvent) {
@@ -187,7 +223,7 @@ function onHeaderMousedown(evt: MouseEvent) {
const clickX = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientX : evt.clientX;
const clickY = evt.touches && evt.touches.length > 0 ? evt.touches[0].clientY : evt.clientY;
const moveBaseX = beforeMaximized ? parseInt(unMaximizedWidth, 10) / 2 : clickX - position.left; // TODO: parseIntやめる
const moveBaseX = beforeMaximized ? parseInt(unResizedWidth, 10) / 2 : clickX - position.left; // TODO: parseIntやめる
const moveBaseY = beforeMaximized ? 20 : clickY - position.top;
const browserWidth = window.innerWidth;
const browserHeight = window.innerHeight;

View File

@@ -22,7 +22,7 @@ export const Default = {
};
},
},
template: '<MkA v-bind="props">Text</MkA>',
template: '<MkA v-bind="props">Misskey</MkA>',
};
},
async play({ canvasElement }) {

View File

@@ -4,12 +4,16 @@
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
<div v-if="user.isCat" :class="[$style.ears, { [$style.mask]: useBlurEffect }]">
<div :class="$style.earLeft">
<div v-if="useBlurEffect" :class="$style.layer">
<div v-if="false" :class="$style.layer">
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
</div>
</div>
<div :class="$style.earRight">
<div v-if="useBlurEffect" :class="$style.layer">
<div v-if="false" :class="$style.layer">
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
<div :class="$style.plot" :style="{ backgroundImage: `url(${JSON.stringify(url)})` }"/>
</div>
</div>
@@ -195,11 +199,21 @@ watch(() => props.user.avatarBlurhash, () => {
> .plot {
contain: strict;
position: absolute;
width: 100%;
height: 100%;
clip-path: path('M0 0H1V1H0z');
transform: scale(32767);
transform-origin: 0 0;
opacity: 0.5;
&:first-child {
opacity: 1;
}
&:last-child {
opacity: calc(1 / 3);
}
}
}
}
@@ -221,6 +235,14 @@ watch(() => props.user.avatarBlurhash, () => {
> .plot {
background-position: 20% 10%; /* ~= 37.5deg */
&:first-child {
background-position-x: 21%;
}
&:last-child {
background-position-y: 11%;
}
}
}
}
@@ -241,7 +263,16 @@ watch(() => props.user.avatarBlurhash, () => {
-38.5857864376%); /* 40 - 2 * sqrt(2) */
> .plot {
position: absolute;
background-position: 80% 10%; /* ~= 37.5deg */
&:first-child {
background-position-x: 79%;
}
&:last-child {
background-position-y: 11%;
}
}
}
}

View File

@@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { waitFor } from '@storybook/testing-library';
import { StoryObj } from '@storybook/vue3';
import MkPageHeader from './MkPageHeader.vue';
export const Empty = {
@@ -22,16 +23,16 @@ export const Empty = {
template: '<MkPageHeader v-bind="props" />',
};
},
async play() {
const wait = new Promise((resolve) => setTimeout(resolve, 800));
await waitFor(async () => await wait);
},
args: {
static: true,
tabs: [],
},
parameters: {
layout: 'centered',
chromatic: {
/* This component has animations that are implemented with JavaScript. So it's unstable to take a snapshot. */
disableSnapshot: true,
},
},
} satisfies StoryObj<typeof MkPageHeader>;
export const OneTab = {

View File

@@ -13,8 +13,6 @@ export class UserPreview {
this.el = el;
this.user = user;
this.attach();
this.show = this.show.bind(this);
this.close = this.close.bind(this);
this.onMouseover = this.onMouseover.bind(this);
@@ -22,6 +20,8 @@ export class UserPreview {
this.onClick = this.onClick.bind(this);
this.attach = this.attach.bind(this);
this.detach = this.detach.bind(this);
this.attach();
}
private show() {

View File

@@ -187,7 +187,7 @@ try {
} catch (err) {}
const app = createApp(
window.location.search === '?zen' ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
new URLSearchParams(window.location.search).has('zen') ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
!$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) :
ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) :

View File

@@ -217,6 +217,7 @@ const patrons = [
'氷月氷華里',
'Ebise Lutica',
'巣黒るい@リスケモ男の娘VTuber!',
'ふぇいぽむ',
];
let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure'));

View File

@@ -113,16 +113,37 @@ function remove(ad) {
function save(ad) {
if (ad.id == null) {
os.apiWithDialog('admin/ad/create', {
os.api('admin/ad/create', {
...ad,
expiresAt: new Date(ad.expiresAt).getTime(),
startsAt: new Date(ad.startsAt).getTime(),
}).then(() => {
os.alert({
type: 'success',
text: i18n.ts.saved,
});
refresh();
}).catch(err => {
os.alert({
type: 'error',
text: err,
});
});
} else {
os.apiWithDialog('admin/ad/update', {
os.api('admin/ad/update', {
...ad,
expiresAt: new Date(ad.expiresAt).getTime(),
startsAt: new Date(ad.startsAt).getTime(),
}).then(() => {
os.alert({
type: 'success',
text: i18n.ts.saved,
});
}).catch(err => {
os.alert({
type: 'error',
text: err,
});
});
}
}
@@ -141,6 +162,25 @@ function more() {
}));
});
}
function refresh() {
os.api('admin/ad/list').then(adsResponse => {
ads = adsResponse.map(r => {
const exdate = new Date(r.expiresAt);
const stdate = new Date(r.startsAt);
exdate.setMilliseconds(exdate.getMilliseconds() - localTimeDiff);
stdate.setMilliseconds(stdate.getMilliseconds() - localTimeDiff);
return {
...r,
expiresAt: exdate.toISOString().slice(0, 16),
startsAt: stdate.toISOString().slice(0, 16),
};
});
});
}
refresh();
const headerActions = $computed(() => [{
asFullButton: true,
icon: 'ti ti-plus',

View File

@@ -69,6 +69,7 @@ function save(announcement) {
type: 'success',
text: i18n.ts.saved,
});
refresh();
}).catch(err => {
os.alert({
type: 'error',
@@ -90,6 +91,14 @@ function save(announcement) {
}
}
function refresh() {
os.api('admin/announcements/list').then(announcementResponse => {
announcements = announcementResponse;
});
}
refresh();
const headerActions = $computed(() => [{
asFullButton: true,
icon: 'ti ti-plus',

View File

@@ -2,7 +2,7 @@
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="700">
<div v-if="channel" class="_gaps_m">
<div v-if="channelId == null || channel != null" class="_gaps_m">
<MkInput v-model="name">
<template #label>{{ i18n.ts.name }}</template>
</MkInput>

View File

@@ -47,6 +47,7 @@ const featuredPagination = {
const favoritesPagination = {
endpoint: 'channels/my-favorites' as const,
limit: 100,
noPaging: true,
};
const followingPagination = {
endpoint: 'channels/followed' as const,

View File

@@ -88,7 +88,7 @@ const tagUsers = $computed(() => ({
},
}));
const pinnedUsers = { endpoint: 'pinned-users' };
const pinnedUsers = { endpoint: 'pinned-users', noPaging: true };
const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
state: 'alive',
origin: 'local',

View File

@@ -24,6 +24,7 @@ import { definePageMetadata } from '@/scripts/page-metadata';
const pagination = {
endpoint: 'antennas/list' as const,
noPaging: true,
limit: 10,
};

View File

@@ -32,6 +32,7 @@ import { clipsCache } from '@/cache';
const pagination = {
endpoint: 'clips/list' as const,
noPaging: true,
limit: 10,
};

View File

@@ -30,6 +30,7 @@ const pagingComponent = $shallowRef<InstanceType<typeof MkPagination>>();
const pagination = {
endpoint: 'users/lists/list' as const,
noPaging: true,
limit: 10,
};

View File

@@ -130,11 +130,6 @@ const menuDef = computed(() => [{
}, {
title: i18n.ts.otherSettings,
items: [{
icon: 'ti ti-package',
text: i18n.ts.importAndExport,
to: '/settings/import-export',
active: currentPage?.route.name === 'import-export',
}, {
icon: 'ti ti-badges',
text: i18n.ts.roles,
to: '/settings/roles',
@@ -165,6 +160,16 @@ const menuDef = computed(() => [{
to: '/settings/webhook',
active: currentPage?.route.name === 'webhook',
}, {
icon: 'ti ti-package',
text: i18n.ts.importAndExport,
to: '/settings/import-export',
active: currentPage?.route.name === 'import-export',
}, /*{
icon: 'ti ti-plane',
text: i18n.ts.accountMigration,
to: '/settings/migration',
active: currentPage?.route.name === 'migration',
},*/ {
icon: 'ti ti-dots',
text: i18n.ts.other,
to: '/settings/other',
@@ -231,7 +236,7 @@ onUnmounted(() => {
});
watch(router.currentRef, (to) => {
if (to.route.name === "settings" && to.child?.route.name == null && !narrow) {
if (to.route.name === 'settings' && to.child?.route.name == null && !narrow) {
router.replace('/settings/profile');
}
});

View File

@@ -0,0 +1,73 @@
<template>
<div class="_gaps_m">
<FormSection first>
<template #label>{{ i18n.ts._accountMigration.moveTo }}</template>
<MkInput v-model="moveToAccount" manual-save>
<template #prefix><i class="ti ti-plane-departure"></i></template>
<template #label>{{ i18n.ts._accountMigration.moveToLabel }}</template>
</MkInput>
</FormSection>
<FormInfo warn>{{ i18n.ts._accountMigration.moveAccountDescription }}</FormInfo>
<FormSection>
<template #label>{{ i18n.ts._accountMigration.moveFrom }}</template>
<MkInput v-model="accountAlias" manual-save>
<template #prefix><i class="ti ti-plane-arrival"></i></template>
<template #label>{{ i18n.ts._accountMigration.moveFromLabel }}</template>
</MkInput>
</FormSection>
<FormInfo warn>{{ i18n.ts._accountMigration.moveFromDescription }}</FormInfo>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import FormSection from '@/components/form/section.vue';
import FormInfo from '@/components/MkInfo.vue';
import MkInput from '@/components/MkInput.vue';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
const moveToAccount = ref('');
const accountAlias = ref('');
async function move(): Promise<void> {
const account = moveToAccount.value;
const confirm = await os.confirm({
type: 'warning',
text: i18n.t('migrationConfirm', { account: account.toString() }),
});
if (confirm.canceled) return;
os.apiWithDialog('i/move', {
moveToAccount: account,
});
}
async function save(): Promise<void> {
const account = accountAlias.value;
os.apiWithDialog('i/known-as', {
alsoKnownAs: account,
});
}
watch(accountAlias, async () => {
await save();
});
watch(moveToAccount, async () => {
await move();
});
definePageMetadata({
title: i18n.ts.accountMigration,
icon: 'ti ti-plane',
});
</script>
<style lang="scss">
.description {
font-size: .85em;
padding: 1rem;
}
</style>

View File

@@ -7,6 +7,7 @@
<!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> -->
<div class="profile _gaps">
<MkAccountMoved v-if="user.movedToUri" :host="user.movedToUri.host" :acct="user.movedToUri.username"/>
<MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!" class="warn"/>
<div :key="user.id" class="main _panel">
@@ -117,6 +118,7 @@ import calcAge from 's-age';
import * as misskey from 'misskey-js';
import MkNote from '@/components/MkNote.vue';
import MkFollowButton from '@/components/MkFollowButton.vue';
import MkAccountMoved from '@/components/MkAccountMoved.vue';
import MkRemoteCaution from '@/components/MkRemoteCaution.vue';
import MkOmit from '@/components/MkOmit.vue';
import MkInfo from '@/components/MkInfo.vue';

View File

@@ -161,6 +161,10 @@ export const routes = [{
path: '/preferences-backups',
name: 'preferences-backups',
component: page(() => import('./pages/settings/preferences-backups.vue')),
}, {
path: '/migration',
name: 'migration',
component: page(() => import('./pages/settings/migration.vue'))
}, {
path: '/custom-css',
name: 'general',

View File

@@ -127,6 +127,7 @@ hr {
}
.ti {
width: 1.28em;
vertical-align: -12%;
line-height: 1em;

View File

@@ -1353,6 +1353,14 @@ export type Endpoints = {
req: TODO;
res: TODO;
};
'i/move': {
req: TODO;
res: TODO;
};
'i/known-as': {
req: TODO;
res: TODO;
};
'i/notifications': {
req: {
limit?: number;
@@ -2688,6 +2696,8 @@ type UserLite = {
onlineStatus: 'online' | 'active' | 'offline' | 'unknown';
avatarUrl: string;
avatarBlurhash: string;
alsoKnownAs: string[];
movedToUri: any;
emojis: {
name: string;
url: string;
@@ -2709,7 +2719,7 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
//
// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
// src/api.types.ts:594:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
// src/api.types.ts:596:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)

View File

@@ -24,23 +24,23 @@
"@swc/jest": "0.2.24",
"@types/jest": "29.5.0",
"@types/node": "18.15.11",
"@typescript-eslint/eslint-plugin": "5.57.0",
"@typescript-eslint/parser": "5.57.0",
"@typescript-eslint/eslint-plugin": "5.57.1",
"@typescript-eslint/parser": "5.57.1",
"eslint": "8.37.0",
"jest": "^29.5.0",
"jest-fetch-mock": "^3.0.3",
"jest": "29.5.0",
"jest-fetch-mock": "3.0.3",
"jest-websocket-mock": "2.4.0",
"mock-socket": "9.2.1",
"tsd": "0.28.1",
"typescript": "5.0.2"
"typescript": "5.0.3"
},
"files": [
"built"
],
"dependencies": {
"@swc/cli": "0.1.62",
"@swc/core": "1.3.42",
"@swc/core": "1.3.46",
"eventemitter3": "5.0.0",
"reconnecting-websocket": "^4.4.0"
"reconnecting-websocket": "4.4.0"
}
}

View File

@@ -362,6 +362,8 @@ export type Endpoints = {
'i/get-word-muted-notes-count': { req: TODO; res: TODO; };
'i/import-following': { req: TODO; res: TODO; };
'i/import-user-lists': { req: TODO; res: TODO; };
'i/move': { req: TODO; res: TODO; };
'i/known-as': { req: TODO; res: TODO; };
'i/notifications': { req: {
limit?: number;
sinceId?: Notification['id'];

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