diff --git a/.gitignore b/.gitignore index 2b8de330..e98bc989 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,9 @@ /.gradle -/gradle/wrapper/gradle-wrapper.jar /.idea /out /build *.iml *.ipr *.iws -/gradlew.bat -/gradlew -/resources/frontend/ \ No newline at end of file +/resources/frontend/ +/config/ diff --git a/Dockerfile b/Dockerfile index 6b181595..8bcc4592 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /var/server/ ADD build/dist/jar/blogify-0.1.0-all.jar . -EXPOSE 8080 -EXPOSE 5005 +EXPOSE 80 +EXPOSE 443 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/README.md b/README.md index a1ef3ed4..77ccb556 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ Currently, our own (very basic) testing shows that the server can handle upwards ## Building and deploying -The default deploy configuration runs the backend and the database as `docker-compose` services. A functioning, local, test deployment can be achieved by running the `localTestDeploy` gradle task. +The default deploy configuration runs the backend, the database and typesense search engine as `docker-compose` services. A functioning, local, test deployment can be achieved by running the `blogifyDeploy` gradle task. +You need to provide the configuration for database and typesense using `db.yaml` and `ts.yaml` files respectively placed in the root of the project. `*.yaml.example` files have been provided for your guidance. ## Core team diff --git a/build.gradle.kts b/build.gradle.kts index fd794301..1112f560 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,7 @@ @file:Suppress("SpellCheckingInspection", "PropertyName") +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + val ktor_version: String by project val kotlin_version: String by project val logback_version: String by project @@ -11,6 +13,7 @@ val spring_security_core_version: String by project plugins { application kotlin("jvm") version "1.3.41" + id("org.jetbrains.kotlin.plugin.serialization") version "1.3.60" id("com.github.johnrengelman.shadow") version "5.1.0" id("com.avast.gradle.docker-compose") version "0.9.4" @@ -20,7 +23,14 @@ group = "blogify" version = "0.1.0" application { - mainClassName = "io.ktor.server.netty.EngineMain" + mainClassName = "io.ktor.server.tomcat.EngineMain" +} + +buildscript { + dependencies { + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.61") + classpath("org.jetbrains.kotlin:kotlin-serialization:1.3.61") + } } repositories { @@ -38,9 +48,10 @@ dependencies { // Ktor - compile("io.ktor:ktor-server-netty:$ktor_version") + compile("io.ktor:ktor-server-tomcat:$ktor_version") compile("ch.qos.logback:logback-classic:$logback_version") compile("io.ktor:ktor-server-core:$ktor_version") + compile("io.ktor:ktor-network-tls:$ktor_version") compile("io.ktor:ktor-locations:$ktor_version") compile("io.ktor:ktor-auth:$ktor_version") compile("io.ktor:ktor-auth-jwt:$ktor_version") @@ -56,7 +67,8 @@ dependencies { // Database stuff compile("org.postgresql:postgresql:$pg_driver_version") - compile("org.jetbrains.exposed:exposed:$exposed_version") + compile("org.jetbrains.exposed:exposed-core:$exposed_version") + compile("org.jetbrains.exposed:exposed-jdbc:$exposed_version") compile("com.zaxxer:HikariCP:$hikari_version") // Spring security for hashing @@ -82,6 +94,15 @@ dependencies { runtime("io.jsonwebtoken:jjwt-impl:0.10.7") runtime("io.jsonwebtoken:jjwt-jackson:0.10.7") + // Config + + compile("org.jetbrains.kotlinx:kotlinx-serialization-runtime:0.14.0") + compile("org.jetbrains.kotlinx:kotlinx-serialization-runtime-configparser:0.14.0") + + // Testing + + implementation("org.junit.jupiter:junit-jupiter:5.5.2") + } kotlin.sourceSets["main"].kotlin.srcDirs("src") @@ -90,6 +111,18 @@ kotlin.sourceSets["test"].kotlin.srcDirs("test") sourceSets["main"].resources.srcDirs("resources") sourceSets["test"].resources.srcDirs("testresources") + +tasks.test { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + } +} + +tasks.withType().configureEach { + kotlinOptions.jvmTarget = "1.8" +} + // Fat jar tasks.withType { @@ -109,7 +142,7 @@ dockerCompose { projectName = "blogify" - waitForTcpPorts = true + waitForTcpPorts = false stopContainers = true } diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 79a2c757..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,31 +0,0 @@ -version: '2' -services: - web: - build: . - ports: - - '8080:8080' - - '5005:5005' - depends_on: - - db - - ts - volumes: - - 'static-data:/var/static/' - db: - image: 'postgres:11.5' - ports: - - '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/gradle.properties b/gradle.properties index 9fa7bfd9..f5007de0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,8 +1,8 @@ -ktor_version = 1.2.3 +ktor_version = 1.2.6 kotlin.code.style = official -kotlin_version = 1.3.50 +kotlin_version = 1.3.61 logback_version = 1.2.1 -pg_driver_version = 42.2.6 -exposed_version = 0.17.1 -hikari_version = 3.3.1 +pg_driver_version = 42.2.9 +exposed_version = 0.20.1 +hikari_version = 3.4.1 spring_security_core_version = 5.1.6.RELEASE \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..87b738cb Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..af6708ff --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..6d57edc7 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/resources/application.conf b/resources/application.conf index e90ecf86..d02a2ef6 100644 --- a/resources/application.conf +++ b/resources/application.conf @@ -1,10 +1,26 @@ ktor { + deployment { host = "0.0.0.0" - port = 8080 + + port = 80 port = ${?PORT} + + sslPort = 443 + sslPort = ${?PORT_SSL} } + application { modules = [blogify.backend.ApplicationKt.mainModule] } + + security { + ssl { + keyStore = ${BLOGIFY_SSL_DIRECTORY}/certificate.jks + keyAlias = ${BLOGIFY_SSL_KEY_ALIAS} + keyStorePassword = ${BLOGIFY_SSL_KEY_STORE_PASSWORD} + privateKeyPassword = ${BLOGIFY_SSL_KEY_PASSWORD} + } + } + } \ No newline at end of file diff --git a/resources/reference.conf b/resources/reference.conf new file mode 100644 index 00000000..f9af232b --- /dev/null +++ b/resources/reference.conf @@ -0,0 +1,3 @@ +# Provide auto-complete to IntelliJ + +include "reference/blogify_reference.conf" diff --git a/resources/reference/blogify_reference.conf b/resources/reference/blogify_reference.conf new file mode 100644 index 00000000..a2621fd2 --- /dev/null +++ b/resources/reference/blogify_reference.conf @@ -0,0 +1,23 @@ +# Provides configuration for databse access +db { + # Sets the hostname of the database + host = "" + # Sets the port number of the database + port = 0 + # Sets the username to use to connect to the database + username = "" + # Sets the password to use to connect to the database + password = "" + # Sets the database name to use + databaseName = "" +} + +# Provides configuration for the Typesense service +ts { + # Sets the hostname of the Typesense server + host = "" + # Sets the port number of the Typesense server + port = 0 + # Sets the API key to use to connect the Typesense server + apiKey = "" +} diff --git a/src/blogify/backend/Application.kt b/src/blogify/backend/Application.kt index 09543730..b8680703 100644 --- a/src/blogify/backend/Application.kt +++ b/src/blogify/backend/Application.kt @@ -6,19 +6,26 @@ import com.andreapivetta.kolor.cyan import com.fasterxml.jackson.databind.* -import blogify.backend.routes.articles.articles -import blogify.backend.routes.users.users +import blogify.backend.routing.articles +import blogify.backend.routing.users.users import blogify.backend.database.Database import blogify.backend.database.Articles import blogify.backend.database.Comments import blogify.backend.database.Uploadables import blogify.backend.database.Users -import blogify.backend.routes.auth +import blogify.backend.routing.auth import blogify.backend.database.handling.query +import blogify.backend.resources.Article +import blogify.backend.resources.User import blogify.backend.resources.models.Resource -import blogify.backend.routes.static +import blogify.backend.routing.admin.adminSearch +import blogify.backend.routing.static import blogify.backend.search.Typesense +import blogify.backend.search.ext._searchTemplate +import blogify.backend.search.models.Template +import blogify.backend.util.ContentTypeSerializer import blogify.backend.util.SinglePageApplication +import blogify.backend.util.matches import io.ktor.application.call import io.ktor.features.Compression @@ -31,6 +38,7 @@ import io.ktor.features.CachingHeaders import io.ktor.features.CallLogging import io.ktor.features.ContentNegotiation import io.ktor.features.DefaultHeaders +import io.ktor.features.HttpsRedirect import io.ktor.http.CacheControl import io.ktor.http.ContentType import io.ktor.http.content.CachingOptions @@ -44,8 +52,9 @@ import kotlinx.coroutines.runBlocking import org.slf4j.event.Level -const val version = "0.1.0" +const val version = "0.2.0" +@Suppress("GrazieInspection") const val asciiLogo = """ __ __ _ ____ / /_ / /____ ____ _ (_)/ __/__ __ @@ -56,10 +65,6 @@ const val asciiLogo = """ ---- Version $version - Development build - """ -fun main(args: Array) { - io.ktor.server.netty.EngineMain.main(args) -} - @Suppress("unused") // Referenced in application.conf @kotlin.jvm.JvmOverloads fun Application.mainModule(@Suppress("UNUSED_PARAMETER") testing: Boolean = false) { @@ -74,17 +79,28 @@ fun Application.mainModule(@Suppress("UNUSED_PARAMETER") testing: Boolean = fals jackson { enable(SerializationFeature.INDENT_OUTPUT) - // Register a serializer for Resource. + // Register a serializer for Resource and Type. // This will only affect pure Resource objects, so elements produced by the slicer are not affected, // since those don't use Jackson for root serialization. - val resourceModule = SimpleModule() - resourceModule.addSerializer(Resource.ResourceIdSerializer) + val blogifyModule = SimpleModule() + blogifyModule.addSerializer(Resource.ResourceIdSerializer) + blogifyModule.addSerializer(Template.Field.Serializer) + blogifyModule.addSerializer(ContentTypeSerializer) - registerModule(resourceModule) + registerModule(blogifyModule) } } + // Initialize HTTPS refirection + + install(HttpsRedirect) { + // The port to redirect to. By default 443, the default HTTPS port. + sslPort = 443 + // 301 Moved Permanently, or 302 Found redirect. + permanentRedirect = true + } + // Initialize call logging install(CallLogging) { @@ -106,18 +122,22 @@ fun Application.mainModule(@Suppress("UNUSED_PARAMETER") testing: Boolean = fals // Default headers install(DefaultHeaders) { - header("Server", "blogify-core $version") - header("X-Powered-By", "Ktor 1.2.3") + header("X-Blogify-Version", "blogify-core $version") + header("X-Blogify-Backend", "Ktor 1.2.6") } // Caching headers install(CachingHeaders) { options { - when (it.contentType?.withoutParameters()) { - ContentType.Text.JavaScript -> + val contentType = it.contentType?.withoutParameters() ?: return@options null + + when { + contentType matches ContentType.Application.JavaScript -> CachingOptions(CacheControl.MaxAge(30 * 60)) - ContentType.Application.Json -> + contentType matches ContentType.Image.Any -> + CachingOptions(CacheControl.MaxAge(60 * 60)) + contentType matches ContentType.Application.Json -> CachingOptions(CacheControl.MaxAge(60)) else -> null } @@ -130,75 +150,25 @@ fun Application.mainModule(@Suppress("UNUSED_PARAMETER") testing: Boolean = fals // Create tables if they don't exist - runBlocking { query { - SchemaUtils.create ( - Articles, - Articles.Categories, - 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) - + runBlocking { + query { + SchemaUtils.createMissingTablesAndColumns ( + Articles, + Articles.Categories, + Users, + Users.Follows, + Articles.Likes, + Comments, + Uploadables + ) } - }} + + // Submit search templates + + Typesense.submitResourceTemplate(Article::class._searchTemplate) + Typesense.submitResourceTemplate(User::class._searchTemplate) + + } // Initialize routes @@ -209,6 +179,7 @@ fun Application.mainModule(@Suppress("UNUSED_PARAMETER") testing: Boolean = fals users() auth() static() + adminSearch() } get("/") { diff --git a/src/blogify/backend/annotations/BlogifyDsl.kt b/src/blogify/backend/annotations/BlogifyDsl.kt index 8c7b3479..08fd194f 100644 --- a/src/blogify/backend/annotations/BlogifyDsl.kt +++ b/src/blogify/backend/annotations/BlogifyDsl.kt @@ -2,3 +2,6 @@ package blogify.backend.annotations @DslMarker annotation class BlogifyDsl + +@DslMarker +annotation class PipelinesDsl diff --git a/src/blogify/backend/annotations/noslice.kt b/src/blogify/backend/annotations/Invisible.kt similarity index 93% rename from src/blogify/backend/annotations/noslice.kt rename to src/blogify/backend/annotations/Invisible.kt index dcde9a7c..4edc4078 100644 --- a/src/blogify/backend/annotations/noslice.kt +++ b/src/blogify/backend/annotations/Invisible.kt @@ -9,4 +9,4 @@ package blogify.backend.annotations @Target(AnnotationTarget.PROPERTY) @Retention(AnnotationRetention.RUNTIME) @MustBeDocumented -annotation class noslice +annotation class Invisible diff --git a/src/blogify/backend/annotations/maxByteSize.kt b/src/blogify/backend/annotations/maxByteSize.kt new file mode 100644 index 00000000..bccce012 --- /dev/null +++ b/src/blogify/backend/annotations/maxByteSize.kt @@ -0,0 +1,15 @@ +package blogify.backend.annotations + +/** + * Marks a [blogify.backend.resources.static.models.StaticResourceHandle] type as only accepting a + * file of a certain size. Is queried when a file is uploaded to a handle in a [blogify.backend.resources.models.Resource]. + * + * Omitting this annotations is equivalent to not setting a file size limit + * + * @author Benjozork + */ +@Suppress("ClassName") +@Target(AnnotationTarget.TYPE) +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +annotation class maxByteSize(val value: Long) diff --git a/src/blogify/backend/annotations/search/DelegatedSearch.kt b/src/blogify/backend/annotations/search/DelegatedSearch.kt new file mode 100644 index 00000000..b6eeb05d --- /dev/null +++ b/src/blogify/backend/annotations/search/DelegatedSearch.kt @@ -0,0 +1,9 @@ +package blogify.backend.annotations.search + +/** + * Marks a property as having to be treated as the value of one of it's properties. + * The return type of the property should have one and only one property marked with [DelegatedSearchReceiver]. + */ +@Target(AnnotationTarget.TYPE) +@Retention(AnnotationRetention.RUNTIME) +annotation class DelegatedSearch diff --git a/src/blogify/backend/annotations/search/DelegatedSearchReceiver.kt b/src/blogify/backend/annotations/search/DelegatedSearchReceiver.kt new file mode 100644 index 00000000..d9c5697a --- /dev/null +++ b/src/blogify/backend/annotations/search/DelegatedSearchReceiver.kt @@ -0,0 +1,9 @@ +package blogify.backend.annotations.search + +/** + * Marks a property of a class as being the value by which it is represented when present in a class property marked with + * [DelegatedSearch] + */ +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class DelegatedSearchReceiver diff --git a/src/blogify/backend/annotations/search/NoSearch.kt b/src/blogify/backend/annotations/search/NoSearch.kt new file mode 100644 index 00000000..3274613a --- /dev/null +++ b/src/blogify/backend/annotations/search/NoSearch.kt @@ -0,0 +1,5 @@ +package blogify.backend.annotations.search + +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class NoSearch diff --git a/src/blogify/backend/annotations/search/QueryByField.kt b/src/blogify/backend/annotations/search/QueryByField.kt new file mode 100644 index 00000000..c8e40caf --- /dev/null +++ b/src/blogify/backend/annotations/search/QueryByField.kt @@ -0,0 +1,5 @@ +package blogify.backend.annotations.search + +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class QueryByField \ No newline at end of file diff --git a/src/blogify/backend/annotations/search/SearchByUUID.kt b/src/blogify/backend/annotations/search/SearchByUUID.kt new file mode 100644 index 00000000..9130d242 --- /dev/null +++ b/src/blogify/backend/annotations/search/SearchByUUID.kt @@ -0,0 +1,8 @@ +package blogify.backend.annotations.search + +/** + * Marks a property as being represented by it's UUID, and to be used as such in Typesense collections / documents + */ +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class SearchByUUID diff --git a/src/blogify/backend/annotations/search/SearchDefaultSort.kt b/src/blogify/backend/annotations/search/SearchDefaultSort.kt new file mode 100644 index 00000000..5073f04c --- /dev/null +++ b/src/blogify/backend/annotations/search/SearchDefaultSort.kt @@ -0,0 +1,9 @@ +package blogify.backend.annotations.search + +/** + * Marks a property as being the default sorting field of a model class. + * If it doesn't appear on any fields, a fake sorting value is generated + */ +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class SearchDefaultSort diff --git a/src/blogify/backend/auth/handling/Handlers.kt b/src/blogify/backend/auth/handling/Handlers.kt index 35d9ab29..74f59909 100644 --- a/src/blogify/backend/auth/handling/Handlers.kt +++ b/src/blogify/backend/auth/handling/Handlers.kt @@ -7,15 +7,16 @@ import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.request.header import io.ktor.response.respond +import io.ktor.application.ApplicationCall +import io.ktor.util.pipeline.PipelineInterceptor +import io.ktor.util.pipeline.pipelineExecutorFor import blogify.backend.resources.User -import blogify.backend.routes.pipelines.CallPipeLineFunction import blogify.backend.annotations.BlogifyDsl import blogify.backend.resources.models.eqr -import blogify.backend.routes.pipelines.CallPipeline +import blogify.backend.routing.pipelines.CallPipeline import blogify.backend.util.reason - /** * Represents a predicate applied on a [user][User]. */ @@ -31,16 +32,16 @@ fun isUser(mustBe: User): UserAuthPredicate = { user -> } /** - * Allows to wrap a call handler into a block that takes care of authentication using a given [predicate][UserAuthPredicate]. - * - * For example, using [isUser] as a [predicate][UserAuthPredicate] will result in the block only being - * executed if the provided [user][User] matches the authenticating user. + * Allows to wrap a pipeline into a block that takes care of authentication using a given [predicate][UserAuthPredicate]. * * @param predicate the predicate used as a check for authentication * @param block the call handling block that is run if the check succeeds */ @BlogifyDsl -suspend fun CallPipeline.runAuthenticated(predicate: UserAuthPredicate, block: CallPipeLineFunction) { +suspend fun CallPipeline.runAuthenticated ( + predicate: UserAuthPredicate = { true }, + block: PipelineInterceptor +) { val header = call.request.header(HttpHeaders.Authorization) ?: run { call.respond(HttpStatusCode.Unauthorized) // Header is missing return @@ -55,9 +56,9 @@ suspend fun CallPipeline.runAuthenticated(predicate: UserAuthPredicate, block: C } validateJwt(call, token).fold ( - success = { u -> - if (predicate.invoke(u)) { // Check token against predicate - block.invoke(this, Unit) + success = { user -> + if (predicate.invoke(user)) { // Check token against predicate + pipelineExecutorFor(call, listOf(block), user).execute(user) } else call.respond(HttpStatusCode.Forbidden) }, failure = { ex -> call.respond(HttpStatusCode.Forbidden, reason("invalid token - ${ex.javaClass.simpleName}")) diff --git a/src/blogify/backend/auth/jwt/Jwt.kt b/src/blogify/backend/auth/jwt/Jwt.kt index 17f65bcd..328244ce 100644 --- a/src/blogify/backend/auth/jwt/Jwt.kt +++ b/src/blogify/backend/auth/jwt/Jwt.kt @@ -61,7 +61,7 @@ suspend fun validateJwt(callContext: ApplicationCall, token: String): Suspendabl .parseClaimsJws(token) } catch(e: JwtException) { logger.debug("${"invalid token attempted".red()} - ${e.javaClass.simpleName.takeLastWhile { it != '.' }}") - e.printStackTrace() + println(e.message?.red()) return SuspendableResult.error(e) } catch (e: Exception) { logger.debug("${"unknown exception during token validation -".red()} - ${e.javaClass.simpleName.takeLastWhile { it != '.' }}") diff --git a/src/blogify/backend/config/ConfigLoader.kt b/src/blogify/backend/config/ConfigLoader.kt new file mode 100644 index 00000000..297084c7 --- /dev/null +++ b/src/blogify/backend/config/ConfigLoader.kt @@ -0,0 +1,18 @@ +package blogify.backend.config + +import blogify.backend.util.env + +import com.typesafe.config.ConfigFactory + +import kotlinx.serialization.DeserializationStrategy + +import org.jetbrains.kotlinx.serialization.config.ConfigParser + +import java.io.File + +private val directory = env("BLOGIFY_CONFIG_DIRECTORY") ?: error("BLOGIFY_CONFIG_DIRECTORY is not set - cannot load config - cannot start application") + +private val mainConfig = ConfigFactory.parseFile(File("$directory/blogify.conf")) + +fun loadConfig(name: String, deserializer: DeserializationStrategy) + = ConfigParser.parse(mainConfig.getConfig(name), deserializer) \ No newline at end of file diff --git a/src/blogify/backend/config/Configs.kt b/src/blogify/backend/config/Configs.kt new file mode 100644 index 00000000..9f171ed8 --- /dev/null +++ b/src/blogify/backend/config/Configs.kt @@ -0,0 +1,9 @@ +package blogify.backend.config + +object Configs { + + val Database = loadConfig("db", DatabaseConfig.serializer()) + + val Typesense = loadConfig("ts", TypesenseConfig.serializer()) + +} diff --git a/src/blogify/backend/config/DatabaseConfig.kt b/src/blogify/backend/config/DatabaseConfig.kt new file mode 100644 index 00000000..861c16fa --- /dev/null +++ b/src/blogify/backend/config/DatabaseConfig.kt @@ -0,0 +1,12 @@ +package blogify.backend.config + +import kotlinx.serialization.Serializable + +@Serializable +data class DatabaseConfig ( + val host: String, + val port: Int, + val username: String, + val password: String, + val databaseName: String +) diff --git a/src/blogify/backend/config/TypesenseConfig.kt b/src/blogify/backend/config/TypesenseConfig.kt new file mode 100644 index 00000000..ba3c05cf --- /dev/null +++ b/src/blogify/backend/config/TypesenseConfig.kt @@ -0,0 +1,10 @@ +package blogify.backend.config + +import kotlinx.serialization.Serializable + +@Serializable +data class TypesenseConfig ( + val host: String, + val port: Int, + val apiKey: String +) diff --git a/src/blogify/backend/database/CompoundQueries.kt b/src/blogify/backend/database/CompoundQueries.kt new file mode 100644 index 00000000..acb45f9f --- /dev/null +++ b/src/blogify/backend/database/CompoundQueries.kt @@ -0,0 +1,66 @@ +package blogify.backend.database + +import blogify.backend.services.models.Service +import blogify.backend.database.handling.query +import blogify.backend.util.Sr + +import com.github.kittinunf.result.coroutines.mapError + +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.Op +import org.jetbrains.exposed.sql.SqlExpressionBuilder +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.count +import org.jetbrains.exposed.sql.leftJoin +import org.jetbrains.exposed.sql.select + +/** + * Counts the number of references of a certain value in a provided column of a table + * + * @param referenceField the column in which those references to [referenceValue] are to be counted. + * + * @return the number of rows of [referenceField]'s table in which [referenceValue] appears in [referenceField] + * + * @author Benjozork + */ +suspend fun countReferences ( + referenceField: Column, + referenceValue: A, + where: SqlExpressionBuilder.() -> Op = { Op.TRUE } +): Sr { + return query { + referenceField.table.select { referenceField eq referenceValue and where() }.count() + } + .mapError { e -> Service.Exception(e) } +} + +/** + * Counts the number of references for every value of a column in another provided column. + * + * @param originField the column in which the values to count references to are stored + * @param secondField the column in which references to each value of [originField] are stored + * + * @return a map of all the values of [originField] to the number of references to that value in [secondField] + * + * @author Benjozork + */ +private suspend fun countAllReferences ( + originField: Column, + secondField: Column, + where: SqlExpressionBuilder.() -> Op = { Op.TRUE } +): Sr> { + return query { + val refCountColumn = secondField.count() + originField.table + .leftJoin(secondField.table, { originField }, { secondField }) + .slice(originField, refCountColumn) + .select(where) + .groupBy(originField) + .toSet().map { it[originField] to it[refCountColumn] }.toMap() + } +} + +/** + * Associates all values of [this] to the number of references to them in [other] + */ +suspend infix fun Column.referredToBy(other: Column) = countAllReferences(this, other).get() \ No newline at end of file diff --git a/src/blogify/backend/database/Database.kt b/src/blogify/backend/database/Database.kt index b0675b71..2f567f3f 100644 --- a/src/blogify/backend/database/Database.kt +++ b/src/blogify/backend/database/Database.kt @@ -1,5 +1,6 @@ package blogify.backend.database +import blogify.backend.config.Configs import blogify.backend.util.BException import com.zaxxer.hikari.HikariConfig @@ -14,10 +15,12 @@ object Database { lateinit var instance: Database - private fun configureHikariCP(): HikariDataSource { + private val config = Configs.Database + + private fun configureHikariCP(envDbHost: String, envDbPort: Int, envDbUser: String, envDbPass: String): HikariDataSource { val config = HikariConfig() config.driverClassName = "org.postgresql.Driver" - config.jdbcUrl = "jdbc:postgresql://db:5432/postgres" + config.jdbcUrl = "jdbc:postgresql://$envDbHost:$envDbPort/postgres" config.maximumPoolSize = 24 config.minimumIdle = 6 config.validationTimeout = 10 * 1000 @@ -26,14 +29,14 @@ object Database { config.leakDetectionThreshold = 60 * 1000 config.isAutoCommit = false config.transactionIsolation = "TRANSACTION_REPEATABLE_READ" - config.username = "postgres" - config.password = "" + config.username = envDbUser + config.password = envDbPass config.validate() return HikariDataSource(config) } fun init() { - instance = Database.connect(configureHikariCP()) + instance = Database.connect(configureHikariCP(config.host, config.port, config.username, config.password)) } open class Exception(causedBy: kotlin.Exception) : BException(causedBy) { diff --git a/src/blogify/backend/database/Tables.kt b/src/blogify/backend/database/Tables.kt index 4b901767..a85ed69b 100644 --- a/src/blogify/backend/database/Tables.kt +++ b/src/blogify/backend/database/Tables.kt @@ -2,15 +2,19 @@ package blogify.backend.database +import blogify.backend.database.handling.query import blogify.backend.resources.Article import blogify.backend.resources.Comment import blogify.backend.resources.User import blogify.backend.resources.models.Resource import blogify.backend.resources.static.models.StaticResourceHandle -import blogify.backend.services.articles.ArticleService +import blogify.backend.services.ArticleService import blogify.backend.services.UserService -import blogify.backend.services.articles.CommentService +import blogify.backend.services.CommentService import blogify.backend.services.models.Service +import blogify.backend.util.Sr +import blogify.backend.util.Wrap +import blogify.backend.util.SrList import io.ktor.application.ApplicationCall import io.ktor.http.ContentType @@ -20,26 +24,102 @@ import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.Table import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.transactions.transaction +import org.jetbrains.exposed.sql.deleteWhere +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.batchInsert +import org.jetbrains.exposed.sql.update +import org.jetbrains.exposed.sql.selectAll import com.github.kittinunf.result.coroutines.SuspendableResult +import java.util.UUID + abstract class ResourceTable : Table() { + open suspend fun obtainAll(callContext: ApplicationCall, limit: Int): SrList = Wrap { + query { this.selectAll().limit(limit).toSet() }.get().map { this.convert(callContext, it).get() } + } + + open suspend fun obtain(callContext: ApplicationCall, id: UUID): Sr = Wrap { + query { this.select { uuid eq id }.single() }.get() + .let { this.convert(callContext, it).get() } + } + abstract suspend fun convert(callContext: ApplicationCall, source: ResultRow): SuspendableResult - val uuid = uuid("uuid").primaryKey() + abstract suspend fun insert(resource: R): Sr + + abstract suspend fun update(resource: R): Boolean + + open suspend fun delete(resource: R): Sr = Wrap { + query { + this.deleteWhere { uuid eq resource.uuid } + } + + true + } + + val uuid = uuid("uuid") + + override val primaryKey = PrimaryKey(uuid) } object Articles : ResourceTable
() { val title = varchar ("title", 512) - val createdAt = long ("created_at") + val createdAt = integer ("created_at") val createdBy = uuid ("created_by").references(Users.uuid, onDelete = SET_NULL) val content = text ("content") val summary = text ("summary") - override suspend fun convert(callContext: ApplicationCall, source: ResultRow) = SuspendableResult.of { + override suspend fun insert(resource: Article): Sr
{ + return Sr.of { + query { + this.insert { + it[uuid] = resource.uuid + it[title] = resource.title + it[createdAt] = resource.createdAt + it[createdBy] = resource.createdBy.uuid + it[content] = resource.content + it[summary] = resource.summary + } + } + + query { + Categories.batchInsert(resource.categories) { + this[Categories.article] = resource.uuid + this[Categories.name] = it.name + } + } + + return@of resource + } + } + + override suspend fun update(resource: Article): Boolean { + return query { + this.update(where = { uuid eq resource.uuid }) { + it[uuid] = resource.uuid + it[title] = resource.title + it[createdAt] = resource.createdAt + it[createdBy] = resource.createdBy.uuid + it[content] = resource.content + it[summary] = resource.summary + } + }.get() == 1 + } + + override suspend fun delete(resource: Article) = Wrap { + val articleDeleted = super.delete(resource) + query { + Categories.deleteWhere { Categories.article eq resource.uuid } == 1 + } + + true + } + + override suspend fun convert(callContext: ApplicationCall, source: ResultRow) = Sr.of { Article ( uuid = source[uuid], title = source[title], @@ -55,8 +135,10 @@ object Articles : ResourceTable
() { object Categories : Table() { - val article = uuid("article").primaryKey().references(Articles.uuid, onDelete = CASCADE) - val name = varchar("name", 255).primaryKey() + val article = uuid("article").references(Articles.uuid, onDelete = CASCADE) + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(article, name) @Suppress("RedundantSuspendModifier") suspend fun convert(source: ResultRow) = Article.Category ( @@ -65,8 +147,18 @@ object Articles : ResourceTable
() { } + object Likes: Table() { + + val user = uuid("user").references(Users.uuid, onDelete = CASCADE) + val article = uuid("article").references(Articles.uuid, onDelete = CASCADE) + + override val primaryKey = PrimaryKey(user, article) + + } + } +@Suppress("DuplicatedCode") object Users : ResourceTable() { val username = varchar ("username", 255) @@ -74,11 +166,55 @@ object Users : ResourceTable() { val email = varchar ("email", 255) val name = varchar ("name", 255) val profilePicture = varchar ("profile_picture", 32).references(Uploadables.fileId, onDelete = SET_NULL, onUpdate = RESTRICT).nullable() + val coverPicture = varchar ("cover_picture", 32).references(Uploadables.fileId, onDelete = SET_NULL, onUpdate = RESTRICT).nullable() + val isAdmin = bool ("is_admin") init { index(true, username) } + object Follows : Table() { + + val following = uuid("following").references(Users.uuid, onDelete = CASCADE) + val follower = uuid("follower").references(Users.uuid, onDelete = CASCADE) + + override val primaryKey = PrimaryKey(following, follower) + + } + + override suspend fun insert(resource: User): Sr { + return Sr.of { + query { + Users.insert { + it[uuid] = resource.uuid + it[username] = resource.username + it[password] = resource.password + it[email] = resource.email + it[name] = resource.name + it[profilePicture] = if (resource.profilePicture is StaticResourceHandle.Ok) resource.profilePicture.fileId else null + it[isAdmin] = resource.isAdmin + } + } + return@of resource + } + + } + + override suspend fun update(resource: User): Boolean { + return query { + this.update(where = { uuid eq resource.uuid }) { + it[uuid] = resource.uuid + it[username] = resource.username + it[password] = resource.password + it[email] = resource.email + it[name] = resource.name + it[profilePicture] = if (resource.profilePicture is StaticResourceHandle.Ok) resource.profilePicture.fileId else null + it[coverPicture] = if (resource.coverPicture is StaticResourceHandle.Ok) resource.coverPicture.fileId else null + it[isAdmin] = resource.isAdmin + } + }.get() == 1 + } + override suspend fun convert(callContext: ApplicationCall, source: ResultRow) = SuspendableResult.of { User ( uuid = source[uuid], @@ -86,8 +222,12 @@ object Users : ResourceTable() { password = source[password], name = source[name], email = source[email], + isAdmin = source[isAdmin], profilePicture = source[profilePicture]?.let { transaction { Uploadables.select { Uploadables.fileId eq source[profilePicture]!! }.limit(1).single() + }.let { Uploadables.convert(callContext, it).get() } } ?: StaticResourceHandle.None(ContentType.Any), + coverPicture = source[coverPicture]?.let { transaction { + Uploadables.select { Uploadables.fileId eq source[coverPicture]!! }.limit(1).single() }.let { Uploadables.convert(callContext, it).get() } } ?: StaticResourceHandle.None(ContentType.Any) ) } @@ -101,6 +241,33 @@ object Comments : ResourceTable() { val content = text ("content") val parentComment = uuid ("parent_comment").references(uuid, onDelete = CASCADE).nullable() + override suspend fun insert(resource: Comment): Sr { + return Sr.of { + query { + this.insert { + it[uuid] = resource.uuid + it[commenter] = resource.commenter.uuid + it[article] = resource.article.uuid + it[content] = resource.content + it[parentComment] = resource.parentComment?.uuid + } + } + return@of resource + } + } + + override suspend fun update(resource: Comment): Boolean { + return query { + this.update(where = { Users.uuid eq resource.uuid }) { + it[uuid] = resource.uuid + it[commenter] = resource.commenter.uuid + it[article] = resource.article.uuid + it[content] = resource.content + it[parentComment] = resource.parentComment?.uuid + } + }.get() == 1 + } + override suspend fun convert(callContext: ApplicationCall, source: ResultRow) = SuspendableResult.of { Comment ( uuid = source[uuid], @@ -115,10 +282,12 @@ object Comments : ResourceTable() { object Uploadables : Table() { - val fileId = varchar ("id", 32).primaryKey() + val fileId = varchar ("id", 32) val contentType = varchar ("content_type", 64) - suspend fun convert(callContext: ApplicationCall, source: ResultRow) = SuspendableResult.of { + override val primaryKey = PrimaryKey(fileId) + + suspend fun convert(@Suppress("UNUSED_PARAMETER") callContext: ApplicationCall, source: ResultRow) = SuspendableResult.of { StaticResourceHandle.Ok ( contentType = ContentType.parse(source[contentType]), fileId = source[fileId] diff --git a/src/blogify/backend/resources/Article.kt b/src/blogify/backend/resources/Article.kt index bf591975..5e5df7c4 100644 --- a/src/blogify/backend/resources/Article.kt +++ b/src/blogify/backend/resources/Article.kt @@ -1,18 +1,19 @@ package blogify.backend.resources -import com.fasterxml.jackson.annotation.JsonIdentityInfo -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.annotations.search.* import blogify.backend.database.Articles +import blogify.backend.database.Comments +import blogify.backend.resources.computed.compound +import blogify.backend.resources.computed.models.Computed import blogify.backend.resources.models.Resource -import blogify.backend.database.handling.query +import blogify.backend.database.referredToBy -import org.jetbrains.exposed.sql.select +import com.fasterxml.jackson.annotation.JsonIdentityInfo +import com.fasterxml.jackson.annotation.ObjectIdGenerators -import java.util.* +import java.time.Instant +import java.util.UUID /** * Represents an Article [Resource]. @@ -31,20 +32,26 @@ import java.util.* property = "uuid" ) data class Article ( + + @QueryByField val title: @check("^.{0,512}") String, - val createdAt: Long = Date().time, + @SearchDefaultSort + val createdAt: Int = Instant.now().epochSecond.toInt(), - @JsonIdentityReference(alwaysAsId = true) - val createdBy: User, + val createdBy: @DelegatedSearch User, + @QueryByField val content: String, val summary: String, - val categories: List, + @NoSearch + val categories: @DelegatedSearch List, + @NoSearch override val uuid: UUID = UUID.randomUUID() + ) : Resource(uuid) { /** @@ -52,12 +59,12 @@ data class Article ( * * @property name The name content of the category. */ - data class Category(val name: String) + data class Category(@DelegatedSearchReceiver val name: String) + + @[Computed NoSearch] + val likeCount by compound { Articles.uuid referredToBy Articles.Likes.article } - suspend fun category(): List = query { - Articles.Categories.select { - Articles.Categories.article eq this@Article.uuid - }.toList().map{ Articles.Categories.convert(it) } - }.get() + @[Computed NoSearch] + val commentCount by compound { Articles.uuid referredToBy Comments.article } } diff --git a/src/blogify/backend/resources/Follow.kt b/src/blogify/backend/resources/Follow.kt new file mode 100644 index 00000000..27313c58 --- /dev/null +++ b/src/blogify/backend/resources/Follow.kt @@ -0,0 +1,6 @@ +package blogify.backend.resources + +data class Follow( + val following: User, + val follower: User +) diff --git a/src/blogify/backend/resources/User.kt b/src/blogify/backend/resources/User.kt index 125eab62..647e36f3 100644 --- a/src/blogify/backend/resources/User.kt +++ b/src/blogify/backend/resources/User.kt @@ -1,14 +1,28 @@ package blogify.backend.resources +import blogify.backend.annotations.search.NoSearch +import blogify.backend.annotations.Invisible +import blogify.backend.annotations.search.DelegatedSearchReceiver +import blogify.backend.annotations.search.QueryByField +import blogify.backend.annotations.search.SearchDefaultSort +import blogify.backend.annotations.maxByteSize +import blogify.backend.annotations.type +import blogify.backend.database.Users +import blogify.backend.database.handling.query +import blogify.backend.database.referredToBy +import blogify.backend.resources.computed.compound +import blogify.backend.resources.computed.models.Computed import blogify.backend.resources.models.Resource import blogify.backend.resources.static.models.StaticResourceHandle -import blogify.backend.annotations.noslice -import blogify.backend.annotations.type import com.fasterxml.jackson.annotation.JsonIdentityInfo import com.fasterxml.jackson.annotation.ObjectIdGenerators +import org.jetbrains.exposed.sql.JoinType +import org.jetbrains.exposed.sql.selectAll + import java.util.* +import kotlin.random.Random @JsonIdentityInfo ( scope = User::class, @@ -17,11 +31,55 @@ import java.util.* property = "uuid" ) data class User ( - val username: String, - @noslice val password: String, // IMPORTANT : DO NOT EVER REMOVE THIS ANNOTATION ! - val name: String, - val email: String, - val profilePicture: @type("image/*") StaticResourceHandle, + @QueryByField + @DelegatedSearchReceiver + val username: String, + + @Invisible + val password: String, // IMPORTANT : DO NOT EVER REMOVE THIS ANNOTATION ! + + @QueryByField + val name: String, + + val email: String, + + @NoSearch + val profilePicture: + @type("image/*") + @maxByteSize(500_000) + StaticResourceHandle, + @NoSearch + val coverPicture: + @type("image/*") + @maxByteSize(1_000_000) + StaticResourceHandle, + + @Invisible + val isAdmin: Boolean = false, + + @SearchDefaultSort + val dsf: Int = Random.nextInt(), + + @NoSearch override val uuid: UUID = UUID.randomUUID() -) : Resource(uuid) +) : Resource(uuid) { + + @Computed + val followCount by compound { Users.uuid referredToBy Users.Follows.following } + + @Computed + val followers by compound { + query { + Users.join ( Users.Follows, JoinType.LEFT, + onColumn = Users.uuid, otherColumn = Users.Follows.following + ) + .slice(Users.uuid, Users.Follows.follower) + .selectAll() + .map { it[Users.uuid] to it.getOrNull(Users.Follows.follower) } + .groupBy { it.first } + .mapValues { it.value.mapNotNull { pair -> pair.second } } + }.get() + } + +} diff --git a/src/blogify/backend/resources/computed/CompoundProperties.kt b/src/blogify/backend/resources/computed/CompoundProperties.kt new file mode 100644 index 00000000..182328e7 --- /dev/null +++ b/src/blogify/backend/resources/computed/CompoundProperties.kt @@ -0,0 +1,54 @@ +package blogify.backend.resources.computed + +import blogify.backend.resources.computed.models.ComputedPropertyDelegate +import blogify.backend.resources.models.Resource +import blogify.backend.resources.reflect.models.Mapped + +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.Dispatchers + +import kotlin.reflect.KClass +import kotlin.reflect.KProperty + +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +// Data cache + +data class CompoundCacheKey(val klass: KClass<*>, val property: KProperty<*>) +val compoundCache = ConcurrentHashMap>() + +class CompoundCachedComputedPropertyDelegate ( + val initializer: () -> Map +): ComputedPropertyDelegate() { + + @Suppress("UNCHECKED_CAST") + override operator fun getValue(thisRef: Resource, property: KProperty<*>): A { + val key = CompoundCacheKey(thisRef::class, property) + var cacheValue = compoundCache[key]?.toMutableMap() + + if (cacheValue == null) { + cacheValue = initializer().toMutableMap() + compoundCache[key] = cacheValue + } + + var finalValue = cacheValue[thisRef.uuid] + cacheValue.remove(thisRef.uuid) + compoundCache[key] = cacheValue + + if (finalValue == null) { + cacheValue = initializer().toMutableMap() + compoundCache[key] = cacheValue + finalValue = cacheValue[thisRef.uuid] + } + + return finalValue as A + } + +} + +/** + * `compound` is a property delegate that allows running a costly operation only once for a batch of resources in a same request + */ +fun compound(initializer: suspend () -> Map) + = CompoundCachedComputedPropertyDelegate { runBlocking(context = Dispatchers.IO, block = { initializer() }) } diff --git a/src/blogify/backend/resources/computed/models/ComputedPropertyDelegate.kt b/src/blogify/backend/resources/computed/models/ComputedPropertyDelegate.kt new file mode 100644 index 00000000..5c0a3956 --- /dev/null +++ b/src/blogify/backend/resources/computed/models/ComputedPropertyDelegate.kt @@ -0,0 +1,16 @@ +package blogify.backend.resources.computed.models + +import blogify.backend.resources.models.Resource + +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +annotation class Computed + +abstract class ComputedPropertyDelegate : ReadOnlyProperty { + + abstract override fun getValue(thisRef: Resource, property: KProperty<*>): A + +} diff --git a/src/blogify/backend/resources/models/Resource.kt b/src/blogify/backend/resources/models/Resource.kt index 1f5197fb..25845ddb 100644 --- a/src/blogify/backend/resources/models/Resource.kt +++ b/src/blogify/backend/resources/models/Resource.kt @@ -9,10 +9,10 @@ import com.fasterxml.jackson.databind.ser.std.StdSerializer import blogify.backend.resources.Article import blogify.backend.resources.Comment import blogify.backend.resources.User -import blogify.backend.resources.slicing.models.Mapped +import blogify.backend.resources.reflect.models.Mapped import blogify.backend.services.UserService -import blogify.backend.services.articles.ArticleService -import blogify.backend.services.articles.CommentService +import blogify.backend.services.ArticleService +import blogify.backend.services.CommentService import io.ktor.application.Application import io.ktor.application.ApplicationCall diff --git a/src/blogify/backend/resources/reflect/Mapper.kt b/src/blogify/backend/resources/reflect/Mapper.kt new file mode 100644 index 00000000..1a98d6b4 --- /dev/null +++ b/src/blogify/backend/resources/reflect/Mapper.kt @@ -0,0 +1,141 @@ +package blogify.backend.resources.reflect + +import blogify.backend.annotations.check +import blogify.backend.annotations.Invisible +import blogify.backend.resources.computed.models.Computed +import blogify.backend.resources.reflect.models.Mapped +import blogify.backend.resources.reflect.models.PropMap + +import com.andreapivetta.kolor.green + +import org.slf4j.LoggerFactory + +import kotlin.reflect.KClass +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.full.findAnnotation + +private val logger = LoggerFactory.getLogger("blogify-datamap") + +/** + * Builds a [property map][PropMap] on the receiver [KClass] + * + * @receiver the [class][KClass] for which the [PropMap] should be built + * + * @return the generated [PropMap] + * + * @author Benjozork + */ +@Suppress("UNCHECKED_CAST") +private fun KClass.buildPropMap(unsafe: Boolean = false): PropMap { + return PropMap(this.declaredMemberProperties + .asSequence() + .associateBy { + it.name + }.mapValues, PropMap.PropertyHandle> { (name, self) -> + if (self.findAnnotation() != null && !unsafe) { + PropMap.PropertyHandle.AccessDenied(name) + } else { + if (self.findAnnotation() != null) { + PropMap.PropertyHandle.Computed(name, self as KProperty1) + } else { + if (self.returnType.findAnnotation() != null) { + val regex = Regex(self.returnType.findAnnotation()!!.pattern) + PropMap.PropertyHandle.Ok(name, regex, self as KProperty1) + } else { + PropMap.PropertyHandle.Ok(name, null, self as KProperty1) + } + } + } + }.also { logger.debug("built propmap for class ${this.simpleName}".green()) }) +} + +/** + * A cache storing computed [property maps][PropMap] for various [classes][KClass], using the [class][KClass] itself as a key + * + * @author Benjozork + */ +private val propMapCache: MutableMap, PropMap> = mutableMapOf() + +/** + * A cache storing computed unsage [property maps][PropMap] for various [classes][KClass], using the [class][KClass] itself as a key + * + * @author Benjozork + */ +private val unsafePropMapCache: MutableMap, PropMap> = mutableMapOf() + +/** + * Fetches (or computes if the class is not in the cache) a [property map][PropMap] for the receiver [KClass] + * + * @receiver the [class][KClass] for which the [PropMap] should be obtained + * + * @return the obtained [PropMap] + * + * @author Benjozork + */ +fun M.cachedPropMap(): PropMap { + var cached: PropMap? = propMapCache[this::class] + if (cached == null) { + cached = this::class.buildPropMap() + propMapCache[this::class] = cached + } + + return cached +} + +/** + * Fetches (or computes if the class is not in the cache) a [property map][PropMap] for the receiver [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) { + cached = this.buildPropMap() + propMapCache[this] = cached + } + + return cached +} + +/** + * Fetches (or computes if the class is not in the cache) an unsafe [property map][PropMap] for the receiver [KClass] + * + * @receiver the [class][KClass] for which the [PropMap] should be obtained + * + * @return the obtained [PropMap] + * + * @author Benjozork + */ +fun M.cachedUnsafePropMap(): PropMap { + var cached: PropMap? = unsafePropMapCache[this::class] + if (cached == null) { + cached = this::class.buildPropMap(unsafe = true) + unsafePropMapCache[this::class] = cached + } + + return cached +} + +/** + * Fetches (or computes if the class is not in the cache) an unsafe [property map][PropMap] for the receiver [KClass] + * + * @receiver the [class][KClass] for which the [PropMap] should be obtained + * + * @return the obtained [PropMap] + * + * @author Benjozork + */ +fun KClass.cachedUnsafePropMap(): PropMap { + var cached: PropMap? = unsafePropMapCache[this] + if (cached == null) { + cached = this.buildPropMap(unsafe = true) + unsafePropMapCache[this] = cached + } + + return cached +} diff --git a/src/blogify/backend/resources/slicing/Slicer.kt b/src/blogify/backend/resources/reflect/Slicer.kt similarity index 67% rename from src/blogify/backend/resources/slicing/Slicer.kt rename to src/blogify/backend/resources/reflect/Slicer.kt index 3fc746d3..8cfadf54 100644 --- a/src/blogify/backend/resources/slicing/Slicer.kt +++ b/src/blogify/backend/resources/reflect/Slicer.kt @@ -1,7 +1,14 @@ -package blogify.backend.resources.slicing +package blogify.backend.resources.reflect +import blogify.backend.annotations.search.NoSearch import blogify.backend.resources.models.Resource -import blogify.backend.annotations.noslice +import blogify.backend.annotations.Invisible +import blogify.backend.resources.computed.models.ComputedPropertyDelegate +import blogify.backend.resources.reflect.models.PropMap +import blogify.backend.resources.reflect.models.ext.valid + +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.jvm.isAccessible /** * Represents the result of [getPropValueOnInstance]. @@ -41,11 +48,11 @@ sealed class SlicedProperty(val name: String) { } /** - * Reads a property from an instance of [R] with [a certain name][propertyName] using reflection + * Reads a property from an instance of [M] with [a certain name][propertyName] using reflection * * Shamelessly stolen from: [https://stackoverflow.com/a/35539628] * - * @param instance instance of [R] to read property from + * @param instance instance of [M] to read property from * @param propertyName name of the property to read * * @return a [SlicedProperty] representing the result of the query. Can be either [SlicedProperty.Value] for success, @@ -55,21 +62,30 @@ sealed class SlicedProperty(val name: String) { * @author hamza1311, Benjozork */ @Suppress("UNCHECKED_CAST") -private fun getPropValueOnInstance(instance: R, propertyName: String): SlicedProperty { - return instance.cachedPropMap() +private fun getPropValueOnInstance(instance: M, propertyName: String): SlicedProperty { + return instance.cachedPropMap().map .entries.firstOrNull { (name, _) -> name == propertyName } ?.value?.let { handle -> return when (handle) { - is PropertyHandle.Ok -> { + is PropMap.PropertyHandle.Ok -> { // Handle is ok, proceed if (handle.property.returnType.isMarkedNullable) { SlicedProperty.NullableValue(propertyName, handle.property.get(instance)) } else { SlicedProperty.Value(propertyName, handle.property.get(instance)) } } - is PropertyHandle.AccessDenied -> SlicedProperty.AccessNotAllowed(handle.name) + is PropMap.PropertyHandle.Computed -> { + handle.property.isAccessible = true + + val delegate = handle.property.getDelegate(instance) as? ComputedPropertyDelegate<*> + ?: error("no / illegal delegate on @Computed property '${handle.name}' of class '${instance::class.simpleName}'") + + val delegateResult = delegate.getValue(instance, handle.property) + SlicedProperty.Value(propertyName, delegateResult) + } + is PropMap.PropertyHandle.AccessDenied -> SlicedProperty.AccessNotAllowed(handle.name) // Handle is denied } - } ?: SlicedProperty.NotFound(propertyName) + } ?: SlicedProperty.NotFound(propertyName) // No property handle found } /** @@ -83,7 +99,7 @@ private fun getPropValueOnInstance(instance: R, propertyName: Str * * @author hamza1311, Benjozork */ -fun R.slice(selectedPropertyNames: Set): Map { +fun M.slice(selectedPropertyNames: Set): Map { val selectedPropertiesSanitized = selectedPropertyNames.toMutableSet().apply { removeIf { it == "uuid" || it == "UUID" } @@ -94,7 +110,7 @@ fun R.slice(selectedPropertyNames: Set): Map() return selectedPropertiesSanitized.associateWith { propName -> - when (val result = getPropValueOnInstance(this, propName)) { + when (val result = getPropValueOnInstance(this, propName)) { is SlicedProperty.Value -> result.value is SlicedProperty.NullableValue -> result.value is SlicedProperty.NotFound -> unknownProperties += result.name @@ -111,17 +127,21 @@ fun R.slice(selectedPropertyNames: Set): Map R.sanitize(): Map { - val sanitizedClassProps = this::class.cachedPropMap() +fun M.sanitize(noSearch: Boolean = false): Map { + val sanitizedClassProps = this::class.cachedPropMap().valid() .asSequence() - .filter { it.value is PropertyHandle.Ok } - .map { it.key } + .filter { + !noSearch || it.value.property.findAnnotation() == null + } + .map { it.key } .toSet() return this.slice(sanitizedClassProps) diff --git a/src/blogify/backend/resources/reflect/Updater.kt b/src/blogify/backend/resources/reflect/Updater.kt new file mode 100644 index 00000000..68241950 --- /dev/null +++ b/src/blogify/backend/resources/reflect/Updater.kt @@ -0,0 +1,79 @@ +package blogify.backend.resources.reflect + +import blogify.backend.resources.models.Resource +import blogify.backend.resources.reflect.models.PropMap +import blogify.backend.resources.reflect.models.ext.ok +import blogify.backend.util.Sr +import blogify.backend.util.service +import blogify.backend.util.toUUID + +import java.lang.Exception +import java.util.UUID + +import com.andreapivetta.kolor.yellow + +import org.slf4j.LoggerFactory + +import kotlin.reflect.KClass +import kotlin.reflect.full.createType +import kotlin.reflect.full.functions +import kotlin.reflect.full.isSubtypeOf + +private val logger = LoggerFactory.getLogger("blogify-datamap-updater") + +/** + * Updates a [Resource] using a map of [`Ok` handles][PropMap.PropertyHandle.Ok] to new data values + * + * @param R the class associated with [target] + * @param target the [Resource] to update + * @param rawData a map of [`Ok` handles][PropMap.PropertyHandle.Ok] to new data values + * + * @return an updated instance of [R] with all new data from [rawData], but the same unchanged data from [target] + * + * @author Benjozork + */ +suspend fun update(target: R, rawData: Map): Sr { + + val targetPropMap = target.cachedUnsafePropMap() // Get unsafe handles too + val targetCopyFunction = target::class.functions.first { it.name == "copy" } + + // Make paramMap for unchanged properties + + val notUpdatedParameterMap = (targetPropMap.ok().values subtract rawData.keys) + .associateWith { targetCopyFunction.parameters.first { p -> p.name == it.name } } + .map { it.value to it.key.property.get(target) } + + // Make paramMap for changed properties + + val updatedParameterMap = (targetPropMap.ok().values intersect rawData.keys) + .associateWith { targetCopyFunction.parameters.first { p -> p.name == it.name } } + .map { it.value to rawData[it.key] } + .map { (k ,v) -> // Do some known obvious conversions + when { + k.type.isSubtypeOf(Resource::class.createType()) -> { // KType of property is subtype of Resource + @Suppress("UNCHECKED_CAST") + val keyResourceType = k.type.classifier as KClass + val valueUUID = (v as String).toUUID() + k to keyResourceType.service.get(id = valueUUID).get() + } + k.type.isSubtypeOf(UUID::class.createType()) -> { // KType of property is subtype of UUID + k to (v as String).toUUID() + } + else -> k to v + } + } + + val completeData = + (notUpdatedParameterMap + updatedParameterMap + (targetCopyFunction.parameters.first() to target)).toMap() + + logger.trace("attempting with paramMap: ${completeData.map { "${it.key.name}: ${it.value!!::class.simpleName}" }}".yellow()) + logger.trace("function wants paramMap: ${targetCopyFunction.parameters.map { "${it.name}: ${it.type.classifier}" } }".yellow()) + + return Sr.of { + @Suppress("UNCHECKED_CAST") + targetCopyFunction.callBy ( + completeData + ) as R + } + +} \ No newline at end of file diff --git a/src/blogify/backend/resources/slicing/Verifier.kt b/src/blogify/backend/resources/reflect/Verifier.kt similarity index 62% rename from src/blogify/backend/resources/slicing/Verifier.kt rename to src/blogify/backend/resources/reflect/Verifier.kt index 92ca1ef9..4ab26b47 100644 --- a/src/blogify/backend/resources/slicing/Verifier.kt +++ b/src/blogify/backend/resources/reflect/Verifier.kt @@ -1,6 +1,8 @@ -package blogify.backend.resources.slicing +package blogify.backend.resources.reflect -import blogify.backend.resources.slicing.models.Mapped +import blogify.backend.resources.reflect.models.Mapped +import blogify.backend.resources.reflect.models.PropMap +import blogify.backend.resources.reflect.models.ext.ok import blogify.backend.util.filterThenMapValues /** @@ -9,7 +11,7 @@ import blogify.backend.util.filterThenMapValues * * @author Benjozork */ -fun Mapped.verify(): Map = this.cachedPropMap().okHandles() +fun Mapped.verify(): Map = this.cachedPropMap().ok() .mapKeys { it.value } // Use property handles as keys .filterThenMapValues ( { it.property.returnType.classifier == String::class }, diff --git a/src/blogify/backend/resources/reflect/models/Mapped.kt b/src/blogify/backend/resources/reflect/models/Mapped.kt new file mode 100644 index 00000000..617cedcf --- /dev/null +++ b/src/blogify/backend/resources/reflect/models/Mapped.kt @@ -0,0 +1,3 @@ +package blogify.backend.resources.reflect.models + +open class Mapped() \ No newline at end of file diff --git a/src/blogify/backend/resources/reflect/models/PropMap.kt b/src/blogify/backend/resources/reflect/models/PropMap.kt new file mode 100644 index 00000000..554b72bf --- /dev/null +++ b/src/blogify/backend/resources/reflect/models/PropMap.kt @@ -0,0 +1,77 @@ +package blogify.backend.resources.reflect.models + +import kotlin.reflect.KProperty1 + +/** + * Represents the map of different properties that can be obtained, changed or set on a [Mapped] object, depending + * on the annotation the property possesses. + * + * @property map the map of [property names][String] to [property handles][PropertyHandle] + * + * @author Benjozork, hamza1311 + */ +class PropMap(val map: Map): Iterable> { + + override fun iterator() = this.map.iterator() + + operator fun get(name: String) = this.map[name] + + /** + * Represents a handle on a [KProperty1]. Can be either [Ok] or [AccessDenied] to represent the state of the handle. + * + * @property name the canonical name of the property + * + * @author Benjozork + */ + sealed class PropertyHandle(val name: String) { + + override fun equals(other: Any?): Boolean { + return when (other) { + is PropertyHandle -> this.name == other.name + else -> false + } + } + + override fun hashCode() = name.hashCode() + + override fun toString() = name + + interface Valid { + val name: String; + val property: KProperty1 + } + + /** + * Represents a valid handle, which points to a [KProperty1] + * + * @param name the canonical name of the property + * + * @property regexCheck a regular expression that the value of the property is checked against by [verify] if the property is of type [String] + * @property property the property that is pointed to + * + * @author Benjozork + */ + class Ok(name: String, val regexCheck: Regex?, override val property: KProperty1): PropertyHandle(name), Valid + + /** + * Represents a valid computed handle, which points to a [KProperty1] + * + * @param name the canonical name of the property + * + * @property property the property that is pointed to + * + * @author Benjozork + */ + class Computed(name: String, override val property: KProperty1): PropertyHandle(name), Valid + + /** + * Represents a handle that points to a property that cannot be accessed due to security policy reasons. This incident will be reported. + * + * @param name the canonical name of the property + * + * @author Benjozork + */ + class AccessDenied(name: String): PropertyHandle(name) + } + +} diff --git a/src/blogify/backend/resources/reflect/models/ext/PropMapExt.kt b/src/blogify/backend/resources/reflect/models/ext/PropMapExt.kt new file mode 100644 index 00000000..05643dc0 --- /dev/null +++ b/src/blogify/backend/resources/reflect/models/ext/PropMapExt.kt @@ -0,0 +1,33 @@ +package blogify.backend.resources.reflect.models.ext + +import blogify.backend.resources.reflect.models.PropMap +import blogify.backend.util.filterThenMapValues + +/** + * Only returns [PropMap.PropertyHandle]s that respect interface [PropMap.PropertyHandle.Valid] + * + * @author Benjozork + */ +fun PropMap.valid() = this.map + .filterThenMapValues ( + { it is PropMap.PropertyHandle.Valid }, + { it.value as PropMap.PropertyHandle.Valid } + ) + +/** + * Only returns [PropMap.PropertyHandle]s that are of type [PropMap.PropertyHandle.Ok] + * + * @author Benjozork + */ +fun PropMap.ok() = this.map + .filterThenMapValues ( + { it is PropMap.PropertyHandle.Ok }, + { it.value as PropMap.PropertyHandle.Ok } + ) + +/** + * Only returns [PropMap.PropertyHandle]s that are of type [PropMap.PropertyHandle.AccessDenied] + * + * @author Benjozork + */ +fun PropMap.accessDenied() = this.map.filterThenMapValues ({ it is PropMap.PropertyHandle.AccessDenied }, { it.value as PropMap.PropertyHandle.AccessDenied }) diff --git a/src/blogify/backend/resources/search/Search.kt b/src/blogify/backend/resources/search/Search.kt deleted file mode 100644 index 71fe01d9..00000000 --- a/src/blogify/backend/resources/search/Search.kt +++ /dev/null @@ -1,108 +0,0 @@ -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 deleted file mode 100644 index 793acc6a..00000000 --- a/src/blogify/backend/resources/slicing/Mapper.kt +++ /dev/null @@ -1,135 +0,0 @@ -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 - -import org.slf4j.LoggerFactory - -import kotlin.reflect.KClass -import kotlin.reflect.KProperty1 -import kotlin.reflect.full.declaredMemberProperties -import kotlin.reflect.full.findAnnotation - -private val logger = LoggerFactory.getLogger("blogify-datamap") - -/** - * Represents a handle on a [KProperty1]. Can be either [Ok] or [AccessDenied] to represent the state of the handle. - * - * @property name the canonical name of the property - * - * @author Benjozork - */ -sealed class PropertyHandle(val name: String) { - /** - * Represents a valid handle, which points to a [KProperty1] - * - * @param name the canonical name of the property - * - * @property regexCheck a regular expression that the value of the property is checked against by [verify] if the property is of type [String] - * @property property the property that is pointed to - * - * @author Benjozork - */ - class Ok(name: String, val regexCheck: Regex?, val property: KProperty1) : PropertyHandle(name) - - /** - * Represents a handle that points to a property that cannot be accessed due to security policy reasons. This incident will be reported. - * - * @param name the canonical name of the property - * - * @author Benjozork - */ - class AccessDenied(name: String) : PropertyHandle(name) -} - -/** - * Syntactic sugar for Map<[String], [PropertyHandle]> - * - * @author Benjozork - */ -typealias PropMap = Map - -/** - * Builds a [property map][PropMap] on the receiver [KClass] - * - * @receiver the [class][KClass] for which the [PropMap] should be built - * - * @return the generated [PropMap] - * - * @author Benjozork - */ -@Suppress("UNCHECKED_CAST") -fun KClass.buildPropMap(): PropMap { - return this.declaredMemberProperties - .asSequence() - .associateBy { - it.name - }.mapValues { (name, self) -> - if (self.findAnnotation() != null) { - PropertyHandle.AccessDenied(name) - } else { - if (self.returnType.findAnnotation() != null) { - val regex = Regex(self.returnType.findAnnotation()!!.pattern) - PropertyHandle.Ok(name, regex, self as KProperty1) - } else { - PropertyHandle.Ok(name, null, self as KProperty1) - } - } - }.also { logger.debug("built propmap for class ${this.simpleName}".green()) } -} - -/** - * A cache storing computed [property maps][PropMap] for various [classes][KClass], using the [class][KClass] itself as a key - * - * @author Benjozork - */ -private val propMapCache: MutableMap, PropMap> = mutableMapOf() - -/** - * 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 M.cachedPropMap(): PropMap { - var cached: PropMap? = propMapCache[this::class] - if (cached == null) { - cached = this::class.buildPropMap() - propMapCache[this::class] = cached - } - - 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) { - cached = this.buildPropMap() - propMapCache[this] = cached - } - - 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/models/Mapped.kt b/src/blogify/backend/resources/slicing/models/Mapped.kt deleted file mode 100644 index 58412c97..00000000 --- a/src/blogify/backend/resources/slicing/models/Mapped.kt +++ /dev/null @@ -1,5 +0,0 @@ -package blogify.backend.resources.slicing.models - -import blogify.backend.resources.slicing.PropMap - -open class Mapped() \ No newline at end of file diff --git a/src/blogify/backend/resources/static/fs/StaticFileHandler.kt b/src/blogify/backend/resources/static/fs/StaticFileHandler.kt index 599b93a1..028219d0 100644 --- a/src/blogify/backend/resources/static/fs/StaticFileHandler.kt +++ b/src/blogify/backend/resources/static/fs/StaticFileHandler.kt @@ -2,7 +2,6 @@ package blogify.backend.resources.static.fs import blogify.backend.resources.static.models.StaticData import blogify.backend.resources.static.models.StaticResourceHandle -import com.andreapivetta.kolor.red import io.ktor.http.ContentType @@ -10,6 +9,8 @@ import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.withContext import kotlin.random.Random +import com.andreapivetta.kolor.red + import org.slf4j.LoggerFactory import java.io.File diff --git a/src/blogify/backend/routes/articles/ArticleRoutes.kt b/src/blogify/backend/routes/articles/ArticleRoutes.kt deleted file mode 100644 index 52603cec..00000000 --- a/src/blogify/backend/routes/articles/ArticleRoutes.kt +++ /dev/null @@ -1,166 +0,0 @@ -@file:Suppress("DuplicatedCode") - -package blogify.backend.routes.articles - -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 io.ktor.client.request.* -import io.ktor.http.HttpStatusCode - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper - -fun Route.articles() { - - route("/articles") { - - get("/") { - fetchAll(ArticleService::getAll) - } - - get("/{uuid}") { - fetchWithId(ArticleService::get) - } - - get("/{uuid}/commentCount") { - countReferringToResource(ArticleService::get, CommentService::getReferring, Comments.article) - } - - get("/forUser/{username}") { - - val params = call.parameters - val username = params["username"] ?: error("Username is null") - val selectedPropertyNames = params["fields"]?.split(",")?.toSet() - - UserService.getMatching { Users.username eq username }.fold( - success = { - ArticleService.getMatching { Articles.createdBy eq it.single().uuid }.fold( - success = { articles -> - try { - selectedPropertyNames?.let { props -> - - call.respond(articles.map { it.slice(props) }) - - } ?: call.respond(articles.map { it.sanitize() }) - } catch (bruhMoment: Service.Exception) { - call.respondExceptionMessage(bruhMoment) - } - }, - failure = { call.respondExceptionMessage(it) } - ) - }, - failure = { call.respondExceptionMessage(it) } - ) - } - - delete("/{uuid}") { - 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 }, - 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 }, - 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 deleted file mode 100644 index 616560dc..00000000 --- a/src/blogify/backend/routes/handling/Handlers.kt +++ /dev/null @@ -1,586 +0,0 @@ -@file:Suppress("DuplicatedCode") - -/** - * Blogify resource API service wrappers - * ------------------------------------- - * - * Those functions are meant to be used to handle calls managing resources. - * - * For example, `fetchWithIdAndRespond` handles a request following these steps : - * - Make sure the query URL provides a UUID - * - If the calling endpoint specified an authentication predicate, execute it - * - If it doesn't pass, respond with 401 Unauthorized / 403 Forbidden -* - If it does, keep going -* - Fetch the resource with the provided UUID - * - Run a transformation function of type `(Resource) -> Any` if specified by the endpoint - * - Handle any errors that occurred during the fetch/transform process - * - If an error occurred, respond with an appropriate message - * - If no error occurred, respond with the resource. - * - * As you can see, those wrappers handle a *lot* of the request process, and cover for many edge cases. - * - * If you feel like some request was not handled in a way that seems logical (given that it isn't a problem with business logic), - * please try to improve the handling of requests at a higher level inside those wrappers first. - * - * @author Benjozork, hamza1311, Stan-Sst - */ - -package blogify.backend.routes.handling - -import blogify.backend.auth.handling.runAuthenticated -import blogify.backend.database.Uploadables -import blogify.backend.database.handling.query -import blogify.backend.resources.User -import blogify.backend.resources.models.Resource -import blogify.backend.resources.slicing.PropertyHandle -import blogify.backend.resources.slicing.cachedPropMap -import blogify.backend.resources.slicing.sanitize -import blogify.backend.resources.slicing.slice -import blogify.backend.resources.static.fs.StaticFileHandler -import blogify.backend.resources.static.models.StaticData -import blogify.backend.resources.static.models.StaticResourceHandle -import blogify.backend.services.models.ResourceResult -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 -import blogify.backend.util.short -import blogify.backend.util.toUUID - -import io.ktor.application.ApplicationCall -import io.ktor.application.call -import io.ktor.http.HttpStatusCode -import io.ktor.response.respond -import io.ktor.util.pipeline.PipelineContext -import io.ktor.request.ContentTransformationException -import io.ktor.request.receive -import io.ktor.http.ContentType -import io.ktor.http.content.PartData -import io.ktor.http.content.forEachPart -import io.ktor.http.content.streamProvider -import io.ktor.request.receiveMultipart - -import com.github.kittinunf.result.coroutines.SuspendableResult - -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.Column -import org.jetbrains.exposed.sql.deleteWhere -import org.jetbrains.exposed.sql.insert -import org.jetbrains.exposed.sql.select - -import org.slf4j.Logger -import org.slf4j.LoggerFactory - -import java.util.UUID - -import kotlin.reflect.KClass -import kotlin.reflect.full.findAnnotation -import kotlin.reflect.full.isSuperclassOf - -private val logger: Logger = LoggerFactory.getLogger("blogify-service-wrapper") - -/** - * The default predicate used by the wrappers in this file - */ -val defaultPredicateLambda: suspend (user: User, res: Resource) -> Boolean = { _, _ -> true } - -/** - * The default resource-less predicate used by the wrappers in this file - */ -val defaultResourceLessPredicateLambda: suspend (user: User) -> Boolean = { _ -> true } - -/** - * Sends an object describing an exception as a response - */ -suspend fun ApplicationCall.respondExceptionMessage(ex: Service.Exception) { - respond(HttpStatusCode.InternalServerError, object { @Suppress("unused") val message = ex.message }) // Failure ? Send a simple object with the exception message. -} - -fun logUnusedAuth(func: String) { - logger.debug("${"skipped auth for $func".yellow()} - endpoint didn't request auth") -} - -/** - * Adds a handler to a [CallPipeline] that handles fetching a set of resources with a certain list of desired properties. - * - * Requires a [Map] of specific property names to be passed in the query URL. - * - * **WARNING:** Those property names must *exactly* match property names present in the class of the specific resource type. - *Users - * @param fetch the [function][Function] that retrieves the resources - * - * @author hamza1311, Benjozork - */ -@BlogifyDsl -suspend fun PipelineContext.fetchAll ( - fetch: suspend (ApplicationCall, Int) -> ResourceResultSet -) { - val params = call.parameters - val limit = params["amount"]?.toInt() ?: 25 - val selectedPropertyNames = params["fields"]?.split(",")?.toSet() - - if (selectedPropertyNames == null) - logger.debug("slicer: getting all fields".magenta()) - else - logger.debug("slicer: getting fields $selectedPropertyNames".magenta()) - - fetch(call, limit).fold ( - success = { resources -> - try { - selectedPropertyNames?.let { props -> - - call.respond(resources.map { it.slice(props) }) - - } ?: call.respond(resources.map { it.sanitize() }) - } catch (bruhMoment: Service.Exception) { - call.respondExceptionMessage(bruhMoment) - } - }, - failure = call::respondExceptionMessage - ) -} - -/** - * Adds a handler to a [CallPipeline] that handles fetching a resource. - * - * Requires a [UUID] to be passed in the query URL. - * - * @param R the type of [Resource] to be fetched - * @param fetch the [function][Function] that retrieves the resource - * @param authPredicate the [function][Function] that should be run to authenticate the client - * - * @author Benjozork, hamza1311 - */ -@BlogifyDsl -suspend fun CallPipeline.fetchWithId ( - fetch: suspend (ApplicationCall, UUID) -> ResourceResult, - authPredicate: suspend (User) -> Boolean = defaultResourceLessPredicateLambda -) { - val params = call.parameters - - params["uuid"]?.let { id -> // Check if the query URL provides any UUID - val selectedPropertyNames = params["fields"]?.split(",")?.toSet() - - if (selectedPropertyNames == null) - logger.debug("slicer: getting all fields".magenta()) - else - logger.debug("slicer: getting fields $selectedPropertyNames".magenta()) - - val doFetch: CallPipeLineFunction = { - fetch.invoke(call, id.toUUID()).fold ( - success = { fetched -> - try { - selectedPropertyNames?.let { props -> - - call.respond(fetched.slice(props)) - - } ?: call.respond(fetched.sanitize()) - } catch (bruhMoment: Service.Exception) { - call.respondExceptionMessage(bruhMoment) - } - }, - failure = call::respondExceptionMessage - ) - } - - if (authPredicate != defaultResourceLessPredicateLambda) { // Don't authenticate if the endpoint doesn't authenticate - runAuthenticated(predicate = authPredicate, block = doFetch) - } else { - logUnusedAuth("fetchWithIdAndRespond") - doFetch(this, Unit) - } - } ?: call.respond(HttpStatusCode.BadRequest) // If not, send Bad Request. -} - -/** - * Adds a handler to a [CallPipeline] that handles fetching all the available resources that are related to a particular resource. - * - * Requires a [UUID] to be passed in the query URL. - * - * @param R the type of [Resource] to be fetched - * @param fetch the [function][Function] that retrieves the resources using the [ID][UUID] of another resource - * @param transform a transformation [function][Function] that transforms the [resources][Resource] before sending them back to the client - * @param authPredicate the [function][Function] that should be run to authenticate the client. If omitted, no authentication is performed. - * - * @author Benjozork - */ -@BlogifyDsl -suspend fun CallPipeline.fetchAllWithId ( - fetch: suspend (UUID) -> ResourceResultSet, - transform: suspend (R) -> Resource = { it }, - authPredicate: suspend (User) -> Boolean = defaultResourceLessPredicateLambda -) { - val params = call.parameters - - params["uuid"]?.let { id -> // Check if the query URL provides any UUID - - val selectedPropertyNames = params["fields"]?.split(",")?.toSet() - - if (selectedPropertyNames == null) - logger.debug("slicer: getting all fields".magenta()) - else - logger.debug("slicer: getting fields $selectedPropertyNames".magenta()) - - val doFetch: CallPipeLineFunction = { - fetch.invoke(id.toUUID()).fold ( - success = { fetchedSet -> - if (fetchedSet.isNotEmpty()) { - SuspendableResult.of, Service.Exception> { - fetchedSet.map { transform.invoke(it) }.toSet() // Cover for any errors in transform() - }.fold ( - success = { fetched -> - try { - selectedPropertyNames?.let { props -> - - call.respond(fetched.map { it.slice(props) }) - - } ?: call.respond(fetched.map { it.sanitize() }) - } catch (bruhMoment: Service.Exception) { - call.respondExceptionMessage(bruhMoment) - } - }, - failure = call::respondExceptionMessage - ) - } else { - call.respond(HttpStatusCode.NoContent) - } - }, - failure = call::respondExceptionMessage - ) - } - - if (authPredicate != defaultResourceLessPredicateLambda) { // Don't authenticate if the endpoint doesn't authenticate - runAuthenticated(predicate = authPredicate, block = doFetch) // Run provided predicate on authenticated user and provided resource, then run doFetch if the predicate matches - } else { - logUnusedAuth("fetchAllWithId") - doFetch(this, Unit) // Run doFetch without checking predicate - } - - } ?: call.respond(HttpStatusCode.BadRequest) // If not, send Bad Request. -} - -@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE") -@BlogifyDsl -suspend inline fun CallPipeline.uploadToResource ( - crossinline fetch: suspend (ApplicationCall, UUID) -> ResourceResult, - crossinline modify: suspend (R, StaticResourceHandle) -> R, - crossinline update: suspend (R) -> ResourceResult<*>, - noinline authPredicate: suspend (User, R) -> Boolean = defaultPredicateLambda -) = pipeline("uuid", "target") { (uuid, target) -> - - // Find target resource - val targetResource = fetch(call, UUID.fromString(uuid)) - .getOrPipelineError(message = "couldn't fetch resource") // Handle result - - handleAuthentication("uploadToResource", { authPredicate(it, targetResource) }) { - - val targetClass = R::class - - // Find target property - val targetPropHandle = targetClass.cachedPropMap()[target] - ?.takeIf { - it is PropertyHandle.Ok - && StaticResourceHandle::class.isSuperclassOf(it.property.returnType.classifier as KClass<*>) - } as? PropertyHandle.Ok - ?: pipelineError ( - message = "can't find property of type StaticResourceHandle '$target' on class '${targetClass.simpleName}'" - ) - - // Receive data - val multiPartData = call.receiveMultipart() - - var fileContentType: ContentType = ContentType.Application.Any - var fileBytes = byteArrayOf() - - multiPartData.forEachPart { part -> - when (part) { - is PartData.FileItem -> { - part.streamProvider().use { input -> fileBytes = input.readBytes() } - fileContentType = part.contentType ?: ContentType.Application.Any - } - } - } - - // Check content type - val propContentType = targetPropHandle.property.returnType - .findAnnotation() - ?.contentType?.letCatchingOrNull(ContentType.Companion::parse) ?: ContentType.Any - - if (!(propContentType matches fileContentType)) { - pipelineError ( // Throw an error - HttpStatusCode.UnsupportedMediaType, - "property '${targetPropHandle.property.name}' of class '${targetClass.simpleName}' does not accept content type '$fileContentType'" - ) - } - - // Write to file - val newHandle = StaticFileHandler.writeStaticResource ( - StaticData(fileContentType, fileBytes) - ) - - query { - Uploadables.insert { - it[fileId] = newHandle.fileId - it[contentType] = newHandle.contentType.toString() - } - }.getOrPipelineError(HttpStatusCode.InternalServerError, "error while writing static resource to db") - - // idk - temporary - val rep = modify(targetResource, newHandle) - - update(rep) - .getOrPipelineError(HttpStatusCode.InternalServerError, "error while updating resource ${targetResource.uuid.short()} with new information") - - call.respond(newHandle.toString()) - - } - -} - -@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, - noinline authPredicate: suspend (User, R) -> Boolean = defaultPredicateLambda -) = pipeline("uuid", "target") { (uuid, target) -> - - // Find target resource - val targetResource = fetch(call, UUID.fromString(uuid)) - .getOrPipelineError(message = "couldn't fetch resource") // Handle result - - handleAuthentication("uploadToResource", { authPredicate(it, targetResource) }) { - - val targetClass = R::class - - // Find target property - val targetPropHandle = targetClass.cachedPropMap()[target] - ?.takeIf { - it is PropertyHandle.Ok - && StaticResourceHandle::class.isSuperclassOf(it.property.returnType.classifier as KClass<*>) - } as? PropertyHandle.Ok - ?: pipelineError ( - message = "can't find property of type StaticResourceHandle '$target' on class '${targetClass.simpleName}'" - ) - - when (val targetPropHandleValue = targetPropHandle.property.get(targetResource) as StaticResourceHandle) { - is StaticResourceHandle.Ok -> { - - val uploadableId = targetPropHandleValue.fileId - - // Fake handle - 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") - - } - is StaticResourceHandle.None -> { - call.respond(HttpStatusCode.NotFound) - return@handleAuthentication - } - } - - } - -} - -/** - * Adds a handler to a [CallPipeline] that handles creating a new resource. - * - * @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, 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 doAfter: suspend (R) -> Unit = {} -) = pipeline { - try { - - val received = call.receive() // Receive a resource from the request body - - val verifiedReceived = received.verify() // Verify received resource - verifiedReceived.entries - .firstOrNull { !it.value } // Find any that were not valid - ?.run { pipelineError(HttpStatusCode.BadRequest, "invalid value for property '${key.name}'") } - - val doCreate: CallPipeLineFunction = { - val res = create(received) - - res.fold ( - success = { - call.respond(HttpStatusCode.Created, it) - doAfter(it) - }, - failure = call::respondExceptionMessage - ) - } - - if (authPredicate != defaultPredicateLambda) { // Don't authenticate if the endpoint doesn't authenticate - runAuthenticated(predicate = { u -> authPredicate(u, received) }, block = doCreate) // Run provided predicate on authenticated user and provided resource, then run doCreate if the predicate matches - } else { - logUnusedAuth("createWithResource") - doCreate(this, Unit) // Run doCreate without checking predicate - } - - } catch (e: ContentTransformationException) { - call.respond(HttpStatusCode.BadRequest) - } -} // KT-33440 | Doesn't compile when lambda called with invoke() for now - -/** - * Adds a handler to a [CallPipeline] that handles deleting a new resource. - * - * Requires a [UUID] to be passed in the query URL. - * - * @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, hamza1311 - */ -@BlogifyDsl -suspend fun CallPipeline.deleteWithId ( - fetch: suspend (ApplicationCall, UUID) -> ResourceResult, - delete: suspend (UUID) -> ResourceResult<*>, - authPredicate: suspend (User, R) -> Boolean = defaultPredicateLambda, - doAfter: suspend (String) -> Unit = {} -) { - call.parameters["uuid"]?.let { id -> - - val doDelete: CallPipeLineFunction = { - delete.invoke(id.toUUID()).fold ( - success = { - call.respond(HttpStatusCode.OK) - doAfter(id) - }, - failure = call::respondExceptionMessage - ) - } - - if (authPredicate != defaultPredicateLambda) { // Optimization : fetch is only necessary if a predicate is defined - fetch.invoke(call, id.toUUID()).fold ( - success = { - runAuthenticated(predicate = { u -> authPredicate(u, it)}, block = doDelete) // Run provided predicate on authenticated user and provided resource, then run doDelete if the predicate matches - }, failure = call::respondExceptionMessage - ) - } else { - logUnusedAuth("deleteWithId") - doDelete(this, Unit) // Run doDelete without checking predicate - } - - } ?: 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 - */ -@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE") -@BlogifyDsl -suspend inline fun CallPipeline.updateWithId ( - noinline update: suspend (R) -> ResourceResult<*>, - fetch: suspend (ApplicationCall, UUID) -> ResourceResult, - noinline authPredicate: suspend (User, R) -> Boolean = defaultPredicateLambda, - noinline doAfter: suspend (R) -> Unit = {} -) { - - val replacement = call.receive() - - 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 - runAuthenticated ( - predicate = { u -> authPredicate(u, replacement) }, - block = doUpdate - ) // Run provided predicate on authenticated user and provided resource, then run doCreate if the predicate matches - } else { - logUnusedAuth("createWithResource") - doUpdate(this, Unit) // Run doCreate without checking predicate - } - }, - failure = call::respondExceptionMessage - ) - -} - -/** - * 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 deleted file mode 100644 index cbe8dca9..00000000 --- a/src/blogify/backend/routes/users/UserRoutes.kt +++ /dev/null @@ -1,123 +0,0 @@ -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.* - -/** - * Defines the API routes for interacting with [users][User]. - */ -fun Route.users() { - - route("/users") { - - get("/") { - fetchAll(UserService::getAll) - } - - get("/{uuid}") { - fetchWithId(UserService::get) - } - - delete("/{uuid}") { - 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}") { - updateWithId(UserService::update, UserService::get, authPredicate = { _, _ -> true }) - } - - get("/byUsername/{username}") { - val params = call.parameters - val username = params["username"] ?: error("Username is null") - val selectedPropertyNames = params["fields"]?.split(",")?.toSet() - - UserService.getMatching { Users.username eq username }.fold( - success = { - val user = it.single() - try { - selectedPropertyNames?.let { props -> - - call.respond(user.slice(props)) - - } ?: call.respond(user.sanitize()) - } catch (bruhMoment: Service.Exception) { - call.respondExceptionMessage(bruhMoment) - } - }, - failure = { call.respondExceptionMessage(it) } - ) - - } - - post("/profilePicture/{uuid}") { - uploadToResource ( - fetch = UserService::get, - modify = { r, h -> r.copy(profilePicture = h) }, - update = UserService::update, - authPredicate = { user, manipulated -> user eqr manipulated } - ) - } - - delete("/profilePicture/{uuid}") { - deleteOnResource ( - fetch = UserService::get, - authPredicate = { user, manipulated -> user eqr manipulated } - ) - } - - 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/routing/ArticleRoutes.kt b/src/blogify/backend/routing/ArticleRoutes.kt new file mode 100644 index 00000000..baf2685e --- /dev/null +++ b/src/blogify/backend/routing/ArticleRoutes.kt @@ -0,0 +1,172 @@ +@file:Suppress("DuplicatedCode") + +package blogify.backend.routing + +import blogify.backend.auth.handling.runAuthenticated +import blogify.backend.database.Articles +import blogify.backend.database.Users +import blogify.backend.database.handling.query +import blogify.backend.resources.Article +import blogify.backend.resources.models.eqr +import blogify.backend.resources.reflect.cachedPropMap +import blogify.backend.resources.reflect.models.ext.ok +import blogify.backend.resources.reflect.sanitize +import blogify.backend.resources.reflect.slice +import blogify.backend.routing.pipelines.optionalParam +import blogify.backend.routing.pipelines.pipeline +import blogify.backend.routing.handling.createResource +import blogify.backend.routing.handling.deleteResource +import blogify.backend.routing.handling.fetchAllResources +import blogify.backend.routing.handling.fetchResource +import blogify.backend.routing.handling.getValidations +import blogify.backend.routing.handling.respondExceptionMessage +import blogify.backend.routing.handling.updateResource +import blogify.backend.search.Typesense +import blogify.backend.search.ext.asSearchView +import blogify.backend.services.UserService +import blogify.backend.services.ArticleService +import blogify.backend.services.models.Service +import blogify.backend.util.getOrPipelineError +import blogify.backend.util.reason +import blogify.backend.util.toUUID + +import io.ktor.application.call +import io.ktor.http.HttpStatusCode +import io.ktor.routing.* +import io.ktor.response.respond + +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.deleteWhere +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.select + +fun Route.articles() { + + route("/articles") { + + get("/") { + fetchAllResources
() + } + + get("/{uuid}") { + fetchResource
() + } + + val likes = Articles.Likes + + get("/{uuid}/like") { + pipeline("uuid") { (id) -> + runAuthenticated { + val article = ArticleService.get(call, id.toUUID()) + .getOrPipelineError(HttpStatusCode.NotFound, "couldn't fetch article") + + val liked = query { + likes.select { + (likes.article eq article.uuid) and (likes.user eq subject.uuid) }.count() + }.getOrPipelineError() == 1; + + call.respond(liked) + } + } + } + + post("/{uuid}/like") { + pipeline("uuid") { (id) -> + runAuthenticated { + val articleToLike = ArticleService.get(call, id.toUUID()) + .getOrPipelineError(HttpStatusCode.NotFound, "couldn't fetch article") + + // Figure whether the article was already liked by the user + val alreadyLiked = query { + likes.select { + (likes.article eq articleToLike.uuid) and (likes.user eq subject.uuid) }.count() + }.getOrPipelineError() == 1 + + if (!alreadyLiked) { // Add a like if none were present + query { + likes.insert { + it[article] = articleToLike.uuid + it[user] = subject.uuid + } + }.getOrPipelineError(HttpStatusCode.InternalServerError, "couldn't like article") + + call.respond(HttpStatusCode.OK, reason("article liked")) + } else { // Remove an existing like if there was one + query { + likes.deleteWhere { + (likes.article eq articleToLike.uuid) and (likes.user eq subject.uuid) + } + }.getOrPipelineError(HttpStatusCode.InternalServerError, "couldn't unlike article") + + call.respond(HttpStatusCode.OK, reason("article unliked")) + } + } + } + } + + get("/forUser/{username}") { + + val params = call.parameters + val username = params["username"] ?: error("Username is null") + val selectedPropertyNames = params["fields"]?.split(",")?.toSet() + + UserService.getMatching { Users.username eq username }.fold ( + success = { + ArticleService.getMatching { Articles.createdBy eq it.single().uuid }.fold ( + success = { articles -> + try { + selectedPropertyNames?.let { props -> + + call.respond(articles.map { it.slice(props) }) + + } ?: call.respond(articles.map { it.sanitize() }) + } catch (bruhMoment: Service.Exception) { + call.respondExceptionMessage(bruhMoment) + } + }, + failure = { call.respondExceptionMessage(it) } + ) + }, + failure = { call.respondExceptionMessage(it) } + ) + } + + delete("/{uuid}") { + deleteResource
( + authPredicate = { user, article -> article.createdBy == user } + ) + } + + patch("/{uuid}") { + updateResource
( + authPredicate = { user, article -> article.createdBy eqr user } + ) + } + + post("/") { + createResource
( + authPredicate = { user, article -> article.createdBy eqr user } + ) + } + + get("/search") { + pipeline("q") { (query) -> + val user = optionalParam("byUser")?.toUUID() + if (user != null) { + val userHandle = Article::class.cachedPropMap().ok()["createdBy"] ?: error("a") + call.respond(Typesense.search
(query, mapOf(userHandle to user)).asSearchView()) + } else { + call.respond(Typesense.search
(query).asSearchView()) + } + } + } + + get("_validations") { + getValidations
() + } + + articleComments() + + } + +} diff --git a/src/blogify/backend/routes/AuthRoutes.kt b/src/blogify/backend/routing/AuthRoutes.kt similarity index 78% rename from src/blogify/backend/routes/AuthRoutes.kt rename to src/blogify/backend/routing/AuthRoutes.kt index e0168914..bcae02e4 100644 --- a/src/blogify/backend/routes/AuthRoutes.kt +++ b/src/blogify/backend/routing/AuthRoutes.kt @@ -1,4 +1,4 @@ -package blogify.backend.routes +package blogify.backend.routing import blogify.backend.annotations.check import blogify.backend.auth.encoder @@ -6,20 +6,14 @@ 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.routing.handling.respondExceptionMessage +import blogify.backend.search.Typesense 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 @@ -66,22 +60,12 @@ data class RegisterCredentials ( password = this.password.hash(), email = this.email, name = this.name, - profilePicture = StaticResourceHandle.None(ContentType.Image.PNG) + profilePicture = StaticResourceHandle.None(ContentType.Image.PNG), + coverPicture = StaticResourceHandle.None(ContentType.Image.PNG) ) - UserService.add(created).fold( - 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) } - } - }, + UserService.add(created).fold ( + success = { user -> Typesense.uploadResource(user) }, failure = { error("$created: signup couldn't create user\nError:$it") } diff --git a/src/blogify/backend/routes/articles/CommentRoutes.kt b/src/blogify/backend/routing/CommentRoutes.kt similarity index 55% rename from src/blogify/backend/routes/articles/CommentRoutes.kt rename to src/blogify/backend/routing/CommentRoutes.kt index 0fc74a16..0b376bca 100644 --- a/src/blogify/backend/routes/articles/CommentRoutes.kt +++ b/src/blogify/backend/routing/CommentRoutes.kt @@ -1,13 +1,19 @@ -package blogify.backend.routes.articles +package blogify.backend.routing import io.ktor.application.call import io.ktor.response.respond import io.ktor.routing.* import blogify.backend.database.Comments +import blogify.backend.resources.Comment import blogify.backend.resources.models.eqr -import blogify.backend.routes.handling.* -import blogify.backend.services.articles.CommentService +import blogify.backend.routing.handling.createResource +import blogify.backend.routing.handling.deleteResource +import blogify.backend.routing.handling.fetchAllResources +import blogify.backend.routing.handling.fetchAllWithId +import blogify.backend.routing.handling.fetchResource +import blogify.backend.routing.handling.updateResource +import blogify.backend.services.CommentService import blogify.backend.util.expandCommentNode import blogify.backend.util.toUUID @@ -18,29 +24,35 @@ fun Route.articleComments() { route("/comments") { get("/") { - fetchAll(CommentService::getAll) + fetchAllResources() } get("/{uuid}") { + fetchResource() + } + + get("/article/{uuid}") { fetchAllWithId(fetch = { articleId -> CommentService.getMatching(call) { Comments.article eq articleId and Comments.parentComment.isNull() } }) } delete("/{uuid}") { - deleteWithId(CommentService::get, CommentService::delete, authPredicate = { user, comment -> comment.commenter eqr user }) + deleteResource ( + authPredicate = { user, comment -> comment.commenter eqr user } + ) } patch("/{uuid}") { - updateWithId ( - update = CommentService::update, - fetch = CommentService::get, + updateResource ( authPredicate = { user, comment -> comment.commenter eqr user } ) } post("/") { - createWithResource(CommentService::add, authPredicate = { user, comment -> comment.commenter eqr user }) + createResource ( + authPredicate = { user, comment -> comment.commenter eqr user } + ) } get("/tree/{uuid}") { diff --git a/src/blogify/backend/routes/StaticRoutes.kt b/src/blogify/backend/routing/StaticRoutes.kt similarity index 50% rename from src/blogify/backend/routes/StaticRoutes.kt rename to src/blogify/backend/routing/StaticRoutes.kt index 88759856..0bfbfafc 100644 --- a/src/blogify/backend/routes/StaticRoutes.kt +++ b/src/blogify/backend/routing/StaticRoutes.kt @@ -1,30 +1,14 @@ -package blogify.backend.routes +package blogify.backend.routing -import blogify.backend.database.Uploadables -import blogify.backend.database.handling.query -import blogify.backend.resources.models.eqr import blogify.backend.resources.static.fs.StaticFileHandler -import blogify.backend.routes.pipelines.CallPipeLineFunction -import blogify.backend.routes.handling.uploadToResource -import blogify.backend.routes.pipelines.handleAuthentication -import blogify.backend.routes.pipelines.pipeline -import blogify.backend.routes.pipelines.pipelineError -import blogify.backend.services.UserService +import blogify.backend.routing.pipelines.pipeline import io.ktor.application.call import io.ktor.http.HttpStatusCode import io.ktor.response.respond import io.ktor.response.respondBytes import io.ktor.routing.Route -import io.ktor.routing.delete import io.ktor.routing.get -import io.ktor.routing.post - -import org.jetbrains.exposed.sql.deleteWhere -import org.jetbrains.exposed.sql.select - -import com.github.kittinunf.result.coroutines.failure -import com.github.kittinunf.result.coroutines.map fun Route.static() { diff --git a/src/blogify/backend/routing/admin/AdminSearch.kt b/src/blogify/backend/routing/admin/AdminSearch.kt new file mode 100644 index 00000000..3a57d4ee --- /dev/null +++ b/src/blogify/backend/routing/admin/AdminSearch.kt @@ -0,0 +1,34 @@ +package blogify.backend.routing.admin + +import blogify.backend.auth.handling.runAuthenticated +import blogify.backend.resources.Article +import blogify.backend.resources.User +import blogify.backend.search.Typesense + +import io.ktor.application.call +import io.ktor.client.call.receive +import io.ktor.response.respond +import io.ktor.routing.Route +import io.ktor.routing.get +import io.ktor.routing.route + +fun Route.adminSearch() { + + route("/admin/search") { + + get("/reindex") { + val what = call.parameters["what"] ?: error("bruh") + runAuthenticated(predicate = { it.isAdmin }) { + when(what) { + "article" -> Typesense.refreshIndex
() + "user" -> Typesense.refreshIndex() + else -> error("Wrong param provided") + }.let { + call.respond(mapOf("ts_response" to it.receive>())) + } + } + } + + } + +} \ No newline at end of file diff --git a/src/blogify/backend/routing/handling/Handlers.kt b/src/blogify/backend/routing/handling/Handlers.kt new file mode 100644 index 00000000..2eea526a --- /dev/null +++ b/src/blogify/backend/routing/handling/Handlers.kt @@ -0,0 +1,521 @@ +@file:Suppress("DuplicatedCode") + +/** + * Blogify resource API service wrappers + * ------------------------------------- + * + * Those functions are meant to be used to handle calls managing resources. + * + * For example, `fetchWithIdAndRespond` handles a request following these steps : + * - Make sure the query URL provides a UUID + * - If the calling endpoint specified an authentication predicate, execute it + * - If it doesn't pass, respond with 401 Unauthorized / 403 Forbidden + * - If it does, keep going + * - Fetch the resource with the provided UUID + * - Run a transformation function of type `(Resource) -> Any` if specified by the endpoint + * - Handle any errors that occurred during the fetch/transform process + * - If an error occurred, respond with an appropriate message + * - If no error occurred, respond with the resource. + * + * As you can see, those wrappers handle a *lot* of the request process, and cover for many edge cases. + * + * If you feel like some request was not handled in a way that seems logical (given that it isn't a problem with business logic), + * please try to improve the handling of requests at a higher level inside those wrappers first. + * + * @author Benjozork, hamza1311, Stan-Sst + */ + +package blogify.backend.routing.handling + +import blogify.backend.database.Uploadables +import blogify.backend.database.handling.query +import blogify.backend.resources.User +import blogify.backend.resources.models.Resource +import blogify.backend.resources.reflect.cachedPropMap +import blogify.backend.resources.reflect.sanitize +import blogify.backend.resources.reflect.slice +import blogify.backend.resources.static.fs.StaticFileHandler +import blogify.backend.resources.static.models.StaticData +import blogify.backend.resources.static.models.StaticResourceHandle +import blogify.backend.services.models.Service +import blogify.backend.annotations.BlogifyDsl +import blogify.backend.annotations.maxByteSize +import blogify.backend.annotations.type +import blogify.backend.resources.reflect.models.Mapped +import blogify.backend.resources.reflect.models.PropMap +import blogify.backend.resources.reflect.models.ext.ok +import blogify.backend.resources.reflect.verify +import blogify.backend.routing.pipelines.CallPipeline +import blogify.backend.routing.pipelines.fetchResource +import blogify.backend.routing.pipelines.fetchResources +import blogify.backend.routing.pipelines.handleAuthentication +import blogify.backend.routing.pipelines.optionalParam +import blogify.backend.routing.pipelines.pipeline +import blogify.backend.routing.pipelines.pipelineError +import blogify.backend.routing.pipelines.service +import blogify.backend.search.Typesense +import blogify.backend.util.SrList +import blogify.backend.util.filterThenMapValues +import blogify.backend.util.getOrPipelineError +import blogify.backend.util.letCatchingOrNull +import blogify.backend.util.matches +import blogify.backend.util.reason +import blogify.backend.util.reasons +import blogify.backend.util.short +import blogify.backend.util.toUUID + +import io.ktor.application.ApplicationCall +import io.ktor.application.call +import io.ktor.http.HttpStatusCode +import io.ktor.response.respond +import io.ktor.util.pipeline.PipelineContext +import io.ktor.request.ContentTransformationException +import io.ktor.request.receive +import io.ktor.http.ContentType +import io.ktor.http.content.PartData +import io.ktor.http.content.forEachPart +import io.ktor.http.content.streamProvider +import io.ktor.request.receiveMultipart + +import com.github.kittinunf.result.coroutines.SuspendableResult +import com.github.kittinunf.result.coroutines.failure +import com.github.kittinunf.result.coroutines.map + +import com.andreapivetta.kolor.magenta +import com.andreapivetta.kolor.yellow + +import org.jetbrains.exposed.sql.deleteWhere +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.select + +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +import java.util.UUID + +import kotlinx.coroutines.launch + +import kotlin.reflect.KClass +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.isSuperclassOf + +/* temp private */ val logger: Logger = LoggerFactory.getLogger("blogify-service-wrapper") + +/** + * The default predicate used by the wrappers in this file + */ +val defaultPredicateLambda: suspend (user: User, res: Resource) -> Boolean = { _, _ -> true } + +/** + * The default resource-less predicate used by the wrappers in this file + */ +val defaultResourceLessPredicateLambda: suspend (user: User) -> Boolean = { _ -> true } + +/** + * Sends an object describing an exception as a response + */ +suspend fun ApplicationCall.respondExceptionMessage(ex: Exception) { + respond(HttpStatusCode.InternalServerError, object { @Suppress("unused") val message = ex.message }) // Failure ? Send a simple object with the exception message. +} + +fun logUnusedAuth(func: String) { + logger.debug("${"skipped auth for $func".yellow()} - endpoint didn't request auth") +} + +/** + * Adds a handler to a [CallPipeline] that handles fetching a set of resources with a certain list of desired properties. + * + * Requires a [Map] of specific property names to be passed in the query URL. + * + * **WARNING:** Those property names must *exactly* match property names present in the class of the specific resource type. + * + * @author hamza1311, Benjozork + */ +@BlogifyDsl +suspend inline fun PipelineContext.fetchAllResources() = pipeline { + val limit = optionalParam("amount")?.toInt() ?: 25 + val selectedProperties = optionalParam("fields")?.split(",")?.toSet() + + val resources = fetchResources(service()::getAll, limit) + + if (selectedProperties == null) { + logger.debug("slicer: getting all fields".magenta()) + call.respond(resources.map { it.sanitize() }) + } else { + logger.debug("slicer: getting fields $selectedProperties".magenta()) + call.respond(resources.map { it.slice(selectedProperties) }) + } +} + +/** + * Adds a handler to a [CallPipeline] that handles fetching a resource. + * + * Requires a [UUID] to be passed in the query URL. + * + * @param R the type of [Resource] to be fetched + * @param authPredicate the [function][Function] that should be run to authenticate the client + * + * @author Benjozork, hamza1311 + */ +@BlogifyDsl +suspend inline fun CallPipeline.fetchResource ( + noinline authPredicate: suspend (User) -> Boolean = defaultResourceLessPredicateLambda +) = pipeline("uuid") { (uuid) -> + + val selectedProperties = optionalParam("fields")?.split(",")?.toSet() + + handleAuthentication("fetchWithIdAndRespond", authPredicate) { + + val resource = fetchResource(service()::get, uuid.toUUID()) + + if (selectedProperties == null) { + logger.debug("slicer: getting all fields".magenta()) + call.respond(resource.sanitize()) + } else { + logger.debug("slicer: getting fields $selectedProperties".magenta()) + call.respond(resource.slice(selectedProperties)) + } + + } +} + +/** + * Adds a handler to a [CallPipeline] that handles fetching all the available resources that are related to a particular resource. + * + * Requires a [UUID] to be passed in the query URL. + * + * @param R the type of [Resource] to be fetched + * @param fetch the [function][Function] that retrieves the resources using the [ID][UUID] of another resource + * @param transform a transformation [function][Function] that transforms the [resources][Resource] before sending them back to the client* + * @author Benjozork + */ +@BlogifyDsl +suspend fun CallPipeline.fetchAllWithId ( + fetch: suspend (UUID) -> SrList, + transform: suspend (R) -> Resource = { it } +) = pipeline("uuid") { (uuid) -> + + val selectedPropertyNames = optionalParam("fields")?.split(",")?.toSet() + + if (selectedPropertyNames == null) + logger.debug("slicer: getting all fields".magenta()) + else + logger.debug("slicer: getting fields $selectedPropertyNames".magenta()) + + fetch.invoke(uuid.toUUID()).fold ( + success = { fetchedSet -> + if (fetchedSet.isNotEmpty()) { + SuspendableResult.of, Service.Exception> { + fetchedSet.map { transform.invoke(it) }.toSet() // Cover for any errors in transform() + }.fold ( + success = { fetched -> + try { + selectedPropertyNames?.let { props -> + + call.respond(fetched.map { it.slice(props) }) + + } ?: call.respond(fetched.map { it.sanitize() }) + } catch (bruhMoment: Service.Exception) { + call.respondExceptionMessage(bruhMoment) + } + }, + failure = call::respondExceptionMessage + ) + } else { + call.respond(HttpStatusCode.NoContent) + } + }, + failure = call::respondExceptionMessage + ) + +} + +@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE") +@BlogifyDsl +suspend inline fun CallPipeline.uploadToResource ( + noinline authPredicate: suspend (User, R) -> Boolean = defaultPredicateLambda +) = pipeline("uuid", "target") { (uuid, target) -> + + // Find target resource + val targetResource = fetchResource(service()::get, uuid.toUUID()) + + handleAuthentication("uploadToResource", { authPredicate(it, targetResource) }) { + + val targetClass = R::class + + // Find target property + val targetPropHandle = targetClass.cachedPropMap()[target] + ?.takeIf { + it is PropMap.PropertyHandle.Ok + && StaticResourceHandle::class.isSuperclassOf(it.property.returnType.classifier as KClass<*>) + } as? PropMap.PropertyHandle.Ok + ?: pipelineError ( + message = "can't find property of type StaticResourceHandle '$target' on class '${targetClass.simpleName}'" + ) + + var shouldDelete = false + + // Check if there's already an uploaded file + val existingValue = targetPropHandle.property.get(targetResource) as StaticResourceHandle + if (existingValue is StaticResourceHandle.Ok) { + // Delete later, if successful + shouldDelete = true + } + + // Obtain property content type + val propContentType = targetPropHandle.property.returnType + .findAnnotation() + ?.contentType?.letCatchingOrNull(ContentType.Companion::parse) ?: ContentType.Any + + // Obtain property max size + val propMaxByteSize = targetPropHandle.property.returnType + .findAnnotation() + ?.value ?: Long.MAX_VALUE + + // Receive data + val multiPartData = call.receiveMultipart() + + var fileContentType: ContentType = ContentType.Application.Any + var fileBytes = byteArrayOf() + + multiPartData.forEachPart { part -> + when (part) { + is PartData.FileItem -> { + if (part.headers["Content-Length"]?.toInt() ?: 0 > propMaxByteSize) { // Check data size + call.respond(HttpStatusCode.PayloadTooLarge, reason("file is too large")) + } else { + // Receive data + part.streamProvider().use { input -> fileBytes = input.readBytes() } + + if (fileBytes.size > propMaxByteSize) { // Check data size again + call.respond(HttpStatusCode.BadRequest, reasons("Content-Length header incorrect", "file is too large")) + } else { + fileContentType = part.contentType ?: ContentType.Application.Any + } + } + } + } + } + + if (fileContentType matches propContentType) { + + // Write to file + val newHandle = StaticFileHandler.writeStaticResource ( + StaticData(fileContentType, fileBytes) + ) + + query { + Uploadables.insert { + it[fileId] = newHandle.fileId + it[contentType] = newHandle.contentType.toString() + } + }.getOrPipelineError(HttpStatusCode.InternalServerError, "error while writing static resource to db") + + service().update(targetResource, mapOf(targetPropHandle to newHandle)) + .getOrPipelineError(HttpStatusCode.InternalServerError, "error while updating resource ${targetResource.uuid.short()} with new information") + + call.respond(newHandle.toString()) + + // Since at this point it was successful, we can delete + if (shouldDelete) { + val idToDelete = (existingValue as StaticResourceHandle.Ok).fileId + + // Delete in DB + + query { + Uploadables.deleteWhere { Uploadables.fileId eq idToDelete } + }.getOrPipelineError(HttpStatusCode.InternalServerError, "could not delete stale static resource $idToDelete from db") + + // Delete in FS + + if (!StaticFileHandler.deleteStaticResource(existingValue)) { + pipelineError(HttpStatusCode.InternalServerError, "couldn't delete stale static resource file") + } + + } + + } else { + pipelineError ( // Throw an error + HttpStatusCode.UnsupportedMediaType, + "property '${targetPropHandle.property.name}' of class '${targetClass.simpleName}' does not accept content type '$fileContentType'" + ) + } + + } + +} + +@BlogifyDsl +suspend inline fun CallPipeline.deleteUpload ( + noinline authPredicate: suspend (User, R) -> Boolean = defaultPredicateLambda +) = pipeline("uuid", "target") { (uuid, target) -> + + // Find target resource + val targetResource = fetchResource(service()::get, uuid.toUUID()) + + handleAuthentication("uploadToResource", { authPredicate(it, targetResource) }) { + + val targetClass = R::class + + // Find target property + val targetPropHandle = targetClass.cachedPropMap()[target] + ?.takeIf { + it is PropMap.PropertyHandle.Ok + && StaticResourceHandle::class.isSuperclassOf(it.property.returnType.classifier as KClass<*>) + } as? PropMap.PropertyHandle.Ok + ?: pipelineError ( + message = "can't find property of type StaticResourceHandle '$target' on class '${targetClass.simpleName}'" + ) + + when (val targetPropHandleValue = targetPropHandle.property.get(targetResource) as StaticResourceHandle) { + is StaticResourceHandle.Ok -> { + + val uploadableId = targetPropHandleValue.fileId + + // Fake handle + 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") + + } + is StaticResourceHandle.None -> { + call.respond(HttpStatusCode.NotFound) + return@handleAuthentication + } + } + + } + +} + +/** + * Adds a handler to a [CallPipeline] that handles creating a new resource. + * + * @param R the type of [Resource] to be created + * @param authPredicate the [function][Function] that should be run to authenticate the client. If omitted, no authentication is performed. + * + * @author Benjozork, hamza1311 + */ +@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE") +@BlogifyDsl +suspend inline fun CallPipeline.createResource ( + noinline authPredicate: suspend (User, R) -> Boolean = defaultPredicateLambda +) = pipeline { + try { + + val received = call.receive() // Receive a resource from the request body + + val firstInvalidValue = received.verify().entries.firstOrNull { !it.value } + if (firstInvalidValue != null) { // Check for any invalid data + pipelineError(HttpStatusCode.BadRequest, "invalid value for property '${firstInvalidValue.key.name}'") + } + + handleAuthentication(predicate = { u -> authPredicate(u, received) }) { + service().add(received).fold ( + success = { + call.respond(HttpStatusCode.Created, it.sanitize()) + launch { Typesense.uploadResource(it) } + }, + failure = call::respondExceptionMessage + ) + } + + } catch (e: ContentTransformationException) { + call.respond(HttpStatusCode.BadRequest) + } +} + +/** + * Adds a handler to a [CallPipeline] that handles deleting a new resource. + * + * Requires a [UUID] to be passed in the query URL. + * + * @param authPredicate the [function][Function] that should be run to authenticate the client. If omitted, no authentication is performed. + * + * @author Benjozork, hamza1311 + */ +@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE") +@BlogifyDsl +suspend inline fun CallPipeline.deleteResource ( + noinline authPredicate: suspend (User, R) -> Boolean = defaultPredicateLambda +) = pipeline("uuid") { (uuid) -> + + val toDelete = fetchResource(service()::get, uuid.toUUID()) + + handleAuthentication ( + funcName = "deleteWithId", + predicate = { user -> authPredicate(user, toDelete) } + ) { + service().delete(toDelete).fold ( + success = { + call.respond(HttpStatusCode.OK) + launch { Typesense.deleteResource(toDelete.uuid) } + }, + failure = call::respondExceptionMessage + ) + } + +} + +/** + * Adds a handler to a [CallPipeline] that handles updating a resource with the given uuid. + * + * @param R the type of [Resource] to be updated + * @param authPredicate the [function][Function] that should be run to authenticate the client. If omitted, no authentication is performed. + * + * @author hamza1311 + */ +@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE") +@BlogifyDsl +suspend inline fun CallPipeline.updateResource ( + noinline authPredicate: suspend (User, R) -> Boolean = defaultPredicateLambda +) { + + val replacement = call.receive>() + val current = fetchResource(service()::get, (replacement["uuid"] as String).toUUID()) + + val rawData = replacement.mapKeys { n -> R::class.cachedPropMap().ok().values.first { it.name == n.key } } + + handleAuthentication ( + funcName = "createWithResource", + predicate = { user -> authPredicate(user, current) } + ) { + service().update(current, rawData).fold ( + success = { + call.respond(HttpStatusCode.OK) + launch { Typesense.updateResource(it) } + }, + failure = { e -> + e.printStackTrace() + } + ) + } + +} + +/** + * 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().ok() + .filterThenMapValues ( + { it.regexCheck != null }, + { it.value.regexCheck!!.pattern } + ) + ) +} diff --git a/src/blogify/backend/routes/pipelines/Pipelines.kt b/src/blogify/backend/routing/pipelines/Pipelines.kt similarity index 68% rename from src/blogify/backend/routes/pipelines/Pipelines.kt rename to src/blogify/backend/routing/pipelines/Pipelines.kt index 64e76b92..1337fb0d 100644 --- a/src/blogify/backend/routes/pipelines/Pipelines.kt +++ b/src/blogify/backend/routing/pipelines/Pipelines.kt @@ -1,12 +1,16 @@ -package blogify.backend.routes.pipelines +package blogify.backend.routing.pipelines +import blogify.backend.annotations.PipelinesDsl import blogify.backend.auth.handling.UserAuthPredicate import blogify.backend.auth.handling.runAuthenticated -import blogify.backend.resources.User -import blogify.backend.routes.handling.defaultResourceLessPredicateLambda -import blogify.backend.routes.handling.logUnusedAuth -import blogify.backend.services.models.ResourceResult +import blogify.backend.resources.models.Resource +import blogify.backend.routing.handling.defaultResourceLessPredicateLambda +import blogify.backend.routing.handling.logUnusedAuth +import blogify.backend.util.Sr +import blogify.backend.util.SrList +import blogify.backend.util.getOrPipelineError import blogify.backend.util.reason +import blogify.backend.util.service import io.ktor.application.ApplicationCall import io.ktor.application.call @@ -18,6 +22,7 @@ import io.ktor.util.pipeline.PipelineInterceptor import com.andreapivetta.kolor.red import org.slf4j.LoggerFactory +import java.util.UUID private val logger = LoggerFactory.getLogger("blogify-pipeline-manager") @@ -54,6 +59,7 @@ class PipelineException(val code: HttpStatusCode, override val message: String) * * @author Benjozork */ +@PipelinesDsl suspend fun CallPipeline.pipeline(vararg wantedParams: String = emptyArray(), block: suspend CallPipeline.(Array) -> Unit) { try { block(this, wantedParams.map { param -> call.parameters[param] ?: pipelineError(message = "query parameter $param is null") }.toTypedArray()) @@ -70,6 +76,12 @@ suspend fun CallPipeline.pipeline(vararg wantedParams: String = emptyArray(), bl } } +/** + * Returns a query parameter that may or may not exist + */ +@PipelinesDsl +fun CallPipeline.optionalParam(name: String): String? = call.parameters[name] + /** * A default [CallPipeline] that handles client authentication. * @@ -79,15 +91,39 @@ suspend fun CallPipeline.pipeline(vararg wantedParams: String = emptyArray(), bl * * @author Benjozork */ +@PipelinesDsl suspend fun CallPipeline.handleAuthentication(funcName: String = "", predicate: UserAuthPredicate, block: CallPipeLineFunction) { if (predicate != defaultResourceLessPredicateLambda) { // Don't authenticate if the endpoint doesn't authenticate - runAuthenticated(predicate, block) + runAuthenticated(predicate, { block(this@handleAuthentication, Unit) }) } else { logUnusedAuth(funcName) block(this, Unit) } } +/** + * Simplifies fetching a resource from a [CallPipeline] + */ +@PipelinesDsl +suspend fun CallPipeline.fetchResource(fetcher: suspend (ApplicationCall, UUID) -> Sr, id: UUID): R { + return fetcher(call, id) + .getOrPipelineError(HttpStatusCode.InternalServerError, "couldn't fetch resource") +} + +/** + * Simplifies fetching resources from a [CallPipeline] + */ +@PipelinesDsl +suspend fun CallPipeline.fetchResources(fetcher: suspend (ApplicationCall, Int) -> SrList, limit: Int = 25): List { + return fetcher(call, limit) + .getOrPipelineError(HttpStatusCode.InternalServerError, "couldn't fetch resource") +} + +/** + * Get a [blogify.backend.services.models.Service] from a reified Resource type parameter + */ +inline fun service() = R::class.service + /** * Signals that a [CallPipeline] has encountered an error, and will stop being executed. * This function throws a [PipelineException], and therefore stops the entire pipeline call chain, entering its own request handler. @@ -101,6 +137,10 @@ suspend fun CallPipeline.handleAuthentication(funcName: String = "" * @author Benjozork */ fun pipelineError(code: HttpStatusCode = HttpStatusCode.BadRequest, message: String, rootException: Exception? = null): Nothing { - logger.debug("pipeline error - $message".red() + rootException?.let { " - ${it::class.simpleName} - ${it.message}".red() }) + logger.debug ( + "pipeline error - $message".red() + + (rootException?.let { " - ${it::class.simpleName} - ${it.message}".red() } ?: "") + ) + rootException?.printStackTrace() throw PipelineException(code, message) } diff --git a/src/blogify/backend/services/caching/ResourceCache.kt b/src/blogify/backend/routing/pipelines/caching/ResourceCache.kt similarity index 59% rename from src/blogify/backend/services/caching/ResourceCache.kt rename to src/blogify/backend/routing/pipelines/caching/ResourceCache.kt index 23b34566..07c141d1 100644 --- a/src/blogify/backend/services/caching/ResourceCache.kt +++ b/src/blogify/backend/routing/pipelines/caching/ResourceCache.kt @@ -1,8 +1,9 @@ -package blogify.backend.services.caching +package blogify.backend.routing.pipelines.caching import blogify.backend.resources.models.Resource import blogify.backend.resources.models.Resource.ObjectResolver.FakeApplicationCall -import blogify.backend.services.models.ResourceResult +import blogify.backend.util.Sr +import blogify.backend.util.Wrap import blogify.backend.util.short import io.ktor.application.ApplicationCall @@ -26,7 +27,7 @@ private val ResourceCacheKey = AttributeKey>("blogify /** * Gives access to an [ApplicationCall]'s [Resource] cache. */ -val ApplicationCall.cache: MutableMap +private val ApplicationCall.cache: MutableMap get() { return try { this.attributes[ResourceCacheKey] @@ -43,7 +44,21 @@ private fun ApplicationCall.createResourceCache() { this.attributes.put(ResourceCacheKey, mutableMapOf()) } -suspend fun ApplicationCall.cachedOrElse(id: UUID, fetcher: suspend () -> ResourceResult): ResourceResult { +/** + * Tries to fetched a cached version of a [Resource] of type [R] and UUID [id] from the [ApplicationCall], + * and caches the result of a fetcher function if it misses. + * + * @receiver the [ApplicationCall] on which to perform the lookup + * + * @param id the [UUID] for which to perform the lookup + * @param fetcher the function that would fetch the resource if there is a cache miss + * + * @return an [Wrap] of the requested resource + * + * @author Benjozork + */ +@Suppress("UNCHECKED_CAST") +suspend fun ApplicationCall.cachedOrElse(id: UUID, fetcher: suspend () -> Sr): Sr { if (this is FakeApplicationCall) { // Since a FakeApplicationCall does not have anything in it, do not try to access the cache return fetcher() } @@ -51,17 +66,14 @@ suspend fun ApplicationCall.cachedOrElse(id: UUID, fetcher: suspe val cached = this.cache[id] return if (cached == null) { - val fetchResult = fetcher() - - if (fetchResult is SuspendableResult.Success) { - this.cache[id] = fetchResult.get() - logger.debug("added ${id.toString().takeLast(8)} to call cache".green()) - fetchResult - } else { - fetchResult + fetcher().also { + if (it is SuspendableResult.Success) { // Add to cache if it's successfully fetched + this.cache[id] = it.get() + logger.debug("added ${id.short()} to call cache".green()) + } } } else { logger.debug("used cache for ${id.short()} !".green()) - SuspendableResult.of { cached as R } + Wrap { cached as R } } -} \ No newline at end of file +} diff --git a/src/blogify/backend/routing/users/UserRoutes.kt b/src/blogify/backend/routing/users/UserRoutes.kt new file mode 100644 index 00000000..d4089a98 --- /dev/null +++ b/src/blogify/backend/routing/users/UserRoutes.kt @@ -0,0 +1,137 @@ +package blogify.backend.routing.users + +import blogify.backend.auth.handling.runAuthenticated +import blogify.backend.database.Users +import blogify.backend.database.handling.query +import blogify.backend.resources.User +import blogify.backend.resources.models.eqr +import blogify.backend.resources.reflect.sanitize +import blogify.backend.resources.reflect.slice +import blogify.backend.routing.pipelines.fetchResource +import blogify.backend.routing.pipelines.pipeline +import blogify.backend.routing.handling.deleteResource +import blogify.backend.routing.handling.deleteUpload +import blogify.backend.routing.handling.fetchAllResources +import blogify.backend.routing.handling.fetchResource +import blogify.backend.routing.handling.respondExceptionMessage +import blogify.backend.routing.handling.updateResource +import blogify.backend.routing.handling.uploadToResource +import blogify.backend.search.Typesense +import blogify.backend.search.ext.asSearchView +import blogify.backend.services.UserService +import blogify.backend.services.models.Service +import blogify.backend.util.toUUID + +import io.ktor.application.call +import io.ktor.http.HttpStatusCode +import io.ktor.response.respond +import io.ktor.routing.* +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.deleteWhere +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.select + +/** + * Defines the API routes for interacting with [users][User]. + */ +fun Route.users() { + + route("/users") { + + get("/") { + fetchAllResources() + } + + get("/{uuid}") { + fetchResource() + } + + delete("/{uuid}") { + deleteResource ( + authPredicate = { user, manipulated -> user eqr manipulated } + ) + } + + patch("/{uuid}") { + updateResource ( + authPredicate = { user, replaced -> user eqr replaced } + ) + } + + get("/byUsername/{username}") { + val params = call.parameters + val username = params["username"] ?: error("Username is null") + val selectedPropertyNames = params["fields"]?.split(",")?.toSet() + + UserService.getMatching { Users.username eq username }.fold( + success = { + val user = it.single() + try { + selectedPropertyNames?.let { props -> + + call.respond(user.slice(props)) + + } ?: call.respond(user.sanitize()) + } catch (bruhMoment: Service.Exception) { + call.respondExceptionMessage(bruhMoment) + } + }, + failure = { call.respondExceptionMessage(it) } + ) + + } + + post("/upload/{uuid}") { + uploadToResource ( + authPredicate = { user, manipulated -> user eqr manipulated } + ) + } + + delete("/upload/{uuid}") { + deleteUpload(authPredicate = { user, manipulated -> user eqr manipulated }) + } + + get("/search") { + pipeline("q") { (query) -> + call.respond(Typesense.search(query).asSearchView()) + } + } + + post("{uuid}/follow") { + val follows = Users.Follows + + pipeline("uuid") { (uuid) -> + + val following = fetchResource(UserService::get, uuid.toUUID()) + + runAuthenticated { + + val hasAlreadyFollowed = query { + follows.select { + (follows.follower eq subject.uuid) and (follows.following eq following.uuid) + }.count() + }.get() == 1 + + if (!hasAlreadyFollowed) { + query { + follows.insert { + it[Users.Follows.follower] = subject.uuid + it[Users.Follows.following] = following.uuid + } + } + } else { + query { + follows.deleteWhere { + (follows.follower eq subject.uuid) and (follows.following eq following.uuid) + } + } + } + call.respond(HttpStatusCode.OK) + } + } + + } + + } + +} diff --git a/src/blogify/backend/search/Typesense.kt b/src/blogify/backend/search/Typesense.kt index 357275eb..5569146f 100644 --- a/src/blogify/backend/search/Typesense.kt +++ b/src/blogify/backend/search/Typesense.kt @@ -1,52 +1,310 @@ package blogify.backend.search +import blogify.backend.config.Configs +import blogify.backend.resources.models.Resource +import blogify.backend.resources.reflect.models.PropMap +import blogify.backend.resources.reflect.sanitize +import blogify.backend.routing.pipelines.pipelineError +import blogify.backend.routing.pipelines.service +import blogify.backend.search.ext.TEMPLATE_DEFAULT_DSF +import blogify.backend.search.ext._rebuildSearchTemplate +import blogify.backend.search.ext._searchTemplate +import blogify.backend.search.models.Search +import blogify.backend.search.models.Template +import blogify.backend.util.short + 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.client.response.HttpResponse import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import io.ktor.client.call.receive +import io.ktor.client.features.defaultRequest +import io.ktor.client.request.get +import io.ktor.client.request.delete import io.ktor.http.content.TextContent +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.module.SimpleModule + +import com.andreapivetta.kolor.green +import com.andreapivetta.kolor.red + +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +import java.util.UUID +import kotlin.random.Random + +val tscLogger: Logger = LoggerFactory.getLogger("blogify-typesense-client") + /** * Meta object regrouping setup and utility functions for the Typesense search engine. */ object Typesense { + private val config = Configs.Typesense + /** * Typesense REST API URL */ - private const val TYPESENSE_URL = "http://ts:8108" + val TYPESENSE_URL = "http://${config.host}:${config.port}" /** * Typesense API key HTTP header string */ private const val TYPESENSE_API_KEY_HEADER = "X-TYPESENSE-API-KEY" + lateinit var objectMapper: ObjectMapper + private val typesenseSerializer = JacksonSerializer { + // Register a serializer for Resource. + // This will only affect pure Resource objects, so elements produced by the slicer are not affected, + // since those don't use Jackson for root serialization. + + val blogifyModule = SimpleModule() + blogifyModule.addSerializer(Resource.ResourceIdSerializer) + registerModule(blogifyModule) + + objectMapper = this // Capture the objectMapper + } + + val typesenseClient = HttpClient { + install(JsonFeature) { + serializer = typesenseSerializer + } + + // Always include Typesense headers + defaultRequest { + header(TYPESENSE_API_KEY_HEADER, config.apiKey) + } + + // Allows us to read response even when status < 300 + expectSuccess = false + } + /** - * Typesense API key + * Builds the document to be sent to typesense. + * Checks if delegation exists. If it does, returns the delegated result; original value if it doesn't + * + * @return The sanitized document ready for typesense + * + * @author Benjozork */ - private const val TYPESENSE_API_KEY = "Hu52dwsas2AdxdE" + inline fun makeDocument(resource: R): Map { + val template = R::class._searchTemplate + + val documentEntries = (resource.sanitize(noSearch = true) + ("id" to resource.uuid)).entries + .map { + it.key to ( + template.delegatedFields + .firstOrNull { df -> df.name == it.key } // Check if we have a delegated field + ?.let { df -> df.delegatedTo!!.get(it.value) } ?: it.value + // Return the delegation result if there is; the original value if there is not. + ) + }.toMutableList() + + if (template.defaultSortingField == TEMPLATE_DEFAULT_DSF) + documentEntries.add(TEMPLATE_DEFAULT_DSF to Random.nextInt()) - private val typesenseClient = HttpClient { install(JsonFeature) { serializer = JacksonSerializer(); } } + return documentEntries.toMap() + } + + /** + * Get a message from a Typesense error response + */ + suspend fun typesenseMessage(response: HttpResponse) = response.receive>()["message"] as? String /** * Uploads a document template to the Typesense REST API * - * @param template the document template, in JSON format. + * @param R class associated with [template] + * @param template the document template. * See the [typesense docs](https://typesense.org/docs/0.11.0/api/#create-collection) for more info. * + * @author hamza1311, Benjozork + */ + suspend fun submitResourceTemplate(template: Template) { + typesenseClient.post { + url("$TYPESENSE_URL/collections") + contentType(ContentType.Application.Json) + + body = template + }.let { response -> + when (response.status) { + HttpStatusCode.Created, + HttpStatusCode.Conflict -> { // Both of those cases mean the template either already exists or was created + tscLogger.info("uploaded Typesense template '${template.name}'".green()) + } + else -> { + error("error while uploading Typesense template: ${template.name} ${typesenseMessage(response)}") + } + } + } + } + + /** + * Adds a [Resource] to the [Typesense] index + * + * @param R the class of the resource to upload + * @param resource the resource to upload + * * @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) } + suspend inline fun uploadResource(resource: R) { + val template = R::class._searchTemplate + + typesenseClient.post { + url("$TYPESENSE_URL/collections/${template.name}/documents") + contentType(ContentType.Application.Json) + + body = makeDocument(resource) + }.let { response -> + if (response.status.isSuccess()) { + tscLogger.trace("uploaded resource ${resource.uuid.short()} to Typesense index".green()) + } else { + tscLogger.error("couldn't upload resource ${resource.uuid.short()} to Typesense index: ${typesenseMessage(response)}".red()) + } } } -} \ No newline at end of file + /** + * Removes a [Resource] from the [Typesense] index + * + * @param R the class of the resource to delete + * @param id the id resource to delete + * + * @author Benjozork + */ + suspend inline fun deleteResource(id: UUID) { + val template = R::class._searchTemplate + + typesenseClient.delete { + url("$TYPESENSE_URL/collections/${template.name}/documents/$id") + }.let { response -> + if (response.status.isSuccess()) { + tscLogger.trace("deleted resource ${id.short()} from Typesense index".green()) + } else { + tscLogger.error("couldn't delete resource ${id.short()} from Typesense index: ${typesenseMessage(response)}".red()) + } + } + } + + /** + * Updates a [Resource] in the [Typesense] index + * + * @param R the class of the resource to update + * @param resource the resource to replace the previous resource of the same UUID with + * + * @author Benjozork + */ + suspend inline fun updateResource(resource: R) { + deleteResource(resource.uuid) + uploadResource(resource) + } + + /** + * Executes a search [query] for resources of type [R] + * + * @param R the type of resources to search for + * @param query the search query to use + * + * @return a [Search] containing the results + * + * @author Benjozork + */ + suspend inline fun search ( + query: String, + filters: Map = emptyMap() + ): Search { + val template = R::class._searchTemplate + val excludedFieldsString = template.fields + .joinToString(separator = ",") { it.name } + val filtersString = filters.takeIf { it.isNotEmpty() }?.entries + ?.joinToString(separator = "&&") { "${it.key.name}:${it.value}" } + + return typesenseClient.get { + url ( + TYPESENSE_URL + + "/collections/${template.name}" + + "/documents/search?q=$query" + + "&query_by=${template.queryByParams}" + + "&exclude_fields=$excludedFieldsString" + + if (filtersString != null) "&filter_by=$filtersString" else "" + ) + }.let { response -> + if (response.status.isSuccess()) { + return@let response.receive>() + } else { + tscLogger.error("couldn't search in Typesense index ${template.name}: ${typesenseMessage(response)}".red()) + pipelineError(HttpStatusCode.InternalServerError, "error during Typesense search") + } + } + } + + /** + * Refreshes typesense index. It sends the following requests: + * * [Drop collection][deleteCollection] + * * Rebuild the search template + * * Submit the aforementioned template + * * [Bulk uploads][bulkUploadResources] the resources + * + * @param R The [Resource] whose corresponding index is to be refreshed + * + * @return The [HttpResponse] of the request + * + * @author hamza1311 + */ + suspend inline fun refreshIndex(): HttpResponse { + val resources = service().getAll().get() + val docs = resources.map { this.makeDocument(it) } + + deleteCollection() + submitResourceTemplate(R::class._rebuildSearchTemplate()) + + return bulkUploadResources(docs) + } + + /** + * Uploads [resources][Resource] to typesense in bulk. + * A document import request is sent to typesense. + * + * @param documents The documents to be imported. These are converted into a format typesense can understand before sending the request + * + * @return The [HttpResponse] of the request + * + * @author Benjozork, hamza1311 + */ + suspend inline fun bulkUploadResources(documents: List>): HttpResponse { + val template = R::class._searchTemplate + + return typesenseClient.post { + url("$TYPESENSE_URL/collections/${template.name}/documents/import") + body = TextContent ( + documents.joinToString(separator = "\n") { + objectMapper.writeValueAsString(it).replace("\n", " ") + }, ContentType.Text.Plain) + } + } + + /** + * Drops a typesense collection + * @param R The resource whose collection is to be dropped + * + * @return The [HttpResponse] of the request + * + * @author hamza1311 + */ + suspend inline fun deleteCollection(): HttpResponse { + val template = R::class._searchTemplate + + return typesenseClient.delete { + url("$TYPESENSE_URL/collections/${template.name}/") + } + } +} diff --git a/src/blogify/backend/search/autogen/AutogenClassVisitor.kt b/src/blogify/backend/search/autogen/AutogenClassVisitor.kt new file mode 100644 index 00000000..6b20083c --- /dev/null +++ b/src/blogify/backend/search/autogen/AutogenClassVisitor.kt @@ -0,0 +1,32 @@ +package blogify.backend.search.autogen + +import blogify.backend.annotations.search.NoSearch +import blogify.backend.resources.computed.models.Computed +import blogify.backend.resources.models.Resource +import blogify.backend.resources.reflect.cachedPropMap +import blogify.backend.resources.reflect.models.ext.ok +import blogify.backend.search.models.Template +import blogify.backend.util.filterThenMapValues + +import com.andreapivetta.kolor.green +import kotlin.reflect.KClass + +import kotlin.reflect.full.findAnnotation + +object AutogenClassVisitor { + + fun visitAndMapClass(klass: KClass): Set { + return klass + .cachedPropMap() + .ok() + .filterThenMapValues ( + predicate = { + it.property.findAnnotation() == null && it.property.findAnnotation() == null + && it.name !== "uuid" + }, mapper = { + AutogenPropertyVisitor.visitAndMapProperty(it.value) + } + ).values.toSet() + } + +} \ No newline at end of file diff --git a/src/blogify/backend/search/autogen/AutogenDelegatedTypeVisitor.kt b/src/blogify/backend/search/autogen/AutogenDelegatedTypeVisitor.kt new file mode 100644 index 00000000..8191036a --- /dev/null +++ b/src/blogify/backend/search/autogen/AutogenDelegatedTypeVisitor.kt @@ -0,0 +1,17 @@ +package blogify.backend.search.autogen + +import blogify.backend.annotations.search.DelegatedSearchReceiver + +import kotlin.reflect.KClass +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.full.findAnnotation + +object AutogenDelegatedTypeVisitor { + + fun visitAndFindDelegate(klass: KClass<*>): KProperty1<*, *> { + return klass.declaredMemberProperties.firstOrNull { it.findAnnotation() != null } + ?: error("couldn't find delegated search field receiver in class '${klass.simpleName}'") + } + +} \ No newline at end of file diff --git a/src/blogify/backend/search/autogen/AutogenPropertyVisitor.kt b/src/blogify/backend/search/autogen/AutogenPropertyVisitor.kt new file mode 100644 index 00000000..59027b14 --- /dev/null +++ b/src/blogify/backend/search/autogen/AutogenPropertyVisitor.kt @@ -0,0 +1,58 @@ +package blogify.backend.search.autogen + +import blogify.backend.annotations.search.DelegatedSearch +import blogify.backend.resources.models.Resource +import blogify.backend.resources.reflect.models.PropMap +import blogify.backend.search.models.Template + +import kotlin.reflect.KClass +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.findAnnotation + +import com.andreapivetta.kolor.green + +import org.slf4j.LoggerFactory + +object AutogenPropertyVisitor { + + private val logger = LoggerFactory.getLogger("blogify-typesense-autogen") + + val fieldTypes by lazy { + Template.Field::class.sealedSubclasses + .filter { it.findAnnotation() != null } + .associateWith { it.findAnnotation()!! } + .also { Template.Field.tsaLogger.debug("mapped field subclasses".green()) } + } + + fun visitAndMapProperty(handle: PropMap.PropertyHandle.Ok): Template.Field { + val property = handle.property + val propertyClass = property.returnType.classifier as KClass<*> + val propertyAnnotations = property.annotations + val typeAnnotations = property.returnType.annotations + + // Is it delegated ? + return if (typeAnnotations.any { it.annotationClass == DelegatedSearch::class }) { + val delegateProperty = AutogenDelegatedTypeVisitor.visitAndFindDelegate(propertyClass) + val delegatePropertyFieldType = getVisitedPropertyFieldType(delegateProperty) + ?: error("invalid delegated property '${delegateProperty.name}' (delegated from '${property.name}') type") + + logger.trace("created typesense field for property '${property.name}' (delegated to '${delegateProperty.name}'): assigned type ${delegatePropertyFieldType.simpleName}".green()) + + // Call constructor with delegate + delegatePropertyFieldType.constructors.first().call(property.name, false, delegateProperty) + } else { + val propertyFieldType = getVisitedPropertyFieldType(property) + ?: error("invalid property '${property.name}' type") + + logger.trace("created typesense field for property '${property.name}': assigned type ${propertyFieldType.simpleName}".green()) + + // Call constructor without delegate + propertyFieldType.constructors.first().call(property.name, false, null) + } + } + + private fun getVisitedPropertyFieldType(property: KProperty1<*, *>): KClass? { + return fieldTypes.entries.firstOrNull { it.value.type == property.returnType.classifier }?.key + } + +} \ No newline at end of file diff --git a/src/blogify/backend/search/ext/SearchExt.kt b/src/blogify/backend/search/ext/SearchExt.kt new file mode 100644 index 00000000..5cbb4a8a --- /dev/null +++ b/src/blogify/backend/search/ext/SearchExt.kt @@ -0,0 +1,29 @@ +package blogify.backend.search.ext + +import blogify.backend.resources.models.Resource +import blogify.backend.resources.reflect.sanitize +import blogify.backend.search.models.Search +import blogify.backend.search.models.SearchView +import blogify.backend.util.Sr +import blogify.backend.util.service +import blogify.backend.util.toUUID + +import java.lang.Exception +import java.lang.IllegalStateException + +suspend inline fun Search.Hit.fetchResource(): Sr { + val resourceUUID = (this.document["uuid"] as String).toUUID() + return T::class.service.get(Resource.ObjectResolver.FakeApplicationCall, resourceUUID) +} + +suspend inline fun Search.asSearchView(): SearchView { + val processedHits = try { + this.hits?.map { + SearchView.Hit(it.fetchResource().get().sanitize(), it.highlights) + } ?: emptyList() + } catch (e: Exception) { + throw IllegalStateException("error while parsing search results: ${e::class.simpleName}: ${e.message}", e) + } + + return SearchView(this.facet_counts, this.found, processedHits, this.page, this.search_time_ms) +} diff --git a/src/blogify/backend/search/ext/TemplateExt.kt b/src/blogify/backend/search/ext/TemplateExt.kt new file mode 100644 index 00000000..72691ac2 --- /dev/null +++ b/src/blogify/backend/search/ext/TemplateExt.kt @@ -0,0 +1,49 @@ +package blogify.backend.search.ext + +import blogify.backend.annotations.search.QueryByField +import blogify.backend.annotations.search.SearchDefaultSort +import blogify.backend.resources.models.Resource +import blogify.backend.resources.reflect.cachedPropMap +import blogify.backend.resources.reflect.models.ext.ok +import blogify.backend.search.models.Template + +import kotlin.reflect.KClass +import kotlin.reflect.full.findAnnotation + +private val templateCache: MutableMap, Template<*>> = mutableMapOf() + +@Suppress("ObjectPropertyName", "UNCHECKED_CAST") +val KClass._searchTemplate: Template get() { + var cached: Template? = templateCache[this] as? Template? + if (cached == null) { + cached = this._buildSearchTemplate() + templateCache[this] = cached + } + + return cached +} + +const val TEMPLATE_DEFAULT_DSF = "_dsf" + +@Suppress("FunctionName") +fun KClass._buildSearchTemplate(): Template { + return Template ( + klass = this, + name = this.simpleName!!, + defaultSortingField = this.cachedPropMap().ok().values + .filter { it.property.findAnnotation() != null } + .toSet().firstOrNull()?.name ?: TEMPLATE_DEFAULT_DSF, // Generate TEMPLATE_DEFAULT_DSF if there is no annotated DSF + queryByParams = this.cachedPropMap().ok().values + .filter { it.property.findAnnotation() != null } + .toSet().joinToString(separator = ",") { it.name } + ) +} + +@Suppress("FunctionName") +fun KClass._rebuildSearchTemplate(): Template { + + val template = this._buildSearchTemplate() + + templateCache[this] = template + return template +} \ No newline at end of file diff --git a/src/blogify/backend/search/models/Search.kt b/src/blogify/backend/search/models/Search.kt new file mode 100644 index 00000000..0dc7e9be --- /dev/null +++ b/src/blogify/backend/search/models/Search.kt @@ -0,0 +1,32 @@ +package blogify.backend.search.models + +import blogify.backend.resources.models.Resource +import blogify.backend.search.Typesense + +/** + * 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 +) { + + /** + * Represents a hit + */ + data class Hit ( + val document: Map, + val highlights: List + ) + + data class Highlight ( + val field: String, + val snippet: String + ) + +} diff --git a/src/blogify/backend/search/models/SearchView.kt b/src/blogify/backend/search/models/SearchView.kt new file mode 100644 index 00000000..4b95d856 --- /dev/null +++ b/src/blogify/backend/search/models/SearchView.kt @@ -0,0 +1,27 @@ +package blogify.backend.search.models + +import blogify.backend.resources.models.Resource +import blogify.backend.search.Typesense + +/** + * Models for sending processed [Typesense] search results to a client + * + * @author Benjozork + */ +data class SearchView ( + val facet_counts: List?, // |\ + val found: Int?, // | Will not appear on no results + val hits: List?, // |/ + val page: Int, + val search_time_ms: Int +) { + + /** + * Represents a hit + */ + data class Hit ( + val document: Map, + val highlights: List + ) + +} diff --git a/src/blogify/backend/search/models/Template.kt b/src/blogify/backend/search/models/Template.kt new file mode 100644 index 00000000..c675644b --- /dev/null +++ b/src/blogify/backend/search/models/Template.kt @@ -0,0 +1,133 @@ +package blogify.backend.search.models + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer + +import blogify.backend.resources.models.Resource +import blogify.backend.search.Typesense +import blogify.backend.search.autogen.AutogenClassVisitor +import blogify.backend.search.ext.TEMPLATE_DEFAULT_DSF + +import org.slf4j.LoggerFactory + +import com.andreapivetta.kolor.green + +import kotlin.reflect.KClass +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.findAnnotation + +@Suppress("unused") +data class Template( + @JsonIgnore + val klass: KClass, + + val name: String, + + @get:JsonProperty("default_sorting_field") + val defaultSortingField: String, + + val queryByParams: String +) { + + val fields = AutogenClassVisitor.visitAndMapClass(klass) + // Make sure we have _dsf if TEMPLATE_DEFAULT_DSF was generated + .let { if (this.defaultSortingField == TEMPLATE_DEFAULT_DSF) it + Field.Int32(TEMPLATE_DEFAULT_DSF) else it } + + val delegatedFields = fields + .filter { it.delegatedTo != null } + + /** + * Represents a [Typesense] field used for indexing documents. Should generally only exist once for a given resource class. + * + * @property name the given name of the field + * @property type a type string used to set the type of the field + * @property facet whether or not the field is a [facet](https://typesense.org/docs/0.11.0/api/#create-collection) + * + * @author Benjozork + */ + @Suppress("unused") + sealed class Field ( + val name: kotlin.String, + @get:JsonProperty("type") + val type: kotlin.String, + val facet: Boolean, + @JsonIgnore + val delegatedTo: KProperty1? + ) { + + /** + * Marks a class as reflecting a [Typesense] type + * + * @property type the equivalent Kotlin type for this type + * @property canBeFacet whether this type can be a facet when sent in a template to [Typesense]. + * See [the typesense docs](https://typesense.org/docs/0.11.0/api/#create-collection) for details. + * + * @author Benjozork + */ + @Target(AnnotationTarget.CLASS) + @Retention(AnnotationRetention.RUNTIME) + annotation class TypesenseFieldType(val type: KClass<*>, val canBeFacet: Boolean) + + /** [Typesense] type implementation for [kotlin.String] */ + @TypesenseFieldType(kotlin.String::class, true) + class String(name: kotlin.String, facet: Boolean = false, delegatedTo: KProperty1? = null): Field(name, "string", facet, delegatedTo) + + /** [Typesense] type implementation for an array of [kotlin.String] */ + @TypesenseFieldType(Array::class, true) + class StringArray (name: kotlin.String, facet: Boolean = false, delegatedTo: KProperty1? = null): Field(name, "string[]", facet, delegatedTo) + + /** [Typesense] type implementation for [kotlin.Int] */ + @TypesenseFieldType(Int::class, false) + class Int32 (name: kotlin.String, facet: Boolean = false, delegatedTo: KProperty1? = null): Field(name, "int32", facet, delegatedTo) + + /** [Typesense] type implementation for an array of [kotlin.Int] */ + @TypesenseFieldType(Array::class, false) + class Int32Array (name: kotlin.String, facet: Boolean = false, delegatedTo: KProperty1? = null): Field(name, "int32[]", facet, delegatedTo) + + /** [Typesense] type implementation for [kotlin.Long] */ + @TypesenseFieldType(Long::class, false) + class Int64 (name: kotlin.String, facet: Boolean = false, delegatedTo: KProperty1? = null): Field(name, "int64", facet, delegatedTo) + + /** [Typesense] type implementation for an array of [kotlin.Long] */ + @TypesenseFieldType(Array::class, false) + class Int64Array (name: kotlin.String, facet: Boolean = false, delegatedTo: KProperty1? = null): Field(name, "int64[]", facet, delegatedTo) + + /** [Typesense] type implementation for [kotlin.Float] */ + @TypesenseFieldType(Float::class, false) + class Float (name: kotlin.String, facet: Boolean = false, delegatedTo: KProperty1? = null): Field(name, "float", facet, delegatedTo) + + /** [Typesense] type implementation for an array of [kotlin.Float] */ + @TypesenseFieldType(Array::class, false) + class FloatArray (name: kotlin.String, facet: Boolean = false, delegatedTo: KProperty1? = null): Field(name, "float[]", facet, delegatedTo) + + /** [Typesense] type implementation for [kotlin.Boolean] */ + @TypesenseFieldType(Boolean::class, false) + class Bool (name: kotlin.String, facet: Boolean = false, delegatedTo: KProperty1? = null): Field(name, "bool", facet, delegatedTo) + + /** [Typesense] type implementation for an array of [kotlin.Boolean] */ + @TypesenseFieldType(Array::class, false) + class BoolArray (name: kotlin.String, facet: Boolean = false, delegatedTo: KProperty1? = null): Field(name, "bool[]", facet, delegatedTo) + + companion object { + + val tsaLogger = LoggerFactory.getLogger("blogify-typesense-autogen") + + private val subClassCache by lazy { + Field::class.sealedSubclasses + .filter { it.findAnnotation() != null } + .associateWith { it.findAnnotation()!! } + .also { tsaLogger.debug("mapped field subclasses".green()) } + } + + } + + object Serializer : StdSerializer(Field::class.java) { + override fun serialize(value: Field?, gen: JsonGenerator?, provider: SerializerProvider?) = gen!!.writeString(value?.type) + } + + } + +} diff --git a/src/blogify/backend/services/ArticleService.kt b/src/blogify/backend/services/ArticleService.kt new file mode 100644 index 00000000..fd27e34c --- /dev/null +++ b/src/blogify/backend/services/ArticleService.kt @@ -0,0 +1,7 @@ +package blogify.backend.services + +import blogify.backend.database.Articles +import blogify.backend.resources.Article +import blogify.backend.services.models.Service + +object ArticleService : Service
(table = Articles) diff --git a/src/blogify/backend/services/CommentService.kt b/src/blogify/backend/services/CommentService.kt new file mode 100644 index 00000000..ffab378f --- /dev/null +++ b/src/blogify/backend/services/CommentService.kt @@ -0,0 +1,7 @@ +package blogify.backend.services + +import blogify.backend.database.Comments +import blogify.backend.resources.Comment +import blogify.backend.services.models.Service + +object CommentService : Service(table = Comments) diff --git a/src/blogify/backend/services/UserService.kt b/src/blogify/backend/services/UserService.kt index 40708247..912a10b8 100644 --- a/src/blogify/backend/services/UserService.kt +++ b/src/blogify/backend/services/UserService.kt @@ -2,38 +2,6 @@ package blogify.backend.services import blogify.backend.database.Users import blogify.backend.resources.User -import blogify.backend.services.models.ResourceResult import blogify.backend.services.models.Service -import blogify.backend.database.handling.query -import blogify.backend.resources.static.models.StaticResourceHandle -import com.github.kittinunf.result.coroutines.mapError - -import org.jetbrains.exposed.sql.insert -import org.jetbrains.exposed.sql.update - -object UserService : Service(Users) { - - override suspend fun add(res: User) = query { - Users.insert { - it[uuid] = res.uuid - it[username] = res.username - it[password] = res.password - it[name] = res.name - it[email] = res.email - } - - return@query res - }.mapError { e -> Exception.Creating(e) } - - override suspend fun update(res: User): ResourceResult<*> = query { - Users.update(where = { Users.uuid eq res.uuid }) { - it[username] = res.username - it[name] = res.name - it[email] = res.email - it[profilePicture] = (res.profilePicture as? StaticResourceHandle.Ok)?.fileId - } - }.mapError { e -> Exception.Updating(e) } - - -} +object UserService : Service(table = Users) diff --git a/src/blogify/backend/services/articles/ArticleService.kt b/src/blogify/backend/services/articles/ArticleService.kt deleted file mode 100644 index a4655c7e..00000000 --- a/src/blogify/backend/services/articles/ArticleService.kt +++ /dev/null @@ -1,60 +0,0 @@ -package blogify.backend.services.articles - -import blogify.backend.database.Articles -import blogify.backend.database.Articles.uuid -import blogify.backend.resources.Article -import blogify.backend.services.models.ResourceResult -import blogify.backend.services.models.Service -import blogify.backend.database.handling.query - -import com.github.kittinunf.result.coroutines.mapError - -import org.jetbrains.exposed.sql.deleteWhere -import org.jetbrains.exposed.sql.insert -import org.jetbrains.exposed.sql.update - -object ArticleService : Service
(Articles) { - - override suspend fun add(res: Article) = query { - Articles.insert { - it[uuid] = res.uuid - it[title] = res.title - it[createdAt] = res.createdAt - it[createdBy] = res.createdBy.uuid - it[content] = res.content - it[summary] = res.summary - } - - val cats = res.categories - - for (cat in cats) { - Articles.Categories.insert { - it[name] = cat.name - it[article] = res.uuid - } - } - - return@query res // So that we return the resource and not an insert statement - }.mapError { e -> Exception.Creating(e) } // Wrap possible error - - override suspend fun update(res: Article): ResourceResult<*> = query { - Articles.update(where = { uuid eq res.uuid }) { - it[title] = res.title - it[content] = res.content - it[summary] = res.summary - } - - val cats = res.categories - - Articles.Categories.deleteWhere { Articles.Categories.article eq res.uuid } - - cats.forEach { cat -> - 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/articles/CommentService.kt b/src/blogify/backend/services/articles/CommentService.kt deleted file mode 100644 index ed93b063..00000000 --- a/src/blogify/backend/services/articles/CommentService.kt +++ /dev/null @@ -1,35 +0,0 @@ -package blogify.backend.services.articles - -import blogify.backend.resources.Comment -import blogify.backend.services.models.Service -import blogify.backend.database.handling.query -import blogify.backend.database.Comments -import blogify.backend.database.Comments.uuid -import blogify.backend.services.models.ResourceResult - -import com.github.kittinunf.result.coroutines.mapError - -import org.jetbrains.exposed.sql.insert -import org.jetbrains.exposed.sql.update - -object CommentService : Service(Comments) { - - override suspend fun add(res: Comment) = query { - Comments.insert { - it[uuid] = res.uuid - it[commenter] = res.commenter.uuid - it[article] = res.article.uuid - it[content] = res.content - it[parentComment] = res.parentComment?.uuid - } - - return@query res - }.mapError { e -> Service.Exception.Creating(e) } - - override suspend fun update(res: Comment): ResourceResult<*> = query { - Comments.update({ uuid eq res.uuid }) { - it[content] = res.content - } - }.mapError { e -> Service.Exception.Updating(e) } - -} diff --git a/src/blogify/backend/services/handling/Handlers.kt b/src/blogify/backend/services/handling/Handlers.kt deleted file mode 100644 index 8641a6c6..00000000 --- a/src/blogify/backend/services/handling/Handlers.kt +++ /dev/null @@ -1,115 +0,0 @@ -package blogify.backend.services.handling - -import blogify.backend.database.ResourceTable -import blogify.backend.resources.models.Resource -import blogify.backend.services.models.ResourceResult -import blogify.backend.services.models.ResourceResultSet -import blogify.backend.services.models.Service -import blogify.backend.database.handling.query - -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 - -import java.util.UUID - -/** - * Retrieves all the [resources][Resource] from a certain table of the DB. - * - * @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 { - table.selectAll().toSet() // First, query the DB - } - .mapError { e -> Service.Exception.Fetching(e) } // Wrap a possible DBEx inside a Service exception - .map { rows -> // Map the set of ResultRow to converted resources - rows.map { table.convert(callContext, it).get() }.toSet() // Mote : get() is fine, since any error thrown - } // by it is automatically wrapped into a failure result. -} - -/** - * Retrieves a number of [resources][Resource] from a certain table of the DB. - * - * @param callContext the [ApplicationCall] in context - * @param table the [ResourceTable] to query - * @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 { - table.selectAll().take(limit).toSet() - } - .mapError { e -> Service.Exception.Fetching(e) } // Wrap a possible DBEx inside a Service exception - .map { rows -> // Map the set of ResultRow to converted resources - rows.map { table.convert(callContext, it).get() }.toSet() // Mote : get() is fine, since any error thrown - } // by it is automatically wrapped into a failure result. -} - -/** - * Retrieves a [resource][Resource] of a certain type from the DB. - * - * @param callContext the [ApplicationCall] in context - * @param table the [ResourceTable] to query - * @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 { - table.select { table.uuid eq id }.single() // First, query the DB - } - .mapError { e -> Service.Exception.Fetching(e) } // Wrap a possible DBEx inside a Service exception - .map { r -> table.convert(callContext, r).get() } // Map the ResultRow to a converted resource. See note above. -} - -/** - * Deletes a certain [resource][Resource] from the DB. - * - * @param table the [ResourceTable] to act on - * @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 { - table.deleteWhere { table.uuid eq id } // First, instruct the DB to delete the corresponding row - return@query id - } - .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 8e573a0d..15008466 100644 --- a/src/blogify/backend/services/models/Service.kt +++ b/src/blogify/backend/services/models/Service.kt @@ -3,21 +3,23 @@ package blogify.backend.services.models 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 +import blogify.backend.resources.reflect.models.PropMap +import blogify.backend.routing.pipelines.caching.cachedOrElse import blogify.backend.util.BException +import blogify.backend.util.Sr +import blogify.backend.util.Wrap +import blogify.backend.util.SrList +import blogify.backend.util.getOrPipelineError import io.ktor.application.ApplicationCall +import io.ktor.http.HttpStatusCode import com.github.kittinunf.result.coroutines.SuspendableResult +import com.github.kittinunf.result.coroutines.map 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 @@ -27,14 +29,10 @@ import org.slf4j.LoggerFactory import java.util.* -typealias ResourceResult = SuspendableResult - -typealias ResourceResultSet = ResourceResult> - /** * Service interface for fetching, creating, updating and deleting [resources][Resource]. */ -abstract class Service(val table: ResourceTable) { +open class Service(val table: ResourceTable) { private val logger = LoggerFactory.getLogger("blogify-service-${this::class.simpleName}") @@ -46,12 +44,12 @@ abstract class Service(val table: ResourceTable) { * * @param limit the max number of items to fetch. Defaults to 256. * - * @return a [ResourceResultSet] of [R] items + * @return a [SrList] of [R] items * * @author Benjozork, hamza1311 */ - suspend fun getAll(callContext: ApplicationCall = FakeApplicationCall, limit: Int = 256): ResourceResultSet - = fetchNumberFromTable(callContext, table, limit) + suspend fun getAll(callContext: ApplicationCall = FakeApplicationCall, limit: Int = 256): SrList + = this.table.obtainAll(callContext, limit) /** * Obtains an instance of [R] with a specific [id][UUID] ]in the database @@ -61,12 +59,12 @@ abstract class Service(val table: ResourceTable) { * * @param id the [UUID] of the resource to fetch * - * @return a [ResourceResult] of an [R] item with the provided [id] + * @return a [Wrap] of an [R] item with the provided [id] * * @author Benjozork, hamza1311 */ - suspend fun get(callContext: ApplicationCall = FakeApplicationCall, id: UUID): ResourceResult - = callContext.cachedOrElse(id) { fetchWithIdFromTable(callContext, table, id) } + suspend fun get(callContext: ApplicationCall = FakeApplicationCall, id: UUID): Sr + = callContext.cachedOrElse(id) { table.obtain(callContext, id) } /** * Obtains a set of instances of [R] matching a given [predicate] @@ -76,62 +74,47 @@ abstract class Service(val table: ResourceTable) { * * @param predicate an Exposed predicate that is used to return the needed items * - * @return a [ResourceResultSet] of [R] items matching [predicate] + * @return a [SrList] of [R] items matching [predicate] * * @author hamza1311 */ - suspend fun getMatching(callContext: ApplicationCall = FakeApplicationCall, predicate: SqlExpressionBuilder.() -> Op): ResourceResultSet { - return SuspendableResult.of, Exception> { + suspend fun getMatching(callContext: ApplicationCall = FakeApplicationCall, predicate: SqlExpressionBuilder.() -> Op): SrList { + return Wrap { transaction { val query = table.select(predicate).toSet() - runBlocking { query.map { table.convert(callContext, it).get() }.toSet() } + runBlocking { query.map { table.convert(callContext, it).get() }.toList() } } - }.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) + suspend fun add(res: R): Sr = this.table.insert(res) - abstract suspend fun add(res: R): ResourceResult + suspend fun update(res: R, rawData: Map): SuspendableResult { + val new = blogify.backend.resources.reflect.update(res, rawData) + .getOrPipelineError(HttpStatusCode.InternalServerError, "couldn't update resource") - abstract suspend fun update(res: R): SuspendableResult<*, Exception> + this.table.update(new) + + return Sr.of { new } + } /** * Deletes an instance of [R] from the database * - * @param id the [UUID] of the resource to fetch + * @param res the resource to delete * - * @return a [ResourceResultSet] of the [UUID] of the deleted item + * @return a [Wrap] of the [UUID] of the deleted resource * * @author Benjozork, hamza1311 */ - suspend fun delete(id: UUID): ResourceResult - = deleteWithIdInTable(table, id) + suspend fun delete(res: R): Sr + = this.table.delete(res).map { res.uuid } // Service exceptions open class Exception(causedBy: BException) : BException(causedBy) { - open class Fetching(causedBy: BException) : Exception(causedBy) { - - class NotFound(causedBy: BException) : Fetching(causedBy) - - } - - open class Creating(causedBy: BException) : Exception(causedBy) - - open class Deleting(causedBy: BException) : Exception(causedBy) - open class Updating(causedBy: BException) : Exception(causedBy) + class Fetching(causedBy: BException) : Exception(causedBy) } diff --git a/src/blogify/backend/util/ContentTypeSerialization.kt b/src/blogify/backend/util/ContentTypeSerialization.kt new file mode 100644 index 00000000..f8acbfff --- /dev/null +++ b/src/blogify/backend/util/ContentTypeSerialization.kt @@ -0,0 +1,15 @@ +package blogify.backend.util + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer + +import io.ktor.http.ContentType + +object ContentTypeSerializer : StdSerializer(ContentType::class.java) { + + override fun serialize(value: ContentType?, gen: JsonGenerator?, provider: SerializerProvider?) { + gen?.writeString(value.toString()) + } + +} diff --git a/src/blogify/backend/util/Extentions.kt b/src/blogify/backend/util/Extentions.kt index a393212d..a4c93b88 100644 --- a/src/blogify/backend/util/Extentions.kt +++ b/src/blogify/backend/util/Extentions.kt @@ -4,17 +4,9 @@ import blogify.backend.auth.encoder import java.util.* -import kotlin.concurrent.schedule - fun String.toUUID(): UUID = UUID.fromString(this) fun UUID.short(): String = this.toString().takeLast(8) fun String.hash(): String = encoder.encode(this) -/** - * Please don't kill me. - */ -fun T.letIn(time: Long, block: TimerTask.(T) -> Unit) { - Timer().schedule(time) { block(this@letIn); this.cancel() } -} diff --git a/src/blogify/backend/util/Misc.kt b/src/blogify/backend/util/Misc.kt index 3ff9c76e..e824c8d2 100644 --- a/src/blogify/backend/util/Misc.kt +++ b/src/blogify/backend/util/Misc.kt @@ -3,6 +3,7 @@ package blogify.backend.util import io.ktor.http.ContentType fun reason(text: String) = object { val reason = text } +fun reasons(vararg texts: String) = object { val reasons = texts } fun T.letCatchingOrNull(block: (T) -> R): R? { return try { @@ -14,4 +15,9 @@ fun T.letCatchingOrNull(block: (T) -> R): R? { infix fun ContentType.matches(other: ContentType) = this.match(other) -const val TYPESENSE_API_KEY = "Hu52dwsas2AdxdE" \ No newline at end of file +/** + * Returns the content of an environment variable, or `null` if it's empty / non-existent + * + * @author Benjozork + */ +fun env(name: String) = System.getenv(name).takeIf { it?.isNotBlank() ?: false } diff --git a/src/blogify/backend/util/Results.kt b/src/blogify/backend/util/Results.kt index 79451c6b..e6418e19 100644 --- a/src/blogify/backend/util/Results.kt +++ b/src/blogify/backend/util/Results.kt @@ -1,6 +1,6 @@ package blogify.backend.util -import blogify.backend.routes.pipelines.pipelineError +import blogify.backend.routing.pipelines.pipelineError import io.ktor.http.HttpStatusCode @@ -8,12 +8,18 @@ import com.github.kittinunf.result.coroutines.SuspendableResult open class BException(causedBy: Exception) : Exception(causedBy) +typealias Sr = SuspendableResult +typealias SrList = SuspendableResult, Exception> + +@Suppress("FunctionName") +suspend fun Wrap(producer: suspend () -> T): Sr = Sr.of(producer) + fun SuspendableResult.getOrPipelineError ( code: HttpStatusCode = HttpStatusCode.InternalServerError, message: String = "error while fetching generic result" ): V { when (this) { is SuspendableResult.Success -> return this.get() - is SuspendableResult.Failure -> pipelineError(code, message) + is SuspendableResult.Failure -> pipelineError(code, message, this.error) } } \ No newline at end of file diff --git a/src/blogify/backend/util/TempResourceClassServices.kt b/src/blogify/backend/util/TempResourceClassServices.kt new file mode 100644 index 00000000..15323cee --- /dev/null +++ b/src/blogify/backend/util/TempResourceClassServices.kt @@ -0,0 +1,37 @@ +package blogify.backend.util + +import blogify.backend.resources.Article +import blogify.backend.resources.Comment +import blogify.backend.resources.User +import blogify.backend.resources.models.Resource +import blogify.backend.services.UserService +import blogify.backend.services.ArticleService +import blogify.backend.services.CommentService +import blogify.backend.services.models.Service + +import kotlin.reflect.KClass + +val KClass
.service: ArticleService + get() = ArticleService + +val KClass.service: UserService + get() = UserService + +val KClass.service: CommentService + get() = CommentService + +val KClass.service: Service + get() { + return when { + this == Article::class -> { + Article::class.service as Service + } + this == User::class -> { + User::class.service as Service + } + this == Comment::class -> { + Comment::class.service as Service + } + else -> error("fuck") + } + } diff --git a/src/blogify/backend/util/Trees.kt b/src/blogify/backend/util/Trees.kt index 03449239..42d71b25 100644 --- a/src/blogify/backend/util/Trees.kt +++ b/src/blogify/backend/util/Trees.kt @@ -3,8 +3,8 @@ package blogify.backend.util import blogify.backend.database.Comments import blogify.backend.resources.Comment import blogify.backend.resources.models.Resource.ObjectResolver.FakeApplicationCall -import blogify.backend.resources.slicing.sanitize -import blogify.backend.services.articles.CommentService +import blogify.backend.resources.reflect.sanitize +import blogify.backend.services.CommentService import io.ktor.application.ApplicationCall diff --git a/src/blogify/frontend/package-lock.json b/src/blogify/frontend/package-lock.json index 05a90397..cd61a66a 100644 --- a/src/blogify/frontend/package-lock.json +++ b/src/blogify/frontend/package-lock.json @@ -5,41 +5,57 @@ "requires": true, "dependencies": { "@angular-devkit/architect": { - "version": "0.802.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.802.2.tgz", - "integrity": "sha512-bMMo8BejHi3+n4xqewgcfat5+OYDmQQCLxWQ2W+qr7/u08vmTQTix3Q/wClp0nxgN0Zc9/1gSPaeudHLAlEizg==", + "version": "0.803.20", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.803.20.tgz", + "integrity": "sha512-NjyDJ61i9kh8J+qXt0E2j+P5Xsmi2mPasBzwcQyrZZGiho4zC0IFxcdxyzcsXFEupmilJKkjdt2g4QQRC5rUDQ==", "dev": true, "requires": { - "@angular-devkit/core": "8.2.2", + "@angular-devkit/core": "8.3.20", "rxjs": "6.4.0" + }, + "dependencies": { + "rxjs": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.4.0.tgz", + "integrity": "sha512-Z9Yfa11F6B9Sg/BK9MnqnQ+aQYicPLtilXBp2yUtDt2JRCE0h26d33EnfO3ZxoNxG0T92OUucP3Ct7cpfkdFfw==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + } } }, "@angular-devkit/build-angular": { - "version": "0.802.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-0.802.2.tgz", - "integrity": "sha512-48WCSX5IpSbVe/cG9+KrcL6f93JwHicKfYLyrrGhywSENlBYVNLNfbJHz/AuaxjmsiCmiI9gLnRb/W5JoVxuMA==", - "dev": true, - "requires": { - "@angular-devkit/architect": "0.802.2", - "@angular-devkit/build-optimizer": "0.802.2", - "@angular-devkit/build-webpack": "0.802.2", - "@angular-devkit/core": "8.2.2", - "@ngtools/webpack": "8.2.2", + "version": "0.803.20", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-0.803.20.tgz", + "integrity": "sha512-JDZkZjOqPbOtCMsSKxQf9C+uSTZ7fQGlKGsCpJMzfa4iQ0WrmrhZvnRKQeEpMTTZTpuou/HQeQjyDV+Sx3yumw==", + "dev": true, + "requires": { + "@angular-devkit/architect": "0.803.20", + "@angular-devkit/build-optimizer": "0.803.20", + "@angular-devkit/build-webpack": "0.803.20", + "@angular-devkit/core": "8.3.20", + "@babel/core": "7.5.5", + "@babel/preset-env": "7.5.5", + "@ngtools/webpack": "8.3.20", "ajv": "6.10.2", "autoprefixer": "9.6.1", "browserslist": "4.6.6", - "caniuse-lite": "1.0.30000986", - "circular-dependency-plugin": "5.0.2", + "cacache": "12.0.2", + "caniuse-lite": "1.0.30000989", + "circular-dependency-plugin": "5.2.0", "clean-css": "4.2.1", "copy-webpack-plugin": "5.0.4", - "core-js": "3.1.4", - "file-loader": "4.1.0", + "core-js": "3.2.1", + "file-loader": "4.2.0", + "find-cache-dir": "3.0.0", "glob": "7.1.4", "istanbul-instrumenter-loader": "3.0.1", + "jest-worker": "24.9.0", "karma-source-map-support": "1.4.0", "less": "3.9.0", "less-loader": "5.0.0", - "license-webpack-plugin": "2.1.1", + "license-webpack-plugin": "2.1.2", "loader-utils": "1.2.3", "mini-css-extract-plugin": "0.8.0", "minimatch": "3.0.4", @@ -48,77 +64,119 @@ "postcss": "7.0.17", "postcss-import": "12.0.1", "postcss-loader": "3.0.0", - "raw-loader": "1.0.0", + "raw-loader": "3.1.0", + "regenerator-runtime": "0.13.3", "rxjs": "6.4.0", - "sass": "1.22.7", - "sass-loader": "7.1.0", + "sass": "1.22.9", + "sass-loader": "7.2.0", "semver": "6.3.0", + "source-map": "0.7.3", "source-map-loader": "0.2.4", - "source-map-support": "0.5.12", + "source-map-support": "0.5.13", "speed-measure-webpack-plugin": "1.3.1", - "style-loader": "0.23.1", + "style-loader": "1.0.0", "stylus": "0.54.5", "stylus-loader": "3.0.2", - "terser-webpack-plugin": "1.3.0", + "terser": "4.3.9", + "terser-webpack-plugin": "1.4.1", "tree-kill": "1.2.1", - "webpack": "4.38.0", - "webpack-dev-middleware": "3.7.0", - "webpack-dev-server": "3.7.2", + "webpack": "4.39.2", + "webpack-dev-middleware": "3.7.2", + "webpack-dev-server": "3.9.0", "webpack-merge": "4.2.1", - "webpack-sources": "1.3.0", + "webpack-sources": "1.4.3", "webpack-subresource-integrity": "1.1.0-rc.6", - "worker-plugin": "3.1.0" + "worker-plugin": "3.2.0" }, "dependencies": { + "rxjs": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.4.0.tgz", + "integrity": "sha512-Z9Yfa11F6B9Sg/BK9MnqnQ+aQYicPLtilXBp2yUtDt2JRCE0h26d33EnfO3ZxoNxG0T92OUucP3Ct7cpfkdFfw==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, + "schema-utils": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.6.1.tgz", + "integrity": "sha512-0WXHDs1VDJyo+Zqs9TKLKyD/h7yDpHUhEFsM2CzkICFdoX1av+GBq/J2xRTFfsQO5kBfhZzANf2VcIm84jqDbg==", + "dev": true, + "requires": { + "ajv": "^6.10.2", + "ajv-keywords": "^3.4.1" + } + }, + "source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, "style-loader": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-0.23.1.tgz", - "integrity": "sha512-XK+uv9kWwhZMZ1y7mysB+zoihsEj4wneFWAS5qoiLwzW0WzSqMrrsIy+a3zkQJq0ipFtBpX5W3MqyRIBF/WFGg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-1.0.0.tgz", + "integrity": "sha512-B0dOCFwv7/eY31a5PCieNwMgMhVGFe9w+rh7s/Bx8kfFkrth9zfTZquoYvdw8URgiqxObQKcpW51Ugz1HjfdZw==", "dev": true, "requires": { - "loader-utils": "^1.1.0", - "schema-utils": "^1.0.0" + "loader-utils": "^1.2.3", + "schema-utils": "^2.0.1" } } } }, "@angular-devkit/build-optimizer": { - "version": "0.802.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-optimizer/-/build-optimizer-0.802.2.tgz", - "integrity": "sha512-0QkTxMgCr2YiysdRVY64smtogDnWz0eyqhmUJbd9kEq1xxDDfuvs+6OT1Lk6xU7tcucVf33DKB9jK/3n3LZIpw==", + "version": "0.803.20", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-optimizer/-/build-optimizer-0.803.20.tgz", + "integrity": "sha512-Vzxf1g1EuzaPBoScDYUhyxemi5chlgnpWmObNo5dzVAVzjxo5gJeDIGpiyDqHvr6LBkprqb6XHcZhMWqIcdIHg==", "dev": true, "requires": { "loader-utils": "1.2.3", - "source-map": "0.5.6", + "source-map": "0.7.3", "tslib": "1.10.0", "typescript": "3.5.3", - "webpack-sources": "1.3.0" - }, - "dependencies": { - "source-map": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", - "integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI=", - "dev": true - } + "webpack-sources": "1.4.3" } }, "@angular-devkit/build-webpack": { - "version": "0.802.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.802.2.tgz", - "integrity": "sha512-odsY7hkqUBsRgqTCcGXFuIBd6NJYSCduFHheoDpqwK0SIAlAZ6Q9pB6jv9J0FTwKUJBsVsHk+cXUuaeZhUQcIg==", + "version": "0.803.20", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.803.20.tgz", + "integrity": "sha512-35af8kD3KG/cIv7AB09YNER5HIPlx55ipBxdVk8D+X3MuUcTmD6fFvqXcV0EPlD1vQephthfzSgtNpvuPv4xuA==", "dev": true, "requires": { - "@angular-devkit/architect": "0.802.2", - "@angular-devkit/core": "8.2.2", - "rxjs": "6.4.0", - "webpack-merge": "4.2.1" + "@angular-devkit/architect": "0.803.20", + "@angular-devkit/core": "8.3.20", + "rxjs": "6.4.0" + }, + "dependencies": { + "rxjs": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.4.0.tgz", + "integrity": "sha512-Z9Yfa11F6B9Sg/BK9MnqnQ+aQYicPLtilXBp2yUtDt2JRCE0h26d33EnfO3ZxoNxG0T92OUucP3Ct7cpfkdFfw==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + } } }, "@angular-devkit/core": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-8.2.2.tgz", - "integrity": "sha512-qA1lK/OQhNptCxoEGbTryn6yeFS1F/e/EiUTwgU/j4DkBwPyYGE8iqWBd/cgI9AVqQaRSLLhVWXtDPxoNL0TKg==", + "version": "8.3.20", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-8.3.20.tgz", + "integrity": "sha512-UCfW/BJBJnioJU34QennQhA4o+rLoCXWiSrI2LM7yw8/MEM9I8KbqRETP1My3HjHkQnvP+Qh3noedpcu3Nnt8A==", "dev": true, "requires": { "ajv": "6.10.2", @@ -126,46 +184,70 @@ "magic-string": "0.25.3", "rxjs": "6.4.0", "source-map": "0.7.3" + }, + "dependencies": { + "rxjs": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.4.0.tgz", + "integrity": "sha512-Z9Yfa11F6B9Sg/BK9MnqnQ+aQYicPLtilXBp2yUtDt2JRCE0h26d33EnfO3ZxoNxG0T92OUucP3Ct7cpfkdFfw==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + } } }, "@angular-devkit/schematics": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-8.2.2.tgz", - "integrity": "sha512-wAbP+IriWgTSLR4prezuFlLbxMZMGXiN0FNH2i/v8MfxNXCBiEvD4YtD/8s8YRsZs+IW7sp3bErSD/EIlS4DyQ==", + "version": "8.3.20", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-8.3.20.tgz", + "integrity": "sha512-sDHZakh4e3A5WenR9zr1x6Va9GNRqQlRhqT3xcbkG88v2M0YqEt7dHB7YwnOhm7zSxiWQM8PdWEQHiQ4iu9NyQ==", "dev": true, "requires": { - "@angular-devkit/core": "8.2.2", + "@angular-devkit/core": "8.3.20", "rxjs": "6.4.0" + }, + "dependencies": { + "rxjs": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.4.0.tgz", + "integrity": "sha512-Z9Yfa11F6B9Sg/BK9MnqnQ+aQYicPLtilXBp2yUtDt2JRCE0h26d33EnfO3ZxoNxG0T92OUucP3Ct7cpfkdFfw==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + } } }, "@angular/animations": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-8.2.4.tgz", - "integrity": "sha512-EHTvA5ugoFiYVwi9SyozJORWcBhUIn06VbNa2uhQQdOUrsbvKBCF0PpH2nZZJz7wsQ6Pyonizee8vgea8/X59Q==", + "version": "8.2.14", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-8.2.14.tgz", + "integrity": "sha512-3Vc9TnNpKdtvKIXcWDFINSsnwgEMiDmLzjceWg1iYKwpeZGQahUXPoesLwQazBMmxJzQiA4HOMj0TTXKZ+Jzkg==", "requires": { "tslib": "^1.9.0" } }, "@angular/cli": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-8.2.2.tgz", - "integrity": "sha512-iQvNVbegNXvnuAo8Pal6hjwK8joGcaCTcIa3jh1GLZ9JT4fZk2p9D/8Kay8C0jLm2KytV3f4eSlPAuX5V6p/XQ==", + "version": "8.3.20", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-8.3.20.tgz", + "integrity": "sha512-bCo8zVFZ6iPc1EnHmVCmKvIcV7YkvalBKGNU7LtVHq6qZBI+ZmFtuyL5obKvFg1vJcminjKcY/UcMr9uGcAQrQ==", "dev": true, "requires": { - "@angular-devkit/architect": "0.802.2", - "@angular-devkit/core": "8.2.2", - "@angular-devkit/schematics": "8.2.2", - "@schematics/angular": "8.2.2", - "@schematics/update": "0.802.2", + "@angular-devkit/architect": "0.803.20", + "@angular-devkit/core": "8.3.20", + "@angular-devkit/schematics": "8.3.20", + "@schematics/angular": "8.3.20", + "@schematics/update": "0.803.20", "@yarnpkg/lockfile": "1.1.0", "ansi-colors": "4.1.1", "debug": "^4.1.1", "ini": "1.3.5", - "inquirer": "6.5.0", + "inquirer": "6.5.1", "npm-package-arg": "6.1.0", + "npm-pick-manifest": "3.0.2", "open": "6.4.0", - "pacote": "9.5.4", + "pacote": "9.5.5", "read-package-tree": "5.3.1", + "rimraf": "3.0.0", "semver": "6.3.0", "symbol-observable": "1.2.0", "universal-analytics": "^0.4.20", @@ -192,29 +274,38 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true + }, + "rimraf": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.0.tgz", + "integrity": "sha512-NDGVxTsjqfunkds7CqsOiEnxln4Bo7Nddl3XhS4pXg5OzwkLqJ971ZVAAnB+DDLnF76N+VnDEiBHaVV8I06SUg==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } } } }, "@angular/common": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-8.2.4.tgz", - "integrity": "sha512-sPeTkor3uf8T3MvpekS0ZQe9K/yzlHBSoMyT0bIPOYeDTHUph3f/0XyYhH7KSGXLo7tSw1Mx9Ua05nQ+VHtLGQ==", + "version": "8.2.14", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-8.2.14.tgz", + "integrity": "sha512-Qmt+aX2quUW54kaNT7QH7WGXnFxr/cC2C6sf5SW5SdkZfDQSiz8IaItvieZfXVQUbBOQKFRJ7TlSkt0jI/yjvw==", "requires": { "tslib": "^1.9.0" } }, "@angular/compiler": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-8.2.4.tgz", - "integrity": "sha512-LYaYhQlW3GFiXrNywJBYQtsLOWmUFcgudacF1m7QHHhlljnkG3BqkosbT0Dkcl7ayrIDYT/ZMTkVmaiGvgAhnw==", + "version": "8.2.14", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-8.2.14.tgz", + "integrity": "sha512-ABZO4E7eeFA1QyJ2trDezxeQM5ZFa1dXw1Mpl/+1vuXDKNjJgNyWYwKp/NwRkLmrsuV0yv4UDCDe4kJOGbPKnw==", "requires": { "tslib": "^1.9.0" } }, "@angular/compiler-cli": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-8.2.4.tgz", - "integrity": "sha512-tN269yWPbJsetzmO8x/Bx7wLwqCfnD8BYoJsBFPcZOZpW0cfELzVdY13R325WB1uXiMrVN0lskNtPBLe9OcMTA==", + "version": "8.2.14", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-8.2.14.tgz", + "integrity": "sha512-XDrTyrlIZM+0NquVT+Kbg5bn48AaWFT+B3bAT288PENrTdkuxuF9AhjFRZj8jnMdmaE4O2rioEkXBtl6z3zptA==", "dev": true, "requires": { "canonical-path": "1.0.0", @@ -300,6 +391,12 @@ "upath": "^1.1.1" } }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, "extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -987,47 +1084,47 @@ } }, "@angular/core": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-8.2.4.tgz", - "integrity": "sha512-8FSdkUSb5S4+K2w49iLzrQF/jzcmoRnOogFZQ8CctiXQHSVHHF8AjpoFpFVUAI6/77UVL8CehlyBSKF5EE1Z8A==", + "version": "8.2.14", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-8.2.14.tgz", + "integrity": "sha512-zeePkigi+hPh3rN7yoNENG/YUBUsIvUXdxx+AZq+QPaFeKEA2FBSrKn36ojHFrdJUjKzl0lPMEiGC2b6a6bo6g==", "requires": { "tslib": "^1.9.0" } }, "@angular/forms": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-8.2.4.tgz", - "integrity": "sha512-TsaMrfy/Ls9kpxxlkqaPSQCL3DWqIzh3fMd0aGXTjcsEFI3gztttAmE/dlU0dtVsQxD0M9cdqjjPqi0TGamfTw==", + "version": "8.2.14", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-8.2.14.tgz", + "integrity": "sha512-zhyKL3CFIqcyHJ/TQF/h1OZztK611a6rxuPHCrt/5Sn1SuBTJJQ1pPTkOYIDy6IrCrtyANc8qB6P17Mao71DNQ==", "requires": { "tslib": "^1.9.0" } }, "@angular/language-service": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-8.2.4.tgz", - "integrity": "sha512-8yoplzpNgZOMMluvxiyc0hwVs6WJBkHJoM8Dy3pb0UIRfYO1IDwrEEV6bbn25oORW59q7tUO3GD1yXwuJCswPQ==", + "version": "8.2.14", + "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-8.2.14.tgz", + "integrity": "sha512-7EhN9JJbAJcH2xCa+rIOmekjiEuB0qwPdHuD5qn/wwMfRzMZo+Db4hHbR9KHrLH6H82PTwYKye/LLpDaZqoHOA==", "dev": true }, "@angular/platform-browser": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-8.2.4.tgz", - "integrity": "sha512-3nd71h6S4RT9lHu9mVGD/741O+8MBSjI1A0V8H/LjT79yWnkxoR6BgZA7KL76AeTTITagUcVIuxtNAaxssgLHg==", + "version": "8.2.14", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-8.2.14.tgz", + "integrity": "sha512-MtJptptyKzsE37JZ2VB/tI4cvMrdAH+cT9pMBYZd66YSZfKjIj5s+AZo7z8ncoskQSB1o3HMfDjSK7QXGx1mLQ==", "requires": { "tslib": "^1.9.0" } }, "@angular/platform-browser-dynamic": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-8.2.4.tgz", - "integrity": "sha512-qn84B796UZIl1KB+YcxwFCx/Ze439Zf7G8ZK+xKsUA16H0R6GswAgBndnYq8xFjww1g4dY7zNH/XZ2FKngETKA==", + "version": "8.2.14", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-8.2.14.tgz", + "integrity": "sha512-mO2JPR5kLU/A3AQngy9+R/Q5gaF9csMStBQjwsCRI0wNtlItOIGL6+wTYpiTuh/ux+WVN1F2sLcEYU4Zf1ud9A==", "requires": { "tslib": "^1.9.0" } }, "@angular/router": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-8.2.4.tgz", - "integrity": "sha512-ZJwsztlD1vbb2HF9SgvHfpIK82BkOlDP2OmdZZavnxV7RstbU5hUkJ4lB4JB/EN9B76djo117CwOxxIveOhJMQ==", + "version": "8.2.14", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-8.2.14.tgz", + "integrity": "sha512-DHA2BhODqV7F0g6ZKgFaZgbsqzHHWRcfWchCOrOVKu2rYiKUTwwHVLBgZAhrpNeinq2pWanVYSIhMr7wy+LfEA==", "requires": { "tslib": "^1.9.0" } @@ -1041,6 +1138,66 @@ "@babel/highlight": "^7.0.0" } }, + "@babel/core": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.5.5.tgz", + "integrity": "sha512-i4qoSr2KTtce0DmkuuQBV4AuQgGPUcPXMr9L5MyYAtk06z068lQ10a4O009fe5OB/DfNV+h+qqT7ddNV8UnRjg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.5.5", + "@babel/generator": "^7.5.5", + "@babel/helpers": "^7.5.5", + "@babel/parser": "^7.5.5", + "@babel/template": "^7.4.4", + "@babel/traverse": "^7.5.5", + "@babel/types": "^7.5.5", + "convert-source-map": "^1.1.0", + "debug": "^4.1.0", + "json5": "^2.1.0", + "lodash": "^4.17.13", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "json5": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.1.tgz", + "integrity": "sha512-l+3HXD0GEI3huGq1njuqtzYK8OYJyXMkOLtQ53pjWh89tvWS2h6l+1zMkYWqlb57+SiQodKZyvMEFb2X+KrFhQ==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, "@babel/generator": { "version": "7.5.5", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.5.5.tgz", @@ -1068,60 +1225,1741 @@ } } }, - "@babel/helper-function-name": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.1.0.tgz", - "integrity": "sha512-A95XEoCpb3TO+KZzJ4S/5uW5fNe26DjBGqf1o9ucyLyCmi1dXq/B3c8iaWTfBk3VvetUxl16e8tIrd5teOCfGw==", + "@babel/helper-annotate-as-pure": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.7.4.tgz", + "integrity": "sha512-2BQmQgECKzYKFPpiycoF9tlb5HA4lrVyAmLLVK177EcQAqjVLciUb2/R+n1boQ9y5ENV3uz2ZqiNw7QMBBw1Og==", "dev": true, "requires": { - "@babel/helper-get-function-arity": "^7.0.0", - "@babel/template": "^7.1.0", - "@babel/types": "^7.0.0" + "@babel/types": "^7.7.4" + }, + "dependencies": { + "@babel/types": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.7.4.tgz", + "integrity": "sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } } }, - "@babel/helper-get-function-arity": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0.tgz", - "integrity": "sha512-r2DbJeg4svYvt3HOS74U4eWKsUAMRH01Z1ds1zx8KNTPtpTL5JAsdFv8BNyOpVqdFhHkkRDIg5B4AsxmkjAlmQ==", + "@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.7.4.tgz", + "integrity": "sha512-Biq/d/WtvfftWZ9Uf39hbPBYDUo986m5Bb4zhkeYDGUllF43D+nUe5M6Vuo6/8JDK/0YX/uBdeoQpyaNhNugZQ==", "dev": true, "requires": { - "@babel/types": "^7.0.0" + "@babel/helper-explode-assignable-expression": "^7.7.4", + "@babel/types": "^7.7.4" + }, + "dependencies": { + "@babel/types": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.7.4.tgz", + "integrity": "sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } } }, - "@babel/helper-split-export-declaration": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.4.4.tgz", - "integrity": "sha512-Ro/XkzLf3JFITkW6b+hNxzZ1n5OQ80NvIUdmHspih1XAhtN3vPTuUFT4eQnela+2MaZ5ulH+iyP513KJrxbN7Q==", + "@babel/helper-call-delegate": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-call-delegate/-/helper-call-delegate-7.7.4.tgz", + "integrity": "sha512-8JH9/B7J7tCYJ2PpWVpw9JhPuEVHztagNVuQAFBVFYluRMlpG7F1CgKEgGeL6KFqcsIa92ZYVj6DSc0XwmN1ZA==", "dev": true, "requires": { - "@babel/types": "^7.4.4" + "@babel/helper-hoist-variables": "^7.7.4", + "@babel/traverse": "^7.7.4", + "@babel/types": "^7.7.4" + }, + "dependencies": { + "@babel/generator": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.7.4.tgz", + "integrity": "sha512-m5qo2WgdOJeyYngKImbkyQrnUN1mPceaG5BV+G0E3gWsa4l/jCSryWJdM2x8OuGAOyh+3d5pVYfZWCiNFtynxg==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } + }, + "@babel/helper-function-name": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.7.4.tgz", + "integrity": "sha512-AnkGIdiBhEuiwdoMnKm7jfPfqItZhgRaZfMg1XX3bS25INOnLPjPG1Ppnajh8eqgt5kPJnfqrRHqFqmjKDZLzQ==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.7.4", + "@babel/template": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.7.4.tgz", + "integrity": "sha512-QTGKEdCkjgzgfJ3bAyRwF4yyT3pg+vDgan8DSivq1eS0gwi+KGKE5x8kRcbeFTb/673mkO5SN1IZfmCfA5o+EA==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.7.4.tgz", + "integrity": "sha512-guAg1SXFcVr04Guk9eq0S4/rWS++sbmyqosJzVs8+1fH5NI+ZcmkaSkc7dmtAFbHFva6yRJnjW3yAcGxjueDug==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + } + }, + "@babel/parser": { + "version": "7.7.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.7.5.tgz", + "integrity": "sha512-KNlOe9+/nk4i29g0VXgl8PEXIRms5xKLJeuZ6UptN0fHv+jDiriG+y94X6qAgWTR0h3KaoM1wK5G5h7MHFRSig==", + "dev": true + }, + "@babel/template": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.7.4.tgz", + "integrity": "sha512-qUzihgVPguAzXCK7WXw8pqs6cEwi54s3E+HrejlkuWO6ivMKx9hZl3Y2fSXp9i5HgyWmj7RKP+ulaYnKM4yYxw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/traverse": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.7.4.tgz", + "integrity": "sha512-P1L58hQyupn8+ezVA2z5KBm4/Zr4lCC8dwKCMYzsa5jFMDMQAzaBNy9W5VjB+KAmBjb40U7a/H6ao+Xo+9saIw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.5.5", + "@babel/generator": "^7.7.4", + "@babel/helper-function-name": "^7.7.4", + "@babel/helper-split-export-declaration": "^7.7.4", + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.7.4.tgz", + "integrity": "sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } } }, - "@babel/highlight": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.5.0.tgz", - "integrity": "sha512-7dV4eu9gBxoM0dAnj/BCFDW9LFU0zvTrkq0ugM7pnHEgguOEeOz1so2ZghEdzviYzQEED0r4EAgpsBChKy1TRQ==", + "@babel/helper-create-regexp-features-plugin": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.7.4.tgz", + "integrity": "sha512-Mt+jBKaxL0zfOIWrfQpnfYCN7/rS6GKx6CCCfuoqVVd+17R8zNDlzVYmIi9qyb2wOk002NsmSTDymkIygDUH7A==", + "dev": true, + "requires": { + "@babel/helper-regex": "^7.4.4", + "regexpu-core": "^4.6.0" + }, + "dependencies": { + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", + "dev": true + }, + "regexpu-core": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.6.0.tgz", + "integrity": "sha512-YlVaefl8P5BnFYOITTNzDvan1ulLOiXJzCNZxduTIosN17b87h3bvG9yHMoHaRuo88H4mQ06Aodj5VtYGGGiTg==", + "dev": true, + "requires": { + "regenerate": "^1.4.0", + "regenerate-unicode-properties": "^8.1.0", + "regjsgen": "^0.5.0", + "regjsparser": "^0.6.0", + "unicode-match-property-ecmascript": "^1.0.4", + "unicode-match-property-value-ecmascript": "^1.1.0" + } + }, + "regjsgen": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.5.1.tgz", + "integrity": "sha512-5qxzGZjDs9w4tzT3TPhCJqWdCc3RLYwy9J2NB0nm5Lz+S273lvWcpjaTGHsT1dc6Hhfq41uSEOw8wBmxrKOuyg==", + "dev": true + }, + "regjsparser": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.6.0.tgz", + "integrity": "sha512-RQ7YyokLiQBomUJuUG8iGVvkgOLxwyZM8k6d3q5SAXpg4r5TZJZigKFvC6PpD+qQ98bCDC5YelPeA3EucDoNeQ==", + "dev": true, + "requires": { + "jsesc": "~0.5.0" + } + } + } + }, + "@babel/helper-define-map": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-define-map/-/helper-define-map-7.7.4.tgz", + "integrity": "sha512-v5LorqOa0nVQUvAUTUF3KPastvUt/HzByXNamKQ6RdJRTV7j8rLL+WB5C/MzzWAwOomxDhYFb1wLLxHqox86lg==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.7.4", + "@babel/types": "^7.7.4", + "lodash": "^4.17.13" + }, + "dependencies": { + "@babel/helper-function-name": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.7.4.tgz", + "integrity": "sha512-AnkGIdiBhEuiwdoMnKm7jfPfqItZhgRaZfMg1XX3bS25INOnLPjPG1Ppnajh8eqgt5kPJnfqrRHqFqmjKDZLzQ==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.7.4", + "@babel/template": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.7.4.tgz", + "integrity": "sha512-QTGKEdCkjgzgfJ3bAyRwF4yyT3pg+vDgan8DSivq1eS0gwi+KGKE5x8kRcbeFTb/673mkO5SN1IZfmCfA5o+EA==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + } + }, + "@babel/parser": { + "version": "7.7.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.7.5.tgz", + "integrity": "sha512-KNlOe9+/nk4i29g0VXgl8PEXIRms5xKLJeuZ6UptN0fHv+jDiriG+y94X6qAgWTR0h3KaoM1wK5G5h7MHFRSig==", + "dev": true + }, + "@babel/template": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.7.4.tgz", + "integrity": "sha512-qUzihgVPguAzXCK7WXw8pqs6cEwi54s3E+HrejlkuWO6ivMKx9hZl3Y2fSXp9i5HgyWmj7RKP+ulaYnKM4yYxw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/types": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.7.4.tgz", + "integrity": "sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-explode-assignable-expression": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.7.4.tgz", + "integrity": "sha512-2/SicuFrNSXsZNBxe5UGdLr+HZg+raWBLE9vC98bdYOKX/U6PY0mdGlYUJdtTDPSU0Lw0PNbKKDpwYHJLn2jLg==", + "dev": true, + "requires": { + "@babel/traverse": "^7.7.4", + "@babel/types": "^7.7.4" + }, + "dependencies": { + "@babel/generator": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.7.4.tgz", + "integrity": "sha512-m5qo2WgdOJeyYngKImbkyQrnUN1mPceaG5BV+G0E3gWsa4l/jCSryWJdM2x8OuGAOyh+3d5pVYfZWCiNFtynxg==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } + }, + "@babel/helper-function-name": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.7.4.tgz", + "integrity": "sha512-AnkGIdiBhEuiwdoMnKm7jfPfqItZhgRaZfMg1XX3bS25INOnLPjPG1Ppnajh8eqgt5kPJnfqrRHqFqmjKDZLzQ==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.7.4", + "@babel/template": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.7.4.tgz", + "integrity": "sha512-QTGKEdCkjgzgfJ3bAyRwF4yyT3pg+vDgan8DSivq1eS0gwi+KGKE5x8kRcbeFTb/673mkO5SN1IZfmCfA5o+EA==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.7.4.tgz", + "integrity": "sha512-guAg1SXFcVr04Guk9eq0S4/rWS++sbmyqosJzVs8+1fH5NI+ZcmkaSkc7dmtAFbHFva6yRJnjW3yAcGxjueDug==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + } + }, + "@babel/parser": { + "version": "7.7.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.7.5.tgz", + "integrity": "sha512-KNlOe9+/nk4i29g0VXgl8PEXIRms5xKLJeuZ6UptN0fHv+jDiriG+y94X6qAgWTR0h3KaoM1wK5G5h7MHFRSig==", + "dev": true + }, + "@babel/template": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.7.4.tgz", + "integrity": "sha512-qUzihgVPguAzXCK7WXw8pqs6cEwi54s3E+HrejlkuWO6ivMKx9hZl3Y2fSXp9i5HgyWmj7RKP+ulaYnKM4yYxw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/traverse": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.7.4.tgz", + "integrity": "sha512-P1L58hQyupn8+ezVA2z5KBm4/Zr4lCC8dwKCMYzsa5jFMDMQAzaBNy9W5VjB+KAmBjb40U7a/H6ao+Xo+9saIw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.5.5", + "@babel/generator": "^7.7.4", + "@babel/helper-function-name": "^7.7.4", + "@babel/helper-split-export-declaration": "^7.7.4", + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.7.4.tgz", + "integrity": "sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/helper-function-name": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.1.0.tgz", + "integrity": "sha512-A95XEoCpb3TO+KZzJ4S/5uW5fNe26DjBGqf1o9ucyLyCmi1dXq/B3c8iaWTfBk3VvetUxl16e8tIrd5teOCfGw==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.0.0", + "@babel/template": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0.tgz", + "integrity": "sha512-r2DbJeg4svYvt3HOS74U4eWKsUAMRH01Z1ds1zx8KNTPtpTL5JAsdFv8BNyOpVqdFhHkkRDIg5B4AsxmkjAlmQ==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.7.4.tgz", + "integrity": "sha512-wQC4xyvc1Jo/FnLirL6CEgPgPCa8M74tOdjWpRhQYapz5JC7u3NYU1zCVoVAGCE3EaIP9T1A3iW0WLJ+reZlpQ==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + }, + "dependencies": { + "@babel/types": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.7.4.tgz", + "integrity": "sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.7.4.tgz", + "integrity": "sha512-9KcA1X2E3OjXl/ykfMMInBK+uVdfIVakVe7W7Lg3wfXUNyS3Q1HWLFRwZIjhqiCGbslummPDnmb7vIekS0C1vw==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + }, + "dependencies": { + "@babel/types": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.7.4.tgz", + "integrity": "sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-module-imports": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.7.4.tgz", + "integrity": "sha512-dGcrX6K9l8258WFjyDLJwuVKxR4XZfU0/vTUgOQYWEnRD8mgr+p4d6fCUMq/ys0h4CCt/S5JhbvtyErjWouAUQ==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + }, + "dependencies": { + "@babel/types": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.7.4.tgz", + "integrity": "sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-module-transforms": { + "version": "7.7.5", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.7.5.tgz", + "integrity": "sha512-A7pSxyJf1gN5qXVcidwLWydjftUN878VkalhXX5iQDuGyiGK3sOrrKKHF4/A4fwHtnsotv/NipwAeLzY4KQPvw==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.7.4", + "@babel/helper-simple-access": "^7.7.4", + "@babel/helper-split-export-declaration": "^7.7.4", + "@babel/template": "^7.7.4", + "@babel/types": "^7.7.4", + "lodash": "^4.17.13" + }, + "dependencies": { + "@babel/helper-split-export-declaration": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.7.4.tgz", + "integrity": "sha512-guAg1SXFcVr04Guk9eq0S4/rWS++sbmyqosJzVs8+1fH5NI+ZcmkaSkc7dmtAFbHFva6yRJnjW3yAcGxjueDug==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + } + }, + "@babel/parser": { + "version": "7.7.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.7.5.tgz", + "integrity": "sha512-KNlOe9+/nk4i29g0VXgl8PEXIRms5xKLJeuZ6UptN0fHv+jDiriG+y94X6qAgWTR0h3KaoM1wK5G5h7MHFRSig==", + "dev": true + }, + "@babel/template": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.7.4.tgz", + "integrity": "sha512-qUzihgVPguAzXCK7WXw8pqs6cEwi54s3E+HrejlkuWO6ivMKx9hZl3Y2fSXp9i5HgyWmj7RKP+ulaYnKM4yYxw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/types": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.7.4.tgz", + "integrity": "sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.7.4.tgz", + "integrity": "sha512-VB7gWZ2fDkSuqW6b1AKXkJWO5NyNI3bFL/kK79/30moK57blr6NbH8xcl2XcKCwOmJosftWunZqfO84IGq3ZZg==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + }, + "dependencies": { + "@babel/types": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.7.4.tgz", + "integrity": "sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-plugin-utils": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.0.0.tgz", + "integrity": "sha512-CYAOUCARwExnEixLdB6sDm2dIJ/YgEAKDM1MOeMeZu9Ld/bDgVo8aiWrXwcY7OBh+1Ea2uUcVRcxKk0GJvW7QA==", + "dev": true + }, + "@babel/helper-regex": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/helper-regex/-/helper-regex-7.5.5.tgz", + "integrity": "sha512-CkCYQLkfkiugbRDO8eZn6lRuR8kzZoGXCg3149iTk5se7g6qykSpy3+hELSwquhu+TgHn8nkLiBwHvNX8Hofcw==", + "dev": true, + "requires": { + "lodash": "^4.17.13" + } + }, + "@babel/helper-remap-async-to-generator": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.7.4.tgz", + "integrity": "sha512-Sk4xmtVdM9sA/jCI80f+KS+Md+ZHIpjuqmYPk1M7F/upHou5e4ReYmExAiu6PVe65BhJPZA2CY9x9k4BqE5klw==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.7.4", + "@babel/helper-wrap-function": "^7.7.4", + "@babel/template": "^7.7.4", + "@babel/traverse": "^7.7.4", + "@babel/types": "^7.7.4" + }, + "dependencies": { + "@babel/generator": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.7.4.tgz", + "integrity": "sha512-m5qo2WgdOJeyYngKImbkyQrnUN1mPceaG5BV+G0E3gWsa4l/jCSryWJdM2x8OuGAOyh+3d5pVYfZWCiNFtynxg==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } + }, + "@babel/helper-function-name": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.7.4.tgz", + "integrity": "sha512-AnkGIdiBhEuiwdoMnKm7jfPfqItZhgRaZfMg1XX3bS25INOnLPjPG1Ppnajh8eqgt5kPJnfqrRHqFqmjKDZLzQ==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.7.4", + "@babel/template": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.7.4.tgz", + "integrity": "sha512-QTGKEdCkjgzgfJ3bAyRwF4yyT3pg+vDgan8DSivq1eS0gwi+KGKE5x8kRcbeFTb/673mkO5SN1IZfmCfA5o+EA==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.7.4.tgz", + "integrity": "sha512-guAg1SXFcVr04Guk9eq0S4/rWS++sbmyqosJzVs8+1fH5NI+ZcmkaSkc7dmtAFbHFva6yRJnjW3yAcGxjueDug==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + } + }, + "@babel/parser": { + "version": "7.7.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.7.5.tgz", + "integrity": "sha512-KNlOe9+/nk4i29g0VXgl8PEXIRms5xKLJeuZ6UptN0fHv+jDiriG+y94X6qAgWTR0h3KaoM1wK5G5h7MHFRSig==", + "dev": true + }, + "@babel/template": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.7.4.tgz", + "integrity": "sha512-qUzihgVPguAzXCK7WXw8pqs6cEwi54s3E+HrejlkuWO6ivMKx9hZl3Y2fSXp9i5HgyWmj7RKP+ulaYnKM4yYxw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/traverse": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.7.4.tgz", + "integrity": "sha512-P1L58hQyupn8+ezVA2z5KBm4/Zr4lCC8dwKCMYzsa5jFMDMQAzaBNy9W5VjB+KAmBjb40U7a/H6ao+Xo+9saIw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.5.5", + "@babel/generator": "^7.7.4", + "@babel/helper-function-name": "^7.7.4", + "@babel/helper-split-export-declaration": "^7.7.4", + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.7.4.tgz", + "integrity": "sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/helper-replace-supers": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.7.4.tgz", + "integrity": "sha512-pP0tfgg9hsZWo5ZboYGuBn/bbYT/hdLPVSS4NMmiRJdwWhP0IznPwN9AE1JwyGsjSPLC364I0Qh5p+EPkGPNpg==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.7.4", + "@babel/helper-optimise-call-expression": "^7.7.4", + "@babel/traverse": "^7.7.4", + "@babel/types": "^7.7.4" + }, + "dependencies": { + "@babel/generator": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.7.4.tgz", + "integrity": "sha512-m5qo2WgdOJeyYngKImbkyQrnUN1mPceaG5BV+G0E3gWsa4l/jCSryWJdM2x8OuGAOyh+3d5pVYfZWCiNFtynxg==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } + }, + "@babel/helper-function-name": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.7.4.tgz", + "integrity": "sha512-AnkGIdiBhEuiwdoMnKm7jfPfqItZhgRaZfMg1XX3bS25INOnLPjPG1Ppnajh8eqgt5kPJnfqrRHqFqmjKDZLzQ==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.7.4", + "@babel/template": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.7.4.tgz", + "integrity": "sha512-QTGKEdCkjgzgfJ3bAyRwF4yyT3pg+vDgan8DSivq1eS0gwi+KGKE5x8kRcbeFTb/673mkO5SN1IZfmCfA5o+EA==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.7.4.tgz", + "integrity": "sha512-guAg1SXFcVr04Guk9eq0S4/rWS++sbmyqosJzVs8+1fH5NI+ZcmkaSkc7dmtAFbHFva6yRJnjW3yAcGxjueDug==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + } + }, + "@babel/parser": { + "version": "7.7.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.7.5.tgz", + "integrity": "sha512-KNlOe9+/nk4i29g0VXgl8PEXIRms5xKLJeuZ6UptN0fHv+jDiriG+y94X6qAgWTR0h3KaoM1wK5G5h7MHFRSig==", + "dev": true + }, + "@babel/template": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.7.4.tgz", + "integrity": "sha512-qUzihgVPguAzXCK7WXw8pqs6cEwi54s3E+HrejlkuWO6ivMKx9hZl3Y2fSXp9i5HgyWmj7RKP+ulaYnKM4yYxw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/traverse": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.7.4.tgz", + "integrity": "sha512-P1L58hQyupn8+ezVA2z5KBm4/Zr4lCC8dwKCMYzsa5jFMDMQAzaBNy9W5VjB+KAmBjb40U7a/H6ao+Xo+9saIw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.5.5", + "@babel/generator": "^7.7.4", + "@babel/helper-function-name": "^7.7.4", + "@babel/helper-split-export-declaration": "^7.7.4", + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.7.4.tgz", + "integrity": "sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/helper-simple-access": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.7.4.tgz", + "integrity": "sha512-zK7THeEXfan7UlWsG2A6CI/L9jVnI5+xxKZOdej39Y0YtDYKx9raHk5F2EtK9K8DHRTihYwg20ADt9S36GR78A==", + "dev": true, + "requires": { + "@babel/template": "^7.7.4", + "@babel/types": "^7.7.4" + }, + "dependencies": { + "@babel/parser": { + "version": "7.7.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.7.5.tgz", + "integrity": "sha512-KNlOe9+/nk4i29g0VXgl8PEXIRms5xKLJeuZ6UptN0fHv+jDiriG+y94X6qAgWTR0h3KaoM1wK5G5h7MHFRSig==", + "dev": true + }, + "@babel/template": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.7.4.tgz", + "integrity": "sha512-qUzihgVPguAzXCK7WXw8pqs6cEwi54s3E+HrejlkuWO6ivMKx9hZl3Y2fSXp9i5HgyWmj7RKP+ulaYnKM4yYxw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/types": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.7.4.tgz", + "integrity": "sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.4.4.tgz", + "integrity": "sha512-Ro/XkzLf3JFITkW6b+hNxzZ1n5OQ80NvIUdmHspih1XAhtN3vPTuUFT4eQnela+2MaZ5ulH+iyP513KJrxbN7Q==", + "dev": true, + "requires": { + "@babel/types": "^7.4.4" + } + }, + "@babel/helper-wrap-function": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.7.4.tgz", + "integrity": "sha512-VsfzZt6wmsocOaVU0OokwrIytHND55yvyT4BPB9AIIgwr8+x7617hetdJTsuGwygN5RC6mxA9EJztTjuwm2ofg==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.7.4", + "@babel/template": "^7.7.4", + "@babel/traverse": "^7.7.4", + "@babel/types": "^7.7.4" + }, + "dependencies": { + "@babel/generator": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.7.4.tgz", + "integrity": "sha512-m5qo2WgdOJeyYngKImbkyQrnUN1mPceaG5BV+G0E3gWsa4l/jCSryWJdM2x8OuGAOyh+3d5pVYfZWCiNFtynxg==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } + }, + "@babel/helper-function-name": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.7.4.tgz", + "integrity": "sha512-AnkGIdiBhEuiwdoMnKm7jfPfqItZhgRaZfMg1XX3bS25INOnLPjPG1Ppnajh8eqgt5kPJnfqrRHqFqmjKDZLzQ==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.7.4", + "@babel/template": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.7.4.tgz", + "integrity": "sha512-QTGKEdCkjgzgfJ3bAyRwF4yyT3pg+vDgan8DSivq1eS0gwi+KGKE5x8kRcbeFTb/673mkO5SN1IZfmCfA5o+EA==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.7.4.tgz", + "integrity": "sha512-guAg1SXFcVr04Guk9eq0S4/rWS++sbmyqosJzVs8+1fH5NI+ZcmkaSkc7dmtAFbHFva6yRJnjW3yAcGxjueDug==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + } + }, + "@babel/parser": { + "version": "7.7.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.7.5.tgz", + "integrity": "sha512-KNlOe9+/nk4i29g0VXgl8PEXIRms5xKLJeuZ6UptN0fHv+jDiriG+y94X6qAgWTR0h3KaoM1wK5G5h7MHFRSig==", + "dev": true + }, + "@babel/template": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.7.4.tgz", + "integrity": "sha512-qUzihgVPguAzXCK7WXw8pqs6cEwi54s3E+HrejlkuWO6ivMKx9hZl3Y2fSXp9i5HgyWmj7RKP+ulaYnKM4yYxw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/traverse": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.7.4.tgz", + "integrity": "sha512-P1L58hQyupn8+ezVA2z5KBm4/Zr4lCC8dwKCMYzsa5jFMDMQAzaBNy9W5VjB+KAmBjb40U7a/H6ao+Xo+9saIw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.5.5", + "@babel/generator": "^7.7.4", + "@babel/helper-function-name": "^7.7.4", + "@babel/helper-split-export-declaration": "^7.7.4", + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.7.4.tgz", + "integrity": "sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/helpers": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.7.4.tgz", + "integrity": "sha512-ak5NGZGJ6LV85Q1Zc9gn2n+ayXOizryhjSUBTdu5ih1tlVCJeuQENzc4ItyCVhINVXvIT/ZQ4mheGIsfBkpskg==", + "dev": true, + "requires": { + "@babel/template": "^7.7.4", + "@babel/traverse": "^7.7.4", + "@babel/types": "^7.7.4" + }, + "dependencies": { + "@babel/generator": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.7.4.tgz", + "integrity": "sha512-m5qo2WgdOJeyYngKImbkyQrnUN1mPceaG5BV+G0E3gWsa4l/jCSryWJdM2x8OuGAOyh+3d5pVYfZWCiNFtynxg==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } + }, + "@babel/helper-function-name": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.7.4.tgz", + "integrity": "sha512-AnkGIdiBhEuiwdoMnKm7jfPfqItZhgRaZfMg1XX3bS25INOnLPjPG1Ppnajh8eqgt5kPJnfqrRHqFqmjKDZLzQ==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.7.4", + "@babel/template": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.7.4.tgz", + "integrity": "sha512-QTGKEdCkjgzgfJ3bAyRwF4yyT3pg+vDgan8DSivq1eS0gwi+KGKE5x8kRcbeFTb/673mkO5SN1IZfmCfA5o+EA==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.7.4.tgz", + "integrity": "sha512-guAg1SXFcVr04Guk9eq0S4/rWS++sbmyqosJzVs8+1fH5NI+ZcmkaSkc7dmtAFbHFva6yRJnjW3yAcGxjueDug==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + } + }, + "@babel/parser": { + "version": "7.7.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.7.5.tgz", + "integrity": "sha512-KNlOe9+/nk4i29g0VXgl8PEXIRms5xKLJeuZ6UptN0fHv+jDiriG+y94X6qAgWTR0h3KaoM1wK5G5h7MHFRSig==", + "dev": true + }, + "@babel/template": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.7.4.tgz", + "integrity": "sha512-qUzihgVPguAzXCK7WXw8pqs6cEwi54s3E+HrejlkuWO6ivMKx9hZl3Y2fSXp9i5HgyWmj7RKP+ulaYnKM4yYxw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/traverse": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.7.4.tgz", + "integrity": "sha512-P1L58hQyupn8+ezVA2z5KBm4/Zr4lCC8dwKCMYzsa5jFMDMQAzaBNy9W5VjB+KAmBjb40U7a/H6ao+Xo+9saIw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.5.5", + "@babel/generator": "^7.7.4", + "@babel/helper-function-name": "^7.7.4", + "@babel/helper-split-export-declaration": "^7.7.4", + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.7.4.tgz", + "integrity": "sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/highlight": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.5.0.tgz", + "integrity": "sha512-7dV4eu9gBxoM0dAnj/BCFDW9LFU0zvTrkq0ugM7pnHEgguOEeOz1so2ZghEdzviYzQEED0r4EAgpsBChKy1TRQ==", + "dev": true, + "requires": { + "chalk": "^2.0.0", + "esutils": "^2.0.2", + "js-tokens": "^4.0.0" + }, + "dependencies": { + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + } + } + }, + "@babel/parser": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.5.5.tgz", + "integrity": "sha512-E5BN68cqR7dhKan1SfqgPGhQ178bkVKpXTPEXnFJBrEt8/DKRZlybmy+IgYLTeN7tp1R5Ccmbm2rBk17sHYU3g==", + "dev": true + }, + "@babel/plugin-proposal-async-generator-functions": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.7.4.tgz", + "integrity": "sha512-1ypyZvGRXriY/QP668+s8sFr2mqinhkRDMPSQLNghCQE+GAkFtp+wkHVvg2+Hdki8gwP+NFzJBJ/N1BfzCCDEw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-remap-async-to-generator": "^7.7.4", + "@babel/plugin-syntax-async-generators": "^7.7.4" + } + }, + "@babel/plugin-proposal-dynamic-import": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.7.4.tgz", + "integrity": "sha512-StH+nGAdO6qDB1l8sZ5UBV8AC3F2VW2I8Vfld73TMKyptMU9DY5YsJAS8U81+vEtxcH3Y/La0wG0btDrhpnhjQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-dynamic-import": "^7.7.4" + } + }, + "@babel/plugin-proposal-json-strings": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.7.4.tgz", + "integrity": "sha512-wQvt3akcBTfLU/wYoqm/ws7YOAQKu8EVJEvHip/mzkNtjaclQoCCIqKXFP5/eyfnfbQCDV3OLRIK3mIVyXuZlw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-json-strings": "^7.7.4" + } + }, + "@babel/plugin-proposal-object-rest-spread": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.7.4.tgz", + "integrity": "sha512-rnpnZR3/iWKmiQyJ3LKJpSwLDcX/nSXhdLk4Aq/tXOApIvyu7qoabrige0ylsAJffaUC51WiBu209Q0U+86OWQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-object-rest-spread": "^7.7.4" + } + }, + "@babel/plugin-proposal-optional-catch-binding": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.7.4.tgz", + "integrity": "sha512-DyM7U2bnsQerCQ+sejcTNZh8KQEUuC3ufzdnVnSiUv/qoGJp2Z3hanKL18KDhsBT5Wj6a7CMT5mdyCNJsEaA9w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-syntax-optional-catch-binding": "^7.7.4" + } + }, + "@babel/plugin-proposal-unicode-property-regex": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.7.4.tgz", + "integrity": "sha512-cHgqHgYvffluZk85dJ02vloErm3Y6xtH+2noOBOJ2kXOJH3aVCDnj5eR/lVNlTnYu4hndAPJD3rTFjW3qee0PA==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.7.4", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-async-generators": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.7.4.tgz", + "integrity": "sha512-Li4+EjSpBgxcsmeEF8IFcfV/+yJGxHXDirDkEoyFjumuwbmfCVHUt0HuowD/iGM7OhIRyXJH9YXxqiH6N815+g==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-dynamic-import": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.7.4.tgz", + "integrity": "sha512-jHQW0vbRGvwQNgyVxwDh4yuXu4bH1f5/EICJLAhl1SblLs2CDhrsmCk+v5XLdE9wxtAFRyxx+P//Iw+a5L/tTg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-json-strings": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.7.4.tgz", + "integrity": "sha512-QpGupahTQW1mHRXddMG5srgpHWqRLwJnJZKXTigB9RPFCCGbDGCgBeM/iC82ICXp414WeYx/tD54w7M2qRqTMg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-object-rest-spread": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.7.4.tgz", + "integrity": "sha512-mObR+r+KZq0XhRVS2BrBKBpr5jqrqzlPvS9C9vuOf5ilSwzloAl7RPWLrgKdWS6IreaVrjHxTjtyqFiOisaCwg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-syntax-optional-catch-binding": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.7.4.tgz", + "integrity": "sha512-4ZSuzWgFxqHRE31Glu+fEr/MirNZOMYmD/0BhBWyLyOOQz/gTAl7QmWm2hX1QxEIXsr2vkdlwxIzTyiYRC4xcQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-arrow-functions": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.7.4.tgz", + "integrity": "sha512-zUXy3e8jBNPiffmqkHRNDdZM2r8DWhCB7HhcoyZjiK1TxYEluLHAvQuYnTT+ARqRpabWqy/NHkO6e3MsYB5YfA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-async-to-generator": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.7.4.tgz", + "integrity": "sha512-zpUTZphp5nHokuy8yLlyafxCJ0rSlFoSHypTUWgpdwoDXWQcseaect7cJ8Ppk6nunOM6+5rPMkod4OYKPR5MUg==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.7.4", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-remap-async-to-generator": "^7.7.4" + } + }, + "@babel/plugin-transform-block-scoped-functions": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.7.4.tgz", + "integrity": "sha512-kqtQzwtKcpPclHYjLK//3lH8OFsCDuDJBaFhVwf8kqdnF6MN4l618UDlcA7TfRs3FayrHj+svYnSX8MC9zmUyQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-block-scoping": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.7.4.tgz", + "integrity": "sha512-2VBe9u0G+fDt9B5OV5DQH4KBf5DoiNkwFKOz0TCvBWvdAN2rOykCTkrL+jTLxfCAm76l9Qo5OqL7HBOx2dWggg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "lodash": "^4.17.13" + } + }, + "@babel/plugin-transform-classes": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.7.4.tgz", + "integrity": "sha512-sK1mjWat7K+buWRuImEzjNf68qrKcrddtpQo3swi9j7dUcG6y6R6+Di039QN2bD1dykeswlagupEmpOatFHHUg==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.7.4", + "@babel/helper-define-map": "^7.7.4", + "@babel/helper-function-name": "^7.7.4", + "@babel/helper-optimise-call-expression": "^7.7.4", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-replace-supers": "^7.7.4", + "@babel/helper-split-export-declaration": "^7.7.4", + "globals": "^11.1.0" + }, + "dependencies": { + "@babel/helper-function-name": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.7.4.tgz", + "integrity": "sha512-AnkGIdiBhEuiwdoMnKm7jfPfqItZhgRaZfMg1XX3bS25INOnLPjPG1Ppnajh8eqgt5kPJnfqrRHqFqmjKDZLzQ==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.7.4", + "@babel/template": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.7.4.tgz", + "integrity": "sha512-QTGKEdCkjgzgfJ3bAyRwF4yyT3pg+vDgan8DSivq1eS0gwi+KGKE5x8kRcbeFTb/673mkO5SN1IZfmCfA5o+EA==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.7.4.tgz", + "integrity": "sha512-guAg1SXFcVr04Guk9eq0S4/rWS++sbmyqosJzVs8+1fH5NI+ZcmkaSkc7dmtAFbHFva6yRJnjW3yAcGxjueDug==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + } + }, + "@babel/parser": { + "version": "7.7.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.7.5.tgz", + "integrity": "sha512-KNlOe9+/nk4i29g0VXgl8PEXIRms5xKLJeuZ6UptN0fHv+jDiriG+y94X6qAgWTR0h3KaoM1wK5G5h7MHFRSig==", + "dev": true + }, + "@babel/template": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.7.4.tgz", + "integrity": "sha512-qUzihgVPguAzXCK7WXw8pqs6cEwi54s3E+HrejlkuWO6ivMKx9hZl3Y2fSXp9i5HgyWmj7RKP+ulaYnKM4yYxw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/types": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.7.4.tgz", + "integrity": "sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/plugin-transform-computed-properties": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.7.4.tgz", + "integrity": "sha512-bSNsOsZnlpLLyQew35rl4Fma3yKWqK3ImWMSC/Nc+6nGjC9s5NFWAer1YQ899/6s9HxO2zQC1WoFNfkOqRkqRQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-destructuring": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.7.4.tgz", + "integrity": "sha512-4jFMXI1Cu2aXbcXXl8Lr6YubCn6Oc7k9lLsu8v61TZh+1jny2BWmdtvY9zSUlLdGUvcy9DMAWyZEOqjsbeg/wA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-dotall-regex": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.7.4.tgz", + "integrity": "sha512-mk0cH1zyMa/XHeb6LOTXTbG7uIJ8Rrjlzu91pUx/KS3JpcgaTDwMS8kM+ar8SLOvlL2Lofi4CGBAjCo3a2x+lw==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.7.4", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-duplicate-keys": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.7.4.tgz", + "integrity": "sha512-g1y4/G6xGWMD85Tlft5XedGaZBCIVN+/P0bs6eabmcPP9egFleMAo65OOjlhcz1njpwagyY3t0nsQC9oTFegJA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-exponentiation-operator": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.7.4.tgz", + "integrity": "sha512-MCqiLfCKm6KEA1dglf6Uqq1ElDIZwFuzz1WH5mTf8k2uQSxEJMbOIEh7IZv7uichr7PMfi5YVSrr1vz+ipp7AQ==", + "dev": true, + "requires": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.7.4", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-for-of": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.7.4.tgz", + "integrity": "sha512-zZ1fD1B8keYtEcKF+M1TROfeHTKnijcVQm0yO/Yu1f7qoDoxEIc/+GX6Go430Bg84eM/xwPFp0+h4EbZg7epAA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-function-name": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.7.4.tgz", + "integrity": "sha512-E/x09TvjHNhsULs2IusN+aJNRV5zKwxu1cpirZyRPw+FyyIKEHPXTsadj48bVpc1R5Qq1B5ZkzumuFLytnbT6g==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.7.4", + "@babel/helper-plugin-utils": "^7.0.0" + }, + "dependencies": { + "@babel/helper-function-name": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.7.4.tgz", + "integrity": "sha512-AnkGIdiBhEuiwdoMnKm7jfPfqItZhgRaZfMg1XX3bS25INOnLPjPG1Ppnajh8eqgt5kPJnfqrRHqFqmjKDZLzQ==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.7.4", + "@babel/template": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.7.4.tgz", + "integrity": "sha512-QTGKEdCkjgzgfJ3bAyRwF4yyT3pg+vDgan8DSivq1eS0gwi+KGKE5x8kRcbeFTb/673mkO5SN1IZfmCfA5o+EA==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + } + }, + "@babel/parser": { + "version": "7.7.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.7.5.tgz", + "integrity": "sha512-KNlOe9+/nk4i29g0VXgl8PEXIRms5xKLJeuZ6UptN0fHv+jDiriG+y94X6qAgWTR0h3KaoM1wK5G5h7MHFRSig==", + "dev": true + }, + "@babel/template": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.7.4.tgz", + "integrity": "sha512-qUzihgVPguAzXCK7WXw8pqs6cEwi54s3E+HrejlkuWO6ivMKx9hZl3Y2fSXp9i5HgyWmj7RKP+ulaYnKM4yYxw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.7.4", + "@babel/types": "^7.7.4" + } + }, + "@babel/types": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.7.4.tgz", + "integrity": "sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/plugin-transform-literals": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.7.4.tgz", + "integrity": "sha512-X2MSV7LfJFm4aZfxd0yLVFrEXAgPqYoDG53Br/tCKiKYfX0MjVjQeWPIhPHHsCqzwQANq+FLN786fF5rgLS+gw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-member-expression-literals": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.7.4.tgz", + "integrity": "sha512-9VMwMO7i69LHTesL0RdGy93JU6a+qOPuvB4F4d0kR0zyVjJRVJRaoaGjhtki6SzQUu8yen/vxPKN6CWnCUw6bA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-modules-amd": { + "version": "7.7.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.7.5.tgz", + "integrity": "sha512-CT57FG4A2ZUNU1v+HdvDSDrjNWBrtCmSH6YbbgN3Lrf0Di/q/lWRxZrE72p3+HCCz9UjfZOEBdphgC0nzOS6DQ==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.7.5", + "@babel/helper-plugin-utils": "^7.0.0", + "babel-plugin-dynamic-import-node": "^2.3.0" + } + }, + "@babel/plugin-transform-modules-commonjs": { + "version": "7.7.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.7.5.tgz", + "integrity": "sha512-9Cq4zTFExwFhQI6MT1aFxgqhIsMWQWDVwOgLzl7PTWJHsNaqFvklAU+Oz6AQLAS0dJKTwZSOCo20INwktxpi3Q==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.7.5", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-simple-access": "^7.7.4", + "babel-plugin-dynamic-import-node": "^2.3.0" + } + }, + "@babel/plugin-transform-modules-systemjs": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.7.4.tgz", + "integrity": "sha512-y2c96hmcsUi6LrMqvmNDPBBiGCiQu0aYqpHatVVu6kD4mFEXKjyNxd/drc18XXAf9dv7UXjrZwBVmTTGaGP8iw==", + "dev": true, + "requires": { + "@babel/helper-hoist-variables": "^7.7.4", + "@babel/helper-plugin-utils": "^7.0.0", + "babel-plugin-dynamic-import-node": "^2.3.0" + } + }, + "@babel/plugin-transform-modules-umd": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.7.4.tgz", + "integrity": "sha512-u2B8TIi0qZI4j8q4C51ktfO7E3cQ0qnaXFI1/OXITordD40tt17g/sXqgNNCcMTcBFKrUPcGDx+TBJuZxLx7tw==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.7.4", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.7.4.tgz", + "integrity": "sha512-jBUkiqLKvUWpv9GLSuHUFYdmHg0ujC1JEYoZUfeOOfNydZXp1sXObgyPatpcwjWgsdBGsagWW0cdJpX/DO2jMw==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.7.4" + } + }, + "@babel/plugin-transform-new-target": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.7.4.tgz", + "integrity": "sha512-CnPRiNtOG1vRodnsyGX37bHQleHE14B9dnnlgSeEs3ek3fHN1A1SScglTCg1sfbe7sRQ2BUcpgpTpWSfMKz3gg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-object-super": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.7.4.tgz", + "integrity": "sha512-ho+dAEhC2aRnff2JCA0SAK7V2R62zJd/7dmtoe7MHcso4C2mS+vZjn1Pb1pCVZvJs1mgsvv5+7sT+m3Bysb6eg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-replace-supers": "^7.7.4" + } + }, + "@babel/plugin-transform-parameters": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.7.4.tgz", + "integrity": "sha512-VJwhVePWPa0DqE9vcfptaJSzNDKrWU/4FbYCjZERtmqEs05g3UMXnYMZoXja7JAJ7Y7sPZipwm/pGApZt7wHlw==", + "dev": true, + "requires": { + "@babel/helper-call-delegate": "^7.7.4", + "@babel/helper-get-function-arity": "^7.7.4", + "@babel/helper-plugin-utils": "^7.0.0" + }, + "dependencies": { + "@babel/helper-get-function-arity": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.7.4.tgz", + "integrity": "sha512-QTGKEdCkjgzgfJ3bAyRwF4yyT3pg+vDgan8DSivq1eS0gwi+KGKE5x8kRcbeFTb/673mkO5SN1IZfmCfA5o+EA==", + "dev": true, + "requires": { + "@babel/types": "^7.7.4" + } + }, + "@babel/types": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.7.4.tgz", + "integrity": "sha512-cz5Ji23KCi4T+YIE/BolWosrJuSmoZeN1EFnRtBwF+KKLi8GG/Z2c2hOJJeCXPk4mwk4QFvTmwIodJowXgttRA==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } + } + }, + "@babel/plugin-transform-property-literals": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.7.4.tgz", + "integrity": "sha512-MatJhlC4iHsIskWYyawl53KuHrt+kALSADLQQ/HkhTjX954fkxIEh4q5slL4oRAnsm/eDoZ4q0CIZpcqBuxhJQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-regenerator": { + "version": "7.7.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.7.5.tgz", + "integrity": "sha512-/8I8tPvX2FkuEyWbjRCt4qTAgZK0DVy8QRguhA524UH48RfGJy94On2ri+dCuwOpcerPRl9O4ebQkRcVzIaGBw==", + "dev": true, + "requires": { + "regenerator-transform": "^0.14.0" + } + }, + "@babel/plugin-transform-reserved-words": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.7.4.tgz", + "integrity": "sha512-OrPiUB5s5XvkCO1lS7D8ZtHcswIC57j62acAnJZKqGGnHP+TIc/ljQSrgdX/QyOTdEK5COAhuc820Hi1q2UgLQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-shorthand-properties": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.7.4.tgz", + "integrity": "sha512-q+suddWRfIcnyG5YiDP58sT65AJDZSUhXQDZE3r04AuqD6d/XLaQPPXSBzP2zGerkgBivqtQm9XKGLuHqBID6Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-spread": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.7.4.tgz", + "integrity": "sha512-8OSs0FLe5/80cndziPlg4R0K6HcWSM0zyNhHhLsmw/Nc5MaA49cAsnoJ/t/YZf8qkG7fD+UjTRaApVDB526d7Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-sticky-regex": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.7.4.tgz", + "integrity": "sha512-Ls2NASyL6qtVe1H1hXts9yuEeONV2TJZmplLONkMPUG158CtmnrzW5Q5teibM5UVOFjG0D3IC5mzXR6pPpUY7A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/helper-regex": "^7.0.0" + } + }, + "@babel/plugin-transform-template-literals": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.7.4.tgz", + "integrity": "sha512-sA+KxLwF3QwGj5abMHkHgshp9+rRz+oY9uoRil4CyLtgEuE/88dpkeWgNk5qKVsJE9iSfly3nvHapdRiIS2wnQ==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.7.4", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-typeof-symbol": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.7.4.tgz", + "integrity": "sha512-KQPUQ/7mqe2m0B8VecdyaW5XcQYaePyl9R7IsKd+irzj6jvbhoGnRE+M0aNkyAzI07VfUQ9266L5xMARitV3wg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/plugin-transform-unicode-regex": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.7.4.tgz", + "integrity": "sha512-N77UUIV+WCvE+5yHw+oks3m18/umd7y392Zv7mYTpFqHtkpcc+QUz+gLJNTWVlWROIWeLqY0f3OjZxV5TcXnRw==", "dev": true, "requires": { - "chalk": "^2.0.0", - "esutils": "^2.0.2", - "js-tokens": "^4.0.0" + "@babel/helper-create-regexp-features-plugin": "^7.7.4", + "@babel/helper-plugin-utils": "^7.0.0" + } + }, + "@babel/preset-env": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.5.5.tgz", + "integrity": "sha512-GMZQka/+INwsMz1A5UEql8tG015h5j/qjptpKY2gJ7giy8ohzU710YciJB5rcKsWGWHiW3RUnHib0E5/m3Tp3A==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.0.0", + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-proposal-async-generator-functions": "^7.2.0", + "@babel/plugin-proposal-dynamic-import": "^7.5.0", + "@babel/plugin-proposal-json-strings": "^7.2.0", + "@babel/plugin-proposal-object-rest-spread": "^7.5.5", + "@babel/plugin-proposal-optional-catch-binding": "^7.2.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.4.4", + "@babel/plugin-syntax-async-generators": "^7.2.0", + "@babel/plugin-syntax-dynamic-import": "^7.2.0", + "@babel/plugin-syntax-json-strings": "^7.2.0", + "@babel/plugin-syntax-object-rest-spread": "^7.2.0", + "@babel/plugin-syntax-optional-catch-binding": "^7.2.0", + "@babel/plugin-transform-arrow-functions": "^7.2.0", + "@babel/plugin-transform-async-to-generator": "^7.5.0", + "@babel/plugin-transform-block-scoped-functions": "^7.2.0", + "@babel/plugin-transform-block-scoping": "^7.5.5", + "@babel/plugin-transform-classes": "^7.5.5", + "@babel/plugin-transform-computed-properties": "^7.2.0", + "@babel/plugin-transform-destructuring": "^7.5.0", + "@babel/plugin-transform-dotall-regex": "^7.4.4", + "@babel/plugin-transform-duplicate-keys": "^7.5.0", + "@babel/plugin-transform-exponentiation-operator": "^7.2.0", + "@babel/plugin-transform-for-of": "^7.4.4", + "@babel/plugin-transform-function-name": "^7.4.4", + "@babel/plugin-transform-literals": "^7.2.0", + "@babel/plugin-transform-member-expression-literals": "^7.2.0", + "@babel/plugin-transform-modules-amd": "^7.5.0", + "@babel/plugin-transform-modules-commonjs": "^7.5.0", + "@babel/plugin-transform-modules-systemjs": "^7.5.0", + "@babel/plugin-transform-modules-umd": "^7.2.0", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.4.5", + "@babel/plugin-transform-new-target": "^7.4.4", + "@babel/plugin-transform-object-super": "^7.5.5", + "@babel/plugin-transform-parameters": "^7.4.4", + "@babel/plugin-transform-property-literals": "^7.2.0", + "@babel/plugin-transform-regenerator": "^7.4.5", + "@babel/plugin-transform-reserved-words": "^7.2.0", + "@babel/plugin-transform-shorthand-properties": "^7.2.0", + "@babel/plugin-transform-spread": "^7.2.0", + "@babel/plugin-transform-sticky-regex": "^7.2.0", + "@babel/plugin-transform-template-literals": "^7.4.4", + "@babel/plugin-transform-typeof-symbol": "^7.2.0", + "@babel/plugin-transform-unicode-regex": "^7.4.4", + "@babel/types": "^7.5.5", + "browserslist": "^4.6.0", + "core-js-compat": "^3.1.1", + "invariant": "^2.2.2", + "js-levenshtein": "^1.1.3", + "semver": "^5.5.0" }, "dependencies": { - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", "dev": true } } }, - "@babel/parser": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.5.5.tgz", - "integrity": "sha512-E5BN68cqR7dhKan1SfqgPGhQ178bkVKpXTPEXnFJBrEt8/DKRZlybmy+IgYLTeN7tp1R5Ccmbm2rBk17sHYU3g==", - "dev": true - }, "@babel/template": { "version": "7.4.4", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.4.4.tgz", @@ -1213,6 +3051,14 @@ "@fortawesome/fontawesome-common-types": "^0.2.25" } }, + "@fortawesome/free-regular-svg-icons": { + "version": "5.11.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.11.2.tgz", + "integrity": "sha512-k0vbThRv9AvnXYBWi1gn1rFW4X7co/aFkbm0ZNmAR5PoWb9vY9EDDDobg8Ay4ISaXtCPypvJ0W1FWkSpLQwZ6w==", + "requires": { + "@fortawesome/fontawesome-common-types": "^0.2.25" + } + }, "@fortawesome/free-solid-svg-icons": { "version": "5.11.2", "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.11.2.tgz", @@ -1222,42 +3068,64 @@ } }, "@ngtools/webpack": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-8.2.2.tgz", - "integrity": "sha512-ksPFlZbH0+Rj+0qTGmkbtU3GHLjQKF4nN047AZn8Q4QnPynKqItHskSlyVi0CMnKfJxOr2VTxlSkiKN+pUb0sA==", + "version": "8.3.20", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-8.3.20.tgz", + "integrity": "sha512-2e9Kat6PQEzqtNsZZpnOIvoDzyGwMELiuBYBa9keZeaXOD6TxjSyCRzHHXAldAXqvh4Uj2qjTid54Sy14CxtsQ==", "dev": true, "requires": { - "@angular-devkit/core": "8.2.2", + "@angular-devkit/core": "8.3.20", "enhanced-resolve": "4.1.0", "rxjs": "6.4.0", "tree-kill": "1.2.1", - "webpack-sources": "1.3.0" + "webpack-sources": "1.4.3" + }, + "dependencies": { + "rxjs": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.4.0.tgz", + "integrity": "sha512-Z9Yfa11F6B9Sg/BK9MnqnQ+aQYicPLtilXBp2yUtDt2JRCE0h26d33EnfO3ZxoNxG0T92OUucP3Ct7cpfkdFfw==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + } } }, "@schematics/angular": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-8.2.2.tgz", - "integrity": "sha512-0kZoGXwYRDLREwMYT+m0MyGenpPidLEulrWxgYWoLhsJAFKax7lTy2YYljtFTd+AlZYyB3PTpDsDip8uT743tA==", + "version": "8.3.20", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-8.3.20.tgz", + "integrity": "sha512-Y20pSJhQ0KQd8Tk2kPQlmpRDNDaoIKMeOOGLT2FgCFrumxZXuIbBgN9fGDgW40iI2sq80bccOeo24RKkn3QpcA==", "dev": true, "requires": { - "@angular-devkit/core": "8.2.2", - "@angular-devkit/schematics": "8.2.2" + "@angular-devkit/core": "8.3.20", + "@angular-devkit/schematics": "8.3.20" } }, "@schematics/update": { - "version": "0.802.2", - "resolved": "https://registry.npmjs.org/@schematics/update/-/update-0.802.2.tgz", - "integrity": "sha512-ohwdxf0+uQ0aCTk27evs1l04rJ1nB3S95ihDr3rSQOl0WWizdto6TbXURtQ4PubORehjqvhrqqKGVp+QL2npGw==", + "version": "0.803.20", + "resolved": "https://registry.npmjs.org/@schematics/update/-/update-0.803.20.tgz", + "integrity": "sha512-MseLreuHdnSLUEnRxZFVSHKKK+3mGXH12SgOSeirwATIL22Df74+Q5BYvsge/Kd2k6s9ak/NCuRXG7FAo8mkMA==", "dev": true, "requires": { - "@angular-devkit/core": "8.2.2", - "@angular-devkit/schematics": "8.2.2", + "@angular-devkit/core": "8.3.20", + "@angular-devkit/schematics": "8.3.20", "@yarnpkg/lockfile": "1.1.0", "ini": "1.3.5", - "pacote": "9.5.4", + "pacote": "9.5.5", "rxjs": "6.4.0", "semver": "6.3.0", "semver-intersect": "1.4.0" + }, + "dependencies": { + "rxjs": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.4.0.tgz", + "integrity": "sha512-Z9Yfa11F6B9Sg/BK9MnqnQ+aQYicPLtilXBp2yUtDt2JRCE0h26d33EnfO3ZxoNxG0T92OUucP3Ct7cpfkdFfw==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + } } }, "@types/events": { @@ -1284,9 +3152,9 @@ "dev": true }, "@types/jasminewd2": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/jasminewd2/-/jasminewd2-2.0.6.tgz", - "integrity": "sha512-2ZOKrxb8bKRmP/po5ObYnRDgFE4i+lQiEB27bAMmtMWLgJSqlIDqlLx6S0IRorpOmOPRQ6O80NujTmQAtBkeNw==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@types/jasminewd2/-/jasminewd2-2.0.8.tgz", + "integrity": "sha512-d9p31r7Nxk0ZH0U39PTH0hiDlJ+qNVGjlt1ucOoTUptxb2v+Y5VMnsxfwN+i3hK4yQnqBi3FMmoMFcd1JHDxdg==", "dev": true, "requires": { "@types/jasmine": "*" @@ -1561,9 +3429,9 @@ } }, "acorn": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.3.0.tgz", - "integrity": "sha512-/czfa8BwS88b9gWQVhc8eknunSA2DoJpJyTQkhheIf5E48u1N0R4q/YxxsAeqRrmK9TQ/uYfgLDfZo91UlANIA==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.0.tgz", + "integrity": "sha512-gac8OEcQ2Li1dxIEWGZzsp2BitJxwkwcOm0zHAJLcPJaVvm58FRnk6RkuLRpU1EujipU2ZFODv2P9DLMfnV8mw==", "dev": true }, "adm-zip": { @@ -1638,10 +3506,13 @@ "dev": true }, "ansi-escapes": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", - "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", - "dev": true + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.0.tgz", + "integrity": "sha512-EiYhwo0v255HUL6eDyuLrXEkTi7WwVCLAw+SeOQ7M7qdun1z1pum4DEm/nuqIVbPvi9RPPc9k9LbyBv6H0DwVg==", + "dev": true, + "requires": { + "type-fest": "^0.8.1" + } }, "ansi-html": { "version": "0.0.7", @@ -1665,9 +3536,9 @@ } }, "anymatch": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.0.tgz", - "integrity": "sha512-Ozz7l4ixzI7Oxj2+cw+p0tVUt27BpaJ+1+q1TCeANWxHpvyn2+Un+YamBdfKu0uh8xLodGhoa1v7595NhKDAuA==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz", + "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==", "dev": true, "requires": { "normalize-path": "^3.0.0", @@ -1965,6 +3836,12 @@ "trim-right": "^1.0.1" }, "dependencies": { + "jsesc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz", + "integrity": "sha1-RsP+yMGJKxKwgz25vHYiF226s0s=", + "dev": true + }, "source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -1982,6 +3859,15 @@ "babel-runtime": "^6.22.0" } }, + "babel-plugin-dynamic-import-node": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.0.tgz", + "integrity": "sha512-o6qFkpeQEBxcqt0XYlWzAVxNCSCZdUgcR8IRlhD/8DylxjjO4foPcvTW0GGKa/cVt3rvxZ7o5ippJ+/0nvLhlQ==", + "dev": true, + "requires": { + "object.assign": "^4.1.0" + } + }, "babel-runtime": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", @@ -1993,9 +3879,15 @@ }, "dependencies": { "core-js": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.9.tgz", - "integrity": "sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A==", + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz", + "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==", + "dev": true + }, + "regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", "dev": true } } @@ -2028,6 +3920,14 @@ "globals": "^9.18.0", "invariant": "^2.2.2", "lodash": "^4.17.4" + }, + "dependencies": { + "globals": { + "version": "9.18.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-9.18.0.tgz", + "integrity": "sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==", + "dev": true + } } }, "babel-types": { @@ -2040,6 +3940,14 @@ "esutils": "^2.0.2", "lodash": "^4.17.4", "to-fast-properties": "^1.0.3" + }, + "dependencies": { + "to-fast-properties": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz", + "integrity": "sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=", + "dev": true + } } }, "babylon": { @@ -2359,9 +4267,9 @@ } }, "buffer": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", - "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", "dev": true, "requires": { "base64-js": "^1.0.2", @@ -2434,9 +4342,9 @@ "dev": true }, "cacache": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-11.3.3.tgz", - "integrity": "sha512-p8WcneCytvzPxhDvYp31PD039vi77I12W+/KfR9S8AZbaiARFBCpsPJS+9uhWfeBfeAtW7o/4vt3MUqLkbY6nA==", + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.2.tgz", + "integrity": "sha512-ifKgxH2CKhJEg6tNdAwziu6Q33EvuG26tYcda6PT3WKisZcYDXsnEdnRv67Po3yCzFfaSoMjGZzJyD2c3DT1dg==", "dev": true, "requires": { "bluebird": "^3.5.5", @@ -2444,6 +4352,7 @@ "figgy-pudding": "^3.5.1", "glob": "^7.1.4", "graceful-fs": "^4.1.15", + "infer-owner": "^1.0.3", "lru-cache": "^5.1.1", "mississippi": "^3.0.0", "mkdirp": "^0.5.1", @@ -2509,9 +4418,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30000986", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000986.tgz", - "integrity": "sha512-pM+LnkoAX0+QnIH3tpW5EnkmfpEoqOD8FAcoBvsl3Xh6DXkgctiCxeCbXphP/k3XJtJzm+zOAJbi6U6IVkpWZQ==", + "version": "1.0.30000989", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000989.tgz", + "integrity": "sha512-vrMcvSuMz16YY6GSVZ0dWDTJP8jqk3iFQ/Aq5iqblPwxSVVZI+zxDyTX0VPqtQsDnfdrBDcsmhgTEOh5R8Lbpw==", "dev": true }, "canonical-path": { @@ -2544,25 +4453,25 @@ "dev": true }, "chokidar": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.0.2.tgz", - "integrity": "sha512-c4PR2egjNjI1um6bamCQ6bUNPDiyofNQruHvKgHQ4gDUP/ITSVSzNsiI5OWtHOsX323i5ha/kk4YmOZ1Ktg7KA==", - "dev": true, - "requires": { - "anymatch": "^3.0.1", - "braces": "^3.0.2", - "fsevents": "^2.0.6", - "glob-parent": "^5.0.0", - "is-binary-path": "^2.1.0", - "is-glob": "^4.0.1", - "normalize-path": "^3.0.0", - "readdirp": "^3.1.1" + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.3.0.tgz", + "integrity": "sha512-dGmKLDdT3Gdl7fBUe8XK+gAtGmzy5Fn0XkkWQuYxGIgWVPPse2CxFA5mtrlD0TOHaHjEUqkWNyP1XdHoJES/4A==", + "dev": true, + "requires": { + "anymatch": "~3.1.1", + "braces": "~3.0.2", + "fsevents": "~2.1.1", + "glob-parent": "~5.1.0", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.2.0" }, "dependencies": { "glob-parent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.0.0.tgz", - "integrity": "sha512-Z2RwiujPRGluePM6j699ktJYxmPpJKCfpGA13jz2hmFZC7gKetzrWvg5KN3+OsIFmydGyZ1AVwERCq1w/ZZwRg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.0.tgz", + "integrity": "sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw==", "dev": true, "requires": { "is-glob": "^4.0.1" @@ -2571,9 +4480,9 @@ } }, "chownr": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.2.tgz", - "integrity": "sha512-GkfeAQh+QNy3wquu9oIZr6SS5x7wGdSgNQvD10X3r+AZr1Oys22HW8kAmDMvNg2+Dm0TeGaEuO8gFwdBXxwO8A==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.3.tgz", + "integrity": "sha512-i70fVHhmV3DtTl6nqvZOnIjbY0Pe4kAUjwHj8z0zAdgBtYrJyYwLKCCuRBQ5ppkyL0AkN7HKRnETdmdp1zqNXw==", "dev": true }, "chrome-trace-event": { @@ -2596,9 +4505,9 @@ } }, "circular-dependency-plugin": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/circular-dependency-plugin/-/circular-dependency-plugin-5.0.2.tgz", - "integrity": "sha512-oC7/DVAyfcY3UWKm0sN/oVoDedQDQiw/vIiAnuTWTpE5s0zWf7l3WY417Xw/Fbi/QbAjctAkxgMiS9P0s3zkmA==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/circular-dependency-plugin/-/circular-dependency-plugin-5.2.0.tgz", + "integrity": "sha512-7p4Kn/gffhQaavNfyDFg7LS5S/UT1JAjyGd4UqR2+jzoYF02eDkj0Ec3+48TsIa4zghjLY87nQHIh/ecK9qLdw==", "dev": true }, "class-utils": { @@ -2642,12 +4551,12 @@ } }, "cli-cursor": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", - "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", "dev": true, "requires": { - "restore-cursor": "^2.0.0" + "restore-cursor": "^3.1.0" } }, "cli-width": { @@ -2702,15 +4611,14 @@ "dev": true }, "clone-deep": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-2.0.2.tgz", - "integrity": "sha512-SZegPTKjCgpQH63E+eN6mVEEPdQBOUzjyJm5Pora4lrwWRFS8I0QAxV/KD6vV/i0WuijHZWQC1fMsPEdxfdVCQ==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", "dev": true, "requires": { - "for-own": "^1.0.0", "is-plain-object": "^2.0.4", - "kind-of": "^6.0.0", - "shallow-clone": "^1.0.0" + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" } }, "co": { @@ -2726,9 +4634,9 @@ "dev": true }, "codelyzer": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/codelyzer/-/codelyzer-5.1.0.tgz", - "integrity": "sha512-QiyY2/oDQnYx4mAVEDqr+z9MwrOto18tQFjExiuRChXCy0yvngS5fQpWIxvAGpbOmZFiR1PRTRLbEI71u10maA==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/codelyzer/-/codelyzer-5.2.0.tgz", + "integrity": "sha512-izfUfhEOOgAizszPlEDxo71DK/C4wprZw0vkY6UWcOSTQvN1JyfXf9DXwaV7WX+/JC+hH0ShXfdtGLA9Rca7LA==", "dev": true, "requires": { "app-root-path": "^2.2.1", @@ -2892,13 +4800,10 @@ "dev": true }, "console-browserify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", - "integrity": "sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=", - "dev": true, - "requires": { - "date-now": "^0.1.4" - } + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", + "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==", + "dev": true }, "constants-browserify": { "version": "1.0.0", @@ -2922,9 +4827,9 @@ "dev": true }, "convert-source-map": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.6.0.tgz", - "integrity": "sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", + "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", "dev": true, "requires": { "safe-buffer": "~5.1.1" @@ -2980,14 +4885,78 @@ "schema-utils": "^1.0.0", "serialize-javascript": "^1.7.0", "webpack-log": "^2.0.0" + }, + "dependencies": { + "cacache": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-11.3.3.tgz", + "integrity": "sha512-p8WcneCytvzPxhDvYp31PD039vi77I12W+/KfR9S8AZbaiARFBCpsPJS+9uhWfeBfeAtW7o/4vt3MUqLkbY6nA==", + "dev": true, + "requires": { + "bluebird": "^3.5.5", + "chownr": "^1.1.1", + "figgy-pudding": "^3.5.1", + "glob": "^7.1.4", + "graceful-fs": "^4.1.15", + "lru-cache": "^5.1.1", + "mississippi": "^3.0.0", + "mkdirp": "^0.5.1", + "move-concurrently": "^1.0.1", + "promise-inflight": "^1.0.1", + "rimraf": "^2.6.3", + "ssri": "^6.0.1", + "unique-filename": "^1.1.1", + "y18n": "^4.0.0" + } + }, + "find-cache-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", + "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^2.0.0", + "pkg-dir": "^3.0.0" + } + } } }, "core-js": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.1.4.tgz", - "integrity": "sha512-YNZN8lt82XIMLnLirj9MhKDFZHalwzzrL9YLt6eb0T5D0EDl4IQ90IGkua8mHbnxNrkj1d8hbdizMc0Qmg1WnQ==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.2.1.tgz", + "integrity": "sha512-Qa5XSVefSVPRxy2XfUC13WbvqkxhkwB3ve+pgCQveNgYzbM/UxZeu1dcOX/xr4UmfUd+muuvsaxilQzCyUurMw==", "dev": true }, + "core-js-compat": { + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.4.8.tgz", + "integrity": "sha512-l3WTmnXHV2Sfu5VuD7EHE2w7y+K68+kULKt5RJg8ZJk3YhHF1qLD4O8v8AmNq+8vbOwnPFFDvds25/AoEvMqlQ==", + "dev": true, + "requires": { + "browserslist": "^4.8.2", + "semver": "^6.3.0" + }, + "dependencies": { + "browserslist": { + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.8.2.tgz", + "integrity": "sha512-+M4oeaTplPm/f1pXDw84YohEv7B1i/2Aisei8s4s6k3QsoSHa7i5sz8u/cGQkkatCPxMASKxPualR4wwYgVboA==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001015", + "electron-to-chromium": "^1.3.322", + "node-releases": "^1.1.42" + } + }, + "caniuse-lite": { + "version": "1.0.30001015", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001015.tgz", + "integrity": "sha512-/xL2AbW/XWHNu1gnIrO8UitBGoFthcsDgU9VLK1/dpsoxbaD5LscHozKze05R6WLsBvLhqv78dAPozMFQBYLbQ==", + "dev": true + } + } + }, "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -3122,9 +5091,9 @@ "dev": true }, "cyclist": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-0.2.2.tgz", - "integrity": "sha1-GzN5LhHpFKL9bW7WRHRkRE5fpkA=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz", + "integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=", "dev": true }, "damerau-levenshtein": { @@ -3148,12 +5117,6 @@ "integrity": "sha512-bYQuGLeFxhkxNOF3rcMtiZxvCBAquGzZm6oWA1oZ0g2THUzivaRhv8uOhdr19LmoobSOLoIAxeUK2RdbM8IFTA==", "dev": true }, - "date-now": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", - "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=", - "dev": true - }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -3182,9 +5145,9 @@ "dev": true }, "deep-equal": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.0.tgz", - "integrity": "sha512-ZbfWJq/wN1Z273o7mUSjILYqehAktR2NVoSrOukDkU9kg2v/Uv89yU4Cvz8seJeAmtN5oqiefKq8FPuXOboqLw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", + "integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==", "dev": true, "requires": { "is-arguments": "^1.0.4", @@ -3327,9 +5290,9 @@ "dev": true }, "des.js": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.0.tgz", - "integrity": "sha1-wHTS4qpqipoH29YfmhXCzYPsjsw=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz", + "integrity": "sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==", "dev": true, "requires": { "inherits": "^2.0.1", @@ -3471,15 +5434,15 @@ "dev": true }, "electron-to-chromium": { - "version": "1.3.245", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.245.tgz", - "integrity": "sha512-W1Tjm8VhabzYmiqLUD/sT/KTKkvZ8QpSkbTfLELBrFdnrolfkCgcbxFE3NXAxL5xedWXF74wWn0j6oVrgBdemw==", + "version": "1.3.322", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.322.tgz", + "integrity": "sha512-Tc8JQEfGQ1MzfSzI/bTlSr7btJv/FFO7Yh6tanqVmIWOuNCu6/D1MilIEgLtmWqIrsv+o4IjpLAhgMBr/ncNAA==", "dev": true }, "elliptic": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.0.tgz", - "integrity": "sha512-eFOJTMyCYb7xtE/caJ6JJu+bhi67WCYNbkGSknu20pmM8Ke/bqOfdnZWxyoGN26JgfxTbXrsCkEw4KheCT/KGg==", + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.2.tgz", + "integrity": "sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw==", "dev": true, "requires": { "bn.js": "^4.4.0", @@ -3492,9 +5455,9 @@ } }, "emoji-regex": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, "emojis-list": { @@ -3519,9 +5482,9 @@ } }, "end-of-stream": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", - "integrity": "sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==", + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", "dev": true, "requires": { "once": "^1.4.0" @@ -3649,23 +5612,27 @@ } }, "es-abstract": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.13.0.tgz", - "integrity": "sha512-vDZfg/ykNxQVwup/8E1BZhVzFfBxs9NqMzGcvIJrqg5k2/5Za2bWo40dK2J1pgLngZ7c+Shh8lwYtLGyrwPutg==", + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.16.3.tgz", + "integrity": "sha512-WtY7Fx5LiOnSYgF5eg/1T+GONaGmpvpPdCpSnYij+U2gDTL0UPfWrhDw7b2IYb+9NQJsYpCA0wOQvZfsd6YwRw==", "dev": true, "requires": { - "es-to-primitive": "^1.2.0", + "es-to-primitive": "^1.2.1", "function-bind": "^1.1.1", "has": "^1.0.3", + "has-symbols": "^1.0.1", "is-callable": "^1.1.4", "is-regex": "^1.0.4", - "object-keys": "^1.0.12" + "object-inspect": "^1.7.0", + "object-keys": "^1.1.1", + "string.prototype.trimleft": "^2.1.0", + "string.prototype.trimright": "^2.1.0" } }, "es-to-primitive": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.0.tgz", - "integrity": "sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", "dev": true, "requires": { "is-callable": "^1.1.4", @@ -4025,18 +5992,18 @@ "dev": true }, "figures": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", - "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.1.0.tgz", + "integrity": "sha512-ravh8VRXqHuMvZt/d8GblBeqDMkdJMBdv/2KntFH+ra5MXkO7nxNKpzQ3n6QD/2da1kH0aWmNISdvhM7gl2gVg==", "dev": true, "requires": { "escape-string-regexp": "^1.0.5" } }, "file-loader": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-4.1.0.tgz", - "integrity": "sha512-ajDk1nlByoalZAGR4b0H6oD+EGlWnyW1qbSxzaUc7RFiqmn+RbXQQRbTc72jsiUIlVusJ4Et58ltds8ZwTfnAw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-4.2.0.tgz", + "integrity": "sha512-+xZnaK5R8kBJrHK0/6HRlrKNamvVS5rjyuju+rnyxRGuwUJwpAMsVzUl5dz6rK8brkzjV6JpcFNjp6NqV0g1OQ==", "dev": true, "requires": { "loader-utils": "^1.2.3", @@ -4044,13 +6011,13 @@ }, "dependencies": { "schema-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.1.0.tgz", - "integrity": "sha512-g6SViEZAfGNrToD82ZPUjq52KUPDYc+fN5+g6Euo5mLokl/9Yx14z0Cu4RR1m55HtBXejO0sBt+qw79axN+Fiw==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.6.1.tgz", + "integrity": "sha512-0WXHDs1VDJyo+Zqs9TKLKyD/h7yDpHUhEFsM2CzkICFdoX1av+GBq/J2xRTFfsQO5kBfhZzANf2VcIm84jqDbg==", "dev": true, "requires": { - "ajv": "^6.1.0", - "ajv-keywords": "^3.1.0" + "ajv": "^6.10.2", + "ajv-keywords": "^3.4.1" } } } @@ -4090,14 +6057,68 @@ } }, "find-cache-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", - "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.0.0.tgz", + "integrity": "sha512-t7ulV1fmbxh5G9l/492O1p5+EBbr3uwpt6odhFTMc+nWyhmbloe+ja9BZ8pIBtqFWhOmCWVjx+pTW4zDkFoclw==", "dev": true, "requires": { "commondir": "^1.0.1", - "make-dir": "^2.0.0", - "pkg-dir": "^3.0.0" + "make-dir": "^3.0.0", + "pkg-dir": "^4.1.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "make-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.0.0.tgz", + "integrity": "sha512-grNJDhb8b1Jm1qeqW5R/O63wUo4UXo2v2HMic6YT9i/HBlF93S8jkMgH7yugvY9ABDShH4VZMn8I+U8+fCNegw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + } + } } }, "find-up": { @@ -4162,15 +6183,6 @@ "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", "dev": true }, - "for-own": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz", - "integrity": "sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs=", - "dev": true, - "requires": { - "for-in": "^1.0.1" - } - }, "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -4240,12 +6252,12 @@ } }, "fs-minipass": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.6.tgz", - "integrity": "sha512-crhvyXcMejjv3Z5d2Fa9sf5xLYVCF5O1c71QxbVnbLsmYMBEvDAftewesN/HhY03YRoA7zOMxjNGrF5svGaaeQ==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz", + "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==", "dev": true, "requires": { - "minipass": "^2.2.1" + "minipass": "^2.6.0" } }, "fs-write-stream-atomic": { @@ -4267,9 +6279,9 @@ "dev": true }, "fsevents": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.0.7.tgz", - "integrity": "sha512-a7YT0SV3RB+DjYcppwVDLtn13UQnmg0SWZS7ezZD0UjnLwXmy8Zm21GMVGLaFGimIqcvyMQaOJBrop8MyOp1kQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.2.tgz", + "integrity": "sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA==", "dev": true, "optional": true }, @@ -4351,9 +6363,9 @@ } }, "globals": { - "version": "9.18.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-9.18.0.tgz", - "integrity": "sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==", + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", "dev": true }, "globby": { @@ -4400,9 +6412,9 @@ "dev": true }, "handlebars": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.4.3.tgz", - "integrity": "sha512-B0W4A2U1ww3q7VVthTKfh+epHx+q4mCt6iK+zEAzbMBpWQAwxCeKxEGpj/1oQTpzPXDNSOG7hmG14TsISH50yw==", + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.5.3.tgz", + "integrity": "sha512-3yPecJoJHK/4c6aZhSvxOyG4vJKDshV36VHp0iVCDVh7o9w2vwi3NSnL2MMPj3YdduqaBcu7cGbggJQM0br9xA==", "dev": true, "requires": { "neo-async": "^2.6.0", @@ -4483,9 +6495,9 @@ "dev": true }, "has-symbols": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz", - "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", "dev": true }, "has-value": { @@ -4572,9 +6584,9 @@ } }, "hosted-git-info": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.4.tgz", - "integrity": "sha512-pzXIvANXEFrc5oFFXRMkbLPQ2rXRoDERwDLyrcUxGhaZhgP54BBSl9Oheh7Vv0T090cszWBxPjkQQ5Sq1PbBRQ==", + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.5.tgz", + "integrity": "sha512-kssjab8CvdXfcXMXVcvsXum4Hwdq9XGtRD3TteMEvEbq0LXyiNQr6AprqKqfeaDXze7SxWvRxdpwE6ku7ikLkg==", "dev": true }, "hpack.js": { @@ -4759,9 +6771,9 @@ "dev": true }, "ignore-walk": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.1.tgz", - "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.3.tgz", + "integrity": "sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw==", "dev": true, "requires": { "minimatch": "^3.0.4" @@ -4859,32 +6871,60 @@ "dev": true }, "inquirer": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-6.5.0.tgz", - "integrity": "sha512-scfHejeG/lVZSpvCXpsB4j/wQNPM5JC8kiElOI0OUTwmc1RTpXr4H32/HOlQHcZiYl2z2VElwuCVDRG8vFmbnA==", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-6.5.1.tgz", + "integrity": "sha512-uxNHBeQhRXIoHWTSNYUFhQVrHYFThIt6IVo2fFmSe8aBwdR3/w6b58hJpiL/fMukFkvGzjg+hSxFtwvVmKZmXw==", "dev": true, "requires": { - "ansi-escapes": "^3.2.0", + "ansi-escapes": "^4.2.1", "chalk": "^2.4.2", - "cli-cursor": "^2.1.0", + "cli-cursor": "^3.1.0", "cli-width": "^2.0.0", "external-editor": "^3.0.3", - "figures": "^2.0.0", - "lodash": "^4.17.12", - "mute-stream": "0.0.7", + "figures": "^3.0.0", + "lodash": "^4.17.15", + "mute-stream": "0.0.8", "run-async": "^2.2.0", "rxjs": "^6.4.0", - "string-width": "^2.1.0", + "string-width": "^4.1.0", "strip-ansi": "^5.1.0", "through": "^2.3.6" }, "dependencies": { "ansi-regex": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", - "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + } + } + }, "strip-ansi": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", @@ -4892,6 +6932,14 @@ "dev": true, "requires": { "ansi-regex": "^4.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + } } } } @@ -4939,6 +6987,12 @@ "integrity": "sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA==", "dev": true }, + "is-absolute-url": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-3.0.3.tgz", + "integrity": "sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q==", + "dev": true + }, "is-accessor-descriptor": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", @@ -5146,12 +7200,12 @@ "dev": true }, "is-symbol": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.2.tgz", - "integrity": "sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", + "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", "dev": true, "requires": { - "has-symbols": "^1.0.0" + "has-symbols": "^1.0.1" } }, "is-typedarray": { @@ -5453,6 +7507,33 @@ "integrity": "sha1-43zwsX8ZnM4jvqcbIDk5Uka07E4=", "dev": true }, + "jest-worker": { + "version": "24.9.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-24.9.0.tgz", + "integrity": "sha512-51PE4haMSXcHohnSMdM42anbvZANYTqMrr52tVKPqqsPJMzoP6FYYDVqahX/HrAoKEKz3uUPzSvKs9A3qR4iVw==", + "dev": true, + "requires": { + "merge-stream": "^2.0.0", + "supports-color": "^6.1.0" + }, + "dependencies": { + "supports-color": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", + "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true + }, "js-tokens": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", @@ -5476,9 +7557,9 @@ "dev": true }, "jsesc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz", - "integrity": "sha1-RsP+yMGJKxKwgz25vHYiF226s0s=", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", "dev": true }, "json-parse-better-errors": { @@ -6407,9 +8488,9 @@ } }, "license-webpack-plugin": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-2.1.1.tgz", - "integrity": "sha512-TiarZIg5vkQ2rGdYJn2+5YxO/zqlqjpK5IVglr7OfmrN1sBCakS+PQrsP2uC5gtve1ZDb9WMSUMlmHDQ0FoW4w==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-2.1.2.tgz", + "integrity": "sha512-7poZHRla+ae0eEButlwMrPpkXyhNVBf2EHePYWT0jyLnI6311/OXJkTI2sOIRungRpQgU2oDMpro5bSFPT5F0A==", "dev": true, "requires": { "@types/webpack-sources": "^0.1.5", @@ -6464,12 +8545,6 @@ "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", "dev": true }, - "lodash.tail": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.tail/-/lodash.tail-4.1.1.tgz", - "integrity": "sha1-0jM6NtnncXyK0vfKyv7HwytERmQ=", - "dev": true - }, "log4js": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/log4js/-/log4js-4.5.1.tgz", @@ -6501,9 +8576,9 @@ } }, "loglevel": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.6.3.tgz", - "integrity": "sha512-LoEDv5pgpvWgPF4kNYuIp0qqSJVWak/dML0RY74xlzMZiT9w77teNAwKYKWBTYjlokMirg+o3jBwp+vlLrcfAA==", + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.6.6.tgz", + "integrity": "sha512-Sgr5lbboAUBo3eXCSPL4/KoVz3ROKquOjcctxmHIt+vol2DrqTQe3SwkKKuYhEiWB5kYa13YyopJ69deJ1irzQ==", "dev": true }, "loose-envify": { @@ -6558,47 +8633,22 @@ "dev": true }, "make-fetch-happen": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-5.0.0.tgz", - "integrity": "sha512-nFr/vpL1Jc60etMVKeaLOqfGjMMb3tAHFVJWxHOFCFS04Zmd7kGlMxo0l1tzfhoQje0/UPnd0X8OeGUiXXnfPA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-5.0.2.tgz", + "integrity": "sha512-07JHC0r1ykIoruKO8ifMXu+xEU8qOXDFETylktdug6vJDACnP+HKevOu3PXyNPzFyTSlz8vrBYlBO1JZRe8Cag==", "dev": true, "requires": { "agentkeepalive": "^3.4.1", "cacache": "^12.0.0", "http-cache-semantics": "^3.8.1", "http-proxy-agent": "^2.1.0", - "https-proxy-agent": "^2.2.1", + "https-proxy-agent": "^2.2.3", "lru-cache": "^5.1.1", "mississippi": "^3.0.0", "node-fetch-npm": "^2.0.2", "promise-retry": "^1.1.1", "socks-proxy-agent": "^4.0.0", "ssri": "^6.0.0" - }, - "dependencies": { - "cacache": { - "version": "12.0.3", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.3.tgz", - "integrity": "sha512-kqdmfXEGFepesTuROHMs3MpFLWrPkSSpRqOw80RCflZXy/khxaArvFrQ7uJxSUduzAufc6G0g1VUCOZXxWavPw==", - "dev": true, - "requires": { - "bluebird": "^3.5.5", - "chownr": "^1.1.1", - "figgy-pudding": "^3.5.1", - "glob": "^7.1.4", - "graceful-fs": "^4.1.15", - "infer-owner": "^1.0.3", - "lru-cache": "^5.1.1", - "mississippi": "^3.0.0", - "mkdirp": "^0.5.1", - "move-concurrently": "^1.0.1", - "promise-inflight": "^1.0.1", - "rimraf": "^2.6.3", - "ssri": "^6.0.1", - "unique-filename": "^1.1.1", - "y18n": "^4.0.0" - } - } } }, "mamacro": { @@ -6680,6 +8730,12 @@ "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=", "dev": true }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -6868,9 +8924,9 @@ "dev": true }, "minipass": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.5.0.tgz", - "integrity": "sha512-9FwMVYhn6ERvMR8XFdOavRz4QK/VJV8elU1x50vYexf9lslDcWe/f4HBRxCPd185ekRSjU6CfYyJCECa/CQy7Q==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", + "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", "dev": true, "requires": { "safe-buffer": "^5.1.2", @@ -6878,12 +8934,12 @@ } }, "minizlib": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.2.1.tgz", - "integrity": "sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz", + "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==", "dev": true, "requires": { - "minipass": "^2.2.1" + "minipass": "^2.9.0" } }, "mississippi": { @@ -6925,24 +8981,6 @@ } } }, - "mixin-object": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz", - "integrity": "sha1-T7lJRB2rGCVA8f4DW6YOGUel5X4=", - "dev": true, - "requires": { - "for-in": "^0.1.3", - "is-extendable": "^0.1.1" - }, - "dependencies": { - "for-in": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz", - "integrity": "sha1-2Hc5COMSVhCZUrH9ubP6hn0ndeE=", - "dev": true - } - } - }, "mkdirp": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", @@ -6997,9 +9035,9 @@ "dev": true }, "mute-stream": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", - "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=", + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true }, "nan": { @@ -7040,6 +9078,15 @@ "integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==", "dev": true }, + "ngx-clipboard": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/ngx-clipboard/-/ngx-clipboard-12.3.0.tgz", + "integrity": "sha512-ToSsuDv9I1L0g+TcthePcZ4B859/MpoarlHVr2KnHWy3pR8SxfJlNyP2i9STYRQkJ5bSEg65RFErW4tx52lHYQ==", + "requires": { + "ngx-window-token": "^2.0.0", + "tslib": "^1.9.0" + } + }, "ngx-markdown": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/ngx-markdown/-/ngx-markdown-8.2.1.tgz", @@ -7052,6 +9099,14 @@ "tslib": "^1.9.0" } }, + "ngx-window-token": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ngx-window-token/-/ngx-window-token-2.0.1.tgz", + "integrity": "sha512-rvqdqJEfnWXQFU5fyfYt06E10tR/UtFOYdF3QebfcOh5VIJhnTKiprX8e4B9OrX7WEVFm9BT8uV72xXcEgsaKA==", + "requires": { + "tslib": "^1.9.0" + } + }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -7070,9 +9125,9 @@ } }, "node-forge": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.7.5.tgz", - "integrity": "sha512-MmbQJ2MTESTjt3Gi/3yG1wGpIMhUfcIypUCGtTizFR9IiccFwxSpfp0vtIZlkFclEqERemxfnSdZEMR9VqqEFQ==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.9.0.tgz", + "integrity": "sha512-7ASaDa3pD+lJ3WvXFsxekJQelBKRpne+GOVbLbtHYdd7pFspyeuJHnWfLplGf3SwKGbfs/aYl5V/JCIaHVUKKQ==", "dev": true }, "node-libs-browser": { @@ -7115,20 +9170,12 @@ } }, "node-releases": { - "version": "1.1.29", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.29.tgz", - "integrity": "sha512-R5bDhzh6I+tpi/9i2hrrvGJ3yKPYzlVOORDkXhnZuwi5D3q1I5w4vYy24PJXTcLk9Q0kws9TO77T75bcK8/ysQ==", + "version": "1.1.42", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.42.tgz", + "integrity": "sha512-OQ/ESmUqGawI2PRX+XIRao44qWYBBfN54ImQYdWVTQqUckuejOg76ysSqDBK8NG3zwySRVnX36JwDQ6x+9GxzA==", "dev": true, "requires": { - "semver": "^5.3.0" - }, - "dependencies": { - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true - } + "semver": "^6.3.0" } }, "normalize-package-data": { @@ -7176,9 +9223,9 @@ } }, "npm-bundled": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.0.6.tgz", - "integrity": "sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.0.tgz", + "integrity": "sha512-ez6dcKBFNo4FvlMqscBEFUum6M2FTLW5grqm3DyBKB5XOyKVCeeWvAuoZtbmW/5Cv8EM2bQUOA6ufxa/TKVN0g==", "dev": true }, "npm-package-arg": { @@ -7202,9 +9249,9 @@ } }, "npm-packlist": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.4.tgz", - "integrity": "sha512-zTLo8UcVYtDU3gdeaFu2Xu0n0EvelfHDGuqtNIn5RO7yQj4H1TqNdBc/yZjxnWA0PVB8D3Woyp0i5B43JwQ6Vw==", + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.6.tgz", + "integrity": "sha512-u65uQdb+qwtGvEJh/DgQgW1Xg7sqeNbmxYyrvlNznaVTjV3E5P6F/EFjM+BVHXl7JJlsdG8A64M0XI8FI/IOlg==", "dev": true, "requires": { "ignore-walk": "^3.0.1", @@ -7212,9 +9259,9 @@ } }, "npm-pick-manifest": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-2.2.3.tgz", - "integrity": "sha512-+IluBC5K201+gRU85vFlUwX3PFShZAbAgDNp2ewJdWMVSppdo/Zih0ul2Ecky/X7b51J7LrrUAP+XOmOCvYZqA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-3.0.2.tgz", + "integrity": "sha512-wNprTNg+X5nf+tDi+hbjdHhM4bX+mKqv6XmPh7B5eG+QY9VARfQPfCEH013H5GqfNj6ee8Ij2fg8yk0mzps1Vw==", "dev": true, "requires": { "figgy-pudding": "^3.5.1", @@ -7231,9 +9278,9 @@ } }, "npm-registry-fetch": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-4.0.0.tgz", - "integrity": "sha512-Jllq35Jag8dtv0M17ue74XtdQTyqKzuAYGiX9mAjOhkmNjib3bBUgK6mUY61+AHnXeSRobQkpY3/xIOS/omptw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-4.0.2.tgz", + "integrity": "sha512-Z0IFtPEozNdeZRPh3aHHxdG+ZRpzcbQaJLthsm3VhNf6DScicTFRHZzK82u8RsJUsUHkX+QH/zcB/5pmd20H4A==", "dev": true, "requires": { "JSONStream": "^1.3.4", @@ -7241,7 +9288,16 @@ "figgy-pudding": "^3.4.1", "lru-cache": "^5.1.1", "make-fetch-happen": "^5.0.0", - "npm-package-arg": "^6.1.0" + "npm-package-arg": "^6.1.0", + "safe-buffer": "^5.2.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", + "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==", + "dev": true + } } }, "npm-run-path": { @@ -7320,6 +9376,12 @@ } } }, + "object-inspect": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", + "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==", + "dev": true + }, "object-is": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.0.1.tgz", @@ -7341,6 +9403,18 @@ "isobject": "^3.0.0" } }, + "object.assign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", + "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", + "dev": true, + "requires": { + "define-properties": "^1.1.2", + "function-bind": "^1.1.1", + "has-symbols": "^1.0.0", + "object-keys": "^1.0.11" + } + }, "object.getownpropertydescriptors": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz", @@ -7391,20 +9465,12 @@ } }, "onetime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", - "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.0.tgz", + "integrity": "sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==", "dev": true, "requires": { - "mimic-fn": "^1.0.0" - }, - "dependencies": { - "mimic-fn": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", - "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", - "dev": true - } + "mimic-fn": "^2.1.0" } }, "open": { @@ -7549,16 +9615,17 @@ "dev": true }, "pacote": { - "version": "9.5.4", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-9.5.4.tgz", - "integrity": "sha512-nWr0ari6E+apbdoN0hToTKZElO5h4y8DGFa2pyNA5GQIdcP0imC96bA0bbPw1gpeguVIiUgHHaAlq/6xfPp8Qw==", + "version": "9.5.5", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-9.5.5.tgz", + "integrity": "sha512-jAEP+Nqj4kyMWyNpfTU/Whx1jA7jEc5cCOlurm0/0oL+v8TAp1QSsK83N7bYe+2bEdFzMAtPG5TBebjzzGV0cA==", "dev": true, "requires": { "bluebird": "^3.5.3", - "cacache": "^12.0.0", + "cacache": "^12.0.2", "figgy-pudding": "^3.5.1", "get-stream": "^4.1.0", "glob": "^7.1.3", + "infer-owner": "^1.0.4", "lru-cache": "^5.1.1", "make-fetch-happen": "^5.0.0", "minimatch": "^3.0.4", @@ -7583,27 +9650,15 @@ "which": "^1.3.1" }, "dependencies": { - "cacache": { - "version": "12.0.3", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.3.tgz", - "integrity": "sha512-kqdmfXEGFepesTuROHMs3MpFLWrPkSSpRqOw80RCflZXy/khxaArvFrQ7uJxSUduzAufc6G0g1VUCOZXxWavPw==", + "npm-pick-manifest": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-2.2.3.tgz", + "integrity": "sha512-+IluBC5K201+gRU85vFlUwX3PFShZAbAgDNp2ewJdWMVSppdo/Zih0ul2Ecky/X7b51J7LrrUAP+XOmOCvYZqA==", "dev": true, "requires": { - "bluebird": "^3.5.5", - "chownr": "^1.1.1", "figgy-pudding": "^3.5.1", - "glob": "^7.1.4", - "graceful-fs": "^4.1.15", - "infer-owner": "^1.0.3", - "lru-cache": "^5.1.1", - "mississippi": "^3.0.0", - "mkdirp": "^0.5.1", - "move-concurrently": "^1.0.1", - "promise-inflight": "^1.0.1", - "rimraf": "^2.6.3", - "ssri": "^6.0.1", - "unique-filename": "^1.1.1", - "y18n": "^4.0.0" + "npm-package-arg": "^6.0.0", + "semver": "^5.4.1" } }, "semver": { @@ -7621,20 +9676,20 @@ "dev": true }, "parallel-transform": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.1.0.tgz", - "integrity": "sha1-1BDwZbBdojCB/NEPKIVMKb2jOwY=", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.2.0.tgz", + "integrity": "sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg==", "dev": true, "requires": { - "cyclist": "~0.2.2", + "cyclist": "^1.0.1", "inherits": "^2.0.3", "readable-stream": "^2.1.5" } }, "parse-asn1": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.4.tgz", - "integrity": "sha512-Qs5duJcuvNExRfFZ99HDD3z4mAi3r9Wl/FOjEOijlxwCZs7E7mW2vjTpgQ4J8LpTF8x5v+1Vn5UQFejmWT11aw==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.5.tgz", + "integrity": "sha512-jkMYn1dcJqF6d5CpU689bq7w/b5ALS9ROVSpQDPrZsqqesUJii9qutvoT5ltGedNXMO2e16YUWIghG9KxaViTQ==", "dev": true, "requires": { "asn1.js": "^4.0.0", @@ -7776,9 +9831,9 @@ "dev": true }, "picomatch": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.0.7.tgz", - "integrity": "sha512-oLHIdio3tZ0qH76NybpeneBhYVj0QFTfXEFTc/B3zKQspYfYYkWYgFsmzo+4kvId/bQRcNkVeguI3y+CD22BtA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.1.1.tgz", + "integrity": "sha512-OYMyqkKzK7blWO/+XZYP6w8hH0LDvkBvdvKukti+7kqYFCiEAk+gI3DWnryapc0Dau05ugGTy0foQ6mqn4AHYA==", "dev": true }, "pify": { @@ -7812,20 +9867,29 @@ } }, "portfinder": { - "version": "1.0.23", - "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.23.tgz", - "integrity": "sha512-B729mL/uLklxtxuiJKfQ84WPxNw5a7Yhx3geQZdcA4GjNjZSTSSMMWyoennMVnTWSmAR0lMdzWYN0JLnHrg1KQ==", + "version": "1.0.25", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.25.tgz", + "integrity": "sha512-6ElJnHBbxVA1XSLgBp7G1FiCkQdlqGzuF7DswL5tcea+E8UpuvPU7beVAjjRwCioTS9ZluNbu+ZyRvgTsmqEBg==", "dev": true, "requires": { - "async": "^1.5.2", - "debug": "^2.2.0", - "mkdirp": "0.5.x" + "async": "^2.6.2", + "debug": "^3.1.1", + "mkdirp": "^0.5.1" }, "dependencies": { - "async": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", - "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true } } @@ -7926,6 +9990,12 @@ "clipboard": "^2.0.0" } }, + "private": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz", + "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==", + "dev": true + }, "process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -8303,13 +10373,25 @@ } }, "raw-loader": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-1.0.0.tgz", - "integrity": "sha512-Uqy5AqELpytJTRxYT4fhltcKPj0TyaEpzJDcGz7DFJi+pQOOi3GjR/DOdxTkTsF+NzhnldIoG6TORaBlInUuqA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-3.1.0.tgz", + "integrity": "sha512-lzUVMuJ06HF4rYveaz9Tv0WRlUMxJ0Y1hgSkkgg+50iEdaI0TthyEDe08KIHb0XsF6rn8WYTqPCaGTZg3sX+qA==", "dev": true, "requires": { "loader-utils": "^1.1.0", - "schema-utils": "^1.0.0" + "schema-utils": "^2.0.1" + }, + "dependencies": { + "schema-utils": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.6.1.tgz", + "integrity": "sha512-0WXHDs1VDJyo+Zqs9TKLKyD/h7yDpHUhEFsM2CzkICFdoX1av+GBq/J2xRTFfsQO5kBfhZzANf2VcIm84jqDbg==", + "dev": true, + "requires": { + "ajv": "^6.10.2", + "ajv-keywords": "^3.4.1" + } + } } }, "read-cache": { @@ -8381,9 +10463,9 @@ } }, "readdirp": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.1.2.tgz", - "integrity": "sha512-8rhl0xs2cxfVsqzreYCvs8EwBfn/DhVdqtoLmw19uI3SC5avYX9teCurlErfpPXGmYtMHReGaP2RsLnFvz/lnw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.2.0.tgz", + "integrity": "sha512-crk4Qu3pmXwgxdSgGhgA/eXiJAPQiX4GMOZZMXnqKxHX7TaoL+3gQVo/WeuAiogr07DpnfjIMpXXa+PAIvwPGQ==", "dev": true, "requires": { "picomatch": "^2.0.4" @@ -8401,12 +10483,30 @@ "integrity": "sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg==", "dev": true }, + "regenerate-unicode-properties": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-8.1.0.tgz", + "integrity": "sha512-LGZzkgtLY79GeXLm8Dp0BVLdQlWICzBnJz/ipWUgo59qBaZ+BHtq51P2q1uVZlppMuUAT37SDk39qUbjTWB7bA==", + "dev": true, + "requires": { + "regenerate": "^1.4.0" + } + }, "regenerator-runtime": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", - "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz", + "integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw==", "dev": true }, + "regenerator-transform": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.14.1.tgz", + "integrity": "sha512-flVuee02C3FKRISbxhXl9mGzdbWUVHubl1SMaknjxkFB1/iqpJhArQUvRxOOPEc/9tAiX0BaQ28FJH10E4isSQ==", + "dev": true, + "requires": { + "private": "^0.1.6" + } + }, "regex-not": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", @@ -8460,6 +10560,11 @@ } } }, + "relative-time-format": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/relative-time-format/-/relative-time-format-1.0.5.tgz", + "integrity": "sha512-MAgx/YKcUQYJpIaWcfetPstElnWf26JxVis4PirdwVrrymFdbxyCSm6yENpfB1YuwFbtHSHksN3aBajVNxk10Q==" + }, "remove-trailing-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", @@ -8564,12 +10669,12 @@ "dev": true }, "restore-cursor": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", - "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", "dev": true, "requires": { - "onetime": "^2.0.0", + "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, @@ -8629,9 +10734,9 @@ } }, "rxjs": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.4.0.tgz", - "integrity": "sha512-Z9Yfa11F6B9Sg/BK9MnqnQ+aQYicPLtilXBp2yUtDt2JRCE0h26d33EnfO3ZxoNxG0T92OUucP3Ct7cpfkdFfw==", + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.3.tgz", + "integrity": "sha512-wuYsAYYFdWTAnAaPoKGNhfpWwKZbJW+HgAJ+mImp+Epl7BG8oNWBCTyRM8gba9k4lk8BgWdoYm21Mo/RYhhbgA==", "requires": { "tslib": "^1.9.0" } @@ -8658,34 +10763,27 @@ "dev": true }, "sass": { - "version": "1.22.7", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.22.7.tgz", - "integrity": "sha512-ahREi0AdG7RTovSv14+yd1prQSfIvFcrDpOsth5EQf1+RM7SvOxsSttzNQaFmK1aa/k/3vyYwlYF5l0Xl+6c+g==", + "version": "1.22.9", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.22.9.tgz", + "integrity": "sha512-FzU1X2V8DlnqabrL4u7OBwD2vcOzNMongEJEx3xMEhWY/v26FFR3aG0hyeu2T965sfR0E9ufJwmG+Qjz78vFPQ==", "dev": true, "requires": { "chokidar": ">=2.0.0 <4.0.0" } }, "sass-loader": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-7.1.0.tgz", - "integrity": "sha512-+G+BKGglmZM2GUSfT9TLuEp6tzehHPjAMoRRItOojWIqIGPloVCMhNIQuG639eJ+y033PaGTSjLaTHts8Kw79w==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-7.2.0.tgz", + "integrity": "sha512-h8yUWaWtsbuIiOCgR9fd9c2lRXZ2uG+h8Dzg/AGNj+Hg/3TO8+BBAW9mEP+mh8ei+qBKqSJ0F1FLlYjNBc61OA==", "dev": true, "requires": { - "clone-deep": "^2.0.1", + "clone-deep": "^4.0.1", "loader-utils": "^1.0.1", - "lodash.tail": "^4.1.1", "neo-async": "^2.5.0", - "pify": "^3.0.0", + "pify": "^4.0.1", "semver": "^5.5.0" }, "dependencies": { - "pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", - "dev": true - }, "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", @@ -8756,12 +10854,12 @@ } }, "selfsigned": { - "version": "1.10.4", - "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-1.10.4.tgz", - "integrity": "sha512-9AukTiDmHXGXWtWjembZ5NDmVvP2695EtpgbCsxCa68w3c88B+alqbmZ4O3hZ4VWGXeGWzEVdvqgAJD8DQPCDw==", + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-1.10.7.tgz", + "integrity": "sha512-8M3wBCzeWIJnQfl43IKwOmC4H/RAp50S8DF60znzjW5GVqTcSe2vWclt7hmYVPkKPlHWOu5EaWOMZ2Y6W8ZXTA==", "dev": true, "requires": { - "node-forge": "0.7.5" + "node-forge": "0.9.0" } }, "semver": { @@ -8834,9 +10932,9 @@ } }, "serialize-javascript": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-1.9.0.tgz", - "integrity": "sha512-UkGlcYMtw4d9w7YfCtJFgdRTps8N4L0A48R+SmcGL57ki1+yHwJXnalk5bjgrw+ljv6SfzjzPjhohod2qllg/Q==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-1.9.1.tgz", + "integrity": "sha512-0Vb/54WJ6k5v8sSWN09S0ora+Hnr+cX40r9F170nT+mSkaxltoE/7R3OrIdBSUv1OoiobH1QoWQbCnAO+e8J1A==", "dev": true }, "serve-index": { @@ -8950,22 +11048,12 @@ } }, "shallow-clone": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-1.0.0.tgz", - "integrity": "sha512-oeXreoKR/SyNJtRJMAKPDSvd28OqEwG4eR/xc856cRGBII7gX9lvAqDxusPm0846z/w/hWYjI1NpKwJ00NHzRA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", "dev": true, "requires": { - "is-extendable": "^0.1.1", - "kind-of": "^5.0.0", - "mixin-object": "^2.0.1" - }, - "dependencies": { - "kind-of": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", - "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", - "dev": true - } + "kind-of": "^6.0.2" } }, "shebang-command": { @@ -8996,9 +11084,9 @@ "dev": true }, "smart-buffer": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.0.2.tgz", - "integrity": "sha512-JDhEpTKzXusOqXZ0BUIdH+CjFdO/CR3tLlf5CN34IypI+xMmXW1uB16OOY8z3cICbJlDAVJzNbwBhNO0wt9OAw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.1.0.tgz", + "integrity": "sha512-iVICrxOzCynf/SNaBQCw34eM9jROU/s5rzIhpOvzhzuYHfJR/DhZfDkXiZSgKXfgv26HT3Yni3AV/DGw0cGnnw==", "dev": true }, "snapdragon": { @@ -9229,9 +11317,9 @@ } }, "sockjs-client": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.3.0.tgz", - "integrity": "sha512-R9jxEzhnnrdxLCNln0xg5uGHqMnkhPSTzUZH2eXcR03S/On9Yvoq2wyUZILRUhZCNVu2PmwWVoyuiPz8th8zbg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.4.0.tgz", + "integrity": "sha512-5zaLyO8/nri5cua0VtOrFXBPK1jbL4+1cebT/mmKA1E1ZXOvJrII75bPu0l0k843G/+iAbhEqzyKr0w/eCCj7g==", "dev": true, "requires": { "debug": "^3.2.5", @@ -9269,13 +11357,13 @@ } }, "socks": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.3.2.tgz", - "integrity": "sha512-pCpjxQgOByDHLlNqlnh/mNSAxIUkyBBuwwhTcV+enZGbDaClPvHdvm6uvOwZfFJkam7cGhBNbb4JxiP8UZkRvQ==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.3.3.tgz", + "integrity": "sha512-o5t52PCNtVdiOvzMry7wU4aOqYWL0PeCXRWBEiJow4/i/wr+wpsJQ9awEu1EonLIqsfGd5qSgDdxEOvCdmBEpA==", "dev": true, "requires": { - "ip": "^1.1.5", - "smart-buffer": "4.0.2" + "ip": "1.1.5", + "smart-buffer": "^4.1.0" } }, "socks-proxy-agent": { @@ -9656,6 +11744,26 @@ } } }, + "string.prototype.trimleft": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.0.tgz", + "integrity": "sha512-FJ6b7EgdKxxbDxc79cOlok6Afd++TTs5szo+zJTUyow3ycrRfJVE2pq3vcN53XexvKZu/DJMDfeI/qMiZTrjTw==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "function-bind": "^1.1.1" + } + }, + "string.prototype.trimright": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.0.tgz", + "integrity": "sha512-fXZTSV55dNBwv16uw+hh5jkghxSnc5oHq+5K/gXgizHwAvMetdAJlHqqoFC1FSDVPYWLkAKl2cxpUT41sV7nSg==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "function-bind": "^1.1.1" + } + }, "string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -9687,9 +11795,9 @@ "dev": true }, "style-loader": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-1.0.0.tgz", - "integrity": "sha512-B0dOCFwv7/eY31a5PCieNwMgMhVGFe9w+rh7s/Bx8kfFkrth9zfTZquoYvdw8URgiqxObQKcpW51Ugz1HjfdZw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-1.0.1.tgz", + "integrity": "sha512-CnpEkSR1C+REjudiTWCv4+ssP7SCiuaQZJTZDWBRwTJoS90mdqkB8uOGMHKgVeUzpaU7IfLWoyQbvvs5Joj3Xw==", "dev": true, "requires": { "loader-utils": "^1.2.3", @@ -9697,13 +11805,13 @@ }, "dependencies": { "schema-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.1.0.tgz", - "integrity": "sha512-g6SViEZAfGNrToD82ZPUjq52KUPDYc+fN5+g6Euo5mLokl/9Yx14z0Cu4RR1m55HtBXejO0sBt+qw79axN+Fiw==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.6.1.tgz", + "integrity": "sha512-0WXHDs1VDJyo+Zqs9TKLKyD/h7yDpHUhEFsM2CzkICFdoX1av+GBq/J2xRTFfsQO5kBfhZzANf2VcIm84jqDbg==", "dev": true, "requires": { - "ajv": "^6.1.0", - "ajv-keywords": "^3.1.0" + "ajv": "^6.10.2", + "ajv-keywords": "^3.4.1" } } } @@ -9780,14 +11888,14 @@ "dev": true }, "tar": { - "version": "4.4.10", - "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.10.tgz", - "integrity": "sha512-g2SVs5QIxvo6OLp0GudTqEf05maawKUxXru104iaayWA09551tFCTI8f1Asb4lPfkBr91k07iL4c11XO3/b0tA==", + "version": "4.4.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.13.tgz", + "integrity": "sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA==", "dev": true, "requires": { "chownr": "^1.1.1", "fs-minipass": "^1.2.5", - "minipass": "^2.3.5", + "minipass": "^2.8.6", "minizlib": "^1.2.1", "mkdirp": "^0.5.0", "safe-buffer": "^5.1.2", @@ -9795,9 +11903,9 @@ } }, "terser": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-4.2.1.tgz", - "integrity": "sha512-cGbc5utAcX4a9+2GGVX4DsenG6v0x3glnDi5hx8816X1McEAwPlPgRtXPJzSBsbpILxZ8MQMT0KvArLuE0HP5A==", + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/terser/-/terser-4.3.9.tgz", + "integrity": "sha512-NFGMpHjlzmyOtPL+fDw3G7+6Ueh/sz4mkaUYa4lJCxOPTNzd0Uj0aZJOmsDYoSQyfuVoWDMSWTPU3huyOm2zdA==", "dev": true, "requires": { "commander": "^2.20.0", @@ -9814,23 +11922,33 @@ } }, "terser-webpack-plugin": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.3.0.tgz", - "integrity": "sha512-W2YWmxPjjkUcOWa4pBEv4OP4er1aeQJlSo2UhtCFQCuRXEHjOFscO8VyWHj9JLlA0RzQb8Y2/Ta78XZvT54uGg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-1.4.1.tgz", + "integrity": "sha512-ZXmmfiwtCLfz8WKZyYUuuHf3dMYEjg8NrjHMb0JqHVHVOSkzp3cW2/XG1fP3tRhqEqSzMwzzRQGtAPbs4Cncxg==", "dev": true, "requires": { - "cacache": "^11.3.2", - "find-cache-dir": "^2.0.0", + "cacache": "^12.0.2", + "find-cache-dir": "^2.1.0", "is-wsl": "^1.1.0", - "loader-utils": "^1.2.3", "schema-utils": "^1.0.0", "serialize-javascript": "^1.7.0", "source-map": "^0.6.1", - "terser": "^4.0.0", - "webpack-sources": "^1.3.0", + "terser": "^4.1.2", + "webpack-sources": "^1.4.0", "worker-farm": "^1.7.0" }, "dependencies": { + "find-cache-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", + "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^2.0.0", + "pkg-dir": "^3.0.0" + } + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -9856,9 +11974,9 @@ } }, "thunky": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.0.3.tgz", - "integrity": "sha512-YwT8pjmNcAXBZqrubu22P4FYsh2D4dxRmnWBOL8Jk8bUcRUtc5326kx32tuTmFDAZtLOGEVNl8POAR8j896Iow==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", "dev": true }, "timers-browserify": { @@ -9898,9 +12016,9 @@ "dev": true }, "to-fast-properties": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz", - "integrity": "sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", "dev": true }, "to-object-path": { @@ -10060,6 +12178,12 @@ "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", "dev": true }, + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + }, "type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -10083,16 +12207,23 @@ "dev": true }, "uglify-js": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.6.2.tgz", - "integrity": "sha512-+gh/xFte41GPrgSMJ/oJVq15zYmqr74pY9VoM69UzMzq9NFk4YDylclb1/bhEzZSaUQjbW5RvniHeq1cdtRYjw==", + "version": "3.6.9", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.6.9.tgz", + "integrity": "sha512-pcnnhaoG6RtrvHJ1dFncAe8Od6Nuy30oaJ82ts6//sGSXOP5UjBMEthiProjXmMNHOfd93sqlkztifFMcb+4yw==", "dev": true, "optional": true, "requires": { - "commander": "2.20.0", + "commander": "~2.20.3", "source-map": "~0.6.1" }, "dependencies": { + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "optional": true + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -10108,6 +12239,34 @@ "integrity": "sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og==", "dev": true }, + "unicode-canonical-property-names-ecmascript": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", + "integrity": "sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==", + "dev": true + }, + "unicode-match-property-ecmascript": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz", + "integrity": "sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg==", + "dev": true, + "requires": { + "unicode-canonical-property-names-ecmascript": "^1.0.4", + "unicode-property-aliases-ecmascript": "^1.0.4" + } + }, + "unicode-match-property-value-ecmascript": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.1.0.tgz", + "integrity": "sha512-hDTHvaBk3RmFzvSl0UVrUmC3PuW9wKVnpoUDYH0JDkSIovzw+J5viQmeYHxVSBptubnr7PbH2e0fnpDRQnQl5g==", + "dev": true + }, + "unicode-property-aliases-ecmascript": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.0.5.tgz", + "integrity": "sha512-L5RAqCfXqAwR3RriF8pM0lU0w4Ryf/GgzONwi6KnL1taJQa7x1TCxdJnILX59WIGOwR57IVxn7Nej0fz1Ny6fw==", + "dev": true + }, "union-value": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", @@ -10381,9 +12540,9 @@ } }, "vm-browserify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.0.tgz", - "integrity": "sha512-iq+S7vZJE60yejDYM0ek6zg308+UZsdtPExWP9VZoCFCz1zkJoXFnAX7aZfd/ZwrkidzdUZL0C/ryW+JwAiIGw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", + "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", "dev": true }, "void-elements": { @@ -11107,34 +13266,34 @@ } }, "webpack": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.38.0.tgz", - "integrity": "sha512-lbuFsVOq8PZY+1Ytz/mYOvYOo+d4IJ31hHk/7iyoeWtwN33V+5HYotSH+UIb9tq914ey0Hot7z6HugD+je3sWw==", + "version": "4.39.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-4.39.2.tgz", + "integrity": "sha512-AKgTfz3xPSsEibH00JfZ9sHXGUwIQ6eZ9tLN8+VLzachk1Cw2LVmy+4R7ZiwTa9cZZ15tzySjeMui/UnSCAZhA==", "dev": true, "requires": { "@webassemblyjs/ast": "1.8.5", "@webassemblyjs/helper-module-context": "1.8.5", "@webassemblyjs/wasm-edit": "1.8.5", "@webassemblyjs/wasm-parser": "1.8.5", - "acorn": "^6.2.0", - "ajv": "^6.1.0", - "ajv-keywords": "^3.1.0", - "chrome-trace-event": "^1.0.0", + "acorn": "^6.2.1", + "ajv": "^6.10.2", + "ajv-keywords": "^3.4.1", + "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^4.1.0", - "eslint-scope": "^4.0.0", + "eslint-scope": "^4.0.3", "json-parse-better-errors": "^1.0.2", - "loader-runner": "^2.3.0", - "loader-utils": "^1.1.0", - "memory-fs": "~0.4.1", - "micromatch": "^3.1.8", - "mkdirp": "~0.5.0", - "neo-async": "^2.5.0", - "node-libs-browser": "^2.0.0", + "loader-runner": "^2.4.0", + "loader-utils": "^1.2.3", + "memory-fs": "^0.4.1", + "micromatch": "^3.1.10", + "mkdirp": "^0.5.1", + "neo-async": "^2.6.1", + "node-libs-browser": "^2.2.1", "schema-utils": "^1.0.0", - "tapable": "^1.1.0", - "terser-webpack-plugin": "^1.1.0", - "watchpack": "^1.5.0", - "webpack-sources": "^1.3.0" + "tapable": "^1.1.3", + "terser-webpack-plugin": "^1.4.1", + "watchpack": "^1.6.0", + "webpack-sources": "^1.4.1" } }, "webpack-core": { @@ -11165,13 +13324,14 @@ } }, "webpack-dev-middleware": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-3.7.0.tgz", - "integrity": "sha512-qvDesR1QZRIAZHOE3iQ4CXLZZSQ1lAUsSpnQmlB1PBfoN/xdRjmge3Dok0W4IdaVLJOGJy3sGI4sZHwjRU0PCA==", + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-3.7.2.tgz", + "integrity": "sha512-1xC42LxbYoqLNAhV6YzTYacicgMZQTqRd27Sim9wn5hJrX3I5nxYy1SxSd4+gjUFsz1dQFj+yEe6zEVmSkeJjw==", "dev": true, "requires": { "memory-fs": "^0.4.1", - "mime": "^2.4.2", + "mime": "^2.4.4", + "mkdirp": "^0.5.1", "range-parser": "^1.2.1", "webpack-log": "^2.0.0" }, @@ -11185,41 +13345,43 @@ } }, "webpack-dev-server": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-3.7.2.tgz", - "integrity": "sha512-mjWtrKJW2T9SsjJ4/dxDC2fkFVUw8jlpemDERqV0ZJIkjjjamR2AbQlr3oz+j4JLhYCHImHnXZK5H06P2wvUew==", + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-3.9.0.tgz", + "integrity": "sha512-E6uQ4kRrTX9URN9s/lIbqTAztwEPdvzVrcmHE8EQ9YnuT9J8Es5Wrd8n9BKg1a0oZ5EgEke/EQFgUsp18dSTBw==", "dev": true, "requires": { "ansi-html": "0.0.7", "bonjour": "^3.5.0", - "chokidar": "^2.1.6", + "chokidar": "^2.1.8", "compression": "^1.7.4", "connect-history-api-fallback": "^1.6.0", "debug": "^4.1.1", "del": "^4.1.1", "express": "^4.17.1", "html-entities": "^1.2.1", - "http-proxy-middleware": "^0.19.1", + "http-proxy-middleware": "0.19.1", "import-local": "^2.0.0", "internal-ip": "^4.3.0", "ip": "^1.1.5", + "is-absolute-url": "^3.0.3", "killable": "^1.0.1", - "loglevel": "^1.6.3", + "loglevel": "^1.6.4", "opn": "^5.5.0", "p-retry": "^3.0.1", - "portfinder": "^1.0.20", + "portfinder": "^1.0.25", "schema-utils": "^1.0.0", - "selfsigned": "^1.10.4", - "semver": "^6.1.1", + "selfsigned": "^1.10.7", + "semver": "^6.3.0", "serve-index": "^1.9.1", "sockjs": "0.3.19", - "sockjs-client": "1.3.0", - "spdy": "^4.0.0", + "sockjs-client": "1.4.0", + "spdy": "^4.0.1", "strip-ansi": "^3.0.1", "supports-color": "^6.1.0", "url": "^0.11.0", - "webpack-dev-middleware": "^3.7.0", + "webpack-dev-middleware": "^3.7.2", "webpack-log": "^2.0.0", + "ws": "^6.2.1", "yargs": "12.0.5" }, "dependencies": { @@ -11928,6 +14090,15 @@ "is-number": "^3.0.0", "repeat-string": "^1.6.1" } + }, + "ws": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz", + "integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==", + "dev": true, + "requires": { + "async-limiter": "~1.0.0" + } } } }, @@ -11951,9 +14122,9 @@ } }, "webpack-sources": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.3.0.tgz", - "integrity": "sha512-OiVgSrbGu7NEnEvQJJgdSFPl2qWKkWq5lHMhgiToIiN9w34EBnjYzSYs+VbL5KoYiLNtFFa7BZIKxRED3I32pA==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", + "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", "dev": true, "requires": { "source-list-map": "^2.0.0", @@ -12031,9 +14202,9 @@ } }, "worker-plugin": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/worker-plugin/-/worker-plugin-3.1.0.tgz", - "integrity": "sha512-iQ9KTTmmN5fhfc2KMR7CcDblvcrg1QQ4pXymqZ3cRZF8L0890YLBcEqlIsGPdxoFwghyN8RA1pCEhCKuTF4Lkw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/worker-plugin/-/worker-plugin-3.2.0.tgz", + "integrity": "sha512-W5nRkw7+HlbsEt3qRP6MczwDDISjiRj2GYt9+bpe8A2La00TmJdwzG5bpdMXhRt1qcWmwAvl1TiKaHRa+XDS9Q==", "dev": true, "requires": { "loader-utils": "^1.1.0" @@ -12131,9 +14302,9 @@ "dev": true }, "yallist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", - "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, "yargs": { diff --git a/src/blogify/frontend/package.json b/src/blogify/frontend/package.json index 4df4092a..da62d1d8 100644 --- a/src/blogify/frontend/package.json +++ b/src/blogify/frontend/package.json @@ -11,35 +11,38 @@ }, "private": true, "dependencies": { - "@angular/animations": "~8.2.0", - "@angular/common": "~8.2.0", - "@angular/compiler": "~8.2.0", - "@angular/core": "~8.2.0", - "@angular/forms": "~8.2.0", - "@angular/platform-browser": "~8.2.0", - "@angular/platform-browser-dynamic": "~8.2.0", - "@angular/router": "~8.2.0", + "@angular/animations": "~8.2.14", + "@angular/common": "~8.2.14", + "@angular/compiler": "~8.2.14", + "@angular/core": "~8.2.14", + "@angular/forms": "~8.2.14", + "@angular/platform-browser": "~8.2.14", + "@angular/platform-browser-dynamic": "~8.2.14", + "@angular/router": "~8.2.14", "@fortawesome/angular-fontawesome": "^0.5.0", "@fortawesome/fontawesome-svg-core": "^1.2.25", + "@fortawesome/free-regular-svg-icons": "^5.11.2", "@fortawesome/free-solid-svg-icons": "^5.11.2", "angular-font-awesome": "^3.1.2", "font-awesome": "^4.7.0", + "ngx-clipboard": "^12.3.0", "ngx-markdown": "^8.2.1", "prismjs": "^1.17.1", - "rxjs": "~6.4.0", + "relative-time-format": "^1.0.5", + "rxjs": "~6.5.3", "tslib": "^1.10.0", "uuid": "^3.3.3", "zone.js": "~0.9.1" }, "devDependencies": { - "@angular-devkit/build-angular": "~0.802.2", - "@angular/cli": "~8.2.2", - "@angular/compiler-cli": "~8.2.0", - "@angular/language-service": "~8.2.0", - "@types/node": "~8.9.4", + "@angular-devkit/build-angular": "^0.803.20", + "@angular/cli": "^8.3.20", + "@angular/compiler-cli": "~8.2.14", + "@angular/language-service": "~8.2.14", "@types/jasmine": "~3.3.8", - "@types/jasminewd2": "~2.0.3", - "codelyzer": "^5.0.0", + "@types/jasminewd2": "^2.0.8", + "@types/node": "~8.9.4", + "codelyzer": "^5.2.0", "jasmine-core": "~3.4.0", "jasmine-spec-reporter": "~4.2.1", "karma": "~4.1.0", @@ -48,7 +51,7 @@ "karma-jasmine": "~2.0.1", "karma-jasmine-html-reporter": "^1.4.0", "protractor": "~5.4.0", - "style-loader": "^1.0.0", + "style-loader": "^1.0.1", "ts-node": "~7.0.0", "tslint": "~5.15.0", "typescript": "~3.5.3" diff --git a/src/blogify/frontend/src/app/app-routing.module.ts b/src/blogify/frontend/src/app/app-routing.module.ts index 7f2ac4bc..1c29ae0c 100644 --- a/src/blogify/frontend/src/app/app-routing.module.ts +++ b/src/blogify/frontend/src/app/app-routing.module.ts @@ -2,11 +2,10 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { HomeComponent } from './components/home/home.component'; import { LoginComponent } from './components/login/login.component'; -import { ProfileComponent } from './components/profile/profile.component'; import { NewArticleComponent } from './components/newarticle/new-article.component'; import { ShowArticleComponent } from './components/show-article/show-article.component'; import { UpdateArticleComponent } from './components/update-article/update-article.component'; - +import { UsersComponent } from './components/users/users.component'; const routes: Routes = [ { path: 'home', component: HomeComponent }, @@ -14,9 +13,12 @@ const routes: Routes = [ { path: 'login', component: LoginComponent }, { path: 'register', component: LoginComponent }, { path: 'article/new', component: NewArticleComponent }, - { path: 'profile/**', component: ProfileComponent }, + /*{ path: 'profile/**', component: ProfileComponent },*/ { path: 'article/:uuid', component: ShowArticleComponent }, { path: 'article/update/:uuid', component: UpdateArticleComponent }, + /*{ path: 'admin/**', component: AdminComponent },*/ + /*{ path: '**', component: Error404FallbackComponent },*/ + { path: 'users', component: UsersComponent } ]; @NgModule({ diff --git a/src/blogify/frontend/src/app/app.component.html b/src/blogify/frontend/src/app/app.component.html index 109dafc7..cf8a1b01 100644 --- a/src/blogify/frontend/src/app/app.component.html +++ b/src/blogify/frontend/src/app/app.component.html @@ -1,10 +1,5 @@ diff --git a/src/blogify/frontend/src/app/app.component.scss b/src/blogify/frontend/src/app/app.component.scss index 41fc77e1..e69de29b 100644 --- a/src/blogify/frontend/src/app/app.component.scss +++ b/src/blogify/frontend/src/app/app.component.scss @@ -1,10 +0,0 @@ -a+a { - margin-left: 10px; -} - -#license-info { - display: flex; - flex-direction: column; - justify-content: space-evenly; - align-items: center; -} diff --git a/src/blogify/frontend/src/app/app.module.ts b/src/blogify/frontend/src/app/app.module.ts index 191064c1..88ee1817 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, ReactiveFormsModule} 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'; @@ -19,6 +19,13 @@ import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { MarkdownModule } from 'ngx-markdown'; import { SharedModule } from './shared/shared.module'; import { ProfileModule } from './components/profile/profile/profile.module'; +import { FooterComponent } from './components/footer/footer.component'; +import { ClipboardModule } from "ngx-clipboard"; +import { Error404FallbackComponent } from './components/error404-fallback/error404-fallback.component'; +import { AdminComponent } from './components/admin/admin.component'; +import { AdminModule } from './components/admin/admin/admin.module'; +import { FollowsComponent } from './components/profile/profile/follows/follows.component'; +import { UsersComponent } from './components/users/users.component'; @NgModule({ declarations: [ @@ -33,6 +40,11 @@ import { ProfileModule } from './components/profile/profile/profile.module'; SingleCommentComponent, CreateCommentComponent, UpdateArticleComponent, + FooterComponent, + Error404FallbackComponent, + AdminComponent, + FollowsComponent, + UsersComponent, ], imports: [ BrowserModule, @@ -41,10 +53,14 @@ import { ProfileModule } from './components/profile/profile/profile.module'; HttpClientModule, FormsModule, ReactiveFormsModule, + FontAwesomeModule, MarkdownModule.forRoot(), - ProfileModule, + ClipboardModule, + SharedModule, + ProfileModule, + AdminModule, ], providers: [], exports: [], diff --git a/src/blogify/frontend/src/app/components/admin/admin.component.html b/src/blogify/frontend/src/app/components/admin/admin.component.html new file mode 100644 index 00000000..eeba7103 --- /dev/null +++ b/src/blogify/frontend/src/app/components/admin/admin.component.html @@ -0,0 +1 @@ + diff --git a/src/blogify/frontend/src/app/components/admin/admin.component.scss b/src/blogify/frontend/src/app/components/admin/admin.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/blogify/frontend/src/app/components/admin/admin.component.spec.ts b/src/blogify/frontend/src/app/components/admin/admin.component.spec.ts new file mode 100644 index 00000000..72e742ff --- /dev/null +++ b/src/blogify/frontend/src/app/components/admin/admin.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminComponent } from './admin.component'; + +describe('AdminComponent', () => { + let component: AdminComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ AdminComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AdminComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/blogify/frontend/src/app/components/admin/admin.component.ts b/src/blogify/frontend/src/app/components/admin/admin.component.ts new file mode 100644 index 00000000..c3d1f34e --- /dev/null +++ b/src/blogify/frontend/src/app/components/admin/admin.component.ts @@ -0,0 +1,15 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-admin', + templateUrl: './admin.component.html', + styleUrls: ['./admin.component.scss'] +}) +export class AdminComponent implements OnInit { + + constructor() { } + + ngOnInit() { + } + +} diff --git a/src/blogify/frontend/src/app/components/admin/admin/admin-routing.module.ts b/src/blogify/frontend/src/app/components/admin/admin/admin-routing.module.ts new file mode 100644 index 00000000..5a531b8e --- /dev/null +++ b/src/blogify/frontend/src/app/components/admin/admin/admin-routing.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { MainAdminComponent } from './main/main-admin.component'; +import { OverviewComponent } from './overview/overview.component'; + +const routes: Routes = [ + { + path: 'admin', component: MainAdminComponent, + children: [ + { path: '', redirectTo: 'overview', pathMatch: 'full' }, + { path: 'overview', component: OverviewComponent } + ] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class AdminRoutingModule { } diff --git a/src/blogify/frontend/src/app/components/admin/admin/admin.module.ts b/src/blogify/frontend/src/app/components/admin/admin/admin.module.ts new file mode 100644 index 00000000..f4892bee --- /dev/null +++ b/src/blogify/frontend/src/app/components/admin/admin/admin.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { AdminRoutingModule } from './admin-routing.module'; +import { MainAdminComponent } from './main/main-admin.component'; +import { OverviewComponent } from './overview/overview.component'; + +@NgModule({ + declarations: [ + MainAdminComponent, + OverviewComponent + ], + exports: [ + MainAdminComponent + ], + imports: [ + CommonModule, + AdminRoutingModule + ] +}) +export class AdminModule { } diff --git a/src/blogify/frontend/src/app/components/admin/admin/main/main-admin.component.html b/src/blogify/frontend/src/app/components/admin/admin/main/main-admin.component.html new file mode 100644 index 00000000..ff46f9ed --- /dev/null +++ b/src/blogify/frontend/src/app/components/admin/admin/main/main-admin.component.html @@ -0,0 +1,8 @@ +
+ + +

Admin panel

+ + + +
diff --git a/src/blogify/frontend/src/app/components/admin/admin/main/main-admin.component.scss b/src/blogify/frontend/src/app/components/admin/admin/main/main-admin.component.scss new file mode 100644 index 00000000..83c7b5f5 --- /dev/null +++ b/src/blogify/frontend/src/app/components/admin/admin/main/main-admin.component.scss @@ -0,0 +1,5 @@ +@import "../../../../../styles/mixins"; + +.admin-container { + @include pageContainer(); +} diff --git a/src/blogify/frontend/src/app/components/admin/admin/main/main-admin.component.spec.ts b/src/blogify/frontend/src/app/components/admin/admin/main/main-admin.component.spec.ts new file mode 100644 index 00000000..613ad6e5 --- /dev/null +++ b/src/blogify/frontend/src/app/components/admin/admin/main/main-admin.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MainAdminComponent } from './main-admin.component'; + +describe('MainAdminComponent', () => { + let component: MainAdminComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ MainAdminComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MainAdminComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/blogify/frontend/src/app/components/admin/admin/main/main-admin.component.ts b/src/blogify/frontend/src/app/components/admin/admin/main/main-admin.component.ts new file mode 100644 index 00000000..fed86c15 --- /dev/null +++ b/src/blogify/frontend/src/app/components/admin/admin/main/main-admin.component.ts @@ -0,0 +1,14 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-main-admin', + templateUrl: './main-admin.component.html', + styleUrls: ['./main-admin.component.scss'] +}) +export class MainAdminComponent implements OnInit { + + constructor() {} + + ngOnInit() {} + +} diff --git a/src/blogify/frontend/src/app/components/admin/admin/overview/overview.component.html b/src/blogify/frontend/src/app/components/admin/admin/overview/overview.component.html new file mode 100644 index 00000000..3c51c50a --- /dev/null +++ b/src/blogify/frontend/src/app/components/admin/admin/overview/overview.component.html @@ -0,0 +1,3 @@ +

overview works!

+ + diff --git a/src/blogify/frontend/src/app/components/admin/admin/overview/overview.component.scss b/src/blogify/frontend/src/app/components/admin/admin/overview/overview.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/blogify/frontend/src/app/components/admin/admin/overview/overview.component.spec.ts b/src/blogify/frontend/src/app/components/admin/admin/overview/overview.component.spec.ts new file mode 100644 index 00000000..97f05b7d --- /dev/null +++ b/src/blogify/frontend/src/app/components/admin/admin/overview/overview.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { OverviewComponent } from './overview.component'; + +describe('OverviewComponent', () => { + let component: OverviewComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ OverviewComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(OverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/blogify/frontend/src/app/components/admin/admin/overview/overview.component.ts b/src/blogify/frontend/src/app/components/admin/admin/overview/overview.component.ts new file mode 100644 index 00000000..1a75ab94 --- /dev/null +++ b/src/blogify/frontend/src/app/components/admin/admin/overview/overview.component.ts @@ -0,0 +1,19 @@ +import { Component, OnInit } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +@Component({ + selector: 'app-overview', + templateUrl: './overview.component.html', + styleUrls: ['./overview.component.scss'] +}) +export class OverviewComponent implements OnInit { + + constructor(private http: HttpClient) {} + + ngOnInit() {} + + callReindex() { + this.http.post('/api/admin/search/reindex', null).toPromise().then(r => alert(r)); + } + +} diff --git a/src/blogify/frontend/src/app/components/comment/article-comments.component.ts b/src/blogify/frontend/src/app/components/comment/article-comments.component.ts index 8f9e5b8d..6fd17b37 100644 --- a/src/blogify/frontend/src/app/components/comment/article-comments.component.ts +++ b/src/blogify/frontend/src/app/components/comment/article-comments.component.ts @@ -35,13 +35,13 @@ export class ArticleCommentsComponent implements OnInit { out.push(comment); }); this.rootComments = out; - - console.log(out); }); - } - getNewComment(comment: Comment) { - this.rootComments.push(comment); + this.commentService.latestRootSubmittedComment.subscribe(comment => { + if (comment) { + this.rootComments.push(comment); + } + }); } isLoggedIn(): boolean { 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 4bfd091c..0d9b26ed 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 @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, EventEmitter, Input, OnInit } from '@angular/core'; import { Article } from '../../../models/Article'; import { CommentsService } from '../../../services/comments/comments.service'; import { AuthService } from '../../../shared/auth/auth.service'; @@ -15,7 +15,7 @@ export class CreateCommentComponent implements OnInit { commentContent = ''; @Input() article: Article; @Input() comment: Comment; - @Input() replying: boolean = false; + @Input() replying = false; replyComment: Comment; replyError: string; @@ -38,7 +38,7 @@ export class CreateCommentComponent implements OnInit { if (this.authService.observeIsLoggedIn() && this.replyComment.commenter instanceof User) { if (this.comment === undefined) { // Reply to article - await this.commentsService.createComment ( + const newComment = await this.commentsService.createComment ( this.replyComment.content, this.article.uuid, this.replyComment.commenter.uuid @@ -53,7 +53,7 @@ export class CreateCommentComponent implements OnInit { } } else { - this.replyError = 'You must be logged in to comment.' + this.replyError = 'You must be logged in to comment.'; } } 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 27e220fe..4326dd9b 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 @@ -16,7 +16,7 @@
- +
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 d2e9f2a7..b106d3b8 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 @@ -12,6 +12,7 @@ import { ArticleService } from '../../../services/article/article.service'; }) export class SingleCommentComponent implements OnInit { + @Input() parent: Comment; @Input() comment: Comment; @Input() child: boolean; @@ -21,24 +22,31 @@ export class SingleCommentComponent implements OnInit { replyComment: Comment; replyError: string; - constructor (private authService: AuthService, - private commentsService: CommentsService, - private articleService: ArticleService) {} + constructor ( + private authService: AuthService, + private commentsService: CommentsService, + private articleService: ArticleService, + ) {} async ngOnInit() { - // Fetch full user instead of uuid only if it hasn't been fetched + // Fetch full user instead of uuid only if it hasn't been fetched, or get it from parent if available - if (typeof this.comment.commenter === 'string') { + if (this.parent !== undefined && this.parent.commenter === this.comment.commenter) { + this.comment.commenter = this.parent.commenter; + } else if (typeof this.comment.commenter === 'string') { this.comment.commenter = await this.authService.fetchUser(this.comment.commenter); } - // Fetch full article instead of uuid only if it hasn't been fetched + // Article is always the same as parent - if (typeof this.comment.article === 'string') { + if (this.parent !== undefined) { + this.comment.article = this.parent.article; + } else if (typeof this.comment.article === 'string') { this.comment.article = await this.articleService.getArticleByUUID(this.comment.article); } + this.isReady = true; // We're ready, so we can populate the dummy reply comment @@ -50,13 +58,9 @@ export class SingleCommentComponent implements OnInit { uuid: '' }; - // console.log(`type: ${this.replyComment.commenter instanceof User}, logged in: ${this.authService.isLoggedIn()}, istype: ${this.authService.userProfile instanceof User}`); } async replyToSelf() { - - console.log(this.replyComment.commenter instanceof User); - // Make sure the user is authenticated if (this.authService.observeIsLoggedIn() && this.replyComment.commenter instanceof User) { await this.commentsService.replyToComment ( diff --git a/src/blogify/frontend/src/app/components/error404-fallback/error404-fallback.component.html b/src/blogify/frontend/src/app/components/error404-fallback/error404-fallback.component.html new file mode 100644 index 00000000..20c92717 --- /dev/null +++ b/src/blogify/frontend/src/app/components/error404-fallback/error404-fallback.component.html @@ -0,0 +1,4 @@ +

+ Uh oh
+ Looks like you stumbled on a place that doesn't exist +

diff --git a/src/blogify/frontend/src/app/components/error404-fallback/error404-fallback.component.scss b/src/blogify/frontend/src/app/components/error404-fallback/error404-fallback.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/blogify/frontend/src/app/components/error404-fallback/error404-fallback.component.spec.ts b/src/blogify/frontend/src/app/components/error404-fallback/error404-fallback.component.spec.ts new file mode 100644 index 00000000..54a9e9e1 --- /dev/null +++ b/src/blogify/frontend/src/app/components/error404-fallback/error404-fallback.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Error404FallbackComponent } from './error404-fallback.component'; + +describe('Error404FallbackComponent', () => { + let component: Error404FallbackComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ Error404FallbackComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(Error404FallbackComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/blogify/frontend/src/app/components/error404-fallback/error404-fallback.component.ts b/src/blogify/frontend/src/app/components/error404-fallback/error404-fallback.component.ts new file mode 100644 index 00000000..f6020408 --- /dev/null +++ b/src/blogify/frontend/src/app/components/error404-fallback/error404-fallback.component.ts @@ -0,0 +1,15 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-error404-fallback', + templateUrl: './error404-fallback.component.html', + styleUrls: ['./error404-fallback.component.scss'] +}) +export class Error404FallbackComponent implements OnInit { + + constructor() { } + + ngOnInit() { + } + +} diff --git a/src/blogify/frontend/src/app/components/footer/footer.component.html b/src/blogify/frontend/src/app/components/footer/footer.component.html new file mode 100644 index 00000000..6ee6afd3 --- /dev/null +++ b/src/blogify/frontend/src/app/components/footer/footer.component.html @@ -0,0 +1,22 @@ +
diff --git a/src/blogify/frontend/src/app/components/footer/footer.component.scss b/src/blogify/frontend/src/app/components/footer/footer.component.scss new file mode 100644 index 00000000..232d4e97 --- /dev/null +++ b/src/blogify/frontend/src/app/components/footer/footer.component.scss @@ -0,0 +1,75 @@ +@import "../../../styles/queries"; + +:host { + margin-top: auto; + margin-bottom: 1.75em; +} +#footer { + display: flex; + flex-direction: row; + justify-content: stretch; + align-items: center; + gap: 2rem; + + @media (max-width: $query-desktop) { + flex-direction: column; + gap: 1rem; + .separator { + height: 1px; + width: 100%; + } + } + + max-width: 70%; + overflow: hidden; + + margin: auto; + + > * { + margin: .75em; + } + + #footer-logo > img { + width: 4em; + filter: var(--logo-filter); + } + + #version-container { + + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: 1em; + + @media (max-width: $query-desktop) { + align-items: center; + } + + #version-number { + font-size: 1.5em; + font-weight: 600; + } + + #version-dev { + font-size: 1.5em; + font-weight: 600; + color: red; + } + + #version-detailed { + font-size: 1.3em; + } + + } + + #links-container { + display: flex; + flex-direction: row; + gap: 3rem; + > a { + font-size: 1.5em; + font-weight: 600; + } + } + +} diff --git a/src/blogify/frontend/src/app/components/footer/footer.component.spec.ts b/src/blogify/frontend/src/app/components/footer/footer.component.spec.ts new file mode 100644 index 00000000..2ca6c454 --- /dev/null +++ b/src/blogify/frontend/src/app/components/footer/footer.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FooterComponent } from './footer.component'; + +describe('FooterComponent', () => { + let component: FooterComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ FooterComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(FooterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/blogify/frontend/src/app/components/footer/footer.component.ts b/src/blogify/frontend/src/app/components/footer/footer.component.ts new file mode 100644 index 00000000..da17d824 --- /dev/null +++ b/src/blogify/frontend/src/app/components/footer/footer.component.ts @@ -0,0 +1,15 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-footer', + templateUrl: './footer.component.html', + styleUrls: ['./footer.component.scss'] +}) +export class FooterComponent implements OnInit { + + constructor() { } + + ngOnInit() { + } + +} 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 8f082071..6b839766 100644 --- a/src/blogify/frontend/src/app/components/home/home.component.ts +++ b/src/blogify/frontend/src/app/components/home/home.component.ts @@ -8,6 +8,7 @@ import { Article } from '../../models/Article'; styleUrls: ['./home.component.scss'] }) export class HomeComponent implements OnInit { + title = 'blogify'; articles: Article[]; @@ -16,11 +17,10 @@ export class HomeComponent implements OnInit { ngOnInit() { this.articleService.getAllArticles ( - ['title', 'summary', 'createdBy', 'categories', 'createdAt', 'numberOfComments'] + ['title', 'summary', 'createdBy', 'categories', 'createdAt', 'likeCount', 'commentCount'] ).then( articles => { this.articles = 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 41f8490d..c9dda9ac 100644 --- a/src/blogify/frontend/src/app/components/login/login.component.ts +++ b/src/blogify/frontend/src/app/components/login/login.component.ts @@ -29,17 +29,15 @@ export class LoginComponent implements OnInit { async login() { this.authService.login(this.loginCredentials) .then(async token => { - console.log(token); - 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); @@ -58,9 +56,9 @@ export class LoginComponent implements OnInit { .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); 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 45dde576..f507632e 100644 --- a/src/blogify/frontend/src/app/components/navbar/navbar.component.html +++ b/src/blogify/frontend/src/app/components/navbar/navbar.component.html @@ -15,18 +15,23 @@ - - - {{user.name}} - + + - + - + Login @@ -36,7 +41,7 @@ --> - + diff --git a/src/blogify/frontend/src/app/components/navbar/navbar.component.scss b/src/blogify/frontend/src/app/components/navbar/navbar.component.scss index 7c88f9a0..1f59feed 100644 --- a/src/blogify/frontend/src/app/components/navbar/navbar.component.scss +++ b/src/blogify/frontend/src/app/components/navbar/navbar.component.scss @@ -39,7 +39,7 @@ justify-content: flex-end; align-items: center; - span { + span, a { fa-icon { margin-right: .5em; vertical-align: middle; @@ -57,13 +57,6 @@ } - span.separator { - width: 1px; - margin-left: .85em !important; - align-self: stretch; - background-color: var(--header-ct); - } - #profile, #login, #logout, #notifications, #theme-switch { cursor: pointer; } #profile { @@ -80,16 +73,12 @@ font-weight: 600 !important; } - #logout { + /* #logout { &:hover { color: var(--accent-negative); } - } + } */ #notifications { &:hover { color: var(--accent-mild); } } - #theme-switch { - &:hover { color: var(--accent-neutral); } - } - } 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 233ec2f0..beb90471 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,6 +1,8 @@
+ +

New Article

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 c67d9e82..245f9c10 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; } - &:not(:last-of-type) span:first-child { + &:not(:last-of-type) > span:first-child { width: 40%; font-size: 1.5em; @@ -95,6 +95,7 @@ @media (max-width: $query-desktop) { flex-direction: column; + align-items: stretch; } } 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 679903e1..e1fe9c1c 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 @@ -1,13 +1,16 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, ViewChild } from '@angular/core'; import { Article } from '../../models/Article'; import { ArticleService } from '../../services/article/article.service'; import { User } from '../../models/User'; -import { StaticFile } from "../../models/Static"; +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"; +import { faExclamationCircle, faPlus, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { Router } from '@angular/router'; +import { ToasterComponent } from '../../shared/components/toaster/toaster.component'; +import { ToasterService } from '../../shared/services/toaster/toaster.service'; +import { Toast, ToastStyle } from '../../shared/services/toaster/models/Toast'; type Result = 'none' | 'success' | 'error'; @@ -20,25 +23,28 @@ export class NewArticleComponent implements OnInit { faPlus = faPlus; - article: Article = { - uuid: '', - title: '', - categories: [], - content: '', - summary: '', - createdBy: new User('', '', '', '', new StaticFile('-1')), - createdAt: Date.now(), - numberOfComments: 0, - }; + article: Article = new Article ( + '', + '', + '', + '', + new User('', '', '', '', [], new StaticFile('-1'), new StaticFile('-1')), + Date.now(), + [] + ); user: User; validations: object; result: { status: Result, message: string } = { status: 'none', message: null }; + @ViewChild(ToasterComponent, { static: false }) + private toaster: ToasterComponent; + constructor ( private articleService: ArticleService, private authService: AuthService, + private toasterService: ToasterService, private http: HttpClient, private router: Router, ) {} @@ -46,7 +52,27 @@ export class NewArticleComponent implements OnInit { async ngOnInit() { this.user = await this.authService.userProfile; this.validations = await this.http.get('/api/articles/_validations').toPromise(); - console.warn(this.validations); + + this.toasterService.plugInto(this.toaster); + this.toasterService.feed ( + new Toast ({ + header: 'One toast !', + content: 'Body of the first toast, neutral colored ! :)', + backgroundColor: ToastStyle.NEUTRAL + }), + new Toast ({ + header: 'The Second Toast...', + content: 'Contents of the second toast. Interesting.', + icon: faExclamationCircle, + backgroundColor: ToastStyle.MILD + }), + new Toast ({ + header: 'A THIRD ONE !', + content: 'Danger danger danger danger danger danger danger !', + icon: faTimes, + backgroundColor: ToastStyle.NEGATIVE + }) + ); } private validateOnServer(fieldName: string): ValidatorFn { @@ -82,14 +108,17 @@ export class NewArticleComponent implements OnInit { // noinspection JSMethodCanBeStatic transformArticleData(input: object): object { - input['categories'] = input['categories'].map(cat => { return { name: cat }}); + input['categories'] = input['categories'] + .filter((cat: string) => cat.match(/\\s/) !== null) + .map(cat => { return { name: cat }}); return input } createNewArticle() { this.articleService.createNewArticle ( (
this.transformArticleData(this.form.value)) - ).then(async uuid => { + ).then(async (article: object) => { + const uuid = article['uuid']; this.result = { status: 'success', message: 'Article created successfully' }; await this.router.navigateByUrl(`/article/${uuid}`) }).catch(() => diff --git a/src/blogify/frontend/src/app/components/profile/profile.component.ts b/src/blogify/frontend/src/app/components/profile/profile.component.ts index 04942f97..e55f045b 100644 --- a/src/blogify/frontend/src/app/components/profile/profile.component.ts +++ b/src/blogify/frontend/src/app/components/profile/profile.component.ts @@ -38,8 +38,6 @@ export class ProfileComponent implements OnInit, OnDestroy { this.user = await this.authService.getByUsername(username); - console.log(this.user.username + ' GOTTEN'); - this.articleService.getArticleByForUser(username, ['title', 'createdBy', 'content', 'summary', 'uuid', 'categories', 'createdAt'] ).then(it => { diff --git a/src/blogify/frontend/src/app/components/profile/profile/cover-picture/cover-picture.component.html b/src/blogify/frontend/src/app/components/profile/profile/cover-picture/cover-picture.component.html new file mode 100644 index 00000000..f9b15991 --- /dev/null +++ b/src/blogify/frontend/src/app/components/profile/profile/cover-picture/cover-picture.component.html @@ -0,0 +1,4 @@ +Cover picture diff --git a/src/blogify/frontend/src/app/components/profile/profile/cover-picture/cover-picture.component.scss b/src/blogify/frontend/src/app/components/profile/profile/cover-picture/cover-picture.component.scss new file mode 100644 index 00000000..0ce93f14 --- /dev/null +++ b/src/blogify/frontend/src/app/components/profile/profile/cover-picture/cover-picture.component.scss @@ -0,0 +1,10 @@ +:host { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; +} + +.cvp-image { + width: 100%; +} diff --git a/src/blogify/frontend/src/app/components/profile/profile/cover-picture/cover-picture.component.spec.ts b/src/blogify/frontend/src/app/components/profile/profile/cover-picture/cover-picture.component.spec.ts new file mode 100644 index 00000000..49fc7185 --- /dev/null +++ b/src/blogify/frontend/src/app/components/profile/profile/cover-picture/cover-picture.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CoverPictureComponent } from './cover-picture.component'; + +describe('CoverPictureComponent', () => { + let component: CoverPictureComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ CoverPictureComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CoverPictureComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/blogify/frontend/src/app/components/profile/profile/cover-picture/cover-picture.component.ts b/src/blogify/frontend/src/app/components/profile/profile/cover-picture/cover-picture.component.ts new file mode 100644 index 00000000..4f1e6d95 --- /dev/null +++ b/src/blogify/frontend/src/app/components/profile/profile/cover-picture/cover-picture.component.ts @@ -0,0 +1,24 @@ +import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { StaticContentService } from '../../../../services/static/static-content.service'; +import { StaticFile } from "../../../../models/Static"; + +@Component({ + selector: 'app-cover-picture', + templateUrl: './cover-picture.component.html', + styleUrls: ['./cover-picture.component.scss'] +}) +export class CoverPictureComponent implements OnInit, OnChanges { + + @Input() cvpFile: StaticFile; + + sourceUrl: string | null = null; + + constructor(private staticContentService: StaticContentService) {} + + ngOnInit() {} + + ngOnChanges(changes: SimpleChanges): void { + this.sourceUrl = this.cvpFile.fileId ? this.staticContentService.urlFor(this.cvpFile) : null; + } + +} diff --git a/src/blogify/frontend/src/app/components/profile/profile/follows/follows.component.html b/src/blogify/frontend/src/app/components/profile/profile/follows/follows.component.html new file mode 100644 index 00000000..37bd7838 --- /dev/null +++ b/src/blogify/frontend/src/app/components/profile/profile/follows/follows.component.html @@ -0,0 +1,7 @@ + + diff --git a/src/blogify/frontend/src/app/components/profile/profile/follows/follows.component.scss b/src/blogify/frontend/src/app/components/profile/profile/follows/follows.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/blogify/frontend/src/app/components/profile/profile/follows/follows.component.spec.ts b/src/blogify/frontend/src/app/components/profile/profile/follows/follows.component.spec.ts new file mode 100644 index 00000000..a1ad705a --- /dev/null +++ b/src/blogify/frontend/src/app/components/profile/profile/follows/follows.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FollowsComponent } from './follows.component'; + +describe('FollowsComponent', () => { + let component: FollowsComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ FollowsComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(FollowsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/blogify/frontend/src/app/components/profile/profile/follows/follows.component.ts b/src/blogify/frontend/src/app/components/profile/profile/follows/follows.component.ts new file mode 100644 index 00000000..a9e1ed59 --- /dev/null +++ b/src/blogify/frontend/src/app/components/profile/profile/follows/follows.component.ts @@ -0,0 +1,30 @@ +import { Component, OnInit } from '@angular/core'; +import { User } from "../../../../models/User"; +import { ActivatedRoute, Params } from '@angular/router'; +import { UserService } from '../../../../shared/services/user-service/user.service'; +import { AuthService } from '../../../../shared/auth/auth.service'; + +@Component({ + selector: 'app-follows', + templateUrl: './follows.component.html', + styleUrls: ['./follows.component.scss'] +}) +export class FollowsComponent implements OnInit { + + followed: User; + following: User[]; + + constructor ( + private userService: UserService, + private route: ActivatedRoute, + private authService: AuthService + ) {} + + ngOnInit() { + this.route.parent.params.subscribe( async (params: Params) => { + this.followed = await this.authService.getByUsername(params['username']); + this.following = await this.authService.fillUsersFromUUIDs(this.followed.followers); + }); + } + +} diff --git a/src/blogify/frontend/src/app/components/profile/profile/main/main-profile.component.html b/src/blogify/frontend/src/app/components/profile/profile/main/main-profile.component.html index 878da6e0..7670a1a3 100644 --- a/src/blogify/frontend/src/app/components/profile/profile/main/main-profile.component.html +++ b/src/blogify/frontend/src/app/components/profile/profile/main/main-profile.component.html @@ -1,13 +1,32 @@ -
+ + -
- +
- {{user.name}} +
+ + - -
+ + + + + + + - + +
-
+ + +
+ diff --git a/src/blogify/frontend/src/app/components/profile/profile/main/main-profile.component.scss b/src/blogify/frontend/src/app/components/profile/profile/main/main-profile.component.scss index 7f44455d..f8850afb 100644 --- a/src/blogify/frontend/src/app/components/profile/profile/main/main-profile.component.scss +++ b/src/blogify/frontend/src/app/components/profile/profile/main/main-profile.component.scss @@ -7,17 +7,28 @@ margin-bottom: 1em; - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; + //height: 10rem; + display: flex; + flex-wrap: wrap; + gap: 1.15rem; - #header-user-name { - margin-left: .5em; + .separator { + margin-left: 1.75rem !important; + } + + #header-follow-button { + align-self: center; + + margin-left: 1.75rem; margin-right: auto; + } + + #flexible-space { + flex-grow: 10; + } - font-size: 2.75em; - font-weight: 600; + #header-tabs { + justify-self: end; } } 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 33e919da..1160a83d 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 @@ -3,6 +3,8 @@ import { Tab, TabList } from '../../../../shared/components/tab-header/tab-heade import { User } from '../../../../models/User'; import { ActivatedRoute, Params } from '@angular/router'; import { AuthService } from '../../../../shared/auth/auth.service'; +import { UserService } from '../../../../shared/services/user-service/user.service'; +import { HttpResponse } from '@angular/common/http'; @Component({ selector: 'app-main-profile', @@ -13,36 +15,84 @@ export class MainProfileComponent implements OnInit { user: User; - baseTabs: TabList = [ - new Tab('Overview', 'overview') + isLoggedIn = false; + isSelf = false; + alreadyFollowed = false; + baseTabs: TabList = [ + new Tab('Overview', 'overview'), + new Tab('Friends', 'friends'), ]; loggedInTabs: TabList = [ - new Tab('Settings', 'settings') + new Tab('Settings', 'settings'), ]; finalTabs: TabList = this.baseTabs; constructor ( private authService: AuthService, + private userService: UserService, private route: ActivatedRoute ) {} ngOnInit() { + // This listener updates the logged in value, is necessary for tabs. + this.authService.observeIsLoggedIn().subscribe(value => { + this.isLoggedIn = value; + this.updateTabs(); + }); + this.route.params.subscribe(async (params: Params) => { let username = params['username']; - this.authService.observeIsLoggedIn().subscribe(async value => { - const loggedInUsername = (await this.authService.userProfile).username; + this.authService.getByUsername(username).then(profile => { + + // Set the correct profile data + this.user = profile; + + this.authService.observeIsLoggedIn().subscribe(async value => { + this.authService.userProfile.then(u => { + this.alreadyFollowed + = this.user.followers.findIndex(it => it === u.uuid) !== -1; + }).catch(error => { + alert('[blogifyProfiles] Error while fetching logged in profile: ' + error); + }); + }); + }).catch(error => { + alert('[blogifyProfiles] Error while fetching profile: \': ' + error); + }); - if (username === loggedInUsername) { - this.finalTabs = this.baseTabs.concat(this.loggedInTabs); - } + // This second listener must always be called at least once after user variable is initialized, + // so it needs to be created here. We update the tabs as well. + this.authService.observeIsLoggedIn().subscribe(async value => { + this.isSelf = value && this.user.uuid === (await this.authService.userProfile).uuid; + this.updateTabs(); }); + }); + } - this.user = await this.authService.getByUsername(username); - }) + /** + * Make sure tabs are consistent for logged in or not, self or not + */ + private updateTabs() { + if (this.isLoggedIn && this.isSelf) { + this.finalTabs = this.baseTabs.concat(this.loggedInTabs); + } else { + this.finalTabs = this.baseTabs; + } + } + + /** + * Toggle the follow state and update UI accordingly + */ + toggleFollow() { + this.userService.toggleFollowUser(this.user, this.authService.userToken) + .then((r: HttpResponse) => { + if (r.status == 200) this.alreadyFollowed = !this.alreadyFollowed; + }).catch(e => { + console.error(`[blogifyUsers] Couldn't like ${this.user.uuid}` ) + }); } } diff --git a/src/blogify/frontend/src/app/components/profile/profile/overview/overview.component.html b/src/blogify/frontend/src/app/components/profile/profile/overview/overview.component.html index 8a8d601a..fb03694d 100644 --- a/src/blogify/frontend/src/app/components/profile/profile/overview/overview.component.html +++ b/src/blogify/frontend/src/app/components/profile/profile/overview/overview.component.html @@ -1,4 +1,4 @@ - - + + diff --git a/src/blogify/frontend/src/app/components/profile/profile/overview/overview.component.ts b/src/blogify/frontend/src/app/components/profile/profile/overview/overview.component.ts index 61b5a755..58cdfed3 100644 --- a/src/blogify/frontend/src/app/components/profile/profile/overview/overview.component.ts +++ b/src/blogify/frontend/src/app/components/profile/profile/overview/overview.component.ts @@ -1,7 +1,9 @@ import { Component, OnInit } from '@angular/core'; import { Article } from "../../../../models/Article"; import { ArticleService } from "../../../../services/article/article.service"; +import { AuthService } from '../../../../shared/auth/auth.service'; import { ActivatedRoute, Params } from '@angular/router'; +import { User } from '../../../../models/User'; @Component({ selector: 'app-overview', @@ -10,10 +12,12 @@ import { ActivatedRoute, Params } from '@angular/router'; }) export class OverviewComponent implements OnInit { - articles: Article[] = []; + articles: Article[]; + forUser: User; constructor ( private articleService: ArticleService, + private authService: AuthService, private route: ActivatedRoute ) {} @@ -23,10 +27,14 @@ export class OverviewComponent implements OnInit { this.articleService.getArticleByForUser ( username, - ['title', 'createdBy', 'content', 'summary', 'uuid', 'categories', 'createdAt'] + ['title', 'summary', 'createdBy', 'categories', 'createdAt', 'likeCount', 'commentCount'] ).then(articles => { - this.articles = articles - }) + this.articles = articles; + }); + + this.authService.getByUsername(username).then(user => {{ + this.forUser = user; + }}); }) } diff --git a/src/blogify/frontend/src/app/components/profile/profile/profile-routing.module.ts b/src/blogify/frontend/src/app/components/profile/profile/profile-routing.module.ts index 3c3f5304..fca65d38 100644 --- a/src/blogify/frontend/src/app/components/profile/profile/profile-routing.module.ts +++ b/src/blogify/frontend/src/app/components/profile/profile/profile-routing.module.ts @@ -3,14 +3,16 @@ import { Routes, RouterModule } from '@angular/router'; import { MainProfileComponent } from './main/main-profile.component'; import { OverviewComponent } from './overview/overview.component'; import { SettingsComponent } from './settings/settings.component'; +import {FollowsComponent} from "./follows/follows.component"; const routes: Routes = [ { - path: 'profile/:username', component: MainProfileComponent, + path: 'profile/:username', component: MainProfileComponent, children: [ { path: '', redirectTo: 'overview', pathMatch: 'full' }, { path: 'overview', component: OverviewComponent, }, { path: 'settings', component: SettingsComponent, }, + { path: 'friends', component: FollowsComponent, }, ] } ]; diff --git a/src/blogify/frontend/src/app/components/profile/profile/profile.module.ts b/src/blogify/frontend/src/app/components/profile/profile/profile.module.ts index 81770075..0184a13a 100644 --- a/src/blogify/frontend/src/app/components/profile/profile/profile.module.ts +++ b/src/blogify/frontend/src/app/components/profile/profile/profile.module.ts @@ -5,13 +5,14 @@ import { SettingsComponent } from './settings/settings.component'; import { MainProfileComponent } from './main/main-profile.component'; import { OverviewComponent } from './overview/overview.component'; import { SharedModule } from '../../../shared/shared.module'; -import {AppModule} from '../../../app.module'; +import { CoverPictureComponent } from './cover-picture/cover-picture.component'; @NgModule({ declarations: [ SettingsComponent, MainProfileComponent, - OverviewComponent + OverviewComponent, + CoverPictureComponent ], exports: [ MainProfileComponent 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 1040bc81..8a397728 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 @@ -1,5 +1,7 @@

Settings

- - + + + + diff --git a/src/blogify/frontend/src/app/components/profile/profile/settings/settings.component.ts b/src/blogify/frontend/src/app/components/profile/profile/settings/settings.component.ts index 5d29607b..3677715c 100644 --- a/src/blogify/frontend/src/app/components/profile/profile/settings/settings.component.ts +++ b/src/blogify/frontend/src/app/components/profile/profile/settings/settings.component.ts @@ -1,6 +1,5 @@ import { Component, OnInit } from '@angular/core'; -import {ActivatedRoute} from '@angular/router'; -import {AuthService} from "../../../../shared/auth/auth.service"; +import { AuthService } from "../../../../shared/auth/auth.service"; @Component({ selector: 'app-settings', @@ -9,7 +8,8 @@ import {AuthService} from "../../../../shared/auth/auth.service"; }) export class SettingsComponent implements OnInit { - file: File = null; + pfpFile: File = null; + coverFile: File = null; constructor ( private authService: AuthService, @@ -17,12 +17,20 @@ export class SettingsComponent implements OnInit { ngOnInit() {} - async fileChange(event) { - this.file = event.target.files[0]; + async pfpFileChange(event) { + this.pfpFile = event.target.files[0]; + } + + async coverFileChange(event) { + this.coverFile = event.target.files[0]; } async setProfilePicture() { - await this.authService.addProfilePicture(this.file, (await this.authService.userUUID)) + await this.authService.uploadFile(this.pfpFile, 'profilePicture') + } + + async setCoverPicture() { + await this.authService.uploadFile(this.coverFile, 'coverPicture') } } diff --git a/src/blogify/frontend/src/app/components/show-article/show-article.component.html b/src/blogify/frontend/src/app/components/show-article/show-article.component.html index e8b7b494..85bbfbb7 100644 --- a/src/blogify/frontend/src/app/components/show-article/show-article.component.html +++ b/src/blogify/frontend/src/app/components/show-article/show-article.component.html @@ -22,31 +22,34 @@

{{article.title}}

+ + {{article.createdAt | relativeTime}} + + + + + + + - + + + - + + + - +
diff --git a/src/blogify/frontend/src/app/components/show-article/show-article.component.scss b/src/blogify/frontend/src/app/components/show-article/show-article.component.scss index 5ea26c08..afe8d3ae 100644 --- a/src/blogify/frontend/src/app/components/show-article/show-article.component.scss +++ b/src/blogify/frontend/src/app/components/show-article/show-article.component.scss @@ -1,4 +1,5 @@ @import "../../../styles/mixins"; +@import "../../../styles/layouts"; .container-article { @include pageContainer(true, 80%); @@ -29,9 +30,9 @@ &:not(.article-no-tags) > * { margin: 0 .25em; - padding: .15em .65em; + padding: .25em .75em; - border-radius: .3em; + border-radius: $std-border-radius; background-color: var(--header-bg); &:first-child { margin-left: 0; } @@ -54,26 +55,42 @@ justify-content: flex-start; align-items: center; + * { vertical-align: middle; } + .user { margin-left: .5em; font-size: 1.85em; - font-weight: bold; + } + + .date { + margin-left: 1rem; + font-size: 1.5em; + margin-top: 4px; + } + + .separator { + height: 20px; + margin: auto 0 auto 1rem !important; + } + + #button-share { + margin-left: 1rem; } #button-update { margin-left: auto; + cursor: pointer; } #button-delete { - margin-left: 1em; + margin-left: 1.75rem; + cursor: pointer; } } } #article-content { - width: 100%; - font-size: 1.15em; text-align: justify; text-justify: inter-word; } 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 35415fa3..90c03d09 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,13 @@ import { Component, OnInit } from '@angular/core'; -import {ActivatedRoute, Router} 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 { User } from '../../models/User'; +import { faPenFancy, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { faCopy } from '@fortawesome/free-regular-svg-icons'; +import { ClipboardService } from "ngx-clipboard"; @Component({ selector: 'app-show-article', @@ -20,33 +23,39 @@ export class ShowArticleComponent implements OnInit { private activatedRoute: ActivatedRoute, private articleService: ArticleService, public authService: AuthService, - private router: Router + private router: Router, + private clipboardService: ClipboardService, ) {} + faEdit = faPenFancy; + faTimes = faTimes; + faCopy = faCopy; + showUpdateButton = false; showDeleteButton = false; ngOnInit() { this.routeMapSubscription = this.activatedRoute.paramMap.subscribe(async (map) => { const articleUUID = map.get('uuid'); - console.log(articleUUID); this.article = await this.articleService.getArticleByUUID ( articleUUID, ['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; - - console.log(this.article); + this.authService.observeIsLoggedIn().subscribe(async it => { + this.showUpdateButton = it && (await this.authService.userUUID) == ( this.article.createdBy).uuid; + this.showDeleteButton = it && (await this.authService.userUUID) == ( this.article.createdBy).uuid; + }); }); } - deleteArticle() { this.articleService.deleteArticle(this.article.uuid).then(it => console.log(it)); this.router.navigateByUrl("/home").then(() => {}) } + copyUrlToClipboard() { + this.clipboardService.copyFromContent(window.location.href) + } } 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 747afdca..e0c02872 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 @@ -1,10 +1,10 @@ import { Component, OnInit } from '@angular/core'; -import {ActivatedRoute, Router} from '@angular/router'; +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"; +import { User } from "../../models/User"; +import { AuthService } from "../../shared/auth/auth.service"; @Component({ selector: 'app-update-article', @@ -27,14 +27,11 @@ export class UpdateArticleComponent implements OnInit { ngOnInit() { this.routeMapSubscription = this.activatedRoute.paramMap.subscribe(async (map) => { const articleUUID = map.get('uuid'); - console.log(articleUUID); this.article = await this.articleService.getArticleByUUID( articleUUID, ['title', 'createdBy', 'content', 'summary', 'uuid', 'categories', 'createdAt'] ); - - console.log(this.article); }); this.authService.userProfile.then(it => { this.user = it }) } diff --git a/src/blogify/frontend/src/app/components/users/users.component.html b/src/blogify/frontend/src/app/components/users/users.component.html new file mode 100644 index 00000000..09065f02 --- /dev/null +++ b/src/blogify/frontend/src/app/components/users/users.component.html @@ -0,0 +1 @@ + diff --git a/src/blogify/frontend/src/app/components/users/users.component.scss b/src/blogify/frontend/src/app/components/users/users.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/blogify/frontend/src/app/components/users/users.component.spec.ts b/src/blogify/frontend/src/app/components/users/users.component.spec.ts new file mode 100644 index 00000000..909b5baf --- /dev/null +++ b/src/blogify/frontend/src/app/components/users/users.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UsersComponent } from './users.component'; + +describe('UsersComponent', () => { + let component: UsersComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ UsersComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UsersComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/blogify/frontend/src/app/components/users/users.component.ts b/src/blogify/frontend/src/app/components/users/users.component.ts new file mode 100644 index 00000000..ffbb8cb2 --- /dev/null +++ b/src/blogify/frontend/src/app/components/users/users.component.ts @@ -0,0 +1,26 @@ +import { Component, OnInit } from '@angular/core'; +import { User } from '../../models/User'; +import { AuthService } from '../../shared/auth/auth.service'; + +@Component({ + selector: 'app-users', + templateUrl: './users.component.html', + styleUrls: ['./users.component.scss'] +}) +export class UsersComponent implements OnInit { + + title = 'Users'; + + users: User[]; + + constructor(private authService: AuthService) {} + + ngOnInit() { + this.authService + .getAllUsers() + .then( users => { + this.users = users; + }); + } + +} diff --git a/src/blogify/frontend/src/app/models/Article.ts b/src/blogify/frontend/src/app/models/Article.ts index 1c7d687f..ef125a42 100644 --- a/src/blogify/frontend/src/app/models/Article.ts +++ b/src/blogify/frontend/src/app/models/Article.ts @@ -10,7 +10,9 @@ export class Article { public createdBy: User | string, public createdAt: number, public categories: Category[], - public numberOfComments: number = 0, + public likedByUser: boolean | null = null, + public likeCount: number = 0, + public commentCount: number = 0, ) {} } diff --git a/src/blogify/frontend/src/app/models/SearchView.ts b/src/blogify/frontend/src/app/models/SearchView.ts new file mode 100644 index 00000000..53a181b1 --- /dev/null +++ b/src/blogify/frontend/src/app/models/SearchView.ts @@ -0,0 +1,18 @@ +class SearchView { + + constructor ( + public found: number, + public hits: Hit[], + public page: number, + public search_time_ms: number + ) {} + +} + +class Hit { + + constructor ( + public document: T + ) {} + +} diff --git a/src/blogify/frontend/src/app/models/User.ts b/src/blogify/frontend/src/app/models/User.ts index 21b56e3e..1d9e11b0 100644 --- a/src/blogify/frontend/src/app/models/User.ts +++ b/src/blogify/frontend/src/app/models/User.ts @@ -1,4 +1,4 @@ -import {StaticFile} from './Static'; +import { StaticFile } from './Static'; export class User { constructor ( @@ -6,7 +6,9 @@ export class User { public username: string, public name: string, public email: string, - public profilePicture: StaticFile + public followers: string[], + public profilePicture: StaticFile, + public coverPicture: StaticFile ) {} } 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 40aa2518..b8eb180d 100644 --- a/src/blogify/frontend/src/app/services/article/article.service.ts +++ b/src/blogify/frontend/src/app/services/article/article.service.ts @@ -1,8 +1,9 @@ import { Injectable } from '@angular/core'; -import { HttpClient, HttpHeaders} from '@angular/common/http'; +import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http'; import { Article } from '../../models/Article'; import { AuthService } from '../../shared/auth/auth.service'; import * as uuid from 'uuid/v4'; +import { User } from '../../models/User'; @Injectable({ providedIn: 'root' @@ -25,15 +26,31 @@ export class ArticleService { }); } - private async fetchCommentCount(articles: Article[]): Promise { + private async fetchLikeStatus(articles: Article[], userToken: string): Promise { return Promise.all(articles.map(async a => { - a.numberOfComments = await this.httpClient.get(`/api/articles/${a.uuid}/commentCount`).toPromise(); + + const httpOptions = { + headers: new HttpHeaders({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${userToken}` + }), + }; + + // @ts-ignore + this.httpClient.get(`/api/articles/${a.uuid}/like`, httpOptions).toPromise() + .then((res: boolean) => { + a.likedByUser = res; + }).catch(_ => { + a.likedByUser = null; + }); return a - })); + })) } private async prepareArticleData(articles: Article[]): Promise { - return this.fetchUserObjects(articles).then(articles2 => this.fetchCommentCount(articles2)) + return this + .fetchUserObjects(articles) + .then(a => this.authService.userToken ? this.fetchLikeStatus(a, this.authService.userToken) : a) } async getAllArticles(fields: string[] = [], amount: number = 25): Promise { @@ -53,7 +70,7 @@ 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.fetchUserObjects(articles); + return this.prepareArticleData(articles); } async createNewArticle(article: Article, userToken: string = this.authService.userToken): Promise { @@ -79,6 +96,21 @@ export class ArticleService { return this.httpClient.post(`/api/articles/`, newArticle, httpOptions).toPromise(); } + async likeArticle(article: Article, userToken: string): Promise> { + + const httpOptions = { + headers: new HttpHeaders({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${userToken}` + }), + observe: 'response', + }; + + // TypeScript bug with method overloads. + // @ts-ignore + return this.httpClient.post>(`/api/articles/${article.uuid}/like`, null, httpOptions).toPromise() + } + updateArticle(article: Article, uuid: string = article.uuid, userToken: string = this.authService.userToken) { const httpOptions = { headers: new HttpHeaders({ @@ -110,13 +142,14 @@ 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) + search(query: string, fields: string[], byUser: User | null) { + const byUserString = (byUser ? `&byUser=${byUser.uuid}` : ""); + const url = `/api/articles/search/?q=${query}&fields=${fields.join(',')}${byUserString}`; + return this.httpClient.get>(url) .toPromise() - .then(hits => { + .then((hits) => { if (hits != null) { - return this.prepareArticleData(hits); + return this.prepareArticleData(hits.hits.map(hit => hit.document)); } else { return Promise.all([]); } diff --git a/src/blogify/frontend/src/app/services/comments/comments.service.ts b/src/blogify/frontend/src/app/services/comments/comments.service.ts index 9eb03fce..145f45d2 100644 --- a/src/blogify/frontend/src/app/services/comments/comments.service.ts +++ b/src/blogify/frontend/src/app/services/comments/comments.service.ts @@ -4,6 +4,7 @@ import { Comment } from '../../models/Comment'; import { AuthService } from '../../shared/auth/auth.service'; import * as uuid from 'uuid/v4'; import { Article } from '../../models/Article'; +import { BehaviorSubject } from 'rxjs'; const commentsEndpoint = '/api/articles/comments'; @@ -11,11 +12,12 @@ const commentsEndpoint = '/api/articles/comments'; providedIn: 'root' }) export class CommentsService { + private newRootComment = new BehaviorSubject(undefined); constructor(private httpClient: HttpClient, private authService: AuthService) {} async getCommentsForArticle(article: Article): Promise { - const comments = await this.httpClient.get(`${commentsEndpoint}/${article.uuid}`).toPromise(); + const comments = await this.httpClient.get(`${commentsEndpoint}/article/${article.uuid}`).toPromise(); const userUUIDs = new Set(); comments.forEach(it => { userUUIDs.add(it.commenter.toString()); @@ -28,6 +30,13 @@ export class CommentsService { return comments; } + // tslint:disable-next-line + async getComment(uuid: string): Promise { + const comment = await this.httpClient.get(`${commentsEndpoint}/${uuid}`).toPromise(); + comment.commenter = await this.authService.fetchUser(comment.commenter.toString()); + return comment; + } + async deleteComment(commentUUID: string, userToken: string = this.authService.userToken): Promise { const httpOptions = { headers: new HttpHeaders({ @@ -44,7 +53,7 @@ export class CommentsService { articleUUID: string, userUUID: string, userToken: string = this.authService.userToken - ) { + ): Promise { const httpOptions = { headers: new HttpHeaders({ 'Content-Type': 'application/json', @@ -59,12 +68,14 @@ export class CommentsService { content: commentContent }; - const res = await this.httpClient.post(`${commentsEndpoint}`, comment, httpOptions).toPromise(); - if (res == null) { - return comment; - } else { - return undefined; - } + const res = await this.httpClient.post(`${commentsEndpoint}`, comment, httpOptions).toPromise(); + console.log('---------Comment------'); + console.log(res); + + this.newRootComment.next(res); + console.log('next'); + + return res; } async replyToComment( @@ -100,4 +111,8 @@ export class CommentsService { async getChildrenOf(commentUUID: string, depth: number): Promise { return this.httpClient.get(`/api/articles/comments/tree/${commentUUID}/?depth=${depth}`).toPromise(); } + + get latestRootSubmittedComment() { + return this.newRootComment.asObservable(); + } } diff --git a/src/blogify/frontend/src/app/services/static/static-content.service.ts b/src/blogify/frontend/src/app/services/static/static-content.service.ts index 42f1315d..9358296b 100644 --- a/src/blogify/frontend/src/app/services/static/static-content.service.ts +++ b/src/blogify/frontend/src/app/services/static/static-content.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { StaticFile } from '../../models/Static'; -import {HttpClient, HttpHeaders} from "@angular/common/http"; +import { HttpClient, HttpHeaders } from "@angular/common/http"; @Injectable({ providedIn: 'root' @@ -14,8 +14,6 @@ export class StaticContentService { } uploadFile(file: File, userToken: string, url: string) { - console.log(url); - console.log(file.name); const httpOptions = { headers: new HttpHeaders({ 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 1ef58b94..735c3931 100644 --- a/src/blogify/frontend/src/app/shared/auth/auth.service.ts +++ b/src/blogify/frontend/src/app/shared/auth/auth.service.ts @@ -1,4 +1,5 @@ -import {Injectable} from '@angular/core'; +/* tslint:disable:variable-name no-console */ +import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { LoginCredentials, RegisterCredentials, User } from 'src/app/models/User'; import { BehaviorSubject, Observable } from 'rxjs'; @@ -10,17 +11,43 @@ import { StaticContentService } from '../../services/static/static-content.servi }) export class AuthService { - private readonly dummyUser: User = new User('', '', '', '', new StaticFile('-1')); + constructor ( + private httpClient: HttpClient, + private staticContentService: StaticContentService, + ) { + this.attemptRestoreLogin(); + } + + // noinspection JSMethodCanBeStatic + get userToken(): string | null { + return localStorage.getItem('userToken'); + } + + get userUUID(): Promise { + if (this.currentUserUuid_.getValue()) { + return Promise.resolve(this.currentUserUuid_.getValue()); + } else { + const uuid = this.getUserUUIDFromToken(this.userToken); + uuid.then(it => { + console.log(it); + this.currentUserUuid_.next(it); + }); + return uuid; + } + } + + get userProfile(): Promise { + return this.loginObservable_.value ? this.getUser() : null; + } + + private readonly dummyUser: User = new User('', '', '', '', [], new StaticFile('-1'), new StaticFile('-1')); private currentUserUuid_ = new BehaviorSubject(''); private currentUser_ = new BehaviorSubject(this.dummyUser); private loginObservable_ = new BehaviorSubject(false); - constructor ( - private httpClient: HttpClient, - private staticContentService: StaticContentService, - ) { - this.attemptRestoreLogin() + private static attemptFindLocalToken(): string | null { + return localStorage.getItem('userToken'); } private attemptRestoreLogin() { @@ -30,7 +57,7 @@ export class AuthService { } else { this.login(token).then ( () => { - console.info('[blogifyAuth] Logged in with stored token') + 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'); @@ -38,10 +65,6 @@ export class AuthService { } } - private static attemptFindLocalToken(): string | null { - return localStorage.getItem('userToken'); - } - async login(creds: LoginCredentials | string): Promise { let token: Observable; @@ -53,24 +76,34 @@ export class AuthService { localStorage.setItem('userToken', it.token); } else { // token - it = { token: creds } + it = { token: creds }; } const uuid = await this.getUserUUIDFromToken(it.token); // Fix JS bullshit const fetchedUserObj: User = await this.fetchUser(uuid); - const fetchedUser = new User(fetchedUserObj.uuid, fetchedUserObj.username, fetchedUserObj.name, fetchedUserObj.email, fetchedUserObj.profilePicture); + const fetchedUser = new User ( + fetchedUserObj.uuid, + fetchedUserObj.username, + fetchedUserObj.name, + fetchedUserObj.email, + fetchedUserObj.followers, + fetchedUserObj.profilePicture, + fetchedUserObj.coverPicture + ); this.currentUser_.next(fetchedUser); this.currentUserUuid_.next(fetchedUser.uuid); this.loginObservable_.next(true); - return it + return it; } logout() { - localStorage.removeItem("userToken"); + localStorage.removeItem('userToken'); + this.currentUser_.next(this.dummyUser); + this.currentUserUuid_.next(''); this.loginObservable_.next(false); } @@ -89,58 +122,48 @@ export class AuthService { } async fetchUser(uuid: string): Promise { - return this.httpClient.get(`/api/users/${uuid}`).toPromise() + return this.httpClient.get(`/api/users/${uuid}`).toPromise(); } - // noinspection JSMethodCanBeStatic - get userToken(): string | null { - return localStorage.getItem('userToken'); - } - - get userUUID(): Promise { - if (this.currentUserUuid_.getValue()) - return Promise.resolve(this.currentUserUuid_.getValue()); - else { - const uuid = this.getUserUUIDFromToken(this.userToken); - uuid.then(it => { - console.log(it); - this.currentUserUuid_.next(it); - }); - return uuid; + private async getUser(): Promise { + if (this.currentUser_.getValue().uuid !== '') { + return this.currentUser_.getValue(); + } else { + return this.fetchUser(await this.userUUID); } } - get userProfile(): Promise { - return this.getUser() + async getByUsername(username: string): Promise { + return this.httpClient.get(`/api/users/byUsername/${username}`).toPromise(); } - private async getUser(): Promise { - if (this.currentUser_.getValue().uuid != '') { - return this.currentUser_.getValue() - } else { - return this.fetchUser(await this.userUUID) - } + async uploadFile(file: File, uploadableName: string) { + return this.staticContentService.uploadFile( + file, + this.userToken, + `/api/users/upload/${await this.userUUID}/?target=${uploadableName}` + ); } - getByUsername(username: string): Promise { - return this.httpClient.get(`/api/users/byUsername/${username}`).toPromise() + search(query: string, fields: string[]) { + const url = `/api/users/search/?q=${query}&fields=${fields.join(',')}`; + return this.httpClient.get>(url).toPromise(); } - addProfilePicture(file: File, userUUID: string, userToken: string = this.userToken) { - return this.staticContentService.uploadFile(file, userToken, `/api/users/profilePicture/${userUUID}/?target=profilePicture`) + async getAllUsers(): Promise { + return this.httpClient.get('/api/users').toPromise(); } - search(query: string, fields: string[]) { - const url = `/api/articles/search/?q=${query}&fields=${fields.join(',')}`; - return this.httpClient.get(url).toPromise() + async fillUsersFromUUIDs(uuids: string[]): Promise { + return Promise.all(uuids.map(it => this.fetchUser(it))) } } interface UserToken { - token: string + token: string; } interface UserUUID { - uuid: string + uuid: string; } diff --git a/src/blogify/frontend/src/app/shared/components/profile-picture/profile-picture.component.html b/src/blogify/frontend/src/app/shared/components/profile-picture/profile-picture.component.html index b646e062..dbd04897 100644 --- a/src/blogify/frontend/src/app/shared/components/profile-picture/profile-picture.component.html +++ b/src/blogify/frontend/src/app/shared/components/profile-picture/profile-picture.component.html @@ -1,7 +1,15 @@ -Profile picture +Profile picture + + + diff --git a/src/blogify/frontend/src/app/shared/components/profile-picture/profile-picture.component.ts b/src/blogify/frontend/src/app/shared/components/profile-picture/profile-picture.component.ts index e09d3897..b5b6abff 100644 --- a/src/blogify/frontend/src/app/shared/components/profile-picture/profile-picture.component.ts +++ b/src/blogify/frontend/src/app/shared/components/profile-picture/profile-picture.component.ts @@ -1,28 +1,29 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; import { StaticContentService } from '../../../services/static/static-content.service'; import { StaticFile } from "../../../models/Static"; +import { faUser } from '@fortawesome/free-regular-svg-icons'; @Component({ selector: 'app-profile-picture', templateUrl: './profile-picture.component.html', styleUrls: ['./profile-picture.component.scss'] }) -export class ProfilePictureComponent implements OnInit { +export class ProfilePictureComponent implements OnInit, OnChanges { @Input() pfpFile: StaticFile; @Input() emSize: number = 3; + @Input() displayedVertically: boolean = false; - sourceUrl: string; - erroredOut = false; + sourceUrl: string | null = null; + + faUser = faUser; constructor(private staticContentService: StaticContentService) {} - ngOnInit() { - this.sourceUrl = this.staticContentService.urlFor(this.pfpFile); - } + ngOnInit() {} - handleError() { - this.erroredOut = true; + ngOnChanges(changes: SimpleChanges): void { + this.sourceUrl = this.pfpFile.fileId ? this.staticContentService.urlFor(this.pfpFile) : null; } } 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 c361482f..1fd848ba 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,4 +1,4 @@ -
+
@@ -15,7 +15,8 @@

{{showingSearchResults ? ' *ngIf="!showingMobileSearchBar" id="header-search-pad" type="text" placeholder="Search / Filter ... Type '?' for help" - (keydown.enter)="navigateToSearch()"> + (keydown.enter)="navigateToSearch()" + (keydown.escape)="stopSearch()"> @@ -44,6 +45,13 @@

{{showingSearchResults ? '

+
+
+ + {{noContentMessage}} +
+
+
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 0a999bf6..ffba5445 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 @@ -52,6 +52,20 @@ } + #content-empty { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + + margin-top: 5em; + + #content-empty-text { + font-size: 1.65em; + font-weight: 600; + } + } + #search-results { display: flex; flex-direction: column; 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 53db9fd5..53f0898f 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 @@ -5,6 +5,7 @@ import { ActivatedRoute, Router, UrlSegment } from '@angular/router'; import { StaticContentService } from '../../../services/static/static-content.service'; import { faArrowLeft, faPencilAlt, faSearch, faTimes} from '@fortawesome/free-solid-svg-icons'; import { ArticleService } from '../../../services/article/article.service'; +import { User } from '../../../models/User'; @Component({ selector: 'app-show-all-articles', @@ -21,6 +22,9 @@ export class ShowAllArticlesComponent implements OnInit { @Input() title = 'Articles'; @Input() articles: Article[]; + @Input() noContentMessage = 'Nothing to see here !'; + @Input() noResultsMessage = 'No search results :('; + @Input() forUser: User | null; @Input() allowCreate = true; forceNoAllowCreate = false; @@ -43,7 +47,7 @@ export class ShowAllArticlesComponent implements OnInit { 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; + const actualQuery = query.match(/^"[^"']+"$/) != null ? query.substring(1, query.length - 1): null; if (actualQuery != null) { this.searchQuery = actualQuery; this.startSearch(); @@ -58,16 +62,21 @@ export class ShowAllArticlesComponent implements OnInit { await this.router.navigate([{ search: `"${this.searchQuery}"` }], { relativeTo: this.activatedRoute }) } + async navigateToNoSearch() { + await this.router.navigateByUrl(this.router.url.replace(/search/, '')) // Hacky, but works ! + } + private async startSearch() { this.articleService.search ( this.searchQuery, - ['title', 'summary', 'createdBy', 'categories', 'createdAt'] + ['title', 'summary', 'createdBy', 'categories', 'createdAt'], + this.forUser ).then(it => { this.searchResults = it; this.showingSearchResults = true; this.forceNoAllowCreate = true; }).catch((err: Error) => { - console.error(`[blogifySearch] Error while search: ${err.name}: ${err.message}`) + console.error(`[blogifySearch] Error during search: ${err.name}: ${err.message}`) }); } @@ -75,7 +84,8 @@ export class ShowAllArticlesComponent implements OnInit { this.showingSearchResults = false; this.forceNoAllowCreate = false; this.searchQuery = undefined; - this.showingMobileSearchBar = false + this.showingMobileSearchBar = false; + this.navigateToNoSearch(); } async navigateToNewArticle() { 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 index 91ba4f19..d4243c48 100644 --- 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 @@ -1,6 +1,6 @@
-
+

{{article.title}}

@@ -8,7 +8,7 @@

{{article.title}}

-
+
{{article.summary}} @@ -16,13 +16,37 @@

{{article.title}}

{{article.createdAt | relativeTime}}
-

- Read More -

-
+ + + +  {{this.article.likedByUser ? article.likeCount + 1 : this.article.likeCount}} + + + + + +  {{article.likeCount}} + + + + -

{{article.numberOfComments}}

+ + +  {{article.commentCount}} + + + + + + + + Share +
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 index 2c3df211..78cac9bb 100644 --- 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 @@ -1,4 +1,5 @@ @import "../../../../../styles/layouts"; +@import "../../../../../styles/queries"; .article { @@ -10,6 +11,10 @@ color: var(--card-fg); background: var(--card-bg); + &:hover { + background: var(--header-bg); + } + border: none; border-radius: $std-border-radius; @@ -69,43 +74,64 @@ .article-last-line { width: 100%; + margin-top: 1rem; + 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; + $element-separation: 1.5rem; + .separator { + margin-left: $element-separation !important; + } + a { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; - fa-icon:first-child { margin-right: .75em; } + fa-icon:first-child { margin-right: .7rem; } - align-self: flex-end; - } + span { font-size: 1.4rem; } + font-weight: 600; - .article-no-tags, - .article-tags { - display: flex; - align-self: flex-end; + @media (max-width: $query-desktop) { + & .button-text { + display: none; + } + } - margin-top: 1.2em; + &:not(:first-child) { margin-left: $element-separation; } + } - & > * { - font-size: 1.35em; - margin: 0 .25em; - padding: .15em .65em; + .article-comments-count { - 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 } + .article-share { + margin-right: auto; } + + .article-no-tags, + .article-tags { + display: flex; + + & > * { + font-size: 1.35em; + margin: 0 .25em; + padding: .25em .75em; + + border-radius: $std-border-radius; + + &: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.ts b/src/blogify/frontend/src/app/shared/components/show-all-articles/single-article-box/single-article-box.component.ts index 96b917ab..6ced739d 100644 --- 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 @@ -1,7 +1,10 @@ import { Component, Input, OnInit } from '@angular/core'; import { Article } from "../../../../models/Article"; +import { faHeart, faCommentAlt, faCopy } from '@fortawesome/free-regular-svg-icons'; +import { faHeart as faHeartFilled } from '@fortawesome/free-solid-svg-icons'; +import { ClipboardService } from "ngx-clipboard"; +import { AuthService } from '../../../auth/auth.service'; import { ArticleService } from '../../../../services/article/article.service'; -import { faCommentAlt } from '@fortawesome/free-solid-svg-icons'; @Component({ selector: 'app-single-article-box', @@ -11,10 +14,35 @@ import { faCommentAlt } from '@fortawesome/free-solid-svg-icons'; export class SingleArticleBoxComponent implements OnInit { @Input() article: Article; + + faHeartOutline = faHeart; + faHeartFilled = faHeartFilled; + faCommentAlt = faCommentAlt; + faCopy = faCopy; + + constructor ( + private authService: AuthService, + private articleService: ArticleService, + private clipboardService: ClipboardService + ) {} - constructor() {} + loggedInObs = this.authService.observeIsLoggedIn(); ngOnInit() {} + toggleLike() { + this.articleService + .likeArticle(this.article, this.authService.userToken) + .then(_ => { + this.article.likedByUser = !this.article.likedByUser; + }).catch(error => { + console.error(`[blogifyArticles] Couldn't like ${this.article.uuid}` ) + }) + } + + copyLinkToClipboard() { + this.clipboardService.copyFromContent(window.location.href) + } + } diff --git a/src/blogify/frontend/src/app/shared/components/show-all-users/show-all-users.component.html b/src/blogify/frontend/src/app/shared/components/show-all-users/show-all-users.component.html new file mode 100644 index 00000000..ac514779 --- /dev/null +++ b/src/blogify/frontend/src/app/shared/components/show-all-users/show-all-users.component.html @@ -0,0 +1,57 @@ +
+ +
+ + + + + + +

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

+ + + + + + + + + + + + + + +
+ +
+ +
+ +
+
+ + {{noContentMessage}} +
+
+ +
+
+ + {{noResultsMessage}} +
+ +
+ +
diff --git a/src/blogify/frontend/src/app/shared/components/show-all-users/show-all-users.component.scss b/src/blogify/frontend/src/app/shared/components/show-all-users/show-all-users.component.scss new file mode 100644 index 00000000..fc97787f --- /dev/null +++ b/src/blogify/frontend/src/app/shared/components/show-all-users/show-all-users.component.scss @@ -0,0 +1,96 @@ +@import "../../../../styles/fonts"; +@import "../../../../styles/colours"; +@import "../../../../styles/layouts"; +@import "../../../../styles/mixins"; + +:host { + &.no-padding { .users { @include pageContainer(false) } } + &:not(.no-padding) { .users { @include pageContainer(true) } } +} + +.users { + + #users-header { + + @include pageContainerHeader; + + $search-icon-break: 1300px; + + #header-search-back { + margin-right: 1.15em; + } + + #header-title { + margin-right: 1em; + } + + #header-search-pad, #header-mobile-search-pad { + flex-grow: 1; + &#header-search-pad { + @media (min-width: 0) and (max-width: $search-icon-break) { + display: none; + } + } + } + + #header-search-icon { + margin-left: auto; + + @media (min-width: $search-icon-break) { + display: none; + } + } + + #header-create-btn { + cursor: pointer; + + margin-left: 2.7em; + } + } + + #users-main { + display: grid; + grid-template: auto / repeat(3, 1fr); + grid-column-gap: 1.25em; + + @media (max-width: $query-desktop) { grid-template-columns: 1fr } + } + + #content-empty { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + + margin-top: 5em; + + #content-empty-text { + font-size: 1.65em; + font-weight: 600; + } + } + + #search-results { + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + + > * { width: 100%; } + + #results-empty { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + + margin-top: 5em; + + #empty-text { + font-size: 1.65em; + font-weight: 600; + } + } + } + +} diff --git a/src/blogify/frontend/src/app/shared/components/show-all-users/show-all-users.component.spec.ts b/src/blogify/frontend/src/app/shared/components/show-all-users/show-all-users.component.spec.ts new file mode 100644 index 00000000..89aa98e2 --- /dev/null +++ b/src/blogify/frontend/src/app/shared/components/show-all-users/show-all-users.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ShowAllUsersComponent } from './show-all-users.component'; + +describe('ShowAllUsersComponent', () => { + let component: ShowAllUsersComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ ShowAllUsersComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ShowAllUsersComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/blogify/frontend/src/app/shared/components/show-all-users/show-all-users.component.ts b/src/blogify/frontend/src/app/shared/components/show-all-users/show-all-users.component.ts new file mode 100644 index 00000000..12ff8d7b --- /dev/null +++ b/src/blogify/frontend/src/app/shared/components/show-all-users/show-all-users.component.ts @@ -0,0 +1,91 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { faArrowLeft, faSearch, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { AuthService} from '../../auth/auth.service'; +import { StaticContentService } from '../../../services/static/static-content.service'; +import { ActivatedRoute, Router, UrlSegment } from '@angular/router'; +import { User } from '../../../models/User'; + +@Component({ + selector: 'app-show-all-users', + templateUrl: './show-all-users.component.html', + styleUrls: ['./show-all-users.component.scss'] +}) +export class ShowAllUsersComponent implements OnInit { + + faSearch = faSearch; + faArrowLeft = faArrowLeft; + + faTimes = faTimes; + + @Input() title = 'Users'; + @Input() users: User[]; + @Input() noContentMessage = 'Nothing to see here !'; + @Input() noResultsMessage = 'No search results :('; + + forceNoAllowCreate = false; + + showingSearchResults = false; + searchQuery: string; + searchResults: User[]; + showingMobileSearchBar: boolean; + + constructor ( + private authService: AuthService, + private staticContentService: StaticContentService, + private activatedRoute: ActivatedRoute, + private router: Router + ) {} + + ngOnInit() { + // Check for searches + + 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(/^"[^"']+"$/) != 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}); + } + + async navigateToNoSearch() { + await this.router.navigateByUrl(this.router.url.replace(/search/, '')); // Hacky, but works ! + } + + private async startSearch() { + this.authService.search ( + this.searchQuery, + ['name', 'username', 'profilePicture'], + ).then(it => { + this.searchResults = it.hits.map(user => user.document); + this.showingSearchResults = true; + this.forceNoAllowCreate = true; + }).catch((err: Error) => { + console.error(`[blogifySearch] Error during search: ${err.name}: ${err.message}`); + }); + } + + async stopSearch() { + this.showingSearchResults = false; + this.forceNoAllowCreate = false; + this.searchQuery = undefined; + this.showingMobileSearchBar = false; + this.navigateToNoSearch(); + } + + + setShowSearchBar(val: boolean) { + this.showingMobileSearchBar = val; + } + +} diff --git a/src/blogify/frontend/src/app/shared/components/show-all-users/single-user-box/single-user-box.component.html b/src/blogify/frontend/src/app/shared/components/show-all-users/single-user-box/single-user-box.component.html new file mode 100644 index 00000000..3f95a28c --- /dev/null +++ b/src/blogify/frontend/src/app/shared/components/show-all-users/single-user-box/single-user-box.component.html @@ -0,0 +1,17 @@ +
+ +
+ + +
+ + + +
diff --git a/src/blogify/frontend/src/app/shared/components/show-all-users/single-user-box/single-user-box.component.scss b/src/blogify/frontend/src/app/shared/components/show-all-users/single-user-box/single-user-box.component.scss new file mode 100644 index 00000000..b6ed60e7 --- /dev/null +++ b/src/blogify/frontend/src/app/shared/components/show-all-users/single-user-box/single-user-box.component.scss @@ -0,0 +1,100 @@ +@import "../../../../../styles/layouts"; +@import "../../../../../styles/queries"; + +.user { + + 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; + + .user-first-line { + width: 100%; + + text-align: center; + + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + .header-title { + text-align: left; + } + + .user-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; } + + } + + } + + .user-read-more { color: var(--card-fg); } + + .user-last-line { + width: 100%; + + margin-top: 1rem; + + text-align: center; + + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + $element-separation: 1.5rem; + .separator { + margin-left: $element-separation !important; + } + a { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + fa-icon:first-child { margin-right: .7rem; } + + span { font-size: 1.4rem; } + font-weight: 600; + + @media (max-width: $query-desktop) { + & .button-text { + display: none; + } + } + + &:not(:first-child) { margin-left: $element-separation; } + } + + .user-share { + margin-right: auto; + } + + } + +} diff --git a/src/blogify/frontend/src/app/shared/components/show-all-users/single-user-box/single-user-box.component.spec.ts b/src/blogify/frontend/src/app/shared/components/show-all-users/single-user-box/single-user-box.component.spec.ts new file mode 100644 index 00000000..614b49d2 --- /dev/null +++ b/src/blogify/frontend/src/app/shared/components/show-all-users/single-user-box/single-user-box.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SingleUserBoxComponent } from './single-user-box.component'; + +describe('SingleUserBoxComponent', () => { + let component: SingleUserBoxComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ SingleUserBoxComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SingleUserBoxComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/blogify/frontend/src/app/shared/components/show-all-users/single-user-box/single-user-box.component.ts b/src/blogify/frontend/src/app/shared/components/show-all-users/single-user-box/single-user-box.component.ts new file mode 100644 index 00000000..47ade502 --- /dev/null +++ b/src/blogify/frontend/src/app/shared/components/show-all-users/single-user-box/single-user-box.component.ts @@ -0,0 +1,33 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { User } from '../../../../models/User'; +import { faCopy } from '@fortawesome/free-regular-svg-icons'; +import { ClipboardService } from 'ngx-clipboard'; +import { StaticContentService } from '../../../../services/static/static-content.service'; + +@Component({ + selector: 'app-single-user-box', + templateUrl: './single-user-box.component.html', + styleUrls: ['./single-user-box.component.scss'] +}) +export class SingleUserBoxComponent implements OnInit { + + @Input() user: User; + + faCopy = faCopy; + + cvpPath: string; + + constructor ( + private staticContentService: StaticContentService, + private clipboardService: ClipboardService, + ) {} + + ngOnInit() { + this.cvpPath = this.staticContentService.urlFor(this.user.coverPicture); + } + + copyLinkToClipboard() { + this.clipboardService.copyFromContent(window.location.href); + } + +} diff --git a/src/blogify/frontend/src/app/shared/components/tab-header/tab-header.component.ts b/src/blogify/frontend/src/app/shared/components/tab-header/tab-header.component.ts index f8e80081..b2cca9e9 100644 --- a/src/blogify/frontend/src/app/shared/components/tab-header/tab-header.component.ts +++ b/src/blogify/frontend/src/app/shared/components/tab-header/tab-header.component.ts @@ -1,4 +1,5 @@ import { Component, Input, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; @Component({ selector: 'app-tab-header', @@ -8,12 +9,31 @@ import { Component, Input, OnInit } from '@angular/core'; export class TabHeaderComponent implements OnInit { @Input() tabs: TabList; + @Input() activatedTab = 0; - activatedTab = 0; + currentUrl; - constructor() {} + constructor ( + private router: Router + ) {} - ngOnInit() {} + ngOnInit() { + this.currentUrl = this.router.url; + this.adjustSelectedtab(this.currentUrl); + + this.router.events.subscribe(event => { // weird hack for setting correct tab on profile change, but works. sort of. for now. + const newUrl = this.router.url; + if (newUrl !== this.currentUrl) { + this.currentUrl = newUrl; + this.adjustSelectedtab(newUrl); + } + }); + } + + private adjustSelectedtab(url: string) { + let newSegment = url.substring(url.lastIndexOf('/') + 1); + this.activatedTab = this.tabs.findIndex(tab => tab.tabRouterLink === newSegment); + } } diff --git a/src/blogify/frontend/src/app/shared/components/toaster/toaster.component.html b/src/blogify/frontend/src/app/shared/components/toaster/toaster.component.html new file mode 100644 index 00000000..98da3ef8 --- /dev/null +++ b/src/blogify/frontend/src/app/shared/components/toaster/toaster.component.html @@ -0,0 +1,14 @@ +
+ +
+

{{this.currentToast.header}}

+ {{this.currentToast.content}} +
+ + + +
diff --git a/src/blogify/frontend/src/app/shared/components/toaster/toaster.component.scss b/src/blogify/frontend/src/app/shared/components/toaster/toaster.component.scss new file mode 100644 index 00000000..c997e71b --- /dev/null +++ b/src/blogify/frontend/src/app/shared/components/toaster/toaster.component.scss @@ -0,0 +1,46 @@ +@import "../../../../styles/queries"; + +.toast { + width: 300px; + @media (max-width: $query-desktop) { + width: calc(100% - 2rem); + } + + position: fixed; + top: 7rem; + right: 2rem; + left: unset; + @media (max-width: $query-desktop) { + top: unset; + right: 2rem !important; + left: 1rem; + bottom: 1rem; + } + + padding: 1rem 1.5rem; + border-radius: .75rem; + + transition: opacity 1000ms ease-in-out, background-color 150ms ease-in-out; + &.clearing { opacity: 0; } + + display: flex; + flex-direction: row; + justify-content: stretch; + + .toast-body { + + .toast-header { + padding-top: 0; + } + + } + + .toast-icon { + display: flex; + flex-direction: column; + justify-content: center; + + margin-left: auto; + } + +} diff --git a/src/blogify/frontend/src/app/shared/components/toaster/toaster.component.spec.ts b/src/blogify/frontend/src/app/shared/components/toaster/toaster.component.spec.ts new file mode 100644 index 00000000..a0774c4e --- /dev/null +++ b/src/blogify/frontend/src/app/shared/components/toaster/toaster.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ToasterComponent } from './toaster.component'; + +describe('ToasterComponent', () => { + let component: ToasterComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ ToasterComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ToasterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/blogify/frontend/src/app/shared/components/toaster/toaster.component.ts b/src/blogify/frontend/src/app/shared/components/toaster/toaster.component.ts new file mode 100644 index 00000000..f312bd5d --- /dev/null +++ b/src/blogify/frontend/src/app/shared/components/toaster/toaster.component.ts @@ -0,0 +1,57 @@ +import { Component, OnInit } from '@angular/core'; +import { Toast } from '../../services/toaster/models/Toast'; +import { timer } from 'rxjs'; +import { DomSanitizer } from '@angular/platform-browser'; + +@Component({ + selector: 'app-toaster', + templateUrl: './toaster.component.html', + styleUrls: ['./toaster.component.scss'] +}) +export class ToasterComponent implements OnInit { + + private toastQueue: Toast[] = []; + currentToast: Toast | null; + clearingTopState = 2; + + constructor(private domSanitizer: DomSanitizer) {} + + ngOnInit() { + // Check for new toast + timer(0, 250).subscribe(_ => { + if (this.currentToast == null) { + this.popQueue(); + } + }); + // Clear stale toast + timer(0, 5000).subscribe(_ => { + if (this.toastQueue.length >= 1) { // More than one toast are left + this.popQueue(); + } else { + if (this.clearingTopState == 0) { // Next cycle after transition -> remove it + this.popQueue(); + this.currentToast = null; + this.clearingTopState = 3; + } else if (this.clearingTopState == 1 || this.clearingTopState == 2) { + this.clearingTopState--; + } else { + this.clearingTopState--; // Triggers transition + } + } + }) + } + + private popQueue() { + const toastToPop = this.toastQueue.pop(); + this.currentToast = toastToPop ? toastToPop : null; + } + + bake(...toast: Toast[]) { + this.toastQueue.push(...toast); + toast.forEach(it => { + it.backgroundColor = this.domSanitizer.bypassSecurityTrustStyle( it.backgroundColor); + it.foregroundColor = this.domSanitizer.bypassSecurityTrustStyle( it.foregroundColor); + }); + } + +} 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 7e2a7ffe..907f6b80 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,8 +1,16 @@ - + + + {{infoText}} + {{secondaryInfoText}} diff --git a/src/blogify/frontend/src/app/shared/components/user-display/user-display.component.scss b/src/blogify/frontend/src/app/shared/components/user-display/user-display.component.scss index ae8f7c3c..9392e6a4 100644 --- a/src/blogify/frontend/src/app/shared/components/user-display/user-display.component.scss +++ b/src/blogify/frontend/src/app/shared/components/user-display/user-display.component.scss @@ -1,28 +1,41 @@ +@import "../../../../styles/queries"; + :host { display: inline-flex; flex-direction: column; justify-content: center; align-items: center; + + &.collapse-on-mobile .user-display { + @media (max-width: $query-desktop) { + grid-column-gap: 0; + .display-info { display: none; } + } + } } .user-display { - display: inline-flex; - flex-direction: row; - justify-content: space-between; - align-items: center; + display: grid; + grid-template: 1fr auto / 1fr auto; + grid-column-gap: 1em; + align-items: center; + > * { justify-self: start; } cursor: pointer; .display-pfp { - + grid-row: span 2; } .display-info { - margin-left: .5em; - font-size: 1.75em; + line-height: 1.15em; font-weight: 600; } + .display-secondary-info { + font-size: .85em; + } + } 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 5ed4e548..1f646e9f 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 @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; import { User } from '../../../models/User'; @Component({ @@ -6,20 +6,28 @@ import { User } from '../../../models/User'; templateUrl: './user-display.component.html', styleUrls: ['./user-display.component.scss'] }) -export class UserDisplayComponent implements OnInit { - - readonly EM_SIZE_TEXT_RATIO = 2.4; +export class UserDisplayComponent implements OnInit, OnChanges { @Input() user: User; @Input() info: 'username' | 'name' = 'username'; + @Input() showSecondaryInfo: boolean; @Input() emSize: number = 3; + @Input() sizeRatio = 2.4; + @Input() displayedVertically: boolean = false; infoText: string; + secondaryInfoText: string; constructor() {} ngOnInit() { - this.infoText = this.info === 'username' ? this.user.username : this.user.name + this.infoText = this.info === 'username' ? this.user.username : this.user.name; + this.secondaryInfoText = this.info === 'username' ? this.user.name : `@${this.user.username}`; + } + + ngOnChanges(changes: SimpleChanges): void { + this.infoText = this.info === 'username' ? this.user.username : this.user.name; + this.secondaryInfoText = this.info === 'username' ? this.user.name : `@${this.user.username}`; } } diff --git a/src/blogify/frontend/src/app/shared/relative-time/relative-time.pipe.ts b/src/blogify/frontend/src/app/shared/relative-time/relative-time.pipe.ts index 02252c1a..ce2dce28 100644 --- a/src/blogify/frontend/src/app/shared/relative-time/relative-time.pipe.ts +++ b/src/blogify/frontend/src/app/shared/relative-time/relative-time.pipe.ts @@ -1,8 +1,8 @@ import { Pipe, PipeTransform } from '@angular/core'; -const milliSecondsInDay = 1000 * 3600 * 24; -const milliSecondsInHour = 1000 * 3600; -const milliSecondsInMinute = (1000 * 3600) / 60; +const milliSecondsInDay = 1000 * 60 * 60 * 24; +const milliSecondsInHour = 1000 * 60 * 60; +const milliSecondsInMinute = 1000 * 60; // Cast as any because typescript typing haven't updated yet // tslint:disable-next-line:no-any @@ -17,13 +17,15 @@ const rtf = new (Intl as any).RelativeTimeFormat('en'); export class RelativeTimePipe implements PipeTransform { /** - * Transform function for `relativeTime` pipe - * @param timeInMills a unix timestamp + * Transform function for `relativeTime` + * + * @param epochTime a unix timestamp + * + * @return relative difference. * If the difference is less than a minute, it returns: 'Just now' - * @return relative difference */ - transform(timeInMills: number): string { - const diffInMilliseconds = timeInMills - new Date().getTime(); + transform(epochTime: number): string { + const diffInMilliseconds = (epochTime * 1000) - new Date().getTime(); const formattedDays = rtf.format(Math.round(diffInMilliseconds / milliSecondsInDay), 'day'); const formattedHour = formattedDays !== '0 days ago' ? formattedDays : diff --git a/src/blogify/frontend/src/app/shared/services/toaster/models/Toast.ts b/src/blogify/frontend/src/app/shared/services/toaster/models/Toast.ts new file mode 100644 index 00000000..a1b2b513 --- /dev/null +++ b/src/blogify/frontend/src/app/shared/services/toaster/models/Toast.ts @@ -0,0 +1,38 @@ +import { SafeStyle } from '@angular/platform-browser'; +import { faInfoCircle, IconDefinition } from '@fortawesome/free-solid-svg-icons'; + +export enum ToastStyle { + NEUTRAL = 'var(--toast-neutral)', + POSITIVE = 'var(--toast-positive)', + NEGATIVE = 'var(--toast-negative)', + MILD = 'var(--toast-mild)', + GRAY = 'var(--toast-gray)', +} + +type ColorValue = SafeStyle | ToastStyle | string; + +export interface ToastParameters { + header: string; + content: string; + icon?: IconDefinition | null; + backgroundColor?: ColorValue; + foregroundColor?: ColorValue; +} export class Toast { + + public header: string; + public content: string; + public icon: IconDefinition | null; + public backgroundColor: ColorValue; + public foregroundColor: ColorValue; + + constructor ( + { header, content, icon = faInfoCircle, backgroundColor = ToastStyle.NEUTRAL, foregroundColor = 'var(--card-fg)' }: ToastParameters + ) { + this.header = header; + this.content = content; + this.icon = icon; + this.backgroundColor = backgroundColor; + this.foregroundColor = foregroundColor; + } + +} diff --git a/src/blogify/frontend/src/app/shared/services/toaster/toaster.service.spec.ts b/src/blogify/frontend/src/app/shared/services/toaster/toaster.service.spec.ts new file mode 100644 index 00000000..3d93b6d5 --- /dev/null +++ b/src/blogify/frontend/src/app/shared/services/toaster/toaster.service.spec.ts @@ -0,0 +1,12 @@ +import { TestBed } from '@angular/core/testing'; + +import { ToasterService } from './toaster.service'; + +describe('ToasterService', () => { + beforeEach(() => TestBed.configureTestingModule({})); + + it('should be created', () => { + const service: ToasterService = TestBed.get(ToasterService); + expect(service).toBeTruthy(); + }); +}); diff --git a/src/blogify/frontend/src/app/shared/services/toaster/toaster.service.ts b/src/blogify/frontend/src/app/shared/services/toaster/toaster.service.ts new file mode 100644 index 00000000..676aac43 --- /dev/null +++ b/src/blogify/frontend/src/app/shared/services/toaster/toaster.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { Toast } from './models/Toast'; +import { ToasterComponent } from '../../components/toaster/toaster.component'; + +@Injectable({ + providedIn: 'root' +}) +export class ToasterService { + + private pluggedToaster: ToasterComponent | null; + + constructor() {} + + plugInto(toaster: ToasterComponent) { + this.pluggedToaster = toaster + } + + feed(...toast: Toast[]) { + if (this.pluggedToaster != null) { + this.pluggedToaster.bake(...toast.reverse()) + } else console.error("[blogifyToaster] No destination for toast !") + } + +} diff --git a/src/blogify/frontend/src/app/shared/services/user-service/user.service.spec.ts b/src/blogify/frontend/src/app/shared/services/user-service/user.service.spec.ts new file mode 100644 index 00000000..9e7fd1c3 --- /dev/null +++ b/src/blogify/frontend/src/app/shared/services/user-service/user.service.spec.ts @@ -0,0 +1,12 @@ +import { TestBed } from '@angular/core/testing'; + +import { UserService } from './user.service'; + +describe('UserService', () => { + beforeEach(() => TestBed.configureTestingModule({})); + + it('should be created', () => { + const service: UserService = TestBed.get(UserService); + expect(service).toBeTruthy(); + }); +}); diff --git a/src/blogify/frontend/src/app/shared/services/user-service/user.service.ts b/src/blogify/frontend/src/app/shared/services/user-service/user.service.ts new file mode 100644 index 00000000..3798bbc7 --- /dev/null +++ b/src/blogify/frontend/src/app/shared/services/user-service/user.service.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http'; +import { User } from '../../../models/User'; + +@Injectable({ + providedIn: 'root' +}) +export class UserService { + + constructor(private httpClient: HttpClient) {} + + async toggleFollowUser(user: User, userToken: string): Promise> { + + const httpOptions = { + headers: new HttpHeaders({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${userToken}` + }), + observe: 'response', + }; + + // TypeScript bug with method overloads. + // @ts-ignore + return this.httpClient.post>(`/api/users/${user.uuid}/follow`, null, httpOptions).toPromise() + } + +} diff --git a/src/blogify/frontend/src/app/shared/shared.module.ts b/src/blogify/frontend/src/app/shared/shared.module.ts index 81be437c..ac897b5a 100644 --- a/src/blogify/frontend/src/app/shared/shared.module.ts +++ b/src/blogify/frontend/src/app/shared/shared.module.ts @@ -9,9 +9,13 @@ 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 { 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'; +import { ToasterComponent } from './components/toaster/toaster.component'; +import { AdminRoutingModule } from '../components/admin/admin/admin-routing.module'; +import { ShowAllUsersComponent } from './components/show-all-users/show-all-users.component'; +import { SingleUserBoxComponent } from './components/show-all-users/single-user-box/single-user-box.component'; @NgModule({ declarations: [ @@ -24,10 +28,14 @@ import { FilteringMenuComponent } from './components/show-all-articles/filtering UserDisplayComponent, SingleArticleBoxComponent, FilteringMenuComponent, + ToasterComponent, + ShowAllUsersComponent, + SingleUserBoxComponent, ], imports: [ CommonModule, ProfileRoutingModule, + AdminRoutingModule, FontAwesomeModule, FormsModule, ], @@ -38,7 +46,9 @@ import { FilteringMenuComponent } from './components/show-all-articles/filtering TabHeaderComponent, ProfilePictureComponent, ShowAllArticlesComponent, - UserDisplayComponent + UserDisplayComponent, + ToasterComponent, + ShowAllUsersComponent ] }) export class SharedModule { } diff --git a/src/blogify/frontend/src/polyfills.ts b/src/blogify/frontend/src/polyfills.ts index aa665d6b..33d53ef0 100644 --- a/src/blogify/frontend/src/polyfills.ts +++ b/src/blogify/frontend/src/polyfills.ts @@ -61,3 +61,10 @@ import 'zone.js/dist/zone'; // Included with Angular CLI. /*************************************************************************************************** * APPLICATION IMPORTS */ +import RelativeTimeFormat from "relative-time-format"; +// @ts-ignore +import en from "relative-time-format/locale/en.json"; + +RelativeTimeFormat.addLocale(en) +// @ts-ignore +Intl.RelativeTimeFormat = RelativeTimeFormat; diff --git a/src/blogify/frontend/src/proxy.conf.json b/src/blogify/frontend/src/proxy.conf.json index 4772242b..f4e74dd5 100644 --- a/src/blogify/frontend/src/proxy.conf.json +++ b/src/blogify/frontend/src/proxy.conf.json @@ -1,6 +1,6 @@ { "/api": { - "target": "http://172.105.28.10:8080/", + "target": "http://localhost:8080/", "secure": false } } diff --git a/src/blogify/frontend/src/styles.scss b/src/blogify/frontend/src/styles.scss index 7eec82d6..8fd45f4c 100644 --- a/src/blogify/frontend/src/styles.scss +++ b/src/blogify/frontend/src/styles.scss @@ -36,3 +36,17 @@ margin-top: auto; } } + +a, a:visited, a:active, a:hover { + color: unset; +} + +// No idea why, but this doesn't work in SA +markdown { + * { + font-size: 1.35em; + } + p:not(:last-of-type) { + margin-bottom: 1.15em; + } +} diff --git a/src/blogify/frontend/src/styles/code.scss b/src/blogify/frontend/src/styles/code.scss index 12a2a85b..9d0c2cda 100644 --- a/src/blogify/frontend/src/styles/code.scss +++ b/src/blogify/frontend/src/styles/code.scss @@ -5,7 +5,14 @@ code * { font-family: $source; } pre[class*="language-"] { border: none !important; box-shadow: none !important; - * { text-shadow: none !important; } + * { + text-shadow: none !important; + font-size: 12px !important; + } + + &:not(:first-child) { + margin-top: 1.25em; + } background: var(--card-bg) !important; } diff --git a/src/blogify/frontend/src/styles/colours.scss b/src/blogify/frontend/src/styles/colours.scss index 523fdc67..56033fa2 100644 --- a/src/blogify/frontend/src/styles/colours.scss +++ b/src/blogify/frontend/src/styles/colours.scss @@ -26,6 +26,12 @@ Please try to only use these colors in all other files. This ensures consistency --accent-positive: hsl(158, 49%, 54%); --accent-gray: hsl(240, 1%, 57%); + --toast-mild: hsl(41, 33%, 45%); + --toast-negative: hsl(8, 36%, 45%); + --toast-neutral: hsl(175, 41%, 45%); + --toast-positive: hsl(158, 49%, 45%); + --toast-gray: hsl(240, 1%, 30%); + --logo-filter: brightness(0); &[data-theme="dark"] { @@ -54,3 +60,11 @@ Please try to only use these colors in all other files. This ensures consistency } } + +// General accent classes + +*.neutral { &, & * {color: var(--accent-neutral); } }; +*.positive { &, & * {color: var(--accent-positive); } }; +*.mild { &, & * {color: var(--accent-mild); } }; +*.negative { &, & * {color: var(--accent-negative); } }; +*.gray { &, & * {color: var(--accent-gray); } }; diff --git a/src/blogify/frontend/src/styles/fonts.scss b/src/blogify/frontend/src/styles/fonts.scss index 7d79295a..e6f3e8c3 100644 --- a/src/blogify/frontend/src/styles/fonts.scss +++ b/src/blogify/frontend/src/styles/fonts.scss @@ -3,7 +3,3 @@ $nunito: 'Nunito', sans-serif; $source: 'Source Code Pro', sans-serif; - -a:hover, a:hover * { - color: var(--accent-neutral) !important; -} diff --git a/src/blogify/frontend/src/styles/forms.scss b/src/blogify/frontend/src/styles/forms.scss index b95d0883..adc4ab86 100644 --- a/src/blogify/frontend/src/styles/forms.scss +++ b/src/blogify/frontend/src/styles/forms.scss @@ -8,7 +8,7 @@ input, button, textarea, select { input, textarea { color: var(--card-fg); - padding: .65em .9em; + padding: .65rem .9rem; border-radius: $std-border-radius; border: 1px solid var(--border-color); @@ -126,11 +126,11 @@ button { // Color classes - &.neutral { background: var(--accent-neutral); } - &.positive { background: var(--accent-positive); } - &.mild { background: var(--accent-mild); } - &.negative { background: var(--accent-negative); } - &.gray { background: var(--accent-gray); } + &.neutral { color: black; background-color: var(--accent-neutral) } + &.positive { color: black; background-color: var(--accent-positive); } + &.mild { color: black; background-color: var(--accent-mild); } + &.negative { color: black; background-color: var(--accent-negative); } + &.gray { color: black; background-color: var(--accent-gray); } // Shape classes @@ -154,5 +154,30 @@ button { &.rounded { border-radius: 1.15em; padding: .5em 1.15em; + &.small { padding: .4em 1em; } } } + +*.clickable { + + cursor: pointer; + + &:not(.no-highlight) { + &:hover, &:hover * { color: var(--accent-neutral); } + } + + &:hover.hover-neutral { &, & * {color: var(--accent-neutral); } }; + &:hover.hover-positive { &, & * {color: var(--accent-positive); } }; + &:hover.hover-mild { &, & * {color: var(--accent-mild); } }; + &:hover.hover-negative { &, & * {color: var(--accent-negative); } }; + &:hover.hover-gray { &, & * {color: var(--accent-gray); } }; + +} + +span.separator { + width: 1px; + + margin-left: .85em !important; + align-self: stretch; + background-color: var(--header-ct); +} diff --git a/src/blogify/frontend/src/styles/layouts.scss b/src/blogify/frontend/src/styles/layouts.scss index 113f685c..48cb4cce 100644 --- a/src/blogify/frontend/src/styles/layouts.scss +++ b/src/blogify/frontend/src/styles/layouts.scss @@ -1 +1 @@ -$std-border-radius: 5px; +$std-border-radius: 6px; diff --git a/src/test/kotlin/PropMapTests.kt b/src/test/kotlin/PropMapTests.kt new file mode 100644 index 00000000..9c9a64c2 --- /dev/null +++ b/src/test/kotlin/PropMapTests.kt @@ -0,0 +1,47 @@ +import blogify.backend.annotations.Invisible +import blogify.backend.annotations.check +import blogify.backend.resources.computed.models.Computed +import blogify.backend.resources.models.Resource +import blogify.backend.resources.reflect.cachedPropMap +import blogify.backend.resources.reflect.models.ext.ok +import blogify.backend.resources.reflect.models.ext.valid + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +import kotlin.reflect.full.findAnnotation + +class PropMapTests { + + data class TestClass(val visible: String, @Invisible val invisible: String): Resource() + + @Test + fun `valid() should not return Invisible properties`() { + val none = TestClass::class.cachedPropMap().valid() + .none { it.value.property.findAnnotation() !== null } + + assertTrue(none, "Should not contain @Invisible properties") + } + + @Test + fun `ok() should not return Invisible or Computed properties`() { + val none = TestClass::class.cachedPropMap().ok() + .none { it.value.property.findAnnotation() != null|| it.value.property.findAnnotation() != null } + + assertTrue(none, "Should not contain @Invisible or @Computed properties") + } + + data class TestClassWithRegexes(val noRegex: String, val withRegex: @check("[a-zA-Z0-9]{3}") String): Resource() + + @Test + fun `should pick up regexes`() { + val prop = TestClassWithRegexes::class.cachedPropMap().ok().values.first { it.name == "withRegex" } + + val hasRegex = prop.regexCheck != null + val regexPattern = prop.regexCheck?.pattern + + assertTrue(hasRegex, "Should have a non-null regex property") + assertEquals("[a-zA-Z0-9]{3}", regexPattern) + } + +} diff --git a/src/test/kotlin/SlicerTests.kt b/src/test/kotlin/SlicerTests.kt new file mode 100644 index 00000000..a02435ad --- /dev/null +++ b/src/test/kotlin/SlicerTests.kt @@ -0,0 +1,33 @@ +import blogify.backend.annotations.Invisible +import blogify.backend.resources.models.Resource +import blogify.backend.resources.reflect.cachedUnsafePropMap +import blogify.backend.resources.reflect.models.ext.ok +import blogify.backend.resources.reflect.slice + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class SlicerTests { + + data class TestClass ( + val name: String, + val age: Int, + @Invisible val password: String + ) : Resource() + + val propMap = TestClass::class.cachedUnsafePropMap() + val testObject = TestClass("abc", 17, "whatever") + val slicedObject = testObject.slice(propMap.ok().keys - "uuid") + + @Test + fun `should include visible properties in sliced resource`() { + assertTrue(slicedObject["name"] == "abc" && slicedObject["age"] == 17, "Should contain valid properties") + } + + @Test + fun `should not include invisible properties in sliced resource`() { + assertTrue(slicedObject["password"] == null, "Should not contain password in main values") + assertTrue(slicedObject["_accessDenied"] == setOf("password"), "Should contain password in _accessDenied") + } + +}