Merge http4k
This commit is contained in:
+123
@@ -0,0 +1,123 @@
|
||||
<project>
|
||||
<parent>
|
||||
<artifactId>parent</artifactId>
|
||||
<groupId>be.simplenotes</groupId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<artifactId>app</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>be.simplenotes</groupId>
|
||||
<artifactId>persistance</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>be.simplenotes</groupId>
|
||||
<artifactId>domain</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>be.simplenotes</groupId>
|
||||
<artifactId>shared</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.http4k</groupId>
|
||||
<artifactId>http4k-core</artifactId>
|
||||
<version>3.254.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.http4k</groupId>
|
||||
<artifactId>http4k-server-jetty</artifactId>
|
||||
<version>3.254.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlinx</groupId>
|
||||
<artifactId>kotlinx-html-jvm</artifactId>
|
||||
<version>0.7.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlinx</groupId>
|
||||
<artifactId>kotlinx-serialization-runtime</artifactId>
|
||||
<version>0.20.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>be.simplenotes</groupId>
|
||||
<artifactId>shared</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<type>test-jar</type>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.http4k</groupId>
|
||||
<artifactId>http4k-testing-hamkrest</artifactId>
|
||||
<version>3.254.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.2.4</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<minimizeJar>true</minimizeJar>
|
||||
<transformers>
|
||||
<transformer
|
||||
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>be.simplenotes.app.SimpleNotesKt</mainClass>
|
||||
</transformer>
|
||||
</transformers>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>com.h2database:h2</artifact>
|
||||
<includes>
|
||||
<include>**</include>
|
||||
</includes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>org.mariadb.jdbc:mariadb-java-client</artifact>
|
||||
<includes>
|
||||
<include>**</include>
|
||||
</includes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>org.jetbrains.kotlin:kotlin-reflect</artifact>
|
||||
<includes>
|
||||
<include>**</include>
|
||||
</includes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
<exclude>META-INF/maven/**</exclude>
|
||||
<exclude>META-INF/proguard/**</exclude>
|
||||
<exclude>META-INF/*.kotlin_module</exclude>
|
||||
<exclude>META-INF/DEPENDENCIES*</exclude>
|
||||
<exclude>META-INF/NOTICE*</exclude>
|
||||
<exclude>META-INF/LICENSE*</exclude>
|
||||
<exclude>LICENSE*</exclude>
|
||||
<exclude>META-INF/README*</exclude>
|
||||
<exclude>META-INF/native-image/**</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,49 @@
|
||||
package be.simplenotes.app
|
||||
|
||||
import be.simplenotes.shared.config.DataSourceConfig
|
||||
import be.simplenotes.shared.config.JwtConfig
|
||||
import be.simplenotes.shared.config.ServerConfig
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object Config {
|
||||
//region Config loading
|
||||
private val properties: Properties = javaClass
|
||||
.getResource("/application.properties")
|
||||
.openStream()
|
||||
.use {
|
||||
Properties().apply { load(it) }
|
||||
}
|
||||
|
||||
private val env = System.getenv()
|
||||
|
||||
private fun value(key: String): String =
|
||||
env[key.toUpperCase().replace(".", "_")]
|
||||
?: properties.getProperty(key)
|
||||
?: error("Missing config key $key")
|
||||
//endregion
|
||||
|
||||
val jwtConfig
|
||||
get() = JwtConfig(
|
||||
secret = value("jwt.secret"),
|
||||
validity = value("jwt.validity").toLong(),
|
||||
timeUnit = TimeUnit.HOURS,
|
||||
)
|
||||
|
||||
val dataSourceConfig
|
||||
get() = DataSourceConfig(
|
||||
jdbcUrl = value("jdbcUrl"),
|
||||
driverClassName = value("driverClassName"),
|
||||
username = value("username"),
|
||||
password = value("password"),
|
||||
maximumPoolSize = value("maximumPoolSize").toInt(),
|
||||
connectionTimeout = value("connectionTimeout").toLong()
|
||||
)
|
||||
|
||||
val serverConfig
|
||||
get() = ServerConfig(
|
||||
host = value("host"),
|
||||
port = value("port").toInt(),
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package be.simplenotes.app
|
||||
|
||||
import org.eclipse.jetty.server.Server
|
||||
import org.eclipse.jetty.server.ServerConnector
|
||||
import org.http4k.routing.RoutingHttpHandler
|
||||
import org.http4k.server.ConnectorBuilder
|
||||
import org.http4k.server.Jetty
|
||||
import org.http4k.server.ServerConfig
|
||||
import org.http4k.server.asServer
|
||||
import org.slf4j.LoggerFactory
|
||||
import be.simplenotes.shared.config.ServerConfig as SimpleNotesServeConfig
|
||||
|
||||
class Server(
|
||||
private val config: SimpleNotesServeConfig,
|
||||
private val serverConfig: ServerConfig,
|
||||
private val router: RoutingHttpHandler,
|
||||
) {
|
||||
fun start() {
|
||||
router.asServer(serverConfig).start()
|
||||
LoggerFactory.getLogger(javaClass).info("Listening on http://${config.host}:${config.port}")
|
||||
}
|
||||
}
|
||||
|
||||
fun serverConfig(config: SimpleNotesServeConfig): ServerConfig {
|
||||
val builder: ConnectorBuilder = { server: Server ->
|
||||
ServerConnector(server).apply {
|
||||
port = config.port
|
||||
host = config.host
|
||||
}
|
||||
}
|
||||
return Jetty(config.port, builder)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package be.simplenotes.app
|
||||
|
||||
import be.simplenotes.app.controllers.BaseController
|
||||
import be.simplenotes.app.controllers.NoteController
|
||||
import be.simplenotes.app.controllers.UserController
|
||||
import be.simplenotes.app.filters.AuthFilter
|
||||
import be.simplenotes.app.filters.AuthType
|
||||
import be.simplenotes.app.routes.Router
|
||||
import be.simplenotes.app.utils.StaticFileResolver
|
||||
import be.simplenotes.app.utils.StaticFileResolverImpl
|
||||
import be.simplenotes.app.views.BaseView
|
||||
import be.simplenotes.app.views.NoteView
|
||||
import be.simplenotes.app.views.UserView
|
||||
import be.simplenotes.domain.domainModule
|
||||
import be.simplenotes.persistance.DbMigrations
|
||||
import be.simplenotes.persistance.persistanceModule
|
||||
import be.simplenotes.shared.config.DataSourceConfig
|
||||
import be.simplenotes.shared.config.JwtConfig
|
||||
import org.http4k.core.RequestContexts
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.qualifier.qualifier
|
||||
import org.koin.dsl.module
|
||||
import org.slf4j.LoggerFactory
|
||||
import be.simplenotes.shared.config.ServerConfig as SimpleNotesServeConfig
|
||||
|
||||
|
||||
fun main() {
|
||||
val koin = startKoin {
|
||||
modules(
|
||||
persistanceModule,
|
||||
configModule,
|
||||
domainModule,
|
||||
serverModule,
|
||||
userModule,
|
||||
baseModule,
|
||||
noteModule,
|
||||
)
|
||||
}.koin
|
||||
|
||||
val dataSourceConfig = koin.get<DataSourceConfig>()
|
||||
val jwtConfig = koin.get<JwtConfig>()
|
||||
val serverConfig = koin.get<SimpleNotesServeConfig>()
|
||||
val logger = LoggerFactory.getLogger("SimpleNotes")
|
||||
logger.info("datasource: $dataSourceConfig")
|
||||
logger.info("jwt: $jwtConfig")
|
||||
logger.info("server: $serverConfig")
|
||||
|
||||
val migrations = koin.get<DbMigrations>()
|
||||
migrations.migrate()
|
||||
|
||||
koin.get<Server>().start()
|
||||
}
|
||||
|
||||
val serverModule = module {
|
||||
single { Server(get(), get(), get()) }
|
||||
single<StaticFileResolver> { StaticFileResolverImpl() }
|
||||
single {
|
||||
Router(
|
||||
get(),
|
||||
get(),
|
||||
get(),
|
||||
requiredAuth = get(AuthType.Required.qualifier),
|
||||
optionalAuth = get(AuthType.Optional.qualifier),
|
||||
get()
|
||||
)()
|
||||
}
|
||||
single { serverConfig(get()) }
|
||||
single { RequestContexts() }
|
||||
single(AuthType.Optional.qualifier) { AuthFilter(get(), AuthType.Optional, get())() }
|
||||
single(AuthType.Required.qualifier) { AuthFilter(get(), AuthType.Required, get())() }
|
||||
}
|
||||
|
||||
val userModule = module {
|
||||
single { UserController(get(), get()) }
|
||||
single { UserView(get()) }
|
||||
}
|
||||
|
||||
val baseModule = module {
|
||||
single { BaseController(get()) }
|
||||
single { BaseView(get()) }
|
||||
}
|
||||
|
||||
val noteModule = module {
|
||||
single { NoteController(get(), get()) }
|
||||
single { NoteView(get()) }
|
||||
}
|
||||
|
||||
val configModule = module {
|
||||
single { Config.dataSourceConfig }
|
||||
single { Config.jwtConfig }
|
||||
single { Config.serverConfig }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package be.simplenotes.app.controllers
|
||||
|
||||
import be.simplenotes.app.extensions.html
|
||||
import be.simplenotes.app.views.BaseView
|
||||
import be.simplenotes.domain.security.JwtPayload
|
||||
import org.http4k.core.Request
|
||||
import org.http4k.core.Response
|
||||
import org.http4k.core.Status.Companion.OK
|
||||
|
||||
class BaseController(private val view: BaseView) {
|
||||
fun index(@Suppress("UNUSED_PARAMETER") request: Request, jwtPayload: JwtPayload?) =
|
||||
Response(OK).html(view.renderHome(jwtPayload))
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package be.simplenotes.app.controllers
|
||||
|
||||
import be.simplenotes.app.extensions.html
|
||||
import be.simplenotes.app.extensions.redirect
|
||||
import be.simplenotes.app.views.NoteView
|
||||
import be.simplenotes.domain.security.JwtPayload
|
||||
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 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 kotlin.math.abs
|
||||
|
||||
class NoteController(
|
||||
private val view: NoteView,
|
||||
private val noteService: NoteService,
|
||||
) {
|
||||
|
||||
fun new(request: Request, jwtPayload: JwtPayload): Response {
|
||||
if (request.method == Method.GET) return Response(OK).html(view.noteEditor(jwtPayload))
|
||||
|
||||
val markdownForm = request.form("markdown") ?: ""
|
||||
|
||||
return noteService.create(jwtPayload.userId, markdownForm).fold({
|
||||
val html = when (it) {
|
||||
MissingMeta -> view.noteEditor(jwtPayload, error = "Missing note metadata", textarea = markdownForm)
|
||||
InvalidMeta -> view.noteEditor(jwtPayload, error = "Invalid note metadata", textarea = markdownForm)
|
||||
is ValidationError -> view.noteEditor(jwtPayload, validationErrors = it.validationErrors, textarea = markdownForm)
|
||||
}
|
||||
Response(BAD_REQUEST).html(html)
|
||||
}, {
|
||||
Response.redirect("/notes/${it.uuid}")
|
||||
})
|
||||
}
|
||||
|
||||
fun list(request: Request, jwtPayload: JwtPayload): Response {
|
||||
val currentPage = request.query("page")?.toIntOrNull()?.let(::abs) ?: 1
|
||||
val (pages, notes) = noteService.paginatedNotes(jwtPayload.userId, currentPage)
|
||||
return Response(OK).html(view.notes(jwtPayload, notes, currentPage, pages))
|
||||
}
|
||||
|
||||
fun note(request: Request, jwtPayload: JwtPayload): Response {
|
||||
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
|
||||
|
||||
if (request.method == Method.POST && request.form("delete") != null) {
|
||||
return if (noteService.delete(jwtPayload.userId, noteUuid))
|
||||
Response.redirect("/notes") // FIXME: flash cookie to show success ?
|
||||
else
|
||||
Response(NOT_FOUND) // FIXME: show an error
|
||||
}
|
||||
|
||||
val note = noteService.find(jwtPayload.userId, noteUuid) ?: return Response(NOT_FOUND)
|
||||
return Response(OK).html(view.renderedNote(jwtPayload, note))
|
||||
}
|
||||
|
||||
fun edit(request: Request, jwtPayload: JwtPayload): Response {
|
||||
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
|
||||
val note = noteService.find(jwtPayload.userId, noteUuid) ?: return Response(NOT_FOUND)
|
||||
|
||||
if (request.method == Method.GET) {
|
||||
return Response(OK).html(view.noteEditor(jwtPayload, textarea = note.markdown))
|
||||
}
|
||||
|
||||
val markdownForm = request.form("markdown") ?: ""
|
||||
|
||||
return noteService.update(jwtPayload.userId, note.uuid, markdownForm).fold({
|
||||
val html = when (it) {
|
||||
MissingMeta -> view.noteEditor(jwtPayload, error = "Missing note metadata", textarea = markdownForm)
|
||||
InvalidMeta -> view.noteEditor(jwtPayload, error = "Invalid note metadata", textarea = markdownForm)
|
||||
is ValidationError -> view.noteEditor(jwtPayload, validationErrors = it.validationErrors, textarea = markdownForm)
|
||||
}
|
||||
Response(BAD_REQUEST).html(html)
|
||||
}, {
|
||||
Response.redirect("/notes/${note.uuid}")
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
private fun Request.uuidPath(): UUID? {
|
||||
val uuidPath = path("uuid")!!
|
||||
return try {
|
||||
UUID.fromString(uuidPath)!!
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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.app.views.UserView
|
||||
import be.simplenotes.domain.security.JwtPayload
|
||||
import be.simplenotes.domain.usecases.UserService
|
||||
import be.simplenotes.domain.usecases.login.*
|
||||
import be.simplenotes.domain.usecases.register.InvalidRegisterForm
|
||||
import be.simplenotes.domain.usecases.register.RegisterForm
|
||||
import be.simplenotes.domain.usecases.register.UserExists
|
||||
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
|
||||
|
||||
class UserController(
|
||||
private val userService: UserService,
|
||||
private val userView: UserView,
|
||||
) {
|
||||
fun register(request: Request, jwtPayload: JwtPayload?): Response {
|
||||
if (request.method == GET) return Response(OK).html(
|
||||
userView.register(jwtPayload)
|
||||
)
|
||||
|
||||
val result = userService.register(request.registerForm())
|
||||
|
||||
return result.fold(
|
||||
{
|
||||
val html = when (it) {
|
||||
UserExists -> userView.register(
|
||||
jwtPayload,
|
||||
error = "User already exists"
|
||||
)
|
||||
is InvalidRegisterForm ->
|
||||
userView.register(
|
||||
jwtPayload,
|
||||
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, jwtPayload: JwtPayload?): Response {
|
||||
if (request.method == GET) return Response(OK).html(
|
||||
userView.login(jwtPayload)
|
||||
)
|
||||
|
||||
val result = userService.login(request.loginForm())
|
||||
|
||||
return result.fold(
|
||||
{
|
||||
val html = when (it) {
|
||||
Unregistered ->
|
||||
userView.login(
|
||||
jwtPayload,
|
||||
error = "User does not exist"
|
||||
)
|
||||
WrongPassword ->
|
||||
userView.login(
|
||||
jwtPayload,
|
||||
error = "Wrong password"
|
||||
)
|
||||
is InvalidLoginForm ->
|
||||
userView.login(
|
||||
jwtPayload,
|
||||
validationErrors = it.validationErrors
|
||||
)
|
||||
}
|
||||
Response(OK).html(html)
|
||||
},
|
||||
{ token ->
|
||||
Response.redirect("/").loginCookie(token, request.isSecure())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun Response.loginCookie(token: Token, secure: Boolean): Response {
|
||||
// FIXME: expires
|
||||
// val expiresAt = JWT.decode(token).expiresAt
|
||||
// LocalDateTime.ofEpochSecond(expiresAt.time, 0)
|
||||
|
||||
return this.cookie(
|
||||
Cookie(
|
||||
name = "Authorization",
|
||||
value = "Bearer $token",
|
||||
path = "/",
|
||||
httpOnly = true,
|
||||
sameSite = SameSite.Lax,
|
||||
secure = secure
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun logout(@Suppress("UNUSED_PARAMETER") request: Request) = Response.redirect("/")
|
||||
.invalidateCookie("Authorization")
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package be.simplenotes.app.extensions
|
||||
|
||||
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
|
||||
|
||||
fun Response.html(html: String) = body(html).header("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
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
|
||||
@@ -0,0 +1,45 @@
|
||||
package be.simplenotes.app.filters
|
||||
|
||||
import be.simplenotes.app.extensions.redirect
|
||||
import be.simplenotes.domain.security.JwtPayload
|
||||
import be.simplenotes.domain.security.JwtPayloadExtractor
|
||||
import org.http4k.core.Filter
|
||||
import org.http4k.core.Request
|
||||
import org.http4k.core.RequestContexts
|
||||
import org.http4k.core.Response
|
||||
import org.http4k.core.cookie.cookie
|
||||
|
||||
enum class AuthType {
|
||||
Optional, Required
|
||||
}
|
||||
|
||||
private const val authKey = "auth"
|
||||
|
||||
class AuthFilter(
|
||||
private val extractor: JwtPayloadExtractor,
|
||||
private val authType: AuthType,
|
||||
private val ctx: RequestContexts
|
||||
) {
|
||||
operator fun invoke() = Filter { next ->
|
||||
{
|
||||
val jwtPayload = it.bearerToken()?.let { token -> extractor(token) }
|
||||
when {
|
||||
jwtPayload != null -> {
|
||||
ctx[it][authKey] = jwtPayload
|
||||
next(it)
|
||||
}
|
||||
authType == AuthType.Required -> Response.redirect("/login")
|
||||
else -> next(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Request.jwtPayload(ctx: RequestContexts): JwtPayload? = ctx[this][authKey]
|
||||
|
||||
private fun Request.bearerToken(): String? = cookie("Authorization")
|
||||
?.value
|
||||
?.trim()
|
||||
?.takeIf { it.startsWith("Bearer") }
|
||||
?.substringAfter("Bearer")
|
||||
?.trim()
|
||||
@@ -0,0 +1,26 @@
|
||||
package be.simplenotes.app.filters
|
||||
|
||||
import org.http4k.core.Filter
|
||||
import org.http4k.core.Response
|
||||
import org.http4k.core.Status
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
object ErrorFilter {
|
||||
private val logger = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
operator fun invoke(): Filter = Filter { next ->
|
||||
{
|
||||
try {
|
||||
val response = next(it)
|
||||
if (response.status == Status.NOT_FOUND) Response(Status.NOT_FOUND).body("TODO(NOT_FOUND)")
|
||||
else response
|
||||
} catch (e: Exception) {
|
||||
logger.error(e.stackTraceToString())
|
||||
Response(Status.INTERNAL_SERVER_ERROR).body("TODO(INTERNAL_SERVER_ERROR)")
|
||||
} catch (e: NotImplementedError) {
|
||||
logger.error(e.stackTraceToString())
|
||||
Response(Status.INTERNAL_SERVER_ERROR).body("TODO(NotImplementedError)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package be.simplenotes.app.filters
|
||||
|
||||
import org.http4k.core.Filter
|
||||
import org.http4k.core.HttpHandler
|
||||
import org.http4k.core.Method
|
||||
import org.http4k.core.Request
|
||||
|
||||
object ImmutableFilter {
|
||||
operator fun invoke(): Filter {
|
||||
return Filter { next: HttpHandler ->
|
||||
{ request: Request ->
|
||||
val response = next(request)
|
||||
if (request.method == Method.GET)
|
||||
response.header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
else response
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package be.simplenotes.app.filters
|
||||
|
||||
import org.http4k.core.Filter
|
||||
import org.http4k.core.HttpHandler
|
||||
import org.http4k.core.Request
|
||||
|
||||
object SecurityFilter {
|
||||
operator fun invoke(): Filter {
|
||||
return Filter { next: 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'")
|
||||
else response
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package be.simplenotes.app.routes
|
||||
|
||||
import be.simplenotes.app.controllers.BaseController
|
||||
import be.simplenotes.app.controllers.NoteController
|
||||
import be.simplenotes.app.controllers.UserController
|
||||
import be.simplenotes.app.filters.ErrorFilter
|
||||
import be.simplenotes.app.filters.ImmutableFilter
|
||||
import be.simplenotes.app.filters.SecurityFilter
|
||||
import be.simplenotes.app.filters.jwtPayload
|
||||
import be.simplenotes.domain.security.JwtPayload
|
||||
import org.http4k.core.*
|
||||
import org.http4k.core.Method.GET
|
||||
import org.http4k.core.Method.POST
|
||||
import org.http4k.filter.ResponseFilters
|
||||
import org.http4k.filter.ServerFilters.InitialiseRequestContext
|
||||
import org.http4k.routing.*
|
||||
|
||||
class Router(
|
||||
private val baseController: BaseController,
|
||||
private val userController: UserController,
|
||||
private val noteController: NoteController,
|
||||
private val requiredAuth: Filter,
|
||||
private val optionalAuth: Filter,
|
||||
private val contexts: RequestContexts,
|
||||
) {
|
||||
operator fun invoke(): RoutingHttpHandler {
|
||||
|
||||
val basicRoutes = routes(
|
||||
ImmutableFilter().then(static(ResourceLoader.Classpath(("/static")))),
|
||||
)
|
||||
|
||||
fun public(request: Request, handler: PublicHandler) = handler(request, request.jwtPayload(contexts))
|
||||
fun protected(request: Request, handler: ProtectedHandler) = handler(request, request.jwtPayload(contexts)!!)
|
||||
|
||||
val publicRoutes: RoutingHttpHandler = routes(
|
||||
"/" bind GET to { public(it, baseController::index) },
|
||||
"/register" bind GET to { public(it, userController::register) },
|
||||
"/register" bind POST to { public(it, userController::register) },
|
||||
"/login" bind GET to { public(it, userController::login) },
|
||||
"/login" bind POST to { public(it, userController::login) },
|
||||
"/logout" bind POST to userController::logout,
|
||||
)
|
||||
|
||||
val protectedRoutes = routes(
|
||||
"/account" bind GET to { TODO() },
|
||||
"/export" bind POST to { TODO() },
|
||||
"/notes" bind GET to { protected(it, noteController::list) },
|
||||
"/notes/new" bind GET to { protected(it, noteController::new) },
|
||||
"/notes/new" bind POST to { protected(it, noteController::new) },
|
||||
"/notes/{uuid}" bind GET to { protected(it, noteController::note) },
|
||||
"/notes/{uuid}" bind POST to { protected(it, noteController::note) },
|
||||
"/notes/{uuid}/edit" bind GET to { protected(it, noteController::edit) },
|
||||
"/notes/{uuid}/edit" bind POST to { protected(it, noteController::edit) },
|
||||
)
|
||||
|
||||
val routes = routes(
|
||||
basicRoutes,
|
||||
optionalAuth.then(publicRoutes),
|
||||
requiredAuth.then(protectedRoutes),
|
||||
)
|
||||
|
||||
val globalFilters = ErrorFilter()
|
||||
.then(InitialiseRequestContext(contexts))
|
||||
.then(SecurityFilter())
|
||||
.then(ResponseFilters.GZip())
|
||||
|
||||
return globalFilters.then(routes)
|
||||
}
|
||||
}
|
||||
|
||||
private typealias PublicHandler = (Request, JwtPayload?) -> Response
|
||||
private typealias ProtectedHandler = (Request, JwtPayload) -> Response
|
||||
@@ -0,0 +1,24 @@
|
||||
package be.simplenotes.app.utils
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonConfiguration
|
||||
|
||||
interface StaticFileResolver {
|
||||
fun resolve(name: String): String?
|
||||
}
|
||||
|
||||
class StaticFileResolverImpl : StaticFileResolver {
|
||||
private val mappings: Map<String, String>
|
||||
|
||||
init {
|
||||
val json = Json(JsonConfiguration.Stable)
|
||||
val manifest = javaClass.getResource("/css-manifest.json").readText()
|
||||
val manifestObject = json.parseJson(manifest).jsonObject
|
||||
val keys = manifestObject.keys
|
||||
mappings = keys.map {
|
||||
it to "/${manifestObject[it]!!.primitive.content}"
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
override fun resolve(name: String) = mappings[name]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package be.simplenotes.app.views
|
||||
|
||||
import be.simplenotes.app.utils.StaticFileResolver
|
||||
import be.simplenotes.domain.security.JwtPayload
|
||||
import kotlinx.html.div
|
||||
import kotlinx.html.h1
|
||||
|
||||
class BaseView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
|
||||
fun renderHome(jwtPayload: JwtPayload?) = renderPage(title = "Home", jwtPayload = jwtPayload) {
|
||||
div("centered container mx-auto flex justify-center items-center") {
|
||||
div("bg-gray-800 md:w-1/3 w-full rounded-lg m-4 p-6 text-center") {
|
||||
h1("text-3xl") { +"SimpleNotes" }
|
||||
div("text-teal-400") { +"Welcome" }
|
||||
div("text-gray-200") { +"TODO" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package be.simplenotes.app.views
|
||||
|
||||
import be.simplenotes.app.utils.StaticFileResolver
|
||||
import be.simplenotes.app.views.components.Alert
|
||||
import be.simplenotes.app.views.components.alert
|
||||
import be.simplenotes.app.views.components.submitButton
|
||||
import be.simplenotes.domain.model.PersistedNote
|
||||
import be.simplenotes.domain.model.PersistedNoteMetadata
|
||||
import be.simplenotes.domain.security.JwtPayload
|
||||
import io.konform.validation.ValidationError
|
||||
import kotlinx.html.*
|
||||
|
||||
class NoteView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
|
||||
|
||||
fun noteEditor(
|
||||
jwtPayload: JwtPayload,
|
||||
error: String? = null,
|
||||
textarea: String? = null,
|
||||
validationErrors: List<ValidationError> = emptyList(),
|
||||
) = renderPage(title = "New note", jwtPayload = jwtPayload) {
|
||||
div("container mx-auto p-4") {
|
||||
// TODO: error
|
||||
error?.let { alert(Alert.Warning, error) }
|
||||
validationErrors.forEach {
|
||||
alert(Alert.Warning, it.dataPath.substringAfter('.') + ": " + it.message)
|
||||
}
|
||||
form(method = FormMethod.post) {
|
||||
textArea(classes = "w-full bg-gray-800 p-5 outline-none font-mono") {
|
||||
attributes.also {
|
||||
it["rows"] = "20"
|
||||
it["id"] = "markdown"
|
||||
it["name"] = "markdown"
|
||||
it["aria-label"] = "markdown text area"
|
||||
it["spellcheck"] = "false"
|
||||
}
|
||||
textarea?.let {
|
||||
+it
|
||||
} ?: +"""
|
||||
|---
|
||||
|title: ''
|
||||
|tags: []
|
||||
|---
|
||||
|
|
||||
""".trimMargin("|")
|
||||
}
|
||||
submitButton("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun notes(jwtPayload: JwtPayload, notes: List<PersistedNoteMetadata>, currentPage: Int, numberOfPages: Int) =
|
||||
renderPage(title = "Notes", jwtPayload = jwtPayload) {
|
||||
div("container mx-auto p-4") {
|
||||
div("flex justify-between mb-4") {
|
||||
h1("text-2xl underline") { +"Notes" }
|
||||
a(
|
||||
href = "/notes/new",
|
||||
classes = "text-gray-800 bg-green-500 hover:bg-green-700 " +
|
||||
"inline ml-2 text-md font-semibold rounded px-4 py-2"
|
||||
) { +"New" }
|
||||
}
|
||||
if (notes.isNotEmpty()) {
|
||||
|
||||
ul {
|
||||
notes.forEach { (title, tags, _, uuid) ->
|
||||
li("flex justify-between") {
|
||||
a(classes = "text-blue-200 text-xl hover:underline", href = "/notes/${uuid}") {
|
||||
+title
|
||||
}
|
||||
span {
|
||||
tags.forEach {
|
||||
span("tag ml-2") { +"#$it" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (numberOfPages > 1)
|
||||
pagination(currentPage, numberOfPages)
|
||||
} else
|
||||
span { +"No notes yet" } // FIXME if too far in pagination, it it displayed
|
||||
}
|
||||
}
|
||||
|
||||
private fun DIV.pagination(currentPage: Int, numberOfPages: Int) {
|
||||
val links = mutableListOf<Pair<String, String>>()
|
||||
//if (currentPage > 1) links += "Previous" to "?page=${currentPage - 1}"
|
||||
links += (1..numberOfPages).map { "$it" to "?page=$it" }
|
||||
//if (currentPage < numberOfPages) links += "Next" to "?page=${currentPage + 1}"
|
||||
|
||||
nav("pages") {
|
||||
links.forEach { (name, href) ->
|
||||
a(href, classes = if (name == currentPage.toString()) "active" else null) { +name }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun renderedNote(jwtPayload: JwtPayload, note: PersistedNote) = renderPage(note.meta.title, jwtPayload = jwtPayload) {
|
||||
div("container mx-auto p-4") {
|
||||
div("flex items-center justify-between mb-4") {
|
||||
h1("text-3xl fond-bold underline") { +note.meta.title }
|
||||
span {
|
||||
note.meta.tags.forEach {
|
||||
span("tag ml-2") { +"#$it" }
|
||||
}
|
||||
}
|
||||
}
|
||||
span("flex justify-end mb-4") {
|
||||
a(
|
||||
href = "/notes/${note.uuid}/edit",
|
||||
classes = "mx-2 bg-teal-500 hover:bg-teal-600 focus:bg-teal-600" +
|
||||
" focus:outline-none text-white font-bold py-2 px-4 rounded"
|
||||
) { +"Edit" }
|
||||
form(method = FormMethod.post, classes = "inline") {
|
||||
button(
|
||||
type = ButtonType.submit,
|
||||
name = "delete",
|
||||
classes = "mx-2 bg-red-500 hover:bg-red-600 focus:bg-red-600" +
|
||||
" focus:outline-none text-white font-bold py-2 px-4 rounded"
|
||||
) { +"Delete" }
|
||||
}
|
||||
}
|
||||
div {
|
||||
attributes["id"] = "note"
|
||||
unsafe {
|
||||
+note.html
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package be.simplenotes.app.views
|
||||
|
||||
import be.simplenotes.app.utils.StaticFileResolver
|
||||
import be.simplenotes.app.views.components.Alert
|
||||
import be.simplenotes.app.views.components.alert
|
||||
import be.simplenotes.app.views.components.input
|
||||
import be.simplenotes.app.views.components.submitButton
|
||||
import be.simplenotes.domain.security.JwtPayload
|
||||
import io.konform.validation.ValidationError
|
||||
import kotlinx.html.*
|
||||
|
||||
class UserView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
|
||||
fun register(
|
||||
jwtPayload: JwtPayload?,
|
||||
error: String? = null,
|
||||
validationErrors: List<ValidationError> = emptyList(),
|
||||
) = accountForm("Register", jwtPayload, error, validationErrors, "Create an account", "Register") {
|
||||
+"Already have an account? "
|
||||
a(href = "/login", classes = "no-underline text-blue-500 font-bold") { +"Sign In" }
|
||||
}
|
||||
|
||||
fun login(
|
||||
jwtPayload: JwtPayload?,
|
||||
error: String? = null,
|
||||
validationErrors: List<ValidationError> = emptyList(),
|
||||
new: Boolean = false,
|
||||
) = accountForm("Login", jwtPayload, error, validationErrors, "Sign In", "Sign In", new) {
|
||||
+"Don't have an account yet? "
|
||||
a(href = "/register", classes = "no-underline text-blue-500 font-bold") { +"Create an account" }
|
||||
}
|
||||
|
||||
private fun accountForm(
|
||||
title: String,
|
||||
jwtPayload: JwtPayload?,
|
||||
error: String? = null,
|
||||
validationErrors: List<ValidationError> = emptyList(),
|
||||
h1: String,
|
||||
submit: String,
|
||||
new: Boolean = false,
|
||||
footer: FlowContent.() -> Unit,
|
||||
) = renderPage(title = title, jwtPayload = jwtPayload) {
|
||||
div("centered container mx-auto flex justify-center items-center") {
|
||||
div("w-full md:w-1/2 lg:w-1/3 m-4") {
|
||||
h1("font-semibold text-lg mb-6 text-center") { +h1 }
|
||||
div("p-8 mb-6") {
|
||||
if (new) alert(Alert.Success, "Your account has been created")
|
||||
error?.let { alert(Alert.Warning, error) }
|
||||
form(method = FormMethod.post) {
|
||||
input(
|
||||
id = "username",
|
||||
placeholder = "Username",
|
||||
autoComplete = "username",
|
||||
error = validationErrors.find { it.dataPath == ".username" }?.message
|
||||
)
|
||||
input(
|
||||
id = "password",
|
||||
placeholder = "Password",
|
||||
autoComplete = "new-password",
|
||||
type = InputType.password,
|
||||
error = validationErrors.find { it.dataPath == ".password" }?.message
|
||||
)
|
||||
submitButton(submit)
|
||||
}
|
||||
}
|
||||
div("text-center") {
|
||||
p("text-gray-200 text-sm") {
|
||||
footer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package be.simplenotes.app.views
|
||||
|
||||
import be.simplenotes.app.utils.StaticFileResolver
|
||||
import be.simplenotes.app.views.components.navbar
|
||||
import be.simplenotes.domain.security.JwtPayload
|
||||
import kotlinx.html.*
|
||||
import kotlinx.html.stream.appendHTML
|
||||
|
||||
abstract class View(private val staticFileResolver: StaticFileResolver) {
|
||||
|
||||
private val styles = staticFileResolver.resolve("styles.css")!!
|
||||
|
||||
fun renderPage(
|
||||
title: String,
|
||||
description: String? = null,
|
||||
jwtPayload: JwtPayload?,
|
||||
body: BODY.() -> Unit = {},
|
||||
) = buildString {
|
||||
appendLine("<!DOCTYPE html>")
|
||||
appendHTML().html {
|
||||
attributes["lang"] = "en"
|
||||
head {
|
||||
meta(charset = "UTF-8")
|
||||
meta(name = "viewport", content = "width=device-width, initial-scale=1")
|
||||
title("$title - SimpleNotes")
|
||||
description?.let { meta(name = "description", content = it) }
|
||||
link(rel = "stylesheet", href = styles)
|
||||
link(rel = "shortcut icon", href="/favicon.ico", type = "image/x-icon")
|
||||
}
|
||||
body("bg-gray-900 text-white") {
|
||||
navbar(jwtPayload)
|
||||
main { this@body.body() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package be.simplenotes.app.views.components
|
||||
|
||||
import kotlinx.html.*
|
||||
|
||||
fun FlowContent.alert(type: Alert, title: String, details: String? = null) {
|
||||
val colors = when (type) {
|
||||
Alert.Success -> "bg-green-500 border border-green-400 text-gray-800"
|
||||
Alert.Warning -> "bg-red-500 border border-red-400 text-red-200"
|
||||
}
|
||||
div("$colors px-4 py-3 mb-4 rounded relative") {
|
||||
attributes["role"] = "alert"
|
||||
strong("font-bold") { +title }
|
||||
details?.let { span("block sm:inline") { +details } }
|
||||
}
|
||||
}
|
||||
|
||||
enum class Alert {
|
||||
Success, Warning
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package be.simplenotes.app.views.components
|
||||
|
||||
import kotlinx.html.*
|
||||
import kotlinx.html.ButtonType.submit
|
||||
|
||||
fun FlowContent.input(
|
||||
type: InputType = InputType.text,
|
||||
placeholder: String,
|
||||
id: String,
|
||||
autoComplete: String? = null,
|
||||
error: String? = null
|
||||
) {
|
||||
val colors = "bg-gray-800 border-gray-700 focus:border-teal-500 text-white"
|
||||
div("mb-8") {
|
||||
input(
|
||||
type = type,
|
||||
classes = "$colors rounded w-full border appearance-none focus:outline-none text-base p-2"
|
||||
) {
|
||||
attributes["placeholder"] = placeholder
|
||||
attributes["aria-label"] = placeholder
|
||||
attributes["name"] = id
|
||||
attributes["id"] = id
|
||||
autoComplete?.let { attributes["autocomplete"] = it }
|
||||
}
|
||||
error?.let { p("mt-2 text-red-500 text-sm italic") { +"$placeholder $error" } }
|
||||
}
|
||||
}
|
||||
|
||||
fun FlowContent.submitButton(text: String) {
|
||||
div("flex items-center mt-6") {
|
||||
button(
|
||||
type = submit,
|
||||
classes = "bg-teal-500 hover:bg-teal-600 focus:bg-teal-600" +
|
||||
" w-full focus:outline-none text-white font-bold py-2 px-4 rounded"
|
||||
) { +text }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package be.simplenotes.app.views.components
|
||||
|
||||
import be.simplenotes.domain.security.JwtPayload
|
||||
import kotlinx.html.*
|
||||
|
||||
fun BODY.navbar(jwtPayload: JwtPayload?) {
|
||||
nav("nav bg-teal-700 shadow-md flex items-center justify-between px-4") {
|
||||
a(href = "/", classes = "text-2xl text-gray-100 font-bold") { +"SimpleNotes" }
|
||||
ul {
|
||||
if (jwtPayload != null) {
|
||||
li("inline text-gray-100 ml-2 text-md font-semibold") {
|
||||
a(href = "/notes") { +"Notes" }
|
||||
}
|
||||
li(
|
||||
"text-gray-800 bg-green-500 hover:bg-green-700" +
|
||||
" inline ml-2 text-md font-semibold rounded px-4 py-2"
|
||||
) {
|
||||
form(classes = "inline", action = "/logout", method = FormMethod.post) {
|
||||
button(type = ButtonType.submit) { +"Logout" }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
li("inline text-gray-100 pl-2 text-md font-semibold") {
|
||||
a(href = "/login") { +"Sign In" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
host=localhost
|
||||
port=8080
|
||||
#
|
||||
jdbcUrl=jdbc:h2:./notes-db;
|
||||
driverClassName=org.h2.Driver
|
||||
username=h2
|
||||
password=
|
||||
maximumPoolSize=10
|
||||
connectionTimeout=3000
|
||||
#
|
||||
jwt.secret=PliLvfk7l4WF+cZJk66LR5Mpnh+ocbvJ2wfUCK2UCms=
|
||||
jwt.validity=24
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -0,0 +1 @@
|
||||
package be.simplenotes.app
|
||||
@@ -0,0 +1,94 @@
|
||||
package be.simplenotes.app.filters
|
||||
|
||||
import be.simplenotes.domain.security.JwtPayload
|
||||
import be.simplenotes.domain.security.JwtPayloadExtractor
|
||||
import be.simplenotes.domain.security.SimpleJwt
|
||||
import be.simplenotes.shared.config.JwtConfig
|
||||
import com.natpryce.hamkrest.assertion.assertThat
|
||||
import org.http4k.core.*
|
||||
import org.http4k.core.Method.GET
|
||||
import org.http4k.core.Status.Companion.FOUND
|
||||
import org.http4k.core.Status.Companion.OK
|
||||
import org.http4k.core.cookie.cookie
|
||||
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 AuthFilterTest {
|
||||
|
||||
// region setup
|
||||
private val jwtConfig = JwtConfig("secret", 1, TimeUnit.HOURS)
|
||||
private val simpleJwt = SimpleJwt(jwtConfig)
|
||||
private val extractor = JwtPayloadExtractor(simpleJwt)
|
||||
private val ctx = RequestContexts()
|
||||
private val requiredAuth = AuthFilter(extractor, AuthType.Required, ctx)()
|
||||
private val optionalAuth = AuthFilter(extractor, AuthType.Optional, ctx)()
|
||||
|
||||
private val echoJwtPayloadHandler = { request: Request -> Response(OK).body(request.jwtPayload(ctx).toString()) }
|
||||
|
||||
private val app = ServerFilters.InitialiseRequestContext(ctx).then(
|
||||
routes(
|
||||
"/optional" bind GET to optionalAuth.then(echoJwtPayloadHandler),
|
||||
"/protected" bind GET to requiredAuth.then(echoJwtPayloadHandler)
|
||||
)
|
||||
)
|
||||
// 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("Authorization", "Bearer nnkjnkjnk"))
|
||||
assertThat(response, hasStatus(OK))
|
||||
assertThat(response, hasBody("null"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `it should allow a valid token`() {
|
||||
val jwtPayload = JwtPayload(1, "user")
|
||||
val token = simpleJwt.sign(jwtPayload)
|
||||
val response = app(Request(GET, "/optional").cookie("Authorization", "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("Authorization", "Bearer nnkjnkjnk"))
|
||||
assertThat(response, hasStatus(FOUND))
|
||||
assertThat(response, hasHeader("Location"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `it should allow a valid token"`() {
|
||||
val jwtPayload = JwtPayload(1, "user")
|
||||
val token = simpleJwt.sign(jwtPayload)
|
||||
val response = app(Request(GET, "/protected").cookie("Authorization", "Bearer $token"))
|
||||
assertThat(response, hasStatus(OK))
|
||||
assertThat(response, hasBody("$jwtPayload"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user