aboutsummaryrefslogtreecommitdiff
path: root/src/main
diff options
context:
space:
mode:
authorJakob Odersky <jakob@odersky.com>2018-10-27 18:45:06 -0700
committerJakob Odersky <jakob@odersky.com>2018-10-27 18:45:06 -0700
commitde095d377859887352c7380e52ea89bcabf662a0 (patch)
tree6cee6eb17c1977b85e01078e926499b33854047d /src/main
downloadbyspel-de095d377859887352c7380e52ea89bcabf662a0.tar.gz
byspel-de095d377859887352c7380e52ea89bcabf662a0.tar.bz2
byspel-de095d377859887352c7380e52ea89bcabf662a0.zip
Initial commit
Diffstat (limited to 'src/main')
-rw-r--r--src/main/resources/assets/logo.svg80
-rw-r--r--src/main/resources/assets/main.css54
-rw-r--r--src/main/resources/assets/normalize.css341
-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
13 files changed, 1079 insertions, 0 deletions
diff --git a/src/main/resources/assets/logo.svg b/src/main/resources/assets/logo.svg
new file mode 100644
index 0000000..4615f46
--- /dev/null
+++ b/src/main/resources/assets/logo.svg
@@ -0,0 +1,80 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="24.12602mm"
+ height="27.503229mm"
+ viewBox="0 0 24.12602 27.503229"
+ version="1.1"
+ id="svg8"
+ inkscape:version="0.92.3 (2405546, 2018-03-11)"
+ sodipodi:docname="logo.svg">
+ <defs
+ id="defs2" />
+ <sodipodi:namedview
+ id="base"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageopacity="0.0"
+ inkscape:pageshadow="2"
+ inkscape:zoom="0.7"
+ inkscape:cx="-32.854882"
+ inkscape:cy="-395.10082"
+ inkscape:document-units="mm"
+ inkscape:current-layer="layer1"
+ showgrid="false"
+ inkscape:snap-global="false"
+ inkscape:snap-bbox="true"
+ inkscape:bbox-nodes="true"
+ inkscape:object-paths="true"
+ inkscape:window-width="1918"
+ inkscape:window-height="1056"
+ inkscape:window-x="0"
+ inkscape:window-y="22"
+ inkscape:window-maximized="0"
+ fit-margin-top="0"
+ fit-margin-left="0"
+ fit-margin-right="0"
+ fit-margin-bottom="0" />
+ <metadata
+ id="metadata5">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1"
+ transform="translate(-6.62074,-4.821526)">
+ <g
+ id="g885"
+ transform="rotate(90,163.82661,114.20112)"
+ style="fill:#cccccc">
+ <path
+ id="path871"
+ d="m 54.447016,259.34398 c 0,-0.41251 6.51827,-11.70207 6.87555,-11.90832 0.35728,-0.20625 13.39434,-0.20626 13.75161,0 0.35728,0.20626 6.87607,11.4958 6.87607,11.90832 10e-6,0.41251 -6.51879,11.70206 -6.87607,11.90832 -0.35727,0.20626 -13.39433,0.20625 -13.75161,-10e-6 -0.35728,-0.20625 -6.87555,-11.4958 -6.87555,-11.90831 z m 0.86868,1e-5 c 0,0.38642 6.10676,10.96216 6.44147,11.15537 0.33471,0.19322 12.54822,0.19323 12.88293,0 0.33471,-0.19321 6.44147,-10.76895 6.44147,-11.15538 0,-0.38643 -6.10676,-10.96217 -6.44147,-11.1554 -0.33471,-0.1932 -12.54822,-0.19321 -12.88293,0 -0.33471,0.19322 -6.44147,10.76896 -6.44147,11.15541 z"
+ style="fill:#cccccc;fill-opacity:1;stroke:none;stroke-width:0.15927917;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path873"
+ d="m 68.986516,259.38836 c 0,-0.34087 5.34873,-9.60935 5.70206,-9.88448 0.53173,0.60355 5.66767,9.50847 5.66765,9.84278 10e-6,0.3409 -5.35009,9.61103 -5.70248,9.88449 -0.53273,-0.60539 -5.66719,-9.50851 -5.66723,-9.84275 z"
+ style="fill:#cccccc;fill-opacity:1;stroke:none;stroke-width:0.15927917;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:connector-curvature="0" />
+ </g>
+ </g>
+</svg>
diff --git a/src/main/resources/assets/main.css b/src/main/resources/assets/main.css
new file mode 100644
index 0000000..0f0e42f
--- /dev/null
+++ b/src/main/resources/assets/main.css
@@ -0,0 +1,54 @@
+body {
+ font-family: sans-serif;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+}
+
+form {
+ border: 1px solid #f1f1f1;
+ border-radius: .25em;
+ padding: 1em;
+ box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
+}
+
+h3 {
+ color: cccccc;
+}
+
+input[type=text], input[type=password] {
+ display: block;
+ margin-top: 1em;
+ margin-bottom: 1em;
+ border: 1px solid #f1f1f1;
+ border-radius: 2px;
+ padding: 0.25em;
+ width: 100%;
+}
+
+button {
+ border-radius: 2px;
+ background-color: #0daa1a;
+ color: #ffffff;
+ padding: 1em;
+ border: none;
+ cursor: pointer;
+ width: 100%;
+}
+button:hover {
+ opacity: 0.8;
+}
+img {
+ margin-bottom: 2em;
+}
+
+.alert {
+ padding: 1em;
+ margin-bottom: 1em;
+ border-radius: 2px;
+ background-color: #ff6f6f;
+ color: #5c0500;
+} \ No newline at end of file
diff --git a/src/main/resources/assets/normalize.css b/src/main/resources/assets/normalize.css
new file mode 100644
index 0000000..47b010e
--- /dev/null
+++ b/src/main/resources/assets/normalize.css
@@ -0,0 +1,341 @@
+/*! normalize.css v8.0.0 | MIT License | github.com/necolas/normalize.css */
+
+/* Document
+ ========================================================================== */
+
+/**
+ * 1. Correct the line height in all browsers.
+ * 2. Prevent adjustments of font size after orientation changes in iOS.
+ */
+
+html {
+ line-height: 1.15; /* 1 */
+ -webkit-text-size-adjust: 100%; /* 2 */
+}
+
+/* Sections
+ ========================================================================== */
+
+/**
+ * Remove the margin in all browsers.
+ */
+
+body {
+ margin: 0;
+}
+
+/**
+ * Correct the font size and margin on `h1` elements within `section` and
+ * `article` contexts in Chrome, Firefox, and Safari.
+ */
+
+h1 {
+ font-size: 2em;
+ margin: 0.67em 0;
+}
+
+/* Grouping content
+ ========================================================================== */
+
+/**
+ * 1. Add the correct box sizing in Firefox.
+ * 2. Show the overflow in Edge and IE.
+ */
+
+hr {
+ box-sizing: content-box; /* 1 */
+ height: 0; /* 1 */
+ overflow: visible; /* 2 */
+}
+
+/**
+ * 1. Correct the inheritance and scaling of font size in all browsers.
+ * 2. Correct the odd `em` font sizing in all browsers.
+ */
+
+pre {
+ font-family: monospace, monospace; /* 1 */
+ font-size: 1em; /* 2 */
+}
+
+/* Text-level semantics
+ ========================================================================== */
+
+/**
+ * Remove the gray background on active links in IE 10.
+ */
+
+a {
+ background-color: transparent;
+}
+
+/**
+ * 1. Remove the bottom border in Chrome 57-
+ * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
+ */
+
+abbr[title] {
+ border-bottom: none; /* 1 */
+ text-decoration: underline; /* 2 */
+ text-decoration: underline dotted; /* 2 */
+}
+
+/**
+ * Add the correct font weight in Chrome, Edge, and Safari.
+ */
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+/**
+ * 1. Correct the inheritance and scaling of font size in all browsers.
+ * 2. Correct the odd `em` font sizing in all browsers.
+ */
+
+code,
+kbd,
+samp {
+ font-family: monospace, monospace; /* 1 */
+ font-size: 1em; /* 2 */
+}
+
+/**
+ * Add the correct font size in all browsers.
+ */
+
+small {
+ font-size: 80%;
+}
+
+/**
+ * Prevent `sub` and `sup` elements from affecting the line height in
+ * all browsers.
+ */
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+sup {
+ top: -0.5em;
+}
+
+/* Embedded content
+ ========================================================================== */
+
+/**
+ * Remove the border on images inside links in IE 10.
+ */
+
+img {
+ border-style: none;
+}
+
+/* Forms
+ ========================================================================== */
+
+/**
+ * 1. Change the font styles in all browsers.
+ * 2. Remove the margin in Firefox and Safari.
+ */
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family: inherit; /* 1 */
+ font-size: 100%; /* 1 */
+ line-height: 1.15; /* 1 */
+ margin: 0; /* 2 */
+}
+
+/**
+ * Show the overflow in IE.
+ * 1. Show the overflow in Edge.
+ */
+
+button,
+input { /* 1 */
+ overflow: visible;
+}
+
+/**
+ * Remove the inheritance of text transform in Edge, Firefox, and IE.
+ * 1. Remove the inheritance of text transform in Firefox.
+ */
+
+button,
+select { /* 1 */
+ text-transform: none;
+}
+
+/**
+ * Correct the inability to style clickable types in iOS and Safari.
+ */
+
+button,
+[type="button"],
+[type="reset"],
+[type="submit"] {
+ -webkit-appearance: button;
+}
+
+/**
+ * Remove the inner border and padding in Firefox.
+ */
+
+button::-moz-focus-inner,
+[type="button"]::-moz-focus-inner,
+[type="reset"]::-moz-focus-inner,
+[type="submit"]::-moz-focus-inner {
+ border-style: none;
+ padding: 0;
+}
+
+/**
+ * Restore the focus styles unset by the previous rule.
+ */
+
+button:-moz-focusring,
+[type="button"]:-moz-focusring,
+[type="reset"]:-moz-focusring,
+[type="submit"]:-moz-focusring {
+ outline: 1px dotted ButtonText;
+}
+
+/**
+ * Correct the padding in Firefox.
+ */
+
+fieldset {
+ padding: 0.35em 0.75em 0.625em;
+}
+
+/**
+ * 1. Correct the text wrapping in Edge and IE.
+ * 2. Correct the color inheritance from `fieldset` elements in IE.
+ * 3. Remove the padding so developers are not caught out when they zero out
+ * `fieldset` elements in all browsers.
+ */
+
+legend {
+ box-sizing: border-box; /* 1 */
+ color: inherit; /* 2 */
+ display: table; /* 1 */
+ max-width: 100%; /* 1 */
+ padding: 0; /* 3 */
+ white-space: normal; /* 1 */
+}
+
+/**
+ * Add the correct vertical alignment in Chrome, Firefox, and Opera.
+ */
+
+progress {
+ vertical-align: baseline;
+}
+
+/**
+ * Remove the default vertical scrollbar in IE 10+.
+ */
+
+textarea {
+ overflow: auto;
+}
+
+/**
+ * 1. Add the correct box sizing in IE 10.
+ * 2. Remove the padding in IE 10.
+ */
+
+[type="checkbox"],
+[type="radio"] {
+ box-sizing: border-box; /* 1 */
+ padding: 0; /* 2 */
+}
+
+/**
+ * Correct the cursor style of increment and decrement buttons in Chrome.
+ */
+
+[type="number"]::-webkit-inner-spin-button,
+[type="number"]::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/**
+ * 1. Correct the odd appearance in Chrome and Safari.
+ * 2. Correct the outline style in Safari.
+ */
+
+[type="search"] {
+ -webkit-appearance: textfield; /* 1 */
+ outline-offset: -2px; /* 2 */
+}
+
+/**
+ * Remove the inner padding in Chrome and Safari on macOS.
+ */
+
+[type="search"]::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/**
+ * 1. Correct the inability to style clickable types in iOS and Safari.
+ * 2. Change font properties to `inherit` in Safari.
+ */
+
+::-webkit-file-upload-button {
+ -webkit-appearance: button; /* 1 */
+ font: inherit; /* 2 */
+}
+
+/* Interactive
+ ========================================================================== */
+
+/*
+ * Add the correct display in Edge, IE 10+, and Firefox.
+ */
+
+details {
+ display: block;
+}
+
+/*
+ * Add the correct display in all browsers.
+ */
+
+summary {
+ display: list-item;
+}
+
+/* Misc
+ ========================================================================== */
+
+/**
+ * Add the correct display in IE 10+.
+ */
+
+template {
+ display: none;
+}
+
+/**
+ * Add the correct display in IE 10.
+ */
+
+[hidden] {
+ display: none;
+}
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
+ }
+}