scheduled erasure of attachments WIP

This commit is contained in:
zadam 2023-04-21 00:19:17 +02:00
parent e71b0d82a1
commit 5e2efca933
11 changed files with 505 additions and 315 deletions

View File

@ -10,9 +10,12 @@ CREATE TABLE IF NOT EXISTS "attachments"
blobId TEXT DEFAULT null, blobId TEXT DEFAULT null,
dateModified TEXT NOT NULL, dateModified TEXT NOT NULL,
utcDateModified TEXT not null, utcDateModified TEXT not null,
utcDateScheduledForDeletionSince TEXT DEFAULT NULL, utcDateScheduledForErasureSince TEXT DEFAULT NULL,
isDeleted INT not null, isDeleted INT not null,
deleteId TEXT DEFAULT NULL); deleteId TEXT DEFAULT NULL);
CREATE INDEX IDX_attachments_parentId_role CREATE INDEX IDX_attachments_parentId_role
on attachments (parentId, role); on attachments (parentId, role);
CREATE INDEX IDX_attachments_utcDateScheduledForErasureSince
on attachments (utcDateScheduledForErasureSince);

View File

@ -121,7 +121,7 @@ CREATE TABLE IF NOT EXISTS "attachments"
blobId TEXT DEFAULT null, blobId TEXT DEFAULT null,
dateModified TEXT NOT NULL, dateModified TEXT NOT NULL,
utcDateModified TEXT not null, utcDateModified TEXT not null,
utcDateScheduledForDeletionSince TEXT DEFAULT NULL, utcDateScheduledForErasureSince TEXT DEFAULT NULL,
isDeleted INT not null, isDeleted INT not null,
deleteId TEXT DEFAULT NULL); deleteId TEXT DEFAULT NULL);
CREATE INDEX IDX_attachments_parentId_role CREATE INDEX IDX_attachments_parentId_role

