Add note auto delete feature #1

Merged
heartles merged 5 commits from wip-self-destruct into main 2024-03-18 04:46:58 +00:00
13 changed files with 224 additions and 0 deletions

View file

@ -99,6 +99,7 @@ serverIsDead: "This server is not responding. Please wait for a while and try ag
youShouldUpgradeClient: "To view this page, please refresh to update your client."
enterListName: "Enter a name for the list"
privacy: "Privacy"
autoDeleteNotes: "Self-destructing notes"
makeFollowManuallyApprove: "Follow requests require approval"
defaultNoteVisibility: "Default visibility"
follow: "Follow"
@ -947,6 +948,7 @@ oneHour: "One hour"
oneDay: "One day"
oneWeek: "One week"
oneMonth: "One month"
oneYear: "One year"
reflectMayTakeTime: "It may take some time for this to be reflected."
failedToFetchAccountInformation: "Could not fetch account information"
rateLimitExceeded: "Rate limit exceeded"
@ -2000,6 +2002,8 @@ _time:
minute: "Minute(s)"
hour: "Hour(s)"
day: "Day(s)"
month: "Month(s)"
year: "Year(s)"
_2fa:
alreadyRegistered: "You have already registered a 2-factor authentication device."
registerTOTP: "Register authenticator app"

View file

@ -0,0 +1,14 @@
export class AutoDeleteNotes1709530777533 {
name = "AutoDeleteNotes1709530777533";
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE user_profile ADD "autoDeleteNotes" boolean NOT NULL DEFAULT false;`);
await queryRunner.query(`ALTER TABLE user_profile ADD "autoDeleteNotesMinutes" integer NOT NULL DEFAULT 43200;`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE user_profile DROP COLUMN "autoDeleteNotes";`);
await queryRunner.query(`ALTER TABLE user_profile DROP COLUMN "autoDeleteNotesMinutes";`);
}
}

View file

@ -69,6 +69,12 @@ export class QueueService {
repeat: { pattern: '*/5 * * * *' },
removeOnComplete: true,
});
this.systemQueue.add('autoDeleteNotes', {
}, {
repeat: { pattern: '*/5 * * * *' },
removeOnComplete: true,
});
}
@bindThis

View file

