upload.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. /*
  2. upload.js
  3. File upload: chunked low-memory WebSocket mode and standard XHR POST mode.
  4. Part of the ArozOS File Manager. Loaded as a plain script from
  5. file_explorer.html - see the <script> block at the end of that file.
  6. */
  7. function upload(){
  8. var input = document.createElement('input');
  9. input.type = 'file';
  10. input.multiple = true;
  11. input.onchange = e => {
  12. var files = e.target.files;
  13. msgbox("upload",applocale.getString("message/upload/started", "Upload Started"));
  14. for (var i = 0; i < files.length; i++){
  15. uploadFile(files[i]);
  16. }
  17. }
  18. input.click();
  19. }
  20. function initUploadMode(){
  21. //Get the avaible space on tmp disk and decide the cutoff file size that need to directly write to disk
  22. $.ajax({
  23. url: "../../system/disk/space/tmp",
  24. method: "POST",
  25. data: {},
  26. success: function(data){
  27. if (data.error !== undefined){
  28. console.log("[File Explorer] Unable to auto-detect huge file cutoff size: " + data.error);
  29. }else{
  30. if (!isNaN(data.Available) && data.Available > 0){
  31. largeFileCutoffSize = data.Available/16 - 4096;
  32. console.log("[File Explorer] Setting huge file cutoff size at: " + ao_module_utils.formatBytes(data.Available/16));
  33. }else if (isNaN(data.Available)){
  34. console.log("[File Explorer] Unable to read available tmp disk size. Using default huge file cutoff size.");
  35. }
  36. }
  37. },
  38. error: function(){
  39. //Hardware mode disabled. Use default value.
  40. }
  41. });
  42. }
  43. function uploadFile(file, uuid=undefined, targetDir=undefined) {
  44. if (file.size > postUploadModeCutoff && lowMemoryMode){
  45. /*
  46. Low Memory Upload Mode
  47. */
  48. var filename = encodeURIComponent(file.name);
  49. //Generate a new file item
  50. let taskUUID = uuid; //For queueing objects
  51. if (taskUUID == undefined){
  52. //If this is a new file to be uploaded
  53. taskUUID = appendUploadFileItem(file.name, file.size);
  54. setTimeout(function(){
  55. updateUploadFileCount();
  56. }, 100);
  57. }
  58. //Push to upload pending list if the max concurrent upload is reached
  59. if (uploadingFileCount >= maxConcurrentUpload){
  60. let uploadDir = currentPath;
  61. if (targetDir !== undefined){
  62. uploadDir = targetDir;
  63. }else if (isFirefox && uploadDir == currentPath && file.webkitRelativePath != ""){
  64. uploadDir = targetDir;
  65. }
  66. uploadPendingList.push({
  67. File: file,
  68. UUID: taskUUID,
  69. TargetDir: JSON.parse(JSON.stringify(uploadDir)),
  70. });
  71. return
  72. }
  73. //Open the websocket
  74. let path = currentPath;
  75. let protocol = "wss://";
  76. if (location.protocol !== 'https:') {
  77. protocol = "ws://";
  78. }
  79. var port = window.location.port;
  80. if (window.location.port == ""){
  81. if (location.protocol !== 'https:') {
  82. port = "80";
  83. }else{
  84. port = "443";
  85. }
  86. }
  87. let uploadDir = currentPath;
  88. if (targetDir !== undefined){
  89. //Not uploading to current directory. Change upload path to target
  90. uploadDir = targetDir;
  91. }
  92. //Fixing Firefox path issues on or above FF48.0
  93. if (isFirefox && file.webkitRelativePath != ""){
  94. //Use the webkitRelativePath instead of the name, this is a folder upload
  95. let pathinfo = file.webkitRelativePath.split("/");
  96. pathinfo.pop();
  97. let subpath = pathinfo.join("/");
  98. uploadDir = uploadDir + subpath;
  99. }
  100. let hugeFileMode = "";
  101. if (file.size > largeFileCutoffSize){
  102. //Filesize over cutoff line. Use huge file mode
  103. hugeFileMode = "&hugefile=true";
  104. }
  105. let socket = new WebSocket(protocol + window.location.hostname + ":" + port + "/system/file_system/lowmemUpload?filename=" + encodeURIComponent(filename) + "&path=" + encodeURIComponent(uploadDir) + hugeFileMode);
  106. let currentSendingIndex = 0;
  107. let chunks = Math.ceil(file.size/uploadFileChunkSize);
  108. // Per-chunk retry state
  109. let chunkRetryCount = 0;
  110. let chunkTimeoutTimer = null;
  111. // Running CRC32 state across all chunks for full-file checksum
  112. // Initialized to 0xFFFFFFFF (pre-conditioning), finalized with XOR at the end
  113. let runningCRC32State = 0xFFFFFFFF;
  114. // Track which chunk indices have already been factored into the running state
  115. // so that retries do not corrupt the full-file CRC32
  116. let chunkCRC32Committed = {};
  117. // Store file reference in retry map so the user can retry on failure
  118. uploadRetryMap.set(taskUUID, {file: file, targetDir: JSON.parse(JSON.stringify(uploadDir))});
  119. /*
  120. Pause / resume state.
  121. Chunks are pulled by the server: every "next" acknowledgement asks
  122. for one more. Pausing therefore just means not answering that ask
  123. and remembering that one is outstanding, so nothing has to be
  124. unwound and the transfer resumes exactly where it stopped.
  125. */
  126. let paused = false;
  127. let resumeWaiting = false;
  128. let aborted = false;
  129. // Mark an upload task as failed and reveal the retry button
  130. function markUploadFailed(tUUID) {
  131. clearTimeout(chunkTimeoutTimer);
  132. if (aborted) {
  133. return;
  134. }
  135. unregisterUploadTransfer(tUUID);
  136. setUploadTaskState(tUUID, "failed");
  137. }
  138. // Send a specific chunk by index.
  139. // Reads the slice as ArrayBuffer, computes CRC32, sends metadata then binary.
  140. // Sets a CHUNK_TIMEOUT_MS timer; on expiry retries up to MAX_CHUNK_RETRIES times.
  141. async function sendChunk(id) {
  142. var offsetStart = id * uploadFileChunkSize;
  143. var offsetEnd = id * uploadFileChunkSize + uploadFileChunkSize;
  144. var thisblob = file.slice(offsetStart, offsetEnd);
  145. let arrayBuffer;
  146. try {
  147. arrayBuffer = await thisblob.arrayBuffer();
  148. } catch(e) {
  149. console.error("[Upload] Failed to read chunk " + id + ": " + e);
  150. markUploadFailed(taskUUID);
  151. return;
  152. }
  153. let bytes = new Uint8Array(arrayBuffer);
  154. // Update the running full-file CRC32 only on the first attempt for each
  155. // chunk index so that retries do not double-count the bytes
  156. if (!chunkCRC32Committed[id]) {
  157. runningCRC32State = crc32UpdateState(runningCRC32State, bytes);
  158. chunkCRC32Committed[id] = true;
  159. }
  160. // Compute a standalone CRC32 for this chunk for transmission verification
  161. let chunkCRCHex = crc32Hex(bytes);
  162. // Protocol: text metadata frame, then binary data frame
  163. socket.send(JSON.stringify({index: id, checksum: chunkCRCHex}));
  164. socket.send(arrayBuffer);
  165. // Report the bytes actually handed to the socket
  166. setUploadTaskProgress(taskUUID, Math.min(file.size, offsetEnd), file.size);
  167. // (Re)start the per-chunk acknowledgement timeout
  168. clearTimeout(chunkTimeoutTimer);
  169. chunkTimeoutTimer = setTimeout(function() {
  170. if (chunkRetryCount < MAX_CHUNK_RETRIES) {
  171. chunkRetryCount++;
  172. console.warn("[Upload] Chunk " + id + " ACK timeout – retry " + chunkRetryCount + "/" + MAX_CHUNK_RETRIES);
  173. sendChunk(id);
  174. } else {
  175. console.error("[Upload] Chunk " + id + " failed after " + MAX_CHUNK_RETRIES + " retries");
  176. markUploadFailed(taskUUID);
  177. socket.close();
  178. }
  179. }, CHUNK_TIMEOUT_MS);
  180. }
  181. // Send whatever the server asked for next, or park the request if the
  182. // user has paused. Every send path goes through here so pause only has
  183. // to be handled once.
  184. async function sendNext() {
  185. if (paused || aborted) {
  186. resumeWaiting = true;
  187. return;
  188. }
  189. if (currentSendingIndex >= chunks){
  190. // All chunks sent and acknowledged - send done + full-file checksum
  191. let finalCRC32Hex = ((runningCRC32State ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, '0');
  192. socket.send(JSON.stringify({done: true, totalChunks: chunks, fileChecksum: finalCRC32Hex}));
  193. setUploadTaskState(taskUUID, "processing");
  194. }else{
  195. await sendChunk(currentSendingIndex);
  196. currentSendingIndex++;
  197. }
  198. }
  199. registerUploadTransfer(taskUUID, {
  200. pausable: true,
  201. pause: function(){
  202. paused = true;
  203. clearTimeout(chunkTimeoutTimer);
  204. },
  205. resume: function(){
  206. paused = false;
  207. if (resumeWaiting){
  208. resumeWaiting = false;
  209. sendNext();
  210. }
  211. },
  212. abort: function(){
  213. aborted = true;
  214. clearTimeout(chunkTimeoutTimer);
  215. try { socket.close(); } catch(e) {}
  216. }
  217. });
  218. //Update all UI elements
  219. updateUploadFileCount();
  220. uploadingFileCount++;
  221. //Start sending
  222. socket.onopen = async function(e) {
  223. currentSendingIndex = 0;
  224. await sendNext();
  225. };
  226. socket.onmessage = async function(event) {
  227. var incomingValue = event.data;
  228. if (incomingValue == "next"){
  229. // Server acknowledged the last chunk; clear timeout and reset retry counter
  230. clearTimeout(chunkTimeoutTimer);
  231. chunkRetryCount = 0;
  232. await sendNext();
  233. }else if (incomingValue == "OK"){
  234. //Merge completed successfully
  235. uploadRetryMap.delete(taskUUID);
  236. unregisterUploadTransfer(taskUUID);
  237. setUploadTaskState(taskUUID, "done");
  238. }else{
  239. //Try to parse it as JSON
  240. try{
  241. var resp = JSON.parse(incomingValue);
  242. if (resp.error !== undefined){
  243. //Server reported an error
  244. msgbox("red remove", resp.error);
  245. markUploadFailed(taskUUID);
  246. }else if (resp.retryChunk !== undefined){
  247. // Server detected a CRC32 mismatch and requests a chunk re-send
  248. clearTimeout(chunkTimeoutTimer);
  249. if (chunkRetryCount < MAX_CHUNK_RETRIES){
  250. chunkRetryCount++;
  251. console.warn("[Upload] Server requested retry for chunk " + resp.retryChunk + " (CRC32 mismatch) – retry " + chunkRetryCount + "/" + MAX_CHUNK_RETRIES);
  252. await sendChunk(resp.retryChunk);
  253. } else {
  254. console.error("[Upload] Chunk " + resp.retryChunk + " CRC32 mismatch after max retries");
  255. markUploadFailed(taskUUID);
  256. socket.close();
  257. }
  258. }else if (resp.move !== undefined){
  259. //File move from tmp to archive – show progress
  260. setUploadTaskState(taskUUID, "processing");
  261. setUploadTaskStatusText(taskUUID, resp.move);
  262. }
  263. }catch(ex){
  264. //Something else
  265. console.log(ex);
  266. }
  267. }
  268. };
  269. socket.onclose = function(event) {
  270. clearTimeout(chunkTimeoutTimer);
  271. unregisterUploadTransfer(taskUUID);
  272. uploadingFileCount--;
  273. updateUploadFileCount();
  274. //After the previous file has uploaded / errored, check if there are another file needed to be uploaded
  275. setTimeout(function(){
  276. if (uploadPendingList.length > 0){
  277. let nextFile = uploadPendingList.shift();
  278. uploadFile(nextFile.File, nextFile.UUID, nextFile.TargetDir);
  279. }
  280. }, 100)
  281. };
  282. socket.onerror = function(error) {
  283. console.error("[Upload] WebSocket error:", error);
  284. // Mark the task as failed and show the retry button
  285. markUploadFailed(taskUUID);
  286. };
  287. }else{
  288. /*
  289. Standard Upload Mode
  290. */
  291. //Create the task progress Object
  292. let taskUUID = uuid; //For queueing objects
  293. if (taskUUID == undefined){
  294. //If this is a new file to be uploaded
  295. taskUUID = appendUploadFileItem(file.name, file.size);
  296. setTimeout(function(){
  297. updateUploadFileCount();
  298. }, 100);
  299. }
  300. //TODO: Make the upload management interface a bit better
  301. //return;
  302. //Updates 22-10-2020
  303. //Added file upload queuing system to prevent too many request on-the-fly at the same time
  304. if (uploadingFileCount >= maxConcurrentUpload){
  305. //Push to upload pending list
  306. //Sometime files will be recursively uploaded (aka retry multiple time), hence the targetDir needed to be copied as well
  307. let uploadDir = currentPath;
  308. if (targetDir !== undefined){
  309. uploadDir = targetDir;
  310. }
  311. uploadPendingList.push({
  312. File: file,
  313. UUID: taskUUID,
  314. TargetDir: JSON.parse(JSON.stringify(uploadDir)),
  315. });
  316. return
  317. }
  318. //Prase upload Form
  319. let uploadCurrentPath = JSON.parse(JSON.stringify(currentPath));
  320. if (targetDir !== undefined){
  321. //The upload paramter supplied targetDir
  322. uploadCurrentPath = targetDir;
  323. }
  324. let url = '../../system/file_system/upload?path=' + encodeURIComponent(uploadCurrentPath)
  325. let formData = new FormData()
  326. let xhr = new XMLHttpRequest()
  327. formData.append('file', file);
  328. formData.append('path', uploadCurrentPath);
  329. //Let the user retry this task if it fails
  330. uploadRetryMap.set(taskUUID, {file: file, targetDir: JSON.parse(JSON.stringify(uploadCurrentPath))});
  331. /*
  332. A POST body cannot be suspended once it is in flight, so this mode
  333. offers cancel rather than pause. pausable:false is what makes the
  334. row draw a cancel button instead of a pause button.
  335. */
  336. let xhrAborted = false;
  337. registerUploadTransfer(taskUUID, {
  338. pausable: false,
  339. abort: function(){
  340. xhrAborted = true;
  341. xhr.abort();
  342. }
  343. });
  344. xhr.open('POST', url, true)
  345. xhr.upload.addEventListener("progress", function(e) {
  346. setUploadTaskProgress(taskUUID, e.loaded, e.total);
  347. if (e.total > 0 && e.loaded >= e.total){
  348. //Bytes are all sent but the server has not answered yet - it is
  349. //still writing the file out
  350. setUploadTaskState(taskUUID, "processing");
  351. }
  352. })
  353. xhr.addEventListener('readystatechange', function(e) {
  354. if (xhr.readyState == 4 && xhr.status == 200) {
  355. //Upload process ended
  356. unregisterUploadTransfer(taskUUID);
  357. setUploadTaskState(taskUUID, "done");
  358. var resp = JSON.parse(e.target.response);
  359. if (resp.error !== undefined){
  360. msgbox("caution",resp.error);
  361. //Something went wrong. Set the color to red
  362. setUploadTaskState(taskUUID, "failed");
  363. }else{
  364. uploadRetryMap.delete(taskUUID);
  365. }
  366. uploadingFileCount--;
  367. //After the previous file has uploaded / errored, check if there are another file needed to be uploaded
  368. setTimeout(function(){
  369. if (uploadPendingList.length > 0){
  370. let nextFile = uploadPendingList.shift();
  371. uploadFile(nextFile.File, nextFile.UUID, nextFile.TargetDir);
  372. }
  373. }, 100)
  374. }else if (xhr.readyState == 4 && xhr.status != 200) {
  375. unregisterUploadTransfer(taskUUID);
  376. if (!xhrAborted){
  377. //An abort is the user's own doing - the row is already gone
  378. msgbox("red remove",applocale.getString( "message/uploadFailed", "File too big or the target disk is fulled"));
  379. console.log(xhr);
  380. setUploadTaskState(taskUUID, "failed");
  381. }
  382. uploadingFileCount--;
  383. //After the previous file has uploaded / errored, check if there are another file needed to be uploaded
  384. setTimeout(function(){
  385. if (uploadPendingList.length > 0){
  386. let nextFile = uploadPendingList.shift();
  387. uploadFile(nextFile.File, nextFile.UUID, nextFile.TargetDir);
  388. }
  389. }, 100)
  390. }
  391. updateUploadFileCount();
  392. })
  393. xhr.send(formData);
  394. uploadingFileCount++;
  395. updateUploadFileCount();
  396. }
  397. }
  398. /*
  399. Reachable from outside this file: inline on* attributes in the markup,
  400. handlers generated in template strings, or another frame. Renaming any
  401. of these means updating those call sites too.
  402. */
  403. window.upload = upload;