2018-03-09 22:59:12 +02:00
|
|
|
const BaseModel = require('lib/BaseModel.js');
|
|
|
|
const BaseItem = require('lib/models/BaseItem.js');
|
2018-03-16 19:39:44 +02:00
|
|
|
const NoteResource = require('lib/models/NoteResource.js');
|
2018-03-09 22:59:12 +02:00
|
|
|
const Setting = require('lib/models/Setting.js');
|
|
|
|
const ArrayUtils = require('lib/ArrayUtils.js');
|
|
|
|
const pathUtils = require('lib/path-utils.js');
|
|
|
|
const { mime } = require('lib/mime-utils.js');
|
2018-09-30 11:15:46 +02:00
|
|
|
const { filename, safeFilename } = require('lib/path-utils.js');
|
2018-03-09 22:59:12 +02:00
|
|
|
const { FsDriverDummy } = require('lib/fs-driver-dummy.js');
|
2018-05-23 13:14:38 +02:00
|
|
|
const markdownUtils = require('lib/markdownUtils');
|
2018-03-09 22:59:12 +02:00
|
|
|
const JoplinError = require('lib/JoplinError');
|
2017-06-24 20:51:43 +02:00
|
|
|
|
2017-07-02 14:02:07 +02:00
|
|
|
class Resource extends BaseItem {
|
2018-03-09 22:59:12 +02:00
|
|
|
|
2017-06-24 20:51:43 +02:00
|
|
|
static tableName() {
|
2018-03-09 22:59:12 +02:00
|
|
|
return 'resources';
|
2017-06-24 20:51:43 +02:00
|
|
|
}
|
|
|
|
|
2017-07-03 21:50:45 +02:00
|
|
|
static modelType() {
|
|
|
|
return BaseModel.TYPE_RESOURCE;
|
2017-06-24 20:51:43 +02:00
|
|
|
}
|
|
|
|
|
2017-12-19 21:01:29 +02:00
|
|
|
static encryptionService() {
|
2018-03-09 22:59:12 +02:00
|
|
|
if (!this.encryptionService_) throw new Error('Resource.encryptionService_ is not set!!');
|
2017-12-19 21:01:29 +02:00
|
|
|
return this.encryptionService_;
|
|
|
|
}
|
|
|
|
|
2017-08-01 23:40:14 +02:00
|
|
|
static isSupportedImageMimeType(type) {
|
2018-06-30 20:05:45 +02:00
|
|
|
const imageMimeTypes = ["image/jpg", "image/jpeg", "image/png", "image/gif", "image/svg+xml"];
|
2017-08-01 23:40:14 +02:00
|
|
|
return imageMimeTypes.indexOf(type.toLowerCase()) >= 0;
|
|
|
|
}
|
|
|
|
|
2017-07-05 23:52:31 +02:00
|
|
|
static fsDriver() {
|
|
|
|
if (!Resource.fsDriver_) Resource.fsDriver_ = new FsDriverDummy();
|
|
|
|
return Resource.fsDriver_;
|
|
|
|
}
|
|
|
|
|
2017-12-19 21:01:29 +02:00
|
|
|
static filename(resource, encryptedBlob = false) {
|
2018-03-09 22:59:12 +02:00
|
|
|
let extension = encryptedBlob ? 'crypted' : resource.file_extension;
|
|
|
|
if (!extension) extension = resource.mime ? mime.toFileExtension(resource.mime) : '';
|
|
|
|
extension = extension ? ('.' + extension) : '';
|
2017-11-20 20:25:23 +02:00
|
|
|
return resource.id + extension;
|
|
|
|
}
|
|
|
|
|
2018-10-07 21:18:43 +02:00
|
|
|
static serializeForSyncExcludedKeys() {
|
|
|
|
return ['fetch_status', 'fetch_error'];
|
|
|
|
}
|
|
|
|
|
2018-09-30 11:15:46 +02:00
|
|
|
static friendlyFilename(resource) {
|
|
|
|
let output = safeFilename(resource.title); // Make sure not to allow spaces or any special characters as it's not supported in HTTP headers
|
|
|
|
if (!output) output = resource.id;
|
|
|
|
let extension = resource.file_extension;
|
|
|
|
if (!extension) extension = resource.mime ? mime.toFileExtension(resource.mime) : '';
|
|
|
|
extension = extension ? ('.' + extension) : '';
|
|
|
|
return output + extension;
|
|
|
|
}
|
|
|
|
|
2017-12-19 21:01:29 +02:00
|
|
|
static fullPath(resource, encryptedBlob = false) {
|
2018-03-09 22:59:12 +02:00
|
|
|
return Setting.value('resourceDir') + '/' + this.filename(resource, encryptedBlob);
|
2017-12-19 21:01:29 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// For resources, we need to decrypt the item (metadata) and the resource binary blob.
|
|
|
|
static async decrypt(item) {
|
2017-12-21 21:06:08 +02:00
|
|
|
// The item might already be decrypted but not the blob (for instance if it crashes while
|
|
|
|
// decrypting the blob or was otherwise interrupted).
|
|
|
|
const decryptedItem = item.encryption_cipher_text ? await super.decrypt(item) : Object.assign({}, item);
|
2017-12-19 21:01:29 +02:00
|
|
|
if (!decryptedItem.encryption_blob_encrypted) return decryptedItem;
|
|
|
|
|
|
|
|
const plainTextPath = this.fullPath(decryptedItem);
|
|
|
|
const encryptedPath = this.fullPath(decryptedItem, true);
|
2018-03-09 22:59:12 +02:00
|
|
|
const noExtPath = pathUtils.dirname(encryptedPath) + '/' + pathUtils.filename(encryptedPath);
|
|
|
|
|
2017-12-19 21:01:29 +02:00
|
|
|
// When the resource blob is downloaded by the synchroniser, it's initially a file with no
|
|
|
|
// extension (since it's encrypted, so we don't know its extension). So here rename it
|
|
|
|
// to a file with a ".crypted" extension so that it's better identified, and then decrypt it.
|
|
|
|
// Potentially plainTextPath is also a path with no extension if it's an unknown mime type.
|
|
|
|
if (await this.fsDriver().exists(noExtPath)) {
|
|
|
|
await this.fsDriver().move(noExtPath, encryptedPath);
|
|
|
|
}
|
|
|
|
|
2018-04-22 14:33:12 +02:00
|
|
|
try {
|
2018-06-25 19:14:57 +02:00
|
|
|
// const stat = await this.fsDriver().stat(encryptedPath);
|
|
|
|
await this.encryptionService().decryptFile(encryptedPath, plainTextPath, {
|
|
|
|
// onProgress: (progress) => {
|
|
|
|
// console.info('Decryption: ', progress.doneSize / stat.size);
|
|
|
|
// },
|
|
|
|
});
|
2018-04-22 14:33:12 +02:00
|
|
|
} catch (error) {
|
|
|
|
if (error.code === 'invalidIdentifier') {
|
|
|
|
// As the identifier is invalid it most likely means that this is not encrypted data
|
|
|
|
// at all. It can happen for example when there's a crash between the moment the data
|
|
|
|
// is decrypted and the resource item is updated.
|
|
|
|
this.logger().warn('Found a resource that was most likely already decrypted but was marked as encrypted. Marked it as decrypted: ' + item.id)
|
|
|
|
this.fsDriver().move(encryptedPath, plainTextPath);
|
|
|
|
} else {
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
}
|
2018-01-02 21:17:14 +02:00
|
|
|
|
2017-12-21 21:06:08 +02:00
|
|
|
decryptedItem.encryption_blob_encrypted = 0;
|
2018-01-02 21:17:14 +02:00
|
|
|
return super.save(decryptedItem, { autoTimestamp: false });
|
2017-12-19 21:01:29 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// Prepare the resource by encrypting it if needed.
|
2017-12-04 21:01:56 +02:00
|
|
|
// The call returns the path to the physical file AND a representation of the resource object
|
|
|
|
// as it should be uploaded to the sync target. Note that this may be different from what is stored
|
|
|
|
// in the database. In particular, the flag encryption_blob_encrypted might be 1 on the sync target
|
|
|
|
// if the resource is encrypted, but will be 0 locally because the device has the decrypted resource.
|
2017-12-19 21:01:29 +02:00
|
|
|
static async fullPathForSyncUpload(resource) {
|
|
|
|
const plainTextPath = this.fullPath(resource);
|
|
|
|
|
2018-03-09 22:59:12 +02:00
|
|
|
if (!Setting.value('encryption.enabled')) {
|
2018-01-02 21:17:14 +02:00
|
|
|
// Normally not possible since itemsThatNeedSync should only return decrypted items
|
2018-03-09 22:59:12 +02:00
|
|
|
if (!!resource.encryption_blob_encrypted) throw new Error('Trying to access encrypted resource but encryption is currently disabled');
|
2017-12-19 21:01:29 +02:00
|
|
|
return { path: plainTextPath, resource: resource };
|
|
|
|
}
|
|
|
|
|
|
|
|
const encryptedPath = this.fullPath(resource, true);
|
|
|
|
if (resource.encryption_blob_encrypted) return { path: encryptedPath, resource: resource };
|
2018-01-28 19:37:03 +02:00
|
|
|
|
|
|
|
try {
|
2018-06-25 19:14:57 +02:00
|
|
|
// const stat = await this.fsDriver().stat(plainTextPath);
|
|
|
|
await this.encryptionService().encryptFile(plainTextPath, encryptedPath, {
|
|
|
|
// onProgress: (progress) => {
|
|
|
|
// console.info(progress.doneSize / stat.size);
|
|
|
|
// },
|
|
|
|
});
|
2018-01-28 19:37:03 +02:00
|
|
|
} catch (error) {
|
2018-03-09 22:59:12 +02:00
|
|
|
if (error.code === 'ENOENT') throw new JoplinError('File not found:' + error.toString(), 'fileNotFound');
|
2018-01-28 19:37:03 +02:00
|
|
|
throw error;
|
|
|
|
}
|
2017-12-19 21:01:29 +02:00
|
|
|
|
2017-12-04 21:01:56 +02:00
|
|
|
const resourceCopy = Object.assign({}, resource);
|
|
|
|
resourceCopy.encryption_blob_encrypted = 1;
|
|
|
|
return { path: encryptedPath, resource: resourceCopy };
|
2017-06-24 20:51:43 +02:00
|
|
|
}
|
|
|
|
|
2017-08-01 23:40:14 +02:00
|
|
|
static markdownTag(resource) {
|
|
|
|
let tagAlt = resource.alt ? resource.alt : resource.title;
|
2018-03-09 22:59:12 +02:00
|
|
|
if (!tagAlt) tagAlt = '';
|
2017-08-01 23:40:14 +02:00
|
|
|
let lines = [];
|
|
|
|
if (Resource.isSupportedImageMimeType(resource.mime)) {
|
|
|
|
lines.push("![");
|
2017-08-02 19:47:25 +02:00
|
|
|
lines.push(markdownUtils.escapeLinkText(tagAlt));
|
2017-08-01 23:40:14 +02:00
|
|
|
lines.push("](:/" + resource.id + ")");
|
|
|
|
} else {
|
|
|
|
lines.push("[");
|
2017-08-02 19:47:25 +02:00
|
|
|
lines.push(markdownUtils.escapeLinkText(tagAlt));
|
2017-08-01 23:40:14 +02:00
|
|
|
lines.push("](:/" + resource.id + ")");
|
|
|
|
}
|
2018-03-09 22:59:12 +02:00
|
|
|
return lines.join('');
|
2017-08-01 23:40:14 +02:00
|
|
|
}
|
|
|
|
|
2018-05-23 13:14:38 +02:00
|
|
|
static internalUrl(resource) {
|
|
|
|
return ':/' + resource.id;
|
|
|
|
}
|
|
|
|
|
2017-06-25 01:19:11 +02:00
|
|
|
static pathToId(path) {
|
|
|
|
return filename(path);
|
|
|
|
}
|
|
|
|
|
2017-10-24 21:40:15 +02:00
|
|
|
static async content(resource) {
|
2018-03-09 22:59:12 +02:00
|
|
|
return this.fsDriver().readFile(this.fullPath(resource), 'Buffer');
|
2017-07-02 14:02:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
static setContent(resource, content) {
|
2017-07-05 23:52:31 +02:00
|
|
|
return this.fsDriver().writeBinaryFile(this.fullPath(resource), content);
|
2017-07-02 14:02:07 +02:00
|
|
|
}
|
|
|
|
|
2017-07-26 20:36:16 +02:00
|
|
|
static isResourceUrl(url) {
|
2018-03-09 22:59:12 +02:00
|
|
|
return url && url.length === 34 && url[0] === ':' && url[1] === '/';
|
2017-07-26 20:36:16 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
static urlToId(url) {
|
2018-03-09 22:59:12 +02:00
|
|
|
if (!this.isResourceUrl(url)) throw new Error('Not a valid resource URL: ' + url);
|
2017-07-26 20:36:16 +02:00
|
|
|
return url.substr(2);
|
|
|
|
}
|
2018-03-09 22:59:12 +02:00
|
|
|
|
2018-03-15 19:46:54 +02:00
|
|
|
static async batchDelete(ids, options = null) {
|
|
|
|
// For resources, there's not really batch deleting since there's the file data to delete
|
|
|
|
// too, so each is processed one by one with the item being deleted last (since the db
|
|
|
|
// call is the less likely to fail).
|
|
|
|
for (let i = 0; i < ids.length; i++) {
|
|
|
|
const id = ids[i];
|
|
|
|
const resource = await Resource.load(id);
|
2018-04-22 14:33:12 +02:00
|
|
|
if (!resource) continue;
|
|
|
|
|
2018-03-15 19:46:54 +02:00
|
|
|
const path = Resource.fullPath(resource);
|
|
|
|
await this.fsDriver().remove(path);
|
2018-03-16 19:39:44 +02:00
|
|
|
await super.batchDelete([id], options);
|
|
|
|
await NoteResource.deleteByResource(id); // Clean up note/resource relationships
|
2018-03-15 19:46:54 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-06-24 20:51:43 +02:00
|
|
|
}
|
|
|
|
|
2017-10-20 00:02:13 +02:00
|
|
|
Resource.IMAGE_MAX_DIMENSION = 1920;
|
|
|
|
|
2018-10-07 21:11:33 +02:00
|
|
|
Resource.FETCH_STATUS_IDLE = 0;
|
|
|
|
Resource.FETCH_STATUS_STARTED = 1;
|
|
|
|
Resource.FETCH_STATUS_DONE = 2;
|
|
|
|
Resource.FETCH_STATUS_ERROR = 3;
|
|
|
|
|
2018-03-09 22:59:12 +02:00
|
|
|
module.exports = Resource;
|