Merge pull request #1401 from gitbucket/git-lfs-support

GitLFS support
This commit is contained in:
Naoki Takezoe
2017-01-06 01:48:22 +09:00
committed by GitHub
15 changed files with 344 additions and 49 deletions

View File

@@ -57,7 +57,7 @@ abstract class ControllerBase extends ScalatraFilter
// Redirect to dashboard
httpResponse.sendRedirect(baseUrl + "/")
}
} else if(path.startsWith("/git/")){
} else if(path.startsWith("/git/") || path.startsWith("/git-lfs/")){
// Git repository
chain.doFilter(request, response)
} else {

View File

@@ -1,6 +1,7 @@
package gitbucket.core.controller
import javax.servlet.http.{HttpServletResponse, HttpServletRequest}
import java.io.FileInputStream
import javax.servlet.http.{HttpServletRequest, HttpServletResponse}
import gitbucket.core.plugin.PluginRegistry
import gitbucket.core.repo.html
@@ -16,9 +17,8 @@ import gitbucket.core.model.{Account, WebHook}
import gitbucket.core.service.WebHookService._
import gitbucket.core.view
import gitbucket.core.view.helpers
import io.github.gitbucket.scalatra.forms._
import org.apache.commons.io.FileUtils
import org.apache.commons.io.{FileUtils, IOUtils}
import org.eclipse.jgit.api.{ArchiveCommand, Git}
import org.eclipse.jgit.archive.{TgzFormat, ZipFormat}
import org.eclipse.jgit.dircache.DirCache
@@ -255,13 +255,9 @@ trait RepositoryViewerControllerBase extends ControllerBase {
val (id, path) = repository.splitPath(multiParams("splat").head)
using(Git.open(getRepositoryDir(repository.owner, repository.name))){ git =>
val revCommit = JGitUtil.getRevCommitFromId(git, git.getRepository.resolve(id))
getPathObjectId(git, path, revCommit).flatMap { objectId =>
JGitUtil.getObjectLoaderFromId(git, objectId){ loader =>
contentType = FileUtil.getMimeType(path)
response.setContentLength(loader.getSize.toInt)
loader.copyTo(response.outputStream)
()
}
getPathObjectId(git, path, revCommit).map { objectId =>
responseRawFile(git, objectId, path, repository)
} getOrElse NotFound()
}
})
@@ -277,23 +273,62 @@ trait RepositoryViewerControllerBase extends ControllerBase {
getPathObjectId(git, path, revCommit).map { objectId =>
if(raw){
// Download (This route is left for backword compatibility)
JGitUtil.getObjectLoaderFromId(git, objectId){ loader =>
contentType = FileUtil.getMimeType(path)
response.setContentLength(loader.getSize.toInt)
loader.copyTo(response.outputStream)
()
} getOrElse NotFound()
responseRawFile(git, objectId, path, repository)
} else {
html.blob(id, repository, path.split("/").toList,
JGitUtil.getContentInfo(git, path, objectId),
new JGitUtil.CommitInfo(JGitUtil.getLastModifiedCommit(git, revCommit, path)),
hasDeveloperRole(repository.owner, repository.name, context.loginAccount),
request.paths(2) == "blame")
request.paths(2) == "blame",
isLfsFile(git, objectId))
}
} getOrElse NotFound()
}
})
private def isLfsFile(git: Git, objectId: ObjectId): Boolean = {
JGitUtil.getObjectLoaderFromId(git, objectId){ loader =>
if(loader.isLarge){
false
} else {
new String(loader.getCachedBytes, "UTF-8").startsWith("version https://git-lfs.github.com/spec/v1")
}
}.getOrElse(false)
}
private def responseRawFile(git: Git, objectId: ObjectId, path: String,
repository: RepositoryService.RepositoryInfo): Unit = {
JGitUtil.getObjectLoaderFromId(git, objectId){ loader =>
contentType = FileUtil.getMimeType(path)
if(loader.isLarge){
response.setContentLength(loader.getSize.toInt)
loader.copyTo(response.outputStream)
} else {
val bytes = loader.getCachedBytes
val text = new String(bytes, "UTF-8")
if(text.startsWith("version https://git-lfs.github.com/spec/v1")){
// LFS objects
val attrs = text.split("\n").map { line =>
val dim = line.split(" ")
dim(0) -> dim(1)
}.toMap
response.setContentLength(attrs("size").toInt)
val oid = attrs("oid").split(":")(1)
using(new FileInputStream(FileUtil.getLfsFilePath(repository.owner, repository.name, oid))){ in =>
IOUtils.copy(in, response.getOutputStream)
}
} else {
response.setContentLength(loader.getSize.toInt)
response.getOutputStream.write(bytes)
}
}
}
}
get("/:owner/:repository/blame/*"){
blobRoute.action()
}

