1 Commits

Author SHA1 Message Date
hubert 7c7aca6d7a Use mapstruct 2020-10-21 21:34:57 +02:00
240 changed files with 5461 additions and 5135 deletions
+3 -4
View File
@@ -9,10 +9,9 @@ charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
[*.{kt,kts}] [*.{kt, kts}]
indent_size = 4 indent_size = 4
insert_final_newline = true insert_final_newline = true
max_line_length = 120 max_line_length = 120
ktlint_standard_no-wildcard-imports = disabled disabled_rules = no-wildcard-imports
ktlint_standard_import-ordering = disabled kotlin_imports_layout = idea
ktlint_standard_multiline-if-else = disabled
+9
View File
@@ -1 +1,10 @@
## 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=
+97 -5
View File
@@ -1,6 +1,24 @@
# Gradle # Java
build/ .mtj.tmp/
.gradle *.class
*.jar
*.war
*.ear
*.nar
hs_err_pid*
# Maven
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
pom.xml.bak
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
.mvn/wrapper/maven-wrapper.jar
# IntelliJ # IntelliJ
out/ out/
@@ -10,8 +28,11 @@ out/
*.ipr *.ipr
*.iws *.iws
# Vue
node_modules
/dist
# Local env files # Local env files
.env
.env.local .env.local
.env.*.local .env.*.local
@@ -28,13 +49,85 @@ pids
*.seed *.seed
*.pid.lock *.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories # Dependency directories
node_modules/ node_modules/
jspm_packages/ jspm_packages/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file # Yarn Integrity file
.yarn-integrity .yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# Nuxt generate
dist
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# IDE / Editor
.idea
# Service worker
sw.*
*.private.env.json
# Certificates
data/
letsencrypt/
# generated resources
app/src/main/resources/css-manifest.json
app/src/main/resources/static/styles*
# h2 db # h2 db
*.db *.db
@@ -43,4 +136,3 @@ jspm_packages/
# python # python
__pycache__ __pycache__
-5
View File
@@ -1,5 +0,0 @@
# 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
+33 -23
View File
@@ -1,8 +1,30 @@
FROM eclipse-temurin:19-alpine as jdkbuilder FROM maven:3.6.3-jdk-14 as builder
WORKDIR /tmp
# Cache dependencies
COPY pom.xml .
COPY app/pom.xml app/pom.xml
COPY domain/pom.xml domain/pom.xml
COPY persistance/pom.xml persistance/pom.xml
COPY shared/pom.xml shared/pom.xml
COPY search/pom.xml search/pom.xml
RUN mvn verify clean --fail-never
COPY app/src app/src
COPY domain/src domain/src
COPY persistance/src persistance/src
COPY shared/src shared/src
COPY search/src search/src
RUN mvn -Dstyle.color=always package
FROM openjdk:14-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,jdk.zipfs 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
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,28 +34,16 @@ FROM alpine
RUN apk add --no-cache curl RUN apk add --no-cache curl
RUN mkdir /app ENV APPLICATION_USER simplenotes
RUN mkdir /app/data RUN adduser -D -g '' $APPLICATION_USER
RUN mkdir /app
RUN chown -R $APPLICATION_USER /app
USER $APPLICATION_USER
COPY --from=builder /tmp/app/target/app-*.jar /app/app.jar
COPY --from=jdkbuilder /myjdk /myjdk COPY --from=jdkbuilder /myjdk /myjdk
COPY app/build/libs/app-with-dependencies*.jar /app/simplenotes.jar
WORKDIR /app WORKDIR /app
VOLUME /app/data CMD ["/myjdk/bin/java", "-server", "-XX:+UnlockExperimentalVMOptions", "-Xms64m", "-Xmx256m", "-XX:+UseG1GC", "-XX:MaxGCPauseMillis=100", "-XX:+UseStringDeduplication", "-jar", "app.jar"]
ENV SERVER_HOST 0.0.0.0
CMD [ \
"/myjdk/bin/java", \
"--add-opens", \
"java.base/java.nio=ALL-UNNAMED", \
"-server", \
"-XX:+UnlockExperimentalVMOptions", \
"-Xms64m", \
"-Xmx256m", \
"-XX:+UseG1GC", \
"-XX:MaxGCPauseMillis=100", \
"-XX:+UseStringDeduplication", \
"-jar", \
"simplenotes.jar" \
]
+2 -1
View File
@@ -15,4 +15,5 @@
## 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 yaml file in simplenotes-app/src/main/resources/application.yaml. If no match is found within the env, a default value is read from a properties file in /app/src/main/resources/application.properties.
Don't use the default values for secrets ! Every value inside *.env.dist* should be changed.
-33
View File
@@ -1,33 +0,0 @@
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"
}
+191
View File
@@ -0,0 +1,191 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>parent</artifactId>
<groupId>be.simplenotes</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>app</artifactId>
<properties>
<http4k.version>3.268.0</http4k.version>
</properties>
<dependencies>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>persistance</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>search</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>domain</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>shared</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.http4k</groupId>
<artifactId>http4k-core</artifactId>
</dependency>
<dependency>
<groupId>org.http4k</groupId>
<artifactId>http4k-server-jetty</artifactId>
<exclusions>
<exclusion>
<groupId>org.eclipse.jetty.websocket</groupId>
<artifactId>javax-websocket-server-impl</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-html-jvm</artifactId>
<version>0.7.1</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-serialization-json-jvm</artifactId>
</dependency>
<dependency>
<groupId>org.ocpsoft.prettytime</groupId>
<artifactId>prettytime</artifactId>
<version>4.0.5.Final</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>shared</artifactId>
<version>1.0-SNAPSHOT</version>
<type>test-jar</type>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.http4k</groupId>
<artifactId>http4k-testing-hamkrest</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>me.liuwj.ktorm</groupId>
<artifactId>ktorm-core</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.http4k</groupId>
<artifactId>http4k-bom</artifactId>
<version>${http4k.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<minimizeJar>true</minimizeJar>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>be.simplenotes.app.SimpleNotesKt</mainClass>
</transformer>
</transformers>
<filters>
<filter>
<artifact>com.h2database:h2</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter>
<artifact>org.mariadb.jdbc:mariadb-java-client</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter>
<artifact>org.jetbrains.kotlin:kotlin-reflect</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter>
<artifact>org.eclipse.jetty:*</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter>
<artifact>org.apache.lucene:*</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter>
<artifact>org.ocpsoft.prettytime:prettytime</artifact>
<includes>
<include>**</include>
</includes>
</filter>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/maven/**</exclude>
<exclude>META-INF/proguard/**</exclude>
<exclude>META-INF/*.kotlin_module</exclude>
<exclude>META-INF/DEPENDENCIES*</exclude>
<exclude>META-INF/NOTICE*</exclude>
<exclude>META-INF/LICENSE*</exclude>
<exclude>LICENSE*</exclude>
<exclude>META-INF/README*</exclude>
<exclude>META-INF/native-image/**</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
-14
View File
@@ -1,14 +0,0 @@
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() })
}
-79
View File
@@ -1,79 +0,0 @@
package be.simplenotes.app.api
import be.simplenotes.app.extensions.auto
import be.simplenotes.domain.NoteService
import be.simplenotes.types.LoggedInUser
import be.simplenotes.types.PersistedNote
import be.simplenotes.types.PersistedNoteMetadata
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.BAD_REQUEST
import org.http4k.core.Status.Companion.NOT_FOUND
import org.http4k.core.Status.Companion.OK
import org.http4k.lens.Path
import org.http4k.lens.uuid
import java.util.*
import jakarta.inject.Singleton
@Singleton
class ApiNoteController(
json: Json,
private val noteService: NoteService,
) {
fun createNote(request: Request, loggedInUser: LoggedInUser): Response {
val content = noteContentLens(request)
return noteService.create(loggedInUser, content).fold(
{ Response(BAD_REQUEST) },
{ uuidContentLens(UuidContent(it.uuid), Response(OK)) },
)
}
fun notes(@Suppress("UNUSED_PARAMETER") request: Request, loggedInUser: LoggedInUser): Response {
val notes = noteService.paginatedNotes(loggedInUser.userId, page = 1).notes
return persistedNotesMetadataLens(notes, Response(OK))
}
fun note(request: Request, loggedInUser: LoggedInUser): Response =
noteService.find(loggedInUser.userId, uuidLens(request))
?.let { persistedNoteLens(it, Response(OK)) }
?: Response(NOT_FOUND)
fun update(request: Request, loggedInUser: LoggedInUser): Response {
val content = noteContentLens(request)
return noteService.update(loggedInUser, uuidLens(request), content).fold(
{
Response(BAD_REQUEST)
},
{
if (it == null) Response(NOT_FOUND)
else Response(OK)
},
)
}
fun search(request: Request, loggedInUser: LoggedInUser): Response {
val query = searchContentLens(request)
val notes = noteService.search(loggedInUser.userId, query)
return persistedNotesMetadataLens(notes, Response(OK))
}
private val uuidContentLens = json.auto<UuidContent>().toLens()
private val noteContentLens = json.auto<NoteContent>().map { it.content }.toLens()
private val searchContentLens = json.auto<SearchContent>().map { it.query }.toLens()
private val persistedNotesMetadataLens = json.auto<List<PersistedNoteMetadata>>().toLens()
private val persistedNoteLens = json.auto<PersistedNote>().toLens()
private val uuidLens = Path.uuid().of("uuid")
}
@Serializable
data class NoteContent(val content: String)
@Serializable
data class UuidContent(@Contextual val uuid: UUID)
@Serializable
data class SearchContent(val query: String)
-31
View File
@@ -1,31 +0,0 @@
package be.simplenotes.app.api
import be.simplenotes.app.extensions.auto
import be.simplenotes.domain.LoginForm
import be.simplenotes.domain.UserService
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
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 jakarta.inject.Singleton
@Singleton
class ApiUserController(
json: Json,
private val userService: UserService,
) {
private val tokenLens = json.auto<Token>().toLens()
private val loginFormLens = json.auto<LoginForm>().toLens()
fun login(request: Request) = userService
.login(loginFormLens(request))
.fold(
{ Response(BAD_REQUEST) },
{ tokenLens(Token(it), Response(OK)) },
)
}
@Serializable
data class Token(val token: String)
-15
View File
@@ -1,15 +0,0 @@
package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html
import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.BaseView
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK
import jakarta.inject.Singleton
@Singleton
class BaseController(private val view: BaseView) {
fun index(@Suppress("UNUSED_PARAMETER") request: Request, loggedInUser: LoggedInUser?) =
Response(OK).html(view.renderHome(loggedInUser))
}
-166
View File
@@ -1,166 +0,0 @@
package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.redirect
import be.simplenotes.domain.MarkdownParsingError
import be.simplenotes.domain.NoteService
import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.NoteView
import org.http4k.core.Method
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.BAD_REQUEST
import org.http4k.core.Status.Companion.NOT_FOUND
import org.http4k.core.Status.Companion.OK
import org.http4k.core.body.form
import org.http4k.routing.path
import java.util.*
import jakarta.inject.Singleton
import kotlin.math.abs
@Singleton
class NoteController(
private val view: NoteView,
private val noteService: NoteService,
) {
fun new(request: Request, loggedInUser: LoggedInUser): Response {
if (request.method == Method.GET) return Response(OK).html(view.noteEditor(loggedInUser))
val markdownForm = request.form("markdown") ?: ""
return noteService.create(loggedInUser, markdownForm).fold(
{
val html = when (it) {
MarkdownParsingError.MissingMeta -> view.noteEditor(
loggedInUser,
error = "Missing note metadata",
textarea = markdownForm,
)
MarkdownParsingError.InvalidMeta -> view.noteEditor(
loggedInUser,
error = "Invalid note metadata",
textarea = markdownForm,
)
is MarkdownParsingError.ValidationError -> view.noteEditor(
loggedInUser,
validationErrors = it.validationErrors,
textarea = markdownForm,
)
}
Response(BAD_REQUEST).html(html)
},
{
Response.redirect("/notes/${it.uuid}")
},
)
}
fun list(request: Request, loggedInUser: LoggedInUser): Response {
val currentPage = request.query("page")?.toIntOrNull()?.let(::abs) ?: 1
val tag = request.query("tag")
val (pages, notes) = noteService.paginatedNotes(loggedInUser.userId, currentPage, tag = tag)
val deletedCount = noteService.countDeleted(loggedInUser.userId)
return Response(OK).html(view.notes(loggedInUser, notes, currentPage, pages, deletedCount, tag = tag))
}
fun search(request: Request, loggedInUser: LoggedInUser): Response {
val query = request.form("search") ?: ""
val notes = noteService.search(loggedInUser.userId, query)
val deletedCount = noteService.countDeleted(loggedInUser.userId)
return Response(OK).html(view.search(loggedInUser, notes, query, deletedCount))
}
fun note(request: Request, loggedInUser: LoggedInUser): Response {
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
if (request.method == Method.POST) {
if (request.form("delete") != null) {
return if (noteService.trash(loggedInUser.userId, noteUuid))
Response.redirect("/notes") // TODO: flash cookie to show success ?
else
Response(NOT_FOUND) // TODO: show an error
}
if (request.form("public") != null) {
if (!noteService.makePublic(loggedInUser.userId, noteUuid)) return Response(NOT_FOUND)
} else if (request.form("private") != null) {
if (!noteService.makePrivate(loggedInUser.userId, noteUuid)) return Response(NOT_FOUND)
}
}
val note = noteService.find(loggedInUser.userId, noteUuid) ?: return Response(NOT_FOUND)
return Response(OK).html(view.renderedNote(loggedInUser, note, shared = false))
}
fun public(request: Request, loggedInUser: LoggedInUser?): Response {
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
val note = noteService.findPublic(noteUuid) ?: return Response(NOT_FOUND)
return Response(OK).html(view.renderedNote(loggedInUser, note, shared = true))
}
fun edit(request: Request, loggedInUser: LoggedInUser): Response {
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
val note = noteService.find(loggedInUser.userId, noteUuid) ?: return Response(NOT_FOUND)
if (request.method == Method.GET) {
return Response(OK).html(view.noteEditor(loggedInUser, textarea = note.markdown))
}
val markdownForm = request.form("markdown") ?: ""
return noteService.update(loggedInUser, note.uuid, markdownForm).fold(
{
val html = when (it) {
MarkdownParsingError.MissingMeta -> view.noteEditor(
loggedInUser,
error = "Missing note metadata",
textarea = markdownForm,
)
MarkdownParsingError.InvalidMeta -> view.noteEditor(
loggedInUser,
error = "Invalid note metadata",
textarea = markdownForm,
)
is MarkdownParsingError.ValidationError -> view.noteEditor(
loggedInUser,
validationErrors = it.validationErrors,
textarea = markdownForm,
)
}
Response(BAD_REQUEST).html(html)
},
{
Response.redirect("/notes/${note.uuid}")
},
)
}
fun trash(request: Request, loggedInUser: LoggedInUser): Response {
val currentPage = request.query("page")?.toIntOrNull()?.let(::abs) ?: 1
val tag = request.query("tag")
val (pages, notes) = noteService.paginatedNotes(loggedInUser.userId, currentPage, tag = tag, deleted = true)
return Response(OK).html(view.trash(loggedInUser, notes, currentPage, pages))
}
fun deleted(request: Request, loggedInUser: LoggedInUser): Response {
val uuid = request.uuidPath() ?: return Response(NOT_FOUND)
return if (request.form("delete") != null)
if (noteService.delete(loggedInUser.userId, uuid))
Response.redirect("/notes/trash")
else
Response(NOT_FOUND)
else if (noteService.restore(loggedInUser.userId, uuid))
Response.redirect("/notes/$uuid")
else
Response(NOT_FOUND)
}
private fun Request.uuidPath(): UUID? {
val uuidPath = path("uuid")!!
return try {
UUID.fromString(uuidPath)!!
} catch (e: IllegalArgumentException) {
null
}
}
}
-36
View File
@@ -1,36 +0,0 @@
package be.simplenotes.app.extensions
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.http4k.asString
import org.http4k.core.Body
import org.http4k.core.ContentType
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.FOUND
import org.http4k.core.Status.Companion.MOVED_PERMANENTLY
import org.http4k.lens.*
fun Response.html(html: String) = body(html)
.header("Content-Type", "text/html; charset=utf-8")
.header("Cache-Control", "no-cache")
fun Response.Companion.redirect(url: String, permanent: Boolean = false) =
Response(if (permanent) MOVED_PERMANENTLY else FOUND).header("Location", url)
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,
).map(
{ it.payload.asString() },
{ Body(it) },
)
inline fun <reified T> Json.auto(): BiDiBodyLensSpec<T> = bodyLens.map(
{ decodeFromString(it) },
{ encodeToString(it) },
)
-24
View File
@@ -1,24 +0,0 @@
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()
@@ -1,23 +0,0 @@
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) }))
}
}
@@ -1,31 +0,0 @@
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)
}
}
}
-40
View File
@@ -1,40 +0,0 @@
package be.simplenotes.app.jetty
import org.eclipse.jetty.server.Server
import org.eclipse.jetty.server.ServerConnector
import org.eclipse.jetty.servlet.ServletContextHandler
import org.eclipse.jetty.servlet.ServletContextHandler.SESSIONS
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.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 {
server.insertHandler(http.toJettyHandler())
return object : Http4kServer {
override fun start(): Http4kServer = apply {
server.start()
}
override fun stop(): Http4kServer = apply { server.stop() }
override fun port(): Int = if (port > 0) port else server.uri.port
}
}
}
fun HttpHandler.toJettyHandler() = ServletContextHandler(SESSIONS).apply {
addServlet(ServletHolder(this@toJettyHandler.asServlet()), "/*")
}
typealias ConnectorBuilder = (Server) -> ServerConnector
+48
View File
@@ -0,0 +1,48 @@
package be.simplenotes.app
import be.simplenotes.shared.config.DataSourceConfig
import be.simplenotes.shared.config.JwtConfig
import be.simplenotes.shared.config.ServerConfig
import java.util.*
import java.util.concurrent.TimeUnit
class Config {
//region Config loading
private val properties: Properties = javaClass
.getResource("/application.properties")
.openStream()
.use {
Properties().apply { load(it) }
}
private val env = System.getenv()
private fun value(key: String): String =
env[key.toUpperCase().replace(".", "_")]
?: properties.getProperty(key)
?: error("Missing config key $key")
//endregion
val jwtConfig
get() = JwtConfig(
secret = value("jwt.secret"),
validity = value("jwt.validity").toLong(),
timeUnit = TimeUnit.HOURS,
)
val dataSourceConfig
get() = DataSourceConfig(
jdbcUrl = value("jdbcUrl"),
driverClassName = value("driverClassName"),
username = value("username"),
password = value("password"),
maximumPoolSize = value("maximumPoolSize").toInt(),
connectionTimeout = value("connectionTimeout").toLong()
)
val serverConfig
get() = ServerConfig(
host = value("host"),
port = value("port").toInt(),
)
}
@@ -1,27 +1,21 @@
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.shared.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}:${http4kServer.port()}") logger.info("Listening on http://${config.host}:${config.port}")
return this return this
} }
@PreDestroy
fun stop() { fun stop() {
logger.info("Stopping server") logger.info("Stopping server")
http4kServer.close() http4kServer.close()
+31
View File
@@ -0,0 +1,31 @@
package be.simplenotes.app
import be.simplenotes.app.extensions.addShutdownHook
import be.simplenotes.app.modules.*
import be.simplenotes.domain.domainModule
import be.simplenotes.persistance.migrationModule
import be.simplenotes.persistance.persistanceModule
import be.simplenotes.search.searchModule
import org.koin.core.context.startKoin
import org.koin.core.context.unloadKoinModules
fun main() {
startKoin {
modules(
serverModule,
persistanceModule,
migrationModule,
configModule,
baseModule,
userModule,
noteModule,
settingsModule,
domainModule,
searchModule,
apiModule,
jsonModule
)
}.addShutdownHook()
unloadKoinModules(listOf(migrationModule, configModule))
}
@@ -0,0 +1,77 @@
package be.simplenotes.app.api
import be.simplenotes.app.extensions.json
import be.simplenotes.app.utils.parseSearchTerms
import be.simplenotes.domain.model.PersistedNote
import be.simplenotes.domain.model.PersistedNoteMetadata
import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.usecases.NoteService
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.BAD_REQUEST
import org.http4k.core.Status.Companion.NOT_FOUND
import org.http4k.core.Status.Companion.OK
import org.http4k.routing.path
import java.util.*
class ApiNoteController(private val noteService: NoteService, private val json: Json) {
fun createNote(request: Request, jwtPayload: JwtPayload): Response {
val content = json.decodeFromString(NoteContent.serializer(), request.bodyString()).content
return noteService.create(jwtPayload.userId, content).fold(
{
Response(BAD_REQUEST)
},
{
Response(OK).json(json.encodeToString(UuidContent.serializer(), UuidContent(it.uuid)))
}
)
}
fun notes(request: Request, jwtPayload: JwtPayload): Response {
val notes = noteService.paginatedNotes(jwtPayload.userId, page = 1).notes
val json = json.encodeToString(ListSerializer(PersistedNoteMetadata.serializer()), notes)
return Response(OK).json(json)
}
fun note(request: Request, jwtPayload: JwtPayload): Response {
val uuid = request.path("uuid")!!
return noteService.find(jwtPayload.userId, UUID.fromString(uuid))
?.let { Response(OK).json(json.encodeToString(PersistedNote.serializer(), it)) }
?: Response(NOT_FOUND)
}
fun update(request: Request, jwtPayload: JwtPayload): Response {
val uuid = UUID.fromString(request.path("uuid")!!)
val content = json.decodeFromString(NoteContent.serializer(), request.bodyString()).content
return noteService.update(jwtPayload.userId, uuid, content).fold({
Response(BAD_REQUEST)
}, {
if (it == null) Response(NOT_FOUND)
else Response(OK)
})
}
fun search(request: Request, jwtPayload: JwtPayload): Response {
val query = json.decodeFromString(SearchContent.serializer(), request.bodyString()).query
val terms = parseSearchTerms(query)
val notes = noteService.search(jwtPayload.userId, terms)
val json = json.encodeToString(ListSerializer(PersistedNoteMetadata.serializer()), notes)
return Response(OK).json(json)
}
}
@Serializable
data class NoteContent(val content: String)
@Serializable
data class UuidContent(@Contextual val uuid: UUID)
@Serializable
data class SearchContent(@Contextual val query: String)
@@ -0,0 +1,26 @@
package be.simplenotes.app.api
import be.simplenotes.app.extensions.json
import be.simplenotes.domain.usecases.UserService
import be.simplenotes.domain.usecases.users.login.LoginForm
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status
class ApiUserController(private val userService: UserService, private val json: Json) {
fun login(request: Request): Response {
val form = json.decodeFromString(LoginForm.serializer(), request.bodyString())
val result = userService.login(form)
return result.fold({
Response(Status.BAD_REQUEST)
}, {
Response(Status.OK).json(json.encodeToString(Token.serializer(), Token(it)))
})
}
}
@Serializable
data class Token(val token: String)
@@ -0,0 +1,13 @@
package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html
import be.simplenotes.app.views.BaseView
import be.simplenotes.domain.security.JwtPayload
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK
class BaseController(private val view: BaseView) {
fun index(@Suppress("UNUSED_PARAMETER") request: Request, jwtPayload: JwtPayload?) =
Response(OK).html(view.renderHome(jwtPayload))
}
@@ -0,0 +1,12 @@
package be.simplenotes.app.controllers
import be.simplenotes.persistance.DbHealthCheck
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK
import org.http4k.core.Status.Companion.SERVICE_UNAVAILABLE
class HealthCheckController(private val dbHealthCheck: DbHealthCheck) {
fun healthCheck(request: Request) =
if (dbHealthCheck.isOk()) Response(OK) else Response(SERVICE_UNAVAILABLE)
}
@@ -0,0 +1,152 @@
package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.redirect
import be.simplenotes.app.utils.parseSearchTerms
import be.simplenotes.app.views.NoteView
import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.usecases.NoteService
import be.simplenotes.domain.usecases.markdown.InvalidMeta
import be.simplenotes.domain.usecases.markdown.MissingMeta
import be.simplenotes.domain.usecases.markdown.ValidationError
import org.http4k.core.Method
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.BAD_REQUEST
import org.http4k.core.Status.Companion.NOT_FOUND
import org.http4k.core.Status.Companion.OK
import org.http4k.core.body.form
import org.http4k.routing.path
import java.util.*
import kotlin.math.abs
class NoteController(
private val view: NoteView,
private val noteService: NoteService,
) {
fun new(request: Request, jwtPayload: JwtPayload): Response {
if (request.method == Method.GET) return Response(OK).html(view.noteEditor(jwtPayload))
val markdownForm = request.form("markdown") ?: ""
return noteService.create(jwtPayload.userId, markdownForm).fold(
{
val html = when (it) {
MissingMeta -> view.noteEditor(jwtPayload, error = "Missing note metadata", textarea = markdownForm)
InvalidMeta -> view.noteEditor(jwtPayload, error = "Invalid note metadata", textarea = markdownForm)
is ValidationError -> view.noteEditor(
jwtPayload,
validationErrors = it.validationErrors,
textarea = markdownForm
)
}
Response(BAD_REQUEST).html(html)
},
{
Response.redirect("/notes/${it.uuid}")
}
)
}
fun list(request: Request, jwtPayload: JwtPayload): Response {
val currentPage = request.query("page")?.toIntOrNull()?.let(::abs) ?: 1
val tag = request.query("tag")
val (pages, notes) = noteService.paginatedNotes(jwtPayload.userId, currentPage, tag = tag)
val deletedCount = noteService.countDeleted(jwtPayload.userId)
return Response(OK).html(view.notes(jwtPayload, notes, currentPage, pages, deletedCount, tag = tag))
}
fun search(request: Request, jwtPayload: JwtPayload): Response {
val query = request.form("search") ?: ""
val terms = parseSearchTerms(query)
val notes = noteService.search(jwtPayload.userId, terms)
val deletedCount = noteService.countDeleted(jwtPayload.userId)
return Response(OK).html(view.search(jwtPayload, notes, query, deletedCount))
}
fun note(request: Request, jwtPayload: JwtPayload): Response {
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
if (request.method == Method.POST) {
if (request.form("delete") != null) {
return if (noteService.trash(jwtPayload.userId, noteUuid))
Response.redirect("/notes") // TODO: flash cookie to show success ?
else
Response(NOT_FOUND) // TODO: show an error
}
if (request.form("public") != null) {
if (!noteService.makePublic(jwtPayload.userId, noteUuid)) return Response(NOT_FOUND)
} else if (request.form("private") != null) {
if (!noteService.makePrivate(jwtPayload.userId, noteUuid)) return Response(NOT_FOUND)
}
}
val note = noteService.find(jwtPayload.userId, noteUuid) ?: return Response(NOT_FOUND)
return Response(OK).html(view.renderedNote(jwtPayload, note, shared = false))
}
fun public(request: Request, jwtPayload: JwtPayload?): Response {
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
val note = noteService.findPublic(noteUuid) ?: return Response(NOT_FOUND)
return Response(OK).html(view.renderedNote(jwtPayload, note, shared = true))
}
fun edit(request: Request, jwtPayload: JwtPayload): Response {
val noteUuid = request.uuidPath() ?: return Response(NOT_FOUND)
val note = noteService.find(jwtPayload.userId, noteUuid) ?: return Response(NOT_FOUND)
if (request.method == Method.GET) {
return Response(OK).html(view.noteEditor(jwtPayload, textarea = note.markdown))
}
val markdownForm = request.form("markdown") ?: ""
return noteService.update(jwtPayload.userId, note.uuid, markdownForm).fold(
{
val html = when (it) {
MissingMeta -> view.noteEditor(jwtPayload, error = "Missing note metadata", textarea = markdownForm)
InvalidMeta -> view.noteEditor(jwtPayload, error = "Invalid note metadata", textarea = markdownForm)
is ValidationError -> view.noteEditor(
jwtPayload,
validationErrors = it.validationErrors,
textarea = markdownForm
)
}
Response(BAD_REQUEST).html(html)
},
{
Response.redirect("/notes/${note.uuid}")
}
)
}
fun trash(request: Request, jwtPayload: JwtPayload): Response {
val currentPage = request.query("page")?.toIntOrNull()?.let(::abs) ?: 1
val tag = request.query("tag")
val (pages, notes) = noteService.paginatedNotes(jwtPayload.userId, currentPage, tag = tag, deleted = true)
return Response(OK).html(view.trash(jwtPayload, notes, currentPage, pages))
}
fun deleted(request: Request, jwtPayload: JwtPayload): Response {
val uuid = request.uuidPath() ?: return Response(NOT_FOUND)
return if (request.form("delete") != null)
if (noteService.delete(jwtPayload.userId, uuid))
Response.redirect("/notes/trash")
else
Response(NOT_FOUND)
else if (noteService.restore(jwtPayload.userId, uuid))
Response.redirect("/notes/$uuid")
else
Response(NOT_FOUND)
}
private fun Request.uuidPath(): UUID? {
val uuidPath = path("uuid")!!
return try {
UUID.fromString(uuidPath)!!
} catch (e: IllegalArgumentException) {
null
}
}
}
@@ -2,26 +2,24 @@ 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.* import be.simplenotes.app.views.SettingView
import be.simplenotes.types.LoggedInUser import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.views.SettingView import be.simplenotes.domain.usecases.UserService
import jakarta.inject.Singleton import be.simplenotes.domain.usecases.users.delete.DeleteError
import be.simplenotes.domain.usecases.users.delete.DeleteForm
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, jwtPayload: JwtPayload): Response {
if (request.method == Method.GET) if (request.method == Method.GET)
return Response(Status.OK).html(settingView.settings(loggedInUser)) return Response(Status.OK).html(settingView.settings(jwtPayload))
val deleteForm = request.deleteForm(loggedInUser) val deleteForm = request.deleteForm(jwtPayload)
val result = userService.delete(deleteForm) val result = userService.delete(deleteForm)
return result.fold( return result.fold(
@@ -30,22 +28,21 @@ class SettingsController(
DeleteError.Unregistered -> Response.redirect("/").invalidateCookie("Bearer") DeleteError.Unregistered -> Response.redirect("/").invalidateCookie("Bearer")
DeleteError.WrongPassword -> Response(Status.OK).html( DeleteError.WrongPassword -> Response(Status.OK).html(
settingView.settings( settingView.settings(
loggedInUser, jwtPayload,
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, jwtPayload,
validationErrors = it.validationErrors, validationErrors = it.validationErrors
), )
) )
} }
}, },
{ {
Response.redirect("/").invalidateCookie("Bearer") Response.redirect("/").invalidateCookie("Bearer")
}, }
) )
} }
@@ -56,34 +53,23 @@ class SettingsController(
.header("Content-Type", contentType) .header("Content-Type", contentType)
} }
fun export(request: Request, loggedInUser: LoggedInUser): Response { fun export(request: Request, jwtPayload: JwtPayload): Response {
val isDownload = request.form("download") != null val isDownload = request.form("download") != null
return if (isDownload) { return if (isDownload) {
val filename = "simplenotes-export-${loggedInUser.username}" val filename = "simplenotes-export-${jwtPayload.username}"
if (request.form("format") == "zip") { if (request.form("format") == "zip") {
val zip = exportService.exportAsZip(loggedInUser.userId) val zip = userService.exportAsZip(jwtPayload.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(exportService.exportAsJson(loggedInUser.userId)) .body(userService.exportAsJson(jwtPayload.userId))
} else Response(Status.OK).body(exportService.exportAsJson(loggedInUser.userId)).header( } else Response(Status.OK).body(userService.exportAsJson(jwtPayload.userId)).header("Content-Type", "application/json")
"Content-Type",
"application/json",
)
} }
private fun Request.deleteForm(loggedInUser: LoggedInUser) = private fun Request.deleteForm(jwtPayload: JwtPayload) =
DeleteForm(loggedInUser.username, form("password"), form("checked") != null) DeleteForm(jwtPayload.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")
}
} }
@@ -3,10 +3,14 @@ package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html 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.app.views.UserView
import be.simplenotes.domain.* import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.types.LoggedInUser import be.simplenotes.domain.usecases.UserService
import be.simplenotes.views.UserView 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.shared.config.JwtConfig
import org.http4k.core.Method.GET import org.http4k.core.Method.GET
import org.http4k.core.Request import org.http4k.core.Request
import org.http4k.core.Response import org.http4k.core.Response
@@ -17,17 +21,15 @@ 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,
private val jwtConfig: JwtConfig, private val jwtConfig: JwtConfig,
) { ) {
fun register(request: Request, loggedInUser: LoggedInUser?): Response { fun register(request: Request, jwtPayload: JwtPayload?): Response {
if (request.method == GET) return Response(OK).html( if (request.method == GET) return Response(OK).html(
userView.register(loggedInUser), userView.register(jwtPayload)
) )
val result = userService.register(request.registerForm()) val result = userService.register(request.registerForm())
@@ -35,30 +37,30 @@ class UserController(
return result.fold( return result.fold(
{ {
val html = when (it) { val html = when (it) {
RegisterError.UserExists -> userView.register( UserExists -> userView.register(
loggedInUser, jwtPayload,
error = "User already exists", error = "User already exists"
) )
is RegisterError.InvalidRegisterForm -> is InvalidRegisterForm ->
userView.register( userView.register(
loggedInUser, jwtPayload,
validationErrors = it.validationErrors, validationErrors = it.validationErrors
) )
} }
Response(OK).html(html) Response(OK).html(html)
}, },
{ {
Response.redirect("/login") Response.redirect("/login")
}, }
) )
} }
private fun Request.registerForm() = RegisterForm(form("username"), form("password")) private fun Request.registerForm() = RegisterForm(form("username"), form("password"))
private fun Request.loginForm(): LoginForm = registerForm() private fun Request.loginForm(): LoginForm = registerForm()
fun login(request: Request, loggedInUser: LoggedInUser?): Response { fun login(request: Request, jwtPayload: JwtPayload?): Response {
if (request.method == GET) return Response(OK).html( if (request.method == GET) return Response(OK).html(
userView.login(loggedInUser), userView.login(jwtPayload)
) )
val result = userService.login(request.loginForm()) val result = userService.login(request.loginForm())
@@ -66,27 +68,27 @@ class UserController(
return result.fold( return result.fold(
{ {
val html = when (it) { val html = when (it) {
LoginError.Unregistered -> Unregistered ->
userView.login( userView.login(
loggedInUser, jwtPayload,
error = "User does not exist", error = "User does not exist"
) )
LoginError.WrongPassword -> WrongPassword ->
userView.login( userView.login(
loggedInUser, jwtPayload,
error = "Wrong password", error = "Wrong password"
) )
is LoginError.InvalidLoginForm -> is InvalidLoginForm ->
userView.login( userView.login(
loggedInUser, jwtPayload,
validationErrors = it.validationErrors, validationErrors = it.validationErrors
) )
} }
Response(OK).html(html) Response(OK).html(html)
}, },
{ token -> { token ->
Response.redirect("/notes").loginCookie(token, request.isSecure()) Response.redirect("/notes").loginCookie(token, request.isSecure())
}, }
) )
} }
@@ -101,8 +103,8 @@ class UserController(
httpOnly = true, httpOnly = true,
sameSite = SameSite.Lax, sameSite = SameSite.Lax,
maxAge = validityInSeconds, maxAge = validityInSeconds,
secure = secure, secure = secure
), )
) )
} }
@@ -0,0 +1,17 @@
package be.simplenotes.app.extensions
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.FOUND
import org.http4k.core.Status.Companion.MOVED_PERMANENTLY
fun Response.html(html: String) = body(html)
.header("Content-Type", "text/html; charset=utf-8")
.header("Cache-Control", "no-cache")
fun Response.json(json: String) = body(json).header("Content-Type", "application/json")
fun Response.Companion.redirect(url: String, permanent: Boolean = false) =
Response(if (permanent) MOVED_PERMANENTLY else FOUND).header("Location", url)
fun Request.isSecure() = header("X-Forwarded-Proto")?.contains("https") ?: false
@@ -0,0 +1,12 @@
package be.simplenotes.app.extensions
import org.koin.core.KoinApplication
import kotlin.concurrent.thread
fun KoinApplication.addShutdownHook() {
Runtime.getRuntime().addShutdownHook(
thread(start = false) {
close()
}
)
}
@@ -0,0 +1,15 @@
package be.simplenotes.app.extensions
import kotlinx.html.*
class SUMMARY(consumer: TagConsumer<*>) :
HTMLTag(
"summary", consumer, emptyMap(),
inlineTag = true,
emptyTag = false
),
HtmlInlineTag
fun DETAILS.summary(block: SUMMARY.() -> Unit = {}) {
SUMMARY(consumer).visit(block)
}
+58
View File
@@ -0,0 +1,58 @@
package be.simplenotes.app.filters
import be.simplenotes.app.extensions.redirect
import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.security.JwtPayloadExtractor
import org.http4k.core.*
import org.http4k.core.Status.Companion.UNAUTHORIZED
import org.http4k.core.cookie.cookie
enum class AuthType {
Optional, Required
}
private const val authKey = "auth"
class AuthFilter(
private val extractor: JwtPayloadExtractor,
private val authType: AuthType,
private val ctx: RequestContexts,
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 { token -> extractor(token) }
when {
jwtPayload != null -> {
ctx[it][authKey] = jwtPayload
next(it)
}
authType == AuthType.Required -> {
if (redirect) Response.redirect("/login")
else Response(UNAUTHORIZED)
}
else -> next(it)
}
}
}
fun Request.jwtPayload(ctx: RequestContexts): JwtPayload? = ctx[this][authKey]
enum class JwtSource {
Header, Cookie
}
private fun Request.bearerTokenCookie(): String? = cookie("Bearer")
?.value
?.trim()
private fun Request.bearerTokenHeader(): String? =
header("Authorization")
?.trim()
?.takeIf { it.startsWith("Bearer") }
?.substringAfter("Bearer")
?.trim()
@@ -1,8 +1,8 @@
package be.simplenotes.app.filters package be.simplenotes.app.filters
import be.simplenotes.app.extensions.html import be.simplenotes.app.extensions.html
import be.simplenotes.views.ErrorView import be.simplenotes.app.views.ErrorView
import be.simplenotes.views.ErrorView.Type.* import be.simplenotes.app.views.ErrorView.Type.*
import org.http4k.core.* import org.http4k.core.*
import org.http4k.core.Status.Companion.INTERNAL_SERVER_ERROR import org.http4k.core.Status.Companion.INTERNAL_SERVER_ERROR
import org.http4k.core.Status.Companion.NOT_FOUND import org.http4k.core.Status.Companion.NOT_FOUND
@@ -10,9 +10,7 @@ import org.http4k.core.Status.Companion.NOT_IMPLEMENTED
import org.http4k.core.Status.Companion.SERVICE_UNAVAILABLE import org.http4k.core.Status.Companion.SERVICE_UNAVAILABLE
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.sql.SQLTransientException import java.sql.SQLTransientException
import 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,13 +3,9 @@ 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 ->
val res = next(request) next(request).header("Cache-Control", "public, max-age=31536000, immutable")
if (res.status == OK)
res.header("Cache-Control", "public, max-age=31536000, immutable")
else res
} }
} }
@@ -0,0 +1,13 @@
package be.simplenotes.app.filters
import me.liuwj.ktorm.database.Database
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
class TransactionFilter(private val db: Database) : Filter {
override fun invoke(next: HttpHandler): HttpHandler = { request ->
db.useTransaction {
next(request)
}
}
}
+24
View File
@@ -0,0 +1,24 @@
package be.simplenotes.app.modules
import be.simplenotes.app.api.ApiNoteController
import be.simplenotes.app.api.ApiUserController
import be.simplenotes.app.filters.AuthFilter
import be.simplenotes.app.filters.AuthType
import be.simplenotes.app.filters.JwtSource
import org.http4k.core.Filter
import org.koin.core.qualifier.named
import org.koin.dsl.module
val apiModule = module {
single { ApiUserController(get(), get()) }
single { ApiNoteController(get(), get()) }
single<Filter>(named("apiAuthFilter")) {
AuthFilter(
extractor = get(),
authType = AuthType.Required,
ctx = get(),
source = JwtSource.Header,
redirect = false
)
}
}
@@ -0,0 +1,11 @@
package be.simplenotes.app.modules
import be.simplenotes.app.Config
import org.koin.dsl.module
val configModule = module {
single { Config() }
single { get<Config>().dataSourceConfig }
single { get<Config>().jwtConfig }
single { get<Config>().serverConfig }
}
@@ -0,0 +1,29 @@
package be.simplenotes.app.modules
import be.simplenotes.app.controllers.*
import be.simplenotes.app.views.BaseView
import be.simplenotes.app.views.NoteView
import be.simplenotes.app.views.SettingView
import be.simplenotes.app.views.UserView
import org.koin.dsl.module
val userModule = module {
single { UserController(get(), get(), get()) }
single { UserView(get()) }
}
val baseModule = module {
single { HealthCheckController(get()) }
single { BaseController(get()) }
single { BaseView(get()) }
}
val noteModule = module {
single { NoteController(get(), get()) }
single { NoteView(get()) }
}
val settingsModule = module {
single { SettingsController(get(), get()) }
single { SettingView(get()) }
}
@@ -2,20 +2,21 @@ 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
@Factory val jsonModule = module {
class JsonModule { single {
Json {
@Singleton
fun json() = Json {
prettyPrint = true prettyPrint = true
serializersModule = SerializersModule { serializersModule = get()
}
}
single {
SerializersModule {
contextual(LocalDateTime::class, LocalDateTimeSerializer()) contextual(LocalDateTime::class, LocalDateTimeSerializer())
contextual(UUID::class, UuidSerializer()) contextual(UUID::class, UuidSerializer())
} }
@@ -0,0 +1,63 @@
package be.simplenotes.app.modules
import be.simplenotes.app.Server
import be.simplenotes.app.filters.AuthFilter
import be.simplenotes.app.filters.AuthType
import be.simplenotes.app.filters.ErrorFilter
import be.simplenotes.app.filters.TransactionFilter
import be.simplenotes.app.routes.Router
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.utils.StaticFileResolverImpl
import be.simplenotes.app.views.ErrorView
import be.simplenotes.shared.config.ServerConfig
import org.eclipse.jetty.server.ServerConnector
import org.http4k.core.Filter
import org.http4k.core.RequestContexts
import org.http4k.routing.RoutingHttpHandler
import org.http4k.server.ConnectorBuilder
import org.http4k.server.Jetty
import org.http4k.server.asServer
import org.koin.core.qualifier.named
import org.koin.core.qualifier.qualifier
import org.koin.dsl.module
import org.koin.dsl.onClose
import org.http4k.server.ServerConfig as Http4kServerConfig
val serverModule = module {
single(createdAtStart = true) { Server(get(), get()).start() } onClose { it?.stop() }
single { get<RoutingHttpHandler>().asServer(get()) }
single<Http4kServerConfig> {
val config = get<ServerConfig>()
val builder: ConnectorBuilder = { server: org.eclipse.jetty.server.Server ->
ServerConnector(server).apply {
port = config.port
host = config.host
}
}
Jetty(config.port, builder)
}
single<StaticFileResolver> { StaticFileResolverImpl(get()) }
single {
Router(
get(),
get(),
get(),
get(),
get(),
get(),
get(),
requiredAuth = get(AuthType.Required.qualifier),
optionalAuth = get(AuthType.Optional.qualifier),
apiAuth = get(named("apiAuthFilter")),
get(),
get(),
get(),
)()
}
single { RequestContexts() }
single<Filter>(AuthType.Optional.qualifier) { AuthFilter(get(), AuthType.Optional, get()) }
single<Filter>(AuthType.Required.qualifier) { AuthFilter(get(), AuthType.Required, get()) }
single { ErrorFilter(get()) }
single { TransactionFilter(get()) }
single { ErrorView(get()) }
}
+106
View File
@@ -0,0 +1,106 @@
package be.simplenotes.app.routes
import be.simplenotes.app.api.ApiNoteController
import be.simplenotes.app.api.ApiUserController
import be.simplenotes.app.controllers.*
import be.simplenotes.app.filters.*
import be.simplenotes.domain.security.JwtPayload
import org.http4k.core.*
import org.http4k.core.Method.*
import org.http4k.filter.ResponseFilters.GZip
import org.http4k.filter.ServerFilters.InitialiseRequestContext
import org.http4k.routing.*
import org.http4k.routing.ResourceLoader.Companion.Classpath
class Router(
private val baseController: BaseController,
private val userController: UserController,
private val noteController: NoteController,
private val settingsController: SettingsController,
private val apiUserController: ApiUserController,
private val apiNoteController: ApiNoteController,
private val healthCheckController: HealthCheckController,
private val requiredAuth: Filter,
private val optionalAuth: Filter,
private val apiAuth: Filter,
private val errorFilter: ErrorFilter,
private val transactionFilter: TransactionFilter,
private val contexts: RequestContexts,
) {
operator fun invoke(): RoutingHttpHandler {
val basicRoutes =
routes(
"/health" bind GET to healthCheckController::healthCheck,
ImmutableFilter.then(static(Classpath("/static"), "woff2" to ContentType("font/woff2")))
)
val publicRoutes = routes(
"/" bind GET public baseController::index,
"/register" bind GET public userController::register,
"/register" bind POST `public transactional` userController::register,
"/login" bind GET public userController::login,
"/login" bind POST public userController::login,
"/logout" bind POST to userController::logout,
"/notes/public/{uuid}" bind GET public noteController::public,
)
val protectedRoutes = routes(
"/settings" bind GET protected settingsController::settings,
"/settings" bind POST transactional settingsController::settings,
"/export" bind POST protected settingsController::export,
"/notes" bind GET protected noteController::list,
"/notes" bind POST protected noteController::search,
"/notes/new" bind GET protected noteController::new,
"/notes/new" bind POST transactional noteController::new,
"/notes/trash" bind GET protected noteController::trash,
"/notes/{uuid}" bind GET protected noteController::note,
"/notes/{uuid}" bind POST transactional noteController::note,
"/notes/{uuid}/edit" bind GET protected noteController::edit,
"/notes/{uuid}/edit" bind POST transactional noteController::edit,
"/notes/deleted/{uuid}" bind POST transactional noteController::deleted,
)
val apiRoutes = routes(
"/api/login" bind POST to apiUserController::login,
)
val protectedApiRoutes = routes(
"/api/notes" bind GET protected apiNoteController::notes,
"/api/notes" bind POST transactional apiNoteController::createNote,
"/api/notes/search" bind POST transactional apiNoteController::search,
"/api/notes/{uuid}" bind GET protected apiNoteController::note,
"/api/notes/{uuid}" bind PUT transactional apiNoteController::update,
)
val routes = routes(
basicRoutes,
optionalAuth.then(publicRoutes),
requiredAuth.then(protectedRoutes),
apiAuth.then(protectedApiRoutes),
apiRoutes,
)
val globalFilters = errorFilter
.then(InitialiseRequestContext(contexts))
.then(SecurityFilter)
.then(GZip())
return globalFilters.then(routes)
}
private inline infix fun PathMethod.public(crossinline handler: PublicHandler) =
this to { handler(it, it.jwtPayload(contexts)) }
private inline infix fun PathMethod.protected(crossinline handler: ProtectedHandler) =
this to { handler(it, it.jwtPayload(contexts)!!) }
private inline infix fun PathMethod.transactional(crossinline handler: ProtectedHandler) =
this to transactionFilter.then { handler(it, it.jwtPayload(contexts)!!) }
private inline infix fun PathMethod.`public transactional`(crossinline handler: PublicHandler) =
this to transactionFilter.then { handler(it, it.jwtPayload(contexts)) }
}
private typealias PublicHandler = (Request, JwtPayload?) -> Response
private typealias ProtectedHandler = (Request, JwtPayload) -> Response
@@ -17,6 +17,6 @@ internal class LocalDateTimeSerializer : KSerializer<LocalDateTime> {
} }
override fun deserialize(decoder: Decoder): LocalDateTime { override fun deserialize(decoder: Decoder): LocalDateTime {
return LocalDateTime.parse(decoder.decodeString()) TODO("Not implemented, isn't needed")
} }
} }
@@ -10,7 +10,7 @@ import java.util.*
internal class UuidSerializer : KSerializer<UUID> { internal class UuidSerializer : KSerializer<UUID> {
override val descriptor: SerialDescriptor override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) get() = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: UUID) { override fun serialize(encoder: Encoder, value: UUID) {
encoder.encodeString(value.toString()) encoder.encodeString(value.toString())
+10
View File
@@ -0,0 +1,10 @@
package be.simplenotes.app.utils
import org.ocpsoft.prettytime.PrettyTime
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.*
private val prettyTime = PrettyTime()
fun LocalDateTime.toTimeAgo(): String = prettyTime.format(Date.from(atZone(ZoneId.systemDefault()).toInstant()))
@@ -0,0 +1,43 @@
package be.simplenotes.app.utils
import be.simplenotes.domain.usecases.search.SearchTerms
private fun innerRegex(name: String) =
"""$name:['"](.*?)['"]""".toRegex()
private fun outerRegex(name: String) =
"""($name:['"].*?['"])""".toRegex()
private val titleRe = innerRegex("title")
private val outerTitleRe = outerRegex("title")
private val tagRe = innerRegex("tag")
private val outerTagRe = outerRegex("tag")
private val contentRe = innerRegex("content")
private val outerContentRe = outerRegex("content")
fun parseSearchTerms(input: String): SearchTerms {
var c: String = input
fun extract(innerRegex: Regex, outerRegex: Regex): String? {
val match = innerRegex.find(input)?.groups?.get(1)?.value
if (match != null) {
val group = outerRegex.find(input)?.groups?.get(1)?.value
group?.let { c = c.replace(it, "") }
}
return match
}
val title: String? = extract(titleRe, outerTitleRe)
val tag: String? = extract(tagRe, outerTagRe)
val content: String? = extract(contentRe, outerContentRe)
val all = c.trim().ifEmpty { null }
return SearchTerms(
title = title,
tag = tag,
content = content,
all = all
)
}
@@ -3,13 +3,11 @@ 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,26 +1,25 @@
package be.simplenotes.views package be.simplenotes.app.views
import be.simplenotes.types.LoggedInUser import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.domain.security.JwtPayload
import kotlinx.html.* import kotlinx.html.*
import kotlinx.html.ThScope.col import kotlinx.html.ThScope.col
import jakarta.inject.Named
import jakarta.inject.Singleton
@Singleton class BaseView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
class BaseView(@Named("styles") styles: String) : View(styles) { fun renderHome(jwtPayload: JwtPayload?) = renderPage(
fun renderHome(loggedInUser: LoggedInUser?) = renderPage(
title = "Home", title = "Home",
description = "A fast and simple note taking website", description = "A fast and simple note taking website",
loggedInUser = loggedInUser, jwtPayload = jwtPayload
) { ) {
section("text-center my-2 p-2") { section("text-center my-2 p-2") {
h1("text-5xl casual") { h1("text-5xl casual") {
span("text-teal-300") { +"SimpleNotes " } span("text-teal-300") { +"Simplenotes " }
+"- access your notes anywhere" +"- access your notes anywhere"
} }
} }
div("container mx-auto flex flex-wrap justify-center content-center") { div("container mx-auto flex flex-wrap justify-center content-center") {
div("md:order-1 order-2 flipped p-4 my-10 w-full md:w-1/2") { div("md:order-1 order-2 flipped p-4 my-10 w-full md:w-1/2") {
attributes["aria-label"] = "demo" attributes["aria-label"] = "demo"
div("flex justify-between mb-4") { div("flex justify-between mb-4") {
@@ -36,7 +35,7 @@ class BaseView(@Named("styles") styles: String) : View(styles) {
attributes["aria-label"] = "demo-search" attributes["aria-label"] = "demo-search"
attributes["name"] = "search" attributes["name"] = "search"
attributes["disabled"] = "" attributes["disabled"] = ""
attributes["value"] = "tag:demo" attributes["value"] = "tag:\"demo\""
} }
span { span {
id = "buttons" id = "buttons"
@@ -1,14 +1,12 @@
package be.simplenotes.views package be.simplenotes.app.views
import be.simplenotes.views.components.Alert import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.views.components.alert import be.simplenotes.app.views.components.Alert
import be.simplenotes.app.views.components.alert
import kotlinx.html.a import kotlinx.html.a
import kotlinx.html.div import kotlinx.html.div
import jakarta.inject.Named
import jakarta.inject.Singleton
@Singleton class ErrorView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
class ErrorView(@Named("styles") styles: String) : View(styles) {
enum class Type(val title: String) { enum class Type(val title: String) {
SqlTransientError("Database unavailable"), SqlTransientError("Database unavailable"),
@@ -16,14 +14,14 @@ class ErrorView(@Named("styles") styles: String) : View(styles) {
Other("Error"), Other("Error"),
} }
fun error(errorType: Type) = renderPage(errorType.title, loggedInUser = null) { fun error(errorType: Type) = renderPage(errorType.title, jwtPayload = null) {
div("container mx-auto p-4") { div("container mx-auto p-4") {
when (errorType) { when (errorType) {
Type.SqlTransientError -> alert( Type.SqlTransientError -> alert(
Alert.Warning, Alert.Warning,
errorType.title, errorType.title,
"Please try again later", "Please try again later",
multiline = true, multiline = true
) )
Type.NotFound -> alert(Alert.Warning, errorType.title, "Page not found", multiline = true) Type.NotFound -> alert(Alert.Warning, errorType.title, "Page not found", multiline = true)
Type.Other -> alert(Alert.Warning, errorType.title) Type.Other -> alert(Alert.Warning, errorType.title)
@@ -1,23 +1,21 @@
package be.simplenotes.views package be.simplenotes.app.views
import be.simplenotes.types.LoggedInUser import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.types.PersistedNote import be.simplenotes.app.views.components.*
import be.simplenotes.types.PersistedNoteMetadata import be.simplenotes.domain.model.PersistedNote
import be.simplenotes.views.components.* import be.simplenotes.domain.model.PersistedNoteMetadata
import be.simplenotes.domain.security.JwtPayload
import io.konform.validation.ValidationError import io.konform.validation.ValidationError
import kotlinx.html.* import kotlinx.html.*
import jakarta.inject.Named
import jakarta.inject.Singleton
@Singleton class NoteView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
class NoteView(@Named("styles") styles: String) : View(styles) {
fun noteEditor( fun noteEditor(
loggedInUser: LoggedInUser, jwtPayload: JwtPayload,
error: String? = null, error: String? = null,
textarea: String? = null, textarea: String? = null,
validationErrors: List<ValidationError> = emptyList(), validationErrors: List<ValidationError> = emptyList(),
) = renderPage(title = "New note", loggedInUser = loggedInUser) { ) = renderPage(title = "New note", jwtPayload = jwtPayload) {
div("container mx-auto p-4") { div("container mx-auto p-4") {
error?.let { alert(Alert.Warning, error) } error?.let { alert(Alert.Warning, error) }
validationErrors.forEach { validationErrors.forEach {
@@ -40,9 +38,7 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
|tags: [] |tags: []
|--- |---
| |
""".trimMargin( """.trimMargin("|")
"|",
)
} }
submitButton("Save") submitButton("Save")
} }
@@ -50,13 +46,13 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
} }
fun notes( fun notes(
loggedInUser: LoggedInUser, jwtPayload: JwtPayload,
notes: List<PersistedNoteMetadata>, notes: List<PersistedNoteMetadata>,
currentPage: Int, currentPage: Int,
numberOfPages: Int, numberOfPages: Int,
numberOfDeletedNotes: Int, numberOfDeletedNotes: Int,
tag: String?, tag: String?,
) = renderPage(title = "Notes", loggedInUser = loggedInUser) { ) = renderPage(title = "Notes", jwtPayload = jwtPayload) {
div("container mx-auto p-4") { div("container mx-auto p-4") {
noteListHeader(numberOfDeletedNotes) noteListHeader(numberOfDeletedNotes)
if (notes.isNotEmpty()) if (notes.isNotEmpty())
@@ -72,11 +68,11 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
} }
fun search( fun search(
loggedInUser: LoggedInUser, jwtPayload: JwtPayload,
notes: List<PersistedNoteMetadata>, notes: List<PersistedNoteMetadata>,
query: String, query: String,
numberOfDeletedNotes: Int, numberOfDeletedNotes: Int,
) = renderPage("Notes", loggedInUser = loggedInUser) { ) = renderPage("Notes", jwtPayload = jwtPayload) {
div("container mx-auto p-4") { div("container mx-auto p-4") {
noteListHeader(numberOfDeletedNotes, query) noteListHeader(numberOfDeletedNotes, query)
noteTable(notes) noteTable(notes)
@@ -84,11 +80,11 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
} }
fun trash( fun trash(
loggedInUser: LoggedInUser, jwtPayload: JwtPayload,
notes: List<PersistedNoteMetadata>, notes: List<PersistedNoteMetadata>,
currentPage: Int, currentPage: Int,
numberOfPages: Int, numberOfPages: Int,
) = renderPage(title = "Notes", loggedInUser = loggedInUser) { ) = renderPage(title = "Notes", jwtPayload = jwtPayload) {
div("container mx-auto p-4") { div("container mx-auto p-4") {
div("flex justify-between mb-4") { div("flex justify-between mb-4") {
h1("text-2xl underline") { +"Deleted notes" } h1("text-2xl underline") { +"Deleted notes" }
@@ -120,12 +116,13 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
} }
} }
fun renderedNote(loggedInUser: LoggedInUser?, note: PersistedNote, shared: Boolean) = renderPage( fun renderedNote(jwtPayload: JwtPayload?, note: PersistedNote, shared: Boolean) = renderPage(
note.title, note.meta.title,
loggedInUser = loggedInUser, jwtPayload = jwtPayload,
scripts = listOf("/highlight.10.1.2.js", "/init-highlight.0.0.1.js"), scripts = listOf("/highlight.10.1.2.js", "/init-highlight.0.0.1.js")
) { ) {
div("container mx-auto p-4") { div("container mx-auto p-4") {
if (shared) { if (shared) {
p("p-4 bg-gray-800") { p("p-4 bg-gray-800") {
+"You are viewing a public note " +"You are viewing a public note "
@@ -135,9 +132,9 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
} }
div("flex items-center justify-between mb-4") { div("flex items-center justify-between mb-4") {
h1("text-3xl fond-bold underline") { +note.title } h1("text-3xl fond-bold underline") { +note.meta.title }
span("space-x-2") { span("space-x-2") {
note.tags.forEach { note.meta.tags.forEach {
a(href = "/notes?tag=$it", classes = "tag") { a(href = "/notes?tag=$it", classes = "tag") {
+"#$it" +"#$it"
} }
@@ -146,6 +143,7 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
} }
if (!shared) { if (!shared) {
noteActionForm(note) noteActionForm(note)
publicPrivateForm(note)
if (note.public) { if (note.public) {
p("my-4") { p("my-4") {
@@ -168,35 +166,36 @@ class NoteView(@Named("styles") styles: String) : View(styles) {
} }
private fun DIV.noteActionForm(note: PersistedNote) { private fun DIV.noteActionForm(note: PersistedNote) {
form(method = FormMethod.post, classes = "inline flex space-x-2 justify-end mb-4") { span("flex space-x-2 justify-end mb-4") {
a( a(
href = "/notes/${note.uuid}/edit", href = "/notes/${note.uuid}/edit",
classes = "btn btn-green", classes = "btn btn-teal"
) { +"Edit" } ) { +"Edit" }
span { form(method = FormMethod.post, classes = "inline") {
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",
) {
+"Private"
}
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-r bg-teal-200 text-gray-800",
) {
+"Public"
}
}
button( button(
type = ButtonType.submit, type = ButtonType.submit,
name = "delete", name = "delete",
classes = "btn btn-red", classes = "btn btn-red"
) { +"Delete" } ) { +"Delete" }
} }
} }
}
private fun DIV.publicPrivateForm(note: PersistedNote) {
span("flex space-x-2 justify-end mb-4") {
form(method = FormMethod.post, classes = "ml-auto ") {
button(
type = ButtonType.submit,
name = if (note.public) "private" else "public",
classes = "btn btn-teal"
) {
if (note.public)
+"This note is public, do you want to make it private ?"
else
+"This note is private, do you want to make it public ?"
}
}
}
}
} }
+105
View File
@@ -0,0 +1,105 @@
package be.simplenotes.app.views
import be.simplenotes.app.extensions.summary
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.views.components.Alert
import be.simplenotes.app.views.components.alert
import be.simplenotes.app.views.components.input
import be.simplenotes.domain.security.JwtPayload
import io.konform.validation.ValidationError
import kotlinx.html.*
import kotlinx.html.ButtonType.submit
class SettingView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
fun settings(
jwtPayload: JwtPayload,
error: String? = null,
validationErrors: List<ValidationError> = emptyList(),
) = renderPage("Settings", jwtPayload = jwtPayload) {
div("container mx-auto") {
section("m-4 p-4 bg-gray-800 rounded") {
h1("text-xl") {
+"Welcome "
span("text-teal-200 font-semibold") { +jwtPayload.username }
}
}
section("m-4 p-4 bg-gray-800 rounded flex justify-around") {
form(method = FormMethod.post, action = "/export") {
button(name = "display",
classes = "inline btn btn-teal block",
type = submit) { +"Display my data" }
}
form(method = FormMethod.post, action = "/export") {
listOf("json", "zip").forEach { format ->
div {
radioInput(name = "format") {
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) {
+"Download my data"
}
}
}
section(classes = "m-4 p-4 bg-gray-800 rounded") {
h2(classes = "mb-4 text-red-400 text-lg font-semibold") {
+"Delete my account"
}
error?.let { alert(Alert.Warning, error) }
details {
if (error != null || validationErrors.isNotEmpty()) {
attributes["open"] = ""
}
summary {
span(classes = "mb-4 font-semibold underline") {
+"Are you sure? "
+"You are about to delete this user, and this process is irreversible !"
}
}
form(classes = "mt-4", method = FormMethod.post) {
input(
id = "password",
placeholder = "Password",
autoComplete = "off",
type = InputType.password,
error = validationErrors.find { it.dataPath == ".password" }?.message
)
checkBoxInput(name = "checked") {
id = "checked"
attributes["required"] = ""
label {
attributes["for"] = "checked"
+" Do you want to proceed ?"
}
}
button(
type = submit,
classes = "block mt-4 btn btn-red",
name = "delete"
) { +"I'm sure" }
}
}
}
}
}
}
@@ -1,40 +1,38 @@
package be.simplenotes.views package be.simplenotes.app.views
import be.simplenotes.types.LoggedInUser import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.views.components.Alert import be.simplenotes.app.views.components.Alert
import be.simplenotes.views.components.alert import be.simplenotes.app.views.components.alert
import be.simplenotes.views.components.input import be.simplenotes.app.views.components.input
import be.simplenotes.views.components.submitButton import be.simplenotes.app.views.components.submitButton
import be.simplenotes.domain.security.JwtPayload
import io.konform.validation.ValidationError import io.konform.validation.ValidationError
import kotlinx.html.* import kotlinx.html.*
import jakarta.inject.Named
import jakarta.inject.Singleton
@Singleton class UserView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
class UserView(@Named("styles") styles: String) : View(styles) {
fun register( fun register(
loggedInUser: LoggedInUser?, jwtPayload: JwtPayload?,
error: String? = null, error: String? = null,
validationErrors: List<ValidationError> = emptyList(), validationErrors: List<ValidationError> = emptyList(),
) = accountForm( ) = accountForm(
"Register", "Register",
"Registration page", "Registration page",
loggedInUser, jwtPayload,
error, error,
validationErrors, validationErrors,
"Create an account", "Create an account",
"Register", "Register"
) { ) {
+"Already have an account? " +"Already have an account? "
a(href = "/login", classes = "no-underline text-blue-500 hover:text-blue-400 font-bold") { +"Sign In" } a(href = "/login", classes = "no-underline text-blue-500 hover:text-blue-400 font-bold") { +"Sign In" }
} }
fun login( fun login(
loggedInUser: LoggedInUser?, jwtPayload: JwtPayload?,
error: String? = null, error: String? = null,
validationErrors: List<ValidationError> = emptyList(), validationErrors: List<ValidationError> = emptyList(),
new: Boolean = false, new: Boolean = false,
) = accountForm("Login", "Login page", loggedInUser, error, validationErrors, "Sign In", "Sign In", new) { ) = accountForm("Login", "Login page", jwtPayload, error, validationErrors, "Sign In", "Sign In", new) {
+"Don't have an account yet? " +"Don't have an account yet? "
a(href = "/register", classes = "no-underline text-blue-500 hover:text-blue-400 font-bold") { a(href = "/register", classes = "no-underline text-blue-500 hover:text-blue-400 font-bold") {
+"Create an account" +"Create an account"
@@ -44,14 +42,14 @@ class UserView(@Named("styles") styles: String) : View(styles) {
private fun accountForm( private fun accountForm(
title: String, title: String,
description: String, description: String,
loggedInUser: LoggedInUser?, jwtPayload: JwtPayload?,
error: String? = null, error: String? = null,
validationErrors: List<ValidationError> = emptyList(), validationErrors: List<ValidationError> = emptyList(),
h1: String, h1: String,
submit: String, submit: String,
new: Boolean = false, new: Boolean = false,
footer: FlowContent.() -> Unit, footer: FlowContent.() -> Unit,
) = renderPage(title = title, description, loggedInUser = loggedInUser) { ) = renderPage(title = title, description, jwtPayload = jwtPayload) {
div("centered container mx-auto flex justify-center items-center") { div("centered container mx-auto flex justify-center items-center") {
div("w-full md:w-1/2 lg:w-1/3 m-4") { div("w-full md:w-1/2 lg:w-1/3 m-4") {
div("p-8 mb-6") { div("p-8 mb-6") {
@@ -63,14 +61,14 @@ class UserView(@Named("styles") styles: String) : View(styles) {
id = "username", id = "username",
placeholder = "Username", placeholder = "Username",
autoComplete = "username", autoComplete = "username",
error = validationErrors.find { it.dataPath == ".username" }?.message, error = validationErrors.find { it.dataPath == ".username" }?.message
) )
input( input(
id = "password", id = "password",
placeholder = "Password", placeholder = "Password",
autoComplete = "new-password", autoComplete = "new-password",
type = InputType.password, type = InputType.password,
error = validationErrors.find { it.dataPath == ".password" }?.message, error = validationErrors.find { it.dataPath == ".password" }?.message
) )
submitButton(submit) submitButton(submit)
} }
@@ -1,16 +1,19 @@
package be.simplenotes.views package be.simplenotes.app.views
import be.simplenotes.types.LoggedInUser import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.views.components.navbar import be.simplenotes.app.views.components.navbar
import be.simplenotes.domain.security.JwtPayload
import kotlinx.html.* import kotlinx.html.*
import kotlinx.html.stream.appendHTML import kotlinx.html.stream.appendHTML
abstract class View(private val styles: String) { abstract class View(staticFileResolver: StaticFileResolver) {
private val styles = staticFileResolver.resolve("styles.css")!!
fun renderPage( fun renderPage(
title: String, title: String,
description: String? = null, description: String? = null,
loggedInUser: LoggedInUser?, jwtPayload: JwtPayload?,
scripts: List<String> = emptyList(), scripts: List<String> = emptyList(),
body: MAIN.() -> Unit = {}, body: MAIN.() -> Unit = {},
) = buildString { ) = buildString {
@@ -22,7 +25,7 @@ abstract class View(private val styles: String) {
meta(name = "viewport", content = "width=device-width, initial-scale=1") meta(name = "viewport", content = "width=device-width, initial-scale=1")
title("$title - SimpleNotes") title("$title - SimpleNotes")
description?.let { meta(name = "description", content = it) } description?.let { meta(name = "description", content = it) }
link(rel = "preload", href = "/recursive-0.0.1.woff2") { link(rel = "preload", href = "/recursive-0.0.1.woff2"){
attributes["as"] = "font" attributes["as"] = "font"
attributes["type"] = "font/woff2" attributes["type"] = "font/woff2"
attributes["crossorigin"] = "anonymous" attributes["crossorigin"] = "anonymous"
@@ -34,7 +37,7 @@ abstract class View(private val styles: String) {
} }
} }
body("bg-gray-900 text-white") { body("bg-gray-900 text-white") {
navbar(loggedInUser) navbar(jwtPayload)
main { body() } main { body() }
} }
} }
@@ -1,8 +1,8 @@
package be.simplenotes.views.components package be.simplenotes.app.views.components
import kotlinx.html.* import kotlinx.html.*
internal fun FlowContent.alert(type: Alert, title: String, details: String? = null, multiline: Boolean = false) { fun FlowContent.alert(type: Alert, title: String, details: String? = null, multiline: Boolean = false) {
val colors = when (type) { val colors = when (type) {
Alert.Success -> "bg-green-500 border border-green-400 text-gray-800" Alert.Success -> "bg-green-500 border border-green-400 text-gray-800"
Alert.Warning -> "bg-red-500 border border-red-400 text-red-200" Alert.Warning -> "bg-red-500 border border-red-400 text-red-200"
@@ -17,6 +17,6 @@ internal fun FlowContent.alert(type: Alert, title: String, details: String? = nu
} }
} }
internal enum class Alert { enum class Alert {
Success, Warning Success, Warning
} }
@@ -1,13 +1,13 @@
package be.simplenotes.views.components package be.simplenotes.app.views.components
import be.simplenotes.types.PersistedNoteMetadata import be.simplenotes.app.utils.toTimeAgo
import be.simplenotes.views.utils.toTimeAgo import be.simplenotes.domain.model.PersistedNoteMetadata
import kotlinx.html.* import kotlinx.html.*
import kotlinx.html.ButtonType.submit import kotlinx.html.ButtonType.submit
import kotlinx.html.FormMethod.post import kotlinx.html.FormMethod.post
import kotlinx.html.ThScope.col import kotlinx.html.ThScope.col
internal fun FlowContent.deletedNoteTable(notes: List<PersistedNoteMetadata>) = div("overflow-x-auto") { fun FlowContent.deletedNoteTable(notes: List<PersistedNoteMetadata>) = div("overflow-x-auto") {
table { table {
id = "notes" id = "notes"
thead { thead {
@@ -25,8 +25,8 @@ internal fun FlowContent.deletedNoteTable(notes: List<PersistedNoteMetadata>) =
td("text-center") { +updatedAt.toTimeAgo() } td("text-center") { +updatedAt.toTimeAgo() }
td { tags(tags) } td { tags(tags) }
td("text-center") { td("text-center") {
form(method = post, action = "/notes/deleted/$uuid") { form(classes = "inline", method = post, action = "/notes/deleted/$uuid") {
button(classes = "btn btn-red mb-2", type = submit, name = "delete") { button(classes = "btn btn-red", type = submit, name = "delete") {
+"Delete permanently" +"Delete permanently"
} }
button(classes = "ml-2 btn btn-green", type = submit, name = "restore") { button(classes = "ml-2 btn btn-green", type = submit, name = "restore") {
@@ -1,20 +1,20 @@
package be.simplenotes.views.components package be.simplenotes.app.views.components
import kotlinx.html.* import kotlinx.html.*
import kotlinx.html.ButtonType.submit import kotlinx.html.ButtonType.submit
internal fun FlowContent.input( fun FlowContent.input(
type: InputType = InputType.text, type: InputType = InputType.text,
placeholder: String, placeholder: String,
id: String, id: String,
autoComplete: String? = null, autoComplete: String? = null,
error: String? = null, error: String? = null
) { ) {
val colors = "bg-gray-800 border-gray-700 focus:border-teal-500 text-white" val colors = "bg-gray-800 border-gray-700 focus:border-teal-500 text-white"
div("mb-8") { div("mb-8") {
input( input(
type = type, type = type,
classes = "$colors rounded w-full border appearance-none focus:outline-none text-base p-2", classes = "$colors rounded w-full border appearance-none focus:outline-none text-base p-2"
) { ) {
attributes["placeholder"] = placeholder attributes["placeholder"] = placeholder
attributes["aria-label"] = placeholder attributes["aria-label"] = placeholder
@@ -26,11 +26,11 @@ internal fun FlowContent.input(
} }
} }
internal fun FlowContent.submitButton(text: String) { fun FlowContent.submitButton(text: String) {
div("flex items-center mt-6") { div("flex items-center mt-6") {
button( button(
type = submit, type = submit,
classes = "btn btn-teal w-full", classes = "btn btn-teal w-full"
) { +text } ) { +text }
} }
} }
@@ -1,9 +1,9 @@
package be.simplenotes.views.components package be.simplenotes.app.views.components
import be.simplenotes.types.LoggedInUser import be.simplenotes.domain.security.JwtPayload
import kotlinx.html.* import kotlinx.html.*
internal fun BODY.navbar(loggedInUser: LoggedInUser?) { fun BODY.navbar(jwtPayload: JwtPayload?) {
nav { nav {
id = "navbar" id = "navbar"
a("/") { a("/") {
@@ -12,7 +12,7 @@ internal fun BODY.navbar(loggedInUser: LoggedInUser?) {
} }
ul("space-x-2") { ul("space-x-2") {
id = "navigation" id = "navigation"
if (loggedInUser != null) { if (jwtPayload != null) {
val links = listOf( val links = listOf(
"/notes" to "Notes", "/notes" to "Notes",
"/settings" to "Settings", "/settings" to "Settings",
@@ -1,20 +1,20 @@
package be.simplenotes.views.components package be.simplenotes.app.views.components
import kotlinx.html.* import kotlinx.html.*
import kotlinx.html.ButtonType.submit import kotlinx.html.ButtonType.submit
import kotlinx.html.FormMethod.post import kotlinx.html.FormMethod.post
internal fun DIV.noteListHeader(numberOfDeletedNotes: Int, query: String = "") { fun DIV.noteListHeader(numberOfDeletedNotes: Int, query: String = "") {
div("flex justify-between mb-4") { div("flex justify-between mb-4") {
h1("text-2xl underline") { +"Notes" } h1("text-2xl underline") { +"Notes" }
span { span {
a( a(
href = "/notes/trash", href = "/notes/trash",
classes = "btn btn-teal", classes = "btn btn-teal"
) { +"Trash ($numberOfDeletedNotes)" } ) { +"Trash ($numberOfDeletedNotes)" }
a( a(
href = "/notes/new", href = "/notes/new",
classes = "ml-2 btn btn-green", classes = "ml-2 btn btn-green"
) { +"New" } ) { +"New" }
} }
} }
@@ -1,11 +1,11 @@
package be.simplenotes.views.components package be.simplenotes.app.views.components
import be.simplenotes.types.PersistedNoteMetadata import be.simplenotes.app.utils.toTimeAgo
import be.simplenotes.views.utils.toTimeAgo import be.simplenotes.domain.model.PersistedNoteMetadata
import kotlinx.html.* import kotlinx.html.*
import kotlinx.html.ThScope.col import kotlinx.html.ThScope.col
internal fun FlowContent.noteTable(notes: List<PersistedNoteMetadata>) = div("overflow-x-auto") { fun FlowContent.noteTable(notes: List<PersistedNoteMetadata>) = div("overflow-x-auto") {
table { table {
id = "notes" id = "notes"
thead { thead {
@@ -0,0 +1,12 @@
host=localhost
port=8080
#
jdbcUrl=jdbc:h2:./notes-db;
driverClassName=org.h2.Driver
username=h2
password=
maximumPoolSize=10
connectionTimeout=3000
#
jwt.secret=PliLvfk7l4WF+cZJk66LR5Mpnh+ocbvJ2wfUCK2UCms=
jwt.validity=24

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

-47
View File
@@ -1,47 +0,0 @@
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()
}
-38
View File
@@ -1,38 +0,0 @@
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
@@ -1,46 +0,0 @@
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
@@ -1,55 +0,0 @@
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
@@ -1,45 +0,0 @@
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
@@ -1,8 +0,0 @@
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
@@ -1,31 +0,0 @@
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
@@ -1,37 +0,0 @@
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,
),
)
}
}
@@ -1,24 +1,15 @@
package be.simplenotes.app.filters package be.simplenotes.app.filters
import be.simplenotes.app.filters.auth.OptionalAuthFilter import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.app.filters.auth.OptionalAuthLens import be.simplenotes.domain.security.JwtPayloadExtractor
import be.simplenotes.app.filters.auth.RequiredAuthFilter
import be.simplenotes.app.filters.auth.RequiredAuthLens
import be.simplenotes.config.JwtConfig
import be.simplenotes.domain.security.SimpleJwt import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.domain.security.UserJwtMapper import be.simplenotes.shared.config.JwtConfig
import be.simplenotes.types.LoggedInUser
import com.natpryce.hamkrest.assertion.assertThat import com.natpryce.hamkrest.assertion.assertThat
import io.micronaut.context.BeanContext import org.http4k.core.*
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
@@ -29,37 +20,23 @@ 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 RequiredAuthFilterTest { internal class AuthFilterTest {
// 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, UserJwtMapper()) private val simpleJwt = SimpleJwt(jwtConfig)
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 beanCtx = BeanContext.build() private val echoJwtPayloadHandler = { request: Request -> Response(OK).body(request.jwtPayload(ctx).toString()) }
.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 { request: Request -> "/optional" bind GET to optionalAuth.then(echoJwtPayloadHandler),
Response(OK).body(optionalLens(request).toString()) "/protected" bind GET to requiredAuth.then(echoJwtPayloadHandler)
}, )
"/protected" bind GET to requiredAuth.then { request: Request ->
Response(OK).body(requiredLens(request).toString())
},
),
) )
// endregion // endregion
@@ -81,7 +58,7 @@ internal class RequiredAuthFilterTest {
@Test @Test
fun `it should allow a valid token`() { fun `it should allow a valid token`() {
val jwtPayload = LoggedInUser(1, "user") val jwtPayload = JwtPayload(1, "user")
val token = simpleJwt.sign(jwtPayload) val token = simpleJwt.sign(jwtPayload)
val response = app(Request(GET, "/optional").cookie("Bearer", token)) val response = app(Request(GET, "/optional").cookie("Bearer", token))
assertThat(response, hasStatus(OK)) assertThat(response, hasStatus(OK))
@@ -106,8 +83,8 @@ internal class RequiredAuthFilterTest {
} }
@Test @Test
fun `it should allow a valid token`() { fun `it should allow a valid token"`() {
val jwtPayload = LoggedInUser(1, "user") val jwtPayload = JwtPayload(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))
assertThat(response, hasStatus(OK)) assertThat(response, hasStatus(OK))
@@ -1,8 +1,7 @@
package be.simplenotes.domain.utils package be.simplenotes.app.utils
import be.simplenotes.search.SearchTerms import be.simplenotes.domain.usecases.search.SearchTerms
import com.natpryce.hamkrest.assertion.assertThat import org.assertj.core.api.Assertions.assertThat
import com.natpryce.hamkrest.equalTo
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource import org.junit.jupiter.params.provider.MethodSource
import java.util.stream.Stream import java.util.stream.Stream
@@ -14,7 +13,7 @@ internal class SearchTermsParserKtTest {
title: String? = null, title: String? = null,
tag: String? = null, tag: String? = null,
content: String? = null, content: String? = null,
all: String? = null, all: String? = null
): Pair<String, SearchTerms> = input to SearchTerms(title, tag, content, all) ): Pair<String, SearchTerms> = input to SearchTerms(title, tag, content, all)
@Suppress("Unused") @Suppress("Unused")
@@ -31,23 +30,13 @@ internal class SearchTermsParserKtTest {
createResult("tag:'example' title:'other' end", title = "other", tag = "example", all = "end"), createResult("tag:'example' title:'other' end", title = "other", tag = "example", all = "end"),
createResult( createResult(
"tag:'example abc' title:'other with words' this is the end ", "tag:'example abc' title:'other with words' this is the end ",
title = "other with words", title = "other with words", tag = "example abc", all = "this is the end"
tag = "example abc",
all = "this is the end",
),
createResult("tag:blah", tag = "blah"),
createResult("tag:'some words'", tag = "some words"),
createResult("tag:'some words ' global", tag = "some words ", all = "global"),
createResult(
"tag:'double quote inside single \" ' global",
tag = "double quote inside single \" ",
all = "global",
), ),
) )
@ParameterizedTest @ParameterizedTest
@MethodSource("results") @MethodSource("results")
fun `valid search parser`(case: Pair<String, SearchTerms>) { fun `valid search parser`(case: Pair<String, SearchTerms>) {
assertThat(parseSearchTerms(case.first), equalTo(case.second)) assertThat(parseSearchTerms(case.first)).isEqualTo(case.second)
} }
} }
-3
View File
@@ -1,3 +0,0 @@
plugins {
id("be.simplenotes.versions")
}
-15
View File
@@ -1,15 +0,0 @@
plugins {
`kotlin-dsl`
}
repositories {
gradlePluginPortal()
}
dependencies {
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")
}
@@ -1,44 +0,0 @@
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)
}
}
}
}
}
@@ -1,13 +0,0 @@
@file:Suppress("SpellCheckingInspection")
package be.simplenotes
object Libs {
object Micronaut {
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"
}
}
@@ -1,33 +0,0 @@
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)
}
@@ -1,26 +0,0 @@
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)
}
}
@@ -1,75 +0,0 @@
package be.simplenotes
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.tasks.*
import org.gradle.kotlin.dsl.getByType
import java.io.File
import java.lang.ProcessBuilder.Redirect.PIPE
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
open class PostcssTask : DefaultTask() {
private val viewsProject = project
.parent
?.project(":views")
?: error("Missing :views")
@get:InputDirectory
val templatesDir = viewsProject.extensions
.getByType<SourceSetContainer>()
.asMap.getOrElse("main") { error("main sources not found") }
.allSource.srcDirs
.find { it.endsWith("src") }
?: error("kotlin sources not found")
private val yarnRoot = File(project.rootDir, "css")
@get:InputDirectory
val postCssDir = File(project.rootDir, "css/src")
@get:InputFiles
val postCssConfig = listOf(
"tailwind.config.js",
"postcss.config.js",
"package.json"
).map { File(yarnRoot, it) }
@get:OutputDirectory
val outputRootDir = File(project.buildDir, "generated-resources/css")
private val cssIndex = File(postCssDir, "styles.pcss")
private val cssOutput = File(outputRootDir, "static/styles.css")
private val manifestOutput = File(outputRootDir, "css-manifest.json")
private val purgeGlob = "$templatesDir/**/*.kt"
@TaskAction
fun generateCss() {
// TODO: auto yarn install ?
outputRootDir.deleteRecursively()
ProcessBuilder("yarn", "run", "postcss", "$cssIndex", "--output", "$cssOutput")
.apply {
environment().let {
it["MANIFEST"] = "$manifestOutput"
it["NODE_ENV"] = "production"
it["PURGE"] = purgeGlob
}
}
.redirectOutput(PIPE)
.redirectError(PIPE)
.directory(yarnRoot)
.start()
.apply {
thread { inputStream.use { it.copyTo(System.out) } }
thread { errorStream.use { it.copyTo(System.out) } }
waitFor(30, TimeUnit.SECONDS)
if (exitValue() != 0) throw GradleException(":/")
}
}
}
@@ -1,41 +0,0 @@
package be.simplenotes
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
plugins {
id("com.github.johnrengelman.shadow")
}
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")
.listFiles()!!
.flatMap {
it.readLines()
.asSequence()
.map { it.trim() }
.filterNot { it.isBlank() }
.filterNot { it.startsWith("#") }
.asIterable()
}.forEach { exclude(it) }
}
tasks.create("package") {
tasks.getByName("build").dependsOn("package")
dependsOn("shadowJar")
doLast {
println("SimpleNotes Packaged !")
}
}
@@ -1,14 +0,0 @@
package be.simplenotes
plugins {
id("be.simplenotes.java-convention")
id("be.simplenotes.kotlin-convention")
id("be.simplenotes.junit-convention")
id("com.diffplug.spotless")
}
spotless {
kotlin {
ktlint("0.48.0").setEditorConfigPath(project.rootProject.file(".editorconfig"))
}
}
@@ -1,28 +0,0 @@
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>())
@@ -1,15 +0,0 @@
package be.simplenotes
plugins {
java apply false
}
tasks.withType<Test> {
useJUnitPlatform {
excludeTags("slow")
}
}
dependencies {
testRuntimeOnly(project(":junit-config"))
}

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