@ -509,6 +509,8 @@ export class UserEntityService implements OnModuleInit {
mutedWords: profile!.mutedWords,
hardMutedWords: profile!.hardMutedWords,
mutedInstances: profile!.mutedInstances,
autoDeleteNotes: profile!.autoDeleteNotes,
autoDeleteNotesMinutes: profile!.autoDeleteNotesMinutes,
mutingNotificationTypes: [], // 後方互換性のため
notificationRecieveConfig: profile!.notificationRecieveConfig,
emailNotificationTypes: profile!.emailNotificationTypes,

View file

@ -277,6 +277,17 @@ export class MiUserProfile {
unlockedAt: number;
}[];
@Column('boolean', {
default: false,
})
public autoDeleteNotes: boolean;
@Column('integer', {
default: 43200, // 30 days in minutes
})
public autoDeleteNotesMinutes: number;
//#region Denormalized fields
@Index()
@Column('varchar', {

View file

@ -605,6 +605,14 @@ export const packedMeDetailedOnlySchema = {
nullable: false, optional: false,
},
},
autoDeleteNotes: {
type: 'boolean',
nullable: false, optional: false,
},
autoDeleteNotesMinutes: {
type: 'number',
nullable: false, optional: false,
},
notificationRecieveConfig: {
type: 'object',
nullable: false, optional: false,

View file

@ -13,6 +13,7 @@ import { EndedPollNotificationProcessorService } from './processors/EndedPollNot
import { InboxProcessorService } from './processors/InboxProcessorService.js';
import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js';
import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js';
import { AutoDeleteNotesProcessorService } from './processors/AutoDeleteNotesProcessorService.js';
import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js';
import { CleanProcessorService } from './processors/CleanProcessorService.js';
import { CleanRemoteFilesProcessorService } from './processors/CleanRemoteFilesProcessorService.js';
@ -52,6 +53,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
ResyncChartsProcessorService,
CleanChartsProcessorService,
CheckExpiredMutingsProcessorService,
AutoDeleteNotesProcessorService,
CleanProcessorService,
DeleteDriveFilesProcessorService,
ExportAccountDataProcessorService,

View file

@ -38,6 +38,7 @@ import { TickChartsProcessorService } from './processors/TickChartsProcessorServ
import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js';
import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js';
import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js';
import { AutoDeleteNotesProcessorService } from './processors/AutoDeleteNotesProcessorService.js';
import { CleanProcessorService } from './processors/CleanProcessorService.js';
import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js';
import { QueueLoggerService } from './QueueLoggerService.js';
@ -118,6 +119,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
private cleanChartsProcessorService: CleanChartsProcessorService,
private aggregateRetentionProcessorService: AggregateRetentionProcessorService,
private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService,
private autoDeleteNotesProcessorService: AutoDeleteNotesProcessorService,
private cleanProcessorService: CleanProcessorService,
) {
this.logger = this.queueLoggerService.logger;
@ -146,6 +148,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
case 'cleanCharts': return this.cleanChartsProcessorService.process();
case 'aggregateRetention': return this.aggregateRetentionProcessorService.process();
case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process();
case 'autoDeleteNotes': return this.autoDeleteNotesProcessorService.process();
case 'clean': return this.cleanProcessorService.process();
default: throw new Error(`unrecognized job type ${job.name} for system`);
}

View file

@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { UserProfilesRepository, NotesRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { NoteDeleteService } from '@/core/NoteDeleteService.js';
import { IdService } from '@/core/IdService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
@Injectable()
export class AutoDeleteNotesProcessorService {
private logger: Logger;
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.userNotePiningsRepository)
private userNotePiningsRepository: UserNotePiningsRepository,
private idService: IdService,
private noteDeleteService: NoteDeleteService,
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('auto-delete-notes');
}
@bindThis
public async process(): Promise<void> {
this.logger.info('Auto deleting old notes...');
const userProfiles = await this.userProfilesRepository.createQueryBuilder('user_profile')
.innerJoinAndSelect('user_profile.user', 'user')
.where('user.host IS NULL')
.andWhere('user_profile.autoDeleteNotes')
.getMany();
for (const userProfile of userProfiles) {
const user = userProfile.user;
this.logger.debug(`Deleting old notes of user @${user.username} (id ${user.id})`);
const untilTime = Date.now() - (userProfile.autoDeleteNotesMinutes * 1000 * 60);
const untilId = this.idService.gen(untilTime);
const pins = await this.userNotePiningsRepository.createQueryBuilder('user_note_pining')
.where('"userId" = :userId', { userId: user.id })
.getMany();
const pinnedNoteIds = pins.map((p) => p.noteId);
const notes = await this.notesRepository.createQueryBuilder('note')
.where('note."userId" = :userId', { userId: user.id })
.andWhere('note.id < :untilId', { untilId })
.andWhere('note.id NOT IN (SELECT "noteId" FROM note_favorite WHERE "userId" = :userId)')
.getMany();
for (const note of notes) {
if (pinnedNoteIds.includes(note.id)) {
this.logger.debug(`Skipping note ${note.id} as it is pinned`);
continue;
}
this.logger.debug(`Deleting note ${note.id}`);
await this.noteDeleteService.delete(user, note, false, user);
}
}
this.logger.succ('Done with note auto-delete');
}
}

View file

@ -88,6 +88,14 @@ export const meta = {
type: 'string',
},
},
autoDeleteNotes: {
type: 'boolean',
optional: false, nullable: false,
},
autoDeleteNotesMinutes: {
type: 'number',
optional: false, nullable: false,
},
notificationRecieveConfig: {
type: 'object',
optional: false, nullable: false,
@ -239,6 +247,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
receiveAnnouncementEmail: profile.receiveAnnouncementEmail,
mutedWords: profile.mutedWords,
mutedInstances: profile.mutedInstances,
autoDeleteNotesMinutes: profile.autoDeleteNotesMinutes,
autoDeleteNotes: profile.autoDeleteNotes,
notificationRecieveConfig: profile.notificationRecieveConfig,
isModerator: isModerator,
isSilenced: isSilenced,

View file

@ -202,6 +202,8 @@ export const paramDef = {
mutedInstances: { type: 'array', items: {
type: 'string',
} },
autoDeleteNotes: { type: 'boolean', nullable: false },
autoDeleteNotesMinutes: { type: 'number', nullable: false, minimum: 1 },
notificationRecieveConfig: {
type: 'object',
nullable: false,
@ -319,6 +321,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
profileUpdates.hardMutedWords = ps.hardMutedWords;
}
if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances;
if (ps.autoDeleteNotes !== undefined) profileUpdates.autoDeleteNotes = ps.autoDeleteNotes;
if (ps.autoDeleteNotesMinutes !== undefined) profileUpdates.autoDeleteNotesMinutes = ps.autoDeleteNotesMinutes;
if (ps.notificationRecieveConfig !== undefined) profileUpdates.notificationRecieveConfig = ps.notificationRecieveConfig;
if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked;
if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable;

View file

@ -0,0 +1,73 @@
<template>
<div class="_gaps_m">
<MkButton :disabled="!changed" :primary="changed" @click="save()"><i class="ph-check ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton>
<MkSwitch v-model="enable">
{{ i18n.ts.enable }}
<template #caption>
When enabled, notes you post will automatically delete themselves if they are older than the given threshold age.
Notes of yours that you have favorited (aka bookmarked) or pinned will be excluded from this.
<br />
<br />
WARNING: Even if a note is favorited or pinned, if any notes above it in the thread are deleted (or autodeleted)
then the favorited/pinned post will be deleted as well. Consider favoriting all notes in a thread if you pin/favorite one of them.
</template>
</MkSwitch>
<MkInput v-model="threshold" type="number" :min="1">
<template #suffix>{{ i18n.ts._time.minute }}</template>
<template #caption>Note age threshold</template>
</MkInput>
<MkFolder>
<template #label>{{ i18n.ts.selectFromPresets }}</template>
<FormSplit :minWidth="100">
<MkButton @click="setThreshold(WEEK_MINUTES)" inline small>{{ i18n.ts.oneWeek }}</MkButton>
<MkButton @click="setThreshold(MONTH_MINUTES)" inline small>{{ i18n.ts.oneMonth }}</MkButton>
<MkButton @click="setThreshold(YEAR_MINUTES)" inline small>{{ i18n.ts.oneYear }}</MkButton>
</FormSplit>
</MkFolder>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import FormSplit from '@/components/form/split.vue';
import MkFolder from '@/components/MkFolder.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { signinRequired } from '@/account.js';
const $i = signinRequired();
const enable = ref($i.autoDeleteNotes);
const threshold = ref($i.autoDeleteNotesMinutes);
const changed = computed(() => enable.value !== $i.autoDeleteNotes || threshold.value !== $i.autoDeleteNotesMinutes);
const DAY_MINUTES = 60 * 24;
const WEEK_MINUTES = 7 * DAY_MINUTES;
const MONTH_MINUTES = 30 * DAY_MINUTES;
const YEAR_MINUTES = 365 * DAY_MINUTES;
function setThreshold(value) {
threshold.value = value;
}
async function save() {
if (enable.value) {
const { canceled } = await os.confirm({
type: 'warning',
text: 'This action may immediately delete notes older than the threshold value! Click ok to confirm.',
});
if (canceled) return;
}
misskeyApi('i/update', {
autoDeleteNotes: !!enable.value,
autoDeleteNotesMinutes: threshold.value,
});
}
</script>

View file

@ -68,6 +68,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</FormSection>
<MkSwitch v-model="keepCw" @update:modelValue="save()">{{ i18n.ts.keepCw }}</MkSwitch>
<FormSection>
<div class="_gaps_m">
<MkFolder>
<template #label>{{ i18n.ts.autoDeleteNotes }}</template>
<XAutoDelete />
</MkFolder>
</div>
</FormSection>
</div>
</template>
@ -82,6 +91,7 @@ import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import { signinRequired } from '@/account.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import XAutoDelete from './privacy.autodelete.vue';
const $i = signinRequired();