1 /*
  2  * ContentDigestManager.js
  3  *
  4  * Sweet Home 3D, Copyright (c) 2024 Space Mushrooms <info@sweethome3d.com>
  5  *
  6  * This program is free software; you can redistribute it and/or modify
  7  * it under the terms of the GNU General Public License as published by
  8  * the Free Software Foundation; either version 2 of the License, or
  9  * (at your option) any later version.
 10  *
 11  * This program is distributed in the hope that it will be useful,
 12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
 13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 14  * GNU General Public License for more details.
 15  *
 16  * You should have received a copy of the GNU General Public License
 17  * along with this program; if not, write to the Free Software
 18  * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 19  */
 20 
 21 // Requires jszip.min.js
 22 //          URLContent.js
 23 
 24 /**
 25  * Manager able to store and compute content digest to compare content data faster.
 26  * @constructor
 27  * @private
 28  * @author Emmanuel Puybaret
 29  */
 30 function ContentDigestManager() {
 31   this.contentDigestsCache = {};
 32   this.permanentContents = {};
 33 }
 34 
 35 /**
 36  * Singleton
 37  * @private
 38  */ 
 39 ModelManager.instance = null;
 40 
 41 /**
 42  * Returns an instance of this singleton.
 43  * @return {ModelManager} 
 44  */
 45 ContentDigestManager.getInstance = function() {
 46   if (ContentDigestManager.instance == null) {
 47     ContentDigestManager.instance = new ContentDigestManager();
 48   }
 49   return ContentDigestManager.instance;
 50 }
 51 
 52 /**
 53  * Returns <code>true</code> if the contents in parameter contains the same data,
 54  * comparing their digest. If the digest of the contents was not
 55  * {@linkplain #setContentDigest(Content, byte[]) set} directly or in cache,
 56  * it will return <code>false</code>.
 57  * @param {URLContent} content1
 58  * @param {URLContent} content2
 59  * @return {boolean}
 60  */
 61 ContentDigestManager.prototype.equals = function(content1, content2) {
 62     var content1Digest = this.contentDigestsCache [content1.getURL()];
 63     if (content1Digest == null) {
 64       return false;
 65     } else {
 66       return content1Digest === this.contentDigestsCache [content2.getURL()];
 67     }
 68   }
 69 
 70 /**
 71  * Sets the SHA-1 digest of the given <code>content</code>. 
 72  * It should be used for permanent contents like the ones coming for preferences. 
 73  * @param {URLContent} content
 74  * @param {string} digest
 75  */
 76 ContentDigestManager.prototype.setContentDigest = function(content, digest) {
 77   this.contentDigestsCache [content.getURL()] = digest;
 78   this.permanentContents [digest] = content;
 79 }
 80 
 81 /**
 82  * Returns the permanent content which matches the given <code>content</code> 
 83  * or <code>null</code> if doesn't exist.
 84  * @param {URLContent} content  a content with its digest already computed 
 85  * @return {URLContent}
 86  */
 87 ContentDigestManager.prototype.getPermanentContentDigest = function(content) {
 88   var contentDigest = this.contentDigestsCache [content.getURL()];
 89   if (contentDigest !== undefined) {
 90     var permanentContent = this.permanentContents [contentDigest];
 91     if (permanentContent !== undefined) {
 92       return permanentContent;
 93     }
 94   } 
 95   
 96   return null;
 97 }
 98 
 99 /**
100  * Returns asynchronously the SHA-1 digest of the given content.
101  * @param {URLContent} content
102  * @param {digestReady: function, digestError: function} digestObserver
103  * @ignore
104  */
105 ContentDigestManager.prototype.getContentDigest = function(content, digestObserver) {
106   var contentDigest = this.contentDigestsCache [content.getURL()];
107   if (contentDigest === undefined) {
108     if (content.isJAREntry()) {
109       this.getZipContentDigest(content, digestObserver);
110     } else if (content instanceof LocalURLContent) {
111       var manager = this;
112       content.getBlob({
113           blobReady: function(blob) {
114             if (blob.type === "application/zip") {
115               manager.getZipContentDigest(content, digestObserver);
116             } else {
117               manager.getURLContentDigest(content, digestObserver);
118             }
119           },
120           blobError: function(status, error) {
121             digestObserver.digestError(status, error);
122           }    
123         });
124     } else {
125       this.getURLContentDigest(content, digestObserver);
126     }
127   } else {
128     digestObserver.digestReady(content, contentDigest);
129   }
130 }
131 
132 /**
133  * Returns asynchronously the SHA-1 digest of the given content.
134  * @param {URLContent} content content containing zipped data
135  * @param {digestReady: function, digestError: function} digestObserver
136  * @private
137  */
138 ContentDigestManager.prototype.getZipContentDigest = function(content, digestObserver) {
139   var manager = this;
140   ZIPTools.getZIP(content.isJAREntry() ? content.getJAREntryURL() : content.getURL(), false, {
141       zipReady : function(zip) {
142         try {
143           var entryName = content.isJAREntry() ? content.getJAREntryName() : "";
144           var slashIndex = content instanceof HomeURLContent
145               ? entryName.indexOf('/')
146               : -1;
147           var entryDirectory = entryName.substring(0, slashIndex + 1);
148           var contentData = new Uint8Array(0);
149           var entries = slashIndex > 0 || !(content instanceof HomeURLContent) 
150               ? zip.file(new RegExp("^" + entryDirectory + ".*")).sort(function(entry1, entry2) { return entry1.name === entry2.name ? 0 : (entry1.name < entry2.name ? 1 : -1); }) // Reverse order
151               : [zip.file(entryName)];
152           
153           for (var i = entries.length - 1; i >= 0 ; i--) {
154             var zipEntry = entries [i];
155             if (zipEntry.name !== entryDirectory
156                 && manager.isSignificant(zipEntry.name)) {
157               // Append entry data to contentData
158               var entryData = zipEntry.asUint8Array();
159               var data = new Uint8Array(contentData.length + entryData.length);
160               data.set(contentData);
161               data.set(entryData, contentData.length);
162               contentData = data;
163             }
164           }   
165           
166           manager.computeContentDigest(contentData, function(digest) {
167               manager.contentDigestsCache [content.getURL()] = digest;
168               digestObserver.digestReady(content, digest);
169             });              
170         } catch (ex) {
171           this.zipError(ex);
172         }
173       },
174       zipError : function(error) {
175         digestObserver.digestError(error, error.message);
176       }
177     });
178 }
179 
180 /**
181  * Returns asynchronously the SHA-1 digest of the given content.
182  * @param {URLContent} content content containing no zipped data 
183  * @param {digestReady: function, digestError: function} digestObserver
184  * @private
185  */
186 ContentDigestManager.prototype.getURLContentDigest = function(content, digestObserver) {
187   var manager = this;
188   content.getStreamURL({
189       urlReady: function(url) {
190         var request = new XMLHttpRequest();
191         request.open("GET", url, true);
192         request.responseType = "arraybuffer";
193         request.addEventListener("load", function() {
194             manager.computeContentDigest(request.response, function(digest) {
195                 manager.contentDigestsCache [content.getURL()] = digest;
196                 digestObserver.digestReady(content, digest);
197               });
198           });
199         request.send();
200       },
201       urlError: function(status, error) {
202         digestObserver.digestError(status, error);
203       }    
204     });
205 }
206 
207 /**
208  * Computes the digest of the given data and calls <code>observer</code> when digest is ready. 
209  * @param {Uint8Array} contentData
210  * @param {function} observer callback which will receive in parameter the SHA-1 digest of contentData in Base64
211  * @private
212  */
213 ContentDigestManager.prototype.computeContentDigest = function(contentData, observer) {
214   var crypto = window.msCrypto !== undefined ? window.msCrypto : window.crypto;
215   var digest;
216   try {
217     digest = crypto.subtle.digest("SHA-1", contentData);
218   } catch (ex) {
219     // Use SHA-256 instead even if secured hash is not needed here
220     digest = crypto.subtle.digest("SHA-256", contentData);
221   }
222   if (digest.then !== undefined) { 
223     digest.then(function(hash) {
224         observer(btoa(String.fromCharCode.apply(null, new Uint8Array(hash))));
225       });
226   } else { 
227     // IE 11 digest.result is available without promise support but only after a call to setTimeout 
228     setTimeout(function() {
229         observer(btoa(String.fromCharCode.apply(null, new Uint8Array(digest.result))));      
230       });
231   }
232 }
233 
234 /**
235  * Returns <code>true</code> if entry name is significant to distinguish
236  * the data of a content from an other one. 
237  * @param {string} entryName
238  * @return {boolean} 
239  * @private
240  */
241 ContentDigestManager.prototype.isSignificant = function(entryName) {
242   // Ignore LICENSE.TXT files
243   var entryNameUpperCase = entryName.toUpperCase();
244   return entryNameUpperCase !== "LICENSE.TXT"
245         && entryNameUpperCase.indexOf("/LICENSE.TXT", entryNameUpperCase.length - "/LICENSE.TXT".length) === -1;
246 }
247