35 Commits

Author SHA1 Message Date
hubert 195c7c10ac Import feature 2023-05-09 22:46:07 +02:00
hubert 724fa0483e Tailwind 3 2023-05-09 22:40:39 +02:00
hubert f2bdc8d6c7 Update kotlin libs 2023-05-09 22:40:10 +02:00
hubert 5aa2e80c5f JDK 19 + Gradle 8.1.1 + kotlin 1.8.21 + spotless 2023-05-09 22:39:34 +02:00
hubert 235e8b6e3c Use gradle catalogs 2021-05-06 12:22:16 +02:00
hubert 1e0fe12396 Upgrade kotlin, kotlinx-html, gradle, shadow 2021-05-06 12:22:16 +02:00
hubert 7ad8b7039b Update config 2021-04-12 21:15:42 +02:00
hubert 204ae7988e Update ktorm, clean repositories and drop mariadb support 2021-04-12 18:35:21 +02:00
hubert a4bf998c5b Remove Boilerplate Use-case thingy 2021-03-03 16:57:53 +01:00
hubert 3e1683dfe5 Use a proper search input parser 2021-03-03 14:28:34 +01:00
hubert 51b682c593 Skip slow tests by default 2021-03-03 14:28:34 +01:00
hubert ea110d51d3 Simplify configuration 2021-02-27 20:34:44 +01:00
hubert f255064533 Upgrade versions, etc 2021-02-27 18:44:19 +01:00
hubert 761382da23 Fix typos 2021-02-27 18:07:22 +01:00
hubert 525e3a4a3f Update kotlin + gradle + dependencies 2021-02-06 01:05:33 +01:00
hubert 69e50b158f Update kotlin -> 1.4.20 & java -> 15 2020-11-29 22:22:09 +01:00
hubert 8997433974 Update dependencies 2020-11-29 22:20:40 +01:00
hubert 909fb482a8 Clean gradle build 2020-11-29 21:15:31 +01:00
hubert 90701dcdce Refactor jwt 2020-11-11 23:48:27 +01:00
hubert 8439782430 Flatten packages
Remove modules prefix
2020-11-11 23:48:27 +01:00
hubert e6a7af840a Fix db health check 2020-11-06 23:44:57 +01:00
hubert 568a2c6831 Move search parsing to domain layer 2020-11-06 23:44:37 +01:00
hubert c39a20cf96 Move health check to domain layer 2020-11-05 14:44:59 +01:00
hubert bf56314473 Move transactions to domain layer 2020-11-05 14:37:20 +01:00
hubert 11caff1634 Format 2020-11-03 18:36:09 +01:00
hubert b1478fd154 Why ?! 2020-11-03 18:36:09 +01:00
hubert dd174a6327 Log discarded html tags + small refactor 2020-11-03 18:36:09 +01:00
hubert 941380ad16 Load configuration with micronaut 2020-11-03 18:36:09 +01:00
hubert 4f395d254d Merge branch 'micronaut' into master 2020-11-02 00:34:31 +01:00
hubert 6a43acfd46 Switch from koin to micronaut-inject 2020-11-02 00:33:57 +01:00
hubert 78b84dc62a Add more persistance tests 2020-11-02 00:33:57 +01:00
hubert 1120bc9350 Add .sdkmanrc 2020-10-29 16:57:39 +01:00
hubert a37254452b Fix webmanifest 2020-10-28 02:58:30 +01:00
hubert cd9fdd28e8 Gradle stuff 2020-10-28 02:01:16 +01:00
hubert c3fc6a4e88 Clean gradle scripts 2020-10-28 00:42:51 +01:00
223 changed files with 3787 additions and 4339 deletions
+3 -2
View File
@@ -13,5 +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 ktlint_standard_no-wildcard-imports = disabled
kotlin_imports_layout = idea ktlint_standard_import-ordering = disabled
ktlint_standard_multiline-if-else = disabled
-9
View File
@@ -1,10 +1 @@
## can be generated with `openssl rand -base64 32`
JWT_SECRET= JWT_SECRET=
#
## can be generated with `openssl rand -base64 32`
MYSQL_ROOT_PASSWORD=
#
## can be generated with `openssl rand -base64 32`
MYSQL_PASSWORD=
# password should be the same as mysql_password
PASSWORD=
+5
View File
@@ -0,0 +1,5 @@
# Enable auto-env through the sdkman_auto_env config
# Add key=value pairs of SDKs to use below
java=15-open
gradle=6.8-rc-1
kotlin=1.4.20
+8 -9
View File
@@ -1,8 +1,8 @@
FROM openjdk:14-alpine as jdkbuilder FROM eclipse-temurin:19-alpine as jdkbuilder
RUN apk add --no-cache binutils RUN apk add --no-cache binutils
ENV MODULES java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.scripting,java.security.jgss,java.sql,java.sql.rowset,java.transaction.xa,java.xml,jdk.net ENV MODULES java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.scripting,java.security.jgss,java.sql,java.sql.rowset,java.transaction.xa,java.xml,jdk.net,jdk.zipfs
RUN jlink --output /myjdk --module-path $JAVA_HOME/jmods --add-modules $MODULES --no-header-files --no-man-pages --strip-debug --compress=2 RUN jlink --output /myjdk --module-path $JAVA_HOME/jmods --add-modules $MODULES --no-header-files --no-man-pages --strip-debug --compress=2
@@ -12,18 +12,17 @@ FROM alpine
RUN apk add --no-cache curl RUN apk add --no-cache curl
ENV APPLICATION_USER simplenotes
RUN adduser -D -g '' $APPLICATION_USER
RUN mkdir /app RUN mkdir /app
RUN chown -R $APPLICATION_USER /app RUN mkdir /app/data
USER $APPLICATION_USER
COPY --from=jdkbuilder /myjdk /myjdk COPY --from=jdkbuilder /myjdk /myjdk
COPY simplenotes-app/build/libs/app-*-all.jar /app/simplenotes.jar COPY app/build/libs/app-with-dependencies*.jar /app/simplenotes.jar
WORKDIR /app WORKDIR /app
VOLUME /app/data
ENV SERVER_HOST 0.0.0.0
CMD [ \ CMD [ \
"/myjdk/bin/java", \ "/myjdk/bin/java", \
"--add-opens", \ "--add-opens", \
+1 -2
View File
@@ -15,5 +15,4 @@
## Configuration ## Configuration
The app is configured with environments variables. The app is configured with environments variables.
If no match is found within the env, a default value is read from a properties file in /app/src/main/resources/application.properties. If no match is found within the env, a default value is read from a yaml file in simplenotes-app/src/main/resources/application.yaml.
Don't use the default values for secrets ! Every value inside *.env.dist* should be changed.
+33
View File
@@ -0,0 +1,33 @@
import be.simplenotes.micronaut
plugins {
id("be.simplenotes.base")
id("be.simplenotes.kotlinx-serialization")
id("be.simplenotes.app-shadow")
id("be.simplenotes.docker")
id("be.simplenotes.micronaut")
}
dependencies {
implementation(project(":domain"))
implementation(project(":views"))
implementation(project(":css"))
implementation(libs.http4k.core)
implementation(libs.http4k.multipart)
implementation(libs.bundles.jetty)
implementation(libs.kotlinx.serialization.json)
implementation(libs.slf4j.api)
runtimeOnly(libs.slf4j.logback)
micronaut()
testImplementation(libs.bundles.test)
testImplementation(libs.http4k.testing.hamkrest)
}
docker {
image = "hubv/simplenotes"
tag = "latest"
}
@@ -1,6 +1,5 @@
<configuration> <configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<withJansi>true</withJansi>
<encoder> <encoder>
<pattern>%cyan(%d{YYYY-MM-dd HH:mm:ss.SSS}) [%thread] %highlight(%-5level) %green(%logger{36}) - %msg%n <pattern>%cyan(%d{YYYY-MM-dd HH:mm:ss.SSS}) [%thread] %highlight(%-5level) %green(%logger{36}) - %msg%n
</pattern> </pattern>
@@ -13,4 +12,6 @@
<logger name="me.liuwj.ktorm.database" level="INFO"/> <logger name="me.liuwj.ktorm.database" level="INFO"/>
<logger name="com.zaxxer.hikari" level="INFO"/> <logger name="com.zaxxer.hikari" level="INFO"/>
<logger name="org.flywaydb.core" level="INFO"/> <logger name="org.flywaydb.core" level="INFO"/>
<logger name="io.micronaut" level="INFO"/>
<logger name="io.micronaut.context.lifecycle" level="INFO"/>
</configuration> </configuration>

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Before

Width:  |  Height:  |  Size: 814 B

After

Width:  |  Height:  |  Size: 814 B

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,21 +1,27 @@
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 be.simplenotes.config.ServerConfig as SimpleNotesServerConfig import be.simplenotes.config.ServerConfig as SimpleNotesServerConfig
@Singleton
class Server( class Server(
private val config: SimpleNotesServerConfig, private val config: SimpleNotesServerConfig,
private val http4kServer: Http4kServer, private val http4kServer: Http4kServer,
) { ) {
private val logger = LoggerFactory.getLogger(javaClass) private val logger = LoggerFactory.getLogger(javaClass)
@PostConstruct
fun start(): Server { fun start(): Server {
http4kServer.start() http4kServer.start()
logger.info("Listening on http://${config.host}:${config.port}") logger.info("Listening on http://${config.host}:${http4kServer.port()}")
return this return this
} }
@PreDestroy
fun stop() { fun stop() {
logger.info("Stopping server") logger.info("Stopping server")
http4kServer.close() http4kServer.close()
+14
View File
@@ -0,0 +1,14 @@
package be.simplenotes.app
import io.micronaut.context.ApplicationContext
import java.lang.Runtime.getRuntime
fun main() {
val env = if (System.getenv("ENV") == "dev") "dev" else "prod"
val ctx = ApplicationContext.builder()
.deduceEnvironment(false)
.environments(env)
.start()
ctx.createBean(Server::class.java)
getRuntime().addShutdownHook(Thread { ctx.stop() })
}
@@ -1,8 +1,7 @@
package be.simplenotes.app.api package be.simplenotes.app.api
import be.simplenotes.app.extensions.auto import be.simplenotes.app.extensions.auto
import be.simplenotes.app.utils.parseSearchTerms import be.simplenotes.domain.NoteService
import be.simplenotes.domain.usecases.NoteService
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import be.simplenotes.types.PersistedNote import be.simplenotes.types.PersistedNote
import be.simplenotes.types.PersistedNoteMetadata import be.simplenotes.types.PersistedNoteMetadata
@@ -17,14 +16,19 @@ 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 jakarta.inject.Singleton
class ApiNoteController(private val noteService: NoteService, private val json: Json) { @Singleton
class ApiNoteController(
json: Json,
private val noteService: NoteService,
) {
fun createNote(request: Request, loggedInUser: LoggedInUser): Response { fun createNote(request: Request, loggedInUser: LoggedInUser): Response {
val content = noteContentLens(request) val content = noteContentLens(request)
return noteService.create(loggedInUser.userId, 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)) },
) )
} }
@@ -40,21 +44,20 @@ class ApiNoteController(private val noteService: NoteService, private val json:
fun update(request: Request, loggedInUser: LoggedInUser): Response { fun update(request: Request, loggedInUser: LoggedInUser): Response {
val content = noteContentLens(request) val content = noteContentLens(request)
return noteService.update(loggedInUser.userId, uuidLens(request), content).fold( return noteService.update(loggedInUser, uuidLens(request), content).fold(
{ {
Response(BAD_REQUEST) Response(BAD_REQUEST)
}, },
{ {
if (it == null) Response(NOT_FOUND) if (it == null) Response(NOT_FOUND)
else Response(OK) else Response(OK)
} },
) )
} }
fun search(request: Request, loggedInUser: LoggedInUser): Response { fun search(request: Request, loggedInUser: LoggedInUser): Response {
val query = searchContentLens(request) val query = searchContentLens(request)
val terms = parseSearchTerms(query) val notes = noteService.search(loggedInUser.userId, query)
val notes = noteService.search(loggedInUser.userId, terms)
return persistedNotesMetadataLens(notes, Response(OK)) return persistedNotesMetadataLens(notes, Response(OK))
} }
@@ -73,4 +76,4 @@ data class NoteContent(val content: String)
data class UuidContent(@Contextual val uuid: UUID) data class UuidContent(@Contextual val uuid: UUID)
@Serializable @Serializable
data class SearchContent(@Contextual val query: String) data class SearchContent(val query: String)
@@ -1,16 +1,21 @@
package be.simplenotes.app.api package be.simplenotes.app.api
import be.simplenotes.app.extensions.auto import be.simplenotes.app.extensions.auto
import be.simplenotes.domain.usecases.UserService import be.simplenotes.domain.LoginForm
import be.simplenotes.domain.usecases.users.login.LoginForm import be.simplenotes.domain.UserService
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.http4k.core.Request import org.http4k.core.Request
import org.http4k.core.Response import org.http4k.core.Response
import org.http4k.core.Status.Companion.BAD_REQUEST import org.http4k.core.Status.Companion.BAD_REQUEST
import org.http4k.core.Status.Companion.OK import org.http4k.core.Status.Companion.OK
import jakarta.inject.Singleton
class ApiUserController(private val userService: UserService, private val json: Json) { @Singleton
class ApiUserController(
json: Json,
private val userService: UserService,
) {
private val tokenLens = json.auto<Token>().toLens() private val tokenLens = json.auto<Token>().toLens()
private val loginFormLens = json.auto<LoginForm>().toLens() private val loginFormLens = json.auto<LoginForm>().toLens()
@@ -18,7 +23,7 @@ class ApiUserController(private val userService: UserService, private val json:
.login(loginFormLens(request)) .login(loginFormLens(request))
.fold( .fold(
{ Response(BAD_REQUEST) }, { Response(BAD_REQUEST) },
{ tokenLens(Token(it), Response(OK)) } { tokenLens(Token(it), Response(OK)) },
) )
} }
@@ -6,7 +6,9 @@ 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 jakarta.inject.Singleton
@Singleton
class BaseController(private val view: BaseView) { class BaseController(private val view: BaseView) {
fun index(@Suppress("UNUSED_PARAMETER") request: Request, loggedInUser: LoggedInUser?) = fun index(@Suppress("UNUSED_PARAMETER") request: Request, loggedInUser: LoggedInUser?) =
Response(OK).html(view.renderHome(loggedInUser)) Response(OK).html(view.renderHome(loggedInUser))
@@ -2,11 +2,8 @@ package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.redirect import be.simplenotes.app.extensions.redirect
import be.simplenotes.app.utils.parseSearchTerms import be.simplenotes.domain.MarkdownParsingError
import be.simplenotes.domain.usecases.NoteService import be.simplenotes.domain.NoteService
import be.simplenotes.domain.usecases.markdown.InvalidMeta
import be.simplenotes.domain.usecases.markdown.MissingMeta
import be.simplenotes.domain.usecases.markdown.ValidationError
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.NoteView import be.simplenotes.views.NoteView
import org.http4k.core.Method import org.http4k.core.Method
@@ -18,8 +15,10 @@ 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 jakarta.inject.Singleton
import kotlin.math.abs import kotlin.math.abs
@Singleton
class NoteController( class NoteController(
private val view: NoteView, private val view: NoteView,
private val noteService: NoteService, private val noteService: NoteService,
@@ -30,30 +29,30 @@ class NoteController(
val markdownForm = request.form("markdown") ?: "" val markdownForm = request.form("markdown") ?: ""
return noteService.create(loggedInUser.userId, markdownForm).fold( return noteService.create(loggedInUser, markdownForm).fold(
{ {
val html = when (it) { val html = when (it) {
MissingMeta -> view.noteEditor( MarkdownParsingError.MissingMeta -> view.noteEditor(
loggedInUser, loggedInUser,
error = "Missing note metadata", error = "Missing note metadata",
textarea = markdownForm textarea = markdownForm,
) )
InvalidMeta -> view.noteEditor( MarkdownParsingError.InvalidMeta -> view.noteEditor(
loggedInUser, loggedInUser,
error = "Invalid note metadata", error = "Invalid note metadata",
textarea = markdownForm textarea = markdownForm,
) )
is ValidationError -> view.noteEditor( is MarkdownParsingError.ValidationError -> view.noteEditor(
loggedInUser, loggedInUser,
validationErrors = it.validationErrors, validationErrors = it.validationErrors,
textarea = markdownForm textarea = markdownForm,
) )
} }
Response(BAD_REQUEST).html(html) Response(BAD_REQUEST).html(html)
}, },
{ {
Response.redirect("/notes/${it.uuid}") Response.redirect("/notes/${it.uuid}")
} },
) )
} }
@@ -67,8 +66,7 @@ class NoteController(
fun search(request: Request, loggedInUser: LoggedInUser): Response { fun search(request: Request, loggedInUser: LoggedInUser): Response {
val query = request.form("search") ?: "" val query = request.form("search") ?: ""
val terms = parseSearchTerms(query) val notes = noteService.search(loggedInUser.userId, query)
val notes = noteService.search(loggedInUser.userId, terms)
val deletedCount = noteService.countDeleted(loggedInUser.userId) val deletedCount = noteService.countDeleted(loggedInUser.userId)
return Response(OK).html(view.search(loggedInUser, notes, query, deletedCount)) return Response(OK).html(view.search(loggedInUser, notes, query, deletedCount))
} }
@@ -110,30 +108,30 @@ class NoteController(
val markdownForm = request.form("markdown") ?: "" val markdownForm = request.form("markdown") ?: ""
return noteService.update(loggedInUser.userId, note.uuid, markdownForm).fold( return noteService.update(loggedInUser, note.uuid, markdownForm).fold(
{ {
val html = when (it) { val html = when (it) {
MissingMeta -> view.noteEditor( MarkdownParsingError.MissingMeta -> view.noteEditor(
loggedInUser, loggedInUser,
error = "Missing note metadata", error = "Missing note metadata",
textarea = markdownForm textarea = markdownForm,
) )
InvalidMeta -> view.noteEditor( MarkdownParsingError.InvalidMeta -> view.noteEditor(
loggedInUser, loggedInUser,
error = "Invalid note metadata", error = "Invalid note metadata",
textarea = markdownForm textarea = markdownForm,
) )
is ValidationError -> view.noteEditor( is MarkdownParsingError.ValidationError -> view.noteEditor(
loggedInUser, loggedInUser,
validationErrors = it.validationErrors, validationErrors = it.validationErrors,
textarea = markdownForm textarea = markdownForm,
) )
} }
Response(BAD_REQUEST).html(html) Response(BAD_REQUEST).html(html)
}, },
{ {
Response.redirect("/notes/${note.uuid}") Response.redirect("/notes/${note.uuid}")
} },
) )
} }
@@ -2,17 +2,19 @@ package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.redirect import be.simplenotes.app.extensions.redirect
import be.simplenotes.domain.usecases.UserService import be.simplenotes.domain.*
import be.simplenotes.domain.usecases.users.delete.DeleteError
import be.simplenotes.domain.usecases.users.delete.DeleteForm
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.SettingView import be.simplenotes.views.SettingView
import jakarta.inject.Singleton
import org.http4k.core.* import org.http4k.core.*
import org.http4k.core.body.form import org.http4k.core.body.form
import org.http4k.core.cookie.invalidateCookie import org.http4k.core.cookie.invalidateCookie
@Singleton
class SettingsController( class SettingsController(
private val userService: UserService, private val userService: UserService,
private val exportService: ExportService,
private val importService: ImportService,
private val settingView: SettingView, private val settingView: SettingView,
) { ) {
fun settings(request: Request, loggedInUser: LoggedInUser): Response { fun settings(request: Request, loggedInUser: LoggedInUser): Response {
@@ -29,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")
} },
) )
} }
@@ -59,20 +62,28 @@ class SettingsController(
return if (isDownload) { return if (isDownload) {
val filename = "simplenotes-export-${loggedInUser.username}" val filename = "simplenotes-export-${loggedInUser.username}"
if (request.form("format") == "zip") { if (request.form("format") == "zip") {
val zip = userService.exportAsZip(loggedInUser.userId) val zip = exportService.exportAsZip(loggedInUser.userId)
Response(Status.OK) Response(Status.OK)
.with(attachment("$filename.zip", "application/zip")) .with(attachment("$filename.zip", "application/zip"))
.body(zip) .body(zip)
} else } else
Response(Status.OK) Response(Status.OK)
.with(attachment("$filename.json", "application/json")) .with(attachment("$filename.json", "application/json"))
.body(userService.exportAsJson(loggedInUser.userId)) .body(exportService.exportAsJson(loggedInUser.userId))
} else Response(Status.OK).body(userService.exportAsJson(loggedInUser.userId)).header( } else Response(Status.OK).body(exportService.exportAsJson(loggedInUser.userId)).header(
"Content-Type", "Content-Type",
"application/json" "application/json",
) )
} }
private fun Request.deleteForm(loggedInUser: LoggedInUser) = private fun Request.deleteForm(loggedInUser: LoggedInUser) =
DeleteForm(loggedInUser.username, form("password"), form("checked") != null) DeleteForm(loggedInUser.username, form("password"), form("checked") != null)
fun import(request: Request, loggedInUser: LoggedInUser): Response {
val form = MultipartFormBody.from(request)
val file = form.file("file") ?: return Response(Status.BAD_REQUEST)
val json = file.content.bufferedReader().readText()
importService.importJson(loggedInUser, json)
return Response.redirect("/notes")
}
} }
@@ -4,11 +4,7 @@ import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.isSecure import be.simplenotes.app.extensions.isSecure
import be.simplenotes.app.extensions.redirect import be.simplenotes.app.extensions.redirect
import be.simplenotes.config.JwtConfig import be.simplenotes.config.JwtConfig
import be.simplenotes.domain.usecases.UserService import be.simplenotes.domain.*
import be.simplenotes.domain.usecases.users.login.*
import be.simplenotes.domain.usecases.users.register.InvalidRegisterForm
import be.simplenotes.domain.usecases.users.register.RegisterForm
import be.simplenotes.domain.usecases.users.register.UserExists
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.UserView import be.simplenotes.views.UserView
import org.http4k.core.Method.GET import org.http4k.core.Method.GET
@@ -21,7 +17,9 @@ 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 jakarta.inject.Singleton
@Singleton
class UserController( class UserController(
private val userService: UserService, private val userService: UserService,
private val userView: UserView, private val userView: UserView,
@@ -29,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,21 +35,21 @@ class UserController(
return result.fold( return result.fold(
{ {
val html = when (it) { val html = when (it) {
UserExists -> userView.register( RegisterError.UserExists -> userView.register(
loggedInUser, loggedInUser,
error = "User already exists" error = "User already exists",
) )
is InvalidRegisterForm -> is RegisterError.InvalidRegisterForm ->
userView.register( userView.register(
loggedInUser, loggedInUser,
validationErrors = it.validationErrors validationErrors = it.validationErrors,
) )
} }
Response(OK).html(html) Response(OK).html(html)
}, },
{ {
Response.redirect("/login") Response.redirect("/login")
} },
) )
} }
@@ -60,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())
@@ -68,27 +66,27 @@ class UserController(
return result.fold( return result.fold(
{ {
val html = when (it) { val html = when (it) {
Unregistered -> LoginError.Unregistered ->
userView.login( userView.login(
loggedInUser, loggedInUser,
error = "User does not exist" error = "User does not exist",
) )
WrongPassword -> LoginError.WrongPassword ->
userView.login( userView.login(
loggedInUser, loggedInUser,
error = "Wrong password" error = "Wrong password",
) )
is InvalidLoginForm -> is LoginError.InvalidLoginForm ->
userView.login( userView.login(
loggedInUser, loggedInUser,
validationErrors = it.validationErrors validationErrors = it.validationErrors,
) )
} }
Response(OK).html(html) Response(OK).html(html)
}, },
{ token -> { token ->
Response.redirect("/notes").loginCookie(token, request.isSecure()) Response.redirect("/notes").loginCookie(token, request.isSecure())
} },
) )
} }
@@ -103,8 +101,8 @@ class UserController(
httpOnly = true, httpOnly = true,
sameSite = SameSite.Lax, sameSite = SameSite.Lax,
maxAge = validityInSeconds, maxAge = validityInSeconds,
secure = secure secure = secure,
) ),
) )
} }
@@ -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) },
) )
@@ -10,7 +10,9 @@ 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 jakarta.inject.Singleton
@Singleton
class ErrorFilter(private val errorView: ErrorView) : Filter { class ErrorFilter(private val errorView: ErrorView) : Filter {
private val logger = LoggerFactory.getLogger(javaClass) private val logger = LoggerFactory.getLogger(javaClass)
@@ -3,9 +3,13 @@ package be.simplenotes.app.filters
import org.http4k.core.Filter import org.http4k.core.Filter
import org.http4k.core.HttpHandler import org.http4k.core.HttpHandler
import org.http4k.core.Request import org.http4k.core.Request
import org.http4k.core.Status.Companion.OK
object ImmutableFilter : Filter { object ImmutableFilter : Filter {
override fun invoke(next: HttpHandler) = { request: Request -> override fun invoke(next: HttpHandler) = { request: Request ->
next(request).header("Cache-Control", "public, max-age=31536000, immutable") val res = next(request)
if (res.status == OK)
res.header("Cache-Control", "public, max-age=31536000, immutable")
else res
} }
} }
+24
View File
@@ -0,0 +1,24 @@
package be.simplenotes.app.filters.auth
import be.simplenotes.types.LoggedInUser
import org.http4k.core.Request
import org.http4k.core.cookie.cookie
import org.http4k.lens.BiDiLens
typealias OptionalAuthLens = BiDiLens<@JvmSuppressWildcards Request, @JvmSuppressWildcards LoggedInUser?>
typealias RequiredAuthLens = BiDiLens<@JvmSuppressWildcards Request, @JvmSuppressWildcards LoggedInUser>
enum class JwtSource {
Header, Cookie
}
fun Request.bearerTokenCookie(): String? = cookie("Bearer")
?.value
?.trim()
fun Request.bearerTokenHeader(): String? =
header("Authorization")
?.trim()
?.takeIf { it.startsWith("Bearer") }
?.substringAfter("Bearer")
?.trim()
@@ -0,0 +1,23 @@
package be.simplenotes.app.filters.auth
import be.simplenotes.app.filters.auth.JwtSource.Cookie
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.types.LoggedInUser
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.with
class OptionalAuthFilter(
private val simpleJwt: SimpleJwt<LoggedInUser>,
private val lens: OptionalAuthLens,
private val source: JwtSource = Cookie,
) : Filter {
override fun invoke(next: HttpHandler): HttpHandler = {
val token = when (source) {
JwtSource.Header -> it.bearerTokenHeader()
Cookie -> it.bearerTokenCookie()
}
next(it.with(lens of token?.let { simpleJwt.extract(it) }))
}
}
@@ -0,0 +1,31 @@
package be.simplenotes.app.filters.auth
import be.simplenotes.app.extensions.redirect
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.types.LoggedInUser
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.Response
import org.http4k.core.Status.Companion.UNAUTHORIZED
import org.http4k.core.with
class RequiredAuthFilter(
private val simpleJwt: SimpleJwt<LoggedInUser>,
private val lens: RequiredAuthLens,
private val source: JwtSource = JwtSource.Cookie,
private val redirect: Boolean = true,
) : Filter {
override fun invoke(next: HttpHandler): HttpHandler = {
val token = when (source) {
JwtSource.Header -> it.bearerTokenHeader()
JwtSource.Cookie -> it.bearerTokenCookie()
}
val jwtPayload = token?.let { simpleJwt.extract(token) }
if (jwtPayload != null) next(it.with(lens of jwtPayload))
else {
if (redirect) Response.redirect("/login")
else Response(UNAUTHORIZED)
}
}
}
@@ -8,19 +8,18 @@ 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 = 8000) : this(port, http(port))
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(httpHandler: HttpHandler): Http4kServer { override fun toServer(http: HttpHandler): Http4kServer {
server.insertHandler(httpHandler.toJettyHandler()) server.insertHandler(http.toJettyHandler())
return object : Http4kServer { return object : Http4kServer {
override fun start(): Http4kServer = apply { override fun start(): Http4kServer = apply {
@@ -39,5 +38,3 @@ fun HttpHandler.toJettyHandler() = ServletContextHandler(SESSIONS).apply {
} }
typealias ConnectorBuilder = (Server) -> ServerConnector typealias ConnectorBuilder = (Server) -> ServerConnector
fun http(httpPort: Int): ConnectorBuilder = { server: Server -> ServerConnector(server).apply { port = httpPort } }
+47
View File
@@ -0,0 +1,47 @@
package be.simplenotes.app.modules
import be.simplenotes.app.filters.auth.*
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.types.LoggedInUser
import io.micronaut.context.annotation.Factory
import io.micronaut.context.annotation.Primary
import org.http4k.core.RequestContexts
import org.http4k.lens.RequestContextKey
import jakarta.inject.Named
import jakarta.inject.Singleton
@Factory
class AuthModule {
@Singleton
@Named("optional")
fun optionalAuthLens(ctx: RequestContexts): OptionalAuthLens = RequestContextKey.optional(ctx)
@Singleton
@Named("required")
fun requiredAuthLens(ctx: RequestContexts): RequiredAuthLens = RequestContextKey.required(ctx)
@Singleton
fun optionalAuth(simpleJwt: SimpleJwt<LoggedInUser>, @Named("optional") lens: OptionalAuthLens) =
OptionalAuthFilter(simpleJwt, lens)
@Primary
@Singleton
fun requiredAuth(simpleJwt: SimpleJwt<LoggedInUser>, @Named("required") lens: RequiredAuthLens) =
RequiredAuthFilter(simpleJwt, lens)
@Singleton
@Named("api")
internal fun apiAuthFilter(
simpleJwt: SimpleJwt<LoggedInUser>,
@Named("required") lens: RequiredAuthLens,
) = RequiredAuthFilter(
simpleJwt = simpleJwt,
lens = lens,
source = JwtSource.Header,
redirect = false,
)
@Singleton
fun requestContexts() = RequestContexts()
}
@@ -2,21 +2,20 @@ package be.simplenotes.app.modules
import be.simplenotes.app.serialization.LocalDateTimeSerializer import be.simplenotes.app.serialization.LocalDateTimeSerializer
import be.simplenotes.app.serialization.UuidSerializer import be.simplenotes.app.serialization.UuidSerializer
import io.micronaut.context.annotation.Factory
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.SerializersModule
import org.koin.dsl.module
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.* import java.util.*
import jakarta.inject.Singleton
val jsonModule = module { @Factory
single { class JsonModule {
Json {
@Singleton
fun json() = Json {
prettyPrint = true prettyPrint = true
serializersModule = get() serializersModule = SerializersModule {
}
}
single {
SerializersModule {
contextual(LocalDateTime::class, LocalDateTimeSerializer()) contextual(LocalDateTime::class, LocalDateTimeSerializer())
contextual(UUID::class, UuidSerializer()) contextual(UUID::class, UuidSerializer())
} }
+38
View File
@@ -0,0 +1,38 @@
package be.simplenotes.app.modules
import be.simplenotes.app.jetty.ConnectorBuilder
import be.simplenotes.app.jetty.Jetty
import be.simplenotes.app.routes.Router
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.config.ServerConfig
import io.micronaut.context.annotation.Factory
import org.eclipse.jetty.server.ServerConnector
import org.http4k.server.Http4kServer
import org.http4k.server.asServer
import jakarta.inject.Named
import jakarta.inject.Singleton
import org.eclipse.jetty.server.Server as JettyServer
import org.http4k.server.ServerConfig as Http4kServerConfig
@Factory
class ServerModule {
@Singleton
@Named("styles")
fun styles(resolver: StaticFileResolver) = resolver.resolve("styles.css")!!
@Singleton
fun http4kServer(router: Router, serverConfig: Http4kServerConfig): Http4kServer =
router().asServer(serverConfig)
@Singleton
fun http4kServerConfig(config: ServerConfig): Http4kServerConfig {
val builder: ConnectorBuilder = { server: JettyServer ->
ServerConnector(server).apply {
port = config.port
host = config.host
}
}
return Jetty(config.port, builder)
}
}
+46
View File
@@ -0,0 +1,46 @@
package be.simplenotes.app.routes
import be.simplenotes.app.api.ApiNoteController
import be.simplenotes.app.api.ApiUserController
import be.simplenotes.app.filters.auth.RequiredAuthFilter
import be.simplenotes.app.filters.auth.RequiredAuthLens
import org.http4k.core.Method.*
import org.http4k.core.Request
import org.http4k.core.then
import org.http4k.routing.PathMethod
import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.bind
import org.http4k.routing.routes
import java.util.function.Supplier
import jakarta.inject.Named
import jakarta.inject.Singleton
@Singleton
class ApiRoutes(
private val apiUserController: ApiUserController,
private val apiNoteController: ApiNoteController,
@Named("api") private val auth: RequiredAuthFilter,
@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)) }
return routes(
"/login" bind POST to apiUserController::login,
with(apiNoteController) {
auth.then(
routes(
"/" bind GET to ::notes,
"/" bind POST to ::createNote,
"/search" bind POST to ::search,
"/{uuid}" bind GET to ::note,
"/{uuid}" bind PUT to ::update,
),
).withBasePath("/notes")
},
).withBasePath("/api")
}
}
+55
View File
@@ -0,0 +1,55 @@
package be.simplenotes.app.routes
import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.UserController
import be.simplenotes.app.filters.ImmutableFilter
import be.simplenotes.app.filters.auth.OptionalAuthFilter
import be.simplenotes.app.filters.auth.OptionalAuthLens
import org.http4k.core.ContentType
import org.http4k.core.Method.GET
import org.http4k.core.Method.POST
import org.http4k.core.Request
import org.http4k.core.then
import org.http4k.routing.*
import java.util.function.Supplier
import jakarta.inject.Named
import jakarta.inject.Singleton
@Singleton
class BasicRoutes(
private val baseCtrl: BaseController,
private val userCtrl: UserController,
private val noteCtrl: NoteController,
@Named("optional") private val authLens: OptionalAuthLens,
private val auth: OptionalAuthFilter,
) : Supplier<RoutingHttpHandler> {
override fun get(): RoutingHttpHandler {
infix fun PathMethod.to(action: PublicHandler) =
this to { req: Request -> action(req, authLens(req)) }
val staticHandler = ImmutableFilter.then(
static(
ResourceLoader.Classpath("/static"),
"woff2" to ContentType("font/woff2"),
"webmanifest" to ContentType("application/manifest+json"),
),
)
return routes(
auth.then(
routes(
"/" bind GET to baseCtrl::index,
"/register" bind GET to userCtrl::register,
"/register" bind POST to userCtrl::register,
"/login" bind GET to userCtrl::login,
"/login" bind POST to userCtrl::login,
"/logout" bind POST to userCtrl::logout,
"/notes/public/{uuid}" bind GET to noteCtrl::public,
),
),
staticHandler,
)
}
}
+45
View File
@@ -0,0 +1,45 @@
package be.simplenotes.app.routes
import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.filters.auth.RequiredAuthFilter
import be.simplenotes.app.filters.auth.RequiredAuthLens
import org.http4k.core.Method.GET
import org.http4k.core.Method.POST
import org.http4k.core.Request
import org.http4k.core.then
import org.http4k.routing.PathMethod
import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.bind
import org.http4k.routing.routes
import java.util.function.Supplier
import jakarta.inject.Named
import jakarta.inject.Singleton
@Singleton
class NoteRoutes(
private val noteCtrl: NoteController,
private val auth: RequiredAuthFilter,
@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)) }
return auth.then(
with(noteCtrl) {
routes(
"/" bind GET to ::list,
"/" bind POST to ::search,
"/new" bind GET to ::new,
"/new" bind POST to ::new,
"/trash" bind GET to ::trash,
"/{uuid}" bind GET to ::note,
"/{uuid}" bind POST to ::note,
"/{uuid}/edit" bind GET to ::edit,
"/{uuid}/edit" bind POST to ::edit,
"/deleted/{uuid}" bind POST to ::deleted,
).withBasePath("/notes")
},
)
}
}
+8
View File
@@ -0,0 +1,8 @@
package be.simplenotes.app.routes
import be.simplenotes.types.LoggedInUser
import org.http4k.core.Request
import org.http4k.core.Response
internal typealias PublicHandler = (Request, LoggedInUser?) -> Response
internal typealias ProtectedHandler = (Request, LoggedInUser) -> Response
+31
View File
@@ -0,0 +1,31 @@
package be.simplenotes.app.routes
import be.simplenotes.app.filters.ErrorFilter
import be.simplenotes.app.filters.SecurityFilter
import org.http4k.core.RequestContexts
import org.http4k.core.then
import org.http4k.filter.ResponseFilters.GZip
import org.http4k.filter.ServerFilters.InitialiseRequestContext
import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.routes
import java.util.function.Supplier
import jakarta.inject.Singleton
@Singleton
class Router(
private val errorFilter: ErrorFilter,
private val contexts: RequestContexts,
private val subRouters: List<Supplier<RoutingHttpHandler>>,
) {
operator fun invoke(): RoutingHttpHandler {
val routes = routes(
*subRouters.map { it.get() }.toTypedArray(),
)
return errorFilter
.then(InitialiseRequestContext(contexts))
.then(SecurityFilter)
.then(GZip())
.then(routes)
}
}
+37
View File
@@ -0,0 +1,37 @@
package be.simplenotes.app.routes
import be.simplenotes.app.controllers.SettingsController
import be.simplenotes.app.filters.auth.RequiredAuthFilter
import be.simplenotes.app.filters.auth.RequiredAuthLens
import org.http4k.core.Method.GET
import org.http4k.core.Method.POST
import org.http4k.core.Request
import org.http4k.core.then
import org.http4k.routing.PathMethod
import org.http4k.routing.RoutingHttpHandler
import org.http4k.routing.bind
import org.http4k.routing.routes
import java.util.function.Supplier
import jakarta.inject.Named
import jakarta.inject.Singleton
@Singleton
class SettingsRoutes(
private val settingsController: SettingsController,
private val auth: RequiredAuthFilter,
@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)) }
return auth.then(
routes(
"/settings" bind GET to settingsController::settings,
"/settings" bind POST to settingsController::settings,
"/export" bind POST to settingsController::export,
"/import" bind POST to settingsController::import,
),
)
}
}
@@ -17,6 +17,6 @@ internal class LocalDateTimeSerializer : KSerializer<LocalDateTime> {
} }
override fun deserialize(decoder: Decoder): LocalDateTime { override fun deserialize(decoder: Decoder): LocalDateTime {
TODO("Not implemented, isn't needed") return LocalDateTime.parse(decoder.decodeString())
} }
} }
@@ -3,11 +3,13 @@ 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 jakarta.inject.Singleton
interface StaticFileResolver { interface StaticFileResolver {
fun resolve(name: String): String? fun resolve(name: String): String?
} }
@Singleton
class StaticFileResolverImpl(json: Json) : StaticFileResolver { class StaticFileResolverImpl(json: Json) : StaticFileResolver {
private val mappings: Map<String, String> private val mappings: Map<String, String>
+1
View File
@@ -0,0 +1 @@
package be.simplenotes.app
@@ -1,15 +1,24 @@
package be.simplenotes.app.filters package be.simplenotes.app.filters
import be.simplenotes.app.filters.auth.OptionalAuthFilter
import be.simplenotes.app.filters.auth.OptionalAuthLens
import be.simplenotes.app.filters.auth.RequiredAuthFilter
import be.simplenotes.app.filters.auth.RequiredAuthLens
import be.simplenotes.config.JwtConfig import be.simplenotes.config.JwtConfig
import be.simplenotes.domain.security.JwtPayloadExtractor
import be.simplenotes.domain.security.SimpleJwt import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.security.UserJwtMapper
import be.simplenotes.types.LoggedInUser import be.simplenotes.types.LoggedInUser
import com.natpryce.hamkrest.assertion.assertThat import com.natpryce.hamkrest.assertion.assertThat
import org.http4k.core.* import io.micronaut.context.BeanContext
import io.micronaut.inject.qualifiers.Qualifiers
import org.http4k.core.Method.GET import org.http4k.core.Method.GET
import org.http4k.core.Request
import org.http4k.core.RequestContexts
import org.http4k.core.Response
import org.http4k.core.Status.Companion.FOUND import org.http4k.core.Status.Companion.FOUND
import org.http4k.core.Status.Companion.OK import org.http4k.core.Status.Companion.OK
import org.http4k.core.cookie.cookie import org.http4k.core.cookie.cookie
import org.http4k.core.then
import org.http4k.filter.ServerFilters import org.http4k.filter.ServerFilters
import org.http4k.hamkrest.hasBody import org.http4k.hamkrest.hasBody
import org.http4k.hamkrest.hasHeader import org.http4k.hamkrest.hasHeader
@@ -20,23 +29,37 @@ import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
internal class AuthFilterTest { internal class RequiredAuthFilterTest {
// region setup // region setup
private val jwtConfig = JwtConfig("secret", 1, TimeUnit.HOURS) private val jwtConfig = JwtConfig("secret", 1, TimeUnit.HOURS)
private val simpleJwt = SimpleJwt(jwtConfig) private val simpleJwt = SimpleJwt(jwtConfig, UserJwtMapper())
private val extractor = JwtPayloadExtractor(simpleJwt)
private val ctx = RequestContexts()
private val requiredAuth = AuthFilter(extractor, AuthType.Required, ctx)
private val optionalAuth = AuthFilter(extractor, AuthType.Optional, ctx)
private val echoJwtPayloadHandler = { request: Request -> Response(OK).body(request.jwtPayload(ctx).toString()) } private val beanCtx = BeanContext.build()
.registerSingleton(jwtConfig)
.start()
private inline fun <reified T> BeanContext.getBean(): T = getBean(T::class.java)
private inline fun <reified T> BeanContext.getBean(name: String): T =
getBean(T::class.java, Qualifiers.byName(name))
private val requiredAuth = beanCtx.getBean<RequiredAuthFilter>()
private val requiredLens = beanCtx.getBean<RequiredAuthLens>("required")
private val optionalAuth = beanCtx.getBean<OptionalAuthFilter>()
private val optionalLens = beanCtx.getBean<OptionalAuthLens>("optional")
private val ctx = beanCtx.getBean<RequestContexts>()
private val app = ServerFilters.InitialiseRequestContext(ctx).then( private val app = ServerFilters.InitialiseRequestContext(ctx).then(
routes( routes(
"/optional" bind GET to optionalAuth.then(echoJwtPayloadHandler), "/optional" bind GET to optionalAuth.then { request: Request ->
"/protected" bind GET to requiredAuth.then(echoJwtPayloadHandler) Response(OK).body(optionalLens(request).toString())
) },
"/protected" bind GET to requiredAuth.then { request: Request ->
Response(OK).body(requiredLens(request).toString())
},
),
) )
// endregion // endregion
@@ -83,7 +106,7 @@ internal class AuthFilterTest {
} }
@Test @Test
fun `it should allow a valid token"`() { fun `it should allow a valid token`() {
val jwtPayload = LoggedInUser(1, "user") val jwtPayload = LoggedInUser(1, "user")
val token = simpleJwt.sign(jwtPayload) val token = simpleJwt.sign(jwtPayload)
val response = app(Request(GET, "/protected").cookie("Bearer", token)) val response = app(Request(GET, "/protected").cookie("Bearer", token))
+3
View File
@@ -0,0 +1,3 @@
plugins {
id("be.simplenotes.versions")
}
-1
View File
@@ -1 +0,0 @@
org.gradle.caching=true
+5 -8
View File
@@ -1,18 +1,15 @@
plugins { plugins {
`kotlin-dsl` `kotlin-dsl`
kotlin("jvm") version "1.4.10"
id("com.github.johnrengelman.shadow") version "6.1.0" apply false
kotlin("plugin.serialization") version "1.4.10"
} }
repositories { repositories {
gradlePluginPortal() gradlePluginPortal()
maven { setUrl("https://kotlin.bintray.com/kotlinx") }
} }
dependencies { dependencies {
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.10") implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.21")
implementation("org.jetbrains.kotlin:kotlin-serialization:1.4.10") implementation("org.jetbrains.kotlin:kotlin-serialization:1.8.21")
implementation("com.github.jengelman.gradle.plugins:shadow:6.1.0") implementation("com.github.johnrengelman:shadow:8.1.1")
implementation("org.jlleitschuh.gradle:ktlint-gradle:9.4.1") implementation("com.diffplug.spotless:spotless-plugin-gradle:6.13.0")
implementation("com.github.ben-manes:gradle-versions-plugin:0.46.0")
} }
@@ -0,0 +1,44 @@
package be.simplenotes
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.create
open class DockerPluginExtension {
var image: String? = null
var tag = "latest"
}
class DockerPlugin : Plugin<Project> {
override fun apply(project: Project) {
val extension = project.extensions.create<DockerPluginExtension>("docker")
project.task("dockerBuild") {
dependsOn("package")
group = "docker"
description = "Build a docker image"
doLast {
project.exec {
commandLine("docker", "build", "-t", "${extension.image}:${extension.tag}", ".")
workingDir(project.rootProject.projectDir)
}
}
}
project.task("dockerPush") {
dependsOn("dockerBuild")
group = "docker"
description = "Push a docker image"
doLast {
project.exec {
commandLine("docker", "push", "${extension.image}:${extension.tag}")
workingDir(project.rootProject.projectDir)
}
}
}
}
}
@@ -3,40 +3,11 @@
package be.simplenotes package be.simplenotes
object Libs { object Libs {
const val arrowCoreData = "io.arrow-kt:arrow-core-data:0.11.0"
const val commonsCompress = "org.apache.commons:commons-compress:1.20"
const val flexmark = "com.vladsch.flexmark:flexmark:0.62.2"
const val flexmarkGfmTasklist = "com.vladsch.flexmark:flexmark-ext-gfm-tasklist:0.62.2"
const val flywayCore = "org.flywaydb:flyway-core:6.5.4"
const val h2 = "com.h2database:h2:1.4.200"
const val hikariCP = "com.zaxxer:HikariCP:3.4.3"
const val http4kCore = "org.http4k:http4k-core:3.268.0"
const val javaJwt = "com.auth0:java-jwt:3.10.3"
const val javaxServlet = "javax.servlet:javax.servlet-api:4.0.1"
const val jbcrypt = "org.mindrot:jbcrypt:0.4"
const val jettyServer = "org.eclipse.jetty:jetty-server:9.4.32.v20200930"
const val jettyServlet = "org.eclipse.jetty:jetty-servlet:9.4.32.v20200930"
const val koinCore = "org.koin:koin-core:2.1.6"
const val konform = "io.konform:konform-jvm:0.2.0"
const val kotlinxHtml = "org.jetbrains.kotlinx:kotlinx-html-jvm:0.7.1"
const val kotlinxSerializationJson = "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.0.0"
const val ktormCore = "me.liuwj.ktorm:ktorm-core:3.0.0"
const val ktormMysql = "me.liuwj.ktorm:ktorm-support-mysql:3.0.0"
const val logbackClassic = "ch.qos.logback:logback-classic:1.2.3"
const val luceneAnalyzersCommon = "org.apache.lucene:lucene-analyzers-common:8.6.1"
const val luceneCore = "org.apache.lucene:lucene-core:8.6.1"
const val luceneQueryParser = "org.apache.lucene:lucene-queryparser:8.6.1"
const val mapstruct = "org.mapstruct:mapstruct:1.4.1.Final"
const val mapstructProcessor = "org.mapstruct:mapstruct-processor:1.4.1.Final"
const val mariadbClient = "org.mariadb.jdbc:mariadb-java-client:2.6.2"
const val owaspHtmlSanitizer = "com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20200713.1"
const val prettytime ="org.ocpsoft.prettytime:prettytime:4.0.5.Final"
const val slf4jApi = "org.slf4j:slf4j-api:1.7.25"
const val snakeyaml = "org.yaml:snakeyaml:1.26"
const val assertJ = "org.assertj:assertj-core:3.16.1" object Micronaut {
const val hamkrest = "com.natpryce:hamkrest:1.7.0.3" private const val version = "4.0.0-M2"
const val http4kTestingHamkrest = "org.http4k:http4k-testing-hamkrest:3.268.0" const val inject = "io.micronaut:micronaut-inject:$version"
const val junit = "org.junit.jupiter:junit-jupiter:5.6.2" const val processor = "io.micronaut:micronaut-inject-java:$version"
const val mockk = "io.mockk:mockk:1.10.0" }
} }
@@ -0,0 +1,33 @@
package be.simplenotes
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.dsl.DependencyHandler
import org.jetbrains.kotlin.gradle.plugin.KaptExtension
class MicronautPlugin : Plugin<Project> {
override fun apply(target: Project) {
target.plugins.apply("org.jetbrains.kotlin.kapt")
target.extensions.configure<KaptExtension>("kapt") {
arguments {
arg("micronaut.processing.incremental", true)
}
}
}
}
fun DependencyHandler.micronaut() {
add("kapt", Libs.Micronaut.processor)
add("implementation", Libs.Micronaut.inject)
}
fun DependencyHandler.micronautTest() {
add("kaptTest", Libs.Micronaut.processor)
add("testImplementation", Libs.Micronaut.inject)
}
fun DependencyHandler.micronautFixtures() {
add("kaptTestFixtures", Libs.Micronaut.inject)
add("testFixturesImplementation", Libs.Micronaut.processor)
}
@@ -0,0 +1,26 @@
package be.simplenotes
import org.gradle.api.Plugin
import org.gradle.api.Project
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
class PostcssPlugin : Plugin<Project> {
override fun apply(project: Project) {
with(project.tasks) {
register<PostcssTask>("postcss") {
group = "postcss"
description = "generate postcss resources"
}
getByName("processResources").dependsOn("postcss")
}
val sourceSets = project.extensions.getByType<SourceSetContainer>()
val root = File("${project.buildDir}/generated-resources/css")
sourceSets["main"].resources.srcDir(root)
}
}
@@ -9,21 +9,19 @@ import java.lang.ProcessBuilder.Redirect.PIPE
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread import kotlin.concurrent.thread
open class CssTask : DefaultTask() { open class PostcssTask : DefaultTask() {
private val root = project.parent!!.rootDir
private val viewsProject = project private val viewsProject = project
.parent .parent
?.project(":simplenotes-views") ?.project(":views")
?: error("Missing :simplenotes-views") ?: error("Missing :views")
@get:InputDirectory @get:InputDirectory
val templatesDir = viewsProject.extensions val templatesDir = viewsProject.extensions
.getByType<SourceSetContainer>() .getByType<SourceSetContainer>()
.asMap.getOrElse("main") { error("main sources not found") } .asMap.getOrElse("main") { error("main sources not found") }
.allSource.srcDirs .allSource.srcDirs
.find { it.endsWith("kotlin") } .find { it.endsWith("src") }
?: error("kotlin sources not found") ?: error("kotlin sources not found")
private val yarnRoot = File(project.rootDir, "css") private val yarnRoot = File(project.rootDir, "css")
@@ -54,7 +52,7 @@ open class CssTask : DefaultTask() {
outputRootDir.deleteRecursively() outputRootDir.deleteRecursively()
ProcessBuilder("yarn", "run", "postcss", "build", "$cssIndex", "--output", "$cssOutput") ProcessBuilder("yarn", "run", "postcss", "$cssIndex", "--output", "$cssOutput")
.apply { .apply {
environment().let { environment().let {
it["MANIFEST"] = "$manifestOutput" it["MANIFEST"] = "$manifestOutput"
@@ -1,15 +0,0 @@
package be.simplenotes
import org.gradle.kotlin.dsl.register
plugins {
java apply false
}
tasks.register<CssTask>("css")
sourceSets {
val main by getting
val root = file("$buildDir/generated-resources/css")
main.resources.srcDir(root)
}
@@ -1,39 +0,0 @@
package be.simplenotes
import java.util.concurrent.TimeUnit.MINUTES
import kotlin.concurrent.thread
fun runCommand(vararg args: String, onError: () -> Unit) {
logging.captureStandardOutput(LogLevel.INFO)
ProcessBuilder(*args)
.redirectOutput(ProcessBuilder.Redirect.PIPE)
.redirectError(ProcessBuilder.Redirect.PIPE)
.directory(rootProject.projectDir)
.start()
.apply {
thread { inputStream.use { it.copyTo(System.out) } }
thread { errorStream.use { it.copyTo(System.out) } }
waitFor(2, MINUTES)
if (exitValue() != 0) onError()
}
}
tasks.create("dockerBuild") {
dependsOn("package")
doLast {
runCommand("docker", "build", "-t", "hubv/simplenotes:latest", ".") {
throw GradleException("Docker build failed")
}
}
}
tasks.create("dockerPush") {
dependsOn("dockerBuild")
doLast {
runCommand("docker", "push", "hubv/simplenotes:latest") {
throw GradleException("Docker Push failed")
}
}
}
@@ -8,20 +8,15 @@ plugins {
tasks.withType<ShadowJar> { tasks.withType<ShadowJar> {
archiveBaseName.set("app") 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()
// minimize()
// include("org.mariadb.jdbc:mariadb-java-client")
// include("com.h2database:h2")
// include("org.jetbrains.kotlin:kotlin-reflect")
// include("org.eclipse.jetty:*")
// include("org.apache.lucene:*")
// include("org.ocpsoft.prettytime:prettytime")
File(rootProject.projectDir, "buildSrc/src/main/resources/exclusions") File(rootProject.projectDir, "buildSrc/src/main/resources/exclusions")
.listFiles()!! .listFiles()!!
.flatMap { .flatMap {
@@ -36,10 +31,9 @@ tasks.withType<ShadowJar> {
} }
tasks.create("package") { tasks.create("package") {
rootProject.subprojects.forEach { dependsOn(":${it.name}:test") } tasks.getByName("build").dependsOn("package")
dependsOn("shadowJar") dependsOn("shadowJar")
dependsOn("css")
doLast { doLast {
println("SimpleNotes Packaged !") println("SimpleNotes Packaged !")
@@ -1,57 +1,14 @@
package be.simplenotes package be.simplenotes
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
java id("be.simplenotes.java-convention")
kotlin("jvm") id("be.simplenotes.kotlin-convention")
`java-library` id("be.simplenotes.junit-convention")
id("org.jlleitschuh.gradle.ktlint") id("com.diffplug.spotless")
} }
repositories { spotless {
mavenLocal() kotlin {
mavenCentral() ktlint("0.48.0").setEditorConfigPath(project.rootProject.file(".editorconfig"))
jcenter()
maven { url = uri("https://dl.bintray.com/arrow-kt/arrow-kt/") }
maven { url = uri("https://kotlin.bintray.com/kotlinx") }
}
group = "be.simplenotes"
version = "1.0-SNAPSHOT"
dependencies {
implementation(kotlin("stdlib-jdk8"))
implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.4.10"))
}
tasks.withType<Test> {
useJUnitPlatform()
}
java {
sourceCompatibility = JavaVersion.VERSION_14
targetCompatibility = JavaVersion.VERSION_14
}
sourceSets {
val test by getting
test.resources.srcDir("${rootProject.projectDir}/simplenotes-test-resources/src/test/resources")
}
tasks.withType<KotlinCompile> {
kotlinOptions {
jvmTarget = "14"
javaParameters = true
freeCompilerArgs = listOf(
"-Xinline-classes",
"-Xno-param-assertions",
"-Xno-call-assertions",
"-Xno-receiver-assertions"
)
} }
} }
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
}
@@ -0,0 +1,28 @@
package be.simplenotes
plugins {
`java-library`
}
repositories {
mavenCentral()
}
group = "be.simplenotes"
version = "1.0-SNAPSHOT"
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(19))
vendor.set(JvmVendorSpec.ORACLE)
}
}
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
}
sourceSets["main"].resources.setSrcDirs(listOf("resources"))
sourceSets["main"].java.setSrcDirs(emptyList<String>())
sourceSets["test"].resources.setSrcDirs(listOf("testresources"))
sourceSets["test"].java.setSrcDirs(emptyList<String>())
@@ -0,0 +1,15 @@
package be.simplenotes
plugins {
java apply false
}
tasks.withType<Test> {
useJUnitPlatform {
excludeTags("slow")
}
}
dependencies {
testRuntimeOnly(project(":junit-config"))
}
@@ -0,0 +1,31 @@
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 {
kotlin("jvm")
}
dependencies {
implementation(kotlin("stdlib-jdk8"))
implementation(platform(kotlin("bom")))
testImplementation(platform(kotlin("bom")))
}
tasks.withType<KotlinCompile> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_19)
javaParameters.set(true)
freeCompilerArgs.addAll(
"-Xinline-classes",
"-Xno-param-assertions",
"-Xno-call-assertions",
"-Xno-receiver-assertions",
)
}
}
kotlin.sourceSets["main"].kotlin.setSrcDirs(listOf("src"))
kotlin.sourceSets["test"].kotlin.setSrcDirs(listOf("test"))
@@ -1,6 +1,5 @@
package be.simplenotes package be.simplenotes
plugins { plugins {
kotlin("jvm") apply false
kotlin("plugin.serialization") kotlin("plugin.serialization")
} }
@@ -0,0 +1,17 @@
package be.simplenotes
import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask
plugins {
id("com.github.ben-manes.versions")
}
tasks.named<DependencyUpdatesTask>("dependencyUpdates").configure {
resolutionStrategy {
componentSelection {
all {
if ("alpha|beta|rc".toRegex().containsMatchIn(candidate.version.lowercase())) reject("Non stable version")
}
}
}
}
@@ -0,0 +1 @@
implementation-class=be.simplenotes.DockerPlugin
@@ -0,0 +1 @@
implementation-class=be.simplenotes.MicronautPlugin
@@ -0,0 +1 @@
implementation-class=be.simplenotes.PostcssPlugin
@@ -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
+14
View File
@@ -0,0 +1,14 @@
import be.simplenotes.micronaut
plugins {
id("be.simplenotes.base")
id("be.simplenotes.micronaut")
}
dependencies {
micronaut()
runtimeOnly(libs.yaml)
testImplementation(libs.bundles.test)
testRuntimeOnly(libs.slf4j.logback)
}
+10
View File
@@ -0,0 +1,10 @@
jwt:
secret: 'PliLvfk7l4WF+cZJk66LR5Mpnh+ocbvJ2wfUCK2UCms='
validity: 24
time-unit: hours
server:
host: localhost
port: 8080
data-dir: ./data
+57
View File
@@ -0,0 +1,57 @@
package be.simplenotes.config
import io.micronaut.context.annotation.*
import java.nio.file.Path
import java.util.concurrent.TimeUnit
import jakarta.inject.Singleton
data class DataConfig(val dataDir: String)
data class DataSourceConfig(
val jdbcUrl: String,
val maximumPoolSize: Int,
val connectionTimeout: Long,
)
@ConfigurationProperties("jwt")
data class JwtConfig @ConfigurationInject constructor(
val secret: String,
val validity: Long,
val timeUnit: TimeUnit,
) {
override fun toString() = "JwtConfig(secret='***', validity=$validity, timeUnit=$timeUnit)"
}
@ConfigurationProperties("server")
data class ServerConfig @ConfigurationInject constructor(
val host: String,
val port: Int,
)
@Factory
class ConfigFactory {
@Singleton
@Requires(notEnv = ["test"])
fun datasourceConfig(dataConfig: DataConfig) = DataSourceConfig(
jdbcUrl = "jdbc:h2:" + Path.of(dataConfig.dataDir, "simplenotes").normalize().toAbsolutePath(),
maximumPoolSize = 10,
connectionTimeout = 1000,
)
@Singleton
@Requires(env = ["test"])
fun testDatasourceConfig() = DataSourceConfig(
jdbcUrl = "jdbc:h2:mem:regular;DB_CLOSE_DELAY=-1",
maximumPoolSize = 2,
connectionTimeout = 1000,
)
@Singleton
@Requires(notEnv = ["test"])
fun dataConfig(@Property(name = "data-dir") dataDir: String) = DataConfig(dataDir)
@Singleton
@Requires(env = ["test"])
fun testDataConfig() = DataConfig("/tmp")
}
+29
View File
@@ -0,0 +1,29 @@
package be.simplenotes.config
import io.micronaut.context.ApplicationContext
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class ConfigTest {
private val ctx = ApplicationContext.run()
@Test
fun `check application yaml is on the classpath`() {
val yaml = javaClass.getResource("/application.yaml")
assertThat(yaml).`as`("The application.yaml resource").isNotNull
assertThat(yaml.readText()).`as`("The config content").isNotBlank
}
@Test
fun `check config properties`() {
assertThat(ctx.getProperty("jwt.validity", Int::class.java))
.`as`("Jwt.validity")
.isPresent
}
@Test
fun `load jwt config`() {
val jwtConfig = ctx.getBean(JwtConfig::class.java)
assertThat(jwtConfig).isNotNull
}
}
+13
View File
@@ -0,0 +1,13 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%cyan(%d{YYYY-MM-dd HH:mm:ss.SSS}) [%thread] %highlight(%-5level) %green(%logger{36}) - %msg%n
</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="STDOUT"/>
</root>
<logger name="io.micronaut" level="TRACE"/>
<logger name="io.micronaut.context.lifecycle" level="TRACE"/>
</configuration>
+4
View File
@@ -0,0 +1,4 @@
plugins {
id("be.simplenotes.java-convention")
id("be.simplenotes.postcss")
}
+6 -7
View File
@@ -5,12 +5,11 @@
"//": "`gradle css`" "//": "`gradle css`"
}, },
"dependencies": { "dependencies": {
"autoprefixer": "^9.8.6", "autoprefixer": "^10.4.14",
"cssnano": "^4.1.10", "cssnano": "^6.0.1",
"postcss-cli": "^7.1.1", "postcss-cli": "^10.1.0",
"postcss-hash": "^2.0.0", "postcss-hash": "^3.0.0",
"postcss-import": "^12.0.1", "postcss-import": "^15.1.0",
"postcss-nested": "^4.2.3", "tailwindcss": "^3.3.2"
"tailwindcss": "^1.5.1"
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
module.exports = { module.exports = {
plugins: [ plugins: [
require('postcss-import'), require('postcss-import'),
require('postcss-nested'), require('tailwindcss/nesting'),
require('tailwindcss'), require('tailwindcss'),
require('autoprefixer'), require('autoprefixer'),
require('cssnano')({ require('cssnano')({
+1 -1
View File
@@ -2,7 +2,7 @@
@apply font-semibold py-2 px-4 rounded; @apply font-semibold py-2 px-4 rounded;
&:focus { &:focus {
@apply outline-none shadow-outline; @apply outline-none ring;
} }
&:hover { &:hover {
+1 -1
View File
@@ -1,6 +1,6 @@
#note { #note {
a { a {
@apply text-blue-700 underline; @apply text-blue-500 underline;
} }
p { p {
+1 -1
View File
@@ -2,7 +2,7 @@
@apply italic font-semibold text-sm bg-teal-500 text-gray-900 rounded-full py-1 px-2 align-middle; @apply italic font-semibold text-sm bg-teal-500 text-gray-900 rounded-full py-1 px-2 align-middle;
&:focus { &:focus {
@apply outline-none shadow-outline bg-teal-800 text-white; @apply outline-none ring bg-teal-800 text-white;
} }
&:hover { &:hover {
+1 -3
View File
@@ -1,9 +1,7 @@
module.exports = { module.exports = {
purge: {
content: [ content: [
process.env.PURGE process.env.PURGE
] ],
},
theme: { theme: {
fontFamily: { fontFamily: {
'sans': [ 'sans': [
+822 -1433
View File
File diff suppressed because it is too large Load Diff
+4 -41
View File
@@ -1,56 +1,19 @@
version: '2.2' version: '2.2'
services: services:
db:
image: mariadb:10.5.5
container_name: simplenotes-mariadb
env_file:
- .env
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Brussels
- MYSQL_DATABASE=simplenotes
- MYSQL_USER=simplenotes
# .env:
# - MYSQL_ROOT_PASSWORD
# - MYSQL_PASSWORD
volumes:
- notes-db-volume:/var/lib/mysql
healthcheck:
test: "mysql --protocol=tcp -u simplenotes -p$MYSQL_PASSWORD -e 'show databases'"
interval: 5s
timeout: 1s
start_period: 2s
retries: 10
simplenotes: simplenotes:
image: hubv/simplenotes image: hubv/simplenotes
container_name: simplenotes container_name: simplenotes
env_file: env_file:
- .env - .env
environment: environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Brussels - TZ=Europe/Brussels
- HOST=0.0.0.0
- JDBCURL=jdbc:mariadb://db:3306/simplenotes
- DRIVERCLASSNAME=org.mariadb.jdbc.Driver
- USERNAME=simplenotes
# .env: # .env:
# - JWT_SECRET # - JWT_SECRET
# - PASSWORD
ports: ports:
- 127.0.0.1:8080:8080 - 127.0.0.1:8080:8080
healthcheck:
test: "curl --fail -s http://localhost:8080/health"
interval: 5s
timeout: 1s
start_period: 2s
retries: 3
depends_on:
db:
condition: service_healthy
volumes: volumes:
notes-db-volume: - ./simplenotes-data:/app/data
+29
View File
@@ -0,0 +1,29 @@
import be.simplenotes.micronaut
plugins {
id("be.simplenotes.base")
id("be.simplenotes.kotlinx-serialization")
id("be.simplenotes.micronaut")
}
dependencies {
api(project(":config"))
api(project(":types"))
implementation(project(":persistence"))
implementation(project(":search"))
api(libs.arrow.core)
api(libs.konform)
micronaut()
implementation(libs.kotlinx.serialization.json)
implementation(libs.bcrypt)
implementation(libs.jwt)
implementation(libs.bundles.flexmark)
implementation(libs.yaml)
implementation(libs.owasp.html.sanitizer)
implementation(libs.commonsCompress)
testImplementation(libs.bundles.test)
}
@@ -1,6 +1,6 @@
package be.simplenotes.domain.usecases.export package be.simplenotes.domain
import be.simplenotes.persistance.repositories.NoteRepository import be.simplenotes.persistence.repositories.NoteRepository
import be.simplenotes.types.ExportedNote import be.simplenotes.types.ExportedNote
import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -9,8 +9,19 @@ import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import jakarta.inject.Singleton
interface ExportService {
fun exportAsJson(userId: Int): String
fun exportAsZip(userId: Int): InputStream
}
@Singleton
internal class ExportServiceImpl(
private val noteRepository: NoteRepository,
private val json: Json,
) : ExportService {
internal class ExportUseCaseImpl(private val noteRepository: NoteRepository, private val json: Json) : ExportUseCase {
override fun exportAsJson(userId: Int): String { override fun exportAsJson(userId: Int): String {
val notes = noteRepository.export(userId) val notes = noteRepository.export(userId)
return json.encodeToString(ListSerializer(ExportedNote.serializer()), notes) return json.encodeToString(ListSerializer(ExportedNote.serializer()), notes)
@@ -31,7 +42,7 @@ internal class ExportUseCaseImpl(private val noteRepository: NoteRepository, pri
} }
} }
class ZipOutput : AutoCloseable { private class ZipOutput : AutoCloseable {
val outputStream = ByteArrayOutputStream() val outputStream = ByteArrayOutputStream()
private val zipOutputStream = ZipArchiveOutputStream(outputStream) private val zipOutputStream = ZipArchiveOutputStream(outputStream)
+30
View File
@@ -0,0 +1,30 @@
package be.simplenotes.domain
import be.simplenotes.domain.security.HtmlSanitizer
import be.simplenotes.persistence.repositories.NoteRepository
import be.simplenotes.types.ExportedNote
import be.simplenotes.types.LoggedInUser
import jakarta.inject.Singleton
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
interface ImportService {
fun importJson(user: LoggedInUser, content: String)
}
@Singleton
internal class ImportServiceImpl(
private val noteRepository: NoteRepository,
private val json: Json,
private val htmlSanitizer: HtmlSanitizer,
) : ImportService {
override fun importJson(user: LoggedInUser, content: String) {
val notes = json.decodeFromString(ListSerializer(ExportedNote.serializer()), content)
noteRepository.import(
user.userId,
notes.map {
it.copy(html = htmlSanitizer.sanitize(user, it.html))
},
)
}
}
+73
View File
@@ -0,0 +1,73 @@
package be.simplenotes.domain
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.raise.ensure
import be.simplenotes.domain.validation.NoteValidations
import be.simplenotes.types.NoteMetadata
import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser
import io.konform.validation.ValidationErrors
import jakarta.inject.Singleton
import org.yaml.snakeyaml.Yaml
import org.yaml.snakeyaml.parser.ParserException
import org.yaml.snakeyaml.scanner.ScannerException
interface MarkdownService {
fun renderDocument(input: String): Either<MarkdownParsingError, Document>
}
@Singleton
internal class MarkdownServiceImpl(
private val parser: Parser,
private val renderer: HtmlRenderer,
) : MarkdownService {
private val yamlBoundPattern = "-{3}".toRegex()
private fun splitMetaFromDocument(input: String) = either {
val split = input.split(yamlBoundPattern, 3)
ensure(split.size >= 3) { MarkdownParsingError.MissingMeta }
split[1].trim() to split[2].trim()
}
private val yaml = Yaml()
private fun parseMeta(input: String) = either {
val load: Map<String, Any> = try {
yaml.load(input)
} catch (e: ParserException) {
raise(MarkdownParsingError.InvalidMeta)
} catch (e: ScannerException) {
raise(MarkdownParsingError.InvalidMeta)
}
val title = when (val titleNode = load["title"]) {
is String, is Number -> titleNode.toString()
else -> raise(MarkdownParsingError.InvalidMeta)
}
val tagsNode = load["tags"]
val tags = if (tagsNode !is List<*>)
emptyList()
else
tagsNode.map { it.toString() }
NoteMetadata(title, tags)
}
private fun renderMarkdown(markdown: String) = parser.parse(markdown).run(renderer::render)
override fun renderDocument(input: String) = either {
val (meta, md) = splitMetaFromDocument(input).bind()
val parsedMeta = parseMeta(meta).bind()
NoteValidations.validateMetadata(parsedMeta)?.let { raise(it) }
val html = renderMarkdown(md)
Document(parsedMeta, html)
}
}
sealed class MarkdownParsingError {
object MissingMeta : MarkdownParsingError()
object InvalidMeta : MarkdownParsingError()
class ValidationError(val validationErrors: ValidationErrors) : MarkdownParsingError()
}
data class Document(val metadata: NoteMetadata, val html: String)
@@ -1,43 +1,52 @@
package be.simplenotes.domain.usecases package be.simplenotes.domain
import arrow.core.computations.either import arrow.core.raise.either
import be.simplenotes.domain.security.HtmlSanitizer import be.simplenotes.domain.security.HtmlSanitizer
import be.simplenotes.domain.usecases.markdown.MarkdownConverter import be.simplenotes.domain.utils.parseSearchTerms
import be.simplenotes.domain.usecases.markdown.MarkdownParsingError import be.simplenotes.persistence.repositories.NoteRepository
import be.simplenotes.persistance.repositories.NoteRepository import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.persistance.repositories.UserRepository
import be.simplenotes.search.NoteSearcher import be.simplenotes.search.NoteSearcher
import be.simplenotes.search.SearchTerms 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.*
@Singleton
class NoteService( class NoteService(
private val markdownConverter: MarkdownConverter, private val markdownService: MarkdownService,
private val noteRepository: NoteRepository, private val noteRepository: NoteRepository,
private val userRepository: UserRepository, private val userRepository: UserRepository,
private val searcher: NoteSearcher, private val searcher: NoteSearcher,
private val htmlSanitizer: HtmlSanitizer,
) { ) {
fun create(userId: Int, markdownText: String) = either.eager<MarkdownParsingError, PersistedNote> { fun create(user: LoggedInUser, markdownText: String) = either {
val persistedNote = !markdownConverter.renderDocument(markdownText) markdownService.renderDocument(markdownText)
.map { it.copy(html = HtmlSanitizer.sanitize(it.html)) } .map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) }
.map { Note(it.metadata, markdown = markdownText, html = it.html) } .map { Note(title = it.metadata.title, tags = it.metadata.tags, markdown = markdownText, html = it.html) }
.map { noteRepository.create(userId, it) } .map { noteRepository.create(user.userId, it) }
.bind()
searcher.indexNote(userId, persistedNote) .also { searcher.indexNote(user.userId, it) }
persistedNote
} }
fun update(userId: Int, uuid: UUID, markdownText: String) = either.eager<MarkdownParsingError, PersistedNote?> { fun update(user: LoggedInUser, uuid: UUID, markdownText: String) =
val persistedNote = !markdownConverter.renderDocument(markdownText) either {
.map { it.copy(html = HtmlSanitizer.sanitize(it.html)) } markdownService.renderDocument(markdownText)
.map { Note(it.metadata, markdown = markdownText, html = it.html) } .map { it.copy(html = htmlSanitizer.sanitize(user, it.html)) }
.map { noteRepository.update(userId, uuid, it) } .map {
Note(
persistedNote?.let { searcher.updateIndex(userId, it) } title = it.metadata.title,
persistedNote tags = it.metadata.tags,
markdown = markdownText,
html = it.html,
)
}
.map { noteRepository.update(user.userId, uuid, it) }
.bind()
?.also { searcher.updateIndex(user.userId, it) }
} }
fun paginatedNotes( fun paginatedNotes(
@@ -76,7 +85,9 @@ class NoteService(
fun countDeleted(userId: Int) = noteRepository.count(userId, deleted = true) fun countDeleted(userId: Int) = noteRepository.count(userId, deleted = true)
@PostConstruct
fun indexAll() { fun indexAll() {
dropAllIndexes()
val userIds = userRepository.findAll() val userIds = userRepository.findAll()
userIds.forEach { id -> userIds.forEach { id ->
val notes = noteRepository.findAllDetails(id) val notes = noteRepository.findAllDetails(id)
@@ -84,8 +95,9 @@ class NoteService(
} }
} }
fun search(userId: Int, searchTerms: SearchTerms) = searcher.search(userId, searchTerms) fun search(userId: Int, searchInput: String) = searcher.search(userId, parseSearchTerms(searchInput))
@PreDestroy
fun dropAllIndexes() = searcher.dropAll() fun dropAllIndexes() = searcher.dropAll()
fun makePublic(userId: Int, uuid: UUID) = noteRepository.makePublic(userId, uuid) fun makePublic(userId: Int, uuid: UUID) = noteRepository.makePublic(userId, uuid)
+80
View File
@@ -0,0 +1,80 @@
package be.simplenotes.domain
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.raise.ensure
import arrow.core.raise.ensureNotNull
import be.simplenotes.domain.security.PasswordHash
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.validation.UserValidations
import be.simplenotes.persistence.repositories.UserRepository
import be.simplenotes.search.NoteSearcher
import be.simplenotes.types.LoggedInUser
import be.simplenotes.types.PersistedUser
import io.konform.validation.ValidationErrors
import jakarta.inject.Singleton
import kotlinx.serialization.Serializable
interface UserService {
fun register(form: RegisterForm): Either<RegisterError, PersistedUser>
fun login(form: LoginForm): Either<LoginError, Token>
fun delete(form: DeleteForm): Either<DeleteError, Unit>
}
@Singleton
internal class UserServiceImpl(
private val userRepository: UserRepository,
private val passwordHash: PasswordHash,
private val jwt: SimpleJwt<LoggedInUser>,
private val searcher: NoteSearcher,
) : UserService {
override fun register(form: RegisterForm) = either {
val user = UserValidations.validateRegister(form).bind()
ensure(!userRepository.exists(user.username)) { RegisterError.UserExists }
ensureNotNull(userRepository.create(user.copy(password = passwordHash.crypt(user.password)))) {
RegisterError.UserExists
}
}
override fun login(form: LoginForm) = either {
val user = UserValidations.validateLogin(form).bind()
val persistedUser = ensureNotNull(userRepository.find(user.username)) { LoginError.Unregistered }
ensure(passwordHash.verify(form.password!!, persistedUser.password)) { LoginError.WrongPassword }
jwt.sign(LoggedInUser(persistedUser))
}
override fun delete(form: DeleteForm) = either {
val user = UserValidations.validateDelete(form).bind()
val persistedUser = ensureNotNull(userRepository.find(user.username)) { DeleteError.Unregistered }
ensure(passwordHash.verify(user.password, persistedUser.password)) { DeleteError.WrongPassword }
ensure(userRepository.delete(persistedUser.id)) { DeleteError.Unregistered }
searcher.dropIndex(persistedUser.id)
}
}
sealed class DeleteError {
object Unregistered : DeleteError()
object WrongPassword : DeleteError()
class InvalidForm(val validationErrors: ValidationErrors) : DeleteError()
}
data class DeleteForm(val username: String?, val password: String?, val checked: Boolean)
sealed class LoginError {
object Unregistered : LoginError()
object WrongPassword : LoginError()
class InvalidLoginForm(val validationErrors: ValidationErrors) : LoginError()
}
typealias Token = String
sealed class RegisterError {
object UserExists : RegisterError()
class InvalidRegisterForm(val validationErrors: ValidationErrors) : RegisterError()
}
typealias RegisterForm = LoginForm
@Serializable
data class LoginForm(val username: String?, val password: String?)
+34
View File
@@ -0,0 +1,34 @@
package be.simplenotes.domain.modules
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension
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 jakarta.inject.Singleton
@Factory
class FlexmarkFactory {
@Singleton
fun parser(options: MutableDataSet) = Parser.builder(options).build()
@Singleton
fun htmlRenderer(options: MutableDataSet) = HtmlRenderer.builder(options).build()
@Singleton
fun options() = MutableDataSet().apply {
set(Parser.EXTENSIONS, listOf(TaskListExtension.create()))
set(TaskListExtension.TIGHT_ITEM_CLASS, "")
set(TaskListExtension.LOOSE_ITEM_CLASS, "")
set(
TaskListExtension.ITEM_DONE_MARKER,
"""<input type="checkbox" checked="checked" disabled="disabled" readonly="readonly" />&nbsp;""",
)
set(
TaskListExtension.ITEM_NOT_DONE_MARKER,
"""<input type="checkbox" disabled="disabled" readonly="readonly" />&nbsp;""",
)
set(HtmlRenderer.SOFT_BREAK, "<br>")
}
}
+38
View File
@@ -0,0 +1,38 @@
package be.simplenotes.domain.security
import be.simplenotes.types.LoggedInUser
import org.owasp.html.HtmlChangeListener
import org.owasp.html.HtmlPolicyBuilder
import org.slf4j.LoggerFactory
import jakarta.inject.Singleton
@Singleton
class HtmlSanitizer {
private val htmlPolicy = HtmlPolicyBuilder()
.allowElements("a")
.allowCommonBlockElements()
.allowCommonInlineFormattingElements()
.allowElements("pre")
.allowAttributes("class").onElements("code")
.allowUrlProtocols("http", "https")
.allowAttributes("href").onElements("a")
.allowElements("input")
.allowAttributes("type", "checked", "disabled", "readonly").onElements("input")
.requireRelNofollowOnLinks()
.toFactory()!!
private val logger = LoggerFactory.getLogger(javaClass)
private val htmlChangeListener = object : HtmlChangeListener<LoggedInUser> {
override fun discardedTag(context: LoggedInUser?, elementName: String) {
logger.warn("Discarded tag $elementName for user $context")
}
override fun discardedAttributes(context: LoggedInUser?, tagName: String, vararg attributeNames: String) {
logger.warn("Discarded attributes ${attributeNames.contentToString()} on tag $tagName for user $context")
}
}
fun sanitize(userId: LoggedInUser, unsafeHtml: String) =
htmlPolicy.sanitize(unsafeHtml, htmlChangeListener, userId)!!
}
+30
View File
@@ -0,0 +1,30 @@
package be.simplenotes.domain.security
import be.simplenotes.types.LoggedInUser
import com.auth0.jwt.JWTCreator
import com.auth0.jwt.interfaces.DecodedJWT
import jakarta.inject.Singleton
interface JwtMapper<T> {
fun extract(decodedJWT: DecodedJWT): T?
fun build(builder: JWTCreator.Builder, value: T)
}
@Singleton
class UserJwtMapper : JwtMapper<LoggedInUser> {
private val userIdField = "i"
private val usernameField = "u"
override fun extract(decodedJWT: DecodedJWT): LoggedInUser? {
val id = decodedJWT.getClaim(userIdField).asInt() ?: null
val username = decodedJWT.getClaim(usernameField).asString() ?: null
return if (id != null && username != null)
LoggedInUser(id, username)
else null
}
override fun build(builder: JWTCreator.Builder, value: LoggedInUser) {
builder.withClaim(userIdField, value.userId)
.withClaim(usernameField, value.username)
}
}
@@ -1,13 +1,19 @@
package be.simplenotes.domain.security package be.simplenotes.domain.security
import org.mindrot.jbcrypt.BCrypt import org.mindrot.jbcrypt.BCrypt
import jakarta.inject.Inject
import jakarta.inject.Singleton
internal interface PasswordHash { internal interface PasswordHash {
fun crypt(password: String): String fun crypt(password: String): String
fun verify(password: String, hashedPassword: String): Boolean fun verify(password: String, hashedPassword: String): Boolean
} }
internal class BcryptPasswordHash(test: Boolean = false) : PasswordHash { @Singleton
internal class BcryptPasswordHash constructor(test: Boolean) : PasswordHash {
@Inject
constructor() : this(false)
private val rounds = if (test) 4 else 10 private val rounds = if (test) 4 else 10
override fun crypt(password: String) = BCrypt.hashpw(password, BCrypt.gensalt(rounds))!! override fun crypt(password: String) = BCrypt.hashpw(password, BCrypt.gensalt(rounds))!!
override fun verify(password: String, hashedPassword: String) = BCrypt.checkpw(password, hashedPassword) override fun verify(password: String, hashedPassword: String) = BCrypt.checkpw(password, hashedPassword)

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