0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

Added local revisions to the editor as backstop against data loss (#21044)

ref https://app.incident.io/ghost/incidents/107

- We have a rare bug that causes the initial `POST` request to create a
new post from the editor to be skipped or fail. Subsequent `PUT`
requests then fail because there is no post ID, potentially resulting in
data loss. The aim of this commit is to start saving revisions of posts
in the editor to the browser's localStorage, as a last-ditch option to
restore lost work.
- Since we don't know where the bug is yet, and to protect against
future bugs, we've deliberately avoided depending too heavily on the
`lexical-editor` controller or the ember store. We've aimed to create a
direct route to the state in the editor, by hooking into the
`updateScratch` method (effectively the `onChange` handler for the
editor).
- The `scheduleSave` function on the new `local-revisions` service is
called immediately upon any changes to the state of the lexical editor,
which is effectively every keystroke. The service has some logic and
timeouts, so it doesn't actually save a revision on every change to the
editor.
- The "schema" of the datastore is a simple key-value store, where the
key is of the format: `post-revision-${postId}-${timestamp}` if the post
has an ID, or `post-revision-draft-${timestamp}` for an unsaved draft.
There is also an array of all the revisions' keys, which allows us to
clear all the revisions without having to loop over every key in
localStorage (along with some other conveniences, like filtering).
- There is currently no UI for viewing/restoring revisions. In the event
that you need to restore a revision, you can access the service in the
browser console. You can access all the saved revisions using the
`list()` method, which logs all the revisions to the console by title &
timestamp. You can then choose a revision to restore, and call
`restore(revision_key)`, which will `POST` the revision's data to the
server to create a new post.
- Since localStorage data is limited to a 5mb quota in most browsers,
the service has a mechanism for evicting the oldest revisions once it
meets the quota. If a save fails because it would exceed the quota, the
`performSave` method will evict the oldest revision, then recursively
try to save again.

---------

Co-authored-by: Steve Larson <9larsons@gmail.com>
This commit is contained in:
Chris Raible 2024-09-20 00:08:28 -07:00 committed by GitHub
parent 8ab7182bfe
commit cc88757e2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 597 additions and 1 deletions

View file

@ -159,6 +159,7 @@ export default class LexicalEditorController extends Controller {
@service session;
@service settings;
@service ui;
@service localRevisions;
@inject config;
@ -306,8 +307,18 @@ export default class LexicalEditorController extends Controller {
@action
updateScratch(lexical) {
this.set('post.lexicalScratch', JSON.stringify(lexical));
const lexicalString = JSON.stringify(lexical);
this.set('post.lexicalScratch', lexicalString);
try {
// schedule a local revision save
if (this.post.status === 'draft') {
this.localRevisions.scheduleSave(this.post.displayName, {...this.post.serialize({includeId: true}), lexical: lexicalString});
}
} catch (err) {
// ignore errors
}
// save 3 seconds after last edit
this._autosaveTask.perform();
// force save at 60 seconds
@ -322,6 +333,14 @@ export default class LexicalEditorController extends Controller {
@action
updateTitleScratch(title) {
this.set('post.titleScratch', title);
try {
// schedule a local revision save
if (this.post.status === 'draft') {
this.localRevisions.scheduleSave(this.post.displayName, {...this.post.serialize({includeId: true}), title: title});
}
} catch (err) {
// ignore errors
}
}
@action

View file

@ -0,0 +1,246 @@
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);
}
@service store;
// base key prefix to avoid collisions in localStorage
_prefix = 'post-revision';
latestRevisionTime = null;
// key to store a simple index of all revisions
_indexKey = 'ghost-revisions';
/**
*
* @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) {
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();
}
}
/**
* 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 {
const allKeys = this.keys();
allKeys.push(key);
localStorage.setItem(this._indexKey, JSON.stringify(allKeys));
localStorage.setItem(key, JSON.stringify(data));
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) {
this.removeOldest();
return this.performSave(type, data);
}
// LocalStorage is full and there are no revisions to remove
// We can't save the revision
}
}
}
/**
* Method to trigger the save task
* @param {string} type - post or page
* @param {object} data - serialized post data
*/
scheduleSave(type, data) {
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(localStorage.getItem(key));
}
/**
* Returns all revisions from localStorage, optionally filtered by key prefix
* @param {string | undefined} prefix - optional prefix to filter revision keys
* @returns
*/
findAll(prefix = undefined) {
const keys = this.keys(prefix);
const revisions = {};
for (const key of keys) {
revisions[key] = JSON.parse(localStorage.getItem(key));
}
return revisions;
}
/**
* Removes the specified key from localStorage
* @param {string} key
*/
remove(key) {
localStorage.removeItem(key);
const keys = this.keys();
let index = keys.indexOf(key);
if (index !== -1) {
keys.splice(index, 1);
}
localStorage.setItem(this._indexKey, JSON.stringify(keys));
}
/**
* 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) {
let keys = JSON.parse(localStorage.getItem(this._indexKey) || '[]');
if (prefix) {
keys = keys.filter(key => key.startsWith(prefix));
}
return keys;
}
/**
* 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);
}
}
}

View file

@ -0,0 +1,59 @@
import Pretender from 'pretender';
import ghostPaths from 'ghost-admin/utils/ghost-paths';
import {describe, it} from 'mocha';
import {expect} from 'chai';
import {setupTest} from 'ember-mocha';
function stubCreatePostEndpoint(server) {
server.post(`${ghostPaths().apiRoot}/posts/`, function () {
return [
201,
{'Content-Type': 'application/json'},
JSON.stringify({posts: [{
id: 'test id',
lexical: 'test lexical string',
title: 'test title',
post_revisions: []
}]})
];
});
server.get(`${ghostPaths().apiRoot}/users/`, function () {
return [
200,
{'Content-Type': 'application/json'},
JSON.stringify({users: [{
id: '1',
name: 'test name',
roles: ['owner']
}]})
];
});
}
describe('Integration: Service: local-revisions', function () {
setupTest();
let server;
beforeEach(function () {
server = new Pretender();
this.service = this.owner.lookup('service:local-revisions');
this.service.clear();
});
afterEach(function () {
server.shutdown();
});
it('restores a post from a revision', async function () {
stubCreatePostEndpoint(server);
// create a post to restore
const key = this.service.performSave('post', {id: 'test-id', authors: [{id: '1'}], lexical: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"\\"{\\\\\\"root\\\\\\":{\\\\\\"children\\\\\\":[{\\\\\\"children\\\\\\":[{\\\\\\"detail\\\\\\":0,\\\\\\"format\\\\\\":0,\\\\\\"mode\\\\\\":\\\\\\"normal\\\\\\",\\\\\\"style\\\\\\":\\\\\\"\\\\\\",\\\\\\"text\\\\\\":\\\\\\"T\\\\\\",\\\\\\"type\\\\\\":\\\\\\"extended-text\\\\\\",\\\\\\"version\\\\\\":1}],\\\\\\"direction\\\\\\":\\\\\\"ltr\\\\\\",\\\\\\"format\\\\\\":\\\\\\"\\\\\\",\\\\\\"indent\\\\\\":0,\\\\\\"type\\\\\\":\\\\\\"paragraph\\\\\\",\\\\\\"version\\\\\\":1}],\\\\\\"direction\\\\\\":\\\\\\"ltr\\\\\\",\\\\\\"format\\\\\\":\\\\\\"\\\\\\",\\\\\\"indent\\\\\\":0,\\\\\\"type\\\\\\":\\\\\\"root\\\\\\",\\\\\\"version\\\\\\":1}}\\"","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}'});
// restore the post
const post = await this.service.restore(key);
expect(post.get('lexical')).to.equal('test lexical string');
});
});

View file

@ -0,0 +1,272 @@
import Service from '@ember/service';
import sinon from 'sinon';
import {describe, it} from 'mocha';
import {expect} from 'chai';
import {setupTest} from 'ember-mocha';
const sleep = ms => new Promise((resolve) => {
setTimeout(resolve, ms);
});
describe('Unit: Service: local-revisions', function () {
setupTest();
let localStore, setItemStub;
this.beforeEach(function () {
// Mock localStorage
sinon.restore();
localStore = {};
sinon.stub(localStorage, 'getItem').callsFake(key => localStore[key] || null);
setItemStub = sinon.stub(localStorage, 'setItem').callsFake((key, value) => localStore[key] = value + '');
sinon.stub(localStorage, 'removeItem').callsFake(key => delete localStore[key]);
sinon.stub(localStorage, 'clear').callsFake(() => localStore = {});
// Create the service
this.service = this.owner.lookup('service:local-revisions');
this.service.clear();
});
it('exists', function () {
expect(this.service).to.be.ok;
});
describe('generateKey', function () {
it('generates a key for a post with an id', function () {
const revisionTimestamp = Date.now();
const key = this.service.generateKey({id: 'test', revisionTimestamp});
expect(key).to.equal(`post-revision-test-${revisionTimestamp}`);
});
it('generates a key for a post without a post id', function () {
const revisionTimestamp = Date.now();
const key = this.service.generateKey({id: 'draft', revisionTimestamp});
expect(key).to.equal(`post-revision-draft-${revisionTimestamp}`);
});
});
describe('performSave', function () {
it('saves a revision without a post id', function () {
// save a revision
const key = this.service.performSave('post', {id: 'draft', lexical: 'test'});
const revision = this.service.find(key);
expect(key).to.match(/post-revision-draft-\d+/);
expect(revision.id).to.equal('draft');
expect(revision.lexical).to.equal('test');
});
it('saves a revision with a post id', function () {
// save a revision
const key = this.service.performSave('post', {id: 'test-id', lexical: 'test'});
const revision = this.service.find(key);
expect(key).to.match(/post-revision-test-id-\d+/);
expect(revision.id).to.equal('test-id');
expect(revision.lexical).to.equal('test');
});
it('evicts the oldest version if localStorage is full', async function () {
// save a few revisions
const keyToRemove = this.service.performSave('post', {id: 'test-id', lexical: 'test'});
await sleep(1);
this.service.performSave('post', {id: 'test-id', lexical: 'data-2'});
await sleep(1);
// Simulate a quota exceeded error
const quotaError = new Error('QuotaExceededError');
quotaError.name = 'QuotaExceededError';
const callCount = setItemStub.callCount;
setItemStub.onCall(callCount).throws(quotaError);
const keyToAdd = this.service.performSave('post', {id: 'test-id', lexical: 'data-3'});
// Ensure the oldest revision was removed
expect(this.service.find(keyToRemove)).to.be.null;
// Ensure the latest revision saved
expect(this.service.find(keyToAdd)).to.not.be.null;
});
it('evicts multiple oldest versions if localStorage is full', async function () {
// save a few revisions
const keyToRemove = this.service.performSave('post', {id: 'test-id-1', lexical: 'test'});
await sleep(1);
const nextKeyToRemove = this.service.performSave('post', {id: 'test-id-2', lexical: 'data-2'});
await sleep(1);
// Simulate a quota exceeded error
const quotaError = new Error('QuotaExceededError');
quotaError.name = 'QuotaExceededError';
setItemStub.onCall(setItemStub.callCount).throws(quotaError);
// remove calls setItem() to remove the key from the index
// it's called twice for each quota error, hence the + 3
setItemStub.onCall(setItemStub.callCount + 3).throws(quotaError);
const keyToAdd = this.service.performSave('post', {id: 'test-id-3', lexical: 'data-3'});
// Ensure the oldest revision was removed
expect(this.service.find(keyToRemove)).to.be.null;
expect(this.service.find(nextKeyToRemove)).to.be.null;
// Ensure the latest revision saved
expect(this.service.find(keyToAdd)).to.not.be.null;
});
});
describe('scheduleSave', function () {
it('saves a revision', function () {
// save a revision
this.service.scheduleSave('post', {id: 'draft', lexical: 'test'});
const key = this.service.keys()[0];
const revision = this.service.find(key);
expect(key).to.match(/post-revision-draft-\d+/);
expect(revision.id).to.equal('draft');
expect(revision.lexical).to.equal('test');
});
it('does not save a revision more than once if scheduled multiple times', async function () {
// interval is set to 200 ms in testing
this.service.scheduleSave('post', {id: 'draft', lexical: 'test'});
await sleep(40);
this.service.scheduleSave('post', {id: 'draft', lexical: 'test'});
const keys = this.service.keys();
expect(keys).to.have.lengthOf(1);
});
it('saves another revision if it has been longer than the revision interval', async function () {
// interval is set to 200 ms in testing
this.service.scheduleSave('post', {id: 'draft', lexical: 'test'});
await sleep(100);
this.service.scheduleSave('post', {id: 'draft', lexical: 'test'});
const keys = this.service.keys();
expect(keys).to.have.lengthOf(2);
});
});
describe('find', function () {
it('gets a revision by key', function () {
// save a revision
const key = this.service.performSave('post', {lexical: 'test'});
const result = this.service.find(key);
expect(result.id).to.equal('draft');
expect(result.lexical).to.equal('test');
expect(result.revisionTimestamp).to.match(/\d+/);
});
it('returns null if the key does not exist', function () {
const result = this.service.find('non-existent-key');
expect(result).to.be.null;
});
});
describe('findAll', function () {
it('gets all revisions if no prefix is provided', function () {
// save a revision
this.service.performSave('post', {id: 'test-id', lexical: 'test'});
this.service.performSave('post', {lexical: 'data-2'});
const result = this.service.findAll();
expect(Object.keys(result)).to.have.lengthOf(2);
});
it('gets revisions filtered by prefix', function () {
// save a revision
this.service.performSave('post', {id: 'test-id', lexical: 'test'});
this.service.performSave('post', {lexical: 'data-2'});
const result = this.service.findAll('post-revision-test-id');
expect(Object.keys(result)).to.have.lengthOf(1);
});
it('returns an empty object if there are no revisions', function () {
const result = this.service.findAll();
expect(result).to.deep.equal({});
});
});
describe('keys', function () {
it('returns an empty array if there are no revisions', function () {
const result = this.service.keys();
expect(result).to.deep.equal([]);
});
it('returns the keys for all revisions if not prefix is provided', function () {
// save revision
this.service.performSave('post', {id: 'test-id', lexical: 'data'});
const result = this.service.keys();
expect(Object.keys(result)).to.have.lengthOf(1);
expect(result[0]).to.match(/post-revision-test-id-\d+/);
});
it('returns the keys filtered by prefix if provided', function () {
// save revision
this.service.performSave('post', {id: 'test-id', lexical: 'data'});
this.service.performSave('post', {id: 'draft', lexical: 'data'});
const result = this.service.keys('post-revision-test-id');
expect(Object.keys(result)).to.have.lengthOf(1);
expect(result[0]).to.match(/post-revision-test-id-\d+/);
});
});
describe('remove', function () {
it('removes the specified key', function () {
// save revision
const key = this.service.performSave('post', {id: 'test-id', lexical: 'data'});
this.service.performSave('post', {id: 'test-2', lexical: 'data'});
this.service.remove(key);
const updatedKeys = this.service.keys();
expect(updatedKeys).to.have.lengthOf(1);
expect(this.service.find(key)).to.be.null;
});
it('does nothing if the key does not exist', function () {
// save revision
this.service.performSave('post', {id: 'test-id', lexical: 'data'});
this.service.performSave('post', {id: 'test-2', lexical: 'data'});
this.service.remove('non-existent-key');
const updatedKeys = this.service.keys();
expect(updatedKeys).to.have.lengthOf(2);
});
});
describe('removeOldest', function () {
it('removes the oldest revision', async function () {
// save revision
const keyToRemove = this.service.performSave('post', {id: 'test-id', lexical: 'data'});
await sleep(1);
this.service.performSave('post', {id: 'test-2', lexical: 'data'});
await sleep(1);
this.service.performSave('post', {id: 'test-3', lexical: 'data'});
this.service.removeOldest();
const updatedKeys = this.service.keys();
expect(updatedKeys).to.have.lengthOf(2);
expect(this.service.find(keyToRemove)).to.be.null;
});
});
describe('restore', function () {
it('creates a new post based on the revision data', async function () {
// stub out the store service
let saveStub = sinon.stub().resolves({id: 'test-id'});
let setStub = sinon.stub();
let getStub = sinon.stub().returns('post');
let queryRecordStub = sinon.stub().resolves({id: '1'});
this.owner.register('service:store', Service.extend({
createRecord: () => {
return {
id: 'new-id',
save: saveStub,
set: setStub,
get: getStub
};
},
queryRecord: queryRecordStub
}));
// create a post to restore
const key = this.service.performSave('post', {id: 'test-id', authors: [{id: '1'}], lexical: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"\\"{\\\\\\"root\\\\\\":{\\\\\\"children\\\\\\":[{\\\\\\"children\\\\\\":[{\\\\\\"detail\\\\\\":0,\\\\\\"format\\\\\\":0,\\\\\\"mode\\\\\\":\\\\\\"normal\\\\\\",\\\\\\"style\\\\\\":\\\\\\"\\\\\\",\\\\\\"text\\\\\\":\\\\\\"T\\\\\\",\\\\\\"type\\\\\\":\\\\\\"extended-text\\\\\\",\\\\\\"version\\\\\\":1}],\\\\\\"direction\\\\\\":\\\\\\"ltr\\\\\\",\\\\\\"format\\\\\\":\\\\\\"\\\\\\",\\\\\\"indent\\\\\\":0,\\\\\\"type\\\\\\":\\\\\\"paragraph\\\\\\",\\\\\\"version\\\\\\":1}],\\\\\\"direction\\\\\\":\\\\\\"ltr\\\\\\",\\\\\\"format\\\\\\":\\\\\\"\\\\\\",\\\\\\"indent\\\\\\":0,\\\\\\"type\\\\\\":\\\\\\"root\\\\\\",\\\\\\"version\\\\\\":1}}\\"","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}'});
// restore the post
const post = await this.service.restore(key);
// Ensure the post is saved
expect(saveStub.calledOnce).to.be.true;
// Restore should return the post object
expect(post.id).to.equal('new-id');
});
});
});