Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
jdk: oraclejdk8
language: scala
script: sbt test
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Project Documentation

[![Build Status](https://api.travis-ci.org/typesafehub/project-doc.png?branch=master)](https://travis-ci.org/typesafehub/project-doc)

A general purpose project documentation website.

## Setting up development environment
Expand Down
135 changes: 85 additions & 50 deletions app/controllers/Application.scala
Original file line number Diff line number Diff line change
@@ -1,103 +1,106 @@
package controllers

import java.io.File
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import javax.inject.{Named, Inject}

import akka.actor.ActorRef
import akka.pattern.{AskTimeoutException, ask}
import doc.{DocVersions, DocRenderer}
import doc.DocRenderer
import play.api.libs.MimeTypes
import play.api.libs.concurrent.Execution.Implicits.defaultContext
import play.api.libs.iteratee.{Enumerator, Iteratee}
import play.api.libs.iteratee.Iteratee
import play.api.libs.json.{JsError, JsSuccess, Json, JsPath}
import play.api.mvc._
import play.twirl.api.Html
import settings.Settings

import scala.collection.mutable.ArrayBuffer
import scala.concurrent.Future

object Application {

private[controllers] object MacBodyParser {
def apply(hmacHeader: String, secret: SecretKeySpec, algorithm: String) =
new MacBodyParser(hmacHeader, secret, algorithm)
def apply(hmacHeader: String, secret: SecretKeySpec, algorithm: String, maxBodySize: Int = 8192) =
new MacBodyParser(hmacHeader, secret, algorithm, maxBodySize)
}

private[controllers] class MacBodyParser(
hmacHeader: String,
secret: SecretKeySpec,
algorithm: String) extends BodyParser[Unit] {
algorithm: String,
maxBodySize: Int) extends BodyParser[Array[Byte]] {

def hex2bytes(hex: String): Array[Byte] =
hex.replaceAll("[^0-9A-Fa-f]", "").sliding(2, 2).toArray.map(Integer.parseInt(_, 16).toByte)

override def apply(request: RequestHeader): Iteratee[Array[Byte], Either[Result, Unit]] = {
override def apply(request: RequestHeader): Iteratee[Array[Byte], Either[Result, Array[Byte]]] = {
val hexSignature = request.headers.get(hmacHeader).map(_.dropWhile(_ != '=').drop(1)).getOrElse("")
val signature = hex2bytes(hexSignature)
Iteratee.fold[Array[Byte], Mac] {
Iteratee.fold[Array[Byte], (Mac, ArrayBuffer[Byte])] {
val mac = Mac.getInstance(algorithm)
mac.init(secret)
mac
} { (mac, bytes) =>
mac.update(bytes)
mac
(mac, ArrayBuffer.empty)
} {
case ((mac, buffer), bytes) =>
mac.update(bytes)
val newBuffer = if (buffer.length + bytes.length <= maxBodySize) buffer ++ bytes else buffer
(mac, newBuffer)
}.map {
case _ if signature.isEmpty => Left(Results.BadRequest(s"No $hmacHeader header present"))
case mac if mac.doFinal().sameElements(signature) => Right(())
case _ => Left(Results.Unauthorized("Bad signature"))
case _ if signature.isEmpty => Left(Results.BadRequest(s"No $hmacHeader header present"))
case (mac, buffer) if mac.doFinal().sameElements(signature) => Right(buffer.toArray)
case _ => Left(Results.Unauthorized("Bad signature"))
}
}
}

private def getDocRenderer(
host: String,
docRenderers: Map[String, ActorRef],
pathVersion: String => Option[String],
docRenderers: Map[String, Map[String, ActorRef]],
hostPrefixAliases: Map[String, String]): Option[ActorRef] = {

val hostPrefix = host.takeWhile(c => c != '.' && c != ':')
docRenderers.get(hostPrefix).orElse {
hostPrefixAliases.get(hostPrefix) match {
case Some(aliasedHostPrefix) => docRenderers.get(aliasedHostPrefix)
case None => None
}
}

val resolvedHostPrefix = if (docRenderers.contains(hostPrefix)) Some(hostPrefix) else hostPrefixAliases.get(hostPrefix)

for {
hp <- resolvedHostPrefix
dv <- docRenderers.get(hp)
pv <- pathVersion(hp)
dr <- dv.get(pv)
} yield dr
}
}

class Application @Inject() (
@Named("ConductRDocRenderer") conductrDocRenderer: ActorRef,
@Named("ConductRDocRenderer10") conductrDocRenderer10: ActorRef,
@Named("ConductRDocRenderer11") conductrDocRenderer11: ActorRef,
settings: Settings) extends Controller {

import Application._

private final val MacAlgorithm = "HmacSHA1"
private final val GitHubSignature = "X-Hub-Signature"

private val docRenderers = Map("conductr" -> conductrDocRenderer)

private val secret = new SecretKeySpec(settings.play.crypto.secret.getBytes, MacAlgorithm)

def renderIndex = Action {
Ok(views.html.conductr.index())
}

def renderDocsHome =
renderDocs("")
def renderDocsHome(version: String) =
renderDocs("", version)

def renderResources(path: String, version: String) =
renderDocs(path)
renderDocs(path, version)

def renderDocs(path: String, version: String = DocVersions.Latest) = Action.async { request =>
def renderDocs(path: String, version: String) = Action.async { request =>
request.headers.get(HOST) match {
case Some(host) =>
getDocRenderer(host, docRenderers, settings.application.hostAliases) match {
getDocRenderer(host, _ => Some(version), docRenderers, settings.application.hostAliases) match {
case Some(docRenderer) =>
docRenderer
.ask(DocRenderer.Render(path))(settings.doc.renderer.timeout)
.map {
case html: Html => Ok(html)
case resource: DocRenderer.Resource => renderResource(resource, path)
case DocRenderer.Redirect(rp) => Redirect(routes.Application.renderDocs(rp, DocVersions.Latest))
case DocRenderer.Redirect(rp, v) => Redirect(routes.Application.renderDocs(rp, v))
case DocRenderer.NotFound(rp) => NotFound(s"Cannot find $rp")
case DocRenderer.NotReady => ServiceUnavailable("Initializing documentation. Please try again in a minute.")
}
Expand All @@ -112,26 +115,58 @@ class Application @Inject() (
}
}

private def renderResource(resource: DocRenderer.Resource, path: String): Result = {
val fileName = path.drop(path.lastIndexOf('/') + 1)
Result(ResponseHeader(OK, Map[String, String](
CONTENT_LENGTH -> resource.size.toString,
CONTENT_TYPE -> MimeTypes.forFileName(fileName).getOrElse(BINARY)
)), resource.content)
}

def update() = Action(MacBodyParser(GitHubSignature, secret, MacAlgorithm)) { request =>
request.headers.get(HOST) match {
case Some(host) =>
getDocRenderer(host, docRenderers, settings.application.hostAliases) match {
case Some(docRenderer) =>
docRenderer ! DocRenderer.PropogateGetSite
Ok("Site update requested")
case None =>
NotFound(s"Unknown project: $host")
Json.parse(request.body).validate[String](webhookRef) match {
case JsSuccess(ref, _) =>
val branch = ref.reverse.takeWhile(_ != '/').reverse

def branchToVersion(hostPrefix: String): Option[String] =
branchesToVersions.get(hostPrefix).flatMap(_.get(branch))

getDocRenderer(host, branchToVersion, docRenderers, settings.application.hostAliases) match {
case Some(docRenderer) =>
docRenderer ! DocRenderer.PropogateGetSite
Ok("Site update requested")
case None =>
Ok(s"Site update requested for Unknown project: $host - ignoring")
}
case e: JsError =>
BadRequest(s"Cannot parse webhook: $e")
}
case None =>
NotFound("No host header")
}
}

private final val MacAlgorithm = "HmacSHA1"
private final val GitHubSignature = "X-Hub-Signature"

private val docRenderers = Map(
"conductr" -> Map(
"" -> conductrDocRenderer10,
"1.0.x" -> conductrDocRenderer10,
"1.1.x" -> conductrDocRenderer11
)
)

private val branchesToVersions = Map(
"conductr" -> Map(
"1.0" -> "1.0.x",
"master" -> "1.1.x"
)
)

private val secret = new SecretKeySpec(settings.play.crypto.secret.getBytes, MacAlgorithm)
private val webhookRef = (JsPath \ "ref").read[String]

private def renderResource(resource: DocRenderer.Resource, path: String): Result = {
val fileName = path.drop(path.lastIndexOf('/') + 1)
Result(ResponseHeader(OK, Map[String, String](
CONTENT_LENGTH -> resource.size.toString,
CONTENT_TYPE -> MimeTypes.forFileName(fileName).getOrElse(BINARY)
)), resource.content)
}

}
21 changes: 12 additions & 9 deletions app/doc/DocRenderer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ object DocRenderer {
case class Render(path: String)

/**
* Redirect to a relative documentation path
* Redirect to a relative documentation path given a known version
*/
case class Redirect(path: String)
case class Redirect(path: String, version: String)

/**
* Path is not found
Expand Down Expand Up @@ -68,12 +68,12 @@ object DocRenderer {

def props(
docArchive: URI,
removeRootSegment: Boolean,
removeRootSegmentOfArchive: Boolean,
docRoot: Path,
docUri: String,
version: String,
wsClient: WSClient): Props =
Props(new DocRenderer(docArchive, removeRootSegment, docRoot, docUri, version, wsClient))
Props(new DocRenderer(docArchive, removeRootSegmentOfArchive, docRoot, docUri, version, wsClient))

private[doc] def unzip(input: Enumerator[Array[Byte]], removeRootSegment: Boolean)(implicit ec: ExecutionContext): Future[Path] = {
val archive = Files.createTempFile(null, null)
Expand Down Expand Up @@ -182,7 +182,7 @@ class DocRenderer(
implicit val cluster = Cluster(context.system)

override def preStart(): Unit = {
replicator ! Subscribe(SiteUpdateCounter, self)
replicator ! Subscribe(siteUpdateCounter, self)
self ! GetSite
}

Expand Down Expand Up @@ -217,19 +217,22 @@ class DocRenderer(

case PropogateGetSite =>
log.info(s"Notifying cluster of change for $docArchive")
replicator ! Update(SiteUpdateCounter, GCounter(), WriteLocal)(_ + 1)
replicator ! Update(siteUpdateCounter, GCounter(), WriteLocal)(_ + 1)

case Changed(SiteUpdateCounter, _: GCounter) =>
case Changed(siteUpdateCounter, _: GCounter) =>
self ! GetSite
}


private def siteUpdateCounter: String =
s"$SiteUpdateCounter/${self.path.name}/$version"

private def handleUnready: Receive = {
case _ => sender() ! NotReady
}

private def handleRendering(repo: FilesystemRepository, mdRenderer: PlayDoc, toc: Html, toolbar: Html, cache: Cache[Html]): Receive = {
case Render("") =>
sender() ! Redirect(IndexPath)
sender() ! Redirect(IndexPath, version)

case Render(path) if !path.contains(".") =>
cache(path) {
Expand Down
5 changes: 0 additions & 5 deletions app/doc/DocVersions.scala

This file was deleted.

36 changes: 27 additions & 9 deletions app/modules/ConductRDocRendererModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,53 @@ import java.nio.file.Paths
import javax.inject.{Provider, Inject, Singleton}

import akka.actor.{ActorRef, ActorSystem}
import doc.{DocVersions, DocRenderer}
import doc.DocRenderer
import play.api.{Configuration, Environment}
import play.api.inject.Module
import play.api.libs.ws.WSClient

object ConductRDocRendererModule {

@Singleton
class ConductRDocRendererProvider @Inject()(actorSystem: ActorSystem, wsClient: WSClient)
abstract class ConductRDocRendererProvider(actorSystem: ActorSystem, wsClient: WSClient, docArchive: URI, version: String)
extends Provider[ActorRef] {

private val renderer =
actorSystem.actorOf(DocRenderer.props(
new URI("https://github.com/typesafehub/conductr-doc/archive/master.zip"),
removeRootSegment = true,
docArchive,
removeRootSegmentOfArchive = true,
Paths.get("src/main/play-doc"),
controllers.routes.Application.renderDocsHome().url,
DocVersions.Latest,
wsClient), "conductr-doc-renderer")
controllers.routes.Application.renderDocsHome(version).url,
version,
wsClient), s"conductr-doc-renderer-$version")

override def get = renderer
}

@Singleton
class ConductRDocRendererProvider10 @Inject()(actorSystem: ActorSystem, wsClient: WSClient)
extends ConductRDocRendererProvider(
actorSystem,
wsClient,
new URI("https://github.com/typesafehub/conductr-doc/archive/1.0.zip"),
"1.0.x"
)

@Singleton
class ConductRDocRendererProvider11 @Inject()(actorSystem: ActorSystem, wsClient: WSClient)
extends ConductRDocRendererProvider(
actorSystem,
wsClient,
new URI("https://github.com/typesafehub/conductr-doc/archive/master.zip"),
"1.1.x"
)
}

class ConductRDocRendererModule extends Module {
import ConductRDocRendererModule._

def bindings(environment: Environment,
configuration: Configuration) = Seq(
bind[ActorRef].qualifiedWith("ConductRDocRenderer").toProvider[ConductRDocRendererProvider]
bind[ActorRef].qualifiedWith("ConductRDocRenderer10").toProvider[ConductRDocRendererProvider10],
bind[ActorRef].qualifiedWith("ConductRDocRenderer11").toProvider[ConductRDocRendererProvider11]
)
}
2 changes: 1 addition & 1 deletion app/views/mainNav.scala.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@(showIcon: Boolean)
<ul>
<li><a href="@routes.Application.renderDocsHome()">Documentation</a></li>
<li><a href="@routes.Application.renderDocsHome("")">Documentation</a></li>

@if(showIcon) {
<li><a href="http://www.typesafe.com">@svg.typesafeFullColor()</a></li>
Expand Down
8 changes: 4 additions & 4 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ name := "project-doc"

version := "1.0-SNAPSHOT"

scalaVersion := "2.11.6"
scalaVersion := "2.11.7"

resolvers ++= Seq(
"spray repo" at "http://repo.spray.io",
Expand All @@ -20,12 +20,12 @@ libraryDependencies ++= Seq(
"org.webjars" % "foundation" % "5.5.1",
"org.webjars" % "prettify" % "4-Mar-2013",
"com.googlecode.kiama" %% "kiama" % "1.8.0",
"com.typesafe.conductr" %% "play24-conductr-bundle-lib" % "1.0.0",
"com.typesafe.conductr" %% "play24-conductr-bundle-lib" % "1.0.1",
"com.typesafe.play" %% "play-doc" % "1.2.3",
"io.spray" %% "spray-caching" % "1.3.3",
"com.typesafe.akka" %% "akka-testkit" % "2.3.12",
"org.scalatest" %% "scalatest" % "2.2.4" % "test",
"org.scalatestplus" %% "play" % "1.4.0-M3" % "test",
ws
"org.scalatestplus" %% "play" % "1.4.0-M3" % "test"
)

// Play
Expand Down
Loading