8 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
127 changed files with 1879 additions and 3058 deletions
+3 -1
View File
@@ -13,4 +13,6 @@ insert_final_newline = true
indent_size = 4
insert_final_newline = true
max_line_length = 120
disabled_rules = no-wildcard-imports,import-ordering
ktlint_standard_no-wildcard-imports = disabled
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=
+5 -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
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
@@ -12,18 +12,15 @@ FROM alpine
RUN apk add --no-cache curl
ENV APPLICATION_USER simplenotes
RUN adduser -D -g '' $APPLICATION_USER
RUN mkdir /app
RUN chown -R $APPLICATION_USER /app
USER $APPLICATION_USER
RUN mkdir /app/data
COPY --from=jdkbuilder /myjdk /myjdk
COPY app/build/libs/app-with-dependencies*.jar /app/simplenotes.jar
WORKDIR /app
VOLUME /app/data
ENV SERVER_HOST 0.0.0.0
CMD [ \
+8 -11
View File
@@ -1,4 +1,3 @@
import be.simplenotes.Libs
import be.simplenotes.micronaut
plugins {
@@ -14,20 +13,18 @@ dependencies {
implementation(project(":views"))
implementation(project(":css"))
implementation(Libs.Http4k.core)
implementation(Libs.Jetty.server)
implementation(Libs.Jetty.servlet)
implementation(Libs.javaxServlet)
implementation(Libs.Kotlinx.Serialization.json)
implementation(libs.http4k.core)
implementation(libs.http4k.multipart)
implementation(libs.bundles.jetty)
implementation(libs.kotlinx.serialization.json)
implementation(Libs.Slf4J.api)
runtimeOnly(Libs.Slf4J.logback)
implementation(libs.slf4j.api)
runtimeOnly(libs.slf4j.logback)
micronaut()
testImplementation(Libs.Test.junit)
testImplementation(Libs.Test.assertJ)
testImplementation(Libs.Http4k.testingHamkrest)
testImplementation(libs.bundles.test)
testImplementation(libs.http4k.testing.hamkrest)
}
docker {
+3 -3
View File
@@ -1,10 +1,10 @@
package be.simplenotes.app
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import jakarta.inject.Singleton
import org.http4k.server.Http4kServer
import org.slf4j.LoggerFactory
import javax.annotation.PostConstruct
import javax.annotation.PreDestroy
import javax.inject.Singleton
import be.simplenotes.config.ServerConfig as SimpleNotesServerConfig
@Singleton
+5 -1
View File
@@ -4,7 +4,11 @@ import io.micronaut.context.ApplicationContext
import java.lang.Runtime.getRuntime
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)
getRuntime().addShutdownHook(Thread { ctx.stop() })
}
+3 -3
View File
@@ -16,7 +16,7 @@ import org.http4k.core.Status.Companion.OK
import org.http4k.lens.Path
import org.http4k.lens.uuid
import java.util.*
import javax.inject.Singleton
import jakarta.inject.Singleton
@Singleton
class ApiNoteController(
@@ -28,7 +28,7 @@ class ApiNoteController(
val content = noteContentLens(request)
return noteService.create(loggedInUser, content).fold(
{ 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)
else Response(OK)
}
},
)
}
+2 -2
View File
@@ -9,7 +9,7 @@ import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.BAD_REQUEST
import org.http4k.core.Status.Companion.OK
import javax.inject.Singleton
import jakarta.inject.Singleton
@Singleton
class ApiUserController(
@@ -23,7 +23,7 @@ class ApiUserController(
.login(loginFormLens(request))
.fold(
{ 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.Response
import org.http4k.core.Status.Companion.OK
import javax.inject.Singleton
import jakarta.inject.Singleton
@Singleton
class BaseController(private val view: BaseView) {
@@ -1,14 +0,0 @@
package be.simplenotes.app.controllers
import be.simplenotes.domain.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)
}
+9 -9
View File
@@ -15,7 +15,7 @@ import org.http4k.core.Status.Companion.OK
import org.http4k.core.body.form
import org.http4k.routing.path
import java.util.*
import javax.inject.Singleton
import jakarta.inject.Singleton
import kotlin.math.abs
@Singleton
@@ -35,24 +35,24 @@ class NoteController(
MarkdownParsingError.MissingMeta -> view.noteEditor(
loggedInUser,
error = "Missing note metadata",
textarea = markdownForm
textarea = markdownForm,
)
MarkdownParsingError.InvalidMeta -> view.noteEditor(
loggedInUser,
error = "Invalid note metadata",
textarea = markdownForm
textarea = markdownForm,
)
is MarkdownParsingError.ValidationError -> view.noteEditor(
loggedInUser,
validationErrors = it.validationErrors,
textarea = markdownForm
textarea = markdownForm,
)
}
Response(BAD_REQUEST).html(html)
},
{
Response.redirect("/notes/${it.uuid}")
}
},
)
}
@@ -114,24 +114,24 @@ class NoteController(
MarkdownParsingError.MissingMeta -> view.noteEditor(
loggedInUser,
error = "Missing note metadata",
textarea = markdownForm
textarea = markdownForm,
)
MarkdownParsingError.InvalidMeta -> view.noteEditor(
loggedInUser,
error = "Invalid note metadata",
textarea = markdownForm
textarea = markdownForm,
)
is MarkdownParsingError.ValidationError -> view.noteEditor(
loggedInUser,
validationErrors = it.validationErrors,
textarea = markdownForm
textarea = markdownForm,
)
}
Response(BAD_REQUEST).html(html)
},
{
Response.redirect("/notes/${note.uuid}")
}
},
)
}
+18 -11
View File
@@ -2,21 +2,19 @@ package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.redirect
import be.simplenotes.domain.DeleteError
import be.simplenotes.domain.DeleteForm
import be.simplenotes.domain.ExportService
import be.simplenotes.domain.UserService
import be.simplenotes.domain.*
import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.SettingView
import jakarta.inject.Singleton
import org.http4k.core.*
import org.http4k.core.body.form
import org.http4k.core.cookie.invalidateCookie
import javax.inject.Singleton
@Singleton
class SettingsController(
private val userService: UserService,
private val exportService: ExportService,
private val importService: ImportService,
private val settingView: SettingView,
) {
fun settings(request: Request, loggedInUser: LoggedInUser): Response {
@@ -33,20 +31,21 @@ class SettingsController(
DeleteError.WrongPassword -> Response(Status.OK).html(
settingView.settings(
loggedInUser,
error = "Wrong password"
)
error = "Wrong password",
),
)
is DeleteError.InvalidForm -> Response(Status.OK).html(
settingView.settings(
loggedInUser,
validationErrors = it.validationErrors
)
validationErrors = it.validationErrors,
),
)
}
},
{
Response.redirect("/").invalidateCookie("Bearer")
}
},
)
}
@@ -73,10 +72,18 @@ class SettingsController(
.body(exportService.exportAsJson(loggedInUser.userId))
} else Response(Status.OK).body(exportService.exportAsJson(loggedInUser.userId)).header(
"Content-Type",
"application/json"
"application/json",
)
}
private fun Request.deleteForm(loggedInUser: LoggedInUser) =
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")
}
}
+12 -12
View File
@@ -17,7 +17,7 @@ import org.http4k.core.cookie.SameSite
import org.http4k.core.cookie.cookie
import org.http4k.core.cookie.invalidateCookie
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
import jakarta.inject.Singleton
@Singleton
class UserController(
@@ -27,7 +27,7 @@ class UserController(
) {
fun register(request: Request, loggedInUser: LoggedInUser?): Response {
if (request.method == GET) return Response(OK).html(
userView.register(loggedInUser)
userView.register(loggedInUser),
)
val result = userService.register(request.registerForm())
@@ -37,19 +37,19 @@ class UserController(
val html = when (it) {
RegisterError.UserExists -> userView.register(
loggedInUser,
error = "User already exists"
error = "User already exists",
)
is RegisterError.InvalidRegisterForm ->
userView.register(
loggedInUser,
validationErrors = it.validationErrors
validationErrors = it.validationErrors,
)
}
Response(OK).html(html)
},
{
Response.redirect("/login")
}
},
)
}
@@ -58,7 +58,7 @@ class UserController(
fun login(request: Request, loggedInUser: LoggedInUser?): Response {
if (request.method == GET) return Response(OK).html(
userView.login(loggedInUser)
userView.login(loggedInUser),
)
val result = userService.login(request.loginForm())
@@ -69,24 +69,24 @@ class UserController(
LoginError.Unregistered ->
userView.login(
loggedInUser,
error = "User does not exist"
error = "User does not exist",
)
LoginError.WrongPassword ->
userView.login(
loggedInUser,
error = "Wrong password"
error = "Wrong password",
)
is LoginError.InvalidLoginForm ->
userView.login(
loggedInUser,
validationErrors = it.validationErrors
validationErrors = it.validationErrors,
)
}
Response(OK).html(html)
},
{ token ->
Response.redirect("/notes").loginCookie(token, request.isSecure())
}
},
)
}
@@ -101,8 +101,8 @@ class UserController(
httpOnly = true,
sameSite = SameSite.Lax,
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(
listOf(Meta(true, "body", ParamMeta.ObjectParam, "body")),
ContentType.APPLICATION_JSON.withNoDirectives(),
ContentNegotiation.StrictNoDirective
ContentNegotiation.StrictNoDirective,
).map(
{ it.payload.asString() },
{ Body(it) }
{ Body(it) },
)
inline fun <reified T> Json.auto(): BiDiBodyLensSpec<T> = bodyLens.map(
{ 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.slf4j.LoggerFactory
import java.sql.SQLTransientException
import javax.inject.Singleton
import jakarta.inject.Singleton
@Singleton
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.server.Http4kServer
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 {
constructor(port: Int, vararg inConnectors: ConnectorBuilder) : this(
port,
Server().apply {
inConnectors.forEach { addConnector(it(this)) }
}
},
)
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 org.http4k.core.RequestContexts
import org.http4k.lens.RequestContextKey
import javax.inject.Named
import javax.inject.Singleton
import jakarta.inject.Named
import jakarta.inject.Singleton
@Factory
class AuthModule {
@@ -39,7 +39,7 @@ class AuthModule {
simpleJwt = simpleJwt,
lens = lens,
source = JwtSource.Header,
redirect = false
redirect = false,
)
@Singleton
+1 -1
View File
@@ -7,7 +7,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import java.time.LocalDateTime
import java.util.*
import javax.inject.Singleton
import jakarta.inject.Singleton
@Factory
class JsonModule {
+2 -2
View File
@@ -9,8 +9,8 @@ import io.micronaut.context.annotation.Factory
import org.eclipse.jetty.server.ServerConnector
import org.http4k.server.Http4kServer
import org.http4k.server.asServer
import javax.inject.Named
import javax.inject.Singleton
import jakarta.inject.Named
import jakarta.inject.Singleton
import org.eclipse.jetty.server.Server as JettyServer
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.routes
import java.util.function.Supplier
import javax.inject.Named
import javax.inject.Singleton
import jakarta.inject.Named
import jakarta.inject.Singleton
@Singleton
class ApiRoutes(
@@ -23,7 +23,6 @@ class ApiRoutes(
@Named("required") private val authLens: RequiredAuthLens,
) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler {
infix fun PathMethod.to(action: ProtectedHandler) =
this to { req: Request -> action(req, authLens(req)) }
@@ -38,9 +37,9 @@ class ApiRoutes(
"/search" bind POST to ::search,
"/{uuid}" bind GET to ::note,
"/{uuid}" bind PUT to ::update,
)
),
).withBasePath("/notes")
}
},
).withBasePath("/api")
}
+6 -11
View File
@@ -1,7 +1,6 @@
package be.simplenotes.app.routes
import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.HealthCheckController
import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.UserController
import be.simplenotes.app.filters.ImmutableFilter
@@ -14,12 +13,11 @@ import org.http4k.core.Request
import org.http4k.core.then
import org.http4k.routing.*
import java.util.function.Supplier
import javax.inject.Named
import javax.inject.Singleton
import jakarta.inject.Named
import jakarta.inject.Singleton
@Singleton
class BasicRoutes(
private val healthCheckController: HealthCheckController,
private val baseCtrl: BaseController,
private val userCtrl: UserController,
private val noteCtrl: NoteController,
@@ -28,7 +26,6 @@ class BasicRoutes(
) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler {
infix fun PathMethod.to(action: PublicHandler) =
this to { req: Request -> action(req, authLens(req)) }
@@ -36,8 +33,8 @@ class BasicRoutes(
static(
ResourceLoader.Classpath("/static"),
"woff2" to ContentType("font/woff2"),
"webmanifest" to ContentType("application/manifest+json")
)
"webmanifest" to ContentType("application/manifest+json"),
),
)
return routes(
@@ -50,11 +47,9 @@ class BasicRoutes(
"/login" bind POST to userCtrl::login,
"/logout" bind POST to userCtrl::logout,
"/notes/public/{uuid}" bind GET to noteCtrl::public,
)
),
"/health" bind GET to healthCheckController::healthCheck,
staticHandler
),
staticHandler,
)
}
}
+3 -4
View File
@@ -12,8 +12,8 @@ import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.bind
import org.http4k.routing.routes
import java.util.function.Supplier
import javax.inject.Named
import javax.inject.Singleton
import jakarta.inject.Named
import jakarta.inject.Singleton
@Singleton
class NoteRoutes(
@@ -22,7 +22,6 @@ class NoteRoutes(
@Named("required") private val authLens: RequiredAuthLens,
) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler {
infix fun PathMethod.to(action: ProtectedHandler) =
this to { req: Request -> action(req, authLens(req)) }
@@ -40,7 +39,7 @@ class NoteRoutes(
"/{uuid}/edit" bind POST to ::edit,
"/deleted/{uuid}" bind POST to ::deleted,
).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.routes
import java.util.function.Supplier
import javax.inject.Singleton
import jakarta.inject.Singleton
@Singleton
class Router(
@@ -18,9 +18,8 @@ class Router(
private val subRouters: List<Supplier<RoutingHttpHandler>>,
) {
operator fun invoke(): RoutingHttpHandler {
val routes = routes(
*subRouters.map { it.get() }.toTypedArray()
*subRouters.map { it.get() }.toTypedArray(),
)
return errorFilter
+4 -4
View File
@@ -12,8 +12,8 @@ import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.bind
import org.http4k.routing.routes
import java.util.function.Supplier
import javax.inject.Named
import javax.inject.Singleton
import jakarta.inject.Named
import jakarta.inject.Singleton
@Singleton
class SettingsRoutes(
@@ -22,7 +22,6 @@ class SettingsRoutes(
@Named("required") private val authLens: RequiredAuthLens,
) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler {
infix fun PathMethod.to(action: ProtectedHandler) =
this to { req: Request -> action(req, authLens(req)) }
@@ -31,7 +30,8 @@ class SettingsRoutes(
"/settings" bind GET to settingsController::settings,
"/settings" bind POST to settingsController::settings,
"/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 {
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.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import javax.inject.Singleton
import jakarta.inject.Singleton
interface StaticFileResolver {
fun resolve(name: String): String?
+2 -2
View File
@@ -58,8 +58,8 @@ internal class RequiredAuthFilterTest {
},
"/protected" bind GET to requiredAuth.then { request: Request ->
Response(OK).body(requiredLens(request).toString())
}
)
},
),
)
// endregion
+5 -9
View File
@@ -2,18 +2,14 @@ plugins {
`kotlin-dsl`
}
kotlinDslPluginOptions {
experimentalWarning.set(false)
}
repositories {
gradlePluginPortal()
}
dependencies {
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.31")
implementation("org.jetbrains.kotlin:kotlin-serialization:1.4.31")
implementation("com.github.jengelman.gradle.plugins:shadow:6.1.0")
implementation("org.jlleitschuh.gradle:ktlint-gradle:10.0.0")
implementation("com.github.ben-manes:gradle-versions-plugin:0.28.0")
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.21")
implementation("org.jetbrains.kotlin:kotlin-serialization:1.8.21")
implementation("com.github.johnrengelman:shadow:8.1.1")
implementation("com.diffplug.spotless:spotless-plugin-gradle:6.13.0")
implementation("com.github.ben-manes:gradle-versions-plugin:0.46.0")
}
@@ -4,89 +4,10 @@ package be.simplenotes
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 {
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 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.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.getByType
import org.gradle.kotlin.dsl.register
import java.io.File
@@ -17,7 +19,7 @@ class PostcssPlugin : Plugin<Project> {
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")
sourceSets["main"].resources.srcDir(root)
}
@@ -11,6 +11,10 @@ tasks.withType<ShadowJar> {
archiveAppendix.set("with-dependencies")
manifest.attributes["Main-Class"] = "be.simplenotes.app.SimpleNotesKt"
// johnrengelman/shadow#449
// we need this for lucene-core
manifest.attributes["Multi-Release"] = "true"
mergeServiceFiles()
File(rootProject.projectDir, "buildSrc/src/main/resources/exclusions")
@@ -4,5 +4,11 @@ plugins {
id("be.simplenotes.java-convention")
id("be.simplenotes.kotlin-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,19 +6,16 @@ plugins {
repositories {
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"
version = "1.0-SNAPSHOT"
java {
sourceCompatibility = JavaVersion.VERSION_15
targetCompatibility = JavaVersion.VERSION_15
toolchain {
languageVersion.set(JavaLanguageVersion.of(19))
vendor.set(JvmVendorSpec.ORACLE)
}
}
tasks.withType<JavaCompile> {
@@ -1,5 +1,7 @@
package be.simplenotes
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
@@ -13,14 +15,14 @@ dependencies {
}
tasks.withType<KotlinCompile> {
kotlinOptions {
jvmTarget = "15"
javaParameters = true
freeCompilerArgs = listOf(
compilerOptions {
jvmTarget.set(JvmTarget.JVM_19)
javaParameters.set(true)
freeCompilerArgs.addAll(
"-Xinline-classes",
"-Xno-param-assertions",
"-Xno-call-assertions",
"-Xno-receiver-assertions"
"-Xno-receiver-assertions",
)
}
}
@@ -10,12 +10,7 @@ tasks.named<DependencyUpdatesTask>("dependencyUpdates").configure {
resolutionStrategy {
componentSelection {
all {
if ("RC" in candidate.version) reject("Release candidate")
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")
}
if ("alpha|beta|rc".toRegex().containsMatchIn(candidate.version.lowercase())) reject("Non stable version")
}
}
}
@@ -1,5 +1,6 @@
META-INF/maven/**
META-INF/proguard/**
META-INF/com.android.tools/**
META-INF/*.kotlin_module
META-INF/DEPENDENCIES*
META-INF/NOTICE*
@@ -7,6 +8,7 @@ META-INF/LICENSE*
LICENSE*
META-INF/README*
META-INF/native-image/**
**/module-info.**
# Jetty
about.html
+3 -4
View File
@@ -1,4 +1,3 @@
import be.simplenotes.Libs
import be.simplenotes.micronaut
plugins {
@@ -8,8 +7,8 @@ plugins {
dependencies {
micronaut()
runtimeOnly(libs.yaml)
testImplementation(Libs.Test.junit)
testImplementation(Libs.Test.assertJ)
testRuntimeOnly(Libs.Slf4J.logback)
testImplementation(libs.bundles.test)
testRuntimeOnly(libs.slf4j.logback)
}
+2 -7
View File
@@ -1,10 +1,3 @@
db:
jdbc-url: jdbc:h2:./notes-db;
username: h2
password: ''
connection-timeout: 3000
maximum-pool-size: 10
jwt:
secret: 'PliLvfk7l4WF+cZJk66LR5Mpnh+ocbvJ2wfUCK2UCms='
validity: 24
@@ -13,3 +6,5 @@ jwt:
server:
host: localhost
port: 8080
data-dir: ./data
-32
View File
@@ -1,32 +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 username: String,
val password: String,
val maximumPoolSize: Int,
val connectionTimeout: Long,
) {
override fun toString() = "DataSourceConfig(jdbcUrl='$jdbcUrl', 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`"
},
"dependencies": {
"autoprefixer": "^9.8.6",
"cssnano": "^4.1.10",
"postcss-cli": "^7.1.1",
"postcss-hash": "^2.0.0",
"postcss-import": "^12.0.1",
"postcss-nested": "^4.2.3",
"tailwindcss": "^1.5.1"
"autoprefixer": "^10.4.14",
"cssnano": "^6.0.1",
"postcss-cli": "^10.1.0",
"postcss-hash": "^3.0.0",
"postcss-import": "^15.1.0",
"tailwindcss": "^3.3.2"
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
module.exports = {
plugins: [
require('postcss-import'),
require('postcss-nested'),
require('tailwindcss/nesting'),
require('tailwindcss'),
require('autoprefixer'),
require('cssnano')({
+1 -1
View File
@@ -2,7 +2,7 @@
@apply font-semibold py-2 px-4 rounded;
&:focus {
@apply outline-none shadow-outline;
@apply outline-none ring;
}
&:hover {
+1 -1
View File
@@ -1,6 +1,6 @@
#note {
a {
@apply text-blue-700 underline;
@apply text-blue-500 underline;
}
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;
&:focus {
@apply outline-none shadow-outline bg-teal-800 text-white;
@apply outline-none ring bg-teal-800 text-white;
}
&:hover {
+1 -3
View File
@@ -1,9 +1,7 @@
module.exports = {
purge: {
content: [
process.env.PURGE
]
},
],
theme: {
fontFamily: {
'sans': [
+822 -1433
View File
File diff suppressed because it is too large Load Diff
+4 -39
View File
@@ -1,54 +1,19 @@
version: '2.2'
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:
image: hubv/simplenotes
container_name: simplenotes
env_file:
- .env
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Brussels
- DB_JDBC_URL=jdbc:mariadb://db:3306/simplenotes
- DB_USERNAME=simplenotes
# .env:
# - JWT_SECRET
# - DB_PASSWORD
ports:
- 127.0.0.1:8080:8080
healthcheck:
test: "curl --fail -s http://localhost:8080/health"
interval: 5s
timeout: 1s
start_period: 2s
retries: 3
depends_on:
db:
condition: service_healthy
volumes:
- ./simplenotes-data:/app/data
volumes:
notes-db-volume:
+10 -14
View File
@@ -1,4 +1,3 @@
import be.simplenotes.Libs
import be.simplenotes.micronaut
plugins {
@@ -13,21 +12,18 @@ dependencies {
implementation(project(":persistence"))
implementation(project(":search"))
api(Libs.arrowCoreData)
api(Libs.konform)
api(libs.arrow.core)
api(libs.konform)
micronaut()
implementation(Libs.Kotlinx.Serialization.json)
implementation(Libs.jbcrypt)
implementation(Libs.javaJwt)
implementation(Libs.Flexmark.core)
implementation(Libs.Flexmark.tasklist)
implementation(Libs.snakeyaml)
implementation(Libs.owaspHtmlSanitizer)
implementation(Libs.commonsCompress)
implementation(libs.kotlinx.serialization.json)
implementation(libs.bcrypt)
implementation(libs.jwt)
implementation(libs.bundles.flexmark)
implementation(libs.yaml)
implementation(libs.owasp.html.sanitizer)
implementation(libs.commonsCompress)
testImplementation(Libs.Test.hamkrest)
testImplementation(Libs.Test.junit)
testImplementation(Libs.Test.mockk)
testImplementation(libs.bundles.test)
}
+1 -1
View File
@@ -9,7 +9,7 @@ import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import javax.inject.Singleton
import jakarta.inject.Singleton
interface ExportService {
fun exportAsJson(userId: Int): String
-13
View File
@@ -1,13 +0,0 @@
package be.simplenotes.domain
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()
}
+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))
},
)
}
}
+15 -18
View File
@@ -1,50 +1,47 @@
package be.simplenotes.domain
import arrow.core.Either
import arrow.core.computations.either
import arrow.core.left
import arrow.core.right
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
import javax.inject.Singleton
interface MarkdownService {
fun renderDocument(input: String): Either<MarkdownParsingError, Document>
}
private typealias MetaMdPair = Pair<String, String>
@Singleton
internal class MarkdownServiceImpl(
private val parser: Parser,
private val renderer: HtmlRenderer,
) : MarkdownService {
private val yamlBoundPattern = "-{3}".toRegex()
private fun splitMetaFromDocument(input: String): Either<MarkdownParsingError.MissingMeta, MetaMdPair> {
private fun splitMetaFromDocument(input: String) = either {
val split = input.split(yamlBoundPattern, 3)
if (split.size < 3) return MarkdownParsingError.MissingMeta.left()
return (split[1].trim() to split[2].trim()).right()
ensure(split.size >= 3) { MarkdownParsingError.MissingMeta }
split[1].trim() to split[2].trim()
}
private val yaml = Yaml()
private fun parseMeta(input: String): Either<MarkdownParsingError.InvalidMeta, NoteMetadata> {
private fun parseMeta(input: String) = either {
val load: Map<String, Any> = try {
yaml.load(input)
} catch (e: ParserException) {
return MarkdownParsingError.InvalidMeta.left()
raise(MarkdownParsingError.InvalidMeta)
} catch (e: ScannerException) {
return MarkdownParsingError.InvalidMeta.left()
raise(MarkdownParsingError.InvalidMeta)
}
val title = when (val titleNode = load["title"]) {
is String, is Number -> titleNode.toString()
else -> return MarkdownParsingError.InvalidMeta.left()
else -> raise(MarkdownParsingError.InvalidMeta)
}
val tagsNode = load["tags"]
@@ -53,15 +50,15 @@ internal class MarkdownServiceImpl(
else
tagsNode.map { it.toString() }
return NoteMetadata(title, tags).right()
NoteMetadata(title, tags)
}
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()
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)
}
+24 -23
View File
@@ -1,20 +1,18 @@
package be.simplenotes.domain
import arrow.core.computations.either
import arrow.core.raise.either
import be.simplenotes.domain.security.HtmlSanitizer
import be.simplenotes.domain.utils.parseSearchTerms
import be.simplenotes.persistence.repositories.NoteRepository
import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.persistence.transactions.TransactionService
import be.simplenotes.search.NoteSearcher
import be.simplenotes.types.LoggedInUser
import be.simplenotes.types.Note
import be.simplenotes.types.PersistedNote
import be.simplenotes.types.PersistedNoteMetadata
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import jakarta.inject.Singleton
import java.util.*
import javax.annotation.PostConstruct
import javax.annotation.PreDestroy
import javax.inject.Singleton
@Singleton
class NoteService(
@@ -23,30 +21,33 @@ class NoteService(
private val userRepository: UserRepository,
private val searcher: NoteSearcher,
private val htmlSanitizer: HtmlSanitizer,
private val transaction: TransactionService,
) {
fun create(user: LoggedInUser, markdownText: String) = transaction.use {
either.eager<MarkdownParsingError, PersistedNote> {
fun create(user: LoggedInUser, markdownText: String) = either {
markdownService.renderDocument(markdownText)
.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) }
.bind()
.also { searcher.indexNote(user.userId, it) }
}
}
fun update(user: LoggedInUser, uuid: UUID, markdownText: String) = transaction.use {
either.eager<MarkdownParsingError, PersistedNote?> {
fun update(user: LoggedInUser, uuid: UUID, markdownText: String) =
either {
markdownService.renderDocument(markdownText)
.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.update(user.userId, uuid, it) }
.bind()
?.also { searcher.updateIndex(user.userId, it) }
}
}
fun paginatedNotes(
userId: Int,
@@ -64,22 +65,22 @@ class NoteService(
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)
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)
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)
if (res) searcher.deleteIndex(userId, uuid)
res
return res
}
fun countDeleted(userId: Int) = noteRepository.count(userId, deleted = true)
@@ -99,8 +100,8 @@ class NoteService(
@PreDestroy
fun dropAllIndexes() = searcher.dropAll()
fun makePublic(userId: Int, uuid: UUID) = transaction.use { noteRepository.makePublic(userId, uuid) }
fun makePrivate(userId: Int, uuid: UUID) = transaction.use { noteRepository.makePrivate(userId, uuid) }
fun makePublic(userId: Int, uuid: UUID) = noteRepository.makePublic(userId, uuid)
fun makePrivate(userId: Int, uuid: UUID) = noteRepository.makePrivate(userId, uuid)
fun findPublic(uuid: UUID) = noteRepository.findPublic(uuid)
}
+20 -32
View File
@@ -1,21 +1,19 @@
package be.simplenotes.domain
import arrow.core.Either
import arrow.core.computations.either
import arrow.core.filterOrElse
import arrow.core.leftIfNull
import arrow.core.rightIfNotNull
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.persistence.transactions.TransactionService
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
import javax.inject.Singleton
interface UserService {
fun register(form: RegisterForm): Either<RegisterError, PersistedUser>
@@ -29,40 +27,30 @@ internal class UserServiceImpl(
private val passwordHash: PasswordHash,
private val jwt: SimpleJwt<LoggedInUser>,
private val searcher: NoteSearcher,
private val transactionService: TransactionService,
) : UserService {
override fun register(form: RegisterForm) = transactionService.use {
UserValidations.validateRegister(form)
.filterOrElse({ !userRepository.exists(it.username) }, { RegisterError.UserExists })
.map { it.copy(password = passwordHash.crypt(it.password)) }
.map { userRepository.create(it) }
.leftIfNull { RegisterError.UserExists }
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.eager<LoginError, Token> {
UserValidations.validateLogin(form)
.bind()
.let { userRepository.find(it.username) }
.rightIfNotNull { LoginError.Unregistered }
.filterOrElse({ passwordHash.verify(form.password!!, it.password) }, { LoginError.WrongPassword })
.map { jwt.sign(LoggedInUser(it)) }
.bind()
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) = 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 },
{ }
)
!Either.conditionally(userRepository.delete(persistedUser.id), { DeleteError.Unregistered }, { })
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 {
+3 -3
View File
@@ -5,7 +5,7 @@ import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser
import com.vladsch.flexmark.util.data.MutableDataSet
import io.micronaut.context.annotation.Factory
import javax.inject.Singleton
import jakarta.inject.Singleton
@Factory
class FlexmarkFactory {
@@ -23,11 +23,11 @@ class FlexmarkFactory {
set(TaskListExtension.LOOSE_ITEM_CLASS, "")
set(
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(
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>")
}
+1 -1
View File
@@ -4,7 +4,7 @@ import be.simplenotes.types.LoggedInUser
import org.owasp.html.HtmlChangeListener
import org.owasp.html.HtmlPolicyBuilder
import org.slf4j.LoggerFactory
import javax.inject.Singleton
import jakarta.inject.Singleton
@Singleton
class HtmlSanitizer {
+1 -1
View File
@@ -3,7 +3,7 @@ package be.simplenotes.domain.security
import be.simplenotes.types.LoggedInUser
import com.auth0.jwt.JWTCreator
import com.auth0.jwt.interfaces.DecodedJWT
import javax.inject.Singleton
import jakarta.inject.Singleton
interface JwtMapper<T> {
fun extract(decodedJWT: DecodedJWT): T?
+2 -2
View File
@@ -1,8 +1,8 @@
package be.simplenotes.domain.security
import org.mindrot.jbcrypt.BCrypt
import javax.inject.Inject
import javax.inject.Singleton
import jakarta.inject.Inject
import jakarta.inject.Singleton
internal interface PasswordHash {
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 java.util.*
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
import jakarta.inject.Singleton
@Singleton
class SimpleJwt<T>(jwtConfig: JwtConfig, private val mapper: JwtMapper<T>) {
+1 -1
View File
@@ -89,6 +89,6 @@ internal fun parseSearchTerms(input: String): SearchTerms {
title = title,
tag = tag,
content = content,
all = all
all = all,
)
}
+11 -12
View File
@@ -1,8 +1,7 @@
package be.simplenotes.domain.validation
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import arrow.core.raise.either
import arrow.core.raise.ensure
import be.simplenotes.domain.*
import be.simplenotes.types.User
import io.konform.validation.Validation
@@ -21,16 +20,16 @@ internal object UserValidations {
}
}
fun validateLogin(form: LoginForm): Either<LoginError.InvalidLoginForm, User> {
fun validateLogin(form: LoginForm) = either<LoginError.InvalidLoginForm, User> {
val errors = loginValidator.validate(form).errors
return if (errors.isEmpty()) User(form.username!!, form.password!!).right()
else return LoginError.InvalidLoginForm(errors).left()
ensure(errors.isEmpty()) { LoginError.InvalidLoginForm(errors) }
User(form.username!!, form.password!!)
}
fun validateRegister(form: RegisterForm): Either<RegisterError.InvalidRegisterForm, User> {
fun validateRegister(form: RegisterForm) = either {
val errors = loginValidator.validate(form).errors
return if (errors.isEmpty()) User(form.username!!, form.password!!).right()
else return RegisterError.InvalidRegisterForm(errors).left()
ensure(errors.isEmpty()) { RegisterError.InvalidRegisterForm(errors) }
User(form.username!!, form.password!!)
}
private val deleteValidator = Validation<DeleteForm> {
@@ -47,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
return if (errors.isEmpty()) User(form.username!!, form.password!!).right()
else return DeleteError.InvalidForm(errors).left()
ensure(errors.isEmpty()) { DeleteError.InvalidForm(errors) }
User(form.username!!, form.password!!)
}
}
-5
View File
@@ -9,7 +9,6 @@ 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.persistence.transactions.TransactionService
import be.simplenotes.types.PersistedUser
import com.natpryce.hamkrest.assertion.assertThat
import com.natpryce.hamkrest.equalTo
@@ -23,16 +22,12 @@ internal class UserServiceTest {
val passwordHash = BcryptPasswordHash(test = true)
val jwtConfig = JwtConfig("a secret", 1, TimeUnit.HOURS)
val simpleJwt = SimpleJwt(jwtConfig, UserJwtMapper())
val noopTransactionService = object : TransactionService {
override fun <T> use(block: () -> T) = block()
}
val userService = UserServiceImpl(
userRepository = userRepository,
passwordHash = passwordHash,
jwt = simpleJwt,
searcher = mockk(),
transactionService = noopTransactionService
)
@BeforeEach
@@ -33,7 +33,7 @@ internal class LoggedInUserExtractorTest {
createToken(),
createToken(username = "user", id = 1, secret = "not the correct secret"),
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")
+2 -2
View File
@@ -21,7 +21,7 @@ fun isRight() = object : Matcher<Either<*, *>> {
override fun invoke(actual: Either<*, *>) = when (actual) {
is Either.Right -> MatchResult.Match
is Either.Left -> {
val valueA = actual.a
val valueA = actual.value
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) {
is Either.Right -> MatchResult.Mismatch("was Either.Right<>")
is Either.Left -> {
val valueA = actual.a
val valueA = actual.value
if (valueA is A) MatchResult.Match
else MatchResult.Mismatch("was Left<${if (valueA == null) "Null" else valueA::class.simpleName}>")
}
+2 -2
View File
@@ -33,7 +33,7 @@ internal class SearchTermsParserKtTest {
"tag:'example abc' title:'other with words' this is the end ",
title = "other with words",
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"),
@@ -41,7 +41,7 @@ internal class SearchTermsParserKtTest {
createResult(
"tag:'double quote inside single \" ' global",
tag = "double quote inside single \" ",
all = "global"
all = "global",
),
)
@@ -22,7 +22,7 @@ internal class UserValidationsTest {
LoginForm(username = "", password = ""),
LoginForm(username = "a", password = "aaaa"),
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
@@ -34,7 +34,7 @@ internal class UserValidationsTest {
@Suppress("Unused")
fun validLoginForms(): Stream<LoginForm> = Stream.of(
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
@@ -53,7 +53,7 @@ internal class UserValidationsTest {
RegisterForm(username = "", password = ""),
RegisterForm(username = "a", password = "aaaa"),
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
@@ -65,7 +65,7 @@ internal class UserValidationsTest {
@Suppress("Unused")
fun validRegisterForms(): Stream<RegisterForm> = Stream.of(
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
+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.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
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
zipStorePath=wrapper/dists
+11 -22
View File
@@ -1,4 +1,3 @@
import be.simplenotes.Libs
import be.simplenotes.micronaut
plugins {
@@ -12,39 +11,29 @@ dependencies {
implementation(project(":types"))
implementation(project(":config"))
implementation(Libs.Database.Drivers.mariadb)
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.bundles.database)
implementation(Libs.Slf4J.api)
runtimeOnly(Libs.Slf4J.logback)
implementation(libs.slf4j.api)
runtimeOnly(libs.slf4j.logback)
compileOnly(Libs.Mapstruct.core)
kapt(Libs.Mapstruct.processor)
compileOnly(libs.mapstruct.core)
kapt(libs.mapstruct.processor)
testImplementation(Libs.Test.junit)
testImplementation(Libs.Test.assertJ)
testCompileOnly(Libs.Slf4J.logback)
testImplementation(Libs.Test.mariaTestContainer)
testImplementation(libs.bundles.test)
testCompileOnly(libs.slf4j.logback)
testFixturesImplementation(project(":types"))
testFixturesImplementation(project(":config"))
testFixturesImplementation(project(":persistence"))
testFixturesImplementation(Libs.Test.faker) {
testFixturesImplementation(libs.faker) {
exclude(group = "org.yaml")
}
testFixturesImplementation(Libs.snakeyaml)
testFixturesImplementation(libs.yaml)
testFixturesImplementation(Libs.Test.mariaTestContainer)
testFixturesImplementation(Libs.Database.flyway)
testFixturesImplementation(Libs.Test.junit)
testFixturesImplementation(Libs.Database.Ktorm.core)
testFixturesImplementation(Libs.Database.hikariCP)
testFixturesImplementation(libs.bundles.database)
testFixturesImplementation(libs.junit.jupiter)
micronaut()
@@ -9,7 +9,7 @@ create table Users
create table Notes
(
uuid binary(16) not null primary key,
uuid uuid not null primary key,
title varchar(50) not null,
markdown mediumtext not null,
html mediumtext not null,
@@ -17,7 +17,6 @@ create table Notes
updated_at datetime null,
constraint Notes_fk_user foreign key (user_id) references Users (id) on delete cascade
);
create index user_id on Notes (user_id);
@@ -26,7 +25,7 @@ create table Tags
(
id int auto_increment primary key,
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
);
@@ -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
import be.simplenotes.config.DataSourceConfig
import be.simplenotes.persistence.utils.DbType
import be.simplenotes.persistence.utils.type
import org.flywaydb.core.Flyway
import javax.inject.Singleton
import jakarta.inject.Singleton
import javax.sql.DataSource
interface DbMigrations {
@@ -12,20 +9,12 @@ interface DbMigrations {
}
@Singleton
internal class DbMigrationsImpl(
private val dataSource: DataSource,
private val dataSourceConfig: DataSourceConfig,
) : DbMigrations {
internal class DbMigrationsImpl(private val dataSource: DataSource) : DbMigrations {
override fun migrate() {
val migrationDir = when (dataSourceConfig.type()) {
DbType.H2 -> "db/migration/other"
DbType.MariaDb -> "db/migration/mariadb"
}
Flyway.configure()
.dataSource(dataSource)
.locations(migrationDir)
.locations("db/migration")
.loggers("slf4j")
.load()
.migrate()
}
+15 -10
View File
@@ -1,12 +1,14 @@
package be.simplenotes.persistence
import be.simplenotes.config.DataSourceConfig
import be.simplenotes.persistence.extensions.CustomSqlFormatter
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.micronaut.context.annotation.Bean
import io.micronaut.context.annotation.Factory
import me.liuwj.ktorm.database.Database
import javax.inject.Singleton
import org.ktorm.database.Database
import org.ktorm.database.SqlDialect
import jakarta.inject.Singleton
import javax.sql.DataSource
@Factory
@@ -15,7 +17,13 @@ class PersistenceModule {
@Singleton
internal fun database(migrations: DbMigrations, dataSource: DataSource): Database {
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
@@ -23,15 +31,12 @@ class PersistenceModule {
internal fun dataSource(conf: DataSourceConfig): HikariDataSource {
val hikariConfig = HikariConfig().also {
it.jdbcUrl = conf.jdbcUrl
it.driverClassName = when {
conf.jdbcUrl.startsWith("jdbc:mariadb") -> "org.mariadb.jdbc.Driver"
conf.jdbcUrl.startsWith("jdbc:h2") -> "org.h2.Driver"
else -> error("Unsupported database")
}
it.username = conf.username
it.password = conf.password
it.driverClassName = "org.h2.Driver"
it.username = ""
it.password = ""
it.maximumPoolSize = conf.maximumPoolSize
it.connectionTimeout = conf.connectionTimeout
it.dataSourceProperties["CASE_INSENSITIVE_IDENTIFIERS"] = "TRUE"
}
return HikariDataSource(hikariConfig)
}
+42
View File
@@ -0,0 +1,42 @@
package be.simplenotes.persistence
import be.simplenotes.persistence.extensions.varcharArray
import org.ktorm.schema.*
internal object Users : Table<UserEntity>("Users") {
val id = int("id").primaryKey().bindTo { it.id }
val username = varchar("username").bindTo { it.username }
val password = varchar("password").bindTo { it.password }
}
internal object Notes : Table<NoteEntity>("Notes") {
val uuid = uuid("uuid").primaryKey().bindTo { it.uuid }
val title = varchar("title").bindTo { it.title }
val markdown = text("markdown").bindTo { it.markdown }
val html = text("html").bindTo { it.html }
val userId = int("user_id").references(Users) { it.user }
val updatedAt = datetime("updated_at").bindTo { it.updatedAt }
val deleted = boolean("deleted").bindTo { it.deleted }
val public = boolean("public").bindTo { it.public }
val user get() = userId.referenceTable as Users
}
internal object Tags : Table<TagEntity>("Tags") {
val id = int("id").primaryKey().bindTo { it.id }
val name = varchar("name").bindTo { it.name }
val noteUuid = uuid("note_uuid").references(Notes) { it.note }
val note: Notes get() = noteUuid.referenceTable as Notes
}
internal object NotesWithTags : Table<NoteWithTagsEntity>("NotesWithTags") {
val uuid = uuid("uuid").primaryKey().bindTo { it.uuid }
val title = varchar("title").bindTo { it.title }
val markdown = text("markdown").bindTo { it.markdown }
val html = text("html").bindTo { it.html }
val userId = int("user_id").references(Users) { it.user }
val updatedAt = datetime("updated_at").bindTo { it.updatedAt }
val deleted = boolean("deleted").bindTo { it.deleted }
val public = boolean("public").bindTo { it.public }
val tags = varcharArray("tags").bindTo { it.tags }
val user get() = userId.referenceTable as Users
}
+34
View File
@@ -0,0 +1,34 @@
package be.simplenotes.persistence.converters
import be.simplenotes.persistence.NoteEntity
import be.simplenotes.persistence.NoteWithTagsEntity
import be.simplenotes.persistence.UserEntity
import be.simplenotes.types.*
import org.mapstruct.Mapper
import org.mapstruct.Mapping
import org.mapstruct.ReportingPolicy
import java.time.LocalDateTime
import java.util.*
@Mapper(unmappedTargetPolicy = ReportingPolicy.IGNORE, componentModel = "jsr330")
internal interface UserConverter {
fun toPersistedUser(userEntity: UserEntity?): PersistedUser?
}
@Mapper(unmappedTargetPolicy = ReportingPolicy.IGNORE, componentModel = "jsr330")
internal abstract class NoteConverter {
@Mapping(target = "trash", source = "deleted")
abstract fun toExportedNote(entity: NoteWithTagsEntity?): ExportedNote?
abstract fun toPersistedNote(entity: NoteWithTagsEntity?): PersistedNote?
abstract fun toPersistedNoteMetadata(entity: NoteWithTagsEntity?): PersistedNoteMetadata?
fun toEntity(note: Note, uuid: UUID, userId: Int, updatedAt: LocalDateTime) = NoteEntity {
this.title = note.title
this.markdown = note.markdown
this.html = note.html
this.uuid = uuid
this.user.id = userId
this.updatedAt = updatedAt
}
}
@@ -1,85 +0,0 @@
package be.simplenotes.persistence.converters
import be.simplenotes.persistence.notes.NoteEntity
import be.simplenotes.types.*
import me.liuwj.ktorm.entity.Entity
import org.mapstruct.Mapper
import org.mapstruct.Mapping
import org.mapstruct.Mappings
import org.mapstruct.ReportingPolicy
import java.time.LocalDateTime
import java.util.*
import javax.inject.Singleton
@Mapper(
uses = [NoteEntityFactory::class, UserEntityFactory::class],
unmappedTargetPolicy = ReportingPolicy.IGNORE,
componentModel = "jsr330"
)
internal abstract class NoteConverter {
fun toNote(entity: NoteEntity, tags: Tags) =
Note(NoteMetadata(title = entity.title, tags = tags), entity.markdown, entity.html)
@Mappings(
Mapping(target = ".", source = "entity"),
Mapping(target = "tags", source = "tags"),
)
abstract fun toNoteMetadata(entity: NoteEntity, tags: Tags): NoteMetadata
@Mappings(
Mapping(target = ".", source = "entity"),
Mapping(target = "tags", source = "tags"),
)
abstract fun toPersistedNoteMetadata(entity: NoteEntity, tags: Tags): PersistedNoteMetadata
fun toPersistedNote(entity: NoteEntity, tags: Tags) = PersistedNote(
NoteMetadata(title = entity.title, tags = tags),
entity.markdown,
entity.html,
entity.updatedAt,
entity.uuid,
entity.public
)
@Mappings(
Mapping(target = ".", source = "entity"),
Mapping(target = "trash", source = "entity.deleted"),
Mapping(target = "tags", source = "tags"),
)
abstract fun toExportedNote(entity: NoteEntity, tags: Tags): ExportedNote
fun toEntity(note: Note, uuid: UUID, userId: Int, updatedAt: LocalDateTime) = NoteEntity {
this.title = note.meta.title
this.markdown = note.markdown
this.html = note.html
this.uuid = uuid
this.deleted = false
this.public = false
this.user.id = userId
this.updatedAt = updatedAt
}
@Mappings(
Mapping(target = ".", source = "note"),
Mapping(target = "updatedAt", source = "updatedAt"),
Mapping(target = "uuid", source = "uuid"),
Mapping(target = "public", constant = "false"),
)
abstract fun toPersistedNote(note: Note, updatedAt: LocalDateTime, uuid: UUID): PersistedNote
abstract fun toEntity(persistedNoteMetadata: PersistedNoteMetadata): NoteEntity
abstract fun toEntity(noteMetadata: NoteMetadata): NoteEntity
@Mapping(target = "title", source = "meta.title")
abstract fun toEntity(persistedNote: PersistedNote): NoteEntity
@Mapping(target = "deleted", source = "trash")
abstract fun toEntity(exportedNote: ExportedNote): NoteEntity
}
typealias Tags = List<String>
@Singleton
internal class NoteEntityFactory : Entity.Factory<NoteEntity>()
@@ -1,24 +0,0 @@
package be.simplenotes.persistence.converters
import be.simplenotes.persistence.users.UserEntity
import be.simplenotes.types.PersistedUser
import be.simplenotes.types.User
import me.liuwj.ktorm.entity.Entity
import org.mapstruct.Mapper
import org.mapstruct.ReportingPolicy
import javax.inject.Singleton
@Mapper(
uses = [UserEntityFactory::class],
unmappedTargetPolicy = ReportingPolicy.IGNORE,
componentModel = "jsr330"
)
internal interface UserConverter {
fun toUser(userEntity: UserEntity): User
fun toPersistedUser(userEntity: UserEntity): PersistedUser
fun toEntity(user: User): UserEntity
fun toEntity(user: PersistedUser): UserEntity
}
@Singleton
internal class UserEntityFactory : Entity.Factory<UserEntity>()
+67 -15
View File
@@ -1,26 +1,78 @@
package be.simplenotes.persistence.extensions
import me.liuwj.ktorm.schema.BaseTable
import me.liuwj.ktorm.schema.SqlType
import java.nio.ByteBuffer
import org.ktorm.database.Database
import org.ktorm.expression.*
import org.ktorm.schema.*
import java.sql.PreparedStatement
import java.sql.ResultSet
import java.sql.Types
import java.util.*
internal class UuidBinarySqlType : SqlType<UUID>(Types.BINARY, typeName = "uuidBinary") {
override fun doGetResult(rs: ResultSet, index: Int): UUID? {
val value = rs.getBytes(index) ?: return null
return ByteBuffer.wrap(value).let { b -> UUID(b.long, b.long) }
internal class VarcharArraySqlType : SqlType<List<String>>(Types.ARRAY, typeName = "varchar[]") {
override fun doGetResult(rs: ResultSet, index: Int): List<String>? {
return when (val array = rs.getObject(index)) {
null -> null
is java.sql.Array -> (array.array as Array<*>).filterNotNull().map { it.toString() }
is Array<*> -> array.map { it.toString() }
else -> error("Unable to deserialize varchar[]")
}
}
override fun doSetParameter(ps: PreparedStatement, index: Int, parameter: UUID) {
val bytes = ByteBuffer.allocate(16)
.putLong(parameter.mostSignificantBits)
.putLong(parameter.leastSignificantBits)
.array()
ps.setBytes(index, bytes)
override fun doSetParameter(ps: PreparedStatement, index: Int, parameter: List<String>) {
throw UnsupportedOperationException()
}
}
internal fun <E : Any> BaseTable<E>.uuidBinary(name: String) = registerColumn(name, UuidBinarySqlType())
internal fun <E : Any> BaseTable<E>.varcharArray(name: String) = registerColumn(name, VarcharArraySqlType())
data class ArrayContainsExpression(
val left: ScalarExpression<*>,
val right: ScalarExpression<*>,
override val sqlType: SqlType<Boolean> = BooleanSqlType,
override val isLeafNode: Boolean = false,
) : ScalarExpression<Boolean>() {
override val extraProperties: Map<String, Any> get() = emptyMap()
}
infix fun ColumnDeclaring<*>.arrayContains(arguments: String): ArrayContainsExpression {
return ArrayContainsExpression(asExpression(), ArgumentExpression(arguments, VarcharSqlType))
}
class CustomSqlFormatter(database: Database, beautifySql: Boolean, indentSize: Int) :
SqlFormatter(database, beautifySql, indentSize) {
override fun visitUnknown(expr: SqlExpression): SqlExpression {
return if (expr is ArrayContainsExpression) {
write("ARRAY_CONTAINS(")
if (expr.left.removeBrackets) {
visit(expr.left)
} else {
write("(")
visit(expr.left)
removeLastBlank()
write(") ")
}
write(", ")
if (expr.right.removeBrackets) {
visit(expr.right)
} else {
write("(")
visit(expr.right)
removeLastBlank()
write(") ")
}
write(")")
return expr
} else super.visitUnknown(expr)
}
override fun writePagination(expr: QueryExpression) {
newLine(Indentation.SAME)
writeKeyword("limit ?, ? ")
_parameters += ArgumentExpression(expr.offset ?: 0, IntSqlType)
_parameters += ArgumentExpression(expr.limit ?: Int.MAX_VALUE, IntSqlType)
}
}
-219
View File
@@ -1,219 +0,0 @@
package be.simplenotes.persistence.notes
import be.simplenotes.persistence.converters.NoteConverter
import be.simplenotes.persistence.repositories.NoteRepository
import be.simplenotes.types.ExportedNote
import be.simplenotes.types.Note
import be.simplenotes.types.PersistedNote
import be.simplenotes.types.PersistedNoteMetadata
import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.dsl.*
import me.liuwj.ktorm.entity.*
import java.time.LocalDateTime
import java.util.*
import javax.inject.Singleton
import kotlin.collections.HashMap
@Singleton
internal class NoteRepositoryImpl(
private val db: Database,
private val converter: NoteConverter,
) : NoteRepository {
@Throws(IllegalArgumentException::class)
override fun findAll(
userId: Int,
limit: Int,
offset: Int,
tag: String?,
deleted: Boolean,
): List<PersistedNoteMetadata> {
require(limit > 0) { "limit should be positive" }
require(offset >= 0) { "offset should not be negative" }
val uuids1: List<UUID>? = if (tag != null) {
db.from(Tags)
.leftJoin(Notes, on = Notes.uuid eq Tags.noteUuid)
.select(Notes.uuid)
.where { (Notes.userId eq userId) and (Tags.name eq tag) and (Notes.deleted eq deleted) }
.map { it[Notes.uuid]!! }
} else null
var query = db.notes
.filterColumns { listOf(it.uuid, it.title, it.updatedAt) }
.filter { (it.userId eq userId) and (it.deleted eq deleted) }
if (uuids1 != null) query = query.filter { it.uuid inList uuids1 }
val notes = query
.sortedByDescending { it.updatedAt }
.take(limit)
.drop(offset)
.toList()
val tagsByUuid = notes.tagsByUuid()
return notes.map { note ->
val tags = tagsByUuid[note.uuid] ?: emptyList()
converter.toPersistedNoteMetadata(note, tags)
}
}
override fun exists(userId: Int, uuid: UUID): Boolean {
return db.notes.any { (it.userId eq userId) and (it.uuid eq uuid) and (it.deleted eq false) }
}
override fun create(userId: Int, note: Note): PersistedNote {
val uuid = UUID.randomUUID()
val entity = converter.toEntity(note, uuid, userId, LocalDateTime.now())
db.notes.add(entity)
db.batchInsert(Tags) {
note.meta.tags.forEach { tagName ->
item {
it.noteUuid to uuid
it.name to tagName
}
}
}
return converter.toPersistedNote(entity, note.meta.tags)
}
override fun find(userId: Int, uuid: UUID): PersistedNote? {
val note = db.notes
.filterColumns { it.columns - it.userId }
.filter { it.uuid eq uuid }
.find { (it.userId eq userId) and (it.deleted eq false) }
?: return null
val tags = db.from(Tags)
.select(Tags.name)
.where { Tags.noteUuid eq uuid }
.map { it[Tags.name]!! }
return converter.toPersistedNote(note, tags)
}
override fun update(userId: Int, uuid: UUID, note: Note): PersistedNote? {
val now = LocalDateTime.now()
val count = db.update(Notes) {
it.title to note.meta.title
it.markdown to note.markdown
it.html to note.html
it.updatedAt to now
where { (it.uuid eq uuid) and (it.userId eq userId) and (it.deleted eq false) }
}
if (count == 0) return null
// delete all tags
db.delete(Tags) {
it.noteUuid eq uuid
}
// put new ones
note.meta.tags.forEach { tagName ->
db.insert(Tags) {
it.name to tagName
it.noteUuid to uuid
}
}
return converter.toPersistedNote(note, now, uuid)
}
override fun delete(userId: Int, uuid: UUID, permanent: Boolean): Boolean {
return if (!permanent) {
db.update(Notes) {
it.deleted to true
it.updatedAt to LocalDateTime.now()
where { it.userId eq userId and (it.uuid eq uuid) }
} == 1
} else
db.delete(Notes) { it.uuid eq uuid and (it.userId eq userId) } == 1
}
override fun restore(userId: Int, uuid: UUID): Boolean {
return db.update(Notes) {
it.deleted to false
where { (it.userId eq userId) and (it.uuid eq uuid) }
} == 1
}
override fun getTags(userId: Int): List<String> =
db.from(Tags)
.leftJoin(Notes, on = Notes.uuid eq Tags.noteUuid)
.selectDistinct(Tags.name)
.where { (Notes.userId eq userId) and (Notes.deleted eq false) }
.map { it[Tags.name]!! }
override fun count(userId: Int, tag: String?, deleted: Boolean): Int {
return if (tag == null) db.notes.count { (it.userId eq userId) and (Notes.deleted eq deleted) }
else db.sequenceOf(Tags).count {
(it.name eq tag) and (it.note.userId eq userId) and (it.note.deleted eq deleted)
}
}
override fun export(userId: Int): List<ExportedNote> {
val notes = db.notes
.filterColumns { it.columns - it.userId }
.filter { it.userId eq userId }
.sortedByDescending { it.updatedAt }
.toList()
val tagsByUuid = notes.tagsByUuid()
return notes.map { note ->
val tags = tagsByUuid[note.uuid] ?: emptyList()
converter.toExportedNote(note, tags)
}
}
override fun findAllDetails(userId: Int): List<PersistedNote> {
val notes = db.notes
.filterColumns { it.columns - it.deleted }
.filter { (it.userId eq userId) and (it.deleted eq false) }
.toList()
val tagsByUuid = notes.tagsByUuid()
return notes.map { note ->
val tags = tagsByUuid[note.uuid] ?: emptyList()
converter.toPersistedNote(note, tags)
}
}
override fun makePublic(userId: Int, uuid: UUID) = db.update(Notes) {
it.public to true
where { Notes.userId eq userId and (Notes.uuid eq uuid) and (it.deleted eq false) }
} == 1
override fun makePrivate(userId: Int, uuid: UUID) = db.update(Notes) {
it.public to false
where { Notes.userId eq userId and (Notes.uuid eq uuid) and (it.deleted eq false) }
} == 1
override fun findPublic(uuid: UUID): PersistedNote? {
val note = db.notes
.filterColumns { it.columns - it.userId }
.filter { it.uuid eq uuid }
.filter { it.public eq true }
.find { it.deleted eq false }
?: return null
val tags = db.from(Tags)
.select(Tags.name)
.where { Tags.noteUuid eq uuid }
.map { it[Tags.name]!! }
return converter.toPersistedNote(note, tags)
}
private fun List<NoteEntity>.tagsByUuid(): Map<UUID, List<String>> {
return if (isEmpty()) emptyMap()
else db.tags
.filterColumns { listOf(it.noteUuid, it.name) }
.filter { it.noteUuid inList map { note -> note.uuid } }
.groupByTo(HashMap(), { it.note.uuid }, { it.name })
}
}
-45
View File
@@ -1,45 +0,0 @@
@file:Suppress("unused")
package be.simplenotes.persistence.notes
import be.simplenotes.persistence.extensions.uuidBinary
import be.simplenotes.persistence.users.UserEntity
import be.simplenotes.persistence.users.Users
import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.entity.Entity
import me.liuwj.ktorm.entity.sequenceOf
import me.liuwj.ktorm.schema.*
import java.time.LocalDateTime
import java.util.*
internal open class Notes(alias: String?) : Table<NoteEntity>("Notes", alias) {
companion object : Notes(null)
override fun aliased(alias: String) = Notes(alias)
val uuid = uuidBinary("uuid").primaryKey().bindTo { it.uuid }
val title = varchar("title").bindTo { it.title }
val markdown = text("markdown").bindTo { it.markdown }
val html = text("html").bindTo { it.html }
val userId = int("user_id").references(Users) { it.user }
val updatedAt = datetime("updated_at").bindTo { it.updatedAt }
val deleted = boolean("deleted").bindTo { it.deleted }
val public = boolean("public").bindTo { it.public }
val user get() = userId.referenceTable as Users
}
internal val Database.notes get() = this.sequenceOf(Notes, withReferences = false)
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
}
-30
View File
@@ -1,30 +0,0 @@
package be.simplenotes.persistence.notes
import be.simplenotes.persistence.extensions.uuidBinary
import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.entity.Entity
import me.liuwj.ktorm.entity.sequenceOf
import me.liuwj.ktorm.schema.Table
import me.liuwj.ktorm.schema.int
import me.liuwj.ktorm.schema.varchar
internal open class Tags(alias: String?) : Table<TagEntity>("Tags", alias) {
companion object : Tags(null)
override fun aliased(alias: String) = Tags(alias)
val id = int("id").primaryKey().bindTo { it.id }
val name = varchar("name").bindTo { it.name }
val noteUuid = uuidBinary("note_uuid").references(Notes) { it.note }
val note get() = noteUuid.referenceTable as Notes
}
internal val Database.tags get() = this.sequenceOf(Tags, withReferences = false)
internal interface TagEntity : Entity<TagEntity> {
companion object : Entity.Factory<TagEntity>()
val id: Int
var name: String
var note: NoteEntity
}
@@ -13,7 +13,7 @@ interface NoteRepository {
limit: Int = 20,
offset: Int = 0,
tag: String? = null,
deleted: Boolean = false
deleted: Boolean = false,
): List<PersistedNoteMetadata>
fun count(userId: Int, tag: String? = null, deleted: Boolean = false): Int
@@ -26,6 +26,8 @@ interface NoteRepository {
fun create(userId: Int, note: Note): PersistedNote
fun find(userId: Int, uuid: UUID): PersistedNote?
fun update(userId: Int, uuid: UUID, note: Note): PersistedNote?
fun import(userId: Int, notes: List<ExportedNote>)
fun export(userId: Int): List<ExportedNote>
fun findAllDetails(userId: Int): List<PersistedNote>
@@ -0,0 +1,186 @@
package be.simplenotes.persistence.repositories
import be.simplenotes.persistence.*
import be.simplenotes.persistence.converters.NoteConverter
import be.simplenotes.persistence.extensions.arrayContains
import be.simplenotes.types.ExportedNote
import be.simplenotes.types.Note
import be.simplenotes.types.PersistedNote
import org.ktorm.database.Database
import org.ktorm.dsl.*
import org.ktorm.entity.*
import java.time.LocalDateTime
import java.util.*
import jakarta.inject.Singleton
@Singleton
internal class NoteRepositoryImpl(
private val db: Database,
private val converter: NoteConverter,
) : NoteRepository {
override fun findAll(
userId: Int,
limit: Int,
offset: Int,
tag: String?,
deleted: Boolean,
) = db.noteWithTags
.filterColumns { listOf(it.uuid, it.title, it.updatedAt, it.tags) }
.filter { (it.userId eq userId) and (it.deleted eq deleted) }
.runIf(tag != null) { filter { it.tags.arrayContains(tag!!) } }
.sortedByDescending { it.updatedAt }
.take(limit)
.drop(offset)
.map { converter.toPersistedNoteMetadata(it)!! }
override fun exists(userId: Int, uuid: UUID) =
db.notes.any { (it.userId eq userId) and (it.uuid eq uuid) and (it.deleted eq false) }
override fun create(userId: Int, note: Note): PersistedNote = db.useTransaction {
val uuid = UUID.randomUUID()
val entity = converter.toEntity(note, uuid, userId, LocalDateTime.now())
db.notes.add(entity)
note.tags.takeIf { it.isNotEmpty() }?.run {
db.batchInsert(Tags) {
forEach { tagName ->
item {
set(it.noteUuid, uuid)
set(it.name, tagName)
}
}
}
}
return find(userId, uuid) ?: error("Note not found")
}
override fun find(userId: Int, uuid: UUID) = db.noteWithTags
.filterColumns { NotesWithTags.columns - NotesWithTags.userId - NotesWithTags.deleted }
.find { (it.uuid eq uuid) and (it.userId eq userId) and (it.deleted eq false) }
.let { converter.toPersistedNote(it) }
override fun update(userId: Int, uuid: UUID, note: Note): PersistedNote? = db.useTransaction {
val now = LocalDateTime.now()
val count = db.update(Notes) {
set(it.title, note.title)
set(it.markdown, note.markdown)
set(it.html, note.html)
set(it.updatedAt, now)
where { (it.uuid eq uuid) and (it.userId eq userId) and (it.deleted eq false) }
}
if (count == 0) return null
// delete all tags
db.delete(Tags) {
it.noteUuid eq uuid
}
// put new ones
note.tags.forEach { tagName ->
db.insert(Tags) {
set(it.name, tagName)
set(it.noteUuid, uuid)
}
}
return find(userId, uuid)
}
override fun delete(userId: Int, uuid: UUID, permanent: Boolean) = if (!permanent) {
db.update(Notes) {
set(it.deleted, true)
set(it.updatedAt, LocalDateTime.now())
where { it.userId eq userId and (it.uuid eq uuid) }
} == 1
} else db.delete(Notes) { it.uuid eq uuid and (it.userId eq userId) } == 1
override fun restore(userId: Int, uuid: UUID) = db.update(Notes) {
set(it.deleted, false)
where { (it.userId eq userId) and (it.uuid eq uuid) }
} == 1
override fun getTags(userId: Int): List<String> = db.from(Tags)
.leftJoin(Notes, on = Notes.uuid eq Tags.noteUuid)
.selectDistinct(Tags.name)
.where { (Notes.userId eq userId) and (Notes.deleted eq false) }
.map { it.getString(1)!! }
override fun count(userId: Int, tag: String?, deleted: Boolean) = db.from(Notes)
.runIf(tag != null) { leftJoin(Tags, on = Tags.noteUuid eq Notes.uuid) }
.select(count())
.whereWithConditions {
it += Notes.userId eq userId
it += Notes.deleted eq deleted
tag?.let { tag -> it += Tags.name eq tag }
}
.map { it.getInt(1) }
.first()
override fun import(userId: Int, notes: List<ExportedNote>) {
if (notes.isEmpty()) return
val notesByID = notes.associateBy { UUID.randomUUID() }
val tags = sequence<Pair<UUID, String>> {
notesByID.entries.forEach { (key, value) ->
value.tags.forEach { tag ->
yield(key to tag)
}
}
}.toList()
db.batchInsert(Notes) {
notesByID.forEach { (uuid, note) ->
item {
set(it.uuid, uuid)
set(it.userId, userId)
set(it.title, note.title)
set(it.html, note.html)
set(it.markdown, note.markdown)
set(it.updatedAt, note.updatedAt)
set(it.deleted, note.trash)
}
}
}
tags.takeIf { it.isNotEmpty() }?.run {
db.batchInsert(Tags) {
forEach { (uuid, tagName) ->
item {
set(it.noteUuid, uuid)
set(it.name, tagName)
}
}
}
}
}
override fun export(userId: Int) = db.noteWithTags
.filterColumns { it.columns - it.userId - it.public }
.filter { it.userId eq userId }
.sortedByDescending { it.updatedAt }
.map { converter.toExportedNote(it)!! }
override fun findAllDetails(userId: Int) = db.noteWithTags
.filterColumns { it.columns - it.userId - it.deleted }
.filter { (it.userId eq userId) and (it.deleted eq false) }
.map { converter.toPersistedNote(it)!! }
override fun makePublic(userId: Int, uuid: UUID) = db.update(Notes) {
set(it.public, true)
where { Notes.userId eq userId and (Notes.uuid eq uuid) and (it.deleted eq false) }
} == 1
override fun makePrivate(userId: Int, uuid: UUID) = db.update(Notes) {
set(it.public, false)
where { Notes.userId eq userId and (Notes.uuid eq uuid) and (it.deleted eq false) }
} == 1
override fun findPublic(uuid: UUID) = db.noteWithTags
.filterColumns { it.columns - it.userId - it.deleted }
.find { (it.uuid eq uuid) and (it.public eq true) and (it.deleted eq false) }
.let { converter.toPersistedNote(it) }
}
private inline fun <T> T.runIf(condition: Boolean, block: T.() -> T) = run { if (condition) block(this) else this }
@@ -0,0 +1,40 @@
package be.simplenotes.persistence.repositories
import be.simplenotes.persistence.Users
import be.simplenotes.persistence.converters.UserConverter
import be.simplenotes.persistence.users
import be.simplenotes.types.PersistedUser
import be.simplenotes.types.User
import org.ktorm.database.Database
import org.ktorm.dsl.*
import org.ktorm.entity.any
import org.ktorm.entity.find
import java.sql.SQLIntegrityConstraintViolationException
import jakarta.inject.Singleton
@Singleton
internal class UserRepositoryImpl(
private val db: Database,
private val converter: UserConverter,
) : UserRepository {
override fun create(user: User) = try {
val id = db.insertAndGenerateKey(Users) {
set(it.username, user.username)
set(it.password, user.password)
} as Int
PersistedUser(user.username, user.password, id)
} catch (e: SQLIntegrityConstraintViolationException) {
null
}
override fun find(username: String) = db.users.find { it.username eq username }
.let { converter.toPersistedUser(it) }
override fun find(id: Int) = db.users.find { it.id eq id }
.let { converter.toPersistedUser(it) }
override fun exists(username: String) = db.users.any { it.username eq username }
override fun exists(id: Int) = db.users.any { it.id eq id }
override fun delete(id: Int) = db.delete(Users) { it.id eq id } == 1
override fun findAll() = db.from(Users).select(Users.id).map { it.getInt(1) }
}
@@ -1,9 +0,0 @@
package be.simplenotes.persistence.transactions
import me.liuwj.ktorm.database.Database
import javax.inject.Singleton
@Singleton
internal class KtormTransactionService(private val database: Database) : TransactionService {
override fun <T> use(block: () -> T) = database.useTransaction { block() }
}
@@ -1,5 +0,0 @@
package be.simplenotes.persistence.transactions
interface TransactionService {
fun <T> use(block: () -> T): T
}
@@ -1,42 +0,0 @@
package be.simplenotes.persistence.users
import be.simplenotes.persistence.converters.UserConverter
import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.types.PersistedUser
import be.simplenotes.types.User
import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.dsl.*
import me.liuwj.ktorm.entity.any
import me.liuwj.ktorm.entity.find
import java.sql.SQLIntegrityConstraintViolationException
import javax.inject.Singleton
@Singleton
internal class UserRepositoryImpl(
private val db: Database,
private val converter: UserConverter,
) : UserRepository {
override fun create(user: User): PersistedUser? {
return try {
val id = db.insertAndGenerateKey(Users) {
it.username to user.username
it.password to user.password
} as Int
PersistedUser(user.username, user.password, id)
} catch (e: SQLIntegrityConstraintViolationException) {
null
}
}
override fun find(username: String) = db.users.find { it.username eq username }
?.let { converter.toPersistedUser(it) }
override fun find(id: Int) = db.users.find { it.id eq id }?.let {
converter.toPersistedUser(it)
}
override fun exists(username: String) = db.users.any { it.username eq username }
override fun exists(id: Int) = db.users.any { it.id eq id }
override fun delete(id: Int) = db.delete(Users) { it.id eq id } == 1
override fun findAll() = db.from(Users).select(Users.id).map { it[Users.id]!! }
}
-28
View File
@@ -1,28 +0,0 @@
package be.simplenotes.persistence.users
import me.liuwj.ktorm.database.Database
import me.liuwj.ktorm.entity.Entity
import me.liuwj.ktorm.entity.sequenceOf
import me.liuwj.ktorm.schema.Table
import me.liuwj.ktorm.schema.int
import me.liuwj.ktorm.schema.varchar
internal open class Users(alias: String?) : Table<UserEntity>("Users", alias) {
companion object : Users(null)
override fun aliased(alias: String) = Users(alias)
val id = int("id").primaryKey().bindTo { it.id }
val username = varchar("username").bindTo { it.username }
val password = varchar("password").bindTo { it.password }
}
internal interface UserEntity : Entity<UserEntity> {
companion object : Entity.Factory<UserEntity>()
var id: Int
var username: String
var password: String
}
internal val Database.users get() = this.sequenceOf(Users, withReferences = false)
@@ -1,8 +0,0 @@
package be.simplenotes.persistence.utils
import be.simplenotes.config.DataSourceConfig
enum class DbType { H2, MariaDb }
fun DataSourceConfig.type(): DbType = if (jdbcUrl.contains("mariadb")) DbType.MariaDb
else DbType.H2
-22
View File
@@ -1,22 +0,0 @@
package be.simplenotes.persistence
import be.simplenotes.config.DataSourceConfig
import org.testcontainers.containers.MariaDBContainer
class KMariadbContainer : MariaDBContainer<KMariadbContainer>("mariadb:10.5.5")
fun h2dataSourceConfig() = DataSourceConfig(
jdbcUrl = "jdbc:h2:mem:regular;DB_CLOSE_DELAY=-1;",
username = "h2",
password = "",
maximumPoolSize = 2,
connectionTimeout = 3000
)
fun mariadbDataSourceConfig(jdbcUrl: String) = DataSourceConfig(
jdbcUrl = jdbcUrl,
username = "test",
password = "test",
maximumPoolSize = 2,
connectionTimeout = 3000
)

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