aboutsummaryrefslogtreecommitdiff
path: root/src/main/scala/byspel
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/scala/byspel')
-rw-r--r--src/main/scala/byspel/Inserts.scala57
-rw-r--r--src/main/scala/byspel/Main.scala12
-rw-r--r--src/main/scala/byspel/Migrations.scala21
-rw-r--r--src/main/scala/byspel/PasswordHash.scala23
-rw-r--r--src/main/scala/byspel/Service.scala69
-rw-r--r--src/main/scala/byspel/Tables.scala179
-rw-r--r--src/main/scala/byspel/Ui.scala119
-rw-r--r--src/main/scala/byspel/app/App.scala57
-rw-r--r--src/main/scala/byspel/app/config.scala16
-rw-r--r--src/main/scala/byspel/app/modules.scala51
10 files changed, 604 insertions, 0 deletions
diff --git a/src/main/scala/byspel/Inserts.scala b/src/main/scala/byspel/Inserts.scala
new file mode 100644
index 0000000..c82b98d
--- /dev/null
+++ b/src/main/scala/byspel/Inserts.scala
@@ -0,0 +1,57 @@
+package byspel
+
+import app.{DatabaseApi, DatabaseApp}
+import java.security.SecureRandom
+import java.sql.Timestamp
+import java.time.Instant
+import java.util.UUID
+import scala.concurrent.Await
+import scala.concurrent.duration._
+
+trait Inserts extends DatabaseApp { self: DatabaseApi =>
+ import profile.api._
+
+ private val root =
+ UsersRow(new UUID(0l, 0l).toString,
+ "root@crashbox.io",
+ Some("Root User"),
+ "no avatar",
+ Some(Timestamp.from(Instant.now())))
+
+ private val password = {
+ val prng = new SecureRandom()
+ val bytes = new Array[Byte](8)
+ prng.nextBytes(bytes)
+ bytes.map(b => f"$b%02x").mkString("")
+ }
+
+ private def inserts = Seq(
+ Users insertOrUpdate root,
+ Shadow insertOrUpdate ShadowRow(
+ root.id,
+ PasswordHash.protect(password)
+ )
+ )
+
+ override def start(): Unit = {
+ super.start()
+ log("checking for root user")
+
+ val f = Users.filter(_.id === root.id).exists.result.flatMap {
+ case false =>
+ log("creating root user")
+ log(s"root password is: $password")
+ (Users insertOrUpdate root).andThen(
+ Shadow insertOrUpdate ShadowRow(
+ root.id,
+ PasswordHash.protect(password)
+ )
+ )
+ case true =>
+ log("root user exists")
+ DBIO.successful(())
+ }
+ Await.result(database.run(f), 2.seconds)
+ }
+
+}
diff --git a/src/main/scala/byspel/Main.scala b/src/main/scala/byspel/Main.scala
new file mode 100644
index 0000000..e76af50
--- /dev/null
+++ b/src/main/scala/byspel/Main.scala
@@ -0,0 +1,12 @@
+package byspel
+
+import app.{DatabaseApi, DatabaseApp, HttpApp}
+
+object Main
+ extends DatabaseApp
+ with HttpApp
+ with DatabaseApi
+ with Service
+ with Ui
+ with Migrations
+ with Inserts
diff --git a/src/main/scala/byspel/Migrations.scala b/src/main/scala/byspel/Migrations.scala
new file mode 100644
index 0000000..b2129de
--- /dev/null
+++ b/src/main/scala/byspel/Migrations.scala
@@ -0,0 +1,21 @@
+package byspel
+import byspel.app.DatabaseApi
+import java.io.File
+
+trait Migrations extends app.DatabaseApp { self: DatabaseApi =>
+
+ override def start(): Unit = {
+ super.start()
+ log("running migrations")
+ import sys.process._
+ val cmd = Process(
+ s"sqitch deploy db:sqlite:${config.database.file}",
+ Some(new File(config.database.sqitch_base))
+ )
+ if (cmd.run.exitValue() != 0) {
+ log("fatal: applying database migrations failed")
+ sys.exit(1)
+ }
+ }
+
+}
diff --git a/src/main/scala/byspel/PasswordHash.scala b/src/main/scala/byspel/PasswordHash.scala
new file mode 100644
index 0000000..c97723d
--- /dev/null
+++ b/src/main/scala/byspel/PasswordHash.scala
@@ -0,0 +1,23 @@
+package byspel
+
+import de.mkammerer.argon2.Argon2Factory
+import java.nio.charset.StandardCharsets
+
+object PasswordHash {
+
+ private val argon2 = Argon2Factory.create()
+
+ /** Salt and hash a password. */
+ def protect(plain: String): String =
+ argon2.hash(
+ 10, // iterations
+ 65536, // memory
+ 1, // parallelism
+ plain, // password
+ StandardCharsets.UTF_8
+ )
+
+ def verify(plain: String, hashed: String): Boolean =
+ argon2.verify(hashed, plain)
+
+}
diff --git a/src/main/scala/byspel/Service.scala b/src/main/scala/byspel/Service.scala
new file mode 100644
index 0000000..1d1e841
--- /dev/null
+++ b/src/main/scala/byspel/Service.scala
@@ -0,0 +1,69 @@
+package byspel
+
+import app.DatabaseApi
+import java.sql.Timestamp
+import java.time.Instant
+import java.util.UUID
+import scala.concurrent.{ExecutionContext, Future}
+import scala.concurrent.duration._
+
+trait Service { self: DatabaseApi =>
+ import profile.api._
+
+ implicit def executionContext: ExecutionContext
+
+ def login(id: String,
+ password: String): Future[Option[(UsersRow, SessionsRow)]] = {
+ val query = for {
+ user <- Users
+ if user.primaryEmail === id || user.id === id
+ shadow <- Shadow
+ if shadow.userId === user.id
+ } yield {
+ user -> shadow.hash
+ }
+ val userResult = database.run(query.result.headOption).map {
+ case Some((user, hash)) if PasswordHash.verify(password, hash) =>
+ Some(user)
+ case _ =>
+ // dummy password hash to avoid timing attacks
+ PasswordHash.verify(
+ password,
+ "$argon2i$v=19$m=65536,t=10,p=1$gFZ4l8R2rpuhfqXDFuugNg$fOvTwLSaOMahD/5AfWlbRsSMj4E6k34VpGyl5xe24yA")
+ None
+ }
+
+ userResult.flatMap {
+ case Some(u) =>
+ val newSession = SessionsRow(
+ UUID.randomUUID().toString,
+ u.id,
+ Timestamp.from(Instant.now.plusSeconds(60 * 60 * 24))
+ )
+ database
+ .run(
+ Sessions += newSession
+ )
+ .map(_ => Some(u -> newSession))
+ case None => Future.successful(None)
+ }
+ }
+
+ def checkSession(sessionId: String): Future[Option[UsersRow]] = database.run {
+ val query = for {
+ session <- Sessions
+ if session.sessionId === sessionId
+ if session.expires > Timestamp.from(Instant.now())
+ user <- Users
+ if user.id === session.userId
+ } yield {
+ user
+ }
+ query.result.headOption
+ }
+
+ def endSession(sessionId: String) = database.run {
+ Sessions.filter(_.sessionId === sessionId).delete
+ }
+
+}
diff --git a/src/main/scala/byspel/Tables.scala b/src/main/scala/byspel/Tables.scala
new file mode 100644
index 0000000..c86b785
--- /dev/null
+++ b/src/main/scala/byspel/Tables.scala
@@ -0,0 +1,179 @@
+package byspel
+// AUTO-GENERATED Slick data model
+/** Stand-alone Slick data model for immediate use */
+object Tables extends {
+ val profile = slick.jdbc.SQLiteProfile
+} with Tables
+
+/** Slick data model trait for extension, choice of backend or usage in the cake pattern. (Make sure to initialize this late.) */
+trait Tables {
+ val profile: slick.jdbc.JdbcProfile
+ import profile.api._
+ import slick.model.ForeignKeyAction
+ // NOTE: GetResult mappers for plain SQL are only generated for tables where Slick knows how to map the types of all columns.
+ import slick.jdbc.{GetResult => GR}
+
+ /** DDL for all tables. Call .create to execute. */
+ lazy val schema
+ : profile.SchemaDescription = Sessions.schema ++ Shadow.schema ++ Users.schema
+ @deprecated("Use .schema instead of .ddl", "3.0")
+ def ddl = schema
+
+ /** Entity class storing rows of table Sessions
+ * @param sessionId Database column session_id SqlType(UUID), PrimaryKey
+ * @param userId Database column user_id SqlType(UUID)
+ * @param expires Database column expires SqlType(TIMESTAMP) */
+ case class SessionsRow(sessionId: String,
+ userId: String,
+ expires: java.sql.Timestamp)
+
+ /** GetResult implicit for fetching SessionsRow objects using plain SQL queries */
+ implicit def GetResultSessionsRow(
+ implicit e0: GR[String],
+ e1: GR[java.sql.Timestamp]): GR[SessionsRow] = GR { prs =>
+ import prs._
+ SessionsRow.tupled((<<[String], <<[String], <<[java.sql.Timestamp]))
+ }
+
+ /** Table description of table sessions. Objects of this class serve as prototypes for rows in queries. */
+ class Sessions(_tableTag: Tag)
+ extends profile.api.Table[SessionsRow](_tableTag, "sessions") {
+ def * =
+ (sessionId, userId, expires) <> (SessionsRow.tupled, SessionsRow.unapply)
+
+ /** Maps whole row to an option. Useful for outer joins. */
+ def ? =
+ (Rep.Some(sessionId), Rep.Some(userId), Rep.Some(expires)).shaped.<>(
+ { r =>
+ import r._; _1.map(_ => SessionsRow.tupled((_1.get, _2.get, _3.get)))
+ },
+ (_: Any) =>
+ throw new Exception("Inserting into ? projection not supported."))
+
+ /** Database column session_id SqlType(UUID), PrimaryKey */
+ val sessionId: Rep[String] = column[String]("session_id", O.PrimaryKey)
+
+ /** Database column user_id SqlType(UUID) */
+ val userId: Rep[String] = column[String]("user_id")
+
+ /** Database column expires SqlType(TIMESTAMP) */
+ val expires: Rep[java.sql.Timestamp] = column[java.sql.Timestamp]("expires")
+
+ /** Foreign key referencing Users (database name users_FK_1) */
+ lazy val usersFk = foreignKey("users_FK_1", userId, Users)(
+ r => r.id,
+ onUpdate = ForeignKeyAction.NoAction,
+ onDelete = ForeignKeyAction.Cascade)
+ }
+
+ /** Collection-like TableQuery object for table Sessions */
+ lazy val Sessions = new TableQuery(tag => new Sessions(tag))
+
+ /** Entity class storing rows of table Shadow
+ * @param userId Database column user_id SqlType(UUID), PrimaryKey
+ * @param hash Database column hash SqlType(STRING) */
+ case class ShadowRow(userId: String, hash: String)
+
+ /** GetResult implicit for fetching ShadowRow objects using plain SQL queries */
+ implicit def GetResultShadowRow(implicit e0: GR[String]): GR[ShadowRow] = GR {
+ prs =>
+ import prs._
+ ShadowRow.tupled((<<[String], <<[String]))
+ }
+
+ /** Table description of table shadow. Objects of this class serve as prototypes for rows in queries. */
+ class Shadow(_tableTag: Tag)
+ extends profile.api.Table[ShadowRow](_tableTag, "shadow") {
+ def * = (userId, hash) <> (ShadowRow.tupled, ShadowRow.unapply)
+
+ /** Maps whole row to an option. Useful for outer joins. */
+ def ? =
+ (Rep.Some(userId), Rep.Some(hash)).shaped.<>(
+ { r =>
+ import r._; _1.map(_ => ShadowRow.tupled((_1.get, _2.get)))
+ },
+ (_: Any) =>
+ throw new Exception("Inserting into ? projection not supported."))
+
+ /** Database column user_id SqlType(UUID), PrimaryKey */
+ val userId: Rep[String] = column[String]("user_id", O.PrimaryKey)
+
+ /** Database column hash SqlType(STRING) */
+ val hash: Rep[String] = column[String]("hash")
+
+ /** Foreign key referencing Users (database name users_FK_1) */
+ lazy val usersFk = foreignKey("users_FK_1", userId, Users)(
+ r => r.id,
+ onUpdate = ForeignKeyAction.NoAction,
+ onDelete = ForeignKeyAction.NoAction)
+ }
+
+ /** Collection-like TableQuery object for table Shadow */
+ lazy val Shadow = new TableQuery(tag => new Shadow(tag))
+
+ /** Entity class storing rows of table Users
+ * @param id Database column id SqlType(UUID), PrimaryKey
+ * @param primaryEmail Database column primary_email SqlType(STRING)
+ * @param fullName Database column full_name SqlType(STRING)
+ * @param avatar Database column avatar SqlType(STRING)
+ * @param lastLogin Database column last_login SqlType(TIMESTAMP) */
+ case class UsersRow(id: String,
+ primaryEmail: String,
+ fullName: Option[String],
+ avatar: String,
+ lastLogin: Option[java.sql.Timestamp])
+
+ /** GetResult implicit for fetching UsersRow objects using plain SQL queries */
+ implicit def GetResultUsersRow(
+ implicit e0: GR[String],
+ e1: GR[Option[String]],
+ e2: GR[Option[java.sql.Timestamp]]): GR[UsersRow] = GR { prs =>
+ import prs._
+ UsersRow.tupled(
+ (<<[String],
+ <<[String],
+ <<?[String],
+ <<[String],
+ <<?[java.sql.Timestamp]))
+ }
+
+ /** Table description of table users. Objects of this class serve as prototypes for rows in queries. */
+ class Users(_tableTag: Tag)
+ extends profile.api.Table[UsersRow](_tableTag, "users") {
+ def * =
+ (id, primaryEmail, fullName, avatar, lastLogin) <> (UsersRow.tupled, UsersRow.unapply)
+
+ /** Maps whole row to an option. Useful for outer joins. */
+ def ? =
+ (Rep.Some(id),
+ Rep.Some(primaryEmail),
+ fullName,
+ Rep.Some(avatar),
+ lastLogin).shaped.<>(
+ { r =>
+ import r._;
+ _1.map(_ => UsersRow.tupled((_1.get, _2.get, _3, _4.get, _5)))
+ },
+ (_: Any) =>
+ throw new Exception("Inserting into ? projection not supported."))
+
+ /** Database column id SqlType(UUID), PrimaryKey */
+ val id: Rep[String] = column[String]("id", O.PrimaryKey)
+
+ /** Database column primary_email SqlType(STRING) */
+ val primaryEmail: Rep[String] = column[String]("primary_email")
+
+ /** Database column full_name SqlType(STRING) */
+ val fullName: Rep[Option[String]] = column[Option[String]]("full_name")
+
+ /** Database column avatar SqlType(STRING) */
+ val avatar: Rep[String] = column[String]("avatar")
+
+ /** Database column last_login SqlType(TIMESTAMP) */
+ val lastLogin: Rep[Option[java.sql.Timestamp]] =
+ column[Option[java.sql.Timestamp]]("last_login")
+ }
+
+ /** Collection-like TableQuery object for table Users */
+ lazy val Users = new TableQuery(tag => new Users(tag))
+}
diff --git a/src/main/scala/byspel/Ui.scala b/src/main/scala/byspel/Ui.scala
new file mode 100644
index 0000000..ac26164
--- /dev/null
+++ b/src/main/scala/byspel/Ui.scala
@@ -0,0 +1,119 @@
+package byspel
+
+import akka.http.scaladsl.server.Route
+import akka.http.scaladsl.marshalling.{Marshaller, ToEntityMarshaller}
+import akka.http.scaladsl.model.headers.HttpCookie
+import akka.http.scaladsl.model.{MediaTypes, StatusCodes, Uri}
+import app.HttpApi
+import scalatags.Text.all._
+
+trait Ui extends HttpApi { self: Service with Tables =>
+
+ // allows using scalatags templates as HTTP responses
+ implicit val tagMarshaller: ToEntityMarshaller[Tag] = {
+ Marshaller.stringMarshaller(MediaTypes.`text/html`).compose { (tag: Tag) =>
+ tag.render
+ }
+ }
+
+ def page(content: Tag*) = html(
+ scalatags.Text.all.head(
+ link(
+ rel := "stylesheet",
+ `type` := "text/css",
+ href := "/assets/normalize.css"
+ ),
+ link(
+ rel := "stylesheet",
+ `type` := "text/css",
+ href := "/assets/main.css"
+ )
+ ),
+ body(
+ content
+ )
+ )
+
+ def loginForm(alert: Option[String]) = page(
+ img(src := "/assets/logo.svg"),
+ h3("Sign in to crashbox"),
+ alert match {
+ case Some(message) => div(`class` := "alert")(message)
+ case None => span()
+ },
+ form(action := "/login", attr("method") := "post")(
+ label(`for` := "username")("Username or email address"),
+ input(`type` := "text", placeholder := "", name := "username", required),
+ label(`for` := "password")("Password"),
+ input(`type` := "password",
+ placeholder := "",
+ name := "password",
+ required),
+ button(`type` := "submit")("Sign in")
+ )
+ )
+
+ def mainPage(user: UsersRow) = page(
+ h1(s"Welcome ${user.fullName.getOrElse("")}!"),
+ form(action := "/logout", attr("method") := "post")(
+ button(`type` := "submit")("Sign out")
+ )
+ )
+
+ def authenticated(inner: UsersRow => Route): Route =
+ optionalCookie("session") {
+ case Some(sessionCookie) =>
+ onSuccess(self.checkSession(sessionCookie.value)) {
+ case Some(user) =>
+ inner(user)
+ case None => complete(StatusCodes.NotFound)
+ }
+ case None => complete(StatusCodes.NotFound)
+ }
+
+ def route =
+ pathPrefix("assets") {
+ getFromResourceDirectory("assets")
+ } ~ path("login") {
+ get {
+ complete(loginForm(None))
+ } ~
+ post {
+ formFields("username", "password") {
+ case (u, p) =>
+ onSuccess(self.login(u, p)) {
+ case None =>
+ complete(StatusCodes.NotFound -> loginForm(
+ Some("Incorrect username or password.")))
+ case Some((user, session)) =>
+ setCookie(HttpCookie("session", session.sessionId)) {
+ redirect(Uri(s"/${user.primaryEmail}"), StatusCodes.Found)
+ }
+ }
+ }
+ }
+ } ~ path("logout") {
+ post {
+ cookie("session") { cookiePair =>
+ onSuccess(endSession(cookiePair.value)) { _ =>
+ deleteCookie(cookiePair.name) {
+ redirect(Uri("/"), StatusCodes.Found)
+ }
+ }
+ }
+ }
+ } ~ path(Segment) { userEmail =>
+ authenticated { user =>
+ if (user.primaryEmail == userEmail) {
+ get {
+ complete(mainPage(user))
+ }
+ } else {
+ complete(StatusCodes.NotFound)
+ }
+ }
+ } ~ get {
+ redirect(Uri("/login"), StatusCodes.Found)
+ }
+
+}
diff --git a/src/main/scala/byspel/app/App.scala b/src/main/scala/byspel/app/App.scala
new file mode 100644
index 0000000..65b3c3f
--- /dev/null
+++ b/src/main/scala/byspel/app/App.scala
@@ -0,0 +1,57 @@
+package byspel
+package app
+
+import akka.actor.ActorSystem
+import akka.stream.{ActorMaterializer, Materializer}
+import java.nio.file.{Files, Paths}
+import scala.concurrent.ExecutionContext
+import toml.Toml
+
+trait App {
+
+ implicit lazy val system: ActorSystem = ActorSystem()
+
+ implicit lazy val materializer: Materializer = ActorMaterializer()
+
+ implicit lazy val executionContext: ExecutionContext = system.dispatcher
+
+ def start(): Unit = {}
+ def stop(): Unit = {}
+
+ def log(msg: String) = System.err.println(msg)
+
+ private var _args: List[String] = Nil
+ def args = _args
+
+ lazy val config = args match {
+ case Nil =>
+ log("fatal: no config file given as first argument")
+ sys.exit(1)
+ case head :: _ if Files.isReadable(Paths.get(head)) =>
+ log(s"loading config from '${args(0)}'")
+ import toml.Codecs._
+ Toml.parseAs[Config](Files.readString(Paths.get(head))) match {
+ case Left(err) =>
+ log(s"fatal: syntax error in config file: $err")
+ sys.exit(1)
+ case Right(value) => value
+ }
+ case head :: _ =>
+ log(s"fatal: config file '$head' is not readable or does not exist")
+ sys.exit(1)
+ }
+
+ def main(args: Array[String]): Unit = {
+ log("starting application")
+ _args = args.toList
+ config
+ sys.addShutdownHook {
+ log("stopping application")
+ stop()
+ log("bye")
+ }
+ start()
+ log("ready")
+ }
+
+}
diff --git a/src/main/scala/byspel/app/config.scala b/src/main/scala/byspel/app/config.scala
new file mode 100644
index 0000000..6ba9f80
--- /dev/null
+++ b/src/main/scala/byspel/app/config.scala
@@ -0,0 +1,16 @@
+package byspel
+package app
+
+case class Config(
+ http: HttpConfig,
+ database: DatabaseConfig
+)
+
+case class HttpConfig(
+ address: String,
+ port: Int
+)
+case class DatabaseConfig(
+ file: String,
+ sqitch_base: String
+)
diff --git a/src/main/scala/byspel/app/modules.scala b/src/main/scala/byspel/app/modules.scala
new file mode 100644
index 0000000..a4b85ae
--- /dev/null
+++ b/src/main/scala/byspel/app/modules.scala
@@ -0,0 +1,51 @@
+package byspel
+package app
+
+import akka.http.scaladsl.Http
+import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport
+import akka.http.scaladsl.server.{Directives, Route}
+import spray.json.DefaultJsonProtocol
+import scala.concurrent.Await
+import scala.concurrent.duration._
+
+trait HttpApi
+ extends Directives
+ with SprayJsonSupport
+ with DefaultJsonProtocol {
+ def route: Route
+}
+
+trait HttpApp extends App { self: HttpApi =>
+
+ override def start() = {
+ super.start()
+ log("binding to interface")
+ val future =
+ Http().bindAndHandle(route, config.http.address, config.http.port)
+ Await.result(future, 2.seconds)
+ }
+
+}
+
+trait DatabaseApi extends Tables {
+ val profile = Tables.profile
+ import profile.api._
+
+ def database: Database
+
+}
+
+trait DatabaseApp extends App { self: DatabaseApi =>
+ import profile.api.Database
+
+ lazy val database: Database = Database.forURL(
+ s"jdbc:sqlite:${config.database.file}",
+ driver = "org.sqlite.JDBC"
+ )
+
+ override def start() = {
+ super.start()
+ log("initializing database")
+ database
+ }
+}