Prefix maven modules

This commit is contained in:
2020-10-23 15:45:28 +02:00
parent 4ff97044f0
commit 4c9ac8944e
135 changed files with 30 additions and 30 deletions
+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>simplenotes-parent</artifactId>
<groupId>be.simplenotes</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>simplenotes-app</artifactId>
<properties>
<http4k.version>3.268.0</http4k.version>
</properties>
<dependencies>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>simplenotes-persistance</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>simplenotes-search</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>simplenotes-domain</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>be.simplenotes</groupId>
<artifactId>simplenotes-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>simplenotes-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>
+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(),
)
}
+23
View File
@@ -0,0 +1,23 @@
package be.simplenotes.app
import org.http4k.server.Http4kServer
import org.slf4j.LoggerFactory
import be.simplenotes.shared.config.ServerConfig as SimpleNotesServerConfig
class Server(
private val config: SimpleNotesServerConfig,
private val http4kServer: Http4kServer,
) {
private val logger = LoggerFactory.getLogger(javaClass)
fun start(): Server {
http4kServer.start()
logger.info("Listening on http://${config.host}:${config.port}")
return this
}
fun stop() {
logger.info("Stopping server")
http4kServer.close()
}
}
@@ -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,74 @@
package be.simplenotes.app.api
import be.simplenotes.app.extensions.auto
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.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.*
class ApiNoteController(private val noteService: NoteService, private val json: Json) {
fun createNote(request: Request, jwtPayload: JwtPayload): Response {
val content = noteContentLens(request)
return noteService.create(jwtPayload.userId, content).fold(
{ Response(BAD_REQUEST) },
{ uuidContentLens(UuidContent(it.uuid), Response(OK)) }
)
}
fun notes(request: Request, jwtPayload: JwtPayload): Response {
val notes = noteService.paginatedNotes(jwtPayload.userId, page = 1).notes
return persistedNotesMetadataLens(notes, Response(OK))
}
fun note(request: Request, jwtPayload: JwtPayload): Response =
noteService.find(jwtPayload.userId, uuidLens(request))
?.let { persistedNoteLens(it, Response(OK)) }
?: Response(NOT_FOUND)
fun update(request: Request, jwtPayload: JwtPayload): Response {
val content = noteContentLens(request)
return noteService.update(jwtPayload.userId, uuidLens(request), content).fold({
Response(BAD_REQUEST)
}, {
if (it == null) Response(NOT_FOUND)
else Response(OK)
})
}
fun search(request: Request, jwtPayload: JwtPayload): Response {
val query = searchContentLens(request)
val terms = parseSearchTerms(query)
val notes = noteService.search(jwtPayload.userId, terms)
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(@Contextual val query: String)
@@ -0,0 +1,26 @@
package be.simplenotes.app.api
import be.simplenotes.app.extensions.auto
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.Companion.BAD_REQUEST
import org.http4k.core.Status.Companion.OK
class ApiUserController(private val userService: UserService, private val json: Json) {
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)
@@ -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
}
}
}
@@ -0,0 +1,75 @@
package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.redirect
import be.simplenotes.app.views.SettingView
import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.usecases.UserService
import be.simplenotes.domain.usecases.users.delete.DeleteError
import be.simplenotes.domain.usecases.users.delete.DeleteForm
import org.http4k.core.*
import org.http4k.core.body.form
import org.http4k.core.cookie.invalidateCookie
class SettingsController(
private val userService: UserService,
private val settingView: SettingView,
) {
fun settings(request: Request, jwtPayload: JwtPayload): Response {
if (request.method == Method.GET)
return Response(Status.OK).html(settingView.settings(jwtPayload))
val deleteForm = request.deleteForm(jwtPayload)
val result = userService.delete(deleteForm)
return result.fold(
{
when (it) {
DeleteError.Unregistered -> Response.redirect("/").invalidateCookie("Bearer")
DeleteError.WrongPassword -> Response(Status.OK).html(
settingView.settings(
jwtPayload,
error = "Wrong password"
)
)
is DeleteError.InvalidForm -> Response(Status.OK).html(
settingView.settings(
jwtPayload,
validationErrors = it.validationErrors
)
)
}
},
{
Response.redirect("/").invalidateCookie("Bearer")
}
)
}
private fun attachment(filename: String, contentType: String) = { response: Response ->
val name = filename.replace("[^a-zA-Z0-9-_.]".toRegex(), "_")
response
.header("Content-Disposition", "attachment; filename=\"$name\"")
.header("Content-Type", contentType)
}
fun export(request: Request, jwtPayload: JwtPayload): Response {
val isDownload = request.form("download") != null
return if (isDownload) {
val filename = "simplenotes-export-${jwtPayload.username}"
if (request.form("format") == "zip") {
val zip = userService.exportAsZip(jwtPayload.userId)
Response(Status.OK)
.with(attachment("$filename.zip", "application/zip"))
.body(zip)
} else
Response(Status.OK)
.with(attachment("$filename.json", "application/json"))
.body(userService.exportAsJson(jwtPayload.userId))
} else Response(Status.OK).body(userService.exportAsJson(jwtPayload.userId)).header("Content-Type", "application/json")
}
private fun Request.deleteForm(jwtPayload: JwtPayload) =
DeleteForm(jwtPayload.username, form("password"), form("checked") != null)
}
@@ -0,0 +1,113 @@
package be.simplenotes.app.controllers
import be.simplenotes.app.extensions.html
import be.simplenotes.app.extensions.isSecure
import be.simplenotes.app.extensions.redirect
import be.simplenotes.app.views.UserView
import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.usecases.UserService
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.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK
import org.http4k.core.body.form
import org.http4k.core.cookie.Cookie
import org.http4k.core.cookie.SameSite
import org.http4k.core.cookie.cookie
import org.http4k.core.cookie.invalidateCookie
import java.util.concurrent.TimeUnit
class UserController(
private val userService: UserService,
private val userView: UserView,
private val jwtConfig: JwtConfig,
) {
fun register(request: Request, jwtPayload: JwtPayload?): Response {
if (request.method == GET) return Response(OK).html(
userView.register(jwtPayload)
)
val result = userService.register(request.registerForm())
return result.fold(
{
val html = when (it) {
UserExists -> userView.register(
jwtPayload,
error = "User already exists"
)
is InvalidRegisterForm ->
userView.register(
jwtPayload,
validationErrors = it.validationErrors
)
}
Response(OK).html(html)
},
{
Response.redirect("/login")
}
)
}
private fun Request.registerForm() = RegisterForm(form("username"), form("password"))
private fun Request.loginForm(): LoginForm = registerForm()
fun login(request: Request, jwtPayload: JwtPayload?): Response {
if (request.method == GET) return Response(OK).html(
userView.login(jwtPayload)
)
val result = userService.login(request.loginForm())
return result.fold(
{
val html = when (it) {
Unregistered ->
userView.login(
jwtPayload,
error = "User does not exist"
)
WrongPassword ->
userView.login(
jwtPayload,
error = "Wrong password"
)
is InvalidLoginForm ->
userView.login(
jwtPayload,
validationErrors = it.validationErrors
)
}
Response(OK).html(html)
},
{ token ->
Response.redirect("/notes").loginCookie(token, request.isSecure())
}
)
}
private fun Response.loginCookie(token: Token, secure: Boolean): Response {
val validityInSeconds = TimeUnit.SECONDS.convert(jwtConfig.validity, jwtConfig.timeUnit)
return this.cookie(
Cookie(
name = "Bearer",
value = token,
path = "/",
httpOnly = true,
sameSite = SameSite.Lax,
maxAge = validityInSeconds,
secure = secure
)
)
}
fun logout(@Suppress("UNUSED_PARAMETER") request: Request) = Response.redirect("/")
.invalidateCookie("Bearer")
}
@@ -0,0 +1,35 @@
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) }
)
@@ -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)
}
@@ -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()
@@ -0,0 +1,45 @@
package be.simplenotes.app.filters
import be.simplenotes.app.extensions.html
import be.simplenotes.app.views.ErrorView
import be.simplenotes.app.views.ErrorView.Type.*
import org.http4k.core.*
import org.http4k.core.Status.Companion.INTERNAL_SERVER_ERROR
import org.http4k.core.Status.Companion.NOT_FOUND
import org.http4k.core.Status.Companion.NOT_IMPLEMENTED
import org.http4k.core.Status.Companion.SERVICE_UNAVAILABLE
import org.slf4j.LoggerFactory
import java.sql.SQLTransientException
class ErrorFilter(private val errorView: ErrorView) : Filter {
private val logger = LoggerFactory.getLogger(javaClass)
private fun errorResponse(status: Status): Response {
val type = when (status) {
SERVICE_UNAVAILABLE -> SqlTransientError
NOT_FOUND -> NotFound
NOT_IMPLEMENTED -> Other
else -> Other
}
return Response(status).html(errorView.error(type)).noCache()
}
override fun invoke(next: HttpHandler): HttpHandler = { request ->
try {
val response = next(request)
if (response.status == NOT_FOUND) errorResponse(NOT_FOUND)
else response
} catch (e: SQLTransientException) {
logger.error(e.stackTraceToString())
errorResponse(SERVICE_UNAVAILABLE)
} catch (e: Exception) {
logger.error(e.stackTraceToString())
errorResponse(INTERNAL_SERVER_ERROR)
} catch (e: NotImplementedError) {
logger.error(e.stackTraceToString())
errorResponse(NOT_IMPLEMENTED)
}
}
}
@@ -0,0 +1,11 @@
package be.simplenotes.app.filters
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.Request
object ImmutableFilter : Filter {
override fun invoke(next: HttpHandler) = { request: Request ->
next(request).header("Cache-Control", "public, max-age=31536000, immutable")
}
}
@@ -0,0 +1,18 @@
package be.simplenotes.app.filters
import org.http4k.core.Filter
import org.http4k.core.HttpHandler
import org.http4k.core.Request
object SecurityFilter : Filter {
override fun invoke(next: HttpHandler): HttpHandler = { request: Request ->
val response = next(request)
.header("X-Content-Type-Options", "nosniff")
if (response.header("Content-Type")?.contains("text/html") == true) {
response
.header("Content-Security-Policy", "default-src 'self'")
.header("Referrer-Policy", "no-referrer")
} else response
}
}
@@ -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)
}
}
}
@@ -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()) }
}
@@ -0,0 +1,24 @@
package be.simplenotes.app.modules
import be.simplenotes.app.serialization.LocalDateTimeSerializer
import be.simplenotes.app.serialization.UuidSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import org.koin.dsl.module
import java.time.LocalDateTime
import java.util.*
val jsonModule = module {
single {
Json {
prettyPrint = true
serializersModule = get()
}
}
single {
SerializersModule {
contextual(LocalDateTime::class, LocalDateTimeSerializer())
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()) }
}
@@ -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
@@ -0,0 +1,22 @@
package be.simplenotes.app.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.LocalDateTime
internal class LocalDateTimeSerializer : KSerializer<LocalDateTime> {
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: LocalDateTime) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): LocalDateTime {
TODO("Not implemented, isn't needed")
}
}
@@ -0,0 +1,22 @@
package be.simplenotes.app.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.util.*
internal class UuidSerializer : KSerializer<UUID> {
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: UUID) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): UUID {
TODO()
}
}
@@ -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
)
}
@@ -0,0 +1,24 @@
package be.simplenotes.app.utils
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
interface StaticFileResolver {
fun resolve(name: String): String?
}
class StaticFileResolverImpl(json: Json) : StaticFileResolver {
private val mappings: Map<String, String>
init {
val manifest = javaClass.getResource("/css-manifest.json").readText()
val manifestObject = json.parseToJsonElement(manifest).jsonObject
val keys = manifestObject.keys
mappings = keys.map {
it to "/${manifestObject[it]!!.jsonPrimitive.content}"
}.toMap()
}
override fun resolve(name: String) = mappings[name]
}
@@ -0,0 +1,108 @@
package be.simplenotes.app.views
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.domain.security.JwtPayload
import kotlinx.html.*
import kotlinx.html.ThScope.col
class BaseView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
fun renderHome(jwtPayload: JwtPayload?) = renderPage(
title = "Home",
description = "A fast and simple note taking website",
jwtPayload = jwtPayload
) {
section("text-center my-2 p-2") {
h1("text-5xl casual") {
span("text-teal-300") { +"SimpleNotes " }
+"- access your notes anywhere"
}
}
div("container mx-auto flex flex-wrap justify-center content-center") {
div("md:order-1 order-2 flipped p-4 my-10 w-full md:w-1/2") {
attributes["aria-label"] = "demo"
div("flex justify-between mb-4") {
h1("text-2xl underline") { +"Notes" }
span {
span("btn btn-teal pointer-events-none") { +"Trash (3)" }
span("ml-2 btn btn-green pointer-events-none") { +"New" }
}
}
form(classes = "md:space-x-2") {
id = "search"
input {
attributes["aria-label"] = "demo-search"
attributes["name"] = "search"
attributes["disabled"] = ""
attributes["value"] = "tag:\"demo\""
}
span {
id = "buttons"
button(type = ButtonType.button, classes = "btn btn-green pointer-events-none") {
attributes["disabled"] = ""
+"search"
}
span("btn btn-red pointer-events-none") { +"clear" }
}
}
div("overflow-x-auto") {
demoTable()
}
}
welcome()
}
}
@Suppress("NOTHING_TO_INLINE")
private inline fun DIV.demoTable() {
table {
id = "notes"
thead {
tr {
th(scope = col, classes = "w-1/2") { +"Title" }
th(scope = col, classes = "w-1/4") { +"Updated" }
th(scope = col, classes = "w-1/4") { +"Tags" }
}
}
tbody {
listOf(
Triple("Formula 1", "moments ago", arrayOf("#demo")),
Triple("Syntax highlighting", "2 hours ago", arrayOf("#features", "#demo")),
Triple("report", "5 days ago", arrayOf("#study", "#demo")),
).forEach { (title, ago, tags) ->
tr {
td { span("text-blue-200 font-semibold underline") { +title } }
td("text-center") { +ago }
td {
ul("inline flex flex-wrap justify-center") {
tags.forEach { tag ->
li("mx-2 my-1") { span("tag disabled") { +tag } }
}
}
}
}
}
}
}
}
@Suppress("NOTHING_TO_INLINE")
private inline fun DIV.welcome() {
div("w-full my-auto md:w-1/2 md:order-2 order-1 text-center") {
div("m-4 rounded-lg p-6") {
h2("text-3xl text-teal-400 underline") { +"Features:" }
ul("list-disc text-lg list-inside") {
li { +"Markdown support" }
li { +"Full text search" }
li { +"Structured search" }
li { +"Code highlighting" }
li { +"Fast and lightweight" }
li { +"No tracking" }
li { +"Works without javascript" }
li { +"Data export" }
}
}
}
}
}
@@ -0,0 +1,34 @@
package be.simplenotes.app.views
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.views.components.Alert
import be.simplenotes.app.views.components.alert
import kotlinx.html.a
import kotlinx.html.div
class ErrorView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
enum class Type(val title: String) {
SqlTransientError("Database unavailable"),
NotFound("Not Found"),
Other("Error"),
}
fun error(errorType: Type) = renderPage(errorType.title, jwtPayload = null) {
div("container mx-auto p-4") {
when (errorType) {
Type.SqlTransientError -> alert(
Alert.Warning,
errorType.title,
"Please try again later",
multiline = true
)
Type.NotFound -> alert(Alert.Warning, errorType.title, "Page not found", multiline = true)
Type.Other -> alert(Alert.Warning, errorType.title)
}
div {
a(href = "/", classes = "btn btn-green") { +"Go back to the homepage" }
}
}
}
}
@@ -0,0 +1,198 @@
package be.simplenotes.app.views
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.views.components.*
import be.simplenotes.domain.model.PersistedNote
import be.simplenotes.domain.model.PersistedNoteMetadata
import be.simplenotes.domain.security.JwtPayload
import io.konform.validation.ValidationError
import kotlinx.html.*
class NoteView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
fun noteEditor(
jwtPayload: JwtPayload,
error: String? = null,
textarea: String? = null,
validationErrors: List<ValidationError> = emptyList(),
) = renderPage(title = "New note", jwtPayload = jwtPayload) {
div("container mx-auto p-4") {
error?.let { alert(Alert.Warning, error) }
validationErrors.forEach {
alert(Alert.Warning, it.dataPath.substringAfter('.') + ": " + it.message)
}
form(method = FormMethod.post) {
textArea {
attributes.also {
it["rows"] = "20"
it["id"] = "markdown"
it["name"] = "markdown"
it["aria-label"] = "markdown text area"
it["spellcheck"] = "false"
}
textarea?.let {
+it
} ?: +"""
|---
|title: ''
|tags: []
|---
|
""".trimMargin("|")
}
submitButton("Save")
}
}
}
fun notes(
jwtPayload: JwtPayload,
notes: List<PersistedNoteMetadata>,
currentPage: Int,
numberOfPages: Int,
numberOfDeletedNotes: Int,
tag: String?,
) = renderPage(title = "Notes", jwtPayload = jwtPayload) {
div("container mx-auto p-4") {
noteListHeader(numberOfDeletedNotes)
if (notes.isNotEmpty())
noteTable(notes)
else
span {
if (numberOfPages > 1) +"You went too far"
else +"No notes yet"
}
if (numberOfPages > 1) pagination(currentPage, numberOfPages, tag)
}
}
fun search(
jwtPayload: JwtPayload,
notes: List<PersistedNoteMetadata>,
query: String,
numberOfDeletedNotes: Int,
) = renderPage("Notes", jwtPayload = jwtPayload) {
div("container mx-auto p-4") {
noteListHeader(numberOfDeletedNotes, query)
noteTable(notes)
}
}
fun trash(
jwtPayload: JwtPayload,
notes: List<PersistedNoteMetadata>,
currentPage: Int,
numberOfPages: Int,
) = renderPage(title = "Notes", jwtPayload = jwtPayload) {
div("container mx-auto p-4") {
div("flex justify-between mb-4") {
h1("text-2xl underline") { +"Deleted notes" }
}
if (notes.isNotEmpty())
deletedNoteTable(notes)
else
span {
if (numberOfPages > 1) +"You went too far"
else +"No deleted notes"
}
if (numberOfPages > 1) pagination(currentPage, numberOfPages, null)
}
}
private fun DIV.pagination(currentPage: Int, numberOfPages: Int, tag: String?) {
val links = mutableListOf<Pair<String, String>>()
// if (currentPage > 1) links += "Previous" to "?page=${currentPage - 1}"
links += (1..numberOfPages).map { page ->
"$page" to (tag?.let { "?page=$page&tag=$it" } ?: "?page=$page")
}
// if (currentPage < numberOfPages) links += "Next" to "?page=${currentPage + 1}"
nav("pages") {
links.forEach { (name, href) ->
a(href, classes = if (name == currentPage.toString()) "active" else null) { +name }
}
}
}
fun renderedNote(jwtPayload: JwtPayload?, note: PersistedNote, shared: Boolean) = renderPage(
note.meta.title,
jwtPayload = jwtPayload,
scripts = listOf("/highlight.10.1.2.js", "/init-highlight.0.0.1.js")
) {
div("container mx-auto p-4") {
if (shared) {
p("p-4 bg-gray-800") {
+"You are viewing a public note "
}
hr { }
}
div("flex items-center justify-between mb-4") {
h1("text-3xl fond-bold underline") { +note.meta.title }
span("space-x-2") {
note.meta.tags.forEach {
a(href = "/notes?tag=$it", classes = "tag") {
+"#$it"
}
}
}
}
if (!shared) {
noteActionForm(note)
if (note.public) {
p("my-4") {
+"You can share this link : "
a(href = "/notes/public/${note.uuid}", classes = "text-blue-300 underline") {
+"/notes/public/${note.uuid}"
}
}
hr { }
}
}
div {
attributes["id"] = "note"
unsafe {
+note.html
}
}
}
}
private fun DIV.noteActionForm(note: PersistedNote) {
form(method = FormMethod.post, classes = "inline flex space-x-2 justify-end mb-4") {
a(
href = "/notes/${note.uuid}/edit",
classes = "btn btn-green"
) { +"Edit" }
span {
button(
type = ButtonType.submit,
name = if (note.public) "private" else "public",
classes = "font-semibold border-b-4 ${if (note.public) "border-teal-200" else "border-green-500"}" +
" p-2 rounded-l bg-teal-200 text-gray-800"
) {
+"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(
type = ButtonType.submit,
name = "delete",
classes = "btn btn-red"
) { +"Delete" }
}
}
}
@@ -0,0 +1,106 @@
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-2 bg-gray-800 rounded flex flex-wrap justify-around items-end") {
form(classes = "m-2", method = FormMethod.post, action = "/export") {
button(name = "display",
classes = "inline btn btn-teal block",
type = submit) { +"Display my data" }
}
form(classes = "m-2", method = FormMethod.post, action = "/export") {
div {
listOf("json", "zip").forEach { format ->
radioInput(name = "format") {
id = format
attributes["value"] = format
if (format == "json") attributes["checked"] = ""
else attributes["class"] = "ml-4"
}
label(classes = "ml-2") {
attributes["for"] = format
+format
}
}
}
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" }
}
}
}
}
}
}
@@ -0,0 +1,84 @@
package be.simplenotes.app.views
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.app.views.components.submitButton
import be.simplenotes.domain.security.JwtPayload
import io.konform.validation.ValidationError
import kotlinx.html.*
class UserView(staticFileResolver: StaticFileResolver) : View(staticFileResolver) {
fun register(
jwtPayload: JwtPayload?,
error: String? = null,
validationErrors: List<ValidationError> = emptyList(),
) = accountForm(
"Register",
"Registration page",
jwtPayload,
error,
validationErrors,
"Create an account",
"Register"
) {
+"Already have an account? "
a(href = "/login", classes = "no-underline text-blue-500 hover:text-blue-400 font-bold") { +"Sign In" }
}
fun login(
jwtPayload: JwtPayload?,
error: String? = null,
validationErrors: List<ValidationError> = emptyList(),
new: Boolean = false,
) = accountForm("Login", "Login page", jwtPayload, error, validationErrors, "Sign In", "Sign In", new) {
+"Don't have an account yet? "
a(href = "/register", classes = "no-underline text-blue-500 hover:text-blue-400 font-bold") {
+"Create an account"
}
}
private fun accountForm(
title: String,
description: String,
jwtPayload: JwtPayload?,
error: String? = null,
validationErrors: List<ValidationError> = emptyList(),
h1: String,
submit: String,
new: Boolean = false,
footer: FlowContent.() -> Unit,
) = renderPage(title = title, description, jwtPayload = jwtPayload) {
div("centered container mx-auto flex justify-center items-center") {
div("w-full md:w-1/2 lg:w-1/3 m-4") {
div("p-8 mb-6") {
h1("font-semibold text-lg mb-6 text-center") { +h1 }
if (new) alert(Alert.Success, "Your account has been created")
error?.let { alert(Alert.Warning, error) }
form(method = FormMethod.post) {
input(
id = "username",
placeholder = "Username",
autoComplete = "username",
error = validationErrors.find { it.dataPath == ".username" }?.message
)
input(
id = "password",
placeholder = "Password",
autoComplete = "new-password",
type = InputType.password,
error = validationErrors.find { it.dataPath == ".password" }?.message
)
submitButton(submit)
}
}
div("text-center") {
p("text-gray-200 text-sm") {
footer()
}
}
}
}
}
}
@@ -0,0 +1,56 @@
package be.simplenotes.app.views
import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.views.components.navbar
import be.simplenotes.domain.security.JwtPayload
import kotlinx.html.*
import kotlinx.html.stream.appendHTML
abstract class View(staticFileResolver: StaticFileResolver) {
private val styles = staticFileResolver.resolve("styles.css")!!
fun renderPage(
title: String,
description: String? = null,
jwtPayload: JwtPayload?,
scripts: List<String> = emptyList(),
body: MAIN.() -> Unit = {},
) = buildString {
appendLine("<!DOCTYPE html>")
appendHTML().html {
attributes["lang"] = "en"
head {
meta(charset = "UTF-8")
meta(name = "viewport", content = "width=device-width, initial-scale=1")
title("$title - SimpleNotes")
description?.let { meta(name = "description", content = it) }
link(rel = "preload", href = "/recursive-0.0.1.woff2"){
attributes["as"] = "font"
attributes["type"] = "font/woff2"
attributes["crossorigin"] = "anonymous"
}
link(rel = "stylesheet", href = styles)
icons()
scripts.forEach { src ->
script(src = src) {}
}
}
body("bg-gray-900 text-white") {
navbar(jwtPayload)
main { body() }
}
}
}
@Suppress("NOTHING_TO_INLINE")
private inline fun HEAD.icons() {
link(rel = "apple-touch-icon", href = "/apple-touch-icon.png") { attributes["sizes"] = "180x180" }
link(rel = "icon", href = "/favicon-32x32.png", type = "image/png") { attributes["sizes"] = "32x32" }
link(rel = "icon", href = "/favicon-16x16.png", type = "image/png") { attributes["sizes"] = "16x16" }
link(rel = "manifest", href = "/site.webmanifest")
link(rel = "mask-icon", href = "/safari-pinned-tab.svg") { attributes["color"] = "#2c7a7b" }
meta(name = "msapplication-TileColor", content = "#00aba9")
meta(name = "theme-color", content = "#2c7a7b")
}
}
@@ -0,0 +1,22 @@
package be.simplenotes.app.views.components
import kotlinx.html.*
fun FlowContent.alert(type: Alert, title: String, details: String? = null, multiline: Boolean = false) {
val colors = when (type) {
Alert.Success -> "bg-green-500 border border-green-400 text-gray-800"
Alert.Warning -> "bg-red-500 border border-red-400 text-red-200"
}
div("$colors px-4 py-3 mb-4 rounded relative") {
attributes["role"] = "alert"
strong("font-bold") { +title }
details?.let {
if (multiline) p { +details }
else span("block sm:inline") { +details }
}
}
}
enum class Alert {
Success, Warning
}
@@ -0,0 +1,51 @@
package be.simplenotes.app.views.components
import be.simplenotes.app.utils.toTimeAgo
import be.simplenotes.domain.model.PersistedNoteMetadata
import kotlinx.html.*
import kotlinx.html.ButtonType.submit
import kotlinx.html.FormMethod.post
import kotlinx.html.ThScope.col
fun FlowContent.deletedNoteTable(notes: List<PersistedNoteMetadata>) = div("overflow-x-auto") {
table {
id = "notes"
thead {
tr {
th(col, "w-1/4") { +"Title" }
th(col, "w-1/4") { +"Updated" }
th(col, "w-1/4") { +"Tags" }
th(col, "w-1/4") { +"Restore" }
}
}
tbody {
notes.forEach { (title, tags, updatedAt, uuid) ->
tr {
td { +title }
td("text-center") { +updatedAt.toTimeAgo() }
td { tags(tags) }
td("text-center") {
form(method = post, action = "/notes/deleted/$uuid") {
button(classes = "btn btn-red mb-2", type = submit, name = "delete") {
+"Delete permanently"
}
button(classes = "ml-2 btn btn-green", type = submit, name = "restore") {
+"Restore"
}
}
}
}
}
}
}
}
private fun FlowContent.tags(tags: List<String>) {
ul("inline flex flex-wrap justify-center") {
tags.forEach { tag ->
li("mx-2 my-1") {
span("tag disabled") { +"#$tag" }
}
}
}
}
@@ -0,0 +1,36 @@
package be.simplenotes.app.views.components
import kotlinx.html.*
import kotlinx.html.ButtonType.submit
fun FlowContent.input(
type: InputType = InputType.text,
placeholder: String,
id: String,
autoComplete: String? = null,
error: String? = null
) {
val colors = "bg-gray-800 border-gray-700 focus:border-teal-500 text-white"
div("mb-8") {
input(
type = type,
classes = "$colors rounded w-full border appearance-none focus:outline-none text-base p-2"
) {
attributes["placeholder"] = placeholder
attributes["aria-label"] = placeholder
attributes["name"] = id
attributes["id"] = id
autoComplete?.let { attributes["autocomplete"] = it }
}
error?.let { p("mt-2 text-red-500 text-sm italic") { +"$placeholder $error" } }
}
}
fun FlowContent.submitButton(text: String) {
div("flex items-center mt-6") {
button(
type = submit,
classes = "btn btn-teal w-full"
) { +text }
}
}
@@ -0,0 +1,37 @@
package be.simplenotes.app.views.components
import be.simplenotes.domain.security.JwtPayload
import kotlinx.html.*
fun BODY.navbar(jwtPayload: JwtPayload?) {
nav {
id = "navbar"
a("/") {
id = "home"
+"SimpleNotes"
}
ul("space-x-2") {
id = "navigation"
if (jwtPayload != null) {
val links = listOf(
"/notes" to "Notes",
"/settings" to "Settings",
)
links.forEach { (href, name) ->
li("txt") {
a(href = href) { +name }
}
}
li {
form(action = "/logout", method = FormMethod.post) {
button(type = ButtonType.submit, classes = "btn btn-green") { +"Logout" }
}
}
} else {
li {
a(href = "/login", classes = "btn btn-green") { +"Sign In" }
}
}
}
}
}
@@ -0,0 +1,37 @@
package be.simplenotes.app.views.components
import kotlinx.html.*
import kotlinx.html.ButtonType.submit
import kotlinx.html.FormMethod.post
fun DIV.noteListHeader(numberOfDeletedNotes: Int, query: String = "") {
div("flex justify-between mb-4") {
h1("text-2xl underline") { +"Notes" }
span {
a(
href = "/notes/trash",
classes = "btn btn-teal"
) { +"Trash ($numberOfDeletedNotes)" }
a(
href = "/notes/new",
classes = "ml-2 btn btn-green"
) { +"New" }
}
}
form(method = post, classes = "md:space-x-2") {
id = "search"
input(name = "search") {
attributes["value"] = query
attributes["aria-label"] = "search"
}
span {
id = "buttons"
button(type = submit, classes = "btn btn-green") {
+"search"
}
a(href = "/notes", classes = "btn btn-red") {
+"clear"
}
}
}
}
@@ -0,0 +1,42 @@
package be.simplenotes.app.views.components
import be.simplenotes.app.utils.toTimeAgo
import be.simplenotes.domain.model.PersistedNoteMetadata
import kotlinx.html.*
import kotlinx.html.ThScope.col
fun FlowContent.noteTable(notes: List<PersistedNoteMetadata>) = div("overflow-x-auto") {
table {
id = "notes"
thead {
tr {
th(col, "w-1/2") { +"Title" }
th(col, "w-1/4") { +"Updated" }
th(col, "w-1/4") { +"Tags" }
}
}
tbody {
notes.forEach { (title, tags, updatedAt, uuid) ->
tr {
td {
a(classes = "text-blue-200 font-semibold underline", href = "/notes/$uuid") { +title }
}
td("text-center") {
+updatedAt.toTimeAgo()
}
td { tags(tags) }
}
}
}
}
}
private fun FlowContent.tags(tags: List<String>) {
ul("inline flex flex-wrap justify-center") {
tags.forEach { tag ->
li("mx-2 my-1") {
a(href = "?tag=$tag", classes = "tag") { +"#$tag" }
}
}
}
}
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#00aba9</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 814 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('#note pre code').forEach((b) => {
hljs.highlightBlock(b);
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,3 @@
User-agent: *
Allow: /$
Disallow: /
@@ -0,0 +1,33 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M1255 6993 c-179 -23 -313 -62 -461 -133 -396 -187 -665 -533 -766
-981 l-23 -104 0 -2275 0 -2275 23 -102 c125 -565 530 -970 1095 -1095 l102
-23 2275 0 2275 0 102 23 c565 125 970 530 1095 1095 l23 102 0 2275 0 2275
-23 102 c-125 566 -521 964 -1090 1095 l-97 22 -2250 2 c-1237 1 -2263 -1
-2280 -3z m1024 -1979 c128 -18 287 -70 394 -127 262 -139 448 -395 472 -649
3 -34 8 -78 11 -95 l5 -33 -255 0 -254 0 -6 28 c-2 15 -7 44 -11 65 -27 168
-204 335 -416 393 -94 26 -317 24 -421 -4 -218 -59 -345 -196 -356 -384 -6
-105 16 -173 80 -243 81 -88 227 -148 563 -230 509 -123 742 -228 916 -412
123 -131 170 -248 176 -448 9 -245 -55 -420 -212 -580 -162 -165 -387 -270
-667 -311 -154 -22 -461 -15 -595 15 -280 62 -513 193 -662 373 -72 85 -162
262 -185 358 -8 36 -18 95 -22 133 l-6 67 254 0 255 0 6 -53 c22 -179 135
-332 310 -417 127 -61 195 -74 387 -75 195 0 279 16 399 76 179 89 260 234
231 415 -22 137 -98 231 -243 302 -109 55 -202 84 -447 142 -237 57 -306 76
-427 120 -340 125 -535 303 -600 550 -23 87 -23 271 0 360 87 335 409 595 827
665 108 19 371 18 499 -1z m3170 0 c202 -35 325 -95 453 -224 77 -77 93 -100
141 -201 30 -63 63 -143 72 -179 49 -183 48 -159 52 -1307 l4 -1083 -256 0
-255 0 0 1034 c0 1160 1 1133 -70 1282 -92 192 -259 271 -548 262 -117 -4
-149 -9 -221 -33 -177 -60 -340 -200 -440 -376 l-31 -56 0 -1056 0 -1057 -255
0 -255 0 0 1480 0 1480 239 0 238 0 6 -82 c4 -46 7 -120 7 -165 0 -45 3 -88 6
-97 4 -11 28 8 92 72 163 164 356 267 575 307 103 19 334 18 446 -1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,19 @@
{
"name": "SimpleNotes",
"short_name": "SimpleNotes",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
+1
View File
@@ -0,0 +1 @@
package be.simplenotes.app
@@ -0,0 +1,94 @@
package be.simplenotes.app.filters
import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.security.JwtPayloadExtractor
import be.simplenotes.domain.security.SimpleJwt
import be.simplenotes.shared.config.JwtConfig
import com.natpryce.hamkrest.assertion.assertThat
import org.http4k.core.*
import org.http4k.core.Method.GET
import org.http4k.core.Status.Companion.FOUND
import org.http4k.core.Status.Companion.OK
import org.http4k.core.cookie.cookie
import org.http4k.filter.ServerFilters
import org.http4k.hamkrest.hasBody
import org.http4k.hamkrest.hasHeader
import org.http4k.hamkrest.hasStatus
import org.http4k.routing.bind
import org.http4k.routing.routes
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.util.concurrent.TimeUnit
internal class AuthFilterTest {
// region setup
private val jwtConfig = JwtConfig("secret", 1, TimeUnit.HOURS)
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 echoJwtPayloadHandler = { request: Request -> Response(OK).body(request.jwtPayload(ctx).toString()) }
private val app = ServerFilters.InitialiseRequestContext(ctx).then(
routes(
"/optional" bind GET to optionalAuth.then(echoJwtPayloadHandler),
"/protected" bind GET to requiredAuth.then(echoJwtPayloadHandler)
)
)
// endregion
@Nested
inner class OptionalAuth {
@Test
fun `it should allow no token`() {
val response = app(Request(GET, "/optional"))
assertThat(response, hasStatus(OK))
assertThat(response, hasBody("null"))
}
@Test
fun `it should allow an invalid token`() {
val response = app(Request(GET, "/optional").cookie("Bearer", "nnkjnkjnk"))
assertThat(response, hasStatus(OK))
assertThat(response, hasBody("null"))
}
@Test
fun `it should allow a valid token`() {
val jwtPayload = JwtPayload(1, "user")
val token = simpleJwt.sign(jwtPayload)
val response = app(Request(GET, "/optional").cookie("Bearer", token))
assertThat(response, hasStatus(OK))
assertThat(response, hasBody("$jwtPayload"))
}
}
@Nested
inner class RequiredAuth {
@Test
fun `it shouldn't allow a missing token`() {
val response = app(Request(GET, "/protected"))
assertThat(response, hasStatus(FOUND))
assertThat(response, hasHeader("Location"))
}
@Test
fun `it shouldn't allow an invalid token`() {
val response = app(Request(GET, "/protected").cookie("Bearer", "nnkjnkjnk"))
assertThat(response, hasStatus(FOUND))
assertThat(response, hasHeader("Location"))
}
@Test
fun `it should allow a valid token"`() {
val jwtPayload = JwtPayload(1, "user")
val token = simpleJwt.sign(jwtPayload)
val response = app(Request(GET, "/protected").cookie("Bearer", token))
assertThat(response, hasStatus(OK))
assertThat(response, hasBody("$jwtPayload"))
}
}
}
@@ -0,0 +1,42 @@
package be.simplenotes.app.utils
import be.simplenotes.domain.usecases.search.SearchTerms
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import java.util.stream.Stream
internal class SearchTermsParserKtTest {
private fun createResult(
input: String,
title: String? = null,
tag: String? = null,
content: String? = null,
all: String? = null
): Pair<String, SearchTerms> = input to SearchTerms(title, tag, content, all)
@Suppress("Unused")
private fun results() = Stream.of(
createResult("title:'example'", title = "example"),
createResult("title:'example with words'", title = "example with words"),
createResult("title:'example with words'", title = "example with words"),
createResult("""title:"double quotes"""", title = "double quotes"),
createResult("title:'example' something else", title = "example", all = "something else"),
createResult("tag:'example'", tag = "example"),
createResult("tag:'example' title:'other'", title = "other", tag = "example"),
createResult("blah blah tag:'example' title:'other'", title = "other", tag = "example", all = "blah blah"),
createResult("tag:'example' middle title:'other'", title = "other", tag = "example", all = "middle"),
createResult("tag:'example' title:'other' end", title = "other", tag = "example", all = "end"),
createResult(
"tag:'example abc' title:'other with words' this is the end ",
title = "other with words", tag = "example abc", all = "this is the end"
),
)
@ParameterizedTest
@MethodSource("results")
fun `valid search parser`(case: Pair<String, SearchTerms>) {
assertThat(parseSearchTerms(case.first)).isEqualTo(case.second)
}
}