Compare commits

..

4 Commits

85 changed files with 1245 additions and 1752 deletions

View File

@ -13,4 +13,6 @@ insert_final_newline = true
indent_size = 4 indent_size = 4
insert_final_newline = true insert_final_newline = true
max_line_length = 120 max_line_length = 120
disabled_rules = no-wildcard-imports,import-ordering ktlint_standard_no-wildcard-imports = disabled
ktlint_standard_import-ordering = disabled
ktlint_standard_multiline-if-else = disabled

View File

@ -1,8 +1,8 @@
FROM openjdk:15-alpine as jdkbuilder FROM eclipse-temurin:19-alpine as jdkbuilder
RUN apk add --no-cache binutils RUN apk add --no-cache binutils
ENV MODULES java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.scripting,java.security.jgss,java.sql,java.sql.rowset,java.transaction.xa,java.xml,jdk.net ENV MODULES java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.scripting,java.security.jgss,java.sql,java.sql.rowset,java.transaction.xa,java.xml,jdk.net,jdk.zipfs
RUN jlink --output /myjdk --module-path $JAVA_HOME/jmods --add-modules $MODULES --no-header-files --no-man-pages --strip-debug --compress=2 RUN jlink --output /myjdk --module-path $JAVA_HOME/jmods --add-modules $MODULES --no-header-files --no-man-pages --strip-debug --compress=2

View File

@ -14,6 +14,7 @@ dependencies {
implementation(project(":css")) implementation(project(":css"))
implementation(libs.http4k.core) implementation(libs.http4k.core)
implementation(libs.http4k.multipart)
implementation(libs.bundles.jetty) implementation(libs.bundles.jetty)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)

View File

@ -1,10 +1,10 @@
package be.simplenotes.app package be.simplenotes.app
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import jakarta.inject.Singleton
import org.http4k.server.Http4kServer import org.http4k.server.Http4kServer
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import javax.annotation.PostConstruct
import javax.annotation.PreDestroy
import javax.inject.Singleton
import be.simplenotes.config.ServerConfig as SimpleNotesServerConfig import be.simplenotes.config.ServerConfig as SimpleNotesServerConfig
@Singleton @Singleton

View File

@ -16,7 +16,7 @@ import org.http4k.core.Status.Companion.OK
import org.http4k.lens.Path import org.http4k.lens.Path
import org.http4k.lens.uuid import org.http4k.lens.uuid
import java.util.* import java.util.*
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class ApiNoteController( class ApiNoteController(
@ -28,7 +28,7 @@ class ApiNoteController(
val content = noteContentLens(request) val content = noteContentLens(request)
return noteService.create(loggedInUser, content).fold( return noteService.create(loggedInUser, content).fold(
{ Response(BAD_REQUEST) }, { Response(BAD_REQUEST) },
{ uuidContentLens(UuidContent(it.uuid), Response(OK)) } { uuidContentLens(UuidContent(it.uuid), Response(OK)) },
) )
} }
@ -51,7 +51,7 @@ class ApiNoteController(
{ {
if (it == null) Response(NOT_FOUND) if (it == null) Response(NOT_FOUND)
else Response(OK) else Response(OK)
} },
) )
} }

View File

@ -9,7 +9,7 @@ import org.http4k.core.Request
import org.http4k.core.Response import org.http4k.core.Response
import org.http4k.core.Status.Companion.BAD_REQUEST import org.http4k.core.Status.Companion.BAD_REQUEST
import org.http4k.core.Status.Companion.OK import org.http4k.core.Status.Companion.OK
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class ApiUserController( class ApiUserController(
@ -23,7 +23,7 @@ class ApiUserController(
.login(loginFormLens(request)) .login(loginFormLens(request))
.fold( .fold(
{ Response(BAD_REQUEST) }, { Response(BAD_REQUEST) },
{ tokenLens(Token(it), Response(OK)) } { tokenLens(Token(it), Response(OK)) },
) )
} }

View File

@ -6,7 +6,7 @@ import be.simplenotes.views.BaseView
import org.http4k.core.Request import org.http4k.core.Request
import org.http4k.core.Response import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK import org.http4k.core.Status.Companion.OK
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class BaseController(private val view: BaseView) { class BaseController(private val view: BaseView) {

View File

@ -15,7 +15,7 @@ import org.http4k.core.Status.Companion.OK
import org.http4k.core.body.form import org.http4k.core.body.form
import org.http4k.routing.path import org.http4k.routing.path
import java.util.* import java.util.*
import javax.inject.Singleton import jakarta.inject.Singleton
import kotlin.math.abs import kotlin.math.abs
@Singleton @Singleton
@ -35,24 +35,24 @@ class NoteController(
MarkdownParsingError.MissingMeta -> view.noteEditor( MarkdownParsingError.MissingMeta -> view.noteEditor(
loggedInUser, loggedInUser,
error = "Missing note metadata", error = "Missing note metadata",
textarea = markdownForm textarea = markdownForm,
) )
MarkdownParsingError.InvalidMeta -> view.noteEditor( MarkdownParsingError.InvalidMeta -> view.noteEditor(
loggedInUser, loggedInUser,
error = "Invalid note metadata", error = "Invalid note metadata",
textarea = markdownForm textarea = markdownForm,
) )
is MarkdownParsingError.ValidationError -> view.noteEditor( is MarkdownParsingError.ValidationError -> view.noteEditor(
loggedInUser, loggedInUser,
validationErrors = it.validationErrors, validationErrors = it.validationErrors,
textarea = markdownForm textarea = markdownForm,
) )
} }
Response(BAD_REQUEST).html(html) Response(BAD_REQUEST).html(html)
}, },
{ {
Response.redirect("/notes/${it.uuid}") Response.redirect("/notes/${it.uuid}")
} },
) )
} }
@ -114,24 +114,24 @@ class NoteController(
MarkdownParsingError.MissingMeta -> view.noteEditor( MarkdownParsingError.MissingMeta -> view.noteEditor(
loggedInUser, loggedInUser,
error = "Missing note metadata", error = "Missing note metadata",
textarea = markdownForm textarea = markdownForm,
) )
MarkdownParsingError.InvalidMeta -> view.noteEditor( MarkdownParsingError.InvalidMeta -> view.noteEditor(
loggedInUser, loggedInUser,
error = "Invalid note metadata", error = "Invalid note metadata",
textarea = markdownForm textarea = markdownForm,
) )
is MarkdownParsingError.ValidationError -> view.noteEditor( is MarkdownParsingError.ValidationError -> view.noteEditor(
loggedInUser, loggedInUser,
validationErrors = it.validationErrors, validationErrors = it.validationErrors,
textarea = markdownForm textarea = markdownForm,
) )
} }
Response(BAD_REQUEST).html(html) Response(BAD_REQUEST).html(html)
}, },
{ {
Response.redirect("/notes/${note.uuid}") Response.redirect("/notes/${note.uuid}")
} },
) )
} }

View File

@ -2,21 +2,19 @@ package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.redirect import be.simplenotes.app.extensions.redirect
import be.simplenotes.domain.DeleteError import be.simplenotes.domain.*
import be.simplenotes.domain.DeleteForm
import be.simplenotes.domain.ExportService
import be.simplenotes.domain.UserService
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.SettingView import be.simplenotes.views.SettingView
import jakarta.inject.Singleton
import org.http4k.core.* import org.http4k.core.*
import org.http4k.core.body.form import org.http4k.core.body.form
import org.http4k.core.cookie.invalidateCookie import org.http4k.core.cookie.invalidateCookie
import javax.inject.Singleton
@Singleton @Singleton
class SettingsController( class SettingsController(
private val userService: UserService, private val userService: UserService,
private val exportService: ExportService, private val exportService: ExportService,
private val importService: ImportService,
private val settingView: SettingView, private val settingView: SettingView,
) { ) {
fun settings(request: Request, loggedInUser: LoggedInUser): Response { fun settings(request: Request, loggedInUser: LoggedInUser): Response {
@ -33,20 +31,21 @@ class SettingsController(
DeleteError.WrongPassword -> Response(Status.OK).html( DeleteError.WrongPassword -> Response(Status.OK).html(
settingView.settings( settingView.settings(
loggedInUser, loggedInUser,
error = "Wrong password" error = "Wrong password",
) ),
) )
is DeleteError.InvalidForm -> Response(Status.OK).html( is DeleteError.InvalidForm -> Response(Status.OK).html(
settingView.settings( settingView.settings(
loggedInUser, loggedInUser,
validationErrors = it.validationErrors validationErrors = it.validationErrors,
) ),
) )
} }
}, },
{ {
Response.redirect("/").invalidateCookie("Bearer") Response.redirect("/").invalidateCookie("Bearer")
} },
) )
} }
@ -73,10 +72,18 @@ class SettingsController(
.body(exportService.exportAsJson(loggedInUser.userId)) .body(exportService.exportAsJson(loggedInUser.userId))
} else Response(Status.OK).body(exportService.exportAsJson(loggedInUser.userId)).header( } else Response(Status.OK).body(exportService.exportAsJson(loggedInUser.userId)).header(
"Content-Type", "Content-Type",
"application/json" "application/json",
) )
} }
private fun Request.deleteForm(loggedInUser: LoggedInUser) = private fun Request.deleteForm(loggedInUser: LoggedInUser) =
DeleteForm(loggedInUser.username, form("password"), form("checked") != null) DeleteForm(loggedInUser.username, form("password"), form("checked") != null)
fun import(request: Request, loggedInUser: LoggedInUser): Response {
val form = MultipartFormBody.from(request)
val file = form.file("file") ?: return Response(Status.BAD_REQUEST)
val json = file.content.bufferedReader().readText()
importService.importJson(loggedInUser, json)
return Response.redirect("/notes")
}
} }

View File

