From 605f4a46b09f90c96324064342afa61a406b6a7b Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 15 Jul 2016 19:41:48 +0200 Subject: [PATCH] typesafe ApiVersion --- app/controllers/Api.scala | 4 ++-- app/controllers/LilaController.scala | 4 ++-- app/controllers/Round.scala | 8 +++++--- app/views/challenge/js.scala.html | 2 +- app/views/study/show.scala.html | 2 +- modules/api/src/main/Mobile.scala | 20 +++++++++++--------- modules/api/src/main/RoundApi.scala | 8 ++++---- modules/api/src/main/RoundApiBalancer.scala | 13 +++++++------ modules/common/src/main/PackageObject.scala | 4 ++++ modules/common/src/main/model.scala | 3 +++ modules/round/src/main/JsonView.scala | 5 +++-- modules/round/src/main/Socket.scala | 4 ++-- modules/round/src/main/SocketHandler.scala | 17 +++++++++++------ modules/round/src/main/actorApi.scala | 18 ++++++++++++------ modules/security/src/main/Api.scala | 3 ++- modules/security/src/main/Store.scala | 15 +++++++++------ modules/user/src/main/UserRepo.scala | 7 ++++--- public/stylesheets/common.css | 4 ++++ 18 files changed, 87 insertions(+), 54 deletions(-) create mode 100644 modules/common/src/main/model.scala diff --git a/app/controllers/Api.scala b/app/controllers/Api.scala index 34811b9669..bf65f37023 100644 --- a/app/controllers/Api.scala +++ b/app/controllers/Api.scala @@ -15,10 +15,10 @@ object Api extends LilaController { val api = lila.api.Mobile.Api Ok(Json.obj( "api" -> Json.obj( - "current" -> api.currentVersion, + "current" -> api.currentVersion.value, "olds" -> api.oldVersions.map { old => Json.obj( - "version" -> old.version, + "version" -> old.version.value, "deprecatedAt" -> old.deprecatedAt, "unsupportedAt" -> old.unsupportedAt) }) diff --git a/app/controllers/LilaController.scala b/app/controllers/LilaController.scala index 5597684eae..5736c1f5ff 100644 --- a/app/controllers/LilaController.scala +++ b/app/controllers/LilaController.scala @@ -12,7 +12,7 @@ import scalaz.Monoid import lila.api.{ PageData, Context, HeaderContext, BodyContext, TokenBucket } import lila.app._ -import lila.common.{ LilaCookie, HTTPRequest } +import lila.common.{ LilaCookie, HTTPRequest, ApiVersion } import lila.notify.Notification.Notifies import lila.security.{ Permission, Granter, FingerprintedUser } import lila.user.{ UserContext, User => UserModel } @@ -277,7 +277,7 @@ private[controllers] trait LilaController res, res withCookies LilaCookie.makeSessionId(req)) - protected def negotiate(html: => Fu[Result], api: Int => Fu[Result])(implicit ctx: Context): Fu[Result] = + protected def negotiate(html: => Fu[Result], api: ApiVersion => Fu[Result])(implicit ctx: Context): Fu[Result] = (lila.api.Mobile.Api.requestVersion(ctx.req) match { case Some(v) => api(v) map (_ as JSON) case _ => html diff --git a/app/controllers/Round.scala b/app/controllers/Round.scala index c93a3e625b..c2c81bc8b3 100644 --- a/app/controllers/Round.scala +++ b/app/controllers/Round.scala @@ -8,7 +8,7 @@ import play.twirl.api.Html import lila.api.Context import lila.app._ -import lila.common.HTTPRequest +import lila.common.{ HTTPRequest, ApiVersion } import lila.game.{ Pov, PlayerRef, GameRepo, Game => GameModel } import lila.hub.actorApi.map.Tell import lila.round.actorApi.round._ @@ -31,7 +31,9 @@ object Round extends LilaController with TheftPrevention { uid = uid, user = ctx.me, ip = ctx.ip, - userTv = get("userTv")) + userTv = get("userTv"), + apiVersion = lila.api.Mobile.Api.currentVersion // yeah it should be in the URL + ) } } @@ -41,7 +43,7 @@ object Round extends LilaController with TheftPrevention { if (isTheft(pov)) fuccess(Left(theftResponse)) else get("sri") match { case Some(uid) => requestAiMove(pov) >> env.socketHandler.player( - pov, uid, ~get("ran"), ctx.me, ctx.ip + pov, uid, ~get("ran"), ctx.me, ctx.ip, ApiVersion(apiVersion) ) map Right.apply case None => fuccess(Left(NotFound)) } diff --git a/app/views/challenge/js.scala.html b/app/views/challenge/js.scala.html index ce78b65266..524fa95cbd 100644 --- a/app/views/challenge/js.scala.html +++ b/app/views/challenge/js.scala.html @@ -3,7 +3,7 @@ @jsTag("challenge.js") @embedJs { lichess.startChallenge(document.getElementById('challenge'), { -socketUrl: '@routes.Challenge.websocket(c.id, apiVersion)', +socketUrl: '@routes.Challenge.websocket(c.id, apiVersion.value)', xhrUrl: '@routes.Challenge.show(c.id)', owner: @owner, data: @Html(toJson(json)) diff --git a/app/views/study/show.scala.html b/app/views/study/show.scala.html index 04cc264d90..a5ea14f0cc 100644 --- a/app/views/study/show.scala.html +++ b/app/views/study/show.scala.html @@ -16,7 +16,7 @@ explorer: { endpoint: "@explorerEndpoint", tablebaseEndpoint: "@tablebaseEndpoint" }, -socketUrl: "@routes.Study.websocket(s.id, apiVersion)", +socketUrl: "@routes.Study.websocket(s.id, apiVersion.value)", socketVersion: @socketVersion }; } diff --git a/modules/api/src/main/Mobile.scala b/modules/api/src/main/Mobile.scala index fb1a761a76..5b0c572ac2 100644 --- a/modules/api/src/main/Mobile.scala +++ b/modules/api/src/main/Mobile.scala @@ -4,39 +4,41 @@ import org.joda.time.DateTime import play.api.http.HeaderNames import play.api.mvc.RequestHeader +import lila.common.ApiVersion + object Mobile { object Api { case class Old( - version: Int, + version: ApiVersion, // date when a newer version was released deprecatedAt: DateTime, // date when the server stops accepting requests unsupportedAt: DateTime) - val currentVersion = 2 + val currentVersion = ApiVersion(2) - val acceptedVersionNumbers = Set(1, 2) + val acceptedVersions: Set[ApiVersion] = Set(1, 2) map ApiVersion.apply val oldVersions: List[Old] = List( Old( // chat messages are html escaped - version = 1, + version = ApiVersion(1), deprecatedAt = new DateTime("2016-08-13"), unsupportedAt = new DateTime("2016-11-13")) ) private val PathPattern = """^.+/socket/v(\d+)$""".r - def requestVersion(req: RequestHeader): Option[Int] = { + def requestVersion(req: RequestHeader): Option[ApiVersion] = { val accepts = ~req.headers.get(HeaderNames.ACCEPT) - if (accepts contains "application/vnd.lichess.v2+json") Some(2) - else if (accepts contains "application/vnd.lichess.v1+json") Some(1) + if (accepts contains "application/vnd.lichess.v2+json") Some(ApiVersion(2)) + else if (accepts contains "application/vnd.lichess.v1+json") Some(ApiVersion(1)) else req.path match { - case PathPattern(version) => parseIntOption(version) + case PathPattern(version) => parseIntOption(version) map ApiVersion.apply case _ => None } - } filter acceptedVersionNumbers.contains + } filter acceptedVersions.contains def requested(req: RequestHeader) = requestVersion(req).isDefined } diff --git a/modules/api/src/main/RoundApi.scala b/modules/api/src/main/RoundApi.scala index 4714986df3..0ed3e81dbd 100644 --- a/modules/api/src/main/RoundApi.scala +++ b/modules/api/src/main/RoundApi.scala @@ -3,8 +3,8 @@ package lila.api import play.api.libs.json._ import lila.analyse.{ JsonView => analysisJson, Analysis, Info } -import lila.common.LightUser import lila.common.PimpedJson._ +import lila.common.{ LightUser, ApiVersion } import lila.game.{ Pov, Game, GameRepo } import lila.pref.Pref import lila.round.{ JsonView, Forecast } @@ -21,7 +21,7 @@ private[api] final class RoundApi( getSimul: Simul.ID => Fu[Option[Simul]], lightUser: String => Option[LightUser]) { - def player(pov: Pov, apiVersion: Int)(implicit ctx: Context): Fu[JsObject] = + def player(pov: Pov, apiVersion: ApiVersion)(implicit ctx: Context): Fu[JsObject] = GameRepo.initialFen(pov.game) flatMap { initialFen => jsonView.playerJson(pov, ctx.pref, apiVersion, ctx.me, withBlurs = ctx.me ?? Granter(_.ViewBlurs), @@ -40,7 +40,7 @@ private[api] final class RoundApi( } } - def watcher(pov: Pov, apiVersion: Int, tv: Option[lila.round.OnTv], + def watcher(pov: Pov, apiVersion: ApiVersion, tv: Option[lila.round.OnTv], initialFenO: Option[Option[String]] = None)(implicit ctx: Context): Fu[JsObject] = initialFenO.fold(GameRepo initialFen pov.game)(fuccess) flatMap { initialFen => jsonView.watcherJson(pov, ctx.pref, apiVersion, ctx.me, tv, @@ -60,7 +60,7 @@ private[api] final class RoundApi( } } - def review(pov: Pov, apiVersion: Int, tv: Option[lila.round.OnTv], + def review(pov: Pov, apiVersion: ApiVersion, tv: Option[lila.round.OnTv], analysis: Option[Analysis] = None, initialFenO: Option[Option[String]] = None, withMoveTimes: Boolean = false, diff --git a/modules/api/src/main/RoundApiBalancer.scala b/modules/api/src/main/RoundApiBalancer.scala index 3f90ea2b29..0bcc6f6daf 100644 --- a/modules/api/src/main/RoundApiBalancer.scala +++ b/modules/api/src/main/RoundApiBalancer.scala @@ -5,6 +5,7 @@ import akka.pattern.{ ask, pipe } import play.api.libs.json.JsObject import scala.concurrent.duration._ +import lila.common.ApiVersion import lila.analyse.Analysis import lila.game.Pov import lila.pref.Pref @@ -20,11 +21,11 @@ private[api] final class RoundApiBalancer( implicit val timeout = makeTimeout seconds 20 - case class Player(pov: Pov, apiVersion: Int, ctx: Context) - case class Watcher(pov: Pov, apiVersion: Int, tv: Option[lila.round.OnTv], + case class Player(pov: Pov, apiVersion: ApiVersion, ctx: Context) + case class Watcher(pov: Pov, apiVersion: ApiVersion, tv: Option[lila.round.OnTv], initialFenO: Option[Option[String]] = None, ctx: Context) - case class Review(pov: Pov, apiVersion: Int, tv: Option[lila.round.OnTv], + case class Review(pov: Pov, apiVersion: ApiVersion, tv: Option[lila.round.OnTv], analysis: Option[Analysis] = None, initialFenO: Option[Option[String]] = None, withMoveTimes: Boolean = false, @@ -58,7 +59,7 @@ private[api] final class RoundApiBalancer( import implementation._ - def player(pov: Pov, apiVersion: Int)(implicit ctx: Context): Fu[JsObject] = { + def player(pov: Pov, apiVersion: ApiVersion)(implicit ctx: Context): Fu[JsObject] = { router ? Player(pov, apiVersion, ctx) mapTo manifest[JsObject] addFailureEffect { e => logger.error(pov.toString, e) } @@ -67,12 +68,12 @@ private[api] final class RoundApiBalancer( .logIfSlow(500, logger) { _ => s"outer player $pov" } .result - def watcher(pov: Pov, apiVersion: Int, tv: Option[lila.round.OnTv], + def watcher(pov: Pov, apiVersion: ApiVersion, tv: Option[lila.round.OnTv], initialFenO: Option[Option[String]] = None)(implicit ctx: Context): Fu[JsObject] = { router ? Watcher(pov, apiVersion, tv, initialFenO, ctx) mapTo manifest[JsObject] }.mon(_.round.api.watcher) - def review(pov: Pov, apiVersion: Int, tv: Option[lila.round.OnTv], + def review(pov: Pov, apiVersion: ApiVersion, tv: Option[lila.round.OnTv], analysis: Option[Analysis] = None, initialFenO: Option[Option[String]] = None, withMoveTimes: Boolean = false, diff --git a/modules/common/src/main/PackageObject.scala b/modules/common/src/main/PackageObject.scala index ec76f06d43..6ab1aeda34 100644 --- a/modules/common/src/main/PackageObject.scala +++ b/modules/common/src/main/PackageObject.scala @@ -13,6 +13,10 @@ trait PackageObject extends Steroids with WithFuture { def value: String override def toString = value } + trait IntValue extends Any { + def value: Int + override def toString = value.toString + } def !![A](msg: String): Valid[A] = msg.failureNel[A] diff --git a/modules/common/src/main/model.scala b/modules/common/src/main/model.scala new file mode 100644 index 0000000000..5c9cffba33 --- /dev/null +++ b/modules/common/src/main/model.scala @@ -0,0 +1,3 @@ +package lila.common + +case class ApiVersion(value: Int) extends AnyVal with IntValue diff --git a/modules/round/src/main/JsonView.scala b/modules/round/src/main/JsonView.scala index a9f5c6b2b8..9e3f4536cf 100644 --- a/modules/round/src/main/JsonView.scala +++ b/modules/round/src/main/JsonView.scala @@ -7,6 +7,7 @@ import org.apache.commons.lang3.StringEscapeUtils.escapeHtml4 import play.api.libs.json._ import lila.common.PimpedJson._ +import lila.common.ApiVersion import lila.game.JsonView._ import lila.game.{ Pov, Game, PerfPicker, Source, GameRepo, CorrespondenceClock } import lila.pref.Pref @@ -34,7 +35,7 @@ final class JsonView( def playerJson( pov: Pov, pref: Pref, - apiVersion: Int, + apiVersion: ApiVersion, playerUser: Option[User], initialFen: Option[String], withBlurs: Boolean): Fu[JsObject] = @@ -119,7 +120,7 @@ final class JsonView( def watcherJson( pov: Pov, pref: Pref, - apiVersion: Int, + apiVersion: ApiVersion, user: Option[User], tv: Option[OnTv], withBlurs: Boolean, diff --git a/modules/round/src/main/Socket.scala b/modules/round/src/main/Socket.scala index 8c35834ef6..8f37ab3d94 100644 --- a/modules/round/src/main/Socket.scala +++ b/modules/round/src/main/Socket.scala @@ -141,9 +141,9 @@ private[round] final class Socket( blackIsGone = blackIsGone) } pipeTo sender - case Join(uid, user, color, playerId, ip, userTv) => + case Join(uid, user, color, playerId, ip, userTv, apiVersion) => val (enumerator, channel) = Concurrent.broadcast[JsValue] - val member = Member(channel, user, color, playerId, ip, userTv = userTv) + val member = Member(channel, user, color, playerId, ip, userTv = userTv, apiVersion = apiVersion) addMember(uid, member) notifyCrowd playerDo(color, _.ping) diff --git a/modules/round/src/main/SocketHandler.scala b/modules/round/src/main/SocketHandler.scala index 2785cdca34..ff4ae42860 100644 --- a/modules/round/src/main/SocketHandler.scala +++ b/modules/round/src/main/SocketHandler.scala @@ -10,6 +10,7 @@ import chess.format.Uci import play.api.libs.json.{ JsObject, Json } import actorApi._, round._ +import lila.common.ApiVersion import lila.common.PimpedJson._ import lila.game.{ Game, Pov, PovRef, PlayerRef, GameRepo } import lila.hub.actorApi.map._ @@ -105,9 +106,10 @@ private[round] final class SocketHandler( uid: String, user: Option[User], ip: String, - userTv: Option[String]): Fu[Option[JsSocketHandler]] = + userTv: Option[String], + apiVersion: ApiVersion): Fu[Option[JsSocketHandler]] = GameRepo.pov(gameId, colorName) flatMap { - _ ?? { join(_, none, uid, "", user, ip, userTv = userTv) map some } + _ ?? { join(_, none, uid, "", user, ip, userTv = userTv, apiVersion) map some } } def player( @@ -115,8 +117,9 @@ private[round] final class SocketHandler( uid: String, token: String, user: Option[User], - ip: String): Fu[JsSocketHandler] = - join(pov, Some(pov.playerId), uid, token, user, ip, userTv = none) + ip: String, + apiVersion: ApiVersion): Fu[JsSocketHandler] = + join(pov, Some(pov.playerId), uid, token, user, ip, userTv = none, apiVersion) private def join( pov: Pov, @@ -125,14 +128,16 @@ private[round] final class SocketHandler( token: String, user: Option[User], ip: String, - userTv: Option[String]): Fu[JsSocketHandler] = { + userTv: Option[String], + apiVersion: ApiVersion): Fu[JsSocketHandler] = { val join = Join( uid = uid, user = user, color = pov.color, playerId = playerId, ip = ip, - userTv = userTv) + userTv = userTv, + apiVersion = apiVersion) socketHub ? Get(pov.gameId) mapTo manifest[ActorRef] flatMap { socket => Handler(hub, socket, uid, join, user map (_.id)) { case Connected(enum, member) => diff --git a/modules/round/src/main/actorApi.scala b/modules/round/src/main/actorApi.scala index 431e94a5e7..d4b8556a3a 100644 --- a/modules/round/src/main/actorApi.scala +++ b/modules/round/src/main/actorApi.scala @@ -7,6 +7,7 @@ import scala.concurrent.Promise import chess.Color import chess.format.Uci +import lila.common.ApiVersion import lila.game.{ Game, Event, PlayerRef } import lila.socket.SocketMember import lila.user.User @@ -20,6 +21,7 @@ sealed trait Member extends SocketMember { val troll: Boolean val ip: String val userTv: Option[String] + val apiVersion: ApiVersion def owner = playerIdOption.isDefined def watcher = !owner @@ -34,11 +36,12 @@ object Member { color: Color, playerIdOption: Option[String], ip: String, - userTv: Option[String]): Member = { + userTv: Option[String], + apiVersion: ApiVersion): Member = { val userId = user map (_.id) val troll = user.??(_.troll) - playerIdOption.fold[Member](Watcher(channel, userId, color, troll, ip, userTv)) { playerId => - Owner(channel, userId, playerId, color, troll, ip) + playerIdOption.fold[Member](Watcher(channel, userId, color, troll, ip, userTv, apiVersion)) { playerId => + Owner(channel, userId, playerId, color, troll, ip, apiVersion) } } } @@ -49,7 +52,8 @@ case class Owner( playerId: String, color: Color, troll: Boolean, - ip: String) extends Member { + ip: String, + apiVersion: ApiVersion) extends Member { val playerIdOption = playerId.some val userTv = none @@ -61,7 +65,8 @@ case class Watcher( color: Color, troll: Boolean, ip: String, - userTv: Option[String]) extends Member { + userTv: Option[String], + apiVersion: ApiVersion) extends Member { val playerIdOption = none } @@ -72,7 +77,8 @@ case class Join( color: Color, playerId: Option[String], ip: String, - userTv: Option[String]) + userTv: Option[String], + apiVersion: ApiVersion) case class Connected(enumerator: JsEnumerator, member: Member) case class Bye(color: Color) case class IsGone(color: Color) diff --git a/modules/security/src/main/Api.scala b/modules/security/src/main/Api.scala index 0a709af687..4dd6603724 100644 --- a/modules/security/src/main/Api.scala +++ b/modules/security/src/main/Api.scala @@ -7,6 +7,7 @@ import play.api.data.Forms._ import play.api.mvc.RequestHeader import reactivemongo.bson._ +import lila.common.ApiVersion import lila.db.BSON.BSONJodaDateTimeHandler import lila.db.dsl._ import lila.user.{ User, UserRepo } @@ -46,7 +47,7 @@ final class Api( private def authenticateCandidate(candidate: Option[User.LoginCandidate])(username: String, password: String): Option[User] = candidate ?? { _(password) } - def saveAuthentication(userId: String, apiVersion: Option[Int])(implicit req: RequestHeader): Fu[String] = + def saveAuthentication(userId: String, apiVersion: Option[ApiVersion])(implicit req: RequestHeader): Fu[String] = UserRepo mustConfirmEmail userId flatMap { case true => fufail(Api MustConfirmEmail userId) case false => diff --git a/modules/security/src/main/Store.scala b/modules/security/src/main/Store.scala index 4e2d152646..b3a9a026d9 100644 --- a/modules/security/src/main/Store.scala +++ b/modules/security/src/main/Store.scala @@ -6,10 +6,10 @@ import org.joda.time.DateTime import play.api.mvc.RequestHeader import reactivemongo.bson.Macros -import lila.db.dsl._ +import lila.common.{ HTTPRequest, ApiVersion } import lila.db.BSON.BSONJodaDateTimeHandler +import lila.db.dsl._ import lila.user.{ User, UserRepo } -import lila.common.HTTPRequest object Store { @@ -20,7 +20,7 @@ object Store { sessionId: String, userId: String, req: RequestHeader, - apiVersion: Option[Int]): Funit = + apiVersion: Option[ApiVersion]): Funit = coll.insert($doc( "_id" -> sessionId, "user" -> userId, @@ -28,13 +28,16 @@ object Store { "ua" -> HTTPRequest.userAgent(req).|("?"), "date" -> DateTime.now, "up" -> true, - "api" -> apiVersion + "api" -> apiVersion.map(_.value) )).void + private val userIdProjection = $doc("user" -> true, "_id" -> false) + private val userIdFingerprintProjection = $doc("user" -> true, "fp" -> true, "_id" -> false) + def userId(sessionId: String): Fu[Option[String]] = coll.find( $doc("_id" -> sessionId, "up" -> true), - $doc("user" -> true, "_id" -> false) + userIdProjection ).uno[Bdoc] map { _ flatMap (_.getAs[String]("user")) } case class UserIdAndFingerprint(user: String, fp: Option[String]) @@ -43,7 +46,7 @@ object Store { def userIdAndFingerprint(sessionId: String): Fu[Option[UserIdAndFingerprint]] = coll.find( $doc("_id" -> sessionId, "up" -> true), - $doc("user" -> true, "fp" -> true, "_id" -> false) + userIdFingerprintProjection ).uno[UserIdAndFingerprint] def delete(sessionId: String): Funit = diff --git a/modules/user/src/main/UserRepo.scala b/modules/user/src/main/UserRepo.scala index 5fc8ef5028..c9acbf0b9f 100644 --- a/modules/user/src/main/UserRepo.scala +++ b/modules/user/src/main/UserRepo.scala @@ -5,6 +5,7 @@ import org.joda.time.DateTime import reactivemongo.api._ import reactivemongo.bson._ +import lila.common.ApiVersion import lila.db.BSON.BSONJodaDateTimeHandler import lila.db.dsl._ import lila.rating.{ Glicko, Perf, PerfType } @@ -219,7 +220,7 @@ object UserRepo { def getPasswordHash(id: ID): Fu[Option[String]] = coll.primitiveOne[String]($id(id), "password") - def create(username: String, password: String, email: Option[String], blind: Boolean, mobileApiVersion: Option[Int]): Fu[Option[User]] = + def create(username: String, password: String, email: Option[String], blind: Boolean, mobileApiVersion: Option[ApiVersion]): Fu[Option[User]] = !nameExists(username) flatMap { _ ?? { val doc = newUser(username, password, email, blind, mobileApiVersion) ++ @@ -347,7 +348,7 @@ object UserRepo { def setEmailConfirmed(id: String): Funit = coll.update($id(id), $unset(F.mustConfirmEmail)).void - private def newUser(username: String, password: String, email: Option[String], blind: Boolean, mobileApiVersion: Option[Int]) = { + private def newUser(username: String, password: String, email: Option[String], blind: Boolean, mobileApiVersion: Option[ApiVersion]) = { val salt = ornicar.scalalib.Random nextStringUppercase 32 implicit def countHandler = Count.countBSONHandler @@ -365,7 +366,7 @@ object UserRepo { F.count -> Count.default, F.enabled -> true, F.createdAt -> DateTime.now, - F.createdWithApiVersion -> mobileApiVersion, + F.createdWithApiVersion -> mobileApiVersion.map(_.value), F.seenAt -> DateTime.now) ++ { if (blind) $doc("blind" -> true) else $empty } diff --git a/public/stylesheets/common.css b/public/stylesheets/common.css index 6bc297fb8a..500c11419a 100644 --- a/public/stylesheets/common.css +++ b/public/stylesheets/common.css @@ -2007,6 +2007,10 @@ body.base .crosstable .loss { .crosstable .user { padding-left: 7px; } +.crosstable .unavailable { + margin-top: 40px; + opacity: 0.7; +} .bookmark { float: right; outline: none;