1
0
mirror of https://github.com/MisskeyIO/misskey synced 2024-11-27 22:38:30 +09:00

Merge pull request MisskeyIO#740 from update-host

This commit is contained in:
まっちゃとーにゅ 2024-09-17 21:24:15 +09:00 committed by GitHub
commit 1ebda086fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 419 additions and 282 deletions

View File

@ -72,7 +72,7 @@ dbReplications: false
#───┘ Redis configuration └─────────────────────────────────────
redis:
host: keydb
host: dragonfly
port: 6379
#family: 0 # 0=Both, 4=IPv4, 6=IPv6
#pass: example-pass
@ -80,7 +80,7 @@ redis:
#db: 1
#redisForPubsub:
# host: keydb
# host: dragonfly
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
@ -88,7 +88,7 @@ redis:
# #db: 1
#redisForJobQueue:
# host: keydb
# host: dragonfly
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
@ -96,7 +96,7 @@ redis:
# #db: 1
#redisForTimelines:
# host: keydb
# host: dragonfly
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass

View File

@ -72,7 +72,7 @@ dbReplications: false
#───┘ Redis configuration └─────────────────────────────────────
redis:
host: keydb
host: dragonfly
port: 6379
#family: 0 # 0=Both, 4=IPv4, 6=IPv6
#pass: example-pass
@ -80,7 +80,7 @@ redis:
#db: 1
#redisForPubsub:
# host: keydb
# host: dragonfly
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
@ -88,7 +88,7 @@ redis:
# #db: 1
#redisForJobQueue:
# host: keydb
# host: dragonfly
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass
@ -96,7 +96,7 @@ redis:
# #db: 1
#redisForTimelines:
# host: keydb
# host: dragonfly
# port: 6379
# #family: 0 # 0=Both, 4=IPv4, 6=IPv6
# #pass: example-pass

View File

@ -15,17 +15,25 @@ services:
- internal_network
- external_network
keydb:
dragonfly:
restart: unless-stopped
image: eqalpha/keydb:latest
image: docker.dragonflydb.io/dragonflydb/dragonfly
ulimits:
memlock: -1
environment:
DFLY_snapshot_cron: '* * * * *'
DFLY_version_check: false
DFLY_tcp_backlog: 2048
DFLY_default_lua_flags: allow-undeclared-keys
DFLY_pipeline_squash: 0
DFLY_multi_exec_squash: false
DFLY_conn_io_threads: 4
DFLY_epoll_file_threads: 4
DFLY_proactor_threads: 4
networks:
- internal_network
volumes:
- keydb-data:/data
healthcheck:
test: "keydb-cli ping"
interval: 5s
retries: 20
- dragonfly-data:/data
db:
restart: unless-stopped
@ -45,7 +53,7 @@ services:
volumes:
postgres-data:
keydb-data:
dragonfly-data:
networks:
internal_network:

View File

