Skip to content

Commit c7aa5c4

Browse files
authored
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
1 parent 587d1a4 commit c7aa5c4

37 files changed

+2000
-357
lines changed

build.gradle.kts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ plugins {
22
kotlin("jvm") version "2.0.10"
33
kotlin("plugin.allopen") version "2.0.10"
44
kotlin("plugin.noarg") version "2.0.10"
5+
kotlin("plugin.serialization") version "2.0.10"
56
id("io.quarkus")
67
}
78

@@ -15,18 +16,26 @@ val quarkusPlatformArtifactId: String by project
1516
val quarkusPlatformVersion: String by project
1617

1718
dependencies {
19+
// quarkus & reactive JPA
20+
implementation("io.quarkus:quarkus-rest-jackson")
21+
implementation("io.quarkus:quarkus-security-jpa-reactive")
1822
implementation("io.quarkus:quarkus-jacoco")
1923
implementation("io.quarkus:quarkus-elytron-security-common")
2024
implementation("io.quarkus:quarkus-security")
2125
implementation("io.quarkus:quarkus-reactive-pg-client")
2226
implementation("io.quarkus:quarkus-hibernate-reactive-panache-kotlin")
2327
implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
2428
implementation("io.quarkus:quarkus-kotlin")
25-
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
2629
implementation("io.quarkus:quarkus-arc")
2730
implementation("io.quarkus:quarkus-rest")
31+
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
32+
implementation("org.jetbrains.kotlin:kotlin-noarg")
33+
// ssr
34+
implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0")
35+
// testing
2836
testImplementation("io.quarkus:quarkus-junit5")
2937
testImplementation("io.quarkus:quarkus-test-vertx")
38+
testImplementation("io.quarkus:quarkus-test-security")
3039
testImplementation("io.rest-assured:rest-assured")
3140
testImplementation("io.rest-assured:kotlin-extensions:5.3.2")
3241
}
@@ -52,6 +61,7 @@ allOpen {
5261

5362
noArg {
5463
annotation("jakarta.persistence.Entity")
64+
annotation("jakarta.persistence.Embeddable")
5565
}
5666

5767
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {

smoke-test.http

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
###
2+
POST localhost:8080/public/visits/hit
3+
Authorization: Basic dG9odXdhYm9odTo1MDM2NGIzOC1mODI0LTRhODQtOGEzMS0wMWI3MjRmZjg3M2E=
4+
5+
/test/path-9
6+
###

src/main/kotlin/io/tohuwabohu/kamifusen/AdminResource.kt

Lines changed: 0 additions & 33 deletions
This file was deleted.
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package io.tohuwabohu.kamifusen
2+
3+
import io.quarkus.logging.Log
4+
import io.smallrye.mutiny.Uni
5+
import io.tohuwabohu.kamifusen.crud.ApiUser
6+
import io.tohuwabohu.kamifusen.crud.ApiUserRepository
7+
import io.tohuwabohu.kamifusen.crud.PageRepository
8+
import io.tohuwabohu.kamifusen.crud.dto.PageVisitDtoRepository
9+
import io.tohuwabohu.kamifusen.crud.security.*
10+
import io.tohuwabohu.kamifusen.ssr.*
11+
import io.tohuwabohu.kamifusen.ssr.response.recoverWithHtmxResponse
12+
import io.vertx.ext.web.RoutingContext
13+
import jakarta.annotation.security.RolesAllowed
14+
import jakarta.ws.rs.*
15+
import jakarta.ws.rs.core.*
16+
import org.eclipse.microprofile.config.inject.ConfigProperty
17+
import java.time.Instant
18+
import java.time.LocalDateTime
19+
import java.util.*
20+
21+
22+
@Path("/")
23+
class AppAdminResource(
24+
private val apiUserRepository: ApiUserRepository,
25+
private val pageVisitDtoRepository: PageVisitDtoRepository,
26+
private val pageRepository: PageRepository
27+
) {
28+
@ConfigProperty(name = "quarkus.http.auth.form.cookie-name")
29+
lateinit var cookieName: String
30+
31+
@Path("/fragment/register")
32+
@POST
33+
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
34+
@Produces(MediaType.TEXT_HTML)
35+
@RolesAllowed("app-admin")
36+
fun registerAdmin(
37+
@FormParam("password") password: String,
38+
@FormParam("password-confirm") passwordConfirm: String
39+
): Uni<Response> =
40+
validatePassword(password, passwordConfirm).flatMap { result ->
41+
if (result == PasswordValidation.VALID) {
42+
apiUserRepository.setAdminPassword(password)
43+
.map { Response.ok(renderPasswordFlow(result)).build() }
44+
} else {
45+
Uni.createFrom().item(Response.ok(renderPasswordFlow(result)).build())
46+
}
47+
}.onFailure().invoke { e -> Log.error("Error during admin password update.", e) }
48+
.onFailure().recoverWithHtmxResponse(Response.Status.INTERNAL_SERVER_ERROR)
49+
50+
@Path("/logout")
51+
@GET
52+
@Produces(MediaType.TEXT_PLAIN)
53+
fun logoutAdmin(@Context routingContext: RoutingContext): Uni<Response> =
54+
Uni.createFrom().item(
55+
Response.status(Response.Status.FOUND)
56+
.cookie(
57+
NewCookie.Builder(cookieName)
58+
.maxAge(0)
59+
.expiry(Date.from(Instant.EPOCH))
60+
.path("/")
61+
.build()
62+
)
63+
.header("Location", "/index.html")
64+
.build()
65+
)
66+
67+
@Path("/dashboard")
68+
@GET
69+
@Consumes(MediaType.TEXT_PLAIN)
70+
@Produces(MediaType.TEXT_HTML)
71+
@RolesAllowed("app-admin")
72+
fun renderAdminDashboard(@Context securityContext: SecurityContext): Uni<Response> =
73+
apiUserRepository.findByUsername(securityContext.userPrincipal.name).map { adminUser ->
74+
Response.ok(renderAdminPage("Dashboard", isFirstTimeSetup = adminUser!!.updated == null) {
75+
dashboard(adminUser)
76+
}).build()
77+
}.onFailure().invoke { e -> Log.error("Error during dashboard rendering.", e) }
78+
.onFailure().recoverWithHtmxResponse(Response.Status.INTERNAL_SERVER_ERROR)
79+
80+
@Path("/stats")
81+
@GET
82+
@Consumes(MediaType.TEXT_PLAIN)
83+
@Produces(MediaType.TEXT_HTML)
84+
@RolesAllowed("app-admin")
85+
fun renderVisits(): Uni<Response> =
86+
pageVisitDtoRepository.getAllPageVisits()
87+
.flatMap {
88+
Uni.createFrom().item(Response.ok(renderAdminPage("Stats") {
89+
stats(it)
90+
}).build())
91+
}
92+
.onFailure().invoke { e -> Log.error("Error during stats rendering.", e) }
93+
.onFailure().recoverWithHtmxResponse(Response.Status.INTERNAL_SERVER_ERROR)
94+
95+
@Path("/pages")
96+
@GET
97+
@Consumes(MediaType.TEXT_PLAIN)
98+
@Produces(MediaType.TEXT_HTML)
99+
@RolesAllowed("app-admin")
100+
fun renderPageList(): Uni<Response> =
101+
pageRepository.listAllPages().flatMap {
102+
Uni.createFrom().item(Response.ok(renderAdminPage("Pages") {
103+
pages(it)
104+
}).build())
105+
}.onFailure().invoke { e -> Log.error("Error during pages rendering.", e) }
106+
.onFailure().recoverWithHtmxResponse(Response.Status.INTERNAL_SERVER_ERROR)
107+
108+
@Path("/users")
109+
@GET
110+
@Consumes(MediaType.TEXT_PLAIN)
111+
@Produces(MediaType.TEXT_HTML)
112+
@RolesAllowed("app-admin")
113+
fun renderUserList(): Uni<Response> =
114+
apiUserRepository.listAll().flatMap {
115+
Uni.createFrom().item(Response.ok(renderAdminPage("Users") {
116+
users(it)
117+
}).build())
118+
}.onFailure().invoke { e -> Log.error("Error during user list rendering.", e) }
119+
.onFailure().recoverWithHtmxResponse(Response.Status.INTERNAL_SERVER_ERROR)
120+
121+
@Path("/fragment/keygen")
122+
@POST
123+
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
124+
@Produces(MediaType.TEXT_PLAIN)
125+
@RolesAllowed("app-admin")
126+
fun renderNewApiKey(
127+
@FormParam("username") username: String,
128+
@FormParam("role") role: String,
129+
@FormParam("expiresAt") expiresAt: String
130+
): Uni<Response> =
131+
validateUser(username, apiUserRepository).flatMap { result ->
132+
if (result == UserValidation.VALID) {
133+
apiUserRepository.addUser(
134+
ApiUser(
135+
username = username,
136+
role = role,
137+
expiresAt = when (expiresAt) {
138+
"" -> null
139+
else -> LocalDateTime.parse(expiresAt)
140+
},
141+
)
142+
).map { keyRaw -> Response.ok(renderCreatedApiKey(keyRaw)).build() }
143+
} else {
144+
Uni.createFrom().item(Response
145+
.ok(renderUsernameValidationError(result))
146+
.header("hx-retarget", "#username")
147+
.build())
148+
}
149+
}.onFailure().invoke { e -> Log.error("Error during keygen.", e) }
150+
.onFailure().recoverWithItem(Response.status(Response.Status.INTERNAL_SERVER_ERROR).build())
151+
152+
@Path("/fragment/retire/{userId}")
153+
@POST
154+
@Produces(MediaType.TEXT_HTML)
155+
@RolesAllowed("app-admin")
156+
fun retireApiKey(userId: UUID): Uni<Response> =
157+
apiUserRepository.expireUser(userId).onItem()
158+
.transform { Response.ok().header("hx-redirect", "/users").build() }
159+
.onFailure().invoke { e -> Log.error("Error during key retirement", e) }
160+
.onFailure().recoverWithItem(Response.serverError().build())
161+
162+
@Path("/fragment/pageadd")
163+
@POST
164+
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
165+
@Produces(MediaType.TEXT_PLAIN)
166+
@RolesAllowed("app-admin")
167+
fun registerNewPage(@FormParam("path") path: String, @FormParam("domain") domain: String): Uni<Response> =
168+
validatePage(path, domain, pageRepository).flatMap { result ->
169+
if (result == PageValidation.VALID) {
170+
pageRepository.addPage(path, domain).map { Response.ok().header("hx-redirect", "/pages").build() }
171+
} else {
172+
Uni.createFrom().item(Response
173+
.ok(renderPageValidationError(result))
174+
.header("hx-retarget", "#path")
175+
.build())
176+
}
177+
}.onFailure().invoke { e -> Log.error("Error during page registration.", e) }
178+
.onFailure().recoverWithItem(Response.serverError().build())
179+
180+
@Path("/fragment/pagedel/{pageId}")
181+
@POST
182+
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
183+
@Produces(MediaType.TEXT_PLAIN)
184+
@RolesAllowed("app-admin")
185+
fun unregisterPage(pageId: UUID): Uni<Response> =
186+
pageRepository.deletePage(pageId).map { Response.ok().header("hx-redirect", "/pages").build() }
187+
.onFailure().invoke { e -> Log.error("Error during page registration.", e) }
188+
.onFailure().recoverWithItem(Response.serverError().build())
189+
}

src/main/kotlin/io/tohuwabohu/kamifusen/PageVisitResource.kt

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@ import io.tohuwabohu.kamifusen.crud.PageVisitRepository
77
import io.tohuwabohu.kamifusen.crud.VisitorRepository
88
import io.tohuwabohu.kamifusen.crud.error.recoverWithResponse
99
import io.vertx.core.http.HttpServerRequest
10+
import jakarta.annotation.security.RolesAllowed
1011
import jakarta.ws.rs.*
1112
import jakarta.ws.rs.core.Context
1213
import jakarta.ws.rs.core.MediaType
1314
import jakarta.ws.rs.core.Response
14-
import java.net.URLDecoder
15-
import java.nio.charset.Charset
15+
import jakarta.ws.rs.core.SecurityContext
16+
import java.util.*
1617

17-
@Path("/visits")
18+
@Path("/public/visits")
1819
class PageVisitResource(
1920
private val pageRepository: PageRepository,
2021
private val pageVisitRepository: PageVisitRepository,
@@ -24,7 +25,12 @@ class PageVisitResource(
2425
@POST
2526
@Consumes(MediaType.TEXT_PLAIN)
2627
@Produces(MediaType.TEXT_PLAIN)
27-
fun hit(@Context request: HttpServerRequest, body: String): Uni<Response> =
28+
@RolesAllowed("api-user")
29+
fun hit(
30+
@Context securityContext: SecurityContext,
31+
@Context request: HttpServerRequest,
32+
body: String
33+
): Uni<Response> =
2834
pageRepository.findPageByPath(body).flatMap { page ->
2935
visitorRepository.findByInfo(
3036
remoteAddress = request.remoteAddress().host(),
@@ -41,24 +47,29 @@ class PageVisitResource(
4147
remoteAddress = request.remoteAddress().host(),
4248
userAgent = request.headers().get("User-Agent") ?: "unknown"
4349
).chain { newVisitor -> pageVisitRepository.addVisit(page.id, newVisitor.id) }
50+
.map { _ -> page }
4451
} else {
4552
pageVisitRepository.countVisitsForVisitor(page.id, visitor.id).chain { count ->
4653
if (count <= 0) {
4754
pageVisitRepository.addVisit(page.id, visitor.id)
4855
} else Uni.createFrom().voidItem()
49-
}
56+
}.map { _ -> page }
5057
}
51-
}.onItem().transform { Response.ok().build() }
58+
}.flatMap { page -> pageVisitRepository.countVisits(page.id).map { it } }
59+
.onItem().transform { count -> Response.ok(count).build() }
5260
.onFailure().recoverWithResponse()
5361

54-
@Path("/count/{pagePath}")
62+
@Path("/count/{pageId}")
5563
@GET
5664
@Consumes(MediaType.TEXT_PLAIN)
5765
@Produces(MediaType.TEXT_PLAIN)
58-
fun count(@Context request: HttpServerRequest, @PathParam("pagePath") pagePath: String): Uni<Response> =
59-
pageRepository.findPageByPath(URLDecoder.decode(pagePath,
60-
request.headers().get("Accept-Charset")?.let { Charset.forName(it) } ?: Charsets.UTF_8)
61-
).chain { page ->
66+
@RolesAllowed("api-admin")
67+
fun count(
68+
@Context securityContext: SecurityContext,
69+
@Context request: HttpServerRequest,
70+
@PathParam("pageId") pageId: UUID
71+
): Uni<Response> =
72+
pageRepository.findByPageId(pageId).chain { page ->
6273
if (page != null) {
6374
pageVisitRepository.countVisits(page.id).map { visits ->
6475
Response.ok(visits).build()

0 commit comments

Comments
 (0)