Skip to content

Commit

Permalink
register pages automatically (#11)
Browse files Browse the repository at this point in the history
* automatically create pages to track on POST

* fix visitor being redundant

* add example snippet

* update README
  • Loading branch information
thwbh authored Nov 8, 2024
1 parent 40441bf commit 7d0a980
Show file tree
Hide file tree
Showing 12 changed files with 233 additions and 32 deletions.
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
<div align="center">
<img src="src/main/resources/META-INF/resources/static/images/kamifusen-logo.png">
</div>

---

![GitHub Release](https://img.shields.io/github/v/release/tohuwabohu-io/kamifusen) ![Coverage](https://raw.githubusercontent.com/tohuwabohu-io/kamifusen/badges/jacoco.svg)

# kamifusen

> A simple page hit counter written in kotlin.
Expand All @@ -6,6 +14,40 @@ This project uses Quarkus, the Supersonic Subatomic Java Framework.

If you want to learn more about Quarkus, please visit its website: <https://quarkus.io/>.

## First time setup
For development, a default user with username/password `admin` exists. You will be prompted to change your
password after your first login. For production, access the database and execute the given `insert.sql` in
`src/main/resources`.

## API Key creation
Issue a new API Key on: http://localhost:8080/users

The new Key will be visible only once. Copy and distribute it according to your needs. Do not use the same API Key across
multiple domains (or do, it's not like I can stop you).

## Registering pages
You need to add some JavaScript to your page if you want the hit counter to work. Use this snippet provided:

```html
<script language="JavaScript">
document.addEventListener('DOMContentLoaded', function () {
const url = new URL(window.location.href);
fetch('http://localhost:8080/public/visits/hit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Basic <API-KEY>'
},
body: JSON.stringify({
path: url.pathname,
domain: url.hostname
})
});
})
</script>
```

## Running the application in dev mode

You can run your application in dev mode that enables live coding using:
Expand Down
2 changes: 1 addition & 1 deletion smoke-test.http
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
###
POST localhost:8080/public/visits/hit
Authorization: Basic dG9odXdhYm9odTo1MDM2NGIzOC1mODI0LTRhODQtOGEzMS0wMWI3MjRmZjg3M2E=
Authorization: Basic <API-KEY>

/test/path-9
###
29 changes: 29 additions & 0 deletions snippet.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script language="JavaScript">
document.addEventListener('DOMContentLoaded', function () {
const url = new URL(window.location.href);

fetch('http://localhost:8080/public/visits/hit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Basic <API-KEY>'
},
body: JSON.stringify({
path: url.pathname,
domain: url.hostname
})
});
})
</script>
</head>
<body>
<div>
<h1>Hi</h1>
</div>
</body>
</html>
10 changes: 7 additions & 3 deletions src/main/kotlin/io/tohuwabohu/kamifusen/PageVisitResource.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import io.smallrye.mutiny.tuples.Tuple2
import io.tohuwabohu.kamifusen.crud.PageRepository
import io.tohuwabohu.kamifusen.crud.PageVisitRepository
import io.tohuwabohu.kamifusen.crud.VisitorRepository
import io.tohuwabohu.kamifusen.crud.dto.PageHitDto
import io.tohuwabohu.kamifusen.crud.error.recoverWithResponse
import io.vertx.core.http.HttpServerRequest
import jakarta.annotation.security.RolesAllowed
Expand All @@ -23,15 +24,18 @@ class PageVisitResource(
) {
@Path("/hit")
@POST
@Consumes(MediaType.TEXT_PLAIN)
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.TEXT_PLAIN)
@RolesAllowed("api-user")
fun hit(
@Context securityContext: SecurityContext,
@Context request: HttpServerRequest,
body: String
body: PageHitDto
): Uni<Response> =
pageRepository.findPageByPath(body).flatMap { page ->
pageRepository.addPageIfAbsent(
path = body.path,
domain = body.domain
).flatMap { page ->
visitorRepository.findByInfo(
remoteAddress = request.remoteAddress().host(),
userAgent = request.headers().get("User-Agent") ?: "unknown"
Expand Down
12 changes: 9 additions & 3 deletions src/main/kotlin/io/tohuwabohu/kamifusen/crud/Page.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import java.util.*
@Entity
@NamedQueries(
NamedQuery(
name = "Page.findByPath",
query = "FROM Page p WHERE p.path = :path")
name = "Page.findByPathAndDomain",
query = "FROM Page p WHERE p.path = :path AND p.domain = :domain")
)
data class Page(
@Id
Expand Down Expand Up @@ -55,7 +55,13 @@ data class Page(
class PageRepository : PanacheRepositoryBase<Page, UUID> {
fun findByPageId(id: UUID) = find("id", id).firstResult()

fun findPageByPath(path: String) = find("#Page.findByPath", mapOf("path" to path)).firstResult()
@WithTransaction
fun addPageIfAbsent(path: String, domain: String): Uni<Page?> = findPageByPathAndDomain(path, domain)
.onItem().ifNull().switchTo(addPage(path, domain))

fun findPageByPathAndDomain(path: String, domain: String) = find("#Page.findByPathAndDomain",
mapOf("path" to path, "domain" to domain)
).firstResult()

fun listAllPages() = listAll()

Expand Down
4 changes: 2 additions & 2 deletions src/main/kotlin/io/tohuwabohu/kamifusen/crud/Visitor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ data class Visitor (
class VisitorRepository : PanacheRepositoryBase<Visitor, UUID> {
@WithTransaction
fun addVisitor(remoteAddress: String, userAgent: String): Uni<Visitor> {
val visitor = Visitor(UUID.randomUUID(), BcryptUtil.bcryptHash("$remoteAddress $userAgent"))
val visitor = Visitor(UUID.randomUUID(), sha256("$remoteAddress $userAgent"))

return persist(visitor)
}

fun findByInfo(remoteAddress: String, userAgent: String) = find("info", BcryptUtil.bcryptHash("$remoteAddress $userAgent")).firstResult()
fun findByInfo(remoteAddress: String, userAgent: String) = find("info", sha256("$remoteAddress $userAgent")).firstResult()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.tohuwabohu.kamifusen.crud.dto

data class PageHitDto(
var path: String,
var domain: String
)
28 changes: 28 additions & 0 deletions src/main/kotlin/io/tohuwabohu/kamifusen/crud/dto/PageVisitDto.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ import jakarta.persistence.Tuple
import java.time.LocalDateTime
import java.util.*

/**
* Data Transfer Object (DTO) representing a page visit data aggregation of two tables.
*
* @property id A unique identifier for the page visit.
* @property path The URL path of the visited page.
* @property domain The domain of the visited page, which might be nullable.
* @property pageAdded The timestamp indicating when the page was added.
* @property visits The number of visits the page has received.
*/
data class PageVisitDto(
var id: UUID,
var path: String,
Expand All @@ -16,6 +25,12 @@ data class PageVisitDto(
var visits: Long
)

/**
* Repository for managing PageVisitDto entities. It does not possess a JPA setup.
*
* This repository uses a native query to join the [io.tohuwabohu.kamifusen.crud.Page] and
* [io.tohuwabohu.kamifusen.PageVisit] entities for receiving a total hit count per page.
*/
@ApplicationScoped
class PageVisitDtoRepository() : PanacheRepository<PageVisitDto> {
val query = """
Expand All @@ -34,6 +49,19 @@ class PageVisitDtoRepository() : PanacheRepository<PageVisitDto> {

}

/**
* Extension function to convert a JPA Tuple object to a PageVisitDto.
*
* This function assumes that the Tuple contains the following data at the specified positions:
* - Position 0: The unique identifier of the page visit (UUID).
* - Position 1: The URL path of the visited page (String).
* - Position 2: The timestamp indicating when the page was added (LocalDateTime).
* - Position 3: The number of visits the page has received (Long).
* - Position 4: The domain of the visited page, which can be nullable (String).
*
* @receiver Tuple The JPA Tuple object containing the necessary data.
* @return PageVisitDto The PageVisitDto object created from the Tuple data.
*/
fun Tuple.toPageVisitDto() = PageVisitDto(
id = this.get(0, UUID::class.java),
path = this.get(1, String::class.java),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ enum class PageValidation(val valid: Boolean, val message: String? = null) {
EXISTS(false, "Page already exists.");
}

fun validatePage(path: String, domain: String? = null, pageRepository: PageRepository): Uni<PageValidation> {
fun validatePage(path: String, domain: String = "", pageRepository: PageRepository): Uni<PageValidation> {
return if (path.isBlank()) {
Uni.createFrom().item(PageValidation.EMPTY)
} else {
pageRepository.findPageByPath(path).map { page ->
pageRepository.addPageIfAbsent(path, domain).map { page ->
if (page != null && page.domain == domain) {
PageValidation.EXISTS
} else PageValidation.VALID
Expand Down
2 changes: 2 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ quarkus.http.auth.permission.form.paths=/
quarkus.http.auth.permission.form.policy=authenticated

# dev config
%dev.quarkus.http.cors=true
%dev.quarkus.http.cors.origins=https://www.tohuwabohu.io
%dev.quarkus.datasource.username=kamifusen
%dev.quarkus.datasource.password=kamifusen
%dev.quarkus.datasource.reactive.url=postgresql://localhost:5432/dev
Expand Down
Loading

0 comments on commit 7d0a980

Please sign in to comment.