@ -11,7 +11,6 @@ docker-compose.yml
node_modules/
packages/*/node_modules
redis/
keydb/
files/
fluent-emojis/
.pnp.*

View File

@ -32,8 +32,18 @@ jobs:
env:
POSTGRES_DB: test-misskey
POSTGRES_HOST_AUTH_METHOD: trust
keydb:
image: eqalpha/keydb:latest
dragonfly:
image: docker.dragonflydb.io/dragonflydb/dragonfly
options: --ulimit "memlock=-1"
env:
DFLY_version_check: false
DFLY_tcp_backlog: 2048
DFLY_default_lua_flags: allow-undeclared-keys
DFLY_pipeline_squash: 0
DFLY_multi_exec_squash: false
DFLY_conn_io_threads: 4
DFLY_epoll_file_threads: 4
DFLY_proactor_threads: 4
ports:
- 56312:6379
@ -84,8 +94,18 @@ jobs:
env:
POSTGRES_DB: test-misskey
POSTGRES_HOST_AUTH_METHOD: trust
keydb:
image: eqalpha/keydb:latest
dragonfly:
image: docker.dragonflydb.io/dragonflydb/dragonfly
options: --ulimit "memlock=-1"
env:
DFLY_version_check: false
DFLY_tcp_backlog: 2048
DFLY_default_lua_flags: allow-undeclared-keys
DFLY_pipeline_squash: 0
DFLY_multi_exec_squash: false
DFLY_conn_io_threads: 4
DFLY_epoll_file_threads: 4
DFLY_proactor_threads: 4
ports:
- 56312:6379

1
.gitignore vendored
View File

@ -53,7 +53,6 @@ run.bat
api-docs.json
*.log
/redis
/keydb
*.code-workspace
.DS_Store
/files

View File

@ -37,8 +37,25 @@ spec:
value: "misskey"
ports:
- containerPort: 5432
- name: keydb
image: eqalpha/keydb:latest
- name: dragonfly
image: docker.dragonflydb.io/dragonflydb/dragonfly
env:
- name: DFLY_version_check
value: false
- name: DFLY_tcp_backlog
value: 2048
- name: DFLY_default_lua_flags
value: allow-undeclared-keys
- name: DFLY_pipeline_squash
value: 0
- name: DFLY_multi_exec_squash
value: false
- name: DFLY_conn_io_threads
value: 4
- name: DFLY_epoll_file_threads
value: 4
- name: DFLY_proactor_threads
value: 4
ports:
- containerPort: 6379
volumes:

View File

@ -3,17 +3,25 @@ version: "3"
# このconfigは、 dockerでMisskey本体を起動せず、 redisとpostgresql などだけを起動します
services:
keydb:
dragonfly:
restart: always
image: eqalpha/keydb:latest
image: docker.dragonflydb.io/dragonflydb/dragonfly
ulimits:
memlock: -1
environment:
DFLY_snapshot_cron: '* * * * *'
DFLY_version_check: false
DFLY_tcp_backlog: 2048
DFLY_default_lua_flags: allow-undeclared-keys
DFLY_pipeline_squash: 0
DFLY_multi_exec_squash: false
DFLY_conn_io_threads: 4
DFLY_epoll_file_threads: 4
DFLY_proactor_threads: 4
ports:
- "6379:6379"
volumes:
- ./keydb:/data
healthcheck:
test: "keydb-cli ping"
interval: 5s
retries: 20
- ./redis:/data
db:
restart: always

View File

@ -6,13 +6,13 @@ services:
restart: always
links:
- db
- keydb
- dragonfly
# - mcaptcha
# - meilisearch
depends_on:
db:
condition: service_healthy
keydb:
dragonfly:
condition: service_healthy
ports:
- "3000:3000"
@ -23,17 +23,25 @@ services:
- ./files:/misskey/files
- ./.config:/misskey/.config:ro
keydb:
dragonfly:
restart: always
image: eqalpha/keydb:latest
image: docker.dragonflydb.io/dragonflydb/dragonfly
ulimits:
memlock: -1
environment:
DFLY_snapshot_cron: '* * * * *'
DFLY_version_check: false
DFLY_tcp_backlog: 2048
DFLY_default_lua_flags: allow-undeclared-keys
DFLY_pipeline_squash: 0
DFLY_multi_exec_squash: false
DFLY_conn_io_threads: 4
DFLY_epoll_file_threads: 4
DFLY_proactor_threads: 4
networks:
- internal_network
volumes:
- ./keydb:/data
healthcheck:
test: "keydb-cli ping"
interval: 5s
retries: 20
- ./redis:/data
db:
restart: always

View File

@ -442,6 +442,10 @@ moderation: "Moderation"
moderationNote: "Moderation note"
addModerationNote: "Add moderation note"
moderationLogs: "Moderation logs"
userAccountMoveLogs: "Account migration logs"
userAccountMoveLogsTitle: "{from} migrated the account to {to}"
movedToId: "ID of the account migrated to"
moveFromId: "ID of the account migrated from"
nUsersMentioned: "Mentioned by {n} users"
securityKeyAndPasskey: "Security- and passkeys"
securityKey: "Security key"
@ -1087,6 +1091,7 @@ audioFiles: "Audio"
dataSaver: "Data Saver"
accountMigration: "Account Migration"
accountMoved: "This user has moved to a new account:"
accountMovedFrom: "This user has been migrated from the following account:"
accountMovedShort: "This account has been migrated."
operationForbidden: "Operation forbidden"
forceShowAds: "Always show ads"

View File

@ -441,6 +441,10 @@ moderation: "조정"
moderationNote: "조정 기록"
addModerationNote: "조정 기록 추가하기"
moderationLogs: "모더레이션 로그"
userAccountMoveLogs: "계정 이사 사용 로그"
userAccountMoveLogsTitle: "{from} 가 {to} 로 계정을 이사했습니다"
movedToId: "이사 후 계정의 ID"
moveFromId: "이사 전 계정의 ID"
nUsersMentioned: "{n}명이 언급함"
securityKeyAndPasskey: "보안 키 또는 패스 키"
securityKey: "보안 키"
@ -908,6 +912,7 @@ followingVisibility: "팔로우의 공개 범위"
followersVisibility: "팔로워의 공개 범위"
continueThread: "글타래 더 보기"
deleteAccountConfirm: "계정이 삭제되고 되돌릴 수 없게 됩니다. 계속하시겠습니까?"
deleteAccountConfirmAndWarn: "계정이 삭제됩니다.\n삭제 요청 후 다시 로그인하면 계정 삭제가 중단되어 버립니다.\n계속하시겠습니까?"
incorrectPassword: "비밀번호가 올바르지 않습니다."
voteConfirm: "\"{choice}\"에 투표하시겠습니까?"
hide: "숨기기"
@ -1085,6 +1090,7 @@ audioFiles: "소리"
dataSaver: "데이터 절약 모드"
accountMigration: "계정 이동"
accountMoved: "이 사용자는 다음 계정으로 이사했습니다:"
accountMovedFrom: "이 사용자는 다음 계정에서 이사했습니다:"
accountMovedShort: "이사한 계정입니다"
operationForbidden: "사용할 수 없습니다"
forceShowAds: "광고를 항상 표시"
@ -1798,6 +1804,7 @@ _accountDelete:
requestAccountDelete: "계정 삭제 요청"
started: "삭제 작업이 시작되었습니다."
inProgress: "삭제 진행 중"
dontLogin: "삭제가 중단되어 버릴 수 있으므로, 계정에 로그인하지 않는 것을 권장합니다."
_ad:
back: "뒤로"
reduceFrequencyOfThisAd: "이 광고의 표시 빈도 낮추기"

View File

@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2024.5.0-host.2e",
"version": "2024.5.0-host.2f",
"codename": "nasubi",
"repository": {
"type": "git",

View File

@ -138,7 +138,7 @@ export class AnnouncementService {
limit: number,
offset: number,
moderator: MiUser,
): Promise<(MiAnnouncement & { userInfo: Packed<'UserLite'> | null, reads: number })[]> {
): Promise<(MiAnnouncement & { userInfo: Packed<'UserLite'> | null, reads: number, lastReadAt: Date | null })[]> {
const query = this.announcementsRepository.createQueryBuilder('announcement');
if (userId) {
@ -157,13 +157,14 @@ export class AnnouncementService {
.offset(offset)
.getMany();
const reads = new Map<MiAnnouncement, number>();
for (const announcement of announcements) {
reads.set(announcement, await this.announcementReadsRepository.countBy({
announcementId: announcement.id,
}));
}
const reads = announcements.length > 0
? await this.announcementReadsRepository.createQueryBuilder()
.select('"announcementId", count(*) as "reads", max("id") as "lastReadId"')
.where('"announcementId" IN (:...announcementIds)', { announcementIds: announcements.map(a => a.id) })
.groupBy('"announcementId"')
.getRawMany<{ announcementId: string, reads: number, lastReadId: string | null }>()
.then(rs => new Map(rs.map(r => [r.announcementId, { reads: r.reads, lastReadAt: r.lastReadId ? this.idService.parse(r.lastReadId).date : null }])))
: new Map();
const users = await this.usersRepository.findBy({
id: In(announcements.map(a => a.userId).filter(id => id != null)),
@ -174,8 +175,8 @@ export class AnnouncementService {
return announcements.map(announcement => ({
...announcement,
...reads.get(announcement.id) ?? { reads: 0, lastReadAt: null },
userInfo: packedUsers.find(u => u.id === announcement.userId) ?? null,
reads: reads.get(announcement) ?? 0,
}));
}
@ -293,18 +294,20 @@ export class AnnouncementService {
'read.id IS NOT NULL as "isRead"',
]);
query
.andWhere(
new Brackets((qb) => {
qb.orWhere('announcement."userId" = :userId', { userId: me.id });
qb.orWhere('announcement."userId" IS NULL');
}),
)
.andWhere(
new Brackets((qb) => {
.andWhere(new Brackets((qb) => {
qb.orWhere(new Brackets((nqb) => {
nqb.andWhere('announcement."userId" = :userId', { userId: me.id });
nqb.andWhere(isActive ? 'read.id IS NULL' : 'read.id IS NOT NULL');
}));
qb.orWhere(new Brackets((nqb) => {
nqb.andWhere('announcement."userId" IS NULL');
nqb.andWhere('announcement."isActive" = :isActive', { isActive });
}));
}))
.andWhere(new Brackets((qb) => {
qb.orWhere('announcement."forExistingUsers" = false');
qb.orWhere('announcement.id > :userId', { userId: me.id });
}),
);
}));
} else {
query.select([
'announcement.*',
@ -312,12 +315,9 @@ export class AnnouncementService {
]);
query.andWhere('announcement."userId" IS NULL');
query.andWhere('announcement."forExistingUsers" = false');
query.andWhere('announcement."isActive" = :isActive', { isActive });
}
query.andWhere('announcement."isActive" = :isActive', {
isActive: isActive,
});
if (isActive) {
query.orderBy({
'"isRead"': 'ASC',

View File

@ -219,7 +219,7 @@ export class NoteCreateService implements OnApplicationShutdown {
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('note:create');
this.updateNotesCountQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseNotesCount, this.performUpdateNotesCount);
this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount);
}
@bindThis

View File

@ -364,6 +364,16 @@ export class QueueService {
});
}
@bindThis
public createUserSuspendJob(user: ThinUser) {
return this.dbQueue.add('userSuspend', {
user: { id: user.id },
}, {
removeOnComplete: true,
removeOnFail: true,
});
}
@bindThis
public createReportAbuseJob(report: MiAbuseUserReport) {
return this.dbQueue.add('reportAbuse', report);

View File

@ -9,16 +9,7 @@ import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
import type Logger from '@/logger.js';
import type { MiUser } from '@/models/User.js';
import type {
AntennasRepository,
ClipNotesRepository,
ClipsRepository,
FollowingsRepository,
FollowRequestsRepository,
UserListMembershipsRepository,
UserListsRepository,
WebhooksRepository,
} from '@/models/_.js';
import type { FollowingsRepository } from '@/models/_.js';
import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
@ -36,27 +27,6 @@ export class UserSuspendService {
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
@Inject(DI.antennasRepository)
private antennasRepository: AntennasRepository,
@Inject(DI.webhooksRepository)
private webhooksRepository: WebhooksRepository,
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.clipsRepository)
private clipsRepository: ClipsRepository,
@Inject(DI.clipNotesRepository)
private clipNotesRepository: ClipNotesRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
private queueService: QueueService,
private globalEventService: GlobalEventService,
private apRendererService: ApRendererService,
@ -72,41 +42,7 @@ export class UserSuspendService {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
const promises: Promise<unknown>[] = [];
let cursor = '';
while (true) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition, no-constant-condition
const clipNotes = await this.clipNotesRepository.createQueryBuilder('c')
.select('c.id')
.innerJoin('c.note', 'n')
.where('n.userId = :userId', { userId: user.id })
.andWhere('c.id > :cursor', { cursor })
.orderBy('c.id', 'ASC')
.limit(500)
.getRawMany<{ id: string }>();
if (clipNotes.length === 0) break;
cursor = clipNotes.at(-1)?.id ?? '';
promises.push(this.clipNotesRepository.createQueryBuilder()
.delete()
.where('id IN (:...ids)', { ids: clipNotes.map((clipNote) => clipNote.id) })
.execute());
}
await Promise.allSettled([
this.followRequestsRepository.delete({ followeeId: user.id }),
this.followRequestsRepository.delete({ followerId: user.id }),
this.antennasRepository.delete({ userId: user.id }),
this.webhooksRepository.delete({ userId: user.id }),
this.userListsRepository.delete({ userId: user.id }),
this.clipsRepository.delete({ userId: user.id }),
...promises,
this.userListMembershipsRepository.delete({ userId: user.id }),
]);
await this.queueService.createUserSuspendJob(user);
if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信

View File

@ -7,6 +7,7 @@ import { Injectable, Inject } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { MiUser } from '@/models/User.js';
import { AppLockService } from '@/core/AppLockService.js';
import { addTime, dateUTC, subtractTime } from '@/misc/prelude/time.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import Chart from '../core.js';
@ -54,10 +55,30 @@ export default class PerUserPvChart extends Chart<typeof schema> { // eslint-dis
}
@bindThis
public async getChartUsers(span: 'hour' | 'day', order: 'ASC' | 'DESC', amount: number, cursor: Date | null, limit = 0, offset = 0): Promise<{
userId: string;
count: number;
}[]> {
return await this.getChartPv(span, amount, cursor, limit, offset, order);
public async getUsersRanking(span: 'hour' | 'day', order: 'ASC' | 'DESC', amount: number, cursor: Date | null, limit = 0, offset = 0): Promise<{ userId: string; count: number; }[]> {
const [y, m, d, h, _m, _s, _ms] = cursor ? Chart.parseDate(subtractTime(addTime(cursor, 1, span), 1)) : Chart.getCurrentDate();
const [y2, m2, d2, h2] = cursor ? Chart.parseDate(addTime(cursor, 1, span)) : [] as never;
const lt = dateUTC([y, m, d, h, _m, _s, _ms]);
const gt =
span === 'day' ? subtractTime(cursor ? dateUTC([y2, m2, d2, 0]) : dateUTC([y, m, d, 0]), amount - 1, 'day') :
span === 'hour' ? subtractTime(cursor ? dateUTC([y2, m2, d2, h2]) : dateUTC([y, m, d, h]), amount - 1, 'hour') :
new Error('not happen') as never;
const repository =
span === 'hour' ? this.repositoryForHour :
span === 'day' ? this.repositoryForDay :
new Error('not happen') as never;
// ログ取得
return await repository.createQueryBuilder()
.select('"group" as "userId", sum("___upv_user" + "___upv_visitor") as "count"')
.where('date BETWEEN :gt AND :lt', { gt: Chart.dateToTimestamp(gt), lt: Chart.dateToTimestamp(lt) })
.groupBy('"userId"')
.orderBy('"count"', order)
.offset(offset)
.limit(limit)
.getRawMany<{ userId: string, count: number }>();
}
}

View File

@ -147,10 +147,8 @@ export default abstract class Chart<T extends Schema> {
// ↓にしたいけどfindOneとかで型エラーになる
//private repositoryForHour: Repository<RawRecord<T>>;
//private repositoryForDay: Repository<RawRecord<T>>;
private repositoryForHour: Repository<{ id: number; group?: string | null; date: number;}>;
private repositoryForDay: Repository<{ id: number; group?: string | null; date: number;}>;
private repositoryUserPvForHour: Repository<{ id: number; group?: string | null; date: number; ___pv_user:number; ___upv_user:number; ___pv_visitor:number; ___upv_visitor:number;}>;
private repositoryUserPvForDay: Repository<{ id: number; group?: string | null; date: number; ___pv_user:number; ___upv_user:number; ___pv_visitor:number; ___upv_visitor:number;}>;
protected repositoryForHour: Repository<{ id: number; group?: string | null; date: number; }>;
protected repositoryForDay: Repository<{ id: number; group?: string | null; date: number; }>;
/**
* 1(CASCADE削除などアプリケーション側で感知できない変動によるズレの修正用)
*/
@ -186,11 +184,11 @@ export default abstract class Chart<T extends Schema> {
return columns;
}
private static dateToTimestamp(x: Date): number {
protected static dateToTimestamp(x: Date): number {
return Math.floor(x.getTime() / 1000);
}
private static parseDate(date: Date): [number, number, number, number, number, number, number] {
protected static parseDate(date: Date): [number, number, number, number, number, number, number] {
const y = date.getUTCFullYear();
const m = date.getUTCMonth();
const d = date.getUTCDate();
@ -202,7 +200,7 @@ export default abstract class Chart<T extends Schema> {
return [y, m, d, h, _m, _s, _ms];
}
private static getCurrentDate() {
protected static getCurrentDate() {
return Chart.parseDate(new Date());
}
@ -274,8 +272,6 @@ export default abstract class Chart<T extends Schema> {
const { hour, day } = Chart.schemaToEntity(name, schema, grouped);
this.repositoryForHour = db.getRepository<{ id: number; group?: string | null; date: number; }>(hour);
this.repositoryForDay = db.getRepository<{ id: number; group?: string | null; date: number; }>(day);
this.repositoryUserPvForHour = db.getRepository<{ id: number; group?: string | null; date: number; ___pv_user:number; ___upv_user:number; ___pv_visitor:number; ___upv_visitor:number;}>(hour);
this.repositoryUserPvForDay = db.getRepository<{ id: number; group?: string | null; date: number; ___pv_user:number; ___upv_user:number; ___pv_visitor:number; ___upv_visitor:number;}>(day);
}
@bindThis
@ -725,37 +721,4 @@ export default abstract class Chart<T extends Schema> {
}
return object as Unflatten<ChartResult<T>>;
}
@bindThis
public async getChartPv(span: 'hour' | 'day', amount: number, cursor: Date | null, limit: number, offset: number, order: 'ASC' | 'DESC'): Promise<
{
userId: string,
count: number,
}[]
> {
const [y, m, d, h, _m, _s, _ms] = cursor ? Chart.parseDate(subtractTime(addTime(cursor, 1, span), 1)) : Chart.getCurrentDate();
const [y2, m2, d2, h2] = cursor ? Chart.parseDate(addTime(cursor, 1, span)) : [] as never;
const lt = dateUTC([y, m, d, h, _m, _s, _ms]);
const gt =
span === 'day' ? subtractTime(cursor ? dateUTC([y2, m2, d2, 0]) : dateUTC([y, m, d, 0]), amount - 1, 'day') :
span === 'hour' ? subtractTime(cursor ? dateUTC([y2, m2, d2, h2]) : dateUTC([y, m, d, h]), amount - 1, 'hour') :
new Error('not happen') as never;
const repository =
span === 'hour' ? this.repositoryUserPvForHour :
span === 'day' ? this.repositoryUserPvForDay :
new Error('not happen') as never;
// ログ取得
return await repository.createQueryBuilder()
.select('"group" as "userId", sum("___upv_user" + "___upv_visitor") as "count"')
.where('date BETWEEN :gt AND :lt', { gt: Chart.dateToTimestamp(gt), lt: Chart.dateToTimestamp(lt) })
.groupBy('"userId"')
.orderBy('"count"', order)
.offset(offset)
.limit(limit)
.getRawMany<{ userId: string, count: number }>();
}
}

View File

@ -16,6 +16,7 @@ import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMu
import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js';
import { CleanProcessorService } from './processors/CleanProcessorService.js';
import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js';
import { UserSuspendProcessorService } from './processors/UserSuspendProcessorService.js';
import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js';
import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js';
import { DeleteFileProcessorService } from './processors/DeleteFileProcessorService.js';
@ -68,6 +69,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
ImportUserListsProcessorService,
ImportCustomEmojisProcessorService,
ImportAntennasProcessorService,
UserSuspendProcessorService,
DeleteAccountProcessorService,
DeleteFileProcessorService,
CleanRemoteFilesProcessorService,

View File

@ -28,6 +28,7 @@ import { ImportBlockingProcessorService } from './processors/ImportBlockingProce
import { ImportUserListsProcessorService } from './processors/ImportUserListsProcessorService.js';
import { ImportCustomEmojisProcessorService } from './processors/ImportCustomEmojisProcessorService.js';
import { ImportAntennasProcessorService } from './processors/ImportAntennasProcessorService.js';
import { UserSuspendProcessorService } from './processors/UserSuspendProcessorService.js';
import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js';
import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js';
import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js';
@ -106,6 +107,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private importUserListsProcessorService: ImportUserListsProcessorService,
private importCustomEmojisProcessorService: ImportCustomEmojisProcessorService,
private importAntennasProcessorService: ImportAntennasProcessorService,
private userSuspendProcessorService: UserSuspendProcessorService,
private deleteAccountProcessorService: DeleteAccountProcessorService,
private deleteFileProcessorService: DeleteFileProcessorService,
private cleanRemoteFilesProcessorService: CleanRemoteFilesProcessorService,
@ -184,6 +186,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
case 'importCustomEmojis': return this.importCustomEmojisProcessorService.process(job);
case 'importAntennas': return this.importAntennasProcessorService.process(job);
case 'deleteAccount': return this.deleteAccountProcessorService.process(job);
case 'userSuspend': return this.userSuspendProcessorService.process(job);
case 'reportAbuse': return this.reportAbuseProcessorService.process(job);
default: throw new Error(`unrecognized job type ${job.name} for db`);
}

View File

@ -40,7 +40,7 @@ export class DeleteAccountProcessorService {
private roleService: RoleService,
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('delete-account');
this.logger = this.queueLoggerService.logger.createSubLogger('account:delete');
}
private async deleteNotes(user: MiUser) {

View File

@ -51,7 +51,7 @@ export class InboxProcessorService implements OnApplicationShutdown {
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('inbox');
this.updateInstanceQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseUpdateInstanceJobs, this.performUpdateInstance);
this.updateInstanceQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseUpdateInstanceJobs, this.performUpdateInstance);
}
@bindThis

