org.http4s.StaticFile.scala Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of http4s-core_3 Show documentation
Show all versions of http4s-core_3 Show documentation
Core http4s library for servers and clients
/*
* Copyright 2013 http4s.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.http4s
import cats.ApplicativeThrow
import cats.Functor
import cats.MonadError
import cats.MonadThrow
import cats.Semigroup
import cats.data.NonEmptyList
import cats.data.OptionT
import cats.effect.Sync
import cats.effect.SyncIO
import cats.syntax.all._
import fs2.Stream
import fs2.io._
import fs2.io.file.Files
import fs2.io.file.Path
import org.http4s.Status.NotModified
import org.http4s.headers._
import org.http4s.syntax.header._
import org.typelevel.vault._
import java.io._
import java.net.URL
object StaticFile {
private[this] val logger = Platform.loggerFactory.getLogger
val DefaultBufferSize = 10240
def fromString[F[_]: Files: MonadThrow](
url: String,
req: Option[Request[F]] = None,
): OptionT[F, Response[F]] =
fromPath(Path(url), req)
def fromResource[F[_]: Sync](
name: String,
req: Option[Request[F]] = None,
preferGzipped: Boolean = false,
classloader: Option[ClassLoader] = None,
): OptionT[F, Response[F]] = {
val loader = classloader.getOrElse(getClass.getClassLoader)
val acceptEncodingHeader: Option[`Accept-Encoding`] =
req.flatMap(_.headers.get[`Accept-Encoding`])
val tryGzipped =
preferGzipped && acceptEncodingHeader.exists { acceptEncoding =>
acceptEncoding.satisfiedBy(ContentCoding.gzip) || acceptEncoding.satisfiedBy(
ContentCoding.`x-gzip`
)
}
val normalizedName = name.split("/").filter(_.nonEmpty).mkString("/")
def getResource(name: String) =
OptionT(Sync[F].blocking(Option(loader.getResource(name))))
val gzUrl: OptionT[F, URL] =
if (tryGzipped) getResource(normalizedName + ".gz") else OptionT.none
gzUrl
.flatMap { url =>
fromURL(url, req).map {
_.removeHeader[`Content-Type`]
.putHeaders(
`Content-Encoding`(ContentCoding.gzip),
nameToContentType(normalizedName), // Guess content type from the name without ".gz"
)
}
}
.orElse(
getResource(normalizedName)
.flatMap(fromURL(_, req))
)
}
def fromURL[F[_]](url: URL, req: Option[Request[F]] = None)(implicit
F: Sync[F]
): OptionT[F, Response[F]] = {
val fileUrl = url.getFile()
val file = new File(fileUrl)
OptionT.apply(F.defer {
if (url.getProtocol === "file" && file.isDirectory())
F.pure(none[Response[F]])
else {
val urlConn = url.openConnection
val lastmod = HttpDate.fromEpochSecond(urlConn.getLastModified / 1000).toOption
val ifModifiedSince: Option[`If-Modified-Since`] =
req.flatMap(_.headers.get[`If-Modified-Since`])
val expired = (ifModifiedSince, lastmod).mapN(_.date < _).getOrElse(true)
if (expired) {
val len = urlConn.getContentLengthLong
val headers = Headers(
lastmod.map(`Last-Modified`(_)),
nameToContentType(url.getPath),
if (len >= 0) `Content-Length`.unsafeFromLong(len)
else `Transfer-Encoding`(TransferCoding.chunked.pure[NonEmptyList]),
)
F.blocking(urlConn.getInputStream)
.redeem(
recover = {
case _: FileNotFoundException => None
case other => throw other
},
f = { inputStream =>
Some(
Response(
headers = headers,
body = readInputStream[F](F.pure(inputStream), DefaultBufferSize),
)
)
},
)
} else
F.blocking(urlConn.getInputStream.close())
.handleError(_ => ())
.as(Some(Response(NotModified)))
}
})
}
@deprecated("Use calculateETag", "0.23.5")
def calcETag[F[_]: Files: Functor]: File => F[String] =
f =>
Files[F]
.isRegularFile(Path.fromNioPath(f.toPath()))
.map(isFile =>
if (isFile) s"${f.lastModified().toHexString}-${f.length().toHexString}" else ""
)
def calculateETag[F[_]: Files: ApplicativeThrow]: Path => F[String] =
f =>
Files[F]
.getBasicFileAttributes(f, followLinks = true)
.map(attr =>
if (attr.isRegularFile)
s"${attr.lastModifiedTime.toMillis.toHexString}-${attr.size.toHexString}"
else ""
)
@deprecated("Use fromPath", "0.23.5")
def fromFile[F[_]: Files: MonadThrow](
f: File,
req: Option[Request[F]] = None,
): OptionT[F, Response[F]] =
fromPath(Path.fromNioPath(f.toPath()), DefaultBufferSize, req, calculateETag[F])
def fromPath[F[_]: Files: MonadThrow](
f: Path,
req: Option[Request[F]] = None,
): OptionT[F, Response[F]] =
fromPath(f, DefaultBufferSize, req, calculateETag[F])
@deprecated("Use fromPath", "0.23.5")
def fromFile[F[_]: Files: MonadThrow](
f: File,
req: Option[Request[F]],
etagCalculator: File => F[String],
): OptionT[F, Response[F]] =
fromPath(
Path.fromNioPath(f.toPath()),
DefaultBufferSize,
req,
etagCalculator.compose(_.toNioPath.toFile()),
)
def fromPath[F[_]: Files: MonadThrow](
f: Path,
req: Option[Request[F]],
etagCalculator: Path => F[String],
): OptionT[F, Response[F]] =
fromPath(f, DefaultBufferSize, req, etagCalculator)
@deprecated("Use fromPath", "0.23.5")
def fromFile[F[_]: Files: MonadThrow](
f: File,
buffsize: Int,
req: Option[Request[F]],
etagCalculator: File => F[String],
): OptionT[F, Response[F]] =
fromPath(
Path.fromNioPath(f.toPath()),
0,
f.length(),
buffsize,
req,
etagCalculator.compose(_.toNioPath.toFile()),
)
def fromPath[F[_]: Files: MonadThrow](
f: Path,
buffsize: Int,
req: Option[Request[F]],
etagCalculator: Path => F[String],
): OptionT[F, Response[F]] =
OptionT
.liftF(Files[F].getBasicFileAttributes(f, followLinks = true).map(_.size))
.flatMap { size =>
fromPath(f, 0, size, buffsize, req, etagCalculator)
}
.recoverWith { case _: fs2.io.file.NoSuchFileException =>
OptionT.none
}
@deprecated("Use fromPath", "0.23.5")
def fromFile[F[_]: Files](
f: File,
start: Long,
end: Long,
buffsize: Int,
req: Option[Request[F]],
etagCalculator: File => F[String],
)(implicit
F: MonadError[F, Throwable]
): OptionT[F, Response[F]] =
fromPath(
Path.fromNioPath(f.toPath()),
start,
end,
buffsize,
req,
etagCalculator.compose(_.toNioPath.toFile()),
)
def fromPath[F[_]: Files](
f: Path,
start: Long,
end: Long,
buffsize: Int,
req: Option[Request[F]],
etagCalculator: Path => F[String],
)(implicit
F: MonadError[F, Throwable]
): OptionT[F, Response[F]] =
OptionT(for {
etagCalc <- etagCalculator(f).map(et => ETag(et))
res <- Files[F].isRegularFile(f).flatMap[Option[Response[F]]] { isFile =>
if (isFile) {
if (start >= 0 && end >= start && buffsize > 0) {
Files[F]
.getBasicFileAttributes(f, followLinks = true)
.flatMap { attr =>
val lastModified =
HttpDate.fromEpochSecond(attr.lastModifiedTime.toSeconds).toOption
F.pure(notModified(req, etagCalc, lastModified).orElse {
val (body, contentLength) =
if (attr.size < end) (Stream.empty, 0L)
else (fileToBody[F](f, start, end), end - start)
val contentType = nameToContentType(f.fileName.toString)
val hs =
Headers(
lastModified.map(`Last-Modified`(_)),
`Content-Length`.fromLong(contentLength).toOption,
contentType,
etagCalc,
)
val r = Response(
headers = hs,
body = body,
attributes = Vault.empty.insert(staticPathKey, f),
)
logger.trace(s"Static file generated response: $r").unsafeRunSync()
r.some
})
}
} else {
F.raiseError[Option[Response[F]]](
new IllegalArgumentException(
s"requirement failed: start: $start, end: $end, buffsize: $buffsize"
)
)
}
} else {
F.pure(none[Response[F]])
}
}
} yield res)
private def notModified[F[_]](
req: Option[Request[F]],
etagCalc: ETag,
lastModified: Option[HttpDate],
): Option[Response[F]] = {
implicit val conjunction: Semigroup[Boolean] = new Semigroup[Boolean] {
def combine(x: Boolean, y: Boolean): Boolean = x && y
}
List(etagMatch(req, etagCalc), notModifiedSince(req, lastModified)).combineAll
.filter(identity)
.map(_ => Response[F](NotModified))
}
private def etagMatch[F[_]](req: Option[Request[F]], etagCalc: ETag) =
for {
r <- req
etagHeader <- r.headers.get[`If-None-Match`]
etagMatch = etagHeader.tags.exists(_.exists(_ == etagCalc.tag))
_ = logger.trace(
s"Matches `If-None-Match`: $etagMatch Previous ETag: ${etagHeader.value}, New ETag: $etagCalc"
)
} yield etagMatch
private def notModifiedSince[F[_]](req: Option[Request[F]], lastModified: Option[HttpDate]) =
for {
r <- req
h <- r.headers.get[`If-Modified-Since`]
lm <- lastModified
notModified = h.date >= lm
_ = logger.trace(
s"Matches `If-Modified-Since`: $notModified. Request age: ${h.date}, Modified: $lm"
)
} yield notModified
private def fileToBody[F[_]: Files](f: Path, start: Long, end: Long): EntityBody[F] =
Files[F].readRange(f, DefaultBufferSize, start, end)
private def nameToContentType(name: String): Option[`Content-Type`] =
name.lastIndexOf('.') match {
case -1 => None
case i => MediaType.forExtension(name.substring(i + 1)).map(`Content-Type`(_))
}
private[http4s] val staticPathKey = Key.newKey[SyncIO, Path].unsafeRunSync()
@deprecated("Use staticPathKey", since = "0.23.5")
private[http4s] lazy val staticFileKey: Key[File] =
staticPathKey.imap(_.toNioPath.toFile)(f => Path.fromNioPath(f.toPath))
}
© 2015 - 2024 Weber Informatics LLC | Privacy Policy