From de095d377859887352c7380e52ea89bcabf662a0 Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Sat, 27 Oct 2018 18:45:06 -0700 Subject: Initial commit --- .gitignore | 2 + Dockerfile | 29 +++ LICENSE | 30 +++ README.md | 62 ++++++ build.sbt | 21 ++ debian/byspel.1.md | 31 +++ debian/byspel.install | 3 + debian/byspel.manpages | 1 + debian/byspel.service | 12 ++ debian/changelog | 5 + debian/compat | 1 + debian/control | 15 ++ debian/copyright | 36 ++++ debian/postinst | 42 ++++ debian/rules | 36 ++++ debian/source/format | 1 + dev.toml | 7 + launcher/procname.c | 10 + migrations/deploy/sessions.sql | 13 ++ migrations/deploy/users.sql | 19 ++ migrations/revert/sessions.sql | 7 + migrations/revert/users.sql | 8 + migrations/sqitch.plan | 5 + migrations/verify/sessions.sql | 7 + migrations/verify/users.sql | 7 + misc/5xx.html | 23 +++ misc/byspel.conf | 17 ++ misc/no.svg | 79 +++++++ project/LocalPlugin.scala | 126 ++++++++++++ project/build.properties | 1 + project/plugins.sbt | 3 + sqitch.conf | 10 + src/main/resources/assets/logo.svg | 80 ++++++++ src/main/resources/assets/main.css | 54 +++++ src/main/resources/assets/normalize.css | 341 +++++++++++++++++++++++++++++++ src/main/scala/byspel/Inserts.scala | 57 ++++++ src/main/scala/byspel/Main.scala | 12 ++ src/main/scala/byspel/Migrations.scala | 21 ++ src/main/scala/byspel/PasswordHash.scala | 23 +++ src/main/scala/byspel/Service.scala | 69 +++++++ src/main/scala/byspel/Tables.scala | 179 ++++++++++++++++ src/main/scala/byspel/Ui.scala | 119 +++++++++++ src/main/scala/byspel/app/App.scala | 57 ++++++ src/main/scala/byspel/app/config.scala | 16 ++ src/main/scala/byspel/app/modules.scala | 51 +++++ 45 files changed, 1748 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.sbt create mode 100644 debian/byspel.1.md create mode 100644 debian/byspel.install create mode 100644 debian/byspel.manpages create mode 100644 debian/byspel.service create mode 100644 debian/changelog create mode 100644 debian/compat create mode 100644 debian/control create mode 100644 debian/copyright create mode 100644 debian/postinst create mode 100755 debian/rules create mode 100644 debian/source/format create mode 100644 dev.toml create mode 100644 launcher/procname.c create mode 100644 migrations/deploy/sessions.sql create mode 100644 migrations/deploy/users.sql create mode 100644 migrations/revert/sessions.sql create mode 100644 migrations/revert/users.sql create mode 100644 migrations/sqitch.plan create mode 100644 migrations/verify/sessions.sql create mode 100644 migrations/verify/users.sql create mode 100644 misc/5xx.html create mode 100644 misc/byspel.conf create mode 100644 misc/no.svg create mode 100644 project/LocalPlugin.scala create mode 100644 project/build.properties create mode 100644 project/plugins.sbt create mode 100644 sqitch.conf create mode 100644 src/main/resources/assets/logo.svg create mode 100644 src/main/resources/assets/main.css create mode 100644 src/main/resources/assets/normalize.css create mode 100644 src/main/scala/byspel/Inserts.scala create mode 100644 src/main/scala/byspel/Main.scala create mode 100644 src/main/scala/byspel/Migrations.scala create mode 100644 src/main/scala/byspel/PasswordHash.scala create mode 100644 src/main/scala/byspel/Service.scala create mode 100644 src/main/scala/byspel/Tables.scala create mode 100644 src/main/scala/byspel/Ui.scala create mode 100644 src/main/scala/byspel/app/App.scala create mode 100644 src/main/scala/byspel/app/config.scala create mode 100644 src/main/scala/byspel/app/modules.scala diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f6ab73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target/ +debian/byspel.1 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..47e580c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# Note: this image can only be built after a root filesystem has +# been created with `sbt fhsDist`. + +FROM debian:buster + +RUN \ + export DEBIAN_FRONTEND=noninteractive \ + && apt-get --quiet update && apt-get --quiet install --yes \ + openjdk-11-jdk-headless \ + libdbd-sqlite3-perl \ + nano \ + sqitch \ + sqlite3 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +COPY target/dist/ / + +RUN \ + adduser \ + --system \ + --home=/var/lib/byspel \ + --group \ + byspel +RUN chown byspel:byspel /var/lib/byspel +USER byspel + +EXPOSE 8555 +CMD ["byspel", "/etc/byspel"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..48a3c81 --- /dev/null +++ b/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2018, Jakob Odersky +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + Neither the name of the authors nor the names of its contributors + may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a201b68 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# Byspel + +An example project that showcases how Akka HTTP, Slick and Sqitch can +be used to create a simple web application, and how it can be +*natively* packaged for multiple platforms, including Debian systems +and Docker. + +## Building + +## Local development + +Run `sbt start` to compile sources and run the project. Once started, the +sample website can be viewed at . + +Note that in case schema definitions are changed (via `sqitch +deploy`), table definitions need to be regenerated with `sbt +dbTables`. + +## Basic Linux distribution + +Run `sbt fhsDist` to copy all project artifacts into a standalone root +filesystem that can be used for platform-specific packaging. Note that +this step also bundles sqitch migrations, compiles a native utility to +change process name, and creates a launcher script. + +Check out `tree target/dist/` for a hierarchical representation of all +files and directories created. + +## Docker image + +A docker image can be created by running + +``` +docker build -t byspel . +``` + +Note that this requires that `sbt fhsDist` has been run before. Once +built, `byspel` can be run with `docker run -p 8555:8555 byspel`, and +will be available at . + +## Debian package + +One of the main goals of this project was to explore packaging Scala +apps for debian natively. As a result, this project is also a Debian +*source* package, and can be built with any apropriate utility. + +For example, +``` +debuild +``` +will invoke various `dpkg-*` utilities to create a final binary +packages in the parent directory. + +Unfortunately, due to sbt's reliance on internet connectivity, it is +difficult to ensure that this package can be built in isolation, as is +done with Debian's official packages. Hence, any packages following +this example project could probably only ever be part of the `contrib` +collection of packages and never in `main`. + +## Copying +This project is released under the terms of the 3-clause BSD +license. See LICENSE for details. diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..804417a --- /dev/null +++ b/build.sbt @@ -0,0 +1,21 @@ +lazy val byspel = project + .in(file(".")) + .settings( + libraryDependencies ++= Seq( + "com.lihaoyi" %% "scalatags" % "0.6.7", + "com.typesafe.akka" %% "akka-http" % "10.1.5", + "com.typesafe.akka" %% "akka-http-spray-json" % "10.1.5", + "com.typesafe.akka" %% "akka-stream" % "2.5.17", + "de.mkammerer" % "argon2-jvm" % "2.5", + "tech.sparse" %% "toml-scala" % "0.1.1" + ) + ) + +lazy val dist = taskKey[File]( + "Generate single, distributable package under a well-known directory." +) +dist := { + val out = target.value / "dist.jar" + IO.copyFile(assembly.value, out) + out +} diff --git a/debian/byspel.1.md b/debian/byspel.1.md new file mode 100644 index 0000000..1be9f09 --- /dev/null +++ b/debian/byspel.1.md @@ -0,0 +1,31 @@ +--- +title: byspel +section: 1 +date: October 2018 +footer: byspel 0.1 +--- + + +# NAME + +byspel - example webapp written in Scala + +# SYNOPSIS + +**byspel** CONFIG_FILE + +# DESCRIPTION + +**byspel** is a simple web application using Akka HTTP and Slick. It +is designed to demonstrate packaging of Scala applications. + +# BUGS +Report bugs to Jakob Odersky or at the project's +issue tracker . + +# EXAMPLE + +``` +byspel /etc/byspel.toml + +``` diff --git a/debian/byspel.install b/debian/byspel.install new file mode 100644 index 0000000..8dbd8e7 --- /dev/null +++ b/debian/byspel.install @@ -0,0 +1,3 @@ +misc/byspel.conf etc/nginx/sites-available/ +misc/5xx.html usr/share/byspel/www/error/ +misc/no.svg usr/share/byspel/www/error/ \ No newline at end of file diff --git a/debian/byspel.manpages b/debian/byspel.manpages new file mode 100644 index 0000000..aecd56c --- /dev/null +++ b/debian/byspel.manpages @@ -0,0 +1 @@ +debian/byspel.1 \ No newline at end of file diff --git a/debian/byspel.service b/debian/byspel.service new file mode 100644 index 0000000..27177f0 --- /dev/null +++ b/debian/byspel.service @@ -0,0 +1,12 @@ +[Unit] +Description=Example webapp built with Scala +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/byspel /etc/byspel.toml +User=byspel +Group=byspel + +[Install] +WantedBy=multi-user.target diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..f809147 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +byspel (0.0.1) unstable; urgency=medium + + * Initial release. + + -- Jakob Odersky Sat, 27 Oct 2018 15:26:32 -0700 diff --git a/debian/compat b/debian/compat new file mode 100644 index 0000000..b4de394 --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +11 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..9025234 --- /dev/null +++ b/debian/control @@ -0,0 +1,15 @@ +Source: byspel +Section: net +Priority: optional +Maintainer: Jakob Odersky +Build-Depends: debhelper (>= 11), pandoc, sbt +Standards-Version: 4.1.3 +Homepage: https://github.com/jodersky/byspel + +Package: byspel +Architecture: any +Depends: ${shlibs:Depends}, ${misc:Depends}, adduser, openjdk-11-jdk-headless, libdbd-sqlite3-perl, sqitch, sqlite3 +Recommends: nginx +Description: Example Scala application + This application demonstrates how Akka HTTP and Slick can be used to create + simple web applications, and how they can be packaged for debian. \ No newline at end of file diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..6fe8243 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,36 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: byspel +Source: https://gitub.com/jodersky/byspel + +Files: * +Copyright: 2018 Jakob Odersky +License: BSD-3-Clause + +Files: debian/* +Copyright: 2018 Jakob Odersky +License: BSD-3-Clause + +License: BSD-3-Clause + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + 3. Neither the name of the University nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + . + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE HOLDERS OR + CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/debian/postinst b/debian/postinst new file mode 100644 index 0000000..58b2e36 --- /dev/null +++ b/debian/postinst @@ -0,0 +1,42 @@ +#!/bin/sh +# postinst script for byspel +# +# see: dh_installdeb(1) + +set -e + +# summary of how this script can be called: +# * `configure' +# * `abort-upgrade' +# * `abort-remove' `in-favour' +# +# * `abort-remove' +# * `abort-deconfigure' `in-favour' +# `removing' +# +# for details, see https://www.debian.org/doc/debian-policy/ or +# the debian-policy package + + +case "$1" in + configure) + adduser --system --home /var/lib/byspel --group byspel + chown byspel:byspel /var/lib/byspel + deb-systemd-invoke restart byspel + ;; + + abort-upgrade|abort-remove|abort-deconfigure) + ;; + + *) + echo "postinst called with unknown argument \`$1'" >&2 + exit 1 + ;; +esac + +# dh_installdeb will replace this with shell code automatically +# generated by other debhelper scripts. + +#DEBHELPER# + +exit 0 diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..e7b28a4 --- /dev/null +++ b/debian/rules @@ -0,0 +1,36 @@ +#!/usr/bin/make -f +# See debhelper(7) (uncomment to enable) +# output every command that modifies files on the build system. +#export DH_VERBOSE = 1 + + +# see FEATURE AREAS in dpkg-buildflags(1) +#export DEB_BUILD_MAINT_OPTIONS = hardening=+all + +# see ENVIRONMENT in dpkg-buildflags(1) +# package maintainers to append CFLAGS +#export DEB_CFLAGS_MAINT_APPEND = -Wall -pedantic +# package maintainers to append LDFLAGS +#export DEB_LDFLAGS_MAINT_APPEND = -Wl,--as-needed + + +%: + dh $@ --with-systemd + +override_dh_auto_build: + pandoc --standalone debian/byspel.1.md -o debian/byspel.1 + sbt fhsDist + dh_auto_build + +override_dh_auto_install: + mkdir debian/byspel + cp -r target/dist/* debian/byspel + dh_auto_install + +override_dh_auto_clean: + rm -rf target + dh_auto_clean + +override_dh_strip_nondeterminism: + true + diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..89ae9db --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (native) diff --git a/dev.toml b/dev.toml new file mode 100644 index 0000000..9a7b0c3 --- /dev/null +++ b/dev.toml @@ -0,0 +1,7 @@ +[http] +address = "::" +port = 8080 + +[database] +file = "target/main.db" +sqitch_base = "." \ No newline at end of file diff --git a/launcher/procname.c b/launcher/procname.c new file mode 100644 index 0000000..01800d4 --- /dev/null +++ b/launcher/procname.c @@ -0,0 +1,10 @@ +#include +#include + +static void __attribute__ ((constructor)) procname_init() +{ + const char *name; + if ((name = getenv("PROCNAME")) && (*name)) { + prctl(PR_SET_NAME, name); + } +} diff --git a/migrations/deploy/sessions.sql b/migrations/deploy/sessions.sql new file mode 100644 index 0000000..6d0122c --- /dev/null +++ b/migrations/deploy/sessions.sql @@ -0,0 +1,13 @@ +-- Deploy byspel:sessions to sqlite +-- requires: users + +BEGIN; + +create table sessions( + session_id uuid not null primary key, + user_id uuid not null, + expires timestamp not null, + foreign key (user_id) references users(id) on delete cascade +); + +COMMIT; diff --git a/migrations/deploy/users.sql b/migrations/deploy/users.sql new file mode 100644 index 0000000..63b25cc --- /dev/null +++ b/migrations/deploy/users.sql @@ -0,0 +1,19 @@ +-- Deploy byspel:users to sqlite + +BEGIN; + +create table users( + id uuid not null primary key, + primary_email string not null unique, + full_name string, + avatar string not null, + last_login timestamp +); + +create table shadow( + user_id uuid not null primary key, + hash string not null, + foreign key (user_id) references users(id) +); + +COMMIT; diff --git a/migrations/revert/sessions.sql b/migrations/revert/sessions.sql new file mode 100644 index 0000000..8763989 --- /dev/null +++ b/migrations/revert/sessions.sql @@ -0,0 +1,7 @@ +-- Revert byspel:sessions from sqlite + +BEGIN; + +drop table sessions; + +COMMIT; diff --git a/migrations/revert/users.sql b/migrations/revert/users.sql new file mode 100644 index 0000000..e3c42d9 --- /dev/null +++ b/migrations/revert/users.sql @@ -0,0 +1,8 @@ +-- Revert byspel:users from sqlite + +BEGIN; + +drop table users; +drop table shadow; + +COMMIT; diff --git a/migrations/sqitch.plan b/migrations/sqitch.plan new file mode 100644 index 0000000..7b153e8 --- /dev/null +++ b/migrations/sqitch.plan @@ -0,0 +1,5 @@ +%syntax-version=1.0.0 +%project=byspel + +users 2018-10-27T05:12:05Z Jakob Odersky # Add users and shadow tables +sessions [users] 2018-10-27T08:05:32Z Jakob Odersky # Add sessions table diff --git a/migrations/verify/sessions.sql b/migrations/verify/sessions.sql new file mode 100644 index 0000000..c4fff42 --- /dev/null +++ b/migrations/verify/sessions.sql @@ -0,0 +1,7 @@ +-- Verify byspel:sessions on sqlite + +BEGIN; + +-- XXX Add verifications here. + +ROLLBACK; diff --git a/migrations/verify/users.sql b/migrations/verify/users.sql new file mode 100644 index 0000000..24a7b50 --- /dev/null +++ b/migrations/verify/users.sql @@ -0,0 +1,7 @@ +-- Verify byspel:users on sqlite + +BEGIN; + +-- XXX Add verifications here. + +ROLLBACK; diff --git a/misc/5xx.html b/misc/5xx.html new file mode 100644 index 0000000..2507d16 --- /dev/null +++ b/misc/5xx.html @@ -0,0 +1,23 @@ + + + + 5xx + + + + +