View File

@ -0,0 +1,101 @@
import { Inject, Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
import type Logger from '@/logger.js';
import type {
AntennasRepository,
ClipNotesRepository,
ClipsRepository,
FollowRequestsRepository,
UserListMembershipsRepository,
UserListsRepository, UsersRepository,
WebhooksRepository,
} from '@/models/_.js';
import { QueueLoggerService } from '@/queue/QueueLoggerService.js';
import type * as Bull from "bullmq";
import type { DbUserSuspendJobData } from "@/queue/types.js";
@Injectable()
export class UserSuspendProcessorService {
public logger: Logger;
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
@Inject(DI.antennasRepository)
private antennasRepository: AntennasRepository,
@Inject(DI.webhooksRepository)
private webhooksRepository: WebhooksRepository,
@Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository,
@Inject(DI.clipsRepository)
private clipsRepository: ClipsRepository,
@Inject(DI.clipNotesRepository)
private clipNotesRepository: ClipNotesRepository,
@Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository,
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('account:suspend');
}
@bindThis
public async process(job: Bull.Job<DbUserSuspendJobData>): Promise<string | void> {
this.logger.warn(`Cleaning up suspended account of ${job.data.user.id} ...`, { userSuspendJobData: job.data });
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
if (user == null) {
return 'User not found';
}
const promises: Promise<unknown>[] = [];
let cursor = '';
while (true) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition, no-constant-condition
const clipNotes = await this.clipNotesRepository.createQueryBuilder('c')
.select('c.id')
.innerJoin('c.note', 'n')
.where('n.userId = :userId', { userId: user.id })
.andWhere('c.id > :cursor', { cursor })
.orderBy('c.id', 'ASC')
.limit(100)
.getRawMany<{ id: string }>();
if (clipNotes.length === 0) break;
cursor = clipNotes.at(-1)?.id ?? '';
promises.push(this.clipNotesRepository.createQueryBuilder()
.delete()
.where('id IN (:...ids)', { ids: clipNotes.map((clipNote) => clipNote.id) })
.execute());
}
await Promise.allSettled([
this.followRequestsRepository.delete({ followeeId: user.id }),
this.followRequestsRepository.delete({ followerId: user.id }),
this.antennasRepository.delete({ userId: user.id }),
this.webhooksRepository.delete({ userId: user.id }),
this.userListsRepository.delete({ userId: user.id }),
this.clipsRepository.delete({ userId: user.id }),
...promises,
this.userListMembershipsRepository.delete({ userId: user.id }),
]);
this.logger.info(`Completed cleaning up suspended account of ${job.data.user.id}`);
return 'done';
}
}

