This commit is contained in:
Hubert Van De Walle 2020-09-02 21:41:57 +02:00
parent b015f3a97e
commit c7cf71441f
10 changed files with 236 additions and 22 deletions

View File

@ -1,5 +1,7 @@
package be.simplenotes.app package be.simplenotes.app
import be.simplenotes.app.api.ApiNoteController
import be.simplenotes.app.api.ApiUserController
import be.simplenotes.app.controllers.BaseController import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.NoteController import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.SettingsController import be.simplenotes.app.controllers.SettingsController
@ -7,6 +9,7 @@ import be.simplenotes.app.controllers.UserController
import be.simplenotes.app.filters.AuthFilter import be.simplenotes.app.filters.AuthFilter
import be.simplenotes.app.filters.AuthType import be.simplenotes.app.filters.AuthType
import be.simplenotes.app.filters.ErrorFilter import be.simplenotes.app.filters.ErrorFilter
import be.simplenotes.app.filters.JwtSource
import be.simplenotes.app.routes.Router import be.simplenotes.app.routes.Router
import be.simplenotes.app.utils.StaticFileResolver import be.simplenotes.app.utils.StaticFileResolver
import be.simplenotes.app.utils.StaticFileResolverImpl import be.simplenotes.app.utils.StaticFileResolverImpl
@ -18,12 +21,22 @@ import be.simplenotes.persistance.persistanceModule
import be.simplenotes.search.searchModule import be.simplenotes.search.searchModule
import be.simplenotes.shared.config.DataSourceConfig import be.simplenotes.shared.config.DataSourceConfig
import be.simplenotes.shared.config.JwtConfig import be.simplenotes.shared.config.JwtConfig
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 kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import org.http4k.core.RequestContexts import org.http4k.core.RequestContexts
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
import org.koin.core.qualifier.qualifier import org.koin.core.qualifier.qualifier
import org.koin.dsl.module import org.koin.dsl.module
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.time.LocalDateTime
import java.util.*
import be.simplenotes.shared.config.ServerConfig as SimpleNotesServeConfig import be.simplenotes.shared.config.ServerConfig as SimpleNotesServeConfig
fun main() { fun main() {
@ -38,6 +51,7 @@ fun main() {
noteModule, noteModule,
settingsModule, settingsModule,
searchModule, searchModule,
apiModule,
) )
}.koin }.koin
@ -68,9 +82,12 @@ val serverModule = module {
get(), get(),
get(), get(),
get(), get(),
get(),
get(),
requiredAuth = get(AuthType.Required.qualifier), requiredAuth = get(AuthType.Required.qualifier),
optionalAuth = get(AuthType.Optional.qualifier), optionalAuth = get(AuthType.Optional.qualifier),
errorFilter = get(named("ErrorFilter")), errorFilter = get(named("ErrorFilter")),
apiAuth = get(named("apiAuthFilter")),
get() get()
)() )()
} }
@ -107,3 +124,57 @@ val configModule = module {
single { Config.jwtConfig } single { Config.jwtConfig }
single { Config.serverConfig } single { Config.serverConfig }
} }
val apiModule = module {
single { ApiUserController(get(), get()) }
single { ApiNoteController(get(), get()) }
single {
Json {
prettyPrint = true
serializersModule = get()
}
}
single {
SerializersModule {
contextual(LocalDateTime::class, LocalDateTimeSerializer)
contextual(UUID::class, UuidSerializer)
}
}
single(named("apiAuthFilter")) {
AuthFilter(
extractor = get(),
authType = AuthType.Required,
ctx = get(),
source = JwtSource.Header,
redirect = false
)()
}
}
internal object 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")
}
}
internal object 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()
}
}

View File

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

View File

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

View File

@ -9,6 +9,8 @@ fun Response.html(html: String) = body(html)
.header("Content-Type", "text/html; charset=utf-8") .header("Content-Type", "text/html; charset=utf-8")
.header("Cache-Control", "no-cache") .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) = fun Response.Companion.redirect(url: String, permanent: Boolean = false) =
Response(if (permanent) MOVED_PERMANENTLY else FOUND).header("Location", url) Response(if (permanent) MOVED_PERMANENTLY else FOUND).header("Location", url)

View File

