mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-05-15 09:15:47 +02:00
Merge branch 'develop' into renovate/major-backend-update-dependencies
This commit is contained in:
@@ -184,9 +184,9 @@ export type Config = {
|
||||
authUrl: string;
|
||||
driveUrl: string;
|
||||
userAgent: string;
|
||||
frontendEntry: string;
|
||||
frontendEntry: { file: string | null };
|
||||
frontendManifestExists: boolean;
|
||||
frontendEmbedEntry: string;
|
||||
frontendEmbedEntry: { file: string | null };
|
||||
frontendEmbedManifestExists: boolean;
|
||||
mediaProxy: string;
|
||||
externalMediaProxyEnabled: boolean;
|
||||
@@ -235,10 +235,10 @@ export function loadConfig(): Config {
|
||||
const frontendEmbedManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_embed_vite_/manifest.json');
|
||||
const frontendManifest = frontendManifestExists ?
|
||||
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_vite_/manifest.json`, 'utf-8'))
|
||||
: { 'src/_boot_.ts': { file: 'src/_boot_.ts' } };
|
||||
: { 'src/_boot_.ts': { file: null } };
|
||||
const frontendEmbedManifest = frontendEmbedManifestExists ?
|
||||
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8'))
|
||||
: { 'src/boot.ts': { file: 'src/boot.ts' } };
|
||||
: { 'src/boot.ts': { file: null } };
|
||||
|
||||
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ import { CacheService } from '@/core/CacheService.js';
|
||||
import { isReply } from '@/misc/is-reply.js';
|
||||
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
|
||||
|
||||
type NoteFilter = (note: MiNote) => boolean;
|
||||
|
||||
type TimelineOptions = {
|
||||
untilId: string | null,
|
||||
sinceId: string | null,
|
||||
@@ -28,7 +30,7 @@ type TimelineOptions = {
|
||||
me?: { id: MiUser['id'] } | undefined | null,
|
||||
useDbFallback: boolean,
|
||||
redisTimelines: FanoutTimelineName[],
|
||||
noteFilter?: (note: MiNote) => boolean,
|
||||
noteFilter?: NoteFilter,
|
||||
alwaysIncludeMyNotes?: boolean;
|
||||
ignoreAuthorFromBlock?: boolean;
|
||||
ignoreAuthorFromMute?: boolean;
|
||||
@@ -79,7 +81,7 @@ export class FanoutTimelineEndpointService {
|
||||
const shouldFallbackToDb = noteIds.length === 0 || ps.sinceId != null && ps.sinceId < oldestNoteId;
|
||||
|
||||
if (!shouldFallbackToDb) {
|
||||
let filter = ps.noteFilter ?? (_note => true);
|
||||
let filter = ps.noteFilter ?? (_note => true) as NoteFilter;
|
||||
|
||||
if (ps.alwaysIncludeMyNotes && ps.me) {
|
||||
const me = ps.me;
|
||||
@@ -145,15 +147,11 @@ export class FanoutTimelineEndpointService {
|
||||
{
|
||||
const parentFilter = filter;
|
||||
filter = (note) => {
|
||||
const noteJoined = note as MiNote & {
|
||||
renoteUser: MiUser | null;
|
||||
replyUser: MiUser | null;
|
||||
};
|
||||
if (!ps.ignoreAuthorFromUserSuspension) {
|
||||
if (note.user!.isSuspended) return false;
|
||||
}
|
||||
if (note.userId !== note.renoteUserId && noteJoined.renoteUser?.isSuspended) return false;
|
||||
if (note.userId !== note.replyUserId && noteJoined.replyUser?.isSuspended) return false;
|
||||
if (note.userId !== note.renoteUserId && note.renote?.user?.isSuspended) return false;
|
||||
if (note.userId !== note.replyUserId && note.reply?.user?.isSuspended) return false;
|
||||
|
||||
return parentFilter(note);
|
||||
};
|
||||
@@ -200,7 +198,7 @@ export class FanoutTimelineEndpointService {
|
||||
return await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit);
|
||||
}
|
||||
|
||||
private async getAndFilterFromDb(noteIds: string[], noteFilter: (note: MiNote) => boolean, idCompare: (a: string, b: string) => number): Promise<MiNote[]> {
|
||||
private async getAndFilterFromDb(noteIds: string[], noteFilter: NoteFilter, idCompare: (a: string, b: string) => number): Promise<MiNote[]> {
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
|
||||
@@ -421,7 +421,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
emojis,
|
||||
userId: user.id,
|
||||
localOnly: data.localOnly!,
|
||||
reactionAcceptance: data.reactionAcceptance,
|
||||
reactionAcceptance: data.reactionAcceptance ?? null,
|
||||
visibility: data.visibility as any,
|
||||
visibleUserIds: data.visibility === 'specified'
|
||||
? data.visibleUsers
|
||||
@@ -483,7 +483,11 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
await this.notesRepository.insert(insert);
|
||||
}
|
||||
|
||||
return insert;
|
||||
return {
|
||||
...insert,
|
||||
reply: data.reply ?? null,
|
||||
renote: data.renote ?? null,
|
||||
};
|
||||
} catch (e) {
|
||||
// duplicate key error
|
||||
if (isDuplicateKeyValueError(e)) {
|
||||
|
||||
@@ -62,7 +62,6 @@ export class NoteDeleteService {
|
||||
*/
|
||||
async delete(user: { id: MiUser['id']; uri: MiUser['uri']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, quiet = false, deleter?: MiUser) {
|
||||
const deletedAt = new Date();
|
||||
const cascadingNotes = await this.findCascadingNotes(note);
|
||||
|
||||
if (note.replyId) {
|
||||
await this.notesRepository.decrement({ id: note.replyId }, 'repliesCount', 1);
|
||||
@@ -90,15 +89,6 @@ export class NoteDeleteService {
|
||||
|
||||
this.deliverToConcerned(user, note, content);
|
||||
}
|
||||
|
||||
// also deliver delete activity to cascaded notes
|
||||
const federatedLocalCascadingNotes = (cascadingNotes).filter(note => !note.localOnly && note.userHost == null); // filter out local-only notes
|
||||
for (const cascadingNote of federatedLocalCascadingNotes) {
|
||||
if (!cascadingNote.user) continue;
|
||||
if (!this.userEntityService.isLocalUser(cascadingNote.user)) continue;
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${cascadingNote.id}`), cascadingNote.user));
|
||||
this.deliverToConcerned(cascadingNote.user, cascadingNote, content);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
this.notesChart.update(note, false);
|
||||
@@ -118,9 +108,6 @@ export class NoteDeleteService {
|
||||
}
|
||||
}
|
||||
|
||||
for (const cascadingNote of cascadingNotes) {
|
||||
this.searchService.unindexNote(cascadingNote);
|
||||
}
|
||||
this.searchService.unindexNote(note);
|
||||
|
||||
await this.notesRepository.delete({
|
||||
@@ -140,29 +127,6 @@ export class NoteDeleteService {
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async findCascadingNotes(note: MiNote): Promise<MiNote[]> {
|
||||
const recursive = async (noteId: string): Promise<MiNote[]> => {
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.where('note.replyId = :noteId', { noteId })
|
||||
.orWhere(new Brackets(q => {
|
||||
q.where('note.renoteId = :noteId', { noteId })
|
||||
.andWhere('note.text IS NOT NULL');
|
||||
}))
|
||||
.leftJoinAndSelect('note.user', 'user');
|
||||
const replies = await query.getMany();
|
||||
|
||||
return [
|
||||
replies,
|
||||
...await Promise.all(replies.map(reply => recursive(reply.id))),
|
||||
].flat();
|
||||
};
|
||||
|
||||
const cascadingNotes: MiNote[] = await recursive(note.id);
|
||||
|
||||
return cascadingNotes;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async getMentionedRemoteUsers(note: MiNote) {
|
||||
const where = [] as any[];
|
||||
|
||||
@@ -360,7 +360,7 @@ export class QueryService {
|
||||
public generateSuspendedUserQueryForNote(q: SelectQueryBuilder<any>, excludeAuthor?: boolean): void {
|
||||
if (excludeAuthor) {
|
||||
const brakets = (user: string) => new Brackets(qb => qb
|
||||
.where(`note.${user}Id IS NULL`)
|
||||
.where(`${user}.id IS NULL`) // そもそもreplyやrenoteではない、もしくはleftjoinなどでuserが存在しなかった場合を考慮
|
||||
.orWhere(`user.id = ${user}.id`)
|
||||
.orWhere(`${user}.isSuspended = FALSE`));
|
||||
q
|
||||
@@ -368,7 +368,7 @@ export class QueryService {
|
||||
.andWhere(brakets('renoteUser'));
|
||||
} else {
|
||||
const brakets = (user: string) => new Brackets(qb => qb
|
||||
.where(`note.${user}Id IS NULL`)
|
||||
.where(`${user}.id IS NULL`) // そもそもreplyやrenoteではない、もしくはleftjoinなどでuserが存在しなかった場合を考慮
|
||||
.orWhere(`${user}.isSuspended = FALSE`));
|
||||
q
|
||||
.andWhere('user.isSuspended = FALSE')
|
||||
|
||||
@@ -17,6 +17,7 @@ import { bindThis } from '@/decorators.js';
|
||||
import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
|
||||
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
|
||||
import { type SystemWebhookPayload } from '@/core/SystemWebhookService.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { type UserWebhookPayload } from './UserWebhookService.js';
|
||||
import type {
|
||||
DbJobData,
|
||||
@@ -39,7 +40,6 @@ import type {
|
||||
} from './QueueModule.js';
|
||||
import type httpSignature from '@peertube/http-signature';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
|
||||
export const QUEUE_TYPES = [
|
||||
'system',
|
||||
@@ -53,6 +53,37 @@ export const QUEUE_TYPES = [
|
||||
'systemWebhookDeliver',
|
||||
] as const;
|
||||
|
||||
const REPEATABLE_SYSTEM_JOB_DEF = [{
|
||||
name: 'tickCharts',
|
||||
pattern: '55 * * * *',
|
||||
}, {
|
||||
name: 'resyncCharts',
|
||||
pattern: '0 0 * * *',
|
||||
}, {
|
||||
name: 'cleanCharts',
|
||||
pattern: '0 0 * * *',
|
||||
}, {
|
||||
name: 'aggregateRetention',
|
||||
pattern: '0 0 * * *',
|
||||
}, {
|
||||
name: 'clean',
|
||||
pattern: '0 0 * * *',
|
||||
}, {
|
||||
name: 'checkExpiredMutings',
|
||||
pattern: '*/5 * * * *',
|
||||
}, {
|
||||
name: 'bakeBufferedReactions',
|
||||
pattern: '0 0 * * *',
|
||||
}, {
|
||||
name: 'checkModeratorsActivity',
|
||||
// 毎時30分に起動
|
||||
pattern: '30 * * * *',
|
||||
}, {
|
||||
name: 'cleanRemoteNotes',
|
||||
// 毎日午前4時に起動(最も人の少ない時間帯)
|
||||
pattern: '0 4 * * *',
|
||||
}];
|
||||
|
||||
@Injectable()
|
||||
export class QueueService {
|
||||
constructor(
|
||||
@@ -69,61 +100,30 @@ export class QueueService {
|
||||
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
|
||||
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
|
||||
) {
|
||||
this.systemQueue.add('tickCharts', {
|
||||
}, {
|
||||
repeat: { pattern: '55 * * * *' },
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 30,
|
||||
});
|
||||
for (const def of REPEATABLE_SYSTEM_JOB_DEF) {
|
||||
this.systemQueue.upsertJobScheduler(def.name, {
|
||||
pattern: def.pattern,
|
||||
}, {
|
||||
name: def.name,
|
||||
opts: {
|
||||
// 期限ではなくcountで設定したいが、ジョブごとではなくキュー全体でカウントされるため、高頻度で実行されるジョブによって低頻度で実行されるジョブのログが消えることになる
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.systemQueue.add('resyncCharts', {
|
||||
}, {
|
||||
repeat: { pattern: '0 0 * * *' },
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 30,
|
||||
});
|
||||
|
||||
this.systemQueue.add('cleanCharts', {
|
||||
}, {
|
||||
repeat: { pattern: '0 0 * * *' },
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 30,
|
||||
});
|
||||
|
||||
this.systemQueue.add('aggregateRetention', {
|
||||
}, {
|
||||
repeat: { pattern: '0 0 * * *' },
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 30,
|
||||
});
|
||||
|
||||
this.systemQueue.add('clean', {
|
||||
}, {
|
||||
repeat: { pattern: '0 0 * * *' },
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 30,
|
||||
});
|
||||
|
||||
this.systemQueue.add('checkExpiredMutings', {
|
||||
}, {
|
||||
repeat: { pattern: '*/5 * * * *' },
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 30,
|
||||
});
|
||||
|
||||
this.systemQueue.add('bakeBufferedReactions', {
|
||||
}, {
|
||||
repeat: { pattern: '0 0 * * *' },
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 30,
|
||||
});
|
||||
|
||||
this.systemQueue.add('checkModeratorsActivity', {
|
||||
}, {
|
||||
// 毎時30分に起動
|
||||
repeat: { pattern: '30 * * * *' },
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 30,
|
||||
// 古いバージョンで作成され現在使われなくなったrepeatableジョブをクリーンアップ
|
||||
this.systemQueue.getJobSchedulers().then(schedulers => {
|
||||
for (const scheduler of schedulers) {
|
||||
if (!REPEATABLE_SYSTEM_JOB_DEF.some(def => def.name === scheduler.key)) {
|
||||
this.systemQueue.removeJobScheduler(scheduler.key);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -810,6 +810,13 @@ export class QueueService {
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async queueGetJobLogs(queueType: typeof QUEUE_TYPES[number], jobId: string) {
|
||||
const queue = this.getQueue(queueType);
|
||||
const result = await queue.getJobLogs(jobId);
|
||||
return result.logs;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async queueGetJobs(queueType: typeof QUEUE_TYPES[number], jobTypes: JobType[], search?: string) {
|
||||
const RETURN_LIMIT = 100;
|
||||
|
||||
@@ -227,9 +227,9 @@ export class SearchService {
|
||||
|
||||
if (opts.host) {
|
||||
if (opts.host === '.') {
|
||||
query.andWhere('user.host IS NULL');
|
||||
query.andWhere('note.userHost IS NULL');
|
||||
} else {
|
||||
query.andWhere('user.host = :host', { host: opts.host });
|
||||
query.andWhere('note.userHost = :host', { host: opts.host });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { EntityNotFoundError, In } from 'typeorm';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
@@ -46,6 +46,17 @@ function getAppearNoteIds(notes: MiNote[]): Set<string> {
|
||||
return appearNoteIds;
|
||||
}
|
||||
|
||||
async function nullIfEntityNotFound<T>(promise: Promise<T>): Promise<T | null> {
|
||||
try {
|
||||
return await promise;
|
||||
} catch (err) {
|
||||
if (err instanceof EntityNotFoundError) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NoteEntityService implements OnModuleInit {
|
||||
private userEntityService: UserEntityService;
|
||||
@@ -436,19 +447,21 @@ export class NoteEntityService implements OnModuleInit {
|
||||
...(opts.detail ? {
|
||||
clippedCount: note.clippedCount,
|
||||
|
||||
reply: note.replyId ? this.pack(note.reply ?? note.replyId, me, {
|
||||
// そもそもJOINしていない場合はundefined、JOINしたけど存在していなかった場合はnullで区別される
|
||||
reply: (note.replyId && note.reply === null) ? null : note.replyId ? nullIfEntityNotFound(this.pack(note.reply ?? note.replyId, me, {
|
||||
detail: false,
|
||||
skipHide: opts.skipHide,
|
||||
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
|
||||
_hint_: options?._hint_,
|
||||
}) : undefined,
|
||||
})) : undefined,
|
||||
|
||||
renote: note.renoteId ? this.pack(note.renote ?? note.renoteId, me, {
|
||||
// そもそもJOINしていない場合はundefined、JOINしたけど存在していなかった場合はnullで区別される
|
||||
renote: (note.renoteId && note.renote === null) ? null : note.renoteId ? nullIfEntityNotFound(this.pack(note.renote ?? note.renoteId, me, {
|
||||
detail: true,
|
||||
skipHide: opts.skipHide,
|
||||
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
|
||||
_hint_: options?._hint_,
|
||||
}) : undefined,
|
||||
})) : undefined,
|
||||
|
||||
poll: note.hasPoll ? this.populatePoll(note, meId) : undefined,
|
||||
|
||||
@@ -591,7 +604,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||
private findNoteOrFail(id: string): Promise<MiNote> {
|
||||
return this.notesRepository.findOneOrFail({
|
||||
where: { id },
|
||||
relations: ['user'],
|
||||
relations: ['user', 'renote', 'reply'],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -654,7 +654,7 @@ export class MiMeta {
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128,
|
||||
default: 'all',
|
||||
default: 'none',
|
||||
})
|
||||
public federation: 'all' | 'specified' | 'none';
|
||||
|
||||
@@ -701,6 +701,21 @@ export class MiMeta {
|
||||
default: true,
|
||||
})
|
||||
public allowExternalApRedirect: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public enableRemoteNotesCleaning: boolean;
|
||||
|
||||
@Column('integer', {
|
||||
default: 60, // minutes
|
||||
})
|
||||
public remoteNotesCleaningMaxProcessingDurationInMinutes: number;
|
||||
|
||||
@Column('integer', {
|
||||
default: 90, // days
|
||||
})
|
||||
public remoteNotesCleaningExpiryDaysForEachNotes: number;
|
||||
}
|
||||
|
||||
export type SoftwareSuspension = {
|
||||
|
||||
@@ -36,7 +36,7 @@ export class MiNote {
|
||||
public replyId: MiNote['id'] | null;
|
||||
|
||||
@ManyToOne(type => MiNote, {
|
||||
onDelete: 'CASCADE',
|
||||
createForeignKeyConstraints: false,
|
||||
})
|
||||
@JoinColumn()
|
||||
public reply: MiNote | null;
|
||||
@@ -50,7 +50,7 @@ export class MiNote {
|
||||
public renoteId: MiNote['id'] | null;
|
||||
|
||||
@ManyToOne(type => MiNote, {
|
||||
onDelete: 'CASCADE',
|
||||
createForeignKeyConstraints: false,
|
||||
})
|
||||
@JoinColumn()
|
||||
public renote: MiNote | null;
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
|
||||
import { QueueLoggerService } from './QueueLoggerService.js';
|
||||
import { QueueProcessorService } from './QueueProcessorService.js';
|
||||
import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
|
||||
@@ -18,6 +17,8 @@ import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMu
|
||||
import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js';
|
||||
import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js';
|
||||
import { CleanProcessorService } from './processors/CleanProcessorService.js';
|
||||
import { CheckModeratorsActivityProcessorService } from './processors/CheckModeratorsActivityProcessorService.js';
|
||||
import { CleanRemoteNotesProcessorService } from './processors/CleanRemoteNotesProcessorService.js';
|
||||
import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js';
|
||||
import { DeleteAccountProcessorService } from './processors/DeleteAccountProcessorService.js';
|
||||
import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js';
|
||||
@@ -83,6 +84,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
|
||||
AggregateRetentionProcessorService,
|
||||
CheckExpiredMutingsProcessorService,
|
||||
CheckModeratorsActivityProcessorService,
|
||||
CleanRemoteNotesProcessorService,
|
||||
QueueProcessorService,
|
||||
],
|
||||
exports: [
|
||||
|
||||
@@ -43,6 +43,7 @@ import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMu
|
||||
import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js';
|
||||
import { CleanProcessorService } from './processors/CleanProcessorService.js';
|
||||
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
|
||||
import { CleanRemoteNotesProcessorService } from './processors/CleanRemoteNotesProcessorService.js';
|
||||
import { QueueLoggerService } from './QueueLoggerService.js';
|
||||
import { QUEUE, baseWorkerOptions } from './const.js';
|
||||
|
||||
@@ -123,6 +124,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService,
|
||||
private checkModeratorsActivityProcessorService: CheckModeratorsActivityProcessorService,
|
||||
private cleanProcessorService: CleanProcessorService,
|
||||
private cleanRemoteNotesProcessorService: CleanRemoteNotesProcessorService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger;
|
||||
|
||||
@@ -164,6 +166,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
case 'bakeBufferedReactions': return this.bakeBufferedReactionsProcessorService.process();
|
||||
case 'checkModeratorsActivity': return this.checkModeratorsActivityProcessorService.process();
|
||||
case 'clean': return this.cleanProcessorService.process();
|
||||
case 'cleanRemoteNotes': return this.cleanRemoteNotesProcessorService.process(job);
|
||||
default: throw new Error(`unrecognized job type ${job.name} for system`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MiMeta, MiNote, NotesRepository } from '@/models/_.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
@Injectable()
|
||||
export class CleanRemoteNotesProcessorService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger.createSubLogger('clean-remote-notes');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<Record<string, unknown>>): Promise<{
|
||||
deletedCount: number;
|
||||
oldest: number | null;
|
||||
newest: number | null;
|
||||
skipped?: boolean;
|
||||
}> {
|
||||
if (!this.meta.enableRemoteNotesCleaning) {
|
||||
this.logger.info('Remote notes cleaning is disabled, skipping...');
|
||||
return {
|
||||
deletedCount: 0,
|
||||
oldest: null,
|
||||
newest: null,
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.info('cleaning remote notes...');
|
||||
|
||||
const maxDuration = this.meta.remoteNotesCleaningMaxProcessingDurationInMinutes * 60 * 1000; // Convert minutes to milliseconds
|
||||
const startAt = Date.now();
|
||||
|
||||
const MAX_NOTE_COUNT_PER_QUERY = 50;
|
||||
|
||||
//#retion queries
|
||||
// We use string literals instead of query builder for several reasons:
|
||||
// - for removeCondition, we need to use it in having clause, which is not supported by Brackets.
|
||||
// - for recursive part, we need to preserve the order of columns, but typeorm query builder does not guarantee the order of columns in the result query
|
||||
|
||||
// The condition for removing the notes.
|
||||
// The note must be:
|
||||
// - old enough (older than the newestLimit)
|
||||
// - a remote note (userHost is not null).
|
||||
// - not have clipped
|
||||
// - not have pinned on the user profile
|
||||
// - not has been favorite by any user
|
||||
const removeCondition = 'note.id < :newestLimit'
|
||||
+ ' AND note."clippedCount" = 0'
|
||||
+ ' AND note."userHost" IS NOT NULL'
|
||||
// using both userId and noteId instead of just noteId to use index on user_note_pining table.
|
||||
// This is safe because notes are only pinned by the user who created them.
|
||||
+ ' AND NOT EXISTS(SELECT 1 FROM "user_note_pining" WHERE "noteId" = note."id" AND "userId" = note."userId")'
|
||||
// We cannot use userId trick because users can favorite notes from other users.
|
||||
+ ' AND NOT EXISTS(SELECT 1 FROM "note_favorite" WHERE "noteId" = note."id")'
|
||||
;
|
||||
|
||||
// The initiator query contains the oldest ${MAX_NOTE_COUNT_PER_QUERY} remote non-clipped notes
|
||||
const initiatorQuery = this.notesRepository.createQueryBuilder('note')
|
||||
.select('note.id', 'id')
|
||||
.where(removeCondition)
|
||||
.andWhere('note.id > :cursor')
|
||||
.orderBy('note.id', 'ASC')
|
||||
.limit(MAX_NOTE_COUNT_PER_QUERY);
|
||||
|
||||
// The union query queries the related notes and replies related to the initiator query
|
||||
const unionQuery = `
|
||||
SELECT "note"."id", "note"."replyId", "note"."renoteId", rn."initiatorId"
|
||||
FROM "note" "note"
|
||||
INNER JOIN "related_notes" "rn"
|
||||
ON "note"."replyId" = rn.id
|
||||
OR "note"."renoteId" = rn.id
|
||||
OR "note"."id" = rn."replyId"
|
||||
OR "note"."id" = rn."renoteId"
|
||||
`;
|
||||
|
||||
const selectRelatedNotesFromInitiatorIdsQuery = `
|
||||
SELECT "note"."id" AS "id", "note"."replyId" AS "replyId", "note"."renoteId" AS "renoteId", "note"."id" AS "initiatorId"
|
||||
FROM "note" "note" WHERE "note"."id" IN (:...initiatorIds)
|
||||
`;
|
||||
|
||||
const recursiveQuery = `(${selectRelatedNotesFromInitiatorIdsQuery}) UNION (${unionQuery})`;
|
||||
|
||||
const removableInitiatorNotesQuery = this.notesRepository.createQueryBuilder('note')
|
||||
.select('rn."initiatorId"')
|
||||
.innerJoin('related_notes', 'rn', 'note.id = rn.id')
|
||||
.groupBy('rn."initiatorId"')
|
||||
.having(`bool_and(${removeCondition})`);
|
||||
|
||||
const notesQuery = this.notesRepository.createQueryBuilder('note')
|
||||
.addCommonTableExpression(recursiveQuery, 'related_notes', { recursive: true })
|
||||
.select('note.id', 'id')
|
||||
.addSelect('rn."initiatorId"')
|
||||
.innerJoin('related_notes', 'rn', 'note.id = rn.id')
|
||||
.where(`rn."initiatorId" IN (${removableInitiatorNotesQuery.getQuery()})`)
|
||||
.distinctOn(['note.id']);
|
||||
//#endregion
|
||||
|
||||
const stats = {
|
||||
deletedCount: 0,
|
||||
oldest: null as number | null,
|
||||
newest: null as number | null,
|
||||
};
|
||||
|
||||
// The date limit for the newest note to be considered for deletion.
|
||||
// All notes newer than this limit will always be retained.
|
||||
const newestLimit = this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes));
|
||||
|
||||
let cursor = '0'; // oldest note ID to start from
|
||||
|
||||
while (true) {
|
||||
//#region check time
|
||||
const batchBeginAt = Date.now();
|
||||
|
||||
const elapsed = batchBeginAt - startAt;
|
||||
|
||||
if (elapsed >= maxDuration) {
|
||||
this.logger.info(`Reached maximum duration of ${maxDuration}ms, stopping...`);
|
||||
job.log('Reached maximum duration, stopping cleaning.');
|
||||
job.updateProgress(100);
|
||||
break;
|
||||
}
|
||||
|
||||
job.updateProgress((elapsed / maxDuration) * 100);
|
||||
//#endregion
|
||||
|
||||
// First, we fetch the initiator notes that are older than the newestLimit.
|
||||
const initiatorNotes: { id: MiNote['id'] }[] = await initiatorQuery.setParameters({ cursor, newestLimit }).getRawMany();
|
||||
|
||||
// update the cursor to the newest initiatorId found in the fetched notes.
|
||||
const newCursor = initiatorNotes.reduce((max, note) => note.id > max ? note.id : max, cursor);
|
||||
|
||||
if (initiatorNotes.length === 0 || cursor === newCursor || newCursor >= newestLimit) {
|
||||
// If no notes were found or the cursor did not change, we can stop.
|
||||
job.log('No more notes to clean. (no initiator notes found or cursor did not change.)');
|
||||
break;
|
||||
}
|
||||
|
||||
const notes: { id: MiNote['id'], initiatorId: MiNote['id'] }[] = await notesQuery.setParameters({
|
||||
initiatorIds: initiatorNotes.map(note => note.id),
|
||||
newestLimit,
|
||||
}).getRawMany();
|
||||
|
||||
cursor = newCursor;
|
||||
|
||||
if (notes.length > 0) {
|
||||
await this.notesRepository.delete(notes.map(note => note.id));
|
||||
|
||||
for (const { id } of notes) {
|
||||
const t = this.idService.parse(id).date.getTime();
|
||||
if (stats.oldest === null || t < stats.oldest) {
|
||||
stats.oldest = t;
|
||||
}
|
||||
if (stats.newest === null || t > stats.newest) {
|
||||
stats.newest = t;
|
||||
}
|
||||
}
|
||||
|
||||
stats.deletedCount += notes.length;
|
||||
}
|
||||
|
||||
job.log(`Deleted ${notes.length} from ${initiatorNotes.length} initiators; ${Date.now() - batchBeginAt}ms`);
|
||||
|
||||
if (initiatorNotes.length < MAX_NOTE_COUNT_PER_QUERY) {
|
||||
// If we fetched less than the maximum, it means there are no more notes to process.
|
||||
job.log(`No more notes to clean. (fewer than MAX_NOTE_COUNT_PER_QUERY =${MAX_NOTE_COUNT_PER_QUERY}.)`);
|
||||
break;
|
||||
}
|
||||
|
||||
await setTimeout(1000 * 5); // Wait a moment to avoid overwhelming the db
|
||||
}
|
||||
|
||||
this.logger.succ('cleaning of remote notes completed.');
|
||||
|
||||
return {
|
||||
deletedCount: stats.deletedCount,
|
||||
oldest: stats.oldest,
|
||||
newest: stats.newest,
|
||||
skipped: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,8 @@ export class GetterService {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getNoteWithUser(noteId: MiNote['id']) {
|
||||
const note = await this.notesRepository.findOne({ where: { id: noteId }, relations: ['user'] });
|
||||
public async getNoteWithRelations(noteId: MiNote['id']) {
|
||||
const note = await this.notesRepository.findOne({ where: { id: noteId }, relations: ['user', 'reply', 'renote', 'reply.user', 'renote.user'] });
|
||||
|
||||
if (note == null) {
|
||||
throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.');
|
||||
|
||||
@@ -70,6 +70,7 @@ export * as 'admin/queue/inbox-delayed' from './endpoints/admin/queue/inbox-dela
|
||||
export * as 'admin/queue/retry-job' from './endpoints/admin/queue/retry-job.js';
|
||||
export * as 'admin/queue/remove-job' from './endpoints/admin/queue/remove-job.js';
|
||||
export * as 'admin/queue/show-job' from './endpoints/admin/queue/show-job.js';
|
||||
export * as 'admin/queue/show-job-logs' from './endpoints/admin/queue/show-job-logs.js';
|
||||
export * as 'admin/queue/promote-jobs' from './endpoints/admin/queue/promote-jobs.js';
|
||||
export * as 'admin/queue/jobs' from './endpoints/admin/queue/jobs.js';
|
||||
export * as 'admin/queue/stats' from './endpoints/admin/queue/stats.js';
|
||||
|
||||
@@ -571,6 +571,18 @@ export const meta = {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableRemoteNotesCleaning: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
remoteNotesCleaningExpiryDaysForEachNotes: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
remoteNotesCleaningMaxProcessingDurationInMinutes: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -722,6 +734,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
proxyRemoteFiles: instance.proxyRemoteFiles,
|
||||
signToActivityPubGet: instance.signToActivityPubGet,
|
||||
allowExternalApRedirect: instance.allowExternalApRedirect,
|
||||
enableRemoteNotesCleaning: instance.enableRemoteNotesCleaning,
|
||||
remoteNotesCleaningExpiryDaysForEachNotes: instance.remoteNotesCleaningExpiryDaysForEachNotes,
|
||||
remoteNotesCleaningMaxProcessingDurationInMinutes: instance.remoteNotesCleaningMaxProcessingDurationInMinutes,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
kind: 'read:admin:queue',
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
optional: false, nullable: false,
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
queue: { type: 'string', enum: QUEUE_TYPES },
|
||||
jobId: { type: 'string' },
|
||||
},
|
||||
required: ['queue', 'jobId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
return this.queueService.queueGetJobLogs(ps.queue, ps.jobId);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -48,8 +48,8 @@ export const paramDef = {
|
||||
},
|
||||
secret: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 1024,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
required: [
|
||||
@@ -57,7 +57,6 @@ export const paramDef = {
|
||||
'name',
|
||||
'on',
|
||||
'url',
|
||||
'secret',
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -52,8 +52,8 @@ export const paramDef = {
|
||||
},
|
||||
secret: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 1024,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
required: [
|
||||
@@ -62,7 +62,6 @@ export const paramDef = {
|
||||
'name',
|
||||
'on',
|
||||
'url',
|
||||
'secret',
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -205,6 +205,9 @@ export const paramDef = {
|
||||
proxyRemoteFiles: { type: 'boolean' },
|
||||
signToActivityPubGet: { type: 'boolean' },
|
||||
allowExternalApRedirect: { type: 'boolean' },
|
||||
enableRemoteNotesCleaning: { type: 'boolean' },
|
||||
remoteNotesCleaningExpiryDaysForEachNotes: { type: 'number' },
|
||||
remoteNotesCleaningMaxProcessingDurationInMinutes: { type: 'number' },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
@@ -723,6 +726,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
set.allowExternalApRedirect = ps.allowExternalApRedirect;
|
||||
}
|
||||
|
||||
if (ps.enableRemoteNotesCleaning !== undefined) {
|
||||
set.enableRemoteNotesCleaning = ps.enableRemoteNotesCleaning;
|
||||
}
|
||||
|
||||
if (ps.remoteNotesCleaningExpiryDaysForEachNotes !== undefined) {
|
||||
set.remoteNotesCleaningExpiryDaysForEachNotes = ps.remoteNotesCleaningExpiryDaysForEachNotes;
|
||||
}
|
||||
|
||||
if (ps.remoteNotesCleaningMaxProcessingDurationInMinutes !== undefined) {
|
||||
set.remoteNotesCleaningMaxProcessingDurationInMinutes = ps.remoteNotesCleaningMaxProcessingDurationInMinutes;
|
||||
}
|
||||
|
||||
const before = await this.metaService.fetch(true);
|
||||
|
||||
await this.metaService.update(set);
|
||||
|
||||
@@ -269,7 +269,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
let renote: MiNote | null = null;
|
||||
if (ps.renoteId != null) {
|
||||
// Fetch renote to note
|
||||
renote = await this.notesRepository.findOneBy({ id: ps.renoteId });
|
||||
renote = await this.notesRepository.findOne({
|
||||
where: { id: ps.renoteId },
|
||||
relations: ['user', 'renote', 'reply'],
|
||||
});
|
||||
|
||||
if (renote == null) {
|
||||
throw new ApiError(meta.errors.noSuchRenoteTarget);
|
||||
@@ -315,7 +318,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
let reply: MiNote | null = null;
|
||||
if (ps.replyId != null) {
|
||||
// Fetch reply
|
||||
reply = await this.notesRepository.findOneBy({ id: ps.replyId });
|
||||
reply = await this.notesRepository.findOne({
|
||||
where: { id: ps.replyId },
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (reply == null) {
|
||||
throw new ApiError(meta.errors.noSuchReplyTarget);
|
||||
|
||||
@@ -55,7 +55,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private getterService: GetterService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const note = await this.getterService.getNoteWithUser(ps.noteId).catch(err => {
|
||||
const note = await this.getterService.getNoteWithRelations(ps.noteId).catch(err => {
|
||||
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
|
||||
throw err;
|
||||
});
|
||||
|
||||
@@ -237,7 +237,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
}
|
||||
|
||||
if (ps.withRenotes === false) {
|
||||
query.andWhere('note.renoteId IS NULL');
|
||||
query.andWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.renoteId IS NULL');
|
||||
qb.orWhere(new Brackets(qb => {
|
||||
qb.orWhere('note.text IS NOT NULL');
|
||||
qb.orWhere('note.fileIds != \'{}\'');
|
||||
}));
|
||||
}));
|
||||
}
|
||||
//#endregion
|
||||
|
||||
|
||||
@@ -20,17 +20,6 @@ import type { Config } from '@/config.js';
|
||||
import { getNoteSummary } from '@/misc/get-note-summary.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import * as Acct from '@/misc/acct.js';
|
||||
import type {
|
||||
DbQueue,
|
||||
DeliverQueue,
|
||||
EndedPollNotificationQueue,
|
||||
InboxQueue,
|
||||
ObjectStorageQueue,
|
||||
RelationshipQueue,
|
||||
SystemQueue,
|
||||
UserWebhookDeliverQueue,
|
||||
SystemWebhookDeliverQueue,
|
||||
} from '@/core/QueueModule.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { PageEntityService } from '@/core/entities/PageEntityService.js';
|
||||
@@ -129,16 +118,6 @@ export class ClientServerService {
|
||||
private feedService: FeedService,
|
||||
private roleService: RoleService,
|
||||
private clientLoggerService: ClientLoggerService,
|
||||
|
||||
@Inject('queue:system') public systemQueue: SystemQueue,
|
||||
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
|
||||
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
|
||||
@Inject('queue:inbox') public inboxQueue: InboxQueue,
|
||||
@Inject('queue:db') public dbQueue: DbQueue,
|
||||
@Inject('queue:relationship') public relationshipQueue: RelationshipQueue,
|
||||
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
|
||||
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
|
||||
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
|
||||
) {
|
||||
//this.createServer = this.createServer.bind(this);
|
||||
}
|
||||
@@ -188,6 +167,10 @@ export class ClientServerService {
|
||||
'url': 'url',
|
||||
},
|
||||
},
|
||||
'shortcuts': [{
|
||||
'name': 'Safemode',
|
||||
'url': '/?safemode=true',
|
||||
}],
|
||||
};
|
||||
|
||||
manifest = {
|
||||
@@ -580,7 +563,7 @@ export class ClientServerService {
|
||||
id: request.params.note,
|
||||
visibility: In(['public', 'home']),
|
||||
},
|
||||
relations: ['user'],
|
||||
relations: ['user', 'reply', 'renote'],
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -821,8 +804,11 @@ export class ClientServerService {
|
||||
fastify.get<{ Params: { note: string; } }>('/embed/notes/:note', async (request, reply) => {
|
||||
reply.removeHeader('X-Frame-Options');
|
||||
|
||||
const note = await this.notesRepository.findOneBy({
|
||||
id: request.params.note,
|
||||
const note = await this.notesRepository.findOne({
|
||||
where: {
|
||||
id: request.params.note,
|
||||
},
|
||||
relations: ['user', 'reply', 'renote'],
|
||||
});
|
||||
|
||||
if (note == null) return;
|
||||
|
||||
@@ -32,61 +32,30 @@
|
||||
}
|
||||
|
||||
//#region Detect language & fetch translations
|
||||
if (!localStorage.hasOwnProperty('locale')) {
|
||||
const supportedLangs = LANGS;
|
||||
let lang = localStorage.getItem('lang');
|
||||
if (lang == null || !supportedLangs.includes(lang)) {
|
||||
if (supportedLangs.includes(navigator.language)) {
|
||||
lang = navigator.language;
|
||||
} else {
|
||||
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
|
||||
|
||||
// Fallback
|
||||
if (lang == null) lang = 'en-US';
|
||||
}
|
||||
}
|
||||
|
||||
const metaRes = await window.fetch('/api/meta', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
credentials: 'omit',
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (metaRes.status !== 200) {
|
||||
renderError('META_FETCH');
|
||||
return;
|
||||
}
|
||||
const meta = await metaRes.json();
|
||||
const v = meta.version;
|
||||
if (v == null) {
|
||||
renderError('META_FETCH_V');
|
||||
return;
|
||||
}
|
||||
|
||||
// for https://github.com/misskey-dev/misskey/issues/10202
|
||||
if (lang == null || lang.toString == null || lang.toString() === 'null') {
|
||||
console.error('invalid lang value detected!!!', typeof lang, lang);
|
||||
lang = 'en-US';
|
||||
}
|
||||
|
||||
const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`);
|
||||
if (localRes.status === 200) {
|
||||
localStorage.setItem('lang', lang);
|
||||
localStorage.setItem('locale', await localRes.text());
|
||||
localStorage.setItem('localeVersion', v);
|
||||
const supportedLangs = LANGS;
|
||||
/** @type { string } */
|
||||
let lang = localStorage.getItem('lang');
|
||||
if (lang == null || !supportedLangs.includes(lang)) {
|
||||
if (supportedLangs.includes(navigator.language)) {
|
||||
lang = navigator.language;
|
||||
} else {
|
||||
renderError('LOCALE_FETCH');
|
||||
return;
|
||||
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
|
||||
|
||||
// Fallback
|
||||
if (lang == null) lang = 'en-US';
|
||||
}
|
||||
}
|
||||
|
||||
// for https://github.com/misskey-dev/misskey/issues/10202
|
||||
if (lang == null || lang.toString == null || lang.toString() === 'null') {
|
||||
console.error('invalid lang value detected!!!', typeof lang, lang);
|
||||
lang = 'en-US';
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region Script
|
||||
async function importAppScript() {
|
||||
await import(`/embed_vite/${CLIENT_ENTRY}`)
|
||||
await import(CLIENT_ENTRY ? `/embed_vite/${CLIENT_ENTRY.replace('scripts', lang)}` : '/embed_vite/src/_boot_.ts')
|
||||
.catch(async e => {
|
||||
console.error(e);
|
||||
renderError('APP_IMPORT');
|
||||
@@ -115,10 +84,26 @@
|
||||
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
|
||||
}
|
||||
|
||||
const locale = JSON.parse(localStorage.getItem('locale') || '{}');
|
||||
let messages = null;
|
||||
const bootloaderLocales = localStorage.getItem('bootloaderLocales');
|
||||
if (bootloaderLocales) {
|
||||
messages = JSON.parse(bootloaderLocales);
|
||||
}
|
||||
if (!messages) {
|
||||
// older version of misskey does not store bootloaderLocales, stores locale as a whole
|
||||
const legacyLocale = localStorage.getItem('locale');
|
||||
if (legacyLocale) {
|
||||
const parsed = JSON.parse(legacyLocale);
|
||||
messages = {
|
||||
...(parsed._bootErrors ?? {}),
|
||||
reload: parsed.reload,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!messages) messages = {};
|
||||
|
||||
const title = locale?._bootErrors?.title || 'Failed to initialize Misskey';
|
||||
const reload = locale?.reload || 'Reload';
|
||||
const title = messages?.title || 'Failed to initialize Misskey';
|
||||
const reload = messages?.reload || 'Reload';
|
||||
|
||||
document.body.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M12 9v4" /><path d="M12 16v.01" /></svg>
|
||||
<div class="message">${title}</div>
|
||||
|
||||
@@ -22,62 +22,31 @@
|
||||
return;
|
||||
}
|
||||
|
||||
//#region Detect language & fetch translations
|
||||
if (!localStorage.hasOwnProperty('locale')) {
|
||||
const supportedLangs = LANGS;
|
||||
let lang = localStorage.getItem('lang');
|
||||
if (lang == null || !supportedLangs.includes(lang)) {
|
||||
if (supportedLangs.includes(navigator.language)) {
|
||||
lang = navigator.language;
|
||||
} else {
|
||||
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
|
||||
|
||||
// Fallback
|
||||
if (lang == null) lang = 'en-US';
|
||||
}
|
||||
}
|
||||
|
||||
const metaRes = await window.fetch('/api/meta', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
credentials: 'omit',
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (metaRes.status !== 200) {
|
||||
renderError('META_FETCH');
|
||||
return;
|
||||
}
|
||||
const meta = await metaRes.json();
|
||||
const v = meta.version;
|
||||
if (v == null) {
|
||||
renderError('META_FETCH_V');
|
||||
return;
|
||||
}
|
||||
|
||||
// for https://github.com/misskey-dev/misskey/issues/10202
|
||||
if (lang == null || lang.toString == null || lang.toString() === 'null') {
|
||||
console.error('invalid lang value detected!!!', typeof lang, lang);
|
||||
lang = 'en-US';
|
||||
}
|
||||
|
||||
const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`);
|
||||
if (localRes.status === 200) {
|
||||
localStorage.setItem('lang', lang);
|
||||
localStorage.setItem('locale', await localRes.text());
|
||||
localStorage.setItem('localeVersion', v);
|
||||
//#region Detect language
|
||||
const supportedLangs = LANGS;
|
||||
/** @type { string } */
|
||||
let lang = localStorage.getItem('lang');
|
||||
if (lang == null || !supportedLangs.includes(lang)) {
|
||||
if (supportedLangs.includes(navigator.language)) {
|
||||
lang = navigator.language;
|
||||
} else {
|
||||
renderError('LOCALE_FETCH');
|
||||
return;
|
||||
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
|
||||
|
||||
// Fallback
|
||||
if (lang == null) lang = 'en-US';
|
||||
}
|
||||
}
|
||||
|
||||
// for https://github.com/misskey-dev/misskey/issues/10202
|
||||
if (lang == null || lang.toString == null || lang.toString() === 'null') {
|
||||
console.error('invalid lang value detected!!!', typeof lang, lang);
|
||||
lang = 'en-US';
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region Script
|
||||
async function importAppScript() {
|
||||
await import(`/vite/${CLIENT_ENTRY}`)
|
||||
await import(CLIENT_ENTRY ? `/vite/${CLIENT_ENTRY.replace('scripts', lang)}` : '/vite/src/_boot_.ts')
|
||||
.catch(async e => {
|
||||
console.error(e);
|
||||
renderError('APP_IMPORT', e);
|
||||
@@ -94,23 +63,37 @@
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region Theme
|
||||
const theme = localStorage.getItem('theme');
|
||||
if (theme) {
|
||||
for (const [k, v] of Object.entries(JSON.parse(theme))) {
|
||||
document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString());
|
||||
let isSafeMode = (localStorage.getItem('isSafeMode') === 'true');
|
||||
|
||||
// HTMLの theme-color 適用
|
||||
if (k === 'htmlThemeColor') {
|
||||
for (const tag of document.head.children) {
|
||||
if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
|
||||
tag.setAttribute('content', v);
|
||||
break;
|
||||
if (!isSafeMode) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
if (urlParams.has('safemode') && urlParams.get('safemode') === 'true') {
|
||||
localStorage.setItem('isSafeMode', 'true');
|
||||
isSafeMode = true;
|
||||
}
|
||||
}
|
||||
|
||||
//#region Theme
|
||||
if (!isSafeMode) {
|
||||
const theme = localStorage.getItem('theme');
|
||||
if (theme) {
|
||||
for (const [k, v] of Object.entries(JSON.parse(theme))) {
|
||||
document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString());
|
||||
|
||||
// HTMLの theme-color 適用
|
||||
if (k === 'htmlThemeColor') {
|
||||
for (const tag of document.head.children) {
|
||||
if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
|
||||
tag.setAttribute('content', v);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const colorScheme = localStorage.getItem('colorScheme');
|
||||
if (colorScheme) {
|
||||
document.documentElement.style.setProperty('color-scheme', colorScheme);
|
||||
@@ -127,11 +110,13 @@
|
||||
document.documentElement.classList.add('useSystemFont');
|
||||
}
|
||||
|
||||
const customCss = localStorage.getItem('customCss');
|
||||
if (customCss && customCss.length > 0) {
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = customCss;
|
||||
document.head.appendChild(style);
|
||||
if (!isSafeMode) {
|
||||
const customCss = localStorage.getItem('customCss');
|
||||
if (customCss && customCss.length > 0) {
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = customCss;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
async function addStyle(styleText) {
|
||||
@@ -146,9 +131,25 @@
|
||||
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
|
||||
}
|
||||
|
||||
const locale = JSON.parse(localStorage.getItem('locale') || '{}');
|
||||
let messages = null;
|
||||
const bootloaderLocales = localStorage.getItem('bootloaderLocales');
|
||||
if (bootloaderLocales) {
|
||||
messages = JSON.parse(bootloaderLocales);
|
||||
}
|
||||
if (!messages) {
|
||||
// older version of misskey does not store bootloaderLocales, stores locale as a whole
|
||||
const legacyLocale = localStorage.getItem('locale');
|
||||
if (legacyLocale) {
|
||||
const parsed = JSON.parse(legacyLocale);
|
||||
messages = {
|
||||
...(parsed._bootErrors ?? {}),
|
||||
reload: parsed.reload,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!messages) messages = {};
|
||||
|
||||
const messages = Object.assign({
|
||||
messages = Object.assign({
|
||||
title: 'Failed to initialize Misskey',
|
||||
solution: 'The following actions may solve the problem.',
|
||||
solution1: 'Update your os and browser',
|
||||
@@ -159,8 +160,12 @@
|
||||
otherOption1: 'Clear preferences and cache',
|
||||
otherOption2: 'Start the simple client',
|
||||
otherOption3: 'Start the repair tool',
|
||||
}, locale?._bootErrors || {});
|
||||
const reload = locale?.reload || 'Reload';
|
||||
otherOption4: 'Start Misskey in safe mode',
|
||||
reload: 'Reload',
|
||||
}, messages);
|
||||
|
||||
const safeModeUrl = new URL(window.location.href);
|
||||
safeModeUrl.searchParams.set('safemode', 'true');
|
||||
|
||||
let errorsElement = document.getElementById('errors');
|
||||
|
||||
@@ -173,7 +178,7 @@
|
||||
</svg>
|
||||
<h1>${messages.title}</h1>
|
||||
<button class="button-big" onclick="location.reload(true);">
|
||||
<span class="button-label-big">${reload}</span>
|
||||
<span class="button-label-big">${messages?.reload}</span>
|
||||
</button>
|
||||
<p><b>${messages.solution}</b></p>
|
||||
<p>${messages.solution1}</p>
|
||||
@@ -182,6 +187,12 @@
|
||||
<p>${messages.solution4}</p>
|
||||
<details style="color: #86b300;">
|
||||
<summary>${messages.otherOption}</summary>
|
||||
<a href="${safeModeUrl}">
|
||||
<button class="button-small">
|
||||
<span class="button-label-small">${messages.otherOption4}</span>
|
||||
</button>
|
||||
</a>
|
||||
<br>
|
||||
<a href="/flush">
|
||||
<button class="button-small">
|
||||
<span class="button-label-small">${messages.otherOption1}</span>
|
||||
|
||||
@@ -34,5 +34,11 @@
|
||||
"text": "text",
|
||||
"url": "url"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Safemode",
|
||||
"url": "/?safemode=true"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ html(class='embed')
|
||||
meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no')
|
||||
link(rel='icon' href= icon || '/favicon.ico')
|
||||
link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png')
|
||||
link(rel='modulepreload' href=`/embed_vite/${entry.file}`)
|
||||
|
||||
if !config.frontendEmbedManifestExists
|
||||
script(type="module" src="/embed_vite/@vite/client")
|
||||
@@ -40,7 +39,7 @@ html(class='embed')
|
||||
|
||||
script.
|
||||
var VERSION = "#{version}";
|
||||
var CLIENT_ENTRY = "#{entry.file}";
|
||||
var CLIENT_ENTRY = !{JSON.stringify(entry.file)};
|
||||
|
||||
script(type='application/json' id='misskey_meta' data-generated-at=now)
|
||||
!= metaJson
|
||||
|
||||
@@ -37,7 +37,6 @@ html
|
||||
link(rel='prefetch' href=serverErrorImageUrl)
|
||||
link(rel='prefetch' href=infoImageUrl)
|
||||
link(rel='prefetch' href=notFoundImageUrl)
|
||||
link(rel='modulepreload' href=`/vite/${entry.file}`)
|
||||
|
||||
if !config.frontendManifestExists
|
||||
script(type="module" src="/vite/@vite/client")
|
||||
@@ -69,7 +68,7 @@ html
|
||||
|
||||
script.
|
||||
var VERSION = "#{version}";
|
||||
var CLIENT_ENTRY = "#{entry.file}";
|
||||
var CLIENT_ENTRY = !{JSON.stringify(entry.file)};
|
||||
|
||||
script(type='application/json' id='misskey_meta' data-generated-at=now)
|
||||
!= metaJson
|
||||
|
||||
Reference in New Issue
Block a user