View File

@ -83,6 +83,10 @@ export type DbUserDeleteJobData = {
onlyFiles?: boolean;
};
export type DbUserSuspendJobData = {
user: ThinUser
};
export type DbUserImportJobData = {
user: ThinUser;
fileId: MiDriveFile['id'];

View File

@ -97,6 +97,11 @@ export const meta = {
type: 'number',
optional: false, nullable: false,
},
lastReadAt: {
type: 'string',
optional: false, nullable: true,
format: 'date-time',
},
},
},
},
@ -140,6 +145,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
userId: announcement.userId,
user: announcement.userInfo,
reads: announcement.reads,
lastReadAt: announcement.lastReadAt?.toISOString() ?? null,
}));
});
}

View File

@ -72,12 +72,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.hostname) {
query.andWhere('user.host = :hostname', { hostname: ps.hostname.toLowerCase() });
}
const chartUsers: { userId: string; count: number; }[] = [];
let pvRankedUsers: { userId: string; count: number; }[] | undefined = undefined;
if (ps.sort?.endsWith('pv')) {
await this.perUserPvChart.getChartUsers('hour', ps.sort === '+pv' ? 'DESC' : 'ASC', 0, null, ps.limit, ps.offset).then(users => {
chartUsers.push(...users);
});
// 直近12時間のPVランキングを取得
pvRankedUsers = await this.perUserPvChart.getUsersRanking(
'hour', ps.sort.startsWith('+') ? 'DESC' : 'ASC',
12, null, ps.limit, ps.offset,
);
}
switch (ps.sort) {
case '+follower': query.orderBy('user.followersCount', 'DESC'); break;
case '-follower': query.orderBy('user.followersCount', 'ASC'); break;
@ -85,16 +89,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
case '-createdAt': query.orderBy('user.id', 'ASC'); break;
case '+updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'DESC'); break;
case '-updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'ASC'); break;
case '+pv':
if (chartUsers.length > 0) {
query.andWhere('user.id IN (:...userIds)', { userIds: chartUsers.map(user => user.userId) });
}
break;
case '-pv':
if (chartUsers.length > 0) {
query.andWhere('user.id IN (:...userIds)', { userIds: chartUsers.map(user => user.userId) });
}
break;
case '+pv': query.andWhere((pvRankedUsers?.length ?? 0) > 0 ? 'user.id IN (:...userIds)' : '1 = 0', { userIds: pvRankedUsers?.map(user => user.userId) ?? [] }); break;
case '-pv': query.andWhere((pvRankedUsers?.length ?? 0) > 0 ? 'user.id IN (:...userIds)' : '1 = 0', { userIds: pvRankedUsers?.map(user => user.userId) ?? [] }); break;
default: query.orderBy('user.id', 'ASC'); break;
}
@ -107,14 +103,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const users = await query.getMany();
if (ps.sort === '+pv') {
users.sort((a, b) => {
const aPv = chartUsers.find(user => user.userId === a.id)?.count ?? 0;
const bPv = chartUsers.find(user => user.userId === b.id)?.count ?? 0;
const aPv = pvRankedUsers?.find(u => u.userId === a.id)?.count ?? 0;
const bPv = pvRankedUsers?.find(u => u.userId === b.id)?.count ?? 0;
return bPv - aPv;
});
} else if (ps.sort === '-pv') {
users.sort((a, b) => {
const aPv = chartUsers.find(user => user.userId === a.id)?.count ?? 0;
const bPv = chartUsers.find(user => user.userId === b.id)?.count ?? 0;
const aPv = pvRankedUsers?.find(u => u.userId === a.id)?.count ?? 0;
const bPv = pvRankedUsers?.find(u => u.userId === b.id)?.count ?? 0;
return aPv - bPv;
});
}

