aboutsummaryrefslogtreecommitdiff
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
downloadbyspel-de095d377859887352c7380e52ea89bcabf662a0.tar.gz
byspel-de095d377859887352c7380e52ea89bcabf662a0.tar.bz2
byspel-de095d377859887352c7380e52ea89bcabf662a0.zip
Initial commit
-rw-r--r--.gitignore2
-rw-r--r--Dockerfile29
-rw-r--r--LICENSE30
-rw-r--r--README.md62
-rw-r--r--build.sbt21
-rw-r--r--debian/byspel.1.md31
-rw-r--r--debian/byspel.install3
-rw-r--r--debian/byspel.manpages1
-rw-r--r--debian/byspel.service12
-rw-r--r--debian/changelog5
-rw-r--r--debian/compat1
-rw-r--r--debian/control15
-rw-r--r--debian/copyright36
-rw-r--r--debian/postinst42
-rwxr-xr-xdebian/rules36
-rw-r--r--debian/source/format1
-rw-r--r--dev.toml7
-rw-r--r--launcher/procname.c10
-rw-r--r--migrations/deploy/sessions.sql13
-rw-r--r--migrations/deploy/users.sql19
-rw-r--r--migrations/revert/sessions.sql7
-rw-r--r--migrations/revert/users.sql8
-rw-r--r--migrations/sqitch.plan5
-rw-r--r--migrations/verify/sessions.sql7
-rw-r--r--migrations/verify/users.sql7
-rw-r--r--misc/5xx.html23
-rw-r--r--misc/byspel.conf17
-rw-r--r--misc/no.svg79
-rw-r--r--project/LocalPlugin.scala126
-rw-r--r--project/build.properties1
-rw-r--r--project/plugins.sbt3
-rw-r--r--sqitch.conf10
-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
45 files changed, 1748 insertions, 0 deletions
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 <http://localhost:8080>.
+
+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 <http://localhost:8555>.
+
+## 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 <jakob@odersky.com> or at the project's
+issue tracker <https://github.com/jodersky/byspel/issues>.
+
+# 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 <jakob@odersky.com> 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 <jakob@odersky.com>
+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 <jakob@odersky.com>
+License: BSD-3-Clause
+
+Files: debian/*
+Copyright: 2018 Jakob Odersky <jakob@odersky.com>
+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:
+# * <postinst> `configure' <most-recently-configured-version>
+# * <old-postinst> `abort-upgrade' <new version>
+# * <conflictor's-postinst> `abort-remove' `in-favour' <package>
+# <new-version>
+# * <postinst> `abort-remove'
+# * <deconfigured's-postinst> `abort-deconfigure' `in-favour'
+# <failed-install-package> <version> `removing'
+# <conflicting-package> <version>
+# 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 <sys/prctl.h>
+#include <stdlib.h>
+
+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 <jakob@odersky.com> # Add users and shadow tables
+sessions [users] 2018-10-27T08:05:32Z Jakob Odersky <jakob@odersky.com> # 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 @@
+<!doctype html>
+<html>
+ <head>
+ <title>5xx</title>
+ <style>
+ html, body {
+ font-family: sans-serif;
+ width: 100%;
+ height: 100%;
+ }
+ body {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ }
+ </style>
+ </head>
+ <body>
+ <img src="/error/no.svg">
+ <p>computer says no</p>
+ </body>
+</html>
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 @@
+<?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="no.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.49497475"
+ inkscape:cx="122.72298"
+ inkscape:cy="-314.27504"
+ 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="958"
+ inkscape:window-height="1056"
+ inkscape:window-x="960"
+ 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(-64.45109,-97.61468)">
+ <g
+ id="g893"
+ transform="rotate(90,175.16589,160.5032)">
+ <path
+ id="path879"
+ d="m 112.27737,259.15499 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 1e-5,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,10e-6 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:#ff8080;fill-opacity:1;stroke:none;stroke-width:0.15927917;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+ inkscape:connector-curvature="0" />
+ <path
+ id="path881"
+ d="m 126.81687,259.19937 c 0,-0.34087 5.34873,-9.60935 5.70206,-9.88448 0.53173,0.60355 5.66767,9.50847 5.66765,9.84278 1e-5,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:#ff8080;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/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 @@
+<?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
+ }
+}