From c7aa5c43512cb1812b62b3782d28f309a21813ef Mon Sep 17 00:00:00 2001 From: Stefan Poindl <55439476+tohuwabohu-io@users.noreply.github.com> Date: Thu, 7 Nov 2024 11:57:47 +0100 Subject: [PATCH] admin page (#7) * change auth mechanism to basic * change /hit to return count; restrict /count to api-admin role and require pageId * add /pages endpoint to list registered pages * change /register media type to x-www-form-urlencoded * add htmx for admin UI * add kotlinx.html dsl to create page fragments * move admin login & ssr endpoints to separate rest resource * add admin -> stats ssr * add page stats page and some css * add some more management fragments * add user management page * add option to revoke api keys * add page add and remove masks * refacotring: serve admin pages as whole SSR pages * protect admin routes * add form based auth for ssr endpoints * retire api-admin role; add domain column to Page * add github link to main page * update api-user tests * ensure nonexisting api keys return 401 * move first-time setup step to dashboard * add user page input validation * add validation for page creation * sort stats by domain * add example request for intellij http client * add test files * add tailwind config and finalized css * fix admin login redirect --- build.gradle.kts | 12 +- smoke-test.http | 6 + .../io/tohuwabohu/kamifusen/AdminResource.kt | 33 -- .../tohuwabohu/kamifusen/AppAdminResource.kt | 189 +++++++++ .../tohuwabohu/kamifusen/PageVisitResource.kt | 33 +- .../io/tohuwabohu/kamifusen/crud/ApiKey.kt | 57 --- .../io/tohuwabohu/kamifusen/crud/ApiUser.kt | 119 ++++++ .../io/tohuwabohu/kamifusen/crud/Page.kt | 38 +- .../io/tohuwabohu/kamifusen/crud/PageVisit.kt | 10 +- .../kamifusen/crud/dto/PageVisitDto.kt | 43 ++ .../crud/security/ApiUserIdentityProvider.kt | 43 ++ .../kamifusen/crud/security/PageValidator.kt | 22 ++ .../crud/security/PasswordValidator.kt | 22 ++ .../kamifusen/crud/security/UserValidator.kt | 22 ++ .../kamifusen/security/IdentityProvider.kt | 100 ----- .../kamifusen/ssr/htmx-admin-page.kt | 161 ++++++++ .../kamifusen/ssr/htmx-components.kt | 206 ++++++++++ .../kamifusen/ssr/htmx-extensions.kt | 52 +++ .../kamifusen/ssr/htmx-fragments.kt | 95 +++++ .../tohuwabohu/kamifusen/ssr/htmx-routes.kt | 368 ++++++++++++++++++ .../kamifusen/ssr/response/htmx-errors.kt | 32 ++ .../resources/META-INF/resources/index.html | 65 ++++ .../META-INF/resources/scripts/htmx.min.js | 1 + .../static/images/kamifusen-logo.png | Bin 0 -> 52886 bytes .../META-INF/resources/styles/main.css | 2 + src/main/resources/application.properties | 26 +- src/main/resources/import.sql | 73 ++++ src/main/resources/input.css | 152 ++++++++ src/main/resources/tailwind.config.js | 9 + .../tohuwabohu/kamifusen/AdminResourceIT.kt | 3 - .../kamifusen/AppAdminResourceIT.kt | 3 + .../tohuwabohu/kamifusen/AdminResourceTest.kt | 102 ----- .../kamifusen/AppAdminResourceTest.kt | 139 +++++++ .../kamifusen/PageVisitResourceTest.kt | 60 ++- .../tohuwabohu/kamifusen/mock/ApiUserMock.kt | 32 ++ .../kamifusen/mock/IdentityProviderMock.kt | 15 - .../io/tohuwabohu/kamifusen/mock/PageMock.kt | 12 +- 37 files changed, 2000 insertions(+), 357 deletions(-) create mode 100644 smoke-test.http delete mode 100644 src/main/kotlin/io/tohuwabohu/kamifusen/AdminResource.kt create mode 100644 src/main/kotlin/io/tohuwabohu/kamifusen/AppAdminResource.kt delete mode 100644 src/main/kotlin/io/tohuwabohu/kamifusen/crud/ApiKey.kt create mode 100644 src/main/kotlin/io/tohuwabohu/kamifusen/crud/ApiUser.kt create mode 100644 src/main/kotlin/io/tohuwabohu/kamifusen/crud/dto/PageVisitDto.kt create mode 100644 src/main/kotlin/io/tohuwabohu/kamifusen/crud/security/ApiUserIdentityProvider.kt create mode 100644 src/main/kotlin/io/tohuwabohu/kamifusen/crud/security/PageValidator.kt create mode 100644 src/main/kotlin/io/tohuwabohu/kamifusen/crud/security/PasswordValidator.kt create mode 100644 src/main/kotlin/io/tohuwabohu/kamifusen/crud/security/UserValidator.kt delete mode 100644 src/main/kotlin/io/tohuwabohu/kamifusen/security/IdentityProvider.kt create mode 100644 src/main/kotlin/io/tohuwabohu/kamifusen/ssr/htmx-admin-page.kt create mode 100644 src/main/kotlin/io/tohuwabohu/kamifusen/ssr/htmx-components.kt create mode 100644 src/main/kotlin/io/tohuwabohu/kamifusen/ssr/htmx-extensions.kt create mode 100644 src/main/kotlin/io/tohuwabohu/kamifusen/ssr/htmx-fragments.kt create mode 100644 src/main/kotlin/io/tohuwabohu/kamifusen/ssr/htmx-routes.kt create mode 100644 src/main/kotlin/io/tohuwabohu/kamifusen/ssr/response/htmx-errors.kt create mode 100644 src/main/resources/META-INF/resources/index.html create mode 100644 src/main/resources/META-INF/resources/scripts/htmx.min.js create mode 100644 src/main/resources/META-INF/resources/static/images/kamifusen-logo.png create mode 100644 src/main/resources/META-INF/resources/styles/main.css create mode 100644 src/main/resources/import.sql create mode 100644 src/main/resources/input.css create mode 100644 src/main/resources/tailwind.config.js delete mode 100644 src/native-test/kotlin/io/tohuwabohu/kamifusen/AdminResourceIT.kt create mode 100644 src/native-test/kotlin/io/tohuwabohu/kamifusen/AppAdminResourceIT.kt delete mode 100644 src/test/kotlin/io/tohuwabohu/kamifusen/AdminResourceTest.kt create mode 100644 src/test/kotlin/io/tohuwabohu/kamifusen/AppAdminResourceTest.kt create mode 100644 src/test/kotlin/io/tohuwabohu/kamifusen/mock/ApiUserMock.kt delete mode 100644 src/test/kotlin/io/tohuwabohu/kamifusen/mock/IdentityProviderMock.kt diff --git a/build.gradle.kts b/build.gradle.kts index 3dce0fa..e2975dc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ plugins { kotlin("jvm") version "2.0.10" kotlin("plugin.allopen") version "2.0.10" kotlin("plugin.noarg") version "2.0.10" + kotlin("plugin.serialization") version "2.0.10" id("io.quarkus") } @@ -15,6 +16,9 @@ val quarkusPlatformArtifactId: String by project val quarkusPlatformVersion: String by project dependencies { + // quarkus & reactive JPA + implementation("io.quarkus:quarkus-rest-jackson") + implementation("io.quarkus:quarkus-security-jpa-reactive") implementation("io.quarkus:quarkus-jacoco") implementation("io.quarkus:quarkus-elytron-security-common") implementation("io.quarkus:quarkus-security") @@ -22,11 +26,16 @@ dependencies { implementation("io.quarkus:quarkus-hibernate-reactive-panache-kotlin") implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) implementation("io.quarkus:quarkus-kotlin") - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") implementation("io.quarkus:quarkus-arc") implementation("io.quarkus:quarkus-rest") + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + implementation("org.jetbrains.kotlin:kotlin-noarg") + // ssr + implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0") + // testing testImplementation("io.quarkus:quarkus-junit5") testImplementation("io.quarkus:quarkus-test-vertx") + testImplementation("io.quarkus:quarkus-test-security") testImplementation("io.rest-assured:rest-assured") testImplementation("io.rest-assured:kotlin-extensions:5.3.2") } @@ -52,6 +61,7 @@ allOpen { noArg { annotation("jakarta.persistence.Entity") + annotation("jakarta.persistence.Embeddable") } tasks.withType { diff --git a/smoke-test.http b/smoke-test.http new file mode 100644 index 0000000..e9aacbf --- /dev/null +++ b/smoke-test.http @@ -0,0 +1,6 @@ +### +POST localhost:8080/public/visits/hit +Authorization: Basic dG9odXdhYm9odTo1MDM2NGIzOC1mODI0LTRhODQtOGEzMS0wMWI3MjRmZjg3M2E= + +/test/path-9 +### \ No newline at end of file diff --git a/src/main/kotlin/io/tohuwabohu/kamifusen/AdminResource.kt b/src/main/kotlin/io/tohuwabohu/kamifusen/AdminResource.kt deleted file mode 100644 index f4d2e54..0000000 --- a/src/main/kotlin/io/tohuwabohu/kamifusen/AdminResource.kt +++ /dev/null @@ -1,33 +0,0 @@ -package io.tohuwabohu.kamifusen - -import io.smallrye.mutiny.Uni -import io.tohuwabohu.kamifusen.crud.PageRepository -import jakarta.ws.rs.Consumes -import jakarta.ws.rs.POST -import jakarta.ws.rs.Path -import jakarta.ws.rs.Produces -import jakarta.ws.rs.core.Context -import jakarta.ws.rs.core.Response -import jakarta.ws.rs.core.SecurityContext - -@Path("/admin") -class AdminResource(val pageRepository: PageRepository) { - @Path("/add") - @POST - @Consumes("text/plain") - @Produces("text/plain") - fun registerPage(@Context securityContext: SecurityContext, body: String): Uni = - Uni.createFrom().item(securityContext.isUserInRole("api-admin")).flatMap { isAdmin -> - if (isAdmin) { - pageRepository.findPageByPath(body).chain { page -> - if (page == null) { - pageRepository.addPage(body).onItem().transform { - Response.status(Response.Status.CREATED).build() - } - } else { - Uni.createFrom().item(Response.noContent().build()) - } - } - } else Uni.createFrom().item(Response.status(Response.Status.FORBIDDEN).build()) - } -} \ No newline at end of file diff --git a/src/main/kotlin/io/tohuwabohu/kamifusen/AppAdminResource.kt b/src/main/kotlin/io/tohuwabohu/kamifusen/AppAdminResource.kt new file mode 100644 index 0000000..5db9440 --- /dev/null +++ b/src/main/kotlin/io/tohuwabohu/kamifusen/AppAdminResource.kt @@ -0,0 +1,189 @@ +package io.tohuwabohu.kamifusen + +import io.quarkus.logging.Log +import io.smallrye.mutiny.Uni +import io.tohuwabohu.kamifusen.crud.ApiUser +import io.tohuwabohu.kamifusen.crud.ApiUserRepository +import io.tohuwabohu.kamifusen.crud.PageRepository +import io.tohuwabohu.kamifusen.crud.dto.PageVisitDtoRepository +import io.tohuwabohu.kamifusen.crud.security.* +import io.tohuwabohu.kamifusen.ssr.* +import io.tohuwabohu.kamifusen.ssr.response.recoverWithHtmxResponse +import io.vertx.ext.web.RoutingContext +import jakarta.annotation.security.RolesAllowed +import jakarta.ws.rs.* +import jakarta.ws.rs.core.* +import org.eclipse.microprofile.config.inject.ConfigProperty +import java.time.Instant +import java.time.LocalDateTime +import java.util.* + + +@Path("/") +class AppAdminResource( + private val apiUserRepository: ApiUserRepository, + private val pageVisitDtoRepository: PageVisitDtoRepository, + private val pageRepository: PageRepository +) { + @ConfigProperty(name = "quarkus.http.auth.form.cookie-name") + lateinit var cookieName: String + + @Path("/fragment/register") + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_HTML) + @RolesAllowed("app-admin") + fun registerAdmin( + @FormParam("password") password: String, + @FormParam("password-confirm") passwordConfirm: String + ): Uni = + validatePassword(password, passwordConfirm).flatMap { result -> + if (result == PasswordValidation.VALID) { + apiUserRepository.setAdminPassword(password) + .map { Response.ok(renderPasswordFlow(result)).build() } + } else { + Uni.createFrom().item(Response.ok(renderPasswordFlow(result)).build()) + } + }.onFailure().invoke { e -> Log.error("Error during admin password update.", e) } + .onFailure().recoverWithHtmxResponse(Response.Status.INTERNAL_SERVER_ERROR) + + @Path("/logout") + @GET + @Produces(MediaType.TEXT_PLAIN) + fun logoutAdmin(@Context routingContext: RoutingContext): Uni = + Uni.createFrom().item( + Response.status(Response.Status.FOUND) + .cookie( + NewCookie.Builder(cookieName) + .maxAge(0) + .expiry(Date.from(Instant.EPOCH)) + .path("/") + .build() + ) + .header("Location", "/index.html") + .build() + ) + + @Path("/dashboard") + @GET + @Consumes(MediaType.TEXT_PLAIN) + @Produces(MediaType.TEXT_HTML) + @RolesAllowed("app-admin") + fun renderAdminDashboard(@Context securityContext: SecurityContext): Uni = + apiUserRepository.findByUsername(securityContext.userPrincipal.name).map { adminUser -> + Response.ok(renderAdminPage("Dashboard", isFirstTimeSetup = adminUser!!.updated == null) { + dashboard(adminUser) + }).build() + }.onFailure().invoke { e -> Log.error("Error during dashboard rendering.", e) } + .onFailure().recoverWithHtmxResponse(Response.Status.INTERNAL_SERVER_ERROR) + + @Path("/stats") + @GET + @Consumes(MediaType.TEXT_PLAIN) + @Produces(MediaType.TEXT_HTML) + @RolesAllowed("app-admin") + fun renderVisits(): Uni = + pageVisitDtoRepository.getAllPageVisits() + .flatMap { + Uni.createFrom().item(Response.ok(renderAdminPage("Stats") { + stats(it) + }).build()) + } + .onFailure().invoke { e -> Log.error("Error during stats rendering.", e) } + .onFailure().recoverWithHtmxResponse(Response.Status.INTERNAL_SERVER_ERROR) + + @Path("/pages") + @GET + @Consumes(MediaType.TEXT_PLAIN) + @Produces(MediaType.TEXT_HTML) + @RolesAllowed("app-admin") + fun renderPageList(): Uni = + pageRepository.listAllPages().flatMap { + Uni.createFrom().item(Response.ok(renderAdminPage("Pages") { + pages(it) + }).build()) + }.onFailure().invoke { e -> Log.error("Error during pages rendering.", e) } + .onFailure().recoverWithHtmxResponse(Response.Status.INTERNAL_SERVER_ERROR) + + @Path("/users") + @GET + @Consumes(MediaType.TEXT_PLAIN) + @Produces(MediaType.TEXT_HTML) + @RolesAllowed("app-admin") + fun renderUserList(): Uni = + apiUserRepository.listAll().flatMap { + Uni.createFrom().item(Response.ok(renderAdminPage("Users") { + users(it) + }).build()) + }.onFailure().invoke { e -> Log.error("Error during user list rendering.", e) } + .onFailure().recoverWithHtmxResponse(Response.Status.INTERNAL_SERVER_ERROR) + + @Path("/fragment/keygen") + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_PLAIN) + @RolesAllowed("app-admin") + fun renderNewApiKey( + @FormParam("username") username: String, + @FormParam("role") role: String, + @FormParam("expiresAt") expiresAt: String + ): Uni = + validateUser(username, apiUserRepository).flatMap { result -> + if (result == UserValidation.VALID) { + apiUserRepository.addUser( + ApiUser( + username = username, + role = role, + expiresAt = when (expiresAt) { + "" -> null + else -> LocalDateTime.parse(expiresAt) + }, + ) + ).map { keyRaw -> Response.ok(renderCreatedApiKey(keyRaw)).build() } + } else { + Uni.createFrom().item(Response + .ok(renderUsernameValidationError(result)) + .header("hx-retarget", "#username") + .build()) + } + }.onFailure().invoke { e -> Log.error("Error during keygen.", e) } + .onFailure().recoverWithItem(Response.status(Response.Status.INTERNAL_SERVER_ERROR).build()) + + @Path("/fragment/retire/{userId}") + @POST + @Produces(MediaType.TEXT_HTML) + @RolesAllowed("app-admin") + fun retireApiKey(userId: UUID): Uni = + apiUserRepository.expireUser(userId).onItem() + .transform { Response.ok().header("hx-redirect", "/users").build() } + .onFailure().invoke { e -> Log.error("Error during key retirement", e) } + .onFailure().recoverWithItem(Response.serverError().build()) + + @Path("/fragment/pageadd") + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_PLAIN) + @RolesAllowed("app-admin") + fun registerNewPage(@FormParam("path") path: String, @FormParam("domain") domain: String): Uni = + validatePage(path, domain, pageRepository).flatMap { result -> + if (result == PageValidation.VALID) { + pageRepository.addPage(path, domain).map { Response.ok().header("hx-redirect", "/pages").build() } + } else { + Uni.createFrom().item(Response + .ok(renderPageValidationError(result)) + .header("hx-retarget", "#path") + .build()) + } + }.onFailure().invoke { e -> Log.error("Error during page registration.", e) } + .onFailure().recoverWithItem(Response.serverError().build()) + + @Path("/fragment/pagedel/{pageId}") + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_PLAIN) + @RolesAllowed("app-admin") + fun unregisterPage(pageId: UUID): Uni = + pageRepository.deletePage(pageId).map { Response.ok().header("hx-redirect", "/pages").build() } + .onFailure().invoke { e -> Log.error("Error during page registration.", e) } + .onFailure().recoverWithItem(Response.serverError().build()) +} \ No newline at end of file diff --git a/src/main/kotlin/io/tohuwabohu/kamifusen/PageVisitResource.kt b/src/main/kotlin/io/tohuwabohu/kamifusen/PageVisitResource.kt index f6e032d..e1795e9 100644 --- a/src/main/kotlin/io/tohuwabohu/kamifusen/PageVisitResource.kt +++ b/src/main/kotlin/io/tohuwabohu/kamifusen/PageVisitResource.kt @@ -7,14 +7,15 @@ import io.tohuwabohu.kamifusen.crud.PageVisitRepository import io.tohuwabohu.kamifusen.crud.VisitorRepository import io.tohuwabohu.kamifusen.crud.error.recoverWithResponse import io.vertx.core.http.HttpServerRequest +import jakarta.annotation.security.RolesAllowed import jakarta.ws.rs.* import jakarta.ws.rs.core.Context import jakarta.ws.rs.core.MediaType import jakarta.ws.rs.core.Response -import java.net.URLDecoder -import java.nio.charset.Charset +import jakarta.ws.rs.core.SecurityContext +import java.util.* -@Path("/visits") +@Path("/public/visits") class PageVisitResource( private val pageRepository: PageRepository, private val pageVisitRepository: PageVisitRepository, @@ -24,7 +25,12 @@ class PageVisitResource( @POST @Consumes(MediaType.TEXT_PLAIN) @Produces(MediaType.TEXT_PLAIN) - fun hit(@Context request: HttpServerRequest, body: String): Uni = + @RolesAllowed("api-user") + fun hit( + @Context securityContext: SecurityContext, + @Context request: HttpServerRequest, + body: String + ): Uni = pageRepository.findPageByPath(body).flatMap { page -> visitorRepository.findByInfo( remoteAddress = request.remoteAddress().host(), @@ -41,24 +47,29 @@ class PageVisitResource( remoteAddress = request.remoteAddress().host(), userAgent = request.headers().get("User-Agent") ?: "unknown" ).chain { newVisitor -> pageVisitRepository.addVisit(page.id, newVisitor.id) } + .map { _ -> page } } else { pageVisitRepository.countVisitsForVisitor(page.id, visitor.id).chain { count -> if (count <= 0) { pageVisitRepository.addVisit(page.id, visitor.id) } else Uni.createFrom().voidItem() - } + }.map { _ -> page } } - }.onItem().transform { Response.ok().build() } + }.flatMap { page -> pageVisitRepository.countVisits(page.id).map { it } } + .onItem().transform { count -> Response.ok(count).build() } .onFailure().recoverWithResponse() - @Path("/count/{pagePath}") + @Path("/count/{pageId}") @GET @Consumes(MediaType.TEXT_PLAIN) @Produces(MediaType.TEXT_PLAIN) - fun count(@Context request: HttpServerRequest, @PathParam("pagePath") pagePath: String): Uni = - pageRepository.findPageByPath(URLDecoder.decode(pagePath, - request.headers().get("Accept-Charset")?.let { Charset.forName(it) } ?: Charsets.UTF_8) - ).chain { page -> + @RolesAllowed("api-admin") + fun count( + @Context securityContext: SecurityContext, + @Context request: HttpServerRequest, + @PathParam("pageId") pageId: UUID + ): Uni = + pageRepository.findByPageId(pageId).chain { page -> if (page != null) { pageVisitRepository.countVisits(page.id).map { visits -> Response.ok(visits).build() diff --git a/src/main/kotlin/io/tohuwabohu/kamifusen/crud/ApiKey.kt b/src/main/kotlin/io/tohuwabohu/kamifusen/crud/ApiKey.kt deleted file mode 100644 index 48df19a..0000000 --- a/src/main/kotlin/io/tohuwabohu/kamifusen/crud/ApiKey.kt +++ /dev/null @@ -1,57 +0,0 @@ -package io.tohuwabohu.kamifusen.crud - -import io.quarkus.elytron.security.common.BcryptUtil -import io.quarkus.hibernate.reactive.panache.kotlin.PanacheRepositoryBase -import jakarta.enterprise.context.ApplicationScoped -import jakarta.persistence.Entity -import jakarta.persistence.Id -import jakarta.persistence.NamedQueries -import jakarta.persistence.NamedQuery -import jakarta.persistence.PrePersist -import org.hibernate.proxy.HibernateProxy -import java.time.LocalDateTime - -@NamedQueries( - NamedQuery( - name = "ApiKey.findValidKey", - query = "FROM ApiKey a WHERE a.apiKey = :apiKey AND a.expiresAt > :now") -) -@Entity -data class ApiKey ( - @Id - var apiKey: String, - var name: String, - var role: String, - var expiresAt: LocalDateTime? = null -) { - @PrePersist - fun obfuscateKey() { - apiKey = BcryptUtil.bcryptHash(apiKey) - } - - final override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null) return false - val oEffectiveClass = - if (other is HibernateProxy) other.hibernateLazyInitializer.persistentClass else other.javaClass - val thisEffectiveClass = - if (this is HibernateProxy) this.hibernateLazyInitializer.persistentClass else this.javaClass - if (thisEffectiveClass != oEffectiveClass) return false - other as ApiKey - - return apiKey != null && apiKey == other.apiKey - } - - final override fun hashCode(): Int = - if (this is HibernateProxy) this.hibernateLazyInitializer.persistentClass.hashCode() else javaClass.hashCode() - - @Override - override fun toString(): String { - return this::class.simpleName + "( apiKey = $apiKey , name = $name , role = $role , expiresAt = $expiresAt )" - } -} - -@ApplicationScoped -class ApiKeyRepository : PanacheRepositoryBase { - fun findKey(key: String) = find("#ApiKey.findValidKey", BcryptUtil.bcryptHash(key)).firstResult() -} \ No newline at end of file diff --git a/src/main/kotlin/io/tohuwabohu/kamifusen/crud/ApiUser.kt b/src/main/kotlin/io/tohuwabohu/kamifusen/crud/ApiUser.kt new file mode 100644 index 0000000..a1d3237 --- /dev/null +++ b/src/main/kotlin/io/tohuwabohu/kamifusen/crud/ApiUser.kt @@ -0,0 +1,119 @@ +package io.tohuwabohu.kamifusen.crud + +import io.quarkus.elytron.security.common.BcryptUtil +import io.quarkus.hibernate.reactive.panache.Panache +import io.quarkus.hibernate.reactive.panache.common.WithTransaction +import io.quarkus.hibernate.reactive.panache.kotlin.PanacheRepositoryBase +import io.quarkus.security.UnauthorizedException +import io.quarkus.security.jpa.Password +import io.quarkus.security.jpa.Roles +import io.quarkus.security.jpa.UserDefinition +import io.quarkus.security.jpa.Username +import io.smallrye.mutiny.Uni +import io.smallrye.mutiny.unchecked.Unchecked +import jakarta.enterprise.context.ApplicationScoped +import jakarta.persistence.* +import org.hibernate.proxy.HibernateProxy +import java.time.LocalDateTime +import java.util.* + +@NamedQueries( + NamedQuery( + name = "ApiUser.findValidUser", + query = "FROM ApiUser a WHERE a.username = :username AND (a.expiresAt IS NULL OR a.expiresAt > :now)" + ) +) +@Entity +@UserDefinition +data class ApiUser( + @Id + var id: UUID? = null, + @Username + var username: String, + @Password + var password: String? = null, + @Roles + var role: String, + var expiresAt: LocalDateTime? = null, + var added: LocalDateTime? = null, + var updated: LocalDateTime? = null +) { + @PrePersist + fun beforePersist() { + id = UUID.randomUUID() + password = BcryptUtil.bcryptHash(password) + added = LocalDateTime.now() + } + + @PreUpdate + fun beforeUpdate() { + updated = LocalDateTime.now() + } + + final override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null) return false + val oEffectiveClass = + if (other is HibernateProxy) other.hibernateLazyInitializer.persistentClass else other.javaClass + val thisEffectiveClass = + if (this is HibernateProxy) this.hibernateLazyInitializer.persistentClass else this.javaClass + if (thisEffectiveClass != oEffectiveClass) return false + other as ApiUser + + return id != null && id == other.id + } + + final override fun hashCode(): Int = + if (this is HibernateProxy) this.hibernateLazyInitializer.persistentClass.hashCode() else javaClass.hashCode() + + @Override + override fun toString(): String { + return this::class.simpleName + "( id = $id , username = $username , password = $password , role = $role , expiresAt = $expiresAt , added = $added )" + } + +} + +@ApplicationScoped +class ApiUserRepository : PanacheRepositoryBase { + + @WithTransaction + fun addUser(apiUser: ApiUser): Uni { + val randomPwd = UUID.randomUUID().toString() + apiUser.password = randomPwd + + return persist(apiUser).map { Base64.getEncoder().encodeToString("${it.username}:${randomPwd}".toByteArray()) } + } + + @WithTransaction + fun findByUsername(username: String) = find( + "#ApiUser.findValidUser", mapOf( + "username" to username, + "now" to LocalDateTime.now() + ) + ).firstResult() + + @WithTransaction + fun findByUsernameAndPassword(username: String, password: String): Uni = + findByUsername(username).onItem().ifNotNull().invoke(Unchecked.consumer { user -> + if (!BcryptUtil.matches(password, user!!.password)) { + throw UnauthorizedException() + } + }).onItem().ifNull().failWith(UnauthorizedException()) + + @WithTransaction + fun setAdminPassword(password: String): Uni { + return findByUsername("admin").onItem().ifNotNull().call { user -> + user?.password = BcryptUtil.bcryptHash(password) + + Panache.getSession().call { s -> s.merge(user) } + }.onItem().ifNull().failWith(EntityNotFoundException()) + } + + @WithTransaction + fun expireUser(userId: UUID): Uni = + findById(userId).onItem().ifNotNull().call { user -> + user.expiresAt = LocalDateTime.now() + + Panache.getSession().call { s -> s.merge(user) } + }.onItem().ifNull().failWith(EntityNotFoundException()) +} \ No newline at end of file diff --git a/src/main/kotlin/io/tohuwabohu/kamifusen/crud/Page.kt b/src/main/kotlin/io/tohuwabohu/kamifusen/crud/Page.kt index c5c7889..02e69a9 100644 --- a/src/main/kotlin/io/tohuwabohu/kamifusen/crud/Page.kt +++ b/src/main/kotlin/io/tohuwabohu/kamifusen/crud/Page.kt @@ -5,21 +5,25 @@ import io.quarkus.hibernate.reactive.panache.kotlin.PanacheEntityBase import io.quarkus.hibernate.reactive.panache.kotlin.PanacheRepositoryBase import io.smallrye.mutiny.Uni import jakarta.enterprise.context.ApplicationScoped -import jakarta.persistence.Entity -import jakarta.persistence.Id -import jakarta.persistence.PreUpdate +import jakarta.persistence.* import org.hibernate.proxy.HibernateProxy import java.time.LocalDateTime import java.util.* @Entity -data class Page ( +@NamedQueries( + NamedQuery( + name = "Page.findByPath", + query = "FROM Page p WHERE p.path = :path") +) +data class Page( @Id var id: UUID, var path: String, + var domain: String? = null, val pageAdded: LocalDateTime = LocalDateTime.now(), - var lastHit: LocalDateTime? = null -): PanacheEntityBase { + var lastHit: LocalDateTime? = null, +) : PanacheEntityBase { @PreUpdate fun updateLastHit() { lastHit = LocalDateTime.now() @@ -35,7 +39,7 @@ data class Page ( if (thisEffectiveClass != oEffectiveClass) return false other as Page - return id == other.id + return id != null && id == other.id } final override fun hashCode(): Int = @@ -43,18 +47,30 @@ data class Page ( @Override override fun toString(): String { - return this::class.simpleName + "( id = $id , path = $path , pageAdded = $pageAdded , lastHit = $lastHit )" + return this::class.simpleName + "( id = $id , path = $path , domain = $domain , pageAdded = $pageAdded , lastHit = $lastHit )" } } @ApplicationScoped class PageRepository : PanacheRepositoryBase { - fun findPageByPath(path: String) = find("path", path).firstResult() + fun findByPageId(id: UUID) = find("id", id).firstResult() + + fun findPageByPath(path: String) = find("#Page.findByPath", mapOf("path" to path)).firstResult() + + fun listAllPages() = listAll() @WithTransaction - fun addPage(path: String): Uni { - val page = Page(UUID.randomUUID(), path) + fun addPage(path: String, domain: String): Uni { + val page = Page( + id = UUID.randomUUID(), + path = path, + domain = domain + ) return persist(page) } + + @WithTransaction + fun deletePage(pageId: UUID): Uni = findById(pageId).onItem().ifNull().failWith(EntityNotFoundException()).onItem() + .ifNotNull().transformToUni { entry -> deleteById(entry.id)} } \ No newline at end of file diff --git a/src/main/kotlin/io/tohuwabohu/kamifusen/crud/PageVisit.kt b/src/main/kotlin/io/tohuwabohu/kamifusen/crud/PageVisit.kt index 45b4e2b..41fc702 100644 --- a/src/main/kotlin/io/tohuwabohu/kamifusen/crud/PageVisit.kt +++ b/src/main/kotlin/io/tohuwabohu/kamifusen/crud/PageVisit.kt @@ -4,15 +4,20 @@ import io.quarkus.hibernate.reactive.panache.common.WithTransaction import io.quarkus.hibernate.reactive.panache.kotlin.PanacheEntityBase import io.quarkus.hibernate.reactive.panache.kotlin.PanacheRepository import jakarta.enterprise.context.ApplicationScoped +import jakarta.persistence.Embeddable import jakarta.persistence.Entity import jakarta.persistence.Id +import jakarta.persistence.IdClass import org.hibernate.proxy.HibernateProxy +import java.io.Serializable import java.util.* @Entity +@IdClass(CompositeKey::class) data class PageVisit ( @Id var pageId: UUID, + @Id var visitorId: UUID, ): PanacheEntityBase { final override fun equals(other: Any?): Boolean { @@ -44,4 +49,7 @@ class PageVisitRepository: PanacheRepository { @WithTransaction fun addVisit(pageId: UUID, visitorId: UUID) = persist(PageVisit(pageId, visitorId)) -} \ No newline at end of file +} + +@Embeddable +class CompositeKey(val pageId: UUID, val visitorId: UUID) : Serializable \ No newline at end of file diff --git a/src/main/kotlin/io/tohuwabohu/kamifusen/crud/dto/PageVisitDto.kt b/src/main/kotlin/io/tohuwabohu/kamifusen/crud/dto/PageVisitDto.kt new file mode 100644 index 0000000..a449d98 --- /dev/null +++ b/src/main/kotlin/io/tohuwabohu/kamifusen/crud/dto/PageVisitDto.kt @@ -0,0 +1,43 @@ +package io.tohuwabohu.kamifusen.crud.dto + +import io.quarkus.hibernate.reactive.panache.Panache +import io.quarkus.hibernate.reactive.panache.kotlin.PanacheRepository +import io.smallrye.mutiny.Uni +import jakarta.enterprise.context.ApplicationScoped +import jakarta.persistence.Tuple +import java.time.LocalDateTime +import java.util.* + +data class PageVisitDto( + var id: UUID, + var path: String, + var domain: String?, + var pageAdded: LocalDateTime, + var visits: Long +) + +@ApplicationScoped +class PageVisitDtoRepository() : PanacheRepository { + val query = """ + SELECT p.id, p.path, p.pageAdded, COUNT(pv.visitorId), p.domain AS visits + FROM Page p + LEFT JOIN PageVisit pv ON p.id = pv.pageId + GROUP BY p.id + ORDER BY p.domain, p.path, p.pageAdded DESC + """ + + fun getAllPageVisits() : Uni> = + Panache.getSession().flatMap { session -> + session.createQuery(query, Tuple::class.java).resultList + .onItem().transform { it.map ( Tuple::toPageVisitDto ) } + } + +} + +fun Tuple.toPageVisitDto() = PageVisitDto( + id = this.get(0, UUID::class.java), + path = this.get(1, String::class.java), + pageAdded = this.get(2, LocalDateTime::class.java), + visits = this.get(3, Long::class.javaObjectType), + domain = this.get(4, String::class.java) +) \ No newline at end of file diff --git a/src/main/kotlin/io/tohuwabohu/kamifusen/crud/security/ApiUserIdentityProvider.kt b/src/main/kotlin/io/tohuwabohu/kamifusen/crud/security/ApiUserIdentityProvider.kt new file mode 100644 index 0000000..e72b371 --- /dev/null +++ b/src/main/kotlin/io/tohuwabohu/kamifusen/crud/security/ApiUserIdentityProvider.kt @@ -0,0 +1,43 @@ +package io.tohuwabohu.kamifusen.crud.security + +import io.quarkus.security.UnauthorizedException +import io.quarkus.security.identity.AuthenticationRequestContext +import io.quarkus.security.identity.IdentityProvider +import io.quarkus.security.identity.SecurityIdentity +import io.quarkus.security.identity.request.UsernamePasswordAuthenticationRequest +import io.quarkus.security.runtime.QuarkusSecurityIdentity +import io.smallrye.mutiny.Uni +import io.tohuwabohu.kamifusen.crud.ApiUserRepository +import jakarta.annotation.Priority +import jakarta.enterprise.context.ApplicationScoped +import jakarta.ws.rs.Priorities + +@ApplicationScoped +@Priority(Priorities.AUTHENTICATION) +class ApiUserIdentityProvider( + val apiUserRepository: ApiUserRepository +) : IdentityProvider { + + override fun authenticate(request: UsernamePasswordAuthenticationRequest, context: AuthenticationRequestContext): Uni { + val username = request.username + val password = String(request.password.password) + + return apiUserRepository.findByUsernameAndPassword(username, password) + .flatMap { apiUser -> + if (apiUser != null) { + Uni.createFrom().item( + QuarkusSecurityIdentity.builder() + .addRole(apiUser.role) + .setPrincipal { username } + .build() + ) + } else { + Uni.createFrom().failure(UnauthorizedException()) + } + } + } + + override fun getRequestType(): Class { + return UsernamePasswordAuthenticationRequest::class.java + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/tohuwabohu/kamifusen/crud/security/PageValidator.kt b/src/main/kotlin/io/tohuwabohu/kamifusen/crud/security/PageValidator.kt new file mode 100644 index 0000000..72690e9 --- /dev/null +++ b/src/main/kotlin/io/tohuwabohu/kamifusen/crud/security/PageValidator.kt @@ -0,0 +1,22 @@ +package io.tohuwabohu.kamifusen.crud.security + +import io.smallrye.mutiny.Uni +import io.tohuwabohu.kamifusen.crud.PageRepository + +enum class PageValidation(val valid: Boolean, val message: String? = null) { + VALID(true), + EMPTY(false, "Path must not be empty."), + EXISTS(false, "Page already exists."); +} + +fun validatePage(path: String, domain: String? = null, pageRepository: PageRepository): Uni { + return if (path.isBlank()) { + Uni.createFrom().item(PageValidation.EMPTY) + } else { + pageRepository.findPageByPath(path).map { page -> + if (page != null && page.domain == domain) { + PageValidation.EXISTS + } else PageValidation.VALID + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/tohuwabohu/kamifusen/crud/security/PasswordValidator.kt b/src/main/kotlin/io/tohuwabohu/kamifusen/crud/security/PasswordValidator.kt new file mode 100644 index 0000000..4f39c04 --- /dev/null +++ b/src/main/kotlin/io/tohuwabohu/kamifusen/crud/security/PasswordValidator.kt @@ -0,0 +1,22 @@ +package io.tohuwabohu.kamifusen.crud.security + +import io.smallrye.mutiny.Uni + +enum class PasswordValidation(val valid: Boolean, val message: String? = null) { + VALID(true), + EMPTY(false, "Your password is empty."), + TOO_SHORT(false, "Your password must be at least 8 characters long."), + NO_MATCH(false, "Your passwords do not match."); +} + +fun validatePassword(password: String, passwordConfirmation: String): Uni { + val result = if (password != passwordConfirmation) { + PasswordValidation.NO_MATCH + } else if (password.isBlank() || passwordConfirmation.isBlank()) { + PasswordValidation.EMPTY + } else if (password.length < 8) { + PasswordValidation.TOO_SHORT + } else PasswordValidation.VALID + + return Uni.createFrom().item(result) +} \ No newline at end of file diff --git a/src/main/kotlin/io/tohuwabohu/kamifusen/crud/security/UserValidator.kt b/src/main/kotlin/io/tohuwabohu/kamifusen/crud/security/UserValidator.kt new file mode 100644 index 0000000..062d676 --- /dev/null +++ b/src/main/kotlin/io/tohuwabohu/kamifusen/crud/security/UserValidator.kt @@ -0,0 +1,22 @@ +package io.tohuwabohu.kamifusen.crud.security + +import io.smallrye.mutiny.Uni +import io.tohuwabohu.kamifusen.crud.ApiUserRepository + +enum class UserValidation(val valid: Boolean, val message: String? = null) { + VALID(true), + EMPTY(false, "Name must not be empty."), + EXISTS(false, "Name is already taken."); +} + +fun validateUser(username: String, apiUserRepository: ApiUserRepository): Uni { + return if (username.isBlank()) { + Uni.createFrom().item(UserValidation.EMPTY) + } else { + apiUserRepository.findByUsername(username).map { user -> + if (user != null) { + UserValidation.EXISTS + } else UserValidation.VALID + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/tohuwabohu/kamifusen/security/IdentityProvider.kt b/src/main/kotlin/io/tohuwabohu/kamifusen/security/IdentityProvider.kt deleted file mode 100644 index ea3ff62..0000000 --- a/src/main/kotlin/io/tohuwabohu/kamifusen/security/IdentityProvider.kt +++ /dev/null @@ -1,100 +0,0 @@ -package io.tohuwabohu.kamifusen.security - -import io.quarkus.security.UnauthorizedException -import io.quarkus.security.identity.AuthenticationRequestContext -import io.quarkus.security.identity.IdentityProvider -import io.quarkus.security.identity.IdentityProviderManager -import io.quarkus.security.identity.SecurityIdentity -import io.quarkus.security.identity.request.AuthenticationRequest -import io.quarkus.security.runtime.QuarkusSecurityIdentity -import io.smallrye.mutiny.Uni -import io.tohuwabohu.kamifusen.crud.ApiKeyRepository -import jakarta.annotation.Priority -import jakarta.enterprise.context.ApplicationScoped -import jakarta.inject.Inject -import jakarta.ws.rs.Priorities -import jakarta.ws.rs.container.ContainerRequestContext -import jakarta.ws.rs.container.ContainerRequestFilter -import jakarta.ws.rs.core.SecurityContext -import jakarta.ws.rs.ext.Provider -import java.security.Principal - -class ApiKeyAuthenticationRequest(val apiKey: String) : AuthenticationRequest { - - private val attributes: MutableMap = mutableMapOf() - - override fun getAttribute(name: String?): T? { - @Suppress("UNCHECKED_CAST") - return attributes[name] as T? - } - - override fun setAttribute(name: String?, value: Any?) { - if (name != null && value != null) { - attributes[name] = value - } - } - - override fun getAttributes(): MutableMap { - return attributes - } -} - -@ApplicationScoped -class ApiKeyIdentityProvider( - private val apiKeyRepository: ApiKeyRepository -) : IdentityProvider { - - override fun authenticate( - credentials: ApiKeyAuthenticationRequest, - context: AuthenticationRequestContext - ): Uni { - return apiKeyRepository.findKey(credentials.apiKey).flatMap { apiKey -> - if (apiKey != null) { - Uni.createFrom().item(QuarkusSecurityIdentity.builder() - .addRole(apiKey.role) - .setPrincipal { credentials.apiKey } - .build() - ) - } else { - Uni.createFrom().failure(UnauthorizedException()) - } - } - } - - override fun getRequestType(): Class { - return ApiKeyAuthenticationRequest::class.java - } -} - -@Provider -@Priority(Priorities.AUTHENTICATION) -class ApiKeyFilter @Inject constructor( - private val identityProviderManager: IdentityProviderManager -) : ContainerRequestFilter { - - override fun filter(requestContext: ContainerRequestContext) { - val apiKey = requestContext.getHeaderString("Authorization") - ?: throw UnauthorizedException() - - val authRequest = ApiKeyAuthenticationRequest(apiKey) - val uni: Uni = identityProviderManager.authenticate(authRequest) - - // Block until result is available - uni.subscribe().with( - { identity -> requestContext.securityContext = ApiKeySecurityContext(identity) }, - { throw UnauthorizedException() } - ) - } -} - -class ApiKeySecurityContext( - private val securityIdentity: SecurityIdentity -) : SecurityContext { - override fun getUserPrincipal(): Principal = securityIdentity.principal - - override fun isUserInRole(role: String?) = securityIdentity.roles.contains(role) - - override fun isSecure() = true - - override fun getAuthenticationScheme() = "API_KEY" -} \ No newline at end of file diff --git a/src/main/kotlin/io/tohuwabohu/kamifusen/ssr/htmx-admin-page.kt b/src/main/kotlin/io/tohuwabohu/kamifusen/ssr/htmx-admin-page.kt new file mode 100644 index 0000000..4d66e5c --- /dev/null +++ b/src/main/kotlin/io/tohuwabohu/kamifusen/ssr/htmx-admin-page.kt @@ -0,0 +1,161 @@ +package io.tohuwabohu.kamifusen.ssr + +import kotlinx.html.* +import kotlinx.html.classes +import kotlinx.html.stream.createHTML + +private val navActiveClasses = setOf( + "rounded-md", + "px-3", + "py-2", + "text-sm", + "font-medium", + "text-gray-300", + "hover:bg-gray-700", + "hover:text-white", + "bg-gray-900", +) + +private val navInactiveClasses = setOf( + "rounded-md", + "px-3", + "py-2", + "text-sm", + "font-medium", + "text-gray-300", + "hover:bg-gray-700", + "hover:text-white" +) + +fun renderAdminPage(navId: String, isFirstTimeSetup: Boolean = false, block: FlowContent.() -> Unit) = createHTML().html { + attributes["lang"] = "EN" + classes = setOf("h-full", "bg-gray-100") + + head { + title("kamifusen - $navId") + script(src = "/scripts/htmx.min.js") {} + link(rel = "stylesheet", href = "/styles/main.css") + } + + body { + classes = setOf("h-full") + + div { + classes = setOf("min-h-full") + + renderNavigation(navId, isFirstTimeSetup) + + block() + } + } +} + +private fun FlowContent.renderNavigation(navId: String, isFirstTimeSetup: Boolean = false) = + nav { + classes = setOf("bg-gray-800") + + div { + classes = setOf("mx-auto", "max-w-7xl", "px-4", "sm:px-6", "lg:px-8") + + div { + classes = setOf("flex", "h-16", "items-center", "justify-between") + + div { + classes = setOf("flex", "items-center") + + div { + classes = setOf("flex-shrink-0") + style = "min-width: 32px;" + img(classes = "h-8 w-auto", src = "/static/images/kamifusen-logo.png", alt = "kamifusen logo") + } + + div { + classes = setOf("block") + + if (!isFirstTimeSetup) { + div { + classes = setOf("ml-10", "flex", "items-baseline", "space-x-4") + + a(href = "/dashboard") { + classes = when (navId) { + "Dashboard" -> navActiveClasses + else -> navInactiveClasses + } + + +"Dashboard" + } + + a(href = "/pages") { + classes = when (navId) { + "Pages" -> navActiveClasses + else -> navInactiveClasses + } + + +"Pages" + } + + a(href = "/users") { + classes = when (navId) { + "Users" -> navActiveClasses + else -> navInactiveClasses + } + + +"Users" + } + + a(href = "/stats") { + classes = when (navId) { + "Stats" -> navActiveClasses + else -> navInactiveClasses + } + + +"Stats" + } + } + } + } + } + + div { + classes = setOf("block") + + div { + classes = setOf("ml-4", "flex", "items-center", "md:ml-6") + + button(type = ButtonType.button) { + classes = setOf( + "relative", + "rounded-full", + "bg-gray-800", + "p-1", + "text-gray-400", + "hover:text-white", + "focus:outline-none", + "focus:ring-2", + "focus:ring-white", + "focus:ring-offset-2", + "focus:ring-offset-gray-800" + ) + + span { + classes = setOf("absolute", "-inset-1.5") + } + span { + classes = setOf("sr-only") + +"View notifications" + } + } + + div { + classes = setOf("ml-10", "flex", "items-baseline", "space-x-4") + a(href = "/logout") { + classes = navInactiveClasses + + +"Logout" + } + } + } + } + } + } + } \ No newline at end of file diff --git a/src/main/kotlin/io/tohuwabohu/kamifusen/ssr/htmx-components.kt b/src/main/kotlin/io/tohuwabohu/kamifusen/ssr/htmx-components.kt new file mode 100644 index 0000000..6207f51 --- /dev/null +++ b/src/main/kotlin/io/tohuwabohu/kamifusen/ssr/htmx-components.kt @@ -0,0 +1,206 @@ +package io.tohuwabohu.kamifusen.ssr + +import io.tohuwabohu.kamifusen.crud.security.PasswordValidation +import kotlinx.html.* + +private val passwordButtonStyles: Set = setOf( + "flex", + "w-full", + "justify-center", + "rounded-md", + "bg-gray-800", + "px-3", + "py-1.5", + "text-sm/6", + "font-semibold", + "text-white", + "shadow-sm", + "hover:bg-indigo-700", + "focus-visible:outline", + "focus-visible:outline-2", + "focus-visible:outline-offset-2", + "focus-visible:outline-indigo-900" +) + +private val passwordInputStyles: Set = setOf( + "block", + "w-full", + "rounded-md", + "border-0", + "py-1.5", + "text-gray-900", + "shadow-sm", + "ring-1", + "ring-inset", + "ring-gray-300", + "placeholder:text-gray-400", + "focus:ring-2", + "focus:ring-inset", + "focus:ring-indigo-600", + "sm:text-sm/6" +) + +/** + * Creates a header section with the given heading text. + * + * @param headingText The text to be displayed as the header's main heading. + */ +fun MAIN.contentHeader(headingText: String) = header { + classes = setOf("bg-white", "shadow") + + div { + classes = setOf("mx-auto", "max-w-7xl", "px-4", "py-6", "sm:px-6", "lg:px-8") + + h1 { + id = "content-heading" + + classes = setOf("text-3xl", "font-bold", "tracking-tight", "text-gray-900") + + +headingText + } + } +} + +/** + * Renders a div element with predefined CSS classes for styling and layout. + * + * @param block A lambda function containing the content to be placed inside the div element. + */ +fun MAIN.contentDiv(block: () -> Unit) = div { + classes = setOf("mx-auto", "max-w-7xl", "px-4", "py-6", "sm:px-6", "lg:px-8") + + block() +} + +/** + * Adds styled table header (th) cells with predefined classes for consistent styling. + * + * @param block A lambda function that defines the content to be placed inside the th element. + */ +fun TR.styledTh(block: () -> Unit) = th { + classes = setOf("bg-gray-800", "px-3", "py-2", "text-sm", "font-medium", "text-white") + + block() +} + +/** + * Creates a styled table cell (`td`) element with predefined CSS classes and applies the provided content block to it. + * + * @param block A lambda function that represents the content to be nested within the `td` element. + */ +fun TR.styledTd(block: () -> Unit) = td { + classes = setOf("px-3", "py-2", "text-sm", "font-medium") + + block() +} + +/** + * Handles the password setup flow within the application's first-time setup process. + * + * Displays a form for creating an admin password if validation is not successful. If the password + * has been successfully updated, it displays a success message along with a button to return to the login page. + * + * @param validation An optional validation result that indicates whether the password is valid. + * If validation is provided and is not valid, an error message is displayed. + */ +fun FlowContent.passwordFlow(validation: PasswordValidation? = null) = div { + classes = setOf("mt-10", "sm:mx-auto", "sm:w-full", "sm:max-w-sm") + + id = "first-time-setup" + + h2 { + classes = setOf("text-2xl", "font-bold", "tracking-tight", "text-gray-900") + + +"First time setup" + } + + if (validation?.valid == true) { + p { +"Successfully updated password!" } + div { + div { + a(href = "/logout") { + classes = passwordButtonStyles + + +"Back to login" + } + } + } + } else { + p { +"It seems like this is your first visit. Please set an admin password to proceed." } + + form { + classes = setOf("space-y-6") + + attributes["hx-post"] = "/fragment/register" + attributes["hx-swap"] = "outerHTML" + attributes["hx-target"] = "#first-time-setup" + attributes["hx-trigger"] = "submit" + + div { + div { + classes = setOf("flex", "items-center", "justify-between") + + label { + classes = setOf("block", "text-sm/6", "font-medium", "text-gray-900") + + attributes["for"] = "password" + + +"Password: " + } + } + div { + classes = setOf("mt-2") + + input(type = InputType.password) { + classes = passwordInputStyles + + id = "password" + name = "password" + required = true + } + } + + div { + classes = setOf("flex", "items-center", "justify-between") + + label { + classes = setOf("block", "text-sm/6", "font-medium", "text-gray-900") + + attributes["for"] = "password-confirm" + + +"Confirm password: " + } + } + + div { + classes = setOf("mt-2") + + input(type = InputType.password) { + classes = passwordInputStyles + + id = "password-confirm" + name = "password-confirm" + required = true + } + } + } + div { + button { + classes = passwordButtonStyles + + type = ButtonType.submit + + +"Set password" + } + } + + if (validation != null && !validation.valid) { + div { + classes = setOf("text-sm/6", "text-red-600") + + p { +validation.message!! } + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/tohuwabohu/kamifusen/ssr/htmx-extensions.kt b/src/main/kotlin/io/tohuwabohu/kamifusen/ssr/htmx-extensions.kt new file mode 100644 index 0000000..da2c9a2 --- /dev/null +++ b/src/main/kotlin/io/tohuwabohu/kamifusen/ssr/htmx-extensions.kt @@ -0,0 +1,52 @@ +package io.tohuwabohu.kamifusen.ssr + +import kotlinx.html.* +import kotlinx.html.attributes.enumEncode + +/** + * Creates a form element within the given HTML flow content block. The form can be configured + * with various attributes including action, encoding type, and method. Additional attributes related + * to htmx (hx-post, hx-target, hx-swap, and hx-trigger) can also be specified. + * + * @param action Specifies the URL to which the form data will be submitted. + * @param encType Specifies the encoding type of the form (e.g., application/x-www-form-urlencoded, multipart/form-data). + * @param method Specifies the HTTP method to use when sending form data (e.g., GET, POST). + * @param classes Specifies the CSS classes to be applied to the form element. + * @param hxPost Specifies a htmx attribute for making AJAX requests upon form submission. + * @param hxTarget Specifies the target element for content replacement via htmx. + * @param hxSwap Specifies how the response content should be swapped via htmx. + * @param hxTrigger Specifies the events that trigger the htmx requests. + * @param block Lambda function defining the content of the form. + * @return Unit + */ +@HtmlTagMarker +inline fun FlowContent.form( + action: String? = null, + encType: FormEncType? = null, + method: FormMethod? = null, + classes: String? = null, + hxPost: String? = null, + hxTarget: String? = null, + hxSwap: String? = null, + hxTrigger: String? = null, + crossinline block: FORM.() -> Unit = {} +): Unit = FORM( + attributesMapOf( + "action", + action, + "enctype", + encType?.enumEncode(), + "method", + method?.enumEncode(), + "class", + "hx-post", + hxPost, + "hx-target", + hxTarget, + "hx-swap", + hxSwap, + "hx-trigger", + hxTrigger, + classes + ), consumer +).visit(block) diff --git a/src/main/kotlin/io/tohuwabohu/kamifusen/ssr/htmx-fragments.kt b/src/main/kotlin/io/tohuwabohu/kamifusen/ssr/htmx-fragments.kt new file mode 100644 index 0000000..86a72b5 --- /dev/null +++ b/src/main/kotlin/io/tohuwabohu/kamifusen/ssr/htmx-fragments.kt @@ -0,0 +1,95 @@ +package io.tohuwabohu.kamifusen.ssr + +import io.tohuwabohu.kamifusen.crud.security.PageValidation +import io.tohuwabohu.kamifusen.crud.security.PasswordValidation +import io.tohuwabohu.kamifusen.crud.security.UserValidation +import jakarta.ws.rs.core.Response +import kotlinx.html.* +import kotlinx.html.stream.createHTML + +/** + * Renders the HTML content for the password setup flow based on the given validation result. + * The generated content includes forms for password entry and messages for different validation outcomes. + * + * @param validationResult An optional validation result of type [PasswordValidation]. If provided and the validation is not successful, + * an appropriate error message is displayed in the generated HTML content. + */ +fun renderPasswordFlow(validationResult: PasswordValidation?) = createHTML().div { + passwordFlow(validationResult) +} + +/** + * Renders an HTML button for the provided API key, allowing users to copy the key to their clipboard. + * + * @param keyRaw The raw API key string to be rendered in the button's click event. + */ +fun renderCreatedApiKey(keyRaw: String) = createHTML().button { + button { + id = "key" + classes = setOf("flex", "w-full", "p-2") + + onClick = "navigator.clipboard.writeText('$keyRaw')" + + span { + classes = setOf("tabler--key-filled") + } + + span { + classes = setOf("sr-only") + p { +"API Key: " } + } + + p { + +"Copy to clipboard" + } + } +} + +/** + * Renders the username validation error message and input field with error styling. + * + * @param validationResult The result of the username validation process containing the validation status and message. + */ +fun renderUsernameValidationError(validationResult: UserValidation) = createHTML().div { + id = "username" + + p ("sr-only") { + +validationResult.message!! + } + + input(InputType.text) { + classes = setOf("table-input-inline", "h-8", "!bg-red-200") + onFocus = "this.classList.remove('!bg-red-200')" + name = "username" + required = true + + placeholder = validationResult.message!! + } +} + +fun renderPageValidationError(validationResult: PageValidation) = createHTML().div { + id = "page" + + p ("sr-only") { + +validationResult.message!! + } + + input(InputType.text) { + classes = setOf("table-input-inline", "h-8", "!bg-red-200") + onFocus = "this.classList.remove('!bg-red-200')" + name = "path" + required = true + + placeholder = validationResult.message!! + } +} + +fun renderError(function: () -> Response.Status): String = createHTML().div { + id = "error" + h1 { +function().reasonPhrase } + p { +"Whoops. Something went wrong." } + button { + onClick = "window.location.href = '/'" + +"Go back" + } +} diff --git a/src/main/kotlin/io/tohuwabohu/kamifusen/ssr/htmx-routes.kt b/src/main/kotlin/io/tohuwabohu/kamifusen/ssr/htmx-routes.kt new file mode 100644 index 0000000..c13dc23 --- /dev/null +++ b/src/main/kotlin/io/tohuwabohu/kamifusen/ssr/htmx-routes.kt @@ -0,0 +1,368 @@ +package io.tohuwabohu.kamifusen.ssr + +import io.tohuwabohu.kamifusen.crud.ApiUser +import io.tohuwabohu.kamifusen.crud.Page +import io.tohuwabohu.kamifusen.crud.dto.PageVisitDto +import kotlinx.html.* +import java.time.format.DateTimeFormatter + +/** + * Determines the CSS class to apply to a row based on its index. + * + * @param index The index of the row. + * @return A set of CSS class names. + */ +private fun rowClass(index: Int): Set = when (index % 2 == 0) { + true -> setOf("bg-slate-100") + false -> setOf("bg-slate-300") +} + +private val displayDateFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd") +private val displayDateTimeFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + +/** + * Generates the main dashboard view for the admin user. + * + * @param adminUser The authenticated admin user for whom the dashboard is generated. + */ +fun FlowContent.dashboard(adminUser: ApiUser) = main { + id = "main-content" + + contentHeader("Dashboard") + contentDiv { + if (adminUser.updated != null) { + p { +"Welcome to kamifusen. Manage your pages and statistics here." } + + } else { + passwordFlow() + } + } +} + +/** + * Renders statistical data of page visits in an HTML table format. + * + * @param pageVisits List of page visit data transfer objects containing information about visits. + */ +fun FlowContent.stats(pageVisits: List) = main { + id = "main-content" + + contentHeader("Stats") + contentDiv { + table { + classes = setOf("table-auto", "rounded-md") + + thead { + tr { + styledTh { +"Path" } + styledTh { +"Domain" } + styledTh { +"Visits" } + styledTh { +"Added" } + } + } + tbody { + pageVisits.forEachIndexed { index, visit -> + tr { + classes = when (index % 2 == 0) { + true -> setOf("bg-slate-100") + false -> setOf("bg-slate-300") + } + + styledTd { +visit.path } + styledTd { +if(visit.domain == null) "" else visit.domain!! } + styledTd { +visit.visits.toString() } + styledTd { +visit.pageAdded.format(displayDateTimeFormat) } + } + } + } + } + } +} + +/** + * Adds and manages a list of pages for tracking within the main content area. + * + * @param pages List of pages to be displayed and managed in the table. + */ +fun FlowContent.pages(pages: List) = main { + id = "main-content" + + contentHeader("Pages") + contentDiv { + form { + p { +"Add pages to track or remove pages from tracking." } + p { +"If you use an API Key to track a page that does not show up here, visits won't be tracked." } + p { +"Add a domain for better oversight. Following best practise, each domain should have their own API Key." } + + table { + classes = setOf("table-auto", "rounded-md") + + thead { + tr { + styledTh { +"Path" } + styledTh { +"Domain" } + styledTh { +"Last Hit" } + styledTh { +"Added" } + styledTh { +"Action" } + } + } + tbody { + pages.forEachIndexed { index, page -> + tr { + classes = rowClass(index) + + styledTd { +page.path } + styledTd { +if(page.domain == null) "" else page.domain!! } + styledTd { + when (page.lastHit) { + null -> +"-" + else -> page.lastHit!!.format(displayDateFormat) + } + } + styledTd { +page.pageAdded.format(displayDateTimeFormat) } + + td { + classes = setOf("flex", "justify-center", "px-3", "py-2", "text-sm", "font-medium") + div { + button { + id = "delete" + + attributes["hx-swap"] = "outerHTML" + attributes["hx-target"] = "#delete" + attributes["hx-post"] = "/fragment/pagedel/${page.id}" + attributes["hx-confirm"] = + "This will remove tracking for this page. Do you want to proceed?" + + span { + classes = setOf("tabler--trash") + } + + span { + classes = setOf("sr-only") + p { +"Remove page" } + } + } + } + } + } + } + + tr { + classes = rowClass(pages.size) + + td { + div { + id = "path" + + input(InputType.text) { + classes = setOf("table-input-inline", "h-8") + + name = "path" + required = true + } + } + } + + td { + div { + id = "domain" + + input(InputType.text) { + classes = setOf("table-input-inline", "h-8") + + name = "domain" + required = false + } + } + } + td {} + td {} + + td { + classes = setOf("flex", "justify-center", "px-3", "py-2", "text-sm", "font-medium") + div { + button { + id = "add" + + attributes["hx-swap"] = "outerHTML" + attributes["hx-target"] = "#add" + attributes["hx-post"] = "/fragment/pageadd" + + span { + classes = setOf("tabler--world-plus") + } + + span { + classes = setOf("sr-only") + p { +"Add page" } + } + } + } + } + } + } + } + } + } +} + +/** + * Renders a HTML content block displaying a table of API users with functionality to issue and revoke API keys. + * + * @param users A list of `ApiUser` objects to be displayed in the table. Each user includes details such as username, role, + * expiration date, and the date they were added. The table also provides actions to retire or regenerate API keys + * for the users. + */ +fun FlowContent.users(users: List) = main { + id = "main-content" + + contentHeader("Users") + contentDiv { + div { + classes = setOf("p-2") + + p { +"You can issue and revoke new API Keys here. Immediately copy and distribute the API Key after generation, you will be able to do that only once." } + } + + form { + table { + classes = setOf("table-auto", "rounded-md") + + thead { + tr { + styledTh { +"Username" } + styledTh { +"Role" } + styledTh { +"Expires" } + styledTh { +"Added" } + styledTh { +"Actions" } + } + } + + tbody { + users.forEachIndexed { index, user -> + tr { + id = "user-${user.id.toString()}" + + classes = rowClass(index) + + styledTd { +user.username } + styledTd { +user.role } + styledTd { + when (user.expiresAt) { + null -> +"-" + else -> +user.expiresAt!!.format(displayDateTimeFormat) + } + } + styledTd { + when (user.added) { + null -> +"-" + else -> +user.added!!.format(displayDateFormat) + } + } + td { + classes = setOf("flex", "justify-center", "px-3", "py-2", "text-sm", "font-medium") + + if (user.username != "admin") { + button { + if (user.expiresAt == null) { + attributes["hx-post"] = "/fragment/retire/${user.id}" + attributes["hx-swap"] = "outerHTML" + attributes["hx-target"] = "#user-${user.id.toString()}" + attributes["hx-confirm"] = + "This will revoke any access granted by this API Key. Do you want to proceed?" + + span { + classes = setOf("tabler--key-off") + } + + span { + classes = setOf("sr-only") + p { +"Retire user" } + } + } else { + attributes["hx-post"] = "/fragment/refresh/${user.id}" + attributes["hx-swap"] = "outerHTML" + attributes["hx-target"] = "#user-${user.id.toString()}" + + span { + classes = setOf("tabler--refresh") + } + + span { + classes = setOf("sr-only") + p { +"Regenerate" } + } + } + } + } + } + } + } + + tr { + classes = rowClass(users.size) + + td { + div { + id = "username" + + input(InputType.text) { + classes = setOf("table-input-inline", "h-8") + + name = "username" + required = true + } + } + } + td { + div { + id = "role" + + select { + classes = setOf("table-input-inline", "h-8") + + name = "role" + + option { +"api-user" } + option { +"api-admin" } + } + } + } + td {} + td { + div { + input(InputType.date) { + classes = setOf("table-input-inline", "h-8") + + name = "expiresAt" + required = false + } + } + } + td { + classes = setOf("flex", "justify-center", "px-3", "py-2", "text-sm", "font-medium") + div { + button { + id = "key" + + attributes["hx-swap"] = "outerHTML" + attributes["hx-target"] = "#key" + attributes["hx-post"] = "/fragment/keygen" + + span { + classes = setOf("tabler--user-plus") + } + + span { + classes = setOf("sr-only") + p { +"Add user" } + } + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/tohuwabohu/kamifusen/ssr/response/htmx-errors.kt b/src/main/kotlin/io/tohuwabohu/kamifusen/ssr/response/htmx-errors.kt new file mode 100644 index 0000000..27121f2 --- /dev/null +++ b/src/main/kotlin/io/tohuwabohu/kamifusen/ssr/response/htmx-errors.kt @@ -0,0 +1,32 @@ +package io.tohuwabohu.kamifusen.ssr.response + +import io.quarkus.logging.Log +import io.smallrye.common.annotation.CheckReturnValue +import io.smallrye.mutiny.Uni +import io.smallrye.mutiny.groups.UniOnFailure +import io.tohuwabohu.kamifusen.ssr.renderError +import jakarta.ws.rs.core.Response + +/** + * Creates an HTMX response containing an error message based on the given status. + * The error message is rendered on a
element with id `"error"`. + * + * @param status The HTTP status to be included in the error response. + * @return A `Uni` that emits the `Response` containing the HTML error message. + */ +fun createHtmxErrorResponse(status: Response.Status): Uni { + return Uni.createFrom().item(Response.status(Response.Status.PARTIAL_CONTENT).entity(renderError { status }).build()) +} + +/** + * Recovers from a failure by returning a structured HTMX error response. Logs the exception + * and wraps the result of `createHtmxErrorResponse` into a `Response`. + * + * @param status The HTTP status to be included in the error response. + * @return A `Uni` instance that emits a `Response` containing the HTMX error message. + */ +@CheckReturnValue +fun UniOnFailure.recoverWithHtmxResponse(status: Response.Status): Uni { + return this.invoke { e -> Log.error("Error occured: ${status.statusCode}", e) }.onFailure() + .recoverWithUni(createHtmxErrorResponse(status)) +} diff --git a/src/main/resources/META-INF/resources/index.html b/src/main/resources/META-INF/resources/index.html new file mode 100644 index 0000000..abd18b3 --- /dev/null +++ b/src/main/resources/META-INF/resources/index.html @@ -0,0 +1,65 @@ + + + + kamifusen + + + + +
+
+ Your Company +

+ Login +

+
+ +
+
+
+ + +
+ + +
+
+ +
+
+ +
+ +
+
+
+
+ +
+

+ © 2024 tohuwabohu.io +

+
+
+
+ + \ No newline at end of file diff --git a/src/main/resources/META-INF/resources/scripts/htmx.min.js b/src/main/resources/META-INF/resources/scripts/htmx.min.js new file mode 100644 index 0000000..423cf01 --- /dev/null +++ b/src/main/resources/META-INF/resources/scripts/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.3"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=de;Q.ajax=Rn;Q.find=r;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=h;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:dn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:i,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:dt,triggerEvent:de,triggerErrorEvent:fe,withExtensions:Ft};const o=["get","post","put","delete","patch"];const R=o.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function h(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function i(e,t){while(e&&!t(e)){e=c(e)}return e||null}function H(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;i(t,function(e){return!!(r=H(t,ue(e),n))});if(r!=="unset"){return r}}function d(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function N(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function A(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(A(e)){const t=N(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){C(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){const t=e.getRootNode&&e.getRootNode();if(t&&t instanceof window.ShadowRoot){return ne().body.contains(t.host)}else{return ne().body.contains(e)}}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){C(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function r(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return r(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||d(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(e,t,n){e=y(e);if(t.indexOf("closest ")===0){return[g(ue(e),ge(t.substr(8)))]}else if(t.indexOf("find ")===0){return[r(f(e),ge(t.substr(5)))]}else if(t==="next"){return[ue(e).nextElementSibling]}else if(t.indexOf("next ")===0){return[pe(e,ge(t.substr(5)),!!n)]}else if(t==="previous"){return[ue(e).previousElementSibling]}else if(t.indexOf("previous ")===0){return[me(e,ge(t.substr(9)),!!n)]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else if(t==="root"){return[m(e,!!n)]}else if(t==="host"){return[e.getRootNode().host]}else if(t.indexOf("global ")===0){return p(e,t.slice(7),true)}else{return M(f(m(e,!!n)).querySelectorAll(ge(t)))}}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return r(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){C('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(i(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substr(0,e.indexOf(":"));n=e.substr(e.indexOf(":")+1,e.length)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!de(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){de(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=r("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=r("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=r("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","
");e=r("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ne(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ae(f(e));de(e,"htmx:load")}}function Ae(e){const t="[autofocus]";const n=$(d(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function u(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ne(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}de(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function w(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=w(e,Qe).trim();e.shift()}else{t=w(e,b)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{w(o,v);const l=o.length;const c=w(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};w(o,v);u.pollInterval=h(w(o,/[,\[\s]/));w(o,v);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}w(o,v);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=h(w(o,b))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=w(o,b);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const d=rt(o);if(d.length>0){s+=" "+d}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=h(w(o,b))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=w(o,b)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=w(o,b)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}w(o,v)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}w(o,v)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(d(e,"form")){return[{trigger:"submit"}]}else if(d(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(d(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){a(n);return}he(r,o,n,t)},n,e,true)})}}function dt(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(d(n,'input[type="submit"], button')&&g(n,"form")!==null){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function ht(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(ht(l,e)){return}if(a||dt(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!d(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){de(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){de(l,"htmx:trigger");c(l,e)},u.delay)}else{de(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){de(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){de(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(o,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){a(n);return}he(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function Nt(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function At(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){de(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;de(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;de(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){de(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});de(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!d(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:Nn(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function dn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.substr(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function hn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!hn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.substr(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const d=l.substr("focus-scroll:".length);r.focusScroll=d=="true"}else if(e==0){r.swapStyle=l}else{C("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||d(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.substr(11);t=true}else if(e.indexOf("js:")===0){e=e.substr(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function O(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return he(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||!e&&!y(r.source)){e=ve}return he(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return de(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function Nn(r){return new Proxy(r,{get:function(e,t){if(typeof t==="symbol"){return Reflect.get(e,t)}if(t==="toJSON"){return()=>Object.fromEntries(r)}if(t in e){if(typeof e[t]==="function"){return function(){return r[t].apply(r,arguments)}}else{return e[t]}}const n=r.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function he(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const N=ee(a,"formmethod");if(N!=null){if(N.toLowerCase()!=="dialog"){t=N}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return he(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(de(r,"htmx:confirm",G)===false){oe(s);return e}}let d=r;let h=re(r,"hx-sync");let g=null;let F=false;if(h){const A=h.split(":");const I=A[0].trim();if(I==="this"){d=Se(r,"hx-sync")}else{d=ue(ae(r,I))}h=(A[1]||"drop").trim();u=ie(d);if(h==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(h==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(h==="replace"){de(d,"htmx:abort")}else if(h.indexOf("queue")===0){const W=h.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){de(d,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){he(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!de(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=dn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:Nn(w),unfilteredFormData:v,unfilteredParameters:Nn(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!de(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){de(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}de(r,"htmx:afterRequest",H);de(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){de(e,"htmx:afterRequest",H);de(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!de(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){de(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});de(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function An(e,t){const n=t.xhr;let r=null;let o=null;if(O(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(O(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(O(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){de(e,"htmx:restored",{document:ne(),triggerEvent:de})})}else{if(n){n(e)}}};E().setTimeout(function(){de(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/src/main/resources/META-INF/resources/static/images/kamifusen-logo.png b/src/main/resources/META-INF/resources/static/images/kamifusen-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..4fb618c1b830f3225a7d828447b6f6ea1f8c3eb9 GIT binary patch literal 52886 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 b8WjHzxLDj4t:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1.5rem*var(--tw-space-y-reverse));margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)))}.self-center{align-self:center}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border-0{border-width:0}.\!bg-red-200{--tw-bg-opacity:1!important;background-color:rgb(254 202 202/var(--tw-bg-opacity))!important}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity))}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity))}.bg-slate-300{--tw-bg-opacity:1;background-color:rgb(203 213 225/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.p-1{padding:.25rem}.p-2{padding:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.text-center{text-align:center}.text-2xl{font-size:1.5rem;line-height:2rem}.text-2xl\/9{font-size:1.5rem;line-height:2.25rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-sm\/6{font-size:.875rem;line-height:1.5rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.tracking-tight{letter-spacing:-.025em}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-inset{--tw-ring-inset:inset}.ring-gray-300{--tw-ring-opacity:1;--tw-ring-color:rgb(209 213 219/var(--tw-ring-opacity))}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } +/*! tailwindcss v3.4.14 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-radius:0;border-width:1px;font-size:1rem;line-height:1.5rem;padding:.5rem .75rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);border-color:#2563eb;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;background-origin:border-box;border-color:#6b7280;border-width:1px;color:#2563eb;display:inline-block;flex-shrink:0;height:1rem;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;width:1rem;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{background-color:currentColor;border-color:transparent}[type=checkbox]:indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{background-color:currentColor;border-color:transparent}[type=file]{background:unset;border-color:inherit;border-radius:0;border-width:0;font-size:unset;line-height:inherit;padding:0}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}@tailwind forms;.table-input-inline{background:transparent;border-width:0 0 1px}.tabler--key-off{--svg:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m10.17 6.159 2.316-2.316a2.877 2.877 0 0 1 4.069 0l3.602 3.602a2.877 2.877 0 0 1 0 4.069l-2.33 2.33m-2.896 1.104a2.86 2.86 0 0 1-1.486-.79l-.301-.302-6.558 6.558a2 2 0 0 1-1.239.578L5.172 21H4a1 1 0 0 1-.993-.883L3 20v-1.172a2 2 0 0 1 .467-1.284l.119-.13L4 17h2v-2h2v-2l2.144-2.144-.301-.301a2.86 2.86 0 0 1-.794-1.504M15 9h.01M3 3l18 18'/%3E%3C/svg%3E")}.tabler--key-off,.tabler--user-plus{background-color:currentColor;display:inline-block;height:1.25em;-webkit-mask-image:var(--svg);mask-image:var(--svg);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;width:1.25em}.tabler--user-plus{--svg:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M8 7a4 4 0 1 0 8 0 4 4 0 0 0-8 0m8 12h6m-3-3v6M6 21v-2a4 4 0 0 1 4-4h4'/%3E%3C/svg%3E")}.tabler--key-filled{--svg:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M14.52 2c1.029 0 2.015.409 2.742 1.136l3.602 3.602a3.877 3.877 0 0 1 0 5.483l-2.643 2.643a3.88 3.88 0 0 1-4.941.452l-.105-.078-5.882 5.883a3 3 0 0 1-1.68.843l-.22.027-.221.009H4c-1.014 0-1.867-.759-1.991-1.823L2 20v-1.172c0-.704.248-1.386.73-1.96l.149-.161.414-.414A1 1 0 0 1 4 16h1v-1a1 1 0 0 1 .883-.993L6 14h1v-1a1 1 0 0 1 .206-.608l.087-.1 1.468-1.469-.076-.103a3.9 3.9 0 0 1-.678-1.963L8 8.521c0-1.029.409-2.015 1.136-2.742l2.643-2.643A3.88 3.88 0 0 1 14.52 2m.495 5h-.02a2 2 0 1 0 0 4h.02a2 2 0 1 0 0-4'/%3E%3C/svg%3E")}.tabler--clipboard,.tabler--key-filled{background-color:currentColor;display:inline-block;height:1.25em;-webkit-mask-image:var(--svg);mask-image:var(--svg);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;width:1.25em}.tabler--clipboard{--svg:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2'/%3E%3Cpath d='M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v0a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2'/%3E%3C/g%3E%3C/svg%3E")}.tabler--refresh{--svg:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4'/%3E%3C/svg%3E")}.tabler--refresh,.tabler--world-plus{background-color:currentColor;display:inline-block;height:1.25em;-webkit-mask-image:var(--svg);mask-image:var(--svg);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;width:1.25em}.tabler--world-plus{--svg:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='M20.985 12.518a9 9 0 1 0-8.45 8.466M3.6 9h16.8M3.6 15H15'/%3E%3Cpath d='M11.5 3a17 17 0 0 0 0 18m1-18a17 17 0 0 1 2.283 12.157M16 19h6m-3-3v6'/%3E%3C/g%3E%3C/svg%3E")}.tabler--world-cancel{--svg:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='M21 12a9 9 0 1 0-8.985 9M3.6 9h16.8M3.6 15h9.9'/%3E%3Cpath d='M11.5 3a17 17 0 0 0 0 18m1-18a17 17 0 0 1 2.53 10.275M16 19a3 3 0 1 0 6 0 3 3 0 1 0-6 0m1 2 4-4'/%3E%3C/g%3E%3C/svg%3E")}.tabler--trash,.tabler--world-cancel{background-color:currentColor;display:inline-block;height:1.25em;-webkit-mask-image:var(--svg);mask-image:var(--svg);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;width:1.25em}.tabler--trash{--svg:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3'/%3E%3C/svg%3E")}.tabler--brand-github{--svg:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M9 19c-4.3 1.4-4.3-2.5-6-3m12 5v-3.5c0-1 .1-1.4-.5-2 2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2 4.2 4.2 0 0 0-.1-3.2s-1.1-.3-3.5 1.3a12.3 12.3 0 0 0-6.2 0C6.5 2.8 5.4 3.1 5.4 3.1a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 4 9.5c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21'/%3E%3C/svg%3E")}.tabler--brand-github,.tabler--world-www{background-color:currentColor;display:inline-block;height:1.25em;-webkit-mask-image:var(--svg);mask-image:var(--svg);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%;width:1.25em}.tabler--world-www{--svg:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='M19.5 7A9 9 0 0 0 12 3a8.99 8.99 0 0 0-7.484 4'/%3E%3Cpath d='M11.5 3a17 17 0 0 0-1.826 4M12.5 3a17 17 0 0 1 1.828 4M19.5 17a9 9 0 0 1-7.5 4 8.99 8.99 0 0 1-7.484-4'/%3E%3Cpath d='M11.5 21a17 17 0 0 1-1.826-4m2.826 4a17 17 0 0 0 1.828-4M2 10l1 4 1.5-4L6 14l1-4m10 0 1 4 1.5-4 1.5 4 1-4M9.5 10l1 4 1.5-4 1.5 4 1-4'/%3E%3C/g%3E%3C/svg%3E")}.placeholder\:text-gray-400::-moz-placeholder{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.placeholder\:text-gray-400::placeholder{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}.hover\:bg-indigo-700:hover{--tw-bg-opacity:1;background-color:rgb(67 56 202/var(--tw-bg-opacity))}.hover\:text-indigo-500:hover{--tw-text-opacity:1;color:rgb(99 102 241/var(--tw-text-opacity))}.hover\:text-indigo-600:hover{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-inset:focus{--tw-ring-inset:inset}.focus\:ring-gray-600:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(75 85 99/var(--tw-ring-opacity))}.focus\:ring-indigo-600:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(79 70 229/var(--tw-ring-opacity))}.focus\:ring-white:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(255 255 255/var(--tw-ring-opacity))}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.focus\:ring-offset-gray-800:focus{--tw-ring-offset-color:#1f2937}.focus-visible\:outline:focus-visible{outline-style:solid}.focus-visible\:outline-2:focus-visible{outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-indigo-900:focus-visible{outline-color:#312e81}@media (min-width:640px){.sm\:mx-auto{margin-left:auto;margin-right:auto}.sm\:w-full{width:100%}.sm\:max-w-sm{max-width:24rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:text-sm\/6{font-size:.875rem;line-height:1.5rem}}@media (min-width:768px){.md\:ml-6{margin-left:1.5rem}}@media (min-width:1024px){.lg\:px-8{padding-left:2rem;padding-right:2rem}} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 12b0d49..8d80b15 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,7 +1,6 @@ quarkus.application.name=kamifusen quarkus.application.version=0.0.1 - quarkus.datasource.db-kind=postgresql quarkus.datasource.username=kamifusen quarkus.datasource.password=kamifusen @@ -14,4 +13,27 @@ quarkus.hibernate-orm.dialect=org.hibernate.dialect.PostgreSQLDialect quarkus.live-reload.instrumentation=true -quarkus.jacoco.excludes=io/tohuwabohu/kamifusen/crud/* \ No newline at end of file +quarkus.jacoco.excludes=io/tohuwabohu/kamifusen/crud/* + +quarkus.http.auth.basic=true +quarkus.http.auth.proactive=true +quarkus.http.auth.permission.basic.paths=/public/* +quarkus.http.auth.permission.basic.policy=authenticated + +quarkus.http.auth.form.enabled=true +quarkus.http.auth.form.landing-page=/dashboard +quarkus.http.auth.form.login-page=/index.html +quarkus.http.auth.form.error-page= +quarkus.http.auth.form.post-location=/login +quarkus.http.auth.form.username-parameter=username +quarkus.http.auth.form.password-parameter=password +quarkus.http.auth.form.http-only-cookie=true +quarkus.http.auth.form.cookie-name=AuthToken +quarkus.http.auth.form.timeout=P1D +quarkus.http.auth.form.new-cookie-interval=PT1M +quarkus.http.auth.form.cookie-same-site=strict +quarkus.http.auth.form.cookie-path=/ +quarkus.http.auth.permission.form.paths=/ +quarkus.http.auth.permission.form.policy=authenticated + +quarkus.http.auth.session.encryption-key=encryption-key-3000 diff --git a/src/main/resources/import.sql b/src/main/resources/import.sql new file mode 100644 index 0000000..aa791bf --- /dev/null +++ b/src/main/resources/import.sql @@ -0,0 +1,73 @@ +insert into api_user (id, username, role, password) values (gen_random_uuid(), 'admin', 'app-admin', '$2a$10$1M/kyr.zOz6y9Owsp8qDUul1RmUfaI0zapjZED4wdwO1nLZ3Jz7OW'); + +insert into page (id, path, page_added) values ('9f685bd0-90e6-479a-99b6-2fad28d2a641', '/test/path-1', NOW()); +insert into page (id, path, page_added) values ('9f685bd0-90e6-479a-99b6-2fad28d2a642', '/test/path-2', NOW()); +insert into page (id, path, page_added) values ('9f685bd0-90e6-479a-99b6-2fad28d2a643', '/test/path-3', NOW()); +insert into page (id, path, page_added) values ('9f685bd0-90e6-479a-99b6-2fad28d2a644', '/test/path-4', NOW()); +insert into page (id, path, page_added) values ('9f685bd0-90e6-479a-99b6-2fad28d2a645', '/test/path-5', NOW()); +insert into page (id, path, page_added) values ('9f685bd0-90e6-479a-99b6-2fad28d2a646', '/test/path-6', NOW()); +insert into page (id, path, page_added) values ('9f685bd0-90e6-479a-99b6-2fad28d2a647', '/test/path-7', NOW()); +insert into page (id, path, page_added) values ('9f685bd0-90e6-479a-99b6-2fad28d2a648', '/test/path-8', NOW()); + +insert into visitor (id, info) VALUES ('9f685bd0-90e6-479a-99b6-2fad28d2a641', 'redacted'); +insert into visitor (id, info) VALUES ('9f685bd0-90e6-479a-99b6-2fad28d2a642', 'redacted'); +insert into visitor (id, info) VALUES ('9f685bd0-90e6-479a-99b6-2fad28d2a643', 'redacted'); +insert into visitor (id, info) VALUES ('9f685bd0-90e6-479a-99b6-2fad28d2a644', 'redacted'); +insert into visitor (id, info) VALUES ('9f685bd0-90e6-479a-99b6-2fad28d2a645', 'redacted'); +insert into visitor (id, info) VALUES ('9f685bd0-90e6-479a-99b6-2fad28d2a646', 'redacted'); +insert into visitor (id, info) VALUES ('9f685bd0-90e6-479a-99b6-2fad28d2a647', 'redacted'); +insert into visitor (id, info) VALUES ('9f685bd0-90e6-479a-99b6-2fad28d2a648', 'redacted'); +insert into visitor (id, info) VALUES ('9f685bd0-90e6-479a-99b6-2fad28d2a649', 'redacted'); +insert into visitor (id, info) VALUES ('9f685bd0-90e6-479a-99b6-2fad28d2a640', 'redacted'); + +-- 7 visitors for the first page +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a641', '9f685bd0-90e6-479a-99b6-2fad28d2a641'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a641', '9f685bd0-90e6-479a-99b6-2fad28d2a642'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a641', '9f685bd0-90e6-479a-99b6-2fad28d2a643'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a641', '9f685bd0-90e6-479a-99b6-2fad28d2a644'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a641', '9f685bd0-90e6-479a-99b6-2fad28d2a645'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a641', '9f685bd0-90e6-479a-99b6-2fad28d2a646'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a641', '9f685bd0-90e6-479a-99b6-2fad28d2a647'); + +-- 2 visitors for the second page +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a642', '9f685bd0-90e6-479a-99b6-2fad28d2a648'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a642', '9f685bd0-90e6-479a-99b6-2fad28d2a649'); + +-- 1 visitor for the third page +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a643', '9f685bd0-90e6-479a-99b6-2fad28d2a640'); + +-- 9 visitors for the fourth page +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a644', '9f685bd0-90e6-479a-99b6-2fad28d2a641'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a644', '9f685bd0-90e6-479a-99b6-2fad28d2a642'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a644', '9f685bd0-90e6-479a-99b6-2fad28d2a643'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a644', '9f685bd0-90e6-479a-99b6-2fad28d2a645'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a644', '9f685bd0-90e6-479a-99b6-2fad28d2a646'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a644', '9f685bd0-90e6-479a-99b6-2fad28d2a647'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a644', '9f685bd0-90e6-479a-99b6-2fad28d2a648'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a644', '9f685bd0-90e6-479a-99b6-2fad28d2a649'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a644', '9f685bd0-90e6-479a-99b6-2fad28d2a640'); + +-- 4 visitors for the fifth page +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a645', '9f685bd0-90e6-479a-99b6-2fad28d2a641'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a645', '9f685bd0-90e6-479a-99b6-2fad28d2a642'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a645', '9f685bd0-90e6-479a-99b6-2fad28d2a643'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a645', '9f685bd0-90e6-479a-99b6-2fad28d2a644'); + +-- 3 visitors for the sixth page +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a646', '9f685bd0-90e6-479a-99b6-2fad28d2a645'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a646', '9f685bd0-90e6-479a-99b6-2fad28d2a646'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a646', '9f685bd0-90e6-479a-99b6-2fad28d2a647'); + +-- 3 visitors for the seventh page +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a647', '9f685bd0-90e6-479a-99b6-2fad28d2a648'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a647', '9f685bd0-90e6-479a-99b6-2fad28d2a649'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a647', '9f685bd0-90e6-479a-99b6-2fad28d2a640'); + +-- 7 visitors for the eighth page +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a648', '9f685bd0-90e6-479a-99b6-2fad28d2a641'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a648', '9f685bd0-90e6-479a-99b6-2fad28d2a642'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a648', '9f685bd0-90e6-479a-99b6-2fad28d2a643'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a648', '9f685bd0-90e6-479a-99b6-2fad28d2a644'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a648', '9f685bd0-90e6-479a-99b6-2fad28d2a645'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a648', '9f685bd0-90e6-479a-99b6-2fad28d2a646'); +insert into page_visit (page_id, visitor_id) values ('9f685bd0-90e6-479a-99b6-2fad28d2a648', '9f685bd0-90e6-479a-99b6-2fad28d2a647'); \ No newline at end of file diff --git a/src/main/resources/input.css b/src/main/resources/input.css new file mode 100644 index 0000000..c35cb4b --- /dev/null +++ b/src/main/resources/input.css @@ -0,0 +1,152 @@ +@tailwind components; +@tailwind utilities; +@tailwind base; +@tailwind forms; + + +/* input elements inside tables (pages, user) */ +.table-input-inline { + border-width: 0 0 1px 0; + background: transparent; +} + +/* button icons for key/user management */ +.tabler--key-off { + display: inline-block; + width: 1.25em; + height: 1.25em; + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m10.17 6.159l2.316-2.316a2.877 2.877 0 0 1 4.069 0l3.602 3.602a2.877 2.877 0 0 1 0 4.069l-2.33 2.33m-2.896 1.104a2.86 2.86 0 0 1-1.486-.79l-.301-.302l-6.558 6.558a2 2 0 0 1-1.239.578L5.172 21H4a1 1 0 0 1-.993-.883L3 20v-1.172a2 2 0 0 1 .467-1.284l.119-.13L4 17h2v-2h2v-2l2.144-2.144l-.301-.301a2.86 2.86 0 0 1-.794-1.504M15 9h.01M3 3l18 18'/%3E%3C/svg%3E"); + background-color: currentColor; + -webkit-mask-image: var(--svg); + mask-image: var(--svg); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + +.tabler--user-plus { + display: inline-block; + width: 1.25em; + height: 1.25em; + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M8 7a4 4 0 1 0 8 0a4 4 0 0 0-8 0m8 12h6m-3-3v6M6 21v-2a4 4 0 0 1 4-4h4'/%3E%3C/svg%3E"); + background-color: currentColor; + -webkit-mask-image: var(--svg); + mask-image: var(--svg); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + +.tabler--key-filled { + display: inline-block; + width: 1.25em; + height: 1.25em; + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M14.52 2c1.029 0 2.015.409 2.742 1.136l3.602 3.602a3.877 3.877 0 0 1 0 5.483l-2.643 2.643a3.88 3.88 0 0 1-4.941.452l-.105-.078l-5.882 5.883a3 3 0 0 1-1.68.843l-.22.027l-.221.009H4c-1.014 0-1.867-.759-1.991-1.823L2 20v-1.172c0-.704.248-1.386.73-1.96l.149-.161l.414-.414A1 1 0 0 1 4 16h1v-1a1 1 0 0 1 .883-.993L6 14h1v-1a1 1 0 0 1 .206-.608l.087-.1l1.468-1.469l-.076-.103a3.9 3.9 0 0 1-.678-1.963L8 8.521c0-1.029.409-2.015 1.136-2.742l2.643-2.643A3.88 3.88 0 0 1 14.52 2m.495 5h-.02a2 2 0 1 0 0 4h.02a2 2 0 1 0 0-4'/%3E%3C/svg%3E"); + background-color: currentColor; + -webkit-mask-image: var(--svg); + mask-image: var(--svg); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + +.tabler--clipboard { + display: inline-block; + width: 1.25em; + height: 1.25em; + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2'/%3E%3Cpath d='M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v0a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2'/%3E%3C/g%3E%3C/svg%3E"); + background-color: currentColor; + -webkit-mask-image: var(--svg); + mask-image: var(--svg); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + +.tabler--refresh { + display: inline-block; + width: 1.25em; + height: 1.25em; + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4'/%3E%3C/svg%3E"); + background-color: currentColor; + -webkit-mask-image: var(--svg); + mask-image: var(--svg); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + +.tabler--world-plus { + display: inline-block; + width: 1.25em; + height: 1.25em; + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='M20.985 12.518a9 9 0 1 0-8.45 8.466M3.6 9h16.8M3.6 15H15'/%3E%3Cpath d='M11.5 3a17 17 0 0 0 0 18m1-18a17 17 0 0 1 2.283 12.157M16 19h6m-3-3v6'/%3E%3C/g%3E%3C/svg%3E"); + background-color: currentColor; + -webkit-mask-image: var(--svg); + mask-image: var(--svg); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + +.tabler--world-cancel { + display: inline-block; + width: 1.25em; + height: 1.25em; + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='M21 12a9 9 0 1 0-8.985 9M3.6 9h16.8M3.6 15h9.9'/%3E%3Cpath d='M11.5 3a17 17 0 0 0 0 18m1-18a17 17 0 0 1 2.53 10.275M16 19a3 3 0 1 0 6 0a3 3 0 1 0-6 0m1 2l4-4'/%3E%3C/g%3E%3C/svg%3E"); + background-color: currentColor; + -webkit-mask-image: var(--svg); + mask-image: var(--svg); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + +.tabler--trash { + display: inline-block; + width: 1.25em; + height: 1.25em; + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3'/%3E%3C/svg%3E"); + background-color: currentColor; + -webkit-mask-image: var(--svg); + mask-image: var(--svg); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + +.tabler--brand-github { + display: inline-block; + width: 1.25em; + height: 1.25em; + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M9 19c-4.3 1.4-4.3-2.5-6-3m12 5v-3.5c0-1 .1-1.4-.5-2c2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2a4.2 4.2 0 0 0-.1-3.2s-1.1-.3-3.5 1.3a12.3 12.3 0 0 0-6.2 0C6.5 2.8 5.4 3.1 5.4 3.1a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 4 9.5c0 4.6 2.7 5.7 5.5 6c-.6.6-.6 1.2-.5 2V21'/%3E%3C/svg%3E"); + background-color: currentColor; + -webkit-mask-image: var(--svg); + mask-image: var(--svg); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} + +.tabler--world-www { + display: inline-block; + width: 1.25em; + height: 1.25em; + --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='M19.5 7A9 9 0 0 0 12 3a8.99 8.99 0 0 0-7.484 4'/%3E%3Cpath d='M11.5 3a17 17 0 0 0-1.826 4M12.5 3a17 17 0 0 1 1.828 4M19.5 17a9 9 0 0 1-7.5 4a8.99 8.99 0 0 1-7.484-4'/%3E%3Cpath d='M11.5 21a17 17 0 0 1-1.826-4m2.826 4a17 17 0 0 0 1.828-4M2 10l1 4l1.5-4L6 14l1-4m10 0l1 4l1.5-4l1.5 4l1-4M9.5 10l1 4l1.5-4l1.5 4l1-4'/%3E%3C/g%3E%3C/svg%3E"); + background-color: currentColor; + -webkit-mask-image: var(--svg); + mask-image: var(--svg); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; +} diff --git a/src/main/resources/tailwind.config.js b/src/main/resources/tailwind.config.js new file mode 100644 index 0000000..db53c59 --- /dev/null +++ b/src/main/resources/tailwind.config.js @@ -0,0 +1,9 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ "META-INF/resources/*.html", "../kotlin/io/tohuwabohu/kamifusen/**/htmx*.kt" ], + theme: { + extend: {}, + }, + plugins: [ require('@tailwindcss/forms') ], +} + diff --git a/src/native-test/kotlin/io/tohuwabohu/kamifusen/AdminResourceIT.kt b/src/native-test/kotlin/io/tohuwabohu/kamifusen/AdminResourceIT.kt deleted file mode 100644 index 7e7732c..0000000 --- a/src/native-test/kotlin/io/tohuwabohu/kamifusen/AdminResourceIT.kt +++ /dev/null @@ -1,3 +0,0 @@ -package io.tohuwabohu.kamifusen - -class AdminResourceIT : AdminResourceTest() \ No newline at end of file diff --git a/src/native-test/kotlin/io/tohuwabohu/kamifusen/AppAdminResourceIT.kt b/src/native-test/kotlin/io/tohuwabohu/kamifusen/AppAdminResourceIT.kt new file mode 100644 index 0000000..730ebb2 --- /dev/null +++ b/src/native-test/kotlin/io/tohuwabohu/kamifusen/AppAdminResourceIT.kt @@ -0,0 +1,3 @@ +package io.tohuwabohu.kamifusen + +class AppAdminResourceIT : AppAdminResourceTest() \ No newline at end of file diff --git a/src/test/kotlin/io/tohuwabohu/kamifusen/AdminResourceTest.kt b/src/test/kotlin/io/tohuwabohu/kamifusen/AdminResourceTest.kt deleted file mode 100644 index 4b12808..0000000 --- a/src/test/kotlin/io/tohuwabohu/kamifusen/AdminResourceTest.kt +++ /dev/null @@ -1,102 +0,0 @@ -package io.tohuwabohu.kamifusen - -import io.quarkus.test.common.http.TestHTTPEndpoint -import io.quarkus.test.junit.QuarkusMock -import io.quarkus.test.junit.QuarkusTest -import io.quarkus.test.vertx.RunOnVertxContext -import io.quarkus.test.vertx.UniAsserter -import io.restassured.module.kotlin.extensions.Given -import io.restassured.module.kotlin.extensions.Then -import io.restassured.module.kotlin.extensions.When -import io.tohuwabohu.kamifusen.crud.ApiKeyRepository -import io.tohuwabohu.kamifusen.crud.Page -import io.tohuwabohu.kamifusen.crud.PageRepository -import io.tohuwabohu.kamifusen.mock.ApiKeyRepositoryMock -import io.tohuwabohu.kamifusen.mock.PageRepositoryMock -import jakarta.inject.Inject -import org.junit.jupiter.api.Assertions -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import java.util.* - -@QuarkusTest -@TestHTTPEndpoint(AdminResource::class) -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class AdminResourceTest { - @Inject - lateinit var pageRepository: PageRepository - - @BeforeAll - fun init() { - QuarkusMock.installMockForType(ApiKeyRepositoryMock(), ApiKeyRepository::class.java) - } - - @Test - @RunOnVertxContext - fun `should add a page`(uniAsserter: UniAsserter) { - QuarkusMock.installMockForInstance(PageRepositoryMock(), pageRepository) - - Given { - header("Authorization", "api-key-admin") - body("/page/test-page") - } When { - post("/add") - } Then { - statusCode(201) - } - - uniAsserter.assertThat( - { pageRepository.findPageByPath("/page/test-page") }, - { result -> Assertions.assertNotNull(result)} - ) - } - - @Test - @RunOnVertxContext - fun `should not add the same page twice`(uniAsserter: UniAsserter) { - val pageRepositoryMock = PageRepositoryMock() - pageRepositoryMock.pages.add(Page(UUID.randomUUID(), "/page/test-page")) - - QuarkusMock.installMockForInstance(pageRepositoryMock, pageRepository) - - Given { - header("Authorization", "api-key-admin") - body("/page/test-page") - } When { - post("/add") - } Then { - statusCode(204) - } - - uniAsserter.assertThat( - { pageRepository.findPageByPath("/page/test-page") }, - { result -> Assertions.assertEquals(pageRepositoryMock.pages[0].id, result!!.id)} - ) - } - - @Test - @RunOnVertxContext - fun `should not add a page without api key`(uniAsserter: UniAsserter) { - Given { - body("/page/test-page") - } When { - post("/add") - } Then { - statusCode(403) - } - } - - @Test - @RunOnVertxContext - fun `should not add a page with user api key`(uniAsserter: UniAsserter) { - Given { - body("/page/test-page") - header("Authorization", "api-key-user") - } When { - post("/add") - } Then { - statusCode(403) - } - } -} \ No newline at end of file diff --git a/src/test/kotlin/io/tohuwabohu/kamifusen/AppAdminResourceTest.kt b/src/test/kotlin/io/tohuwabohu/kamifusen/AppAdminResourceTest.kt new file mode 100644 index 0000000..1dbf140 --- /dev/null +++ b/src/test/kotlin/io/tohuwabohu/kamifusen/AppAdminResourceTest.kt @@ -0,0 +1,139 @@ +package io.tohuwabohu.kamifusen + +import io.quarkus.test.common.http.TestHTTPEndpoint +import io.quarkus.test.junit.QuarkusMock +import io.quarkus.test.junit.QuarkusTest +import io.quarkus.test.security.TestSecurity +import io.quarkus.test.vertx.RunOnVertxContext +import io.quarkus.test.vertx.UniAsserter +import io.restassured.http.ContentType +import io.restassured.module.kotlin.extensions.Extract +import io.restassured.module.kotlin.extensions.Given +import io.restassured.module.kotlin.extensions.Then +import io.restassured.module.kotlin.extensions.When +import io.tohuwabohu.kamifusen.crud.ApiUserRepository +import io.tohuwabohu.kamifusen.crud.security.PasswordValidation +import io.tohuwabohu.kamifusen.mock.ApiUserRepositoryMock +import io.tohuwabohu.kamifusen.ssr.renderPasswordFlow +import jakarta.inject.Inject +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@QuarkusTest +@TestHTTPEndpoint(AppAdminResource::class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AppAdminResourceTest { + + @Inject + lateinit var apiUserRepository: ApiUserRepository + + @Test + @RunOnVertxContext + @TestSecurity(user = "admin", roles = ["app-admin"]) + fun `should update admin password`(uniAsserter: UniAsserter) { + QuarkusMock.installMockForInstance(ApiUserRepositoryMock(), apiUserRepository) + + val expectedPassword = "awesome-administrator" + val expectedHtmlBody = renderPasswordFlow(PasswordValidation.VALID) + + val actualHtmlBody = Given { + header("Content-Type", ContentType.URLENC) + formParam("password", expectedPassword) + formParam("password-confirm", expectedPassword) + } When { + post("/fragment/register") + } Then { + statusCode(200) + } Extract { + body().asString() + } + + Assertions.assertEquals(expectedHtmlBody, actualHtmlBody) + + uniAsserter.assertThat( + { apiUserRepository.findByUsername("admin") }, + { result -> Assertions.assertEquals(expectedPassword, result!!.password)} + ) + } + + @Test + @TestSecurity(user = "admin", roles = ["app-admin"]) + fun `should show admin password update NO_MATCH error`() { + val passwordFlowApiUserRepositoryMock = ApiUserRepositoryMock() + passwordFlowApiUserRepositoryMock.apiUsers.find { it.username == "admin" }?.let { it.password = null } + + QuarkusMock.installMockForInstance(passwordFlowApiUserRepositoryMock, apiUserRepository) + + val expectedHtmlBody = renderPasswordFlow(PasswordValidation.NO_MATCH) + + val actualHtmlBody = Given { + header("Content-Type", ContentType.URLENC) + formParam("password", "admin-password-update") + formParam("password-confirm", "admin-password-misspell") + } When { + post("/fragment/register") + } Then { + statusCode(200) + } Extract { + body().asString() + } + + Assertions.assertEquals(expectedHtmlBody, actualHtmlBody) + + QuarkusMock.installMockForType(ApiUserRepositoryMock(), ApiUserRepository::class.java) + } + + @Test + @TestSecurity(user = "admin", roles = ["app-admin"]) + fun `should show admin password update EMPTY error`() { + installMockForPasswordFlow() + + val expectedHtmlBody = renderPasswordFlow(PasswordValidation.EMPTY) + + val actualHtmlBody = Given { + header("Content-Type", ContentType.URLENC) + formParam("password", "") + formParam("password-confirm", "") + } When { + post("/fragment/register") + } Then { + statusCode(200) + } Extract { + body().asString() + } + + Assertions.assertEquals(expectedHtmlBody, actualHtmlBody) + } + + @Test + @TestSecurity(user = "admin", roles = ["app-admin"]) + fun `should show admin password update TOO_SHORT error`() { + installMockForPasswordFlow() + + val expectedHtmlBody = renderPasswordFlow(PasswordValidation.TOO_SHORT) + + val actualHtmlBody = Given { + header("Content-Type", ContentType.URLENC) + formParam("password", "short") + formParam("password-confirm", "short") + } When { + post("/fragment/register") + } Then { + statusCode(200) + } Extract { + body().asString() + } + + Assertions.assertEquals(expectedHtmlBody, actualHtmlBody) + + QuarkusMock.installMockForType(ApiUserRepositoryMock(), ApiUserRepository::class.java) + } + + private fun installMockForPasswordFlow() { + val passwordFlowApiUserRepositoryMock = ApiUserRepositoryMock() + passwordFlowApiUserRepositoryMock.apiUsers.find { it.username == "admin" }?.let { it.password = null } + + QuarkusMock.installMockForInstance(passwordFlowApiUserRepositoryMock, apiUserRepository) + } +} diff --git a/src/test/kotlin/io/tohuwabohu/kamifusen/PageVisitResourceTest.kt b/src/test/kotlin/io/tohuwabohu/kamifusen/PageVisitResourceTest.kt index 7d1b05c..e8c29c6 100644 --- a/src/test/kotlin/io/tohuwabohu/kamifusen/PageVisitResourceTest.kt +++ b/src/test/kotlin/io/tohuwabohu/kamifusen/PageVisitResourceTest.kt @@ -10,8 +10,9 @@ import io.restassured.module.kotlin.extensions.Extract import io.restassured.module.kotlin.extensions.Given import io.restassured.module.kotlin.extensions.Then import io.restassured.module.kotlin.extensions.When +import io.smallrye.mutiny.Uni import io.tohuwabohu.kamifusen.crud.* -import io.tohuwabohu.kamifusen.mock.ApiKeyRepositoryMock +import io.tohuwabohu.kamifusen.mock.ApiUserRepositoryMock import io.tohuwabohu.kamifusen.mock.PageRepositoryMock import io.tohuwabohu.kamifusen.mock.PageVisitRepositoryMock import io.tohuwabohu.kamifusen.mock.VisitorRepositoryMock @@ -38,7 +39,7 @@ class PageVisitResourceTest { @BeforeAll fun init() { - QuarkusMock.installMockForType(ApiKeyRepositoryMock(), ApiKeyRepository::class.java) + QuarkusMock.installMockForType(ApiUserRepositoryMock(), ApiUserRepository::class.java) } @Test @@ -54,7 +55,7 @@ class PageVisitResourceTest { Given { header("Content-Type", "text/plain") header("User-Agent", "test-user-agent") - header("Authorization", "api-key-user") + auth().preemptive().basic("api-key-user", "api-key-user") body("/page/test-page") } When { post("/hit") @@ -70,7 +71,7 @@ class PageVisitResourceTest { @Test @RunOnVertxContext - fun `should increase the hit count`(uniAsserter: UniAsserter) { + fun `should increase and return the hit count`(uniAsserter: UniAsserter) { val pageRepositoryMock = PageRepositoryMock() pageRepositoryMock.pages.add(Page(UUID.randomUUID(), "/page/test-page")) @@ -82,21 +83,28 @@ class PageVisitResourceTest { QuarkusMock.installMockForInstance(PageVisitRepositoryMock(), pageVisitRepository) - Given { + val count = Given { header("Content-Type", "text/plain") header("User-Agent", "test-user-agent") - header("Authorization", "api-key-user") + auth().preemptive().basic("api-key-user", "api-key-user") body("/page/test-page") } When { post("/hit") } Then { statusCode(200) + } Extract { + body().asString() } uniAsserter.assertThat( { pageVisitRepository.countVisits(pageRepositoryMock.pages[0].id) }, { result -> Assertions.assertEquals(1, result) } ) + + uniAsserter.assertThat( + { Uni.createFrom().item(count) }, + { result -> Assertions.assertEquals(1, result.toLong()) } + ) } @Test @@ -118,7 +126,7 @@ class PageVisitResourceTest { Given { header("Content-Type", "text/plain") header("User-Agent", "test-user-agent") - header("Authorization", "api-key-user") + auth().preemptive().basic("api-key-user", "api-key-user") body("/page/test-page") } When { post("/hit") @@ -134,7 +142,7 @@ class PageVisitResourceTest { @Test @RunOnVertxContext - fun `should increase the hit count for a different visitor`(uniAsserter: UniAsserter) { + fun `should increase and return the hit count for a different visitor`(uniAsserter: UniAsserter) { val pageRepositoryMock = PageRepositoryMock() pageRepositoryMock.pages.add(Page(UUID.randomUUID(), "/page/test-page")) @@ -148,21 +156,28 @@ class PageVisitResourceTest { QuarkusMock.installMockForInstance(visitorRepositoryMock, visitorRepository) QuarkusMock.installMockForInstance(pageVisitRepositoryMock, pageVisitRepository) - Given { + val count = Given { header("Content-Type", "text/plain") header("User-Agent", "test-user-agent") - header("Authorization", "api-key-user") + auth().preemptive().basic("api-key-user", "api-key-user") body("/page/test-page") } When { post("/hit") } Then { statusCode(200) + } Extract { + body().asString() } uniAsserter.assertThat( { pageVisitRepository.countVisits(pageRepositoryMock.pages[0].id) }, { result -> Assertions.assertEquals(2, result) } ) + + uniAsserter.assertThat( + { Uni.createFrom().item(count) }, + { result -> Assertions.assertEquals(2, result.toLong()) } + ) } @Test @@ -181,7 +196,7 @@ class PageVisitResourceTest { Given { header("Content-Type", "text/plain") header("User-Agent", "test-user-agent") - header("Authorization", "api-key-user") + auth().preemptive().basic("api-key-user", "api-key-user") body("/page/test-page") } When { post("/hit") @@ -211,9 +226,9 @@ class PageVisitResourceTest { QuarkusMock.installMockForInstance(pageVisitRepositoryMock, pageVisitRepository) val count = Given { - header("Authorization", "api-key-user") + auth().preemptive().basic("api-key-admin", "api-key-admin") } When { - get("/count/${URLEncoder.encode("/page/test-page", "UTF-8")}") + get("/count/${pageRepositoryMock.pages[0].id}") } Then { statusCode(200) } Extract { @@ -233,9 +248,9 @@ class PageVisitResourceTest { QuarkusMock.installMockForInstance(PageVisitRepositoryMock(), pageVisitRepository) val count = Given { - header("Authorization", "api-key-user") + auth().preemptive().basic("api-key-admin", "api-key-admin") } When { - get("/count/${URLEncoder.encode("/page/test-page", "UTF-8")}") + get("/count/${pageRepositoryMock.pages[0].id}") } Then { statusCode(200) } Extract { @@ -255,7 +270,7 @@ class PageVisitResourceTest { QuarkusMock.installMockForInstance(PageVisitRepositoryMock(), pageVisitRepository) Given { - header("Authorization", "api-key-user") + auth().preemptive().basic("api-key-admin", "api-key-admin") } When { get("/count/${URLEncoder.encode("/page/test-page2", "UTF-8")}") } Then { @@ -266,22 +281,29 @@ class PageVisitResourceTest { @Test @RunOnVertxContext fun `should return a 403 without api key for hit`(uniAsserter: UniAsserter) { + QuarkusMock.installMockForInstance(PageVisitRepositoryMock(), pageVisitRepository) + Given { + header("Authorization", "") body("does-not-matter") } When { post("/hit") } Then { - statusCode(403) + statusCode(401) } } @Test @RunOnVertxContext fun `should return a 403 without api key for hit count`(uniAsserter: UniAsserter) { - When { + QuarkusMock.installMockForInstance(PageVisitRepositoryMock(), pageVisitRepository) + + Given { + header("Authorization", "") + } When { get("/count/foo") } Then { - statusCode(403) + statusCode(401) } } } \ No newline at end of file diff --git a/src/test/kotlin/io/tohuwabohu/kamifusen/mock/ApiUserMock.kt b/src/test/kotlin/io/tohuwabohu/kamifusen/mock/ApiUserMock.kt new file mode 100644 index 0000000..7d06a0a --- /dev/null +++ b/src/test/kotlin/io/tohuwabohu/kamifusen/mock/ApiUserMock.kt @@ -0,0 +1,32 @@ +package io.tohuwabohu.kamifusen.mock + +import io.smallrye.mutiny.Uni +import io.tohuwabohu.kamifusen.crud.ApiUser +import io.tohuwabohu.kamifusen.crud.ApiUserRepository + +class ApiUserRepositoryMock : ApiUserRepository() { + final val apiUsers = mutableListOf( + ApiUser(username = "api-key-user", password = "api-key-user", role = "api-user"), + ApiUser(username = "api-key-admin", password = "api-key-admin", role = "api-admin"), + ApiUser(username = "admin", password = "admin", role = "app-admin") + ) + + override fun findByUsername(username: String): Uni { + return apiUsers.find { it.username == username }.let { Uni.createFrom().item(it)} + } + + override fun findByUsernameAndPassword(username: String, password: String): Uni { + return apiUsers.find { it.username == username && it.password == password }.let { Uni.createFrom().item(it)} + } + + override fun addUser(apiUser: ApiUser): Uni { + apiUsers.add(apiUser) + return Uni.createFrom().item(apiUser.password) + } + + override fun setAdminPassword(password: String): Uni { + apiUsers.find { it.username == "admin" }?.password = password + + return Uni.createFrom().item(apiUsers.find { it.username == "admin" }) + } +} \ No newline at end of file diff --git a/src/test/kotlin/io/tohuwabohu/kamifusen/mock/IdentityProviderMock.kt b/src/test/kotlin/io/tohuwabohu/kamifusen/mock/IdentityProviderMock.kt deleted file mode 100644 index c003fd7..0000000 --- a/src/test/kotlin/io/tohuwabohu/kamifusen/mock/IdentityProviderMock.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.tohuwabohu.kamifusen.mock - -import io.smallrye.mutiny.Uni -import io.tohuwabohu.kamifusen.crud.ApiKey -import io.tohuwabohu.kamifusen.crud.ApiKeyRepository - -class ApiKeyRepositoryMock : ApiKeyRepository() { - val keys = mutableListOf( - ApiKey("api-key-user", "test-user", "api-user"), - ApiKey("api-key-admin", "test-admin", "api-admin")) - - override fun findKey(key: String): Uni { - return keys.find { it.apiKey == key }.let { Uni.createFrom().item(it)} - } -} \ No newline at end of file diff --git a/src/test/kotlin/io/tohuwabohu/kamifusen/mock/PageMock.kt b/src/test/kotlin/io/tohuwabohu/kamifusen/mock/PageMock.kt index 6616293..ddb4afe 100644 --- a/src/test/kotlin/io/tohuwabohu/kamifusen/mock/PageMock.kt +++ b/src/test/kotlin/io/tohuwabohu/kamifusen/mock/PageMock.kt @@ -8,12 +8,20 @@ import java.util.* class PageRepositoryMock : PageRepository() { val pages = mutableListOf() + override fun findByPageId(id: UUID): Uni { + return pages.find { it.id == id }.let { Uni.createFrom().item(it) } + } + override fun findPageByPath(path: String): Uni { return Uni.createFrom().item(pages.find { it.path == path }) } - override fun addPage(path: String): Uni { - val page = Page(UUID.randomUUID(), path) + override fun listAllPages(): Uni> { + return Uni.createFrom().item(pages) + } + + override fun addPage(path: String, domain: String): Uni { + val page = Page(UUID.randomUUID(), path, domain) pages.add(page)