computer says no

+ + diff --git a/misc/byspel.conf b/misc/byspel.conf new file mode 100644 index 0000000..854bf33 --- /dev/null +++ b/misc/byspel.conf @@ -0,0 +1,17 @@ +server { + server_name byspel.*; + listen 80; + listen [::]:80; + listen 443 ssl; + listen [::]:443 ssl; + + error_page 501 502 503 /error/5xx.html; + location /error { + root /usr/share/byspel/www; + try_files $uri =404; + } + + location / { + proxy_pass http://localhost:8555; + } +} diff --git a/misc/no.svg b/misc/no.svg new file mode 100644 index 0000000..daf16dd --- /dev/null +++ b/misc/no.svg @@ -0,0 +1,79 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/project/LocalPlugin.scala b/project/LocalPlugin.scala new file mode 100644 index 0000000..61da0b6 --- /dev/null +++ b/project/LocalPlugin.scala @@ -0,0 +1,126 @@ +import java.nio.file.attribute.PosixFilePermissions +import java.nio.file.{Files, StandardCopyOption} +import sbt.Keys._ +import sbt.{Def, _} +import sbtassembly.AssemblyPlugin + +object LocalPlugin extends AutoPlugin { + + override def requires = plugins.JvmPlugin && AssemblyPlugin + override def trigger = allRequirements + + object autoImport { + val dbMigrate = taskKey[File]("Apply sqitch database migrations") + val dbTables = taskKey[Seq[File]]("Generate database tables") + val fhsDist = taskKey[File]( + "Copy application to a directory structure according to the " + + "Filesystem Hierarchy Standard, simplifying further packaging for " + + "final platforms such as Debian or Docker.") + } + import autoImport._ + + override def projectSettings: Seq[Def.Setting[_]] = Seq( + scalacOptions ++= Seq("-deprecation", "-feature"), + libraryDependencies ++= Seq( + "com.typesafe.slick" %% "slick" % "3.2.3", + "com.typesafe.slick" %% "slick-codegen" % "3.2.3", + "org.slf4j" % "slf4j-nop" % "1.7.19", + "org.xerial" % "sqlite-jdbc" % "3.25.2" + ), + dbMigrate := { + import sys.process._ + val dbFile = target.value / "main.db" + val cmd = s"sqitch deploy db:sqlite:${dbFile.getPath}" + val status = cmd.run().exitValue() + if (status != 0) { + throw new MessageOnlyException(s"command '$cmd' exited with $status") + } + dbFile + }, + dbTables := { + val dbUrl = s"jdbc:sqlite:${dbMigrate.value.toPath}" + val out = (scalaSource in Compile).value + (runner in Compile).value.run( + "slick.codegen.SourceCodeGenerator", + (dependencyClasspath in Compile).value.files, + Array( + "slick.jdbc.SQLiteProfile", // slick driver + "org.sqlite.JDBC", // JDBC driver + dbUrl, // database connection + out.toString, // file + "byspel" // package + ), + streams.value.log + ) + Seq(out / "Tables.scala") + }, + fhsDist := { + val root = (target in Compile).value / "dist" + Files.createDirectories((root / "etc").toPath) + Files.createDirectories((root / "usr" / "bin").toPath) + Files.createDirectories((root / "usr" / "share" / "byspel").toPath) + Files.createDirectories((root / "usr" / "lib" / "byspel").toPath) + Files.createDirectories((root / "var" / "lib" / "byspel").toPath) + Files.copy( + AssemblyPlugin.autoImport.assembly.value.toPath, + (root / "usr" / "share" / "byspel" / "main.jar").toPath, + StandardCopyOption.REPLACE_EXISTING + ) + Files.writeString( + (root / "etc" / "byspel.toml").toPath, + """|[http] + |address = "0.0.0.0" + |port = 8555 + | + |[database] + |file = "/var/lib/byspel/main.db" + |sqitch_base = "/usr/share/byspel/sqitch" + |""".stripMargin + ) + import sys.process._ + require( + s"sqitch bundle --dest-dir=$root/usr/share/byspel/sqitch" + .run() + .exitValue() == 0, + "error bundling sqitch" + ) + require( + Process( + Seq( + "cc", + "-g", + "-O2", + "-Wall", + "-Werror", + "-fPIC", + "-shared", + "-o", + s"$root/usr/lib/byspel/libprocname.so", + "launcher/procname.c" + ) + ).run().exitValue() == 0, + "error compiling launcher" + ) + + val exec = (root / "usr" / "bin" / "byspel").toPath + Files.writeString( + exec, + s"""|#!/bin/bash + |export LD_PRELOAD=/usr/lib/byspel/libprocname.so + |export PROCNAME="byspel" + |exec java -cp /usr/share/byspel/main.jar byspel.Main "$$@" + |""".stripMargin + ) + Files.setPosixFilePermissions( + exec, + PosixFilePermissions.fromString("rwxr-xr-x") + ) + root + } + ) + + override def buildSettings: Seq[Def.Setting[_]] = + addCommandAlias("start", "reStart dev.toml") ++ + addCommandAlias("stop", "reStop") + +} diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..7c58a83 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.2.6 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..1fcbb8f --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,3 @@ +addSbtPlugin("com.geirsson" % "sbt-scalafmt" % "1.5.1") +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.8") +addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") diff --git a/sqitch.conf b/sqitch.conf new file mode 100644 index 0000000..b05fae1 --- /dev/null +++ b/sqitch.conf @@ -0,0 +1,10 @@ +[core] + engine = sqlite + top_dir = migrations + # plan_file = migrations/sqitch.plan +# [engine "sqlite"] + # target = db:sqlite: + # registry = sqitch + # client = sqlite3 +[target "dev"] + uri = db:sqlite:target/main.db 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 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + 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], + < (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 + } +} -- cgit v1.2.3