12 Commits

Author SHA1 Message Date
hubert 195c7c10ac Import feature 2023-05-09 22:46:07 +02:00
hubert 724fa0483e Tailwind 3 2023-05-09 22:40:39 +02:00
hubert f2bdc8d6c7 Update kotlin libs 2023-05-09 22:40:10 +02:00
hubert 5aa2e80c5f JDK 19 + Gradle 8.1.1 + kotlin 1.8.21 + spotless 2023-05-09 22:39:34 +02:00
hubert 235e8b6e3c Use gradle catalogs 2021-05-06 12:22:16 +02:00
hubert 1e0fe12396 Upgrade kotlin, kotlinx-html, gradle, shadow 2021-05-06 12:22:16 +02:00
hubert 7ad8b7039b Update config 2021-04-12 21:15:42 +02:00
hubert 204ae7988e Update ktorm, clean repositories and drop mariadb support 2021-04-12 18:35:21 +02:00
hubert a4bf998c5b Remove Boilerplate Use-case thingy 2021-03-03 16:57:53 +01:00
hubert 3e1683dfe5 Use a proper search input parser 2021-03-03 14:28:34 +01:00
hubert 51b682c593 Skip slow tests by default 2021-03-03 14:28:34 +01:00
hubert ea110d51d3 Simplify configuration 2021-02-27 20:34:44 +01:00
144 changed files with 2252 additions and 3475 deletions
+4 -3
View File
@@ -9,9 +9,10 @@ charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
[*.{kt, kts}] [*.{kt,kts}]
indent_size = 4 indent_size = 4
insert_final_newline = true insert_final_newline = true
max_line_length = 120 max_line_length = 120
disabled_rules = no-wildcard-imports ktlint_standard_no-wildcard-imports = disabled
kotlin_imports_layout = idea ktlint_standard_import-ordering = disabled
ktlint_standard_multiline-if-else = disabled
-5
View File
@@ -1,6 +1 @@
# mariadb
MYSQL_ROOT_PASSWORD=
MYSQL_PASSWORD=
# simplenotes
DB_PASSWORD=
JWT_SECRET= JWT_SECRET=
+7 -8
View File
@@ -1,8 +1,8 @@
FROM openjdk:15-alpine as jdkbuilder FROM eclipse-temurin:19-alpine as jdkbuilder
RUN apk add --no-cache binutils RUN apk add --no-cache binutils
ENV MODULES java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.scripting,java.security.jgss,java.sql,java.sql.rowset,java.transaction.xa,java.xml,jdk.net ENV MODULES java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.scripting,java.security.jgss,java.sql,java.sql.rowset,java.transaction.xa,java.xml,jdk.net,jdk.zipfs
RUN jlink --output /myjdk --module-path $JAVA_HOME/jmods --add-modules $MODULES --no-header-files --no-man-pages --strip-debug --compress=2 RUN jlink --output /myjdk --module-path $JAVA_HOME/jmods --add-modules $MODULES --no-header-files --no-man-pages --strip-debug --compress=2
@@ -12,18 +12,17 @@ FROM alpine
RUN apk add --no-cache curl RUN apk add --no-cache curl
ENV APPLICATION_USER simplenotes
RUN adduser -D -g '' $APPLICATION_USER
RUN mkdir /app RUN mkdir /app
RUN chown -R $APPLICATION_USER /app RUN mkdir /app/data
USER $APPLICATION_USER
COPY --from=jdkbuilder /myjdk /myjdk COPY --from=jdkbuilder /myjdk /myjdk
COPY app/build/libs/app-with-dependencies*.jar /app/simplenotes.jar COPY app/build/libs/app-with-dependencies*.jar /app/simplenotes.jar
WORKDIR /app WORKDIR /app
VOLUME /app/data
ENV SERVER_HOST 0.0.0.0
CMD [ \ CMD [ \
"/myjdk/bin/java", \ "/myjdk/bin/java", \
"--add-opens", \ "--add-opens", \
+8 -11
View File
@@ -1,4 +1,3 @@
import be.simplenotes.Libs
import be.simplenotes.micronaut import be.simplenotes.micronaut
plugins { plugins {
@@ -14,20 +13,18 @@ dependencies {
implementation(project(":views")) implementation(project(":views"))
implementation(project(":css")) implementation(project(":css"))
implementation(Libs.Http4k.core) implementation(libs.http4k.core)
implementation(Libs.Jetty.server) implementation(libs.http4k.multipart)
implementation(Libs.Jetty.servlet) implementation(libs.bundles.jetty)
implementation(Libs.javaxServlet) implementation(libs.kotlinx.serialization.json)
implementation(Libs.Kotlinx.Serialization.json)
implementation(Libs.Slf4J.api) implementation(libs.slf4j.api)
runtimeOnly(Libs.Slf4J.logback) runtimeOnly(libs.slf4j.logback)
micronaut() micronaut()
testImplementation(Libs.Test.junit) testImplementation(libs.bundles.test)
testImplementation(Libs.Test.assertJ) testImplementation(libs.http4k.testing.hamkrest)
testImplementation(Libs.Http4k.testingHamkrest)
} }
docker { docker {
+3 -3
View File
@@ -1,10 +1,10 @@
package be.simplenotes.app package be.simplenotes.app
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import jakarta.inject.Singleton
import org.http4k.server.Http4kServer import org.http4k.server.Http4kServer
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import javax.annotation.PostConstruct
import javax.annotation.PreDestroy
import javax.inject.Singleton
import be.simplenotes.config.ServerConfig as SimpleNotesServerConfig import be.simplenotes.config.ServerConfig as SimpleNotesServerConfig
@Singleton @Singleton
+5 -1
View File
@@ -4,7 +4,11 @@ import io.micronaut.context.ApplicationContext
import java.lang.Runtime.getRuntime import java.lang.Runtime.getRuntime
fun main() { fun main() {
val ctx = ApplicationContext.run() val env = if (System.getenv("ENV") == "dev") "dev" else "prod"
val ctx = ApplicationContext.builder()
.deduceEnvironment(false)
.environments(env)
.start()
ctx.createBean(Server::class.java) ctx.createBean(Server::class.java)
getRuntime().addShutdownHook(Thread { ctx.stop() }) getRuntime().addShutdownHook(Thread { ctx.stop() })
} }
+5 -5
View File
@@ -1,7 +1,7 @@
package be.simplenotes.app.api package be.simplenotes.app.api
import be.simplenotes.app.extensions.auto import be.simplenotes.app.extensions.auto
import be.simplenotes.domain.usecases.NoteService import be.simplenotes.domain.NoteService
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import be.simplenotes.types.PersistedNote import be.simplenotes.types.PersistedNote
import be.simplenotes.types.PersistedNoteMetadata import be.simplenotes.types.PersistedNoteMetadata
@@ -16,7 +16,7 @@ import org.http4k.core.Status.Companion.OK
import org.http4k.lens.Path import org.http4k.lens.Path
import org.http4k.lens.uuid import org.http4k.lens.uuid
import java.util.* import java.util.*
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class ApiNoteController( class ApiNoteController(
@@ -28,7 +28,7 @@ class ApiNoteController(
val content = noteContentLens(request) val content = noteContentLens(request)
return noteService.create(loggedInUser, content).fold( return noteService.create(loggedInUser, content).fold(
{ Response(BAD_REQUEST) }, { Response(BAD_REQUEST) },
{ uuidContentLens(UuidContent(it.uuid), Response(OK)) } { uuidContentLens(UuidContent(it.uuid), Response(OK)) },
) )
} }
@@ -51,7 +51,7 @@ class ApiNoteController(
{ {
if (it == null) Response(NOT_FOUND) if (it == null) Response(NOT_FOUND)
else Response(OK) else Response(OK)
} },
) )
} }
@@ -76,4 +76,4 @@ data class NoteContent(val content: String)
data class UuidContent(@Contextual val uuid: UUID) data class UuidContent(@Contextual val uuid: UUID)
@Serializable @Serializable
data class SearchContent(@Contextual val query: String) data class SearchContent(val query: String)
+4 -4
View File
@@ -1,15 +1,15 @@
package be.simplenotes.app.api package be.simplenotes.app.api
import be.simplenotes.app.extensions.auto import be.simplenotes.app.extensions.auto
import be.simplenotes.domain.usecases.UserService import be.simplenotes.domain.LoginForm
import be.simplenotes.domain.usecases.users.login.LoginForm import be.simplenotes.domain.UserService
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.http4k.core.Request import org.http4k.core.Request
import org.http4k.core.Response import org.http4k.core.Response
import org.http4k.core.Status.Companion.BAD_REQUEST import org.http4k.core.Status.Companion.BAD_REQUEST
import org.http4k.core.Status.Companion.OK import org.http4k.core.Status.Companion.OK
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class ApiUserController( class ApiUserController(
@@ -23,7 +23,7 @@ class ApiUserController(
.login(loginFormLens(request)) .login(loginFormLens(request))
.fold( .fold(
{ Response(BAD_REQUEST) }, { Response(BAD_REQUEST) },
{ tokenLens(Token(it), Response(OK)) } { tokenLens(Token(it), Response(OK)) },
) )
} }
+1 -1
View File
@@ -6,7 +6,7 @@ import be.simplenotes.views.BaseView
import org.http4k.core.Request import org.http4k.core.Request
import org.http4k.core.Response import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK import org.http4k.core.Status.Companion.OK
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class BaseController(private val view: BaseView) { class BaseController(private val view: BaseView) {
@@ -1,14 +0,0 @@
package be.simplenotes.app.controllers
import be.simplenotes.domain.usecases.HealthCheckService
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK
import org.http4k.core.Status.Companion.SERVICE_UNAVAILABLE
import javax.inject.Singleton
@Singleton
class HealthCheckController(private val healthCheckService: HealthCheckService) {
fun healthCheck(@Suppress("UNUSED_PARAMETER") request: Request) =
if (healthCheckService.isOk()) Response(OK) else Response(SERVICE_UNAVAILABLE)
}
+17 -19
View File
@@ -2,10 +2,8 @@ package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.redirect import be.simplenotes.app.extensions.redirect
import be.simplenotes.domain.usecases.NoteService import be.simplenotes.domain.MarkdownParsingError
import be.simplenotes.domain.usecases.markdown.InvalidMeta import be.simplenotes.domain.NoteService
import be.simplenotes.domain.usecases.markdown.MissingMeta
import be.simplenotes.domain.usecases.markdown.ValidationError
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.NoteView import be.simplenotes.views.NoteView
import org.http4k.core.Method import org.http4k.core.Method
@@ -17,7 +15,7 @@ import org.http4k.core.Status.Companion.OK
import org.http4k.core.body.form import org.http4k.core.body.form
import org.http4k.routing.path import org.http4k.routing.path
import java.util.* import java.util.*
import javax.inject.Singleton import jakarta.inject.Singleton
import kotlin.math.abs import kotlin.math.abs
@Singleton @Singleton
@@ -34,27 +32,27 @@ class NoteController(
return noteService.create(loggedInUser, markdownForm).fold( return noteService.create(loggedInUser, markdownForm).fold(
{ {
val html = when (it) { val html = when (it) {
MissingMeta -> view.noteEditor( MarkdownParsingError.MissingMeta -> view.noteEditor(
loggedInUser, loggedInUser,
error = "Missing note metadata", error = "Missing note metadata",
textarea = markdownForm textarea = markdownForm,
) )
InvalidMeta -> view.noteEditor( MarkdownParsingError.InvalidMeta -> view.noteEditor(
loggedInUser, loggedInUser,
error = "Invalid note metadata", error = "Invalid note metadata",
textarea = markdownForm textarea = markdownForm,
) )
is ValidationError -> view.noteEditor( is MarkdownParsingError.ValidationError -> view.noteEditor(
loggedInUser, loggedInUser,
validationErrors = it.validationErrors, validationErrors = it.validationErrors,
textarea = markdownForm textarea = markdownForm,
) )
} }
Response(BAD_REQUEST).html(html) Response(BAD_REQUEST).html(html)
}, },
{ {
Response.redirect("/notes/${it.uuid}") Response.redirect("/notes/${it.uuid}")
} },
) )
} }
@@ -113,27 +111,27 @@ class NoteController(
return noteService.update(loggedInUser, note.uuid, markdownForm).fold( return noteService.update(loggedInUser, note.uuid, markdownForm).fold(
{ {
val html = when (it) { val html = when (it) {
MissingMeta -> view.noteEditor( MarkdownParsingError.MissingMeta -> view.noteEditor(
loggedInUser, loggedInUser,
error = "Missing note metadata", error = "Missing note metadata",
textarea = markdownForm textarea = markdownForm,
) )
InvalidMeta -> view.noteEditor( MarkdownParsingError.InvalidMeta -> view.noteEditor(
loggedInUser, loggedInUser,
error = "Invalid note metadata", error = "Invalid note metadata",
textarea = markdownForm textarea = markdownForm,
) )
is ValidationError -> view.noteEditor( is MarkdownParsingError.ValidationError -> view.noteEditor(
loggedInUser, loggedInUser,
validationErrors = it.validationErrors, validationErrors = it.validationErrors,
textarea = markdownForm textarea = markdownForm,
) )
} }
Response(BAD_REQUEST).html(html) Response(BAD_REQUEST).html(html)
}, },
{ {
Response.redirect("/notes/${note.uuid}") Response.redirect("/notes/${note.uuid}")
} },
) )
} }
+22 -13
View File
@@ -2,19 +2,19 @@ package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.redirect import be.simplenotes.app.extensions.redirect
import be.simplenotes.domain.usecases.UserService import be.simplenotes.domain.*
import be.simplenotes.domain.usecases.users.delete.DeleteError
import be.simplenotes.domain.usecases.users.delete.DeleteForm
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.SettingView import be.simplenotes.views.SettingView
import jakarta.inject.Singleton
import org.http4k.core.* import org.http4k.core.*
import org.http4k.core.body.form import org.http4k.core.body.form
import org.http4k.core.cookie.invalidateCookie import org.http4k.core.cookie.invalidateCookie
import javax.inject.Singleton
@Singleton @Singleton
class SettingsController( class SettingsController(
private val userService: UserService, private val userService: UserService,
private val exportService: ExportService,
private val importService: ImportService,
private val settingView: SettingView, private val settingView: SettingView,
) { ) {
fun settings(request: Request, loggedInUser: LoggedInUser): Response { fun settings(request: Request, loggedInUser: LoggedInUser): Response {
@@ -31,20 +31,21 @@ class SettingsController(
DeleteError.WrongPassword -> Response(Status.OK).html( DeleteError.WrongPassword -> Response(Status.OK).html(
settingView.settings( settingView.settings(
loggedInUser, loggedInUser,
error = "Wrong password" error = "Wrong password",
) ),
) )
is DeleteError.InvalidForm -> Response(Status.OK).html( is DeleteError.InvalidForm -> Response(Status.OK).html(
settingView.settings( settingView.settings(
loggedInUser, loggedInUser,
validationErrors = it.validationErrors validationErrors = it.validationErrors,
) ),
) )
} }
}, },
{ {
Response.redirect("/").invalidateCookie("Bearer") Response.redirect("/").invalidateCookie("Bearer")
} },
) )
} }
@@ -61,20 +62,28 @@ class SettingsController(
return if (isDownload) { return if (isDownload) {
val filename = "simplenotes-export-${loggedInUser.username}" val filename = "simplenotes-export-${loggedInUser.username}"
if (request.form("format") == "zip") { if (request.form("format") == "zip") {
val zip = userService.exportAsZip(loggedInUser.userId) val zip = exportService.exportAsZip(loggedInUser.userId)
Response(Status.OK) Response(Status.OK)
.with(attachment("$filename.zip", "application/zip")) .with(attachment("$filename.zip", "application/zip"))
.body(zip) .body(zip)
} else } else
Response(Status.OK) Response(Status.OK)
.with(attachment("$filename.json", "application/json")) .with(attachment("$filename.json", "application/json"))
.body(userService.exportAsJson(loggedInUser.userId)) .body(exportService.exportAsJson(loggedInUser.userId))
} else Response(Status.OK).body(userService.exportAsJson(loggedInUser.userId)).header( } else Response(Status.OK).body(exportService.exportAsJson(loggedInUser.userId)).header(
"Content-Type", "Content-Type",
"application/json" "application/json",
) )
} }
private fun Request.deleteForm(loggedInUser: LoggedInUser) = private fun Request.deleteForm(loggedInUser: LoggedInUser) =
DeleteForm(loggedInUser.username, form("password"), form("checked") != null) DeleteForm(loggedInUser.username, form("password"), form("checked") != null)
fun import(request: Request, loggedInUser: LoggedInUser): Response {
val form = MultipartFormBody.from(request)
val file = form.file("file") ?: return Response(Status.BAD_REQUEST)
val json = file.content.bufferedReader().readText()
importService.importJson(loggedInUser, json)
return Response.redirect("/notes")
}
} }
+18 -22
View File
@@ -4,11 +4,7 @@ import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.isSecure import be.simplenotes.app.extensions.isSecure
import be.simplenotes.app.extensions.redirect import be.simplenotes.app.extensions.redirect
import be.simplenotes.config.JwtConfig import be.simplenotes.config.JwtConfig
import be.simplenotes.domain.usecases.UserService import be.simplenotes.domain.*
import be.simplenotes.domain.usecases.users.login.*
import be.simplenotes.domain.usecases.users.register.InvalidRegisterForm
import be.simplenotes.domain.usecases.users.register.RegisterForm
import be.simplenotes.domain.usecases.users.register.UserExists
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.UserView import be.simplenotes.views.UserView
import org.http4k.core.Method.GET import org.http4k.core.Method.GET
@@ -21,7 +17,7 @@ import org.http4k.core.cookie.SameSite
import org.http4k.core.cookie.cookie import org.http4k.core.cookie.cookie
import org.http4k.core.cookie.invalidateCookie import org.http4k.core.cookie.invalidateCookie
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class UserController( class UserController(
@@ -31,7 +27,7 @@ class UserController(
) { ) {
fun register(request: Request, loggedInUser: LoggedInUser?): Response { fun register(request: Request, loggedInUser: LoggedInUser?): Response {
if (request.method == GET) return Response(OK).html( if (request.method == GET) return Response(OK).html(
userView.register(loggedInUser) userView.register(loggedInUser),
) )
val result = userService.register(request.registerForm()) val result = userService.register(request.registerForm())
@@ -39,21 +35,21 @@ class UserController(
return result.fold( return result.fold(
{ {
val html = when (it) { val html = when (it) {
UserExists -> userView.register( RegisterError.UserExists -> userView.register(
loggedInUser, loggedInUser,
error = "User already exists" error = "User already exists",
) )
is InvalidRegisterForm -> is RegisterError.InvalidRegisterForm ->
userView.register( userView.register(
loggedInUser, loggedInUser,
validationErrors = it.validationErrors validationErrors = it.validationErrors,
) )
} }
Response(OK).html(html) Response(OK).html(html)
}, },
{ {
Response.redirect("/login") Response.redirect("/login")
} },
) )
} }
@@ -62,7 +58,7 @@ class UserController(
fun login(request: Request, loggedInUser: LoggedInUser?): Response { fun login(request: Request, loggedInUser: LoggedInUser?): Response {
if (request.method == GET) return Response(OK).html( if (request.method == GET) return Response(OK).html(
userView.login(loggedInUser) userView.login(loggedInUser),
) )
val result = userService.login(request.loginForm()) val result = userService.login(request.loginForm())
@@ -70,27 +66,27 @@ class UserController(
return result.fold( return result.fold(
{ {
val html = when (it) { val html = when (it) {
Unregistered -> LoginError.Unregistered ->
userView.login( userView.login(
loggedInUser, loggedInUser,
error = "User does not exist" error = "User does not exist",
) )
WrongPassword -> LoginError.WrongPassword ->
userView.login( userView.login(
loggedInUser, loggedInUser,
error = "Wrong password" error = "Wrong password",
) )
is InvalidLoginForm -> is LoginError.InvalidLoginForm ->
userView.login( userView.login(
loggedInUser, loggedInUser,
validationErrors = it.validationErrors validationErrors = it.validationErrors,
) )
} }
Response(OK).html(html) Response(OK).html(html)
}, },
{ token -> { token ->
Response.redirect("/notes").loginCookie(token, request.isSecure()) Response.redirect("/notes").loginCookie(token, request.isSecure())
} },
) )
} }
@@ -105,8 +101,8 @@ class UserController(
httpOnly = true, httpOnly = true,
sameSite = SameSite.Lax, sameSite = SameSite.Lax,
maxAge = validityInSeconds, maxAge = validityInSeconds,
secure = secure secure = secure,
) ),
) )
} }
+3 -3
View File
@@ -24,13 +24,13 @@ fun Request.isSecure() = header("X-Forwarded-Proto")?.contains("https") ?: false
val bodyLens = httpBodyRoot( val bodyLens = httpBodyRoot(
listOf(Meta(true, "body", ParamMeta.ObjectParam, "body")), listOf(Meta(true, "body", ParamMeta.ObjectParam, "body")),
ContentType.APPLICATION_JSON.withNoDirectives(), ContentType.APPLICATION_JSON.withNoDirectives(),
ContentNegotiation.StrictNoDirective ContentNegotiation.StrictNoDirective,
).map( ).map(
{ it.payload.asString() }, { it.payload.asString() },
{ Body(it) } { Body(it) },
) )
inline fun <reified T> Json.auto(): BiDiBodyLensSpec<T> = bodyLens.map( inline fun <reified T> Json.auto(): BiDiBodyLensSpec<T> = bodyLens.map(
{ decodeFromString(it) }, { decodeFromString(it) },
{ encodeToString(it) } { encodeToString(it) },
) )
+1 -1
View File
@@ -10,7 +10,7 @@ import org.http4k.core.Status.Companion.NOT_IMPLEMENTED
import org.http4k.core.Status.Companion.SERVICE_UNAVAILABLE import org.http4k.core.Status.Companion.SERVICE_UNAVAILABLE
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.sql.SQLTransientException import java.sql.SQLTransientException
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class ErrorFilter(private val errorView: ErrorView) : Filter { class ErrorFilter(private val errorView: ErrorView) : Filter {
+2 -2
View File
@@ -8,14 +8,14 @@ import org.eclipse.jetty.servlet.ServletHolder
import org.http4k.core.HttpHandler import org.http4k.core.HttpHandler
import org.http4k.server.Http4kServer import org.http4k.server.Http4kServer
import org.http4k.server.ServerConfig import org.http4k.server.ServerConfig
import org.http4k.servlet.asServlet import org.http4k.servlet.jakarta.asServlet
class Jetty(private val port: Int, private val server: Server) : ServerConfig { class Jetty(private val port: Int, private val server: Server) : ServerConfig {
constructor(port: Int, vararg inConnectors: ConnectorBuilder) : this( constructor(port: Int, vararg inConnectors: ConnectorBuilder) : this(
port, port,
Server().apply { Server().apply {
inConnectors.forEach { addConnector(it(this)) } inConnectors.forEach { addConnector(it(this)) }
} },
) )
override fun toServer(http: HttpHandler): Http4kServer { override fun toServer(http: HttpHandler): Http4kServer {
+3 -3
View File
@@ -7,8 +7,8 @@ import io.micronaut.context.annotation.Factory
import io.micronaut.context.annotation.Primary import io.micronaut.context.annotation.Primary
import org.http4k.core.RequestContexts import org.http4k.core.RequestContexts
import org.http4k.lens.RequestContextKey import org.http4k.lens.RequestContextKey
import javax.inject.Named import jakarta.inject.Named
import javax.inject.Singleton import jakarta.inject.Singleton
@Factory @Factory
class AuthModule { class AuthModule {
@@ -39,7 +39,7 @@ class AuthModule {
simpleJwt = simpleJwt, simpleJwt = simpleJwt,
lens = lens, lens = lens,
source = JwtSource.Header, source = JwtSource.Header,
redirect = false redirect = false,
) )
@Singleton @Singleton
+1 -1
View File
@@ -7,7 +7,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.SerializersModule
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.* import java.util.*
import javax.inject.Singleton import jakarta.inject.Singleton
@Factory @Factory
class JsonModule { class JsonModule {
+2 -2
View File
@@ -9,8 +9,8 @@ import io.micronaut.context.annotation.Factory
import org.eclipse.jetty.server.ServerConnector import org.eclipse.jetty.server.ServerConnector
import org.http4k.server.Http4kServer import org.http4k.server.Http4kServer
import org.http4k.server.asServer import org.http4k.server.asServer
import javax.inject.Named import jakarta.inject.Named
import javax.inject.Singleton import jakarta.inject.Singleton
import org.eclipse.jetty.server.Server as JettyServer import org.eclipse.jetty.server.Server as JettyServer
import org.http4k.server.ServerConfig as Http4kServerConfig import org.http4k.server.ServerConfig as Http4kServerConfig
+4 -5
View File
@@ -12,8 +12,8 @@ import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.bind import org.http4k.routing.bind
import org.http4k.routing.routes import org.http4k.routing.routes
import java.util.function.Supplier import java.util.function.Supplier
import javax.inject.Named import jakarta.inject.Named
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class ApiRoutes( class ApiRoutes(
@@ -23,7 +23,6 @@ class ApiRoutes(
@Named("required") private val authLens: RequiredAuthLens, @Named("required") private val authLens: RequiredAuthLens,
) : Supplier<RoutingHttpHandler> { ) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler { override fun get(): RoutingHttpHandler {
infix fun PathMethod.to(action: ProtectedHandler) = infix fun PathMethod.to(action: ProtectedHandler) =
this to { req: Request -> action(req, authLens(req)) } this to { req: Request -> action(req, authLens(req)) }
@@ -38,9 +37,9 @@ class ApiRoutes(
"/search" bind POST to ::search, "/search" bind POST to ::search,
"/{uuid}" bind GET to ::note, "/{uuid}" bind GET to ::note,
"/{uuid}" bind PUT to ::update, "/{uuid}" bind PUT to ::update,
) ),
).withBasePath("/notes") ).withBasePath("/notes")
} },
).withBasePath("/api") ).withBasePath("/api")
} }
+6 -11
View File
@@ -1,7 +1,6 @@
package be.simplenotes.app.routes package be.simplenotes.app.routes
import be.simplenotes.app.controllers.BaseController import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.HealthCheckController
import be.simplenotes.app.controllers.NoteController import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.UserController import be.simplenotes.app.controllers.UserController
import be.simplenotes.app.filters.ImmutableFilter import be.simplenotes.app.filters.ImmutableFilter
@@ -14,12 +13,11 @@ import org.http4k.core.Request
import org.http4k.core.then import org.http4k.core.then
import org.http4k.routing.* import org.http4k.routing.*
import java.util.function.Supplier import java.util.function.Supplier
import javax.inject.Named import jakarta.inject.Named
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class BasicRoutes( class BasicRoutes(
private val healthCheckController: HealthCheckController,
private val baseCtrl: BaseController, private val baseCtrl: BaseController,
private val userCtrl: UserController, private val userCtrl: UserController,
private val noteCtrl: NoteController, private val noteCtrl: NoteController,
@@ -28,7 +26,6 @@ class BasicRoutes(
) : Supplier<RoutingHttpHandler> { ) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler { override fun get(): RoutingHttpHandler {
infix fun PathMethod.to(action: PublicHandler) = infix fun PathMethod.to(action: PublicHandler) =
this to { req: Request -> action(req, authLens(req)) } this to { req: Request -> action(req, authLens(req)) }
@@ -36,8 +33,8 @@ class BasicRoutes(
static( static(
ResourceLoader.Classpath("/static"), ResourceLoader.Classpath("/static"),
"woff2" to ContentType("font/woff2"), "woff2" to ContentType("font/woff2"),
"webmanifest" to ContentType("application/manifest+json") "webmanifest" to ContentType("application/manifest+json"),
) ),
) )
return routes( return routes(
@@ -50,11 +47,9 @@ class BasicRoutes(
"/login" bind POST to userCtrl::login, "/login" bind POST to userCtrl::login,
"/logout" bind POST to userCtrl::logout, "/logout" bind POST to userCtrl::logout,
"/notes/public/{uuid}" bind GET to noteCtrl::public, "/notes/public/{uuid}" bind GET to noteCtrl::public,
)
), ),
),
"/health" bind GET to healthCheckController::healthCheck, staticHandler,
staticHandler
) )
} }
} }
+3 -4
View File
@@ -12,8 +12,8 @@ import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.bind import org.http4k.routing.bind
import org.http4k.routing.routes import org.http4k.routing.routes
import java.util.function.Supplier import java.util.function.Supplier
import javax.inject.Named import jakarta.inject.Named
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class NoteRoutes( class NoteRoutes(
@@ -22,7 +22,6 @@ class NoteRoutes(
@Named("required") private val authLens: RequiredAuthLens, @Named("required") private val authLens: RequiredAuthLens,
) : Supplier<RoutingHttpHandler> { ) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler { override fun get(): RoutingHttpHandler {
infix fun PathMethod.to(action: ProtectedHandler) = infix fun PathMethod.to(action: ProtectedHandler) =
this to { req: Request -> action(req, authLens(req)) } this to { req: Request -> action(req, authLens(req)) }
@@ -40,7 +39,7 @@ class NoteRoutes(
"/{uuid}/edit" bind POST to ::edit, "/{uuid}/edit" bind POST to ::edit,
"/deleted/{uuid}" bind POST to ::deleted, "/deleted/{uuid}" bind POST to ::deleted,
).withBasePath("/notes") ).withBasePath("/notes")
} },
) )
} }
} }
+2 -3
View File
@@ -9,7 +9,7 @@ import org.http4k.filter.ServerFilters.InitialiseRequestContext
import org.http4k.routing.RoutingHttpHandler import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.routes import org.http4k.routing.routes
import java.util.function.Supplier import java.util.function.Supplier
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class Router( class Router(
@@ -18,9 +18,8 @@ class Router(
private val subRouters: List<Supplier<RoutingHttpHandler>>, private val subRouters: List<Supplier<RoutingHttpHandler>>,
) { ) {
operator fun invoke(): RoutingHttpHandler { operator fun invoke(): RoutingHttpHandler {
val routes = routes( val routes = routes(
*subRouters.map { it.get() }.toTypedArray() *subRouters.map { it.get() }.toTypedArray(),
) )
return errorFilter return errorFilter
+4 -4
View File
@@ -12,8 +12,8 @@ import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.bind import org.http4k.routing.bind
import org.http4k.routing.routes import org.http4k.routing.routes
import java.util.function.Supplier import java.util.function.Supplier
import javax.inject.Named import jakarta.inject.Named
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class SettingsRoutes( class SettingsRoutes(
@@ -22,7 +22,6 @@ class SettingsRoutes(
@Named("required") private val authLens: RequiredAuthLens, @Named("required") private val authLens: RequiredAuthLens,
) : Supplier<RoutingHttpHandler> { ) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler { override fun get(): RoutingHttpHandler {
infix fun PathMethod.to(action: ProtectedHandler) = infix fun PathMethod.to(action: ProtectedHandler) =
this to { req: Request -> action(req, authLens(req)) } this to { req: Request -> action(req, authLens(req)) }
@@ -31,7 +30,8 @@ class SettingsRoutes(
"/settings" bind GET to settingsController::settings, "/settings" bind GET to settingsController::settings,
"/settings" bind POST to settingsController::settings, "/settings" bind POST to settingsController::settings,
"/export" bind POST to settingsController::export, "/export" bind POST to settingsController::export,
) "/import" bind POST to settingsController::import,
),
) )
} }
} }
@@ -17,6 +17,6 @@ internal class LocalDateTimeSerializer : KSerializer<LocalDateTime> {
} }
override fun deserialize(decoder: Decoder): LocalDateTime { override fun deserialize(decoder: Decoder): LocalDateTime {
TODO("Not implemented, isn't needed") return LocalDateTime.parse(decoder.decodeString())
} }
} }
+1 -1
View File
@@ -3,7 +3,7 @@ package be.simplenotes.app.utils
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import javax.inject.Singleton import jakarta.inject.Singleton
interface StaticFileResolver { interface StaticFileResolver {
fun resolve(name: String): String? fun resolve(name: String): String?
+2 -2
View File
@@ -58,8 +58,8 @@ internal class RequiredAuthFilterTest {
}, },
"/protected" bind GET to requiredAuth.then { request: Request -> "/protected" bind GET to requiredAuth.then { request: Request ->
Response(OK).body(requiredLens(request).toString()) Response(OK).body(requiredLens(request).toString())
} },
) ),
) )
// endregion // endregion
+5 -9
View File
@@ -2,18 +2,14 @@ plugins {
`kotlin-dsl` `kotlin-dsl`
} }
kotlinDslPluginOptions {
experimentalWarning.set(false)
}
repositories { repositories {
gradlePluginPortal() gradlePluginPortal()
} }
dependencies { dependencies {
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.31") implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.21")
implementation("org.jetbrains.kotlin:kotlin-serialization:1.4.31") implementation("org.jetbrains.kotlin:kotlin-serialization:1.8.21")
implementation("com.github.jengelman.gradle.plugins:shadow:6.1.0") implementation("com.github.johnrengelman:shadow:8.1.1")
implementation("org.jlleitschuh.gradle:ktlint-gradle:9.4.1") implementation("com.diffplug.spotless:spotless-plugin-gradle:6.13.0")
implementation("com.github.ben-manes:gradle-versions-plugin:0.28.0") implementation("com.github.ben-manes:gradle-versions-plugin:0.46.0")
} }
@@ -4,89 +4,10 @@ package be.simplenotes
object Libs { object Libs {
object Flexmark {
private const val version = "0.62.2"
const val core = "com.vladsch.flexmark:flexmark:$version"
const val tasklist = "com.vladsch.flexmark:flexmark-ext-gfm-tasklist:$version"
}
object Database {
const val flyway = "org.flywaydb:flyway-core:7.5.4"
const val hikariCP = "com.zaxxer:HikariCP:4.0.2"
object Drivers {
const val h2 = "com.h2database:h2:1.4.200"
const val mariadb = "org.mariadb.jdbc:mariadb-java-client:2.7.2"
}
object Ktorm {
private const val version = "3.0.0"
const val core = "me.liuwj.ktorm:ktorm-core:$version"
const val mysql = "me.liuwj.ktorm:ktorm-support-mysql:$version"
}
}
object Lucene {
private const val version = "8.8.1"
const val core = "org.apache.lucene:lucene-core:$version"
const val analyzersCommon = "org.apache.lucene:lucene-analyzers-common:$version"
const val queryParser = "org.apache.lucene:lucene-queryparser:$version"
}
object Http4k {
private const val version = "4.3.5.4"
const val core = "org.http4k:http4k-core:$version"
const val testingHamkrest = "org.http4k:http4k-testing-hamkrest:$version"
}
object Jetty {
private const val version = "10.0.1"
const val server = "org.eclipse.jetty:jetty-server:$version"
const val servlet = "org.eclipse.jetty:jetty-servlet:$version"
}
object Kotlinx {
const val html = "org.jetbrains.kotlinx:kotlinx-html-jvm:0.7.2"
object Serialization {
const val json = "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.1.0"
}
}
object Slf4J {
const val api = "org.slf4j:slf4j-api:2.0.0-alpha1"
const val logback = "ch.qos.logback:logback-classic:1.3.0-alpha5"
}
object Mapstruct {
private const val version = "1.4.2.Final"
const val core = "org.mapstruct:mapstruct:$version"
const val processor = "org.mapstruct:mapstruct-processor:$version"
}
object Micronaut { object Micronaut {
private const val version = "2.3.3" private const val version = "4.0.0-M2"
const val inject = "io.micronaut:micronaut-inject:$version" const val inject = "io.micronaut:micronaut-inject:$version"
const val processor = "io.micronaut:micronaut-inject-java:$version" const val processor = "io.micronaut:micronaut-inject-java:$version"
} }
const val arrowCoreData = "io.arrow-kt:arrow-core-data:0.11.0"
const val commonsCompress = "org.apache.commons:commons-compress:1.20"
const val javaJwt = "com.auth0:java-jwt:3.13.0"
const val javaxServlet = "javax.servlet:javax.servlet-api:4.0.1"
const val jbcrypt = "org.mindrot:jbcrypt:0.4"
const val konform = "io.konform:konform-jvm:0.2.0"
const val owaspHtmlSanitizer = "com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20200713.1"
const val prettytime = "org.ocpsoft.prettytime:prettytime:5.0.0.Final"
const val snakeyaml = "org.yaml:snakeyaml:1.28"
object Test {
const val assertJ = "org.assertj:assertj-core:3.19.0"
const val hamkrest = "com.natpryce:hamkrest:1.8.0.1"
const val junit = "org.junit.jupiter:junit-jupiter:5.7.1"
const val mockk = "io.mockk:mockk:1.10.6"
const val faker = "com.github.javafaker:javafaker:1.0.2"
const val mariaTestContainer = "org.testcontainers:mariadb:1.15.2"
}
} }
@@ -2,8 +2,10 @@ package be.simplenotes
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.api.plugins.JavaPluginConvention import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.api.tasks.SourceSetContainer
import org.gradle.kotlin.dsl.get import org.gradle.kotlin.dsl.get
import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.register import org.gradle.kotlin.dsl.register
import java.io.File import java.io.File
@@ -17,7 +19,7 @@ class PostcssPlugin : Plugin<Project> {
getByName("processResources").dependsOn("postcss") getByName("processResources").dependsOn("postcss")
} }
val sourceSets = project.convention.getPlugin(JavaPluginConvention::class.java).sourceSets val sourceSets = project.extensions.getByType<SourceSetContainer>()
val root = File("${project.buildDir}/generated-resources/css") val root = File("${project.buildDir}/generated-resources/css")
sourceSets["main"].resources.srcDir(root) sourceSets["main"].resources.srcDir(root)
} }
@@ -11,6 +11,10 @@ tasks.withType<ShadowJar> {
archiveAppendix.set("with-dependencies") archiveAppendix.set("with-dependencies")
manifest.attributes["Main-Class"] = "be.simplenotes.app.SimpleNotesKt" manifest.attributes["Main-Class"] = "be.simplenotes.app.SimpleNotesKt"
// johnrengelman/shadow#449
// we need this for lucene-core
manifest.attributes["Multi-Release"] = "true"
mergeServiceFiles() mergeServiceFiles()
File(rootProject.projectDir, "buildSrc/src/main/resources/exclusions") File(rootProject.projectDir, "buildSrc/src/main/resources/exclusions")
@@ -4,5 +4,11 @@ plugins {
id("be.simplenotes.java-convention") id("be.simplenotes.java-convention")
id("be.simplenotes.kotlin-convention") id("be.simplenotes.kotlin-convention")
id("be.simplenotes.junit-convention") id("be.simplenotes.junit-convention")
id("org.jlleitschuh.gradle.ktlint") id("com.diffplug.spotless")
}
spotless {
kotlin {
ktlint("0.48.0").setEditorConfigPath(project.rootProject.file(".editorconfig"))
}
} }
@@ -6,24 +6,23 @@ plugins {
repositories { repositories {
mavenCentral() mavenCentral()
maven {
url = uri("https://kotlin.bintray.com/kotlinx")
// https://github.com/Kotlin/kotlinx.html/issues/173
content { includeModule("org.jetbrains.kotlinx", "kotlinx-html-jvm") }
}
} }
group = "be.simplenotes" group = "be.simplenotes"
version = "1.0-SNAPSHOT" version = "1.0-SNAPSHOT"
java { java {
sourceCompatibility = JavaVersion.VERSION_15 toolchain {
targetCompatibility = JavaVersion.VERSION_15 languageVersion.set(JavaLanguageVersion.of(19))
vendor.set(JvmVendorSpec.ORACLE)
}
} }
tasks.withType<JavaCompile> { tasks.withType<JavaCompile> {
options.encoding = "UTF-8" options.encoding = "UTF-8"
} }
sourceSets["main"].resources.srcDirs("resources") sourceSets["main"].resources.setSrcDirs(listOf("resources"))
sourceSets["test"].resources.srcDirs("testresources") sourceSets["main"].java.setSrcDirs(emptyList<String>())
sourceSets["test"].resources.setSrcDirs(listOf("testresources"))
sourceSets["test"].java.setSrcDirs(emptyList<String>())
@@ -5,7 +5,9 @@ plugins {
} }
tasks.withType<Test> { tasks.withType<Test> {
useJUnitPlatform() useJUnitPlatform {
excludeTags("slow")
}
} }
dependencies { dependencies {
@@ -1,5 +1,7 @@
package be.simplenotes package be.simplenotes
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
@@ -13,17 +15,17 @@ dependencies {
} }
tasks.withType<KotlinCompile> { tasks.withType<KotlinCompile> {
kotlinOptions { compilerOptions {
jvmTarget = "15" jvmTarget.set(JvmTarget.JVM_19)
javaParameters = true javaParameters.set(true)
freeCompilerArgs = listOf( freeCompilerArgs.addAll(
"-Xinline-classes", "-Xinline-classes",
"-Xno-param-assertions", "-Xno-param-assertions",
"-Xno-call-assertions", "-Xno-call-assertions",
"-Xno-receiver-assertions" "-Xno-receiver-assertions",
) )
} }
} }
kotlin.sourceSets["main"].kotlin.srcDirs("src") kotlin.sourceSets["main"].kotlin.setSrcDirs(listOf("src"))
kotlin.sourceSets["test"].kotlin.srcDirs("test") kotlin.sourceSets["test"].kotlin.setSrcDirs(listOf("test"))
@@ -10,12 +10,7 @@ tasks.named<DependencyUpdatesTask>("dependencyUpdates").configure {
resolutionStrategy { resolutionStrategy {
componentSelection { componentSelection {
all { all {
if ("RC" in candidate.version) reject("Release candidate") if ("alpha|beta|rc".toRegex().containsMatchIn(candidate.version.lowercase())) reject("Non stable version")
when {
candidate.group == "org.eclipse.jetty" && candidate.version.startsWith("11.") -> reject("javax -> jakarta")
candidate.group == "me.liuwj.ktorm" && candidate.version != "3.0.0" -> reject("SQL Case sensitivity changed")
}
} }
} }
} }
@@ -1,5 +1,6 @@
META-INF/maven/** META-INF/maven/**
META-INF/proguard/** META-INF/proguard/**
META-INF/com.android.tools/**
META-INF/*.kotlin_module META-INF/*.kotlin_module
META-INF/DEPENDENCIES* META-INF/DEPENDENCIES*
META-INF/NOTICE* META-INF/NOTICE*
@@ -7,6 +8,7 @@ META-INF/LICENSE*
LICENSE* LICENSE*
META-INF/README* META-INF/README*
META-INF/native-image/** META-INF/native-image/**
**/module-info.**
# Jetty # Jetty
about.html about.html
+3 -4
View File
@@ -1,4 +1,3 @@
import be.simplenotes.Libs
import be.simplenotes.micronaut import be.simplenotes.micronaut
plugins { plugins {
@@ -8,8 +7,8 @@ plugins {
dependencies { dependencies {
micronaut() micronaut()
runtimeOnly(libs.yaml)
testImplementation(Libs.Test.junit) testImplementation(libs.bundles.test)
testImplementation(Libs.Test.assertJ) testRuntimeOnly(libs.slf4j.logback)
testRuntimeOnly(Libs.Slf4J.logback)
} }
+2 -8
View File
@@ -1,11 +1,3 @@
db:
jdbc-url: jdbc:h2:./notes-db;
driver-class-name: org.h2.Driver
username: h2
password: ''
connection-timeout: 3000
maximum-pool-size: 10
jwt: jwt:
secret: 'PliLvfk7l4WF+cZJk66LR5Mpnh+ocbvJ2wfUCK2UCms=' secret: 'PliLvfk7l4WF+cZJk66LR5Mpnh+ocbvJ2wfUCK2UCms='
validity: 24 validity: 24
@@ -14,3 +6,5 @@ jwt:
server: server:
host: localhost host: localhost
port: 8080 port: 8080
data-dir: ./data
-33
View File
@@ -1,33 +0,0 @@
package be.simplenotes.config
import io.micronaut.context.annotation.ConfigurationInject
import io.micronaut.context.annotation.ConfigurationProperties
import java.util.concurrent.TimeUnit
@ConfigurationProperties("db")
data class DataSourceConfig @ConfigurationInject constructor(
val jdbcUrl: String,
val driverClassName: String,
val username: String,
val password: String,
val maximumPoolSize: Int,
val connectionTimeout: Long,
) {
override fun toString() = "DataSourceConfig(jdbcUrl='$jdbcUrl', driverClassName='$driverClassName', " +
"username='$username', password='***', maximumPoolSize=$maximumPoolSize, connectionTimeout=$connectionTimeout)"
}
@ConfigurationProperties("jwt")
data class JwtConfig @ConfigurationInject constructor(
val secret: String,
val validity: Long,
val timeUnit: TimeUnit,
) {
override fun toString() = "JwtConfig(secret='***', validity=$validity, timeUnit=$timeUnit)"
}
@ConfigurationProperties("server")
data class ServerConfig @ConfigurationInject constructor(
val host: String,
val port: Int,
)
+57
View File
@@ -0,0 +1,57 @@
package be.simplenotes.config
import io.micronaut.context.annotation.*
import java.nio.file.Path
import java.util.concurrent.TimeUnit
import jakarta.inject.Singleton
data class DataConfig(val dataDir: String)
data class DataSourceConfig(
val jdbcUrl: String,
val maximumPoolSize: Int,
val connectionTimeout: Long,
)
@ConfigurationProperties("jwt")
data class JwtConfig @ConfigurationInject constructor(
val secret: String,
val validity: Long,
val timeUnit: TimeUnit,
) {
override fun toString() = "JwtConfig(secret='***', validity=$validity, timeUnit=$timeUnit)"
}
@ConfigurationProperties("server")
data class ServerConfig @ConfigurationInject constructor(
val host: String,
val port: Int,
)
@Factory
class ConfigFactory {
@Singleton
@Requires(notEnv = ["test"])
fun datasourceConfig(dataConfig: DataConfig) = DataSourceConfig(
jdbcUrl = "jdbc:h2:" + Path.of(dataConfig.dataDir, "simplenotes").normalize().toAbsolutePath(),
maximumPoolSize = 10,
connectionTimeout = 1000,
)
@Singleton
@Requires(env = ["test"])
fun testDatasourceConfig() = DataSourceConfig(
jdbcUrl = "jdbc:h2:mem:regular;DB_CLOSE_DELAY=-1",
maximumPoolSize = 2,
connectionTimeout = 1000,
)
@Singleton
@Requires(notEnv = ["test"])
fun dataConfig(@Property(name = "data-dir") dataDir: String) = DataConfig(dataDir)
@Singleton
@Requires(env = ["test"])
fun testDataConfig() = DataConfig("/tmp")
}
+6 -7
View File
@@ -5,12 +5,11 @@
"//": "`gradle css`" "//": "`gradle css`"
}, },
"dependencies": { "dependencies": {
"autoprefixer": "^9.8.6", "autoprefixer": "^10.4.14",
"cssnano": "^4.1.10", "cssnano": "^6.0.1",
"postcss-cli": "^7.1.1", "postcss-cli": "^10.1.0",
"postcss-hash": "^2.0.0", "postcss-hash": "^3.0.0",
"postcss-import": "^12.0.1", "postcss-import": "^15.1.0",
"postcss-nested": "^4.2.3", "tailwindcss": "^3.3.2"
"tailwindcss": "^1.5.1"
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
module.exports = { module.exports = {
plugins: [ plugins: [
require('postcss-import'), require('postcss-import'),
require('postcss-nested'), require('tailwindcss/nesting'),
require('tailwindcss'), require('tailwindcss'),
require('autoprefixer'), require('autoprefixer'),
require('cssnano')({ require('cssnano')({
+1 -1
View File
@@ -2,7 +2,7 @@
@apply font-semibold py-2 px-4 rounded; @apply font-semibold py-2 px-4 rounded;
&:focus { &:focus {
@apply outline-none shadow-outline; @apply outline-none ring;
} }
&:hover { &:hover {
+1 -1
View File
@@ -1,6 +1,6 @@
#note { #note {
a { a {
@apply text-blue-700 underline; @apply text-blue-500 underline;
} }
p { p {
+1 -1
View File
@@ -2,7 +2,7 @@
@apply italic font-semibold text-sm bg-teal-500 text-gray-900 rounded-full py-1 px-2 align-middle; @apply italic font-semibold text-sm bg-teal-500 text-gray-900 rounded-full py-1 px-2 align-middle;
&:focus { &:focus {
@apply outline-none shadow-outline bg-teal-800 text-white; @apply outline-none ring bg-teal-800 text-white;
} }
&:hover { &:hover {
+1 -3
View File
@@ -1,9 +1,7 @@
module.exports = { module.exports = {
purge: {
content: [ content: [
process.env.PURGE process.env.PURGE
] ],
},
theme: { theme: {
fontFamily: { fontFamily: {
'sans': [ 'sans': [
+822 -1433
View File
File diff suppressed because it is too large Load Diff
+4 -41
View File
@@ -1,56 +1,19 @@
version: '2.2' version: '2.2'
services: services:
db:
image: mariadb:10.5.5
container_name: simplenotes-mariadb
env_file:
- .env
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Brussels
- MYSQL_DATABASE=simplenotes
- MYSQL_USER=simplenotes
# .env:
# - MYSQL_ROOT_PASSWORD
# - MYSQL_PASSWORD
volumes:
- notes-db-volume:/var/lib/mysql
healthcheck:
test: "mysql --protocol=tcp -u simplenotes -p$MYSQL_PASSWORD -e 'show databases'"
interval: 5s
timeout: 1s
start_period: 2s
retries: 10
simplenotes: simplenotes:
image: hubv/simplenotes image: hubv/simplenotes
container_name: simplenotes container_name: simplenotes
env_file: env_file:
- .env - .env
environment: environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Brussels - TZ=Europe/Brussels
- SERVER_HOST=0.0.0.0
- DB_JDBC_URL=jdbc:mariadb://db:3306/simplenotes
- DB_DRIVER_CLASS_NAME=org.mariadb.jdbc.Driver
- DB_USERNAME=simplenotes
# .env: # .env:
# - JWT_SECRET # - JWT_SECRET
# - DB_PASSWORD
ports: ports:
- 127.0.0.1:8080:8080 - 127.0.0.1:8080:8080
healthcheck: volumes:
test: "curl --fail -s http://localhost:8080/health" - ./simplenotes-data:/app/data
interval: 5s
timeout: 1s
start_period: 2s
retries: 3
depends_on:
db:
condition: service_healthy
volumes:
notes-db-volume:
+10 -14
View File
@@ -1,4 +1,3 @@
import be.simplenotes.Libs
import be.simplenotes.micronaut import be.simplenotes.micronaut
plugins { plugins {
@@ -13,21 +12,18 @@ dependencies {
implementation(project(":persistence")) implementation(project(":persistence"))
implementation(project(":search")) implementation(project(":search"))
api(Libs.arrowCoreData) api(libs.arrow.core)
api(Libs.konform) api(libs.konform)
micronaut() micronaut()
implementation(Libs.Kotlinx.Serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(Libs.jbcrypt) implementation(libs.bcrypt)
implementation(Libs.javaJwt) implementation(libs.jwt)
implementation(Libs.Flexmark.core) implementation(libs.bundles.flexmark)
implementation(Libs.Flexmark.tasklist) implementation(libs.yaml)
implementation(Libs.snakeyaml) implementation(libs.owasp.html.sanitizer)
implementation(Libs.owaspHtmlSanitizer) implementation(libs.commonsCompress)
implementation(Libs.commonsCompress)
testImplementation(Libs.Test.hamkrest) testImplementation(libs.bundles.test)
testImplementation(Libs.Test.junit)
testImplementation(Libs.Test.mockk)
} }
@@ -1,8 +1,7 @@
package be.simplenotes.domain.usecases.export package be.simplenotes.domain
import be.simplenotes.persistence.repositories.NoteRepository import be.simplenotes.persistence.repositories.NoteRepository
import be.simplenotes.types.ExportedNote import be.simplenotes.types.ExportedNote
import io.micronaut.context.annotation.Primary
import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
@@ -10,14 +9,19 @@ import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import javax.inject.Singleton import jakarta.inject.Singleton
interface ExportService {
fun exportAsJson(userId: Int): String
fun exportAsZip(userId: Int): InputStream
}
@Primary
@Singleton @Singleton
internal class ExportUseCaseImpl( internal class ExportServiceImpl(
private val noteRepository: NoteRepository, private val noteRepository: NoteRepository,
private val json: Json, private val json: Json,
) : ExportUseCase { ) : ExportService {
override fun exportAsJson(userId: Int): String { override fun exportAsJson(userId: Int): String {
val notes = noteRepository.export(userId) val notes = noteRepository.export(userId)
return json.encodeToString(ListSerializer(ExportedNote.serializer()), notes) return json.encodeToString(ListSerializer(ExportedNote.serializer()), notes)
@@ -38,7 +42,7 @@ internal class ExportUseCaseImpl(
} }
} }
class ZipOutput : AutoCloseable { private class ZipOutput : AutoCloseable {
val outputStream = ByteArrayOutputStream() val outputStream = ByteArrayOutputStream()
private val zipOutputStream = ZipArchiveOutputStream(outputStream) private val zipOutputStream = ZipArchiveOutputStream(outputStream)
+30
View File
@@ -0,0 +1,30 @@
package be.simplenotes.domain
import be.simplenotes.domain.security.HtmlSanitizer
import be.simplenotes.persistence.repositories.NoteRepository
import be.simplenotes.types.ExportedNote
import be.simplenotes.types.LoggedInUser
import jakarta.inject.Singleton
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
interface ImportService {
fun importJson(user: LoggedInUser, content: String)
}
@Singleton
internal class ImportServiceImpl(
private val noteRepository: NoteRepository,
private val json: Json,
private val htmlSanitizer: HtmlSanitizer,
) : ImportService {
override fun importJson(user: LoggedInUser, content: String) {
val notes = json.decodeFromString(ListSerializer(ExportedNote.serializer()), content)
noteRepository.import(
user.userId,
notes.map {
it.copy(html = htmlSanitizer.sanitize(user, it.html))
},
)
}
}
-1
View File
@@ -1 +0,0 @@
package be.simplenotes.domain
+73
View File
@@ -0,0 +1,73 @@
package be.simplenotes.domain
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.raise.ensure
import be.simplenotes.domain.validation.NoteValidations
import be.simplenotes.types.NoteMetadata
import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser
import io.konform.validation.ValidationErrors
import jakarta.inject.Singleton
import org.yaml.snakeyaml.Yaml
import org.yaml.snakeyaml.parser.ParserException
import org.yaml.snakeyaml.scanner.ScannerException
interface MarkdownService {
fun renderDocument(input: String): Either<MarkdownParsingError, Document>
}
@Singleton
internal class MarkdownServiceImpl(
private val parser: Parser,
private val renderer: HtmlRenderer,
) : MarkdownService {
private val yamlBoundPattern = "-{3}".toRegex()
private fun splitMetaFromDocument(input: String) = either {
val split = input.split(yamlBoundPattern, 3)
ensure(split.size >= 3) { MarkdownParsingError.MissingMeta }
split[1].trim() to split[2].trim()
}
private val yaml = Yaml()
private fun parseMeta(input: String) = either {
val load: Map<String, Any> = try {
yaml.load(input)
} catch (e: ParserException) {
raise(MarkdownParsingError.InvalidMeta)
} catch (e: ScannerException) {
raise(MarkdownParsingError.InvalidMeta)
}
val title = when (val titleNode = load["title"]) {
is String, is Number -> titleNode.toString()
else -> raise(MarkdownParsingError.InvalidMeta)
}
val tagsNode = load["tags"]
val tags = if (tagsNode !is List<*>)
emptyList()
else
tagsNode.map { it.toString() }
NoteMetadata(title, tags)
}
private fun renderMarkdown(markdown: String) = parser.parse(markdown).run(renderer::render)
override fun renderDocument(input: String) = either {
val (meta, md) = splitMetaFromDocument(input).bind()
val parsedMeta = parseMeta(meta).bind()
NoteValidations.validateMetadata(parsedMeta)?.let { raise(it) }
val html = renderMarkdown(md)
Document(parsedMeta, html)
}
}
sealed class MarkdownParsingError {
object MissingMeta : MarkdownParsingError()
object InvalidMeta : MarkdownParsingError()
class ValidationError(val validationErrors: ValidationErrors) : MarkdownParsingError()
}
data class Document(val metadata: NoteMetadata, val html: String)
@@ -1,59 +1,52 @@
package be.simplenotes.domain.usecases package be.simplenotes.domain
import arrow.core.computations.either import arrow.core.raise.either
import be.simplenotes.domain.security.HtmlSanitizer import be.simplenotes.domain.security.HtmlSanitizer
import be.simplenotes.domain.usecases.markdown.MarkdownConverter import be.simplenotes.domain.utils.parseSearchTerms
import be.simplenotes.domain.usecases.markdown.MarkdownParsingError
import be.simplenotes.domain.usecases.search.parseSearchTerms
import be.simplenotes.persistence.repositories.NoteRepository import be.simplenotes.persistence.repositories.NoteRepository
import be.simplenotes.persistence.repositories.UserRepository import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.persistence.transactions.TransactionService
import be.simplenotes.search.NoteSearcher import be.simplenotes.search.NoteSearcher
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import be.simplenotes.types.Note import be.simplenotes.types.Note
import be.simplenotes.types.PersistedNote
import be.simplenotes.types.PersistedNoteMetadata import be.simplenotes.types.PersistedNoteMetadata
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import jakarta.inject.Singleton
import java.util.* import java.util.*
import javax.annotation.PostConstruct
import javax.annotation.PreDestroy
import javax.inject.Singleton
@Singleton @Singleton
class NoteService( class NoteService(
private val markdownConverter: MarkdownConverter, private val markdownService: MarkdownService,
private val noteRepository: NoteRepository, private val noteRepository: NoteRepository,
private val userRepository: UserRepository, private val userRepository: UserRepository,
private val searcher: NoteSearcher, private val searcher: NoteSearcher,
private val htmlSanitizer: HtmlSanitizer, private val htmlSanitizer: HtmlSanitizer,
private val transaction: TransactionService,
) { ) {
fun create(user: LoggedInUser, markdownText: String) = transaction.use { fun create(user: LoggedInUser, markdownText: String) = either {
either.eager<MarkdownParsingError, PersistedNote> { markdownService.renderDocument(markdownText)
val persistedNote = !markdownConverter.renderDocument(markdownText)
.map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) } .map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) }
.map { Note(it.metadata, markdown = markdownText, html = it.html) } .map { Note(title = it.metadata.title, tags = it.metadata.tags, markdown = markdownText, html = it.html) }
.map { noteRepository.create(user.userId, it) } .map { noteRepository.create(user.userId, it) }
.bind()
searcher.indexNote(user.userId, persistedNote) .also { searcher.indexNote(user.userId, it) }
persistedNote
}
} }
fun update( fun update(user: LoggedInUser, uuid: UUID, markdownText: String) =
user: LoggedInUser, either {
uuid: UUID, markdownService.renderDocument(markdownText)
markdownText: String,
) = transaction.use {
either.eager<MarkdownParsingError, PersistedNote?> {
val persistedNote = !markdownConverter.renderDocument(markdownText)
.map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) } .map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) }
.map { Note(it.metadata, markdown = markdownText, html = it.html) } .map {
.map { noteRepository.update(user.userId, uuid, it) } Note(
title = it.metadata.title,
persistedNote?.let { searcher.updateIndex(user.userId, it) } tags = it.metadata.tags,
persistedNote markdown = markdownText,
html = it.html,
)
} }
.map { noteRepository.update(user.userId, uuid, it) }
.bind()
?.also { searcher.updateIndex(user.userId, it) }
} }
fun paginatedNotes( fun paginatedNotes(
@@ -72,22 +65,22 @@ class NoteService(
fun find(userId: Int, uuid: UUID) = noteRepository.find(userId, uuid) fun find(userId: Int, uuid: UUID) = noteRepository.find(userId, uuid)
fun trash(userId: Int, uuid: UUID): Boolean = transaction.use { fun trash(userId: Int, uuid: UUID): Boolean {
val res = noteRepository.delete(userId, uuid, permanent = false) val res = noteRepository.delete(userId, uuid, permanent = false)
if (res) searcher.deleteIndex(userId, uuid) if (res) searcher.deleteIndex(userId, uuid)
res return res
} }
fun restore(userId: Int, uuid: UUID): Boolean = transaction.use { fun restore(userId: Int, uuid: UUID): Boolean {
val res = noteRepository.restore(userId, uuid) val res = noteRepository.restore(userId, uuid)
if (res) find(userId, uuid)?.let { note -> searcher.indexNote(userId, note) } if (res) find(userId, uuid)?.let { note -> searcher.indexNote(userId, note) }
res return res
} }
fun delete(userId: Int, uuid: UUID): Boolean = transaction.use { fun delete(userId: Int, uuid: UUID): Boolean {
val res = noteRepository.delete(userId, uuid, permanent = true) val res = noteRepository.delete(userId, uuid, permanent = true)
if (res) searcher.deleteIndex(userId, uuid) if (res) searcher.deleteIndex(userId, uuid)
res return res
} }
fun countDeleted(userId: Int) = noteRepository.count(userId, deleted = true) fun countDeleted(userId: Int) = noteRepository.count(userId, deleted = true)
@@ -107,8 +100,8 @@ class NoteService(
@PreDestroy @PreDestroy
fun dropAllIndexes() = searcher.dropAll() fun dropAllIndexes() = searcher.dropAll()
fun makePublic(userId: Int, uuid: UUID) = transaction.use { noteRepository.makePublic(userId, uuid) } fun makePublic(userId: Int, uuid: UUID) = noteRepository.makePublic(userId, uuid)
fun makePrivate(userId: Int, uuid: UUID) = transaction.use { noteRepository.makePrivate(userId, uuid) } fun makePrivate(userId: Int, uuid: UUID) = noteRepository.makePrivate(userId, uuid)
fun findPublic(uuid: UUID) = noteRepository.findPublic(uuid) fun findPublic(uuid: UUID) = noteRepository.findPublic(uuid)
} }
+80
View File
@@ -0,0 +1,80 @@
package be.simplenotes.domain
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.raise.ensure
import arrow.core.raise.ensureNotNull
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.validation.UserValidations
import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.search.NoteSearcher
import be.simplenotes.types.LoggedInUser
import be.simplenotes.types.PersistedUser
import io.konform.validation.ValidationErrors
import jakarta.inject.Singleton
import kotlinx.serialization.Serializable
interface UserService {
fun register(form: RegisterForm): Either<RegisterError, PersistedUser>
fun login(form: LoginForm): Either<LoginError, Token>
fun delete(form: DeleteForm): Either<DeleteError, Unit>
}
@Singleton
internal class UserServiceImpl(
private val userRepository: UserRepository,
private val passwordHash: PasswordHash,
private val jwt: SimpleJwt<LoggedInUser>,
private val searcher: NoteSearcher,
) : UserService {
override fun register(form: RegisterForm) = either {
val user = UserValidations.validateRegister(form).bind()
ensure(!userRepository.exists(user.username)) { RegisterError.UserExists }
ensureNotNull(userRepository.create(user.copy(password = passwordHash.crypt(user.password)))) {
RegisterError.UserExists
}
}
override fun login(form: LoginForm) = either {
val user = UserValidations.validateLogin(form).bind()
val persistedUser = ensureNotNull(userRepository.find(user.username)) { LoginError.Unregistered }
ensure(passwordHash.verify(form.password!!, persistedUser.password)) { LoginError.WrongPassword }
jwt.sign(LoggedInUser(persistedUser))
}
override fun delete(form: DeleteForm) = either {
val user = UserValidations.validateDelete(form).bind()
val persistedUser = ensureNotNull(userRepository.find(user.username)) { DeleteError.Unregistered }
ensure(passwordHash.verify(user.password, persistedUser.password)) { DeleteError.WrongPassword }
ensure(userRepository.delete(persistedUser.id)) { DeleteError.Unregistered }
searcher.dropIndex(persistedUser.id)
}
}
sealed class DeleteError {
object Unregistered : DeleteError()
object WrongPassword : DeleteError()
class InvalidForm(val validationErrors: ValidationErrors) : DeleteError()
}
data class DeleteForm(val username: String?, val password: String?, val checked: Boolean)
sealed class LoginError {
object Unregistered : LoginError()
object WrongPassword : LoginError()
class InvalidLoginForm(val validationErrors: ValidationErrors) : LoginError()
}
typealias Token = String
sealed class RegisterError {
object UserExists : RegisterError()
class InvalidRegisterForm(val validationErrors: ValidationErrors) : RegisterError()
}
typealias RegisterForm = LoginForm
@Serializable
data class LoginForm(val username: String?, val password: String?)
@@ -1,11 +1,11 @@
package be.simplenotes.domain.usecases.markdown package be.simplenotes.domain.modules
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension
import com.vladsch.flexmark.html.HtmlRenderer import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser import com.vladsch.flexmark.parser.Parser
import com.vladsch.flexmark.util.data.MutableDataSet import com.vladsch.flexmark.util.data.MutableDataSet
import io.micronaut.context.annotation.Factory import io.micronaut.context.annotation.Factory
import javax.inject.Singleton import jakarta.inject.Singleton
@Factory @Factory
class FlexmarkFactory { class FlexmarkFactory {
@@ -23,11 +23,11 @@ class FlexmarkFactory {
set(TaskListExtension.LOOSE_ITEM_CLASS, "") set(TaskListExtension.LOOSE_ITEM_CLASS, "")
set( set(
TaskListExtension.ITEM_DONE_MARKER, TaskListExtension.ITEM_DONE_MARKER,
"""<input type="checkbox" checked="checked" disabled="disabled" readonly="readonly" />&nbsp;""" """<input type="checkbox" checked="checked" disabled="disabled" readonly="readonly" />&nbsp;""",
) )
set( set(
TaskListExtension.ITEM_NOT_DONE_MARKER, TaskListExtension.ITEM_NOT_DONE_MARKER,
"""<input type="checkbox" disabled="disabled" readonly="readonly" />&nbsp;""" """<input type="checkbox" disabled="disabled" readonly="readonly" />&nbsp;""",
) )
set(HtmlRenderer.SOFT_BREAK, "<br>") set(HtmlRenderer.SOFT_BREAK, "<br>")
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import be.simplenotes.types.LoggedInUser
import org.owasp.html.HtmlChangeListener import org.owasp.html.HtmlChangeListener
import org.owasp.html.HtmlPolicyBuilder import org.owasp.html.HtmlPolicyBuilder
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class HtmlSanitizer { class HtmlSanitizer {
+1 -1
View File
@@ -3,7 +3,7 @@ package be.simplenotes.domain.security
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import com.auth0.jwt.JWTCreator import com.auth0.jwt.JWTCreator
import com.auth0.jwt.interfaces.DecodedJWT import com.auth0.jwt.interfaces.DecodedJWT
import javax.inject.Singleton import jakarta.inject.Singleton
interface JwtMapper<T> { interface JwtMapper<T> {
fun extract(decodedJWT: DecodedJWT): T? fun extract(decodedJWT: DecodedJWT): T?
+2 -2
View File
@@ -1,8 +1,8 @@
package be.simplenotes.domain.security package be.simplenotes.domain.security
import org.mindrot.jbcrypt.BCrypt import org.mindrot.jbcrypt.BCrypt
import javax.inject.Inject import jakarta.inject.Inject
import javax.inject.Singleton import jakarta.inject.Singleton
internal interface PasswordHash { internal interface PasswordHash {
fun crypt(password: String): String fun crypt(password: String): String
+1 -1
View File
@@ -7,7 +7,7 @@ import com.auth0.jwt.algorithms.Algorithm
import com.auth0.jwt.exceptions.JWTVerificationException import com.auth0.jwt.exceptions.JWTVerificationException
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class SimpleJwt<T>(jwtConfig: JwtConfig, private val mapper: JwtMapper<T>) { class SimpleJwt<T>(jwtConfig: JwtConfig, private val mapper: JwtMapper<T>) {
-13
View File
@@ -1,13 +0,0 @@
package be.simplenotes.domain.usecases
import be.simplenotes.persistence.DbHealthCheck
import javax.inject.Singleton
interface HealthCheckService {
fun isOk(): Boolean
}
@Singleton
class HealthCheckServiceImpl(private val dbHealthCheck: DbHealthCheck) : HealthCheckService {
override fun isOk() = dbHealthCheck.isOk()
}
-18
View File
@@ -1,18 +0,0 @@
package be.simplenotes.domain.usecases
import be.simplenotes.domain.usecases.export.ExportUseCase
import be.simplenotes.domain.usecases.users.delete.DeleteUseCase
import be.simplenotes.domain.usecases.users.login.LoginUseCase
import be.simplenotes.domain.usecases.users.register.RegisterUseCase
import javax.inject.Singleton
@Singleton
class UserService(
loginUseCase: LoginUseCase,
registerUseCase: RegisterUseCase,
deleteUseCase: DeleteUseCase,
exportUseCase: ExportUseCase,
) : LoginUseCase by loginUseCase,
RegisterUseCase by registerUseCase,
DeleteUseCase by deleteUseCase,
ExportUseCase by exportUseCase
@@ -1,8 +0,0 @@
package be.simplenotes.domain.usecases.export
import java.io.InputStream
interface ExportUseCase {
fun exportAsJson(userId: Int): String
fun exportAsZip(userId: Int): InputStream
}
@@ -1,16 +0,0 @@
package be.simplenotes.domain.usecases.markdown
import arrow.core.Either
import be.simplenotes.types.NoteMetadata
import io.konform.validation.ValidationErrors
sealed class MarkdownParsingError
object MissingMeta : MarkdownParsingError()
object InvalidMeta : MarkdownParsingError()
class ValidationError(val validationErrors: ValidationErrors) : MarkdownParsingError()
data class Document(val metadata: NoteMetadata, val html: String)
interface MarkdownConverter {
fun renderDocument(input: String): Either<MarkdownParsingError, Document>
}
@@ -1,63 +0,0 @@
package be.simplenotes.domain.usecases.markdown
import arrow.core.Either
import arrow.core.computations.either
import arrow.core.left
import arrow.core.right
import be.simplenotes.domain.validation.NoteValidations
import be.simplenotes.types.NoteMetadata
import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser
import org.yaml.snakeyaml.Yaml
import org.yaml.snakeyaml.parser.ParserException
import org.yaml.snakeyaml.scanner.ScannerException
import javax.inject.Singleton
private typealias MetaMdPair = Pair<String, String>
@Singleton
internal class MarkdownConverterImpl(
private val parser: Parser,
private val renderer: HtmlRenderer,
) : MarkdownConverter {
private val yamlBoundPattern = "-{3}".toRegex()
private fun splitMetaFromDocument(input: String): Either<MissingMeta, MetaMdPair> {
val split = input.split(yamlBoundPattern, 3)
if (split.size < 3) return MissingMeta.left()
return (split[1].trim() to split[2].trim()).right()
}
private val yaml = Yaml()
private fun parseMeta(input: String): Either<InvalidMeta, NoteMetadata> {
val load: Map<String, Any> = try {
yaml.load(input)
} catch (e: ParserException) {
return InvalidMeta.left()
} catch (e: ScannerException) {
return InvalidMeta.left()
}
val title = when (val titleNode = load["title"]) {
is String, is Number -> titleNode.toString()
else -> return InvalidMeta.left()
}
val tagsNode = load["tags"]
val tags = if (tagsNode !is List<*>)
emptyList()
else
tagsNode.map { it.toString() }
return NoteMetadata(title, tags).right()
}
private fun renderMarkdown(markdown: String) = parser.parse(markdown).run(renderer::render)
override fun renderDocument(input: String) = either.eager<MarkdownParsingError, Document> {
val (meta, md) = !splitMetaFromDocument(input)
val parsedMeta = !parseMeta(meta)
!Either.fromNullable(NoteValidations.validateMetadata(parsedMeta)).swap()
val html = renderMarkdown(md)
Document(parsedMeta, html)
}
}
@@ -1,43 +0,0 @@
package be.simplenotes.domain.usecases.search
import be.simplenotes.search.SearchTerms
private fun innerRegex(name: String) =
"""$name:['"](.*?)['"]""".toRegex()
private fun outerRegex(name: String) =
"""($name:['"].*?['"])""".toRegex()
private val titleRe = innerRegex("title")
private val outerTitleRe = outerRegex("title")
private val tagRe = innerRegex("tag")
private val outerTagRe = outerRegex("tag")
private val contentRe = innerRegex("content")
private val outerContentRe = outerRegex("content")
internal fun parseSearchTerms(input: String): SearchTerms {
var c: String = input
fun extract(innerRegex: Regex, outerRegex: Regex): String? {
val match = innerRegex.find(input)?.groups?.get(1)?.value
if (match != null) {
val group = outerRegex.find(input)?.groups?.get(1)?.value
group?.let { c = c.replace(it, "") }
}
return match
}
val title: String? = extract(titleRe, outerTitleRe)
val tag: String? = extract(tagRe, outerTagRe)
val content: String? = extract(contentRe, outerContentRe)
val all = c.trim().ifEmpty { null }
return SearchTerms(
title = title,
tag = tag,
content = content,
all = all
)
}
@@ -1,35 +0,0 @@
package be.simplenotes.domain.usecases.users.delete
import arrow.core.Either
import arrow.core.computations.either
import arrow.core.rightIfNotNull
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.validation.UserValidations
import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.persistence.transactions.TransactionService
import be.simplenotes.search.NoteSearcher
import io.micronaut.context.annotation.Primary
import javax.inject.Singleton
@Primary
@Singleton
internal class DeleteUseCaseImpl(
private val userRepository: UserRepository,
private val passwordHash: PasswordHash,
private val searcher: NoteSearcher,
private val transactionService: TransactionService,
) : DeleteUseCase {
override fun delete(form: DeleteForm) = transactionService.use {
either.eager<DeleteError, Unit> {
val user = !UserValidations.validateDelete(form)
val persistedUser = !userRepository.find(user.username).rightIfNotNull { DeleteError.Unregistered }
!Either.conditionally(
passwordHash.verify(user.password, persistedUser.password),
{ DeleteError.WrongPassword },
{ Unit }
)
!Either.conditionally(userRepository.delete(persistedUser.id), { DeleteError.Unregistered }, { Unit })
searcher.dropIndex(persistedUser.id)
}
}
}
@@ -1,16 +0,0 @@
package be.simplenotes.domain.usecases.users.delete
import arrow.core.Either
import io.konform.validation.ValidationErrors
sealed class DeleteError {
object Unregistered : DeleteError()
object WrongPassword : DeleteError()
class InvalidForm(val validationErrors: ValidationErrors) : DeleteError()
}
data class DeleteForm(val username: String?, val password: String?, val checked: Boolean)
interface DeleteUseCase {
fun delete(form: DeleteForm): Either<DeleteError, Unit>
}
@@ -1,28 +0,0 @@
package be.simplenotes.domain.usecases.users.login
import arrow.core.computations.either
import arrow.core.filterOrElse
import arrow.core.rightIfNotNull
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.validation.UserValidations
import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.types.LoggedInUser
import io.micronaut.context.annotation.Primary
import javax.inject.Singleton
@Singleton
@Primary
internal class LoginUseCaseImpl(
private val userRepository: UserRepository,
private val passwordHash: PasswordHash,
private val jwt: SimpleJwt<LoggedInUser>
) : LoginUseCase {
override fun login(form: LoginForm) = either.eager<LoginError, Token> {
val user = !UserValidations.validateLogin(form)
!userRepository.find(user.username)
.rightIfNotNull { Unregistered }
.filterOrElse({ passwordHash.verify(form.password!!, it.password) }, { WrongPassword })
.map { jwt.sign(LoggedInUser(it)) }
}
}
@@ -1,19 +0,0 @@
package be.simplenotes.domain.usecases.users.login
import arrow.core.Either
import io.konform.validation.ValidationErrors
import kotlinx.serialization.Serializable
sealed class LoginError
object Unregistered : LoginError()
object WrongPassword : LoginError()
class InvalidLoginForm(val validationErrors: ValidationErrors) : LoginError()
typealias Token = String
@Serializable
data class LoginForm(val username: String?, val password: String?)
interface LoginUseCase {
fun login(form: LoginForm): Either<LoginError, Token>
}
@@ -1,28 +0,0 @@
package be.simplenotes.domain.usecases.users.register
import arrow.core.Either
import arrow.core.filterOrElse
import arrow.core.leftIfNull
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.validation.UserValidations
import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.persistence.transactions.TransactionService
import be.simplenotes.types.PersistedUser
import io.micronaut.context.annotation.Primary
import javax.inject.Singleton
@Primary
@Singleton
internal class RegisterUseCaseImpl(
private val userRepository: UserRepository,
private val passwordHash: PasswordHash,
private val transactionService: TransactionService,
) : RegisterUseCase {
override fun register(form: RegisterForm): Either<RegisterError, PersistedUser> = transactionService.use {
UserValidations.validateRegister(form)
.filterOrElse({ !userRepository.exists(it.username) }, { UserExists })
.map { it.copy(password = passwordHash.crypt(it.password)) }
.map { userRepository.create(it) }
.leftIfNull { UserExists }
}
}
@@ -1,16 +0,0 @@
package be.simplenotes.domain.usecases.users.register
import arrow.core.Either
import be.simplenotes.domain.usecases.users.login.LoginForm
import be.simplenotes.types.PersistedUser
import io.konform.validation.ValidationErrors
sealed class RegisterError
object UserExists : RegisterError()
class InvalidRegisterForm(val validationErrors: ValidationErrors) : RegisterError()
typealias RegisterForm = LoginForm
interface RegisterUseCase {
fun register(form: RegisterForm): Either<RegisterError, PersistedUser>
}
+94
View File
@@ -0,0 +1,94 @@
package be.simplenotes.domain.utils
import be.simplenotes.search.SearchTerms
import java.util.*
private enum class Quote { SingleQuote, DoubleQuote, }
private data class ParsedSearchInput(val global: List<String>, val entries: Map<String, String>)
private fun parseInput(input: String): ParsedSearchInput {
val tokenizer = StringTokenizer(input, ":\"' ", true)
val tokens = ArrayList<String>()
val current = StringBuilder()
var quoteOpen: Quote? = null
fun push() {
if (current.isNotEmpty()) {
tokens.add(current.toString())
}
current.setLength(0)
quoteOpen = null
}
while (tokenizer.hasMoreTokens()) {
when (val token = tokenizer.nextToken()) {
"\"" -> when {
Quote.DoubleQuote == quoteOpen -> push()
quoteOpen == null -> quoteOpen = Quote.DoubleQuote
else -> current.append(token)
}
"'" -> when {
Quote.SingleQuote == quoteOpen -> push()
quoteOpen == null -> quoteOpen = Quote.SingleQuote
else -> current.append(token)
}
" " -> {
if (quoteOpen != null) current.append(" ")
else push()
}
":" -> {
push()
tokens.add(token)
}
else -> {
current.append(token)
}
}
}
push()
val entries = HashMap<String, String>()
val colonIndexes = ArrayList<Int>()
tokens.forEachIndexed { index, token ->
if (token == ":") colonIndexes += index
}
var changes = 0
for (colonIndex in colonIndexes) {
val offset = changes * 3
val key = tokens.getOrNull(colonIndex - 1 - offset)
val value = tokens.getOrNull(colonIndex + 1 - offset)
if (key != null && value != null) {
entries[key] = value
tokens.removeAt(colonIndex - 1 - offset) // remove key
tokens.removeAt(colonIndex - 1 - offset) // remove :
tokens.removeAt(colonIndex - 1 - offset) // remove value
changes++
}
}
return ParsedSearchInput(global = tokens, entries = entries)
}
internal fun parseSearchTerms(input: String): SearchTerms {
val parsedInput = parseInput(input)
val title: String? = parsedInput.entries["title"]
val tag: String? = parsedInput.entries["tag"]
val content: String? = parsedInput.entries["content"]
val all = parsedInput.global.takeIf { it.isNotEmpty() }?.joinToString(" ")
return SearchTerms(
title = title,
tag = tag,
content = content,
all = all,
)
}
+3 -3
View File
@@ -1,6 +1,6 @@
package be.simplenotes.domain.validation package be.simplenotes.domain.validation
import be.simplenotes.domain.usecases.markdown.ValidationError import be.simplenotes.domain.MarkdownParsingError
import be.simplenotes.types.NoteMetadata import be.simplenotes.types.NoteMetadata
import io.konform.validation.Validation import io.konform.validation.Validation
import io.konform.validation.jsonschema.maxItems import io.konform.validation.jsonschema.maxItems
@@ -27,9 +27,9 @@ internal object NoteValidations {
} }
} }
fun validateMetadata(meta: NoteMetadata): ValidationError? { fun validateMetadata(meta: NoteMetadata): MarkdownParsingError.ValidationError? {
val errors = metaValidator.validate(meta).errors val errors = metaValidator.validate(meta).errors
return if (errors.isEmpty()) null return if (errors.isEmpty()) null
else return ValidationError(errors) else return MarkdownParsingError.ValidationError(errors)
} }
} }
+12 -18
View File
@@ -1,14 +1,8 @@
package be.simplenotes.domain.validation package be.simplenotes.domain.validation
import arrow.core.Either import arrow.core.raise.either
import arrow.core.left import arrow.core.raise.ensure
import arrow.core.right import be.simplenotes.domain.*
import be.simplenotes.domain.usecases.users.delete.DeleteError
import be.simplenotes.domain.usecases.users.delete.DeleteForm
import be.simplenotes.domain.usecases.users.login.InvalidLoginForm
import be.simplenotes.domain.usecases.users.login.LoginForm
import be.simplenotes.domain.usecases.users.register.InvalidRegisterForm
import be.simplenotes.domain.usecases.users.register.RegisterForm
import be.simplenotes.types.User import be.simplenotes.types.User
import io.konform.validation.Validation import io.konform.validation.Validation
import io.konform.validation.jsonschema.maxLength import io.konform.validation.jsonschema.maxLength
@@ -26,16 +20,16 @@ internal object UserValidations {
} }
} }
fun validateLogin(form: LoginForm): Either<InvalidLoginForm, User> { fun validateLogin(form: LoginForm) = either<LoginError.InvalidLoginForm, User> {
val errors = loginValidator.validate(form).errors val errors = loginValidator.validate(form).errors
return if (errors.isEmpty()) User(form.username!!, form.password!!).right() ensure(errors.isEmpty()) { LoginError.InvalidLoginForm(errors) }
else return InvalidLoginForm(errors).left() User(form.username!!, form.password!!)
} }
fun validateRegister(form: RegisterForm): Either<InvalidRegisterForm, User> { fun validateRegister(form: RegisterForm) = either {
val errors = loginValidator.validate(form).errors val errors = loginValidator.validate(form).errors
return if (errors.isEmpty()) User(form.username!!, form.password!!).right() ensure(errors.isEmpty()) { RegisterError.InvalidRegisterForm(errors) }
else return InvalidRegisterForm(errors).left() User(form.username!!, form.password!!)
} }
private val deleteValidator = Validation<DeleteForm> { private val deleteValidator = Validation<DeleteForm> {
@@ -52,9 +46,9 @@ internal object UserValidations {
} }
} }
fun validateDelete(form: DeleteForm): Either<DeleteError.InvalidForm, User> { fun validateDelete(form: DeleteForm) = either {
val errors = deleteValidator.validate(form).errors val errors = deleteValidator.validate(form).errors
return if (errors.isEmpty()) User(form.username!!, form.password!!).right() ensure(errors.isEmpty()) { DeleteError.InvalidForm(errors) }
else return DeleteError.InvalidForm(errors).left() User(form.username!!, form.password!!)
} }
} }
-1
View File
@@ -1 +0,0 @@
package be.simplenotes.domain
+96
View File
@@ -0,0 +1,96 @@
@file:Suppress("MemberVisibilityCanBePrivate")
package be.simplenotes.domain
import be.simplenotes.config.JwtConfig
import be.simplenotes.domain.security.BcryptPasswordHash
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.security.UserJwtMapper
import be.simplenotes.domain.testutils.isLeftOfType
import be.simplenotes.domain.testutils.isRight
import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.types.PersistedUser
import com.natpryce.hamkrest.assertion.assertThat
import com.natpryce.hamkrest.equalTo
import io.mockk.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.concurrent.TimeUnit
internal class UserServiceTest {
val userRepository = mockk<UserRepository>()
val passwordHash = BcryptPasswordHash(test = true)
val jwtConfig = JwtConfig("a secret", 1, TimeUnit.HOURS)
val simpleJwt = SimpleJwt(jwtConfig, UserJwtMapper())
val userService = UserServiceImpl(
userRepository = userRepository,
passwordHash = passwordHash,
jwt = simpleJwt,
searcher = mockk(),
)
@BeforeEach
fun resetMocks() {
clearMocks(userRepository)
}
@Test
fun `register should fail with invalid form`() {
val form = RegisterForm("", "a".repeat(10))
assertThat(userService.register(form), isLeftOfType<RegisterError.InvalidRegisterForm>())
verify { userRepository wasNot called }
}
@Test
fun `Register should fail with existing username`() {
val form = RegisterForm("someuser", "somepassword")
every { userRepository.exists(form.username!!) } returns true
assertThat(userService.register(form), isLeftOfType<RegisterError.UserExists>())
}
@Test
fun `Register should succeed with new user`() {
val form = RegisterForm("someuser", "somepassword")
every { userRepository.exists(form.username!!) } returns false
every { userRepository.create(any()) } returns PersistedUser(form.username!!, form.password!!, 1)
val res = userService.register(form)
assertThat(res, isRight())
res.map { assertThat(it.username, equalTo(form.username)) }
}
@Test
fun `Login should fail with invalid form`() {
val form = LoginForm("", "a")
assertThat(userService.login(form), isLeftOfType<LoginError.InvalidLoginForm>())
verify { userRepository wasNot called }
}
@Test
fun `Login should fail with non existing user`() {
val form = LoginForm("someusername", "somepassword")
every { userRepository.find(form.username!!) } returns null
assertThat(userService.login(form), isLeftOfType<LoginError.Unregistered>())
}
@Test
fun `Login should fail with wrong password`() {
val form = LoginForm("someusername", "wrongpassword")
every { userRepository.find(form.username!!) } returns
PersistedUser(form.username!!, passwordHash.crypt("right password"), 1)
assertThat(userService.login(form), isLeftOfType<LoginError.WrongPassword>())
}
@Test
fun `Login should succeed with existing user and correct password`() {
val loginForm = LoginForm("someusername", "somepassword")
every { userRepository.find(loginForm.username!!) } returns
PersistedUser(loginForm.username!!, passwordHash.crypt(loginForm.password!!), 1)
val res = userService.login(loginForm)
assertThat(res, isRight())
}
}
@@ -1,7 +1,7 @@
package be.simplenotes.domain.security package be.simplenotes.domain.security
import be.simplenotes.config.JwtConfig import be.simplenotes.config.JwtConfig
import be.simplenotes.domain.usecases.users.login.Token import be.simplenotes.domain.Token
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import com.auth0.jwt.JWT import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm import com.auth0.jwt.algorithms.Algorithm
@@ -33,7 +33,7 @@ internal class LoggedInUserExtractorTest {
createToken(), createToken(),
createToken(username = "user", id = 1, secret = "not the correct secret"), createToken(username = "user", id = 1, secret = "not the correct secret"),
createToken(username = "user", id = 1) + "\"efesfsef", createToken(username = "user", id = 1) + "\"efesfsef",
"something that is not even a token" "something that is not even a token",
) )
@ParameterizedTest(name = "[{index}] token `{0}` should be invalid") @ParameterizedTest(name = "[{index}] token `{0}` should be invalid")
+2 -2
View File
@@ -21,7 +21,7 @@ fun isRight() = object : Matcher<Either<*, *>> {
override fun invoke(actual: Either<*, *>) = when (actual) { override fun invoke(actual: Either<*, *>) = when (actual) {
is Either.Right -> MatchResult.Match is Either.Right -> MatchResult.Match
is Either.Left -> { is Either.Left -> {
val valueA = actual.a val valueA = actual.value
MatchResult.Mismatch("is Either.Left<${if (valueA == null) "Null" else valueA::class.simpleName}>") MatchResult.Mismatch("is Either.Left<${if (valueA == null) "Null" else valueA::class.simpleName}>")
} }
} }
@@ -34,7 +34,7 @@ inline fun <reified A> isLeftOfType() = object : Matcher<Either<*, *>> {
override fun invoke(actual: Either<*, *>) = when (actual) { override fun invoke(actual: Either<*, *>) = when (actual) {
is Either.Right -> MatchResult.Mismatch("was Either.Right<>") is Either.Right -> MatchResult.Mismatch("was Either.Right<>")
is Either.Left -> { is Either.Left -> {
val valueA = actual.a val valueA = actual.value
if (valueA is A) MatchResult.Match if (valueA is A) MatchResult.Match
else MatchResult.Mismatch("was Left<${if (valueA == null) "Null" else valueA::class.simpleName}>") else MatchResult.Mismatch("was Left<${if (valueA == null) "Null" else valueA::class.simpleName}>")
} }
@@ -1,65 +0,0 @@
package be.simplenotes.domain.usecases.users.login
import be.simplenotes.config.JwtConfig
import be.simplenotes.domain.security.BcryptPasswordHash
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.security.UserJwtMapper
import be.simplenotes.domain.testutils.isLeftOfType
import be.simplenotes.domain.testutils.isRight
import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.types.PersistedUser
import com.natpryce.hamkrest.assertion.assertThat
import io.mockk.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.concurrent.TimeUnit
internal class LoginUseCaseImplTest {
// region setup
private val mockUserRepository = mockk<UserRepository>()
private val passwordHash = BcryptPasswordHash(test = true)
private val jwtConfig = JwtConfig("a secret", 1, TimeUnit.HOURS)
private val simpleJwt = SimpleJwt(jwtConfig, UserJwtMapper())
private val loginUseCase = LoginUseCaseImpl(mockUserRepository, passwordHash, simpleJwt)
@BeforeEach
fun resetMocks() {
clearMocks(mockUserRepository)
}
// endregion
@Test
fun `Login should fail with invalid form`() {
val form = LoginForm("", "a")
assertThat(loginUseCase.login(form), isLeftOfType<InvalidLoginForm>())
verify { mockUserRepository wasNot called }
}
@Test
fun `Login should fail with non existing user`() {
val form = LoginForm("someusername", "somepassword")
every { mockUserRepository.find(form.username!!) } returns null
assertThat(loginUseCase.login(form), isLeftOfType<Unregistered>())
}
@Test
fun `Login should fail with wrong password`() {
val form = LoginForm("someusername", "wrongpassword")
every { mockUserRepository.find(form.username!!) } returns
PersistedUser(form.username!!, passwordHash.crypt("right password"), 1)
assertThat(loginUseCase.login(form), isLeftOfType<WrongPassword>())
}
@Test
fun `Login should succeed with existing user and correct password`() {
val loginForm = LoginForm("someusername", "somepassword")
every { mockUserRepository.find(loginForm.username!!) } returns
PersistedUser(loginForm.username!!, passwordHash.crypt(loginForm.password!!), 1)
val res = loginUseCase.login(loginForm)
assertThat(res, isRight())
}
}
@@ -1,54 +0,0 @@
package be.simplenotes.domain.usecases.users.register
import be.simplenotes.domain.security.BcryptPasswordHash
import be.simplenotes.domain.testutils.isLeftOfType
import be.simplenotes.domain.testutils.isRight
import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.persistence.transactions.TransactionService
import be.simplenotes.types.PersistedUser
import com.natpryce.hamkrest.assertion.assertThat
import com.natpryce.hamkrest.equalTo
import io.mockk.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
internal class RegisterUseCaseImplTest {
// region setup
private val mockUserRepository = mockk<UserRepository>()
private val passwordHash = BcryptPasswordHash(test = true)
private val noopTransactionService = object : TransactionService {
override fun <T> use(block: () -> T) = block()
}
private val registerUseCase = RegisterUseCaseImpl(mockUserRepository, passwordHash, noopTransactionService)
@BeforeEach
fun resetMocks() {
clearMocks(mockUserRepository)
}
// endregion
@Test
fun `register should fail with invalid form`() {
val form = RegisterForm("", "a".repeat(10))
assertThat(registerUseCase.register(form), isLeftOfType<InvalidRegisterForm>())
verify { mockUserRepository wasNot called }
}
@Test
fun `Register should fail with existing username`() {
val form = RegisterForm("someuser", "somepassword")
every { mockUserRepository.exists(form.username!!) } returns true
assertThat(registerUseCase.register(form), isLeftOfType<UserExists>())
}
@Test
fun `Register should succeed with new user`() {
val form = RegisterForm("someuser", "somepassword")
every { mockUserRepository.exists(form.username!!) } returns false
every { mockUserRepository.create(any()) } returns PersistedUser(form.username!!, form.password!!, 1)
val res = registerUseCase.register(form)
assertThat(res, isRight())
res.map { assertThat(it.username, equalTo(form.username)) }
}
}
@@ -1,4 +1,4 @@
package be.simplenotes.domain.usecases.search package be.simplenotes.domain.utils
import be.simplenotes.search.SearchTerms import be.simplenotes.search.SearchTerms
import com.natpryce.hamkrest.assertion.assertThat import com.natpryce.hamkrest.assertion.assertThat
@@ -33,7 +33,15 @@ internal class SearchTermsParserKtTest {
"tag:'example abc' title:'other with words' this is the end ", "tag:'example abc' title:'other with words' this is the end ",
title = "other with words", title = "other with words",
tag = "example abc", tag = "example abc",
all = "this is the end" all = "this is the end",
),
createResult("tag:blah", tag = "blah"),
createResult("tag:'some words'", tag = "some words"),
createResult("tag:'some words ' global", tag = "some words ", all = "global"),
createResult(
"tag:'double quote inside single \" ' global",
tag = "double quote inside single \" ",
all = "global",
), ),
) )
@@ -1,10 +1,10 @@
package be.simplenotes.domain.validation package be.simplenotes.domain.validation
import be.simplenotes.domain.LoginError
import be.simplenotes.domain.LoginForm
import be.simplenotes.domain.RegisterForm
import be.simplenotes.domain.testutils.isLeftOfType import be.simplenotes.domain.testutils.isLeftOfType
import be.simplenotes.domain.testutils.isRight import be.simplenotes.domain.testutils.isRight
import be.simplenotes.domain.usecases.users.login.InvalidLoginForm
import be.simplenotes.domain.usecases.users.login.LoginForm
import be.simplenotes.domain.usecases.users.register.RegisterForm
import com.natpryce.hamkrest.assertion.assertThat import com.natpryce.hamkrest.assertion.assertThat
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
@@ -22,19 +22,19 @@ internal class UserValidationsTest {
LoginForm(username = "", password = ""), LoginForm(username = "", password = ""),
LoginForm(username = "a", password = "aaaa"), LoginForm(username = "a", password = "aaaa"),
LoginForm(username = "a".repeat(51), password = "a".repeat(8)), LoginForm(username = "a".repeat(51), password = "a".repeat(8)),
LoginForm(username = "a".repeat(10), password = "a".repeat(7)) LoginForm(username = "a".repeat(10), password = "a".repeat(7)),
) )
@ParameterizedTest @ParameterizedTest
@MethodSource("invalidLoginForms") @MethodSource("invalidLoginForms")
fun `validate invalid logins`(form: LoginForm) { fun `validate invalid logins`(form: LoginForm) {
assertThat(UserValidations.validateLogin(form), isLeftOfType<InvalidLoginForm>()) assertThat(UserValidations.validateLogin(form), isLeftOfType<LoginError.InvalidLoginForm>())
} }
@Suppress("Unused") @Suppress("Unused")
fun validLoginForms(): Stream<LoginForm> = Stream.of( fun validLoginForms(): Stream<LoginForm> = Stream.of(
LoginForm(username = "a".repeat(50), password = "a".repeat(72)), LoginForm(username = "a".repeat(50), password = "a".repeat(72)),
LoginForm(username = "a".repeat(3), password = "a".repeat(8)) LoginForm(username = "a".repeat(3), password = "a".repeat(8)),
) )
@ParameterizedTest @ParameterizedTest
@@ -53,19 +53,19 @@ internal class UserValidationsTest {
RegisterForm(username = "", password = ""), RegisterForm(username = "", password = ""),
RegisterForm(username = "a", password = "aaaa"), RegisterForm(username = "a", password = "aaaa"),
RegisterForm(username = "a".repeat(51), password = "a".repeat(8)), RegisterForm(username = "a".repeat(51), password = "a".repeat(8)),
RegisterForm(username = "a".repeat(10), password = "a".repeat(7)) RegisterForm(username = "a".repeat(10), password = "a".repeat(7)),
) )
@ParameterizedTest @ParameterizedTest
@MethodSource("invalidRegisterForms") @MethodSource("invalidRegisterForms")
fun `validate invalid register`(form: LoginForm) { fun `validate invalid register`(form: LoginForm) {
assertThat(UserValidations.validateLogin(form), isLeftOfType<InvalidLoginForm>()) assertThat(UserValidations.validateLogin(form), isLeftOfType<LoginError.InvalidLoginForm>())
} }
@Suppress("Unused") @Suppress("Unused")
fun validRegisterForms(): Stream<RegisterForm> = Stream.of( fun validRegisterForms(): Stream<RegisterForm> = Stream.of(
RegisterForm(username = "a".repeat(50), password = "a".repeat(72)), RegisterForm(username = "a".repeat(50), password = "a".repeat(72)),
RegisterForm(username = "a".repeat(3), password = "a".repeat(8)) RegisterForm(username = "a".repeat(3), password = "a".repeat(8)),
) )
@ParameterizedTest @ParameterizedTest
+1 -1
View File
@@ -1,3 +1,3 @@
org.gradle.jvmargs=-Xmx2048M -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx2048M -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.caching=true org.gradle.caching=true
org.gradle.parallel=true org.gradle.parallel=true
+62
View File
@@ -0,0 +1,62 @@
[versions]
flexmark = "0.64.4"
lucene = "9.5.0"
http4k = "4.43.0.0"
jetty = "11.0.15"
mapstruct = "1.5.5.Final"
micronaut-inject = "4.0.0-M2"
[libraries]
flexmark-core = { module = "com.vladsch.flexmark:flexmark", version.ref = "flexmark" }
flexmark-tasklist = { module = "com.vladsch.flexmark:flexmark-ext-gfm-tasklist", version.ref = "flexmark" }
flyway = { module = "org.flywaydb:flyway-core", version = "9.17.0" }
hikariCP = { module = "com.zaxxer:HikariCP", version = "5.0.1" }
h2 = { module = "com.h2database:h2", version = "2.1.214" }
ktorm = { module = "org.ktorm:ktorm-core", version = "3.6.0" }
lucene-core = { module = "org.apache.lucene:lucene-core", version.ref = "lucene" }
lucene-analyzers-common = { module = "org.apache.lucene:lucene-analysis-common", version.ref = "lucene" }
lucene-queryparser = { module = "org.apache.lucene:lucene-queryparser", version.ref = "lucene" }
http4k-core = { module = "org.http4k:http4k-core", version.ref = "http4k" }
http4k-multipart = { module = "org.http4k:http4k-multipart", version.ref = "http4k" }
http4k-testing-hamkrest = { module = "org.http4k:http4k-testing-hamkrest", version.ref = "http4k" }
jetty-server = { module = "org.eclipse.jetty:jetty-server", version.ref = "jetty" }
jetty-servlet = { module = "org.eclipse.jetty:jetty-servlet", version.ref = "jetty" }
javax-servlet = { module = "jakarta.servlet:jakarta.servlet-api", version = "6.0.0" }
kotlinx-html = { module = "org.jetbrains.kotlinx:kotlinx-html-jvm", version = "0.8.1" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm", version = "1.5.0" }
slf4j-api = { module = "org.slf4j:slf4j-api", version = "2.0.7" }
slf4j-logback = { module = "ch.qos.logback:logback-classic", version = "1.4.7" }
mapstruct-core = { module = "org.mapstruct:mapstruct", version.ref = "mapstruct" }
mapstruct-processor = { module = "org.mapstruct:mapstruct-processor", version.ref = "mapstruct" }
micronaut-inject = { module = "io.micronaut:micronaut-inject", version.ref = "micronaut-inject" }
micronaut-processor = { module = "io.micronaut:micronaut-inject-java", version.ref = "micronaut-inject" }
arrow-core = { module = "io.arrow-kt:arrow-core", version = "1.2.0-RC" }
commonsCompress = { module = "org.apache.commons:commons-compress", version = "1.23.0" }
jwt = { module = "com.auth0:java-jwt", version = "4.4.0" }
bcrypt = { module = "org.mindrot:jbcrypt", version = "0.4" }
konform = { module = "io.konform:konform-jvm", version = "0.4.0" }
owasp-html-sanitizer = { module = "com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer", version = "20220608.1" }
prettytime = { module = "org.ocpsoft.prettytime:prettytime", version = "5.0.6.Final" }
yaml = { module = "org.yaml:snakeyaml", version = "2.0" }
assertJ = { module = "org.assertj:assertj-core", version = "3.24.2" }
hamkrest = { module = "com.natpryce:hamkrest", version = "1.8.0.1" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version = "5.9.3" }
mockk = { module = "io.mockk:mockk", version = "1.13.5" }
faker = { module = "com.github.javafaker:javafaker", version = "1.0.2" }
[bundles]
flexmark = ["flexmark-core", "flexmark-tasklist"]
database = ["flyway", "hikariCP", "h2", "ktorm"]
lucene = ["lucene-core", "lucene-analyzers-common", "lucene-queryparser"]
jetty = ["jetty-server", "jetty-servlet", "javax-servlet"]
test = ["assertJ", "hamkrest", "junit-jupiter", "mockk"]
Binary file not shown.
+2 -1
View File
@@ -1,5 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
+11 -22
View File
@@ -1,4 +1,3 @@
import be.simplenotes.Libs
import be.simplenotes.micronaut import be.simplenotes.micronaut
plugins { plugins {
@@ -12,39 +11,29 @@ dependencies {
implementation(project(":types")) implementation(project(":types"))
implementation(project(":config")) implementation(project(":config"))
implementation(Libs.Database.Drivers.mariadb) implementation(libs.bundles.database)
implementation(Libs.Database.Drivers.h2)
implementation(Libs.Database.flyway)
implementation(Libs.Database.hikariCP)
implementation(Libs.Database.Ktorm.core)
runtimeOnly(Libs.Database.Ktorm.mysql)
implementation(Libs.Slf4J.api) implementation(libs.slf4j.api)
runtimeOnly(Libs.Slf4J.logback) runtimeOnly(libs.slf4j.logback)
compileOnly(Libs.Mapstruct.core) compileOnly(libs.mapstruct.core)
kapt(Libs.Mapstruct.processor) kapt(libs.mapstruct.processor)
testImplementation(Libs.Test.junit) testImplementation(libs.bundles.test)
testImplementation(Libs.Test.assertJ) testCompileOnly(libs.slf4j.logback)
testCompileOnly(Libs.Slf4J.logback)
testImplementation(Libs.Test.mariaTestContainer)
testFixturesImplementation(project(":types")) testFixturesImplementation(project(":types"))
testFixturesImplementation(project(":config")) testFixturesImplementation(project(":config"))
testFixturesImplementation(project(":persistence")) testFixturesImplementation(project(":persistence"))
testFixturesImplementation(Libs.Test.faker) { testFixturesImplementation(libs.faker) {
exclude(group = "org.yaml") exclude(group = "org.yaml")
} }
testFixturesImplementation(Libs.snakeyaml) testFixturesImplementation(libs.yaml)
testFixturesImplementation(Libs.Test.mariaTestContainer) testFixturesImplementation(libs.bundles.database)
testFixturesImplementation(Libs.Database.flyway) testFixturesImplementation(libs.junit.jupiter)
testFixturesImplementation(Libs.Test.junit)
testFixturesImplementation(Libs.Database.Ktorm.core)
testFixturesImplementation(Libs.Database.hikariCP)
micronaut() micronaut()
@@ -9,7 +9,7 @@ create table Users
create table Notes create table Notes
( (
uuid binary(16) not null primary key, uuid uuid not null primary key,
title varchar(50) not null, title varchar(50) not null,
markdown mediumtext not null, markdown mediumtext not null,
html mediumtext not null, html mediumtext not null,
@@ -17,7 +17,6 @@ create table Notes
updated_at datetime null, updated_at datetime null,
constraint Notes_fk_user foreign key (user_id) references Users (id) on delete cascade constraint Notes_fk_user foreign key (user_id) references Users (id) on delete cascade
); );
create index user_id on Notes (user_id); create index user_id on Notes (user_id);
@@ -26,7 +25,7 @@ create table Tags
( (
id int auto_increment primary key, id int auto_increment primary key,
name varchar(50) not null, name varchar(50) not null,
note_uuid binary(16) not null, note_uuid uuid not null,
constraint Tags_fk_note foreign key (note_uuid) references Notes (uuid) on delete cascade constraint Tags_fk_note foreign key (note_uuid) references Notes (uuid) on delete cascade
); );
@@ -0,0 +1,5 @@
CREATE VIEW NotesWithTags AS
SELECT n.*, coalesce(ARRAY_AGG(t.NAME), ARRAY[]) as tags
FROM Notes n
LEFT JOIN Tags t ON n.uuid = t.note_uuid
GROUP BY n.uuid
@@ -1,36 +0,0 @@
create table Users
(
id int auto_increment primary key,
username varchar(50) not null,
password varchar(255) not null,
constraint username unique (username)
) character set 'utf8mb4'
collate 'utf8mb4_general_ci';
create table Notes
(
uuid binary(16) not null primary key,
title varchar(50) not null,
markdown mediumtext not null,
html mediumtext not null,
user_id int not null,
updated_at datetime null,
constraint Notes_fk_user foreign key (user_id) references Users (id) on delete cascade
) character set 'utf8mb4'
collate 'utf8mb4_general_ci';
create index user_id on Notes (user_id);
create table Tags
(
id int auto_increment primary key,
name varchar(50) not null,
note_uuid binary(16) not null,
constraint Tags_fk_note foreign key (note_uuid) references Notes (uuid) on delete cascade
) character set 'utf8mb4'
collate 'utf8mb4_general_ci';
create index note_uuid on Tags (note_uuid);
@@ -1,2 +0,0 @@
alter table Notes
add column deleted bool not null default false
@@ -1,2 +0,0 @@
alter table Notes
add column public bool not null default false
+55
View File
@@ -0,0 +1,55 @@
package be.simplenotes.persistence
import org.ktorm.database.Database
import org.ktorm.entity.Entity
import org.ktorm.entity.sequenceOf
import java.time.LocalDateTime
import java.util.*
internal val Database.users get() = sequenceOf(Users, withReferences = false)
internal val Database.notes get() = sequenceOf(Notes, withReferences = false)
internal val Database.tags get() = sequenceOf(Tags, withReferences = false)
internal val Database.noteWithTags get() = sequenceOf(NotesWithTags, withReferences = false)
internal interface UserEntity : Entity<UserEntity> {
companion object : Entity.Factory<UserEntity>()
var id: Int
var username: String
var password: String
}
internal interface NoteEntity : Entity<NoteEntity> {
companion object : Entity.Factory<NoteEntity>()
var uuid: UUID
var title: String
var markdown: String
var html: String
var updatedAt: LocalDateTime
var deleted: Boolean
var public: Boolean
var user: UserEntity
}
internal interface TagEntity : Entity<TagEntity> {
companion object : Entity.Factory<TagEntity>()
val id: Int
var name: String
var note: NoteEntity
}
internal interface NoteWithTagsEntity : Entity<NoteWithTagsEntity> {
companion object : Entity.Factory<NoteWithTagsEntity>()
var uuid: UUID
var title: String
var markdown: String
var html: String
var updatedAt: LocalDateTime
var deleted: Boolean
var public: Boolean
var user: UserEntity
val tags: List<String>
}
-31
View File
@@ -1,31 +0,0 @@
package be.simplenotes.persistence
import be.simplenotes.config.DataSourceConfig
import be.simplenotes.persistence.utils.DbType
import be.simplenotes.persistence.utils.type
import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.database.asIterable
import me.liuwj.ktorm.database.use
import java.sql.SQLException
import javax.inject.Singleton
interface DbHealthCheck {
fun isOk(): Boolean
}
@Singleton
internal class DbHealthCheckImpl(
private val db: Database,
private val dataSourceConfig: DataSourceConfig,
) : DbHealthCheck {
override fun isOk() = if (dataSourceConfig.type() == DbType.H2) true
else try {
db.useConnection { connection ->
connection.prepareStatement("""SHOW DATABASES""").use {
it.executeQuery().asIterable().map { it.getString(1) }
}
}.any { it in dataSourceConfig.jdbcUrl }
} catch (e: SQLException) {
false
}
}
+4 -15
View File
@@ -1,10 +1,7 @@
package be.simplenotes.persistence package be.simplenotes.persistence
import be.simplenotes.config.DataSourceConfig
import be.simplenotes.persistence.utils.DbType
import be.simplenotes.persistence.utils.type
import org.flywaydb.core.Flyway import org.flywaydb.core.Flyway
import javax.inject.Singleton import jakarta.inject.Singleton
import javax.sql.DataSource import javax.sql.DataSource
interface DbMigrations { interface DbMigrations {
@@ -12,20 +9,12 @@ interface DbMigrations {
} }
@Singleton @Singleton
internal class DbMigrationsImpl( internal class DbMigrationsImpl(private val dataSource: DataSource) : DbMigrations {
private val dataSource: DataSource,
private val dataSourceConfig: DataSourceConfig,
) : DbMigrations {
override fun migrate() { override fun migrate() {
val migrationDir = when (dataSourceConfig.type()) {
DbType.H2 -> "db/migration/other"
DbType.MariaDb -> "db/migration/mariadb"
}
Flyway.configure() Flyway.configure()
.dataSource(dataSource) .dataSource(dataSource)
.locations(migrationDir) .locations("db/migration")
.loggers("slf4j")
.load() .load()
.migrate() .migrate()
} }
+15 -6
View File
@@ -1,12 +1,14 @@
package be.simplenotes.persistence package be.simplenotes.persistence
import be.simplenotes.config.DataSourceConfig import be.simplenotes.config.DataSourceConfig
import be.simplenotes.persistence.extensions.CustomSqlFormatter
import com.zaxxer.hikari.HikariConfig import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource import com.zaxxer.hikari.HikariDataSource
import io.micronaut.context.annotation.Bean import io.micronaut.context.annotation.Bean
import io.micronaut.context.annotation.Factory import io.micronaut.context.annotation.Factory
import me.liuwj.ktorm.database.Database import org.ktorm.database.Database
import javax.inject.Singleton import org.ktorm.database.SqlDialect
import jakarta.inject.Singleton
import javax.sql.DataSource import javax.sql.DataSource
@Factory @Factory
@@ -15,7 +17,13 @@ class PersistenceModule {
@Singleton @Singleton
internal fun database(migrations: DbMigrations, dataSource: DataSource): Database { internal fun database(migrations: DbMigrations, dataSource: DataSource): Database {
migrations.migrate() migrations.migrate()
return Database.connect(dataSource) return Database.connect(
dataSource,
dialect = object : SqlDialect {
override fun createSqlFormatter(database: Database, beautifySql: Boolean, indentSize: Int) =
CustomSqlFormatter(database, beautifySql, indentSize)
},
)
} }
@Singleton @Singleton
@@ -23,11 +31,12 @@ class PersistenceModule {
internal fun dataSource(conf: DataSourceConfig): HikariDataSource { internal fun dataSource(conf: DataSourceConfig): HikariDataSource {
val hikariConfig = HikariConfig().also { val hikariConfig = HikariConfig().also {
it.jdbcUrl = conf.jdbcUrl it.jdbcUrl = conf.jdbcUrl
it.driverClassName = conf.driverClassName it.driverClassName = "org.h2.Driver"
it.username = conf.username it.username = ""
it.password = conf.password it.password = ""
it.maximumPoolSize = conf.maximumPoolSize it.maximumPoolSize = conf.maximumPoolSize
it.connectionTimeout = conf.connectionTimeout it.connectionTimeout = conf.connectionTimeout
it.dataSourceProperties["CASE_INSENSITIVE_IDENTIFIERS"] = "TRUE"
} }
return HikariDataSource(hikariConfig) return HikariDataSource(hikariConfig)
} }

Some files were not shown because too many files have changed in this diff Show More