Add mustache support with static assets and functional login/registration

This commit is contained in:
William Brawner 2024-03-29 22:18:22 -06:00
parent 040af1ba36
commit 1e14057dfa
41 changed files with 392 additions and 111 deletions

View file

@ -2,8 +2,11 @@ package com.wbrawner.twigs.server
import at.favre.lib.crypto.bcrypt.BCrypt
import ch.qos.logback.classic.Level
import com.github.mustachejava.DefaultMustacheFactory
import com.wbrawner.twigs.*
import com.wbrawner.twigs.db.*
import com.wbrawner.twigs.model.CookieSession
import com.wbrawner.twigs.model.HeaderSession
import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.service.budget.BudgetService
import com.wbrawner.twigs.service.budget.DefaultBudgetService
@ -16,15 +19,18 @@ import com.wbrawner.twigs.service.transaction.TransactionService
import com.wbrawner.twigs.service.user.DefaultUserService
import com.wbrawner.twigs.service.user.UserService
import com.wbrawner.twigs.storage.PasswordHasher
import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE
import com.wbrawner.twigs.web.webRoutes
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.ktor.client.request.forms.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.cio.*
import io.ktor.server.engine.*
import io.ktor.server.mustache.*
import io.ktor.server.plugins.callloging.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.cors.routing.*
@ -124,7 +130,7 @@ fun Application.module() {
application.environment.log.info("Found session!")
}
return@validate if (twoWeeksFromNow.isAfter(storedSession.expiration)) {
sessionRepository.save(storedSession.copy(expiration = twoWeeksFromNow))
sessionRepository.save(storedSession.updateExpiration(newExpiration = twoWeeksFromNow))
} else {
null
}
@ -176,14 +182,27 @@ fun Application.moduleWithDependencies(
install(Sessions) {
header<Session>("Authorization") {
serializer = object : SessionSerializer<Session> {
override fun deserialize(text: String): Session {
override fun deserialize(text: String): HeaderSession {
this@moduleWithDependencies.environment.log.info("Deserializing session!")
return Session(token = text.substringAfter("Bearer "))
return HeaderSession(token = text.substringAfter("Bearer "))
}
override fun serialize(session: Session): String = session.token
}
}
cookie<CookieSession>(TWIGS_SESSION_COOKIE) {
serializer = object : SessionSerializer<CookieSession> {
override fun deserialize(text: String): CookieSession {
this@moduleWithDependencies.environment.log.info("Deserializing session!")
return CookieSession(token = text)
}
override fun serialize(session: CookieSession): String = session.token
}
cookie.httpOnly = true
cookie.secure = true
}
}
install(ContentNegotiation) {
json(json = Json {
@ -196,6 +215,8 @@ fun Application.moduleWithDependencies(
prettyPrint = false
useArrayPolymorphism = true
})
formData()
}
install(CORS) {
allowHost("twigs.wbrawner.com", listOf("http", "https")) // TODO: Make configurable
@ -225,12 +246,15 @@ fun Application.moduleWithDependencies(
allowHeader("DNT")
allowCredentials = true
}
install(Mustache) {
mustacheFactory = DefaultMustacheFactory("templates")
}
budgetRoutes(budgetService)
categoryRoutes(categoryService)
recurringTransactionRoutes(recurringTransactionService)
transactionRoutes(transactionService)
userRoutes(userService)
webRoutes()
webRoutes(budgetService, userService)
launch {
while (currentCoroutineContext().isActive) {
jobs.forEach { it.run() }

View file

@ -6,9 +6,20 @@ import com.wbrawner.twigs.twoWeeksFromNow
import io.ktor.server.auth.*
import java.time.Instant
data class Session(
open class Session(
override val id: String = randomString(),
val userId: String = "",
val token: String = randomString(255),
var expiration: Instant = twoWeeksFromNow
) : Principal, Identifiable
open val token: String = randomString(255),
val expiration: Instant = twoWeeksFromNow
) : Principal, Identifiable {
fun updateExpiration(newExpiration: Instant) = Session(
id = id,
userId = userId,
token = token,
expiration = newExpiration
)
}
data class HeaderSession(override val token: String) : Session(token = token)
data class CookieSession(override val token: String) : Session(token = token)

View file

@ -8,7 +8,7 @@ data class User(
override val id: String = randomString(),
val name: String = "",
val password: String = "",
val email: String = ""
val email: String? = null
) : Principal, Identifiable
enum class Permission {

View file

@ -29,7 +29,7 @@ ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
ktor-server-html = { module = "io.ktor:ktor-server-html-builder", version.ref = "ktor" }
ktor-server-mustache = { module = "io.ktor:ktor-server-mustache", version.ref = "ktor" }
ktor-server-sessions = { module = "io.ktor:ktor-server-sessions", version.ref = "ktor" }
ktor-server-test = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }

View file

@ -22,6 +22,8 @@ interface UserService {
suspend fun user(userId: String): UserResponse
suspend fun session(token: String): SessionResponse
suspend fun save(request: UserRequest, targetUserId: String, requestingUserId: String): UserResponse
suspend fun delete(targetUserId: String, requestingUserId: String)
@ -68,7 +70,7 @@ class DefaultUserService(
User(
name = request.username,
password = passwordHasher.hash(request.password),
email = if (request.email.isNullOrBlank()) "" else request.email
email = if (request.email.isNullOrBlank()) null else request.email
)
).asResponse()
}
@ -77,7 +79,7 @@ class DefaultUserService(
userRepository.findAll(nameOrEmail = request.username)
.firstOrNull()
?.let {
val email = it.email
val email = it.email ?: return@let
val passwordResetToken = passwordResetRepository.save(PasswordResetToken(userId = it.id))
emailService.sendPasswordResetEmail(passwordResetToken, email)
}
@ -132,6 +134,13 @@ class DefaultUserService(
?: throw HttpException(HttpStatusCode.NotFound)
}
override suspend fun session(token: String): SessionResponse {
return sessionRepository.findAll(token = token)
.firstOrNull()
?.asResponse()
?: throw HttpException(HttpStatusCode.Unauthorized)
}
override suspend fun save(
request: UserRequest,
targetUserId: String,

View file

@ -1,5 +1,3 @@
import java.util.*
plugins {
`java-library`
alias(libs.plugins.kotlin.jvm)
@ -7,8 +5,10 @@ plugins {
dependencies {
implementation(kotlin("stdlib"))
implementation(project(":core"))
implementation(project(":service"))
api(libs.ktor.server.core)
implementation(libs.ktor.server.html)
api(libs.ktor.server.mustache)
testImplementation(libs.junit.jupiter.api)
testRuntimeOnly(libs.junit.jupiter.engine)
}
@ -16,37 +16,3 @@ dependencies {
tasks.getByName<Test>("test") {
useJUnitPlatform()
}
// TODO: Replace this hack with either a git submodule or an internal Kotlin-based UI
tasks.register("package") {
doLast {
val built = File(rootProject.rootDir.parent, "twigs-web/dist/twigs")
if (built.exists()) {
built.deleteRecursively()
}
val dest = File(project.projectDir, "src/main/resources/twigs")
if (dest.exists()) {
dest.deleteRecursively()
}
var command = listOf(
"cd", "../../twigs-web", ";",
"npm", "i", ";",
"npm", "run", "package"
)
command = if (System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("windows")) {
listOf("powershell", "-Command") + command
} else {
listOf("bash", "-c", "\"${command.joinToString(" ")}\"")
}
exec {
commandLine(command)
}
if (!built.copyRecursively(dest, true) || !dest.isDirectory) {
throw GradleException("Failed to copy files from ${built.absolutePath} to ${dest.absolutePath}")
}
}
}
//tasks.getByName("processResources") {
// dependsOn.add("package")
//}

View file

@ -0,0 +1,3 @@
package com.wbrawner.twigs.web
open class Page(val title: String)

View file

@ -1,21 +1,34 @@
package com.wbrawner.twigs.web
import com.wbrawner.twigs.model.CookieSession
import com.wbrawner.twigs.service.budget.BudgetService
import com.wbrawner.twigs.service.user.UserService
import com.wbrawner.twigs.web.user.userWebRoutes
import io.ktor.server.application.*
import io.ktor.server.http.content.*
import io.ktor.server.request.*
import io.ktor.server.mustache.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.sessions.*
fun Application.webRoutes() {
fun Application.webRoutes(
budgetService: BudgetService,
userService: UserService
) {
routing {
staticResources("/", "web")
intercept(ApplicationCallPipeline.Setup) {
if (!call.request.path().startsWith("/api") && !call.request.path().matches(Regex(".*\\.\\w+$"))) {
call.resolveResource("web/index.html")?.let {
call.respond(it)
return@intercept finish()
}
}
staticResources("/", "static")
get("/") {
call.sessions.get(CookieSession::class)
?.let { userService.session(it.token) }
?.let { session ->
application.environment.log.info("Session found!")
budgetService.budgetsForUser(session.userId)
.firstOrNull()
?.let { budget ->
call.respondRedirect("/budgets/${budget.id}")
} ?: call.respondRedirect("/budgets")
} ?: call.respond(MustacheContent("index.mustache", null))
}
}
userWebRoutes(userService)
}

View file

@ -0,0 +1,7 @@
package com.wbrawner.twigs.web.user
import com.wbrawner.twigs.web.Page
data class LoginPage(val username: String = "", val error: String? = null) : Page("Login")
data class RegisterPage(val username: String = "", val email: String = "", val error: String? = null) : Page("Register")

View file

@ -0,0 +1,85 @@
package com.wbrawner.twigs.web.user
import com.wbrawner.twigs.model.CookieSession
import com.wbrawner.twigs.service.HttpException
import com.wbrawner.twigs.service.user.LoginRequest
import com.wbrawner.twigs.service.user.UserRequest
import com.wbrawner.twigs.service.user.UserService
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.mustache.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.sessions.*
import io.ktor.server.util.*
const val TWIGS_SESSION_COOKIE = "twigsSession"
fun Application.userWebRoutes(userService: UserService) {
routing {
route("/login") {
get {
call.respond(MustacheContent("login.mustache", LoginPage()))
}
post {
val request = call.receiveParameters().toLoginRequest()
try {
val session = userService.login(request)
call.sessions.set(CookieSession(session.token))
call.respondRedirect("/")
} catch (e: HttpException) {
call.respond(
status = e.statusCode,
MustacheContent("login.mustache", LoginPage(username = request.username, error = e.message))
)
}
}
}
route("/register") {
get {
call.respond(MustacheContent("register.mustache", RegisterPage()))
}
post {
val request = call.receiveParameters()
val userRequest = request.toUserRequest()
val confirmPassword = request.getOrFail("confirmPassword")
if (userRequest.password != confirmPassword) {
call.respond(
HttpStatusCode.BadRequest,
MustacheContent(
"register.mustache",
userRequest.toPage("passwords don't match")
)
)
return@post
}
try {
userService.register(userRequest)
val session = userService.login(
LoginRequest(
requireNotNull(userRequest.username),
requireNotNull(userRequest.password)
)
)
call.sessions.set(CookieSession(session.token))
call.respondRedirect("/")
} catch (e: HttpException) {
call.respond(
status = e.statusCode,
MustacheContent("register.mustache", userRequest.toPage(error = e.message))
)
}
}
}
}
}
private fun Parameters.toLoginRequest() = LoginRequest(getOrFail("username"), getOrFail("password"))
private fun Parameters.toUserRequest() = UserRequest(getOrFail("username"), getOrFail("password"), get("email"))
private fun UserRequest.toPage(error: String? = null) =
RegisterPage(username = username.orEmpty(), email = email.orEmpty(), error = error)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 969 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

View file

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -2,20 +2,19 @@
<!-- 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="1024"
height="1024"
viewBox="0 0 270.93333 270.93334"
version="1.1"
id="svg8"
inkscape:version="0.92.4 (unknown)"
sodipodi:docname="White.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="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="1024"
height="1024"
viewBox="0 0 270.93333 270.93334"
version="1.1"
id="svg8"
inkscape:version="0.92.4 (unknown)"
sodipodi:docname="White.svg">
<defs
id="defs2" />
<sodipodi:namedview

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -0,0 +1,99 @@
{
"name": "Twigs",
"short_name": "Twigs",
"theme_color": "#000000",
"background_color": "#000000",
"display": "standalone",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "assets/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "assets/icons/icon-maskable-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "assets/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "assets/icons/icon-maskable-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "assets/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "assets/icons/icon-maskable-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "assets/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "assets/icons/icon-maskable-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "assets/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "assets/icons/icon-maskable-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "assets/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "assets/icons/icon-maskable-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "assets/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "assets/icons/icon-maskable-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "assets/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "assets/icons/icon-maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View file

@ -11,6 +11,8 @@ html, body {
--logo: url("/img/logo-color.svg");
--background-color-primary: #ffffff;
--background-color-secondary: #bbbbbb;
--border-radius: 5px;
--input-padding: 10px;
}
@media all and (prefers-color-scheme: dark) {
@ -35,12 +37,13 @@ body {
width: 100%;
}
button {
.button {
border: 1px solid var(--color-accent);
border-radius: 1rem;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: bold;
padding: 1rem;
padding: var(--input-padding);
text-decoration: none;
}
.flex-full-width {
@ -74,6 +77,25 @@ button {
width: 200px;
}
form {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 100%;
max-width: 1200px;
}
form > input {
margin-bottom: 10px;
border: 1px solid var(--color-accent);
border-radius: var(--border-radius);
padding: var(--input-padding);
}
a {
color: var(--color-accent);
}
@media all and (max-width: 400px) {
button {
width: 100%;

View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
{{> partials/head }}
<body>
<div id="app">
<div class="center">
<div class="logo"></div>
<div class="flex-full-width">
<a href="/login" class="button button-primary">Login</a>
<a href="/register" class="button button-secondary">Register</a>
</div>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
{{> partials/head }}
<body>
<div id="app">
<div class="center">
<div class="logo"></div>
{{#error }}
<p class="error">{{error}}</p>
{{/error}}
<form action="/login" method="post">
<label for="username">Username</label>
<input id="username" type="text" name="username" value="{{ username }}"/>
<label for="password">Password</label>
<input id="password" type="password" name="password"/>
<input type="submit" class="button button-primary" value="Login"/>
</form>
<a href="/resetpassword" style="padding: var(--input-padding)">Forgot your password?</a>
<a href="/register" style="padding: var(--input-padding)">Create an account</a>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,13 @@
<link rel="stylesheet" href="/style.css"/>
<title>{{ title }}</title>
<meta charset="utf-8">
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/touch-icon.png">
<link rel="icon" type="image/png" sizes="96x96" href="/icons/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png">
<meta name="apple-mobile-web-app-title" content="Twigs">
<meta name="application-name" content="Twigs">
<meta name="theme-color" content="#FFFFFF">
<link rel="manifest" href="manifest.json">

View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
{{> partials/head }}
<body>
<div id="app">
<div class="center">
<div class="logo"></div>
{{#error }}
<p class="error">{{error}}</p>
{{/error}}
<form action="/register" method="post">
<label for="username">Username</label>
<input id="username" type="text" name="username" value="{{ username }}"/>
<label for="email">Email</label>
<input id="email" type="email" name="email" value="{{ email }}"/>
<label for="password">Password</label>
<input id="password" type="password" name="password"/>
<label for="confirm-password">Confirm Password</label>
<input id="confirm-password" type="password" name="confirmPassword"/>
<input type="submit" class="button button-primary" value="Login"/>
</form>
<a href="/login" style="padding: var(--input-padding)">Login</a>
</div>
</div>
</body>
</html>

View file

@ -1,18 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Twigs</title>
<link rel="stylesheet" href="style.css"/>
</head>
<body>
<div id="app">
<div class="center">
<div class="logo"></div>
<noscript>
<p>JavaScript is required to use this app</p>
</noscript>
</div>
</div>
<script type="text/javascript" src="js/index.js"></script>
</body>
</html>

View file

@ -1,16 +0,0 @@
window.onload = () => {
const container = document.getElementsByClassName('center')[0]
container.innerHTML += `
<div class="flex-full-width">
<button class="button-primary" onclick="login()">Login</button>
<button class="button-secondary" onclick="register()">Register</button>
</div>`
}
function login() {
console.log('show login form')
}
function register() {
console.log('show registration form')
}