View File

@ -1,8 +1,19 @@
version: "3"
services:
keydbtest:
image: eqalpha/keydb:latest
dragonflytest:
image: docker.dragonflydb.io/dragonflydb/dragonfly
ulimits:
memlock: -1
environment:
DFLY_version_check: false
DFLY_tcp_backlog: 2048
DFLY_default_lua_flags: allow-undeclared-keys
DFLY_pipeline_squash: 0
DFLY_multi_exec_squash: false
DFLY_conn_io_threads: 4
DFLY_epoll_file_threads: 4
DFLY_proactor_threads: 4
ports:
- "127.0.0.1:56312:6379"

View File

@ -189,7 +189,7 @@ async function onSubmit(): Promise<void> {
submitting.value = true;
try {
await misskeyApi('signup', {
await os.apiWithDialog('signup', {
username: username.value,
password: password.value.password,
emailAddress: email.value,
@ -198,7 +198,7 @@ async function onSubmit(): Promise<void> {
'm-captcha-response': mCaptchaResponse.value,
'g-recaptcha-response': reCaptchaResponse.value,
'turnstile-response': turnstileResponse.value,
});
}, undefined, (res) => {
if (instance.emailRequiredForSignup) {
os.alert({
type: 'success',
@ -207,26 +207,18 @@ async function onSubmit(): Promise<void> {
});
emit('signupEmailPending');
} else {
const res = await misskeyApi('signin', {
username: username.value,
password: password.value.password,
});
emit('signup', res);
emit('signup', { id: res.id, i: res.token });
if (props.autoSet) {
return login(res.i);
login(res.token);
}
}
});
} catch {
submitting.value = false;
hcaptcha.value?.reset?.();
recaptcha.value?.reset?.();
turnstile.value?.reset?.();
os.alert({
type: 'error',
text: i18n.ts.somethingHappened,
});
}
}
</script>

View File

@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts._announcement.silence }}
<template #caption>{{ i18n.ts._announcement.silenceDescription }}</template>
</MkSwitch>
<p v-if="reads">{{ i18n.tsx.nUsersRead({ n: reads }) }}</p>
<p v-if="reads">{{ i18n.tsx.nUsersRead({ n: reads }) }} <span v-if="lastReadAt">(<MkTime :time="lastReadAt" mode="absolute"/>)</span></p>
<MkUserCardMini v-if="props.user.id" :user="props.user"></MkUserCardMini>
<MkButton v-if="announcement" danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
@ -94,6 +94,7 @@ const closeDuration = ref<number>(props.announcement ? props.announcement.closeD
const displayOrder = ref<number>(props.announcement ? props.announcement.displayOrder : 0);
const silence = ref<boolean>(props.announcement ? props.announcement.silence : false);
const reads = ref<number>(props.announcement ? props.announcement.reads : 0);
const lastReadAt = ref<string | null>(props.announcement ? props.announcement.lastReadAt : null);
const emit = defineEmits<{
(ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void,

View File

@ -34,11 +34,19 @@ export const apiWithDialog = (<E extends keyof Misskey.Endpoints = keyof Misskey
endpoint: E,
data: P = {} as P,
token?: string | null | undefined,
) => {
onSuccess?: ((res: Misskey.api.SwitchCaseResponseType<E, P>) => void) | null | undefined,
onFailure?: ((err: Misskey.api.APIError) => void) | null,
): Promise<Misskey.api.SwitchCaseResponseType<E, P>> => {
const promise = misskeyApi(endpoint, data, token);
promiseDialog(promise, null, async (err) => {
promiseDialog(promise, onSuccess, onFailure ?? (err => apiErrorHandler(err, endpoint)));
return promise;
});
export async function apiErrorHandler(err: Misskey.api.APIError, endpoint?: string): Promise<void> {
let title: string | undefined;
let text = err.message + '\n' + err.id;
if (err.code === 'INTERNAL_ERROR') {
title = i18n.ts.internalServerError;
text = i18n.ts.internalServerErrorDescription;
@ -71,23 +79,32 @@ export const apiWithDialog = (<E extends keyof Misskey.Endpoints = keyof Misskey
} else if (err.code === 'ROLE_PERMISSION_DENIED') {
title = i18n.ts.permissionDeniedError;
text = i18n.ts.permissionDeniedErrorDescription;
} else if (err.code.startsWith('TOO_MANY')) {
} else if (err.code?.startsWith('TOO_MANY')) {
title = i18n.ts.youCannotCreateAnymore;
text = `${i18n.ts.error}: ${err.id}`;
} else if (err.message.startsWith('Unexpected token')) {
}
// @ts-expect-error Misskey内部で定義されていない不明なエラー
if (!err.id && (err.statusCode ?? 0) > 499) {
title = i18n.ts.gotInvalidResponseError;
text = i18n.ts.gotInvalidResponseErrorDescription;
}
if (err.id && !title) {
title = i18n.ts.somethingHappened;
} else if (!title) {
title = i18n.ts.somethingHappened;
text = err.message;
}
alert({
type: 'error',
title,
text,
details: err.info,
// @ts-expect-error Misskeyのエラーならinfoを、そうでなければそのまま表示
details: err.id ? err.info : err as unknown,
});
});
return promise;
}) as typeof misskeyApi;
}
export function promiseDialog<T>(
promise: Promise<T>,

View File

@ -149,7 +149,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
</span>
<span>{{ announcement.title }}</span>
<span v-if="announcement.reads > 0" style="margin-left: auto; opacity: 0.7;">{{ i18n.ts.messageRead }}</span>
<span v-if="announcement.reads > 0" style="margin-left: auto; opacity: 0.7;">{{ i18n.ts.messageRead }} <span v-if="announcement.lastReadAt">(<MkTime :time="announcement.lastReadAt" mode="absolute"/>)</span></span>
</div>
</div>
</template>

View File

@ -76,7 +76,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="announcement.silence" :helpText="i18n.ts._announcement.silenceDescription">
{{ i18n.ts._announcement.silence }}
</MkSwitch>
<p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }}</p>
<p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }} <span v-if="announcement.lastReadAt">(<MkTime :time="announcement.lastReadAt" mode="absolute"/>)</span></p>
<MkUserCardMini v-if="announcement.userId" :user="announcement.user" @click="editUser(announcement)"></MkUserCardMini>
<MkButton v-else class="button" inline primary @click="editUser(announcement)">{{ i18n.ts.specifyUser }}</MkButton>
<div class="buttons _buttons">

