scala-news-reader
rss/atom news reader in scala
git clone https://9o.is/git/scala-news-reader.git
commit e2c3440a9da734d3e43d5672ccbe87bf961554c9 parent f42501abc20d7965539ef025a383e12133311e9c Author: Jul <jul@9o.is> Date: Wed, 7 Aug 2013 02:23:36 -0400 made LOTS of changes. Implemented facebook signup and made oauth and video info libraries more typesafe Diffstat:
29 files changed, 811 insertions(+), 235 deletions(-)
diff --git a/src/main/less/styles.less b/src/main/less/styles.less @@ -270,6 +270,39 @@ body { } +#email-signup .big-btn { + padding: 17px 10px; + font-size: 14px; + line-height: 18px; + font-weight: bold; + margin-top: 0 !important; +} + +#email-signup .big-input { + min-height: 50px; + font-size: 23px; +} + +.fb-btn { + background-image: url('http://bundlr.com/images/homepage/facebook.png?1371475259'); + background-repeat: no-repeat; + background-position: 5% 50%; + background-color: rgb(59, 89, 152); + background: none no-repeat scroll 20px 50% / 29px auto rgb(208, 55, 55); + color: white; + text-decoration: none; + padding-left: 40px !important; + box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.3); + + &:hover, &:focus { + background-image: url('http://bundlr.com/images/homepage/facebook.png?1371475259'); + background-repeat: no-repeat; + background-position: 5% 50%; + text-decoration: none; + color: @white; + background-color: lighten(rgb(59, 89, 152), 10%); + } +} .features { padding: 10px; @@ -412,7 +445,7 @@ body { #email-signup { text-align: center; - padding: 20px; + padding: 15px 20px 10px 20px; .border-radius(0px 0px 25px 25px); button, input { margin: 0 } diff --git a/src/main/resources/props/default.props b/src/main/resources/props/default.props @@ -15,3 +15,9 @@ mongo.default.pwd= wordpress.baseurl= wordpress.key= wordpress.secret= + +oauth.baseurl= +facebook.signin.key= +facebook.signin.secret= +facebook.key= +facebook.secret= diff --git a/src/main/resources/props/production.default.props b/src/main/resources/props/production.default.props @@ -10,11 +10,19 @@ aws.access.key= aws.secret.access.key= aws.s3.bucket= +oauth.baseurl= + # Wordpress Oauth wordpress.baseurl= wordpress.key= wordpress.secret= +# Facebook Oauth +facebook.signin.key= +facebook.signin.secret= +facebook.key= +facebook.secret= + # Mongo DB mongo.default.url= mongo.default.user= diff --git a/src/main/scala/com/joereader/config/Site.scala b/src/main/scala/com/joereader/config/Site.scala @@ -3,13 +3,16 @@ package config import model._ import lib._ - +import api.rest.oauth._ +import wordpress._ +import facebook._ import net.liftweb._ import http._ -import sitemap._, Loc._ +import sitemap._ +import Loc._ import common._ - import net.liftmodules.mongoauth.Locs +import com.joereader.lib.api.rest.oauth.wordpress.WordpressBuilder object MenuGroups { val SettingsGroup = LocGroup("settings") @@ -47,8 +50,13 @@ object Site extends Locs { val loginToken = MenuLoc(buildLoginTokenMenu) val inviteToken = MenuLoc(buildInviteTokenMenu) val logout = MenuLoc(buildLogoutMenu) - val wordpressSignIn = MenuLoc(Wordpress.buildWordpressSignInMenu) - val wordpressCallback = MenuLoc(Wordpress.buildWordpressCallbackMenu) + + val wordpressSignIn = MenuLoc(WordpressBuilder.buildSignInMenu) + val wordpressCallback = MenuLoc(WordpressBuilder.buildCallbackMenu) + val facebookSignIn = MenuLoc(FacebookBuilder.buildSignInMenu) + val facebookCallback = MenuLoc(FacebookBuilder.buildCallbackMenu) + val facebookSignInSignIn = MenuLoc(FacebookBuilderSignIn.buildSignInMenu) + val facebookSignInCallback = MenuLoc(FacebookBuilderSignIn.buildCallbackMenu) // sign up wizard val signUp1 = MenuLoc(Menu.i("Sign Up ⋅ Writer") / "signup" / "writer" >> RequireNotLoggedIn) @@ -115,7 +123,7 @@ object Site extends Locs { /* /settings/blog/{blog-name} */ private val blogSettingsParamMenu = Menu.param[Blog]( "Blog Settings", "Blog Settings", - Blog.findByBlogName, _.blogname.get) / "settings" / "blog" / * >> + Blog.findByBlogName, _.blogname.get) / "settings" / "blog" / * >> TemplateBox(() => Templates("settings" :: "blog" :: Nil)) >> RequireLoggedIn lazy val blogSettingsLoc = blogSettingsParamMenu.toLoc @@ -128,7 +136,6 @@ object Site extends Locs { lazy val categoriesLoc = categoriesParamMenu.toLoc - private def menus = List( blogSettingsParamMenu, userPreviewParamMenu, @@ -165,9 +172,12 @@ object Site extends Locs { tumblrHelp.menu, wordpressSignIn.menu, wordpressCallback.menu, + facebookSignIn.menu, + facebookCallback.menu, + facebookSignInSignIn.menu, + facebookSignInCallback.menu, error.menu, - notFound.menu - ) + notFound.menu) /* * Return a SiteMap needed for Lift @@ -186,8 +196,7 @@ object Site extends Locs { def buildInviteTokenMenu = Menu(Loc( "InviteToken", InviteToken.inviteTokenUrl.split("/").filter(_.length > 0).toList, - S ? "liftmodule-monogoauth.locs.inviteToken", inviteTokenLocParams - )) + S ? "liftmodule-monogoauth.locs.inviteToken", inviteTokenLocParams)) protected def inviteTokenLocParams = EarlyResponse(() => User.meta.handleInviteToken()) :: Nil diff --git a/src/main/scala/com/joereader/lib/URLFormatter.scala b/src/main/scala/com/joereader/lib/URLFormatter.scala @@ -8,9 +8,9 @@ import Helper._ * @param in URL in string format */ case class URLFormatter(in: String) { - val url = new java.net.URL(validate(in)) + def url = new java.net.URL(validate(in)) - val ext = "htm" :: "html" :: "php" :: "php3" :: "jsp" :: "asp" :: "phtml" :: + def ext = "htm" :: "html" :: "php" :: "php3" :: "jsp" :: "asp" :: "phtml" :: "shtm" :: "shtml" :: "cgi" :: "cfm" :: "cfml" :: Nil def urlWithoutQueryParams(p: String*): String = { @@ -59,4 +59,9 @@ case class URLFormatter(in: String) { override def toString = url.getProtocol + "://" + url.getHost + (if (url.getPort == -1) "" else ":" + url.getPort) + url.getPath +} + +object URLFormatter { + def same(str1: String, str2: String): Boolean = + URLFormatter(str1).toString == URLFormatter(str2).toString } \ No newline at end of file diff --git a/src/main/scala/com/joereader/lib/VideoInfo.scala b/src/main/scala/com/joereader/lib/VideoInfo.scala @@ -12,55 +12,65 @@ import concurrent.ExecutionContext.Implicits.global * Can only get video duration, but easily extensible. */ abstract class VideoInfo { + protected def name: String protected def req(id: String): Req - def videoDuration(id: String): Future[Option[Int]] + def duration(id: String): Future[Either[String, Int]] } abstract class VideoInfoXML extends VideoInfo { - protected def extract[T](id: String, extractFunc: Elem => Option[T]) = - for (xmlOpt <- getXML(req(id))) - yield for { - xml <- xmlOpt - out <- extractFunc(xml) + protected def extract[T](id: String, extractFunc: Elem => Either[String, T]): Future[Either[String, T]] = + for (xmlEither <- getXML(req(id))) + yield for { + xml <- xmlEither.right + out <- extractFunc(xml).right } yield out - private def getXML(req: Req): Future[Option[Elem]] = - Http(req OK as.xml.Elem).option + private def getXML(req: Req): Future[Either[String, Elem]] = { + val res = Http(req OK as.xml.Elem).either - protected def node2Int(duration: NodeSeq): Option[Int] = - (for (elem <- duration) yield elem.text.toInt).headOption -} + for (e <- res.left) + yield s"Can't connect to ${name}: \n ${e.getMessage}" + } + protected def node2Int(node: NodeSeq)(attr: String): Either[String, Int] = + (for (elem <- node) + yield elem.text.toInt).headOption.toRight { + s"${attr} is missing in ${name} service response" + } +} object YoutubeVideoInfo extends VideoInfoXML { - protected def req(id: String) = + override def name = "youtube" + + override def req(id: String) = url("https://gdata.youtube.com/feeds/api/videos/" + id) <<? Map("v" -> "2") - def videoDuration(id: String) = extract[Int](id, { + override def duration(id: String) = extract[Int](id, { xml: Elem => - node2Int(xml \\ "duration" \ "@seconds") + node2Int(xml \\ "duration" \ "@seconds")("duration") }) } object VimeoVideoInfo extends VideoInfoXML { - protected def req(id: String) = + override def name = "vimeo" + + override def req(id: String) = url("http://vimeo.com/api/v2/video/" + id + ".xml") - def videoDuration(id: String) = extract[Int](id, { + override def duration(id: String) = extract[Int](id, { xml: Elem => - node2Int(xml \\ "duration") + node2Int(xml \\ "duration")("duration") }) } - object VideoService extends Enumeration { type VideoService = Value val Youtube = Value(YoutubeVideoInfo) val Vimeo = Value(VimeoVideoInfo) - class VideoServiceVal(val info: VideoInfo) extends Val(nextId) + class VideoServiceVal(val video: VideoInfo) extends Val(nextId) protected final def Value(info: VideoInfo): VideoServiceVal = new VideoServiceVal(info) diff --git a/src/main/scala/com/joereader/lib/Wordpress.scala b/src/main/scala/com/joereader/lib/Wordpress.scala @@ -1,138 +0,0 @@ -package com.joereader.lib - -import dispatch._ - -import net.liftweb._ -import util._ -import http._ -import json._ -import sitemap._, Loc._ -import common._ - -import concurrent.ExecutionContext.Implicits.global - -/* - * A session var had to be included so we can keep state between signin and - * callback time. Reset after done using it! - */ -object WordpressInfo extends SessionVar(new Wordpress) - -/** - * Wordpress Oauth - */ -class Wordpress { - - var accessToken = "" - var clientCallback = "" - - // wp username - the author's name in rss feed - var username = "" - - // link to json of the blog site - var blogJsonUrl = "" - - // use this var to store the blog url entered by user - var blogUrl = "" - - def signIn: Box[LiftResponse] = { - val req = :/(Wordpress.host).secure / "oauth2" / "authorize" <<? Map( - "client_id" -> Wordpress.key, - "redirect_uri" -> Wordpress.callback, - "response_type" -> "code" - ) - Full(DoRedirectResponse(req.url)) - } - - def callback(): Box[LiftResponse] = { - val code = S.param("code") openOr "" - - val req = :/(Wordpress.host).secure / "oauth2" / "token" << Map( - "client_id" -> Wordpress.key, - "redirect_uri" -> Wordpress.callback, - "client_secret" -> Wordpress.secret, - "code" -> code, - "grant_type" -> "authorization_code" - ) - - val res = Http(req OK as.String).option - - res().map { - jsonStr => - val json = JsonParser.parse(jsonStr) - accessToken = (json \ "access_token").values.toString - } - - Full(RedirectWithState(clientCallback, RedirectState(() => { - S.notice("Try Verifying Now! Click the Verify button below.") - }))) - } - - def validateToken: Boolean = { - if (accessToken.isEmpty) false - else { - val req = :/(Wordpress.host).secure / "rest" / "v1" / "me" <:< - Map("Authorization" -> ("Bearer " + accessToken)) - try { - val json = Http(req OK as.String).option - if (json().isDefined) { - json().map { - jsonStr => - val json = JsonParser.parse(jsonStr) - username = (json \ "username").values.toString - blogJsonUrl = (json \ "meta" \ "links" \ "site"). - values.toString.replaceAll( """\\""", "") - } - true - } else false - } - catch { - case _: Throwable => false - } - } - } - - def isBlogUrl(u: String): Boolean = { - val req = url(blogJsonUrl) <:< - Map("Authorization" -> ("Bearer " + accessToken)) - val json = Http(req OK as.String).option - json().exists { - jsonStr => - val json = JsonParser.parse(jsonStr) - val u1 = (json \ "URL").values.toString - URLFormatter(u1).toString == URLFormatter(u).toString - } - } - -} - -object Wordpress { - val baseUrl = Props.get("wordpress.baseurl") openOr S.hostName - val key = Props.get("wordpress.key") openOr "" - val secret = Props.get("wordpress.secret") openOr "" - val callback = baseUrl + "auth/wordpress/callback" - val host = "public-api.wordpress.com" - - // below functions are handled by Lifts SiteMap - - def buildWordpressCallbackMenu = Menu(Loc( - "Wordpress Callback", "auth" :: "wordpress" :: "callback" :: Nil, - "wordpress.callback", wordpressCallbackLocParams - )) - - def wordpressCallbackLocParams = - EarlyResponse(() => { - WordpressInfo.is.callback() - }) :: Nil - - def buildWordpressSignInMenu = Menu(Loc( - "Wordpress Sign In", "auth" :: "wordpress" :: "signin" :: Nil, - "wordpress.signin", wordpressSignInLocParams - )) - - def wordpressSignInLocParams = - EarlyResponse(() => { - WordpressInfo(new Wordpress) - WordpressInfo.is.clientCallback = S.referer openOr baseUrl - WordpressInfo.is.signIn - }) :: Nil -} diff --git a/src/main/scala/com/joereader/lib/api/rest/RestClient.scala b/src/main/scala/com/joereader/lib/api/rest/RestClient.scala @@ -0,0 +1,93 @@ +package com.joereader.lib.api.rest + +import oauth._ +import dispatch._, Defaults._ + +import net.liftweb._ +import json._, JsonAST._ +import common._ + +import com.ning.http.client._ +import java.net.URL + +/** + * Client that makes rest api calls. + */ +trait RestClient extends Logger { + implicit val formats = DefaultFormats + new URLSerializer + + /* Identification of this rest client. */ + protected def name: String + + /* Oauth Builder */ + protected def token: OauthAccessToken + + /* The server's domain. */ + protected def server: RequestBuilder + + /* Token as request parameter. */ + protected def tokenParam(token: AccessToken): Map[String, String] + + /* Build the request with the access token. */ + def buildTokenRequest(req: RequestBuilder): Box[RequestBuilder] + + /* Builds a request with access token. */ + protected def authReq(path: List[String]): Box[RequestBuilder] = + buildTokenRequest(server / path.mkString("/")) + + /* Builds a request with access token and other query params. */ + protected def authReq(path: List[String], params: Map[String, String]): Box[RequestBuilder] = + buildTokenRequest(server / path.mkString("/") <<? params) + + /* Builds a request with access token given a full url. */ + protected def authReq(u: URL): Box[RequestBuilder] = + buildTokenRequest(url(u.toString)) + + /* Make a GET request to server. */ + protected def getJson[T](req: Box[RequestBuilder]): Future[Either[String, JValue]] = + req match { + case Failure(msg, _, _) => + error(msg) + Future(Left(msg)) + case Empty => + val msg = s"Empty $name request" + error(msg) + Future(Left(msg)) + case Full(req) => + catchThrowable { + getJson(req) + } + } + + /* Extract json response to a typed object. */ + protected def extract[T: Manifest](res: Future[Either[String, JValue]]): Future[Either[String, T]] = + for (jvalue <- res.right) + yield jvalue.extract[T] + + private def getJson(req: RequestBuilder): Future[Either[Throwable, JValue]] = { + val res = Http(req.GET OK as.String).either + for (json <- res.right) + yield parse(json) + } + + /* Catches exception if it fails to get rsponse from the server. */ + private def catchThrowable(res: Future[Either[Throwable, JValue]]): Future[Either[String, JValue]] = { + for (e <- res.left) yield { + val msg = s"Can't connect to $name: \n ${e.getMessage}" + error(msg) + msg + } + } + + /* Extracts the access tokn. */ + protected def extractToken = + for { + token <- token.is ?~ s"$name access token missing" + } yield tokenParam(token) + + /* Removes the current access token. */ + def resetToken { + token(Empty) + info(s"$name has been reset") + } +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/lib/api/rest/URLSerializer.scala b/src/main/scala/com/joereader/lib/api/rest/URLSerializer.scala @@ -0,0 +1,20 @@ +package com.joereader.lib.api.rest + +import net.liftweb.json._ +import java.net.URL + +class URLSerializer extends Serializer[URL] { + private val URLClass = classOf[URL] + + def deserialize(implicit format: Formats): PartialFunction[(TypeInfo, JValue), URL] = { + case (TypeInfo(URLClass, _), json) => json match { + case JString(s) => new URL(s.replaceAll("""\\""", "")) + case x => throw new MappingException("Can't convert " + x + " to URL") + } + } + + def serialize(implicit format: Formats): PartialFunction[Any, JValue] = { + case x: URL => + JString(x.toString.replaceAll("/", """\\/""")) + } +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/lib/api/rest/facebook/FacebookClient.scala b/src/main/scala/com/joereader/lib/api/rest/facebook/FacebookClient.scala @@ -0,0 +1,36 @@ +package com.joereader.lib.api.rest.facebook + +import com.joereader.lib.api._ +import rest.RestClient +import rest.oauth._ +import facebook._ +import FacebookRequest._ + +import com.ning.http.client._ +import dispatch._ + +object FacebookClient extends RestClient { + + override def name = "facebook" + + override def token = FacebookToken + + override def server = :/("graph.facebook.com").secure + + override def tokenParam(token: AccessToken) = + Map("access_token" -> token.access_token) + + override def buildTokenRequest(req: RequestBuilder) = + extractToken.map(req <<? _) + + def me(fields: Field*): Future[Either[String, Me]] = + extract[Me](getJson{ + val t = authReq("me" :: Nil, Map("fields" -> fields.mkString(","))) + t.map(t => info("facebook request: "+t.url)) + t + }) +} + + + + diff --git a/src/main/scala/com/joereader/lib/api/rest/facebook/Field.scala b/src/main/scala/com/joereader/lib/api/rest/facebook/Field.scala @@ -0,0 +1,18 @@ +package com.joereader.lib.api.rest.facebook + +private[facebook] abstract class Field(key: String) { + override def toString = key +} + +// Graph API Fields +object Field { + object Email extends Field("email") + object Verified extends Field("verified") + object Name extends Field("name") + object FirstName extends Field("first_name") + object MiddleName extends Field("middle_name") + object LastName extends Field("last_name") + object Link extends Field("link") + object Username extends Field("username") + object Gender extends Field("gender") +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/lib/api/rest/facebook/Request.scala b/src/main/scala/com/joereader/lib/api/rest/facebook/Request.scala @@ -0,0 +1,28 @@ +package com.joereader.lib.api.rest.facebook + +import java.net.URL + +object FacebookRequest { + object Gender extends Enumeration { + type Gender = Value + val male, female = Value + } + import Gender._ + + /* + * me request get info about current user + * graph.facebook.com/me + * include as many as needed + */ + private[facebook] case class Me( + id: String, + email: Option[String], + verified: Option[Boolean], + name: Option[String], + first_name: Option[String], + middle_name: Option[String], + last_name: Option[String], + link: Option[URL], + username: Option[String], + gender: Option[Gender]) +} diff --git a/src/main/scala/com/joereader/lib/api/rest/oauth/AccessToken.scala b/src/main/scala/com/joereader/lib/api/rest/oauth/AccessToken.scala @@ -0,0 +1,11 @@ +package com.joereader.lib.api.rest.oauth + +import net.liftweb._ +import net.liftweb.http._ +import net.liftweb.common._ + +case class AccessToken(access_token: String) + +abstract class OauthAccessToken(token: Box[AccessToken]) extends SessionVar[Box[AccessToken]](token) { + override def __nameSalt = hashCode.toString +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/lib/api/rest/oauth/ClientCallback.scala b/src/main/scala/com/joereader/lib/api/rest/oauth/ClientCallback.scala @@ -0,0 +1,10 @@ +package com.joereader.lib.api.rest.oauth + +import net.liftweb._ +import http._ +import common._ + +import java.net.URL + +/* url whence the oauth process began. */ +private[oauth] object ClientCallback extends SessionVar[Box[URL]](Empty) +\ No newline at end of file diff --git a/src/main/scala/com/joereader/lib/api/rest/oauth/OauthBuilder.scala b/src/main/scala/com/joereader/lib/api/rest/oauth/OauthBuilder.scala @@ -0,0 +1,187 @@ +package com.joereader.lib.api.rest.oauth + +import com.joereader.config._ + +import dispatch._ +import Defaults._ +import com.ning.http.client._ + +import net.liftweb._ +import common.{ Logger, Box, Full } +import http._ +import util._ +import sitemap._, Loc._ +import json._ + +import java.net.URL + +trait OauthBuilder extends Logger { + + /* Name to identify this oauth. */ + protected def name: String + + /* oauth server's sign in request url */ + protected def signInRequest: RequestBuilder + + /* oauth server's callback request url */ + protected def callbackRequest: RequestBuilder + + /* The access token. */ + protected def token: OauthAccessToken + + /* Called when oauth is done. */ + protected def endResponse(callback: URL): LiftResponse = + DoRedirectResponse(callback.toString) + + /* + * if true, sends callback data in body encoded as + * application/x-www-form-urlencoded + */ + protected def urlEncodedCallback = false + + /* oauth app key */ + private val key = + Props.get(name + ".key") ?~ s"$name app key not found" + + /* oauth app secret */ + private val secret = + Props.get(name + ".secret") ?~ s"$name app secret not found" + + /* Optional permissions. */ + protected def scope: List[String] = Nil + + /* + * Full base url of our server. We need this as + * the redirect uri. + */ + protected val baseUrl = + Props.get("oauth.baseurl") ?~ s"$name base url not found" + + /* + * Full callback url of our server. We need this as + * the redirect uri. + */ + private def callbackUrl = + baseUrl.map(_ + myCallback.mkString("/")) + + /* + * First step of oauth 2.0, send user to oauth server's + * login screen. + */ + private def signIn: Box[LiftResponse] = + for { + key <- key + callback <- callbackUrl + } yield { + + def permissions = + for(scope <- scope) + yield "scope" -> scope + + def req = signInRequest <<? Map( + "client_id" -> key, + "redirect_uri" -> callback, + "scope" -> "email", + "response_type" -> "code", + "scope" -> scope.mkString(",")) + + DoRedirectResponse(req.url) + } + + /* + * Second step of oauth 2.0, server sends us a code + * and we trade that code for an access token. + */ + private def callback: Box[LiftResponse] = + for { + key <- key + secret <- secret + callback <- callbackUrl + code <- S.param("code") ?~ "Callback code not found" + } yield { + def params = Map( + "client_id" -> key, + "redirect_uri" -> callback, + "client_secret" -> secret, + "code" -> code, + "grant_type" -> "authorization_code") + + def req = + if (urlEncodedCallback) callbackRequest << params + else callbackRequest <<? params + + Http(req OK as.String).either() match { + case Left(e) => + error(e.getMessage) + endResponse + case Right(res) => + setAccessToken(res) + endResponse + } + + } + + /* + * Sets the oauth token. Override this if access + * token is not in body as json. + */ + protected def setAccessToken(res: String) { + implicit val formats = DefaultFormats + val value = parse(res).extract[AccessToken] + token(Full(value)) + info(s"$name access token has been set to $value") + } + + /* Called when oauth is done. */ + protected def endResponse: LiftResponse = + (for { + clientCallback <- ClientCallback.get + } yield { + endResponse(clientCallback) + }) openOr { + RedirectWithState(Site.home.url, + RedirectState(() => { + S.notice("Callback URL not found") + })) + } + + /* Our website's sign in url path. */ + private def mySignIn = + "auth" :: name :: "signin" :: Nil + + /* Our website's callback url path. */ + private def myCallback = + "auth" :: name :: "callback" :: Nil + + /* Sign In Menu item for sitemap. */ + def buildSignInMenu = Menu(Loc( + name + " Sign In", mySignIn, + name + ".signin", signInLocParams)) + + /* Callback Menu item for sitemap. */ + def buildCallbackMenu = Menu(Loc( + name + " Callback", myCallback, + name + ".callback", callbackLocParams)) + + /* + * Sets the client callback. + * Sets the referer by default. + */ + protected def setClientCallback { + ClientCallback(S.referer.map { + r => + info(s"$name client callback set to $r") + new java.net.URL(r) + }) + } + + /* Response during sign in. Used by sitemap. */ + private def signInLocParams = EarlyResponse(() => { + setClientCallback + signIn + }) :: Nil + + /* Response during callback. Used by sitemap. */ + private def callbackLocParams = + EarlyResponse(() => { callback }) :: Nil +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/lib/api/rest/oauth/facebook/FacebookBuilder.scala b/src/main/scala/com/joereader/lib/api/rest/oauth/facebook/FacebookBuilder.scala @@ -0,0 +1,106 @@ +package com.joereader.lib.api.rest.oauth.facebook + +import com.joereader._ +import lib.api._ +import rest.facebook._ +import rest.oauth._ +import com.joereader._ +import snippet.EmailVar +import config._ +import model._ + +import dispatch._ +import Defaults._ + +import net.liftweb._ +import net.liftweb.http._ +import net.liftweb.common._ + +import java.net.URL + +trait FacebookBuilder extends OauthBuilder { + + override def name = "facebook" + + override def signInRequest = + :/("www.facebook.com").secure / "dialog" / "oauth" + + override def callbackRequest = + :/("graph.facebook.com").secure / "oauth" / "access_token" + + override def token = FacebookToken + + override def setAccessToken(res: String) = + for (attr <- res.split("&")) { + val param = attr.split("=") + val (key, value) = + (param.headOption, param.tail.headOption) + + val failMsg = s"Can't extract $name access token" + for { + key <- Box(key) ?~ failMsg + value <- Box(value) ?~ failMsg + } if (key == "access_token") { + token(Full(AccessToken(value))) + info(s"$name access token has been set to $value") + } + } + +} + +/* + * This facebook builder is used for tasks that + * require more scopes that a simple sign in. + */ +object FacebookBuilder extends FacebookBuilder { + override def scope = + "user_education_history" :: "user_interests" :: + "user_likes" :: "user_work_history" :: + "publish_actions" :: Nil +} + +/* This facebook builder is used for signing in. */ +object FacebookBuilderSignIn extends FacebookBuilder { + override def name = super.name + ".signin" + + override def scope = "email" :: Nil + + override def setClientCallback { + ClientCallback(baseUrl.map { + bu => + val url = bu + Site.signUp1.url.drop(1) + info(s"$name client callback set to $url") + new URL(url) + }) + } + + override def endResponse(callback: URL) = { + import Field._ + + FacebookClient.me(Email)() match { + case Left(err) => + error(err) + super.endResponse(callback) + + case Right(me) => + (for (email <- me.email) yield { + val user = User.findByEmail(email) + user.map(User.logUserIn(_, true)) + + if (user.isEmpty) { + BetaUser.createRecord.id(email).save + + RedirectWithState(callback.toString, + RedirectState { () => + EmailVar(email) + }) + } else DoRedirectResponse(Site.home.url) + + }) getOrElse { + warn(s"Email for user ${me.id} in $name could not be found") + super.endResponse(callback) + } + } + + } +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/lib/api/rest/oauth/facebook/FacebookToken.scala b/src/main/scala/com/joereader/lib/api/rest/oauth/facebook/FacebookToken.scala @@ -0,0 +1,6 @@ +package com.joereader.lib.api.rest.oauth.facebook + +import com.joereader.lib.api.rest.oauth._ +import net.liftweb.common._ + +object FacebookToken extends OauthAccessToken(Empty) +\ No newline at end of file diff --git a/src/main/scala/com/joereader/lib/api/rest/oauth/wordpress/WordpressBuilder.scala b/src/main/scala/com/joereader/lib/api/rest/oauth/wordpress/WordpressBuilder.scala @@ -0,0 +1,27 @@ +package com.joereader.lib.api.rest.oauth.wordpress + +import com.joereader.lib.api.rest.oauth._ +import dispatch._ +import net.liftweb.http._ +import java.net.URL + +object WordpressBuilder extends OauthBuilder { + + override def name = "wordpress" + + private def server = :/("public-api.wordpress.com").secure + + override def signInRequest = server / "oauth2" / "authorize" + + override def callbackRequest = server / "oauth2" / "token" + + override def urlEncodedCallback = true + + override def token = WordpressToken + + override def endResponse(callback: URL) = + RedirectWithState(callback.toString, + RedirectState { () => + S.notice("Try Verifying Now! Click the Verify button below.") + }) +} +\ No newline at end of file diff --git a/src/main/scala/com/joereader/lib/api/rest/oauth/wordpress/WordpressToken.scala b/src/main/scala/com/joereader/lib/api/rest/oauth/wordpress/WordpressToken.scala @@ -0,0 +1,6 @@ +package com.joereader.lib.api.rest.oauth.wordpress + +import com.joereader.lib.api.rest.oauth._ +import net.liftweb.common._ + +object WordpressToken extends OauthAccessToken(Empty) +\ No newline at end of file diff --git a/src/main/scala/com/joereader/lib/api/rest/wordpress/Request.scala b/src/main/scala/com/joereader/lib/api/rest/wordpress/Request.scala @@ -0,0 +1,36 @@ +package com.joereader.lib.api.rest.wordpress + +import java.net.URL + +object WordpressRequest { + /* + * http://developer.wordpress.com/docs/api/1/get/me/ + */ + private[wordpress] case class Me( + ID: Int, + display_name: String, + username: String, + email: String, + primary_blog: Int, + token_site_id: Option[Int], + avatar_URL: URL, + profile_URL: URL, + verified: Boolean, + meta: Meta) + + /* + * http://developer.wordpress.com/docs/api/1/get/sites/%24site/ + */ + private[wordpress] case class Site( + ID: Int, + name: String, + description: String, + URL: URL, + jetpack: Option[Boolean], + post_count: Option[Int], + lang: Option[String], + meta: Meta) + + private[wordpress] case class Meta(links: Links) + private[wordpress] case class Links(self: URL, help: URL, site: URL) +} diff --git a/src/main/scala/com/joereader/lib/api/rest/wordpress/WordpressClient.scala b/src/main/scala/com/joereader/lib/api/rest/wordpress/WordpressClient.scala @@ -0,0 +1,33 @@ +package com.joereader.lib.api.rest.wordpress + +import com.joereader.lib.api._ +import rest.RestClient +import rest.oauth._ +import wordpress._ +import WordpressRequest._ + +import com.ning.http.client._ +import dispatch._ + +object WordpressClient extends RestClient { + private def ver = "v1" + + override def name = "wordpress" + + override def token = WordpressToken + + override def server = :/("public-api.wordpress.com").secure + + override def tokenParam(token: AccessToken) = + Map("Authorization" -> ("Bearer " + token.access_token)) + + override def buildTokenRequest(req: RequestBuilder) = + extractToken.map(req <:< _) + + def me: Future[Either[String, Me]] = + extract[Me](getJson(authReq("rest" :: ver :: "me" :: Nil))) + + def site(me: Me): Future[Either[String, Site]] = + extract[Site](getJson(authReq(me.meta.links.site))) +} + diff --git a/src/main/scala/com/joereader/snippet/ReaderSnip.scala b/src/main/scala/com/joereader/snippet/ReaderSnip.scala @@ -105,9 +105,6 @@ class ReaderSnip extends UserSnip with ArticleSnip { <div style="margin: 100px 0" class="text-center"> <h3>Have another blog?</h3> <a data-lift="UserBlogsSnip.addBlog" class="btn btn-primary" style="margin-bottom: 50px">Add Blog</a> - <br/><br/> - <p style="width:300px; margin:0 auto">We'll send an email as soon as we're ready. In the meantime, follow us from your favorite social network to get the latest updates!</p> - <br/><br/> { Templates("templates-hidden" :: "parts" :: "social" :: Nil).openOr(NodeSeq.Empty) } </div> diff --git a/src/main/scala/com/joereader/snippet/Search.scala b/src/main/scala/com/joereader/snippet/Search.scala @@ -33,7 +33,7 @@ class Search { */ - val categories: Seq[(String,List[String])] = Seq( + def categories: Seq[(String,List[String])] = Seq( ("technology", "http://www.theverge.com/rss/index.xml" :: Nil), ("fashion", "http://1010woodland.tumblr.com/rss" :: Nil), ("travel", "http://www.lonelyplanet.com/blog/feed/atom/" :: Nil), @@ -44,9 +44,9 @@ class Search { ("gaming", "http://feeds.feedburner.com/psblog" :: Nil) ) - val feeds = categories.map(f => (f._1, f._2.feed)) + def feeds = categories.map(f => (f._1, f._2.feed)) - val imagesByCategory: Seq[(String, List[FeedImage])] = feeds.map{f => + def imagesByCategory: Seq[(String, List[FeedImage])] = feeds.map{f => (f._1, f._2.entries.flatMap(_.nonAuthorImages.headOption)) } diff --git a/src/main/scala/com/joereader/snippet/SignUp.scala b/src/main/scala/com/joereader/snippet/SignUp.scala @@ -4,6 +4,7 @@ import com.joereader._ import model._ import lib.Helper._ import config._ +import SnipHelpers._ import net.liftweb._ import util.Helpers._ @@ -57,8 +58,13 @@ class SignUp { "#signup-style *" #> // @navbarHeight + @emailSignupHeight + fudge in less "@media (min-width: 980px) {body{padding-top: 150px}}" & + "#fb-btn [href]" #> Site.facebookSignInSignIn.url & + "#email-signup-btn [onclick]" #> { + Show("email-join-container") & Hide("fb-btn") & + Show("join-btn") & Hide("email-signup-btn") + } & "#email-join" #> text(email, email = _) & - "#join-btn" #> ajaxSubmit("Join", continue) + "#join-btn" #> ajaxSubmit("Join Now for Free", continue) } /** @@ -167,7 +173,7 @@ class SignUp { } def assert(bool: Boolean) { - if (bool) S.redirectTo(Site.notFound.url) + if (bool) S.redirectTo(Site.home.url) } } diff --git a/src/main/scala/com/joereader/snippet/UserWriterSnipEdit.scala b/src/main/scala/com/joereader/snippet/UserWriterSnipEdit.scala @@ -52,8 +52,7 @@ trait UserWriterSnipEdit extends UserWriterSnipView with BackgroundSnip { else if (u.isEmpty && Site.isAvailableMenu(s)) { user.username(s).update Noop - } - else S.error("Username is taken") + } else S.error("Username is taken") } "*" #> ajaxText(user.username.is, { @@ -68,13 +67,16 @@ trait UserWriterSnipEdit extends UserWriterSnipView with BackgroundSnip { import VideoService._, dispatch._ if (id.isEmpty) Noop else { - val time: Int = Youtube.info.videoDuration(id)().getOrElse(-1) - if (time < 0) S.error("Video could not be found") - else if (time <= 30) { - user.introVid(id).update - Noop + Youtube.video.duration(id)() match { + case Left(msg) => S.error(msg) + case Right(time) => + if (time <= 30) { + user.introVid(id).update + Noop + } else + S.error("Video duration must be " + + "30 seconds or less") } - else S.error("Video duration must be 30 seconds or less") } } "*" #> ajaxText(user.introVid.is, { @@ -104,8 +106,7 @@ trait UserWriterSnipEdit extends UserWriterSnipView with BackgroundSnip { if (validVideo(id)) { user.addVideo(id).update vids = id :: vids - } - else failed = id :: failed + } else failed = id :: failed } if (!failed.isEmpty) @@ -116,8 +117,8 @@ trait UserWriterSnipEdit extends UserWriterSnipView with BackgroundSnip { def validVideo(id: String): Boolean = { import VideoService._, dispatch._ - val time = Youtube.info.videoDuration(id)() - if (time.isDefined) true else false + val time = Youtube.video.duration(id)() + if (time.isRight) true else false } "*" #> ajaxText(vids.mkString(", "), addVideo) diff --git a/src/main/scala/com/joereader/snippet/Verification.scala b/src/main/scala/com/joereader/snippet/Verification.scala @@ -9,11 +9,14 @@ import js._, JsCmds._ import common._ import com.joereader._ -import lib._, rss._ +import lib._ +import api.rest.wordpress._ +import rss._ import config._ import model._ import scala.xml._ +import dispatch._ /* * A session var had to be included so we can keep state between Wordpress @@ -52,20 +55,25 @@ class Verification extends VerificationDesigns { def render = "#verify-state" #> idMemoize(verifyBlog) def process(): JsCmd = { - // check if wordpress auth is set - if (WordpressInfo.is.validateToken) { - if (WordpressInfo.is.isBlogUrl(s.urlHtml)) { - if (s.blog.writersNames.exists(_ == WordpressInfo.is.username)) { - writerName.set(WordpressInfo.is.username) + + // check if wordpress auth works + for { + me <- WordpressClient.me().right + site <- WordpressClient.site(me)().right + } { + if (URLFormatter.same(s.urlHtml, site.URL.toString) && + s.blog.writersNames.exists(_ == me.username)) { + writerName.set(me.username) s.verified = true } - } - } else - s.verified = - MetaVerification(metaName, metaContent.get, s.urlHtml).verified + } + + s.verified = + if (s.verified) true + else MetaVerification(metaName, metaContent.get, s.urlHtml).verified if (s.verified) verifiedProcess() - else S.error("Verification is unsuccessful.") + else S.error("Verification was unsuccessful.") } def verifiedProcess(): JsCmd = { @@ -80,7 +88,7 @@ class Verification extends VerificationDesigns { s.blog.save - WordpressInfo(new Wordpress) // reset + WordpressClient.resetToken // reset CurrentVerification(VerificationSettings()) onVerified() & onVerifiedSignUp() // support signup too @@ -112,7 +120,6 @@ class Verification extends VerificationDesigns { s.searched = true s.urlHtml = s.blog.urlHtml.get // nicely formatted - WordpressInfo.is.blogUrl = s.urlHtml CurrentVerification(s) } else s = CurrentVerification.is @@ -136,8 +143,7 @@ class Verification extends VerificationDesigns { s.blog.unregisteredWritersNames, Full(s.blog.unregisteredWritersNames headOr ""), { s => onWritersRadiosChange(s) & onWritersRadiosChangeSignUp(s) - } - ).unregisteredWritersChoicesToPictureForm(s.blog) + }).unregisteredWritersChoicesToPictureForm(s.blog) val radios = ajaxRadio[Boolean]( Seq(true, false), @@ -145,8 +151,7 @@ class Verification extends VerificationDesigns { bool => owner.set(bool) Noop - } - ) + }) def registeredUsers = s.blog.registeredWriters. @@ -249,10 +254,10 @@ sealed trait VerificationDesigns { val bwu = BlogWriterUser(None, Some(blog), Some(blogWriters(i))) <label> - {item.xhtml}<div> - {item.key.toString} - </div> - <img src={bwu.image} class="writer"/> + { item.xhtml }<div> + { item.key.toString } + </div> + <img src={ bwu.image } class="writer"/> </label> } } @@ -270,10 +275,10 @@ sealed trait VerificationDesigns { <label> <div> - {bwu.name} + { bwu.name } </div> - <a href={bwu.link} target="_blank"> - <img src={bwu.image} class="writer"/> + <a href={ bwu.link } target="_blank"> + <img src={ bwu.image } class="writer"/> </a> </label> } diff --git a/src/main/webapp/signup/social.html b/src/main/webapp/signup/social.html @@ -1,13 +1,7 @@ <div data-lift="surround?with=base-wrap;at=content"> - <div class="text-center up-separate"> - <h2>Thank you for joining!</h2> - - <h5>Follow us from your favorite social network to get the latest updates.</h5> - - <div class="up-separate"> - <span data-lift="embed?what=/templates-hidden/parts/social"></span> - </div> - </div> + <div class="text-center up-separate"> + <span data-lift="embed?what=/templates-hidden/parts/social"></span> + </div> </div> \ No newline at end of file diff --git a/src/main/webapp/templates-hidden/base-wrap.html b/src/main/webapp/templates-hidden/base-wrap.html @@ -61,19 +61,23 @@ <form data-lift="form.ajax"> <div id="email-signup" class="span4 offset4"> <div class="row-fluid"> - <div class="span8"> - - <div class="control-group"> + + <div class="span7"> + <div class="email-input control-group"> <div class="controls"> - <input id="email-join" class="input-block-level" placeholder="email address"> + <a id="fb-btn" class="btn btn-block fb-btn big-btn">Sign up with Facebook</a> + <span id="email-join-container" class="hide"> + <input id="email-join" class="input-block-level big-input" placeholder="email address"> + </span> <span data-alertid="email-join-err" class="notice-block"></span> </div> </div> - </div> - <div class="span4"> - <button id="join-btn" class="btn btn-primary btn-block" type="button"><strong>SignUp!</strong></button> + <div class="span5"> + <button id="email-signup-btn" class="btn btn-block big-btn nolink-decoration">Sign up with Email</button> + <button id="join-btn" class="btn btn-primary btn-block big-btn hide" style="padding-bottom:10px"></button> </div> + </div> </div> </form> diff --git a/src/main/webapp/templates-hidden/parts/social.html b/src/main/webapp/templates-hidden/parts/social.html @@ -1,5 +1,5 @@ <div> - + <div> <div id="fb-root"></div> @@ -11,14 +11,16 @@ fjs.parentNode.insertBefore(js, fjs); }(document, 'script', 'facebook-jssdk'));</script> - <div class="fb-follow" data-href="https://www.facebook.com/readmeans" data-width="200" data-show-faces="true"></div> + <div class="fb-follow" data-href="https://www.facebook.com/readmeans" + data-width="200" data-show-faces="true"></div> </div> <div> - <a href="https://twitter.com/ReadMeans" class="twitter-follow-button" data-show-count="false">Follow @ReadMeans</a> + <a href="https://twitter.com/ReadMeans" class="twitter-follow-button" + data-show-count="false">Follow @ReadMeans</a> <script> !function(d,s,id){ var js,fjs=d.getElementsByTagName(s)[0],p=/^http:/.test(d.location)?'http':'https'; @@ -32,7 +34,8 @@ </div> <div> - <div class="g-follow" data-annotation="bubble" data-height="20" data-href="//plus.google.com/112576307492113436660" data-rel="publisher"></div> + <div class="g-follow" data-annotation="bubble" data-height="20" + data-href="//plus.google.com/112576307492113436660" data-rel="publisher"></div> <script type="text/javascript"> (function() { @@ -43,5 +46,11 @@ </script> </div> + <h2>Thank you for joining!</h2> + <p style="width:300px; margin:0 auto"> + Read Means will be available on a first come first serve. + In the meantime, follow us from your favorite social + network to get the latest updates. + </p> </div> \ No newline at end of file