Skip to content

Commit

Permalink
admin page (#7)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
thwbh authored Nov 7, 2024
1 parent 587d1a4 commit c7aa5c4
Show file tree
Hide file tree
Showing 37 changed files with 2,000 additions and 357 deletions.
12 changes: 11 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand All @@ -15,18 +16,26 @@ 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")
implementation("io.quarkus:quarkus-reactive-pg-client")
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")
}
Expand All @@ -52,6 +61,7 @@ allOpen {

noArg {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.Embeddable")
}

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
Expand Down
6 changes: 6 additions & 0 deletions smoke-test.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
###
POST localhost:8080/public/visits/hit
Authorization: Basic dG9odXdhYm9odTo1MDM2NGIzOC1mODI0LTRhODQtOGEzMS0wMWI3MjRmZjg3M2E=

/test/path-9
###
33 changes: 0 additions & 33 deletions src/main/kotlin/io/tohuwabohu/kamifusen/AdminResource.kt

This file was deleted.

189 changes: 189 additions & 0 deletions src/main/kotlin/io/tohuwabohu/kamifusen/AppAdminResource.kt
Original file line number Diff line number Diff line change
@@ -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<Response> =
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<Response> =
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<Response> =
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<Response> =
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<Response> =
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<Response> =
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<Response> =
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<Response> =
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<Response> =
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<Response> =
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())
}
33 changes: 22 additions & 11 deletions src/main/kotlin/io/tohuwabohu/kamifusen/PageVisitResource.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -24,7 +25,12 @@ class PageVisitResource(
@POST
@Consumes(MediaType.TEXT_PLAIN)
@Produces(MediaType.TEXT_PLAIN)
fun hit(@Context request: HttpServerRequest, body: String): Uni<Response> =
@RolesAllowed("api-user")
fun hit(
@Context securityContext: SecurityContext,
@Context request: HttpServerRequest,
body: String
): Uni<Response> =
pageRepository.findPageByPath(body).flatMap { page ->
visitorRepository.findByInfo(
remoteAddress = request.remoteAddress().host(),
Expand All @@ -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<Response> =
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<Response> =
pageRepository.findByPageId(pageId).chain { page ->
if (page != null) {
pageVisitRepository.countVisits(page.id).map { visits ->
Response.ok(visits).build()
Expand Down
Loading

0 comments on commit c7aa5c4

Please sign in to comment.