2020-11-05 18:58:23 +02:00
const BaseModel = require ( '../BaseModel' ) . default ;
const BaseItem = require ( './BaseItem.js' ) ;
const ItemChange = require ( './ItemChange.js' ) ;
2020-11-08 18:46:48 +02:00
const NoteResource = require ( './NoteResource' ) . default ;
2020-11-05 18:58:23 +02:00
const ResourceLocalState = require ( './ResourceLocalState.js' ) ;
const Setting = require ( './Setting' ) . default ;
const pathUtils = require ( '../path-utils' ) ;
const { mime } = require ( '../mime-utils.js' ) ;
const { filename , safeFilename } = require ( '../path-utils' ) ;
const { FsDriverDummy } = require ( '../fs-driver-dummy.js' ) ;
const markdownUtils = require ( '../markdownUtils' ) . default ;
const JoplinError = require ( '../JoplinError' ) ;
const { _ } = require ( '../locale' ) ;
2017-06-24 20:51:43 +02:00
2017-07-02 14:02:07 +02:00
class Resource extends BaseItem {
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 _ ;
}
2020-08-18 22:52:00 +02:00
static isSupportedImageMimeType ( type ) {
const imageMimeTypes = [ 'image/jpg' , 'image/jpeg' , 'image/png' , 'image/gif' , 'image/svg+xml' , 'image/webp' ] ;
return imageMimeTypes . indexOf ( type . toLowerCase ( ) ) >= 0 ;
2017-08-01 23:40:14 +02:00
}
2019-06-15 22:48:37 +02:00
static fetchStatuses ( resourceIds ) {
if ( ! resourceIds . length ) return [ ] ;
2019-09-19 23:51:18 +02:00
return this . db ( ) . selectAll ( ` SELECT resource_id, fetch_status FROM resource_local_states WHERE resource_id IN (" ${ resourceIds . join ( '","' ) } ") ` ) ;
2019-06-15 22:48:37 +02:00
}
2019-12-28 21:23:38 +02:00
static errorFetchStatuses ( ) {
return this . db ( ) . selectAll ( `
SELECT title AS resource _title , resource _id , fetch _error
FROM resource _local _states
LEFT JOIN resources ON resources . id = resource _local _states . resource _id
WHERE fetch _status = ?
` , [Resource.FETCH_STATUS_ERROR]);
}
2019-05-22 16:56:07 +02:00
static needToBeFetched ( resourceDownloadMode = null , limit = null ) {
2020-03-14 01:46:14 +02:00
const sql = [ 'SELECT * FROM resources WHERE encryption_applied = 0 AND id IN (SELECT resource_id FROM resource_local_states WHERE fetch_status = ?)' ] ;
2019-05-22 16:56:07 +02:00
if ( resourceDownloadMode !== 'always' ) {
sql . push ( 'AND resources.id IN (SELECT resource_id FROM resources_to_download)' ) ;
}
sql . push ( 'ORDER BY updated_time DESC' ) ;
2019-09-19 23:51:18 +02:00
if ( limit !== null ) sql . push ( ` LIMIT ${ limit } ` ) ;
2019-05-22 16:56:07 +02:00
return this . modelSelectAll ( sql . join ( ' ' ) , [ Resource . FETCH _STATUS _IDLE ] ) ;
2018-11-14 00:25:23 +02:00
}
2019-03-08 19:14:17 +02:00
static async resetStartedFetchStatus ( ) {
return await this . db ( ) . exec ( 'UPDATE resource_local_states SET fetch_status = ? WHERE fetch_status = ?' , [ Resource . FETCH _STATUS _IDLE , Resource . FETCH _STATUS _STARTED ] ) ;
}
2019-12-28 21:23:38 +02:00
static resetErrorStatus ( resourceId ) {
return this . db ( ) . exec ( 'UPDATE resource_local_states SET fetch_status = ?, fetch_error = "" WHERE resource_id = ?' , [ Resource . FETCH _STATUS _IDLE , resourceId ] ) ;
}
2017-07-05 23:52:31 +02:00
static fsDriver ( ) {
if ( ! Resource . fsDriver _ ) Resource . fsDriver _ = new FsDriverDummy ( ) ;
return Resource . fsDriver _ ;
}
2020-05-30 14:25:05 +02:00
// DEPRECATED IN FAVOUR OF friendlySafeFilename()
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 ) : '' ;
2019-09-19 23:51:18 +02:00
extension = extension ? ` . ${ extension } ` : '' ;
2018-09-30 11:15:46 +02:00
return output + extension ;
}
2019-01-20 18:27:33 +02:00
static baseDirectoryPath ( ) {
return Setting . value ( 'resourceDir' ) ;
}
2019-05-11 12:46:13 +02:00
static baseRelativeDirectoryPath ( ) {
return Setting . value ( 'resourceDirName' ) ;
}
2019-05-22 16:56:07 +02:00
static filename ( resource , encryptedBlob = false ) {
let extension = encryptedBlob ? 'crypted' : resource . file _extension ;
if ( ! extension ) extension = resource . mime ? mime . toFileExtension ( resource . mime ) : '' ;
2019-09-19 23:51:18 +02:00
extension = extension ? ` . ${ extension } ` : '' ;
2019-05-22 16:56:07 +02:00
return resource . id + extension ;
}
2020-05-30 14:25:05 +02:00
static friendlySafeFilename ( resource ) {
2020-06-09 21:15:43 +02:00
let ext = resource . file _extension ;
2020-05-30 14:25:05 +02:00
if ( ! ext ) ext = resource . mime ? mime . toFileExtension ( resource . mime ) : '' ;
const safeExt = ext ? pathUtils . safeFileExtension ( ext ) . toLowerCase ( ) : '' ;
let title = resource . title ? resource . title : resource . id ;
if ( safeExt && pathUtils . fileExtension ( title ) . toLowerCase ( ) === safeExt ) title = pathUtils . filename ( title ) ;
return pathUtils . friendlySafeFilename ( title ) + ( safeExt ? ` . ${ safeExt } ` : '' ) ;
}
2019-05-11 12:46:13 +02:00
static relativePath ( resource , encryptedBlob = false ) {
2019-09-19 23:51:18 +02:00
return ` ${ Setting . value ( 'resourceDirName' ) } / ${ this . filename ( resource , encryptedBlob ) } ` ;
2019-05-11 12:46:13 +02:00
}
2017-12-19 21:01:29 +02:00
static fullPath ( resource , encryptedBlob = false ) {
2019-09-19 23:51:18 +02:00
return ` ${ Setting . value ( 'resourceDir' ) } / ${ this . filename ( resource , encryptedBlob ) } ` ;
2017-12-19 21:01:29 +02:00
}
2018-11-13 02:45:08 +02:00
static async isReady ( resource ) {
2020-05-31 01:31:29 +02:00
const r = await this . readyStatus ( resource ) ;
return r === 'ok' ;
}
static async readyStatus ( resource ) {
2018-11-13 02:45:08 +02:00
const ls = await this . localState ( resource ) ;
2020-05-31 01:31:29 +02:00
if ( ! resource ) return 'notFound' ;
if ( ls . fetch _status !== Resource . FETCH _STATUS _DONE ) return 'notDownloaded' ;
if ( resource . encryption _blob _encrypted ) return 'encrypted' ;
return 'ok' ;
2018-10-08 20:11:53 +02:00
}
2020-05-31 17:57:16 +02:00
static async requireIsReady ( resource ) {
const readyStatus = await Resource . readyStatus ( resource ) ;
if ( readyStatus !== 'ok' ) throw new Error ( ` Resource is not ready. Status: ${ readyStatus } ` ) ;
}
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 ;
2019-05-22 16:56:07 +02:00
const localState = await this . localState ( item ) ;
if ( localState . fetch _status !== Resource . FETCH _STATUS _DONE ) {
// Not an error - it means the blob has not been downloaded yet.
// It will be decrypted later on, once downloaded.
return decryptedItem ;
}
2017-12-19 21:01:29 +02:00
const plainTextPath = this . fullPath ( decryptedItem ) ;
const encryptedPath = this . fullPath ( decryptedItem , true ) ;
2019-09-19 23:51:18 +02:00
const noExtPath = ` ${ pathUtils . dirname ( encryptedPath ) } / ${ pathUtils . filename ( encryptedPath ) } ` ;
2018-10-11 18:18:33 +02:00
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 {
2019-05-22 16:56:07 +02:00
await this . encryptionService ( ) . decryptFile ( encryptedPath , plainTextPath ) ;
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.
2019-09-19 23:51:18 +02:00
this . logger ( ) . warn ( ` Found a resource that was most likely already decrypted but was marked as encrypted. Marked it as decrypted: ${ item . id } ` ) ;
2018-04-22 14:33:12 +02:00
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
2019-07-29 15:43:53 +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 {
2019-05-22 16:56:07 +02:00
await this . encryptionService ( ) . encryptFile ( plainTextPath , encryptedPath ) ;
2018-01-28 19:37:03 +02:00
} catch ( error ) {
2019-09-19 23:51:18 +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 = '' ;
2020-03-14 01:46:14 +02:00
const lines = [ ] ;
2020-08-18 22:52:00 +02:00
if ( Resource . isSupportedImageMimeType ( resource . mime ) ) {
2019-07-29 15:43:53 +02:00
lines . push ( '![' ) ;
2020-06-07 13:55:40 +02:00
lines . push ( markdownUtils . escapeTitleText ( tagAlt ) ) ;
2019-09-19 23:51:18 +02:00
lines . push ( ` ](:/ ${ resource . id } ) ` ) ;
2017-08-01 23:40:14 +02:00
} else {
2019-07-29 15:43:53 +02:00
lines . push ( '[' ) ;
2020-06-07 13:55:40 +02:00
lines . push ( markdownUtils . escapeTitleText ( tagAlt ) ) ;
2019-09-19 23:51:18 +02:00
lines . push ( ` ](:/ ${ resource . id } ) ` ) ;
2017-08-01 23:40:14 +02:00
}
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 ) {
2019-09-19 23:51:18 +02:00
return ` :/ ${ resource . id } ` ;
2018-05-23 13:14:38 +02:00
}
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 ) {
2019-09-19 23:51:18 +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
2020-05-31 17:57:16 +02:00
static async localState ( resourceOrId ) {
2018-11-13 02:45:08 +02:00
return ResourceLocalState . byResourceId ( typeof resourceOrId === 'object' ? resourceOrId . id : resourceOrId ) ;
}
static async setLocalState ( resourceOrId , state ) {
const id = typeof resourceOrId === 'object' ? resourceOrId . id : resourceOrId ;
await ResourceLocalState . save ( Object . assign ( { } , state , { resource _id : id } ) ) ;
}
2019-05-12 16:53:42 +02:00
static async needFileSizeSet ( ) {
return this . modelSelectAll ( 'SELECT * FROM resources WHERE `size` < 0 AND encryption_blob_encrypted = 0' ) ;
}
2019-05-12 12:41:07 +02:00
// Only set the `size` field and nothing else, not even the update_time
// This is because it's only necessary to do it once after migration 20
// and each client does it so there's no need to sync the resource.
static async setFileSizeOnly ( resourceId , fileSize ) {
return this . db ( ) . exec ( 'UPDATE resources set `size` = ? WHERE id = ?' , [ fileSize , resourceId ] ) ;
}
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
}
2018-11-13 02:45:08 +02:00
await ResourceLocalState . batchDelete ( ids ) ;
2018-03-15 19:46:54 +02:00
}
2019-05-22 16:56:07 +02:00
static async markForDownload ( resourceId ) {
// Insert the row only if it's not already there
const t = Date . now ( ) ;
await this . db ( ) . exec ( 'INSERT INTO resources_to_download (resource_id, updated_time, created_time) SELECT ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM resources_to_download WHERE resource_id = ?)' , [ resourceId , t , t , resourceId ] ) ;
}
2020-04-08 02:00:01 +02:00
static async downloadedButEncryptedBlobCount ( excludedIds = null ) {
let excludedSql = '' ;
if ( excludedIds && excludedIds . length ) {
excludedSql = ` AND resource_id NOT IN (" ${ excludedIds . join ( '","' ) } ") ` ;
}
2019-12-28 21:23:38 +02:00
const r = await this . db ( ) . selectOne ( `
SELECT count ( * ) as total
FROM resource _local _states
2020-04-08 02:00:01 +02:00
WHERE fetch _status = ?
AND resource _id IN ( SELECT id FROM resources WHERE encryption _blob _encrypted = 1 )
$ { excludedSql }
2019-12-28 21:23:38 +02:00
` , [Resource.FETCH_STATUS_DONE]);
2019-05-22 16:56:07 +02:00
return r ? r . total : 0 ;
}
2019-12-28 21:23:38 +02:00
static async downloadStatusCounts ( status ) {
const r = await this . db ( ) . selectOne ( `
SELECT count ( * ) as total
FROM resource _local _states
WHERE fetch _status = ?
` , [status]);
return r ? r . total : 0 ;
}
static fetchStatusToLabel ( status ) {
if ( status === Resource . FETCH _STATUS _IDLE ) return _ ( 'Not downloaded' ) ;
if ( status === Resource . FETCH _STATUS _STARTED ) return _ ( 'Downloading' ) ;
if ( status === Resource . FETCH _STATUS _DONE ) return _ ( 'Downloaded' ) ;
if ( status === Resource . FETCH _STATUS _ERROR ) return _ ( 'Error' ) ;
throw new Error ( ` Invalid status: ${ status } ` ) ;
}
2020-05-31 17:57:16 +02:00
static async updateResourceBlobContent ( resourceId , newBlobFilePath ) {
const resource = await Resource . load ( resourceId ) ;
await this . requireIsReady ( resource ) ;
const fileStat = await this . fsDriver ( ) . stat ( newBlobFilePath ) ;
await this . fsDriver ( ) . copy ( newBlobFilePath , Resource . fullPath ( resource ) ) ;
2020-05-31 18:43:51 +02:00
return await Resource . save ( {
2020-05-31 17:57:16 +02:00
id : resource . id ,
size : fileStat . size ,
} ) ;
}
static async resourceBlobContent ( resourceId , encoding = 'Buffer' ) {
const resource = await Resource . load ( resourceId ) ;
await this . requireIsReady ( resource ) ;
return await this . fsDriver ( ) . readFile ( Resource . fullPath ( resource ) , encoding ) ;
}
2020-05-31 18:43:51 +02:00
static async duplicateResource ( resourceId ) {
const resource = await Resource . load ( resourceId ) ;
const localState = await Resource . localState ( resource ) ;
let newResource = { ... resource } ;
delete newResource . id ;
newResource = await Resource . save ( newResource ) ;
const newLocalState = { ... localState } ;
newLocalState . resource _id = newResource . id ;
delete newLocalState . id ;
await Resource . setLocalState ( newResource , newLocalState ) ;
const sourcePath = Resource . fullPath ( resource ) ;
if ( await this . fsDriver ( ) . exists ( sourcePath ) ) {
await this . fsDriver ( ) . copy ( sourcePath , Resource . fullPath ( newResource ) ) ;
}
return newResource ;
}
static async createConflictResourceNote ( resource ) {
const Note = this . getClass ( 'Note' ) ;
const conflictResource = await Resource . duplicateResource ( resource . id ) ;
await Note . save ( {
title : _ ( 'Attachment conflict: "%s"' , resource . title ) ,
2020-06-07 14:02:58 +02:00
body : _ ( 'There was a [conflict](%s) on the attachment below.\n\n%s' , 'https://joplinapp.org/conflict/' , Resource . markdownTag ( conflictResource ) ) ,
2020-05-31 18:43:51 +02:00
is _conflict : 1 ,
} , { changeSource : ItemChange . SOURCE _SYNC } ) ;
}
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 ;
2019-07-29 15:43:53 +02:00
module . exports = Resource ;