mirror of
https://github.com/tobspr/shapez.io.git
synced 2025-06-13 13:04:03 +00:00
Removed unnecessary compression step
The logic for returning default data is now in the `catch` part of the `readFileAsync` promise, so that new games don't trigger unnecessary compression and then decompression of the default data.
This commit is contained in:
parent
1046e7d4bd
commit
a04dff4d10
@ -20,324 +20,321 @@ const salt = accessNestedPropertyReverse(globalConfig, ["file", "info"]);
|
||||
|
||||
// Helper which only writes / reads if verify() works. Also performs migration
|
||||
export class ReadWriteProxy {
|
||||
constructor(app, filename) {
|
||||
/** @type {Application} */
|
||||
this.app = app;
|
||||
constructor(app, filename) {
|
||||
/** @type {Application} */
|
||||
this.app = app;
|
||||
|
||||
this.filename = filename;
|
||||
this.filename = filename;
|
||||
|
||||
/** @type {object} */
|
||||
this.currentData = null;
|
||||
/** @type {object} */
|
||||
this.currentData = null;
|
||||
|
||||
// TODO: EXTREMELY HACKY! To verify we need to do this a step later
|
||||
if (G_IS_DEV && IS_DEBUG) {
|
||||
setTimeout(() => {
|
||||
assert(
|
||||
this.verify(this.getDefaultData()).result,
|
||||
"Verify() failed for default data: " + this.verify(this.getDefaultData()).reason
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a debounced handler to prevent double writes
|
||||
*/
|
||||
this.debouncedWrite = debounce(this.doWriteAsync.bind(this), 50);
|
||||
}
|
||||
|
||||
// -- Methods to override
|
||||
|
||||
/** @returns {ExplainedResult} */
|
||||
verify(data) {
|
||||
abstract;
|
||||
return ExplainedResult.bad();
|
||||
}
|
||||
|
||||
// Should return the default data
|
||||
getDefaultData() {
|
||||
abstract;
|
||||
return {};
|
||||
}
|
||||
|
||||
// Should return the current version as an integer
|
||||
getCurrentVersion() {
|
||||
abstract;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Should migrate the data (Modify in place)
|
||||
/** @returns {ExplainedResult} */
|
||||
migrate(data) {
|
||||
abstract;
|
||||
return ExplainedResult.bad();
|
||||
}
|
||||
|
||||
// -- / Methods
|
||||
|
||||
// Resets whole data, returns promise
|
||||
resetEverythingAsync() {
|
||||
logger.warn("Reset data to default");
|
||||
this.currentData = this.getDefaultData();
|
||||
return this.writeAsync();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} obj
|
||||
*/
|
||||
static serializeObject(obj) {
|
||||
const jsonString = JSON.stringify(compressObject(obj));
|
||||
const checksum = computeCrc(jsonString + salt);
|
||||
return compressionPrefix + compressX64(checksum + jsonString);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} text
|
||||
*/
|
||||
static deserializeObject(text) {
|
||||
const decompressed = decompressX64(text.substr(compressionPrefix.length));
|
||||
if (!decompressed) {
|
||||
// LZ string decompression failure
|
||||
throw new Error("bad-content / decompression-failed");
|
||||
}
|
||||
if (decompressed.length < 40) {
|
||||
// String too short
|
||||
throw new Error("bad-content / payload-too-small");
|
||||
}
|
||||
|
||||
// Compare stored checksum with actual checksum
|
||||
const checksum = decompressed.substring(0, 40);
|
||||
const jsonString = decompressed.substr(40);
|
||||
|
||||
const desiredChecksum = checksum.startsWith(CRC_PREFIX)
|
||||
? computeCrc(jsonString + salt)
|
||||
: sha1(jsonString + salt);
|
||||
|
||||
if (desiredChecksum !== checksum) {
|
||||
// Checksum mismatch
|
||||
throw new Error("bad-content / checksum-mismatch");
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonString);
|
||||
const decoded = decompressObject(parsed);
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the data asychronously, fails if verify() fails.
|
||||
* Debounces the operation by up to 50ms
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
writeAsync() {
|
||||
const verifyResult = this.internalVerifyEntry(this.currentData);
|
||||
|
||||
if (!verifyResult.result) {
|
||||
logger.error("Tried to write invalid data to", this.filename, "reason:", verifyResult.reason);
|
||||
return Promise.reject(verifyResult.reason);
|
||||
}
|
||||
|
||||
return this.debouncedWrite();
|
||||
}
|
||||
|
||||
/**
|
||||
* Actually writes the data asychronously
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
doWriteAsync() {
|
||||
return asyncCompressor
|
||||
.compressObjectAsync(this.currentData)
|
||||
.then(compressed => {
|
||||
return this.app.storage.writeFileAsync(this.filename, compressed);
|
||||
})
|
||||
.then(() => {
|
||||
logger.log("📄 Wrote", this.filename);
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error("Failed to write", this.filename, ":", err);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
// Reads the data asynchronously, fails if verify() fails
|
||||
readAsync() {
|
||||
// Start read request
|
||||
return (
|
||||
this.app.storage
|
||||
.readFileAsync(this.filename)
|
||||
|
||||
// Check for errors during read
|
||||
.catch(err => {
|
||||
if (err === FILE_NOT_FOUND) {
|
||||
logger.log("File not found, using default data");
|
||||
|
||||
// File not found or unreadable, assume default file
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return Promise.reject("file-error: " + err);
|
||||
})
|
||||
|
||||
// Decrypt data (if its encrypted)
|
||||
// @ts-ignore
|
||||
.then(rawData => {
|
||||
if (rawData == null) {
|
||||
// So, the file has not been found, use default data
|
||||
return JSON.stringify(compressObject(this.getDefaultData()));
|
||||
}
|
||||
|
||||
if (rawData.startsWith(compressionPrefix)) {
|
||||
const decompressed = decompressX64(rawData.substr(compressionPrefix.length));
|
||||
if (!decompressed) {
|
||||
// LZ string decompression failure
|
||||
return Promise.reject("bad-content / decompression-failed");
|
||||
}
|
||||
if (decompressed.length < 40) {
|
||||
// String too short
|
||||
return Promise.reject("bad-content / payload-too-small");
|
||||
}
|
||||
|
||||
// Compare stored checksum with actual checksum
|
||||
const checksum = decompressed.substring(0, 40);
|
||||
const jsonString = decompressed.substr(40);
|
||||
|
||||
const desiredChecksum = checksum.startsWith(CRC_PREFIX)
|
||||
? computeCrc(jsonString + salt)
|
||||
: sha1(jsonString + salt);
|
||||
|
||||
if (desiredChecksum !== checksum) {
|
||||
// Checksum mismatch
|
||||
return Promise.reject(
|
||||
"bad-content / checksum-mismatch: " + desiredChecksum + " vs " + checksum
|
||||
);
|
||||
}
|
||||
return jsonString;
|
||||
} else {
|
||||
if (!G_IS_DEV) {
|
||||
return Promise.reject("bad-content / missing-compression");
|
||||
}
|
||||
}
|
||||
return rawData;
|
||||
})
|
||||
|
||||
// Parse JSON, this could throw but that's fine
|
||||
.then(res => {
|
||||
try {
|
||||
return JSON.parse(res);
|
||||
} catch (ex) {
|
||||
logger.error(
|
||||
"Failed to parse file content of",
|
||||
this.filename,
|
||||
":",
|
||||
ex,
|
||||
"(content was:",
|
||||
res,
|
||||
")"
|
||||
);
|
||||
throw new Error("invalid-serialized-data");
|
||||
}
|
||||
})
|
||||
|
||||
// Decompress
|
||||
.then(compressed => decompressObject(compressed))
|
||||
|
||||
// Verify basic structure
|
||||
.then(contents => {
|
||||
const result = this.internalVerifyBasicStructure(contents);
|
||||
if (!result.isGood()) {
|
||||
return Promise.reject("verify-failed: " + result.reason);
|
||||
}
|
||||
return contents;
|
||||
})
|
||||
|
||||
// Check version and migrate if required
|
||||
.then(contents => {
|
||||
if (contents.version > this.getCurrentVersion()) {
|
||||
return Promise.reject("stored-data-is-newer");
|
||||
}
|
||||
|
||||
if (contents.version < this.getCurrentVersion()) {
|
||||
logger.log(
|
||||
"Trying to migrate data object from version",
|
||||
contents.version,
|
||||
"to",
|
||||
this.getCurrentVersion()
|
||||
);
|
||||
const migrationResult = this.migrate(contents); // modify in place
|
||||
if (migrationResult.isBad()) {
|
||||
return Promise.reject("migration-failed: " + migrationResult.reason);
|
||||
}
|
||||
}
|
||||
return contents;
|
||||
})
|
||||
|
||||
// Verify
|
||||
.then(contents => {
|
||||
const verifyResult = this.internalVerifyEntry(contents);
|
||||
if (!verifyResult.result) {
|
||||
logger.error(
|
||||
"Read invalid data from",
|
||||
this.filename,
|
||||
"reason:",
|
||||
verifyResult.reason,
|
||||
"contents:",
|
||||
contents
|
||||
);
|
||||
return Promise.reject("invalid-data: " + verifyResult.reason);
|
||||
}
|
||||
return contents;
|
||||
})
|
||||
|
||||
// Store
|
||||
.then(contents => {
|
||||
this.currentData = contents;
|
||||
logger.log("📄 Read data with version", this.currentData.version, "from", this.filename);
|
||||
return contents;
|
||||
})
|
||||
|
||||
// Catchall
|
||||
.catch(err => {
|
||||
return Promise.reject("Failed to read " + this.filename + ": " + err);
|
||||
})
|
||||
// TODO: EXTREMELY HACKY! To verify we need to do this a step later
|
||||
if (G_IS_DEV && IS_DEBUG) {
|
||||
setTimeout(() => {
|
||||
assert(
|
||||
this.verify(this.getDefaultData()).result,
|
||||
"Verify() failed for default data: " + this.verify(this.getDefaultData()).reason
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the file
|
||||
* @returns {Promise<void>}
|
||||
* Store a debounced handler to prevent double writes
|
||||
*/
|
||||
deleteAsync() {
|
||||
return this.app.storage.deleteFileAsync(this.filename);
|
||||
this.debouncedWrite = debounce(this.doWriteAsync.bind(this), 50);
|
||||
}
|
||||
|
||||
// -- Methods to override
|
||||
|
||||
/** @returns {ExplainedResult} */
|
||||
verify(data) {
|
||||
abstract;
|
||||
return ExplainedResult.bad();
|
||||
}
|
||||
|
||||
// Should return the default data
|
||||
getDefaultData() {
|
||||
abstract;
|
||||
return {};
|
||||
}
|
||||
|
||||
// Should return the current version as an integer
|
||||
getCurrentVersion() {
|
||||
abstract;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Should migrate the data (Modify in place)
|
||||
/** @returns {ExplainedResult} */
|
||||
migrate(data) {
|
||||
abstract;
|
||||
return ExplainedResult.bad();
|
||||
}
|
||||
|
||||
// -- / Methods
|
||||
|
||||
// Resets whole data, returns promise
|
||||
resetEverythingAsync() {
|
||||
logger.warn("Reset data to default");
|
||||
this.currentData = this.getDefaultData();
|
||||
return this.writeAsync();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} obj
|
||||
*/
|
||||
static serializeObject(obj) {
|
||||
const jsonString = JSON.stringify(compressObject(obj));
|
||||
const checksum = computeCrc(jsonString + salt);
|
||||
return compressionPrefix + compressX64(checksum + jsonString);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} text
|
||||
*/
|
||||
static deserializeObject(text) {
|
||||
const decompressed = decompressX64(text.substr(compressionPrefix.length));
|
||||
if (!decompressed) {
|
||||
// LZ string decompression failure
|
||||
throw new Error("bad-content / decompression-failed");
|
||||
}
|
||||
if (decompressed.length < 40) {
|
||||
// String too short
|
||||
throw new Error("bad-content / payload-too-small");
|
||||
}
|
||||
|
||||
// Internal
|
||||
// Compare stored checksum with actual checksum
|
||||
const checksum = decompressed.substring(0, 40);
|
||||
const jsonString = decompressed.substr(40);
|
||||
|
||||
/** @returns {ExplainedResult} */
|
||||
internalVerifyBasicStructure(data) {
|
||||
if (!data) {
|
||||
return ExplainedResult.bad("Data is empty");
|
||||
}
|
||||
if (!Number.isInteger(data.version) || data.version < 0) {
|
||||
return ExplainedResult.bad(
|
||||
`Data has invalid version: ${data.version} (expected ${this.getCurrentVersion()})`
|
||||
const desiredChecksum = checksum.startsWith(CRC_PREFIX)
|
||||
? computeCrc(jsonString + salt)
|
||||
: sha1(jsonString + salt);
|
||||
|
||||
if (desiredChecksum !== checksum) {
|
||||
// Checksum mismatch
|
||||
throw new Error("bad-content / checksum-mismatch");
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonString);
|
||||
const decoded = decompressObject(parsed);
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the data asychronously, fails if verify() fails.
|
||||
* Debounces the operation by up to 50ms
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
writeAsync() {
|
||||
const verifyResult = this.internalVerifyEntry(this.currentData);
|
||||
|
||||
if (!verifyResult.result) {
|
||||
logger.error("Tried to write invalid data to", this.filename, "reason:", verifyResult.reason);
|
||||
return Promise.reject(verifyResult.reason);
|
||||
}
|
||||
|
||||
return this.debouncedWrite();
|
||||
}
|
||||
|
||||
/**
|
||||
* Actually writes the data asychronously
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
doWriteAsync() {
|
||||
return asyncCompressor
|
||||
.compressObjectAsync(this.currentData)
|
||||
.then(compressed => {
|
||||
return this.app.storage.writeFileAsync(this.filename, compressed);
|
||||
})
|
||||
.then(() => {
|
||||
logger.log("📄 Wrote", this.filename);
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error("Failed to write", this.filename, ":", err);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
// Reads the data asynchronously, fails if verify() fails
|
||||
readAsync() {
|
||||
// Start read request
|
||||
return (
|
||||
this.app.storage
|
||||
.readFileAsync(this.filename)
|
||||
// Decrypt data (if its encrypted)
|
||||
// @ts-ignore
|
||||
.then(rawData => {
|
||||
if (rawData == null) {
|
||||
// So, the file has not been found, use default data
|
||||
return JSON.stringify(compressObject(this.getDefaultData()));
|
||||
}
|
||||
|
||||
if (rawData.startsWith(compressionPrefix)) {
|
||||
const decompressed = decompressX64(rawData.substr(compressionPrefix.length));
|
||||
if (!decompressed) {
|
||||
// LZ string decompression failure
|
||||
return Promise.reject("bad-content / decompression-failed");
|
||||
}
|
||||
if (decompressed.length < 40) {
|
||||
// String too short
|
||||
return Promise.reject("bad-content / payload-too-small");
|
||||
}
|
||||
|
||||
// Compare stored checksum with actual checksum
|
||||
const checksum = decompressed.substring(0, 40);
|
||||
const jsonString = decompressed.substr(40);
|
||||
|
||||
const desiredChecksum = checksum.startsWith(CRC_PREFIX)
|
||||
? computeCrc(jsonString + salt)
|
||||
: sha1(jsonString + salt);
|
||||
|
||||
if (desiredChecksum !== checksum) {
|
||||
// Checksum mismatch
|
||||
return Promise.reject(
|
||||
"bad-content / checksum-mismatch: " + desiredChecksum + " vs " + checksum
|
||||
);
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch (ex) {
|
||||
logger.error(
|
||||
"Failed to parse file content of",
|
||||
this.filename,
|
||||
":",
|
||||
ex,
|
||||
"(content was:",
|
||||
jsonString,
|
||||
")"
|
||||
);
|
||||
throw new Error("invalid-serialized-data");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (!G_IS_DEV) {
|
||||
return Promise.reject("bad-content / missing-compression");
|
||||
}
|
||||
return rawData;
|
||||
})
|
||||
// Check for errors during read
|
||||
.catch(err => {
|
||||
if (err === FILE_NOT_FOUND) {
|
||||
logger.log("File not found, using default data");
|
||||
|
||||
// File not found or unreadable, assume default file
|
||||
return Promise.resolve(this.getDefaultData());
|
||||
}
|
||||
|
||||
return Promise.reject("file-error: " + err);
|
||||
})
|
||||
|
||||
|
||||
// Decompress
|
||||
.then(decompressObject)
|
||||
|
||||
// Verify basic structure
|
||||
.then(contents => {
|
||||
const result = this.internalVerifyBasicStructure(contents);
|
||||
if (!result.isGood()) {
|
||||
return Promise.reject("verify-failed: " + result.reason);
|
||||
}
|
||||
return contents;
|
||||
})
|
||||
|
||||
// Check version and migrate if required
|
||||
.then(contents => {
|
||||
if (contents.version > this.getCurrentVersion()) {
|
||||
return Promise.reject("stored-data-is-newer");
|
||||
}
|
||||
|
||||
if (contents.version < this.getCurrentVersion()) {
|
||||
logger.log(
|
||||
"Trying to migrate data object from version",
|
||||
contents.version,
|
||||
"to",
|
||||
this.getCurrentVersion()
|
||||
);
|
||||
}
|
||||
const migrationResult = this.migrate(contents); // modify in place
|
||||
if (migrationResult.isBad()) {
|
||||
return Promise.reject("migration-failed: " + migrationResult.reason);
|
||||
}
|
||||
}
|
||||
return contents;
|
||||
})
|
||||
|
||||
return ExplainedResult.good();
|
||||
}
|
||||
|
||||
/** @returns {ExplainedResult} */
|
||||
internalVerifyEntry(data) {
|
||||
if (data.version !== this.getCurrentVersion()) {
|
||||
return ExplainedResult.bad(
|
||||
"Version mismatch, got " + data.version + " and expected " + this.getCurrentVersion()
|
||||
// Verify
|
||||
.then(contents => {
|
||||
const verifyResult = this.internalVerifyEntry(contents);
|
||||
if (!verifyResult.result) {
|
||||
logger.error(
|
||||
"Read invalid data from",
|
||||
this.filename,
|
||||
"reason:",
|
||||
verifyResult.reason,
|
||||
"contents:",
|
||||
contents
|
||||
);
|
||||
}
|
||||
return Promise.reject("invalid-data: " + verifyResult.reason);
|
||||
}
|
||||
return contents;
|
||||
})
|
||||
|
||||
const verifyStructureError = this.internalVerifyBasicStructure(data);
|
||||
if (!verifyStructureError.isGood()) {
|
||||
return verifyStructureError;
|
||||
}
|
||||
return this.verify(data);
|
||||
// Store
|
||||
.then(contents => {
|
||||
this.currentData = contents;
|
||||
logger.log("📄 Read data with version", this.currentData.version, "from", this.filename);
|
||||
return contents;
|
||||
})
|
||||
|
||||
// Catchall
|
||||
.catch(err => {
|
||||
return Promise.reject("Failed to read " + this.filename + ": " + err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the file
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
deleteAsync() {
|
||||
return this.app.storage.deleteFileAsync(this.filename);
|
||||
}
|
||||
|
||||
// Internal
|
||||
|
||||
/** @returns {ExplainedResult} */
|
||||
internalVerifyBasicStructure(data) {
|
||||
if (!data) {
|
||||
return ExplainedResult.bad("Data is empty");
|
||||
}
|
||||
if (!Number.isInteger(data.version) || data.version < 0) {
|
||||
return ExplainedResult.bad(
|
||||
`Data has invalid version: ${data.version} (expected ${this.getCurrentVersion()})`
|
||||
);
|
||||
}
|
||||
|
||||
return ExplainedResult.good();
|
||||
}
|
||||
|
||||
/** @returns {ExplainedResult} */
|
||||
internalVerifyEntry(data) {
|
||||
if (data.version !== this.getCurrentVersion()) {
|
||||
return ExplainedResult.bad(
|
||||
"Version mismatch, got " + data.version + " and expected " + this.getCurrentVersion()
|
||||
);
|
||||
}
|
||||
|
||||
const verifyStructureError = this.internalVerifyBasicStructure(data);
|
||||
if (!verifyStructureError.isGood()) {
|
||||
return verifyStructureError;
|
||||
}
|
||||
return this.verify(data);
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user