Flatten packages

Remove modules prefix
This commit is contained in:
2020-11-11 22:32:23 +01:00
parent e6a7af840a
commit 8439782430
155 changed files with 51 additions and 33 deletions
+33
View File
@@ -0,0 +1,33 @@
import be.simplenotes.Libs
plugins {
id("be.simplenotes.base")
id("be.simplenotes.kotlinx-serialization")
id("be.simplenotes.app-shadow")
id("be.simplenotes.app-css")
id("be.simplenotes.app-docker")
kotlin("kapt")
}
dependencies {
implementation(project(":domain"))
implementation(project(":types"))
implementation(project(":config"))
implementation(project(":views"))
implementation(Libs.arrowCoreData)
implementation(Libs.konform)
implementation(Libs.http4kCore)
implementation(Libs.jettyServer)
implementation(Libs.jettyServlet)
implementation(Libs.javaxServlet)
implementation(Libs.kotlinxSerializationJson)
implementation(Libs.logbackClassic)
implementation(Libs.micronaut)
kapt(Libs.micronautProcessor)
testImplementation(Libs.junit)
testImplementation(Libs.assertJ)
testImplementation(Libs.http4kTestingHamkrest)
}
+18
View File
@@ -0,0 +1,18 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<withJansi>true</withJansi>
<encoder>
<pattern>%cyan(%d{YYYY-MM-dd HH:mm:ss.SSS}) [%thread] %highlight(%-5level) %green(%logger{36}) - %msg%n
</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="STDOUT"/>
</root>
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="me.liuwj.ktorm.database" level="INFO"/>
<logger name="com.zaxxer.hikari" level="INFO"/>
<logger name="org.flywaydb.core" level="INFO"/>
<logger name="io.micronaut" level="INFO"/>
<logger name="io.micronaut.context.lifecycle" level="INFO"/>
</configuration>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#00aba9</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 814 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('#note pre code').forEach((b) => {
hljs.highlightBlock(b);
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
User-agent: *
Allow: /$
Disallow: /
@@ -0,0 +1,33 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M1255 6993 c-179 -23 -313 -62 -461 -133 -396 -187 -665 -533 -766
-981 l-23 -104 0 -2275 0 -2275 23 -102 c125 -565 530 -970 1095 -1095 l102
-23 2275 0 2275 0 102 23 c565 125 970 530 1095 1095 l23 102 0 2275 0 2275
-23 102 c-125 566 -521 964 -1090 1095 l-97 22 -2250 2 c-1237 1 -2263 -1
-2280 -3z m1024 -1979 c128 -18 287 -70 394 -127 262 -139 448 -395 472 -649
3 -34 8 -78 11 -95 l5 -33 -255 0 -254 0 -6 28 c-2 15 -7 44 -11 65 -27 168
-204 335 -416 393 -94 26 -317 24 -421 -4 -218 -59 -345 -196 -356 -384 -6
-105 16 -173 80 -243 81 -88 227 -148 563 -230 509 -123 742 -228 916 -412
123 -131 170 -248 176 -448 9 -245 -55 -420 -212 -580 -162 -165 -387 -270
-667 -311 -154 -22 -461 -15 -595 15 -280 62 -513 193 -662 373 -72 85 -162
262 -185 358 -8 36 -18 95 -22 133 l-6 67 254 0 255 0 6 -53 c22 -179 135
-332 310 -417 127 -61 195 -74 387 -75 195 0 279 16 399 76 179 89 260 234
231 415 -22 137 -98 231 -243 302 -109 55 -202 84 -447 142 -237 57 -306 76
-427 120 -340 125 -535 303 -600 550 -23 87 -23 271 0 360 87 335 409 595 827
665 108 19 371 18 499 -1z m3170 0 c202 -35 325 -95 453 -224 77 -77 93 -100
141 -201 30 -63 63 -143 72 -179 49 -183 48 -159 52 -1307 l4 -1083 -256 0
-255 0 0 1034 c0 1160 1 1133 -70 1282 -92 192 -259 271 -548 262 -117 -4
-149 -9 -221 -33 -177 -60 -340 -200 -440 -376 l-31 -56 0 -1056 0 -1057 -255
0 -255 0 0 1480 0 1480 239 0 238 0 6 -82 c4 -46 7 -120 7 -165 0 -45 3 -88 6
-97 4 -11 28 8 92 72 163 164 356 267 575 307 103 19 334 18 446 -1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+19
View File
@@ -0,0 +1,19 @@
{
"name": "SimpleNotes",
"short_name": "SimpleNotes",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
+29
View File
@@ -0,0 +1,29 @@
package be.simplenotes.app
import org.http4k.server.Http4kServer
import org.slf4j.LoggerFactory
import javax.annotation.PostConstruct
import javax.annotation.PreDestroy
import javax.inject.Singleton
import be.simplenotes.config.ServerConfig as SimpleNotesServerConfig
@Singleton
class Server(
private val config: SimpleNotesServerConfig,
private val http4kServer: Http4kServer,
) {
private val logger = LoggerFactory.getLogger(javaClass)
@PostConstruct
fun start(): Server {
http4kServer.start()
logger.info("Listening on http://${config.host}:${http4kServer.port()}")
return this
}
@PreDestroy
fun stop() {
logger.info("Stopping server")
http4kServer.close()
}
}
+10
View File
@@ -0,0 +1,10 @@
package be.simplenotes.app
import io.micronaut.context.ApplicationContext
import java.lang.Runtime.getRuntime
fun main() {
val ctx = ApplicationContext.run()
ctx.createBean(Server::class.java)
getRuntime().addShutdownHook(Thread { ctx.stop() })
}
+79
View File
@@ -0,0 +1,79 @@
package be.simplenotes.app.api
import be.simplenotes.app.extensions.auto
import be.simplenotes.domain.usecases.NoteService
import be.simplenotes.types.LoggedInUser
import be.simplenotes.types.PersistedNote
import be.simplenotes.types.PersistedNoteMetadata
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.BAD_REQUEST
import org.http4k.core.Status.Companion.NOT_FOUND
import org.http4k.core.Status.Companion.OK
import org.http4k.lens.Path
import org.http4k.lens.uuid
import java.util.*
import javax.inject.Singleton
@Singleton
class ApiNoteController(
json: Json,
private val noteService: NoteService,
) {
fun createNote(request: Request, loggedInUser: LoggedInUser): Response {
val content = noteContentLens(request)
return noteService.create(loggedInUser, content).fold(
{ Response(BAD_REQUEST) },
{ uuidContentLens(UuidContent(it.uuid), Response(OK)) }
)
}
fun notes(@Suppress("UNUSED_PARAMETER") request: Request, loggedInUser: LoggedInUser): Response {
val notes = noteService.paginatedNotes(loggedInUser.userId, page = 1).notes
return persistedNotesMetadataLens(notes, Response(OK))
}
fun note(request: Request, loggedInUser: LoggedInUser): Response =
noteService.find(loggedInUser.userId, uuidLens(request))
?.let { persistedNoteLens(it, Response(OK)) }
?: Response(NOT_FOUND)
fun update(request: Request, loggedInUser: LoggedInUser): Response {
val content = noteContentLens(request)
return noteService.update(loggedInUser, uuidLens(request), content).fold(
{
Response(BAD_REQUEST)
},
{
if (it == null) Response(NOT_FOUND)
else Response(OK)
}
)
}
fun search(request: Request, loggedInUser: LoggedInUser): Response {
val query = searchContentLens(request)
val notes = noteService.search(loggedInUser.userId, query)
return persistedNotesMetadataLens(notes, Response(OK))
}
private val uuidContentLens = json.auto<UuidContent>().toLens()
private val noteContentLens = json.auto<NoteContent>().map { it.content }.toLens()
private val searchContentLens = json.auto<SearchContent>().map { it.query }.toLens()
private val persistedNotesMetadataLens = json.auto<List<PersistedNoteMetadata>>().toLens()
private val persistedNoteLens = json.auto<PersistedNote>().toLens()
private val uuidLens = Path.uuid().of("uuid")
}
@Serializable
data class NoteContent(val content: String)
@Serializable
data class UuidContent(@Contextual val uuid: UUID)
@Serializable
data class SearchContent(@Contextual val query: String)
+31
View File
@@ -0,0 +1,31 @@
package be.simplenotes.app.api
import be.simplenotes.app.extensions.auto
import be.simplenotes.domain.usecases.UserService
import be.simplenotes.domain.usecases.users.login.LoginForm
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.BAD_REQUEST
import org.http4k.core.Status.Companion.OK
import javax.inject.Singleton
@Singleton
class ApiUserController(
json: Json,
private val userService: UserService,
) {
private val tokenLens = json.auto<Token>().toLens()
private val loginFormLens = json.auto<LoginForm>().toLens()
fun login(request: Request) = userService
.login(loginFormLens(request))
.fold(
{ Response(BAD_REQUEST) },
{ tokenLens(Token(it), Response(OK)) }
)
}
@Serializable
data class Token(val token: String)
+15
View File
@@ -0,0 +1,15 @@
package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html
import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.BaseView
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK
import javax.inject.Singleton
@Singleton
class BaseController(private val view: BaseView) {
fun index(@Suppress("UNUSED_PARAMETER") request: Request, loggedInUser: LoggedInUser?) =
Response(OK).html(view.renderHome(loggedInUser))
}
@@ -0,0 +1,14 @@
package be.simplenotes.app.controllers
import be.simplenotes.domain.usecases.HealthCheckService
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK
import org.http4k.core.Status.Companion.SERVICE_UNAVAILABLE
import javax.inject.Singleton
@Singleton
class HealthCheckController(private val healthCheckService: HealthCheckService) {
fun healthCheck(@Suppress("UNUSED_PARAMETER") request: Request) =
if (healthCheckService.isOk()) Response(OK) else Response(SERVICE_UNAVAILABLE)
}
+168
View File
@@ -0,0 +1,168 @@
package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.redirect
import be.simplenotes.domain.usecases.NoteService
import be.simplenotes.domain.usecases.markdown.InvalidMeta
import be.simplenotes.domain.usecases.markdown.MissingMeta
import be.simplenotes.domain.usecases.markdown.ValidationError
import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.NoteView
import org.http4k.core.Method
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.BAD_REQUEST
import org.http4k.core.Status.Companion.NOT_FOUND
import org.http4k.core.Status.Companion.OK
import org.http4k.core.body.form
import org.http4k.routing.path
import java.util.*
import javax.inject.Singleton
import kotlin.math.abs
@Singleton
class NoteController(
private val view: NoteView,
private val noteService: NoteService,
) {
fun new(request: Request, loggedInUser: LoggedInUser): Response {
if (request.method == Method.GET) return Response(OK).html(view.noteEditor(loggedInUser))
val markdownForm = request.form("markdown") ?: ""
return noteService.create(loggedInUser, markdownForm).fold(
{
val html = when (it) {
MissingMeta -> view.noteEditor(
loggedInUser,
error = "Missing note metadata",
textarea = markdownForm
)
InvalidMeta -> view.noteEditor(
loggedInUser,
error = "Invalid note metadata",
textarea = markdownForm
)
is ValidationError -> view.noteEditor(
loggedInUser,
validationErrors = it.validationErrors,
textarea = markdownForm
)
}
Response(BAD_REQUEST).html(html)
},
{
Response.redirect("/notes/${it.uuid}")
}
)
}
fun list(request: Request, loggedInUser: LoggedInUser): Response {
val currentPage = request.query("page")?.toIntOrNull()?.let(::abs) ?: 1
val tag = request.query("tag")
val (pages, notes) = noteService.paginatedNotes(loggedInUser.userId, currentPage, tag = tag)
val deletedCount = noteService.countDeleted(loggedInUser.userId)
return Response(OK).html(view.notes(loggedInUser, notes, currentPage, pages, deletedCount, tag = tag))
}
fun search(request: Request, loggedInUser: LoggedInUser): Response {
val query = request.form("search") ?: ""
val notes = noteService.search(loggedInUser.userId, query)
val deletedCount = noteService.countDeleted(loggedInUser.userId)
return Response(OK).html(view.search(loggedInUser, notes, query, deletedCount))
}
fun note(request: Request, loggedInUser: LoggedInUser): Response {
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
if (request.method == Method.POST) {
if (request.form("delete") != null) {
return if (noteService.trash(loggedInUser.userId, noteUuid))
Response.redirect("/notes") // TODO: flash cookie to show success ?
else
Response(NOT_FOUND) // TODO: show an error
}
if (request.form("public") != null) {
if (!noteService.makePublic(loggedInUser.userId, noteUuid)) return Response(NOT_FOUND)
} else if (request.form("private") != null) {
if (!noteService.makePrivate(loggedInUser.userId, noteUuid)) return Response(NOT_FOUND)
}
}
val note = noteService.find(loggedInUser.userId, noteUuid) ?: return Response(NOT_FOUND)
return Response(OK).html(view.renderedNote(loggedInUser, note, shared = false))
}
fun public(request: Request, loggedInUser: LoggedInUser?): Response {
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
val note = noteService.findPublic(noteUuid) ?: return Response(NOT_FOUND)
return Response(OK).html(view.renderedNote(loggedInUser, note, shared = true))
}
fun edit(request: Request, loggedInUser: LoggedInUser): Response {
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
val note = noteService.find(loggedInUser.userId, noteUuid) ?: return Response(NOT_FOUND)
if (request.method == Method.GET) {
return Response(OK).html(view.noteEditor(loggedInUser, textarea = note.markdown))
}
val markdownForm = request.form("markdown") ?: ""
return noteService.update(loggedInUser, note.uuid, markdownForm).fold(
{
val html = when (it) {
MissingMeta -> view.noteEditor(
loggedInUser,
error = "Missing note metadata",
textarea = markdownForm
)
InvalidMeta -> view.noteEditor(
loggedInUser,
error = "Invalid note metadata",
textarea = markdownForm
)
is ValidationError -> view.noteEditor(
loggedInUser,
validationErrors = it.validationErrors,
textarea = markdownForm
)
}
Response(BAD_REQUEST).html(html)
},
{
Response.redirect("/notes/${note.uuid}")
}
)
}
fun trash(request: Request, loggedInUser: LoggedInUser): Response {
val currentPage = request.query("page")?.toIntOrNull()?.let(::abs) ?: 1
val tag = request.query("tag")
val (pages, notes) = noteService.paginatedNotes(loggedInUser.userId, currentPage, tag = tag, deleted = true)
return Response(OK).html(view.trash(loggedInUser, notes, currentPage, pages))
}
fun deleted(request: Request, loggedInUser: LoggedInUser): Response {
val uuid = request.uuidPath() ?: return Response(NOT_FOUND)
return if (request.form("delete") != null)
if (noteService.delete(loggedInUser.userId, uuid))
Response.redirect("/notes/trash")
else
Response(NOT_FOUND)
else if (noteService.restore(loggedInUser.userId, uuid))
Response.redirect("/notes/$uuid")
else
Response(NOT_FOUND)
}
private fun Request.uuidPath(): UUID? {
val uuidPath = path("uuid")!!
return try {
UUID.fromString(uuidPath)!!
} catch (e: IllegalArgumentException) {
null
}
}
}
+80
View File
@@ -0,0 +1,80 @@
package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.redirect
import be.simplenotes.domain.usecases.UserService
import be.simplenotes.domain.usecases.users.delete.DeleteError
import be.simplenotes.domain.usecases.users.delete.DeleteForm
import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.SettingView
import org.http4k.core.*
import org.http4k.core.body.form
import org.http4k.core.cookie.invalidateCookie
import javax.inject.Singleton
@Singleton
class SettingsController(
private val userService: UserService,
private val settingView: SettingView,
) {
fun settings(request: Request, loggedInUser: LoggedInUser): Response {
if (request.method == Method.GET)
return Response(Status.OK).html(settingView.settings(loggedInUser))
val deleteForm = request.deleteForm(loggedInUser)
val result = userService.delete(deleteForm)
return result.fold(
{
when (it) {
DeleteError.Unregistered -> Response.redirect("/").invalidateCookie("Bearer")
DeleteError.WrongPassword -> Response(Status.OK).html(
settingView.settings(
loggedInUser,
error = "Wrong password"
)
)
is DeleteError.InvalidForm -> Response(Status.OK).html(
settingView.settings(
loggedInUser,
validationErrors = it.validationErrors
)
)
}
},
{
Response.redirect("/").invalidateCookie("Bearer")
}
)
}
private fun attachment(filename: String, contentType: String) = { response: Response ->
val name = filename.replace("[^a-zA-Z0-9-_.]".toRegex(), "_")
response
.header("Content-Disposition", "attachment; filename=\"$name\"")
.header("Content-Type", contentType)
}
fun export(request: Request, loggedInUser: LoggedInUser): Response {
val isDownload = request.form("download") != null
return if (isDownload) {
val filename = "simplenotes-export-${loggedInUser.username}"
if (request.form("format") == "zip") {
val zip = userService.exportAsZip(loggedInUser.userId)
Response(Status.OK)
.with(attachment("$filename.zip", "application/zip"))
.body(zip)
} else
Response(Status.OK)
.with(attachment("$filename.json", "application/json"))
.body(userService.exportAsJson(loggedInUser.userId))
} else Response(Status.OK).body(userService.exportAsJson(loggedInUser.userId)).header(
"Content-Type",
"application/json"
)
}
private fun Request.deleteForm(loggedInUser: LoggedInUser) =
DeleteForm(loggedInUser.username, form("password"), form("checked") != null)
}
+115
View File
@@ -0,0 +1,115 @@
package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.isSecure
import be.simplenotes.app.extensions.redirect
import be.simplenotes.config.JwtConfig
import be.simplenotes.domain.usecases.UserService
import be.simplenotes.domain.usecases.users.login.*
import be.simplenotes.domain.usecases.users.register.InvalidRegisterForm
import be.simplenotes.domain.usecases.users.register.RegisterForm
import be.simplenotes.domain.usecases.users.register.UserExists
import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.UserView
import org.http4k.core.Method.GET
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK
import org.http4k.core.body.form
import org.http4k.core.cookie.Cookie
import org.http4k.core.cookie.SameSite
import org.http4k.core.cookie.cookie
import org.http4k.core.cookie.invalidateCookie
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Singleton
class UserController(
private val userService: UserService,
private val userView: UserView,
private val jwtConfig: JwtConfig,
) {
fun register(request: Request, loggedInUser: LoggedInUser?): Response {
if (request.method == GET) return Response(OK).html(
userView.register(loggedInUser)
)
val result = userService.register(request.registerForm())
return result.fold(
{
val html = when (it) {
UserExists -> userView.register(
loggedInUser,
error = "User already exists"
)
is InvalidRegisterForm ->
userView.register(
loggedInUser,
validationErrors = it.validationErrors
)
}
Response(OK).html(html)
},
{
Response.redirect("/login")
}
)
}
private fun Request.registerForm() = RegisterForm(form("username"), form("password"))
private fun Request.loginForm(): LoginForm = registerForm()
fun login(request: Request, loggedInUser: LoggedInUser?): Response {
if (request.method == GET) return Response(OK).html(
userView.login(loggedInUser)
)
val result = userService.login(request.loginForm())
return result.fold(
{
val html = when (it) {
Unregistered ->
userView.login(
loggedInUser,
error = "User does not exist"
)
WrongPassword ->
userView.login(
loggedInUser,
error = "Wrong password"
)
is InvalidLoginForm ->
userView.login(
loggedInUser,
validationErrors = it.validationErrors
)
}
Response(OK).html(html)
},
{ token ->
Response.redirect("/notes").loginCookie(token, request.isSecure())
}
)
}
private fun Response.loginCookie(token: Token, secure: Boolean): Response {
val validityInSeconds = TimeUnit.SECONDS.convert(jwtConfig.validity, jwtConfig.timeUnit)
return this.cookie(
Cookie(
name = "Bearer",
value = token,
path = "/",
httpOnly = true,
sameSite = SameSite.Lax,
maxAge = validityInSeconds,
secure = secure
)
)
}
fun logout(@Suppress("UNUSED_PARAMETER") request: Request) = Response.redirect("/")
.invalidateCookie("Bearer")
}
+36
View File
@@ -0,0 +1,36 @@
package be.simplenotes.app.extensions
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.http4k.asString
import org.http4k.core.Body
import org.http4k.core.ContentType
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.FOUND
import org.http4k.core.Status.Companion.MOVED_PERMANENTLY
import org.http4k.lens.*
fun Response.html(html: String) = body(html)
.header("Content-Type", "text/html; charset=utf-8")
.header("Cache-Control", "no-cache")
fun Response.Companion.redirect(url: String, permanent: Boolean = false) =
Response(if (permanent) MOVED_PERMANENTLY else FOUND).header("Location", url)
fun Request.isSecure() = header("X-Forwarded-Proto")?.contains("https") ?: false
val bodyLens = httpBodyRoot(
listOf(Meta(true, "body", ParamMeta.ObjectParam, "body")),
ContentType.APPLICATION_JSON.withNoDirectives(),
ContentNegotiation.StrictNoDirective
).map(
{ it.payload.asString() },
{ Body(it) }
)
inline fun <reified T> Json.auto(): BiDiBodyLensSpec<T> = bodyLens.map(
{ decodeFromString(it) },
{ encodeToString(it) }
)
+47
View File
@@ -0,0 +1,47 @@
package be.simplenotes.app.filters
import be.simplenotes.app.extensions.html
import be.simplenotes.views.ErrorView
import be.simplenotes.views.ErrorView.Type.*
import org.http4k.core.*
import org.http4k.core.Status.Companion.INTERNAL_SERVER_ERROR
import org.http4k.core.Status.Companion.NOT_FOUND
import org.http4k.core.Status.Companion.NOT_IMPLEMENTED
import org.http4k.core.Status.Companion.SERVICE_UNAVAILABLE
import org.slf4j.LoggerFactory
import java.sql.SQLTransientException
import javax.inject.Singleton
@Singleton
class ErrorFilter(private val errorView: ErrorView) : Filter {
private val logger = LoggerFactory.getLogger(javaClass)
private fun errorResponse(status: Status): Response {
val type = when (status) {
SERVICE_UNAVAILABLE -> SqlTransientError
NOT_FOUND -> NotFound
NOT_IMPLEMENTED -> Other
else -> Other
}
return Response(status).html(errorView.error(type)).noCache()
}
override fun invoke(next: HttpHandler): HttpHandler = { request ->
try {
val response = next(request)
if (response.status == NOT_FOUND) errorResponse(NOT_FOUND)
else response
} catch (e: SQLTransientException) {
logger.error(e.stackTraceToString())
errorResponse(SERVICE_UNAVAILABLE)
} catch (e: Exception) {
logger.error(e.stackTraceToString())
errorResponse(INTERNAL_SERVER_ERROR)
} catch (e: NotImplementedError) {
logger.error(e.stackTraceToString())
errorResponse(NOT_IMPLEMENTED)
}
}
}
+15
View File
@@ -0,0 +1,15 @@
package be.simplenotes.app.filters
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.Request
import org.http4k.core.Status.Companion.OK
object ImmutableFilter : Filter {
override fun invoke(next: HttpHandler) = { request: Request ->
val res = next(request)
if (res.status == OK)
res.header("Cache-Control", "public, max-age=31536000, immutable")
else res
}
}
+18
View File
@@ -0,0 +1,18 @@
package be.simplenotes.app.filters
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.Request
object SecurityFilter : Filter {
override fun invoke(next: HttpHandler): HttpHandler = { request: Request ->
val response = next(request)
.header("X-Content-Type-Options", "nosniff")
if (response.header("Content-Type")?.contains("text/html") == true) {
response
.header("Content-Security-Policy", "default-src 'self'")
.header("Referrer-Policy", "no-referrer")
} else response
}
}
+24
View File
@@ -0,0 +1,24 @@
package be.simplenotes.app.filters.auth
import be.simplenotes.types.LoggedInUser
import org.http4k.core.Request
import org.http4k.core.cookie.cookie
import org.http4k.lens.BiDiLens
typealias OptionalAuthLens = BiDiLens<@JvmSuppressWildcards Request, @JvmSuppressWildcards LoggedInUser?>
typealias RequiredAuthLens = BiDiLens<@JvmSuppressWildcards Request, @JvmSuppressWildcards LoggedInUser>
enum class JwtSource {
Header, Cookie
}
fun Request.bearerTokenCookie(): String? = cookie("Bearer")
?.value
?.trim()
fun Request.bearerTokenHeader(): String? =
header("Authorization")
?.trim()
?.takeIf { it.startsWith("Bearer") }
?.substringAfter("Bearer")
?.trim()
@@ -0,0 +1,22 @@
package be.simplenotes.app.filters.auth
import be.simplenotes.app.filters.auth.JwtSource.Cookie
import be.simplenotes.domain.security.JwtPayloadExtractor
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.with
class OptionalAuthFilter(
private val extractor: JwtPayloadExtractor,
private val lens: OptionalAuthLens,
private val source: JwtSource = Cookie,
) : Filter {
override fun invoke(next: HttpHandler): HttpHandler = {
val token = when (source) {
JwtSource.Header -> it.bearerTokenHeader()
Cookie -> it.bearerTokenCookie()
}
next(it.with(lens of token?.let { extractor(it) }))
}
}
@@ -0,0 +1,30 @@
package be.simplenotes.app.filters.auth
import be.simplenotes.app.extensions.redirect
import be.simplenotes.domain.security.JwtPayloadExtractor
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.Response
import org.http4k.core.Status.Companion.UNAUTHORIZED
import org.http4k.core.with
class RequiredAuthFilter(
private val extractor: JwtPayloadExtractor,
private val lens: RequiredAuthLens,
private val source: JwtSource = JwtSource.Cookie,
private val redirect: Boolean = true,
) : Filter {
override fun invoke(next: HttpHandler): HttpHandler = {
val token = when (source) {
JwtSource.Header -> it.bearerTokenHeader()
JwtSource.Cookie -> it.bearerTokenCookie()
}
val jwtPayload = token?.let { extractor(token) }
if (jwtPayload != null) next(it.with(lens of jwtPayload))
else {
if (redirect) Response.redirect("/login")
else Response(UNAUTHORIZED)
}
}
}
+43
View File
@@ -0,0 +1,43 @@
package be.simplenotes.app.jetty
import org.eclipse.jetty.server.Server
import org.eclipse.jetty.server.ServerConnector
import org.eclipse.jetty.servlet.ServletContextHandler
import org.eclipse.jetty.servlet.ServletContextHandler.SESSIONS
import org.eclipse.jetty.servlet.ServletHolder
import org.http4k.core.HttpHandler
import org.http4k.server.Http4kServer
import org.http4k.server.ServerConfig
import org.http4k.servlet.asServlet
class Jetty(private val port: Int, private val server: Server) : ServerConfig {
constructor(port: Int = 8000) : this(port, http(port))
constructor(port: Int, vararg inConnectors: ConnectorBuilder) : this(
port,
Server().apply {
inConnectors.forEach { addConnector(it(this)) }
}
)
override fun toServer(httpHandler: HttpHandler): Http4kServer {
server.insertHandler(httpHandler.toJettyHandler())
return object : Http4kServer {
override fun start(): Http4kServer = apply {
server.start()
}
override fun stop(): Http4kServer = apply { server.stop() }
override fun port(): Int = if (port > 0) port else server.uri.port
}
}
}
fun HttpHandler.toJettyHandler() = ServletContextHandler(SESSIONS).apply {
addServlet(ServletHolder(this@toJettyHandler.asServlet()), "/*")
}
typealias ConnectorBuilder = (Server) -> ServerConnector
fun http(httpPort: Int): ConnectorBuilder = { server: Server -> ServerConnector(server).apply { port = httpPort } }
+46
View File
@@ -0,0 +1,46 @@
package be.simplenotes.app.modules
import be.simplenotes.app.filters.auth.*
import be.simplenotes.domain.security.JwtPayloadExtractor
import io.micronaut.context.annotation.Factory
import io.micronaut.context.annotation.Primary
import org.http4k.core.RequestContexts
import org.http4k.lens.RequestContextKey
import javax.inject.Named
import javax.inject.Singleton
@Factory
class AuthModule {
@Singleton
@Named("optional")
fun optionalAuthLens(ctx: RequestContexts): OptionalAuthLens = RequestContextKey.optional(ctx)
@Singleton
@Named("required")
fun requiredAuthLens(ctx: RequestContexts): RequiredAuthLens = RequestContextKey.required(ctx)
@Singleton
fun optionalAuth(extractor: JwtPayloadExtractor, @Named("optional") lens: OptionalAuthLens) =
OptionalAuthFilter(extractor, lens)
@Primary
@Singleton
fun requiredAuth(extractor: JwtPayloadExtractor, @Named("required") lens: RequiredAuthLens) =
RequiredAuthFilter(extractor, lens)
@Singleton
@Named("api")
internal fun apiAuthFilter(
jwtPayloadExtractor: JwtPayloadExtractor,
@Named("required") lens: RequiredAuthLens,
) = RequiredAuthFilter(
extractor = jwtPayloadExtractor,
lens = lens,
source = JwtSource.Header,
redirect = false
)
@Singleton
fun requestContexts() = RequestContexts()
}
+23
View File
@@ -0,0 +1,23 @@
package be.simplenotes.app.modules
import be.simplenotes.app.serialization.LocalDateTimeSerializer
import be.simplenotes.app.serialization.UuidSerializer
import io.micronaut.context.annotation.Factory
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import java.time.LocalDateTime
import java.util.*
import javax.inject.Singleton
@Factory
class JsonModule {
@Singleton
fun json() = Json {
prettyPrint = true
serializersModule = SerializersModule {
contextual(LocalDateTime::class, LocalDateTimeSerializer())
contextual(UUID::class, UuidSerializer())
}
}
}
+38
View File
@@ -0,0 +1,38 @@
package be.simplenotes.app.modules
import be.simplenotes.app.jetty.ConnectorBuilder
import be.simplenotes.app.jetty.Jetty
import be.simplenotes.app.routes.Router
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.config.ServerConfig
import io.micronaut.context.annotation.Factory
import org.eclipse.jetty.server.ServerConnector
import org.http4k.server.Http4kServer
import org.http4k.server.asServer
import javax.inject.Named
import javax.inject.Singleton
import org.eclipse.jetty.server.Server as JettyServer
import org.http4k.server.ServerConfig as Http4kServerConfig
@Factory
class ServerModule {
@Singleton
@Named("styles")
fun styles(resolver: StaticFileResolver) = resolver.resolve("styles.css")!!
@Singleton
fun http4kServer(router: Router, serverConfig: Http4kServerConfig): Http4kServer =
router().asServer(serverConfig)
@Singleton
fun http4kServerConfig(config: ServerConfig): Http4kServerConfig {
val builder: ConnectorBuilder = { server: JettyServer ->
ServerConnector(server).apply {
port = config.port
host = config.host
}
}
return Jetty(config.port, builder)
}
}
+47
View File
@@ -0,0 +1,47 @@
package be.simplenotes.app.routes
import be.simplenotes.app.api.ApiNoteController
import be.simplenotes.app.api.ApiUserController
import be.simplenotes.app.filters.auth.RequiredAuthFilter
import be.simplenotes.app.filters.auth.RequiredAuthLens
import org.http4k.core.Method.*
import org.http4k.core.Request
import org.http4k.core.then
import org.http4k.routing.PathMethod
import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.bind
import org.http4k.routing.routes
import java.util.function.Supplier
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class ApiRoutes(
private val apiUserController: ApiUserController,
private val apiNoteController: ApiNoteController,
@Named("api") private val auth: RequiredAuthFilter,
@Named("required") private val authLens: RequiredAuthLens,
) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler {
infix fun PathMethod.to(action: ProtectedHandler) =
this to { req: Request -> action(req, authLens(req)) }
return routes(
"/login" bind POST to apiUserController::login,
with(apiNoteController) {
auth.then(
routes(
"/" bind GET to ::notes,
"/" bind POST to ::createNote,
"/search" bind POST to ::search,
"/{uuid}" bind GET to ::note,
"/{uuid}" bind PUT to ::update,
)
).withBasePath("/notes")
}
).withBasePath("/api")
}
}
+60
View File
@@ -0,0 +1,60 @@
package be.simplenotes.app.routes
import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.HealthCheckController
import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.UserController
import be.simplenotes.app.filters.ImmutableFilter
import be.simplenotes.app.filters.auth.OptionalAuthFilter
import be.simplenotes.app.filters.auth.OptionalAuthLens
import org.http4k.core.ContentType
import org.http4k.core.Method.GET
import org.http4k.core.Method.POST
import org.http4k.core.Request
import org.http4k.core.then
import org.http4k.routing.*
import java.util.function.Supplier
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class BasicRoutes(
private val healthCheckController: HealthCheckController,
private val baseCtrl: BaseController,
private val userCtrl: UserController,
private val noteCtrl: NoteController,
@Named("optional") private val authLens: OptionalAuthLens,
private val auth: OptionalAuthFilter,
) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler {
infix fun PathMethod.to(action: PublicHandler) =
this to { req: Request -> action(req, authLens(req)) }
val staticHandler = ImmutableFilter.then(
static(
ResourceLoader.Classpath("/static"),
"woff2" to ContentType("font/woff2"),
"webmanifest" to ContentType("application/manifest+json")
)
)
return routes(
auth.then(
routes(
"/" bind GET to baseCtrl::index,
"/register" bind GET to userCtrl::register,
"/register" bind POST to userCtrl::register,
"/login" bind GET to userCtrl::login,
"/login" bind POST to userCtrl::login,
"/logout" bind POST to userCtrl::logout,
"/notes/public/{uuid}" bind GET to noteCtrl::public,
)
),
"/health" bind GET to healthCheckController::healthCheck,
staticHandler
)
}
}
+46
View File
@@ -0,0 +1,46 @@
package be.simplenotes.app.routes
import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.filters.auth.RequiredAuthFilter
import be.simplenotes.app.filters.auth.RequiredAuthLens
import org.http4k.core.Method.GET
import org.http4k.core.Method.POST
import org.http4k.core.Request
import org.http4k.core.then
import org.http4k.routing.PathMethod
import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.bind
import org.http4k.routing.routes
import java.util.function.Supplier
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class NoteRoutes(
private val noteCtrl: NoteController,
private val auth: RequiredAuthFilter,
@Named("required") private val authLens: RequiredAuthLens,
) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler {
infix fun PathMethod.to(action: ProtectedHandler) =
this to { req: Request -> action(req, authLens(req)) }
return auth.then(
with(noteCtrl) {
routes(
"/" bind GET to ::list,
"/" bind POST to ::search,
"/new" bind GET to ::new,
"/new" bind POST to ::new,
"/trash" bind GET to ::trash,
"/{uuid}" bind GET to ::note,
"/{uuid}" bind POST to ::note,
"/{uuid}/edit" bind GET to ::edit,
"/{uuid}/edit" bind POST to ::edit,
"/deleted/{uuid}" bind POST to ::deleted,
).withBasePath("/notes")
}
)
}
}
+8
View File
@@ -0,0 +1,8 @@
package be.simplenotes.app.routes
import be.simplenotes.types.LoggedInUser
import org.http4k.core.Request
import org.http4k.core.Response
internal typealias PublicHandler = (Request, LoggedInUser?) -> Response
internal typealias ProtectedHandler = (Request, LoggedInUser) -> Response
+32
View File
@@ -0,0 +1,32 @@
package be.simplenotes.app.routes
import be.simplenotes.app.filters.ErrorFilter
import be.simplenotes.app.filters.SecurityFilter
import org.http4k.core.RequestContexts
import org.http4k.core.then
import org.http4k.filter.ResponseFilters.GZip
import org.http4k.filter.ServerFilters.InitialiseRequestContext
import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.routes
import java.util.function.Supplier
import javax.inject.Singleton
@Singleton
class Router(
private val errorFilter: ErrorFilter,
private val contexts: RequestContexts,
private val subRouters: List<Supplier<RoutingHttpHandler>>,
) {
operator fun invoke(): RoutingHttpHandler {
val routes = routes(
*subRouters.map { it.get() }.toTypedArray()
)
return errorFilter
.then(InitialiseRequestContext(contexts))
.then(SecurityFilter)
.then(GZip())
.then(routes)
}
}
+37
View File
@@ -0,0 +1,37 @@
package be.simplenotes.app.routes
import be.simplenotes.app.controllers.SettingsController
import be.simplenotes.app.filters.auth.RequiredAuthFilter
import be.simplenotes.app.filters.auth.RequiredAuthLens
import org.http4k.core.Method.GET
import org.http4k.core.Method.POST
import org.http4k.core.Request
import org.http4k.core.then
import org.http4k.routing.PathMethod
import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.bind
import org.http4k.routing.routes
import java.util.function.Supplier
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class SettingsRoutes(
private val settingsController: SettingsController,
private val auth: RequiredAuthFilter,
@Named("required") private val authLens: RequiredAuthLens,
) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler {
infix fun PathMethod.to(action: ProtectedHandler) =
this to { req: Request -> action(req, authLens(req)) }
return auth.then(
routes(
"/settings" bind GET to settingsController::settings,
"/settings" bind POST to settingsController::settings,
"/export" bind POST to settingsController::export,
)
)
}
}
@@ -0,0 +1,22 @@
package be.simplenotes.app.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.LocalDateTime
internal class LocalDateTimeSerializer : KSerializer<LocalDateTime> {
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalDateTime) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): LocalDateTime {
TODO("Not implemented, isn't needed")
}
}
+22
View File
@@ -0,0 +1,22 @@
package be.simplenotes.app.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.util.*
internal class UuidSerializer : KSerializer<UUID> {
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: UUID) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): UUID {
TODO()
}
}
+26
View File
@@ -0,0 +1,26 @@
package be.simplenotes.app.utils
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import javax.inject.Singleton
interface StaticFileResolver {
fun resolve(name: String): String?
}
@Singleton
class StaticFileResolverImpl(json: Json) : StaticFileResolver {
private val mappings: Map<String, String>
init {
val manifest = javaClass.getResource("/css-manifest.json").readText()
val manifestObject = json.parseToJsonElement(manifest).jsonObject
val keys = manifestObject.keys
mappings = keys.map {
it to "/${manifestObject[it]!!.jsonPrimitive.content}"
}.toMap()
}
override fun resolve(name: String) = mappings[name]
}
+1
View File
@@ -0,0 +1 @@
package be.simplenotes.app
+116
View File
@@ -0,0 +1,116 @@
package be.simplenotes.app.filters
import be.simplenotes.app.filters.auth.OptionalAuthFilter
import be.simplenotes.app.filters.auth.OptionalAuthLens
import be.simplenotes.app.filters.auth.RequiredAuthFilter
import be.simplenotes.app.filters.auth.RequiredAuthLens
import be.simplenotes.config.JwtConfig
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.types.LoggedInUser
import com.natpryce.hamkrest.assertion.assertThat
import io.micronaut.context.BeanContext
import io.micronaut.inject.qualifiers.Qualifiers
import org.http4k.core.Method.GET
import org.http4k.core.Request
import org.http4k.core.RequestContexts
import org.http4k.core.Response
import org.http4k.core.Status.Companion.FOUND
import org.http4k.core.Status.Companion.OK
import org.http4k.core.cookie.cookie
import org.http4k.core.then
import org.http4k.filter.ServerFilters
import org.http4k.hamkrest.hasBody
import org.http4k.hamkrest.hasHeader
import org.http4k.hamkrest.hasStatus
import org.http4k.routing.bind
import org.http4k.routing.routes
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.util.concurrent.TimeUnit
internal class RequiredAuthFilterTest {
// region setup
private val jwtConfig = JwtConfig("secret", 1, TimeUnit.HOURS)
private val simpleJwt = SimpleJwt(jwtConfig)
private val beanCtx = BeanContext.build()
.registerSingleton(jwtConfig)
.start()
private inline fun <reified T> BeanContext.getBean(): T = getBean(T::class.java)
private inline fun <reified T> BeanContext.getBean(name: String): T =
getBean(T::class.java, Qualifiers.byName(name))
private val requiredAuth = beanCtx.getBean<RequiredAuthFilter>()
private val requiredLens = beanCtx.getBean<RequiredAuthLens>("required")
private val optionalAuth = beanCtx.getBean<OptionalAuthFilter>()
private val optionalLens = beanCtx.getBean<OptionalAuthLens>("optional")
private val ctx = beanCtx.getBean<RequestContexts>()
private val app = ServerFilters.InitialiseRequestContext(ctx).then(
routes(
"/optional" bind GET to optionalAuth.then { request: Request ->
Response(OK).body(optionalLens(request).toString())
},
"/protected" bind GET to requiredAuth.then { request: Request ->
Response(OK).body(requiredLens(request).toString())
}
)
)
// endregion
@Nested
inner class OptionalAuth {
@Test
fun `it should allow no token`() {
val response = app(Request(GET, "/optional"))
assertThat(response, hasStatus(OK))
assertThat(response, hasBody("null"))
}
@Test
fun `it should allow an invalid token`() {
val response = app(Request(GET, "/optional").cookie("Bearer", "nnkjnkjnk"))
assertThat(response, hasStatus(OK))
assertThat(response, hasBody("null"))
}
@Test
fun `it should allow a valid token`() {
val jwtPayload = LoggedInUser(1, "user")
val token = simpleJwt.sign(jwtPayload)
val response = app(Request(GET, "/optional").cookie("Bearer", token))
assertThat(response, hasStatus(OK))
assertThat(response, hasBody("$jwtPayload"))
}
}
@Nested
inner class RequiredAuth {
@Test
fun `it shouldn't allow a missing token`() {
val response = app(Request(GET, "/protected"))
assertThat(response, hasStatus(FOUND))
assertThat(response, hasHeader("Location"))
}
@Test
fun `it shouldn't allow an invalid token`() {
val response = app(Request(GET, "/protected").cookie("Bearer", "nnkjnkjnk"))
assertThat(response, hasStatus(FOUND))
assertThat(response, hasHeader("Location"))
}
@Test
fun `it should allow a valid token"`() {
val jwtPayload = LoggedInUser(1, "user")
val token = simpleJwt.sign(jwtPayload)
val response = app(Request(GET, "/protected").cookie("Bearer", token))
assertThat(response, hasStatus(OK))
assertThat(response, hasBody("$jwtPayload"))
}
}
}