diff --git a/Dockerfile b/Dockerfile index 332c3d74..6b181595 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,9 +2,9 @@ FROM openjdk:10-jre WORKDIR /var/server/ -ADD build/dist/jar/blogify-PRX2-all.jar . +ADD build/dist/jar/blogify-0.1.0-all.jar . EXPOSE 8080 EXPOSE 5005 -CMD ["java", "-server", "-XX:+UnlockExperimentalVMOptions", "-XX:+UseG1GC", "-XX:MaxGCPauseMillis=100", "-XX:+UseStringDeduplication", "-jar", "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005", "blogify-PRX2-all.jar"] \ No newline at end of file +CMD ["java", "-server", "-XX:+UnlockExperimentalVMOptions", "-XX:+UseG1GC", "-XX:MaxGCPauseMillis=100", "-XX:+UseStringDeduplication", "-jar", "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005", "blogify-0.1.0-all.jar"] \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 8912bee6..fd794301 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,7 +17,7 @@ plugins { } group = "blogify" -version = "PRX2" +version = "0.1.0" application { mainClassName = "io.ktor.server.netty.EngineMain" @@ -46,6 +46,13 @@ dependencies { compile("io.ktor:ktor-auth-jwt:$ktor_version") compile("io.ktor:ktor-jackson:$ktor_version") + // Ktor client + + compile("io.ktor:ktor-client-cio:$ktor_version") + compile("io.ktor:ktor-client-json:$ktor_version") + compile("io.ktor:ktor-client-json-jvm:$ktor_version") + compile("io.ktor:ktor-client-jackson:$ktor_version") + // Database stuff compile("org.postgresql:postgresql:$pg_driver_version") diff --git a/docker-compose.yml b/docker-compose.yml index 47508480..79a2c757 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: - '5005:5005' depends_on: - db + - ts volumes: - 'static-data:/var/static/' db: @@ -15,7 +16,16 @@ services: - '5432:5432' volumes: - 'db-data:/var/lib/postgresql/data' + ts: + image: 'typesense/typesense:0.11.0' + ports: + - '8108:8108' + volumes: + - 'ts-data:/data' + command: + --data-dir /data --api-key=Hu52dwsas2AdxdE volumes: db-data: static-data: + ts-data: diff --git a/src/blogify/backend/Application.kt b/src/blogify/backend/Application.kt index 1f054b5f..09543730 100644 --- a/src/blogify/backend/Application.kt +++ b/src/blogify/backend/Application.kt @@ -17,6 +17,7 @@ import blogify.backend.routes.auth import blogify.backend.database.handling.query import blogify.backend.resources.models.Resource import blogify.backend.routes.static +import blogify.backend.search.Typesense import blogify.backend.util.SinglePageApplication import io.ktor.application.call @@ -40,12 +41,10 @@ import io.ktor.routing.routing import org.jetbrains.exposed.sql.SchemaUtils import kotlinx.coroutines.runBlocking -import org.jetbrains.exposed.sql.update import org.slf4j.event.Level -import java.util.UUID -const val version = "PRX4" +const val version = "0.1.0" const val asciiLogo = """ __ __ _ ____ @@ -57,7 +56,9 @@ const val asciiLogo = """ ---- Version $version - Development build - """ -fun main(args: Array): Unit = io.ktor.server.netty.EngineMain.main(args) +fun main(args: Array) { + io.ktor.server.netty.EngineMain.main(args) +} @Suppress("unused") // Referenced in application.conf @kotlin.jvm.JvmOverloads @@ -105,7 +106,7 @@ fun Application.mainModule(@Suppress("UNUSED_PARAMETER") testing: Boolean = fals // Default headers install(DefaultHeaders) { - header("Server", "blogify-core PRX4") + header("Server", "blogify-core $version") header("X-Powered-By", "Ktor 1.2.3") } @@ -136,7 +137,67 @@ fun Application.mainModule(@Suppress("UNUSED_PARAMETER") testing: Boolean = fals Users, Comments, Uploadables - ) + ).also { + val articleJson = """ + { + "name": "articles", + "fields": [ + { + "name": "title", + "type": "string" + }, + { + "name": "createdAt", + "type": "float" + }, + { + "name": "createdBy", + "type": "string" + }, + { + "name": "content", + "type": "string" + }, + { + "name": "summary", + "type": "string" + }, + { + "name": "categories", + "type": "string[]", + "facet": true + } + ], + "default_sorting_field": "createdAt" + } + """.trimIndent() + val userJson = """{ + "name": "users", + "fields": [ + { + "name": "username", + "type": "string" + }, + { + "name": "name", + "type": "string" + }, + { + "name": "email", + "type": "string" + }, + { + "name": "dsf_jank", + "type": "int32" + } + ], + "default_sorting_field": "dsf_jank" + }""".trimIndent() + + Typesense.submitResourceTemplate(articleJson) + Typesense.submitResourceTemplate(userJson) + + } }} // Initialize routes diff --git a/src/blogify/backend/annotations/check.kt b/src/blogify/backend/annotations/check.kt index 13e281c9..9768f0cf 100644 --- a/src/blogify/backend/annotations/check.kt +++ b/src/blogify/backend/annotations/check.kt @@ -8,4 +8,4 @@ import org.intellij.lang.annotations.Language @MustBeDocumented annotation class check ( @Language(value = "RegExp") val pattern: String -) \ No newline at end of file +) diff --git a/src/blogify/backend/database/Database.kt b/src/blogify/backend/database/Database.kt index f3c5a11d..b0675b71 100644 --- a/src/blogify/backend/database/Database.kt +++ b/src/blogify/backend/database/Database.kt @@ -7,6 +7,9 @@ import com.zaxxer.hikari.HikariDataSource import org.jetbrains.exposed.sql.Database +/** + * Meta object regrouping setup and utility functions for PostgreSQL. + */ object Database { lateinit var instance: Database diff --git a/src/blogify/backend/database/Tables.kt b/src/blogify/backend/database/Tables.kt index 23204937..4b901767 100644 --- a/src/blogify/backend/database/Tables.kt +++ b/src/blogify/backend/database/Tables.kt @@ -12,11 +12,8 @@ import blogify.backend.services.UserService import blogify.backend.services.articles.CommentService import blogify.backend.services.models.Service -import com.github.kittinunf.result.coroutines.SuspendableResult - import io.ktor.application.ApplicationCall import io.ktor.http.ContentType -import org.jetbrains.exposed.sql.Column import org.jetbrains.exposed.sql.ReferenceOption.* import org.jetbrains.exposed.sql.ResultRow @@ -24,6 +21,8 @@ import org.jetbrains.exposed.sql.Table import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.transactions.transaction +import com.github.kittinunf.result.coroutines.SuspendableResult + abstract class ResourceTable : Table() { abstract suspend fun convert(callContext: ApplicationCall, source: ResultRow): SuspendableResult @@ -34,7 +33,7 @@ abstract class ResourceTable : Table() { object Articles : ResourceTable
() { - val title: Column = varchar ("title", 512) + val title = varchar ("title", 512) val createdAt = long ("created_at") val createdBy = uuid ("created_by").references(Users.uuid, onDelete = SET_NULL) val content = text ("content") diff --git a/src/blogify/backend/resources/Article.kt b/src/blogify/backend/resources/Article.kt index a1f76c63..bf591975 100644 --- a/src/blogify/backend/resources/Article.kt +++ b/src/blogify/backend/resources/Article.kt @@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonIdentityReference import com.fasterxml.jackson.annotation.ObjectIdGenerators import blogify.backend.annotations.check +import blogify.backend.annotations.noslice import blogify.backend.database.Articles import blogify.backend.resources.models.Resource import blogify.backend.database.handling.query @@ -16,11 +17,12 @@ import java.util.* /** * Represents an Article [Resource]. * - * @property title The title of the [Article]. - * @property createdAt The time of creation of the [Article], in `UNIX` timestamp format. - * @property createdBy The UUID of the [User] author of the article. - * @property content The [Content][Article.Content] of the article. Not included in the JSON serialization. - + * @property title The title of the [Article]. + * @property createdAt The time of creation of the [Article], in `UNIX` timestamp format. + * @property createdBy The UUID of the [User] author of the article. + * @property content The content of the article. + * @property summary The summary of the article. + * @property categories The [categories][Article.Category] of the article. */ @JsonIdentityInfo ( scope = Article::class, @@ -29,7 +31,7 @@ import java.util.* property = "uuid" ) data class Article ( - val title: @check("\\w+") String, + val title: @check("^.{0,512}") String, val createdAt: Long = Date().time, diff --git a/src/blogify/backend/resources/User.kt b/src/blogify/backend/resources/User.kt index 77d06b24..125eab62 100644 --- a/src/blogify/backend/resources/User.kt +++ b/src/blogify/backend/resources/User.kt @@ -1,6 +1,5 @@ package blogify.backend.resources -import blogify.backend.annotations.check import blogify.backend.resources.models.Resource import blogify.backend.resources.static.models.StaticResourceHandle import blogify.backend.annotations.noslice @@ -22,7 +21,7 @@ data class User ( @noslice val password: String, // IMPORTANT : DO NOT EVER REMOVE THIS ANNOTATION ! val name: String, val email: String, - val profilePicture: @type("image/png") StaticResourceHandle, + val profilePicture: @type("image/*") StaticResourceHandle, override val uuid: UUID = UUID.randomUUID() ) : Resource(uuid) diff --git a/src/blogify/backend/resources/search/Search.kt b/src/blogify/backend/resources/search/Search.kt new file mode 100644 index 00000000..71fe01d9 --- /dev/null +++ b/src/blogify/backend/resources/search/Search.kt @@ -0,0 +1,108 @@ +package blogify.backend.resources.search + +import blogify.backend.resources.Article +import blogify.backend.resources.User +import blogify.backend.services.UserService +import io.ktor.application.ApplicationCall +import java.util.* + +/** + * Models for deserializing json returned by typesense + * + * @author hamza1311 + */ +data class Search ( + val facet_counts: List?, // |\ + val found: Int?, // | Will not appear on no results + val hits: List>?, // |/ + val page: Int, + val search_time_ms: Int +) { + data class Hit( + val document: D, + val highlights: List + ) + + /** + * Model representing an [article][Article] hit returned by typesense + */ + data class ArticleDocument( + val categories: List, + val content: String, + val createdAt: Double, + val createdBy: UUID, + val summary: String, + val title: String, + val id: UUID + ) { + + /** + * Convert [ArticleDocument] to [Article]. + * It constructs the [article][Article] object using the properties of the given [document][ArticleDocument] + * It does **NOT** makes a database call + * + * @return The article object created by properties of the given [document][ArticleDocument] + */ + suspend fun article(): Article = Article( + title = title, + content = content, + summary = summary, + createdBy = UserService.get(id = createdBy).get(), + categories = categories.map { Article.Category(it) }, + createdAt = createdAt.toLong(), + uuid = id + ) + } + + /** + * Model representing an [user][User] hit returned by typesense + * + * @param dsf_jank This is a workaround for `default_sorting_field` parameter in typesense, which is a required parameter whose value can only be a `float` or `int32`. Its value is always `0` in our case + */ + data class UserDocument( + val username: String, + val name: String, + val email: String, + val dsf_jank: Int, + val id: UUID + ) { + + /** + * Convert [UserDocument] to [User]. + * It constructs the [user][User] object by fetcting user with uuid of [id] from [users][blogify.backend.database.Users] table + * This is a database call + * + * @return The user object with uuid of [id] + */ + suspend fun user(callContext: ApplicationCall): User = UserService.get(callContext, id).get() + } + + data class Highlight( + val `field`: String, + val snippet: String + ) +} + +/** + * Constructs [Search.ArticleDocument] from [Article] + */ +fun Article.asDocument(): Search.ArticleDocument = Search.ArticleDocument( + title = this.title, + content = this.content, + summary = this.summary, + createdBy = this.createdBy.uuid, + categories = this.categories.map { it.name }, + createdAt = this.createdAt.toDouble(), + id = this.uuid +) + +/** + * Constructs [Search.UserDocument] from [User] + */ +fun User.asDocument(): Search.UserDocument = Search.UserDocument( + username = this.username, + name = this.name, + email = this.email, + dsf_jank = 0, + id = this.uuid +) \ No newline at end of file diff --git a/src/blogify/backend/resources/slicing/Mapper.kt b/src/blogify/backend/resources/slicing/Mapper.kt index 23c6d735..793acc6a 100644 --- a/src/blogify/backend/resources/slicing/Mapper.kt +++ b/src/blogify/backend/resources/slicing/Mapper.kt @@ -3,6 +3,7 @@ package blogify.backend.resources.slicing import blogify.backend.annotations.check import blogify.backend.annotations.noslice import blogify.backend.resources.slicing.models.Mapped +import blogify.backend.util.filterThenMapValues import com.andreapivetta.kolor.green @@ -107,6 +108,15 @@ fun M.cachedPropMap(): PropMap { return cached } +/** + * Fetches (or computes if the class is not in the cache) a [property map][PropMap] for the reciever [KClass] + * + * @receiver the [class][KClass] for which the [PropMap] should be obtained + * + * @return the obtained [PropMap] + * + * @author Benjozork + */ fun KClass.cachedPropMap(): PropMap { var cached: PropMap? = propMapCache[this] if (cached == null) { @@ -116,3 +126,10 @@ fun KClass.cachedPropMap(): PropMap { return cached } + +/** + * Returns a PropMap containing only handles that are [ok][PropertyHandle.Ok] + * + * @author Benjozork + */ +fun PropMap.okHandles() = this.filterThenMapValues({ it is PropertyHandle.Ok }, { it.value as PropertyHandle.Ok }) diff --git a/src/blogify/backend/resources/slicing/Verifier.kt b/src/blogify/backend/resources/slicing/Verifier.kt index 1549fadb..92ca1ef9 100644 --- a/src/blogify/backend/resources/slicing/Verifier.kt +++ b/src/blogify/backend/resources/slicing/Verifier.kt @@ -1,6 +1,7 @@ package blogify.backend.resources.slicing import blogify.backend.resources.slicing.models.Mapped +import blogify.backend.util.filterThenMapValues /** * Verifies that a [Mapped] object's [String] properties conform to @@ -8,9 +9,9 @@ import blogify.backend.resources.slicing.models.Mapped * * @author Benjozork */ -fun Mapped.verify(): Map = this.cachedPropMap() - .filterValues { it is PropertyHandle.Ok } - .mapValues { it.value as PropertyHandle.Ok } - .filterValues { it.property.returnType.classifier == String::class } - .map { it.value to (it.value.regexCheck?.let { regex -> (it.value.property.get(this) as String).matches(regex) } ?: true) } - .toMap() +fun Mapped.verify(): Map = this.cachedPropMap().okHandles() + .mapKeys { it.value } // Use property handles as keys + .filterThenMapValues ( + { it.property.returnType.classifier == String::class }, + { (it.value.regexCheck?.let { regex -> (it.value.property.get(this) as String).matches(regex) } ?: true) } + ) diff --git a/src/blogify/backend/routes/AuthRoutes.kt b/src/blogify/backend/routes/AuthRoutes.kt index 854df3e1..e0168914 100644 --- a/src/blogify/backend/routes/AuthRoutes.kt +++ b/src/blogify/backend/routes/AuthRoutes.kt @@ -6,13 +6,20 @@ import blogify.backend.auth.jwt.generateJWT import blogify.backend.auth.jwt.validateJwt import blogify.backend.database.Users import blogify.backend.resources.User +import blogify.backend.resources.search.asDocument import blogify.backend.resources.static.models.StaticResourceHandle import blogify.backend.routes.handling.respondExceptionMessage import blogify.backend.services.UserService import blogify.backend.services.models.Service import blogify.backend.util.* +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import io.ktor.application.call +import io.ktor.client.HttpClient +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.url +import io.ktor.content.TextContent import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.request.receive @@ -63,7 +70,18 @@ data class RegisterCredentials ( ) UserService.add(created).fold( - success = {}, + success = { user -> + HttpClient().use { client -> + val objectMapper = jacksonObjectMapper() + val jsonAsString = objectMapper.writeValueAsString(user.asDocument()) + println(jsonAsString) + client.post { + url("http://ts:8108/collections/users/documents") + body = TextContent(jsonAsString, contentType = ContentType.Application.Json) + header("X-TYPESENSE-API-KEY", TYPESENSE_API_KEY) + }.also { println(it) } + } + }, failure = { error("$created: signup couldn't create user\nError:$it") } @@ -111,7 +129,7 @@ fun Route.auth() { call.parameters["token"]?.let { token -> validateJwt(call, token).fold( - success = { call.respond( object { val uuid = it.uuid }) }, + success = { call.respond( object { @Suppress("unused") val uuid = it.uuid }) }, failure = { call.respondExceptionMessage(Service.Exception(BException(it))) } ) diff --git a/src/blogify/backend/routes/StaticRoutes.kt b/src/blogify/backend/routes/StaticRoutes.kt index ba89532f..88759856 100644 --- a/src/blogify/backend/routes/StaticRoutes.kt +++ b/src/blogify/backend/routes/StaticRoutes.kt @@ -28,51 +28,21 @@ import com.github.kittinunf.result.coroutines.map fun Route.static() { - post("/testupload/{uuid}") { - uploadToResource ( - fetch = UserService::get, - modify = { r, h -> r.copy(profilePicture = h) }, - update = UserService::update, - authPredicate = { user, manipulated -> user eqr manipulated } - ) - } - get("/get/{uploadableId}") { pipeline("uploadableId") { (uploadableId) -> val actualId = uploadableId.takeWhile(Char::isDigit) // Allow for trailing extensions + if (actualId != "") { + val data = StaticFileHandler.readStaticResource(actualId.toLong()) - val data = StaticFileHandler.readStaticResource(actualId.toLong()) + call.respondBytes(data.bytes, data.contentType) + } else { + call.respond(HttpStatusCode.NoContent) + } - call.respondBytes(data.bytes, data.contentType) } } - delete("/delete/{uploadableId}") { - - val doDelete: CallPipeLineFunction = { pipeline("uploadableId") { (uploadableId) -> - // TODO: None of this should be executed unless the owner is logged in. Fix that - // not-so VERY TEMP - val handle = query { - Uploadables.select { Uploadables.fileId eq uploadableId }.single() - }.map { Uploadables.convert(call, it).get() }.get() - - // Delete in DB - query { - Uploadables.deleteWhere { Uploadables.fileId eq uploadableId } - }.failure { pipelineError(HttpStatusCode.InternalServerError, "couldn't delete static resource from db") } - - // Delete in FS - if (StaticFileHandler.deleteStaticResource(handle)) { - call.respond(HttpStatusCode.OK) - } else pipelineError(HttpStatusCode.InternalServerError, "couldn't delete static resource file") - - } } - - handleAuthentication("resourceDelete", { _ -> true }, doDelete) - - } - } diff --git a/src/blogify/backend/routes/articles/ArticleRoutes.kt b/src/blogify/backend/routes/articles/ArticleRoutes.kt index 72c1d7b8..52603cec 100644 --- a/src/blogify/backend/routes/articles/ArticleRoutes.kt +++ b/src/blogify/backend/routes/articles/ArticleRoutes.kt @@ -2,24 +2,33 @@ package blogify.backend.routes.articles -import io.ktor.application.call -import io.ktor.routing.* - import blogify.backend.database.Articles +import blogify.backend.database.Comments import blogify.backend.database.Users import blogify.backend.resources.Article import blogify.backend.resources.models.eqr +import blogify.backend.resources.search.Search +import blogify.backend.resources.search.asDocument import blogify.backend.resources.slicing.sanitize import blogify.backend.resources.slicing.slice import blogify.backend.routes.handling.* import blogify.backend.services.UserService import blogify.backend.services.articles.ArticleService +import blogify.backend.services.articles.CommentService import blogify.backend.services.models.Service +import blogify.backend.util.TYPESENSE_API_KEY + +import io.ktor.application.call +import io.ktor.routing.* +import io.ktor.client.HttpClient +import io.ktor.client.features.json.JsonFeature +import io.ktor.content.TextContent +import io.ktor.http.ContentType import io.ktor.response.respond -import org.jetbrains.exposed.sql.SqlExpressionBuilder -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq -import org.jetbrains.exposed.sql.transactions.TransactionManager -import org.jetbrains.exposed.sql.transactions.transaction +import io.ktor.client.request.* +import io.ktor.http.HttpStatusCode + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper fun Route.articles() { @@ -33,6 +42,10 @@ fun Route.articles() { fetchWithId(ArticleService::get) } + get("/{uuid}/commentCount") { + countReferringToResource(ArticleService::get, CommentService::getReferring, Comments.article) + } + get("/forUser/{username}") { val params = call.parameters @@ -61,19 +74,89 @@ fun Route.articles() { } delete("/{uuid}") { - deleteWithId(ArticleService::get, ArticleService::delete, authPredicate = { user, article -> article.createdBy == user }) + deleteWithId( + fetch = ArticleService::get, + delete = ArticleService::delete, + authPredicate = { user, article -> article.createdBy == user }, + doAfter = { id -> + HttpClient().use { client -> + client.delete { + url("http://ts:8108/collections/articles/documents/$id") + header("X-TYPESENSE-API-KEY", TYPESENSE_API_KEY) + }.also { println(it) } + } + } + ) } patch("/{uuid}") { updateWithId ( update = ArticleService::update, fetch = ArticleService::get, - authPredicate = { user, article -> article.createdBy eqr user } + authPredicate = { user, article -> article.createdBy eqr user }, + doAfter = { replacement -> + HttpClient().use { client -> + client.delete { + url("http://ts:8108/collections/articles/documents/${replacement.uuid}") + header("X-TYPESENSE-API-KEY", TYPESENSE_API_KEY) + }.also { println(it) } + + val objectMapper = jacksonObjectMapper() + val jsonAsString = objectMapper.writeValueAsString(replacement.asDocument()) + println(jsonAsString) + client.post { + url("http://ts:8108/collections/articles/documents") + body = TextContent(jsonAsString, contentType = ContentType.Application.Json) + header("X-TYPESENSE-API-KEY", TYPESENSE_API_KEY) + }.also { println(it) } + } + } ) } post("/") { - createWithResource(ArticleService::add, authPredicate = { user, article -> article.createdBy eqr user }) + createWithResource( + ArticleService::add, + authPredicate = { user, article -> article.createdBy eqr user }, + doAfter = { article -> + HttpClient().use { client -> + val objectMapper = jacksonObjectMapper() + val jsonAsString = objectMapper.writeValueAsString(article.asDocument()) + println(jsonAsString) + client.post { + url("http://ts:8108/collections/articles/documents") + body = TextContent(jsonAsString, contentType = ContentType.Application.Json) + header("X-TYPESENSE-API-KEY", TYPESENSE_API_KEY) + }.also { println(it) } + } + } + ) + } + + get("/search") { + val params = call.parameters + val selectedPropertyNames = params["fields"]?.split(",")?.toSet() + params["q"]?.let { query -> + HttpClient { install(JsonFeature) }.use { client -> + val parsed = client.get>("http://ts:8108/collections/articles/documents/search?q=$query&query_by=content,title") + parsed.hits?.let { hits -> // Some hits + val hitResources = hits.map { it.document.article() } + try { + selectedPropertyNames?.let { props -> + + call.respond(hitResources.map { it.slice(props) }) + + } ?: call.respond(hitResources.map { it.sanitize() }) + } catch (bruhMoment: Service.Exception) { + call.respondExceptionMessage(bruhMoment) + } + } ?: call.respond(HttpStatusCode.NoContent) // No hits + } + } + } + + get("_validations") { + getValidations
() } articleComments() diff --git a/src/blogify/backend/routes/handling/Handlers.kt b/src/blogify/backend/routes/handling/Handlers.kt index b96750ff..616560dc 100644 --- a/src/blogify/backend/routes/handling/Handlers.kt +++ b/src/blogify/backend/routes/handling/Handlers.kt @@ -44,12 +44,16 @@ import blogify.backend.services.models.ResourceResultSet import blogify.backend.services.models.Service import blogify.backend.annotations.BlogifyDsl import blogify.backend.annotations.type +import blogify.backend.database.ResourceTable +import blogify.backend.resources.slicing.models.Mapped +import blogify.backend.resources.slicing.okHandles import blogify.backend.resources.slicing.verify import blogify.backend.routes.pipelines.CallPipeLineFunction import blogify.backend.routes.pipelines.CallPipeline import blogify.backend.routes.pipelines.handleAuthentication import blogify.backend.routes.pipelines.pipeline import blogify.backend.routes.pipelines.pipelineError +import blogify.backend.util.filterThenMapValues import blogify.backend.util.getOrPipelineError import blogify.backend.util.letCatchingOrNull import blogify.backend.util.matches @@ -75,8 +79,9 @@ import com.andreapivetta.kolor.magenta import com.andreapivetta.kolor.yellow import com.github.kittinunf.result.coroutines.failure import com.github.kittinunf.result.coroutines.map -import org.jetbrains.exposed.sql.deleteWhere +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.select @@ -349,6 +354,22 @@ suspend inline fun CallPipeline.uploadToResource ( } +@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE") +@BlogifyDsl +suspend fun CallPipeline.countReferringToResource ( + fetch: suspend (ApplicationCall, UUID) -> ResourceResult, + countReferences: suspend (Column, R) -> ResourceResult, + referenceField: Column +) = pipeline("uuid") { (uuid) -> + + // Find target resource + val targetResource = fetch(call, UUID.fromString(uuid)) + .getOrPipelineError(message = "couldn't fetch resource") // Handle result + + call.respond(countReferences(referenceField, targetResource).getOrPipelineError(message = "error while fetching comment count")) + +} + @BlogifyDsl suspend inline fun CallPipeline.deleteOnResource ( crossinline fetch: suspend (ApplicationCall, UUID) -> ResourceResult, @@ -383,7 +404,6 @@ suspend inline fun CallPipeline.deleteOnResource ( Uploadables.select { Uploadables.fileId eq uploadableId }.single() }.map { Uploadables.convert(call, it).get() }.get() - // Delete in DB query { Uploadables.deleteWhere { Uploadables.fileId eq uploadableId } @@ -411,14 +431,16 @@ suspend inline fun CallPipeline.deleteOnResource ( * @param R the type of [Resource] to be created * @param create the [function][Function] that retrieves that creates the resource using the call * @param authPredicate the [function][Function] that should be run to authenticate the client. If omitted, no authentication is performed. + * @param doAfter the [function][Function] that is executed after resource creation * - * @author Benjozork + * @author Benjozork, hamza1311 */ @Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE") @BlogifyDsl suspend inline fun CallPipeline.createWithResource ( noinline create: suspend (R) -> ResourceResult, - noinline authPredicate: suspend (User, R) -> Boolean = defaultPredicateLambda + noinline authPredicate: suspend (User, R) -> Boolean = defaultPredicateLambda, + noinline doAfter: suspend (R) -> Unit = {} ) = pipeline { try { @@ -434,7 +456,8 @@ suspend inline fun CallPipeline.createWithResource ( res.fold ( success = { - call.respond(HttpStatusCode.Created) + call.respond(HttpStatusCode.Created, it) + doAfter(it) }, failure = call::respondExceptionMessage ) @@ -450,7 +473,7 @@ suspend inline fun CallPipeline.createWithResource ( } catch (e: ContentTransformationException) { call.respond(HttpStatusCode.BadRequest) } -} // KT-33440 | Doesn't compile when lambda called with invoke() for now */ +} // KT-33440 | Doesn't compile when lambda called with invoke() for now /** * Adds a handler to a [CallPipeline] that handles deleting a new resource. @@ -460,14 +483,16 @@ suspend inline fun CallPipeline.createWithResource ( * @param fetch the [function][Function] that retrieves the specified resource. If no [authPredicate] is provided, this is skipped. * @param delete the [function][Function] that deletes the specified resource * @param authPredicate the [function][Function] that should be run to authenticate the client. If omitted, no authentication is performed. + * @param doAfter the [function][Function] that is executed after resource deletion * - * @author Benjozork + * @author Benjozork, hamza1311 */ @BlogifyDsl suspend fun CallPipeline.deleteWithId ( fetch: suspend (ApplicationCall, UUID) -> ResourceResult, delete: suspend (UUID) -> ResourceResult<*>, - authPredicate: suspend (User, R) -> Boolean = defaultPredicateLambda + authPredicate: suspend (User, R) -> Boolean = defaultPredicateLambda, + doAfter: suspend (String) -> Unit = {} ) { call.parameters["uuid"]?.let { id -> @@ -475,6 +500,7 @@ suspend fun CallPipeline.deleteWithId ( delete.invoke(id.toUUID()).fold ( success = { call.respond(HttpStatusCode.OK) + doAfter(id) }, failure = call::respondExceptionMessage ) @@ -493,12 +519,14 @@ suspend fun CallPipeline.deleteWithId ( } ?: call.respond(HttpStatusCode.BadRequest) } + /** * Adds a handler to a [CallPipeline] that handles updating a resource with the given uuid. * * @param R the type of [Resource] to be created * @param update the [function][Function] that retrieves that creates the resource using the call * @param authPredicate the [function][Function] that should be run to authenticate the client. If omitted, no authentication is performed. + * @param doAfter the [function][Function] that is executed after resource update * * @author hamza1311 */ @@ -507,7 +535,8 @@ suspend fun CallPipeline.deleteWithId ( suspend inline fun CallPipeline.updateWithId ( noinline update: suspend (R) -> ResourceResult<*>, fetch: suspend (ApplicationCall, UUID) -> ResourceResult, - noinline authPredicate: suspend (User, R) -> Boolean = defaultPredicateLambda + noinline authPredicate: suspend (User, R) -> Boolean = defaultPredicateLambda, + noinline doAfter: suspend (R) -> Unit = {} ) { val replacement = call.receive() @@ -515,12 +544,13 @@ suspend inline fun CallPipeline.updateWithId ( val doUpdate: CallPipeLineFunction = { update(replacement).fold( success = { + doAfter(it as R) call.respond(HttpStatusCode.OK) }, failure = call::respondExceptionMessage ) } - + replacement.uuid.let { fetch.invoke(call, it) }.fold( success = { if (authPredicate != defaultPredicateLambda) { // Don't authenticate if the endpoint doesn't authenticate @@ -537,3 +567,20 @@ suspend inline fun CallPipeline.updateWithId ( ) } + +/** + * Adds a handler to a [CallPipeline] that returns the validation regexps for a certain class. + * + * @param M the class for which to return validations + * + * @author Benjozork + */ +suspend inline fun CallPipeline.getValidations() { + call.respond ( + M::class.cachedPropMap().okHandles() + .filterThenMapValues ( + { it.regexCheck != null }, + { it.value.regexCheck!!.pattern } + ) + ) +} diff --git a/src/blogify/backend/routes/users/UserRoutes.kt b/src/blogify/backend/routes/users/UserRoutes.kt index be060718..cbe8dca9 100644 --- a/src/blogify/backend/routes/users/UserRoutes.kt +++ b/src/blogify/backend/routes/users/UserRoutes.kt @@ -3,13 +3,22 @@ package blogify.backend.routes.users import blogify.backend.database.Users import blogify.backend.resources.User import blogify.backend.resources.models.eqr +import blogify.backend.resources.search.Search import blogify.backend.resources.slicing.sanitize import blogify.backend.resources.slicing.slice import blogify.backend.routes.handling.* import blogify.backend.services.UserService import blogify.backend.services.models.Service +import blogify.backend.util.TYPESENSE_API_KEY import io.ktor.application.call +import io.ktor.client.HttpClient +import io.ktor.client.features.json.JsonFeature +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.url +import io.ktor.http.HttpStatusCode import io.ktor.response.respond import io.ktor.routing.* @@ -29,7 +38,19 @@ fun Route.users() { } delete("/{uuid}") { - deleteWithId(UserService::get, UserService::delete) + deleteWithId( + UserService::get, + UserService::delete, + authPredicate = { user, manipulated -> user eqr manipulated }, + doAfter = {id -> + HttpClient().use { client -> + client.delete { + url("http://ts:8108/collections/users/documents/$id") + header("X-TYPESENSE-API-KEY", TYPESENSE_API_KEY) + }.also { println(it) } + } + } + ) } patch("/{uuid}") { @@ -75,6 +96,28 @@ fun Route.users() { ) } + get("/search") { + val params = call.parameters + val selectedPropertyNames = params["fields"]?.split(",")?.toSet() + params["q"]?.let { query -> + HttpClient { install(JsonFeature) }.use { client -> + val parsed = client.get>("http://ts:8108/collections/users/documents/search?q=$query&query_by=username,name,email") + parsed.hits?.let { hits -> // Some hits + val hitResources = hits.map { it.document.user(call) } + try { + selectedPropertyNames?.let { props -> + + call.respond(hitResources.map { it.slice(props) }) + + } ?: call.respond(hitResources.map { it.sanitize() }) + } catch (bruhMoment: Service.Exception) { + call.respondExceptionMessage(bruhMoment) + } + } ?: call.respond(HttpStatusCode.NoContent) // No hits + } + } + } + } } diff --git a/src/blogify/backend/search/Typesense.kt b/src/blogify/backend/search/Typesense.kt new file mode 100644 index 00000000..357275eb --- /dev/null +++ b/src/blogify/backend/search/Typesense.kt @@ -0,0 +1,52 @@ +package blogify.backend.search + +import io.ktor.client.HttpClient +import io.ktor.client.features.json.JacksonSerializer +import io.ktor.client.features.json.JsonFeature +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.url +import io.ktor.http.ContentType +import io.ktor.http.content.TextContent + +/** + * Meta object regrouping setup and utility functions for the Typesense search engine. + */ +object Typesense { + + /** + * Typesense REST API URL + */ + private const val TYPESENSE_URL = "http://ts:8108" + + /** + * Typesense API key HTTP header string + */ + private const val TYPESENSE_API_KEY_HEADER = "X-TYPESENSE-API-KEY" + + /** + * Typesense API key + */ + private const val TYPESENSE_API_KEY = "Hu52dwsas2AdxdE" + + private val typesenseClient = HttpClient { install(JsonFeature) { serializer = JacksonSerializer(); } } + + /** + * Uploads a document template to the Typesense REST API + * + * @param template the document template, in JSON format. + * See the [typesense docs](https://typesense.org/docs/0.11.0/api/#create-collection) for more info. + * + * @author Benjozork + */ + suspend fun submitResourceTemplate(template: String) { + typesenseClient.use { client -> + client.post { + url("$TYPESENSE_URL/collections") + body = TextContent(template, contentType = ContentType.Application.Json) + header(TYPESENSE_API_KEY_HEADER, TYPESENSE_API_KEY) + }.also { println(it) } + } + } + +} \ No newline at end of file diff --git a/src/blogify/backend/services/articles/ArticleService.kt b/src/blogify/backend/services/articles/ArticleService.kt index edb5816b..a4655c7e 100644 --- a/src/blogify/backend/services/articles/ArticleService.kt +++ b/src/blogify/backend/services/articles/ArticleService.kt @@ -11,8 +11,6 @@ import com.github.kittinunf.result.coroutines.mapError import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.insert -import org.jetbrains.exposed.sql.transactions.TransactionManager -import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.update object ArticleService : Service
(Articles) { @@ -51,10 +49,12 @@ object ArticleService : Service
(Articles) { Articles.Categories.deleteWhere { Articles.Categories.article eq res.uuid } cats.forEach { cat -> - Articles.Categories.update { + Articles.Categories.insert { it[name] = cat.name + it[article] = res.uuid } } + return@query res // So that we return the resource }.mapError { e -> Exception.Updating(e) } } diff --git a/src/blogify/backend/services/handling/Handlers.kt b/src/blogify/backend/services/handling/Handlers.kt index 8bc6a4ab..8641a6c6 100644 --- a/src/blogify/backend/services/handling/Handlers.kt +++ b/src/blogify/backend/services/handling/Handlers.kt @@ -12,6 +12,7 @@ import io.ktor.application.ApplicationCall import com.github.kittinunf.result.coroutines.map import com.github.kittinunf.result.coroutines.mapError +import org.jetbrains.exposed.sql.Column import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.selectAll @@ -24,6 +25,8 @@ import java.util.UUID * @param table the [ResourceTable] to query * * @return a [ResourceResultSet] that represents the success of the query, with a Database.Exception wrapped in if necessary. + * + * @author Benjozork */ suspend fun fetchAllFromTable(callContext: ApplicationCall, table: ResourceTable): ResourceResultSet { return query { @@ -43,6 +46,8 @@ suspend fun fetchAllFromTable(callContext: ApplicationCall, table * @param limit the number of [resources][Resource] to fetch * * @return a [ResourceResultSet] that represents the success of the query, with a Database.Exception wrapped in if necessary. + * + * @author Benjozork */ suspend fun fetchNumberFromTable(callContext: ApplicationCall, table: ResourceTable, limit: Int): ResourceResultSet { return query { @@ -62,6 +67,8 @@ suspend fun fetchNumberFromTable(callContext: ApplicationCall, ta * @param id the [UUID] of the resource to fetch * * @return a [ResourceResultSet] that represents the success of the query, with a Database.Exception wrapped in if necessary. + * + * @author Benjozork */ suspend fun fetchWithIdFromTable(callContext: ApplicationCall, table: ResourceTable, id: UUID): ResourceResult { return query { @@ -78,6 +85,8 @@ suspend fun fetchWithIdFromTable(callContext: ApplicationCall, ta * @param id the [UUID] of the resource to delete * * @return a [ResourceResult] that represents the success of the deletion, with a Database.Exception wrapped in if necessary. + * + * @author Benjozork */ suspend fun deleteWithIdInTable(table: ResourceTable, id: UUID): ResourceResult { return query { @@ -86,3 +95,21 @@ suspend fun deleteWithIdInTable(table: ResourceTable, id: UUID } .mapError { e -> Service.Exception.Deleting(e) } // Wrap a possible DBEx inside a Service exception } + +/** + * Returns the number of items in [referenceTable] that refer to [referenceValue] in their [referenceField] column. + * + * @param referenceTable the table to look for references in + * @param referenceField the column in which the reference is stored + * @param referenceValue the value to count occurrences for + * + * @return the number of instances of [referenceValue] in [referenceTable] + * + * @author Benjozork + */ +suspend fun countReferringInTable(referenceTable: ResourceTable, referenceField: Column, referenceValue: A): ResourceResult { + return query { + referenceTable.select { referenceField eq referenceValue }.count() + } + .mapError { e -> Service.Exception(e) } +} diff --git a/src/blogify/backend/services/models/Service.kt b/src/blogify/backend/services/models/Service.kt index bcd47576..8e573a0d 100644 --- a/src/blogify/backend/services/models/Service.kt +++ b/src/blogify/backend/services/models/Service.kt @@ -4,6 +4,7 @@ import blogify.backend.database.ResourceTable import blogify.backend.resources.models.Resource import blogify.backend.resources.models.Resource.ObjectResolver.FakeApplicationCall import blogify.backend.services.caching.cachedOrElse +import blogify.backend.services.handling.countReferringInTable import blogify.backend.services.handling.deleteWithIdInTable import blogify.backend.services.handling.fetchNumberFromTable import blogify.backend.services.handling.fetchWithIdFromTable @@ -16,6 +17,7 @@ import com.github.kittinunf.result.coroutines.mapError import kotlinx.coroutines.runBlocking +import org.jetbrains.exposed.sql.Column import org.jetbrains.exposed.sql.Op import org.jetbrains.exposed.sql.SqlExpressionBuilder import org.jetbrains.exposed.sql.select @@ -87,6 +89,19 @@ abstract class Service(val table: ResourceTable) { }.mapError { Exception.Fetching(it) } } + /** + * Returns the number of [R] that refer to [withValue] in [table]. + * + * @param inField the column of [table] in which the reference is stored + * @param withValue the [Resource] to count occurrences of + * + * @return the number of instances of [withValue] in [table] + * + * @author Benjozork + */ + suspend fun getReferring(inField: Column, withValue: T) + = countReferringInTable(table, inField, withValue.uuid) + abstract suspend fun add(res: R): ResourceResult abstract suspend fun update(res: R): SuspendableResult<*, Exception> diff --git a/src/blogify/backend/util/Collections.kt b/src/blogify/backend/util/Collections.kt index 7ba3e52a..6fbd104e 100644 --- a/src/blogify/backend/util/Collections.kt +++ b/src/blogify/backend/util/Collections.kt @@ -1,24 +1,5 @@ package blogify.backend.util -fun Iterable.singleOrNullOrError(): T? { - when (this) { - is List -> return when { - size == 1 -> this[0] - size > 1 -> error("Collection has multiple elements") - else -> null - } - else -> { - val iterator = iterator() - if (!iterator.hasNext()) - return null - val single = iterator.next() - if (iterator.hasNext()) - error("Collection has multiple elements") - return single - } - } -} - /** * Allows to specify a function to execute depending on whether a collection has exactly one item, multiple items or no items. */ @@ -37,4 +18,21 @@ suspend fun Iterable.foldForOne ( } else { one(e) } -} \ No newline at end of file +} + +fun Collection.filterMap(predicate: (T) -> Boolean, mapper: (T) -> R): Collection { + return this.filter(predicate).map(mapper) +} + +fun Map.filterThenMapKeys ( + predicate: (K) -> Boolean, + mapper: (Map.Entry) -> R +): Map { + return this.filterKeys(predicate).mapKeys(mapper) +} +fun Map.filterThenMapValues ( + predicate: (V) -> Boolean, + mapper: (Map.Entry) -> R +): Map { + return this.filterValues(predicate).mapValues(mapper) +} diff --git a/src/blogify/backend/util/FileCollectionTypes.kt b/src/blogify/backend/util/FileCollectionTypes.kt deleted file mode 100644 index bca0df9c..00000000 --- a/src/blogify/backend/util/FileCollectionTypes.kt +++ /dev/null @@ -1,5 +0,0 @@ -package blogify.backend.util - -enum class FileCollectionTypes { - USER_PROFILE_PICTURES -} diff --git a/src/blogify/backend/util/Misc.kt b/src/blogify/backend/util/Misc.kt index ac6c5c64..3ff9c76e 100644 --- a/src/blogify/backend/util/Misc.kt +++ b/src/blogify/backend/util/Misc.kt @@ -14,7 +14,4 @@ fun T.letCatchingOrNull(block: (T) -> R): R? { infix fun ContentType.matches(other: ContentType) = this.match(other) -fun Byte.hex(): String { - val raw = this.toInt().toString(16).toUpperCase() - return if (raw.length == 1) "0$raw" else raw -} +const val TYPESENSE_API_KEY = "Hu52dwsas2AdxdE" \ No newline at end of file diff --git a/src/blogify/frontend/src/app/app-routing.module.ts b/src/blogify/frontend/src/app/app-routing.module.ts index baaec9ef..7f2ac4bc 100644 --- a/src/blogify/frontend/src/app/app-routing.module.ts +++ b/src/blogify/frontend/src/app/app-routing.module.ts @@ -13,7 +13,7 @@ const routes: Routes = [ { path: '', redirectTo: '/home', pathMatch: 'full' }, { path: 'login', component: LoginComponent }, { path: 'register', component: LoginComponent }, - { path: 'new-article', component: NewArticleComponent }, + { path: 'article/new', component: NewArticleComponent }, { path: 'profile/**', component: ProfileComponent }, { path: 'article/:uuid', component: ShowArticleComponent }, { path: 'article/update/:uuid', component: UpdateArticleComponent }, diff --git a/src/blogify/frontend/src/app/app.component.html b/src/blogify/frontend/src/app/app.component.html index 408e7ff7..109dafc7 100644 --- a/src/blogify/frontend/src/app/app.component.html +++ b/src/blogify/frontend/src/app/app.component.html @@ -2,7 +2,7 @@
- blogify-core PRX4 + blogify-core v0.1.0 Copyright ©2019 the Blogify contributors Licensed under the GNU General Public License Version 3 (GPLv3) Source code diff --git a/src/blogify/frontend/src/app/app.module.ts b/src/blogify/frontend/src/app/app.module.ts index 9deea9de..191064c1 100644 --- a/src/blogify/frontend/src/app/app.module.ts +++ b/src/blogify/frontend/src/app/app.module.ts @@ -1,7 +1,7 @@ import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { HttpClientModule } from '@angular/common/http'; -import { FormsModule } from '@angular/forms'; +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { LoginComponent } from './components/login/login.component'; @@ -12,8 +12,6 @@ import { NewArticleComponent } from './components/newarticle/new-article.compone import { ShowArticleComponent } from './components/show-article/show-article.component'; import { ArticleCommentsComponent } from './components/comment/article-comments.component'; import { NavbarComponent } from './components/navbar/navbar.component'; -import { DarkThemeDirective } from './shared/directives/dark-theme/dark-theme.directive'; -import { CompactDirective } from './shared/directives/compact/compact.directive'; import { SingleCommentComponent } from './components/comment/single-comment/single-comment.component'; import { CreateCommentComponent } from './components/comment/create-comment/create-comment.component'; import { UpdateArticleComponent } from './components/update-article/update-article.component'; @@ -42,6 +40,7 @@ import { ProfileModule } from './components/profile/profile/profile.module'; AppRoutingModule, HttpClientModule, FormsModule, + ReactiveFormsModule, FontAwesomeModule, MarkdownModule.forRoot(), ProfileModule, diff --git a/src/blogify/frontend/src/app/components/comment/article-comments.component.html b/src/blogify/frontend/src/app/components/comment/article-comments.component.html index ee42dc20..f992e09b 100644 --- a/src/blogify/frontend/src/app/components/comment/article-comments.component.html +++ b/src/blogify/frontend/src/app/components/comment/article-comments.component.html @@ -4,4 +4,4 @@
- + diff --git a/src/blogify/frontend/src/app/components/comment/create-comment/create-comment.component.html b/src/blogify/frontend/src/app/components/comment/create-comment/create-comment.component.html index 0c0595b0..8c8895d2 100644 --- a/src/blogify/frontend/src/app/components/comment/create-comment/create-comment.component.html +++ b/src/blogify/frontend/src/app/components/comment/create-comment/create-comment.component.html @@ -3,7 +3,7 @@
- + {{replyError}}
diff --git a/src/blogify/frontend/src/app/components/comment/create-comment/create-comment.component.ts b/src/blogify/frontend/src/app/components/comment/create-comment/create-comment.component.ts index 18e6cde8..4bfd091c 100644 --- a/src/blogify/frontend/src/app/components/comment/create-comment/create-comment.component.ts +++ b/src/blogify/frontend/src/app/components/comment/create-comment/create-comment.component.ts @@ -23,17 +23,19 @@ export class CreateCommentComponent implements OnInit { constructor(private commentsService: CommentsService, private authService: AuthService) {} async ngOnInit() { - this.replyComment = { - commenter: this.authService.isLoggedIn() ? await this.authService.userProfile : '', - article: this.comment === undefined ? this.article : this.comment.article, - content: '', - uuid: '' - }; + this.authService.observeIsLoggedIn().subscribe(async value => { + this.replyComment = { + commenter: value ? await this.authService.userProfile : '', + article: this.comment === undefined ? this.article : this.comment.article, + content: '', + uuid: '' + }; + }); } async doReply() { // Make sure the user is authenticated - if (this.authService.isLoggedIn() && this.replyComment.commenter instanceof User) { + if (this.authService.observeIsLoggedIn() && this.replyComment.commenter instanceof User) { if (this.comment === undefined) { // Reply to article await this.commentsService.createComment ( diff --git a/src/blogify/frontend/src/app/components/comment/single-comment/single-comment.component.html b/src/blogify/frontend/src/app/components/comment/single-comment/single-comment.component.html index ac290f99..27e220fe 100644 --- a/src/blogify/frontend/src/app/components/comment/single-comment/single-comment.component.html +++ b/src/blogify/frontend/src/app/components/comment/single-comment/single-comment.component.html @@ -1,11 +1,11 @@
- {{usernameText()}} +
- {{comment.content}} + {{comment.content}}
diff --git a/src/blogify/frontend/src/app/components/comment/single-comment/single-comment.component.scss b/src/blogify/frontend/src/app/components/comment/single-comment/single-comment.component.scss index 66efbdbf..90e8d202 100644 --- a/src/blogify/frontend/src/app/components/comment/single-comment/single-comment.component.scss +++ b/src/blogify/frontend/src/app/components/comment/single-comment/single-comment.component.scss @@ -17,11 +17,12 @@ } .comment-content { - + margin-top: .5em; + * { font-size: 1.15em; } } .comment-buttons { - + margin-top: .5em; } } diff --git a/src/blogify/frontend/src/app/components/comment/single-comment/single-comment.component.ts b/src/blogify/frontend/src/app/components/comment/single-comment/single-comment.component.ts index 1eac3776..d2e9f2a7 100644 --- a/src/blogify/frontend/src/app/components/comment/single-comment/single-comment.component.ts +++ b/src/blogify/frontend/src/app/components/comment/single-comment/single-comment.component.ts @@ -44,7 +44,7 @@ export class SingleCommentComponent implements OnInit { // We're ready, so we can populate the dummy reply comment this.replyComment = { - commenter: this.authService.isLoggedIn() ? await this.authService.userProfile : '', + commenter: await this.authService.observeIsLoggedIn() ? await this.authService.userProfile : '', article: this.comment.article, content: '', uuid: '' @@ -58,7 +58,7 @@ export class SingleCommentComponent implements OnInit { console.log(this.replyComment.commenter instanceof User); // Make sure the user is authenticated - if (this.authService.isLoggedIn() && this.replyComment.commenter instanceof User) { + if (this.authService.observeIsLoggedIn() && this.replyComment.commenter instanceof User) { await this.commentsService.replyToComment ( this.replyComment.content, this.comment.article.uuid, diff --git a/src/blogify/frontend/src/app/components/home/home.component.ts b/src/blogify/frontend/src/app/components/home/home.component.ts index 82ee6ff2..8f082071 100644 --- a/src/blogify/frontend/src/app/components/home/home.component.ts +++ b/src/blogify/frontend/src/app/components/home/home.component.ts @@ -16,10 +16,9 @@ export class HomeComponent implements OnInit { ngOnInit() { this.articleService.getAllArticles ( - ['title', 'summary', 'createdBy', 'categories', 'createdAt'] + ['title', 'summary', 'createdBy', 'categories', 'createdAt', 'numberOfComments'] ).then( articles => { this.articles = articles; - console.log(articles); }); } diff --git a/src/blogify/frontend/src/app/components/login/login.component.ts b/src/blogify/frontend/src/app/components/login/login.component.ts index 8861927d..41f8490d 100644 --- a/src/blogify/frontend/src/app/components/login/login.component.ts +++ b/src/blogify/frontend/src/app/components/login/login.component.ts @@ -27,42 +27,51 @@ export class LoginComponent implements OnInit { } async login() { - this.authService.login(this.loginCredentials).then(async token => { + this.authService.login(this.loginCredentials) + .then(async token => { + console.log(token); - console.log(token); + const uuid = await this.authService.userUUID; + this.user = await this.authService.userProfile; - const uuid = await this.authService.userUUID; - this.user = await this.authService.userProfile; + console.log('LOGIN ->'); + console.log(uuid); + console.log(this.user); + console.log(this.loginCredentials); + console.log(this.authService.userToken); + console.log(this.redirectTo); - console.log('LOGIN ->'); - console.log(uuid); - console.log(this.user); - console.log(this.loginCredentials); - console.log(this.authService.userToken); - console.log(this.redirectTo); - - if (this.redirectTo) { - await this.router.navigateByUrl(this.redirectTo); - } else { - await this.router.navigateByUrl('/home'); - } - }); + if (this.redirectTo) { + await this.router.navigateByUrl(this.redirectTo); + } else { + await this.router.navigateByUrl('/home'); + } + }) + .catch((error) => { + alert("An error occurred during login"); + console.error(`[login]: ${error}`) + }); } async register() { - this.authService.register(this.registerCredentials).then(async user => { - this.user = user; + this.authService.register(this.registerCredentials) + .then(async user => { + this.user = user; - console.log('REGISTER ->'); - console.log(this.user); - console.log(this.registerCredentials); + console.log('REGISTER ->'); + console.log(this.user); + console.log(this.registerCredentials); - if (this.redirectTo) { - await this.router.navigateByUrl(this.redirectTo); - } else { - await this.router.navigateByUrl('/home'); - } - }); + if (this.redirectTo) { + await this.router.navigateByUrl(this.redirectTo); + } else { + await this.router.navigateByUrl('/home'); + } + }) + .catch((error) => { + alert("An error occurred during login"); + console.error(`[register]: ${error}`) + }); } diff --git a/src/blogify/frontend/src/app/components/navbar/navbar.component.html b/src/blogify/frontend/src/app/components/navbar/navbar.component.html index 5d4fbfea..45dde576 100644 --- a/src/blogify/frontend/src/app/components/navbar/navbar.component.html +++ b/src/blogify/frontend/src/app/components/navbar/navbar.component.html @@ -11,69 +11,35 @@ - - - + - - + {{user.name}} - + - + - + Login - + - -
diff --git a/src/blogify/frontend/src/app/components/navbar/navbar.component.ts b/src/blogify/frontend/src/app/components/navbar/navbar.component.ts index 9d8721d6..4b4f7b0f 100644 --- a/src/blogify/frontend/src/app/components/navbar/navbar.component.ts +++ b/src/blogify/frontend/src/app/components/navbar/navbar.component.ts @@ -25,9 +25,14 @@ export class NavbarComponent implements OnInit, AfterViewInit { private darkModeService: DarkModeService, ) {} - async ngOnInit() { - this.user = await this.authService.userProfile; - console.log(this.user.profilePicture); + ngOnInit() { + this.authService.observeIsLoggedIn().subscribe(async value => { + if (value) { + this.user = await this.authService.userProfile; + } else { + this.user = undefined; + } + }); } ngAfterViewInit() { diff --git a/src/blogify/frontend/src/app/components/newarticle/new-article.component.html b/src/blogify/frontend/src/app/components/newarticle/new-article.component.html index 22dacfb6..233ec2f0 100644 --- a/src/blogify/frontend/src/app/components/newarticle/new-article.component.html +++ b/src/blogify/frontend/src/app/components/newarticle/new-article.component.html @@ -1,5 +1,5 @@ -
+

New Article

@@ -9,29 +9,39 @@

New Article

Title - + Summary - + Content - + - - - + + Categories + + + + + + + + + + - - - + + {{result.message}} - + + +
diff --git a/src/blogify/frontend/src/app/components/newarticle/new-article.component.scss b/src/blogify/frontend/src/app/components/newarticle/new-article.component.scss index b641c015..c67d9e82 100644 --- a/src/blogify/frontend/src/app/components/newarticle/new-article.component.scss +++ b/src/blogify/frontend/src/app/components/newarticle/new-article.component.scss @@ -26,7 +26,7 @@ margin-top: 1em; } - & span:first-child { + &:not(:last-of-type) span:first-child { width: 40%; font-size: 1.5em; @@ -42,11 +42,46 @@ flex-grow: 1; } + &#categories-row { + span:first-child { margin-right: auto; } + justify-content: flex-end; + + input { + margin: 0 .25em; + } + + #category-add { + margin-right: .7em; + height: 100%; + } + } + &#submit { + width: 60%; + margin-left: auto; + flex-direction: row; justify-content: flex-end; + * { font-size: 1.35em; } + #submit-result-container { + margin-right: auto; + text-align: center; + + &.none { + display: none; + } + + &.success { + color: var(--accent-positive); + } + + &.error { + color: var(--accent-negative); + } + } + button { flex-grow: 0; margin-left: .5em; @@ -56,7 +91,7 @@ display: flex; flex-direction: row; justify-content: space-between; - align-items: stretch; + align-items: center; @media (max-width: $query-desktop) { flex-direction: column; diff --git a/src/blogify/frontend/src/app/components/newarticle/new-article.component.ts b/src/blogify/frontend/src/app/components/newarticle/new-article.component.ts index af03ce62..679903e1 100644 --- a/src/blogify/frontend/src/app/components/newarticle/new-article.component.ts +++ b/src/blogify/frontend/src/app/components/newarticle/new-article.component.ts @@ -4,6 +4,12 @@ import { ArticleService } from '../../services/article/article.service'; import { User } from '../../models/User'; import { StaticFile } from "../../models/Static"; import { AuthService } from '../../shared/auth/auth.service'; +import { AbstractControl, FormArray, FormControl, FormGroup, ValidationErrors, ValidatorFn, Validators } from '@angular/forms'; +import { HttpClient } from '@angular/common/http'; +import { faPlus } from '@fortawesome/free-solid-svg-icons'; +import { Router } from "@angular/router"; + +type Result = 'none' | 'success' | 'error'; @Component({ selector: 'app-new-article', @@ -12,6 +18,8 @@ import { AuthService } from '../../shared/auth/auth.service'; }) export class NewArticleComponent implements OnInit { + faPlus = faPlus; + article: Article = { uuid: '', title: '', @@ -20,22 +28,77 @@ export class NewArticleComponent implements OnInit { summary: '', createdBy: new User('', '', '', '', new StaticFile('-1')), createdAt: Date.now(), + numberOfComments: 0, }; + user: User; + validations: object; + + result: { status: Result, message: string } = { status: 'none', message: null }; - constructor(private articleService: ArticleService, private authService: AuthService) {} + constructor ( + private articleService: ArticleService, + private authService: AuthService, + private http: HttpClient, + private router: Router, + ) {} async ngOnInit() { this.user = await this.authService.userProfile; + this.validations = await this.http.get('/api/articles/_validations').toPromise(); + console.warn(this.validations); + } + + private validateOnServer(fieldName: string): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + if (this.validations === undefined) return null; // Validations are not ready; assume valid + const validationRegex = this.validations[fieldName]; + if (validationRegex === undefined) return null; // No validation for the field; assume valid + + const matchResult = ( control.value).match(validationRegex); // Match validation against value + + if (matchResult !== null) { return null; } // Any matches; valid + else return { reason: `doesn't match regex '${validationRegex.source}'` }; // No matches; invalid + } + } + + formTitle = new FormControl('', + [Validators.required, this.validateOnServer('title')] + ); + formSummary = new FormControl('', + [Validators.required, this.validateOnServer('summary')] + ); + formContent = new FormControl('', + [Validators.required, this.validateOnServer('content')] + ); + formCategories = new FormArray([new FormControl('')]); + + form = new FormGroup({ + 'title': this.formTitle, + 'summary': this.formSummary, + 'content': this.formContent, + 'categories': this.formCategories + }); + + // noinspection JSMethodCanBeStatic + transformArticleData(input: object): object { + input['categories'] = input['categories'].map(cat => { return { name: cat }}); + return input } createNewArticle() { - this.articleService.createNewArticle(this.article).then(article => - console.log(article) + this.articleService.createNewArticle ( + (
this.transformArticleData(this.form.value)) + ).then(async uuid => { + this.result = { status: 'success', message: 'Article created successfully' }; + await this.router.navigateByUrl(`/article/${uuid}`) + }).catch(() => + this.result = { status: 'error', message: 'Error while creating article' } ); } addCategory() { - this.article.categories.push({name: ''}); + this.formCategories.push(new FormControl('')); } + } diff --git a/src/blogify/frontend/src/app/components/profile/profile/main/main-profile.component.ts b/src/blogify/frontend/src/app/components/profile/profile/main/main-profile.component.ts index 85700dd9..33e919da 100644 --- a/src/blogify/frontend/src/app/components/profile/profile/main/main-profile.component.ts +++ b/src/blogify/frontend/src/app/components/profile/profile/main/main-profile.component.ts @@ -33,13 +33,13 @@ export class MainProfileComponent implements OnInit { this.route.params.subscribe(async (params: Params) => { let username = params['username']; - if (this.authService.isLoggedIn()) { + this.authService.observeIsLoggedIn().subscribe(async value => { const loggedInUsername = (await this.authService.userProfile).username; if (username === loggedInUsername) { this.finalTabs = this.baseTabs.concat(this.loggedInTabs); } - } + }); this.user = await this.authService.getByUsername(username); }) diff --git a/src/blogify/frontend/src/app/components/profile/profile/settings/settings.component.html b/src/blogify/frontend/src/app/components/profile/profile/settings/settings.component.html index 7336d14c..1040bc81 100644 --- a/src/blogify/frontend/src/app/components/profile/profile/settings/settings.component.html +++ b/src/blogify/frontend/src/app/components/profile/profile/settings/settings.component.html @@ -2,4 +2,4 @@

Settings

- + diff --git a/src/blogify/frontend/src/app/components/show-article/show-article.component.ts b/src/blogify/frontend/src/app/components/show-article/show-article.component.ts index 78c7c8d4..35415fa3 100644 --- a/src/blogify/frontend/src/app/components/show-article/show-article.component.ts +++ b/src/blogify/frontend/src/app/components/show-article/show-article.component.ts @@ -1,10 +1,9 @@ import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import {ActivatedRoute, Router} from '@angular/router'; import { Article } from '../../models/Article'; import { ArticleService } from '../../services/article/article.service'; import { Subscription } from 'rxjs'; import { AuthService } from '../../shared/auth/auth.service'; -import { CommentsService } from '../../services/comments/comments.service'; import { User } from '../../models/User'; @Component({ @@ -21,7 +20,7 @@ export class ShowArticleComponent implements OnInit { private activatedRoute: ActivatedRoute, private articleService: ArticleService, public authService: AuthService, - private commentsService: CommentsService + private router: Router ) {} showUpdateButton = false; @@ -37,8 +36,8 @@ export class ShowArticleComponent implements OnInit { ['title', 'createdBy', 'content', 'summary', 'uuid', 'categories', 'createdAt'] ); - this.showUpdateButton = (await this.authService.userUUID) == this.article.createdBy.uuid; - this.showDeleteButton = (await this.authService.userUUID) == this.article.createdBy.uuid; + this.showUpdateButton = (await this.authService.userUUID) == ( this.article.createdBy).uuid; + this.showDeleteButton = (await this.authService.userUUID) == ( this.article.createdBy).uuid; console.log(this.article); }); @@ -47,6 +46,7 @@ export class ShowArticleComponent implements OnInit { deleteArticle() { this.articleService.deleteArticle(this.article.uuid).then(it => console.log(it)); + this.router.navigateByUrl("/home").then(() => {}) } } diff --git a/src/blogify/frontend/src/app/components/update-article/update-article.component.html b/src/blogify/frontend/src/app/components/update-article/update-article.component.html index 0fe3e2b8..7c318bc0 100644 --- a/src/blogify/frontend/src/app/components/update-article/update-article.component.html +++ b/src/blogify/frontend/src/app/components/update-article/update-article.component.html @@ -1,8 +1,37 @@ -
- Title: - Text: - Summary: - -

{{article | json}}

+ +
+ +
+

Update Article

+ + +
+ + + Title + + + + + Summary + + + + + Content + + + + + + + + + + + + + +
diff --git a/src/blogify/frontend/src/app/components/update-article/update-article.component.scss b/src/blogify/frontend/src/app/components/update-article/update-article.component.scss index e69de29b..bdf771e9 100644 --- a/src/blogify/frontend/src/app/components/update-article/update-article.component.scss +++ b/src/blogify/frontend/src/app/components/update-article/update-article.component.scss @@ -0,0 +1,66 @@ +@import "../../../styles/mixins"; +@import "../../../styles/queries"; + +#update-article { + + @include pageContainer; + + display: flex; + flex-direction: column; + justify-content: stretch; + align-items: flex-start; + + #header-row { + width: 100%; + + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + } + + .data-row { + width: 100%; + + &:not(:first-child) { + margin-top: 1em; + } + + & span:first-child { + width: 40%; + + font-size: 1.5em; + + @media (max-width: $query-desktop) { + font-size: 1.6em; + margin-bottom: .5em; + } + } + + & input:last-child, + & textarea:last-child { + flex-grow: 1; + } + + &#submit { + flex-direction: row; + justify-content: flex-end; + * { font-size: 1.35em; } + + button { + flex-grow: 0; + margin-left: .5em; + } + } + + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: stretch; + + @media (max-width: $query-desktop) { + flex-direction: column; + } + } + +} diff --git a/src/blogify/frontend/src/app/components/update-article/update-article.component.ts b/src/blogify/frontend/src/app/components/update-article/update-article.component.ts index 53c4ad3c..747afdca 100644 --- a/src/blogify/frontend/src/app/components/update-article/update-article.component.ts +++ b/src/blogify/frontend/src/app/components/update-article/update-article.component.ts @@ -3,6 +3,8 @@ import {ActivatedRoute, Router} from '@angular/router'; import { ArticleService } from '../../services/article/article.service'; import { Article } from '../../models/Article'; import { Subscription } from 'rxjs'; +import {User} from "../../models/User"; +import {AuthService} from "../../shared/auth/auth.service"; @Component({ selector: 'app-update-article', @@ -13,11 +15,13 @@ export class UpdateArticleComponent implements OnInit { routeMapSubscription: Subscription; article: Article; + user: User; constructor( private activatedRoute: ActivatedRoute, private articleService: ArticleService, private router: Router, + private authService: AuthService, ) { } ngOnInit() { @@ -32,6 +36,7 @@ export class UpdateArticleComponent implements OnInit { console.log(this.article); }); + this.authService.userProfile.then(it => { this.user = it }) } async updateArticle() { @@ -39,4 +44,8 @@ export class UpdateArticleComponent implements OnInit { await this.articleService.updateArticle(this.article); await this.router.navigateByUrl(`/article/${this.article.uuid}`); } + + addCategory() { + this.article.categories.push({name: ''}); + } } diff --git a/src/blogify/frontend/src/app/models/Article.ts b/src/blogify/frontend/src/app/models/Article.ts index 4f42cc4c..1c7d687f 100644 --- a/src/blogify/frontend/src/app/models/Article.ts +++ b/src/blogify/frontend/src/app/models/Article.ts @@ -1,15 +1,18 @@ import { User } from './User'; export class Article { - constructor( + + constructor ( public uuid: string, public title: string, public content: string, public summary: string, - public createdBy: User, + public createdBy: User | string, public createdAt: number, public categories: Category[], + public numberOfComments: number = 0, ) {} + } export interface Category { diff --git a/src/blogify/frontend/src/app/services/article/article.service.ts b/src/blogify/frontend/src/app/services/article/article.service.ts index 2baf4976..40aa2518 100644 --- a/src/blogify/frontend/src/app/services/article/article.service.ts +++ b/src/blogify/frontend/src/app/services/article/article.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { HttpClient, HttpHeaders} from '@angular/common/http'; import { Article } from '../../models/Article'; import { AuthService } from '../../shared/auth/auth.service'; import * as uuid from 'uuid/v4'; @@ -9,28 +9,37 @@ import * as uuid from 'uuid/v4'; }) export class ArticleService { - constructor(private httpClient: HttpClient, private authService: AuthService) { + constructor(private httpClient: HttpClient, private authService: AuthService) {} + + private async fetchUserObjects(articles: Article[]): Promise { + const userUUIDs = new Set([...articles + .filter (it => typeof it.createdBy === 'string') + .map (it => it.createdBy)]); // Converting to a Set makes sure a single UUID is not fetched more than once + const userObjects = await Promise.all ( + [...userUUIDs].map(it => this.authService.fetchUser(it)) + ); + return articles.map(a => { + a.createdBy = userObjects + .find(u => u.uuid === a.createdBy); + return a + }); + } + + private async fetchCommentCount(articles: Article[]): Promise { + return Promise.all(articles.map(async a => { + a.numberOfComments = await this.httpClient.get(`/api/articles/${a.uuid}/commentCount`).toPromise(); + return a + })); + } + + private async prepareArticleData(articles: Article[]): Promise { + return this.fetchUserObjects(articles).then(articles2 => this.fetchCommentCount(articles2)) } async getAllArticles(fields: string[] = [], amount: number = 25): Promise { const articlesObs = this.httpClient.get(`/api/articles/?fields=${fields.join(',')}&amount=${amount}`); const articles = await articlesObs.toPromise(); - return this.uuidToObjectInArticle(articles) - } - - private async uuidToObjectInArticle(articles: Article[]) { - const userUUIDs = new Set(); - articles.forEach(it => { - userUUIDs.add(it.createdBy.toString()); - }); - const users = await Promise.all(([...userUUIDs]).map(it => this.authService.fetchUser(it.toString()))); - const out: Article[] = []; - articles.forEach((article) => { - const copy = article; - copy.createdBy = users.find((user) => user.uuid === article.createdBy.toString()); - out.push(copy); - }); - return out; + return this.prepareArticleData(articles) } async getArticleByUUID(uuid: string, fields: string[] = []): Promise
{ @@ -44,10 +53,10 @@ export class ArticleService { async getArticleByForUser(username: string, fields: string[] = []): Promise { const articles = await this.httpClient.get(`/api/articles/forUser/${username}?fields=${fields.join(',')}`).toPromise(); - return this.uuidToObjectInArticle(articles); + return this.fetchUserObjects(articles); } - async createNewArticle(article: Article, userToken: string = this.authService.userToken): Promise { + async createNewArticle(article: Article, userToken: string = this.authService.userToken): Promise { const httpOptions = { headers: new HttpHeaders({ @@ -67,7 +76,7 @@ export class ArticleService { createdBy: await this.authService.userUUID, }; - return this.httpClient.post(`/api/articles/`, newArticle, httpOptions).toPromise(); + return this.httpClient.post(`/api/articles/`, newArticle, httpOptions).toPromise(); } updateArticle(article: Article, uuid: string = article.uuid, userToken: string = this.authService.userToken) { @@ -84,7 +93,7 @@ export class ArticleService { title: article.title, summary: article.summary, categories: article.categories, - createdBy: article.createdBy.uuid, + createdBy: (typeof article.createdBy === 'string') ? article.createdBy : article.createdBy.uuid, }; return this.httpClient.patch
(`/api/articles/${uuid}`, newArticle, httpOptions).toPromise(); @@ -101,4 +110,17 @@ export class ArticleService { return this.httpClient.delete(`/api/articles/${uuid}`, httpOptions).toPromise(); } + search(query: string, fields: string[]) { + const url = `/api/articles/search/?q=${query}&fields=${fields.join(',')}`; + return this.httpClient.get(url) + .toPromise() + .then(hits => { + if (hits != null) { + return this.prepareArticleData(hits); + } else { + return Promise.all([]); + } + }); // Make sure user data is present + } + } diff --git a/src/blogify/frontend/src/app/shared/auth/auth.service.ts b/src/blogify/frontend/src/app/shared/auth/auth.service.ts index 7d474107..1ef58b94 100644 --- a/src/blogify/frontend/src/app/shared/auth/auth.service.ts +++ b/src/blogify/frontend/src/app/shared/auth/auth.service.ts @@ -14,24 +14,45 @@ export class AuthService { private currentUserUuid_ = new BehaviorSubject(''); private currentUser_ = new BehaviorSubject(this.dummyUser); + private loginObservable_ = new BehaviorSubject(false); - constructor(private httpClient: HttpClient, private staticContentService: StaticContentService) { - if (localStorage.getItem('userToken') !== null) { - this.login(localStorage.getItem('userToken')) + constructor ( + private httpClient: HttpClient, + private staticContentService: StaticContentService, + ) { + this.attemptRestoreLogin() + } + + private attemptRestoreLogin() { + const token = AuthService.attemptFindLocalToken(); + if (token == null) { + console.info('[blogifyAuth] No stored token'); + } else { + this.login(token).then ( + () => { + console.info('[blogifyAuth] Logged in with stored token') + }, () => { + console.error('[blogifyAuth] Error while attempting stored token, not logging in and clearing token.'); + localStorage.removeItem('userToken'); + }); } } + private static attemptFindLocalToken(): string | null { + return localStorage.getItem('userToken'); + } + async login(creds: LoginCredentials | string): Promise { let token: Observable; let it: UserToken; - if (typeof creds !== 'string') { + if (typeof creds !== 'string') { // user / password token = this.httpClient.post('/api/auth/signin', creds, { responseType: 'json' }); it = await token.toPromise(); localStorage.setItem('userToken', it.token); - } else { + } else { // token it = { token: creds } } @@ -43,16 +64,22 @@ export class AuthService { this.currentUser_.next(fetchedUser); this.currentUserUuid_.next(fetchedUser.uuid); + this.loginObservable_.next(true); return it } + logout() { + localStorage.removeItem("userToken"); + this.loginObservable_.next(false); + } + async register(credentials: RegisterCredentials): Promise { return this.httpClient.post('/api/auth/signup', credentials).toPromise(); } - isLoggedIn(): boolean { - return this.userToken !== null; + observeIsLoggedIn(): Observable { + return this.loginObservable_; } private async getUserUUIDFromToken(token: string): Promise { @@ -65,6 +92,7 @@ export class AuthService { return this.httpClient.get(`/api/users/${uuid}`).toPromise() } + // noinspection JSMethodCanBeStatic get userToken(): string | null { return localStorage.getItem('userToken'); } @@ -86,10 +114,6 @@ export class AuthService { return this.getUser() } - logout() { - localStorage.removeItem("userToken") - } - private async getUser(): Promise { if (this.currentUser_.getValue().uuid != '') { return this.currentUser_.getValue() @@ -106,6 +130,11 @@ export class AuthService { return this.staticContentService.uploadFile(file, userToken, `/api/users/profilePicture/${userUUID}/?target=profilePicture`) } + search(query: string, fields: string[]) { + const url = `/api/articles/search/?q=${query}&fields=${fields.join(',')}`; + return this.httpClient.get(url).toPromise() + } + } interface UserToken { diff --git a/src/blogify/frontend/src/app/shared/components/show-all-articles/filtering-menu/filtering-menu.component.html b/src/blogify/frontend/src/app/shared/components/show-all-articles/filtering-menu/filtering-menu.component.html new file mode 100644 index 00000000..485dbe9d --- /dev/null +++ b/src/blogify/frontend/src/app/shared/components/show-all-articles/filtering-menu/filtering-menu.component.html @@ -0,0 +1 @@ +

filtering-menu works!

diff --git a/src/blogify/frontend/src/app/shared/components/show-all-articles/filtering-menu/filtering-menu.component.scss b/src/blogify/frontend/src/app/shared/components/show-all-articles/filtering-menu/filtering-menu.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/blogify/frontend/src/app/shared/components/show-all-articles/filtering-menu/filtering-menu.component.spec.ts b/src/blogify/frontend/src/app/shared/components/show-all-articles/filtering-menu/filtering-menu.component.spec.ts new file mode 100644 index 00000000..a723ad3c --- /dev/null +++ b/src/blogify/frontend/src/app/shared/components/show-all-articles/filtering-menu/filtering-menu.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FilteringMenuComponent } from './filtering-menu.component'; + +describe('FilteringMenuComponent', () => { + let component: FilteringMenuComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ FilteringMenuComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(FilteringMenuComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/blogify/frontend/src/app/shared/components/show-all-articles/filtering-menu/filtering-menu.component.ts b/src/blogify/frontend/src/app/shared/components/show-all-articles/filtering-menu/filtering-menu.component.ts new file mode 100644 index 00000000..1d4785de --- /dev/null +++ b/src/blogify/frontend/src/app/shared/components/show-all-articles/filtering-menu/filtering-menu.component.ts @@ -0,0 +1,15 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-filtering-menu', + templateUrl: './filtering-menu.component.html', + styleUrls: ['./filtering-menu.component.scss'] +}) +export class FilteringMenuComponent implements OnInit { + + constructor() { } + + ngOnInit() { + } + +} diff --git a/src/blogify/frontend/src/app/shared/components/show-all-articles/show-all-articles.component.html b/src/blogify/frontend/src/app/shared/components/show-all-articles/show-all-articles.component.html index 8e7ab378..c361482f 100644 --- a/src/blogify/frontend/src/app/shared/components/show-all-articles/show-all-articles.component.html +++ b/src/blogify/frontend/src/app/shared/components/show-all-articles/show-all-articles.component.html @@ -1,63 +1,55 @@
+ + + + + -

{{title}}

+

{{showingSearchResults ? 'Search results' : title}}

+ + + + - + - + - + - +
-
- -
- -

{{article.title}}

- - - -
- -
- - {{article.summary}} + - - {{article.createdAt | relativeTime}} -
- -

- Read More -

- -
- - -

4

- - - - - - - No tags - -
+
+ +
+
+
+ + No search results :(
+ +
diff --git a/src/blogify/frontend/src/app/shared/components/show-all-articles/show-all-articles.component.scss b/src/blogify/frontend/src/app/shared/components/show-all-articles/show-all-articles.component.scss index b1a9ac7d..0a999bf6 100644 --- a/src/blogify/frontend/src/app/shared/components/show-all-articles/show-all-articles.component.scss +++ b/src/blogify/frontend/src/app/shared/components/show-all-articles/show-all-articles.component.scss @@ -16,14 +16,20 @@ $search-icon-break: 1300px; + #header-search-back { + margin-right: 1.15em; + } + #header-title { margin-right: 1em; } - #header-search-pad { + #header-search-pad, #header-mobile-search-pad { flex-grow: 1; - @media (min-width: 0) and (max-width: $search-icon-break) { - display: none; + &#header-search-pad { + @media (min-width: 0) and (max-width: $search-icon-break) { + display: none; + } } } @@ -42,114 +48,31 @@ } } - .article { + #articles-main { + + } + #search-results { display: flex; flex-direction: column; - justify-content: center; - align-items: flex-start; - - color: var(--card-fg); - background: var(--card-bg); - - border-radius: $std-border-radius; - border: none; - - $box-shadow: 0 0 6px 1px rgba(0, 0, 0, 0.20); - -webkit-box-shadow: $box-shadow; - -moz-box-shadow: $box-shadow; - box-shadow: $box-shadow;; - - padding: 1.2em 1.5em; - - margin-top: 1.25em; - - .article-first-line { - width: 100%; + justify-content: flex-end; + align-items: center; - text-align: center; + > * { width: 100%; } + #results-empty { display: flex; - flex-direction: row; - justify-content: space-between; + flex-direction: column; + justify-content: flex-start; align-items: center; - .header-title { - text-align: left; - } - - .article-author { - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: center; - - .author-pfp { - margin-right: .85em; - } - - .author-name { font-size: 1.7em; font-weight: 600; } + margin-top: 5em; + #empty-text { + font-size: 1.65em; + font-weight: 600; } - } - - .article-second-line { - width: 100%; - - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - - .article-summary { font-size: 1.25em; } - .article-posted-at { font-size: 1.30em; } - } - - .article-read-more { color: var(--card-fg); } - - .article-last-line { - width: 100%; - - text-align: center; - - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - } - - .article-comments-count { - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - - fa-icon:first-child { margin-right: .75em; } - - align-self: flex-end; - } - - .article-no-tags, - .article-tags { - display: flex; - align-self: flex-end; - - margin-top: 1.2em; - - & > * { - font-size: 1.35em; - margin: 0 .25em; - padding: .15em .65em; - - border-radius: .3em; - - &:not(:nth-child(1)) { background-color: var(--card-ct); } - &:nth-child(1) { margin-right: 0; padding-right: 0; } - &:last-child { margin-right: 0 } - } - } - } } diff --git a/src/blogify/frontend/src/app/shared/components/show-all-articles/show-all-articles.component.ts b/src/blogify/frontend/src/app/shared/components/show-all-articles/show-all-articles.component.ts index 21cda63c..53db9fd5 100644 --- a/src/blogify/frontend/src/app/shared/components/show-all-articles/show-all-articles.component.ts +++ b/src/blogify/frontend/src/app/shared/components/show-all-articles/show-all-articles.component.ts @@ -1,9 +1,10 @@ import { Component, Input, OnInit } from '@angular/core'; import { Article } from '../../../models/Article'; import { AuthService } from '../../auth/auth.service'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router, UrlSegment } from '@angular/router'; import { StaticContentService } from '../../../services/static/static-content.service'; -import { faCommentAlt, faPencilAlt, faSearch } from '@fortawesome/free-solid-svg-icons'; +import { faArrowLeft, faPencilAlt, faSearch, faTimes} from '@fortawesome/free-solid-svg-icons'; +import { ArticleService } from '../../../services/article/article.service'; @Component({ selector: 'app-show-all-articles', @@ -14,28 +15,78 @@ export class ShowAllArticlesComponent implements OnInit { faSearch = faSearch; faPencil = faPencilAlt; - faCommentAlt = faCommentAlt; + faArrowLeft = faArrowLeft; + + faTimes = faTimes; @Input() title = 'Articles'; @Input() articles: Article[]; @Input() allowCreate = true; + forceNoAllowCreate = false; + + showingSearchResults = false; + searchQuery: string; + searchResults: Article[]; + showingMobileSearchBar: boolean; + constructor ( private authService: AuthService, + private articleService: ArticleService, private staticContentService: StaticContentService, + private activatedRoute: ActivatedRoute, private router: Router ) {} - ngOnInit() {} + ngOnInit() { + this.activatedRoute.url.subscribe((it: UrlSegment[]) => { + const isSearching = it[it.length - 1].parameters['search'] != undefined; + if (isSearching) { // We are in a search page + const query = it[it.length - 1].parameters['search']; + const actualQuery = query.match(/"\w+"/) != null ? query.substring(1, query.length - 1): null; + if (actualQuery != null) { + this.searchQuery = actualQuery; + this.startSearch(); + } + } else { // We are in a regular listing + this.stopSearch(); + } + }) + } + + async navigateToSearch() { + await this.router.navigate([{ search: `"${this.searchQuery}"` }], { relativeTo: this.activatedRoute }) + } + + private async startSearch() { + this.articleService.search ( + this.searchQuery, + ['title', 'summary', 'createdBy', 'categories', 'createdAt'] + ).then(it => { + this.searchResults = it; + this.showingSearchResults = true; + this.forceNoAllowCreate = true; + }).catch((err: Error) => { + console.error(`[blogifySearch] Error while search: ${err.name}: ${err.message}`) + }); + } + + async stopSearch() { + this.showingSearchResults = false; + this.forceNoAllowCreate = false; + this.searchQuery = undefined; + this.showingMobileSearchBar = false + } async navigateToNewArticle() { - if (this.authService.userToken === '') { - const url = `/login?redirect=/new-article`; - console.log(url); - await this.router.navigateByUrl(url); - } else { - await this.router.navigateByUrl('/new-article'); - } + this.authService.observeIsLoggedIn().subscribe(it => { + if (it) this.router.navigateByUrl('/article/new'); + else this.router.navigateByUrl('/login?redirect=/article/new') + }); + } + + setShowSearchBar(val: boolean) { + this.showingMobileSearchBar = val } } diff --git a/src/blogify/frontend/src/app/shared/components/show-all-articles/single-article-box/single-article-box.component.html b/src/blogify/frontend/src/app/shared/components/show-all-articles/single-article-box/single-article-box.component.html new file mode 100644 index 00000000..91ba4f19 --- /dev/null +++ b/src/blogify/frontend/src/app/shared/components/show-all-articles/single-article-box/single-article-box.component.html @@ -0,0 +1,39 @@ +
+ +
+ +

{{article.title}}

+ + + +
+ +
+ + {{article.summary}} + + + {{article.createdAt | relativeTime}} +
+ +

+ Read More +

+ +
+ +

{{article.numberOfComments}}

+ + + + + + + No tags + +
+ +
diff --git a/src/blogify/frontend/src/app/shared/components/show-all-articles/single-article-box/single-article-box.component.scss b/src/blogify/frontend/src/app/shared/components/show-all-articles/single-article-box/single-article-box.component.scss new file mode 100644 index 00000000..2c3df211 --- /dev/null +++ b/src/blogify/frontend/src/app/shared/components/show-all-articles/single-article-box/single-article-box.component.scss @@ -0,0 +1,111 @@ +@import "../../../../../styles/layouts"; + +.article { + + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + + color: var(--card-fg); + background: var(--card-bg); + + border: none; + border-radius: $std-border-radius; + + $box-shadow: 0 0 6px 1px rgba(0, 0, 0, 0.20); + -webkit-box-shadow: $box-shadow; + -moz-box-shadow: $box-shadow; + box-shadow: $box-shadow;; + + padding: 1.2em 1.5em; + + margin-top: 1.25em; + + .article-first-line { + width: 100%; + + text-align: center; + + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + .header-title { + text-align: left; + } + + .article-author { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + + .author-pfp { + margin-right: .85em; + } + + .author-name { font-size: 1.7em; font-weight: 600; } + + } + + } + + .article-second-line { + width: 100%; + + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + .article-summary { font-size: 1.25em; } + .article-posted-at { font-size: 1.30em; } + } + + .article-read-more { color: var(--card-fg); } + + .article-last-line { + width: 100%; + + text-align: center; + + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + } + + .article-comments-count { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + fa-icon:first-child { margin-right: .75em; } + + align-self: flex-end; + } + + .article-no-tags, + .article-tags { + display: flex; + align-self: flex-end; + + margin-top: 1.2em; + + & > * { + font-size: 1.35em; + margin: 0 .25em; + padding: .15em .65em; + + border-radius: .3em; + + &:not(:nth-child(1)) { background-color: var(--card-ct); } + &:nth-child(1) { margin-right: 0; padding-right: 0; } + &:last-child { margin-right: 0 } + } + } + +} diff --git a/src/blogify/frontend/src/app/shared/components/show-all-articles/single-article-box/single-article-box.component.spec.ts b/src/blogify/frontend/src/app/shared/components/show-all-articles/single-article-box/single-article-box.component.spec.ts new file mode 100644 index 00000000..0a3a6ebc --- /dev/null +++ b/src/blogify/frontend/src/app/shared/components/show-all-articles/single-article-box/single-article-box.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SingleArticleBoxComponent } from './single-article-box.component'; + +describe('SingleArticleBoxComponent', () => { + let component: SingleArticleBoxComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ SingleArticleBoxComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SingleArticleBoxComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/blogify/frontend/src/app/shared/components/show-all-articles/single-article-box/single-article-box.component.ts b/src/blogify/frontend/src/app/shared/components/show-all-articles/single-article-box/single-article-box.component.ts new file mode 100644 index 00000000..96b917ab --- /dev/null +++ b/src/blogify/frontend/src/app/shared/components/show-all-articles/single-article-box/single-article-box.component.ts @@ -0,0 +1,20 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Article } from "../../../../models/Article"; +import { ArticleService } from '../../../../services/article/article.service'; +import { faCommentAlt } from '@fortawesome/free-solid-svg-icons'; + +@Component({ + selector: 'app-single-article-box', + templateUrl: './single-article-box.component.html', + styleUrls: ['./single-article-box.component.scss'] +}) +export class SingleArticleBoxComponent implements OnInit { + + @Input() article: Article; + faCommentAlt = faCommentAlt; + + constructor() {} + + ngOnInit() {} + +} diff --git a/src/blogify/frontend/src/app/shared/components/user-display/user-display.component.html b/src/blogify/frontend/src/app/shared/components/user-display/user-display.component.html index b41dcbaa..7e2a7ffe 100644 --- a/src/blogify/frontend/src/app/shared/components/user-display/user-display.component.html +++ b/src/blogify/frontend/src/app/shared/components/user-display/user-display.component.html @@ -1,6 +1,6 @@ + [routerLink]="['/profile/' + user.username]"> {{infoText}} diff --git a/src/blogify/frontend/src/app/shared/components/user-display/user-display.component.ts b/src/blogify/frontend/src/app/shared/components/user-display/user-display.component.ts index f1099df1..5ed4e548 100644 --- a/src/blogify/frontend/src/app/shared/components/user-display/user-display.component.ts +++ b/src/blogify/frontend/src/app/shared/components/user-display/user-display.component.ts @@ -8,7 +8,7 @@ import { User } from '../../../models/User'; }) export class UserDisplayComponent implements OnInit { - readonly EM_SIZE_TEXT_RATIO = 2.6; + readonly EM_SIZE_TEXT_RATIO = 2.4; @Input() user: User; @Input() info: 'username' | 'name' = 'username'; diff --git a/src/blogify/frontend/src/app/shared/shared.module.ts b/src/blogify/frontend/src/app/shared/shared.module.ts index 66a68502..81be437c 100644 --- a/src/blogify/frontend/src/app/shared/shared.module.ts +++ b/src/blogify/frontend/src/app/shared/shared.module.ts @@ -9,6 +9,9 @@ import { RelativeTimePipe } from './relative-time/relative-time.pipe'; import { UserDisplayComponent } from './components/user-display/user-display.component'; import { DarkThemeDirective } from './directives/dark-theme/dark-theme.directive'; import { CompactDirective } from './directives/compact/compact.directive'; +import {FormsModule} from "@angular/forms"; +import { SingleArticleBoxComponent } from './components/show-all-articles/single-article-box/single-article-box.component'; +import { FilteringMenuComponent } from './components/show-all-articles/filtering-menu/filtering-menu.component'; @NgModule({ declarations: [ @@ -18,12 +21,15 @@ import { CompactDirective } from './directives/compact/compact.directive'; TabHeaderComponent, ProfilePictureComponent, ShowAllArticlesComponent, - UserDisplayComponent + UserDisplayComponent, + SingleArticleBoxComponent, + FilteringMenuComponent, ], imports: [ CommonModule, ProfileRoutingModule, - FontAwesomeModule + FontAwesomeModule, + FormsModule, ], exports: [ RelativeTimePipe, diff --git a/src/blogify/frontend/src/styles/forms.scss b/src/blogify/frontend/src/styles/forms.scss index 99268a8f..b95d0883 100644 --- a/src/blogify/frontend/src/styles/forms.scss +++ b/src/blogify/frontend/src/styles/forms.scss @@ -8,10 +8,16 @@ input, button, textarea, select { input, textarea { color: var(--card-fg); - padding: .75em 1em; + padding: .65em .9em; border-radius: $std-border-radius; border: 1px solid var(--border-color); + + font-size: 1.15em; + + &.ng-invalid.ng-touched { + border-color: var(--accent-negative); + } } input[type="checkbox"]:not(.togglebox) { @@ -112,6 +118,12 @@ button { font-size: 1.25em; font-weight: 600; + // Disabled + + &[disabled] { + opacity: .6; + } + // Color classes &.neutral { background: var(--accent-neutral); }