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
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

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

View File

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

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

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

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

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) {

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

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

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

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

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 {

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 {

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

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 {

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

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

View File

@ -13,8 +13,8 @@ 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(
@ -26,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)) }
@ -34,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(
@ -48,9 +47,9 @@ class BasicRoutes(
"/login" bind POST to userCtrl::login,
"/logout" bind POST to userCtrl::logout,
"/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.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")
}
},
)
}
}

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

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

View File

@ -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())
}
}

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?

View File

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

View File

@ -7,9 +7,9 @@ repositories {
}
dependencies {
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.0")
implementation("org.jetbrains.kotlin:kotlin-serialization:1.5.0")
implementation("gradle.plugin.com.github.jengelman.gradle.plugins:shadow:7.0.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")
}

View File

@ -5,7 +5,7 @@ package be.simplenotes
object Libs {
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 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.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)
}

View File

@ -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")

View File

@ -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"))
}
}

View File

@ -12,8 +12,10 @@ 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> {

View File

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

View File

@ -10,11 +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")
}
if ("alpha|beta|rc".toRegex().containsMatchIn(candidate.version.lowercase())) reject("Non stable version")
}
}
}

View File

@ -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

View File

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

View File

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

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"
}
}

View File

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

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 {

View File

@ -1,6 +1,6 @@
#note {
a {
@apply text-blue-700 underline;
@apply text-blue-500 underline;
}
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;
&:focus {
@apply outline-none shadow-outline bg-teal-800 text-white;
@apply outline-none ring bg-teal-800 text-white;
}
&:hover {

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

@ -1,6 +1,6 @@
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
@ -8,12 +8,11 @@ import be.simplenotes.persistence.repositories.UserRepository
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(
@ -21,10 +20,10 @@ class NoteService(
private val noteRepository: NoteRepository,
private val userRepository: UserRepository,
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)
.map { it.copy(html = htmlSanitizer.sanitize(user, 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) =
either.eager<MarkdownParsingError, PersistedNote?> {
either {
markdownService.renderDocument(markdownText)
.map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) }
.map {
@ -42,7 +41,7 @@ class NoteService(
title = it.metadata.title,
tags = it.metadata.tags,
markdown = markdownText,
html = it.html
html = it.html,
)
}
.map { noteRepository.update(user.userId, uuid, it) }

View File

@ -1,10 +1,9 @@
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
@ -13,8 +12,8 @@ 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>
@ -30,31 +29,26 @@ internal class UserServiceImpl(
private val searcher: NoteSearcher,
) : UserService {
override fun register(form: RegisterForm) = 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 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 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 delete(form: DeleteForm) = 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 login(form: LoginForm) = either {
val user = UserValidations.validateLogin(form).bind()
val persistedUser = ensureNotNull(userRepository.find(user.username)) { LoginError.Unregistered }
ensure(passwordHash.verify(form.password!!, persistedUser.password)) { LoginError.WrongPassword }
jwt.sign(LoggedInUser(persistedUser))
}
override fun delete(form: DeleteForm) = either {
val user = UserValidations.validateDelete(form).bind()
val persistedUser = ensureNotNull(userRepository.find(user.username)) { DeleteError.Unregistered }
ensure(passwordHash.verify(user.password, persistedUser.password)) { DeleteError.WrongPassword }
ensure(userRepository.delete(persistedUser.id)) { DeleteError.Unregistered }
searcher.dropIndex(persistedUser.id)
}
}

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

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 {

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?

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

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>) {

View File

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

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

View File

@ -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")

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

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

View File

@ -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

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

View File

@ -1,36 +1,37 @@
[versions]
flexmark = "0.62.2"
lucene = "8.8.2"
http4k = "4.8.0.0"
jetty = "10.0.2"
mapstruct = "1.4.2.Final"
micronaut-inject = "2.5.1"
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 = "7.8.2" }
hikariCP = { module = "com.zaxxer:HikariCP", version = "4.0.3" }
h2 = { module = "com.h2database:h2", version = "1.4.200" }
ktorm = { module = "org.ktorm:ktorm-core", version = "3.3.0" }
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-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" }
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 = "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-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm", version = "1.2.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.0-alpha1" }
slf4j-logback = { module = "ch.qos.logback:logback-classic", version = "1.3.0-alpha5" }
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" }
@ -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-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" }
commonsCompress = { module = "org.apache.commons:commons-compress", version = "1.20" }
jwt = { module = "com.auth0:java-jwt", version = "3.15.0" }
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.2.0" }
owasp-html-sanitizer = { module = "com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer", version = "20200713.1" }
prettytime = { module = "org.ocpsoft.prettytime:prettytime", version = "5.0.1.Final" }
yaml = { module = "org.yaml:snakeyaml", version = "1.28" }
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.19.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.7.1" }
mockk = { module = "io.mockk:mockk", version = "1.11.0" }
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]

Binary file not shown.

View File

@ -1,5 +1,6 @@
distributionBase=GRADLE_USER_HOME
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
zipStorePath=wrapper/dists

View File

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

View File

@ -8,7 +8,7 @@ import io.micronaut.context.annotation.Bean
import io.micronaut.context.annotation.Factory
import org.ktorm.database.Database
import org.ktorm.database.SqlDialect
import javax.inject.Singleton
import jakarta.inject.Singleton
import javax.sql.DataSource
@Factory
@ -17,10 +17,13 @@ class PersistenceModule {
@Singleton
internal fun database(migrations: DbMigrations, dataSource: DataSource): Database {
migrations.migrate()
return Database.connect(dataSource, dialect = object : SqlDialect {
override fun createSqlFormatter(database: Database, beautifySql: Boolean, indentSize: Int) =
CustomSqlFormatter(database, beautifySql, indentSize)
})
return Database.connect(
dataSource,
dialect = object : SqlDialect {
override fun createSqlFormatter(database: Database, beautifySql: Boolean, indentSize: Int) =
CustomSqlFormatter(database, beautifySql, indentSize)
},
)
}
@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>? {
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("")
else -> error("Unable to deserialize varchar[]")
}
}
@ -27,7 +28,7 @@ data class ArrayContainsExpression(
val left: ScalarExpression<*>,
val right: ScalarExpression<*>,
override val sqlType: SqlType<Boolean> = BooleanSqlType,
override val isLeafNode: Boolean = false
override val isLeafNode: Boolean = false,
) : ScalarExpression<Boolean>() {
override val extraProperties: Map<String, Any> get() = emptyMap()
}

View File

@ -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>

View File

@ -3,6 +3,7 @@ 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
@ -10,7 +11,7 @@ import org.ktorm.dsl.*
import org.ktorm.entity.*
import java.time.LocalDateTime
import java.util.*
import javax.inject.Singleton
import jakarta.inject.Singleton
@Singleton
internal class NoteRepositoryImpl(
@ -40,14 +41,18 @@ internal class NoteRepositoryImpl(
val uuid = UUID.randomUUID()
val entity = converter.toEntity(note, uuid, userId, LocalDateTime.now())
db.notes.add(entity)
db.batchInsert(Tags) {
note.tags.forEach { tagName ->
item {
set(it.noteUuid, uuid)
set(it.name, tagName)
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")
}
@ -114,6 +119,43 @@ internal class NoteRepositoryImpl(
.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 }

View File

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

View File

@ -11,7 +11,7 @@ import javax.sql.DataSource
@ResourceLock("h2")
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)
@ -22,6 +22,8 @@ abstract class DbTest {
Flyway.configure()
.dataSource(dataSource)
.loggers("slf4j")
.cleanDisabled(false)
.load()
.clean()

View File

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

View File

@ -22,5 +22,5 @@ internal fun Document.toNoteMeta() = PersistedNoteMetadata(
title = get(titleField),
uuid = UuidFieldConverter.fromDoc(get(uuidField)),
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.nio.file.Path
import java.util.*
import javax.inject.Named
import javax.inject.Singleton
import jakarta.inject.Named
import jakarta.inject.Singleton
@Singleton
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.Prototype
import java.nio.file.Path
import javax.inject.Named
import jakarta.inject.Named
@Factory
class SearchModule {

View File

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

View File

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

View File

@ -3,15 +3,15 @@ package be.simplenotes.views
import be.simplenotes.types.LoggedInUser
import kotlinx.html.*
import kotlinx.html.ThScope.col
import javax.inject.Named
import javax.inject.Singleton
import jakarta.inject.Named
import jakarta.inject.Singleton
@Singleton
class BaseView(@Named("styles") styles: String) : View(styles) {
fun renderHome(loggedInUser: LoggedInUser?) = renderPage(
title = "Home",
description = "A fast and simple note taking website",
loggedInUser = loggedInUser
loggedInUser = loggedInUser,
) {
section("text-center my-2 p-2") {
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("md:order-1 order-2 flipped p-4 my-10 w-full md:w-1/2") {
attributes["aria-label"] = "demo"
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 kotlinx.html.a
import kotlinx.html.div
import javax.inject.Named
import javax.inject.Singleton
import jakarta.inject.Named
import jakarta.inject.Singleton
@Singleton
class ErrorView(@Named("styles") styles: String) : View(styles) {
@ -23,7 +23,7 @@ class ErrorView(@Named("styles") styles: String) : View(styles) {
Alert.Warning,
errorType.title,
"Please try again later",
multiline = true
multiline = true,
)
Type.NotFound -> alert(Alert.Warning, errorType.title, "Page not found", multiline = true)
Type.Other -> alert(Alert.Warning, errorType.title)

View File

@ -6,8 +6,8 @@ import be.simplenotes.types.PersistedNoteMetadata
import be.simplenotes.views.components.*
import io.konform.validation.ValidationError
import kotlinx.html.*
import javax.inject.Named
import javax.inject.Singleton
import jakarta.inject.Named
import jakarta.inject.Singleton
@Singleton
class NoteView(@Named("styles") styles: String) : View(styles) {
@ -41,7 +41,7 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
|---
|
""".trimMargin(
"|"
"|",
)
}
submitButton("Save")
@ -123,10 +123,9 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
fun renderedNote(loggedInUser: LoggedInUser?, note: PersistedNote, shared: Boolean) = renderPage(
note.title,
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") {
if (shared) {
p("p-4 bg-gray-800") {
+"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") {
a(
href = "/notes/${note.uuid}/edit",
classes = "btn btn-green"
classes = "btn btn-green",
) { +"Edit" }
span {
button(
type = ButtonType.submit,
name = if (note.public) "private" else "public",
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"
}
@ -188,7 +187,7 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
name = if (note.public) "private" else "public",
classes = "font-semibold border-b-4 " +
(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"
}
@ -196,7 +195,7 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
button(
type = ButtonType.submit,
name = "delete",
classes = "btn btn-red"
classes = "btn btn-red",
) { +"Delete" }
}
}

View File

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

View File

@ -7,8 +7,8 @@ import be.simplenotes.views.components.input
import be.simplenotes.views.components.submitButton
import io.konform.validation.ValidationError
import kotlinx.html.*
import javax.inject.Named
import javax.inject.Singleton
import jakarta.inject.Named
import jakarta.inject.Singleton
@Singleton
class UserView(@Named("styles") styles: String) : View(styles) {
@ -23,7 +23,7 @@ class UserView(@Named("styles") styles: String) : View(styles) {
error,
validationErrors,
"Create an account",
"Register"
"Register",
) {
+"Already have an account? "
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",
placeholder = "Username",
autoComplete = "username",
error = validationErrors.find { it.dataPath == ".username" }?.message
error = validationErrors.find { it.dataPath == ".username" }?.message,
)
input(
id = "password",
placeholder = "Password",
autoComplete = "new-password",
type = InputType.password,
error = validationErrors.find { it.dataPath == ".password" }?.message
error = validationErrors.find { it.dataPath == ".password" }?.message,
)
submitButton(submit)
}

View File

@ -8,13 +8,13 @@ internal fun FlowContent.input(
placeholder: String,
id: String,
autoComplete: String? = null,
error: String? = null
error: String? = null,
) {
val colors = "bg-gray-800 border-gray-700 focus:border-teal-500 text-white"
div("mb-8") {
input(
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["aria-label"] = placeholder
@ -30,7 +30,7 @@ internal fun FlowContent.submitButton(text: String) {
div("flex items-center mt-6") {
button(
type = submit,
classes = "btn btn-teal w-full"
classes = "btn btn-teal w-full",
) { +text }
}
}

View File

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

View File

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

View File

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