pyc-website
main website for pyc inc.
git clone https://9o.is/git/pyc-website.git
commit 168f13ec77dc5848c610d7498e96f3e4629e89e8 parent 69fb169b6c1e3d97107625377eea2cd03ff2c19b Author: Jul <jul@9o.is> Date: Tue, 3 Jun 2014 15:39:21 -0400 Id Verification (fixes issue #11) Diffstat:
| M | build.config.js | | | 3 | ++- |
| M | project/Build.scala | | | 1 | + |
| M | src/main/resources/props/default.props | | | 5 | +++++ |
| M | src/main/resources/props/production.default.props | | | 4 | ++++ |
| M | src/main/scala/bootstrap/liftweb/Boot.scala | | | 4 | ++++ |
| M | src/main/scala/inc/pyc/config/Site.scala | | | 19 | ++++++++++++++++++- |
| A | src/main/scala/inc/pyc/lib/IdVerificationUpload.scala | | | 129 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/main/scala/inc/pyc/lib/aws/S3.scala | | | 179 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/main/scala/inc/pyc/model/User.scala | | | 3 | ++- |
| A | src/main/scala/inc/pyc/model/field/IdVerificationField.scala | | | 80 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/main/scala/inc/pyc/snippet/UserSnip.scala | | | 108 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/main/webapp/app/App.js | | | 121 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- |
| A | src/main/webapp/img/identity-benefits-chart.jpg | | | 0 | |
| M | src/main/webapp/less/overrides.less | | | 8 | ++++++++ |
| M | src/main/webapp/less/styles.less | | | 2 | +- |
| A | src/main/webapp/settings/verification.html | | | 12 | ++++++++++++ |
| A | src/main/webapp/settings/verified.html | | | 38 | ++++++++++++++++++++++++++++++++++++++ |
| A | src/main/webapp/templates-hidden/parts/id-verification-form.html | | | 95 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/main/webapp/vendor/angular-file-upload.js | | | 707 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
19 files changed, 1511 insertions(+), 7 deletions(-)
diff --git a/build.config.js b/build.config.js @@ -62,7 +62,8 @@ module.exports = { "<%= dirs.vendor %>/underscore.min.js", "<%= dirs.vendor %>/angular-google-maps.min.js", "<%= dirs.vendor %>/ui-mask.js", - "<%= dirs.vendor %>/liftAjax.js" + "<%= dirs.vendor %>/liftAjax.js", + "<%= dirs.vendor %>/angular-file-upload.js" ], css: [ ], diff --git a/project/Build.scala b/project/Build.scala @@ -13,6 +13,7 @@ object LiftProjectBuild extends Build { "net.liftweb" %% "lift-mongodb-record" % Ver.lift % "compile", "net.liftmodules" %% ("mongoauth_"+Ver.lift_edition) % "0.6-SNAPSHOT" % "compile", "net.liftmodules" %% ("extras_"+Ver.lift_edition) % "0.4-SNAPSHOT" % "compile", + "net.databinder.dispatch" %% "dispatch-core" % "0.10.0", "org.eclipse.jetty" % "jetty-webapp" % Ver.jetty % "container", "ch.qos.logback" % "logback-classic" % "1.0.13" % "compile", "org.scalatest" %% "scalatest" % "1.9.2" % "test", diff --git a/src/main/resources/props/default.props b/src/main/resources/props/default.props @@ -1,3 +1,4 @@ +<<<<<<< HEAD mail.smtp.host=smtp.gmail.com mail.smtp.user= mail.smtp.pass= @@ -7,3 +8,7 @@ mongo.default.port= mongo.default.name= mongo.default.user= mongo.default.pwd= + +idverify.access.key= +idverify.secret.key= +idverify.bucket= diff --git a/src/main/resources/props/production.default.props b/src/main/resources/props/production.default.props @@ -12,3 +12,7 @@ mongo.default.user= mongo.default.pwd= google.analytics.id= + +idverify.access.key= +idverify.secret.key= +idverify.bucket= diff --git a/src/main/scala/bootstrap/liftweb/Boot.scala b/src/main/scala/bootstrap/liftweb/Boot.scala @@ -10,6 +10,7 @@ import util._ import util.Helpers._ import inc.pyc.config._ +import inc.pyc.lib._ import inc.pyc.model.{SystemUser, User} import inc.pyc.lib.NgUIRouterFactory @@ -83,6 +84,9 @@ class Boot extends Loggable { GoogleAnalytics.init LiftRules.statelessDispatch.append(inc.pyc.snippet.Sitemap) + + // ID Verification API + IdVerificationUpload.init() } private def prettyPrintMime(m: MimeMessage): String = { diff --git a/src/main/scala/inc/pyc/config/Site.scala b/src/main/scala/inc/pyc/config/Site.scala @@ -7,7 +7,7 @@ import model.EmailResetToken._ import net.liftweb._ import common._ -import http.{S, OkResponse, RedirectResponse, RequestVar} +import http.{S, OkResponse, RedirectResponse, RequestVar, Templates} import sitemap._ import sitemap.Loc._ import net.liftmodules.mongoauth.Locs @@ -26,6 +26,13 @@ case class MenuLoc(menu: Menu) { lazy val fullUrl: String = S.hostAndPath+menu.loc.calcDefaultHref } +/* + * Various emails used here and there + */ +object Emails { + val idVerification = "idverification@"+Site.domain +} + object Site extends Locs { import MenuGroups._ @@ -52,6 +59,15 @@ object Site extends Locs { val password = MenuLoc(Menu.i("Password") / "settings" / "password" >> SettingsGroup >> RequireLoggedIn) val emailResetToken = MenuLoc(buildEmailResetTokenMenu) + val idVerification = MenuLoc(Menu.i("ID Verification") / "settings" / "verification" >> SettingsGroup >> RequireLoggedIn >> + TemplateBox(() => User.currentUser.map { user => + val page = + if (user.isPycVerified) "verified" + else "verification" + + Templates("settings" :: page :: Nil) openOr xml.NodeSeq.Empty + })) + val account = MenuLoc(Menu.i("Account") / "settings" / "account" >> SettingsGroup >> RequireLoggedIn) val editProfile = MenuLoc(Menu("EditProfile", "Profile") / "settings" / "profile" >> SettingsGroup >> RequireLoggedIn) @@ -74,6 +90,7 @@ object Site extends Locs { forgotPassword.menu, settings.menu, password.menu, + idVerification.menu, emailResetToken.menu, Menu.i("Error") / "error" >> Hidden, Menu.i("404") / "404" >> Hidden, diff --git a/src/main/scala/inc/pyc/lib/IdVerificationUpload.scala b/src/main/scala/inc/pyc/lib/IdVerificationUpload.scala @@ -0,0 +1,128 @@ +package inc.pyc +package lib + +import aws._ +import model._ +import config._ +import net.liftweb._ +import http._ +import util._ +import rest._ +import common._ +import js.JsCmds._ +import net.liftmodules.mongoauth.MongoAuth + +/** + * The identification files uploaded by a user will be stored here. + * Assure to clean up when done to save memory. + */ +object IdVerificationFiles extends SessionVar[List[FileParamHolder]](Nil) + +/** + * The REST point is used to load identification files to memory only. + * Manipulation should occur somewhere else (like snippet). + */ +object IdVerificationUpload extends RestHelper with Logger { + + def init(): Unit = { + LiftRules.dispatch.append(IdVerificationUpload) + } + + /** + * File can only be a maximum of 5 MB. + */ + def imgTooLarge(file: FileParamHolder) = file.length > 5243000 + + /** + * Only allow images of types: jpeg, png, svg + */ + def imgInvalid(file: FileParamHolder) = ! List( + "image/jpeg", "image/png", "image/svg+xml").exists(_ == file.mimeType) + + /** + * The maximum amount of files allowed in memory per user. + */ + def maxFilesInMemory = 3 + + /** + * Checks if another file can be added to memory or not. + */ + def tooManyFiles = IdVerificationFiles.get.size >= maxFilesInMemory + + /** + * API for uploading files to server. + */ + serve("settings" / "verification" prefix { + case "upload" :: Nil Post req => + for { + user <- User.currentUser ?~ "Must be logged in" ~> 400 + file <- Box(req.uploadedFiles) ?~ "File not found" + } yield { + if(imgInvalid(file)) + ResponseWithReason(BadResponse(), "Can only upload jpeg, png or svg file types.") + else if(imgTooLarge(file)) + ResponseWithReason(BadResponse(), "Image can only be 5MB or less.") + else if(tooManyFiles) + ResponseWithReason(BadResponse(), "Up to "+maxFilesInMemory+" files can be uploaded at once.") + else { + val thisreq = req + IdVerificationFiles.set(IdVerificationFiles.get :+ file) + OkResponse() + } + } + }) +} + +/** + * Other utility functions to help with the process of + * uploading identification files. + */ +object IdVerificationHelper { + + private val s3Obj = new S3( + Props.get("idverify.access.key", "NONE"), + Props.get("idverify.secret.key", "NONE"), + Props.get("idverify.bucket", "NONE")) + + /** + * Create an S3 object for uploading files. + */ + def s3(path: String = "") = s3Obj.copy(path = path) + + /** + * S3 folder formatting: /full_name/id/date/ + */ + def createS3Folder(user: User) = { + val prefix = if(Props.devMode) "dev/" else "" + + prefix + s"%s_%s/%s/%s/".format(user.fname.get, + user.lname.get, user.id.get, new java.util.Date().toString) + } + + /** + * Notifies us of a new user requesting their + * identification to be verified. + * @param thirdParty if the user is requesting verification with 3rd party + */ + def notifyIdentityRequest(user: User, thirdParty: Boolean = false): Unit = { + import net.liftweb.util.Mailer._ + + val msgTxt = + s""" + |${if(thirdParty) "3RD PARTY VERIFICATION" else ""} + |Identity Check Request! + |Full Name: ${user.fname} ${user.lname} + |Id: ${user.id.get} + |Status: ${user.idVerification.get} + |Email: ${user.email.get} + |Username: ${user.username.get} + """ + + sendMail( + From(MongoAuth.systemFancyEmail), + Subject("PYC: Identity Check"), + To(Emails.idVerification), + PlainMailBodyType(msgTxt) + ) + } +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/lib/aws/S3.scala b/src/main/scala/inc/pyc/lib/aws/S3.scala @@ -0,0 +1,178 @@ +package inc.pyc +package lib +package aws + +import dispatch._, Defaults._ +import com.ning.http.client.RequestBuilder +import com.ning.http.util.Base64 +import java.net.URLEncoder._ + +private object AmazonS3 { + + import javax.crypto + + import java.util.{Date, Locale, SimpleTimeZone} + import java.text.SimpleDateFormat + + val UTF_8 = "UTF-8" + + val Root = "s3.amazonaws.com" + + object rfc822DateParser extends SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US) { + this.setTimeZone(new SimpleTimeZone(0, "GMT")) + } + + def trim(s: String): String = s.dropWhile(_ == ' ').reverse.dropWhile(_ == ' ').reverse.toString + + def md5(bytes: Array[Byte]) = { + import java.security.MessageDigest + + val r = MessageDigest.getInstance("MD5") + r.reset() + r.update(bytes) + Base64.encode(r.digest) + } + + def md5(stream: java.io.InputStream) = { + import java.security.MessageDigest + + val buffer = new Array[Byte](1024) + val r = MessageDigest.getInstance("MD5") + var numRead: Int = 0 + do { + numRead = stream.read(buffer) + if (numRead > 0) { + r.update(buffer, 0, numRead) + } + } while (numRead != -1) + + stream.close() + Base64.encode(r.digest()) + } + + def sign(method: String, path: String, secretKey: String, date: Date, + contentType: Option[String], contentMd5: Option[String], amzHeaders: Map[String, Set[String]]): String = { + sign(method, path, secretKey, Left(date), contentType, contentMd5, amzHeaders) + } + + def sign(method: String, path: String, secretKey: String, dateOrExpires: Either[Date, Long], + contentType: Option[String], contentMd5: Option[String], amzHeaders: Map[String, Set[String]]) = { + val SHA1 = "HmacSHA1" + val message = canonicalString(method, path, dateOrExpires, contentType, contentMd5, amzHeaders) + val sig = { + val mac = crypto.Mac.getInstance(SHA1) + val key = new crypto.spec.SecretKeySpec(bytes(secretKey), SHA1) + mac.init(key) + Base64.encode(mac.doFinal(bytes(message))) + } + sig + } + + def signedUri(accessKey: String, secretKey: String, method: String, path: String, amzHeaders: Map[String, Set[String]], + expires: Long = defaultExpiryTime, + contentType: Option[String] = None, contentMd5: Option[String] = None) = { + val signed = encode(sign(method, path, secretKey, Right(expires), contentType, contentMd5, amzHeaders), "UTF-8") + "%s?Signature=%s&Expires=%s&AWSAccessKeyId=%s".format(path, signed, expires, accessKey) + } + + def defaultExpiryTime = System.currentTimeMillis() / 1000 + 600 + + /** + * @return the canonical request string that needs to be signed for authentication + */ + def canonicalString(method: String, path: String, dateOrExpires: Either[Date, Long], contentType: Option[String], + contentMd5: Option[String], amzHeaders: Map[String, Set[String]]) = { + val amzString = amzHeaders.toList.sortWith(_._1.toLowerCase < _._1.toLowerCase).map { + case (k, v) => "%s:%s".format(k.toLowerCase, v.map(trim).mkString(",")) + } + val dateExpiresString = dateOrExpires match { + case Left(date) => rfc822DateParser.format(date) + case Right(expires) => expires.toString + } + (method :: contentMd5.getOrElse("") :: contentType.getOrElse("") :: dateExpiresString :: Nil) ++ amzString ++ List(path) mkString "\n" + } + + def bytes(s: String) = s.getBytes(UTF_8) + + implicit def Request2S3RequestSigner(r: RequestBuilder) = new S3RequestSigner(r) + + implicit def Request2S3RequestSigner(r: String) = new S3RequestSigner(new RequestBuilder().setUrl(r)) + + class S3RequestSigner(r: RequestBuilder) { + + import scala.collection.JavaConverters._ + + protected def path = RawUri(r.build.getUrl).path.getOrElse("") + + def <@(accessKey: String, secretKey: String) = { + val req = r.build + val contentStream = req.getStreamData + val contentMd5 = if (req.getContentLength <= 0) None else Some(md5(contentStream)) + + for (cmd5 <- contentMd5) + r.addHeader("Content-MD5", cmd5) + + val headers = req.getHeaders + val contentType = headers.keySet.asScala.find { + _.toLowerCase == "content-type" + }.map { + headers.get(_).asScala.head + } + + val d = new Date + r.addHeader("Authorization", "AWS %s:%s".format(accessKey, sign(req.getMethod, path, secretKey, d, contentType, contentMd5, amazonHeaders))) + r.addHeader("Date", AmazonS3.rfc822DateParser.format(d)) + r + } + + def signed(accessKey: String, secretKey: String, expires: Long = defaultExpiryTime, + contentType: Option[String] = None, contentMd5: Option[String] = None): RequestBuilder = { + val req = r.build + val path = RawUri(req.getUrl).path.getOrElse("") + val uri = signedUri(accessKey, secretKey, req.getMethod, path, amazonHeaders, expires, contentType, contentMd5) + val requestHeaders = for { + (key, Some(value)) <- Map("Content-Type" -> contentType, "Content-Md5" -> contentMd5) + } yield key -> value + (:/(AmazonS3.Root) / uri.substring(1)).setMethod(req.getMethod).setHeaders(req.getHeaders).secure <:< requestHeaders + } + + private def amazonHeaders = { + val headers = r.build.getHeaders + headers.keySet.asScala.filter { + _.toLowerCase.startsWith("x-amz") + } + .map(name => name -> headers.get(name).asScala.toSet).toMap + } + } + +} + +private object Bucket extends (String => RequestBuilder) { + def apply(name: String) = :/(AmazonS3.Root) / name +} + +/** + * Amazon S3 API + */ +case class S3(access_key: String, secret_key: String, bucket: String, path: String = "") { + import AmazonS3._ + + /** + * Creates a file with file name, binary data and file content type. + */ + def createFile(fn: String, data: Array[Byte], contentType: String) = + Http.configure(_ setCompressionEnabled true)(Bucket(bucket).PUT.setBody(data) / (path + fn) <:< + Map("content-type" -> contentType) <@(access_key, secret_key)) + + /** + * Deletes a file by file name. + */ + def deleteFile(fn: String) = + Http.configure(_ setCompressionEnabled true)(Bucket(bucket).DELETE / + (path + fn) <@(access_key, secret_key)) + + /** + * Generates file's S3 url given a file name. + */ + def fileUrl(fn: String) = "http://" + AmazonS3.Root + "/" + bucket + path + fn +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/model/User.scala b/src/main/scala/inc/pyc/model/User.scala @@ -1,6 +1,7 @@ package inc.pyc package model +import field._ import lib.RogueMetaRecord import org.bson.types.ObjectId @@ -16,7 +17,7 @@ import net.liftmodules.mongoauth._ import net.liftmodules.mongoauth.field._ import net.liftmodules.mongoauth.model._ -class User private () extends ProtoAuthUser[User] with ObjectIdPk[User] { +class User private () extends ProtoAuthUser[User] with ObjectIdPk[User] with UserVerification[User] { def meta = User def userIdAsString: String = id.toString diff --git a/src/main/scala/inc/pyc/model/field/IdVerificationField.scala b/src/main/scala/inc/pyc/model/field/IdVerificationField.scala @@ -0,0 +1,79 @@ +package inc.pyc +package model +package field + +import net.liftweb._ +import http._ +import util.Helpers._ +import record._ +import field._ +import json.JsonAST._ +import common._ + +object IdVerification extends Enumeration { + + val + Unverified, // not verified + PYC_Pending, // application submitted but not verified yet + PYC_Verified, // verified by us + Third_Party_Request, // requested to be verified by 3rd party + Third_Party_Pending, // user info was submitted to 3rd party; waiting for reply + Third_Party_Verified // verified by third party + = Verification + + def Verification = new Verification + + class Verification extends Val { + override def toString = super.toString.replace("_", " ") + } + + def options: List[(String, String)] = + IdVerification.values.map(i => (i.toString, i.toString)).toList + + def select(s: String) = tryo(IdVerification.withName(s)) +} + +/** + * ID Verification field for records. + */ +class IdVerificationField[OwnerType <: Record[OwnerType]](rec: OwnerType) extends EnumField(rec, IdVerification) { + override def setFromJValue(jvalue: JValue): Box[IdVerificationField.this.MyType] = { + val stringToInt = jvalue transform { + case JString(s) => + val value: Box[Int] = IdVerification.select(s).map(_.id) + value.map(JInt(_)).openOr(JNothing) + case _ => JNothing + } + super.setFromJValue(stringToInt) + } +} + +/** + * Add this to a user to keep track of the user's real ID verification stage. + */ +trait UserVerification[A <: Record[A]] { + this: A => + /** The record's real id verification stage. */ + object idVerification extends IdVerificationField[A](this) + + /** + * If user is verified by us. + */ + def isPycVerified: Boolean = + idVerification.get == IdVerification.PYC_Verified || + waiting3rdPartyVerification || + is3rdPartyVerified + + /** + * If user is waiting for verification from 3rd party company. + */ + def waiting3rdPartyVerification: Boolean = + idVerification.get == IdVerification.Third_Party_Request || + idVerification.get == IdVerification.Third_Party_Pending + + /** + * If user is verified by 3rd party company. + */ + def is3rdPartyVerified: Boolean = + idVerification.get == IdVerification.Third_Party_Verified +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/snippet/UserSnip.scala b/src/main/scala/inc/pyc/snippet/UserSnip.scala @@ -1,7 +1,10 @@ package inc.pyc package snippet +import lib._ import model._ +import field._ +import xml._ import net.liftweb._ import common._ import http._ @@ -25,6 +28,13 @@ trait AngularUserSnippet extends AngularSnippet with AngularImplicits { snip(u) }): JValue + protected def serveNodeseq(snip: User => NodeSeq): NodeSeq = + (for { + u <- user ?~ "User not found" + } yield { + snip(u) + }): NodeSeq + /** Validates and saves the currently signed in user. */ protected def validateAndSave(): JValue = serve { user => @@ -263,4 +273,102 @@ class UserSettingsEmail extends AngularUserSnippet with AngularImplicits { user => ("email" -> user.email.get) } +} + +class IdVerification extends AngularUserSnippet with AngularImplicits { + + def roundTrips: List[RoundTripInfo] = List( + "submit" -> submit _, + "thirdPartyVerify" -> thirdPartyVerify _, + "init" -> init _) + + def submit(model: JValue): JValue = serve { + user => + for { + JString(dob) <- model \ "dob" + JString(ssn) <- model \ "ssn" + JString(address) <- model \ "address" + JString(city) <- model \ "city" + JString(state) <- model \ "state" + } yield { + + val output = + s"Name: ${user.fname.get} ${user.lname.get}\n"+ + s"dob: $dob\n"+ + s"ssn: $ssn\n"+ + s"address: $address\n"+ + s"city: $city\n"+ + s"state: $state" + + IdVerificationFiles.set(IdVerificationFiles.get :+ + new InMemFileParamHolder("", "text/plain", "info", output.getBytes)) + + import IdVerificationHelper._ + val service = s3(createS3Folder(user)) + + val statusCodes = IdVerificationFiles.get.map { fp => + service.createFile(fp.fileName, fp.file, fp.mimeType) + } map { + f => + import dispatch._ + f().getStatusCode() + } + + IdVerificationFiles(Nil) + + if(statusCodes.forall(_ == 200)) { + user.idVerification(IdVerification.PYC_Pending) + user.update + notifyIdentityRequest(user) + NgAlert.success( + <i class="fa-fw fa fa-thumbs-o-up"></i> ++ + <span> We have received your application and will notify you as soon as we are done reviewing your information.</span> + ) + } else { + NgAlert.danger( + <i class="fa-fw fa fa-thumbs-o-down"></i> ++ + <span> Your information could not be uploaded. Please try again.</span>, Nil + ) + } + } + } + + def is3rdPartyVerified(ns: NodeSeq): NodeSeq = serveNodeseq { + user => + if(user.is3rdPartyVerified) ns else NodeSeq.Empty + } + + def waiting3rdPartyVerification(ns: NodeSeq): NodeSeq = serveNodeseq { + user => + if(user.waiting3rdPartyVerification && + !user.is3rdPartyVerified) ns + else NodeSeq.Empty + } + + def isPycVerified(ns: NodeSeq): NodeSeq = serveNodeseq { + user => + if(user.isPycVerified && + !user.waiting3rdPartyVerification && + !user.is3rdPartyVerified) ns + else NodeSeq.Empty + } + + def thirdPartyVerify(model: JValue): JValue = serve { + user => + user.idVerification(IdVerification.Third_Party_Request) + user.update + IdVerificationHelper.notifyIdentityRequest(user, thirdParty = true) + + NgAlert.success( + <i class="fa-fw fa fa-thumbs-o-up"></i> ++ + <span> Your application will be verified by the third party company. Thank you for your patience.</span> + ) + } + + def init(model: JValue): JValue = serve { + user => + ("idverification" -> user.idVerification.get.toString) ~ + ("fname" -> user.fname.get) ~ + ("lname" -> user.lname.get) + } } \ No newline at end of file diff --git a/src/main/webapp/app/App.js b/src/main/webapp/app/App.js @@ -1,4 +1,4 @@ -var app = angular.module("app", ['google-maps', 'ui.bootstrap', 'ui.router', 'ui.mask']); +var app = angular.module("app", ['google-maps', 'ui.bootstrap', 'ui.router', 'ui.mask', 'angularFileUpload']); var ZIP_CODE_REGEXP = /^(\d{5}(-\d{4})?|[A-Z]\d[A-Z] *\d[A-Z]\d)$/; var PASSWORD_REGEXP = /^(?=.*[^a-zA-Z])\S{8,}$/; @@ -315,6 +315,75 @@ app.controller('PasswordChangeCtrl', ['$scope', '$controller', '$rootScope', fun }; }]); +app.controller('IdVerificationCtrl', ['$scope', '$controller', '$rootScope', '$fileUploader', '$state', function($scope, $controller, $rootScope, $fileUploader, $state) { + $controller('LoadedFormCtrl', {$scope: $scope, $controller: $controller}); + $scope.init('IdVerification', 'init'); + + $scope.uploader = $fileUploader.create({ + scope: $scope, + url: '/settings/verification/upload' + }); + + // file must be an image + $scope.uploader.filters.push(function(item) { + var type = $scope.uploader.isHTML5 ? item.type : '/' + item.value.slice(item.value.lastIndexOf('.') + 1); + type = '|' + type.toLowerCase().slice(type.lastIndexOf('/') + 1) + '|'; + return '|jpg|png|jpeg|svg|'.indexOf(type) !== -1; + }); + + // file must be less than 5 MB (5243000 bytes) + $scope.uploader.filters.push(function(item) { + return item.size < 5243000; + }); + + // 3 files max are allowed + $scope.uploader.queueLimit = 3; + + // 2 files minimum required before submitting + $scope.filesRequired = function() { + return $scope.uploader.queue.length < 2; + }; + + $scope.uploader.bind('whenaddingfilefailed', function () { + $rootScope.$broadcast('alertDialog', {type: "danger", msg: "Your file could not be added."}); + }); + + $scope.uploader.bind('error', function () { + $rootScope.$broadcast('alertDialog', {type: "danger", msg: "Your files could not be uploaded. Please try again."}); + }); + + $scope.uploader.bind('success', function () { + var success = function(alert) { + $rootScope.$broadcast('alertDialog', alert); + $state.go($state.$current, null, { reload: true }); + }; + + var failure = function(alert) { + $rootScope.$broadcast('alertDialog', alert); + }; + + $scope.submit('IdVerification', 'submit', success, failure); + }); + + $scope.save = function() { + $scope.loading = true; + $scope.uploader.uploadAll(); + }; + + $scope.thirdPartyVerify = function() { + var success = function(alert) { + $rootScope.$broadcast('alertDialog', alert); + $state.go($state.$current, null, { reload: true }); + }; + + var failure = function(alert) { + $rootScope.$broadcast('alertDialog', alert); + }; + + $scope.submit('IdVerification', 'thirdPartyVerify', success, failure); + }; +}]); + app.controller('UserSettingsCtrl', ['$scope', '$controller', '$rootScope', function($scope, $controller, $rootScope) { $controller('AutoUpdateFormCtrl', {$scope: $scope, $controller: $controller, $rootScope: $rootScope}); $scope.init('UserSettings','init'); @@ -353,4 +422,50 @@ app.controller('GMapCtrl', ['$scope', function($scope) { }, zoom: $scope.zoom }; -}]); -\ No newline at end of file +}]); + +app.directive('ngThumb', ['$window', function($window) { + var helper = { + support: !!($window.FileReader && $window.CanvasRenderingContext2D), + isFile: function(item) { + return angular.isObject(item) && item instanceof $window.File; + }, + isImage: function(file) { + var type = '|' + file.type.slice(file.type.lastIndexOf('/') + 1) + '|'; + return '|jpg|png|jpeg|bmp|gif|'.indexOf(type) !== -1; + } + }; + + return { + restrict: 'A', + template: '<canvas/>', + link: function(scope, element, attributes) { + + function onLoadFile(event) { + var img = new Image(); + img.onload = onLoadImage; + img.src = event.target.result; + } + + function onLoadImage() { + var width = params.width || this.width / this.height * params.height; + var height = params.height || this.height / this.width * params.width; + canvas.attr({ width: width, height: height }); + canvas[0].getContext('2d').drawImage(this, 0, 0, width, height); + } + + if (!helper.support) { return; } + + var params = scope.$eval(attributes.ngThumb); + + if (!helper.isFile(params.file)) { return; } + if (!helper.isImage(params.file)) { return; } + + var canvas = element.find('canvas'); + var reader = new FileReader(); + + reader.onload = onLoadFile; + reader.readAsDataURL(params.file); + } + }; + }]); +\ No newline at end of file diff --git a/src/main/webapp/img/identity-benefits-chart.jpg b/src/main/webapp/img/identity-benefits-chart.jpg Binary files differ. diff --git a/src/main/webapp/less/overrides.less b/src/main/webapp/less/overrides.less @@ -226,4 +226,12 @@ body { .icon-append, .icon-prepend { border: none !important; } +} + +/************************************** + * File Upload + *************************************/ +.file-preview { + min-height: 150px; + border: dotted 3px @gray-light; } \ No newline at end of file diff --git a/src/main/webapp/less/styles.less b/src/main/webapp/less/styles.less @@ -40,7 +40,7 @@ @import "@{BootstrapPath}/jumbotron.less"; @import "@{BootstrapPath}/thumbnails.less"; @import "@{BootstrapPath}/alerts.less"; - //@import "@{BootstrapPath}/progress-bars.less"; +@import "@{BootstrapPath}/progress-bars.less"; //@import "@{BootstrapPath}/media.less"; //@import "@{BootstrapPath}/list-group.less"; //@import "@{BootstrapPath}/panels.less"; diff --git a/src/main/webapp/settings/verification.html b/src/main/webapp/settings/verification.html @@ -0,0 +1,12 @@ +<div data-lift="NgUIRouter.surround?withAjax=no-base-settings-wrap&with=settings-wrap&at=content"> + <div class="row margin-top-10"> + <div class="col-xs-12 col-sm-8 col-md-6 col-sm-offset-2 col-md-offset-0"> + <span data-lift="embed?what=/templates-hidden/parts/id-verification-form"></span> + </div> + <div class="col-xs-12 col-sm-8 col-md-6 col-sm-offset-2 col-md-offset-0"> + <p class="lead"> + Prove your identity for our customer benefits. + </p> + </div> + </div> +</div> diff --git a/src/main/webapp/settings/verified.html b/src/main/webapp/settings/verified.html @@ -0,0 +1,37 @@ +<div data-lift="NgUIRouter.surround?withAjax=no-base-settings-wrap&with=settings-wrap&at=content"> + <div class="row margin-top-10"> + <div class="col-xs-12 col-sm-8 col-md-6 col-sm-offset-2 col-md-offset-0"> + <div data-lift="IdVerification" ng-controller="IdVerificationCtrl" ng-cloak> + + <h2 class="text-primary"><b>Status: {{model.idverification}}</b></h2> + + <div data-lift="IdVerification.isPycVerified" class="padding-top-20"> + <button ng-click="thirdPartyVerify()" disabler ng-model="loading" type="button" class="btn btn-primary btn-lg">Verify with 3rd Party</button> + </div> + <p class="padding-top-20"> + <span data-lift="IdVerification.is3rdPartyVerified"> + Your identity has been completely verified. <br/> + You can now buy an unlimited amount of bitcoin! + </span> + <span data-lift="IdVerification.waiting3rdPartyVerification"> + Your identity is in the process of being verified. <br/> + We will notify you with updates. + </span> + <span data-lift="IdVerification.isPycVerified"> + Click the button to have your identity verified with a + third party company and buy unlimited amount of bitcoin. + <br/><br/> + Your current dollar to bitcoin limit is $2,999.99 per transaction. + </span> + </p> + </div> + </div> + <div class="col-xs-12 col-sm-8 col-md-6 col-sm-offset-2 col-md-offset-0"> + <p> + Prove your real identity to receive customer benefits + such as buying unlimited bitcoin per ATM transaction. + </p> + <img style="width:100%" src="/img/identity-benefits-chart.jpg" /> + </div> + </div> +</div> +\ No newline at end of file diff --git a/src/main/webapp/templates-hidden/parts/id-verification-form.html b/src/main/webapp/templates-hidden/parts/id-verification-form.html @@ -0,0 +1,94 @@ +<div data-lift="IdVerification" ng-controller="IdVerificationCtrl" ng-cloak> + + <h2 class="text-primary"><b>Status: {{model.idverification}}</b></h2> + + <form name="form" class="smart-form client-form" ng-submit="save()" ng-upload="complete(content)" novalidate> + <fieldset> + <section > + <label>Name: {{model.fname}} {{model.lname}}</label> + </section> + + <div class="row"> + <section class="col col-6"> + <label>Date of Birth</label> + <label class="input" ng-class="{{ stateSuccessError('dob') }}"> + <i class="icon-append fa fa-calendar-o"></i> + <input name="dob" ng-model="model.dob" ui-mask="99 / 99 / 9999" placeholder="xx / xx / xxxx" type="text" required> + <b class="tooltip tooltip-top-left">Please provide your date of birth.</b> + </label> + </section> + <section class="col col-6"> + <label>Last 4 digits of SSN</label> + <label class="input" ng-class="{{ stateSuccessError('ssn') }}"> + <i class="icon-append fa fa-asterisk"></i> + <input name="ssn" ng-model="model.ssn" ui-mask="9999" placeholder="xxxx" type="text" required> + <b class="tooltip tooltip-top-left">Please provide the last 4 digits of your social security number.</b> + </label> + </section> + </div> + + <section> + <label>Address <small> as shown in your ID</small></label> + <label class="input" ng-class="{{ stateSuccessError('address') }}"> + <input name="address" ng-model="model.address" type="text" required> + <b class="tooltip tooltip-top-left">Please provide the address as shown in your identification.</b> + </label> + </section> + + <div class="row"> + <section class="col col-6"> + <label>City</label> + <label class="input" ng-class="{{ stateSuccessError('city') }}"> + <input name="city" ng-model="model.city" type="text" required> + <b class="tooltip tooltip-top-left">Please provide the city as shown in your identification.</b> + </label> + </section> + <section class="col col-6" ng-class="{{ stateSuccessError('state') }}"> + <label>State</label> + <label data-lift="Selector.states" class="select"> + <select name="state" ng-model="model.state" required></select> + <i></i> + <b class="tooltip tooltip-top-left">Please provide the U.S. state as shown in your identification.</b> + </label> + </section> + </div> + + <h6>Identification Files: </h6> + + <section> + <label class="input input-file" ng-class="{{ stateSuccessError('idfile') }}"> + <div class="button"> + <input name="idfile" type="file" ng-file-select multiple required> + Browse + </div> + <input type="text" readonly=""></input> + <b class="tooltip tooltip-top-left">Click browse and upload your identification document.</b> + <small class="help-block"> + Must include at least 2 files: + <ul class="list-unstyled"> + <li>1. A government issued photo ID (Driver's License, Passport)</li> + <li>2. A document as proof of address (Utility bill, cell phone bill or auto/renters insurance)</li> + </ul> + </small> + </label> + </section> + + <div class="file-preview"> + <div ng-repeat="item in uploader.queue"> + <div class="pull-left padding-10"> + <strong>{{ item.file.name }}</strong> + <div ng-show="uploader.isHTML5" ng-thumb="{ file: item.file, height: 100 }"></div> + </div> + </div> + </div> + + <div ng-show="loading" class="progress progress-striped active margin-10"> + <div class="progress-bar" role="progressbar" ng-style="{ 'width': uploader.progress + '%' }"></div> + </div> + </fieldset> + + <footer> + <button type="submit" class="btn btn-primary" ng-disabled="form.$invalid || filesRequired()" disabler ng-model="loading">Verify My Identity</button> + </footer> + </form> +</div> +\ No newline at end of file diff --git a/src/main/webapp/vendor/angular-file-upload.js b/src/main/webapp/vendor/angular-file-upload.js @@ -0,0 +1,706 @@ +/* + angular-file-upload v0.5.7 + https://github.com/nervgh/angular-file-upload +*/ +(function(angular, factory) { + if (typeof define === 'function' && define.amd) { + define('angular-file-upload', ['angular'], function(angular) { + return factory(angular); + }); + } else { + return factory(angular); + } +}(angular || null, function(angular) { +var app = angular.module('angularFileUpload', []); + +// It is attached to an element that catches the event drop file +app.directive('ngFileDrop', ['$fileUploader', function ($fileUploader) { + 'use strict'; + + return { + // don't use drag-n-drop files in IE9, because not File API support + link: !$fileUploader.isHTML5 ? angular.noop : function (scope, element, attributes) { + element + .bind('drop', function (event) { + var dataTransfer = event.dataTransfer ? + event.dataTransfer : + event.originalEvent.dataTransfer; // jQuery fix; + if (!dataTransfer) return; + event.preventDefault(); + event.stopPropagation(); + scope.$broadcast('file:removeoverclass'); + scope.$emit('file:add', dataTransfer.files, scope.$eval(attributes.ngFileDrop)); + }) + .bind('dragover', function (event) { + var dataTransfer = event.dataTransfer ? + event.dataTransfer : + event.originalEvent.dataTransfer; // jQuery fix; + + event.preventDefault(); + event.stopPropagation(); + dataTransfer.dropEffect = 'copy'; + scope.$broadcast('file:addoverclass'); + }) + .bind('dragleave', function (event) { + if (event.target === element[0]) { + scope.$broadcast('file:removeoverclass'); + } + }); + } + }; +}]) + +// It is attached to an element which will be assigned to a class "ng-file-over" or ng-file-over="className" +app.directive('ngFileOver', function () { + 'use strict'; + + return { + link: function (scope, element, attributes) { + scope.$on('file:addoverclass', function () { + element.addClass(attributes.ngFileOver || 'ng-file-over'); + }); + scope.$on('file:removeoverclass', function () { + element.removeClass(attributes.ngFileOver || 'ng-file-over'); + }); + } + }; +}); +// It is attached to <input type="file"> element like <ng-file-select="options"> +app.directive('ngFileSelect', ['$fileUploader', function($fileUploader) { + 'use strict'; + + return { + link: function(scope, element, attributes) { + if(!$fileUploader.isHTML5) { + element.removeAttr('multiple'); + } + + element.bind('change', function() { + var data = $fileUploader.isHTML5 ? this.files : this; + var options = scope.$eval(attributes.ngFileSelect); + + scope.$emit('file:add', data, options); + + if($fileUploader.isHTML5 && element.attr('multiple')) { + element.prop('value', null); + } + }); + + element.prop('value', null); // FF fix + } + }; +}]); +app.factory('$fileUploader', ['$compile', '$rootScope', '$http', '$window', function($compile, $rootScope, $http, $window) { + 'use strict'; + + /** + * Creates a uploader + * @param {Object} params + * @constructor + */ + function Uploader(params) { + angular.extend(this, { + scope: $rootScope, + url: '/', + alias: 'file', + queue: [], + headers: {}, + progress: null, + autoUpload: false, + removeAfterUpload: false, + method: 'POST', + filters: [], + formData: [], + isUploading: false, + queueLimit: Number.MAX_VALUE, + withCredentials: false, + _nextIndex: 0, + _timestamp: Date.now() + }, params); + + // add default filters + this.filters.unshift(this._queueLimitFilter); + this.filters.unshift(this._emptyFileFilter); + + this.scope.$on('file:add', function(event, items, options) { + event.stopPropagation(); + this.addToQueue(items, options); + }.bind(this)); + + this.bind('beforeupload', Item.prototype._beforeupload); + this.bind('in:progress', Item.prototype._progress); + this.bind('in:success', Item.prototype._success); + this.bind('in:cancel', Item.prototype._cancel); + this.bind('in:error', Item.prototype._error); + this.bind('in:complete', Item.prototype._complete); + this.bind('in:progress', this._progress); + this.bind('in:complete', this._complete); + } + + Uploader.prototype = { + /** + * Link to the constructor + */ + constructor: Uploader, + + /** + * Returns "true" if item is DOMElement or a file with size > 0 + * @param {File|Input} item + * @returns {Boolean} + * @private + */ + _emptyFileFilter: function(item) { + return angular.isElement(item) ? true : !!item.size; + }, + + /** + * Returns "true" if the limit has not been reached + * @returns {Boolean} + * @private + */ + _queueLimitFilter: function() { + return this.queue.length < this.queueLimit; + }, + + /** + * Registers a event handler + * @param {String} event + * @param {Function} handler + * @return {Function} unsubscribe function + */ + bind: function(event, handler) { + return this.scope.$on(this._timestamp + ':' + event, handler.bind(this)); + }, + + /** + * Triggers events + * @param {String} event + * @param {...*} [some] + */ + trigger: function(event, some) { + arguments[0] = this._timestamp + ':' + event; + this.scope.$broadcast.apply(this.scope, arguments); + }, + + /** + * Checks a support the html5 uploader + * @returns {Boolean} + * @readonly + */ + isHTML5: !!($window.File && $window.FormData), + + /** + * Adds items to the queue + * @param {FileList|File|HTMLInputElement} items + * @param {Object} [options] + */ + addToQueue: function(items, options) { + var length = this.queue.length; + var list = 'length' in items ? items : [items]; + + angular.forEach(list, function(file) { + // check a [File|HTMLInputElement] + var isValid = !this.filters.length ? true : this.filters.every(function(filter) { + return filter.call(this, file); + }, this); + + // create new item + var item = new Item(angular.extend({ + url: this.url, + alias: this.alias, + headers: angular.copy(this.headers), + formData: angular.copy(this.formData), + removeAfterUpload: this.removeAfterUpload, + withCredentials: this.withCredentials, + method: this.method, + uploader: this, + file: file + }, options)); + + if(isValid) { + this.queue.push(item); + this.trigger('afteraddingfile', item); + } else { + this.trigger('whenaddingfilefailed', item); + } + }, this); + + if(this.queue.length !== length) { + this.trigger('afteraddingall', this.queue); + this.progress = this._getTotalProgress(); + } + + this._render(); + this.autoUpload && this.uploadAll(); + }, + + /** + * Remove items from the queue. Remove last: index = -1 + * @param {Item|Number} value + */ + removeFromQueue: function(value) { + var index = this.getIndexOfItem(value); + var item = this.queue[index]; + if (item.cancel) item.cancel(); + if (item._destroy) item._destroy(); + this.queue.splice(index, 1); + this.progress = this._getTotalProgress(); + }, + + /** + * Clears the queue + */ + clearQueue: function() { + while(this.queue.length) { + this.queue[this.queue.length - 1].remove(); + } + }, + + /** + * Uploads a item from the queue + * @param {Item|Number} value + */ + uploadItem: function(value) { + var index = this.getIndexOfItem(value); + var item = this.queue[index]; + var transport = this.isHTML5 ? '_xhrTransport' : '_iframeTransport'; + + item.index = item.index || this._nextIndex++; + item.isReady = true; + + if(this.isUploading) return; + + this.isUploading = true; + this[transport](item); + }, + + /** + * Cancels uploading of item from the queue + * @param {Item|Number} value + */ + cancelItem: function(value) { + var index = this.getIndexOfItem(value); + var item = this.queue[index]; + var prop = this.isHTML5 ? '_xhr' : '_form'; + if (item[prop]) item[prop].abort(); + }, + + /** + * Uploads all not uploaded items of queue + */ + uploadAll: function() { + var items = this.getNotUploadedItems().filter(function(item) { + return !item.isUploading; + }); + items.forEach(function(item) { + item.index = item.index || this._nextIndex++; + item.isReady = true; + }, this); + items.length && this.uploadItem(items[0]); + }, + + /** + * Cancels all uploads + */ + cancelAll: function() { + this.getNotUploadedItems().forEach(function(item) { + this.cancelItem(item); + }, this); + }, + + /** + * Returns a index of item from the queue + * @param {Item|Number} value + * @returns {Number} + */ + getIndexOfItem: function(value) { + return angular.isNumber(value) ? value : this.queue.indexOf(value); + }, + + /** + * Returns not uploaded items + * @returns {Array} + */ + getNotUploadedItems: function() { + return this.queue.filter(function(item) { + return !item.isUploaded; + }); + }, + + /** + * Returns items ready for upload + * @returns {Array} + */ + getReadyItems: function() { + return this.queue + .filter(function(item) { + return item.isReady && !item.isUploading; + }) + .sort(function(item1, item2) { + return item1.index - item2.index; + }); + }, + + /** + * Updates angular scope + * @private + */ + _render: function() { + if (!this.scope.$$phase) this.scope.$digest(); + }, + + /** + * Returns the total progress + * @param {Number} [value] + * @returns {Number} + * @private + */ + _getTotalProgress: function(value) { + if(this.removeAfterUpload) { + return value || 0; + } + + var notUploaded = this.getNotUploadedItems().length; + var uploaded = notUploaded ? this.queue.length - notUploaded : this.queue.length; + var ratio = 100 / this.queue.length; + var current = (value || 0) * ratio / 100; + + return Math.round(uploaded * ratio + current); + }, + + /** + * The 'in:progress' handler + * @private + */ + _progress: function(event, item, progress) { + var result = this._getTotalProgress(progress); + this.trigger('progressall', result); + this.progress = result; + this._render(); + }, + + /** + * The 'in:complete' handler + * @private + */ + _complete: function() { + var item = this.getReadyItems()[0]; + this.isUploading = false; + + if(angular.isDefined(item)) { + this.uploadItem(item); + return; + } + + this.trigger('completeall', this.queue); + this.progress = this._getTotalProgress(); + this._render(); + }, + + /** + * The XMLHttpRequest transport + * @private + */ + _xhrTransport: function(item) { + var xhr = item._xhr = new XMLHttpRequest(); + var form = new FormData(); + var that = this; + + this.trigger('beforeupload', item); + + item.formData.forEach(function(obj) { + angular.forEach(obj, function(value, key) { + form.append(key, value); + }); + }); + + form.append(item.alias, item.file); + + xhr.upload.onprogress = function(event) { + var progress = event.lengthComputable ? event.loaded * 100 / event.total : 0; + that.trigger('in:progress', item, Math.round(progress)); + }; + + xhr.onload = function() { + var response = that._transformResponse(xhr.response); + var event = that._isSuccessCode(xhr.status) ? 'success' : 'error'; + that.trigger('in:' + event, xhr, item, response); + that.trigger('in:complete', xhr, item, response); + }; + + xhr.onerror = function() { + that.trigger('in:error', xhr, item); + that.trigger('in:complete', xhr, item); + }; + + xhr.onabort = function() { + that.trigger('in:cancel', xhr, item); + that.trigger('in:complete', xhr, item); + }; + + xhr.open(item.method, item.url, true); + + xhr.withCredentials = item.withCredentials; + + angular.forEach(item.headers, function(value, name) { + xhr.setRequestHeader(name, value); + }); + + xhr.send(form); + }, + + /** + * The IFrame transport + * @private + */ + _iframeTransport: function(item) { + var form = angular.element('<form style="display: none;" />'); + var iframe = angular.element('<iframe name="iframeTransport' + Date.now() + '">'); + var input = item._input; + var that = this; + + if (item._form) item._form.replaceWith(input); // remove old form + item._form = form; // save link to new form + + this.trigger('beforeupload', item); + + input.prop('name', item.alias); + + item.formData.forEach(function(obj) { + angular.forEach(obj, function(value, key) { + form.append(angular.element('<input type="hidden" name="' + key + '" value="' + value + '" />')); + }); + }); + + form.prop({ + action: item.url, + method: 'POST', + target: iframe.prop('name'), + enctype: 'multipart/form-data', + encoding: 'multipart/form-data' // old IE + }); + + iframe.bind('load', function() { + // fixed angular.contents() for iframes + var html = iframe[0].contentDocument.body.innerHTML; + var xhr = {response: html, status: 200, dummy: true}; + var response = that._transformResponse(xhr.response); + that.trigger('in:success', xhr, item, response); + that.trigger('in:complete', xhr, item, response); + }); + + form.abort = function() { + var xhr = {status: 0, dummy: true}; + iframe.unbind('load').prop('src', 'javascript:false;'); + form.replaceWith(input); + that.trigger('in:cancel', xhr, item); + that.trigger('in:complete', xhr, item); + }; + + input.after(form); + form.append(input).append(iframe); + + form[0].submit(); + }, + + /** + * Checks whether upload successful + * @param {Number} status + * @returns {Boolean} + * @private + */ + _isSuccessCode: function(status) { + return (status >= 200 && status < 300) || status === 304; + }, + + /** + * Transforms the server response + * @param {*} response + * @returns {*} + * @private + */ + _transformResponse: function(response) { + $http.defaults.transformResponse.forEach(function(transformFn) { + response = transformFn(response); + }); + return response; + } + }; + + + /** + * Create a item + * @param {Object} [params] + * @constructor + */ + function Item(params) { + // fix for old browsers + if(!Uploader.prototype.isHTML5) { + var input = angular.element(params.file); + var clone = $compile(input.clone())(params.uploader.scope); + var value = input.val(); + + params.file = { + lastModifiedDate: null, + size: null, + type: 'like/' + value.slice(value.lastIndexOf('.') + 1).toLowerCase(), + name: value.slice(value.lastIndexOf('/') + value.lastIndexOf('\\') + 2) + }; + + params._input = input; + clone.prop('value', null); // FF fix + input.css('display', 'none').after(clone); // remove jquery dependency + } + + angular.extend(this, { + isReady: false, + isUploading: false, + isUploaded: false, + isSuccess: false, + isCancel: false, + isError: false, + progress: null, + index: null + }, params); + } + + + Item.prototype = { + /** + * Link to the constructor + */ + constructor: Item, + /** + * Removes a item + */ + remove: function() { + this.uploader.removeFromQueue(this); + }, + /** + * Uploads a item + */ + upload: function() { + this.uploader.uploadItem(this); + }, + /** + * Cancels uploading + */ + cancel: function() { + this.uploader.cancelItem(this); + }, + /** + * Destroys form and input + * @private + */ + _destroy: function() { + if (this._form) this._form.remove(); + if (this._input) this._input.remove(); + delete this._form; + delete this._input; + }, + /** + * The 'beforeupload' handler + * @param {Object} event + * @param {Item} item + * @private + */ + _beforeupload: function(event, item) { + item.isReady = true; + item.isUploading = true; + item.isUploaded = false; + item.isSuccess = false; + item.isCancel = false; + item.isError = false; + item.progress = 0; + }, + /** + * The 'in:progress' handler + * @param {Object} event + * @param {Item} item + * @param {Number} progress + * @private + */ + _progress: function(event, item, progress) { + item.progress = progress; + item.uploader.trigger('progress', item, progress); + }, + /** + * The 'in:success' handler + * @param {Object} event + * @param {XMLHttpRequest} xhr + * @param {Item} item + * @param {*} response + * @private + */ + _success: function(event, xhr, item, response) { + item.isReady = false; + item.isUploading = false; + item.isUploaded = true; + item.isSuccess = true; + item.isCancel = false; + item.isError = false; + item.progress = 100; + item.index = null; + item.uploader.trigger('success', xhr, item, response); + }, + /** + * The 'in:cancel' handler + * @param {Object} event + * @param {XMLHttpRequest} xhr + * @param {Item} item + * @private + */ + _cancel: function(event, xhr, item) { + item.isReady = false; + item.isUploading = false; + item.isUploaded = false; + item.isSuccess = false; + item.isCancel = true; + item.isError = false; + item.progress = 0; + item.index = null; + item.uploader.trigger('cancel', xhr, item); + }, + /** + * The 'in:error' handler + * @param {Object} event + * @param {XMLHttpRequest} xhr + * @param {Item} item + * @param {*} response + * @private + */ + _error: function(event, xhr, item, response) { + item.isReady = false; + item.isUploading = false; + item.isUploaded = true; + item.isSuccess = false; + item.isCancel = false; + item.isError = true; + item.progress = 100; + item.index = null; + item.uploader.trigger('error', xhr, item, response); + }, + /** + * The 'in:complete' handler + * @param {Object} event + * @param {XMLHttpRequest} xhr + * @param {Item} item + * @param {*} response + * @private + */ + _complete: function(event, xhr, item, response) { + item.uploader.trigger('complete', xhr, item, response); + item.removeAfterUpload && item.remove(); + } + }; + + return { + create: function(params) { + return new Uploader(params); + }, + isHTML5: Uploader.prototype.isHTML5 + }; +}]) + + return app; +})); +\ No newline at end of file