From 9097ff1e4ecdf42c99585bc9d399590442720052 Mon Sep 17 00:00:00 2001 From: Patrick Simianer
Date: Sun, 19 Jun 2016 21:54:57 +0200 Subject: init --- .gitignore | 18 +- Gemfile | 4 - README | 2 + README.md | 7 - config.ru | 3 +- file_upload.rb | 133 +- public/css/style.css | 11 - public/files/.gitignore | 0 public/fine-uploader.css | 241 + public/fine-uploader.js | 11339 +++++++++++++++++++++++++++++++++++++++++++++ public/loading.gif | Bin 0 -> 1688 bytes public/upload.html | 131 + upload/.keep | 0 views/index.haml | 22 + views/index.slim | 15 - views/layout.slim | 8 - 16 files changed, 11843 insertions(+), 91 deletions(-) delete mode 100644 Gemfile create mode 100644 README delete mode 100644 README.md delete mode 100644 public/css/style.css delete mode 100644 public/files/.gitignore create mode 100644 public/fine-uploader.css create mode 100644 public/fine-uploader.js create mode 100644 public/loading.gif create mode 100644 public/upload.html create mode 100644 upload/.keep create mode 100644 views/index.haml delete mode 100644 views/index.slim delete mode 100644 views/layout.slim diff --git a/.gitignore b/.gitignore index d87d4be..8b13789 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1 @@ -*.gem -*.rbc -.bundle -.config -.yardoc -Gemfile.lock -InstalledFiles -_yardoc -coverage -doc/ -lib/bundler/man -pkg -rdoc -spec/reports -test/tmp -test/version_tmp -tmp + diff --git a/Gemfile b/Gemfile deleted file mode 100644 index 975cab0..0000000 --- a/Gemfile +++ /dev/null @@ -1,4 +0,0 @@ -source 'https://rubygems.org' - -gem 'sinatra', '~> 1.3' -gem 'slim', '~> 1.3' diff --git a/README b/README new file mode 100644 index 0000000..51f5daa --- /dev/null +++ b/README @@ -0,0 +1,2 @@ +rackup config.ru + diff --git a/README.md b/README.md deleted file mode 100644 index b42adfb..0000000 --- a/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# A simple Sinatra file upload application - -## Setup using [Bundler](http://gembundler.com/ "Bundler") - $ bundle install - -## Starting - $ bundle exec rackup config.ru diff --git a/config.ru b/config.ru index 7e916a6..0c8b758 100644 --- a/config.ru +++ b/config.ru @@ -1,5 +1,4 @@ -# $LOAD_PATH.unshift(File.dirname(__FILE__)) -require 'bundler/setup' require './file_upload' run FileUpload + diff --git a/file_upload.rb b/file_upload.rb index ce2c65f..5bb1d44 100644 --- a/file_upload.rb +++ b/file_upload.rb @@ -1,7 +1,5 @@ -# encoding: utf-8 - require 'sinatra/base' -require 'slim' +require 'haml' class FileUpload < Sinatra::Base configure do @@ -10,48 +8,129 @@ class FileUpload < Sinatra::Base set :views, File.join(File.dirname(__FILE__), 'views') set :public_folder, File.join(File.dirname(__FILE__), 'public') - set :files, File.join(settings.public_folder, 'files') - set :unallowed_paths, ['.', '..'] end - helpers do - def flash(message = '') - session[:flash] = message - end + not_found do + 'err 404' end - before do - @flash = session.delete(:flash) + error do + "err (#{request.env['sinatra.error']})" end - not_found do - slim 'h1 404' + get '/' do + haml :index end - error do - slim "Error (#{request.env['sinatra.error']})" + def log name, params + STDERR.write "[#{name}] #{params.to_s}\n" end - get '/' do - @files = Dir.entries(settings.files) - settings.unallowed_paths + def check_token dir, token + saved_token = `cat #{dir}/.token`.strip + if token == saved_token + return true + end + return false + end + + def check_dirname dirname + return dirname.match /^[a-zA-Z0-9_-]+$/ + end - slim :index + def get_dir dirname + return "upload/#{dirname}" end - + post '/upload' do - if params[:file] - filename = params[:file][:filename] - file = params[:file][:tempfile] + log '/upload', params + + if params[:qqfile] && params[:dirname] && params[:token] + + dirname = params[:dirname] + dir = get_dir params[:dirname] + token = params[:token] + + allowed = check_dirname(dirname) && check_token(dir, token) + + if allowed + filename = params[:qqfile][:filename] + file = params[:qqfile][:tempfile] + + File.open(File.join(dir, filename), 'wb') do |f| + f.write file.read + end - File.open(File.join(settings.files, filename), 'wb') do |f| - f.write file.read + return '{"success":true}' end - flash 'Upload successful' + end + + return '{"success":false}' + end + + post '/mkdir' do + log '/mkdir', params + + dirname = params[:dirname] + token = params[:token] + + return "err" if !dirname||!token + + dir = get_dir params[:dirname] + + return "err" if !check_dirname(dirname) + + allowed = false + if Dir.exists? dir + if check_token dir, token + allowed = true + end else - flash 'You have to choose a file' + `mkdir -p #{dir}` + `echo #{token} >> #{dir}/.token` + allowed = true end - redirect '/' + if allowed + redirect "upload.html?dirname=#{dirname}&token=#{token}" + else + "Falsches token/Wrong token Zurück/Back" + end end + + get "/list_dir/:dirname/:token" do + log '/list_dir', params + + dirname = params[:dirname] + dir = get_dir dirname + token = params[:token] + + allowed = check_dirname(dirname) && check_token(dir, token) + + if allowed + s = "
tag + if (innerHtml && innerHtml.match(/^ 1 && !options.allowMultipleItems) { + options.callbacks.processingDroppedFilesComplete([]); + options.callbacks.dropError("tooManyFilesError", ""); + uploadDropZone.dropDisabled(false); + handleDataTransferPromise.failure(); + } + else { + droppedFiles = []; + + if (qq.isFolderDropSupported(dataTransfer)) { + qq.each(dataTransfer.items, function(idx, item) { + var entry = item.webkitGetAsEntry(); + + if (entry) { + //due to a bug in Chrome's File System API impl - #149735 + if (entry.isFile) { + droppedFiles.push(item.getAsFile()); + } + + else { + pendingFolderPromises.push(traverseFileTree(entry).done(function() { + pendingFolderPromises.pop(); + if (pendingFolderPromises.length === 0) { + handleDataTransferPromise.success(); + } + })); + } + } + }); + } + else { + droppedFiles = dataTransfer.files; + } + + if (pendingFolderPromises.length === 0) { + handleDataTransferPromise.success(); + } + } + + return handleDataTransferPromise; + } + + function setupDropzone(dropArea) { + var dropZone = new qq.UploadDropZone({ + HIDE_ZONES_EVENT_NAME: HIDE_ZONES_EVENT_NAME, + element: dropArea, + onEnter: function(e) { + qq(dropArea).addClass(options.classes.dropActive); + e.stopPropagation(); + }, + onLeaveNotDescendants: function(e) { + qq(dropArea).removeClass(options.classes.dropActive); + }, + onDrop: function(e) { + handleDataTransfer(e.dataTransfer, dropZone).then( + function() { + uploadDroppedFiles(droppedFiles, dropZone); + }, + function() { + options.callbacks.dropLog("Drop event DataTransfer parsing failed. No files will be uploaded.", "error"); + } + ); + } + }); + + disposeSupport.addDisposer(function() { + dropZone.dispose(); + }); + + qq(dropArea).hasAttribute(HIDE_BEFORE_ENTER_ATTR) && qq(dropArea).hide(); + + uploadDropZones.push(dropZone); + + return dropZone; + } + + function isFileDrag(dragEvent) { + var fileDrag; + + qq.each(dragEvent.dataTransfer.types, function(key, val) { + if (val === "Files") { + fileDrag = true; + return false; + } + }); + + return fileDrag; + } + + // Attempt to determine when the file has left the document. It is not always possible to detect this + // in all cases, but it is generally possible in all browsers, with a few exceptions. + // + // Exceptions: + // * IE10+ & Safari: We can't detect a file leaving the document if the Explorer window housing the file + // overlays the browser window. + // * IE10+: If the file is dragged out of the window too quickly, IE does not set the expected values of the + // event's X & Y properties. + function leavingDocumentOut(e) { + if (qq.firefox()) { + return !e.relatedTarget; + } + + if (qq.safari()) { + return e.x < 0 || e.y < 0; + } + + return e.x === 0 && e.y === 0; + } + + function setupDragDrop() { + var dropZones = options.dropZoneElements, + + maybeHideDropZones = function() { + setTimeout(function() { + qq.each(dropZones, function(idx, dropZone) { + qq(dropZone).hasAttribute(HIDE_BEFORE_ENTER_ATTR) && qq(dropZone).hide(); + qq(dropZone).removeClass(options.classes.dropActive); + }); + }, 10); + }; + + qq.each(dropZones, function(idx, dropZone) { + var uploadDropZone = setupDropzone(dropZone); + + // IE <= 9 does not support the File API used for drag+drop uploads + if (dropZones.length && qq.supportedFeatures.fileDrop) { + disposeSupport.attach(document, "dragenter", function(e) { + if (!uploadDropZone.dropDisabled() && isFileDrag(e)) { + qq.each(dropZones, function(idx, dropZone) { + // We can't apply styles to non-HTMLElements, since they lack the `style` property. + // Also, if the drop zone isn't initially hidden, let's not mess with `style.display`. + if (dropZone instanceof HTMLElement && + qq(dropZone).hasAttribute(HIDE_BEFORE_ENTER_ATTR)) { + + qq(dropZone).css({display: "block"}); + } + }); + } + }); + } + }); + + disposeSupport.attach(document, "dragleave", function(e) { + if (leavingDocumentOut(e)) { + maybeHideDropZones(); + } + }); + + // Just in case we were not able to detect when a dragged file has left the document, + // hide all relevant drop zones the next time the mouse enters the document. + // Note that mouse events such as this one are not fired during drag operations. + disposeSupport.attach(qq(document).children()[0], "mouseenter", function(e) { + maybeHideDropZones(); + }); + + disposeSupport.attach(document, "drop", function(e) { + e.preventDefault(); + maybeHideDropZones(); + }); + + disposeSupport.attach(document, HIDE_ZONES_EVENT_NAME, maybeHideDropZones); + } + + setupDragDrop(); + + qq.extend(this, { + setupExtraDropzone: function(element) { + options.dropZoneElements.push(element); + setupDropzone(element); + }, + + removeDropzone: function(element) { + var i, + dzs = options.dropZoneElements; + + for (i in dzs) { + if (dzs[i] === element) { + return dzs.splice(i, 1); + } + } + }, + + dispose: function() { + disposeSupport.dispose(); + qq.each(uploadDropZones, function(idx, dropZone) { + dropZone.dispose(); + }); + } + }); +}; + +qq.DragAndDrop.callbacks = function() { + "use strict"; + + return { + processingDroppedFiles: function() {}, + processingDroppedFilesComplete: function(files, targetEl) {}, + dropError: function(code, errorSpecifics) { + qq.log("Drag & drop error code '" + code + " with these specifics: '" + errorSpecifics + "'", "error"); + }, + dropLog: function(message, level) { + qq.log(message, level); + } + }; +}; + +qq.UploadDropZone = function(o) { + "use strict"; + + var disposeSupport = new qq.DisposeSupport(), + options, element, preventDrop, dropOutsideDisabled; + + options = { + element: null, + onEnter: function(e) {}, + onLeave: function(e) {}, + // is not fired when leaving element by hovering descendants + onLeaveNotDescendants: function(e) {}, + onDrop: function(e) {} + }; + + qq.extend(options, o); + element = options.element; + + function dragoverShouldBeCanceled() { + return qq.safari() || (qq.firefox() && qq.windows()); + } + + function disableDropOutside(e) { + // run only once for all instances + if (!dropOutsideDisabled) { + + // for these cases we need to catch onDrop to reset dropArea + if (dragoverShouldBeCanceled) { + disposeSupport.attach(document, "dragover", function(e) { + e.preventDefault(); + }); + } else { + disposeSupport.attach(document, "dragover", function(e) { + if (e.dataTransfer) { + e.dataTransfer.dropEffect = "none"; + e.preventDefault(); + } + }); + } + + dropOutsideDisabled = true; + } + } + + function isValidFileDrag(e) { + // e.dataTransfer currently causing IE errors + // IE9 does NOT support file API, so drag-and-drop is not possible + if (!qq.supportedFeatures.fileDrop) { + return false; + } + + var effectTest, dt = e.dataTransfer, + // do not check dt.types.contains in webkit, because it crashes safari 4 + isSafari = qq.safari(); + + // dt.effectAllowed is none in Safari 5 + // dt.types.contains check is for firefox + + // dt.effectAllowed crashes IE 11 & 10 when files have been dragged from + // the filesystem + effectTest = qq.ie() && qq.supportedFeatures.fileDrop ? true : dt.effectAllowed !== "none"; + return dt && effectTest && (dt.files || (!isSafari && dt.types.contains && dt.types.contains("Files"))); + } + + function isOrSetDropDisabled(isDisabled) { + if (isDisabled !== undefined) { + preventDrop = isDisabled; + } + return preventDrop; + } + + function triggerHidezonesEvent() { + var hideZonesEvent; + + function triggerUsingOldApi() { + hideZonesEvent = document.createEvent("Event"); + hideZonesEvent.initEvent(options.HIDE_ZONES_EVENT_NAME, true, true); + } + + if (window.CustomEvent) { + try { + hideZonesEvent = new CustomEvent(options.HIDE_ZONES_EVENT_NAME); + } + catch (err) { + triggerUsingOldApi(); + } + } + else { + triggerUsingOldApi(); + } + + document.dispatchEvent(hideZonesEvent); + } + + function attachEvents() { + disposeSupport.attach(element, "dragover", function(e) { + if (!isValidFileDrag(e)) { + return; + } + + // dt.effectAllowed crashes IE 11 & 10 when files have been dragged from + // the filesystem + var effect = qq.ie() && qq.supportedFeatures.fileDrop ? null : e.dataTransfer.effectAllowed; + if (effect === "move" || effect === "linkMove") { + e.dataTransfer.dropEffect = "move"; // for FF (only move allowed) + } else { + e.dataTransfer.dropEffect = "copy"; // for Chrome + } + + e.stopPropagation(); + e.preventDefault(); + }); + + disposeSupport.attach(element, "dragenter", function(e) { + if (!isOrSetDropDisabled()) { + if (!isValidFileDrag(e)) { + return; + } + options.onEnter(e); + } + }); + + disposeSupport.attach(element, "dragleave", function(e) { + if (!isValidFileDrag(e)) { + return; + } + + options.onLeave(e); + + var relatedTarget = document.elementFromPoint(e.clientX, e.clientY); + // do not fire when moving a mouse over a descendant + if (qq(this).contains(relatedTarget)) { + return; + } + + options.onLeaveNotDescendants(e); + }); + + disposeSupport.attach(element, "drop", function(e) { + if (!isOrSetDropDisabled()) { + if (!isValidFileDrag(e)) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + options.onDrop(e); + + triggerHidezonesEvent(); + } + }); + } + + disableDropOutside(); + attachEvents(); + + qq.extend(this, { + dropDisabled: function(isDisabled) { + return isOrSetDropDisabled(isDisabled); + }, + + dispose: function() { + disposeSupport.dispose(); + }, + + getElement: function() { + return element; + } + }); +}; + +/*globals qq, XMLHttpRequest*/ +qq.DeleteFileAjaxRequester = function(o) { + "use strict"; + + var requester, + options = { + method: "DELETE", + uuidParamName: "qquuid", + endpointStore: {}, + maxConnections: 3, + customHeaders: function(id) {return {};}, + paramsStore: {}, + cors: { + expected: false, + sendCredentials: false + }, + log: function(str, level) {}, + onDelete: function(id) {}, + onDeleteComplete: function(id, xhrOrXdr, isError) {} + }; + + qq.extend(options, o); + + function getMandatedParams() { + if (options.method.toUpperCase() === "POST") { + return { + _method: "DELETE" + }; + } + + return {}; + } + + requester = qq.extend(this, new qq.AjaxRequester({ + acceptHeader: "application/json", + validMethods: ["POST", "DELETE"], + method: options.method, + endpointStore: options.endpointStore, + paramsStore: options.paramsStore, + mandatedParams: getMandatedParams(), + maxConnections: options.maxConnections, + customHeaders: function(id) { + return options.customHeaders.get(id); + }, + log: options.log, + onSend: options.onDelete, + onComplete: options.onDeleteComplete, + cors: options.cors + })); + + qq.extend(this, { + sendDelete: function(id, uuid, additionalMandatedParams) { + var additionalOptions = additionalMandatedParams || {}; + + options.log("Submitting delete file request for " + id); + + if (options.method === "DELETE") { + requester.initTransport(id) + .withPath(uuid) + .withParams(additionalOptions) + .send(); + } + else { + additionalOptions[options.uuidParamName] = uuid; + requester.initTransport(id) + .withParams(additionalOptions) + .send(); + } + } + }); +}; + +/*global qq, define */ +/*jshint strict:false,bitwise:false,nonew:false,asi:true,-W064,-W116,-W089 */ +/** + * Mega pixel image rendering library for iOS6+ + * + * Fixes iOS6+'s image file rendering issue for large size image (over mega-pixel), + * which causes unexpected subsampling when drawing it in canvas. + * By using this library, you can safely render the image with proper stretching. + * + * Copyright (c) 2012 Shinichi Tomita + * Released under the MIT license + * + * Heavily modified by Widen for Fine Uploader + */ +(function() { + + /** + * Detect subsampling in loaded image. + * In iOS, larger images than 2M pixels may be subsampled in rendering. + */ + function detectSubsampling(img) { + var iw = img.naturalWidth, + ih = img.naturalHeight, + canvas = document.createElement("canvas"), + ctx; + + if (iw * ih > 1024 * 1024) { // subsampling may happen over megapixel image + canvas.width = canvas.height = 1; + ctx = canvas.getContext("2d"); + ctx.drawImage(img, -iw + 1, 0); + // subsampled image becomes half smaller in rendering size. + // check alpha channel value to confirm image is covering edge pixel or not. + // if alpha value is 0 image is not covering, hence subsampled. + return ctx.getImageData(0, 0, 1, 1).data[3] === 0; + } else { + return false; + } + } + + /** + * Detecting vertical squash in loaded image. + * Fixes a bug which squash image vertically while drawing into canvas for some images. + */ + function detectVerticalSquash(img, iw, ih) { + var canvas = document.createElement("canvas"), + sy = 0, + ey = ih, + py = ih, + ctx, data, alpha, ratio; + + canvas.width = 1; + canvas.height = ih; + ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0); + data = ctx.getImageData(0, 0, 1, ih).data; + + // search image edge pixel position in case it is squashed vertically. + while (py > sy) { + alpha = data[(py - 1) * 4 + 3]; + if (alpha === 0) { + ey = py; + } else { + sy = py; + } + py = (ey + sy) >> 1; + } + + ratio = (py / ih); + return (ratio === 0) ? 1 : ratio; + } + + /** + * Rendering image element (with resizing) and get its data URL + */ + function renderImageToDataURL(img, blob, options, doSquash) { + var canvas = document.createElement("canvas"), + mime = options.mime || "image/jpeg", + promise = new qq.Promise(); + + renderImageToCanvas(img, blob, canvas, options, doSquash) + .then(function() { + promise.success( + canvas.toDataURL(mime, options.quality || 0.8) + ); + }) + + return promise; + } + + function maybeCalculateDownsampledDimensions(spec) { + var maxPixels = 5241000; //iOS specific value + + if (!qq.ios()) { + throw new qq.Error("Downsampled dimensions can only be reliably calculated for iOS!"); + } + + if (spec.origHeight * spec.origWidth > maxPixels) { + return { + newHeight: Math.round(Math.sqrt(maxPixels * (spec.origHeight / spec.origWidth))), + newWidth: Math.round(Math.sqrt(maxPixels * (spec.origWidth / spec.origHeight))) + } + } + } + + /** + * Rendering image element (with resizing) into the canvas element + */ + function renderImageToCanvas(img, blob, canvas, options, doSquash) { + var iw = img.naturalWidth, + ih = img.naturalHeight, + width = options.width, + height = options.height, + ctx = canvas.getContext("2d"), + promise = new qq.Promise(), + modifiedDimensions; + + ctx.save(); + + if (options.resize) { + return renderImageToCanvasWithCustomResizer({ + blob: blob, + canvas: canvas, + image: img, + imageHeight: ih, + imageWidth: iw, + orientation: options.orientation, + resize: options.resize, + targetHeight: height, + targetWidth: width + }) + } + + if (!qq.supportedFeatures.unlimitedScaledImageSize) { + modifiedDimensions = maybeCalculateDownsampledDimensions({ + origWidth: width, + origHeight: height + }); + + if (modifiedDimensions) { + qq.log(qq.format("Had to reduce dimensions due to device limitations from {}w / {}h to {}w / {}h", + width, height, modifiedDimensions.newWidth, modifiedDimensions.newHeight), + "warn"); + + width = modifiedDimensions.newWidth; + height = modifiedDimensions.newHeight; + } + } + + transformCoordinate(canvas, width, height, options.orientation); + + // Fine Uploader specific: Save some CPU cycles if not using iOS + // Assumption: This logic is only needed to overcome iOS image sampling issues + if (qq.ios()) { + (function() { + if (detectSubsampling(img)) { + iw /= 2; + ih /= 2; + } + + var d = 1024, // size of tiling canvas + tmpCanvas = document.createElement("canvas"), + vertSquashRatio = doSquash ? detectVerticalSquash(img, iw, ih) : 1, + dw = Math.ceil(d * width / iw), + dh = Math.ceil(d * height / ih / vertSquashRatio), + sy = 0, + dy = 0, + tmpCtx, sx, dx; + + tmpCanvas.width = tmpCanvas.height = d; + tmpCtx = tmpCanvas.getContext("2d"); + + while (sy < ih) { + sx = 0; + dx = 0; + while (sx < iw) { + tmpCtx.clearRect(0, 0, d, d); + tmpCtx.drawImage(img, -sx, -sy); + ctx.drawImage(tmpCanvas, 0, 0, d, d, dx, dy, dw, dh); + sx += d; + dx += dw; + } + sy += d; + dy += dh; + } + ctx.restore(); + tmpCanvas = tmpCtx = null; + }()) + } + else { + ctx.drawImage(img, 0, 0, width, height); + } + + canvas.qqImageRendered && canvas.qqImageRendered(); + promise.success(); + + return promise; + } + + function renderImageToCanvasWithCustomResizer(resizeInfo) { + var blob = resizeInfo.blob, + image = resizeInfo.image, + imageHeight = resizeInfo.imageHeight, + imageWidth = resizeInfo.imageWidth, + orientation = resizeInfo.orientation, + promise = new qq.Promise(), + resize = resizeInfo.resize, + sourceCanvas = document.createElement("canvas"), + sourceCanvasContext = sourceCanvas.getContext("2d"), + targetCanvas = resizeInfo.canvas, + targetHeight = resizeInfo.targetHeight, + targetWidth = resizeInfo.targetWidth; + + transformCoordinate(sourceCanvas, imageWidth, imageHeight, orientation); + + targetCanvas.height = targetHeight; + targetCanvas.width = targetWidth; + + sourceCanvasContext.drawImage(image, 0, 0); + + resize({ + blob: blob, + height: targetHeight, + image: image, + sourceCanvas: sourceCanvas, + targetCanvas: targetCanvas, + width: targetWidth + }) + .then( + function success() { + targetCanvas.qqImageRendered && targetCanvas.qqImageRendered(); + promise.success(); + }, + promise.failure + ) + + return promise; + } + + /** + * Transform canvas coordination according to specified frame size and orientation + * Orientation value is from EXIF tag + */ + function transformCoordinate(canvas, width, height, orientation) { + switch (orientation) { + case 5: + case 6: + case 7: + case 8: + canvas.width = height; + canvas.height = width; + break; + default: + canvas.width = width; + canvas.height = height; + } + var ctx = canvas.getContext("2d"); + switch (orientation) { + case 2: + // horizontal flip + ctx.translate(width, 0); + ctx.scale(-1, 1); + break; + case 3: + // 180 rotate left + ctx.translate(width, height); + ctx.rotate(Math.PI); + break; + case 4: + // vertical flip + ctx.translate(0, height); + ctx.scale(1, -1); + break; + case 5: + // vertical flip + 90 rotate right + ctx.rotate(0.5 * Math.PI); + ctx.scale(1, -1); + break; + case 6: + // 90 rotate right + ctx.rotate(0.5 * Math.PI); + ctx.translate(0, -height); + break; + case 7: + // horizontal flip + 90 rotate right + ctx.rotate(0.5 * Math.PI); + ctx.translate(width, -height); + ctx.scale(-1, 1); + break; + case 8: + // 90 rotate left + ctx.rotate(-0.5 * Math.PI); + ctx.translate(-width, 0); + break; + default: + break; + } + } + + /** + * MegaPixImage class + */ + function MegaPixImage(srcImage, errorCallback) { + var self = this; + + if (window.Blob && srcImage instanceof Blob) { + (function() { + var img = new Image(), + URL = window.URL && window.URL.createObjectURL ? window.URL : + window.webkitURL && window.webkitURL.createObjectURL ? window.webkitURL : null; + if (!URL) { throw Error("No createObjectURL function found to create blob url"); } + img.src = URL.createObjectURL(srcImage); + self.blob = srcImage; + srcImage = img; + }()); + } + if (!srcImage.naturalWidth && !srcImage.naturalHeight) { + srcImage.onload = function() { + var listeners = self.imageLoadListeners; + if (listeners) { + self.imageLoadListeners = null; + // IE11 doesn't reliably report actual image dimensions immediately after onload for small files, + // so let's push this to the end of the UI thread queue. + setTimeout(function() { + for (var i = 0, len = listeners.length; i < len; i++) { + listeners[i](); + } + }, 0); + } + }; + srcImage.onerror = errorCallback; + this.imageLoadListeners = []; + } + this.srcImage = srcImage; + } + + /** + * Rendering megapix image into specified target element + */ + MegaPixImage.prototype.render = function(target, options) { + options = options || {}; + + var self = this, + imgWidth = this.srcImage.naturalWidth, + imgHeight = this.srcImage.naturalHeight, + width = options.width, + height = options.height, + maxWidth = options.maxWidth, + maxHeight = options.maxHeight, + doSquash = !this.blob || this.blob.type === "image/jpeg", + tagName = target.tagName.toLowerCase(), + opt; + + if (this.imageLoadListeners) { + this.imageLoadListeners.push(function() { self.render(target, options) }); + return; + } + + if (width && !height) { + height = (imgHeight * width / imgWidth) << 0; + } else if (height && !width) { + width = (imgWidth * height / imgHeight) << 0; + } else { + width = imgWidth; + height = imgHeight; + } + if (maxWidth && width > maxWidth) { + width = maxWidth; + height = (imgHeight * width / imgWidth) << 0; + } + if (maxHeight && height > maxHeight) { + height = maxHeight; + width = (imgWidth * height / imgHeight) << 0; + } + + opt = { width: width, height: height }, + qq.each(options, function(optionsKey, optionsValue) { + opt[optionsKey] = optionsValue; + }); + + if (tagName === "img") { + (function() { + var oldTargetSrc = target.src; + renderImageToDataURL(self.srcImage, self.blob, opt, doSquash) + .then(function(dataUri) { + target.src = dataUri; + oldTargetSrc === target.src && target.onload(); + }); + }()) + } else if (tagName === "canvas") { + renderImageToCanvas(this.srcImage, this.blob, target, opt, doSquash); + } + if (typeof this.onrender === "function") { + this.onrender(target); + } + }; + + qq.MegaPixImage = MegaPixImage; +})(); + +/*globals qq */ +/** + * Draws a thumbnail of a Blob/File/URL onto an or . + * + * @constructor + */ +qq.ImageGenerator = function(log) { + "use strict"; + + function isImg(el) { + return el.tagName.toLowerCase() === "img"; + } + + function isCanvas(el) { + return el.tagName.toLowerCase() === "canvas"; + } + + function isImgCorsSupported() { + return new Image().crossOrigin !== undefined; + } + + function isCanvasSupported() { + var canvas = document.createElement("canvas"); + + return canvas.getContext && canvas.getContext("2d"); + } + + // This is only meant to determine the MIME type of a renderable image file. + // It is used to ensure images drawn from a URL that have transparent backgrounds + // are rendered correctly, among other things. + function determineMimeOfFileName(nameWithPath) { + /*jshint -W015 */ + var pathSegments = nameWithPath.split("/"), + name = pathSegments[pathSegments.length - 1], + extension = qq.getExtension(name); + + extension = extension && extension.toLowerCase(); + + switch (extension) { + case "jpeg": + case "jpg": + return "image/jpeg"; + case "png": + return "image/png"; + case "bmp": + return "image/bmp"; + case "gif": + return "image/gif"; + case "tiff": + case "tif": + return "image/tiff"; + } + } + + // This will likely not work correctly in IE8 and older. + // It's only used as part of a formula to determine + // if a canvas can be used to scale a server-hosted thumbnail. + // If canvas isn't supported by the UA (IE8 and older) + // this method should not even be called. + function isCrossOrigin(url) { + var targetAnchor = document.createElement("a"), + targetProtocol, targetHostname, targetPort; + + targetAnchor.href = url; + + targetProtocol = targetAnchor.protocol; + targetPort = targetAnchor.port; + targetHostname = targetAnchor.hostname; + + if (targetProtocol.toLowerCase() !== window.location.protocol.toLowerCase()) { + return true; + } + + if (targetHostname.toLowerCase() !== window.location.hostname.toLowerCase()) { + return true; + } + + // IE doesn't take ports into consideration when determining if two endpoints are same origin. + if (targetPort !== window.location.port && !qq.ie()) { + return true; + } + + return false; + } + + function registerImgLoadListeners(img, promise) { + img.onload = function() { + img.onload = null; + img.onerror = null; + promise.success(img); + }; + + img.onerror = function() { + img.onload = null; + img.onerror = null; + log("Problem drawing thumbnail!", "error"); + promise.failure(img, "Problem drawing thumbnail!"); + }; + } + + function registerCanvasDrawImageListener(canvas, promise) { + // The image is drawn on the canvas by a third-party library, + // and we want to know when this is completed. Since the library + // may invoke drawImage many times in a loop, we need to be called + // back when the image is fully rendered. So, we are expecting the + // code that draws this image to follow a convention that involves a + // function attached to the canvas instance be invoked when it is done. + canvas.qqImageRendered = function() { + promise.success(canvas); + }; + } + + // Fulfills a `qq.Promise` when an image has been drawn onto the target, + // whether that is a or an . The attempt is considered a + // failure if the target is not an or a , or if the drawing + // attempt was not successful. + function registerThumbnailRenderedListener(imgOrCanvas, promise) { + var registered = isImg(imgOrCanvas) || isCanvas(imgOrCanvas); + + if (isImg(imgOrCanvas)) { + registerImgLoadListeners(imgOrCanvas, promise); + } + else if (isCanvas(imgOrCanvas)) { + registerCanvasDrawImageListener(imgOrCanvas, promise); + } + else { + promise.failure(imgOrCanvas); + log(qq.format("Element container of type {} is not supported!", imgOrCanvas.tagName), "error"); + } + + return registered; + } + + // Draw a preview iff the current UA can natively display it. + // Also rotate the image if necessary. + function draw(fileOrBlob, container, options) { + var drawPreview = new qq.Promise(), + identifier = new qq.Identify(fileOrBlob, log), + maxSize = options.maxSize, + // jshint eqnull:true + orient = options.orient == null ? true : options.orient, + megapixErrorHandler = function() { + container.onerror = null; + container.onload = null; + log("Could not render preview, file may be too large!", "error"); + drawPreview.failure(container, "Browser cannot render image!"); + }; + + identifier.isPreviewable().then( + function(mime) { + // If options explicitly specify that Orientation is not desired, + // replace the orient task with a dummy promise that "succeeds" immediately. + var dummyExif = { + parse: function() { + return new qq.Promise().success(); + } + }, + exif = orient ? new qq.Exif(fileOrBlob, log) : dummyExif, + mpImg = new qq.MegaPixImage(fileOrBlob, megapixErrorHandler); + + if (registerThumbnailRenderedListener(container, drawPreview)) { + exif.parse().then( + function(exif) { + var orientation = exif && exif.Orientation; + + mpImg.render(container, { + maxWidth: maxSize, + maxHeight: maxSize, + orientation: orientation, + mime: mime, + resize: options.customResizeFunction + }); + }, + + function(failureMsg) { + log(qq.format("EXIF data could not be parsed ({}). Assuming orientation = 1.", failureMsg)); + + mpImg.render(container, { + maxWidth: maxSize, + maxHeight: maxSize, + mime: mime, + resize: options.customResizeFunction + }); + } + ); + } + }, + + function() { + log("Not previewable"); + drawPreview.failure(container, "Not previewable"); + } + ); + + return drawPreview; + } + + function drawOnCanvasOrImgFromUrl(url, canvasOrImg, draw, maxSize, customResizeFunction) { + var tempImg = new Image(), + tempImgRender = new qq.Promise(); + + registerThumbnailRenderedListener(tempImg, tempImgRender); + + if (isCrossOrigin(url)) { + tempImg.crossOrigin = "anonymous"; + } + + tempImg.src = url; + + tempImgRender.then( + function rendered() { + registerThumbnailRenderedListener(canvasOrImg, draw); + + var mpImg = new qq.MegaPixImage(tempImg); + mpImg.render(canvasOrImg, { + maxWidth: maxSize, + maxHeight: maxSize, + mime: determineMimeOfFileName(url), + resize: customResizeFunction + }); + }, + + draw.failure + ); + } + + function drawOnImgFromUrlWithCssScaling(url, img, draw, maxSize) { + registerThumbnailRenderedListener(img, draw); + // NOTE: The fact that maxWidth/height is set on the thumbnail for scaled images + // that must drop back to CSS is known and exploited by the templating module. + // In this module, we pre-render "waiting" thumbs for all files immediately after they + // are submitted, and we must be sure to pass any style associated with the "waiting" preview. + qq(img).css({ + maxWidth: maxSize + "px", + maxHeight: maxSize + "px" + }); + + img.src = url; + } + + // Draw a (server-hosted) thumbnail given a URL. + // This will optionally scale the thumbnail as well. + // It attempts to use to scale, but will fall back + // to max-width and max-height style properties if the UA + // doesn't support canvas or if the images is cross-domain and + // the UA doesn't support the crossorigin attribute on img tags, + // which is required to scale a cross-origin image using & + // then export it back to an . + function drawFromUrl(url, container, options) { + var draw = new qq.Promise(), + scale = options.scale, + maxSize = scale ? options.maxSize : null; + + // container is an img, scaling needed + if (scale && isImg(container)) { + // Iff canvas is available in this UA, try to use it for scaling. + // Otherwise, fall back to CSS scaling + if (isCanvasSupported()) { + // Attempt to use for image scaling, + // but we must fall back to scaling via CSS/styles + // if this is a cross-origin image and the UA doesn't support CORS. + if (isCrossOrigin(url) && !isImgCorsSupported()) { + drawOnImgFromUrlWithCssScaling(url, container, draw, maxSize); + } + else { + drawOnCanvasOrImgFromUrl(url, container, draw, maxSize); + } + } + else { + drawOnImgFromUrlWithCssScaling(url, container, draw, maxSize); + } + } + // container is a canvas, scaling optional + else if (isCanvas(container)) { + drawOnCanvasOrImgFromUrl(url, container, draw, maxSize); + } + // container is an img & no scaling: just set the src attr to the passed url + else if (registerThumbnailRenderedListener(container, draw)) { + container.src = url; + } + + return draw; + } + + qq.extend(this, { + /** + * Generate a thumbnail. Depending on the arguments, this may either result in + * a client-side rendering of an image (if a `Blob` is supplied) or a server-generated + * image that may optionally be scaled client-side using or CSS/styles (as a fallback). + * + * @param fileBlobOrUrl a `File`, `Blob`, or a URL pointing to the image + * @param container or to contain the preview + * @param options possible properties include `maxSize` (int), `orient` (bool - default true), resize` (bool - default true), and `customResizeFunction`. + * @returns qq.Promise fulfilled when the preview has been drawn, or the attempt has failed + */ + generate: function(fileBlobOrUrl, container, options) { + if (qq.isString(fileBlobOrUrl)) { + log("Attempting to update thumbnail based on server response."); + return drawFromUrl(fileBlobOrUrl, container, options || {}); + } + else { + log("Attempting to draw client-side image preview."); + return draw(fileBlobOrUrl, container, options || {}); + } + } + }); + +}; + +/*globals qq */ +/** + * EXIF image data parser. Currently only parses the Orientation tag value, + * but this may be expanded to other tags in the future. + * + * @param fileOrBlob Attempt to parse EXIF data in this `Blob` + * @constructor + */ +qq.Exif = function(fileOrBlob, log) { + "use strict"; + + // Orientation is the only tag parsed here at this time. + var TAG_IDS = [274], + TAG_INFO = { + 274: { + name: "Orientation", + bytes: 2 + } + }; + + // Convert a little endian (hex string) to big endian (decimal). + function parseLittleEndian(hex) { + var result = 0, + pow = 0; + + while (hex.length > 0) { + result += parseInt(hex.substring(0, 2), 16) * Math.pow(2, pow); + hex = hex.substring(2, hex.length); + pow += 8; + } + + return result; + } + + // Find the byte offset, of Application Segment 1 (EXIF). + // External callers need not supply any arguments. + function seekToApp1(offset, promise) { + var theOffset = offset, + thePromise = promise; + if (theOffset === undefined) { + theOffset = 2; + thePromise = new qq.Promise(); + } + + qq.readBlobToHex(fileOrBlob, theOffset, 4).then(function(hex) { + var match = /^ffe([0-9])/.exec(hex), + segmentLength; + + if (match) { + if (match[1] !== "1") { + segmentLength = parseInt(hex.slice(4, 8), 16); + seekToApp1(theOffset + segmentLength + 2, thePromise); + } + else { + thePromise.success(theOffset); + } + } + else { + thePromise.failure("No EXIF header to be found!"); + } + }); + + return thePromise; + } + + // Find the byte offset of Application Segment 1 (EXIF) for valid JPEGs only. + function getApp1Offset() { + var promise = new qq.Promise(); + + qq.readBlobToHex(fileOrBlob, 0, 6).then(function(hex) { + if (hex.indexOf("ffd8") !== 0) { + promise.failure("Not a valid JPEG!"); + } + else { + seekToApp1().then(function(offset) { + promise.success(offset); + }, + function(error) { + promise.failure(error); + }); + } + }); + + return promise; + } + + // Determine the byte ordering of the EXIF header. + function isLittleEndian(app1Start) { + var promise = new qq.Promise(); + + qq.readBlobToHex(fileOrBlob, app1Start + 10, 2).then(function(hex) { + promise.success(hex === "4949"); + }); + + return promise; + } + + // Determine the number of directory entries in the EXIF header. + function getDirEntryCount(app1Start, littleEndian) { + var promise = new qq.Promise(); + + qq.readBlobToHex(fileOrBlob, app1Start + 18, 2).then(function(hex) { + if (littleEndian) { + return promise.success(parseLittleEndian(hex)); + } + else { + promise.success(parseInt(hex, 16)); + } + }); + + return promise; + } + + // Get the IFD portion of the EXIF header as a hex string. + function getIfd(app1Start, dirEntries) { + var offset = app1Start + 20, + bytes = dirEntries * 12; + + return qq.readBlobToHex(fileOrBlob, offset, bytes); + } + + // Obtain an array of all directory entries (as hex strings) in the EXIF header. + function getDirEntries(ifdHex) { + var entries = [], + offset = 0; + + while (offset + 24 <= ifdHex.length) { + entries.push(ifdHex.slice(offset, offset + 24)); + offset += 24; + } + + return entries; + } + + // Obtain values for all relevant tags and return them. + function getTagValues(littleEndian, dirEntries) { + var TAG_VAL_OFFSET = 16, + tagsToFind = qq.extend([], TAG_IDS), + vals = {}; + + qq.each(dirEntries, function(idx, entry) { + var idHex = entry.slice(0, 4), + id = littleEndian ? parseLittleEndian(idHex) : parseInt(idHex, 16), + tagsToFindIdx = tagsToFind.indexOf(id), + tagValHex, tagName, tagValLength; + + if (tagsToFindIdx >= 0) { + tagName = TAG_INFO[id].name; + tagValLength = TAG_INFO[id].bytes; + tagValHex = entry.slice(TAG_VAL_OFFSET, TAG_VAL_OFFSET + (tagValLength * 2)); + vals[tagName] = littleEndian ? parseLittleEndian(tagValHex) : parseInt(tagValHex, 16); + + tagsToFind.splice(tagsToFindIdx, 1); + } + + if (tagsToFind.length === 0) { + return false; + } + }); + + return vals; + } + + qq.extend(this, { + /** + * Attempt to parse the EXIF header for the `Blob` associated with this instance. + * + * @returns {qq.Promise} To be fulfilled when the parsing is complete. + * If successful, the parsed EXIF header as an object will be included. + */ + parse: function() { + var parser = new qq.Promise(), + onParseFailure = function(message) { + log(qq.format("EXIF header parse failed: '{}' ", message)); + parser.failure(message); + }; + + getApp1Offset().then(function(app1Offset) { + log(qq.format("Moving forward with EXIF header parsing for '{}'", fileOrBlob.name === undefined ? "blob" : fileOrBlob.name)); + + isLittleEndian(app1Offset).then(function(littleEndian) { + + log(qq.format("EXIF Byte order is {} endian", littleEndian ? "little" : "big")); + + getDirEntryCount(app1Offset, littleEndian).then(function(dirEntryCount) { + + log(qq.format("Found {} APP1 directory entries", dirEntryCount)); + + getIfd(app1Offset, dirEntryCount).then(function(ifdHex) { + var dirEntries = getDirEntries(ifdHex), + tagValues = getTagValues(littleEndian, dirEntries); + + log("Successfully parsed some EXIF tags"); + + parser.success(tagValues); + }, onParseFailure); + }, onParseFailure); + }, onParseFailure); + }, onParseFailure); + + return parser; + } + }); + +}; + +/*globals qq */ +qq.Identify = function(fileOrBlob, log) { + "use strict"; + + function isIdentifiable(magicBytes, questionableBytes) { + var identifiable = false, + magicBytesEntries = [].concat(magicBytes); + + qq.each(magicBytesEntries, function(idx, magicBytesArrayEntry) { + if (questionableBytes.indexOf(magicBytesArrayEntry) === 0) { + identifiable = true; + return false; + } + }); + + return identifiable; + } + + qq.extend(this, { + /** + * Determines if a Blob can be displayed natively in the current browser. This is done by reading magic + * bytes in the beginning of the file, so this is an asynchronous operation. Before we attempt to read the + * file, we will examine the blob's type attribute to save CPU cycles. + * + * @returns {qq.Promise} Promise that is fulfilled when identification is complete. + * If successful, the MIME string is passed to the success handler. + */ + isPreviewable: function() { + var self = this, + identifier = new qq.Promise(), + previewable = false, + name = fileOrBlob.name === undefined ? "blob" : fileOrBlob.name; + + log(qq.format("Attempting to determine if {} can be rendered in this browser", name)); + + log("First pass: check type attribute of blob object."); + + if (this.isPreviewableSync()) { + log("Second pass: check for magic bytes in file header."); + + qq.readBlobToHex(fileOrBlob, 0, 4).then(function(hex) { + qq.each(self.PREVIEWABLE_MIME_TYPES, function(mime, bytes) { + if (isIdentifiable(bytes, hex)) { + // Safari is the only supported browser that can deal with TIFFs natively, + // so, if this is a TIFF and the UA isn't Safari, declare this file "non-previewable". + if (mime !== "image/tiff" || qq.supportedFeatures.tiffPreviews) { + previewable = true; + identifier.success(mime); + } + + return false; + } + }); + + log(qq.format("'{}' is {} able to be rendered in this browser", name, previewable ? "" : "NOT")); + + if (!previewable) { + identifier.failure(); + } + }, + function() { + log("Error reading file w/ name '" + name + "'. Not able to be rendered in this browser."); + identifier.failure(); + }); + } + else { + identifier.failure(); + } + + return identifier; + }, + + /** + * Determines if a Blob can be displayed natively in the current browser. This is done by checking the + * blob's type attribute. This is a synchronous operation, useful for situations where an asynchronous operation + * would be challenging to support. Note that the blob's type property is not as accurate as reading the + * file's magic bytes. + * + * @returns {Boolean} true if the blob can be rendered in the current browser + */ + isPreviewableSync: function() { + var fileMime = fileOrBlob.type, + // Assumption: This will only ever be executed in browsers that support `Object.keys`. + isRecognizedImage = qq.indexOf(Object.keys(this.PREVIEWABLE_MIME_TYPES), fileMime) >= 0, + previewable = false, + name = fileOrBlob.name === undefined ? "blob" : fileOrBlob.name; + + if (isRecognizedImage) { + if (fileMime === "image/tiff") { + previewable = qq.supportedFeatures.tiffPreviews; + } + else { + previewable = true; + } + } + + !previewable && log(name + " is not previewable in this browser per the blob's type attr"); + + return previewable; + } + }); +}; + +qq.Identify.prototype.PREVIEWABLE_MIME_TYPES = { + "image/jpeg": "ffd8ff", + "image/gif": "474946", + "image/png": "89504e", + "image/bmp": "424d", + "image/tiff": ["49492a00", "4d4d002a"] +}; + +/*globals qq*/ +/** + * Attempts to validate an image, wherever possible. + * + * @param blob File or Blob representing a user-selecting image. + * @param log Uses this to post log messages to the console. + * @constructor + */ +qq.ImageValidation = function(blob, log) { + "use strict"; + + /** + * @param limits Object with possible image-related limits to enforce. + * @returns {boolean} true if at least one of the limits has a non-zero value + */ + function hasNonZeroLimits(limits) { + var atLeastOne = false; + + qq.each(limits, function(limit, value) { + if (value > 0) { + atLeastOne = true; + return false; + } + }); + + return atLeastOne; + } + + /** + * @returns {qq.Promise} The promise is a failure if we can't obtain the width & height. + * Otherwise, `success` is called on the returned promise with an object containing + * `width` and `height` properties. + */ + function getWidthHeight() { + var sizeDetermination = new qq.Promise(); + + new qq.Identify(blob, log).isPreviewable().then(function() { + var image = new Image(), + url = window.URL && window.URL.createObjectURL ? window.URL : + window.webkitURL && window.webkitURL.createObjectURL ? window.webkitURL : + null; + + if (url) { + image.onerror = function() { + log("Cannot determine dimensions for image. May be too large.", "error"); + sizeDetermination.failure(); + }; + + image.onload = function() { + sizeDetermination.success({ + width: this.width, + height: this.height + }); + }; + + image.src = url.createObjectURL(blob); + } + else { + log("No createObjectURL function available to generate image URL!", "error"); + sizeDetermination.failure(); + } + }, sizeDetermination.failure); + + return sizeDetermination; + } + + /** + * + * @param limits Object with possible image-related limits to enforce. + * @param dimensions Object containing `width` & `height` properties for the image to test. + * @returns {String || undefined} The name of the failing limit. Undefined if no failing limits. + */ + function getFailingLimit(limits, dimensions) { + var failingLimit; + + qq.each(limits, function(limitName, limitValue) { + if (limitValue > 0) { + var limitMatcher = /(max|min)(Width|Height)/.exec(limitName), + dimensionPropName = limitMatcher[2].charAt(0).toLowerCase() + limitMatcher[2].slice(1), + actualValue = dimensions[dimensionPropName]; + + /*jshint -W015*/ + switch (limitMatcher[1]) { + case "min": + if (actualValue < limitValue) { + failingLimit = limitName; + return false; + } + break; + case "max": + if (actualValue > limitValue) { + failingLimit = limitName; + return false; + } + break; + } + } + }); + + return failingLimit; + } + + /** + * Validate the associated blob. + * + * @param limits + * @returns {qq.Promise} `success` is called on the promise is the image is valid or + * if the blob is not an image, or if the image is not verifiable. + * Otherwise, `failure` with the name of the failing limit. + */ + this.validate = function(limits) { + var validationEffort = new qq.Promise(); + + log("Attempting to validate image."); + + if (hasNonZeroLimits(limits)) { + getWidthHeight().then(function(dimensions) { + var failingLimit = getFailingLimit(limits, dimensions); + + if (failingLimit) { + validationEffort.failure(failingLimit); + } + else { + validationEffort.success(); + } + }, validationEffort.success); + } + else { + validationEffort.success(); + } + + return validationEffort; + }; +}; + +/* globals qq */ +/** + * Module used to control populating the initial list of files. + * + * @constructor + */ +qq.Session = function(spec) { + "use strict"; + + var options = { + endpoint: null, + params: {}, + customHeaders: {}, + cors: {}, + addFileRecord: function(sessionData) {}, + log: function(message, level) {} + }; + + qq.extend(options, spec, true); + + function isJsonResponseValid(response) { + if (qq.isArray(response)) { + return true; + } + + options.log("Session response is not an array.", "error"); + } + + function handleFileItems(fileItems, success, xhrOrXdr, promise) { + var someItemsIgnored = false; + + success = success && isJsonResponseValid(fileItems); + + if (success) { + qq.each(fileItems, function(idx, fileItem) { + /* jshint eqnull:true */ + if (fileItem.uuid == null) { + someItemsIgnored = true; + options.log(qq.format("Session response item {} did not include a valid UUID - ignoring.", idx), "error"); + } + else if (fileItem.name == null) { + someItemsIgnored = true; + options.log(qq.format("Session response item {} did not include a valid name - ignoring.", idx), "error"); + } + else { + try { + options.addFileRecord(fileItem); + return true; + } + catch (err) { + someItemsIgnored = true; + options.log(err.message, "error"); + } + } + + return false; + }); + } + + promise[success && !someItemsIgnored ? "success" : "failure"](fileItems, xhrOrXdr); + } + + // Initiate a call to the server that will be used to populate the initial file list. + // Returns a `qq.Promise`. + this.refresh = function() { + /*jshint indent:false */ + var refreshEffort = new qq.Promise(), + refreshCompleteCallback = function(response, success, xhrOrXdr) { + handleFileItems(response, success, xhrOrXdr, refreshEffort); + }, + requesterOptions = qq.extend({}, options), + requester = new qq.SessionAjaxRequester( + qq.extend(requesterOptions, {onComplete: refreshCompleteCallback}) + ); + + requester.queryServer(); + + return refreshEffort; + }; +}; + +/*globals qq, XMLHttpRequest*/ +/** + * Thin module used to send GET requests to the server, expecting information about session + * data used to initialize an uploader instance. + * + * @param spec Various options used to influence the associated request. + * @constructor + */ +qq.SessionAjaxRequester = function(spec) { + "use strict"; + + var requester, + options = { + endpoint: null, + customHeaders: {}, + params: {}, + cors: { + expected: false, + sendCredentials: false + }, + onComplete: function(response, success, xhrOrXdr) {}, + log: function(str, level) {} + }; + + qq.extend(options, spec); + + function onComplete(id, xhrOrXdr, isError) { + var response = null; + + /* jshint eqnull:true */ + if (xhrOrXdr.responseText != null) { + try { + response = qq.parseJson(xhrOrXdr.responseText); + } + catch (err) { + options.log("Problem parsing session response: " + err.message, "error"); + isError = true; + } + } + + options.onComplete(response, !isError, xhrOrXdr); + } + + requester = qq.extend(this, new qq.AjaxRequester({ + acceptHeader: "application/json", + validMethods: ["GET"], + method: "GET", + endpointStore: { + get: function() { + return options.endpoint; + } + }, + customHeaders: options.customHeaders, + log: options.log, + onComplete: onComplete, + cors: options.cors + })); + + qq.extend(this, { + queryServer: function() { + var params = qq.extend({}, options.params); + + options.log("Session query request."); + + requester.initTransport("sessionRefresh") + .withParams(params) + .withCacheBuster() + .send(); + } + }); +}; + +/* globals qq */ +/** + * Module that handles support for existing forms. + * + * @param options Options passed from the integrator-supplied options related to form support. + * @param startUpload Callback to invoke when files "stored" should be uploaded. + * @param log Proxy for the logger + * @constructor + */ +qq.FormSupport = function(options, startUpload, log) { + "use strict"; + var self = this, + interceptSubmit = options.interceptSubmit, + formEl = options.element, + autoUpload = options.autoUpload; + + // Available on the public API associated with this module. + qq.extend(this, { + // To be used by the caller to determine if the endpoint will be determined by some processing + // that occurs in this module, such as if the form has an action attribute. + // Ignore if `attachToForm === false`. + newEndpoint: null, + + // To be used by the caller to determine if auto uploading should be allowed. + // Ignore if `attachToForm === false`. + newAutoUpload: autoUpload, + + // true if a form was detected and is being tracked by this module + attachedToForm: false, + + // Returns an object with names and values for all valid form elements associated with the attached form. + getFormInputsAsObject: function() { + /* jshint eqnull:true */ + if (formEl == null) { + return null; + } + + return self._form2Obj(formEl); + } + }); + + // If the form contains an action attribute, this should be the new upload endpoint. + function determineNewEndpoint(formEl) { + if (formEl.getAttribute("action")) { + self.newEndpoint = formEl.getAttribute("action"); + } + } + + // Return true only if the form is valid, or if we cannot make this determination. + // If the form is invalid, ensure invalid field(s) are highlighted in the UI. + function validateForm(formEl, nativeSubmit) { + if (formEl.checkValidity && !formEl.checkValidity()) { + log("Form did not pass validation checks - will not upload.", "error"); + nativeSubmit(); + } + else { + return true; + } + } + + // Intercept form submit attempts, unless the integrator has told us not to do this. + function maybeUploadOnSubmit(formEl) { + var nativeSubmit = formEl.submit; + + // Intercept and squelch submit events. + qq(formEl).attach("submit", function(event) { + event = event || window.event; + + if (event.preventDefault) { + event.preventDefault(); + } + else { + event.returnValue = false; + } + + validateForm(formEl, nativeSubmit) && startUpload(); + }); + + // The form's `submit()` function may be called instead (i.e. via jQuery.submit()). + // Intercept that too. + formEl.submit = function() { + validateForm(formEl, nativeSubmit) && startUpload(); + }; + } + + // If the element value passed from the uploader is a string, assume it is an element ID - select it. + // The rest of the code in this module depends on this being an HTMLElement. + function determineFormEl(formEl) { + if (formEl) { + if (qq.isString(formEl)) { + formEl = document.getElementById(formEl); + } + + if (formEl) { + log("Attaching to form element."); + determineNewEndpoint(formEl); + interceptSubmit && maybeUploadOnSubmit(formEl); + } + } + + return formEl; + } + + formEl = determineFormEl(formEl); + this.attachedToForm = !!formEl; +}; + +qq.extend(qq.FormSupport.prototype, { + // Converts all relevant form fields to key/value pairs. This is meant to mimic the data a browser will + // construct from a given form when the form is submitted. + _form2Obj: function(form) { + "use strict"; + var obj = {}, + notIrrelevantType = function(type) { + var irrelevantTypes = [ + "button", + "image", + "reset", + "submit" + ]; + + return qq.indexOf(irrelevantTypes, type.toLowerCase()) < 0; + }, + radioOrCheckbox = function(type) { + return qq.indexOf(["checkbox", "radio"], type.toLowerCase()) >= 0; + }, + ignoreValue = function(el) { + if (radioOrCheckbox(el.type) && !el.checked) { + return true; + } + + return el.disabled && el.type.toLowerCase() !== "hidden"; + }, + selectValue = function(select) { + var value = null; + + qq.each(qq(select).children(), function(idx, child) { + if (child.tagName.toLowerCase() === "option" && child.selected) { + value = child.value; + return false; + } + }); + + return value; + }; + + qq.each(form.elements, function(idx, el) { + if ((qq.isInput(el, true) || el.tagName.toLowerCase() === "textarea") && + notIrrelevantType(el.type) && + !ignoreValue(el)) { + + obj[el.name] = el.value; + } + else if (el.tagName.toLowerCase() === "select" && !ignoreValue(el)) { + var value = selectValue(el); + + if (value !== null) { + obj[el.name] = value; + } + } + }); + + return obj; + } +}); + +/* globals qq, ExifRestorer */ +/** + * Controls generation of scaled images based on a reference image encapsulated in a `File` or `Blob`. + * Scaled images are generated and converted to blobs on-demand. + * Multiple scaled images per reference image with varying sizes and other properties are supported. + * + * @param spec Information about the scaled images to generate. + * @param log Logger instance + * @constructor + */ +qq.Scaler = function(spec, log) { + "use strict"; + + var self = this, + customResizeFunction = spec.customResizer, + includeOriginal = spec.sendOriginal, + orient = spec.orient, + defaultType = spec.defaultType, + defaultQuality = spec.defaultQuality / 100, + failedToScaleText = spec.failureText, + includeExif = spec.includeExif, + sizes = this._getSortedSizes(spec.sizes); + + // Revealed API for instances of this module + qq.extend(this, { + // If no targeted sizes have been declared or if this browser doesn't support + // client-side image preview generation, there is no scaling to do. + enabled: qq.supportedFeatures.scaling && sizes.length > 0, + + getFileRecords: function(originalFileUuid, originalFileName, originalBlobOrBlobData) { + var self = this, + records = [], + originalBlob = originalBlobOrBlobData.blob ? originalBlobOrBlobData.blob : originalBlobOrBlobData, + identifier = new qq.Identify(originalBlob, log); + + // If the reference file cannot be rendered natively, we can't create scaled versions. + if (identifier.isPreviewableSync()) { + // Create records for each scaled version & add them to the records array, smallest first. + qq.each(sizes, function(idx, sizeRecord) { + var outputType = self._determineOutputType({ + defaultType: defaultType, + requestedType: sizeRecord.type, + refType: originalBlob.type + }); + + records.push({ + uuid: qq.getUniqueId(), + name: self._getName(originalFileName, { + name: sizeRecord.name, + type: outputType, + refType: originalBlob.type + }), + blob: new qq.BlobProxy(originalBlob, + qq.bind(self._generateScaledImage, self, { + customResizeFunction: customResizeFunction, + maxSize: sizeRecord.maxSize, + orient: orient, + type: outputType, + quality: defaultQuality, + failedText: failedToScaleText, + includeExif: includeExif, + log: log + })) + }); + }); + + records.push({ + uuid: originalFileUuid, + name: originalFileName, + size: originalBlob.size, + blob: includeOriginal ? originalBlob : null + }); + } + else { + records.push({ + uuid: originalFileUuid, + name: originalFileName, + size: originalBlob.size, + blob: originalBlob + }); + } + + return records; + }, + + handleNewFile: function(file, name, uuid, size, fileList, batchId, uuidParamName, api) { + var self = this, + buttonId = file.qqButtonId || (file.blob && file.blob.qqButtonId), + scaledIds = [], + originalId = null, + addFileToHandler = api.addFileToHandler, + uploadData = api.uploadData, + paramsStore = api.paramsStore, + proxyGroupId = qq.getUniqueId(); + + qq.each(self.getFileRecords(uuid, name, file), function(idx, record) { + var blobSize = record.size, + id; + + if (record.blob instanceof qq.BlobProxy) { + blobSize = -1; + } + + id = uploadData.addFile({ + uuid: record.uuid, + name: record.name, + size: blobSize, + batchId: batchId, + proxyGroupId: proxyGroupId + }); + + if (record.blob instanceof qq.BlobProxy) { + scaledIds.push(id); + } + else { + originalId = id; + } + + if (record.blob) { + addFileToHandler(id, record.blob); + fileList.push({id: id, file: record.blob}); + } + else { + uploadData.setStatus(id, qq.status.REJECTED); + } + }); + + // If we are potentially uploading an original file and some scaled versions, + // ensure the scaled versions include reference's to the parent's UUID and size + // in their associated upload requests. + if (originalId !== null) { + qq.each(scaledIds, function(idx, scaledId) { + var params = { + qqparentuuid: uploadData.retrieve({id: originalId}).uuid, + qqparentsize: uploadData.retrieve({id: originalId}).size + }; + + // Make sure the UUID for each scaled image is sent with the upload request, + // to be consistent (since we may need to ensure it is sent for the original file as well). + params[uuidParamName] = uploadData.retrieve({id: scaledId}).uuid; + + uploadData.setParentId(scaledId, originalId); + paramsStore.addReadOnly(scaledId, params); + }); + + // If any scaled images are tied to this parent image, be SURE we send its UUID as an upload request + // parameter as well. + if (scaledIds.length) { + (function() { + var param = {}; + param[uuidParamName] = uploadData.retrieve({id: originalId}).uuid; + paramsStore.addReadOnly(originalId, param); + }()); + } + } + } + }); +}; + +qq.extend(qq.Scaler.prototype, { + scaleImage: function(id, specs, api) { + "use strict"; + + if (!qq.supportedFeatures.scaling) { + throw new qq.Error("Scaling is not supported in this browser!"); + } + + var scalingEffort = new qq.Promise(), + log = api.log, + file = api.getFile(id), + uploadData = api.uploadData.retrieve({id: id}), + name = uploadData && uploadData.name, + uuid = uploadData && uploadData.uuid, + scalingOptions = { + customResizer: specs.customResizer, + sendOriginal: false, + orient: specs.orient, + defaultType: specs.type || null, + defaultQuality: specs.quality, + failedToScaleText: "Unable to scale", + sizes: [{name: "", maxSize: specs.maxSize}] + }, + scaler = new qq.Scaler(scalingOptions, log); + + if (!qq.Scaler || !qq.supportedFeatures.imagePreviews || !file) { + scalingEffort.failure(); + + log("Could not generate requested scaled image for " + id + ". " + + "Scaling is either not possible in this browser, or the file could not be located.", "error"); + } + else { + (qq.bind(function() { + // Assumption: There will never be more than one record + var record = scaler.getFileRecords(uuid, name, file)[0]; + + if (record && record.blob instanceof qq.BlobProxy) { + record.blob.create().then(scalingEffort.success, scalingEffort.failure); + } + else { + log(id + " is not a scalable image!", "error"); + scalingEffort.failure(); + } + }, this)()); + } + + return scalingEffort; + }, + + // NOTE: We cannot reliably determine at this time if the UA supports a specific MIME type for the target format. + // image/jpeg and image/png are the only safe choices at this time. + _determineOutputType: function(spec) { + "use strict"; + + var requestedType = spec.requestedType, + defaultType = spec.defaultType, + referenceType = spec.refType; + + // If a default type and requested type have not been specified, this should be a + // JPEG if the original type is a JPEG, otherwise, a PNG. + if (!defaultType && !requestedType) { + if (referenceType !== "image/jpeg") { + return "image/png"; + } + return referenceType; + } + + // A specified default type is used when a requested type is not specified. + if (!requestedType) { + return defaultType; + } + + // If requested type is specified, use it, as long as this recognized type is supported by the current UA + if (qq.indexOf(Object.keys(qq.Identify.prototype.PREVIEWABLE_MIME_TYPES), requestedType) >= 0) { + if (requestedType === "image/tiff") { + return qq.supportedFeatures.tiffPreviews ? requestedType : defaultType; + } + + return requestedType; + } + + return defaultType; + }, + + // Get a file name for a generated scaled file record, based on the provided scaled image description + _getName: function(originalName, scaledVersionProperties) { + "use strict"; + + var startOfExt = originalName.lastIndexOf("."), + versionType = scaledVersionProperties.type || "image/png", + referenceType = scaledVersionProperties.refType, + scaledName = "", + scaledExt = qq.getExtension(originalName), + nameAppendage = ""; + + if (scaledVersionProperties.name && scaledVersionProperties.name.trim().length) { + nameAppendage = " (" + scaledVersionProperties.name + ")"; + } + + if (startOfExt >= 0) { + scaledName = originalName.substr(0, startOfExt); + + if (referenceType !== versionType) { + scaledExt = versionType.split("/")[1]; + } + + scaledName += nameAppendage + "." + scaledExt; + } + else { + scaledName = originalName + nameAppendage; + } + + return scaledName; + }, + + // We want the smallest scaled file to be uploaded first + _getSortedSizes: function(sizes) { + "use strict"; + + sizes = qq.extend([], sizes); + + return sizes.sort(function(a, b) { + if (a.maxSize > b.maxSize) { + return 1; + } + if (a.maxSize < b.maxSize) { + return -1; + } + return 0; + }); + }, + + _generateScaledImage: function(spec, sourceFile) { + "use strict"; + + var self = this, + customResizeFunction = spec.customResizeFunction, + log = spec.log, + maxSize = spec.maxSize, + orient = spec.orient, + type = spec.type, + quality = spec.quality, + failedText = spec.failedText, + includeExif = spec.includeExif && sourceFile.type === "image/jpeg" && type === "image/jpeg", + scalingEffort = new qq.Promise(), + imageGenerator = new qq.ImageGenerator(log), + canvas = document.createElement("canvas"); + + log("Attempting to generate scaled version for " + sourceFile.name); + + imageGenerator.generate(sourceFile, canvas, {maxSize: maxSize, orient: orient, customResizeFunction: customResizeFunction}).then(function() { + var scaledImageDataUri = canvas.toDataURL(type, quality), + signalSuccess = function() { + log("Success generating scaled version for " + sourceFile.name); + var blob = qq.dataUriToBlob(scaledImageDataUri); + scalingEffort.success(blob); + }; + + if (includeExif) { + self._insertExifHeader(sourceFile, scaledImageDataUri, log).then(function(scaledImageDataUriWithExif) { + scaledImageDataUri = scaledImageDataUriWithExif; + signalSuccess(); + }, + function() { + log("Problem inserting EXIF header into scaled image. Using scaled image w/out EXIF data.", "error"); + signalSuccess(); + }); + } + else { + signalSuccess(); + } + }, function() { + log("Failed attempt to generate scaled version for " + sourceFile.name, "error"); + scalingEffort.failure(failedText); + }); + + return scalingEffort; + }, + + // Attempt to insert the original image's EXIF header into a scaled version. + _insertExifHeader: function(originalImage, scaledImageDataUri, log) { + "use strict"; + + var reader = new FileReader(), + insertionEffort = new qq.Promise(), + originalImageDataUri = ""; + + reader.onload = function() { + originalImageDataUri = reader.result; + insertionEffort.success(qq.ExifRestorer.restore(originalImageDataUri, scaledImageDataUri)); + }; + + reader.onerror = function() { + log("Problem reading " + originalImage.name + " during attempt to transfer EXIF data to scaled version.", "error"); + insertionEffort.failure(); + }; + + reader.readAsDataURL(originalImage); + + return insertionEffort; + }, + + _dataUriToBlob: function(dataUri) { + "use strict"; + + var byteString, mimeString, arrayBuffer, intArray; + + // convert base64 to raw binary data held in a string + if (dataUri.split(",")[0].indexOf("base64") >= 0) { + byteString = atob(dataUri.split(",")[1]); + } + else { + byteString = decodeURI(dataUri.split(",")[1]); + } + + // extract the MIME + mimeString = dataUri.split(",")[0] + .split(":")[1] + .split(";")[0]; + + // write the bytes of the binary string to an ArrayBuffer + arrayBuffer = new ArrayBuffer(byteString.length); + intArray = new Uint8Array(arrayBuffer); + qq.each(byteString, function(idx, character) { + intArray[idx] = character.charCodeAt(0); + }); + + return this._createBlob(arrayBuffer, mimeString); + }, + + _createBlob: function(data, mime) { + "use strict"; + + var BlobBuilder = window.BlobBuilder || + window.WebKitBlobBuilder || + window.MozBlobBuilder || + window.MSBlobBuilder, + blobBuilder = BlobBuilder && new BlobBuilder(); + + if (blobBuilder) { + blobBuilder.append(data); + return blobBuilder.getBlob(mime); + } + else { + return new Blob([data], {type: mime}); + } + } +}); + +//Based on MinifyJpeg +//http://elicon.blog57.fc2.com/blog-entry-206.html + +qq.ExifRestorer = (function() +{ + + var ExifRestorer = {}; + + ExifRestorer.KEY_STR = "ABCDEFGHIJKLMNOP" + + "QRSTUVWXYZabcdef" + + "ghijklmnopqrstuv" + + "wxyz0123456789+/" + + "="; + + ExifRestorer.encode64 = function(input) + { + var output = "", + chr1, chr2, chr3 = "", + enc1, enc2, enc3, enc4 = "", + i = 0; + + do { + chr1 = input[i++]; + chr2 = input[i++]; + chr3 = input[i++]; + + enc1 = chr1 >> 2; + enc2 = ((chr1 & 3) << 4) | (chr2 >> 4); + enc3 = ((chr2 & 15) << 2) | (chr3 >> 6); + enc4 = chr3 & 63; + + if (isNaN(chr2)) { + enc3 = enc4 = 64; + } else if (isNaN(chr3)) { + enc4 = 64; + } + + output = output + + this.KEY_STR.charAt(enc1) + + this.KEY_STR.charAt(enc2) + + this.KEY_STR.charAt(enc3) + + this.KEY_STR.charAt(enc4); + chr1 = chr2 = chr3 = ""; + enc1 = enc2 = enc3 = enc4 = ""; + } while (i < input.length); + + return output; + }; + + ExifRestorer.restore = function(origFileBase64, resizedFileBase64) + { + var expectedBase64Header = "data:image/jpeg;base64,"; + + if (!origFileBase64.match(expectedBase64Header)) + { + return resizedFileBase64; + } + + var rawImage = this.decode64(origFileBase64.replace(expectedBase64Header, "")); + var segments = this.slice2Segments(rawImage); + + var image = this.exifManipulation(resizedFileBase64, segments); + + return expectedBase64Header + this.encode64(image); + + }; + + + ExifRestorer.exifManipulation = function(resizedFileBase64, segments) + { + var exifArray = this.getExifArray(segments), + newImageArray = this.insertExif(resizedFileBase64, exifArray), + aBuffer = new Uint8Array(newImageArray); + + return aBuffer; + }; + + + ExifRestorer.getExifArray = function(segments) + { + var seg; + for (var x = 0; x < segments.length; x++) + { + seg = segments[x]; + if (seg[0] == 255 & seg[1] == 225) //(ff e1) + { + return seg; + } + } + return []; + }; + + + ExifRestorer.insertExif = function(resizedFileBase64, exifArray) + { + var imageData = resizedFileBase64.replace("data:image/jpeg;base64,", ""), + buf = this.decode64(imageData), + separatePoint = buf.indexOf(255,3), + mae = buf.slice(0, separatePoint), + ato = buf.slice(separatePoint), + array = mae; + + array = array.concat(exifArray); + array = array.concat(ato); + return array; + }; + + + + ExifRestorer.slice2Segments = function(rawImageArray) + { + var head = 0, + segments = []; + + while (1) + { + if (rawImageArray[head] == 255 & rawImageArray[head + 1] == 218){break;} + if (rawImageArray[head] == 255 & rawImageArray[head + 1] == 216) + { + head += 2; + } + else + { + var length = rawImageArray[head + 2] * 256 + rawImageArray[head + 3], + endPoint = head + length + 2, + seg = rawImageArray.slice(head, endPoint); + segments.push(seg); + head = endPoint; + } + if (head > rawImageArray.length){break;} + } + + return segments; + }; + + + + ExifRestorer.decode64 = function(input) + { + var output = "", + chr1, chr2, chr3 = "", + enc1, enc2, enc3, enc4 = "", + i = 0, + buf = []; + + // remove all characters that are not A-Z, a-z, 0-9, +, /, or = + var base64test = /[^A-Za-z0-9\+\/\=]/g; + if (base64test.exec(input)) { + throw new Error("There were invalid base64 characters in the input text. " + + "Valid base64 characters are A-Z, a-z, 0-9, '+', '/',and '='"); + } + input = input.replace(/[^A-Za-z0-9\+\/\=]/g, ""); + + do { + enc1 = this.KEY_STR.indexOf(input.charAt(i++)); + enc2 = this.KEY_STR.indexOf(input.charAt(i++)); + enc3 = this.KEY_STR.indexOf(input.charAt(i++)); + enc4 = this.KEY_STR.indexOf(input.charAt(i++)); + + chr1 = (enc1 << 2) | (enc2 >> 4); + chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); + chr3 = ((enc3 & 3) << 6) | enc4; + + buf.push(chr1); + + if (enc3 != 64) { + buf.push(chr2); + } + if (enc4 != 64) { + buf.push(chr3); + } + + chr1 = chr2 = chr3 = ""; + enc1 = enc2 = enc3 = enc4 = ""; + + } while (i < input.length); + + return buf; + }; + + + return ExifRestorer; +})(); + +/* globals qq */ +/** + * Keeps a running tally of total upload progress for a batch of files. + * + * @param callback Invoked when total progress changes, passing calculated total loaded & total size values. + * @param getSize Function that returns the size of a file given its ID + * @constructor + */ +qq.TotalProgress = function(callback, getSize) { + "use strict"; + + var perFileProgress = {}, + totalLoaded = 0, + totalSize = 0, + + lastLoadedSent = -1, + lastTotalSent = -1, + callbackProxy = function(loaded, total) { + if (loaded !== lastLoadedSent || total !== lastTotalSent) { + callback(loaded, total); + } + + lastLoadedSent = loaded; + lastTotalSent = total; + }, + + /** + * @param failed Array of file IDs that have failed + * @param retryable Array of file IDs that are retryable + * @returns true if none of the failed files are eligible for retry + */ + noRetryableFiles = function(failed, retryable) { + var none = true; + + qq.each(failed, function(idx, failedId) { + if (qq.indexOf(retryable, failedId) >= 0) { + none = false; + return false; + } + }); + + return none; + }, + + onCancel = function(id) { + updateTotalProgress(id, -1, -1); + delete perFileProgress[id]; + }, + + onAllComplete = function(successful, failed, retryable) { + if (failed.length === 0 || noRetryableFiles(failed, retryable)) { + callbackProxy(totalSize, totalSize); + this.reset(); + } + }, + + onNew = function(id) { + var size = getSize(id); + + // We might not know the size yet, such as for blob proxies + if (size > 0) { + updateTotalProgress(id, 0, size); + perFileProgress[id] = {loaded: 0, total: size}; + } + }, + + /** + * Invokes the callback with the current total progress of all files in the batch. Called whenever it may + * be appropriate to re-calculate and disseminate this data. + * + * @param id ID of a file that has changed in some important way + * @param newLoaded New loaded value for this file. -1 if this value should no longer be part of calculations + * @param newTotal New total size of the file. -1 if this value should no longer be part of calculations + */ + updateTotalProgress = function(id, newLoaded, newTotal) { + var oldLoaded = perFileProgress[id] ? perFileProgress[id].loaded : 0, + oldTotal = perFileProgress[id] ? perFileProgress[id].total : 0; + + if (newLoaded === -1 && newTotal === -1) { + totalLoaded -= oldLoaded; + totalSize -= oldTotal; + } + else { + if (newLoaded) { + totalLoaded += newLoaded - oldLoaded; + } + if (newTotal) { + totalSize += newTotal - oldTotal; + } + } + + callbackProxy(totalLoaded, totalSize); + }; + + qq.extend(this, { + // Called when a batch of files has completed uploading. + onAllComplete: onAllComplete, + + // Called when the status of a file has changed. + onStatusChange: function(id, oldStatus, newStatus) { + if (newStatus === qq.status.CANCELED || newStatus === qq.status.REJECTED) { + onCancel(id); + } + else if (newStatus === qq.status.SUBMITTING) { + onNew(id); + } + }, + + // Called whenever the upload progress of an individual file has changed. + onIndividualProgress: function(id, loaded, total) { + updateTotalProgress(id, loaded, total); + perFileProgress[id] = {loaded: loaded, total: total}; + }, + + // Called whenever the total size of a file has changed, such as when the size of a generated blob is known. + onNewSize: function(id) { + onNew(id); + }, + + reset: function() { + perFileProgress = {}; + totalLoaded = 0; + totalSize = 0; + } + }); +}; + +/*globals qq */ +// Base handler for UI (FineUploader mode) events. +// Some more specific handlers inherit from this one. +qq.UiEventHandler = function(s, protectedApi) { + "use strict"; + + var disposer = new qq.DisposeSupport(), + spec = { + eventType: "click", + attachTo: null, + onHandled: function(target, event) {} + }; + + // This makes up the "public" API methods that will be accessible + // to instances constructing a base or child handler + qq.extend(this, { + addHandler: function(element) { + addHandler(element); + }, + + dispose: function() { + disposer.dispose(); + } + }); + + function addHandler(element) { + disposer.attach(element, spec.eventType, function(event) { + // Only in IE: the `event` is a property of the `window`. + event = event || window.event; + + // On older browsers, we must check the `srcElement` instead of the `target`. + var target = event.target || event.srcElement; + + spec.onHandled(target, event); + }); + } + + // These make up the "protected" API methods that children of this base handler will utilize. + qq.extend(protectedApi, { + getFileIdFromItem: function(item) { + return item.qqFileId; + }, + + getDisposeSupport: function() { + return disposer; + } + }); + + qq.extend(spec, s); + + if (spec.attachTo) { + addHandler(spec.attachTo); + } +}; + +/* global qq */ +qq.FileButtonsClickHandler = function(s) { + "use strict"; + + var inheritedInternalApi = {}, + spec = { + templating: null, + log: function(message, lvl) {}, + onDeleteFile: function(fileId) {}, + onCancel: function(fileId) {}, + onRetry: function(fileId) {}, + onPause: function(fileId) {}, + onContinue: function(fileId) {}, + onGetName: function(fileId) {} + }, + buttonHandlers = { + cancel: function(id) { spec.onCancel(id); }, + retry: function(id) { spec.onRetry(id); }, + deleteButton: function(id) { spec.onDeleteFile(id); }, + pause: function(id) { spec.onPause(id); }, + continueButton: function(id) { spec.onContinue(id); } + }; + + function examineEvent(target, event) { + qq.each(buttonHandlers, function(buttonType, handler) { + var firstLetterCapButtonType = buttonType.charAt(0).toUpperCase() + buttonType.slice(1), + fileId; + + if (spec.templating["is" + firstLetterCapButtonType](target)) { + fileId = spec.templating.getFileId(target); + qq.preventDefault(event); + spec.log(qq.format("Detected valid file button click event on file '{}', ID: {}.", spec.onGetName(fileId), fileId)); + handler(fileId); + return false; + } + }); + } + + qq.extend(spec, s); + + spec.eventType = "click"; + spec.onHandled = examineEvent; + spec.attachTo = spec.templating.getFileList(); + + qq.extend(this, new qq.UiEventHandler(spec, inheritedInternalApi)); +}; + +/*globals qq */ +// Child of FilenameEditHandler. Used to detect click events on filename display elements. +qq.FilenameClickHandler = function(s) { + "use strict"; + + var inheritedInternalApi = {}, + spec = { + templating: null, + log: function(message, lvl) {}, + classes: { + file: "qq-upload-file", + editNameIcon: "qq-edit-filename-icon" + }, + onGetUploadStatus: function(fileId) {}, + onGetName: function(fileId) {} + }; + + qq.extend(spec, s); + + // This will be called by the parent handler when a `click` event is received on the list element. + function examineEvent(target, event) { + if (spec.templating.isFileName(target) || spec.templating.isEditIcon(target)) { + var fileId = spec.templating.getFileId(target), + status = spec.onGetUploadStatus(fileId); + + // We only allow users to change filenames of files that have been submitted but not yet uploaded. + if (status === qq.status.SUBMITTED) { + spec.log(qq.format("Detected valid filename click event on file '{}', ID: {}.", spec.onGetName(fileId), fileId)); + qq.preventDefault(event); + + inheritedInternalApi.handleFilenameEdit(fileId, target, true); + } + } + } + + spec.eventType = "click"; + spec.onHandled = examineEvent; + + qq.extend(this, new qq.FilenameEditHandler(spec, inheritedInternalApi)); +}; + +/*globals qq */ +// Child of FilenameEditHandler. Used to detect focusin events on file edit input elements. +qq.FilenameInputFocusInHandler = function(s, inheritedInternalApi) { + "use strict"; + + var spec = { + templating: null, + onGetUploadStatus: function(fileId) {}, + log: function(message, lvl) {} + }; + + if (!inheritedInternalApi) { + inheritedInternalApi = {}; + } + + // This will be called by the parent handler when a `focusin` event is received on the list element. + function handleInputFocus(target, event) { + if (spec.templating.isEditInput(target)) { + var fileId = spec.templating.getFileId(target), + status = spec.onGetUploadStatus(fileId); + + if (status === qq.status.SUBMITTED) { + spec.log(qq.format("Detected valid filename input focus event on file '{}', ID: {}.", spec.onGetName(fileId), fileId)); + inheritedInternalApi.handleFilenameEdit(fileId, target); + } + } + } + + spec.eventType = "focusin"; + spec.onHandled = handleInputFocus; + + qq.extend(spec, s); + qq.extend(this, new qq.FilenameEditHandler(spec, inheritedInternalApi)); +}; + +/*globals qq */ +/** + * Child of FilenameInputFocusInHandler. Used to detect focus events on file edit input elements. This child module is only + * needed for UAs that do not support the focusin event. Currently, only Firefox lacks this event. + * + * @param spec Overrides for default specifications + */ +qq.FilenameInputFocusHandler = function(spec) { + "use strict"; + + spec.eventType = "focus"; + spec.attachTo = null; + + qq.extend(this, new qq.FilenameInputFocusInHandler(spec, {})); +}; + +/*globals qq */ +// Handles edit-related events on a file item (FineUploader mode). This is meant to be a parent handler. +// Children will delegate to this handler when specific edit-related actions are detected. +qq.FilenameEditHandler = function(s, inheritedInternalApi) { + "use strict"; + + var spec = { + templating: null, + log: function(message, lvl) {}, + onGetUploadStatus: function(fileId) {}, + onGetName: function(fileId) {}, + onSetName: function(fileId, newName) {}, + onEditingStatusChange: function(fileId, isEditing) {} + }; + + function getFilenameSansExtension(fileId) { + var filenameSansExt = spec.onGetName(fileId), + extIdx = filenameSansExt.lastIndexOf("."); + + if (extIdx > 0) { + filenameSansExt = filenameSansExt.substr(0, extIdx); + } + + return filenameSansExt; + } + + function getOriginalExtension(fileId) { + var origName = spec.onGetName(fileId); + return qq.getExtension(origName); + } + + // Callback iff the name has been changed + function handleNameUpdate(newFilenameInputEl, fileId) { + var newName = newFilenameInputEl.value, + origExtension; + + if (newName !== undefined && qq.trimStr(newName).length > 0) { + origExtension = getOriginalExtension(fileId); + + if (origExtension !== undefined) { + newName = newName + "." + origExtension; + } + + spec.onSetName(fileId, newName); + } + + spec.onEditingStatusChange(fileId, false); + } + + // The name has been updated if the filename edit input loses focus. + function registerInputBlurHandler(inputEl, fileId) { + inheritedInternalApi.getDisposeSupport().attach(inputEl, "blur", function() { + handleNameUpdate(inputEl, fileId); + }); + } + + // The name has been updated if the user presses enter. + function registerInputEnterKeyHandler(inputEl, fileId) { + inheritedInternalApi.getDisposeSupport().attach(inputEl, "keyup", function(event) { + + var code = event.keyCode || event.which; + + if (code === 13) { + handleNameUpdate(inputEl, fileId); + } + }); + } + + qq.extend(spec, s); + + spec.attachTo = spec.templating.getFileList(); + + qq.extend(this, new qq.UiEventHandler(spec, inheritedInternalApi)); + + qq.extend(inheritedInternalApi, { + handleFilenameEdit: function(id, target, focusInput) { + var newFilenameInputEl = spec.templating.getEditInput(id); + + spec.onEditingStatusChange(id, true); + + newFilenameInputEl.value = getFilenameSansExtension(id); + + if (focusInput) { + newFilenameInputEl.focus(); + } + + registerInputBlurHandler(newFilenameInputEl, id); + registerInputEnterKeyHandler(newFilenameInputEl, id); + } + }); +}; +if (typeof define === 'function' && define.amd) { + define(function() { + return qq; + }); +} +else if (typeof module !== 'undefined' && module.exports) { + module.exports = qq; +} +else { + global.qq = qq; +} +}(window)); + +/*! 2016-06-16 */ diff --git a/public/loading.gif b/public/loading.gif new file mode 100644 index 0000000..6fba776 Binary files /dev/null and b/public/loading.gif differ diff --git a/public/upload.html b/public/upload.html new file mode 100644 index 0000000..a6876ac --- /dev/null +++ b/public/upload.html @@ -0,0 +1,131 @@ + + + + + Upload + + + + + + + + + + + + + + Derzeitige Dateien/Current files: + + Verzeichnis/Directory: , + token: , token OK: + + + + diff --git a/upload/.keep b/upload/.keep new file mode 100644 index 0000000..e69de29 diff --git a/views/index.haml b/views/index.haml new file mode 100644 index 0000000..0c07217 --- /dev/null +++ b/views/index.haml @@ -0,0 +1,22 @@ +!!! +%head + %title Upload files/Dateien hochladen + %meta{ :charset => "utf-8" } +%body + %form{ :action => 'mkdir', :method => 'post' } + Directory/Verzeichnis: + %input{ :name => 'dirname', :id => 'dirname' } + Token: + %input{ :name => 'token', :id => 'token' } + %input{ :type => 'submit', :value => "Verzeichnis auswählen/Select directory" } + %p + %strong + Eine kleine Anleitung: + Einfach einen Ordnernamen und ein dazugehöriges 'token' ausdenken (das ist + eine Art Passwort, welches nur dazu dient den Upload nach Abbruch fortzusetzen und gleichzeitig + eine mehrfache Benutzung eines Ordners zu verhindern). Wenn alles gut geht gelangt ihr auf eine sehr schlichte + Seite auf der man die Dateien zum Hochladen auswälen kann. Dabei bitte beachten, dass gleich benannte Dateien überschrieben werden (also besser mehrere Ordner anlegen)! + %br + %br + Bei Fragen bitte an Patrick <p@simianer.de> wenden. + diff --git a/views/index.slim b/views/index.slim deleted file mode 100644 index 270f91f..0000000 --- a/views/index.slim +++ /dev/null @@ -1,15 +0,0 @@ -h1 Sinatra File Upload - -- if @flash - = @flash - -form action='/upload' method='post' enctype='multipart/form-data' - p - input type='file' name='file' - p - input type='submit' value='Upload' -h3 Files -ul - - @files.each do |file| - li - a href='files/#{file}' #{file} diff --git a/views/layout.slim b/views/layout.slim deleted file mode 100644 index 0d4d1f1..0000000 --- a/views/layout.slim +++ /dev/null @@ -1,8 +0,0 @@ -doctype -html xmlns='http://www.w3.org/1999/xhtml' - head - title Sinatra File Upload - link rel='stylesheet' type='text/css' href='css/style.css' - meta content='text/html; charset=utf-8' http-equiv='Content-Type' - body - == yield -- cgit v1.2.3
1 && !options.allowMultipleItems) { + options.callbacks.processingDroppedFilesComplete([]); + options.callbacks.dropError("tooManyFilesError", ""); + uploadDropZone.dropDisabled(false); + handleDataTransferPromise.failure(); + } + else { + droppedFiles = []; + + if (qq.isFolderDropSupported(dataTransfer)) { + qq.each(dataTransfer.items, function(idx, item) { + var entry = item.webkitGetAsEntry(); + + if (entry) { + //due to a bug in Chrome's File System API impl - #149735 + if (entry.isFile) { + droppedFiles.push(item.getAsFile()); + } + + else { + pendingFolderPromises.push(traverseFileTree(entry).done(function() { + pendingFolderPromises.pop(); + if (pendingFolderPromises.length === 0) { + handleDataTransferPromise.success(); + } + })); + } + } + }); + } + else { + droppedFiles = dataTransfer.files; + } + + if (pendingFolderPromises.length === 0) { + handleDataTransferPromise.success(); + } + } + + return handleDataTransferPromise; + } + + function setupDropzone(dropArea) { + var dropZone = new qq.UploadDropZone({ + HIDE_ZONES_EVENT_NAME: HIDE_ZONES_EVENT_NAME, + element: dropArea, + onEnter: function(e) { + qq(dropArea).addClass(options.classes.dropActive); + e.stopPropagation(); + }, + onLeaveNotDescendants: function(e) { + qq(dropArea).removeClass(options.classes.dropActive); + }, + onDrop: function(e) { + handleDataTransfer(e.dataTransfer, dropZone).then( + function() { + uploadDroppedFiles(droppedFiles, dropZone); + }, + function() { + options.callbacks.dropLog("Drop event DataTransfer parsing failed. No files will be uploaded.", "error"); + } + ); + } + }); + + disposeSupport.addDisposer(function() { + dropZone.dispose(); + }); + + qq(dropArea).hasAttribute(HIDE_BEFORE_ENTER_ATTR) && qq(dropArea).hide(); + + uploadDropZones.push(dropZone); + + return dropZone; + } + + function isFileDrag(dragEvent) { + var fileDrag; + + qq.each(dragEvent.dataTransfer.types, function(key, val) { + if (val === "Files") { + fileDrag = true; + return false; + } + }); + + return fileDrag; + } + + // Attempt to determine when the file has left the document. It is not always possible to detect this + // in all cases, but it is generally possible in all browsers, with a few exceptions. + // + // Exceptions: + // * IE10+ & Safari: We can't detect a file leaving the document if the Explorer window housing the file + // overlays the browser window. + // * IE10+: If the file is dragged out of the window too quickly, IE does not set the expected values of the + // event's X & Y properties. + function leavingDocumentOut(e) { + if (qq.firefox()) { + return !e.relatedTarget; + } + + if (qq.safari()) { + return e.x < 0 || e.y < 0; + } + + return e.x === 0 && e.y === 0; + } + + function setupDragDrop() { + var dropZones = options.dropZoneElements, + + maybeHideDropZones = function() { + setTimeout(function() { + qq.each(dropZones, function(idx, dropZone) { + qq(dropZone).hasAttribute(HIDE_BEFORE_ENTER_ATTR) && qq(dropZone).hide(); + qq(dropZone).removeClass(options.classes.dropActive); + }); + }, 10); + }; + + qq.each(dropZones, function(idx, dropZone) { + var uploadDropZone = setupDropzone(dropZone); + + // IE <= 9 does not support the File API used for drag+drop uploads + if (dropZones.length && qq.supportedFeatures.fileDrop) { + disposeSupport.attach(document, "dragenter", function(e) { + if (!uploadDropZone.dropDisabled() && isFileDrag(e)) { + qq.each(dropZones, function(idx, dropZone) { + // We can't apply styles to non-HTMLElements, since they lack the `style` property. + // Also, if the drop zone isn't initially hidden, let's not mess with `style.display`. + if (dropZone instanceof HTMLElement && + qq(dropZone).hasAttribute(HIDE_BEFORE_ENTER_ATTR)) { + + qq(dropZone).css({display: "block"}); + } + }); + } + }); + } + }); + + disposeSupport.attach(document, "dragleave", function(e) { + if (leavingDocumentOut(e)) { + maybeHideDropZones(); + } + }); + + // Just in case we were not able to detect when a dragged file has left the document, + // hide all relevant drop zones the next time the mouse enters the document. + // Note that mouse events such as this one are not fired during drag operations. + disposeSupport.attach(qq(document).children()[0], "mouseenter", function(e) { + maybeHideDropZones(); + }); + + disposeSupport.attach(document, "drop", function(e) { + e.preventDefault(); + maybeHideDropZones(); + }); + + disposeSupport.attach(document, HIDE_ZONES_EVENT_NAME, maybeHideDropZones); + } + + setupDragDrop(); + + qq.extend(this, { + setupExtraDropzone: function(element) { + options.dropZoneElements.push(element); + setupDropzone(element); + }, + + removeDropzone: function(element) { + var i, + dzs = options.dropZoneElements; + + for (i in dzs) { + if (dzs[i] === element) { + return dzs.splice(i, 1); + } + } + }, + + dispose: function() { + disposeSupport.dispose(); + qq.each(uploadDropZones, function(idx, dropZone) { + dropZone.dispose(); + }); + } + }); +}; + +qq.DragAndDrop.callbacks = function() { + "use strict"; + + return { + processingDroppedFiles: function() {}, + processingDroppedFilesComplete: function(files, targetEl) {}, + dropError: function(code, errorSpecifics) { + qq.log("Drag & drop error code '" + code + " with these specifics: '" + errorSpecifics + "'", "error"); + }, + dropLog: function(message, level) { + qq.log(message, level); + } + }; +}; + +qq.UploadDropZone = function(o) { + "use strict"; + + var disposeSupport = new qq.DisposeSupport(), + options, element, preventDrop, dropOutsideDisabled; + + options = { + element: null, + onEnter: function(e) {}, + onLeave: function(e) {}, + // is not fired when leaving element by hovering descendants + onLeaveNotDescendants: function(e) {}, + onDrop: function(e) {} + }; + + qq.extend(options, o); + element = options.element; + + function dragoverShouldBeCanceled() { + return qq.safari() || (qq.firefox() && qq.windows()); + } + + function disableDropOutside(e) { + // run only once for all instances + if (!dropOutsideDisabled) { + + // for these cases we need to catch onDrop to reset dropArea + if (dragoverShouldBeCanceled) { + disposeSupport.attach(document, "dragover", function(e) { + e.preventDefault(); + }); + } else { + disposeSupport.attach(document, "dragover", function(e) { + if (e.dataTransfer) { + e.dataTransfer.dropEffect = "none"; + e.preventDefault(); + } + }); + } + + dropOutsideDisabled = true; + } + } + + function isValidFileDrag(e) { + // e.dataTransfer currently causing IE errors + // IE9 does NOT support file API, so drag-and-drop is not possible + if (!qq.supportedFeatures.fileDrop) { + return false; + } + + var effectTest, dt = e.dataTransfer, + // do not check dt.types.contains in webkit, because it crashes safari 4 + isSafari = qq.safari(); + + // dt.effectAllowed is none in Safari 5 + // dt.types.contains check is for firefox + + // dt.effectAllowed crashes IE 11 & 10 when files have been dragged from + // the filesystem + effectTest = qq.ie() && qq.supportedFeatures.fileDrop ? true : dt.effectAllowed !== "none"; + return dt && effectTest && (dt.files || (!isSafari && dt.types.contains && dt.types.contains("Files"))); + } + + function isOrSetDropDisabled(isDisabled) { + if (isDisabled !== undefined) { + preventDrop = isDisabled; + } + return preventDrop; + } + + function triggerHidezonesEvent() { + var hideZonesEvent; + + function triggerUsingOldApi() { + hideZonesEvent = document.createEvent("Event"); + hideZonesEvent.initEvent(options.HIDE_ZONES_EVENT_NAME, true, true); + } + + if (window.CustomEvent) { + try { + hideZonesEvent = new CustomEvent(options.HIDE_ZONES_EVENT_NAME); + } + catch (err) { + triggerUsingOldApi(); + } + } + else { + triggerUsingOldApi(); + } + + document.dispatchEvent(hideZonesEvent); + } + + function attachEvents() { + disposeSupport.attach(element, "dragover", function(e) { + if (!isValidFileDrag(e)) { + return; + } + + // dt.effectAllowed crashes IE 11 & 10 when files have been dragged from + // the filesystem + var effect = qq.ie() && qq.supportedFeatures.fileDrop ? null : e.dataTransfer.effectAllowed; + if (effect === "move" || effect === "linkMove") { + e.dataTransfer.dropEffect = "move"; // for FF (only move allowed) + } else { + e.dataTransfer.dropEffect = "copy"; // for Chrome + } + + e.stopPropagation(); + e.preventDefault(); + }); + + disposeSupport.attach(element, "dragenter", function(e) { + if (!isOrSetDropDisabled()) { + if (!isValidFileDrag(e)) { + return; + } + options.onEnter(e); + } + }); + + disposeSupport.attach(element, "dragleave", function(e) { + if (!isValidFileDrag(e)) { + return; + } + + options.onLeave(e); + + var relatedTarget = document.elementFromPoint(e.clientX, e.clientY); + // do not fire when moving a mouse over a descendant + if (qq(this).contains(relatedTarget)) { + return; + } + + options.onLeaveNotDescendants(e); + }); + + disposeSupport.attach(element, "drop", function(e) { + if (!isOrSetDropDisabled()) { + if (!isValidFileDrag(e)) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + options.onDrop(e); + + triggerHidezonesEvent(); + } + }); + } + + disableDropOutside(); + attachEvents(); + + qq.extend(this, { + dropDisabled: function(isDisabled) { + return isOrSetDropDisabled(isDisabled); + }, + + dispose: function() { + disposeSupport.dispose(); + }, + + getElement: function() { + return element; + } + }); +}; + +/*globals qq, XMLHttpRequest*/ +qq.DeleteFileAjaxRequester = function(o) { + "use strict"; + + var requester, + options = { + method: "DELETE", + uuidParamName: "qquuid", + endpointStore: {}, + maxConnections: 3, + customHeaders: function(id) {return {};}, + paramsStore: {}, + cors: { + expected: false, + sendCredentials: false + }, + log: function(str, level) {}, + onDelete: function(id) {}, + onDeleteComplete: function(id, xhrOrXdr, isError) {} + }; + + qq.extend(options, o); + + function getMandatedParams() { + if (options.method.toUpperCase() === "POST") { + return { + _method: "DELETE" + }; + } + + return {}; + } + + requester = qq.extend(this, new qq.AjaxRequester({ + acceptHeader: "application/json", + validMethods: ["POST", "DELETE"], + method: options.method, + endpointStore: options.endpointStore, + paramsStore: options.paramsStore, + mandatedParams: getMandatedParams(), + maxConnections: options.maxConnections, + customHeaders: function(id) { + return options.customHeaders.get(id); + }, + log: options.log, + onSend: options.onDelete, + onComplete: options.onDeleteComplete, + cors: options.cors + })); + + qq.extend(this, { + sendDelete: function(id, uuid, additionalMandatedParams) { + var additionalOptions = additionalMandatedParams || {}; + + options.log("Submitting delete file request for " + id); + + if (options.method === "DELETE") { + requester.initTransport(id) + .withPath(uuid) + .withParams(additionalOptions) + .send(); + } + else { + additionalOptions[options.uuidParamName] = uuid; + requester.initTransport(id) + .withParams(additionalOptions) + .send(); + } + } + }); +}; + +/*global qq, define */ +/*jshint strict:false,bitwise:false,nonew:false,asi:true,-W064,-W116,-W089 */ +/** + * Mega pixel image rendering library for iOS6+ + * + * Fixes iOS6+'s image file rendering issue for large size image (over mega-pixel), + * which causes unexpected subsampling when drawing it in canvas. + * By using this library, you can safely render the image with proper stretching. + * + * Copyright (c) 2012 Shinichi Tomita + * Released under the MIT license + * + * Heavily modified by Widen for Fine Uploader + */ +(function() { + + /** + * Detect subsampling in loaded image. + * In iOS, larger images than 2M pixels may be subsampled in rendering. + */ + function detectSubsampling(img) { + var iw = img.naturalWidth, + ih = img.naturalHeight, + canvas = document.createElement("canvas"), + ctx; + + if (iw * ih > 1024 * 1024) { // subsampling may happen over megapixel image + canvas.width = canvas.height = 1; + ctx = canvas.getContext("2d"); + ctx.drawImage(img, -iw + 1, 0); + // subsampled image becomes half smaller in rendering size. + // check alpha channel value to confirm image is covering edge pixel or not. + // if alpha value is 0 image is not covering, hence subsampled. + return ctx.getImageData(0, 0, 1, 1).data[3] === 0; + } else { + return false; + } + } + + /** + * Detecting vertical squash in loaded image. + * Fixes a bug which squash image vertically while drawing into canvas for some images. + */ + function detectVerticalSquash(img, iw, ih) { + var canvas = document.createElement("canvas"), + sy = 0, + ey = ih, + py = ih, + ctx, data, alpha, ratio; + + canvas.width = 1; + canvas.height = ih; + ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0); + data = ctx.getImageData(0, 0, 1, ih).data; + + // search image edge pixel position in case it is squashed vertically. + while (py > sy) { + alpha = data[(py - 1) * 4 + 3]; + if (alpha === 0) { + ey = py; + } else { + sy = py; + } + py = (ey + sy) >> 1; + } + + ratio = (py / ih); + return (ratio === 0) ? 1 : ratio; + } + + /** + * Rendering image element (with resizing) and get its data URL + */ + function renderImageToDataURL(img, blob, options, doSquash) { + var canvas = document.createElement("canvas"), + mime = options.mime || "image/jpeg", + promise = new qq.Promise(); + + renderImageToCanvas(img, blob, canvas, options, doSquash) + .then(function() { + promise.success( + canvas.toDataURL(mime, options.quality || 0.8) + ); + }) + + return promise; + } + + function maybeCalculateDownsampledDimensions(spec) { + var maxPixels = 5241000; //iOS specific value + + if (!qq.ios()) { + throw new qq.Error("Downsampled dimensions can only be reliably calculated for iOS!"); + } + + if (spec.origHeight * spec.origWidth > maxPixels) { + return { + newHeight: Math.round(Math.sqrt(maxPixels * (spec.origHeight / spec.origWidth))), + newWidth: Math.round(Math.sqrt(maxPixels * (spec.origWidth / spec.origHeight))) + } + } + } + + /** + * Rendering image element (with resizing) into the canvas element + */ + function renderImageToCanvas(img, blob, canvas, options, doSquash) { + var iw = img.naturalWidth, + ih = img.naturalHeight, + width = options.width, + height = options.height, + ctx = canvas.getContext("2d"), + promise = new qq.Promise(), + modifiedDimensions; + + ctx.save(); + + if (options.resize) { + return renderImageToCanvasWithCustomResizer({ + blob: blob, + canvas: canvas, + image: img, + imageHeight: ih, + imageWidth: iw, + orientation: options.orientation, + resize: options.resize, + targetHeight: height, + targetWidth: width + }) + } + + if (!qq.supportedFeatures.unlimitedScaledImageSize) { + modifiedDimensions = maybeCalculateDownsampledDimensions({ + origWidth: width, + origHeight: height + }); + + if (modifiedDimensions) { + qq.log(qq.format("Had to reduce dimensions due to device limitations from {}w / {}h to {}w / {}h", + width, height, modifiedDimensions.newWidth, modifiedDimensions.newHeight), + "warn"); + + width = modifiedDimensions.newWidth; + height = modifiedDimensions.newHeight; + } + } + + transformCoordinate(canvas, width, height, options.orientation); + + // Fine Uploader specific: Save some CPU cycles if not using iOS + // Assumption: This logic is only needed to overcome iOS image sampling issues + if (qq.ios()) { + (function() { + if (detectSubsampling(img)) { + iw /= 2; + ih /= 2; + } + + var d = 1024, // size of tiling canvas + tmpCanvas = document.createElement("canvas"), + vertSquashRatio = doSquash ? detectVerticalSquash(img, iw, ih) : 1, + dw = Math.ceil(d * width / iw), + dh = Math.ceil(d * height / ih / vertSquashRatio), + sy = 0, + dy = 0, + tmpCtx, sx, dx; + + tmpCanvas.width = tmpCanvas.height = d; + tmpCtx = tmpCanvas.getContext("2d"); + + while (sy < ih) { + sx = 0; + dx = 0; + while (sx < iw) { + tmpCtx.clearRect(0, 0, d, d); + tmpCtx.drawImage(img, -sx, -sy); + ctx.drawImage(tmpCanvas, 0, 0, d, d, dx, dy, dw, dh); + sx += d; + dx += dw; + } + sy += d; + dy += dh; + } + ctx.restore(); + tmpCanvas = tmpCtx = null; + }()) + } + else { + ctx.drawImage(img, 0, 0, width, height); + } + + canvas.qqImageRendered && canvas.qqImageRendered(); + promise.success(); + + return promise; + } + + function renderImageToCanvasWithCustomResizer(resizeInfo) { + var blob = resizeInfo.blob, + image = resizeInfo.image, + imageHeight = resizeInfo.imageHeight, + imageWidth = resizeInfo.imageWidth, + orientation = resizeInfo.orientation, + promise = new qq.Promise(), + resize = resizeInfo.resize, + sourceCanvas = document.createElement("canvas"), + sourceCanvasContext = sourceCanvas.getContext("2d"), + targetCanvas = resizeInfo.canvas, + targetHeight = resizeInfo.targetHeight, + targetWidth = resizeInfo.targetWidth; + + transformCoordinate(sourceCanvas, imageWidth, imageHeight, orientation); + + targetCanvas.height = targetHeight; + targetCanvas.width = targetWidth; + + sourceCanvasContext.drawImage(image, 0, 0); + + resize({ + blob: blob, + height: targetHeight, + image: image, + sourceCanvas: sourceCanvas, + targetCanvas: targetCanvas, + width: targetWidth + }) + .then( + function success() { + targetCanvas.qqImageRendered && targetCanvas.qqImageRendered(); + promise.success(); + }, + promise.failure + ) + + return promise; + } + + /** + * Transform canvas coordination according to specified frame size and orientation + * Orientation value is from EXIF tag + */ + function transformCoordinate(canvas, width, height, orientation) { + switch (orientation) { + case 5: + case 6: + case 7: + case 8: + canvas.width = height; + canvas.height = width; + break; + default: + canvas.width = width; + canvas.height = height; + } + var ctx = canvas.getContext("2d"); + switch (orientation) { + case 2: + // horizontal flip + ctx.translate(width, 0); + ctx.scale(-1, 1); + break; + case 3: + // 180 rotate left + ctx.translate(width, height); + ctx.rotate(Math.PI); + break; + case 4: + // vertical flip + ctx.translate(0, height); + ctx.scale(1, -1); + break; + case 5: + // vertical flip + 90 rotate right + ctx.rotate(0.5 * Math.PI); + ctx.scale(1, -1); + break; + case 6: + // 90 rotate right + ctx.rotate(0.5 * Math.PI); + ctx.translate(0, -height); + break; + case 7: + // horizontal flip + 90 rotate right + ctx.rotate(0.5 * Math.PI); + ctx.translate(width, -height); + ctx.scale(-1, 1); + break; + case 8: + // 90 rotate left + ctx.rotate(-0.5 * Math.PI); + ctx.translate(-width, 0); + break; + default: + break; + } + } + + /** + * MegaPixImage class + */ + function MegaPixImage(srcImage, errorCallback) { + var self = this; + + if (window.Blob && srcImage instanceof Blob) { + (function() { + var img = new Image(), + URL = window.URL && window.URL.createObjectURL ? window.URL : + window.webkitURL && window.webkitURL.createObjectURL ? window.webkitURL : null; + if (!URL) { throw Error("No createObjectURL function found to create blob url"); } + img.src = URL.createObjectURL(srcImage); + self.blob = srcImage; + srcImage = img; + }()); + } + if (!srcImage.naturalWidth && !srcImage.naturalHeight) { + srcImage.onload = function() { + var listeners = self.imageLoadListeners; + if (listeners) { + self.imageLoadListeners = null; + // IE11 doesn't reliably report actual image dimensions immediately after onload for small files, + // so let's push this to the end of the UI thread queue. + setTimeout(function() { + for (var i = 0, len = listeners.length; i < len; i++) { + listeners[i](); + } + }, 0); + } + }; + srcImage.onerror = errorCallback; + this.imageLoadListeners = []; + } + this.srcImage = srcImage; + } + + /** + * Rendering megapix image into specified target element + */ + MegaPixImage.prototype.render = function(target, options) { + options = options || {}; + + var self = this, + imgWidth = this.srcImage.naturalWidth, + imgHeight = this.srcImage.naturalHeight, + width = options.width, + height = options.height, + maxWidth = options.maxWidth, + maxHeight = options.maxHeight, + doSquash = !this.blob || this.blob.type === "image/jpeg", + tagName = target.tagName.toLowerCase(), + opt; + + if (this.imageLoadListeners) { + this.imageLoadListeners.push(function() { self.render(target, options) }); + return; + } + + if (width && !height) { + height = (imgHeight * width / imgWidth) << 0; + } else if (height && !width) { + width = (imgWidth * height / imgHeight) << 0; + } else { + width = imgWidth; + height = imgHeight; + } + if (maxWidth && width > maxWidth) { + width = maxWidth; + height = (imgHeight * width / imgWidth) << 0; + } + if (maxHeight && height > maxHeight) { + height = maxHeight; + width = (imgWidth * height / imgHeight) << 0; + } + + opt = { width: width, height: height }, + qq.each(options, function(optionsKey, optionsValue) { + opt[optionsKey] = optionsValue; + }); + + if (tagName === "img") { + (function() { + var oldTargetSrc = target.src; + renderImageToDataURL(self.srcImage, self.blob, opt, doSquash) + .then(function(dataUri) { + target.src = dataUri; + oldTargetSrc === target.src && target.onload(); + }); + }()) + } else if (tagName === "canvas") { + renderImageToCanvas(this.srcImage, this.blob, target, opt, doSquash); + } + if (typeof this.onrender === "function") { + this.onrender(target); + } + }; + + qq.MegaPixImage = MegaPixImage; +})(); + +/*globals qq */ +/** + * Draws a thumbnail of a Blob/File/URL onto an or . + * + * @constructor + */ +qq.ImageGenerator = function(log) { + "use strict"; + + function isImg(el) { + return el.tagName.toLowerCase() === "img"; + } + + function isCanvas(el) { + return el.tagName.toLowerCase() === "canvas"; + } + + function isImgCorsSupported() { + return new Image().crossOrigin !== undefined; + } + + function isCanvasSupported() { + var canvas = document.createElement("canvas"); + + return canvas.getContext && canvas.getContext("2d"); + } + + // This is only meant to determine the MIME type of a renderable image file. + // It is used to ensure images drawn from a URL that have transparent backgrounds + // are rendered correctly, among other things. + function determineMimeOfFileName(nameWithPath) { + /*jshint -W015 */ + var pathSegments = nameWithPath.split("/"), + name = pathSegments[pathSegments.length - 1], + extension = qq.getExtension(name); + + extension = extension && extension.toLowerCase(); + + switch (extension) { + case "jpeg": + case "jpg": + return "image/jpeg"; + case "png": + return "image/png"; + case "bmp": + return "image/bmp"; + case "gif": + return "image/gif"; + case "tiff": + case "tif": + return "image/tiff"; + } + } + + // This will likely not work correctly in IE8 and older. + // It's only used as part of a formula to determine + // if a canvas can be used to scale a server-hosted thumbnail. + // If canvas isn't supported by the UA (IE8 and older) + // this method should not even be called. + function isCrossOrigin(url) { + var targetAnchor = document.createElement("a"), + targetProtocol, targetHostname, targetPort; + + targetAnchor.href = url; + + targetProtocol = targetAnchor.protocol; + targetPort = targetAnchor.port; + targetHostname = targetAnchor.hostname; + + if (targetProtocol.toLowerCase() !== window.location.protocol.toLowerCase()) { + return true; + } + + if (targetHostname.toLowerCase() !== window.location.hostname.toLowerCase()) { + return true; + } + + // IE doesn't take ports into consideration when determining if two endpoints are same origin. + if (targetPort !== window.location.port && !qq.ie()) { + return true; + } + + return false; + } + + function registerImgLoadListeners(img, promise) { + img.onload = function() { + img.onload = null; + img.onerror = null; + promise.success(img); + }; + + img.onerror = function() { + img.onload = null; + img.onerror = null; + log("Problem drawing thumbnail!", "error"); + promise.failure(img, "Problem drawing thumbnail!"); + }; + } + + function registerCanvasDrawImageListener(canvas, promise) { + // The image is drawn on the canvas by a third-party library, + // and we want to know when this is completed. Since the library + // may invoke drawImage many times in a loop, we need to be called + // back when the image is fully rendered. So, we are expecting the + // code that draws this image to follow a convention that involves a + // function attached to the canvas instance be invoked when it is done. + canvas.qqImageRendered = function() { + promise.success(canvas); + }; + } + + // Fulfills a `qq.Promise` when an image has been drawn onto the target, + // whether that is a or an . The attempt is considered a + // failure if the target is not an or a , or if the drawing + // attempt was not successful. + function registerThumbnailRenderedListener(imgOrCanvas, promise) { + var registered = isImg(imgOrCanvas) || isCanvas(imgOrCanvas); + + if (isImg(imgOrCanvas)) { + registerImgLoadListeners(imgOrCanvas, promise); + } + else if (isCanvas(imgOrCanvas)) { + registerCanvasDrawImageListener(imgOrCanvas, promise); + } + else { + promise.failure(imgOrCanvas); + log(qq.format("Element container of type {} is not supported!", imgOrCanvas.tagName), "error"); + } + + return registered; + } + + // Draw a preview iff the current UA can natively display it. + // Also rotate the image if necessary. + function draw(fileOrBlob, container, options) { + var drawPreview = new qq.Promise(), + identifier = new qq.Identify(fileOrBlob, log), + maxSize = options.maxSize, + // jshint eqnull:true + orient = options.orient == null ? true : options.orient, + megapixErrorHandler = function() { + container.onerror = null; + container.onload = null; + log("Could not render preview, file may be too large!", "error"); + drawPreview.failure(container, "Browser cannot render image!"); + }; + + identifier.isPreviewable().then( + function(mime) { + // If options explicitly specify that Orientation is not desired, + // replace the orient task with a dummy promise that "succeeds" immediately. + var dummyExif = { + parse: function() { + return new qq.Promise().success(); + } + }, + exif = orient ? new qq.Exif(fileOrBlob, log) : dummyExif, + mpImg = new qq.MegaPixImage(fileOrBlob, megapixErrorHandler); + + if (registerThumbnailRenderedListener(container, drawPreview)) { + exif.parse().then( + function(exif) { + var orientation = exif && exif.Orientation; + + mpImg.render(container, { + maxWidth: maxSize, + maxHeight: maxSize, + orientation: orientation, + mime: mime, + resize: options.customResizeFunction + }); + }, + + function(failureMsg) { + log(qq.format("EXIF data could not be parsed ({}). Assuming orientation = 1.", failureMsg)); + + mpImg.render(container, { + maxWidth: maxSize, + maxHeight: maxSize, + mime: mime, + resize: options.customResizeFunction + }); + } + ); + } + }, + + function() { + log("Not previewable"); + drawPreview.failure(container, "Not previewable"); + } + ); + + return drawPreview; + } + + function drawOnCanvasOrImgFromUrl(url, canvasOrImg, draw, maxSize, customResizeFunction) { + var tempImg = new Image(), + tempImgRender = new qq.Promise(); + + registerThumbnailRenderedListener(tempImg, tempImgRender); + + if (isCrossOrigin(url)) { + tempImg.crossOrigin = "anonymous"; + } + + tempImg.src = url; + + tempImgRender.then( + function rendered() { + registerThumbnailRenderedListener(canvasOrImg, draw); + + var mpImg = new qq.MegaPixImage(tempImg); + mpImg.render(canvasOrImg, { + maxWidth: maxSize, + maxHeight: maxSize, + mime: determineMimeOfFileName(url), + resize: customResizeFunction + }); + }, + + draw.failure + ); + } + + function drawOnImgFromUrlWithCssScaling(url, img, draw, maxSize) { + registerThumbnailRenderedListener(img, draw); + // NOTE: The fact that maxWidth/height is set on the thumbnail for scaled images + // that must drop back to CSS is known and exploited by the templating module. + // In this module, we pre-render "waiting" thumbs for all files immediately after they + // are submitted, and we must be sure to pass any style associated with the "waiting" preview. + qq(img).css({ + maxWidth: maxSize + "px", + maxHeight: maxSize + "px" + }); + + img.src = url; + } + + // Draw a (server-hosted) thumbnail given a URL. + // This will optionally scale the thumbnail as well. + // It attempts to use to scale, but will fall back + // to max-width and max-height style properties if the UA + // doesn't support canvas or if the images is cross-domain and + // the UA doesn't support the crossorigin attribute on img tags, + // which is required to scale a cross-origin image using & + // then export it back to an . + function drawFromUrl(url, container, options) { + var draw = new qq.Promise(), + scale = options.scale, + maxSize = scale ? options.maxSize : null; + + // container is an img, scaling needed + if (scale && isImg(container)) { + // Iff canvas is available in this UA, try to use it for scaling. + // Otherwise, fall back to CSS scaling + if (isCanvasSupported()) { + // Attempt to use for image scaling, + // but we must fall back to scaling via CSS/styles + // if this is a cross-origin image and the UA doesn't support CORS. + if (isCrossOrigin(url) && !isImgCorsSupported()) { + drawOnImgFromUrlWithCssScaling(url, container, draw, maxSize); + } + else { + drawOnCanvasOrImgFromUrl(url, container, draw, maxSize); + } + } + else { + drawOnImgFromUrlWithCssScaling(url, container, draw, maxSize); + } + } + // container is a canvas, scaling optional + else if (isCanvas(container)) { + drawOnCanvasOrImgFromUrl(url, container, draw, maxSize); + } + // container is an img & no scaling: just set the src attr to the passed url + else if (registerThumbnailRenderedListener(container, draw)) { + container.src = url; + } + + return draw; + } + + qq.extend(this, { + /** + * Generate a thumbnail. Depending on the arguments, this may either result in + * a client-side rendering of an image (if a `Blob` is supplied) or a server-generated + * image that may optionally be scaled client-side using or CSS/styles (as a fallback). + * + * @param fileBlobOrUrl a `File`, `Blob`, or a URL pointing to the image + * @param container or to contain the preview + * @param options possible properties include `maxSize` (int), `orient` (bool - default true), resize` (bool - default true), and `customResizeFunction`. + * @returns qq.Promise fulfilled when the preview has been drawn, or the attempt has failed + */ + generate: function(fileBlobOrUrl, container, options) { + if (qq.isString(fileBlobOrUrl)) { + log("Attempting to update thumbnail based on server response."); + return drawFromUrl(fileBlobOrUrl, container, options || {}); + } + else { + log("Attempting to draw client-side image preview."); + return draw(fileBlobOrUrl, container, options || {}); + } + } + }); + +}; + +/*globals qq */ +/** + * EXIF image data parser. Currently only parses the Orientation tag value, + * but this may be expanded to other tags in the future. + * + * @param fileOrBlob Attempt to parse EXIF data in this `Blob` + * @constructor + */ +qq.Exif = function(fileOrBlob, log) { + "use strict"; + + // Orientation is the only tag parsed here at this time. + var TAG_IDS = [274], + TAG_INFO = { + 274: { + name: "Orientation", + bytes: 2 + } + }; + + // Convert a little endian (hex string) to big endian (decimal). + function parseLittleEndian(hex) { + var result = 0, + pow = 0; + + while (hex.length > 0) { + result += parseInt(hex.substring(0, 2), 16) * Math.pow(2, pow); + hex = hex.substring(2, hex.length); + pow += 8; + } + + return result; + } + + // Find the byte offset, of Application Segment 1 (EXIF). + // External callers need not supply any arguments. + function seekToApp1(offset, promise) { + var theOffset = offset, + thePromise = promise; + if (theOffset === undefined) { + theOffset = 2; + thePromise = new qq.Promise(); + } + + qq.readBlobToHex(fileOrBlob, theOffset, 4).then(function(hex) { + var match = /^ffe([0-9])/.exec(hex), + segmentLength; + + if (match) { + if (match[1] !== "1") { + segmentLength = parseInt(hex.slice(4, 8), 16); + seekToApp1(theOffset + segmentLength + 2, thePromise); + } + else { + thePromise.success(theOffset); + } + } + else { + thePromise.failure("No EXIF header to be found!"); + } + }); + + return thePromise; + } + + // Find the byte offset of Application Segment 1 (EXIF) for valid JPEGs only. + function getApp1Offset() { + var promise = new qq.Promise(); + + qq.readBlobToHex(fileOrBlob, 0, 6).then(function(hex) { + if (hex.indexOf("ffd8") !== 0) { + promise.failure("Not a valid JPEG!"); + } + else { + seekToApp1().then(function(offset) { + promise.success(offset); + }, + function(error) { + promise.failure(error); + }); + } + }); + + return promise; + } + + // Determine the byte ordering of the EXIF header. + function isLittleEndian(app1Start) { + var promise = new qq.Promise(); + + qq.readBlobToHex(fileOrBlob, app1Start + 10, 2).then(function(hex) { + promise.success(hex === "4949"); + }); + + return promise; + } + + // Determine the number of directory entries in the EXIF header. + function getDirEntryCount(app1Start, littleEndian) { + var promise = new qq.Promise(); + + qq.readBlobToHex(fileOrBlob, app1Start + 18, 2).then(function(hex) { + if (littleEndian) { + return promise.success(parseLittleEndian(hex)); + } + else { + promise.success(parseInt(hex, 16)); + } + }); + + return promise; + } + + // Get the IFD portion of the EXIF header as a hex string. + function getIfd(app1Start, dirEntries) { + var offset = app1Start + 20, + bytes = dirEntries * 12; + + return qq.readBlobToHex(fileOrBlob, offset, bytes); + } + + // Obtain an array of all directory entries (as hex strings) in the EXIF header. + function getDirEntries(ifdHex) { + var entries = [], + offset = 0; + + while (offset + 24 <= ifdHex.length) { + entries.push(ifdHex.slice(offset, offset + 24)); + offset += 24; + } + + return entries; + } + + // Obtain values for all relevant tags and return them. + function getTagValues(littleEndian, dirEntries) { + var TAG_VAL_OFFSET = 16, + tagsToFind = qq.extend([], TAG_IDS), + vals = {}; + + qq.each(dirEntries, function(idx, entry) { + var idHex = entry.slice(0, 4), + id = littleEndian ? parseLittleEndian(idHex) : parseInt(idHex, 16), + tagsToFindIdx = tagsToFind.indexOf(id), + tagValHex, tagName, tagValLength; + + if (tagsToFindIdx >= 0) { + tagName = TAG_INFO[id].name; + tagValLength = TAG_INFO[id].bytes; + tagValHex = entry.slice(TAG_VAL_OFFSET, TAG_VAL_OFFSET + (tagValLength * 2)); + vals[tagName] = littleEndian ? parseLittleEndian(tagValHex) : parseInt(tagValHex, 16); + + tagsToFind.splice(tagsToFindIdx, 1); + } + + if (tagsToFind.length === 0) { + return false; + } + }); + + return vals; + } + + qq.extend(this, { + /** + * Attempt to parse the EXIF header for the `Blob` associated with this instance. + * + * @returns {qq.Promise} To be fulfilled when the parsing is complete. + * If successful, the parsed EXIF header as an object will be included. + */ + parse: function() { + var parser = new qq.Promise(), + onParseFailure = function(message) { + log(qq.format("EXIF header parse failed: '{}' ", message)); + parser.failure(message); + }; + + getApp1Offset().then(function(app1Offset) { + log(qq.format("Moving forward with EXIF header parsing for '{}'", fileOrBlob.name === undefined ? "blob" : fileOrBlob.name)); + + isLittleEndian(app1Offset).then(function(littleEndian) { + + log(qq.format("EXIF Byte order is {} endian", littleEndian ? "little" : "big")); + + getDirEntryCount(app1Offset, littleEndian).then(function(dirEntryCount) { + + log(qq.format("Found {} APP1 directory entries", dirEntryCount)); + + getIfd(app1Offset, dirEntryCount).then(function(ifdHex) { + var dirEntries = getDirEntries(ifdHex), + tagValues = getTagValues(littleEndian, dirEntries); + + log("Successfully parsed some EXIF tags"); + + parser.success(tagValues); + }, onParseFailure); + }, onParseFailure); + }, onParseFailure); + }, onParseFailure); + + return parser; + } + }); + +}; + +/*globals qq */ +qq.Identify = function(fileOrBlob, log) { + "use strict"; + + function isIdentifiable(magicBytes, questionableBytes) { + var identifiable = false, + magicBytesEntries = [].concat(magicBytes); + + qq.each(magicBytesEntries, function(idx, magicBytesArrayEntry) { + if (questionableBytes.indexOf(magicBytesArrayEntry) === 0) { + identifiable = true; + return false; + } + }); + + return identifiable; + } + + qq.extend(this, { + /** + * Determines if a Blob can be displayed natively in the current browser. This is done by reading magic + * bytes in the beginning of the file, so this is an asynchronous operation. Before we attempt to read the + * file, we will examine the blob's type attribute to save CPU cycles. + * + * @returns {qq.Promise} Promise that is fulfilled when identification is complete. + * If successful, the MIME string is passed to the success handler. + */ + isPreviewable: function() { + var self = this, + identifier = new qq.Promise(), + previewable = false, + name = fileOrBlob.name === undefined ? "blob" : fileOrBlob.name; + + log(qq.format("Attempting to determine if {} can be rendered in this browser", name)); + + log("First pass: check type attribute of blob object."); + + if (this.isPreviewableSync()) { + log("Second pass: check for magic bytes in file header."); + + qq.readBlobToHex(fileOrBlob, 0, 4).then(function(hex) { + qq.each(self.PREVIEWABLE_MIME_TYPES, function(mime, bytes) { + if (isIdentifiable(bytes, hex)) { + // Safari is the only supported browser that can deal with TIFFs natively, + // so, if this is a TIFF and the UA isn't Safari, declare this file "non-previewable". + if (mime !== "image/tiff" || qq.supportedFeatures.tiffPreviews) { + previewable = true; + identifier.success(mime); + } + + return false; + } + }); + + log(qq.format("'{}' is {} able to be rendered in this browser", name, previewable ? "" : "NOT")); + + if (!previewable) { + identifier.failure(); + } + }, + function() { + log("Error reading file w/ name '" + name + "'. Not able to be rendered in this browser."); + identifier.failure(); + }); + } + else { + identifier.failure(); + } + + return identifier; + }, + + /** + * Determines if a Blob can be displayed natively in the current browser. This is done by checking the + * blob's type attribute. This is a synchronous operation, useful for situations where an asynchronous operation + * would be challenging to support. Note that the blob's type property is not as accurate as reading the + * file's magic bytes. + * + * @returns {Boolean} true if the blob can be rendered in the current browser + */ + isPreviewableSync: function() { + var fileMime = fileOrBlob.type, + // Assumption: This will only ever be executed in browsers that support `Object.keys`. + isRecognizedImage = qq.indexOf(Object.keys(this.PREVIEWABLE_MIME_TYPES), fileMime) >= 0, + previewable = false, + name = fileOrBlob.name === undefined ? "blob" : fileOrBlob.name; + + if (isRecognizedImage) { + if (fileMime === "image/tiff") { + previewable = qq.supportedFeatures.tiffPreviews; + } + else { + previewable = true; + } + } + + !previewable && log(name + " is not previewable in this browser per the blob's type attr"); + + return previewable; + } + }); +}; + +qq.Identify.prototype.PREVIEWABLE_MIME_TYPES = { + "image/jpeg": "ffd8ff", + "image/gif": "474946", + "image/png": "89504e", + "image/bmp": "424d", + "image/tiff": ["49492a00", "4d4d002a"] +}; + +/*globals qq*/ +/** + * Attempts to validate an image, wherever possible. + * + * @param blob File or Blob representing a user-selecting image. + * @param log Uses this to post log messages to the console. + * @constructor + */ +qq.ImageValidation = function(blob, log) { + "use strict"; + + /** + * @param limits Object with possible image-related limits to enforce. + * @returns {boolean} true if at least one of the limits has a non-zero value + */ + function hasNonZeroLimits(limits) { + var atLeastOne = false; + + qq.each(limits, function(limit, value) { + if (value > 0) { + atLeastOne = true; + return false; + } + }); + + return atLeastOne; + } + + /** + * @returns {qq.Promise} The promise is a failure if we can't obtain the width & height. + * Otherwise, `success` is called on the returned promise with an object containing + * `width` and `height` properties. + */ + function getWidthHeight() { + var sizeDetermination = new qq.Promise(); + + new qq.Identify(blob, log).isPreviewable().then(function() { + var image = new Image(), + url = window.URL && window.URL.createObjectURL ? window.URL : + window.webkitURL && window.webkitURL.createObjectURL ? window.webkitURL : + null; + + if (url) { + image.onerror = function() { + log("Cannot determine dimensions for image. May be too large.", "error"); + sizeDetermination.failure(); + }; + + image.onload = function() { + sizeDetermination.success({ + width: this.width, + height: this.height + }); + }; + + image.src = url.createObjectURL(blob); + } + else { + log("No createObjectURL function available to generate image URL!", "error"); + sizeDetermination.failure(); + } + }, sizeDetermination.failure); + + return sizeDetermination; + } + + /** + * + * @param limits Object with possible image-related limits to enforce. + * @param dimensions Object containing `width` & `height` properties for the image to test. + * @returns {String || undefined} The name of the failing limit. Undefined if no failing limits. + */ + function getFailingLimit(limits, dimensions) { + var failingLimit; + + qq.each(limits, function(limitName, limitValue) { + if (limitValue > 0) { + var limitMatcher = /(max|min)(Width|Height)/.exec(limitName), + dimensionPropName = limitMatcher[2].charAt(0).toLowerCase() + limitMatcher[2].slice(1), + actualValue = dimensions[dimensionPropName]; + + /*jshint -W015*/ + switch (limitMatcher[1]) { + case "min": + if (actualValue < limitValue) { + failingLimit = limitName; + return false; + } + break; + case "max": + if (actualValue > limitValue) { + failingLimit = limitName; + return false; + } + break; + } + } + }); + + return failingLimit; + } + + /** + * Validate the associated blob. + * + * @param limits + * @returns {qq.Promise} `success` is called on the promise is the image is valid or + * if the blob is not an image, or if the image is not verifiable. + * Otherwise, `failure` with the name of the failing limit. + */ + this.validate = function(limits) { + var validationEffort = new qq.Promise(); + + log("Attempting to validate image."); + + if (hasNonZeroLimits(limits)) { + getWidthHeight().then(function(dimensions) { + var failingLimit = getFailingLimit(limits, dimensions); + + if (failingLimit) { + validationEffort.failure(failingLimit); + } + else { + validationEffort.success(); + } + }, validationEffort.success); + } + else { + validationEffort.success(); + } + + return validationEffort; + }; +}; + +/* globals qq */ +/** + * Module used to control populating the initial list of files. + * + * @constructor + */ +qq.Session = function(spec) { + "use strict"; + + var options = { + endpoint: null, + params: {}, + customHeaders: {}, + cors: {}, + addFileRecord: function(sessionData) {}, + log: function(message, level) {} + }; + + qq.extend(options, spec, true); + + function isJsonResponseValid(response) { + if (qq.isArray(response)) { + return true; + } + + options.log("Session response is not an array.", "error"); + } + + function handleFileItems(fileItems, success, xhrOrXdr, promise) { + var someItemsIgnored = false; + + success = success && isJsonResponseValid(fileItems); + + if (success) { + qq.each(fileItems, function(idx, fileItem) { + /* jshint eqnull:true */ + if (fileItem.uuid == null) { + someItemsIgnored = true; + options.log(qq.format("Session response item {} did not include a valid UUID - ignoring.", idx), "error"); + } + else if (fileItem.name == null) { + someItemsIgnored = true; + options.log(qq.format("Session response item {} did not include a valid name - ignoring.", idx), "error"); + } + else { + try { + options.addFileRecord(fileItem); + return true; + } + catch (err) { + someItemsIgnored = true; + options.log(err.message, "error"); + } + } + + return false; + }); + } + + promise[success && !someItemsIgnored ? "success" : "failure"](fileItems, xhrOrXdr); + } + + // Initiate a call to the server that will be used to populate the initial file list. + // Returns a `qq.Promise`. + this.refresh = function() { + /*jshint indent:false */ + var refreshEffort = new qq.Promise(), + refreshCompleteCallback = function(response, success, xhrOrXdr) { + handleFileItems(response, success, xhrOrXdr, refreshEffort); + }, + requesterOptions = qq.extend({}, options), + requester = new qq.SessionAjaxRequester( + qq.extend(requesterOptions, {onComplete: refreshCompleteCallback}) + ); + + requester.queryServer(); + + return refreshEffort; + }; +}; + +/*globals qq, XMLHttpRequest*/ +/** + * Thin module used to send GET requests to the server, expecting information about session + * data used to initialize an uploader instance. + * + * @param spec Various options used to influence the associated request. + * @constructor + */ +qq.SessionAjaxRequester = function(spec) { + "use strict"; + + var requester, + options = { + endpoint: null, + customHeaders: {}, + params: {}, + cors: { + expected: false, + sendCredentials: false + }, + onComplete: function(response, success, xhrOrXdr) {}, + log: function(str, level) {} + }; + + qq.extend(options, spec); + + function onComplete(id, xhrOrXdr, isError) { + var response = null; + + /* jshint eqnull:true */ + if (xhrOrXdr.responseText != null) { + try { + response = qq.parseJson(xhrOrXdr.responseText); + } + catch (err) { + options.log("Problem parsing session response: " + err.message, "error"); + isError = true; + } + } + + options.onComplete(response, !isError, xhrOrXdr); + } + + requester = qq.extend(this, new qq.AjaxRequester({ + acceptHeader: "application/json", + validMethods: ["GET"], + method: "GET", + endpointStore: { + get: function() { + return options.endpoint; + } + }, + customHeaders: options.customHeaders, + log: options.log, + onComplete: onComplete, + cors: options.cors + })); + + qq.extend(this, { + queryServer: function() { + var params = qq.extend({}, options.params); + + options.log("Session query request."); + + requester.initTransport("sessionRefresh") + .withParams(params) + .withCacheBuster() + .send(); + } + }); +}; + +/* globals qq */ +/** + * Module that handles support for existing forms. + * + * @param options Options passed from the integrator-supplied options related to form support. + * @param startUpload Callback to invoke when files "stored" should be uploaded. + * @param log Proxy for the logger + * @constructor + */ +qq.FormSupport = function(options, startUpload, log) { + "use strict"; + var self = this, + interceptSubmit = options.interceptSubmit, + formEl = options.element, + autoUpload = options.autoUpload; + + // Available on the public API associated with this module. + qq.extend(this, { + // To be used by the caller to determine if the endpoint will be determined by some processing + // that occurs in this module, such as if the form has an action attribute. + // Ignore if `attachToForm === false`. + newEndpoint: null, + + // To be used by the caller to determine if auto uploading should be allowed. + // Ignore if `attachToForm === false`. + newAutoUpload: autoUpload, + + // true if a form was detected and is being tracked by this module + attachedToForm: false, + + // Returns an object with names and values for all valid form elements associated with the attached form. + getFormInputsAsObject: function() { + /* jshint eqnull:true */ + if (formEl == null) { + return null; + } + + return self._form2Obj(formEl); + } + }); + + // If the form contains an action attribute, this should be the new upload endpoint. + function determineNewEndpoint(formEl) { + if (formEl.getAttribute("action")) { + self.newEndpoint = formEl.getAttribute("action"); + } + } + + // Return true only if the form is valid, or if we cannot make this determination. + // If the form is invalid, ensure invalid field(s) are highlighted in the UI. + function validateForm(formEl, nativeSubmit) { + if (formEl.checkValidity && !formEl.checkValidity()) { + log("Form did not pass validation checks - will not upload.", "error"); + nativeSubmit(); + } + else { + return true; + } + } + + // Intercept form submit attempts, unless the integrator has told us not to do this. + function maybeUploadOnSubmit(formEl) { + var nativeSubmit = formEl.submit; + + // Intercept and squelch submit events. + qq(formEl).attach("submit", function(event) { + event = event || window.event; + + if (event.preventDefault) { + event.preventDefault(); + } + else { + event.returnValue = false; + } + + validateForm(formEl, nativeSubmit) && startUpload(); + }); + + // The form's `submit()` function may be called instead (i.e. via jQuery.submit()). + // Intercept that too. + formEl.submit = function() { + validateForm(formEl, nativeSubmit) && startUpload(); + }; + } + + // If the element value passed from the uploader is a string, assume it is an element ID - select it. + // The rest of the code in this module depends on this being an HTMLElement. + function determineFormEl(formEl) { + if (formEl) { + if (qq.isString(formEl)) { + formEl = document.getElementById(formEl); + } + + if (formEl) { + log("Attaching to form element."); + determineNewEndpoint(formEl); + interceptSubmit && maybeUploadOnSubmit(formEl); + } + } + + return formEl; + } + + formEl = determineFormEl(formEl); + this.attachedToForm = !!formEl; +}; + +qq.extend(qq.FormSupport.prototype, { + // Converts all relevant form fields to key/value pairs. This is meant to mimic the data a browser will + // construct from a given form when the form is submitted. + _form2Obj: function(form) { + "use strict"; + var obj = {}, + notIrrelevantType = function(type) { + var irrelevantTypes = [ + "button", + "image", + "reset", + "submit" + ]; + + return qq.indexOf(irrelevantTypes, type.toLowerCase()) < 0; + }, + radioOrCheckbox = function(type) { + return qq.indexOf(["checkbox", "radio"], type.toLowerCase()) >= 0; + }, + ignoreValue = function(el) { + if (radioOrCheckbox(el.type) && !el.checked) { + return true; + } + + return el.disabled && el.type.toLowerCase() !== "hidden"; + }, + selectValue = function(select) { + var value = null; + + qq.each(qq(select).children(), function(idx, child) { + if (child.tagName.toLowerCase() === "option" && child.selected) { + value = child.value; + return false; + } + }); + + return value; + }; + + qq.each(form.elements, function(idx, el) { + if ((qq.isInput(el, true) || el.tagName.toLowerCase() === "textarea") && + notIrrelevantType(el.type) && + !ignoreValue(el)) { + + obj[el.name] = el.value; + } + else if (el.tagName.toLowerCase() === "select" && !ignoreValue(el)) { + var value = selectValue(el); + + if (value !== null) { + obj[el.name] = value; + } + } + }); + + return obj; + } +}); + +/* globals qq, ExifRestorer */ +/** + * Controls generation of scaled images based on a reference image encapsulated in a `File` or `Blob`. + * Scaled images are generated and converted to blobs on-demand. + * Multiple scaled images per reference image with varying sizes and other properties are supported. + * + * @param spec Information about the scaled images to generate. + * @param log Logger instance + * @constructor + */ +qq.Scaler = function(spec, log) { + "use strict"; + + var self = this, + customResizeFunction = spec.customResizer, + includeOriginal = spec.sendOriginal, + orient = spec.orient, + defaultType = spec.defaultType, + defaultQuality = spec.defaultQuality / 100, + failedToScaleText = spec.failureText, + includeExif = spec.includeExif, + sizes = this._getSortedSizes(spec.sizes); + + // Revealed API for instances of this module + qq.extend(this, { + // If no targeted sizes have been declared or if this browser doesn't support + // client-side image preview generation, there is no scaling to do. + enabled: qq.supportedFeatures.scaling && sizes.length > 0, + + getFileRecords: function(originalFileUuid, originalFileName, originalBlobOrBlobData) { + var self = this, + records = [], + originalBlob = originalBlobOrBlobData.blob ? originalBlobOrBlobData.blob : originalBlobOrBlobData, + identifier = new qq.Identify(originalBlob, log); + + // If the reference file cannot be rendered natively, we can't create scaled versions. + if (identifier.isPreviewableSync()) { + // Create records for each scaled version & add them to the records array, smallest first. + qq.each(sizes, function(idx, sizeRecord) { + var outputType = self._determineOutputType({ + defaultType: defaultType, + requestedType: sizeRecord.type, + refType: originalBlob.type + }); + + records.push({ + uuid: qq.getUniqueId(), + name: self._getName(originalFileName, { + name: sizeRecord.name, + type: outputType, + refType: originalBlob.type + }), + blob: new qq.BlobProxy(originalBlob, + qq.bind(self._generateScaledImage, self, { + customResizeFunction: customResizeFunction, + maxSize: sizeRecord.maxSize, + orient: orient, + type: outputType, + quality: defaultQuality, + failedText: failedToScaleText, + includeExif: includeExif, + log: log + })) + }); + }); + + records.push({ + uuid: originalFileUuid, + name: originalFileName, + size: originalBlob.size, + blob: includeOriginal ? originalBlob : null + }); + } + else { + records.push({ + uuid: originalFileUuid, + name: originalFileName, + size: originalBlob.size, + blob: originalBlob + }); + } + + return records; + }, + + handleNewFile: function(file, name, uuid, size, fileList, batchId, uuidParamName, api) { + var self = this, + buttonId = file.qqButtonId || (file.blob && file.blob.qqButtonId), + scaledIds = [], + originalId = null, + addFileToHandler = api.addFileToHandler, + uploadData = api.uploadData, + paramsStore = api.paramsStore, + proxyGroupId = qq.getUniqueId(); + + qq.each(self.getFileRecords(uuid, name, file), function(idx, record) { + var blobSize = record.size, + id; + + if (record.blob instanceof qq.BlobProxy) { + blobSize = -1; + } + + id = uploadData.addFile({ + uuid: record.uuid, + name: record.name, + size: blobSize, + batchId: batchId, + proxyGroupId: proxyGroupId + }); + + if (record.blob instanceof qq.BlobProxy) { + scaledIds.push(id); + } + else { + originalId = id; + } + + if (record.blob) { + addFileToHandler(id, record.blob); + fileList.push({id: id, file: record.blob}); + } + else { + uploadData.setStatus(id, qq.status.REJECTED); + } + }); + + // If we are potentially uploading an original file and some scaled versions, + // ensure the scaled versions include reference's to the parent's UUID and size + // in their associated upload requests. + if (originalId !== null) { + qq.each(scaledIds, function(idx, scaledId) { + var params = { + qqparentuuid: uploadData.retrieve({id: originalId}).uuid, + qqparentsize: uploadData.retrieve({id: originalId}).size + }; + + // Make sure the UUID for each scaled image is sent with the upload request, + // to be consistent (since we may need to ensure it is sent for the original file as well). + params[uuidParamName] = uploadData.retrieve({id: scaledId}).uuid; + + uploadData.setParentId(scaledId, originalId); + paramsStore.addReadOnly(scaledId, params); + }); + + // If any scaled images are tied to this parent image, be SURE we send its UUID as an upload request + // parameter as well. + if (scaledIds.length) { + (function() { + var param = {}; + param[uuidParamName] = uploadData.retrieve({id: originalId}).uuid; + paramsStore.addReadOnly(originalId, param); + }()); + } + } + } + }); +}; + +qq.extend(qq.Scaler.prototype, { + scaleImage: function(id, specs, api) { + "use strict"; + + if (!qq.supportedFeatures.scaling) { + throw new qq.Error("Scaling is not supported in this browser!"); + } + + var scalingEffort = new qq.Promise(), + log = api.log, + file = api.getFile(id), + uploadData = api.uploadData.retrieve({id: id}), + name = uploadData && uploadData.name, + uuid = uploadData && uploadData.uuid, + scalingOptions = { + customResizer: specs.customResizer, + sendOriginal: false, + orient: specs.orient, + defaultType: specs.type || null, + defaultQuality: specs.quality, + failedToScaleText: "Unable to scale", + sizes: [{name: "", maxSize: specs.maxSize}] + }, + scaler = new qq.Scaler(scalingOptions, log); + + if (!qq.Scaler || !qq.supportedFeatures.imagePreviews || !file) { + scalingEffort.failure(); + + log("Could not generate requested scaled image for " + id + ". " + + "Scaling is either not possible in this browser, or the file could not be located.", "error"); + } + else { + (qq.bind(function() { + // Assumption: There will never be more than one record + var record = scaler.getFileRecords(uuid, name, file)[0]; + + if (record && record.blob instanceof qq.BlobProxy) { + record.blob.create().then(scalingEffort.success, scalingEffort.failure); + } + else { + log(id + " is not a scalable image!", "error"); + scalingEffort.failure(); + } + }, this)()); + } + + return scalingEffort; + }, + + // NOTE: We cannot reliably determine at this time if the UA supports a specific MIME type for the target format. + // image/jpeg and image/png are the only safe choices at this time. + _determineOutputType: function(spec) { + "use strict"; + + var requestedType = spec.requestedType, + defaultType = spec.defaultType, + referenceType = spec.refType; + + // If a default type and requested type have not been specified, this should be a + // JPEG if the original type is a JPEG, otherwise, a PNG. + if (!defaultType && !requestedType) { + if (referenceType !== "image/jpeg") { + return "image/png"; + } + return referenceType; + } + + // A specified default type is used when a requested type is not specified. + if (!requestedType) { + return defaultType; + } + + // If requested type is specified, use it, as long as this recognized type is supported by the current UA + if (qq.indexOf(Object.keys(qq.Identify.prototype.PREVIEWABLE_MIME_TYPES), requestedType) >= 0) { + if (requestedType === "image/tiff") { + return qq.supportedFeatures.tiffPreviews ? requestedType : defaultType; + } + + return requestedType; + } + + return defaultType; + }, + + // Get a file name for a generated scaled file record, based on the provided scaled image description + _getName: function(originalName, scaledVersionProperties) { + "use strict"; + + var startOfExt = originalName.lastIndexOf("."), + versionType = scaledVersionProperties.type || "image/png", + referenceType = scaledVersionProperties.refType, + scaledName = "", + scaledExt = qq.getExtension(originalName), + nameAppendage = ""; + + if (scaledVersionProperties.name && scaledVersionProperties.name.trim().length) { + nameAppendage = " (" + scaledVersionProperties.name + ")"; + } + + if (startOfExt >= 0) { + scaledName = originalName.substr(0, startOfExt); + + if (referenceType !== versionType) { + scaledExt = versionType.split("/")[1]; + } + + scaledName += nameAppendage + "." + scaledExt; + } + else { + scaledName = originalName + nameAppendage; + } + + return scaledName; + }, + + // We want the smallest scaled file to be uploaded first + _getSortedSizes: function(sizes) { + "use strict"; + + sizes = qq.extend([], sizes); + + return sizes.sort(function(a, b) { + if (a.maxSize > b.maxSize) { + return 1; + } + if (a.maxSize < b.maxSize) { + return -1; + } + return 0; + }); + }, + + _generateScaledImage: function(spec, sourceFile) { + "use strict"; + + var self = this, + customResizeFunction = spec.customResizeFunction, + log = spec.log, + maxSize = spec.maxSize, + orient = spec.orient, + type = spec.type, + quality = spec.quality, + failedText = spec.failedText, + includeExif = spec.includeExif && sourceFile.type === "image/jpeg" && type === "image/jpeg", + scalingEffort = new qq.Promise(), + imageGenerator = new qq.ImageGenerator(log), + canvas = document.createElement("canvas"); + + log("Attempting to generate scaled version for " + sourceFile.name); + + imageGenerator.generate(sourceFile, canvas, {maxSize: maxSize, orient: orient, customResizeFunction: customResizeFunction}).then(function() { + var scaledImageDataUri = canvas.toDataURL(type, quality), + signalSuccess = function() { + log("Success generating scaled version for " + sourceFile.name); + var blob = qq.dataUriToBlob(scaledImageDataUri); + scalingEffort.success(blob); + }; + + if (includeExif) { + self._insertExifHeader(sourceFile, scaledImageDataUri, log).then(function(scaledImageDataUriWithExif) { + scaledImageDataUri = scaledImageDataUriWithExif; + signalSuccess(); + }, + function() { + log("Problem inserting EXIF header into scaled image. Using scaled image w/out EXIF data.", "error"); + signalSuccess(); + }); + } + else { + signalSuccess(); + } + }, function() { + log("Failed attempt to generate scaled version for " + sourceFile.name, "error"); + scalingEffort.failure(failedText); + }); + + return scalingEffort; + }, + + // Attempt to insert the original image's EXIF header into a scaled version. + _insertExifHeader: function(originalImage, scaledImageDataUri, log) { + "use strict"; + + var reader = new FileReader(), + insertionEffort = new qq.Promise(), + originalImageDataUri = ""; + + reader.onload = function() { + originalImageDataUri = reader.result; + insertionEffort.success(qq.ExifRestorer.restore(originalImageDataUri, scaledImageDataUri)); + }; + + reader.onerror = function() { + log("Problem reading " + originalImage.name + " during attempt to transfer EXIF data to scaled version.", "error"); + insertionEffort.failure(); + }; + + reader.readAsDataURL(originalImage); + + return insertionEffort; + }, + + _dataUriToBlob: function(dataUri) { + "use strict"; + + var byteString, mimeString, arrayBuffer, intArray; + + // convert base64 to raw binary data held in a string + if (dataUri.split(",")[0].indexOf("base64") >= 0) { + byteString = atob(dataUri.split(",")[1]); + } + else { + byteString = decodeURI(dataUri.split(",")[1]); + } + + // extract the MIME + mimeString = dataUri.split(",")[0] + .split(":")[1] + .split(";")[0]; + + // write the bytes of the binary string to an ArrayBuffer + arrayBuffer = new ArrayBuffer(byteString.length); + intArray = new Uint8Array(arrayBuffer); + qq.each(byteString, function(idx, character) { + intArray[idx] = character.charCodeAt(0); + }); + + return this._createBlob(arrayBuffer, mimeString); + }, + + _createBlob: function(data, mime) { + "use strict"; + + var BlobBuilder = window.BlobBuilder || + window.WebKitBlobBuilder || + window.MozBlobBuilder || + window.MSBlobBuilder, + blobBuilder = BlobBuilder && new BlobBuilder(); + + if (blobBuilder) { + blobBuilder.append(data); + return blobBuilder.getBlob(mime); + } + else { + return new Blob([data], {type: mime}); + } + } +}); + +//Based on MinifyJpeg +//http://elicon.blog57.fc2.com/blog-entry-206.html + +qq.ExifRestorer = (function() +{ + + var ExifRestorer = {}; + + ExifRestorer.KEY_STR = "ABCDEFGHIJKLMNOP" + + "QRSTUVWXYZabcdef" + + "ghijklmnopqrstuv" + + "wxyz0123456789+/" + + "="; + + ExifRestorer.encode64 = function(input) + { + var output = "", + chr1, chr2, chr3 = "", + enc1, enc2, enc3, enc4 = "", + i = 0; + + do { + chr1 = input[i++]; + chr2 = input[i++]; + chr3 = input[i++]; + + enc1 = chr1 >> 2; + enc2 = ((chr1 & 3) << 4) | (chr2 >> 4); + enc3 = ((chr2 & 15) << 2) | (chr3 >> 6); + enc4 = chr3 & 63; + + if (isNaN(chr2)) { + enc3 = enc4 = 64; + } else if (isNaN(chr3)) { + enc4 = 64; + } + + output = output + + this.KEY_STR.charAt(enc1) + + this.KEY_STR.charAt(enc2) + + this.KEY_STR.charAt(enc3) + + this.KEY_STR.charAt(enc4); + chr1 = chr2 = chr3 = ""; + enc1 = enc2 = enc3 = enc4 = ""; + } while (i < input.length); + + return output; + }; + + ExifRestorer.restore = function(origFileBase64, resizedFileBase64) + { + var expectedBase64Header = "data:image/jpeg;base64,"; + + if (!origFileBase64.match(expectedBase64Header)) + { + return resizedFileBase64; + } + + var rawImage = this.decode64(origFileBase64.replace(expectedBase64Header, "")); + var segments = this.slice2Segments(rawImage); + + var image = this.exifManipulation(resizedFileBase64, segments); + + return expectedBase64Header + this.encode64(image); + + }; + + + ExifRestorer.exifManipulation = function(resizedFileBase64, segments) + { + var exifArray = this.getExifArray(segments), + newImageArray = this.insertExif(resizedFileBase64, exifArray), + aBuffer = new Uint8Array(newImageArray); + + return aBuffer; + }; + + + ExifRestorer.getExifArray = function(segments) + { + var seg; + for (var x = 0; x < segments.length; x++) + { + seg = segments[x]; + if (seg[0] == 255 & seg[1] == 225) //(ff e1) + { + return seg; + } + } + return []; + }; + + + ExifRestorer.insertExif = function(resizedFileBase64, exifArray) + { + var imageData = resizedFileBase64.replace("data:image/jpeg;base64,", ""), + buf = this.decode64(imageData), + separatePoint = buf.indexOf(255,3), + mae = buf.slice(0, separatePoint), + ato = buf.slice(separatePoint), + array = mae; + + array = array.concat(exifArray); + array = array.concat(ato); + return array; + }; + + + + ExifRestorer.slice2Segments = function(rawImageArray) + { + var head = 0, + segments = []; + + while (1) + { + if (rawImageArray[head] == 255 & rawImageArray[head + 1] == 218){break;} + if (rawImageArray[head] == 255 & rawImageArray[head + 1] == 216) + { + head += 2; + } + else + { + var length = rawImageArray[head + 2] * 256 + rawImageArray[head + 3], + endPoint = head + length + 2, + seg = rawImageArray.slice(head, endPoint); + segments.push(seg); + head = endPoint; + } + if (head > rawImageArray.length){break;} + } + + return segments; + }; + + + + ExifRestorer.decode64 = function(input) + { + var output = "", + chr1, chr2, chr3 = "", + enc1, enc2, enc3, enc4 = "", + i = 0, + buf = []; + + // remove all characters that are not A-Z, a-z, 0-9, +, /, or = + var base64test = /[^A-Za-z0-9\+\/\=]/g; + if (base64test.exec(input)) { + throw new Error("There were invalid base64 characters in the input text. " + + "Valid base64 characters are A-Z, a-z, 0-9, '+', '/',and '='"); + } + input = input.replace(/[^A-Za-z0-9\+\/\=]/g, ""); + + do { + enc1 = this.KEY_STR.indexOf(input.charAt(i++)); + enc2 = this.KEY_STR.indexOf(input.charAt(i++)); + enc3 = this.KEY_STR.indexOf(input.charAt(i++)); + enc4 = this.KEY_STR.indexOf(input.charAt(i++)); + + chr1 = (enc1 << 2) | (enc2 >> 4); + chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); + chr3 = ((enc3 & 3) << 6) | enc4; + + buf.push(chr1); + + if (enc3 != 64) { + buf.push(chr2); + } + if (enc4 != 64) { + buf.push(chr3); + } + + chr1 = chr2 = chr3 = ""; + enc1 = enc2 = enc3 = enc4 = ""; + + } while (i < input.length); + + return buf; + }; + + + return ExifRestorer; +})(); + +/* globals qq */ +/** + * Keeps a running tally of total upload progress for a batch of files. + * + * @param callback Invoked when total progress changes, passing calculated total loaded & total size values. + * @param getSize Function that returns the size of a file given its ID + * @constructor + */ +qq.TotalProgress = function(callback, getSize) { + "use strict"; + + var perFileProgress = {}, + totalLoaded = 0, + totalSize = 0, + + lastLoadedSent = -1, + lastTotalSent = -1, + callbackProxy = function(loaded, total) { + if (loaded !== lastLoadedSent || total !== lastTotalSent) { + callback(loaded, total); + } + + lastLoadedSent = loaded; + lastTotalSent = total; + }, + + /** + * @param failed Array of file IDs that have failed + * @param retryable Array of file IDs that are retryable + * @returns true if none of the failed files are eligible for retry + */ + noRetryableFiles = function(failed, retryable) { + var none = true; + + qq.each(failed, function(idx, failedId) { + if (qq.indexOf(retryable, failedId) >= 0) { + none = false; + return false; + } + }); + + return none; + }, + + onCancel = function(id) { + updateTotalProgress(id, -1, -1); + delete perFileProgress[id]; + }, + + onAllComplete = function(successful, failed, retryable) { + if (failed.length === 0 || noRetryableFiles(failed, retryable)) { + callbackProxy(totalSize, totalSize); + this.reset(); + } + }, + + onNew = function(id) { + var size = getSize(id); + + // We might not know the size yet, such as for blob proxies + if (size > 0) { + updateTotalProgress(id, 0, size); + perFileProgress[id] = {loaded: 0, total: size}; + } + }, + + /** + * Invokes the callback with the current total progress of all files in the batch. Called whenever it may + * be appropriate to re-calculate and disseminate this data. + * + * @param id ID of a file that has changed in some important way + * @param newLoaded New loaded value for this file. -1 if this value should no longer be part of calculations + * @param newTotal New total size of the file. -1 if this value should no longer be part of calculations + */ + updateTotalProgress = function(id, newLoaded, newTotal) { + var oldLoaded = perFileProgress[id] ? perFileProgress[id].loaded : 0, + oldTotal = perFileProgress[id] ? perFileProgress[id].total : 0; + + if (newLoaded === -1 && newTotal === -1) { + totalLoaded -= oldLoaded; + totalSize -= oldTotal; + } + else { + if (newLoaded) { + totalLoaded += newLoaded - oldLoaded; + } + if (newTotal) { + totalSize += newTotal - oldTotal; + } + } + + callbackProxy(totalLoaded, totalSize); + }; + + qq.extend(this, { + // Called when a batch of files has completed uploading. + onAllComplete: onAllComplete, + + // Called when the status of a file has changed. + onStatusChange: function(id, oldStatus, newStatus) { + if (newStatus === qq.status.CANCELED || newStatus === qq.status.REJECTED) { + onCancel(id); + } + else if (newStatus === qq.status.SUBMITTING) { + onNew(id); + } + }, + + // Called whenever the upload progress of an individual file has changed. + onIndividualProgress: function(id, loaded, total) { + updateTotalProgress(id, loaded, total); + perFileProgress[id] = {loaded: loaded, total: total}; + }, + + // Called whenever the total size of a file has changed, such as when the size of a generated blob is known. + onNewSize: function(id) { + onNew(id); + }, + + reset: function() { + perFileProgress = {}; + totalLoaded = 0; + totalSize = 0; + } + }); +}; + +/*globals qq */ +// Base handler for UI (FineUploader mode) events. +// Some more specific handlers inherit from this one. +qq.UiEventHandler = function(s, protectedApi) { + "use strict"; + + var disposer = new qq.DisposeSupport(), + spec = { + eventType: "click", + attachTo: null, + onHandled: function(target, event) {} + }; + + // This makes up the "public" API methods that will be accessible + // to instances constructing a base or child handler + qq.extend(this, { + addHandler: function(element) { + addHandler(element); + }, + + dispose: function() { + disposer.dispose(); + } + }); + + function addHandler(element) { + disposer.attach(element, spec.eventType, function(event) { + // Only in IE: the `event` is a property of the `window`. + event = event || window.event; + + // On older browsers, we must check the `srcElement` instead of the `target`. + var target = event.target || event.srcElement; + + spec.onHandled(target, event); + }); + } + + // These make up the "protected" API methods that children of this base handler will utilize. + qq.extend(protectedApi, { + getFileIdFromItem: function(item) { + return item.qqFileId; + }, + + getDisposeSupport: function() { + return disposer; + } + }); + + qq.extend(spec, s); + + if (spec.attachTo) { + addHandler(spec.attachTo); + } +}; + +/* global qq */ +qq.FileButtonsClickHandler = function(s) { + "use strict"; + + var inheritedInternalApi = {}, + spec = { + templating: null, + log: function(message, lvl) {}, + onDeleteFile: function(fileId) {}, + onCancel: function(fileId) {}, + onRetry: function(fileId) {}, + onPause: function(fileId) {}, + onContinue: function(fileId) {}, + onGetName: function(fileId) {} + }, + buttonHandlers = { + cancel: function(id) { spec.onCancel(id); }, + retry: function(id) { spec.onRetry(id); }, + deleteButton: function(id) { spec.onDeleteFile(id); }, + pause: function(id) { spec.onPause(id); }, + continueButton: function(id) { spec.onContinue(id); } + }; + + function examineEvent(target, event) { + qq.each(buttonHandlers, function(buttonType, handler) { + var firstLetterCapButtonType = buttonType.charAt(0).toUpperCase() + buttonType.slice(1), + fileId; + + if (spec.templating["is" + firstLetterCapButtonType](target)) { + fileId = spec.templating.getFileId(target); + qq.preventDefault(event); + spec.log(qq.format("Detected valid file button click event on file '{}', ID: {}.", spec.onGetName(fileId), fileId)); + handler(fileId); + return false; + } + }); + } + + qq.extend(spec, s); + + spec.eventType = "click"; + spec.onHandled = examineEvent; + spec.attachTo = spec.templating.getFileList(); + + qq.extend(this, new qq.UiEventHandler(spec, inheritedInternalApi)); +}; + +/*globals qq */ +// Child of FilenameEditHandler. Used to detect click events on filename display elements. +qq.FilenameClickHandler = function(s) { + "use strict"; + + var inheritedInternalApi = {}, + spec = { + templating: null, + log: function(message, lvl) {}, + classes: { + file: "qq-upload-file", + editNameIcon: "qq-edit-filename-icon" + }, + onGetUploadStatus: function(fileId) {}, + onGetName: function(fileId) {} + }; + + qq.extend(spec, s); + + // This will be called by the parent handler when a `click` event is received on the list element. + function examineEvent(target, event) { + if (spec.templating.isFileName(target) || spec.templating.isEditIcon(target)) { + var fileId = spec.templating.getFileId(target), + status = spec.onGetUploadStatus(fileId); + + // We only allow users to change filenames of files that have been submitted but not yet uploaded. + if (status === qq.status.SUBMITTED) { + spec.log(qq.format("Detected valid filename click event on file '{}', ID: {}.", spec.onGetName(fileId), fileId)); + qq.preventDefault(event); + + inheritedInternalApi.handleFilenameEdit(fileId, target, true); + } + } + } + + spec.eventType = "click"; + spec.onHandled = examineEvent; + + qq.extend(this, new qq.FilenameEditHandler(spec, inheritedInternalApi)); +}; + +/*globals qq */ +// Child of FilenameEditHandler. Used to detect focusin events on file edit input elements. +qq.FilenameInputFocusInHandler = function(s, inheritedInternalApi) { + "use strict"; + + var spec = { + templating: null, + onGetUploadStatus: function(fileId) {}, + log: function(message, lvl) {} + }; + + if (!inheritedInternalApi) { + inheritedInternalApi = {}; + } + + // This will be called by the parent handler when a `focusin` event is received on the list element. + function handleInputFocus(target, event) { + if (spec.templating.isEditInput(target)) { + var fileId = spec.templating.getFileId(target), + status = spec.onGetUploadStatus(fileId); + + if (status === qq.status.SUBMITTED) { + spec.log(qq.format("Detected valid filename input focus event on file '{}', ID: {}.", spec.onGetName(fileId), fileId)); + inheritedInternalApi.handleFilenameEdit(fileId, target); + } + } + } + + spec.eventType = "focusin"; + spec.onHandled = handleInputFocus; + + qq.extend(spec, s); + qq.extend(this, new qq.FilenameEditHandler(spec, inheritedInternalApi)); +}; + +/*globals qq */ +/** + * Child of FilenameInputFocusInHandler. Used to detect focus events on file edit input elements. This child module is only + * needed for UAs that do not support the focusin event. Currently, only Firefox lacks this event. + * + * @param spec Overrides for default specifications + */ +qq.FilenameInputFocusHandler = function(spec) { + "use strict"; + + spec.eventType = "focus"; + spec.attachTo = null; + + qq.extend(this, new qq.FilenameInputFocusInHandler(spec, {})); +}; + +/*globals qq */ +// Handles edit-related events on a file item (FineUploader mode). This is meant to be a parent handler. +// Children will delegate to this handler when specific edit-related actions are detected. +qq.FilenameEditHandler = function(s, inheritedInternalApi) { + "use strict"; + + var spec = { + templating: null, + log: function(message, lvl) {}, + onGetUploadStatus: function(fileId) {}, + onGetName: function(fileId) {}, + onSetName: function(fileId, newName) {}, + onEditingStatusChange: function(fileId, isEditing) {} + }; + + function getFilenameSansExtension(fileId) { + var filenameSansExt = spec.onGetName(fileId), + extIdx = filenameSansExt.lastIndexOf("."); + + if (extIdx > 0) { + filenameSansExt = filenameSansExt.substr(0, extIdx); + } + + return filenameSansExt; + } + + function getOriginalExtension(fileId) { + var origName = spec.onGetName(fileId); + return qq.getExtension(origName); + } + + // Callback iff the name has been changed + function handleNameUpdate(newFilenameInputEl, fileId) { + var newName = newFilenameInputEl.value, + origExtension; + + if (newName !== undefined && qq.trimStr(newName).length > 0) { + origExtension = getOriginalExtension(fileId); + + if (origExtension !== undefined) { + newName = newName + "." + origExtension; + } + + spec.onSetName(fileId, newName); + } + + spec.onEditingStatusChange(fileId, false); + } + + // The name has been updated if the filename edit input loses focus. + function registerInputBlurHandler(inputEl, fileId) { + inheritedInternalApi.getDisposeSupport().attach(inputEl, "blur", function() { + handleNameUpdate(inputEl, fileId); + }); + } + + // The name has been updated if the user presses enter. + function registerInputEnterKeyHandler(inputEl, fileId) { + inheritedInternalApi.getDisposeSupport().attach(inputEl, "keyup", function(event) { + + var code = event.keyCode || event.which; + + if (code === 13) { + handleNameUpdate(inputEl, fileId); + } + }); + } + + qq.extend(spec, s); + + spec.attachTo = spec.templating.getFileList(); + + qq.extend(this, new qq.UiEventHandler(spec, inheritedInternalApi)); + + qq.extend(inheritedInternalApi, { + handleFilenameEdit: function(id, target, focusInput) { + var newFilenameInputEl = spec.templating.getEditInput(id); + + spec.onEditingStatusChange(id, true); + + newFilenameInputEl.value = getFilenameSansExtension(id); + + if (focusInput) { + newFilenameInputEl.focus(); + } + + registerInputBlurHandler(newFilenameInputEl, id); + registerInputEnterKeyHandler(newFilenameInputEl, id); + } + }); +}; +if (typeof define === 'function' && define.amd) { + define(function() { + return qq; + }); +} +else if (typeof module !== 'undefined' && module.exports) { + module.exports = qq; +} +else { + global.qq = qq; +} +}(window)); + +/*! 2016-06-16 */ diff --git a/public/loading.gif b/public/loading.gif new file mode 100644 index 0000000..6fba776 Binary files /dev/null and b/public/loading.gif differ diff --git a/public/upload.html b/public/upload.html new file mode 100644 index 0000000..a6876ac --- /dev/null +++ b/public/upload.html @@ -0,0 +1,131 @@ + + + + + Upload + + + + + + + + + + + + + + Derzeitige Dateien/Current files: + + Verzeichnis/Directory: , + token: , token OK: + + + + diff --git a/upload/.keep b/upload/.keep new file mode 100644 index 0000000..e69de29 diff --git a/views/index.haml b/views/index.haml new file mode 100644 index 0000000..0c07217 --- /dev/null +++ b/views/index.haml @@ -0,0 +1,22 @@ +!!! +%head + %title Upload files/Dateien hochladen + %meta{ :charset => "utf-8" } +%body + %form{ :action => 'mkdir', :method => 'post' } + Directory/Verzeichnis: + %input{ :name => 'dirname', :id => 'dirname' } + Token: + %input{ :name => 'token', :id => 'token' } + %input{ :type => 'submit', :value => "Verzeichnis auswählen/Select directory" } + %p + %strong + Eine kleine Anleitung: + Einfach einen Ordnernamen und ein dazugehöriges 'token' ausdenken (das ist + eine Art Passwort, welches nur dazu dient den Upload nach Abbruch fortzusetzen und gleichzeitig + eine mehrfache Benutzung eines Ordners zu verhindern). Wenn alles gut geht gelangt ihr auf eine sehr schlichte + Seite auf der man die Dateien zum Hochladen auswälen kann. Dabei bitte beachten, dass gleich benannte Dateien überschrieben werden (also besser mehrere Ordner anlegen)! + %br + %br + Bei Fragen bitte an Patrick <p@simianer.de> wenden. + diff --git a/views/index.slim b/views/index.slim deleted file mode 100644 index 270f91f..0000000 --- a/views/index.slim +++ /dev/null @@ -1,15 +0,0 @@ -h1 Sinatra File Upload - -- if @flash - = @flash - -form action='/upload' method='post' enctype='multipart/form-data' - p - input type='file' name='file' - p - input type='submit' value='Upload' -h3 Files -ul - - @files.each do |file| - li - a href='files/#{file}' #{file} diff --git a/views/layout.slim b/views/layout.slim deleted file mode 100644 index 0d4d1f1..0000000 --- a/views/layout.slim +++ /dev/null @@ -1,8 +0,0 @@ -doctype -html xmlns='http://www.w3.org/1999/xhtml' - head - title Sinatra File Upload - link rel='stylesheet' type='text/css' href='css/style.css' - meta content='text/html; charset=utf-8' http-equiv='Content-Type' - body - == yield -- cgit v1.2.3
Verzeichnis/Directory: , + token: , token OK: