mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-30 01:42:29 +03:00
046b06fe72
ref https://ghost-foundation.sentry.io/issues/5908152800/ - In the current state, we are maintaining an 'index' key for all revisions in localStorage. This gives us quick and easy access to all the revisions in localStorage, but it requires additional "bookkeeping" to update the index each time we add/remove a key. - In some obscure edge cases, this results in the `remove()` method throwing a `QuotaExceededError` (since removing a revision also requires updating the index with `localStorage.setItem()`). If the `remove()` call fails, we are sort of stuck — the only way to reduce our storage usage is to remove items, but if the `remove()` method throws errors, we can't do that. - This change removes the whole index concept, and instead loops over all the keys in localStorage, filtering by the prefix to find all our revisions. This makes the `keys()` method slightly more complex, as it has to filter out keys in localStorage that aren't related to revisions, but it simplifies saving and removing revisions. - Critically, this also means that `remove()` should never throw a `QuotaExceededError`, since it no longer needs to call `localStorage.setItem()` — it now simply calls `localStorage.removeItem()` for the revision, which should never fail.
280 lines
9.5 KiB
JavaScript
280 lines
9.5 KiB
JavaScript
import * as Sentry from '@sentry/ember';
|
|
import Service, {inject as service} from '@ember/service';
|
|
import config from 'ghost-admin/config/environment';
|
|
import {task, timeout} from 'ember-concurrency';
|
|
|
|
/**
|
|
* Service to manage local post revisions in localStorage
|
|
*/
|
|
export default class LocalRevisionsService extends Service {
|
|
constructor() {
|
|
super(...arguments);
|
|
if (this.isTesting === undefined) {
|
|
this.isTesting = config.environment === 'test';
|
|
}
|
|
this.MIN_REVISION_TIME = this.isTesting ? 50 : 60000; // 1 minute in ms
|
|
this.performSave = this.performSave.bind(this);
|
|
this.storage = window.localStorage;
|
|
}
|
|
|
|
@service store;
|
|
|
|
// base key prefix to avoid collisions in localStorage
|
|
_prefix = 'post-revision';
|
|
latestRevisionTime = null;
|
|
|
|
/**
|
|
*
|
|
* @param {object} data - serialized post data, must include id and revisionTimestamp
|
|
* @returns {string} - key to store the revision in localStorage
|
|
*/
|
|
generateKey(data) {
|
|
return `${this._prefix}-${data.id}-${data.revisionTimestamp}`;
|
|
}
|
|
|
|
/**
|
|
* Performs the save operations, either immediately or after a delay
|
|
*
|
|
* leepLatest ensures the latest changes will be saved
|
|
* @param {string} type - post or page
|
|
* @param {object} data - serialized post data
|
|
*/
|
|
@task({keepLatest: true})
|
|
*saveTask(type, data) {
|
|
try {
|
|
const currentTime = Date.now();
|
|
if (!this.lastRevisionTime || currentTime - this.lastRevisionTime > this.MIN_REVISION_TIME) {
|
|
yield this.performSave(type, data);
|
|
this.lastRevisionTime = currentTime;
|
|
} else {
|
|
const waitTime = this.MIN_REVISION_TIME - (currentTime - this.lastRevisionTime);
|
|
yield timeout(waitTime);
|
|
yield this.performSave(type, data);
|
|
this.lastRevisionTime = Date.now();
|
|
}
|
|
} catch (err) {
|
|
Sentry.captureException(err, {tags: {localRevisions: 'saveTaskError'}});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Saves the revision to localStorage
|
|
*
|
|
* If localStorage is full, the oldest revision will be removed
|
|
* @param {string} type - post or page
|
|
* @param {object} data - serialized post data
|
|
* @returns {string | undefined} - key of the saved revision or undefined if it couldn't be saved
|
|
*/
|
|
performSave(type, data) {
|
|
data.id = data.id || 'draft';
|
|
data.type = type;
|
|
data.revisionTimestamp = Date.now();
|
|
const key = this.generateKey(data);
|
|
try {
|
|
this.storage.setItem(key, JSON.stringify(data));
|
|
// Apply the filter after saving
|
|
this.filterRevisions(data.id);
|
|
|
|
return key;
|
|
} catch (err) {
|
|
if (err.name === 'QuotaExceededError') {
|
|
// Remove the current key in case it's already in the index
|
|
this.remove(key);
|
|
|
|
// If there are any revisions, remove the oldest one and try to save again
|
|
if (this.keys().length) {
|
|
Sentry.captureMessage('LocalStorage quota exceeded. Removing old revisions.', {tags: {localRevisions: 'quotaExceeded'}});
|
|
this.removeOldest();
|
|
return this.performSave(type, data);
|
|
}
|
|
// LocalStorage is full and there are no revisions to remove
|
|
// We can't save the revision
|
|
Sentry.captureMessage('LocalStorage quota exceeded. Unable to save revision.', {tags: {localRevisions: 'quotaExceededNoSpace'}});
|
|
return;
|
|
} else {
|
|
Sentry.captureException(err, {tags: {localRevisions: 'saveError'}});
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Method to trigger the save task
|
|
* @param {string} type - post or page
|
|
* @param {object} data - serialized post data
|
|
*/
|
|
scheduleSave(type, data) {
|
|
if (data && data.status && data.status === 'draft') {
|
|
this.saveTask.perform(type, data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the specified revision from localStorage, or null if it doesn't exist
|
|
* @param {string} key - key of the revision to find
|
|
* @returns {string | null}
|
|
*/
|
|
find(key) {
|
|
return JSON.parse(this.storage.getItem(key));
|
|
}
|
|
|
|
/**
|
|
* Returns all revisions from localStorage as an array, optionally filtered by key prefix and ordered by timestamp
|
|
* @param {string | undefined} prefix - optional prefix to filter revision keys
|
|
* @returns {Array} - all revisions matching the prefix, ordered by timestamp (newest first)
|
|
*/
|
|
findAll(prefix = this._prefix) {
|
|
const keys = this.keys(prefix);
|
|
const revisions = keys.map((key) => {
|
|
const revision = JSON.parse(this.storage.getItem(key));
|
|
return {
|
|
key,
|
|
...revision
|
|
};
|
|
});
|
|
|
|
// Sort revisions by timestamp, newest first
|
|
revisions.sort((a, b) => b.revisionTimestamp - a.revisionTimestamp);
|
|
|
|
return revisions;
|
|
}
|
|
|
|
/**
|
|
* Removes the specified key from localStorage
|
|
* @param {string} key
|
|
*/
|
|
remove(key) {
|
|
this.storage.removeItem(key);
|
|
}
|
|
|
|
/**
|
|
* Finds the oldest revision and removes it from localStorage to clear up space
|
|
*/
|
|
removeOldest() {
|
|
const keys = this.keys();
|
|
const keysByTimestamp = keys.map(key => ({key, timestamp: this.find(key).revisionTimestamp}));
|
|
keysByTimestamp.sort((a, b) => a.timestamp - b.timestamp);
|
|
this.remove(keysByTimestamp[0].key);
|
|
}
|
|
|
|
/**
|
|
* Removes all revisions from localStorage
|
|
*/
|
|
clear() {
|
|
const keys = this.keys();
|
|
for (const key of keys) {
|
|
this.remove(key);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns all revision keys from localStorage, optionally filtered by key prefix
|
|
* @param {string | undefined} prefix
|
|
* @returns {string[]}
|
|
*/
|
|
keys(prefix = undefined) {
|
|
const allKeys = [];
|
|
const filterPrefix = prefix || this._prefix;
|
|
for (let i = 0; i < this.storage.length; i++) {
|
|
const key = this.storage.key(i);
|
|
if (key.startsWith(filterPrefix)) {
|
|
allKeys.push(key);
|
|
}
|
|
}
|
|
return allKeys;
|
|
}
|
|
|
|
/**
|
|
* Logs all revisions to the console
|
|
*
|
|
* Currently this is the only UI for local revisions
|
|
*/
|
|
list() {
|
|
const revisions = this.findAll();
|
|
const data = {};
|
|
for (const [key, revision] of Object.entries(revisions)) {
|
|
if (!data[revision.title]) {
|
|
data[revision.title] = [];
|
|
}
|
|
data[revision.title].push({
|
|
key,
|
|
timestamp: revision.revisionTimestamp,
|
|
time: new Date(revision.revisionTimestamp).toLocaleString(),
|
|
title: revision.title,
|
|
type: revision.type,
|
|
id: revision.id
|
|
});
|
|
}
|
|
/* eslint-disable no-console */
|
|
console.groupCollapsed('Local revisions');
|
|
for (const [title, row] of Object.entries(data)) {
|
|
// eslint-disable-next-line no-console
|
|
console.groupCollapsed(`${title}`);
|
|
for (const item of row.sort((a, b) => b.timestamp - a.timestamp)) {
|
|
// eslint-disable-next-line no-console
|
|
console.groupCollapsed(`${item.time}`);
|
|
console.log('Revision ID: ', item.key);
|
|
console.groupEnd();
|
|
}
|
|
console.groupEnd();
|
|
}
|
|
console.groupEnd();
|
|
/* eslint-enable no-console */
|
|
}
|
|
|
|
/**
|
|
* Creates a new post from the specified revision
|
|
*
|
|
* @param {string} key
|
|
* @returns {Promise} - the new post model
|
|
*/
|
|
async restore(key) {
|
|
try {
|
|
const revision = this.find(key);
|
|
let authors = [];
|
|
if (revision.authors) {
|
|
for (const author of revision.authors) {
|
|
const authorModel = await this.store.queryRecord('user', {id: author.id});
|
|
authors.push(authorModel);
|
|
}
|
|
}
|
|
let post = this.store.createRecord('post', {
|
|
title: `(Restored) ${revision.title}`,
|
|
lexical: revision.lexical,
|
|
authors,
|
|
type: revision.type,
|
|
slug: revision.slug || 'untitled',
|
|
status: 'draft',
|
|
tags: revision.tags || [],
|
|
post_revisions: []
|
|
});
|
|
await post.save();
|
|
const location = window.location;
|
|
const url = `${location.origin}${location.pathname}#/editor/${post.get('type')}/${post.id}`;
|
|
// eslint-disable-next-line no-console
|
|
console.log('Post restored: ', url);
|
|
return post;
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Filters revisions to keep only the most recent 5 for a given post ID
|
|
* @param {string} postId - ID of the post to filter revisions for
|
|
*/
|
|
filterRevisions(postId) {
|
|
if (postId === 'draft') {
|
|
return; // Ignore filter for drafts
|
|
}
|
|
|
|
const allRevisions = this.findAll(`${this._prefix}-${postId}`);
|
|
if (allRevisions.length > 5) {
|
|
const revisionsToRemove = allRevisions.slice(5);
|
|
revisionsToRemove.forEach((revision) => {
|
|
this.remove(revision.key);
|
|
});
|
|
}
|
|
}
|
|
} |