662
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -33,10 +33,10 @@
"dependencies": { "dependencies": {
"@braintree/sanitize-url": "6.0.2", "@braintree/sanitize-url": "6.0.2",
"@electron/remote": "2.0.9", "@electron/remote": "2.0.9",
"@excalidraw/excalidraw": "0.14.2", "@excalidraw/excalidraw": "0.15.2",
"archiver": "5.3.1", "archiver": "5.3.1",
"async-mutex": "0.4.0", "async-mutex": "0.4.0",
"axios": "1.3.5", "axios": "1.3.6",
"better-sqlite3": "7.4.5", "better-sqlite3": "7.4.5",
"chokidar": "3.5.3", "chokidar": "3.5.3",
"cls-hooked": "4.2.2", "cls-hooked": "4.2.2",
@ -51,13 +51,13 @@
"electron-debug": "3.2.0", "electron-debug": "3.2.0",
"electron-dl": "3.5.0", "electron-dl": "3.5.0",
"electron-window-state": "5.0.3", "electron-window-state": "5.0.3",
"escape-html": "^1.0.3", "escape-html": "1.0.3",
"express": "4.18.2", "express": "4.18.2",
"express-partial-content": "1.0.2", "express-partial-content": "1.0.2",
"express-rate-limit": "6.7.0", "express-rate-limit": "6.7.0",
"express-session": "1.17.3", "express-session": "1.17.3",
"fs-extra": "11.1.1", "fs-extra": "11.1.1",
"helmet": "6.1.2", "helmet": "6.1.5",
"html": "1.0.0", "html": "1.0.0",
"html2plaintext": "2.1.4", "html2plaintext": "2.1.4",
"http-proxy-agent": "5.0.0", "http-proxy-agent": "5.0.0",
@ -71,7 +71,7 @@
"jsdom": "21.1.1", "jsdom": "21.1.1",
"mime-types": "2.1.35", "mime-types": "2.1.35",
"multer": "1.4.5-lts.1", "multer": "1.4.5-lts.1",
"node-abi": "3.35.0", "node-abi": "3.40.0",
"normalize-strings": "1.1.1", "normalize-strings": "1.1.1",
"open": "8.4.1", "open": "8.4.1",
"rand-token": "1.0.1", "rand-token": "1.0.1",
@ -83,7 +83,7 @@
"sanitize-filename": "1.6.3", "sanitize-filename": "1.6.3",
"sanitize-html": "2.10.0", "sanitize-html": "2.10.0",
"sax": "1.2.4", "sax": "1.2.4",
"semver": "7.3.8", "semver": "7.5.0",
"serve-favicon": "2.5.0", "serve-favicon": "2.5.0",
"session-file-store": "1.5.0", "session-file-store": "1.5.0",
"stream-throttle": "0.1.3", "stream-throttle": "0.1.3",
@ -117,7 +117,7 @@
"prettier": "2.8.7", "prettier": "2.8.7",
"nodemon": "^2.0.22", "nodemon": "^2.0.22",
"rcedit": "3.0.1", "rcedit": "3.0.1",
"webpack": "5.78.0", "webpack": "5.80.0",
"webpack-cli": "5.0.1" "webpack-cli": "5.0.1"
}, },
"optionalDependencies": { "optionalDependencies": {

View File

@ -20,7 +20,7 @@ class BAttachment extends AbstractBeccaEntity {
static get entityName() { return "attachments"; } static get entityName() { return "attachments"; }
static get primaryKeyName() { return "attachmentId"; } static get primaryKeyName() { return "attachmentId"; }
static get hashedProperties() { return ["attachmentId", "parentId", "role", "mime", "title", "blobId", static get hashedProperties() { return ["attachmentId", "parentId", "role", "mime", "title", "blobId",
"utcDateScheduledForDeletionSince", "utcDateModified"]; } "utcDateScheduledForErasureSince", "utcDateModified"]; }
constructor(row) { constructor(row) {
super(); super();
@ -56,7 +56,7 @@ class BAttachment extends AbstractBeccaEntity {
/** @type {string} */ /** @type {string} */
this.utcDateModified = row.utcDateModified; this.utcDateModified = row.utcDateModified;
/** @type {string} */ /** @type {string} */
this.utcDateScheduledForDeletionSince = row.utcDateScheduledForDeletionSince; this.utcDateScheduledForErasureSince = row.utcDateScheduledForErasureSince;
} }
/** @returns {BAttachment} */ /** @returns {BAttachment} */
@ -68,7 +68,7 @@ class BAttachment extends AbstractBeccaEntity {
title: this.title, title: this.title,
blobId: this.blobId, blobId: this.blobId,
isProtected: this.isProtected, isProtected: this.isProtected,
utcDateScheduledForDeletionSince: this.utcDateScheduledForDeletionSince utcDateScheduledForErasureSince: this.utcDateScheduledForErasureSince
}); });
} }
@ -171,7 +171,7 @@ class BAttachment extends AbstractBeccaEntity {
isDeleted: false, isDeleted: false,
dateModified: this.dateModified, dateModified: this.dateModified,
utcDateModified: this.utcDateModified, utcDateModified: this.utcDateModified,
utcDateScheduledForDeletionSince: this.utcDateScheduledForDeletionSince utcDateScheduledForErasureSince: this.utcDateScheduledForErasureSince
}; };
} }

View File

@ -21,7 +21,7 @@ class FAttachment {
/** @type {string} */ /** @type {string} */
this.utcDateModified = row.utcDateModified; this.utcDateModified = row.utcDateModified;
/** @type {string} */ /** @type {string} */
this.utcDateScheduledForDeletionSince = row.utcDateScheduledForDeletionSince; this.utcDateScheduledForErasureSince = row.utcDateScheduledForErasureSince;
this.froca.attachments[this.attachmentId] = this; this.froca.attachments[this.attachmentId] = this;
} }

View File

