From 99cc0ef49a1d087401dbd8e1cfabc7c8d5cc2104 Mon Sep 17 00:00:00 2001 From: sostler Date: Fri, 29 Jan 2010 01:04:51 -0500 Subject: Image uploading initial commit --- src/site.clj | 87 ++++-- static/js/ajaxupload.js | 673 +++++++++++++++++++++++++++++++++++++++++++++++ static/js/jquery.form.js | 660 ++++++++++++++++++++++++++++++++++++++++++++++ static/upload-test.html | 47 ++++ 4 files changed, 1451 insertions(+), 16 deletions(-) create mode 100755 static/js/ajaxupload.js create mode 100755 static/js/jquery.form.js create mode 100755 static/upload-test.html diff --git a/src/site.clj b/src/site.clj index 972da44..1d55ac7 100755 --- a/src/site.clj +++ b/src/site.clj @@ -2,10 +2,13 @@ (:import java.lang.System java.text.SimpleDateFormat java.util.Date + java.io.File org.apache.commons.codec.digest.DigestUtils javax.servlet.http.Cookie org.antlr.stringtemplate.StringTemplateGroup) (:use compojure + clojure.contrib.str-utils + clojure.contrib.duck-streams clojure.contrib.json.write clojure.contrib.sql)) @@ -49,20 +52,36 @@ (. Thread (sleep flusher-sleep-ms)) x) +;; Configuration + +(def *server-url* + (if (= (System/getProperty "user.name") "dumpfmprod") + "http://dump.fm" + "http://localhost:8080")) + +(def *image-directory* "images") + +; Create image directory if it doesn't exist. +(.mkdir (new File *image-directory*)) + ;; Utils -(defn encode-html-entities [s] +(defn replace-in-str [s table] (loop [ret s - [[char replacement] & rest] [["&" "&"] - ["'" "'"] - ["\"" """] - ["<" "<"] - [">" ">"]]] + [[char replacement] & rest] table] (if (nil? char) ret (recur (.replaceAll ret char replacement) rest)))) +(defn encode-html-entities [s] + (replace-in-str s [["&" "&"] + ["'" "'"] + ["\"" """] + ["<" "<"] + [">" ">"]])) + + (defn swap [f] (fn [& more] (apply f (reverse more)))) @@ -77,6 +96,11 @@ (defn non-empty-string? [s] (and s (> (count s) 0))) +(defn rel-join [& more] + (str-join (System/getProperty "file.separator") + (cons (System/getProperty "user.dir") + more))) + ;; Database (defn do-select [query] @@ -220,7 +244,7 @@ (defn login [session params] (let [nick (params :nick) hash (params :hash) - db-user (authorize-nick-hash nick hash)] + db-user (authorize-nick-hash nick hash)] (if db-user [(populate-session-from-db db-user) (resp-success "OK")] @@ -447,9 +471,31 @@ ;; Upload -; TODO +(defn format-filename [s] + (let [spaceless (.replace s \space \-) + subbed (re-gsub #"[^\w.-]" "" spaceless)] + (str (System/currentTimeMillis) "-" subbed))) + (defn upload [session params] - nil) + (if (not (session :nick)) + [404 "NOT_LOGGED_IN"] + (let [image (:image params) + filename (format-filename (:filename image)) + dest (File. (rel-join *image-directory* filename)) + image-url (str-join "/" [*server-url* "images" (.getName dest )])] + (copy (:tempfile image) dest) + (let [room (@rooms (params :room)) + msg-id (msg-db (session :user_id) (room :room_id) image-url) + now (new Date) + msg (struct message-struct (session :nick) image-url now msg-id)] + (dosync + (add-message msg room))) + [200 image-url]))) + +;; 404 + +(defn unknown-page [params] + [404 "Page not Found"]) ;; Compojure Routes @@ -457,19 +503,20 @@ [{:headers {"Cache-Control" "no-cache, no-store, max-age=0, must-revalidate"}} resp]) -(defn serve-static [path] +(defn serve-static [dir path] ; TODO: cache policy for other static files (js, css, etc.) (let [cache-header (if (re-find pic-regex path) {:headers {"Cache-Control" "post-check=3600,pre-check=43200"}} {})] [cache-header - (serve-file "static" path)])) + (serve-file dir path)])) (defroutes pichat (GET "/" (no-cache (landing session))) - (GET "/static/*" (serve-static (params :*))) - (GET "/favicon.ico" (serve-static "favicon.ico")) + (GET "/static/*" (serve-static "static" (params :*))) + (GET "/images/*" (serve-static *image-directory* (params :*))) + (GET "/favicon.ico" (serve-static "static" "favicon.ico")) (GET "/u/:nick" (profile session (params :nick) "0")) (GET "/u/:nick/" (profile session (params :nick) "0")) (GET "/u/:nick/:offset" (profile session @@ -478,7 +525,7 @@ (GET "/update-profile" (update-profile session params)) (GET "/login" (login session params)) (GET "/logout" (logout session)) - (GET "/register" (serve-file "static" "register.html")) + (GET "/register" (serve-static "static" "register.html")) (GET "/submit-registration" (register session params)) (GET "/:room/chat" (no-cache (validated-chat session (-> request :route-params :room)))) (GET "/chat" (no-cache (validated-chat session "RoomA"))) @@ -493,13 +540,20 @@ (-> request :route-params :room) (-> request :route-params :offset) params)) - (GET "/upload" (upload session)) - (ANY "*" [404 "Page not found"])) + (ANY "*" (unknown-page params))) (decorate pichat (with-mimetypes) (with-session {:type :memory, :expires (* 60 60)})) +; All uploading-related actions use the with-multipart decoration. +(defroutes multipart + (POST "/upload" (upload session params))) + +(decorate multipart + (with-mimetypes) + (with-session {:type :memory, :expires (* 60 60)}) + (with-multipart)) ;; Load messages from database @@ -515,6 +569,7 @@ :messages (ref (fetch-messages-by-room (room-db :room_id) false))}))) (run-server {:port 8080} + "/upload" (servlet multipart) "/*" (servlet pichat)) (send-off flusher flush!) \ No newline at end of file diff --git a/static/js/ajaxupload.js b/static/js/ajaxupload.js new file mode 100755 index 0000000..7e51768 --- /dev/null +++ b/static/js/ajaxupload.js @@ -0,0 +1,673 @@ +/** + * AJAX Upload ( http://valums.com/ajax-upload/ ) + * Copyright (c) Andris Valums + * Licensed under the MIT license ( http://valums.com/mit-license/ ) + * Thanks to Gary Haran, David Mark, Corey Burns and others for contributions + */ +(function () { + /* global window */ + /* jslint browser: true, devel: true, undef: true, nomen: true, bitwise: true, regexp: true, newcap: true, immed: true */ + + /** + * Wrapper for FireBug's console.log + */ + function log(){ + if (typeof(console) != 'undefined' && typeof(console.log) == 'function'){ + Array.prototype.unshift.call(arguments, '[Ajax Upload]'); + console.log( Array.prototype.join.call(arguments, ' ')); + } + } + + /** + * Attaches event to a dom element. + * @param {Element} el + * @param type event name + * @param fn callback This refers to the passed element + */ + function addEvent(el, type, fn){ + if (el.addEventListener) { + el.addEventListener(type, fn, false); + } else if (el.attachEvent) { + el.attachEvent('on' + type, function(){ + fn.call(el); + }); + } else { + throw new Error('not supported or DOM not loaded'); + } + } + + /** + * Attaches resize event to a window, limiting + * number of event fired. Fires only when encounteres + * delay of 100 after series of events. + * + * Some browsers fire event multiple times when resizing + * http://www.quirksmode.org/dom/events/resize.html + * + * @param fn callback This refers to the passed element + */ + function addResizeEvent(fn){ + var timeout; + + addEvent(window, 'resize', function(){ + if (timeout){ + clearTimeout(timeout); + } + timeout = setTimeout(fn, 100); + }); + } + + // Needs more testing, will be rewriten for next version + // getOffset function copied from jQuery lib (http://jquery.com/) + if (document.documentElement.getBoundingClientRect){ + // Get Offset using getBoundingClientRect + // http://ejohn.org/blog/getboundingclientrect-is-awesome/ + var getOffset = function(el){ + var box = el.getBoundingClientRect(); + var doc = el.ownerDocument; + var body = doc.body; + var docElem = doc.documentElement; // for ie + var clientTop = docElem.clientTop || body.clientTop || 0; + var clientLeft = docElem.clientLeft || body.clientLeft || 0; + + // In Internet Explorer 7 getBoundingClientRect property is treated as physical, + // while others are logical. Make all logical, like in IE8. + var zoom = 1; + if (body.getBoundingClientRect) { + var bound = body.getBoundingClientRect(); + zoom = (bound.right - bound.left) / body.clientWidth; + } + + if (zoom > 1) { + clientTop = 0; + clientLeft = 0; + } + + var top = box.top / zoom + (window.pageYOffset || docElem && docElem.scrollTop / zoom || body.scrollTop / zoom) - clientTop, left = box.left / zoom + (window.pageXOffset || docElem && docElem.scrollLeft / zoom || body.scrollLeft / zoom) - clientLeft; + + return { + top: top, + left: left + }; + }; + } else { + // Get offset adding all offsets + var getOffset = function(el){ + var top = 0, left = 0; + do { + top += el.offsetTop || 0; + left += el.offsetLeft || 0; + el = el.offsetParent; + } while (el); + + return { + left: left, + top: top + }; + }; + } + + /** + * Returns left, top, right and bottom properties describing the border-box, + * in pixels, with the top-left relative to the body + * @param {Element} el + * @return {Object} Contains left, top, right,bottom + */ + function getBox(el){ + var left, right, top, bottom; + var offset = getOffset(el); + left = offset.left; + top = offset.top; + + right = left + el.offsetWidth; + bottom = top + el.offsetHeight; + + return { + left: left, + right: right, + top: top, + bottom: bottom + }; + } + + /** + * Helper that takes object literal + * and add all properties to element.style + * @param {Element} el + * @param {Object} styles + */ + function addStyles(el, styles){ + for (var name in styles) { + if (styles.hasOwnProperty(name)) { + el.style[name] = styles[name]; + } + } + } + + /** + * Function places an absolutely positioned + * element on top of the specified element + * copying position and dimentions. + * @param {Element} from + * @param {Element} to + */ + function copyLayout(from, to){ + var box = getBox(from); + + addStyles(to, { + position: 'absolute', + left : box.left + 'px', + top : box.top + 'px', + width : from.offsetWidth + 'px', + height : from.offsetHeight + 'px' + }); + } + + /** + * Creates and returns element from html chunk + * Uses innerHTML to create an element + */ + var toElement = (function(){ + var div = document.createElement('div'); + return function(html){ + div.innerHTML = html; + var el = div.firstChild; + return div.removeChild(el); + }; + })(); + + /** + * Function generates unique id + * @return unique id + */ + var getUID = (function(){ + var id = 0; + return function(){ + return 'ValumsAjaxUpload' + id++; + }; + })(); + + /** + * Get file name from path + * @param {String} file path to file + * @return filename + */ + function fileFromPath(file){ + return file.replace(/.*(\/|\\)/, ""); + } + + /** + * Get file extension lowercase + * @param {String} file name + * @return file extenstion + */ + function getExt(file){ + return (-1 !== file.indexOf('.')) ? file.replace(/.*[.]/, '') : ''; + } + + function hasClass(el, name){ + var re = new RegExp('\\b' + name + '\\b'); + return re.test(el.className); + } + function addClass(el, name){ + if ( ! hasClass(el, name)){ + el.className += ' ' + name; + } + } + function removeClass(el, name){ + var re = new RegExp('\\b' + name + '\\b'); + el.className = el.className.replace(re, ''); + } + + function removeNode(el){ + el.parentNode.removeChild(el); + } + + /** + * Easy styling and uploading + * @constructor + * @param button An element you want convert to + * upload button. Tested dimentions up to 500x500px + * @param {Object} options See defaults below. + */ + window.AjaxUpload = function(button, options){ + this._settings = { + // Location of the server-side upload script + action: 'upload.php', + // File upload name + name: 'userfile', + // Additional data to send + data: {}, + // Submit file as soon as it's selected + autoSubmit: true, + // The type of data that you're expecting back from the server. + // html and xml are detected automatically. + // Only useful when you are using json data as a response. + // Set to "json" in that case. + responseType: false, + // Class applied to button when mouse is hovered + hoverClass: 'hover', + // Class applied to button when AU is disabled + disabledClass: 'disabled', + // When user selects a file, useful with autoSubmit disabled + // You can return false to cancel upload + onChange: function(file, extension){ + }, + // Callback to fire before file is uploaded + // You can return false to cancel upload + onSubmit: function(file, extension){ + }, + // Fired when file upload is completed + // WARNING! DO NOT USE "FALSE" STRING AS A RESPONSE! + onComplete: function(file, response){ + } + }; + + // Merge the users options with our defaults + for (var i in options) { + if (options.hasOwnProperty(i)){ + this._settings[i] = options[i]; + } + } + + // button isn't necessary a dom element + if (button.jquery){ + // jQuery object was passed + button = button[0]; + } else if (typeof button == "string") { + if (/^#.*/.test(button)){ + // If jQuery user passes #elementId don't break it + button = button.slice(1); + } + + button = document.getElementById(button); + } + + if ( ! button || button.nodeType !== 1){ + throw new Error("Please make sure that you're passing a valid element"); + } + + if ( button.nodeName.toUpperCase() == 'A'){ + // disable link + addEvent(button, 'click', function(e){ + if (e && e.preventDefault){ + e.preventDefault(); + } else if (window.event){ + window.event.returnValue = false; + } + }); + } + + // DOM element + this._button = button; + // DOM element + this._input = null; + // If disabled clicking on button won't do anything + this._disabled = false; + + // if the button was disabled before refresh if will remain + // disabled in FireFox, let's fix it + this.enable(); + + this._rerouteClicks(); + }; + + // assigning methods to our class + AjaxUpload.prototype = { + setData: function(data){ + this._settings.data = data; + }, + disable: function(){ + addClass(this._button, this._settings.disabledClass); + this._disabled = true; + + var nodeName = this._button.nodeName.toUpperCase(); + if (nodeName == 'INPUT' || nodeName == 'BUTTON'){ + this._button.setAttribute('disabled', 'disabled'); + } + + // hide input + if (this._input){ + // We use visibility instead of display to fix problem with Safari 4 + // The problem is that the value of input doesn't change if it + // has display none when user selects a file + this._input.parentNode.style.visibility = 'hidden'; + } + }, + enable: function(){ + removeClass(this._button, this._settings.disabledClass); + this._button.removeAttribute('disabled'); + this._disabled = false; + + }, + /** + * Creates invisible file input + * that will hover above the button + *
+ */ + _createInput: function(){ + var self = this; + + var input = document.createElement("input"); + input.setAttribute('type', 'file'); + input.setAttribute('name', this._settings.name); + + addStyles(input, { + 'position' : 'absolute', + // in Opera only 'browse' button + // is clickable and it is located at + // the right side of the input + 'right' : 0, + 'margin' : 0, + 'padding' : 0, + 'fontSize' : '480px', + 'cursor' : 'pointer' + }); + + var div = document.createElement("div"); + addStyles(div, { + 'display' : 'block', + 'position' : 'absolute', + 'overflow' : 'hidden', + 'margin' : 0, + 'padding' : 0, + 'opacity' : 0, + // Make sure browse button is in the right side + // in Internet Explorer + 'direction' : 'ltr', + //Max zIndex supported by Opera 9.0-9.2 + 'zIndex': 2147483583 + }); + + // Make sure that element opacity exists. + // Otherwise use IE filter + if ( div.style.opacity !== "0") { + if (typeof(div.filters) == 'undefined'){ + throw new Error('Opacity not supported by the browser'); + } + div.style.filter = "alpha(opacity=0)"; + } + + addEvent(input, 'change', function(){ + + if ( ! input || input.value === ''){ + return; + } + + // Get filename from input, required + // as some browsers have path instead of it + var file = fileFromPath(input.value); + + if (false === self._settings.onChange.call(self, file, getExt(file))){ + self._clearInput(); + return; + } + + // Submit form when value is changed + if (self._settings.autoSubmit) { + self.submit(); + } + }); + + addEvent(input, 'mouseover', function(){ + addClass(self._button, self._settings.hoverClass); + }); + + addEvent(input, 'mouseout', function(){ + removeClass(self._button, self._settings.hoverClass); + + // We use visibility instead of display to fix problem with Safari 4 + // The problem is that the value of input doesn't change if it + // has display none when user selects a file + input.parentNode.style.visibility = 'hidden'; + + }); + + div.appendChild(input); + document.body.appendChild(div); + + this._input = input; + }, + _clearInput : function(){ + if (!this._input){ + return; + } + + // this._input.value = ''; Doesn't work in IE6 + removeNode(this._input.parentNode); + this._input = null; + this._createInput(); + + removeClass(this._button, this._settings.hoverClass); + }, + /** + * Function makes sure that when user clicks upload button, + * the this._input is clicked instead + */ + _rerouteClicks: function(){ + var self = this; + + // IE will later display 'access denied' error + // if you use using self._input.click() + // other browsers just ignore click() + + addEvent(self._button, 'mouseover', function(){ + if (self._disabled){ + return; + } + + if ( ! self._input){ + self._createInput(); + } + + var div = self._input.parentNode; + copyLayout(self._button, div); + div.style.visibility = 'visible'; + + }); + + + // commented because we now hide input on mouseleave + /** + * When the window is resized the elements + * can be misaligned if button position depends + * on window size + */ + //addResizeEvent(function(){ + // if (self._input){ + // copyLayout(self._button, self._input.parentNode); + // } + //}); + + }, + /** + * Creates iframe with unique name + * @return {Element} iframe + */ + _createIframe: function(){ + // We can't use getTime, because it sometimes return + // same value in safari :( + var id = getUID(); + + // We can't use following code as the name attribute + // won't be properly registered in IE6, and new window + // on form submit will open + // var iframe = document.createElement('iframe'); + // iframe.setAttribute('name', id); + + var iframe = toElement('