@ -17,7 +17,7 @@ import org.http4k.core.cookie.SameSite
import org.http4k.core.cookie.cookie import org.http4k.core.cookie.cookie
import org.http4k.core.cookie.invalidateCookie import org.http4k.core.cookie.invalidateCookie
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class UserController( class UserController(
@ -27,7 +27,7 @@ class UserController(
) { ) {
fun register(request: Request, loggedInUser: LoggedInUser?): Response { fun register(request: Request, loggedInUser: LoggedInUser?): Response {
if (request.method == GET) return Response(OK).html( if (request.method == GET) return Response(OK).html(
userView.register(loggedInUser) userView.register(loggedInUser),
) )
val result = userService.register(request.registerForm()) val result = userService.register(request.registerForm())
@ -37,19 +37,19 @@ class UserController(
val html = when (it) { val html = when (it) {
RegisterError.UserExists -> userView.register( RegisterError.UserExists -> userView.register(
loggedInUser, loggedInUser,
error = "User already exists" error = "User already exists",
) )
is RegisterError.InvalidRegisterForm -> is RegisterError.InvalidRegisterForm ->
userView.register( userView.register(
loggedInUser, loggedInUser,
validationErrors = it.validationErrors validationErrors = it.validationErrors,
) )
} }
Response(OK).html(html) Response(OK).html(html)
}, },
{ {
Response.redirect("/login") Response.redirect("/login")
} },
) )
} }
@ -58,7 +58,7 @@ class UserController(
fun login(request: Request, loggedInUser: LoggedInUser?): Response { fun login(request: Request, loggedInUser: LoggedInUser?): Response {
if (request.method == GET) return Response(OK).html( if (request.method == GET) return Response(OK).html(
userView.login(loggedInUser) userView.login(loggedInUser),
) )
val result = userService.login(request.loginForm()) val result = userService.login(request.loginForm())
@ -69,24 +69,24 @@ class UserController(
LoginError.Unregistered -> LoginError.Unregistered ->
userView.login( userView.login(
loggedInUser, loggedInUser,
error = "User does not exist" error = "User does not exist",
) )
LoginError.WrongPassword -> LoginError.WrongPassword ->
userView.login( userView.login(
loggedInUser, loggedInUser,
error = "Wrong password" error = "Wrong password",
) )
is LoginError.InvalidLoginForm -> is LoginError.InvalidLoginForm ->
userView.login( userView.login(
loggedInUser, loggedInUser,
validationErrors = it.validationErrors validationErrors = it.validationErrors,
) )
} }
Response(OK).html(html) Response(OK).html(html)
}, },
{ token -> { token ->
Response.redirect("/notes").loginCookie(token, request.isSecure()) Response.redirect("/notes").loginCookie(token, request.isSecure())
} },
) )
} }
@ -101,8 +101,8 @@ class UserController(
httpOnly = true, httpOnly = true,
sameSite = SameSite.Lax, sameSite = SameSite.Lax,
maxAge = validityInSeconds, maxAge = validityInSeconds,
secure = secure secure = secure,
) ),
) )
} }

View File

@ -24,13 +24,13 @@ fun Request.isSecure() = header("X-Forwarded-Proto")?.contains("https") ?: false
val bodyLens = httpBodyRoot( val bodyLens = httpBodyRoot(
listOf(Meta(true, "body", ParamMeta.ObjectParam, "body")), listOf(Meta(true, "body", ParamMeta.ObjectParam, "body")),
ContentType.APPLICATION_JSON.withNoDirectives(), ContentType.APPLICATION_JSON.withNoDirectives(),
ContentNegotiation.StrictNoDirective ContentNegotiation.StrictNoDirective,
).map( ).map(
{ it.payload.asString() }, { it.payload.asString() },
{ Body(it) } { Body(it) },
) )
inline fun <reified T> Json.auto(): BiDiBodyLensSpec<T> = bodyLens.map( inline fun <reified T> Json.auto(): BiDiBodyLensSpec<T> = bodyLens.map(
{ decodeFromString(it) }, { decodeFromString(it) },
{ encodeToString(it) } { encodeToString(it) },
) )

View File

@ -10,7 +10,7 @@ import org.http4k.core.Status.Companion.NOT_IMPLEMENTED
import org.http4k.core.Status.Companion.SERVICE_UNAVAILABLE import org.http4k.core.Status.Companion.SERVICE_UNAVAILABLE
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.sql.SQLTransientException import java.sql.SQLTransientException
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class ErrorFilter(private val errorView: ErrorView) : Filter { class ErrorFilter(private val errorView: ErrorView) : Filter {

View File

@ -8,14 +8,14 @@ import org.eclipse.jetty.servlet.ServletHolder
import org.http4k.core.HttpHandler import org.http4k.core.HttpHandler
import org.http4k.server.Http4kServer import org.http4k.server.Http4kServer
import org.http4k.server.ServerConfig import org.http4k.server.ServerConfig
import org.http4k.servlet.asServlet import org.http4k.servlet.jakarta.asServlet
class Jetty(private val port: Int, private val server: Server) : ServerConfig { class Jetty(private val port: Int, private val server: Server) : ServerConfig {
constructor(port: Int, vararg inConnectors: ConnectorBuilder) : this( constructor(port: Int, vararg inConnectors: ConnectorBuilder) : this(
port, port,
Server().apply { Server().apply {
inConnectors.forEach { addConnector(it(this)) } inConnectors.forEach { addConnector(it(this)) }
} },
) )
override fun toServer(http: HttpHandler): Http4kServer { override fun toServer(http: HttpHandler): Http4kServer {

View File

@ -7,8 +7,8 @@ import io.micronaut.context.annotation.Factory
import io.micronaut.context.annotation.Primary import io.micronaut.context.annotation.Primary
import org.http4k.core.RequestContexts import org.http4k.core.RequestContexts
import org.http4k.lens.RequestContextKey import org.http4k.lens.RequestContextKey
import javax.inject.Named import jakarta.inject.Named
import javax.inject.Singleton import jakarta.inject.Singleton
@Factory @Factory
class AuthModule { class AuthModule {
@ -39,7 +39,7 @@ class AuthModule {
simpleJwt = simpleJwt, simpleJwt = simpleJwt,
lens = lens, lens = lens,
source = JwtSource.Header, source = JwtSource.Header,
redirect = false redirect = false,
) )
@Singleton @Singleton

View File

@ -7,7 +7,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.SerializersModule
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.* import java.util.*
import javax.inject.Singleton import jakarta.inject.Singleton
@Factory @Factory
class JsonModule { class JsonModule {

View File

@ -9,8 +9,8 @@ import io.micronaut.context.annotation.Factory
import org.eclipse.jetty.server.ServerConnector import org.eclipse.jetty.server.ServerConnector
import org.http4k.server.Http4kServer import org.http4k.server.Http4kServer
import org.http4k.server.asServer import org.http4k.server.asServer
import javax.inject.Named import jakarta.inject.Named
import javax.inject.Singleton import jakarta.inject.Singleton
import org.eclipse.jetty.server.Server as JettyServer import org.eclipse.jetty.server.Server as JettyServer
import org.http4k.server.ServerConfig as Http4kServerConfig import org.http4k.server.ServerConfig as Http4kServerConfig

View File

@ -12,8 +12,8 @@ import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.bind import org.http4k.routing.bind
import org.http4k.routing.routes import org.http4k.routing.routes
import java.util.function.Supplier import java.util.function.Supplier
import javax.inject.Named import jakarta.inject.Named
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class ApiRoutes( class ApiRoutes(
@ -23,7 +23,6 @@ class ApiRoutes(
@Named("required") private val authLens: RequiredAuthLens, @Named("required") private val authLens: RequiredAuthLens,
) : Supplier<RoutingHttpHandler> { ) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler { override fun get(): RoutingHttpHandler {
infix fun PathMethod.to(action: ProtectedHandler) = infix fun PathMethod.to(action: ProtectedHandler) =
this to { req: Request -> action(req, authLens(req)) } this to { req: Request -> action(req, authLens(req)) }
@ -38,9 +37,9 @@ class ApiRoutes(
"/search" bind POST to ::search, "/search" bind POST to ::search,
"/{uuid}" bind GET to ::note, "/{uuid}" bind GET to ::note,
"/{uuid}" bind PUT to ::update, "/{uuid}" bind PUT to ::update,
) ),
).withBasePath("/notes") ).withBasePath("/notes")
} },
).withBasePath("/api") ).withBasePath("/api")
} }

View File

@ -13,8 +13,8 @@ import org.http4k.core.Request
import org.http4k.core.then import org.http4k.core.then
import org.http4k.routing.* import org.http4k.routing.*
import java.util.function.Supplier import java.util.function.Supplier
import javax.inject.Named import jakarta.inject.Named
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class BasicRoutes( class BasicRoutes(
@ -26,7 +26,6 @@ class BasicRoutes(
) : Supplier<RoutingHttpHandler> { ) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler { override fun get(): RoutingHttpHandler {
infix fun PathMethod.to(action: PublicHandler) = infix fun PathMethod.to(action: PublicHandler) =
this to { req: Request -> action(req, authLens(req)) } this to { req: Request -> action(req, authLens(req)) }
@ -34,8 +33,8 @@ class BasicRoutes(
static( static(
ResourceLoader.Classpath("/static"), ResourceLoader.Classpath("/static"),
"woff2" to ContentType("font/woff2"), "woff2" to ContentType("font/woff2"),
"webmanifest" to ContentType("application/manifest+json") "webmanifest" to ContentType("application/manifest+json"),
) ),
) )
return routes( return routes(
@ -48,9 +47,9 @@ class BasicRoutes(
"/login" bind POST to userCtrl::login, "/login" bind POST to userCtrl::login,
"/logout" bind POST to userCtrl::logout, "/logout" bind POST to userCtrl::logout,
"/notes/public/{uuid}" bind GET to noteCtrl::public, "/notes/public/{uuid}" bind GET to noteCtrl::public,
) ),
), ),
staticHandler staticHandler,
) )
} }
} }

View File

@ -12,8 +12,8 @@ import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.bind import org.http4k.routing.bind
import org.http4k.routing.routes import org.http4k.routing.routes
import java.util.function.Supplier import java.util.function.Supplier
import javax.inject.Named import jakarta.inject.Named
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class NoteRoutes( class NoteRoutes(
@ -22,7 +22,6 @@ class NoteRoutes(
@Named("required") private val authLens: RequiredAuthLens, @Named("required") private val authLens: RequiredAuthLens,
) : Supplier<RoutingHttpHandler> { ) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler { override fun get(): RoutingHttpHandler {
infix fun PathMethod.to(action: ProtectedHandler) = infix fun PathMethod.to(action: ProtectedHandler) =
this to { req: Request -> action(req, authLens(req)) } this to { req: Request -> action(req, authLens(req)) }
@ -40,7 +39,7 @@ class NoteRoutes(
"/{uuid}/edit" bind POST to ::edit, "/{uuid}/edit" bind POST to ::edit,
"/deleted/{uuid}" bind POST to ::deleted, "/deleted/{uuid}" bind POST to ::deleted,
).withBasePath("/notes") ).withBasePath("/notes")
} },
) )
} }
} }

View File