@ -27,6 +27,39 @@ function formatTimeWithSeconds(date) {
return `${padNum(date.getHours())}:${padNum(date.getMinutes())}:${padNum(date.getSeconds())}`; return `${padNum(date.getHours())}:${padNum(date.getMinutes())}:${padNum(date.getSeconds())}`;
} }
function formatTimeInterval(ms) {
const seconds = Math.round(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const plural = (count, name) => `${count} ${name}${count > 1 ? 's' : ''}`;
const segments = [];
if (days > 0) {
segments.push(plural(days, 'day'));
}
if (days < 2) {
if (hours % 24 > 0) {
segments.push(plural(hours % 24, 'hour'));
}
if (hours < 4) {
if (minutes % 60 > 0) {
segments.push(plural(minutes % 60, 'minute'));
}
if (minutes < 5) {
if (seconds % 60 > 0) {
segments.push(plural(seconds % 60, 'second'));
}
}
}
}
return segments.join(", ");
}
// this is producing local time! // this is producing local time!
function formatDate(date) { function formatDate(date) {
// return padNum(date.getDate()) + ". " + padNum(date.getMonth() + 1) + ". " + date.getFullYear(); // return padNum(date.getDate()) + ". " + padNum(date.getMonth() + 1) + ". " + date.getFullYear();
@ -489,6 +522,7 @@ export default {
formatDate, formatDate,
formatDateISO, formatDateISO,
formatDateTime, formatDateTime,
formatTimeInterval,
formatSize, formatSize,
localNowDateTime, localNowDateTime,
now, now,

View File

@ -2,6 +2,7 @@ import utils from "../services/utils.js";
import AttachmentActionsWidget from "./buttons/attachments_actions.js"; import AttachmentActionsWidget from "./buttons/attachments_actions.js";
import BasicWidget from "./basic_widget.js"; import BasicWidget from "./basic_widget.js";
import server from "../services/server.js"; import server from "../services/server.js";
import options from "../services/options.js";
const TPL = ` const TPL = `
<div class="attachment-detail"> <div class="attachment-detail">
@ -44,6 +45,10 @@ const TPL = `
max-width: 90%; max-width: 90%;
object-fit: contain; object-fit: contain;
} }
.attachment-detail-wrapper.scheduled-for-deletion .attachment-content img {
filter: contrast(10%);
}
</style> </style>
<div class="attachment-detail-wrapper"> <div class="attachment-detail-wrapper">
@ -54,6 +59,8 @@ const TPL = `
<div class="attachment-actions-container"></div> <div class="attachment-actions-container"></div>
</div> </div>
<div class="attachment-deletion-warning alert alert-info"></div>
<div class="attachment-content"></div> <div class="attachment-content"></div>
</div> </div>
</div>`; </div>`;
@ -100,15 +107,29 @@ export default class AttachmentDetailWidget extends BasicWidget {
.text(this.attachment.title); .text(this.attachment.title);
} }
const {utcDateScheduledForDeletionSince} = this.attachment; const $deletionWarning = this.$wrapper.find('.attachment-deletion-warning');
const {utcDateScheduledForErasureSince} = this.attachment;
if (utcDateScheduledForDeletionSince) { if (utcDateScheduledForErasureSince) {
const scheduledSinceTimestamp = utils.parseDate(utcDateScheduledForDeletionSince)?.getTime(); this.$wrapper.addClass("scheduled-for-deletion");
const interval = 3600 * 1000;
const deletionTimestamp = scheduledSinceTimestamp + interval;
const willBeDeletedInSeconds = Math.round((deletionTimestamp - Date.now()) / 1000);
this.$wrapper.find('.attachment-title').append(`Will be deleted in ${willBeDeletedInSeconds} seconds.`); const scheduledSinceTimestamp = utils.parseDate(utcDateScheduledForErasureSince)?.getTime();
const intervalMs = options.getInt('eraseUnusedImageAttachmentsAfterSeconds') * 1000;
const deletionTimestamp = scheduledSinceTimestamp + intervalMs;
const willBeDeletedInMs = deletionTimestamp - Date.now();
$deletionWarning.show();
if (willBeDeletedInMs >= 60000) {
$deletionWarning.text(`This attachment will be deleted in ${utils.formatTimeInterval(willBeDeletedInMs)}`);
} else {
$deletionWarning.text(`This attachment will be deleted soon`);
}
$deletionWarning.append(", because the image attachment is not used. To prevent deletion, add the image back into the note.");
} else {
this.$wrapper.removeClass("scheduled-for-deletion");
$deletionWarning.hide();
} }
this.$wrapper.find('.attachment-details') this.$wrapper.find('.attachment-details')

View File

@ -61,7 +61,8 @@ const ALLOWED_OPTIONS = new Set([
'downloadImagesAutomatically', 'downloadImagesAutomatically',
'minTocHeadings', 'minTocHeadings',
'checkForUpdates', 'checkForUpdates',
'disableTray' 'disableTray',
'eraseUnusedImageAttachmentsAfterSeconds'
]); ]);
function getOptions() { function getOptions() {

View File

@ -21,6 +21,7 @@ const htmlSanitizer = require("./html_sanitizer");
const ValidationError = require("../errors/validation_error"); const ValidationError = require("../errors/validation_error");
const noteTypesService = require("./note_types"); const noteTypesService = require("./note_types");
const fs = require("fs"); const fs = require("fs");
const BAttachment = require("../becca/entities/battachment");
/** @param {BNote} parentNote */ /** @param {BNote} parentNote */
function getNewNotePosition(parentNote) { function getNewNotePosition(parentNote) {
@ -342,17 +343,17 @@ function checkImageAttachments(note, content) {
let match; let match;
while (match = re.exec(content)) { while (match = re.exec(content)) {
foundAttachmentIds.push(match[1]); foundAttachmentIds.add(match[1]);
} }
for (const attachment of note.getAttachmentByRole('image')) { for (const attachment of note.getAttachmentByRole('image')) {
const imageInContent = foundAttachmentIds.has(attachment.attachmentId); const imageInContent = foundAttachmentIds.has(attachment.attachmentId);
if (attachment.utcDateScheduledForDeletionSince && imageInContent) { if (attachment.utcDateScheduledForErasureSince && imageInContent) {
attachment.utcDateScheduledForDeletionSince = null; attachment.utcDateScheduledForErasureSince = null;
attachment.save(); attachment.save();
} else if (!attachment.utcDateScheduledForDeletionSince && !imageInContent) { } else if (!attachment.utcDateScheduledForErasureSince && !imageInContent) {
attachment.utcDateScheduledForDeletionSince = dateUtils.utcNowDateTime(); attachment.utcDateScheduledForErasureSince = dateUtils.utcNowDateTime();
attachment.save(); attachment.save();
} }
} }
@ -841,6 +842,33 @@ function eraseAttributes(attributeIdsToErase) {
log.info(`Erased attributes: ${JSON.stringify(attributeIdsToErase)}`); log.info(`Erased attributes: ${JSON.stringify(attributeIdsToErase)}`);
} }
function eraseAttachments(attachmentIdsToErase) {
if (attachmentIdsToErase.length === 0) {
return;
}
sql.executeMany(`DELETE FROM attachments WHERE attachmentId IN (???)`, attachmentIdsToErase);
setEntityChangesAsErased(sql.getManyRows(`SELECT * FROM entity_changes WHERE entityName = 'attachments' AND entityId IN (???)`, attachmentIdsToErase));
log.info(`Erased attachments: ${JSON.stringify(attachmentIdsToErase)}`);
}
function eraseUnusedBlobs() {
const unusedBlobIds = sql.getColumn(`
SELECT blobId
FROM blobs
LEFT JOIN notes ON notes.blobId = blobs.blobId
LEFT JOIN attachments ON attachments.blobId = blobs.blobId
WHERE notes.noteId IS NULL AND attachments.attachmentId IS NULL`);
sql.executeMany(`DELETE FROM blobs WHERE blobId IN (???)`, unusedBlobIds);
setEntityChangesAsErased(sql.getManyRows(`SELECT * FROM entity_changes WHERE entityName = 'blobs' AND entityId IN (???)`, unusedBlobIds));
log.info(`Erased unused blobs: ${JSON.stringify(unusedBlobIds)}`);
}
function eraseDeletedEntities(eraseEntitiesAfterTimeInSeconds = null) { function eraseDeletedEntities(eraseEntitiesAfterTimeInSeconds = null) {
// this is important also so that the erased entity changes are sent to the connected clients // this is important also so that the erased entity changes are sent to the connected clients
sql.transactional(() => { sql.transactional(() => {
@ -861,6 +889,12 @@ function eraseDeletedEntities(eraseEntitiesAfterTimeInSeconds = null) {
const attributeIdsToErase = sql.getColumn("SELECT attributeId FROM attributes WHERE isDeleted = 1 AND utcDateModified <= ?", [dateUtils.utcDateTimeStr(cutoffDate)]); const attributeIdsToErase = sql.getColumn("SELECT attributeId FROM attributes WHERE isDeleted = 1 AND utcDateModified <= ?", [dateUtils.utcDateTimeStr(cutoffDate)]);
eraseAttributes(attributeIdsToErase); eraseAttributes(attributeIdsToErase);
const attachmentIdsToErase = sql.getColumn("SELECT attachmentId FROM attachments WHERE isDeleted = 1 AND utcDateModified <= ?", [dateUtils.utcDateTimeStr(cutoffDate)]);
eraseAttachments(attachmentIdsToErase);
eraseUnusedBlobs();
}); });
} }
@ -1013,11 +1047,21 @@ function getNoteIdMapping(origNote) {
return noteIdMapping; return noteIdMapping;
} }
function eraseScheduledAttachments() {
const eraseIntervalSeconds = optionService.getOptionInt('eraseUnusedImageAttachmentsAfterSeconds');
const cutOffDate = dateUtils.utcDateTimeStr(new Date(Date.now() - (eraseIntervalSeconds * 1000)));
const attachmentIdsToErase = sql.getColumn('SELECT attachmentId FROM attachments WHERE utcDateScheduledForErasureSince < ?', [cutOffDate]);
eraseAttachments(attachmentIdsToErase);
}
sqlInit.dbReady.then(() => { sqlInit.dbReady.then(() => {
// first cleanup kickoff 5 minutes after startup // first cleanup kickoff 5 minutes after startup
setTimeout(cls.wrap(() => eraseDeletedEntities()), 5 * 60 * 1000); setTimeout(cls.wrap(() => eraseDeletedEntities()), 5 * 60 * 1000);
setTimeout(cls.wrap(() => eraseScheduledAttachments()), 6 * 60 * 1000);
setInterval(cls.wrap(() => eraseDeletedEntities()), 4 * 3600 * 1000); setInterval(cls.wrap(() => eraseDeletedEntities()), 4 * 3600 * 1000);
setInterval(cls.wrap(() => eraseScheduledAttachments()), 3600 * 1000);
}); });
module.exports = { module.exports = {

View File

@ -88,6 +88,7 @@ const defaultOptions = [
{ name: 'minTocHeadings', value: '5', isSynced: true }, { name: 'minTocHeadings', value: '5', isSynced: true },
{ name: 'checkForUpdates', value: 'true', isSynced: true }, { name: 'checkForUpdates', value: 'true', isSynced: true },
{ name: 'disableTray', value: 'false', isSynced: false }, { name: 'disableTray', value: 'false', isSynced: false },
{ name: 'eraseUnusedImageAttachmentsAfterSeconds', value: '86400', isSynced: false },
]; ];
function initStartupOptions() { function initStartupOptions() {