View File

@@ -20,7 +20,7 @@ trait AccessTokenService {
def tokenToHash(token: String): String = StringUtil.sha1(token)
/**
* @retuen (TokenId, Token)
* @return (TokenId, Token)
*/
def generateAccessToken(userName: String, note: String)(implicit s: Session): (Int, String) = {
var token: String = null

View File

@@ -176,6 +176,9 @@ object SystemSettingsService {
port:Int,
genericUser:String)
case class Lfs(
serverUrl: Option[String])
val DefaultSshPort = 29418
val DefaultSmtpPort = 25
val DefaultLdapPort = 389

View File

@@ -0,0 +1,83 @@
package gitbucket.core.servlet
import java.io.{File, FileInputStream, FileOutputStream}
import java.text.MessageFormat
import javax.servlet.http.{HttpServlet, HttpServletRequest, HttpServletResponse}
import gitbucket.core.util.{Directory, FileUtil, StringUtil}
import org.apache.commons.io.{FileUtils, IOUtils}
import org.json4s.jackson.Serialization._
import org.apache.http.HttpStatus
import gitbucket.core.util.ControlUtil._
/**
* Provides GitLFS Transfer API
* https://github.com/git-lfs/git-lfs/blob/master/docs/api/basic-transfers.md
*/
class GitLfsTransferServlet extends HttpServlet {
private implicit val jsonFormats = gitbucket.core.api.JsonFormat.jsonFormats
private val LongObjectIdLength = 32
private val LongObjectIdStringLength = LongObjectIdLength * 2
override protected def doGet(req: HttpServletRequest, res: HttpServletResponse): Unit = {
for {
(owner, repository, oid) <- getPathInfo(req, res) if checkToken(req, oid)
} yield {
val file = new File(FileUtil.getLfsFilePath(owner, repository, oid))
if(file.exists()){
res.setStatus(HttpStatus.SC_OK)
res.setContentType("application/octet-stream")
res.setContentLength(file.length.toInt)
using(new FileInputStream(file), res.getOutputStream){ (in, out) =>
IOUtils.copy(in, out)
out.flush()
}
} else {
sendError(res, HttpStatus.SC_NOT_FOUND,
MessageFormat.format("Object ''{0}'' not found", oid))
}
}
}
override protected def doPut(req: HttpServletRequest, res: HttpServletResponse): Unit = {
for {
(owner, repository, oid) <- getPathInfo(req, res) if checkToken(req, oid)
} yield {
val file = new File(FileUtil.getLfsFilePath(owner, repository, oid))
FileUtils.forceMkdir(file.getParentFile)
using(req.getInputStream, new FileOutputStream(file)){ (in, out) =>
IOUtils.copy(in, out)
}
res.setStatus(HttpStatus.SC_OK)
}
}
private def checkToken(req: HttpServletRequest, oid: String): Boolean = {
val token = req.getHeader("Authorization")
if(token != null){
val Array(expireAt, targetOid) = StringUtil.decodeBlowfish(token).split(" ")
oid == targetOid && expireAt.toLong > System.currentTimeMillis
} else {
false
}
}
private def getPathInfo(req: HttpServletRequest, res: HttpServletResponse): Option[(String, String, String)] = {
req.getRequestURI.substring(1).split("/") match {
case Array(_, owner, repository, oid) => Some((owner, repository, oid))
case _ => None
}
}
private def sendError(res: HttpServletResponse, status: Int, message: String): Unit = {
res.setStatus(status)
using(res.getWriter()){ out =>
out.write(write(GitLfs.Error(message)))
out.flush()
}
}
}

View File

@@ -1,6 +1,7 @@
package gitbucket.core.servlet
import java.io.File
import java.util.Date
import gitbucket.core.api
import gitbucket.core.model.{Session, WebHook}
@@ -11,16 +12,16 @@ import gitbucket.core.service._
import gitbucket.core.util.ControlUtil._
import gitbucket.core.util.Implicits._
import gitbucket.core.util._
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.http.server.GitServlet
import org.eclipse.jgit.lib._
import org.eclipse.jgit.transport._
import org.eclipse.jgit.transport.resolver._
import org.slf4j.LoggerFactory
import javax.servlet.ServletConfig
import javax.servlet.http.{HttpServletResponse, HttpServletRequest}
import javax.servlet.http.{HttpServletRequest, HttpServletResponse}
import org.json4s.jackson.Serialization._
/**
@@ -32,7 +33,8 @@ import javax.servlet.http.{HttpServletResponse, HttpServletRequest}
class GitRepositoryServlet extends GitServlet with SystemSettingsService {
private val logger = LoggerFactory.getLogger(classOf[GitRepositoryServlet])
private implicit val jsonFormats = gitbucket.core.api.JsonFormat.jsonFormats
override def init(config: ServletConfig): Unit = {
setReceivePackFactory(new GitBucketReceivePackFactory())
@@ -45,15 +47,73 @@ class GitRepositoryServlet extends GitServlet with SystemSettingsService {
override def service(req: HttpServletRequest, res: HttpServletResponse): Unit = {
val agent = req.getHeader("USER-AGENT")
val index = req.getRequestURI.indexOf(".git")
if(index >= 0 && (agent == null || agent.toLowerCase.indexOf("git/") < 0)){
if(index >= 0 && (agent == null || agent.toLowerCase.indexOf("git") < 0)){
// redirect for browsers
val paths = req.getRequestURI.substring(0, index).split("/")
res.sendRedirect(baseUrl(req) + "/" + paths.dropRight(1).last + "/" + paths.last)
} else if(req.getMethod.toUpperCase == "POST" && req.getRequestURI.endsWith("/info/lfs/objects/batch")){
serviceGitLfsBatchAPI(req, res)
} else {
// response for git client
super.service(req, res)
}
}
/**
* Provides GitLFS Batch API
* https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md
*/
protected def serviceGitLfsBatchAPI(req: HttpServletRequest, res: HttpServletResponse): Unit = {
val batchRequest = read[GitLfs.BatchRequest](req.getInputStream)
val settings = loadSystemSettings()
settings.baseUrl match {
case None => {
throw new IllegalStateException("lfs.server_url is not configured.")
}
case Some(baseUrl) => {
req.getRequestURI.substring(1).replace(".git/", "/").split("/") match {
case Array(_, owner, repository, _*) => {
val timeout = System.currentTimeMillis + (60000 * 10) // 10 min.
val batchResponse = batchRequest.operation match {
case "upload" =>
GitLfs.BatchUploadResponse("basic", batchRequest.objects.map { requestObject =>
GitLfs.BatchResponseObject(requestObject.oid, requestObject.size, true,
GitLfs.Actions(
upload = Some(GitLfs.Action(
href = baseUrl + "/git-lfs/" + owner + "/" + repository + "/" + requestObject.oid,
header = Map("Authorization" -> StringUtil.encodeBlowfish(timeout + " " + requestObject.oid)),
expires_at = new Date(timeout)
))
)
)
})
case "download" =>
GitLfs.BatchUploadResponse("basic", batchRequest.objects.map { requestObject =>
GitLfs.BatchResponseObject(requestObject.oid, requestObject.size, true,
GitLfs.Actions(
download = Some(GitLfs.Action(
href = baseUrl + "/git-lfs/" + owner + "/" + repository + "/" + requestObject.oid,
header = Map("Authorization" -> StringUtil.encodeBlowfish(timeout + " " + requestObject.oid)),
expires_at = new Date(timeout)
))
)
)
})
}
res.setContentType("application/vnd.git-lfs+json")
using(res.getWriter){ out =>
out.print(write(batchResponse))
out.flush()
}
}
}
}
}
}
}
class GitBucketRepositoryResolver(parent: FileResolver[HttpServletRequest]) extends RepositoryResolver[HttpServletRequest] {
@@ -232,3 +292,45 @@ class CommitLogHook(owner: String, repository: String, pusher: String, baseUrl:
}
}
object GitLfs {
case class BatchRequest(
operation: String,
transfers: Seq[String],
objects: Seq[BatchRequestObject]
)
case class BatchRequestObject(
oid: String,
size: Long
)
case class BatchUploadResponse(
transfer: String,
objects: Seq[BatchResponseObject]
)
case class BatchResponseObject(
oid: String,
size: Long,
authenticated: Boolean,
actions: Actions
)
case class Actions(
download: Option[Action] = None,
upload: Option[Action] = None
)
case class Action(
href: String,
header: Map[String, String] = Map.empty,
expires_at: Date
)
case class Error(
message: String
)
}

View File

@@ -21,8 +21,9 @@ class TransactionFilter extends Filter {
def destroy(): Unit = {}
def doFilter(req: ServletRequest, res: ServletResponse, chain: FilterChain): Unit = {
if(req.asInstanceOf[HttpServletRequest].getServletPath().startsWith("/assets/")){
// assets don't need transaction
val servletPath = req.asInstanceOf[HttpServletRequest].getServletPath()
if(servletPath.startsWith("/assets/") || servletPath.startsWith("/git-lfs")){
// assets and git-lfs don't need transaction
chain.doFilter(req, res)
} else {
Database() withTransaction { session =>

View File

@@ -20,6 +20,20 @@ object ControlUtil {
}
}
def using[A <% { def close(): Unit }, B <% { def close(): Unit }, C](resource1: A, resource2: B)(f: (A, B) => C): C =
try f(resource1, resource2) finally {
if(resource1 != null){
ignoring(classOf[Throwable]) {
resource1.close()
}
}
if(resource2 != null){
ignoring(classOf[Throwable]) {
resource2.close()
}
}
}
def using[T](git: Git)(f: Git => T): T =
try f(git) finally git.getRepository.close()

View File

@@ -1,11 +1,9 @@
package gitbucket.core.util
import java.io.File
import ControlUtil._
import org.apache.commons.io.FileUtils
/**
* Provides directories used by GitBucket.
* Provides directory locations used by GitBucket.
*/
object Directory {
@@ -50,6 +48,12 @@ object Directory {
def getAttachedDir(owner: String, repository: String): File =
new File(s"${RepositoryHome}/${owner}/${repository}/comments")
/**
* Directory for files which are attached to issue.
*/
def getLfsDir(owner: String, repository: String): File =
new File(s"${RepositoryHome}/${owner}/${repository}/lfs")
/**
* Directory for uploaded files by the specified user.
*/

View File

@@ -62,4 +62,8 @@ object FileUtil {
"image/jpeg",
"image/png",
"text/plain")
def getLfsFilePath(owner: String, repository: String, oid: String): String =
Directory.getLfsDir(owner, repository) + "/" + oid
}

View File

@@ -1,14 +1,23 @@
package gitbucket.core.util
import java.net.{URLDecoder, URLEncoder}
import org.mozilla.universalchardet.UniversalDetector
import ControlUtil._
import org.apache.commons.io.input.BOMInputStream
import org.apache.commons.io.IOUtils
import org.apache.commons.codec.binary.{Base64, StringUtils}
import scala.util.control.Exception._
object StringUtil {
private lazy val BlowfishKey = {
// last 4 numbers in current timestamp
val time = System.currentTimeMillis.toString
time.substring(time.length - 4)
}
def sha1(value: String): String =
defining(java.security.MessageDigest.getInstance("SHA-1")){ md =>
md.update(value.getBytes)
@@ -21,6 +30,20 @@ object StringUtil {
md.digest.map(b => "%02x".format(b)).mkString
}
def encodeBlowfish(value: String): String = {
val spec = new javax.crypto.spec.SecretKeySpec(BlowfishKey.getBytes(), "Blowfish")
val cipher = javax.crypto.Cipher.getInstance("Blowfish")
cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, spec)
new String(Base64.encodeBase64(cipher.doFinal(value.getBytes("UTF-8"))), "UTF-8")
}
def decodeBlowfish(value: String): String = {
val spec = new javax.crypto.spec.SecretKeySpec(BlowfishKey.getBytes(), "Blowfish")
val cipher = javax.crypto.Cipher.getInstance("Blowfish")
cipher.init(javax.crypto.Cipher.DECRYPT_MODE, spec)
new String(cipher.doFinal(Base64.decodeBase64(value)), "UTF-8")
}
def urlEncode(value: String): String = URLEncoder.encode(value, "UTF-8").replace("+", "%20")
def urlDecode(value: String): String = URLDecoder.decode(value, "UTF-8")

View File

@@ -123,27 +123,25 @@
<label class="checkbox">
<input type="checkbox" id="ssh" name="ssh"@if(context.settings.ssh){ checked}/>
Enable SSH access to git repository
<span class="muted normal">(Both of SSH host and Base URL are required if SSH access is enabled)</span>
</label>
</fieldset>
<div class="ssh">
<div class="form-group">
<label class="control-label col-md-3" for="sshHost">SSH Host</label>
<label class="control-label col-md-3" for="sshHost">SSH host</label>
<div class="col-md-9">
<input type="text" id="sshHost" name="sshHost" class="form-control" value="@context.settings.sshHost"/>
<span id="error-sshHost" class="error"></span>
</div>
</div>
<div class="form-group">
<label class="control-label col-md-3" for="sshPort">SSH Port</label>
<label class="control-label col-md-3" for="sshPort">SSH port</label>
<div class="col-md-9">
<input type="text" id="sshPort" name="sshPort" class="form-control" value="@context.settings.sshPort"/>
<span id="error-sshPort" class="error"></span>
</div>
</div>
</div>
<p class="muted">
Both of SSH host and Base URL are required if SSH access is enabled.
</p>
<!--====================================================================-->
<!-- Authentication -->
<!--====================================================================-->
@@ -157,14 +155,14 @@
</fieldset>
<div class="ldap">
<div class="form-group">
<label class="control-label col-md-3" for="ldapHost">LDAP Host</label>
<label class="control-label col-md-3" for="ldapHost">LDAP host</label>
<div class="col-md-9">
<input type="text" id="ldapHost" name="ldap.host" class="form-control" value="@context.settings.ldap.map(_.host)"/>
<span id="error-ldap_host" class="error"></span>
</div>
</div>
<div class="form-group">
<label class="control-label col-md-3" for="ldapPort">LDAP Port</label>
<label class="control-label col-md-3" for="ldapPort">LDAP port</label>
<div class="col-md-9">
<input type="text" id="ldapPort" name="ldap.port" class="form-control input-mini" value="@context.settings.ldap.map(_.port)"/>
<span id="error-ldap_port" class="error"></span>
@@ -178,7 +176,7 @@
</div>
</div>
<div class="form-group">
<label class="control-label col-md-3" for="ldapBindPassword">Bind Password</label>
<label class="control-label col-md-3" for="ldapBindPassword">Bind password</label>
<div class="col-md-9">
<input type="password" id="ldapBindPassword" name="ldap.bindPassword" class="form-control" value="@context.settings.ldap.map(_.bindPassword)"/>
<span id="error-ldap_bindPassword" class="error"></span>
@@ -259,31 +257,32 @@
<label class="checkbox">
<input type="checkbox" id="useSMTP" name="useSMTP" @if(context.settings.useSMTP){ checked}/>
SMTP
<span class="muted normal">(Enable notification not only SMTP configuration if you want to send notification email)</span>
</label>
</fieldset>
<div class="useSMTP">
<div class="form-group">
<label class="control-label col-md-3" for="smtpHost">SMTP Host</label>
<label class="control-label col-md-3" for="smtpHost">SMTP host</label>
<div class="col-md-9">
<input type="text" id="smtpHost" name="smtp.host" class="form-control" value="@context.settings.smtp.map(_.host)"/>
<span id="error-smtp_host" class="error"></span>
</div>
</div>
<div class="form-group">
<label class="control-label col-md-3" for="smtpPort">SMTP Port</label>
<label class="control-label col-md-3" for="smtpPort">SMTP port</label>
<div class="col-md-9">
<input type="text" id="smtpPort" name="smtp.port" class="form-control input-mini" value="@context.settings.smtp.map(_.port)"/>
<span id="error-smtp_port" class="error"></span>
</div>
</div>
<div class="form-group">
<label class="control-label col-md-3" for="smtpUser">SMTP User</label>
<label class="control-label col-md-3" for="smtpUser">SMTP user</label>
<div class="col-md-9">
<input type="text" id="smtpUser" name="smtp.user" class="form-control" value="@context.settings.smtp.map(_.user)"/>
</div>
</div>
<div class="form-group">
<label class="control-label col-md-3" for="smtpPassword">SMTP Password</label>
<label class="control-label col-md-3" for="smtpPassword">SMTP password</label>
<div class="col-md-9">
<input type="password" id="smtpPassword" name="smtp.password" class="form-control" value="@context.settings.smtp.map(_.password)"/>
</div>
@@ -295,13 +294,13 @@
</div>
</div>
<div class="form-group">
<label class="control-label col-md-3" for="fromAddress">FROM Address</label>
<label class="control-label col-md-3" for="fromAddress">FROM address</label>
<div class="col-md-9">
<input type="text" id="fromAddress" name="smtp.fromAddress" class="form-control" value="@context.settings.smtp.map(_.fromAddress)"/>
</div>
</div>
<div class="form-group">
<label class="control-label col-md-3" for="fromName">FROM Name</label>
<label class="control-label col-md-3" for="fromName">FROM name</label>
<div class="col-md-9">
<input type="text" id="fromName" name="smtp.fromName" class="form-control" value="@context.settings.smtp.map(_.fromName)"/>
</div>
@@ -311,11 +310,23 @@
<input type="text" id="testAddress" size="30"/>
<input type="button" id="sendTestMail" value="Send"/>
</div>
<p class="muted">
Enable notification not only SMTP configuration if you want to send notification email.
</p>
</div>
<!--====================================================================-->
<!-- GitLFS -->
<!--====================================================================-->
@*
<hr>
<label class="strong">
GitLFS <span class="muted normal">(Put LFS server url to enable GitLFS support)</span>
</label>
<div class="form-group">
<label class="control-label col-md-3" for="smtpHost">LFS server url</label>
<div class="col-md-9">
<input type="text" id="lfsServerUrl" name="lfs.serverUrl" class="form-control" value="@context.settings.lfs.serverUrl"/>
<span id="error-lfs_serverUrl" class="error"></span>
</div>
</div>
*@
</div>
</div>
<div class="align-right" style="margin-top: 20px;">

View File

@@ -4,7 +4,8 @@
content: gitbucket.core.util.JGitUtil.ContentInfo,
latestCommit: gitbucket.core.util.JGitUtil.CommitInfo,
hasWritePermission: Boolean,
isBlame: Boolean)(implicit context: gitbucket.core.controller.Context)
isBlame: Boolean,
isLfsFile: Boolean)(implicit context: gitbucket.core.controller.Context)
@import gitbucket.core.view.helpers
@gitbucket.core.html.main(s"${(repository.name :: pathList).mkString("/")} at ${helpers.encodeRefName(branch)} - ${repository.owner}/${repository.name}", Some(repository)) {
@gitbucket.core.html.menu("files", repository){
@@ -45,6 +46,9 @@
<a href="@helpers.url(repository)/tree/@helpers.encodeRefName(branch)/@pathList.take(i + 1).mkString("/")">@section</a> /
}
}
@if(isLfsFile){
<span class="label label-info">LFS</span>
}
</div>
<div class="box-header">
@helpers.avatar(latestCommit, 28)

View File

@@ -46,6 +46,17 @@
<url-pattern>/git/*</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>GitLfsTransferServlet</servlet-name>
<servlet-class>gitbucket.core.servlet.GitLfsTransferServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>GitLfsTransferServlet</servlet-name>
<url-pattern>/git-lfs/*</url-pattern>
</servlet-mapping>
<!-- ===================================================================== -->
<!-- Supply assets which are provided by plugins -->
<!-- ===================================================================== -->

View File

@@ -5,7 +5,7 @@ import java.util.Date
import gitbucket.core.model.Account
import gitbucket.core.service.{RequestCache, SystemSettingsService}
import gitbucket.core.controller.Context
import SystemSettingsService.SystemSettings
import SystemSettingsService.{Lfs, SystemSettings}
import javax.servlet.http.{HttpServletRequest, HttpSession}
import play.twirl.api.Html