@ -9,7 +9,7 @@ import org.http4k.filter.ServerFilters.InitialiseRequestContext
import org.http4k.routing.RoutingHttpHandler import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.routes import org.http4k.routing.routes
import java.util.function.Supplier import java.util.function.Supplier
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class Router( class Router(
@ -18,9 +18,8 @@ class Router(
private val subRouters: List<Supplier<RoutingHttpHandler>>, private val subRouters: List<Supplier<RoutingHttpHandler>>,
) { ) {
operator fun invoke(): RoutingHttpHandler { operator fun invoke(): RoutingHttpHandler {
val routes = routes( val routes = routes(
*subRouters.map { it.get() }.toTypedArray() *subRouters.map { it.get() }.toTypedArray(),
) )
return errorFilter return errorFilter

View File

@ -12,8 +12,8 @@ import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.bind import org.http4k.routing.bind
import org.http4k.routing.routes import org.http4k.routing.routes
import java.util.function.Supplier import java.util.function.Supplier
import javax.inject.Named import jakarta.inject.Named
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class SettingsRoutes( class SettingsRoutes(
@ -22,7 +22,6 @@ class SettingsRoutes(
@Named("required") private val authLens: RequiredAuthLens, @Named("required") private val authLens: RequiredAuthLens,
) : Supplier<RoutingHttpHandler> { ) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler { override fun get(): RoutingHttpHandler {
infix fun PathMethod.to(action: ProtectedHandler) = infix fun PathMethod.to(action: ProtectedHandler) =
this to { req: Request -> action(req, authLens(req)) } this to { req: Request -> action(req, authLens(req)) }
@ -31,7 +30,8 @@ class SettingsRoutes(
"/settings" bind GET to settingsController::settings, "/settings" bind GET to settingsController::settings,
"/settings" bind POST to settingsController::settings, "/settings" bind POST to settingsController::settings,
"/export" bind POST to settingsController::export, "/export" bind POST to settingsController::export,
) "/import" bind POST to settingsController::import,
),
) )
} }
} }

View File

@ -17,6 +17,6 @@ internal class LocalDateTimeSerializer : KSerializer<LocalDateTime> {
} }
override fun deserialize(decoder: Decoder): LocalDateTime { override fun deserialize(decoder: Decoder): LocalDateTime {
TODO("Not implemented, isn't needed") return LocalDateTime.parse(decoder.decodeString())
} }
} }

View File

@ -3,7 +3,7 @@ package be.simplenotes.app.utils
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import javax.inject.Singleton import jakarta.inject.Singleton
interface StaticFileResolver { interface StaticFileResolver {
fun resolve(name: String): String? fun resolve(name: String): String?

View File

@ -58,8 +58,8 @@ internal class RequiredAuthFilterTest {
}, },
"/protected" bind GET to requiredAuth.then { request: Request -> "/protected" bind GET to requiredAuth.then { request: Request ->
Response(OK).body(requiredLens(request).toString()) Response(OK).body(requiredLens(request).toString())
} },
) ),
) )
// endregion // endregion

View File

@ -7,9 +7,9 @@ repositories {
} }
dependencies { dependencies {
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.0") implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.21")
implementation("org.jetbrains.kotlin:kotlin-serialization:1.5.0") implementation("org.jetbrains.kotlin:kotlin-serialization:1.8.21")
implementation("gradle.plugin.com.github.jengelman.gradle.plugins:shadow:7.0.0") implementation("com.github.johnrengelman:shadow:8.1.1")
implementation("org.jlleitschuh.gradle:ktlint-gradle:10.0.0") implementation("com.diffplug.spotless:spotless-plugin-gradle:6.13.0")
implementation("com.github.ben-manes:gradle-versions-plugin:0.28.0") implementation("com.github.ben-manes:gradle-versions-plugin:0.46.0")
} }

View File

@ -5,7 +5,7 @@ package be.simplenotes
object Libs { object Libs {
object Micronaut { object Micronaut {
private const val version = "2.5.1" private const val version = "4.0.0-M2"
const val inject = "io.micronaut:micronaut-inject:$version" const val inject = "io.micronaut:micronaut-inject:$version"
const val processor = "io.micronaut:micronaut-inject-java:$version" const val processor = "io.micronaut:micronaut-inject-java:$version"
} }

View File

@ -2,8 +2,10 @@ package be.simplenotes
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.api.plugins.JavaPluginConvention import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.api.tasks.SourceSetContainer
import org.gradle.kotlin.dsl.get import org.gradle.kotlin.dsl.get
import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.register import org.gradle.kotlin.dsl.register
import java.io.File import java.io.File
@ -17,7 +19,7 @@ class PostcssPlugin : Plugin<Project> {
getByName("processResources").dependsOn("postcss") getByName("processResources").dependsOn("postcss")
} }
val sourceSets = project.convention.getPlugin(JavaPluginConvention::class.java).sourceSets val sourceSets = project.extensions.getByType<SourceSetContainer>()
val root = File("${project.buildDir}/generated-resources/css") val root = File("${project.buildDir}/generated-resources/css")
sourceSets["main"].resources.srcDir(root) sourceSets["main"].resources.srcDir(root)
} }

View File

@ -11,6 +11,10 @@ tasks.withType<ShadowJar> {
archiveAppendix.set("with-dependencies") archiveAppendix.set("with-dependencies")
manifest.attributes["Main-Class"] = "be.simplenotes.app.SimpleNotesKt" manifest.attributes["Main-Class"] = "be.simplenotes.app.SimpleNotesKt"
// johnrengelman/shadow#449
// we need this for lucene-core
manifest.attributes["Multi-Release"] = "true"
mergeServiceFiles() mergeServiceFiles()
File(rootProject.projectDir, "buildSrc/src/main/resources/exclusions") File(rootProject.projectDir, "buildSrc/src/main/resources/exclusions")

View File

@ -4,5 +4,11 @@ plugins {
id("be.simplenotes.java-convention") id("be.simplenotes.java-convention")
id("be.simplenotes.kotlin-convention") id("be.simplenotes.kotlin-convention")
id("be.simplenotes.junit-convention") id("be.simplenotes.junit-convention")
id("org.jlleitschuh.gradle.ktlint") id("com.diffplug.spotless")
}
spotless {
kotlin {
ktlint("0.48.0").setEditorConfigPath(project.rootProject.file(".editorconfig"))
}
} }

View File

