Remove nuxt + 100 other things..

This commit is contained in:
2020-07-08 19:46:04 +02:00
parent 3b80ae051d
commit 44b463d9d5
132 changed files with 6202 additions and 10961 deletions
+21
View File
@@ -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)
+100
View File
@@ -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() }
}
+59
View File
@@ -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)
}
}
+54
View File
@@ -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")
}
}
+21
View File
@@ -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)
}
+10
View File
@@ -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
+10
View File
@@ -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
+78
View File
@@ -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")
}
}
+13
View File
@@ -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))
}
}
+87
View File
@@ -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)
+13
View File
@@ -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)
}
}
+83
View File
@@ -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")
}
}
+108
View File
@@ -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("/")
}
}
+22
View File
@@ -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>
}
+11
View File
@@ -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
}
+17
View File
@@ -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()
)
)
}
}
+9
View File
@@ -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()
}
+12
View File
@@ -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")
+36
View File
@@ -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)
+7
View File
@@ -0,0 +1,7 @@
package be.vandewalleh.factories
import be.vandewalleh.Config
import com.sksamuel.hoplite.ConfigLoader
fun configurationFactory() =
ConfigLoader().loadConfigOrThrow<Config>("/application.yaml")
+20
View File
@@ -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)
}
+6
View File
@@ -0,0 +1,6 @@
package be.vandewalleh.factories
import me.liuwj.ktorm.database.*
import javax.sql.DataSource
fun databaseFactory(dataSource: DataSource) = Database.connect(dataSource)
+6
View File
@@ -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)
+10
View File
@@ -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()
}
}
+28
View File
@@ -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)
}
+23
View File
@@ -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
}
}
}
})
+12
View File
@@ -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
}
})
+11
View File
@@ -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()
}
})
+21
View File
@@ -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()
}
}
})
+17
View File
@@ -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)
}
}
})
+39
View File
@@ -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)
+66
View File
@@ -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"
}
+27
View File
@@ -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()
}
}
+13
View File
@@ -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)
}
+111
View File
@@ -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()
}
}
})
+117
View File
@@ -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())
+145
View File
@@ -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>
}
}
+65
View File
@@ -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("??")
}
}
}
+10
View File
@@ -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")
}
})
+37
View File
@@ -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) }
}
}
}
}
})
+18
View File
@@ -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) }
}
}
}
})
+26
View File
@@ -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) }
}
}
}
})
+13
View File
@@ -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) }
}
})
+20
View File
@@ -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) }
}
}
}
})
+10
View File
@@ -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")
}
})
+27
View File
@@ -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) }
}
})
+19
View File
@@ -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
}
+16
View File
@@ -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
}
+14
View File
@@ -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 }
}
+6
View File
@@ -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>()
}
+18
View File
@@ -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()
+15
View File
@@ -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()
+12
View File
@@ -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
}