Remove nuxt + 100 other things..
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
package be.vandewalleh
|
||||
|
||||
import com.sksamuel.hoplite.Masked
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class Config(val database: DatabaseConfig, val server: ServerConfig, val jwt: JwtConfig) {
|
||||
override fun toString(): String {
|
||||
return """
|
||||
Config(
|
||||
database=$database,
|
||||
server=$server,
|
||||
jwt=$jwt
|
||||
)
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
||||
data class DatabaseConfig(val host: String, val port: Int, val name: String, val username: String, val password: Masked)
|
||||
data class ServerConfig(val host: String, val port: Int, val cors: Boolean)
|
||||
data class JwtConfig(val auth: Jwt, val refresh: Jwt)
|
||||
data class Jwt(val validity: Long, val unit: TimeUnit, val secret: Masked)
|
||||
@@ -0,0 +1,100 @@
|
||||
package be.vandewalleh
|
||||
|
||||
import be.vandewalleh.auth.AuthenticationModule
|
||||
import be.vandewalleh.auth.SimpleJWT
|
||||
import be.vandewalleh.controllers.api.ApiNoteController
|
||||
import be.vandewalleh.controllers.api.ApiTagController
|
||||
import be.vandewalleh.controllers.api.ApiUserController
|
||||
import be.vandewalleh.controllers.web.BaseController
|
||||
import be.vandewalleh.controllers.web.NoteController
|
||||
import be.vandewalleh.controllers.web.UserController
|
||||
import be.vandewalleh.extensions.ApplicationBuilder
|
||||
import be.vandewalleh.extensions.RoutingBuilder
|
||||
import be.vandewalleh.factories.*
|
||||
import be.vandewalleh.features.*
|
||||
import be.vandewalleh.markdown.Markdown
|
||||
import be.vandewalleh.repositories.NoteRepository
|
||||
import be.vandewalleh.repositories.UserRepository
|
||||
import be.vandewalleh.routing.api.ApiDocRoutes
|
||||
import be.vandewalleh.routing.api.ApiNoteRoutes
|
||||
import be.vandewalleh.routing.api.ApiTagRoutes
|
||||
import be.vandewalleh.routing.api.ApiUserRoutes
|
||||
import be.vandewalleh.routing.web.BaseRoutes
|
||||
import be.vandewalleh.routing.web.NoteRoutes
|
||||
import be.vandewalleh.routing.web.StaticRoutes
|
||||
import be.vandewalleh.routing.web.UserRoutes
|
||||
import com.soywiz.korte.TemplateProvider
|
||||
import com.soywiz.korte.Templates
|
||||
import io.ktor.features.*
|
||||
import org.kodein.di.*
|
||||
import org.kodein.di.DI
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
val mainModule = DI.Module("main") {
|
||||
bind() from singleton { NoteRepository(instance()) }
|
||||
bind() from singleton { UserRepository(instance(), instance()) }
|
||||
|
||||
bind() from singleton { configurationFactory() }
|
||||
|
||||
bind() from setBinding<ApplicationBuilder>()
|
||||
bind<ApplicationBuilder>().inSet() with singleton { ErrorHandler(instance()) }
|
||||
bind<ApplicationBuilder>().inSet() with singleton { ContentNegotiationFeature() }
|
||||
bind<ApplicationBuilder>().inSet() with singleton { CorsFeature(instance<Config>().server.cors) }
|
||||
bind<ApplicationBuilder>().inSet() with singleton { AuthenticationModule(instance(tag = "auth")) }
|
||||
bind<ApplicationBuilder>().inSet() with singleton { MigrationHook(instance()) }
|
||||
bind<ApplicationBuilder>().inSet() with singleton { ShutdownDatabaseConnection(instance()) }
|
||||
bind<ApplicationBuilder>().inSet() with singleton { SecurityReport() }
|
||||
bind<ApplicationBuilder>().inSet() with singleton { CachingHeadersFeature() }
|
||||
bind<ApplicationBuilder>().inSet() with singleton { CompressionFeature() }
|
||||
bind<ApplicationBuilder>().inSet() with singleton { CallLoggingFeature() }
|
||||
|
||||
bind() from setBinding<RoutingBuilder>()
|
||||
bind<RoutingBuilder>().inSet() with singleton { ApiDocRoutes() }
|
||||
bind<RoutingBuilder>().inSet() with singleton { ApiNoteRoutes(instance()) }
|
||||
bind<RoutingBuilder>().inSet() with singleton { ApiTagRoutes(instance()) }
|
||||
bind<RoutingBuilder>().inSet() with singleton { ApiUserRoutes(instance()) }
|
||||
|
||||
// API
|
||||
bind() from singleton { ApiNoteController(instance(), instance()) }
|
||||
bind() from singleton { ApiTagController(instance()) }
|
||||
bind() from singleton {
|
||||
ApiUserController(
|
||||
userRepository = instance(),
|
||||
authJWT = instance(tag = "auth"),
|
||||
refreshJWT = instance(tag = "refresh"),
|
||||
passwordHash = instance()
|
||||
)
|
||||
}
|
||||
|
||||
// web
|
||||
bind<RoutingBuilder>().inSet() with singleton { BaseRoutes(instance()) }
|
||||
bind<RoutingBuilder>().inSet() with singleton { UserRoutes(instance()) }
|
||||
bind<RoutingBuilder>().inSet() with singleton { NoteRoutes(instance()) }
|
||||
bind<RoutingBuilder>().inSet() with singleton { StaticRoutes() }
|
||||
|
||||
bind() from singleton { NoteController(instance(), instance(), instance()) }
|
||||
bind() from singleton { BaseController(instance()) }
|
||||
bind() from singleton {
|
||||
UserController(
|
||||
userRepository = instance(),
|
||||
authJWT = instance(tag = "auth"),
|
||||
templates = instance(),
|
||||
passwordHash = instance()
|
||||
)
|
||||
}
|
||||
|
||||
bind<TemplateProvider>() with singleton { ResourceTemplateProvider() }
|
||||
bind<Templates>() with singleton { templatesFactory(instance()) }
|
||||
|
||||
bind<SimpleJWT>(tag = "auth") with singleton { simpleJwtFactory(instance<Config>().jwt.auth) }
|
||||
bind<SimpleJWT>(tag = "refresh") with singleton { simpleJwtFactory(instance<Config>().jwt.refresh) }
|
||||
|
||||
bind() from singleton { LoggerFactory.getLogger("Application") }
|
||||
bind() from singleton { dataSourceFactory(instance<Config>().database) }
|
||||
bind() from singleton { databaseFactory(instance()) }
|
||||
bind() from singleton { Migration(instance()) }
|
||||
|
||||
bind<PasswordHash>() with singleton { BcryptPasswordHash() }
|
||||
|
||||
bind() from singleton { Markdown() }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package be.vandewalleh
|
||||
|
||||
import be.vandewalleh.extensions.ApplicationBuilder
|
||||
import be.vandewalleh.extensions.RoutingBuilder
|
||||
import io.ktor.application.Application
|
||||
import io.ktor.application.log
|
||||
import io.ktor.routing.routing
|
||||
import io.ktor.server.engine.*
|
||||
import io.ktor.server.netty.Netty
|
||||
import org.kodein.di.DI
|
||||
import org.kodein.di.description
|
||||
import org.kodein.di.instance
|
||||
import org.slf4j.Logger
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
fun main() {
|
||||
val di = DI { import(mainModule) }
|
||||
val config by di.instance<Config>()
|
||||
val logger by di.instance<Logger>()
|
||||
logger.info("Running application with configuration $config")
|
||||
logger.debug("Kodein bindings\n${di.container.tree.bindings.description()}")
|
||||
serve(di)
|
||||
}
|
||||
|
||||
fun serve(di: DI) {
|
||||
val config by di.instance<Config>()
|
||||
val logger by di.instance<Logger>()
|
||||
val env = applicationEngineEnvironment {
|
||||
module {
|
||||
module(di)
|
||||
}
|
||||
log = logger
|
||||
connector {
|
||||
host = config.server.host
|
||||
port = config.server.port
|
||||
}
|
||||
}
|
||||
with(embeddedServer(Netty, env)) {
|
||||
addShutdownHook { stop(1, 5, TimeUnit.SECONDS) }
|
||||
start(wait = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun Application.module(di: DI) {
|
||||
val builders: Set<ApplicationBuilder> by di.instance()
|
||||
|
||||
builders.forEach {
|
||||
it.builder(this)
|
||||
}
|
||||
|
||||
routing {
|
||||
trace { application.log.trace(it.buildText()) }
|
||||
}
|
||||
|
||||
val routingBuilders: Set<RoutingBuilder> by di.instance()
|
||||
routingBuilders.forEach {
|
||||
routing(it.builder)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package be.vandewalleh.auth
|
||||
|
||||
import be.vandewalleh.extensions.ApplicationBuilder
|
||||
import io.ktor.application.*
|
||||
import io.ktor.auth.*
|
||||
import io.ktor.auth.jwt.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.http.auth.*
|
||||
import io.ktor.request.*
|
||||
import io.ktor.response.*
|
||||
import io.ktor.util.pipeline.*
|
||||
|
||||
class AuthenticationModule(authJwt: SimpleJWT) : ApplicationBuilder({
|
||||
install(Authentication) {
|
||||
jwt {
|
||||
verifier(authJwt.verifier)
|
||||
authHeader { call ->
|
||||
val token = call.request.header(HttpHeaders.Authorization)
|
||||
?: call.request.cookies["Authorization"]
|
||||
token?.let {
|
||||
try {
|
||||
parseAuthorizationHeader(it)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
validate {
|
||||
UserPrincipal(
|
||||
id = it.payload.getClaim("id").asInt(),
|
||||
username = it.payload.getClaim("username").asString()
|
||||
)
|
||||
}
|
||||
challenge { scheme, realm ->
|
||||
authChallenge(scheme, realm)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private suspend fun PipelineContext<*, ApplicationCall>.authChallenge(scheme: String, realm: String) {
|
||||
if (call.request.uri.startsWith("/api"))
|
||||
call.respond(
|
||||
UnauthorizedResponse(
|
||||
HttpAuthHeader.Parameterized(
|
||||
scheme,
|
||||
mapOf(HttpAuthHeader.Parameters.Realm to realm)
|
||||
)
|
||||
)
|
||||
)
|
||||
else {
|
||||
call.respondRedirect("/login")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package be.vandewalleh.auth
|
||||
|
||||
import com.auth0.jwt.JWT
|
||||
import com.auth0.jwt.JWTVerifier
|
||||
import com.auth0.jwt.algorithms.Algorithm
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SimpleJWT(secret: String, validity: Long, unit: TimeUnit) {
|
||||
private val validityInMs = TimeUnit.MILLISECONDS.convert(validity, unit)
|
||||
private val algorithm = Algorithm.HMAC256(secret)
|
||||
|
||||
val verifier: JWTVerifier = JWT.require(algorithm).build()
|
||||
fun sign(id: Int, username: String): String = JWT.create()
|
||||
.withClaim("id", id)
|
||||
.withClaim("username", username)
|
||||
.withExpiresAt(getExpiration())
|
||||
.sign(algorithm)
|
||||
|
||||
private fun getExpiration() = Date(System.currentTimeMillis() + validityInMs)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package be.vandewalleh.auth
|
||||
|
||||
import io.ktor.auth.*
|
||||
|
||||
/**
|
||||
* Represents a simple user's principal identified by it's [id] and [username]
|
||||
* @property id
|
||||
* @property username
|
||||
*/
|
||||
data class UserPrincipal(val id: Int, val username: String) : Principal
|
||||
@@ -0,0 +1,10 @@
|
||||
package be.vandewalleh.auth
|
||||
|
||||
import io.ktor.auth.*
|
||||
|
||||
/**
|
||||
* Represents a simple user [username] and [password] credential pair
|
||||
* @property username
|
||||
* @property password
|
||||
*/
|
||||
data class UsernamePasswordCredential(val username: String, val password: String) : Credential
|
||||
@@ -0,0 +1,78 @@
|
||||
package be.vandewalleh.controllers.api
|
||||
|
||||
import be.vandewalleh.entities.Note
|
||||
import be.vandewalleh.extensions.authenticatedUser
|
||||
import be.vandewalleh.features.ValidationException
|
||||
import be.vandewalleh.markdown.Markdown
|
||||
import be.vandewalleh.markdown.MarkdownDocument
|
||||
import be.vandewalleh.repositories.NoteRepository
|
||||
import be.vandewalleh.utils.Either.Error
|
||||
import be.vandewalleh.utils.Either.Success
|
||||
import io.ktor.application.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.request.*
|
||||
import io.ktor.response.*
|
||||
import java.util.*
|
||||
|
||||
class ApiNoteController(private val noteRepository: NoteRepository, private val md: Markdown) {
|
||||
|
||||
suspend fun create(call: ApplicationCall) {
|
||||
val userId = call.authenticatedUser().id
|
||||
val txt = call.receiveText()
|
||||
|
||||
val doc: MarkdownDocument
|
||||
|
||||
when (val result = md.renderDocument(txt)) {
|
||||
is Error -> return call.respond(HttpStatusCode.BadRequest, result.error)
|
||||
is Success -> doc = result.value
|
||||
}
|
||||
|
||||
val note = Note {
|
||||
this.title = doc.meta.title
|
||||
this.tags = doc.meta.tags
|
||||
this.markdown = txt
|
||||
this.html = doc.html
|
||||
}
|
||||
|
||||
val createdNote = noteRepository.create(userId, note)
|
||||
call.respond(HttpStatusCode.Created, createdNote)
|
||||
}
|
||||
|
||||
suspend fun getAll(call: ApplicationCall) {
|
||||
val userId = call.authenticatedUser().id
|
||||
val limit = call.parameters["limit"]?.toInt() ?: 20 // FIXME validate
|
||||
val after = call.parameters["after"]?.let { UUID.fromString(it) } // FIXME validate
|
||||
val notes = noteRepository.findAll(userId, limit, after)
|
||||
call.respond(notes)
|
||||
}
|
||||
|
||||
suspend fun getOne(call: ApplicationCall) {
|
||||
val userId = call.authenticatedUser().id
|
||||
val noteUuid = call.noteUuid()
|
||||
|
||||
val response = noteRepository.find(userId, noteUuid) ?: return call.response.status(HttpStatusCode.NotFound)
|
||||
call.respond(response)
|
||||
}
|
||||
|
||||
suspend fun update(call: ApplicationCall) {
|
||||
TODO("Not implemented")
|
||||
}
|
||||
|
||||
suspend fun delete(call: ApplicationCall) {
|
||||
val userId = call.authenticatedUser().id
|
||||
val noteUuid = call.noteUuid()
|
||||
|
||||
val success = noteRepository.delete(userId, noteUuid)
|
||||
if (success) call.response.status(HttpStatusCode.NoContent)
|
||||
else call.response.status(HttpStatusCode.NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ApplicationCall.noteUuid(): UUID {
|
||||
val uuid = parameters["uuid"]
|
||||
return try {
|
||||
UUID.fromString(uuid)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
throw ValidationException("`$uuid` is not a valid UUID")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package be.vandewalleh.controllers.api
|
||||
|
||||
import be.vandewalleh.extensions.authenticatedUser
|
||||
import be.vandewalleh.repositories.NoteRepository
|
||||
import io.ktor.application.*
|
||||
import io.ktor.response.*
|
||||
|
||||
class ApiTagController(private val noteRepository: NoteRepository) {
|
||||
|
||||
suspend fun getAll(call: ApplicationCall) {
|
||||
call.respond(noteRepository.getTags(call.authenticatedUser().id))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package be.vandewalleh.controllers.api
|
||||
|
||||
import be.vandewalleh.auth.SimpleJWT
|
||||
import be.vandewalleh.auth.UsernamePasswordCredential
|
||||
import be.vandewalleh.extensions.authenticatedUser
|
||||
import be.vandewalleh.features.PasswordHash
|
||||
import be.vandewalleh.repositories.UserRepository
|
||||
import be.vandewalleh.validation.receiveValidated
|
||||
import be.vandewalleh.validation.registerValidator
|
||||
import com.auth0.jwt.exceptions.JWTVerificationException
|
||||
import io.ktor.application.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.request.*
|
||||
import io.ktor.response.*
|
||||
|
||||
class ApiUserController(
|
||||
private val authJWT: SimpleJWT,
|
||||
private val refreshJWT: SimpleJWT,
|
||||
private val userRepository: UserRepository,
|
||||
private val passwordHash: PasswordHash
|
||||
) {
|
||||
|
||||
suspend fun create(call: ApplicationCall) {
|
||||
val user = call.receiveValidated(registerValidator)
|
||||
|
||||
if (userRepository.exists(user.username))
|
||||
return call.response.status(HttpStatusCode.Conflict)
|
||||
|
||||
userRepository.create(user.username, user.password)
|
||||
?: return call.response.status(HttpStatusCode.Conflict)
|
||||
|
||||
call.response.status(HttpStatusCode.Created)
|
||||
}
|
||||
|
||||
suspend fun login(call: ApplicationCall) {
|
||||
val credential = call.receive<UsernamePasswordCredential>()
|
||||
|
||||
val user = userRepository.find(credential.username)
|
||||
?: return call.response.status(HttpStatusCode.Unauthorized)
|
||||
|
||||
if (!passwordHash.verify(credential.password, user.password)) {
|
||||
return call.response.status(HttpStatusCode.Unauthorized)
|
||||
}
|
||||
|
||||
val response = DualToken(
|
||||
token = authJWT.sign(user.id, user.username),
|
||||
refreshToken = refreshJWT.sign(user.id, user.username)
|
||||
)
|
||||
return call.respond(response)
|
||||
}
|
||||
|
||||
suspend fun refreshToken(call: ApplicationCall) {
|
||||
val token = call.receive<RefreshToken>().refreshToken
|
||||
|
||||
val id = try {
|
||||
val decodedJWT = refreshJWT.verifier.verify(token)
|
||||
decodedJWT.getClaim("id").asInt()
|
||||
} catch (e: JWTVerificationException) {
|
||||
return call.response.status(HttpStatusCode.Unauthorized)
|
||||
}
|
||||
|
||||
val user = userRepository.find(id) ?: return call.response.status(HttpStatusCode.Unauthorized)
|
||||
|
||||
val response = DualToken(
|
||||
token = authJWT.sign(user.id, user.username),
|
||||
refreshToken = refreshJWT.sign(user.id, user.username)
|
||||
)
|
||||
return call.respond(response)
|
||||
}
|
||||
|
||||
suspend fun delete(call: ApplicationCall) {
|
||||
val userId = call.authenticatedUser().id
|
||||
val success = userRepository.delete(userId)
|
||||
if (success) call.response.status(HttpStatusCode.OK)
|
||||
else call.response.status(HttpStatusCode.NotFound)
|
||||
}
|
||||
|
||||
suspend fun info(call: ApplicationCall) {
|
||||
val id = call.authenticatedUser().id
|
||||
val info = userRepository.find(id)
|
||||
if (info != null) call.respond(mapOf("user" to info))
|
||||
else call.response.status(HttpStatusCode.Unauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
private data class RefreshToken(val refreshToken: String)
|
||||
private data class DualToken(val token: String, val refreshToken: String)
|
||||
@@ -0,0 +1,13 @@
|
||||
package be.vandewalleh.controllers.web
|
||||
|
||||
import be.vandewalleh.extensions.respondKorte
|
||||
import com.soywiz.korte.Templates
|
||||
import io.ktor.application.*
|
||||
|
||||
class BaseController(private val templates: Templates) {
|
||||
|
||||
suspend fun index(call: ApplicationCall) {
|
||||
val template = templates.get("index.html")
|
||||
call.respondKorte(template)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package be.vandewalleh.controllers.web
|
||||
|
||||
import be.vandewalleh.entities.Note
|
||||
import be.vandewalleh.extensions.authenticatedUser
|
||||
import be.vandewalleh.extensions.respondKorte
|
||||
import be.vandewalleh.markdown.Markdown
|
||||
import be.vandewalleh.repositories.NoteRepository
|
||||
import be.vandewalleh.utils.Either.Error
|
||||
import be.vandewalleh.utils.Either.Success
|
||||
import com.soywiz.korte.Templates
|
||||
import io.ktor.application.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.http.ContentType.*
|
||||
import io.ktor.http.HttpStatusCode.Companion.BadRequest
|
||||
import io.ktor.http.content.*
|
||||
import io.ktor.request.*
|
||||
import io.ktor.response.*
|
||||
import java.util.*
|
||||
|
||||
class NoteController(
|
||||
private val noteRepository: NoteRepository,
|
||||
private val templates: Templates,
|
||||
private val md: Markdown
|
||||
) {
|
||||
|
||||
suspend fun renderList(call: ApplicationCall) {
|
||||
val userId = call.authenticatedUser().id
|
||||
val notes = noteRepository.findAll(userId)
|
||||
val template = templates.get("list.html")
|
||||
call.respondKorte(template, "notes" to notes)
|
||||
}
|
||||
|
||||
suspend fun renderOne(call: ApplicationCall) {
|
||||
val uuidParam = call.parameters["uuid"]
|
||||
val uuid = UUID.fromString(uuidParam)
|
||||
val note = noteRepository.find(userId = 1, noteUuid = uuid)
|
||||
val template = templates.get("_uuid.html")
|
||||
call.respondKorte(template, "note" to note)
|
||||
}
|
||||
|
||||
suspend fun new(call: ApplicationCall) {
|
||||
val template = templates.get("new.html")
|
||||
|
||||
if (call.request.httpMethod == HttpMethod.Get ||
|
||||
!call.request.contentType().withoutParameters().match(MultiPart.FormData)
|
||||
) {
|
||||
call.respondKorte(template)
|
||||
return
|
||||
}
|
||||
|
||||
val multipart = call.receiveMultipart()
|
||||
val part = multipart.readPart()
|
||||
|
||||
if (part == null || part !is PartData.FormItem || part.name != "markdown") {
|
||||
call.respondKorte(templates.get("error.html"), "error" to "null", statusCode = BadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
val textAreaValue = part.value
|
||||
|
||||
val result = md.renderDocument(textAreaValue)
|
||||
|
||||
val doc = when (result) {
|
||||
is Error -> return call.respondKorte(
|
||||
template,
|
||||
"error" to result.error.msg,
|
||||
statusCode = BadRequest
|
||||
)
|
||||
is Success -> result.value
|
||||
}
|
||||
|
||||
val note = Note {
|
||||
this.title = doc.meta.title
|
||||
this.tags = doc.meta.tags
|
||||
this.markdown = textAreaValue
|
||||
this.html = doc.html
|
||||
}
|
||||
|
||||
noteRepository.create(call.authenticatedUser().id, note)
|
||||
|
||||
call.respondRedirect("/notes")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package be.vandewalleh.controllers.web
|
||||
|
||||
import be.vandewalleh.auth.SimpleJWT
|
||||
import be.vandewalleh.entities.User
|
||||
import be.vandewalleh.extensions.respondKorte
|
||||
import be.vandewalleh.features.PasswordHash
|
||||
import be.vandewalleh.repositories.UserRepository
|
||||
import be.vandewalleh.validation.registerValidator
|
||||
import com.soywiz.korte.Templates
|
||||
import io.ktor.application.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.http.HttpStatusCode.Companion.Unauthorized
|
||||
import io.ktor.http.content.*
|
||||
import io.ktor.request.*
|
||||
import io.ktor.response.*
|
||||
|
||||
class UserController(
|
||||
private val userRepository: UserRepository,
|
||||
private val templates: Templates,
|
||||
private val authJWT: SimpleJWT,
|
||||
private val passwordHash: PasswordHash
|
||||
) {
|
||||
|
||||
suspend fun login(call: ApplicationCall) {
|
||||
val template = templates.get("login.html")
|
||||
|
||||
if (call.request.httpMethod == HttpMethod.Get) {
|
||||
call.respondKorte(template)
|
||||
return
|
||||
}
|
||||
|
||||
val parts = call.receiveMultipart().readAllParts()
|
||||
|
||||
val username = (parts.find { it.name == "username" } as PartData.FormItem).value
|
||||
val password = (parts.find { it.name == "password" } as PartData.FormItem).value
|
||||
|
||||
val user = userRepository.find(username)
|
||||
|
||||
if (user == null) {
|
||||
call.respondKorte(
|
||||
template,
|
||||
"error" to "Invalid credentials",
|
||||
statusCode = Unauthorized
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val verify = passwordHash.verify(password, user.password)
|
||||
|
||||
if (!verify) {
|
||||
call.respondKorte(
|
||||
template,
|
||||
"error" to "Invalid credentials",
|
||||
statusCode = Unauthorized
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val token = authJWT.sign(user.id, user.username)
|
||||
|
||||
call.response.cookies.append("Authorization", "Bearer $token", path = "/")
|
||||
call.respondRedirect("/notes")
|
||||
}
|
||||
|
||||
suspend fun register(call: ApplicationCall) {
|
||||
val template = templates.get("register.html")
|
||||
|
||||
if (call.request.httpMethod == HttpMethod.Get) {
|
||||
call.respondKorte(template)
|
||||
return
|
||||
}
|
||||
|
||||
val parts = call.receiveMultipart().readAllParts()
|
||||
|
||||
val username = (parts.find { it.name == "username" } as PartData.FormItem).value
|
||||
val password = (parts.find { it.name == "password" } as PartData.FormItem).value
|
||||
|
||||
val validation = registerValidator.validate(
|
||||
User {
|
||||
this.username = username
|
||||
this.password = password
|
||||
}
|
||||
)
|
||||
|
||||
if (!validation.isValid) {
|
||||
call.respondKorte(template, "error" to validation.details().map { it.defaultMessage })
|
||||
return
|
||||
}
|
||||
|
||||
if (userRepository.exists(username)) {
|
||||
call.respondKorte(template, "error" to "Please choose another username")
|
||||
return
|
||||
}
|
||||
|
||||
if (userRepository.create(username, password) == null) {
|
||||
// still need a check, for race conditions
|
||||
call.respondKorte(template, "error" to "Please choose another username")
|
||||
return
|
||||
}
|
||||
|
||||
call.respondRedirect("/login")
|
||||
}
|
||||
|
||||
suspend fun logout(call: ApplicationCall) {
|
||||
call.response.cookies.appendExpired("Authorization", path = "/")
|
||||
call.respondRedirect("/")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package be.vandewalleh.entities
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import me.liuwj.ktorm.entity.*
|
||||
import java.time.LocalDateTime
|
||||
import java.util.*
|
||||
|
||||
interface Note : Entity<Note> {
|
||||
companion object : Entity.Factory<Note>()
|
||||
|
||||
var uuid: UUID
|
||||
var title: String
|
||||
var markdown: String
|
||||
var html: String
|
||||
var updatedAt: LocalDateTime
|
||||
|
||||
@get:JsonIgnore
|
||||
var user: User
|
||||
|
||||
// Not part of the Notes table
|
||||
var tags: List<String>
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package be.vandewalleh.entities
|
||||
|
||||
import me.liuwj.ktorm.entity.*
|
||||
|
||||
interface Tag : Entity<Tag> {
|
||||
companion object : Entity.Factory<Tag>()
|
||||
|
||||
val id: Int
|
||||
var name: String
|
||||
var note: Note
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package be.vandewalleh.entities
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import me.liuwj.ktorm.entity.*
|
||||
|
||||
interface User : Entity<User> {
|
||||
companion object : Entity.Factory<User>()
|
||||
|
||||
@get:JsonIgnore
|
||||
val id: Int
|
||||
|
||||
var username: String
|
||||
|
||||
@get:JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||
var password: String
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package be.vandewalleh.extensions
|
||||
|
||||
import be.vandewalleh.auth.UserPrincipal
|
||||
import com.soywiz.korte.Template
|
||||
import io.ktor.application.*
|
||||
import io.ktor.auth.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.http.ContentType.*
|
||||
import io.ktor.request.*
|
||||
import io.ktor.response.*
|
||||
|
||||
/**
|
||||
* @return the userId for the currently authenticated user
|
||||
*/
|
||||
fun ApplicationCall.authenticatedUser() = principal<UserPrincipal>()!!
|
||||
|
||||
/**
|
||||
* @return the userId for the currently authenticated user or null
|
||||
*/
|
||||
fun ApplicationCall.authenticatedUserOrNull() = principal<UserPrincipal>()
|
||||
|
||||
suspend fun ApplicationCall.respondKorte(
|
||||
template: Template,
|
||||
vararg args: Pair<String, Any?>,
|
||||
statusCode: HttpStatusCode = HttpStatusCode.OK
|
||||
) {
|
||||
val uri = request.path().trimEnd('/')
|
||||
respondText(Text.Html.withCharset(Charsets.UTF_8), statusCode) {
|
||||
template(
|
||||
mapOf(
|
||||
*args,
|
||||
"url" to uri,
|
||||
"user" to authenticatedUserOrNull()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package be.vandewalleh.extensions
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
suspend inline fun <T> launchIo(crossinline block: () -> T): T =
|
||||
withContext(Dispatchers.IO) {
|
||||
block()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package be.vandewalleh.extensions
|
||||
|
||||
import io.ktor.application.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.routing.*
|
||||
|
||||
abstract class RoutingBuilder(val builder: Routing.() -> Unit)
|
||||
|
||||
abstract class ApplicationBuilder(val builder: Application.() -> Unit)
|
||||
|
||||
val ContentType.Text.Markdown: ContentType
|
||||
get() = ContentType("text", "markdown")
|
||||
@@ -0,0 +1,36 @@
|
||||
package be.vandewalleh.extensions
|
||||
|
||||
import be.vandewalleh.tables.Notes
|
||||
import be.vandewalleh.tables.Tags
|
||||
import be.vandewalleh.tables.Users
|
||||
import me.liuwj.ktorm.database.*
|
||||
import me.liuwj.ktorm.entity.*
|
||||
import me.liuwj.ktorm.schema.*
|
||||
import java.nio.ByteBuffer
|
||||
import java.sql.PreparedStatement
|
||||
import java.sql.ResultSet
|
||||
import java.sql.Types
|
||||
import java.util.UUID as JavaUUID
|
||||
|
||||
class UuidBinarySqlType : SqlType<JavaUUID>(Types.BINARY, typeName = "uuidBinary") {
|
||||
override fun doGetResult(rs: ResultSet, index: Int): JavaUUID? {
|
||||
val value = rs.getBytes(index) ?: return null
|
||||
return ByteBuffer.wrap(value).let { b -> JavaUUID(b.long, b.long) }
|
||||
}
|
||||
|
||||
override fun doSetParameter(ps: PreparedStatement, index: Int, parameter: JavaUUID) {
|
||||
val bytes = ByteBuffer.allocate(16)
|
||||
.putLong(parameter.mostSignificantBits)
|
||||
.putLong(parameter.leastSignificantBits)
|
||||
.array()
|
||||
ps.setBytes(index, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fun <E : Any> BaseTable<E>.uuidBinary(name: String): Column<JavaUUID> {
|
||||
return registerColumn(name, UuidBinarySqlType())
|
||||
}
|
||||
|
||||
val Database.users get() = this.sequenceOf(Users, withReferences = false)
|
||||
val Database.notes get() = this.sequenceOf(Notes, withReferences = false)
|
||||
val Database.tags get() = this.sequenceOf(Tags, withReferences = false)
|
||||
@@ -0,0 +1,7 @@
|
||||
package be.vandewalleh.factories
|
||||
|
||||
import be.vandewalleh.Config
|
||||
import com.sksamuel.hoplite.ConfigLoader
|
||||
|
||||
fun configurationFactory() =
|
||||
ConfigLoader().loadConfigOrThrow<Config>("/application.yaml")
|
||||
@@ -0,0 +1,20 @@
|
||||
package be.vandewalleh.factories
|
||||
|
||||
import be.vandewalleh.DatabaseConfig
|
||||
import com.zaxxer.hikari.HikariConfig
|
||||
import com.zaxxer.hikari.HikariDataSource
|
||||
|
||||
fun dataSourceFactory(config: DatabaseConfig): HikariDataSource {
|
||||
val host = config.host
|
||||
val port = config.port
|
||||
val name = config.name
|
||||
|
||||
val hikariConfig = HikariConfig().apply {
|
||||
jdbcUrl = "jdbc:mariadb://$host:$port/$name"
|
||||
username = config.username
|
||||
password = config.password.value
|
||||
connectionTimeout = 3000 // 3 seconds
|
||||
}
|
||||
|
||||
return HikariDataSource(hikariConfig)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package be.vandewalleh.factories
|
||||
|
||||
import me.liuwj.ktorm.database.*
|
||||
import javax.sql.DataSource
|
||||
|
||||
fun databaseFactory(dataSource: DataSource) = Database.connect(dataSource)
|
||||
@@ -0,0 +1,6 @@
|
||||
package be.vandewalleh.factories
|
||||
|
||||
import be.vandewalleh.Jwt
|
||||
import be.vandewalleh.auth.SimpleJWT
|
||||
|
||||
fun simpleJwtFactory(jwt: Jwt) = SimpleJWT(jwt.secret.value, jwt.validity, jwt.unit)
|
||||
@@ -0,0 +1,10 @@
|
||||
package be.vandewalleh.factories
|
||||
|
||||
import com.soywiz.korte.TemplateProvider
|
||||
|
||||
class ResourceTemplateProvider : TemplateProvider {
|
||||
override suspend fun get(template: String): String? {
|
||||
val resource = "/templates/$template"
|
||||
return this.javaClass.getResource(resource)?.readText()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package be.vandewalleh.factories
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.soywiz.korte.TeFunction
|
||||
import com.soywiz.korte.TemplateConfig
|
||||
import com.soywiz.korte.TemplateProvider
|
||||
import com.soywiz.korte.Templates
|
||||
|
||||
fun getResourceAsText(path: String): String {
|
||||
return object {}.javaClass.getResource(path).readText()
|
||||
}
|
||||
|
||||
fun templatesFactory(templateProvider: TemplateProvider): Templates {
|
||||
|
||||
val manifest = getResourceAsText("/css-manifest.json")
|
||||
|
||||
val json = ObjectMapper().registerModule(KotlinModule()).readValue<Map<String, String>>(manifest)
|
||||
|
||||
val fn = TeFunction("styles") { args ->
|
||||
val path = args.firstOrNull()
|
||||
json[path]?.let { "/$it" }
|
||||
}
|
||||
|
||||
val config = TemplateConfig(extraFunctions = listOf(fn))
|
||||
return Templates(templateProvider, config = config, cache = true)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package be.vandewalleh.features
|
||||
|
||||
import be.vandewalleh.extensions.ApplicationBuilder
|
||||
import io.ktor.application.*
|
||||
import io.ktor.features.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.http.content.*
|
||||
|
||||
// FIXME: replace with custom features, since immutable is not present
|
||||
class CachingHeadersFeature : ApplicationBuilder({
|
||||
install(CachingHeaders) {
|
||||
options { outgoingContent ->
|
||||
when (outgoingContent.contentType?.withoutParameters()) {
|
||||
ContentType.Text.CSS -> CachingOptions(
|
||||
CacheControl.MaxAge(
|
||||
maxAgeSeconds = 31557600
|
||||
)
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
package be.vandewalleh.features
|
||||
|
||||
import be.vandewalleh.extensions.ApplicationBuilder
|
||||
import io.ktor.application.*
|
||||
import io.ktor.features.*
|
||||
import org.slf4j.event.Level
|
||||
|
||||
class CallLoggingFeature : ApplicationBuilder({
|
||||
install(CallLogging) {
|
||||
this.level = Level.INFO
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
package be.vandewalleh.features
|
||||
|
||||
import be.vandewalleh.extensions.ApplicationBuilder
|
||||
import io.ktor.application.*
|
||||
import io.ktor.features.*
|
||||
|
||||
class CompressionFeature : ApplicationBuilder({
|
||||
install(Compression) {
|
||||
default()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
package be.vandewalleh.features
|
||||
|
||||
import be.vandewalleh.extensions.ApplicationBuilder
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.util.StdDateFormat
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||
import io.ktor.application.*
|
||||
import io.ktor.features.*
|
||||
import io.ktor.jackson.*
|
||||
import me.liuwj.ktorm.jackson.*
|
||||
|
||||
class ContentNegotiationFeature : ApplicationBuilder({
|
||||
install(ContentNegotiation) {
|
||||
jackson {
|
||||
registerModule(KtormModule())
|
||||
registerModule(JavaTimeModule())
|
||||
disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
|
||||
dateFormat = StdDateFormat()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
package be.vandewalleh.features
|
||||
|
||||
import be.vandewalleh.extensions.ApplicationBuilder
|
||||
import io.ktor.application.*
|
||||
import io.ktor.features.*
|
||||
import io.ktor.http.*
|
||||
|
||||
class CorsFeature(enabled: Boolean) : ApplicationBuilder({
|
||||
if (enabled) {
|
||||
install(CORS) {
|
||||
anyHost()
|
||||
header(HttpHeaders.ContentType)
|
||||
header(HttpHeaders.Authorization)
|
||||
methods.add(HttpMethod.Delete)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
package be.vandewalleh.features
|
||||
|
||||
import be.vandewalleh.extensions.ApplicationBuilder
|
||||
import be.vandewalleh.extensions.respondKorte
|
||||
import com.soywiz.korte.Templates
|
||||
import io.ktor.application.*
|
||||
import io.ktor.features.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.request.*
|
||||
import io.ktor.response.*
|
||||
import io.ktor.utils.io.errors.*
|
||||
import java.sql.SQLTransientConnectionException
|
||||
|
||||
class ErrorHandler(templates: Templates) : ApplicationBuilder({
|
||||
install(StatusPages) {
|
||||
|
||||
jacksonErrors()
|
||||
|
||||
status(HttpStatusCode.NotFound, HttpStatusCode.InternalServerError) {
|
||||
if (call.request.path().startsWith("/api")) return@status
|
||||
call.respondKorte(templates.get("error.html"), "status" to it.value)
|
||||
}
|
||||
|
||||
exception<IOException> {
|
||||
call.respond(HttpStatusCode.BadRequest)
|
||||
}
|
||||
exception<ValidationException> {
|
||||
call.respond(HttpStatusCode.BadRequest, ErrorResponse(it.error))
|
||||
}
|
||||
|
||||
exception<SQLTransientConnectionException> {
|
||||
val error = mapOf("error" to "It seems the server can't connect to the database")
|
||||
call.respond(HttpStatusCode.InternalServerError, error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
class ValidationException(val error: String) : RuntimeException()
|
||||
class ErrorResponse(val error: String)
|
||||
@@ -0,0 +1,66 @@
|
||||
package be.vandewalleh.features
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParseException
|
||||
import com.fasterxml.jackson.core.JsonProcessingException
|
||||
import com.fasterxml.jackson.core.exc.InputCoercionException
|
||||
import com.fasterxml.jackson.databind.JsonMappingException
|
||||
import com.fasterxml.jackson.databind.exc.MismatchedInputException
|
||||
import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException
|
||||
import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException
|
||||
import io.ktor.application.*
|
||||
import io.ktor.features.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.response.*
|
||||
|
||||
fun StatusPages.Configuration.jacksonErrors() {
|
||||
exception<MismatchedInputException> {
|
||||
val error = InvalidFormatError(it.path.firstOrNull()?.fieldName, it.targetType)
|
||||
call.respond(HttpStatusCode.BadRequest, error)
|
||||
}
|
||||
exception<JsonParseException> {
|
||||
val error = JsonParseError()
|
||||
call.respond(HttpStatusCode.BadRequest, error)
|
||||
}
|
||||
exception<UnrecognizedPropertyException> {
|
||||
val error = UnrecognizedPropertyError(it.path[0].fieldName)
|
||||
call.respond(HttpStatusCode.BadRequest, error)
|
||||
}
|
||||
exception<MissingKotlinParameterException> {
|
||||
val error = MissingKotlinParameterError(it.path[0].fieldName)
|
||||
call.respond(HttpStatusCode.BadRequest, error)
|
||||
}
|
||||
exception<JsonProcessingException> {
|
||||
call.respond(HttpStatusCode.BadRequest, JsonProcessingError())
|
||||
}
|
||||
exception<JsonMappingException> {
|
||||
if (it.cause is InputCoercionException) {
|
||||
return@exception call.respond(HttpStatusCode.BadRequest, OutOfRangeError(it.path[0].fieldName))
|
||||
}
|
||||
call.respond(HttpStatusCode.BadRequest, JsonProcessingError())
|
||||
}
|
||||
}
|
||||
|
||||
class InvalidFormatError(val value: Any?, targetType: Class<*>) {
|
||||
val msg = "Wrong type"
|
||||
val required = targetType.simpleName
|
||||
}
|
||||
|
||||
class UnrecognizedPropertyError(val field: Any?) {
|
||||
val msg = "Unrecognized field"
|
||||
}
|
||||
|
||||
class MissingKotlinParameterError(val field: String) {
|
||||
val msg = "Missing field"
|
||||
}
|
||||
|
||||
class JsonProcessingError {
|
||||
val msg = "An error occurred while processing JSON"
|
||||
}
|
||||
|
||||
class JsonParseError {
|
||||
val msg = "Invalid JSON"
|
||||
}
|
||||
|
||||
class OutOfRangeError(val field: String) {
|
||||
val msg = "Numeric value out of range"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package be.vandewalleh.features
|
||||
|
||||
import be.vandewalleh.extensions.ApplicationBuilder
|
||||
import io.ktor.application.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.flywaydb.core.Flyway
|
||||
import javax.sql.DataSource
|
||||
|
||||
class MigrationHook(migration: Migration) : ApplicationBuilder({
|
||||
environment.monitor.subscribe(ApplicationStarted) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
migration.migrate()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
class Migration(private val dataSource: DataSource) {
|
||||
fun migrate() {
|
||||
Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.baselineOnMigrate(true)
|
||||
.load()
|
||||
.migrate()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package be.vandewalleh.features
|
||||
|
||||
import org.mindrot.jbcrypt.BCrypt
|
||||
|
||||
interface PasswordHash {
|
||||
fun crypt(password: String): String
|
||||
fun verify(password: String, hashedPassword: String): Boolean
|
||||
}
|
||||
|
||||
class BcryptPasswordHash : PasswordHash {
|
||||
override fun crypt(password: String) = BCrypt.hashpw(password, BCrypt.gensalt())!!
|
||||
override fun verify(password: String, hashedPassword: String) = BCrypt.checkpw(password, hashedPassword)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package be.vandewalleh.features
|
||||
|
||||
import be.vandewalleh.extensions.ApplicationBuilder
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import io.ktor.application.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.http.content.*
|
||||
import io.ktor.request.*
|
||||
import io.ktor.response.*
|
||||
import io.ktor.routing.*
|
||||
import io.ktor.util.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import kotlin.collections.HashMap
|
||||
|
||||
class SecurityReport : ApplicationBuilder({
|
||||
|
||||
val logger = LoggerFactory.getLogger(SecurityReport::class.java)
|
||||
|
||||
routing {
|
||||
post("/csp-reports") {
|
||||
val txt = call.receiveText()
|
||||
val json = ObjectMapper().registerModule(KotlinModule()).readValue<JsonNode>(txt)
|
||||
logger.info(json.toPrettyString())
|
||||
call.response.status(HttpStatusCode.OK)
|
||||
}
|
||||
}
|
||||
|
||||
install(SecurityHeaders) {
|
||||
reportOnly = false
|
||||
reportUri = "/csp-reports"
|
||||
cspValue = "default-src 'self'"
|
||||
}
|
||||
})
|
||||
|
||||
class SecurityHeaders(configuration: Configuration) {
|
||||
private val logger = LoggerFactory.getLogger(key.name)
|
||||
private val headers = buildHeaders(configuration)
|
||||
|
||||
class Configuration {
|
||||
var reportOnly = true
|
||||
var reportUri = "/csp-reports"
|
||||
|
||||
var csp = true
|
||||
var cspValue = "default-src 'self'"
|
||||
|
||||
var noSniff = true
|
||||
var referrerPolicy = true
|
||||
var xssFilter = true
|
||||
var frameguard = true
|
||||
var featurePolicy = false
|
||||
}
|
||||
|
||||
companion object Feature :
|
||||
ApplicationFeature<ApplicationCallPipeline, SecurityHeaders.Configuration, SecurityHeaders> {
|
||||
override val key = AttributeKey<SecurityHeaders>("SecurityHeaders")
|
||||
|
||||
override fun install(pipeline: ApplicationCallPipeline, configure: Configuration.() -> Unit): SecurityHeaders {
|
||||
|
||||
val configuration = SecurityHeaders.Configuration().apply(configure)
|
||||
|
||||
val feature = SecurityHeaders(configuration)
|
||||
|
||||
pipeline.sendPipeline.intercept(ApplicationSendPipeline.After) { subject ->
|
||||
if (subject !is OutgoingContent) return@intercept
|
||||
val contentType = subject.contentType ?: return@intercept
|
||||
if (!ContentType.Text.Html.match(contentType.withoutParameters())) return@intercept
|
||||
if (call.request.path().startsWith("/api")) return@intercept
|
||||
feature.process(call, contentType)
|
||||
}
|
||||
|
||||
return feature
|
||||
}
|
||||
}
|
||||
|
||||
fun process(call: ApplicationCall, contentType: ContentType) {
|
||||
headers.forEach { (name, value) ->
|
||||
call.response.headers.append(name, value)
|
||||
}
|
||||
|
||||
logger.debug("Added security headers")
|
||||
}
|
||||
|
||||
private fun buildHeaders(cfg: Configuration): HashMap<String, String> {
|
||||
val headers = HashMap<String, String>()
|
||||
|
||||
if (cfg.noSniff) headers["X-Content-Type-Options"] = "nosniff"
|
||||
if (cfg.referrerPolicy) headers["Referrer-Policy"] = "no-referrer-when-downgrade"
|
||||
if (cfg.xssFilter) headers["X-XSS-Protection"] = "1; mode=block"
|
||||
if (cfg.frameguard) headers["X-Frame-Options"] = "DENY"
|
||||
|
||||
if (cfg.csp) {
|
||||
val cspValue = if (cfg.reportOnly) cfg.cspValue + "; report-uri ${cfg.reportUri}" else cfg.cspValue
|
||||
val cspKey = if (cfg.reportOnly) "Content-Security-Policy-Report-Only" else "Content-Security-Policy"
|
||||
headers[cspKey] = cspValue
|
||||
}
|
||||
|
||||
if (cfg.featurePolicy) {
|
||||
|
||||
// really ?? https://github.com/w3c/webappsec-feature-policy/issues/189
|
||||
headers["Feature-Policy"] =
|
||||
"accelerometer 'none'; ambient-light-sensor 'none'; autoplay 'none'; battery 'none'; camera 'none'; display-capture 'none'; document-domain 'none'; encrypted-media 'none'; fullscreen 'none'; geolocation 'none'; gyroscope 'none'; legacy-image-formats 'none'; magnetometer 'none'; microphone 'none'; midi 'none'; oversized-images 'none'; payment 'none'; picture-in-picture 'none'; publickey-credentials-get 'none'; sync-xhr 'none'; unoptimized-images 'none'; unsized-media 'none'; usb 'none'; wake-lock 'none'; xr-spatial-tracking 'none'"
|
||||
}
|
||||
|
||||
// TODO: Strict-Transport-Security: "max-age=31536000; includeSubDomains"
|
||||
|
||||
return headers
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package be.vandewalleh.features
|
||||
|
||||
import be.vandewalleh.extensions.ApplicationBuilder
|
||||
import com.zaxxer.hikari.HikariDataSource
|
||||
import io.ktor.application.*
|
||||
|
||||
class ShutdownDatabaseConnection(hikariDataSource: HikariDataSource) : ApplicationBuilder({
|
||||
environment.monitor.subscribe(ApplicationStopPreparing) {
|
||||
if (!hikariDataSource.isClosed) {
|
||||
hikariDataSource.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
package be.vandewalleh.markdown
|
||||
|
||||
import am.ik.yavi.builder.ValidatorBuilder
|
||||
import am.ik.yavi.builder.konstraint
|
||||
import am.ik.yavi.core.Validator
|
||||
import be.vandewalleh.utils.Either
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
|
||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.sksamuel.hoplite.fp.valid
|
||||
import com.vladsch.flexmark.html.HtmlRenderer
|
||||
import com.vladsch.flexmark.parser.Parser
|
||||
import org.owasp.html.HtmlPolicyBuilder
|
||||
import java.util.regex.Pattern
|
||||
|
||||
sealed class MarkdownParsingError(val msg: String) {
|
||||
object MissingMeta : MarkdownParsingError("No metadata found")
|
||||
object InvalidMeta : MarkdownParsingError("Invalid metadata")
|
||||
class ValidationError(msg: String) : MarkdownParsingError(msg)
|
||||
}
|
||||
|
||||
private val metaValidator: Validator<Meta> = ValidatorBuilder.of<Meta>()
|
||||
.konstraint(Meta::title) {
|
||||
notNull().notBlank().lessThanOrEqual(50)
|
||||
}
|
||||
.konstraint(Meta::tags) {
|
||||
lessThanOrEqual(5)
|
||||
}
|
||||
.build()
|
||||
|
||||
class Markdown {
|
||||
|
||||
private val yamlBoundPattern = Pattern.compile("^-{3}(\\s.*)?")
|
||||
|
||||
private val parser = Parser.builder().build()
|
||||
private val renderer = HtmlRenderer.builder().build()
|
||||
|
||||
private val htmlPolicy = HtmlPolicyBuilder()
|
||||
.allowElements("a")
|
||||
.allowCommonBlockElements()
|
||||
.allowCommonInlineFormattingElements()
|
||||
.allowElements("pre")
|
||||
.allowAttributes("class").onElements("code")
|
||||
.allowUrlProtocols("https")
|
||||
.allowAttributes("href").onElements("a")
|
||||
.requireRelNofollowOnLinks()
|
||||
.toFactory()
|
||||
|
||||
fun convertToMarkdown(input: String): String {
|
||||
val document = parser.parse(input)
|
||||
return renderer.render(document)
|
||||
}
|
||||
|
||||
fun renderDocument(input: String): Either<MarkdownParsingError, MarkdownDocument> {
|
||||
val lines = input.lines()
|
||||
|
||||
var startIndex: Int? = null
|
||||
var endIndex: Int? = null
|
||||
|
||||
for ((i, line) in lines.withIndex()) {
|
||||
val isYamlBound = yamlBoundPattern.matcher(line).matches()
|
||||
if (isYamlBound) {
|
||||
if (startIndex == null) {
|
||||
startIndex = i
|
||||
} else {
|
||||
endIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (endIndex == null) return Either.Error(MarkdownParsingError.MissingMeta)
|
||||
|
||||
lateinit var yaml: String
|
||||
lateinit var doc: String
|
||||
|
||||
if (startIndex != null) {
|
||||
val startMeta = startIndex + 1
|
||||
val endMeta = endIndex - 1
|
||||
yaml = lines.slice(startMeta..endMeta)
|
||||
.joinToString("\n")
|
||||
}
|
||||
|
||||
val startMd = endIndex + 1
|
||||
val endMd = lines.size - 1
|
||||
doc = lines.slice(startMd..endMd)
|
||||
.joinToString("\n")
|
||||
|
||||
val meta = try {
|
||||
parseMeta(yaml)
|
||||
} catch (e: Exception) {
|
||||
return Either.Error(MarkdownParsingError.InvalidMeta)
|
||||
}
|
||||
|
||||
val metaValidation = metaValidator.validate(meta)
|
||||
|
||||
metaValidation.valid()
|
||||
|
||||
if (!metaValidation.isValid) {
|
||||
val msg = metaValidation.details().firstOrNull()?.defaultMessage ?: "Invalid" // FIXME
|
||||
return Either.Error(MarkdownParsingError.ValidationError(msg))
|
||||
}
|
||||
|
||||
val unsafeHtml = convertToMarkdown(doc)
|
||||
val safeHTML = htmlPolicy.sanitize(unsafeHtml)
|
||||
|
||||
return Either.Success(MarkdownDocument(meta, safeHTML))
|
||||
}
|
||||
|
||||
private val objectMapper: ObjectMapper = ObjectMapper(YAMLFactory()).apply { registerModule(KotlinModule()) }
|
||||
|
||||
fun parseMeta(input: String): Meta = objectMapper.readValue(input)
|
||||
}
|
||||
|
||||
data class MarkdownDocument(val meta: Meta, val html: String)
|
||||
data class Meta(val title: String, val tags: List<String> = emptyList())
|
||||
@@ -0,0 +1,145 @@
|
||||
package be.vandewalleh.repositories
|
||||
|
||||
import be.vandewalleh.entities.Note
|
||||
import be.vandewalleh.extensions.launchIo
|
||||
import be.vandewalleh.extensions.notes
|
||||
import be.vandewalleh.extensions.tags
|
||||
import be.vandewalleh.tables.Notes
|
||||
import be.vandewalleh.tables.Tags
|
||||
import me.liuwj.ktorm.database.*
|
||||
import me.liuwj.ktorm.dsl.*
|
||||
import me.liuwj.ktorm.entity.*
|
||||
import java.time.LocalDateTime
|
||||
import java.util.*
|
||||
import kotlin.collections.HashMap
|
||||
|
||||
/**
|
||||
* service to handle database queries at the Notes level.
|
||||
*/
|
||||
class NoteRepository(private val db: Database) {
|
||||
|
||||
/**
|
||||
* returns a list of [Note] associated with the userId
|
||||
*/
|
||||
suspend fun findAll(userId: Int, limit: Int = 20, after: UUID? = null): List<Note> = launchIo {
|
||||
|
||||
var previous: LocalDateTime? = null
|
||||
|
||||
if (after != null) {
|
||||
previous = db.notes
|
||||
.filter { it.userId eq userId and (it.uuid eq after) }
|
||||
.mapColumns { it.updatedAt }
|
||||
.firstOrNull() ?: return@launchIo emptyList()
|
||||
}
|
||||
|
||||
val notes = db.notes
|
||||
.filterColumns { listOf(it.uuid, it.title, it.updatedAt) }
|
||||
.filter {
|
||||
if (previous == null) it.userId eq userId
|
||||
else (it.userId eq userId) and (it.updatedAt less previous)
|
||||
}
|
||||
.sortedByDescending { it.updatedAt }
|
||||
.take(limit)
|
||||
.toList()
|
||||
|
||||
if (notes.isEmpty()) return@launchIo emptyList()
|
||||
|
||||
val tagsByUuid =
|
||||
db.tags
|
||||
.filterColumns { listOf(it.noteUuid, it.name) }
|
||||
.filter { it.noteUuid inList notes.map { note -> note.uuid } }
|
||||
.groupByTo(HashMap(), { it.note.uuid }, { it.name })
|
||||
|
||||
notes.forEach {
|
||||
val tags = tagsByUuid[it.uuid]
|
||||
if (tags != null) it.tags = tags
|
||||
}
|
||||
|
||||
notes
|
||||
}
|
||||
|
||||
suspend fun exists(userId: Int, uuid: UUID) = launchIo {
|
||||
db.notes.any { (it.userId eq userId) and (it.uuid eq uuid) }
|
||||
}
|
||||
|
||||
suspend fun create(userId: Int, note: Note): Note = launchIo {
|
||||
val uuid = UUID.randomUUID()
|
||||
val newNote = note.copy().apply {
|
||||
this["uuid"] = uuid
|
||||
this.user["id"] = userId
|
||||
this.updatedAt = LocalDateTime.now()
|
||||
}
|
||||
db.useTransaction {
|
||||
db.notes.add(newNote)
|
||||
db.batchInsert(Tags) {
|
||||
note.tags.forEach { tagName ->
|
||||
item {
|
||||
it.noteUuid to uuid
|
||||
it.name to tagName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
newNote
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
suspend fun find(userId: Int, noteUuid: UUID): Note? = launchIo {
|
||||
val note =
|
||||
db.notes
|
||||
.filterColumns { it.columns - it.userId }
|
||||
.filter { it.uuid eq noteUuid }
|
||||
.find { it.userId eq userId }
|
||||
?: return@launchIo null
|
||||
|
||||
val tags =
|
||||
db.sequenceOf(Tags, withReferences = false)
|
||||
.filter { it.noteUuid eq noteUuid }
|
||||
.mapColumns { it.name } as List<String>
|
||||
|
||||
note.also { it.tags = tags }
|
||||
}
|
||||
|
||||
suspend fun updateNote(userId: Int, note: Note): Boolean = launchIo {
|
||||
if (note["uuid"] == null) error("UUID is required")
|
||||
|
||||
db.useTransaction {
|
||||
val currentNote = db.notes
|
||||
.find { it.uuid eq note.uuid and (it.userId eq userId) }
|
||||
?: return@launchIo false
|
||||
|
||||
currentNote.title = note.title
|
||||
currentNote.markdown = note.markdown
|
||||
currentNote.html = note.html
|
||||
currentNote.updatedAt = LocalDateTime.now()
|
||||
currentNote.flushChanges()
|
||||
|
||||
// delete all tags
|
||||
db.delete(Tags) {
|
||||
it.noteUuid eq note.uuid
|
||||
}
|
||||
|
||||
// put new ones
|
||||
note.tags.forEach { tagName ->
|
||||
db.insert(Tags) {
|
||||
it.name to tagName
|
||||
it.noteUuid to note.uuid
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
suspend fun delete(userId: Int, noteUuid: UUID): Boolean = launchIo {
|
||||
db.useTransaction {
|
||||
db.delete(Notes) { it.uuid eq noteUuid and (it.userId eq userId) } == 1
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
suspend fun getTags(userId: Int): List<String> = launchIo {
|
||||
db.sequenceOf(Tags)
|
||||
.filter { it.note.userId eq userId }
|
||||
.mapColumns(isDistinct = true) { it.name } as List<String>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package be.vandewalleh.repositories
|
||||
|
||||
import be.vandewalleh.entities.User
|
||||
import be.vandewalleh.extensions.launchIo
|
||||
import be.vandewalleh.extensions.users
|
||||
import be.vandewalleh.features.PasswordHash
|
||||
import be.vandewalleh.tables.Users
|
||||
import me.liuwj.ktorm.database.*
|
||||
import me.liuwj.ktorm.dsl.*
|
||||
import me.liuwj.ktorm.entity.*
|
||||
import java.sql.SQLIntegrityConstraintViolationException
|
||||
|
||||
/**
|
||||
* service to handle database queries for users.
|
||||
*/
|
||||
class UserRepository(private val db: Database, private val passwordHash: PasswordHash) {
|
||||
|
||||
/**
|
||||
* returns a user from it's username if found or null
|
||||
*/
|
||||
suspend fun find(username: String): User? = launchIo {
|
||||
db.users.find { it.username eq username }
|
||||
}
|
||||
|
||||
suspend fun find(id: Int): User? = launchIo {
|
||||
db.users.find { it.id eq id }
|
||||
}
|
||||
|
||||
suspend fun exists(username: String) = launchIo {
|
||||
db.users.any { it.username eq username }
|
||||
}
|
||||
|
||||
suspend fun exists(userId: Int) = launchIo {
|
||||
db.users.any { it.id eq userId }
|
||||
}
|
||||
|
||||
suspend fun create(username: String, password: String): User? {
|
||||
val newUser = User {
|
||||
this.username = username
|
||||
this.password = passwordHash.crypt(password)
|
||||
}
|
||||
|
||||
return try {
|
||||
launchIo {
|
||||
db.useTransaction {
|
||||
db.users.add(newUser)
|
||||
newUser
|
||||
}
|
||||
}
|
||||
} catch (e: SQLIntegrityConstraintViolationException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete(userId: Int): Boolean = launchIo {
|
||||
val updateCount = db.useTransaction {
|
||||
db.delete(Users) { it.id eq userId }
|
||||
}
|
||||
when (updateCount) {
|
||||
1 -> true
|
||||
0 -> false
|
||||
else -> error("??")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package be.vandewalleh.routing.api
|
||||
|
||||
import be.vandewalleh.extensions.RoutingBuilder
|
||||
import io.ktor.http.content.*
|
||||
|
||||
class ApiDocRoutes : RoutingBuilder({
|
||||
static("/api/docs") {
|
||||
defaultResource("docs/index.html")
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
package be.vandewalleh.routing.api
|
||||
|
||||
import be.vandewalleh.controllers.api.ApiNoteController
|
||||
import be.vandewalleh.extensions.Markdown
|
||||
import be.vandewalleh.extensions.RoutingBuilder
|
||||
import io.ktor.application.*
|
||||
import io.ktor.auth.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.routing.*
|
||||
|
||||
class ApiNoteRoutes(ctrl: ApiNoteController) : RoutingBuilder({
|
||||
|
||||
route("/api/notes") {
|
||||
|
||||
accept(ContentType.Application.Json) {
|
||||
|
||||
authenticate {
|
||||
|
||||
contentType(ContentType.Text.Markdown) {
|
||||
post { ctrl.create(call) }
|
||||
}
|
||||
|
||||
get { ctrl.getAll(call) }
|
||||
|
||||
route("/{uuid}") {
|
||||
get { ctrl.getOne(call) }
|
||||
|
||||
contentType(ContentType.Text.Markdown) {
|
||||
put { ctrl.update(call) }
|
||||
}
|
||||
|
||||
delete { ctrl.delete(call) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
package be.vandewalleh.routing.api
|
||||
|
||||
import be.vandewalleh.controllers.api.ApiTagController
|
||||
import be.vandewalleh.extensions.RoutingBuilder
|
||||
import io.ktor.application.*
|
||||
import io.ktor.auth.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.routing.*
|
||||
|
||||
class ApiTagRoutes(ctrl: ApiTagController) : RoutingBuilder({
|
||||
route("/api/tags") {
|
||||
accept(ContentType.Application.Json) {
|
||||
authenticate {
|
||||
get { ctrl.getAll(call) }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
package be.vandewalleh.routing.api
|
||||
|
||||
import be.vandewalleh.controllers.api.ApiUserController
|
||||
import be.vandewalleh.extensions.RoutingBuilder
|
||||
import io.ktor.application.*
|
||||
import io.ktor.auth.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.routing.*
|
||||
|
||||
class ApiUserRoutes(ctrl: ApiUserController) : RoutingBuilder({
|
||||
route("/api/user") {
|
||||
accept(ContentType.Application.Json) {
|
||||
|
||||
contentType(ContentType.Application.Json) {
|
||||
post { ctrl.create(call) }
|
||||
post("/login") { ctrl.login(call) }
|
||||
post("/refresh_token") { ctrl.refreshToken(call) }
|
||||
}
|
||||
|
||||
authenticate {
|
||||
delete { ctrl.delete(call) }
|
||||
get("/me") { ctrl.info(call) }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
package be.vandewalleh.routing.web
|
||||
|
||||
import be.vandewalleh.controllers.web.BaseController
|
||||
import be.vandewalleh.extensions.RoutingBuilder
|
||||
import io.ktor.application.*
|
||||
import io.ktor.auth.*
|
||||
import io.ktor.routing.*
|
||||
|
||||
class BaseRoutes(ctrl: BaseController) : RoutingBuilder({
|
||||
authenticate(optional = true) {
|
||||
get("/") { ctrl.index(call) }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
package be.vandewalleh.routing.web
|
||||
|
||||
import be.vandewalleh.controllers.web.NoteController
|
||||
import be.vandewalleh.extensions.RoutingBuilder
|
||||
import io.ktor.application.*
|
||||
import io.ktor.auth.*
|
||||
import io.ktor.routing.*
|
||||
|
||||
class NoteRoutes(ctrl: NoteController) : RoutingBuilder({
|
||||
authenticate {
|
||||
route("/notes") {
|
||||
get { ctrl.renderList(call) }
|
||||
get("/{uuid}") { ctrl.renderOne(call) }
|
||||
route("/new") {
|
||||
get { ctrl.new(call) }
|
||||
post { ctrl.new(call) }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
package be.vandewalleh.routing.web
|
||||
|
||||
import be.vandewalleh.extensions.RoutingBuilder
|
||||
import io.ktor.http.content.*
|
||||
|
||||
class StaticRoutes : RoutingBuilder({
|
||||
static {
|
||||
resources("static")
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
package be.vandewalleh.routing.web
|
||||
|
||||
import be.vandewalleh.controllers.web.UserController
|
||||
import be.vandewalleh.extensions.RoutingBuilder
|
||||
import io.ktor.application.*
|
||||
import io.ktor.auth.*
|
||||
import io.ktor.routing.*
|
||||
|
||||
class UserRoutes(ctrl: UserController) : RoutingBuilder({
|
||||
|
||||
authenticate(optional = true) {
|
||||
|
||||
route("/login") {
|
||||
get { ctrl.login(call) }
|
||||
post { ctrl.login(call) }
|
||||
}
|
||||
|
||||
route("/register") {
|
||||
get { ctrl.register(call) }
|
||||
post { ctrl.register(call) }
|
||||
}
|
||||
}
|
||||
|
||||
authenticate {
|
||||
post("/logout") { ctrl.logout(call) }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
package be.vandewalleh.tables
|
||||
|
||||
import be.vandewalleh.entities.Note
|
||||
import be.vandewalleh.extensions.uuidBinary
|
||||
import me.liuwj.ktorm.schema.*
|
||||
|
||||
open class Notes(alias: String?) : Table<Note>("Notes", alias) {
|
||||
companion object : Notes(null)
|
||||
|
||||
override fun aliased(alias: String) = Notes(alias)
|
||||
|
||||
val uuid = uuidBinary("uuid").primaryKey().bindTo { it.uuid }
|
||||
val title = varchar("title").bindTo { it.title }
|
||||
val markdown = text("markdown").bindTo { it.markdown }
|
||||
val html = text("html").bindTo { it.html }
|
||||
val userId = int("user_id").references(Users) { it.user }
|
||||
val updatedAt = datetime("updated_at").bindTo { it.updatedAt }
|
||||
val user get() = userId.referenceTable as Users
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package be.vandewalleh.tables
|
||||
|
||||
import be.vandewalleh.entities.Tag
|
||||
import be.vandewalleh.extensions.uuidBinary
|
||||
import me.liuwj.ktorm.schema.*
|
||||
|
||||
open class Tags(alias: String?) : Table<Tag>("Tags", alias) {
|
||||
companion object : Tags(null)
|
||||
|
||||
override fun aliased(alias: String) = Tags(alias)
|
||||
|
||||
val id = int("id").primaryKey().bindTo { it.id }
|
||||
val name = varchar("name").bindTo { it.name }
|
||||
val noteUuid = uuidBinary("note_uuid").references(Notes) { it.note }
|
||||
val note get() = noteUuid.referenceTable as Notes
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package be.vandewalleh.tables
|
||||
|
||||
import be.vandewalleh.entities.User
|
||||
import me.liuwj.ktorm.schema.*
|
||||
|
||||
open class Users(alias: String?) : Table<User>("Users", alias) {
|
||||
companion object : Users(null)
|
||||
|
||||
override fun aliased(alias: String) = Users(alias)
|
||||
|
||||
val id = int("id").primaryKey().bindTo { it.id }
|
||||
val username = varchar("username").bindTo { it.username }
|
||||
val password = varchar("password").bindTo { it.password }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package be.vandewalleh.utils
|
||||
|
||||
sealed class Either<A, B> {
|
||||
class Error<A, B>(val error: A) : Either<A, B>()
|
||||
class Success<A, B>(val value: B) : Either<A, B>()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package be.vandewalleh.validation
|
||||
|
||||
import am.ik.yavi.builder.ValidatorBuilder
|
||||
import am.ik.yavi.builder.konstraint
|
||||
import am.ik.yavi.core.Validator
|
||||
import be.vandewalleh.entities.Note
|
||||
|
||||
val noteValidator: Validator<Note> = ValidatorBuilder.of<Note>()
|
||||
.konstraint(Note::title) {
|
||||
notNull().notBlank().lessThanOrEqual(50)
|
||||
}
|
||||
.konstraint(Note::tags) {
|
||||
lessThanOrEqual(10)
|
||||
}
|
||||
.konstraint(Note::markdown) {
|
||||
notNull().notBlank()
|
||||
}
|
||||
.build()
|
||||
@@ -0,0 +1,15 @@
|
||||
package be.vandewalleh.validation
|
||||
|
||||
import am.ik.yavi.builder.ValidatorBuilder
|
||||
import am.ik.yavi.builder.konstraint
|
||||
import am.ik.yavi.core.Validator
|
||||
import be.vandewalleh.entities.User
|
||||
|
||||
val registerValidator: Validator<User> = ValidatorBuilder.of<User>()
|
||||
.konstraint(User::username) {
|
||||
notNull().lessThanOrEqual(50).greaterThanOrEqual(3)
|
||||
}
|
||||
.konstraint(User::password) {
|
||||
notNull().greaterThanOrEqual(6)
|
||||
}
|
||||
.build()
|
||||
@@ -0,0 +1,12 @@
|
||||
package be.vandewalleh.validation
|
||||
|
||||
import am.ik.yavi.core.Validator
|
||||
import be.vandewalleh.features.ValidationException
|
||||
import io.ktor.application.*
|
||||
import io.ktor.request.*
|
||||
|
||||
suspend inline fun <reified T : Any> ApplicationCall.receiveValidated(validator: Validator<T>): T {
|
||||
val value: T = receive()
|
||||
validator.validate(value).throwIfInvalid { ValidationException(it.details()[0].defaultMessage) }
|
||||
return value
|
||||
}
|
||||
Reference in New Issue
Block a user