@ -3,10 +3,8 @@ package be.simplenotes.app.filters
import be.simplenotes.app.extensions.redirect import be.simplenotes.app.extensions.redirect
import be.simplenotes.domain.security.JwtPayload import be.simplenotes.domain.security.JwtPayload
import be.simplenotes.domain.security.JwtPayloadExtractor import be.simplenotes.domain.security.JwtPayloadExtractor
import org.http4k.core.Filter import org.http4k.core.*
import org.http4k.core.Request import org.http4k.core.Status.Companion.UNAUTHORIZED
import org.http4k.core.RequestContexts
import org.http4k.core.Response
import org.http4k.core.cookie.cookie import org.http4k.core.cookie.cookie
enum class AuthType { enum class AuthType {
@ -18,17 +16,26 @@ private const val authKey = "auth"
class AuthFilter( class AuthFilter(
private val extractor: JwtPayloadExtractor, private val extractor: JwtPayloadExtractor,
private val authType: AuthType, private val authType: AuthType,
private val ctx: RequestContexts private val ctx: RequestContexts,
private val source: JwtSource = JwtSource.Cookie,
private val redirect: Boolean = true,
) { ) {
operator fun invoke() = Filter { next -> operator fun invoke() = Filter { next ->
{ {
val jwtPayload = it.bearerToken()?.let { token -> extractor(token) } val token = when (source) {
JwtSource.Header -> it.bearerTokenHeader()
JwtSource.Cookie -> it.bearerTokenCookie()
}
val jwtPayload = token?.let { token -> extractor(token) }
when { when {
jwtPayload != null -> { jwtPayload != null -> {
ctx[it][authKey] = jwtPayload ctx[it][authKey] = jwtPayload
next(it) next(it)
} }
authType == AuthType.Required -> Response.redirect("/login") authType == AuthType.Required -> {
if (redirect) Response.redirect("/login")
else Response(UNAUTHORIZED)
}
else -> next(it) else -> next(it)
} }
} }
@ -37,6 +44,17 @@ class AuthFilter(
fun Request.jwtPayload(ctx: RequestContexts): JwtPayload? = ctx[this][authKey] fun Request.jwtPayload(ctx: RequestContexts): JwtPayload? = ctx[this][authKey]
private fun Request.bearerToken(): String? = cookie("Bearer") enum class JwtSource {
Header, Cookie
}
private fun Request.bearerTokenCookie(): String? = cookie("Bearer")
?.value ?.value
?.trim() ?.trim()
private fun Request.bearerTokenHeader(): String? =
header("Authorization")
?.trim()
?.takeIf { it.startsWith("Bearer") }
?.substringAfter("Bearer")
?.trim()

View File

@ -6,14 +6,9 @@ import org.http4k.core.Method
import org.http4k.core.Request import org.http4k.core.Request
object ImmutableFilter { object ImmutableFilter {
operator fun invoke(): Filter { operator fun invoke() = Filter { next: HttpHandler ->
return Filter { next: HttpHandler -> { request: Request ->
{ request: Request -> next(request).header("Cache-Control", "public, max-age=31536000, immutable")
val response = next(request)
if (request.method == Method.GET)
response.header("Cache-Control", "public, max-age=31536000, immutable")
else response
}
} }
} }
} }

View File

@ -1,5 +1,7 @@
package be.simplenotes.app.routes package be.simplenotes.app.routes
import be.simplenotes.app.api.ApiNoteController
import be.simplenotes.app.api.ApiUserController
import be.simplenotes.app.controllers.BaseController import be.simplenotes.app.controllers.BaseController
import be.simplenotes.app.controllers.NoteController import be.simplenotes.app.controllers.NoteController
import be.simplenotes.app.controllers.SettingsController import be.simplenotes.app.controllers.SettingsController
@ -9,8 +11,7 @@ import be.simplenotes.app.filters.SecurityFilter
import be.simplenotes.app.filters.jwtPayload import be.simplenotes.app.filters.jwtPayload
import be.simplenotes.domain.security.JwtPayload import be.simplenotes.domain.security.JwtPayload
import org.http4k.core.* import org.http4k.core.*
import org.http4k.core.Method.GET import org.http4k.core.Method.*
import org.http4k.core.Method.POST
import org.http4k.filter.ResponseFilters import org.http4k.filter.ResponseFilters
import org.http4k.filter.ServerFilters.InitialiseRequestContext import org.http4k.filter.ServerFilters.InitialiseRequestContext
import org.http4k.routing.* import org.http4k.routing.*
@ -20,9 +21,12 @@ class Router(
private val userController: UserController, private val userController: UserController,
private val noteController: NoteController, private val noteController: NoteController,
private val settingsController: SettingsController, private val settingsController: SettingsController,
private val apiUserController: ApiUserController,
private val apiNoteController: ApiNoteController,
private val requiredAuth: Filter, private val requiredAuth: Filter,
private val optionalAuth: Filter, private val optionalAuth: Filter,
private val errorFilter: Filter, private val errorFilter: Filter,
private val apiAuth: Filter,
private val contexts: RequestContexts, private val contexts: RequestContexts,
) { ) {
operator fun invoke(): RoutingHttpHandler { operator fun invoke(): RoutingHttpHandler {
@ -61,10 +65,24 @@ class Router(
"/notes/deleted/{uuid}" bind POST protected noteController::deleted, "/notes/deleted/{uuid}" bind POST protected 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 protected apiNoteController::createNote,
"/api/notes/search" bind POST protected apiNoteController::search,
"/api/notes/{uuid}" bind GET protected apiNoteController::note,
"/api/notes/{uuid}" bind PUT protected apiNoteController::update,
)
val routes = routes( val routes = routes(
basicRoutes, basicRoutes,
optionalAuth.then(publicRoutes), optionalAuth.then(publicRoutes),
requiredAuth.then(protectedRoutes), requiredAuth.then(protectedRoutes),
apiAuth.then(protectedApiRoutes),
apiRoutes,
) )
val globalFilters = errorFilter val globalFilters = errorFilter

View File

@ -5,30 +5,34 @@ import kotlinx.serialization.Serializable
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.* import java.util.*
@Serializable
data class NoteMetadata( data class NoteMetadata(
val title: String, val title: String,
val tags: List<String>, val tags: List<String>,
) )
@Serializable
data class PersistedNoteMetadata( data class PersistedNoteMetadata(
val title: String, val title: String,
val tags: List<String>, val tags: List<String>,
val updatedAt: LocalDateTime, @Contextual val updatedAt: LocalDateTime,
val uuid: UUID, @Contextual val uuid: UUID,
) )
@Serializable
data class Note( data class Note(
val meta: NoteMetadata, val meta: NoteMetadata,
val markdown: String, val markdown: String,
val html: String, val html: String,
) )
@Serializable
data class PersistedNote( data class PersistedNote(
val meta: NoteMetadata, val meta: NoteMetadata,
val markdown: String, val markdown: String,
val html: String, val html: String,
val updatedAt: LocalDateTime, @Contextual val updatedAt: LocalDateTime,
val uuid: UUID, @Contextual val uuid: UUID,
val public: Boolean, val public: Boolean,
) )

View File

@ -12,6 +12,7 @@ import be.simplenotes.domain.usecases.repositories.NoteRepository
import be.simplenotes.domain.usecases.repositories.UserRepository import be.simplenotes.domain.usecases.repositories.UserRepository
import be.simplenotes.domain.usecases.search.NoteSearcher import be.simplenotes.domain.usecases.search.NoteSearcher
import be.simplenotes.domain.usecases.search.SearchTerms import be.simplenotes.domain.usecases.search.SearchTerms
import kotlinx.serialization.Serializable
import java.util.* import java.util.*
class NoteService( class NoteService(

View File

@ -2,6 +2,7 @@ package be.simplenotes.domain.usecases.users.login
import arrow.core.Either import arrow.core.Either
import io.konform.validation.ValidationErrors import io.konform.validation.ValidationErrors
import kotlinx.serialization.Serializable
sealed class LoginError sealed class LoginError
object Unregistered : LoginError() object Unregistered : LoginError()
@ -10,6 +11,7 @@ class InvalidLoginForm(val validationErrors: ValidationErrors) : LoginError()
typealias Token = String typealias Token = String
@Serializable
data class LoginForm(val username: String?, val password: String?) data class LoginForm(val username: String?, val password: String?)
interface LoginUseCase { interface LoginUseCase {