diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/main/resources/assets/logo.svg | 80 | ||||
-rw-r--r-- | src/main/resources/assets/main.css | 54 | ||||
-rw-r--r-- | src/main/resources/assets/normalize.css | 341 | ||||
-rw-r--r-- | src/main/scala/byspel/Inserts.scala | 57 | ||||
-rw-r--r-- | src/main/scala/byspel/Main.scala | 12 | ||||
-rw-r--r-- | src/main/scala/byspel/Migrations.scala | 21 | ||||
-rw-r--r-- | src/main/scala/byspel/PasswordHash.scala | 23 | ||||
-rw-r--r-- | src/main/scala/byspel/Service.scala | 69 | ||||
-rw-r--r-- | src/main/scala/byspel/Tables.scala | 179 | ||||
-rw-r--r-- | src/main/scala/byspel/Ui.scala | 119 | ||||
-rw-r--r-- | src/main/scala/byspel/app/App.scala | 57 | ||||
-rw-r--r-- | src/main/scala/byspel/app/config.scala | 16 | ||||
-rw-r--r-- | src/main/scala/byspel/app/modules.scala | 51 |
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 + } +} |