View File

@ -114,7 +114,7 @@ async function deleteAccount() {
{
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.deleteAccountConfirmAndWarn,
text: i18n.ts.deleteAccountConfirm,
okWaitInitiate: 'dialog',
okWaitDuration: 5,
});

View File

@ -44,14 +44,15 @@ export function misskeyApi<
},
signal,
}).then(async (res) => {
const body = res.status === 204 ? null : await res.json();
if (res.status === 200) {
if (res.ok && res.status !== 204) {
const body = await res.json();
resolve(body);
} else if (res.status === 204) {
resolve(undefined as _ResT); // void -> undefined
} else {
reject(body.error);
// エラー応答で JSON.parse に失敗した場合は HTTP ステータスコードとメッセージを返す
const body = await res.json().catch(() => ({ statusCode: res.status, message: res.statusText }));
reject(typeof body.error === 'object' ? body.error : body);
}
}).catch(reject);
});

View File

@ -1,7 +1,7 @@
{
"type": "module",
"name": "misskey-js",
"version": "2024.5.0-host.2e",
"version": "2024.5.0-host.2f",
"description": "Misskey SDK for JavaScript",
"types": "./built/dts/index.d.ts",
"exports": {

View File

@ -6168,6 +6168,8 @@ export type operations = {
userId: string | null;
user: components['schemas']['UserLite'] | null;
reads: number;
/** Format: date-time */
lastReadAt: string | null;
})[];
};
};