@ -12,8 +12,10 @@ group = "be.simplenotes"
version = "1.0-SNAPSHOT" version = "1.0-SNAPSHOT"
java { java {
sourceCompatibility = JavaVersion.VERSION_15 toolchain {
targetCompatibility = JavaVersion.VERSION_15 languageVersion.set(JavaLanguageVersion.of(19))
vendor.set(JvmVendorSpec.ORACLE)
}
} }
tasks.withType<JavaCompile> { tasks.withType<JavaCompile> {

View File

@ -1,5 +1,7 @@
package be.simplenotes package be.simplenotes
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
@ -13,14 +15,14 @@ dependencies {
} }
tasks.withType<KotlinCompile> { tasks.withType<KotlinCompile> {
kotlinOptions { compilerOptions {
jvmTarget = "15" jvmTarget.set(JvmTarget.JVM_19)
javaParameters = true javaParameters.set(true)
freeCompilerArgs = listOf( freeCompilerArgs.addAll(
"-Xinline-classes", "-Xinline-classes",
"-Xno-param-assertions", "-Xno-param-assertions",
"-Xno-call-assertions", "-Xno-call-assertions",
"-Xno-receiver-assertions" "-Xno-receiver-assertions",
) )
} }
} }

View File

@ -10,11 +10,7 @@ tasks.named<DependencyUpdatesTask>("dependencyUpdates").configure {
resolutionStrategy { resolutionStrategy {
componentSelection { componentSelection {
all { all {
if ("RC" in candidate.version) reject("Release candidate") if ("alpha|beta|rc".toRegex().containsMatchIn(candidate.version.lowercase())) reject("Non stable version")
when {
candidate.group == "org.eclipse.jetty" && candidate.version.startsWith("11.") -> reject("javax -> jakarta")
}
} }
} }
} }

View File

@ -1,5 +1,6 @@
META-INF/maven/** META-INF/maven/**
META-INF/proguard/** META-INF/proguard/**
META-INF/com.android.tools/**
META-INF/*.kotlin_module META-INF/*.kotlin_module
META-INF/DEPENDENCIES* META-INF/DEPENDENCIES*
META-INF/NOTICE* META-INF/NOTICE*
@ -7,6 +8,7 @@ META-INF/LICENSE*
LICENSE* LICENSE*
META-INF/README* META-INF/README*
META-INF/native-image/** META-INF/native-image/**
**/module-info.**
# Jetty # Jetty
about.html about.html

View File

@ -7,6 +7,7 @@ plugins {
dependencies { dependencies {
micronaut() micronaut()
runtimeOnly(libs.yaml)
testImplementation(libs.bundles.test) testImplementation(libs.bundles.test)
testRuntimeOnly(libs.slf4j.logback) testRuntimeOnly(libs.slf4j.logback)

View File

@ -3,7 +3,7 @@ package be.simplenotes.config
import io.micronaut.context.annotation.* import io.micronaut.context.annotation.*
import java.nio.file.Path import java.nio.file.Path
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Singleton import jakarta.inject.Singleton
data class DataConfig(val dataDir: String) data class DataConfig(val dataDir: String)
@ -36,7 +36,7 @@ class ConfigFactory {
fun datasourceConfig(dataConfig: DataConfig) = DataSourceConfig( fun datasourceConfig(dataConfig: DataConfig) = DataSourceConfig(
jdbcUrl = "jdbc:h2:" + Path.of(dataConfig.dataDir, "simplenotes").normalize().toAbsolutePath(), jdbcUrl = "jdbc:h2:" + Path.of(dataConfig.dataDir, "simplenotes").normalize().toAbsolutePath(),
maximumPoolSize = 10, maximumPoolSize = 10,
connectionTimeout = 1000 connectionTimeout = 1000,
) )
@Singleton @Singleton
@ -44,7 +44,7 @@ class ConfigFactory {
fun testDatasourceConfig() = DataSourceConfig( fun testDatasourceConfig() = DataSourceConfig(
jdbcUrl = "jdbc:h2:mem:regular;DB_CLOSE_DELAY=-1", jdbcUrl = "jdbc:h2:mem:regular;DB_CLOSE_DELAY=-1",
maximumPoolSize = 2, maximumPoolSize = 2,
connectionTimeout = 1000 connectionTimeout = 1000,
) )
@Singleton @Singleton
@ -54,5 +54,4 @@ class ConfigFactory {
@Singleton @Singleton
@Requires(env = ["test"]) @Requires(env = ["test"])
fun testDataConfig() = DataConfig("/tmp") fun testDataConfig() = DataConfig("/tmp")
} }

View File

@ -5,12 +5,11 @@
"//": "`gradle css`" "//": "`gradle css`"
}, },
"dependencies": { "dependencies": {
"autoprefixer": "^9.8.6", "autoprefixer": "^10.4.14",
"cssnano": "^4.1.10", "cssnano": "^6.0.1",
"postcss-cli": "^7.1.1", "postcss-cli": "^10.1.0",
"postcss-hash": "^2.0.0", "postcss-hash": "^3.0.0",
"postcss-import": "^12.0.1", "postcss-import": "^15.1.0",
"postcss-nested": "^4.2.3", "tailwindcss": "^3.3.2"
"tailwindcss": "^1.5.1"
} }
} }

View File

@ -1,7 +1,7 @@
module.exports = { module.exports = {
plugins: [ plugins: [
require('postcss-import'), require('postcss-import'),
require('postcss-nested'), require('tailwindcss/nesting'),
require('tailwindcss'), require('tailwindcss'),
require('autoprefixer'), require('autoprefixer'),
require('cssnano')({ require('cssnano')({

View File

@ -2,7 +2,7 @@
@apply font-semibold py-2 px-4 rounded; @apply font-semibold py-2 px-4 rounded;
&:focus { &:focus {
@apply outline-none shadow-outline; @apply outline-none ring;
} }
&:hover { &:hover {

View File

@ -1,6 +1,6 @@
#note { #note {
a { a {
@apply text-blue-700 underline; @apply text-blue-500 underline;
} }
p { p {

View File

@ -2,7 +2,7 @@
@apply italic font-semibold text-sm bg-teal-500 text-gray-900 rounded-full py-1 px-2 align-middle; @apply italic font-semibold text-sm bg-teal-500 text-gray-900 rounded-full py-1 px-2 align-middle;
&:focus { &:focus {
@apply outline-none shadow-outline bg-teal-800 text-white; @apply outline-none ring bg-teal-800 text-white;
} }
&:hover { &:hover {

View File

@ -1,9 +1,7 @@
module.exports = { module.exports = {
purge: { content: [
content: [ process.env.PURGE
process.env.PURGE ],
]
},
theme: { theme: {
fontFamily: { fontFamily: {
'sans': [ 'sans': [

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,7 @@ dependencies {
implementation(project(":persistence")) implementation(project(":persistence"))
implementation(project(":search")) implementation(project(":search"))
api(libs.arrow.core.data) api(libs.arrow.core)
api(libs.konform) api(libs.konform)
micronaut() micronaut()

View File

@ -9,7 +9,7 @@ import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import javax.inject.Singleton import jakarta.inject.Singleton
interface ExportService { interface ExportService {
fun exportAsJson(userId: Int): String fun exportAsJson(userId: Int): String

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))
},
)
}
}

View File

@ -1,50 +1,47 @@
package be.simplenotes.domain package be.simplenotes.domain
import arrow.core.Either import arrow.core.Either
import arrow.core.computations.either import arrow.core.raise.either
import arrow.core.left import arrow.core.raise.ensure
import arrow.core.right
import be.simplenotes.domain.validation.NoteValidations import be.simplenotes.domain.validation.NoteValidations
import be.simplenotes.types.NoteMetadata import be.simplenotes.types.NoteMetadata
import com.vladsch.flexmark.html.HtmlRenderer import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser import com.vladsch.flexmark.parser.Parser
import io.konform.validation.ValidationErrors import io.konform.validation.ValidationErrors
import jakarta.inject.Singleton
import org.yaml.snakeyaml.Yaml import org.yaml.snakeyaml.Yaml
import org.yaml.snakeyaml.parser.ParserException import org.yaml.snakeyaml.parser.ParserException
import org.yaml.snakeyaml.scanner.ScannerException import org.yaml.snakeyaml.scanner.ScannerException
import javax.inject.Singleton
interface MarkdownService { interface MarkdownService {
fun renderDocument(input: String): Either<MarkdownParsingError, Document> fun renderDocument(input: String): Either<MarkdownParsingError, Document>
} }
private typealias MetaMdPair = Pair<String, String>
@Singleton @Singleton
internal class MarkdownServiceImpl( internal class MarkdownServiceImpl(
private val parser: Parser, private val parser: Parser,
private val renderer: HtmlRenderer, private val renderer: HtmlRenderer,
) : MarkdownService { ) : MarkdownService {
private val yamlBoundPattern = "-{3}".toRegex() 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) val split = input.split(yamlBoundPattern, 3)
if (split.size < 3) return MarkdownParsingError.MissingMeta.left() ensure(split.size >= 3) { MarkdownParsingError.MissingMeta }
return (split[1].trim() to split[2].trim()).right() split[1].trim() to split[2].trim()
} }
private val yaml = Yaml() 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 { val load: Map<String, Any> = try {
yaml.load(input) yaml.load(input)
} catch (e: ParserException) { } catch (e: ParserException) {
return MarkdownParsingError.InvalidMeta.left() raise(MarkdownParsingError.InvalidMeta)
} catch (e: ScannerException) { } catch (e: ScannerException) {
return MarkdownParsingError.InvalidMeta.left() raise(MarkdownParsingError.InvalidMeta)
} }
val title = when (val titleNode = load["title"]) { val title = when (val titleNode = load["title"]) {
is String, is Number -> titleNode.toString() is String, is Number -> titleNode.toString()
else -> return MarkdownParsingError.InvalidMeta.left() else -> raise(MarkdownParsingError.InvalidMeta)
} }
val tagsNode = load["tags"] val tagsNode = load["tags"]
@ -53,15 +50,15 @@ internal class MarkdownServiceImpl(
else else
tagsNode.map { it.toString() } tagsNode.map { it.toString() }
return NoteMetadata(title, tags).right() NoteMetadata(title, tags)
} }
private fun renderMarkdown(markdown: String) = parser.parse(markdown).run(renderer::render) private fun renderMarkdown(markdown: String) = parser.parse(markdown).run(renderer::render)
override fun renderDocument(input: String) = either.eager<MarkdownParsingError, Document> { override fun renderDocument(input: String) = either {
val (meta, md) = !splitMetaFromDocument(input) val (meta, md) = splitMetaFromDocument(input).bind()
val parsedMeta = !parseMeta(meta) val parsedMeta = parseMeta(meta).bind()
!Either.fromNullable(NoteValidations.validateMetadata(parsedMeta)).swap() NoteValidations.validateMetadata(parsedMeta)?.let { raise(it) }
val html = renderMarkdown(md) val html = renderMarkdown(md)
Document(parsedMeta, html) Document(parsedMeta, html)
} }

View File

@ -1,6 +1,6 @@
package be.simplenotes.domain package be.simplenotes.domain
import arrow.core.computations.either import arrow.core.raise.either
import be.simplenotes.domain.security.HtmlSanitizer import be.simplenotes.domain.security.HtmlSanitizer
import be.simplenotes.domain.utils.parseSearchTerms import be.simplenotes.domain.utils.parseSearchTerms
import be.simplenotes.persistence.repositories.NoteRepository import be.simplenotes.persistence.repositories.NoteRepository
@ -8,12 +8,11 @@ import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.search.NoteSearcher import be.simplenotes.search.NoteSearcher
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import be.simplenotes.types.Note import be.simplenotes.types.Note
import be.simplenotes.types.PersistedNote
import be.simplenotes.types.PersistedNoteMetadata import be.simplenotes.types.PersistedNoteMetadata
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import jakarta.inject.Singleton
import java.util.* import java.util.*
import javax.annotation.PostConstruct
import javax.annotation.PreDestroy
import javax.inject.Singleton
@Singleton @Singleton
class NoteService( class NoteService(
@ -21,10 +20,10 @@ class NoteService(
private val noteRepository: NoteRepository, private val noteRepository: NoteRepository,
private val userRepository: UserRepository, private val userRepository: UserRepository,
private val searcher: NoteSearcher, private val searcher: NoteSearcher,
private val htmlSanitizer: HtmlSanitizer private val htmlSanitizer: HtmlSanitizer,
) { ) {
fun create(user: LoggedInUser, markdownText: String) = either.eager<MarkdownParsingError, PersistedNote> { fun create(user: LoggedInUser, markdownText: String) = either {
markdownService.renderDocument(markdownText) markdownService.renderDocument(markdownText)
.map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) } .map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) }
.map { Note(title = it.metadata.title, tags = it.metadata.tags, markdown = markdownText, html = it.html) } .map { Note(title = it.metadata.title, tags = it.metadata.tags, markdown = markdownText, html = it.html) }
@ -34,7 +33,7 @@ class NoteService(
} }
fun update(user: LoggedInUser, uuid: UUID, markdownText: String) = fun update(user: LoggedInUser, uuid: UUID, markdownText: String) =
either.eager<MarkdownParsingError, PersistedNote?> { either {
markdownService.renderDocument(markdownText) markdownService.renderDocument(markdownText)
.map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) } .map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) }
.map { .map {
@ -42,7 +41,7 @@ class NoteService(
title = it.metadata.title, title = it.metadata.title,
tags = it.metadata.tags, tags = it.metadata.tags,
markdown = markdownText, markdown = markdownText,
html = it.html html = it.html,
) )
} }
.map { noteRepository.update(user.userId, uuid, it) } .map { noteRepository.update(user.userId, uuid, it) }

View File

@ -1,10 +1,9 @@
package be.simplenotes.domain package be.simplenotes.domain
import arrow.core.Either import arrow.core.Either
import arrow.core.computations.either import arrow.core.raise.either
import arrow.core.filterOrElse import arrow.core.raise.ensure
import arrow.core.leftIfNull import arrow.core.raise.ensureNotNull
import arrow.core.rightIfNotNull
import be.simplenotes.domain.security.PasswordHash import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.security.SimpleJwt import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.validation.UserValidations import be.simplenotes.domain.validation.UserValidations
@ -13,8 +12,8 @@ import be.simplenotes.search.NoteSearcher
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import be.simplenotes.types.PersistedUser import be.simplenotes.types.PersistedUser
import io.konform.validation.ValidationErrors import io.konform.validation.ValidationErrors
import jakarta.inject.Singleton
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import javax.inject.Singleton
interface UserService { interface UserService {
fun register(form: RegisterForm): Either<RegisterError, PersistedUser> fun register(form: RegisterForm): Either<RegisterError, PersistedUser>
@ -30,31 +29,26 @@ internal class UserServiceImpl(
private val searcher: NoteSearcher, private val searcher: NoteSearcher,
) : UserService { ) : UserService {
override fun register(form: RegisterForm) = UserValidations.validateRegister(form) override fun register(form: RegisterForm) = either {
.filterOrElse({ !userRepository.exists(it.username) }, { RegisterError.UserExists }) val user = UserValidations.validateRegister(form).bind()
.map { it.copy(password = passwordHash.crypt(it.password)) } ensure(!userRepository.exists(user.username)) { RegisterError.UserExists }
.map { userRepository.create(it) } ensureNotNull(userRepository.create(user.copy(password = passwordHash.crypt(user.password)))) {
.leftIfNull { RegisterError.UserExists } 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 delete(form: DeleteForm) = either.eager<DeleteError, Unit> { override fun login(form: LoginForm) = either {
val user = !UserValidations.validateDelete(form) val user = UserValidations.validateLogin(form).bind()
val persistedUser = !userRepository.find(user.username).rightIfNotNull { DeleteError.Unregistered } val persistedUser = ensureNotNull(userRepository.find(user.username)) { LoginError.Unregistered }
!Either.conditionally( ensure(passwordHash.verify(form.password!!, persistedUser.password)) { LoginError.WrongPassword }
passwordHash.verify(user.password, persistedUser.password), jwt.sign(LoggedInUser(persistedUser))
{ DeleteError.WrongPassword }, }
{ }
) override fun delete(form: DeleteForm) = either {
!Either.conditionally(userRepository.delete(persistedUser.id), { DeleteError.Unregistered }, { }) 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) searcher.dropIndex(persistedUser.id)
} }
} }

View File

@ -5,7 +5,7 @@ import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser import com.vladsch.flexmark.parser.Parser
import com.vladsch.flexmark.util.data.MutableDataSet import com.vladsch.flexmark.util.data.MutableDataSet
import io.micronaut.context.annotation.Factory import io.micronaut.context.annotation.Factory
import javax.inject.Singleton import jakarta.inject.Singleton
@Factory @Factory
class FlexmarkFactory { class FlexmarkFactory {
@ -23,11 +23,11 @@ class FlexmarkFactory {
set(TaskListExtension.LOOSE_ITEM_CLASS, "") set(TaskListExtension.LOOSE_ITEM_CLASS, "")
set( set(
TaskListExtension.ITEM_DONE_MARKER, TaskListExtension.ITEM_DONE_MARKER,
"""<input type="checkbox" checked="checked" disabled="disabled" readonly="readonly" />&nbsp;""" """<input type="checkbox" checked="checked" disabled="disabled" readonly="readonly" />&nbsp;""",
) )
set( set(
TaskListExtension.ITEM_NOT_DONE_MARKER, TaskListExtension.ITEM_NOT_DONE_MARKER,
"""<input type="checkbox" disabled="disabled" readonly="readonly" />&nbsp;""" """<input type="checkbox" disabled="disabled" readonly="readonly" />&nbsp;""",
) )
set(HtmlRenderer.SOFT_BREAK, "<br>") set(HtmlRenderer.SOFT_BREAK, "<br>")
} }

View File

@ -4,7 +4,7 @@ import be.simplenotes.types.LoggedInUser
import org.owasp.html.HtmlChangeListener import org.owasp.html.HtmlChangeListener
import org.owasp.html.HtmlPolicyBuilder import org.owasp.html.HtmlPolicyBuilder
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class HtmlSanitizer { class HtmlSanitizer {

View File

@ -3,7 +3,7 @@ package be.simplenotes.domain.security
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import com.auth0.jwt.JWTCreator import com.auth0.jwt.JWTCreator
import com.auth0.jwt.interfaces.DecodedJWT import com.auth0.jwt.interfaces.DecodedJWT
import javax.inject.Singleton import jakarta.inject.Singleton
interface JwtMapper<T> { interface JwtMapper<T> {
fun extract(decodedJWT: DecodedJWT): T? fun extract(decodedJWT: DecodedJWT): T?

View File

@ -1,8 +1,8 @@
package be.simplenotes.domain.security package be.simplenotes.domain.security
import org.mindrot.jbcrypt.BCrypt import org.mindrot.jbcrypt.BCrypt
import javax.inject.Inject import jakarta.inject.Inject
import javax.inject.Singleton import jakarta.inject.Singleton
internal interface PasswordHash { internal interface PasswordHash {
fun crypt(password: String): String fun crypt(password: String): String

View File

@ -7,7 +7,7 @@ import com.auth0.jwt.algorithms.Algorithm
import com.auth0.jwt.exceptions.JWTVerificationException import com.auth0.jwt.exceptions.JWTVerificationException
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class SimpleJwt<T>(jwtConfig: JwtConfig, private val mapper: JwtMapper<T>) { class SimpleJwt<T>(jwtConfig: JwtConfig, private val mapper: JwtMapper<T>) {

View File

@ -89,6 +89,6 @@ internal fun parseSearchTerms(input: String): SearchTerms {
title = title, title = title,
tag = tag, tag = tag,
content = content, content = content,
all = all all = all,
) )
} }

View File

@ -1,8 +1,7 @@
package be.simplenotes.domain.validation package be.simplenotes.domain.validation
import arrow.core.Either import arrow.core.raise.either
import arrow.core.left import arrow.core.raise.ensure
import arrow.core.right
import be.simplenotes.domain.* import be.simplenotes.domain.*
import be.simplenotes.types.User import be.simplenotes.types.User
import io.konform.validation.Validation 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 val errors = loginValidator.validate(form).errors
return if (errors.isEmpty()) User(form.username!!, form.password!!).right() ensure(errors.isEmpty()) { LoginError.InvalidLoginForm(errors) }
else return LoginError.InvalidLoginForm(errors).left() User(form.username!!, form.password!!)
} }
fun validateRegister(form: RegisterForm): Either<RegisterError.InvalidRegisterForm, User> { fun validateRegister(form: RegisterForm) = either {
val errors = loginValidator.validate(form).errors val errors = loginValidator.validate(form).errors
return if (errors.isEmpty()) User(form.username!!, form.password!!).right() ensure(errors.isEmpty()) { RegisterError.InvalidRegisterForm(errors) }
else return RegisterError.InvalidRegisterForm(errors).left() User(form.username!!, form.password!!)
} }
private val deleteValidator = Validation<DeleteForm> { 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 val errors = deleteValidator.validate(form).errors
return if (errors.isEmpty()) User(form.username!!, form.password!!).right() ensure(errors.isEmpty()) { DeleteError.InvalidForm(errors) }
else return DeleteError.InvalidForm(errors).left() User(form.username!!, form.password!!)
} }
} }

View File

@ -33,7 +33,7 @@ internal class LoggedInUserExtractorTest {
createToken(), createToken(),
createToken(username = "user", id = 1, secret = "not the correct secret"), createToken(username = "user", id = 1, secret = "not the correct secret"),
createToken(username = "user", id = 1) + "\"efesfsef", createToken(username = "user", id = 1) + "\"efesfsef",
"something that is not even a token" "something that is not even a token",
) )
@ParameterizedTest(name = "[{index}] token `{0}` should be invalid") @ParameterizedTest(name = "[{index}] token `{0}` should be invalid")

View File

@ -21,7 +21,7 @@ fun isRight() = object : Matcher<Either<*, *>> {
override fun invoke(actual: Either<*, *>) = when (actual) { override fun invoke(actual: Either<*, *>) = when (actual) {
is Either.Right -> MatchResult.Match is Either.Right -> MatchResult.Match
is Either.Left -> { is Either.Left -> {
val valueA = actual.a val valueA = actual.value
MatchResult.Mismatch("is Either.Left<${if (valueA == null) "Null" else valueA::class.simpleName}>") MatchResult.Mismatch("is Either.Left<${if (valueA == null) "Null" else valueA::class.simpleName}>")
} }
} }
@ -34,7 +34,7 @@ inline fun <reified A> isLeftOfType() = object : Matcher<Either<*, *>> {
override fun invoke(actual: Either<*, *>) = when (actual) { override fun invoke(actual: Either<*, *>) = when (actual) {
is Either.Right -> MatchResult.Mismatch("was Either.Right<>") is Either.Right -> MatchResult.Mismatch("was Either.Right<>")
is Either.Left -> { is Either.Left -> {
val valueA = actual.a val valueA = actual.value
if (valueA is A) MatchResult.Match if (valueA is A) MatchResult.Match
else MatchResult.Mismatch("was Left<${if (valueA == null) "Null" else valueA::class.simpleName}>") else MatchResult.Mismatch("was Left<${if (valueA == null) "Null" else valueA::class.simpleName}>")
} }

View File

@ -33,7 +33,7 @@ internal class SearchTermsParserKtTest {
"tag:'example abc' title:'other with words' this is the end ", "tag:'example abc' title:'other with words' this is the end ",
title = "other with words", title = "other with words",
tag = "example abc", tag = "example abc",
all = "this is the end" all = "this is the end",
), ),
createResult("tag:blah", tag = "blah"), createResult("tag:blah", tag = "blah"),
createResult("tag:'some words'", tag = "some words"), createResult("tag:'some words'", tag = "some words"),
@ -41,7 +41,7 @@ internal class SearchTermsParserKtTest {
createResult( createResult(
"tag:'double quote inside single \" ' global", "tag:'double quote inside single \" ' global",
tag = "double quote inside single \" ", tag = "double quote inside single \" ",
all = "global" all = "global",
), ),
) )

View File

@ -22,7 +22,7 @@ internal class UserValidationsTest {
LoginForm(username = "", password = ""), LoginForm(username = "", password = ""),
LoginForm(username = "a", password = "aaaa"), LoginForm(username = "a", password = "aaaa"),
LoginForm(username = "a".repeat(51), password = "a".repeat(8)), LoginForm(username = "a".repeat(51), password = "a".repeat(8)),
LoginForm(username = "a".repeat(10), password = "a".repeat(7)) LoginForm(username = "a".repeat(10), password = "a".repeat(7)),
) )
@ParameterizedTest @ParameterizedTest
@ -34,7 +34,7 @@ internal class UserValidationsTest {
@Suppress("Unused") @Suppress("Unused")
fun validLoginForms(): Stream<LoginForm> = Stream.of( fun validLoginForms(): Stream<LoginForm> = Stream.of(
LoginForm(username = "a".repeat(50), password = "a".repeat(72)), LoginForm(username = "a".repeat(50), password = "a".repeat(72)),
LoginForm(username = "a".repeat(3), password = "a".repeat(8)) LoginForm(username = "a".repeat(3), password = "a".repeat(8)),
) )
@ParameterizedTest @ParameterizedTest
@ -53,7 +53,7 @@ internal class UserValidationsTest {
RegisterForm(username = "", password = ""), RegisterForm(username = "", password = ""),
RegisterForm(username = "a", password = "aaaa"), RegisterForm(username = "a", password = "aaaa"),
RegisterForm(username = "a".repeat(51), password = "a".repeat(8)), RegisterForm(username = "a".repeat(51), password = "a".repeat(8)),
RegisterForm(username = "a".repeat(10), password = "a".repeat(7)) RegisterForm(username = "a".repeat(10), password = "a".repeat(7)),
) )
@ParameterizedTest @ParameterizedTest
@ -65,7 +65,7 @@ internal class UserValidationsTest {
@Suppress("Unused") @Suppress("Unused")
fun validRegisterForms(): Stream<RegisterForm> = Stream.of( fun validRegisterForms(): Stream<RegisterForm> = Stream.of(
RegisterForm(username = "a".repeat(50), password = "a".repeat(72)), RegisterForm(username = "a".repeat(50), password = "a".repeat(72)),
RegisterForm(username = "a".repeat(3), password = "a".repeat(8)) RegisterForm(username = "a".repeat(3), password = "a".repeat(8)),
) )
@ParameterizedTest @ParameterizedTest

View File

@ -1,3 +1,3 @@
org.gradle.jvmargs=-Xmx2048M -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx2048M -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.caching=true org.gradle.caching=true
org.gradle.parallel=true org.gradle.parallel=true

View File

@ -1,36 +1,37 @@
[versions] [versions]
flexmark = "0.62.2" flexmark = "0.64.4"
lucene = "8.8.2" lucene = "9.5.0"
http4k = "4.8.0.0" http4k = "4.43.0.0"
jetty = "10.0.2" jetty = "11.0.15"
mapstruct = "1.4.2.Final" mapstruct = "1.5.5.Final"
micronaut-inject = "2.5.1" micronaut-inject = "4.0.0-M2"
[libraries] [libraries]
flexmark-core = { module = "com.vladsch.flexmark:flexmark", version.ref = "flexmark" } flexmark-core = { module = "com.vladsch.flexmark:flexmark", version.ref = "flexmark" }
flexmark-tasklist = { module = "com.vladsch.flexmark:flexmark-ext-gfm-tasklist", version.ref = "flexmark" } flexmark-tasklist = { module = "com.vladsch.flexmark:flexmark-ext-gfm-tasklist", version.ref = "flexmark" }
flyway = { module = "org.flywaydb:flyway-core", version = "7.8.2" } flyway = { module = "org.flywaydb:flyway-core", version = "9.17.0" }
hikariCP = { module = "com.zaxxer:HikariCP", version = "4.0.3" } hikariCP = { module = "com.zaxxer:HikariCP", version = "5.0.1" }
h2 = { module = "com.h2database:h2", version = "1.4.200" } h2 = { module = "com.h2database:h2", version = "2.1.214" }
ktorm = { module = "org.ktorm:ktorm-core", version = "3.3.0" } ktorm = { module = "org.ktorm:ktorm-core", version = "3.6.0" }
lucene-core = { module = "org.apache.lucene:lucene-core", version.ref = "lucene" } lucene-core = { module = "org.apache.lucene:lucene-core", version.ref = "lucene" }
lucene-analyzers-common = { module = "org.apache.lucene:lucene-analyzers-common", 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" } lucene-queryparser = { module = "org.apache.lucene:lucene-queryparser", version.ref = "lucene" }
http4k-core = { module = "org.http4k:http4k-core", version.ref = "http4k" } 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" } http4k-testing-hamkrest = { module = "org.http4k:http4k-testing-hamkrest", version.ref = "http4k" }
jetty-server = { module = "org.eclipse.jetty:jetty-server", version.ref = "jetty" } jetty-server = { module = "org.eclipse.jetty:jetty-server", version.ref = "jetty" }
jetty-servlet = { module = "org.eclipse.jetty:jetty-servlet", version.ref = "jetty" } jetty-servlet = { module = "org.eclipse.jetty:jetty-servlet", version.ref = "jetty" }
javax-servlet = { module = "javax.servlet:javax.servlet-api", version = "4.0.1" } javax-servlet = { module = "jakarta.servlet:jakarta.servlet-api", version = "6.0.0" }
kotlinx-html = { module = "org.jetbrains.kotlinx:kotlinx-html-jvm", version = "0.7.3" } 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.2.0" } 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.0-alpha1" } slf4j-api = { module = "org.slf4j:slf4j-api", version = "2.0.7" }
slf4j-logback = { module = "ch.qos.logback:logback-classic", version = "1.3.0-alpha5" } slf4j-logback = { module = "ch.qos.logback:logback-classic", version = "1.4.7" }
mapstruct-core = { module = "org.mapstruct:mapstruct", version.ref = "mapstruct" } mapstruct-core = { module = "org.mapstruct:mapstruct", version.ref = "mapstruct" }
mapstruct-processor = { module = "org.mapstruct:mapstruct-processor", version.ref = "mapstruct" } mapstruct-processor = { module = "org.mapstruct:mapstruct-processor", version.ref = "mapstruct" }
@ -38,19 +39,19 @@ mapstruct-processor = { module = "org.mapstruct:mapstruct-processor", version.re
micronaut-inject = { module = "io.micronaut:micronaut-inject", version.ref = "micronaut-inject" } micronaut-inject = { module = "io.micronaut:micronaut-inject", version.ref = "micronaut-inject" }
micronaut-processor = { module = "io.micronaut:micronaut-inject-java", version.ref = "micronaut-inject" } micronaut-processor = { module = "io.micronaut:micronaut-inject-java", version.ref = "micronaut-inject" }
arrow-core-data = { module = "io.arrow-kt:arrow-core-data", version = "0.11.0" } arrow-core = { module = "io.arrow-kt:arrow-core", version = "1.2.0-RC" }
commonsCompress = { module = "org.apache.commons:commons-compress", version = "1.20" } commonsCompress = { module = "org.apache.commons:commons-compress", version = "1.23.0" }
jwt = { module = "com.auth0:java-jwt", version = "3.15.0" } jwt = { module = "com.auth0:java-jwt", version = "4.4.0" }
bcrypt = { module = "org.mindrot:jbcrypt", version = "0.4" } bcrypt = { module = "org.mindrot:jbcrypt", version = "0.4" }
konform = { module = "io.konform:konform-jvm", version = "0.2.0" } 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 = "20200713.1" } 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.1.Final" } prettytime = { module = "org.ocpsoft.prettytime:prettytime", version = "5.0.6.Final" }
yaml = { module = "org.yaml:snakeyaml", version = "1.28" } yaml = { module = "org.yaml:snakeyaml", version = "2.0" }
assertJ = { module = "org.assertj:assertj-core", version = "3.19.0" } assertJ = { module = "org.assertj:assertj-core", version = "3.24.2" }
hamkrest = { module = "com.natpryce:hamkrest", version = "1.8.0.1" } hamkrest = { module = "com.natpryce:hamkrest", version = "1.8.0.1" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version = "5.7.1" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version = "5.9.3" }
mockk = { module = "io.mockk:mockk", version = "1.11.0" } mockk = { module = "io.mockk:mockk", version = "1.13.5" }
faker = { module = "com.github.javafaker:javafaker", version = "1.0.2" } faker = { module = "com.github.javafaker:javafaker", version = "1.0.2" }
[bundles] [bundles]

Binary file not shown.

View File

@ -1,5 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

View File

@ -1,7 +1,7 @@
package be.simplenotes.persistence package be.simplenotes.persistence
import org.flywaydb.core.Flyway import org.flywaydb.core.Flyway
import javax.inject.Singleton import jakarta.inject.Singleton
import javax.sql.DataSource import javax.sql.DataSource
interface DbMigrations { interface DbMigrations {
@ -14,6 +14,7 @@ internal class DbMigrationsImpl(private val dataSource: DataSource) : DbMigratio
Flyway.configure() Flyway.configure()
.dataSource(dataSource) .dataSource(dataSource)
.locations("db/migration") .locations("db/migration")
.loggers("slf4j")
.load() .load()
.migrate() .migrate()
} }

View File

@ -8,7 +8,7 @@ import io.micronaut.context.annotation.Bean
import io.micronaut.context.annotation.Factory import io.micronaut.context.annotation.Factory
import org.ktorm.database.Database import org.ktorm.database.Database
import org.ktorm.database.SqlDialect import org.ktorm.database.SqlDialect
import javax.inject.Singleton import jakarta.inject.Singleton
import javax.sql.DataSource import javax.sql.DataSource
@Factory @Factory
@ -17,10 +17,13 @@ class PersistenceModule {
@Singleton @Singleton
internal fun database(migrations: DbMigrations, dataSource: DataSource): Database { internal fun database(migrations: DbMigrations, dataSource: DataSource): Database {
migrations.migrate() migrations.migrate()
return Database.connect(dataSource, dialect = object : SqlDialect { return Database.connect(
override fun createSqlFormatter(database: Database, beautifySql: Boolean, indentSize: Int) = dataSource,
CustomSqlFormatter(database, beautifySql, indentSize) dialect = object : SqlDialect {
}) override fun createSqlFormatter(database: Database, beautifySql: Boolean, indentSize: Int) =
CustomSqlFormatter(database, beautifySql, indentSize)
},
)
} }
@Singleton @Singleton

View File

@ -11,8 +11,9 @@ internal class VarcharArraySqlType : SqlType<List<String>>(Types.ARRAY, typeName
override fun doGetResult(rs: ResultSet, index: Int): List<String>? { override fun doGetResult(rs: ResultSet, index: Int): List<String>? {
return when (val array = rs.getObject(index)) { return when (val array = rs.getObject(index)) {
null -> null null -> null
is java.sql.Array -> (array.array as Array<*>).filterNotNull().map { it.toString() }
is Array<*> -> array.map { it.toString() } is Array<*> -> array.map { it.toString() }
else -> error("") else -> error("Unable to deserialize varchar[]")
} }
} }
@ -27,7 +28,7 @@ data class ArrayContainsExpression(
val left: ScalarExpression<*>, val left: ScalarExpression<*>,
val right: ScalarExpression<*>, val right: ScalarExpression<*>,
override val sqlType: SqlType<Boolean> = BooleanSqlType, override val sqlType: SqlType<Boolean> = BooleanSqlType,
override val isLeafNode: Boolean = false override val isLeafNode: Boolean = false,
) : ScalarExpression<Boolean>() { ) : ScalarExpression<Boolean>() {
override val extraProperties: Map<String, Any> get() = emptyMap() override val extraProperties: Map<String, Any> get() = emptyMap()
} }

View File

@ -13,7 +13,7 @@ interface NoteRepository {
limit: Int = 20, limit: Int = 20,
offset: Int = 0, offset: Int = 0,
tag: String? = null, tag: String? = null,
deleted: Boolean = false deleted: Boolean = false,
): List<PersistedNoteMetadata> ): List<PersistedNoteMetadata>
fun count(userId: Int, tag: String? = null, deleted: Boolean = false): Int 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 create(userId: Int, note: Note): PersistedNote
fun find(userId: Int, uuid: UUID): PersistedNote? fun find(userId: Int, uuid: UUID): PersistedNote?
fun update(userId: Int, uuid: UUID, note: Note): PersistedNote? fun update(userId: Int, uuid: UUID, note: Note): PersistedNote?
fun import(userId: Int, notes: List<ExportedNote>)
fun export(userId: Int): List<ExportedNote> fun export(userId: Int): List<ExportedNote>
fun findAllDetails(userId: Int): List<PersistedNote> fun findAllDetails(userId: Int): List<PersistedNote>

View File

@ -3,6 +3,7 @@ package be.simplenotes.persistence.repositories
import be.simplenotes.persistence.* import be.simplenotes.persistence.*
import be.simplenotes.persistence.converters.NoteConverter import be.simplenotes.persistence.converters.NoteConverter
import be.simplenotes.persistence.extensions.arrayContains import be.simplenotes.persistence.extensions.arrayContains
import be.simplenotes.types.ExportedNote
import be.simplenotes.types.Note import be.simplenotes.types.Note
import be.simplenotes.types.PersistedNote import be.simplenotes.types.PersistedNote
import org.ktorm.database.Database import org.ktorm.database.Database
@ -10,7 +11,7 @@ import org.ktorm.dsl.*
import org.ktorm.entity.* import org.ktorm.entity.*
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.* import java.util.*
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
internal class NoteRepositoryImpl( internal class NoteRepositoryImpl(
@ -40,14 +41,18 @@ internal class NoteRepositoryImpl(
val uuid = UUID.randomUUID() val uuid = UUID.randomUUID()
val entity = converter.toEntity(note, uuid, userId, LocalDateTime.now()) val entity = converter.toEntity(note, uuid, userId, LocalDateTime.now())
db.notes.add(entity) db.notes.add(entity)
db.batchInsert(Tags) {
note.tags.forEach { tagName -> note.tags.takeIf { it.isNotEmpty() }?.run {
item { db.batchInsert(Tags) {
set(it.noteUuid, uuid) forEach { tagName ->
set(it.name, tagName) item {
set(it.noteUuid, uuid)
set(it.name, tagName)
}
} }
} }
} }
return find(userId, uuid) ?: error("Note not found") return find(userId, uuid) ?: error("Note not found")
} }
@ -114,6 +119,43 @@ internal class NoteRepositoryImpl(
.map { it.getInt(1) } .map { it.getInt(1) }
.first() .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 override fun export(userId: Int) = db.noteWithTags
.filterColumns { it.columns - it.userId - it.public } .filterColumns { it.columns - it.userId - it.public }
.filter { it.userId eq userId } .filter { it.userId eq userId }

View File

@ -10,7 +10,7 @@ import org.ktorm.dsl.*
import org.ktorm.entity.any import org.ktorm.entity.any
import org.ktorm.entity.find import org.ktorm.entity.find
import java.sql.SQLIntegrityConstraintViolationException import java.sql.SQLIntegrityConstraintViolationException
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
internal class UserRepositoryImpl( internal class UserRepositoryImpl(

View File

@ -11,7 +11,7 @@ import javax.sql.DataSource
@ResourceLock("h2") @ResourceLock("h2")
abstract class DbTest { abstract class DbTest {
val beanContext = ApplicationContext.build().deduceEnvironment(false).environments("test").start() val beanContext = ApplicationContext.builder().deduceEnvironment(false).environments("test").start()
inline fun <reified T> BeanContext.getBean(): T = getBean(T::class.java) inline fun <reified T> BeanContext.getBean(): T = getBean(T::class.java)
@ -22,6 +22,8 @@ abstract class DbTest {
Flyway.configure() Flyway.configure()
.dataSource(dataSource) .dataSource(dataSource)
.loggers("slf4j")
.cleanDisabled(false)
.load() .load()
.clean() .clean()

View File

@ -13,7 +13,6 @@ import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.parallel.ResourceLock
import org.ktorm.database.Database import org.ktorm.database.Database
import org.ktorm.dsl.eq import org.ktorm.dsl.eq
import org.ktorm.entity.filter import org.ktorm.entity.filter
@ -22,7 +21,6 @@ import org.ktorm.entity.mapColumns
import org.ktorm.entity.toList import org.ktorm.entity.toList
import java.sql.SQLIntegrityConstraintViolationException import java.sql.SQLIntegrityConstraintViolationException
internal class NoteRepositoryImplTest : DbTest() { internal class NoteRepositoryImplTest : DbTest() {
private lateinit var noteRepo: NoteRepository private lateinit var noteRepo: NoteRepository
@ -86,14 +84,14 @@ internal class NoteRepositoryImplTest : DbTest() {
.hasSize(3) .hasSize(3)
.usingElementComparatorIgnoringFields("updatedAt") .usingElementComparatorIgnoringFields("updatedAt")
.containsExactlyInAnyOrderElementsOf( .containsExactlyInAnyOrderElementsOf(
notes1.map { it.toPersistedMeta() } notes1.map { it.toPersistedMeta() },
) )
assertThat(noteRepo.findAll(user2.id)) assertThat(noteRepo.findAll(user2.id))
.hasSize(1) .hasSize(1)
.usingElementComparatorIgnoringFields("updatedAt") .usingElementComparatorIgnoringFields("updatedAt")
.containsExactlyInAnyOrderElementsOf( .containsExactlyInAnyOrderElementsOf(
notes2.map { it.toPersistedMeta() } notes2.map { it.toPersistedMeta() },
) )
assertThat(noteRepo.findAll(1000)).isEmpty() assertThat(noteRepo.findAll(1000)).isEmpty()
@ -131,7 +129,9 @@ internal class NoteRepositoryImplTest : DbTest() {
val note = db.notes.find { Notes.title eq fakeNote.title }!! val note = db.notes.find { Notes.title eq fakeNote.title }!!
.let { entity -> .let { entity ->
val tags = db.tags.filter { be.simplenotes.persistence.Tags.noteUuid eq entity.uuid }.mapColumns { be.simplenotes.persistence.Tags.name } as List<String> val tags = db.tags.filter {
be.simplenotes.persistence.Tags.noteUuid eq entity.uuid
}.mapColumns { be.simplenotes.persistence.Tags.name } as List<String>
PersistedNote( PersistedNote(
uuid = entity.uuid, uuid = entity.uuid,
title = entity.title, title = entity.title,

View File

@ -22,5 +22,5 @@ internal fun Document.toNoteMeta() = PersistedNoteMetadata(
title = get(titleField), title = get(titleField),
uuid = UuidFieldConverter.fromDoc(get(uuidField)), uuid = UuidFieldConverter.fromDoc(get(uuidField)),
updatedAt = LocalDateTimeFieldConverter.fromDoc(get(updatedAtField)), updatedAt = LocalDateTimeFieldConverter.fromDoc(get(updatedAtField)),
tags = TagsFieldConverter.fromDoc(get(tagsField)) tags = TagsFieldConverter.fromDoc(get(tagsField)),
) )

View File

@ -12,8 +12,8 @@ import org.slf4j.LoggerFactory
import java.io.File import java.io.File
import java.nio.file.Path import java.nio.file.Path
import java.util.* import java.util.*
import javax.inject.Named import jakarta.inject.Named
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
internal class NoteSearcherImpl(@Named("search-index") basePath: Path) : NoteSearcher { internal class NoteSearcherImpl(@Named("search-index") basePath: Path) : NoteSearcher {

View File

@ -4,7 +4,7 @@ import be.simplenotes.config.DataConfig
import io.micronaut.context.annotation.Factory import io.micronaut.context.annotation.Factory
import io.micronaut.context.annotation.Prototype import io.micronaut.context.annotation.Prototype
import java.nio.file.Path import java.nio.file.Path
import javax.inject.Named import jakarta.inject.Named
@Factory @Factory
class SearchModule { class SearchModule {

View File

@ -31,7 +31,7 @@ internal class NoteSearcherImplTest {
html = "", html = "",
updatedAt = LocalDateTime.MIN, updatedAt = LocalDateTime.MIN,
uuid = uuid, uuid = uuid,
public = false public = false,
) )
searcher.indexNote(1, note) searcher.indexNote(1, note)
return note return note

View File

@ -8,5 +8,3 @@ include(":types")
include(":persistence") include(":persistence")
include(":css") include(":css")
include(":junit-config") include(":junit-config")
enableFeaturePreview("VERSION_CATALOGS")

View File

@ -3,15 +3,15 @@ package be.simplenotes.views
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import kotlinx.html.* import kotlinx.html.*
import kotlinx.html.ThScope.col import kotlinx.html.ThScope.col
import javax.inject.Named import jakarta.inject.Named
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class BaseView(@Named("styles") styles: String) : View(styles) { class BaseView(@Named("styles") styles: String) : View(styles) {
fun renderHome(loggedInUser: LoggedInUser?) = renderPage( fun renderHome(loggedInUser: LoggedInUser?) = renderPage(
title = "Home", title = "Home",
description = "A fast and simple note taking website", description = "A fast and simple note taking website",
loggedInUser = loggedInUser loggedInUser = loggedInUser,
) { ) {
section("text-center my-2 p-2") { section("text-center my-2 p-2") {
h1("text-5xl casual") { h1("text-5xl casual") {
@ -21,7 +21,6 @@ class BaseView(@Named("styles") styles: String) : View(styles) {
} }
div("container mx-auto flex flex-wrap justify-center content-center") { div("container mx-auto flex flex-wrap justify-center content-center") {
div("md:order-1 order-2 flipped p-4 my-10 w-full md:w-1/2") { div("md:order-1 order-2 flipped p-4 my-10 w-full md:w-1/2") {
attributes["aria-label"] = "demo" attributes["aria-label"] = "demo"
div("flex justify-between mb-4") { div("flex justify-between mb-4") {

View File

@ -4,8 +4,8 @@ import be.simplenotes.views.components.Alert
import be.simplenotes.views.components.alert import be.simplenotes.views.components.alert
import kotlinx.html.a import kotlinx.html.a
import kotlinx.html.div import kotlinx.html.div
import javax.inject.Named import jakarta.inject.Named
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class ErrorView(@Named("styles") styles: String) : View(styles) { class ErrorView(@Named("styles") styles: String) : View(styles) {
@ -23,7 +23,7 @@ class ErrorView(@Named("styles") styles: String) : View(styles) {
Alert.Warning, Alert.Warning,
errorType.title, errorType.title,
"Please try again later", "Please try again later",
multiline = true multiline = true,
) )
Type.NotFound -> alert(Alert.Warning, errorType.title, "Page not found", multiline = true) Type.NotFound -> alert(Alert.Warning, errorType.title, "Page not found", multiline = true)
Type.Other -> alert(Alert.Warning, errorType.title) Type.Other -> alert(Alert.Warning, errorType.title)

View File

@ -6,8 +6,8 @@ import be.simplenotes.types.PersistedNoteMetadata
import be.simplenotes.views.components.* import be.simplenotes.views.components.*
import io.konform.validation.ValidationError import io.konform.validation.ValidationError
import kotlinx.html.* import kotlinx.html.*
import javax.inject.Named import jakarta.inject.Named
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class NoteView(@Named("styles") styles: String) : View(styles) { class NoteView(@Named("styles") styles: String) : View(styles) {
@ -41,7 +41,7 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
|--- |---
| |
""".trimMargin( """.trimMargin(
"|" "|",
) )
} }
submitButton("Save") submitButton("Save")
@ -123,10 +123,9 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
fun renderedNote(loggedInUser: LoggedInUser?, note: PersistedNote, shared: Boolean) = renderPage( fun renderedNote(loggedInUser: LoggedInUser?, note: PersistedNote, shared: Boolean) = renderPage(
note.title, note.title,
loggedInUser = loggedInUser, loggedInUser = loggedInUser,
scripts = listOf("/highlight.10.1.2.js", "/init-highlight.0.0.1.js") scripts = listOf("/highlight.10.1.2.js", "/init-highlight.0.0.1.js"),
) { ) {
div("container mx-auto p-4") { div("container mx-auto p-4") {
if (shared) { if (shared) {
p("p-4 bg-gray-800") { p("p-4 bg-gray-800") {
+"You are viewing a public note " +"You are viewing a public note "
@ -172,14 +171,14 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
form(method = FormMethod.post, classes = "inline flex space-x-2 justify-end mb-4") { form(method = FormMethod.post, classes = "inline flex space-x-2 justify-end mb-4") {
a( a(
href = "/notes/${note.uuid}/edit", href = "/notes/${note.uuid}/edit",
classes = "btn btn-green" classes = "btn btn-green",
) { +"Edit" } ) { +"Edit" }
span { span {
button( button(
type = ButtonType.submit, type = ButtonType.submit,
name = if (note.public) "private" else "public", name = if (note.public) "private" else "public",
classes = "font-semibold border-b-4 ${if (note.public) "border-teal-200" else "border-green-500"}" + classes = "font-semibold border-b-4 ${if (note.public) "border-teal-200" else "border-green-500"}" +
" p-2 rounded-l bg-teal-200 text-gray-800" " p-2 rounded-l bg-teal-200 text-gray-800",
) { ) {
+"Private" +"Private"
} }
@ -188,7 +187,7 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
name = if (note.public) "private" else "public", name = if (note.public) "private" else "public",
classes = "font-semibold border-b-4 " + classes = "font-semibold border-b-4 " +
(if (!note.public) "border-teal-200" else "border-green-500") + (if (!note.public) "border-teal-200" else "border-green-500") +
" p-2 rounded-r bg-teal-200 text-gray-800" " p-2 rounded-r bg-teal-200 text-gray-800",
) { ) {
+"Public" +"Public"
} }
@ -196,7 +195,7 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
button( button(
type = ButtonType.submit, type = ButtonType.submit,
name = "delete", name = "delete",
classes = "btn btn-red" classes = "btn btn-red",
) { +"Delete" } ) { +"Delete" }
} }
} }

View File

@ -6,10 +6,13 @@ import be.simplenotes.views.components.alert
import be.simplenotes.views.components.input import be.simplenotes.views.components.input
import be.simplenotes.views.extensions.summary import be.simplenotes.views.extensions.summary
import io.konform.validation.ValidationError import io.konform.validation.ValidationError
import jakarta.inject.Named
import jakarta.inject.Singleton
import kotlinx.html.* import kotlinx.html.*
import kotlinx.html.ButtonType.submit import kotlinx.html.ButtonType.submit
import javax.inject.Named import kotlinx.html.FormEncType.multipartFormData
import javax.inject.Singleton import kotlinx.html.FormMethod.post
import kotlinx.html.InputType.file
@Singleton @Singleton
class SettingView(@Named("styles") styles: String) : View(styles) { class SettingView(@Named("styles") styles: String) : View(styles) {
@ -20,7 +23,6 @@ class SettingView(@Named("styles") styles: String) : View(styles) {
validationErrors: List<ValidationError> = emptyList(), validationErrors: List<ValidationError> = emptyList(),
) = renderPage("Settings", loggedInUser = loggedInUser) { ) = renderPage("Settings", loggedInUser = loggedInUser) {
div("container mx-auto") { div("container mx-auto") {
section("m-4 p-4 bg-gray-800 rounded") { section("m-4 p-4 bg-gray-800 rounded") {
h1("text-xl") { h1("text-xl") {
+"Welcome " +"Welcome "
@ -29,36 +31,54 @@ class SettingView(@Named("styles") styles: String) : View(styles) {
} }
section("m-4 p-2 bg-gray-800 rounded flex flex-wrap justify-around items-end") { section("m-4 p-2 bg-gray-800 rounded flex flex-wrap justify-around items-end") {
form(classes = "m-2 flex-1", method = post, action = "/export") {
form(classes = "m-2", method = FormMethod.post, action = "/export") {
button( button(
name = "display", name = "display",
classes = "inline btn btn-teal block", classes = "btn btn-teal block",
type = submit type = submit,
) { +"Display my data" } ) { +"Display my data" }
} }
form(classes = "m-2", method = FormMethod.post, action = "/export") { form(classes = "m-2 flex-1", method = post, action = "/export") {
div { div {
listOf("json", "zip").forEach { format -> listOf("json", "zip").forEach { format ->
radioInput(name = "format") { div("px-2") {
id = format radioInput(
attributes["value"] = format name = "format",
if (format == "json") attributes["checked"] = "" classes =
else attributes["class"] = "ml-4" "checked:bg-blue-500 bg-gray-200 appearance-none rounded-full border-2 h-5 w-5",
} ) {
label(classes = "ml-2") { id = format
attributes["for"] = format attributes["value"] = format
+format if (format == "json") attributes["checked"] = ""
}
label(classes = "ml-2") {
attributes["for"] = format
+format
}
} }
} }
} }
button(name = "download", classes = "inline btn btn-green block mt-2", type = submit) { button(name = "download", classes = "btn btn-green block mt-2", type = submit) {
+"Download my data" +"Download my data"
} }
} }
form(classes = "m-2 flex-1", method = post, encType = multipartFormData, action = "/import") {
input(
file,
classes = "file:hidden mb-4",
name = "file",
) {
attributes["accept"] = ".json,application/json"
}
button(
name = "import",
classes = "btn btn-teal block",
type = submit,
) { +"Import" }
}
} }
section(classes = "m-4 p-4 bg-gray-800 rounded") { section(classes = "m-4 p-4 bg-gray-800 rounded") {
@ -69,7 +89,6 @@ class SettingView(@Named("styles") styles: String) : View(styles) {
error?.let { alert(Alert.Warning, error) } error?.let { alert(Alert.Warning, error) }
details { details {
if (error != null || validationErrors.isNotEmpty()) { if (error != null || validationErrors.isNotEmpty()) {
attributes["open"] = "" attributes["open"] = ""
} }
@ -81,13 +100,13 @@ class SettingView(@Named("styles") styles: String) : View(styles) {
} }
} }
form(classes = "mt-4", method = FormMethod.post) { form(classes = "mt-4", method = post) {
input( input(
id = "password", id = "password",
placeholder = "Password", placeholder = "Password",
autoComplete = "off", autoComplete = "off",
type = InputType.password, type = InputType.password,
error = validationErrors.find { it.dataPath == ".password" }?.message error = validationErrors.find { it.dataPath == ".password" }?.message,
) )
checkBoxInput(name = "checked") { checkBoxInput(name = "checked") {
id = "checked" id = "checked"
@ -100,7 +119,7 @@ class SettingView(@Named("styles") styles: String) : View(styles) {
button( button(
type = submit, type = submit,
classes = "block mt-4 btn btn-red", classes = "block mt-4 btn btn-red",
name = "delete" name = "delete",
) { +"I'm sure" } ) { +"I'm sure" }
} }
} }

View File

@ -7,8 +7,8 @@ import be.simplenotes.views.components.input
import be.simplenotes.views.components.submitButton import be.simplenotes.views.components.submitButton
import io.konform.validation.ValidationError import io.konform.validation.ValidationError
import kotlinx.html.* import kotlinx.html.*
import javax.inject.Named import jakarta.inject.Named
import javax.inject.Singleton import jakarta.inject.Singleton
@Singleton @Singleton
class UserView(@Named("styles") styles: String) : View(styles) { class UserView(@Named("styles") styles: String) : View(styles) {
@ -23,7 +23,7 @@ class UserView(@Named("styles") styles: String) : View(styles) {
error, error,
validationErrors, validationErrors,
"Create an account", "Create an account",
"Register" "Register",
) { ) {
+"Already have an account? " +"Already have an account? "
a(href = "/login", classes = "no-underline text-blue-500 hover:text-blue-400 font-bold") { +"Sign In" } a(href = "/login", classes = "no-underline text-blue-500 hover:text-blue-400 font-bold") { +"Sign In" }
@ -63,14 +63,14 @@ class UserView(@Named("styles") styles: String) : View(styles) {
id = "username", id = "username",
placeholder = "Username", placeholder = "Username",
autoComplete = "username", autoComplete = "username",
error = validationErrors.find { it.dataPath == ".username" }?.message error = validationErrors.find { it.dataPath == ".username" }?.message,
) )
input( input(
id = "password", id = "password",
placeholder = "Password", placeholder = "Password",
autoComplete = "new-password", autoComplete = "new-password",
type = InputType.password, type = InputType.password,
error = validationErrors.find { it.dataPath == ".password" }?.message error = validationErrors.find { it.dataPath == ".password" }?.message,
) )
submitButton(submit) submitButton(submit)
} }

View File

@ -8,13 +8,13 @@ internal fun FlowContent.input(
placeholder: String, placeholder: String,
id: String, id: String,
autoComplete: String? = null, autoComplete: String? = null,
error: String? = null error: String? = null,
) { ) {
val colors = "bg-gray-800 border-gray-700 focus:border-teal-500 text-white" val colors = "bg-gray-800 border-gray-700 focus:border-teal-500 text-white"
div("mb-8") { div("mb-8") {
input( input(
type = type, type = type,
classes = "$colors rounded w-full border appearance-none focus:outline-none text-base p-2" classes = "$colors rounded w-full border appearance-none focus:outline-none text-base p-2",
) { ) {
attributes["placeholder"] = placeholder attributes["placeholder"] = placeholder
attributes["aria-label"] = placeholder attributes["aria-label"] = placeholder
@ -30,7 +30,7 @@ internal fun FlowContent.submitButton(text: String) {
div("flex items-center mt-6") { div("flex items-center mt-6") {
button( button(
type = submit, type = submit,
classes = "btn btn-teal w-full" classes = "btn btn-teal w-full",
) { +text } ) { +text }
} }
} }

View File

@ -10,11 +10,11 @@ internal fun DIV.noteListHeader(numberOfDeletedNotes: Int, query: String = "") {
span { span {
a( a(
href = "/notes/trash", href = "/notes/trash",
classes = "btn btn-teal" classes = "btn btn-teal",
) { +"Trash ($numberOfDeletedNotes)" } ) { +"Trash ($numberOfDeletedNotes)" }
a( a(
href = "/notes/new", href = "/notes/new",
classes = "ml-2 btn btn-green" classes = "ml-2 btn btn-green",
) { +"New" } ) { +"New" }
} }
} }

View File

@ -8,7 +8,7 @@ internal class SUMMARY(consumer: TagConsumer<*>) :
consumer, consumer,
emptyMap(), emptyMap(),
inlineTag = true, inlineTag = true,
emptyTag = false emptyTag = false,
), ),
HtmlInlineTag HtmlInlineTag

View File

@ -8,5 +8,5 @@ import java.util.*
private val prettyTime = PrettyTime() private val prettyTime = PrettyTime()
internal fun LocalDateTime.toTimeAgo(): String = prettyTime.format( internal fun LocalDateTime.toTimeAgo(): String = prettyTime.format(
Date.from(atZone(ZoneId.systemDefault()).toInstant()) Date.from(atZone(ZoneId.systemDefault()).toInstant()),
) )