Compare commits

..

2 commits

109 changed files with 1242 additions and 3740 deletions

View file

@ -1,47 +0,0 @@
name: Publish Docker image
on:
push:
branches:
- main
jobs:
push_to_registry:
name: Push Docker image to Forgejo Packages
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
git.wbrawner.com/wbrawner/twigs
tags: |
type=schedule
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
- name: Set up Docker context
run: docker context create builders
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
endpoint: builders
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: git.wbrawner.com
username: ${{ github.actor }}
password: ${{ secrets.FORGEJO_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View file

@ -1,26 +0,0 @@
name: Pull request workflow
on: pull_request
jobs:
test:
name: Build and Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: https://git.wbrawner.com/actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Validate Gradle Wrapper
uses: https://git.wbrawner.com/gradle/actions/wrapper-validation@v3
- name: Build with Gradle
uses: https://git.wbrawner.com/gradle/actions/setup-gradle@v3
with:
arguments: --stacktrace check
- name: Publish JUnit Results
uses: actions/upload-artifact@v3
if: always()
with:
name: Unit Test Results
path: "*/build/test-results/test/*.xml"
if-no-files-found: error

6
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "gradle"
directory: "/"
schedule:
interval: "weekly"

View file

@ -1,19 +1,26 @@
name: Publish Docker image
on:
push:
branches: main
tags:
- '*'
pull_request:
types:
closed
branches:
- main
- 'main'
jobs:
push_to_registry:
if: github.event.pull_request.merged == true
name: Push Docker image to GitHub Packages
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/wbrawner/twigs
@ -26,17 +33,17 @@ jobs:
type=semver,pattern={{major}}
type=sha
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64

View file

@ -2,7 +2,8 @@ name: Pull request workflow
on: pull_request
permissions:
statuses: write
contents: write
pull-requests: write
checks: write
jobs:
@ -10,15 +11,15 @@ jobs:
name: Build and Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
- uses: actions/checkout@v3
- uses: actions/setup-java@v3
with:
distribution: temurin
java-version: 17
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v3
uses: gradle/wrapper-validation-action@v1
- name: Build with Gradle
uses: gradle/gradle-build-action@v3
uses: gradle/gradle-build-action@v2
with:
arguments: --stacktrace build
- name: Publish JUnit Results
@ -29,3 +30,13 @@ jobs:
path: "*/build/test-results/test/*.xml"
reporter: java-junit
fail-on-error: true
automerge:
name: Enable automerge
runs-on: ubuntu-latest
if: ${{ github.actor == 'wbrawner' || github.actor == 'dependabot[bot]' }}
steps:
- name: Enable auto-merge
run: gh pr merge --auto --merge "$PR_URL"
env:
PR_URL: ${{github.event.pull_request.html_url}}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}

View file

@ -1,4 +1,4 @@
FROM ibm-semeru-runtimes:open-21-jdk as builder
FROM openjdk:17-jdk as builder
MAINTAINER William Brawner <me@wbrawner.com>
RUN groupadd --system --gid 1000 gradle \
@ -8,10 +8,10 @@ COPY --chown=gradle:gradle . /home/gradle/src
WORKDIR /home/gradle/src
RUN /home/gradle/src/gradlew --console=plain --no-daemon shadowJar
FROM ibm-semeru-runtimes:open-21-jre
FROM openjdk:17-slim
EXPOSE 8080
RUN groupadd --system --gid 1000 twigs \
&& useradd --system --gid twigs --uid 1000 --create-home twigs
COPY --from=builder --chown=twigs:twigs /home/gradle/src/app/build/libs/twigs.jar twigs.jar
USER twigs
CMD /opt/java/openjdk/bin/java $JVM_ARGS -jar /twigs.jar
CMD /usr/local/openjdk-17/bin/java $JVM_ARGS -jar /twigs.jar

View file

@ -1,11 +1,12 @@
# Twigs
# Twigs Server
Twigs is a personal finance application tailored to individuals and small groups that want robust budgeting features
without needing to pay a monthly subscription.
This is the backend application that powers the [Android](../../../twigs-android), [iOS](../../../twigs-ios),
and [web](../../../twigs-web) applications for Twigs, a personal finance/budgeting app. None of these apps are complete,
so expect bugs, and they are all in various stages of development, so expect some feature disparity between platforms.
## Prerequisites
- JDK 17 or newer
- JDK 14 or newer
- PostgreSQL 13 or newer
- (optional) Docker

View file

@ -1,14 +1,15 @@
plugins {
kotlin("jvm")
alias(libs.plugins.kotlin.serialization)
`java-library`
}
dependencies {
implementation(kotlin("stdlib"))
api(project(":core"))
implementation(project(":service"))
implementation(project(":storage"))
api(libs.ktor.server.core)
api(libs.ktor.serialization)
api(libs.kotlinx.coroutines.core)
testImplementation(project(":testhelpers"))
}

View file

@ -0,0 +1,62 @@
package com.wbrawner.twigs
import com.wbrawner.twigs.model.Budget
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.storage.BudgetRepository
import com.wbrawner.twigs.storage.PermissionRepository
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.response.*
import io.ktor.util.pipeline.*
suspend inline fun PipelineContext<Unit, ApplicationCall>.requireBudgetWithPermission(
permissionRepository: PermissionRepository,
userId: String,
budgetId: String?,
permission: Permission,
otherwise: () -> Unit
) {
if (budgetId.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "budgetId is required")
return
}
permissionRepository.findAll(
userId = userId,
budgetIds = listOf(budgetId)
).firstOrNull {
it.permission.isAtLeast(permission)
} ?: run {
errorResponse(HttpStatusCode.Forbidden, "Insufficient permissions on budget $budgetId")
otherwise()
}
}
suspend fun PipelineContext<Unit, ApplicationCall>.budgetWithPermission(
budgetRepository: BudgetRepository,
permissionRepository: PermissionRepository,
budgetId: String,
permission: Permission,
block: suspend (Budget) -> Unit
) {
val session = call.principal<Session>()!!
val userPermission = permissionRepository.findAll(
userId = session.userId,
budgetIds = listOf(budgetId)
).firstOrNull()
if (userPermission?.permission?.isAtLeast(permission) != true) {
errorResponse(HttpStatusCode.Forbidden)
return
}
block(budgetRepository.findAll(ids = listOf(budgetId)).first())
}
suspend inline fun PipelineContext<Unit, ApplicationCall>.errorResponse(
httpStatusCode: HttpStatusCode = HttpStatusCode.NotFound,
message: String? = null
) {
message?.let {
call.respond(httpStatusCode, ErrorResponse(message))
} ?: call.respond(httpStatusCode)
}

View file

@ -1,10 +1,9 @@
package com.wbrawner.twigs.service.budget
package com.wbrawner.twigs
import com.wbrawner.twigs.model.Budget
import com.wbrawner.twigs.model.UserPermission
import com.wbrawner.twigs.service.user.UserPermissionRequest
import com.wbrawner.twigs.service.user.UserPermissionResponse
import kotlinx.serialization.Serializable
import java.util.*
@Serializable
data class BudgetRequest(
@ -21,7 +20,7 @@ data class BudgetResponse(
val users: List<UserPermissionResponse>
) {
constructor(budget: Budget, users: Iterable<UserPermission>) : this(
requireNotNull(budget.id),
Objects.requireNonNull<String>(budget.id),
budget.name,
budget.description,
users.map { userPermission: UserPermission ->

View file

@ -1,57 +1,122 @@
package com.wbrawner.twigs
import com.wbrawner.twigs.service.budget.BudgetService
import com.wbrawner.twigs.service.requireSession
import com.wbrawner.twigs.service.respondCatching
import com.wbrawner.twigs.model.Budget
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.model.UserPermission
import com.wbrawner.twigs.storage.BudgetRepository
import com.wbrawner.twigs.storage.PermissionRepository
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.util.*
fun Application.budgetRoutes(budgetService: BudgetService) {
fun Application.budgetRoutes(
budgetRepository: BudgetRepository,
permissionRepository: PermissionRepository
) {
routing {
route("/api/budgets") {
authenticate(optional = false) {
get {
call.respondCatching {
budgetService.budgetsForUser(userId = requireSession().userId)
val session = call.principal<Session>()!!
val budgetIds = permissionRepository.findAll(userId = session.userId).map { it.budgetId }
if (budgetIds.isEmpty()) {
call.respond(emptyList<BudgetResponse>())
return@get
}
val budgets = budgetRepository.findAll(ids = budgetIds).map {
BudgetResponse(it, permissionRepository.findAll(budgetIds = listOf(it.id)))
}
call.respond(budgets)
}
get("/{id}") {
call.respondCatching {
budgetService.budget(
budgetId = call.parameters.getOrFail("id"),
userId = requireSession().userId
)
budgetWithPermission(
budgetRepository,
permissionRepository,
call.parameters["id"]!!,
Permission.READ
) { budget ->
val users = permissionRepository.findAll(budgetIds = listOf(budget.id))
call.respond(BudgetResponse(budget, users))
}
}
post {
call.respondCatching {
budgetService.save(request = call.receive(), userId = requireSession().userId)
val session = call.principal<Session>()!!
val request = call.receive<BudgetRequest>()
if (request.name.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Name cannot be empty or null")
return@post
}
val budget = budgetRepository.save(
Budget(
name = request.name,
description = request.description
)
)
val users = request.users?.map {
permissionRepository.save(
UserPermission(
budgetId = budget.id,
userId = it.user,
permission = it.permission
)
)
}?.toMutableSet() ?: mutableSetOf()
if (users.none { it.userId == session.userId }) {
users.add(
permissionRepository.save(
UserPermission(
budgetId = budget.id,
userId = session.userId,
permission = Permission.OWNER
)
)
)
}
call.respond(BudgetResponse(budget, users))
}
put("/{id}") {
call.respondCatching {
budgetService.save(
request = call.receive(),
userId = requireSession().userId,
budgetId = call.parameters.getOrFail("id")
budgetWithPermission(
budgetRepository,
permissionRepository,
call.parameters["id"]!!,
Permission.MANAGE
) { budget ->
val request = call.receive<BudgetRequest>()
val name = request.name ?: budget.name
val description = request.description ?: budget.description
val users = request.users?.map {
permissionRepository.save(UserPermission(budget.id, it.user, it.permission))
} ?: permissionRepository.findAll(budgetIds = listOf(budget.id))
permissionRepository.findAll(budgetIds = listOf(budget.id)).forEach {
if (it.permission != Permission.OWNER && users.none { userPermission -> userPermission.userId == it.userId }) {
permissionRepository.delete(it)
}
}
call.respond(
BudgetResponse(
budgetRepository.save(budget.copy(name = name, description = description)),
users
)
)
}
}
delete("/{id}") {
call.respondCatching {
budgetService.delete(
budgetId = call.parameters.getOrFail("id"),
userId = requireSession().userId
)
HttpStatusCode.NoContent
budgetWithPermission(
budgetRepository,
permissionRepository,
budgetId = call.parameters["id"]!!,
Permission.OWNER
) { budget ->
budgetRepository.delete(budget)
call.respond(HttpStatusCode.NoContent)
}
}
}

View file

@ -1,4 +1,4 @@
package com.wbrawner.twigs.service.category
package com.wbrawner.twigs
import com.wbrawner.twigs.model.Category
import kotlinx.serialization.Serializable

View file

@ -1,64 +1,139 @@
package com.wbrawner.twigs
import com.wbrawner.twigs.service.category.CategoryRequest
import com.wbrawner.twigs.service.category.CategoryService
import com.wbrawner.twigs.service.requireSession
import com.wbrawner.twigs.service.respondCatching
import com.wbrawner.twigs.model.Category
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.storage.CategoryRepository
import com.wbrawner.twigs.storage.PermissionRepository
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.util.*
fun Application.categoryRoutes(categoryService: CategoryService) {
fun Application.categoryRoutes(
categoryRepository: CategoryRepository,
permissionRepository: PermissionRepository
) {
routing {
route("/api/categories") {
authenticate(optional = false) {
get {
call.respondCatching {
categoryService.categories(
budgetIds = call.request.queryParameters.getAll("budgetIds").orEmpty(),
userId = requireSession().userId,
expense = call.request.queryParameters["expense"]?.toBoolean(),
archived = call.request.queryParameters["archived"]?.toBoolean()
)
val session = call.principal<Session>()!!
val budgetIds = permissionRepository.findAll(
budgetIds = call.request.queryParameters.getAll("budgetIds"),
userId = session.userId
).map { it.budgetId }
if (budgetIds.isEmpty()) {
call.respond(emptyList<CategoryResponse>())
return@get
}
call.respond(categoryRepository.findAll(
budgetIds = budgetIds,
expense = call.request.queryParameters["expense"]?.toBoolean(),
archived = call.request.queryParameters["archived"]?.toBoolean()
).map { it.asResponse() })
}
get("/{id}") {
call.respondCatching {
categoryService.category(
categoryId = call.parameters.getOrFail("id"),
userId = requireSession().userId
)
val session = call.principal<Session>()!!
val budgetIds = permissionRepository.findAll(userId = session.userId).map { it.budgetId }
if (budgetIds.isEmpty()) {
errorResponse()
return@get
}
categoryRepository.findAll(
ids = call.parameters.getAll("id"),
budgetIds = budgetIds
)
.map { it.asResponse() }
.firstOrNull()?.let {
call.respond(it)
} ?: errorResponse()
}
post {
call.respondCatching {
categoryService.save(call.receive<CategoryRequest>(), requireSession().userId)
val session = call.principal<Session>()!!
val request = call.receive<CategoryRequest>()
if (request.title.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Title cannot be null or empty")
return@post
}
if (request.budgetId.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Budget ID cannot be null or empty")
return@post
}
requireBudgetWithPermission(
permissionRepository,
session.userId,
request.budgetId,
Permission.WRITE
) {
return@post
}
call.respond(
categoryRepository.save(
Category(
title = request.title,
description = request.description,
amount = request.amount ?: 0L,
expense = request.expense ?: true,
budgetId = request.budgetId
)
).asResponse()
)
}
put("/{id}") {
call.respondCatching {
categoryService.save(
request = call.receive<CategoryRequest>(),
userId = requireSession().userId,
categoryId = call.parameters.getOrFail("id")
)
val session = call.principal<Session>()!!
val request = call.receive<CategoryRequest>()
val category = categoryRepository.findAll(ids = call.parameters.getAll("id"))
.firstOrNull()
?: run {
call.respond(HttpStatusCode.NotFound)
return@put
}
requireBudgetWithPermission(
permissionRepository,
session.userId,
category.budgetId,
Permission.WRITE
) {
return@put
}
call.respond(
categoryRepository.save(
category.copy(
title = request.title ?: category.title,
description = request.description ?: category.description,
amount = request.amount ?: category.amount,
expense = request.expense ?: category.expense,
archived = request.archived ?: category.archived,
)
).asResponse()
)
}
delete("/{id}") {
call.respondCatching {
categoryService.delete(
call.parameters.getOrFail("id"),
requireSession().userId
)
HttpStatusCode.NoContent
val session = call.principal<Session>()!!
val categoryId = call.parameters.entries().first().value
val category = categoryRepository.findAll(ids = categoryId)
.firstOrNull()
?: run {
errorResponse(HttpStatusCode.NotFound)
return@delete
}
requireBudgetWithPermission(
permissionRepository,
session.userId,
category.budgetId,
Permission.WRITE
) {
return@delete
}
categoryRepository.delete(category)
call.respond(HttpStatusCode.NoContent)
}
}
}

View file

@ -0,0 +1,6 @@
package com.wbrawner.twigs
import kotlinx.serialization.Serializable
@Serializable
data class ErrorResponse(val message: String)

View file

@ -1,59 +1,158 @@
package com.wbrawner.twigs
import com.wbrawner.twigs.service.recurringtransaction.RecurringTransactionRequest
import com.wbrawner.twigs.service.recurringtransaction.RecurringTransactionService
import com.wbrawner.twigs.service.requireSession
import com.wbrawner.twigs.service.respondCatching
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.RecurringTransaction
import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.storage.PermissionRepository
import com.wbrawner.twigs.storage.RecurringTransactionRepository
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.util.*
import io.ktor.util.pipeline.*
import java.time.Instant
fun Application.recurringTransactionRoutes(
recurringTransactionRepository: RecurringTransactionRepository,
permissionRepository: PermissionRepository
) {
suspend fun PipelineContext<Unit, ApplicationCall>.recurringTransactionAfterPermissionCheck(
id: String?,
userId: String,
success: suspend (RecurringTransaction) -> Unit
) {
if (id.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "id is required")
return
}
val recurringTransaction = recurringTransactionRepository.findAll(ids = listOf(id)).firstOrNull()
?: run {
errorResponse()
return
}
requireBudgetWithPermission(
permissionRepository,
userId,
recurringTransaction.budgetId,
Permission.WRITE
) {
application.log.info("No permissions on budget ${recurringTransaction.budgetId}.")
return
}
success(recurringTransaction)
}
fun Application.recurringTransactionRoutes(recurringTransactionService: RecurringTransactionService) {
routing {
route("/api/recurringtransactions") {
authenticate(optional = false) {
get {
call.respondCatching {
recurringTransactionService.recurringTransactions(
budgetId = call.request.queryParameters.getOrFail("budgetId"),
userId = requireSession().userId
)
val session = call.principal<Session>()!!
val budgetId = call.request.queryParameters["budgetId"]
requireBudgetWithPermission(
permissionRepository,
session.userId,
budgetId,
Permission.WRITE
) {
return@get
}
call.respond(
recurringTransactionRepository.findAll(
budgetId = budgetId!!
).map { it.asResponse() }
)
}
get("/{id}") {
call.respondCatching {
recurringTransactionService.recurringTransaction(
call.parameters.getOrFail("id"),
requireSession().userId
)
val session = call.principal<Session>()!!
recurringTransactionAfterPermissionCheck(call.parameters["id"]!!, session.userId) {
call.respond(it.asResponse())
}
}
post {
call.respondCatching {
recurringTransactionService.save(
request = call.receive<RecurringTransactionRequest>(),
userId = requireSession().userId
val session = call.principal<Session>()!!
val request = call.receive<RecurringTransactionRequest>()
if (request.title.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Title cannot be null or empty")
return@post
}
if (request.budgetId.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Budget ID cannot be null or empty")
return@post
}
requireBudgetWithPermission(
permissionRepository,
session.userId,
request.budgetId,
Permission.WRITE
) {
return@post
}
call.respond(
recurringTransactionRepository.save(
RecurringTransaction(
title = request.title,
description = request.description,
amount = request.amount ?: 0L,
expense = request.expense ?: true,
budgetId = request.budgetId,
categoryId = request.categoryId,
createdBy = session.userId,
start = request.start?.toInstant() ?: Instant.now(),
finish = request.finish?.toInstant(),
frequency = request.frequency.asFrequency()
)
).asResponse()
)
}
put("/{id}") {
val session = call.principal<Session>()!!
val request = call.receive<RecurringTransactionRequest>()
recurringTransactionAfterPermissionCheck(
call.parameters["id"]!!,
session.userId
) { recurringTransaction ->
if (request.budgetId != recurringTransaction.budgetId) {
requireBudgetWithPermission(
permissionRepository,
session.userId,
request.budgetId,
Permission.WRITE
) {
return@recurringTransactionAfterPermissionCheck
}
}
call.respond(
recurringTransactionRepository.save(
recurringTransaction.copy(
title = request.title ?: recurringTransaction.title,
description = request.description ?: recurringTransaction.description,
amount = request.amount ?: recurringTransaction.amount,
expense = request.expense ?: recurringTransaction.expense,
categoryId = request.categoryId ?: recurringTransaction.categoryId,
budgetId = request.budgetId ?: recurringTransaction.budgetId,
start = request.start?.toInstant() ?: recurringTransaction.start,
finish = request.finish?.toInstant() ?: recurringTransaction.finish,
frequency = request.frequency.asFrequency()
)
).asResponse()
)
}
}
put("/{id}") {
recurringTransactionService.save(
request = call.receive<RecurringTransactionRequest>(),
userId = requireSession().userId,
recurringTransactionId = call.parameters.getOrFail("id")
)
}
delete("/{id}") {
call.respondCatching {
recurringTransactionService.delete(call.parameters.getOrFail("id"), requireSession().userId)
HttpStatusCode.NoContent
val session = call.principal<Session>()!!
recurringTransactionAfterPermissionCheck(call.parameters["id"]!!, session.userId) {
val response = if (recurringTransactionRepository.delete(it)) {
HttpStatusCode.NoContent
} else {
HttpStatusCode.InternalServerError
}
call.respond(response)
}
}
}

View file

@ -1,4 +1,4 @@
package com.wbrawner.twigs.service.recurringtransaction
package com.wbrawner.twigs
import com.wbrawner.twigs.model.RecurringTransaction
import kotlinx.serialization.Serializable

View file

@ -1,4 +1,4 @@
package com.wbrawner.twigs.service.transaction
package com.wbrawner.twigs
import com.wbrawner.twigs.model.Transaction
import kotlinx.serialization.Serializable

View file

@ -1,85 +1,169 @@
package com.wbrawner.twigs
import com.wbrawner.twigs.service.requireSession
import com.wbrawner.twigs.service.respondCatching
import com.wbrawner.twigs.service.transaction.BalanceResponse
import com.wbrawner.twigs.service.transaction.TransactionRequest
import com.wbrawner.twigs.service.transaction.TransactionService
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.model.Transaction
import com.wbrawner.twigs.storage.PermissionRepository
import com.wbrawner.twigs.storage.TransactionRepository
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.util.*
import java.time.Instant
fun Application.transactionRoutes(transactionService: TransactionService) {
fun Application.transactionRoutes(
transactionRepository: TransactionRepository,
permissionRepository: PermissionRepository
) {
routing {
route("/api/transactions") {
authenticate(optional = false) {
get {
call.respondCatching {
transactionService.transactions(
budgetIds = call.request.queryParameters.getAll("budgetIds").orEmpty(),
val session = call.principal<Session>()!!
call.respond(
transactionRepository.findAll(
budgetIds = permissionRepository.findAll(
budgetIds = call.request.queryParameters.getAll("budgetIds"),
userId = session.userId
).map { it.budgetId },
categoryIds = call.request.queryParameters.getAll("categoryIds"),
from = call.request.queryParameters["from"]?.let { Instant.parse(it) },
to = call.request.queryParameters["to"]?.let { Instant.parse(it) },
expense = call.request.queryParameters["expense"]?.toBoolean(),
userId = requireSession().userId
)
}
).map { it.asResponse() })
}
get("/{id}") {
call.respondCatching {
transactionService.transaction(
transactionId = call.parameters.getOrFail("id"),
userId = requireSession().userId
val session = call.principal<Session>()!!
val transaction = transactionRepository.findAll(
ids = call.parameters.getAll("id"),
budgetIds = permissionRepository.findAll(
userId = session.userId
)
}
.map { it.budgetId }
)
.map { it.asResponse() }
.firstOrNull()
transaction?.let {
call.respond(it)
} ?: errorResponse()
}
get("/sum") {
call.respondCatching {
BalanceResponse(
transactionService.sum(
budgetId = call.request.queryParameters["budgetId"],
categoryId = call.request.queryParameters["categoryId"],
from = call.request.queryParameters["from"]?.toInstant() ?: firstOfMonth,
to = call.request.queryParameters["to"]?.toInstant() ?: endOfMonth,
userId = requireSession().userId,
val categoryId = call.request.queryParameters["categoryId"]
val budgetId = call.request.queryParameters["budgetId"]
val from = call.request.queryParameters["from"]?.toInstant() ?: firstOfMonth
val to = call.request.queryParameters["to"]?.toInstant() ?: endOfMonth
val balance = if (!categoryId.isNullOrBlank()) {
if (!budgetId.isNullOrBlank()) {
errorResponse(
HttpStatusCode.BadRequest,
"budgetId and categoryId cannot be provided together"
)
)
return@get
}
transactionRepository.sumByCategory(categoryId, from, to)
} else if (!budgetId.isNullOrBlank()) {
transactionRepository.sumByBudget(budgetId, from, to)
} else {
errorResponse(HttpStatusCode.BadRequest, "budgetId or categoryId must be provided to sum")
return@get
}
call.respond(BalanceResponse(balance))
}
post {
call.respondCatching {
transactionService.save(
request = call.receive<TransactionRequest>(),
userId = requireSession().userId
)
val session = call.principal<Session>()!!
val request = call.receive<TransactionRequest>()
if (request.title.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Title cannot be null or empty")
return@post
}
if (request.budgetId.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Budget ID cannot be null or empty")
return@post
}
requireBudgetWithPermission(
permissionRepository,
session.userId,
request.budgetId,
Permission.WRITE
) {
return@post
}
call.respond(
transactionRepository.save(
Transaction(
title = request.title,
description = request.description,
amount = request.amount ?: 0L,
expense = request.expense ?: true,
budgetId = request.budgetId,
categoryId = request.categoryId,
createdBy = session.userId,
date = request.date?.let { Instant.parse(it) } ?: Instant.now()
)
).asResponse()
)
}
put("/{id}") {
call.respondCatching {
transactionService.save(
request = call.receive<TransactionRequest>(),
userId = requireSession().userId,
transactionId = call.parameters.getOrFail("id")
)
val session = call.principal<Session>()!!
val request = call.receive<TransactionRequest>()
val transaction = transactionRepository.findAll(ids = call.parameters.getAll("id"))
.firstOrNull()
?: run {
errorResponse()
return@put
}
requireBudgetWithPermission(
permissionRepository,
session.userId,
transaction.budgetId,
Permission.WRITE
) {
return@put
}
call.respond(
transactionRepository.save(
transaction.copy(
title = request.title ?: transaction.title,
description = request.description ?: transaction.description,
amount = request.amount ?: transaction.amount,
expense = request.expense ?: transaction.expense,
date = request.date?.let { Instant.parse(it) } ?: transaction.date,
categoryId = request.categoryId ?: transaction.categoryId,
budgetId = request.budgetId ?: transaction.budgetId,
createdBy = transaction.createdBy,
)
).asResponse()
)
}
delete("/{id}") {
call.respondCatching {
transactionService.delete(
transactionId = call.parameters.getOrFail("id"),
userId = requireSession().userId
)
HttpStatusCode.NoContent
val session = call.principal<Session>()!!
val transaction = transactionRepository.findAll(ids = call.parameters.getAll("id"))
.firstOrNull()
?: run {
errorResponse()
return@delete
}
requireBudgetWithPermission(
permissionRepository,
session.userId,
transaction.budgetId,
Permission.WRITE
) {
return@delete
}
val response = if (transactionRepository.delete(transaction)) {
HttpStatusCode.NoContent
} else {
HttpStatusCode.InternalServerError
}
call.respond(response)
}
}
}

View file

@ -1,4 +1,4 @@
package com.wbrawner.twigs.service.user
package com.wbrawner.twigs
import com.wbrawner.twigs.model.PasswordResetToken
import com.wbrawner.twigs.model.Permission

View file

@ -1,84 +1,190 @@
package com.wbrawner.twigs
import com.wbrawner.twigs.service.requireSession
import com.wbrawner.twigs.service.respondCatching
import com.wbrawner.twigs.service.user.UserService
import com.wbrawner.twigs.model.PasswordResetToken
import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.model.User
import com.wbrawner.twigs.storage.*
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.util.*
import java.time.Instant
fun Application.userRoutes(userService: UserService) {
fun Application.userRoutes(
emailService: EmailService,
passwordResetRepository: PasswordResetRepository,
permissionRepository: PermissionRepository,
sessionRepository: SessionRepository,
userRepository: UserRepository,
passwordHasher: PasswordHasher
) {
routing {
route("/api/users") {
post("/login") {
call.respondCatching {
userService.login(call.receive())
}
val request = call.receive<LoginRequest>()
val user =
userRepository.findAll(
nameOrEmail = request.username,
password = passwordHasher.hash(request.password)
)
.firstOrNull()
?: userRepository.findAll(
nameOrEmail = request.username,
password = passwordHasher.hash(request.password)
)
.firstOrNull()
?: run {
errorResponse(HttpStatusCode.Unauthorized, "Invalid credentials")
return@post
}
val session = sessionRepository.save(Session(userId = user.id))
call.respond(session.asResponse())
}
post("/register") {
call.respondCatching {
userService.register(call.receive())
val request = call.receive<UserRequest>()
if (request.username.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Username must not be null or blank")
return@post
}
}
route("/resetpassword") {
post {
call.respondCatching {
userService.requestPasswordResetEmail(call.receive())
HttpStatusCode.Accepted
}
}
put {
call.respondCatching {
userService.resetPassword(call.receive())
HttpStatusCode.NoContent
if (request.password.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Password must not be null or blank")
return@post
}
val existingUser = userRepository.findAll(nameOrEmail = request.username).firstOrNull()
?: request.email?.let {
return@let if (it.isBlank()) {
null
} else {
userRepository.findAll(nameOrEmail = it).firstOrNull()
}
}
existingUser?.let {
errorResponse(HttpStatusCode.BadRequest, "Username or email already taken")
return@post
}
call.respond(
userRepository.save(
User(
name = request.username,
password = passwordHasher.hash(request.password),
email = if (request.email.isNullOrBlank()) "" else request.email
)
).asResponse()
)
}
authenticate(optional = false) {
get {
call.respondCatching {
userService.users(
query = call.request.queryParameters["query"],
budgetIds = call.request.queryParameters.getAll("budgetId"),
requestingUserId = requireSession().userId
)
val query = call.request.queryParameters["query"]
val budgetIds = call.request.queryParameters.getAll("budgetId")
if (query != null) {
if (query.isBlank()) {
errorResponse(HttpStatusCode.BadRequest, "query cannot be empty")
}
call.respond(userRepository.findAll(nameLike = query).map { it.asResponse() })
return@get
} else if (budgetIds == null || budgetIds.all { it.isBlank() }) {
errorResponse(HttpStatusCode.BadRequest, "query or budgetId required but absent")
}
permissionRepository.findAll(budgetIds = budgetIds)
.mapNotNull {
userRepository.findAll(ids = listOf(it.userId))
.firstOrNull()
?.asResponse()
}.run { call.respond(this) }
}
get("/{id}") {
call.respondCatching {
userService.user(call.parameters.getOrFail("id"))
}
userRepository.findAll(ids = call.parameters.getAll("id"))
.firstOrNull()
?.asResponse()
?.let { call.respond(it) }
?: errorResponse(HttpStatusCode.NotFound)
}
put("/{id}") {
call.respondCatching {
userService.save(
request = call.receive(),
targetUserId = call.parameters.getOrFail("id"),
requestingUserId = requireSession().userId
)
val session = call.principal<Session>()!!
val request = call.receive<UserRequest>()
// TODO: Add some kind of admin denotation to allow admins to edit other users
if (call.parameters["id"] != session.userId) {
errorResponse(HttpStatusCode.Forbidden)
return@put
}
call.respond(
userRepository.save(
userRepository.findAll(ids = call.parameters.getAll("id"))
.first()
.run {
copy(
name = request.username ?: name,
password = request.password?.let { passwordHasher.hash(it) } ?: password,
email = request.email ?: email
)
}
).asResponse()
)
}
delete("/{id}") {
call.respondCatching {
userService.delete(
targetUserId = call.parameters.getOrFail("id"),
requestingUserId = requireSession().userId
)
HttpStatusCode.NoContent
val session = call.principal<Session>()!!
// TODO: Add some kind of admin denotation to allow admins to delete other users
val user = userRepository.findAll(call.parameters.entries().first().value).firstOrNull()
if (user == null) {
errorResponse()
return@delete
}
if (user.id != session.userId) {
errorResponse(HttpStatusCode.Forbidden)
return@delete
}
userRepository.delete(user)
call.respond(HttpStatusCode.NoContent)
}
}
}
route("/api/resetpassword") {
post {
val request = call.receive<ResetPasswordRequest>()
userRepository.findAll(nameOrEmail = request.username)
.firstOrNull()
?.let {
val email = it.email
val passwordResetToken = passwordResetRepository.save(PasswordResetToken(userId = it.id))
emailService.sendPasswordResetEmail(passwordResetToken, email)
}
call.respond(HttpStatusCode.Accepted)
}
}
route("/api/passwordreset") {
post {
val request = call.receive<PasswordResetRequest>()
val passwordResetToken = passwordResetRepository.findAll(listOf(request.token))
.firstOrNull()
?: run {
errorResponse(HttpStatusCode.Unauthorized, "Invalid token")
return@post
}
if (passwordResetToken.expiration.isBefore(Instant.now())) {
errorResponse(HttpStatusCode.Unauthorized, "Token expired")
return@post
}
userRepository.findAll(listOf(passwordResetToken.userId))
.firstOrNull()
?.let {
userRepository.save(it.copy(password = passwordHasher.hash(request.password)))
passwordResetRepository.delete(passwordResetToken)
}
?: run {
errorResponse(HttpStatusCode.InternalServerError, "Invalid token")
return@post
}
call.respond(HttpStatusCode.NoContent)
}
}
}
}

View file

@ -26,7 +26,6 @@ dependencies {
implementation(libs.bcrypt)
implementation(libs.logback)
implementation(libs.mail)
implementation(project(mapOf("path" to ":service")))
testImplementation(project(":testhelpers"))
testImplementation(libs.ktor.client.content.negotiation)
testImplementation(libs.ktor.server.test)

View file

@ -1,43 +1,28 @@
package com.wbrawner.twigs.server
import at.favre.lib.crypto.bcrypt.BCrypt
import com.github.mustachejava.DefaultMustacheFactory
import ch.qos.logback.classic.Level
import com.wbrawner.twigs.*
import com.wbrawner.twigs.db.*
import com.wbrawner.twigs.model.CookieSession
import com.wbrawner.twigs.model.HeaderSession
import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.service.budget.BudgetService
import com.wbrawner.twigs.service.budget.DefaultBudgetService
import com.wbrawner.twigs.service.category.CategoryService
import com.wbrawner.twigs.service.category.DefaultCategoryService
import com.wbrawner.twigs.service.recurringtransaction.DefaultRecurringTransactionService
import com.wbrawner.twigs.service.recurringtransaction.RecurringTransactionService
import com.wbrawner.twigs.service.transaction.DefaultTransactionService
import com.wbrawner.twigs.service.transaction.TransactionService
import com.wbrawner.twigs.service.user.DefaultUserService
import com.wbrawner.twigs.service.user.UserService
import com.wbrawner.twigs.storage.PasswordHasher
import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE
import com.wbrawner.twigs.storage.*
import com.wbrawner.twigs.web.webRoutes
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.ktor.client.request.forms.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.cio.*
import io.ktor.server.engine.*
import io.ktor.server.mustache.*
import io.ktor.server.plugins.callloging.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.plugins.forwardedheaders.*
import io.ktor.server.response.*
import io.ktor.server.sessions.*
import kotlinx.coroutines.*
import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory
import java.util.concurrent.TimeUnit
fun main() {
@ -71,6 +56,7 @@ fun Application.module() {
throw RuntimeException("Unsupported DB type: $dbType")
}
}
(LoggerFactory.getLogger("com.zaxxer.hikari") as ch.qos.logback.classic.Logger).level = Level.DEBUG
HikariDataSource(HikariConfig().apply {
setJdbcUrl(jdbcUrl)
username = dbUser
@ -95,120 +81,78 @@ fun Application.module() {
metadata
}
}
val budgetRepository = JdbcBudgetRepository(it)
val categoryRepository = JdbcCategoryRepository(it)
val permissionRepository = JdbcPermissionRepository(it)
val passwordResetRepository = JdbcPasswordResetRepository(it)
val passwordHasher = PasswordHasher { password ->
String(BCrypt.withDefaults().hash(10, metadata.salt.toByteArray(), password.toByteArray()))
}
val recurringTransactionRepository = JdbcRecurringTransactionRepository(it)
val sessionRepository = JdbcSessionRepository(it)
val transactionRepository = JdbcTransactionRepository(it)
val userRepository = JdbcUserRepository(it)
val emailService = SmtpEmailService(
from = System.getenv("TWIGS_SMTP_FROM"),
host = System.getenv("TWIGS_SMTP_HOST"),
port = System.getenv("TWIGS_SMTP_PORT")?.toIntOrNull(),
username = System.getenv("TWIGS_SMTP_USER"),
password = System.getenv("TWIGS_SMTP_PASS"),
)
val jobs = listOf(
SessionCleanupJob(sessionRepository),
RecurringTransactionProcessingJob(recurringTransactionRepository, transactionRepository)
)
val sessionValidator: suspend ApplicationCall.(Session) -> Principal? = validate@{ session ->
application.environment.log.info("Validating session")
val storedSession = sessionRepository.findAll(session.token)
.firstOrNull()
if (storedSession == null) {
application.environment.log.info("Did not find session!")
return@validate null
} else {
application.environment.log.info("Found session!")
}
return@validate if (twoWeeksFromNow.isAfter(storedSession.expiration)) {
sessionRepository.save(storedSession.updateExpiration(newExpiration = twoWeeksFromNow))
} else {
null
}
}
moduleWithDependencies(
budgetService = DefaultBudgetService(budgetRepository, permissionRepository),
categoryService = DefaultCategoryService(categoryRepository, permissionRepository),
recurringTransactionService = DefaultRecurringTransactionService(
recurringTransactionRepository,
permissionRepository
emailService = SmtpEmailService(
from = System.getenv("TWIGS_SMTP_FROM"),
host = System.getenv("TWIGS_SMTP_HOST"),
port = System.getenv("TWIGS_SMTP_PORT")?.toIntOrNull(),
username = System.getenv("TWIGS_SMTP_USER"),
password = System.getenv("TWIGS_SMTP_PASS"),
),
transactionService = DefaultTransactionService(
transactionRepository,
categoryRepository,
permissionRepository
),
userService = DefaultUserService(
emailService,
passwordResetRepository,
permissionRepository,
sessionRepository,
userRepository,
passwordHasher
),
jobs = jobs,
sessionValidator = sessionValidator
metadataRepository = JdbcMetadataRepository(it),
budgetRepository = JdbcBudgetRepository(it),
categoryRepository = JdbcCategoryRepository(it),
passwordResetRepository = JdbcPasswordResetRepository(it),
passwordHasher = { password ->
String(BCrypt.withDefaults().hash(10, metadata.salt.toByteArray(), password.toByteArray()))
},
permissionRepository = JdbcPermissionRepository(it),
recurringTransactionRepository = JdbcRecurringTransactionRepository(it),
sessionRepository = JdbcSessionRepository(it),
transactionRepository = JdbcTransactionRepository(it),
userRepository = JdbcUserRepository(it)
)
}
}
fun Application.moduleWithDependencies(
budgetService: BudgetService,
categoryService: CategoryService,
recurringTransactionService: RecurringTransactionService,
transactionService: TransactionService,
userService: UserService,
jobs: List<Job>,
sessionValidator: suspend ApplicationCall.(Session) -> Principal?
emailService: EmailService,
metadataRepository: MetadataRepository,
budgetRepository: BudgetRepository,
categoryRepository: CategoryRepository,
passwordResetRepository: PasswordResetRepository,
passwordHasher: PasswordHasher,
permissionRepository: PermissionRepository,
recurringTransactionRepository: RecurringTransactionRepository,
sessionRepository: SessionRepository,
transactionRepository: TransactionRepository,
userRepository: UserRepository
) {
install(XForwardedHeaders)
install(CallLogging)
install(Authentication) {
session<Session> {
challenge {
call.respond(HttpStatusCode.Unauthorized)
}
validate(sessionValidator)
}
session<CookieSession>(TWIGS_SESSION_COOKIE) {
challenge {
call.respond(HttpStatusCode.Unauthorized)
validate { session ->
application.environment.log.info("Validating session")
val storedSession = sessionRepository.findAll(session.token)
.firstOrNull()
if (storedSession == null) {
application.environment.log.info("Did not find session!")
return@validate null
} else {
application.environment.log.info("Found session!")
}
return@validate if (twoWeeksFromNow.isAfter(storedSession.expiration)) {
sessionRepository.save(storedSession.copy(expiration = twoWeeksFromNow))
} else {
null
}
}
validate(sessionValidator)
}
}
install(Sessions) {
header<Session>("Authorization") {
serializer = object : SessionSerializer<Session> {
override fun deserialize(text: String): HeaderSession {
override fun deserialize(text: String): Session {
this@moduleWithDependencies.environment.log.info("Deserializing session!")
return HeaderSession(token = text.substringAfter("Bearer "))
return Session(token = text.substringAfter("Bearer "))
}
override fun serialize(session: Session): String = session.token
}
}
cookie<CookieSession>(TWIGS_SESSION_COOKIE) {
serializer = object : SessionSerializer<CookieSession> {
override fun deserialize(text: String): CookieSession {
this@moduleWithDependencies.environment.log.info("Deserializing session!")
return CookieSession(token = text)
}
override fun serialize(session: CookieSession): String = session.token
}
cookie.httpOnly = true
cookie.secure = true
cookie.extensions["SameSite"] = "Strict"
}
}
install(ContentNegotiation) {
json(json = Json {
@ -221,15 +165,11 @@ fun Application.moduleWithDependencies(
prettyPrint = false
useArrayPolymorphism = true
})
formData()
}
install(CORS) {
allowHost("twigs.wbrawner.com", listOf("http", "https")) // TODO: Make configurable
allowHost("localhost:4200", listOf("http", "https")) // TODO: Make configurable
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete)
allowHeader(HttpHeaders.Authorization)
@ -252,16 +192,17 @@ fun Application.moduleWithDependencies(
allowHeader("DNT")
allowCredentials = true
}
install(Mustache) {
mustacheFactory = DefaultMustacheFactory("templates")
}
budgetRoutes(budgetService)
categoryRoutes(categoryService)
recurringTransactionRoutes(recurringTransactionService)
transactionRoutes(transactionService)
userRoutes(userService)
webRoutes(budgetService, categoryService, transactionService, userService)
budgetRoutes(budgetRepository, permissionRepository)
categoryRoutes(categoryRepository, permissionRepository)
recurringTransactionRoutes(recurringTransactionRepository, permissionRepository)
transactionRoutes(transactionRepository, permissionRepository)
userRoutes(emailService, passwordResetRepository, permissionRepository, sessionRepository, userRepository, passwordHasher)
webRoutes()
launch {
val jobs = listOf(
SessionCleanupJob(sessionRepository),
RecurringTransactionProcessingJob(recurringTransactionRepository, transactionRepository)
)
while (currentCoroutineContext().isActive) {
jobs.forEach { it.run() }
delay(TimeUnit.HOURS.toMillis(1))

View file

@ -1,18 +1,12 @@
package com.wbrawner.twigs.server.api
import com.wbrawner.twigs.server.moduleWithDependencies
import com.wbrawner.twigs.service.budget.DefaultBudgetService
import com.wbrawner.twigs.service.category.DefaultCategoryService
import com.wbrawner.twigs.service.recurringtransaction.DefaultRecurringTransactionService
import com.wbrawner.twigs.service.transaction.DefaultTransactionService
import com.wbrawner.twigs.service.user.DefaultUserService
import com.wbrawner.twigs.test.helpers.FakeEmailService
import com.wbrawner.twigs.test.helpers.repository.*
import io.ktor.client.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.testing.*
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.BeforeEach
open class ApiTest {
@ -44,37 +38,22 @@ open class ApiTest {
fun apiTest(test: suspend ApiTest.(client: HttpClient) -> Unit) = testApplication {
application {
moduleWithDependencies(
budgetService = DefaultBudgetService(budgetRepository, permissionRepository),
categoryService = DefaultCategoryService(categoryRepository, permissionRepository),
recurringTransactionService = DefaultRecurringTransactionService(
recurringTransactionRepository,
permissionRepository
),
transactionService = DefaultTransactionService(
transactionRepository,
categoryRepository,
permissionRepository
),
userService = DefaultUserService(
emailService,
passwordResetRepository,
permissionRepository,
sessionRepository,
userRepository,
{ it }
),
jobs = listOf(),
sessionValidator = {
sessionRepository.findAll(it.token).firstOrNull()
}
emailService = emailService,
metadataRepository = metadataRepository,
budgetRepository = budgetRepository,
categoryRepository = categoryRepository,
passwordHasher = { it },
passwordResetRepository = passwordResetRepository,
permissionRepository = permissionRepository,
recurringTransactionRepository = recurringTransactionRepository,
sessionRepository = sessionRepository,
transactionRepository = transactionRepository,
userRepository = userRepository
)
}
val client = createClient {
install(ContentNegotiation) {
json(json = Json {
encodeDefaults = true
explicitNulls = false
})
json()
}
}
test(client)

View file

@ -1,14 +1,15 @@
package com.wbrawner.twigs.server.api
import com.wbrawner.twigs.BudgetRequest
import com.wbrawner.twigs.BudgetResponse
import com.wbrawner.twigs.UserPermissionRequest
import com.wbrawner.twigs.UserPermissionResponse
import com.wbrawner.twigs.model.*
import com.wbrawner.twigs.service.budget.BudgetRequest
import com.wbrawner.twigs.service.budget.BudgetResponse
import com.wbrawner.twigs.service.user.UserPermissionRequest
import com.wbrawner.twigs.service.user.UserPermissionResponse
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
class BudgetRouteTest : ApiTest() {
@ -227,8 +228,9 @@ class BudgetRouteTest : ApiTest() {
assertEquals(expectedUsers, updatedUsers)
}
@Disabled("Will be fixed with service layer refactor")
@Test
fun `updating budgets returns forbidden for users with no access`() = apiTest { client ->
fun `updating budgets returns not found for users with no access`() = apiTest { client ->
val users = listOf(
User(name = "testuser", password = "testpassword"),
User(name = "otheruser", password = "otherpassword"),
@ -252,6 +254,7 @@ class BudgetRouteTest : ApiTest() {
assertEquals(HttpStatusCode.NotFound, response.status)
}
@Disabled("Will be fixed with service layer refactor")
@Test
fun `updating non-existent budgets returns not found`() = apiTest { client ->
val users = listOf(
@ -270,6 +273,7 @@ class BudgetRouteTest : ApiTest() {
assertEquals(HttpStatusCode.NotFound, response.status)
}
@Disabled("Will be fixed with service layer refactor")
@Test
fun `updating budgets returns forbidden for users with manage access attempting to remove owner`() =
apiTest { client ->

View file

@ -1,10 +1,10 @@
package com.wbrawner.twigs.server.api
import com.wbrawner.twigs.ErrorResponse
import com.wbrawner.twigs.PasswordResetRequest
import com.wbrawner.twigs.ResetPasswordRequest
import com.wbrawner.twigs.model.PasswordResetToken
import com.wbrawner.twigs.randomString
import com.wbrawner.twigs.service.ErrorResponse
import com.wbrawner.twigs.service.user.PasswordResetRequest
import com.wbrawner.twigs.service.user.ResetPasswordRequest
import com.wbrawner.twigs.test.helpers.repository.FakeUserRepository.Companion.TEST_USER
import io.ktor.client.call.*
import io.ktor.client.request.*
@ -18,7 +18,7 @@ class PasswordResetRouteTest : ApiTest() {
@Test
fun `reset password with invalid username returns 202`() = apiTest { client ->
val request = ResetPasswordRequest(username = "invaliduser")
val response = client.post("/api/users/resetpassword") {
val response = client.post("/api/resetpassword") {
header("Content-Type", "application/json")
setBody(request)
}
@ -29,7 +29,7 @@ class PasswordResetRouteTest : ApiTest() {
@Test
fun `reset password with valid username returns 202`() = apiTest { client ->
val request = ResetPasswordRequest(username = "testuser")
val response = client.post("/api/users/resetpassword") {
val response = client.post("/api/resetpassword") {
header("Content-Type", "application/json")
setBody(request)
}
@ -43,9 +43,9 @@ class PasswordResetRouteTest : ApiTest() {
}
@Test
fun `password reset with invalid token returns 401`() = apiTest { client ->
fun `password reset with invalid token returns 400`() = apiTest { client ->
val request = PasswordResetRequest(token = randomString(), password = "newpass")
val response = client.put("/api/users/resetpassword") {
val response = client.post("/api/passwordreset") {
header("Content-Type", "application/json")
setBody(request)
}
@ -55,10 +55,10 @@ class PasswordResetRouteTest : ApiTest() {
}
@Test
fun `password reset with expired token returns 401`() = apiTest { client ->
fun `password reset with expired token returns 400`() = apiTest { client ->
val token = passwordResetRepository.save(PasswordResetToken(expiration = twoWeeksAgo))
val request = PasswordResetRequest(token = token.id, password = "newpass")
val response = client.put("/api/users/resetpassword") {
val response = client.post("/api/passwordreset") {
header("Content-Type", "application/json")
setBody(request)
}
@ -68,11 +68,10 @@ class PasswordResetRouteTest : ApiTest() {
}
@Test
fun `password reset with valid token returns 204`() = apiTest { client ->
val token =
passwordResetRepository.save(PasswordResetToken(userId = userRepository.findAll("testuser").first().id))
fun `password reset with valid token returns 200`() = apiTest { client ->
val token = passwordResetRepository.save(PasswordResetToken(userId = userRepository.findAll("testuser").first().id))
val request = PasswordResetRequest(token = token.id, password = "newpass")
val response = client.put("/api/users/resetpassword") {
val response = client.post("/api/passwordreset") {
header("Content-Type", "application/json")
setBody(request)
}

View file

@ -1,9 +1,8 @@
package com.wbrawner.twigs.server.api
import com.wbrawner.twigs.*
import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.model.User
import com.wbrawner.twigs.service.ErrorResponse
import com.wbrawner.twigs.service.user.*
import com.wbrawner.twigs.test.helpers.repository.FakeUserRepository.Companion.OTHER_USER
import com.wbrawner.twigs.test.helpers.repository.FakeUserRepository.Companion.TEST_USER
import io.ktor.client.call.*
@ -77,7 +76,7 @@ class UserRouteTest : ApiTest() {
@Test
fun `login with valid email and password returns 200`() = apiTest { client ->
val request = LoginRequest(requireNotNull(TEST_USER.email), TEST_USER.password)
val request = LoginRequest(TEST_USER.email, TEST_USER.password)
val response = client.post("/api/users/login") {
header("Content-Type", "application/json")
setBody(request)
@ -97,7 +96,7 @@ class UserRouteTest : ApiTest() {
}
assertEquals(HttpStatusCode.BadRequest, response.status)
val errorBody = response.body<ErrorResponse>()
assertEquals("username must not be null or blank", errorBody.message)
assertEquals("Username must not be null or blank", errorBody.message)
}
@Test
@ -109,7 +108,7 @@ class UserRouteTest : ApiTest() {
}
assertEquals(HttpStatusCode.BadRequest, response.status)
val errorBody = response.body<ErrorResponse>()
assertEquals("username must not be null or blank", errorBody.message)
assertEquals("Username must not be null or blank", errorBody.message)
}
@Test
@ -121,7 +120,7 @@ class UserRouteTest : ApiTest() {
}
assertEquals(HttpStatusCode.BadRequest, response.status)
val errorBody = response.body<ErrorResponse>()
assertEquals("password must not be null or blank", errorBody.message)
assertEquals("Password must not be null or blank", errorBody.message)
}
@Test
@ -133,7 +132,7 @@ class UserRouteTest : ApiTest() {
}
assertEquals(HttpStatusCode.BadRequest, response.status)
val errorBody = response.body<ErrorResponse>()
assertEquals("password must not be null or blank", errorBody.message)
assertEquals("Password must not be null or blank", errorBody.message)
}
@Test
@ -145,7 +144,7 @@ class UserRouteTest : ApiTest() {
}
assertEquals(HttpStatusCode.BadRequest, response.status)
val errorBody = response.body<ErrorResponse>()
assertEquals("username or email already taken", errorBody.message)
assertEquals("Username or email already taken", errorBody.message)
}
@Test
@ -157,7 +156,7 @@ class UserRouteTest : ApiTest() {
}
assertEquals(HttpStatusCode.BadRequest, response.status)
val errorBody = response.body<ErrorResponse>()
assertEquals("username or email already taken", errorBody.message)
assertEquals("Username or email already taken", errorBody.message)
}
@Test
@ -172,14 +171,14 @@ class UserRouteTest : ApiTest() {
val userResponse = response.body<UserResponse>()
assert(userResponse.id.isNotBlank())
assertEquals(request.username, userResponse.username)
assertEquals(null, userResponse.email)
assertEquals("", userResponse.email)
assertEquals(initialUserCount + 1, userRepository.entities.size)
val savedUser: User? = userRepository.findAll("newuser").firstOrNull()
assertNotNull(savedUser)
requireNotNull(savedUser)
assertEquals(userResponse.id, savedUser.id)
assertEquals(request.username, savedUser.name)
assertEquals(null, savedUser.email)
assertEquals("", savedUser.email)
assertEquals("newpass", savedUser.password)
}

View file

@ -15,7 +15,6 @@ buildscript {
plugins {
java
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization) apply false
}
val javaVersion = JavaVersion.VERSION_17

View file

@ -2,7 +2,6 @@ package com.wbrawner.twigs
import com.wbrawner.twigs.model.Frequency
import java.time.Instant
import java.time.format.DateTimeParseException
import java.util.*
private val CALENDAR_FIELDS = intArrayOf(
@ -53,10 +52,4 @@ fun randomString(length: Int = 32): String {
fun String.toInstant(): Instant = Instant.parse(this)
fun String.toInstantOrNull(): Instant? = try {
Instant.parse(this)
} catch (e: DateTimeParseException) {
null
}
fun String.asFrequency(): Frequency = Frequency.parse(this)

View file

@ -6,20 +6,9 @@ import com.wbrawner.twigs.twoWeeksFromNow
import io.ktor.server.auth.*
import java.time.Instant
open class Session(
data class Session(
override val id: String = randomString(),
val userId: String = "",
open val token: String = randomString(255),
val expiration: Instant = twoWeeksFromNow
) : Principal, Identifiable {
fun updateExpiration(newExpiration: Instant) = Session(
id = id,
userId = userId,
token = token,
expiration = newExpiration
)
}
data class HeaderSession(override val token: String) : Session(token = token)
data class CookieSession(override val token: String) : Session(token = token)
val token: String = randomString(255),
var expiration: Instant = twoWeeksFromNow
) : Principal, Identifiable

View file

@ -8,7 +8,7 @@ data class User(
override val id: String = randomString(),
val name: String = "",
val password: String = "",
val email: String? = null
val email: String = ""
) : Principal, Identifiable
enum class Permission {

View file

@ -13,7 +13,7 @@ class JdbcPermissionRepository(dataSource: DataSource) :
override val conflictFields: Collection<String> =
listOf(Fields.USER_ID.name.lowercase(), Fields.BUDGET_ID.name.lowercase())
override suspend fun findAll(budgetIds: List<String>?, userId: String?): List<UserPermission> =
override fun findAll(budgetIds: List<String>?, userId: String?): List<UserPermission> =
dataSource.connection.use { conn ->
if (budgetIds.isNullOrEmpty() && userId.isNullOrBlank()) {
throw Error("budgetIds or userId must be provided")

View file

@ -9,12 +9,11 @@ services:
- db
environment:
TWIGS_DB_HOST: db
TWIGS_DB_TYPE: postgresql
networks:
- twigs
db:
image: postgres:17
image: postgres:13
ports:
- "5432:5432"
environment:

View file

@ -1,15 +1,15 @@
[versions]
bcrypt = "0.10.2"
hikari = "6.2.1"
junit = "5.11.3"
kotlin = "2.0.21"
kotlinx-coroutines = "1.9.0"
ktor = "2.3.13"
logback = "1.5.12"
bcrypt = "0.9.0"
hikari = "5.0.1"
junit = "5.8.2"
kotlin = "1.9.10"
kotlinx-coroutines = "1.6.2"
ktor = "2.3.4"
logback = "1.2.11"
mail = "1.6.2"
postgres = "42.7.4"
shadow = "8.1.1"
sqlite = "3.47.0.0"
postgres = "42.3.8"
shadow = "7.0.0"
sqlite = "3.42.0.0"
[libraries]
bcrypt = { module = "at.favre.lib:bcrypt", version.ref = "bcrypt" }
@ -29,8 +29,6 @@ ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
ktor-server-forwarded-headers = { module = "io.ktor:ktor-server-forwarded-header", version.ref = "ktor" }
ktor-server-mustache = { module = "io.ktor:ktor-server-mustache", version.ref = "ktor" }
ktor-server-sessions = { module = "io.ktor:ktor-server-sessions", version.ref = "ktor" }
ktor-server-test = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
@ -45,7 +43,6 @@ ktor-server = [
"ktor-server-content-negotiation",
"ktor-server-core",
"ktor-server-cors",
"ktor-server-forwarded-headers",
"ktor-server-sessions"
]

Binary file not shown.

View file

@ -1,7 +1,6 @@
#Fri Feb 07 18:11:46 CST 2020
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-all.zip
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

287
gradlew vendored
View file

@ -1,7 +1,7 @@
#!/bin/sh
#!/usr/bin/env sh
#
# Copyright © 2015-2021 the original authors.
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -15,116 +15,80 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
MAX_FD="maximum"
warn () {
echo "$*"
} >&2
}
die () {
echo
echo "$*"
echo
exit 1
} >&2
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD=$JAVA_HOME/bin/java
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
@ -133,120 +97,87 @@ Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

194
gradlew.bat vendored
View file

@ -1,94 +1,100 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -1,3 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}

1
service/.gitignore vendored
View file

@ -1 +0,0 @@
build/

View file

@ -1,19 +0,0 @@
plugins {
`java-library`
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
dependencies {
implementation(kotlin("stdlib"))
api(libs.ktor.server.core)
implementation(project(":storage"))
api(libs.ktor.serialization)
api(libs.kotlinx.coroutines.core)
testImplementation(libs.junit.jupiter.api)
testRuntimeOnly(libs.junit.jupiter.engine)
}
tasks.getByName<Test>("test") {
useJUnitPlatform()
}

View file

@ -1,59 +0,0 @@
package com.wbrawner.twigs.service
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.model.UserPermission
import com.wbrawner.twigs.service.budget.BudgetResponse
import com.wbrawner.twigs.storage.BudgetRepository
import com.wbrawner.twigs.storage.PermissionRepository
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.response.*
import io.ktor.util.pipeline.*
suspend fun PermissionRepository.requirePermission(
userId: String,
budgetIds: List<String>,
permission: Permission
): List<UserPermission> {
val uniqueBudgetIds = budgetIds.toSet()
val allPermissions = findAll(budgetIds = uniqueBudgetIds.toList(), userId = userId)
if (allPermissions.size != uniqueBudgetIds.size) {
throw HttpException(HttpStatusCode.NotFound)
} else if (allPermissions.any { !it.permission.isAtLeast(permission) }) {
throw HttpException(HttpStatusCode.Forbidden)
}
return allPermissions
}
suspend fun PermissionRepository.requirePermission(
userId: String,
budgetId: String,
permission: Permission
): List<UserPermission> = requirePermission(userId, listOf(budgetId), permission)
suspend fun Pair<BudgetRepository, PermissionRepository>.budgetWithPermission(
userId: String,
budgetId: String,
permission: Permission
): BudgetResponse {
val budget = first.findAll(ids = listOf(budgetId)).firstOrNull() ?: throw HttpException(HttpStatusCode.NotFound)
return BudgetResponse(budget, second.requirePermission(userId, budgetId, permission))
}
fun PipelineContext<Unit, ApplicationCall>.requireSession() = requireNotNull(call.principal<Session>()) {
"Session required but was null"
}
suspend inline fun <reified T : Any> ApplicationCall.respondCatching(block: () -> T) =
try {
val response = block()
if (response is HttpStatusCode) {
respond(status = response, message = Unit)
} else {
respond(HttpStatusCode.OK, response)
}
} catch (e: HttpException) {
respond(e.statusCode, e.toResponse())
}

View file

@ -1,18 +0,0 @@
package com.wbrawner.twigs.service
import io.ktor.http.*
import kotlinx.serialization.Serializable
@Serializable
data class ErrorResponse(val message: String)
class HttpException(
val statusCode: HttpStatusCode,
override val cause: Throwable? = null,
override val message: String? = null
) : Throwable() {
constructor(statusCode: HttpStatusCode) : this(statusCode = statusCode, message = statusCode.description)
fun toResponse(): ErrorResponse =
ErrorResponse(requireNotNull(message) { "Cannot send error to client without message" })
}

View file

@ -1,130 +0,0 @@
package com.wbrawner.twigs.service.budget
import com.wbrawner.twigs.model.Budget
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.UserPermission
import com.wbrawner.twigs.service.HttpException
import com.wbrawner.twigs.service.budgetWithPermission
import com.wbrawner.twigs.service.user.UserPermissionRequest
import com.wbrawner.twigs.storage.BudgetRepository
import com.wbrawner.twigs.storage.PermissionRepository
import io.ktor.http.*
interface BudgetService {
suspend fun budgetsForUser(userId: String): List<BudgetResponse>
suspend fun budget(budgetId: String, userId: String): BudgetResponse
suspend fun save(request: BudgetRequest, userId: String, budgetId: String? = null): BudgetResponse
suspend fun delete(budgetId: String, userId: String)
}
class DefaultBudgetService(
private val budgetRepository: BudgetRepository,
private val permissionRepository: PermissionRepository
) : BudgetService {
private val budgetPermissionRepository = budgetRepository to permissionRepository
override suspend fun budgetsForUser(userId: String): List<BudgetResponse> {
val budgetIds = permissionRepository.findAll(userId = userId).map { it.budgetId }
if (budgetIds.isEmpty()) {
return emptyList()
}
return budgetRepository.findAll(ids = budgetIds).map {
BudgetResponse(it, permissionRepository.findAll(budgetIds = listOf(it.id)))
}
}
override suspend fun budget(budgetId: String, userId: String): BudgetResponse =
budgetPermissionRepository.budgetWithPermission(userId, budgetId, Permission.READ)
override suspend fun save(request: BudgetRequest, userId: String, budgetId: String?): BudgetResponse {
val budget = if (budgetId?.isNotBlank() == true) {
budgetPermissionRepository.budgetWithPermission(
budgetId = budgetId,
userId = userId,
permission = Permission.MANAGE
).run {
Budget(
id = budgetId,
name = request.name ?: name,
description = request.description ?: description
)
}
} else {
if (request.name.isNullOrBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "Name cannot be empty or null")
}
Budget(
name = request.name,
description = request.description
)
}
val users = budgetId?.let {
// If user is owner, apply changes
// If user is manager, make sure they're not changing ownership
val oldUsers = permissionRepository.findAll(budgetIds = listOf(budgetId))
val oldPermissions = oldUsers.associate { it.userId to it.permission }
val currentUserPermission = oldPermissions[userId] ?: throw HttpException(HttpStatusCode.NotFound)
val newUsers = request.users?.map { userPermission ->
if (userPermission.permission == Permission.OWNER
&& oldPermissions[userPermission.user] !== Permission.OWNER
&& currentUserPermission != Permission.OWNER
) {
// The user is attempting to add a new owner
throw HttpException(
HttpStatusCode.Forbidden,
message = "You must be an owner to be able to modify other users' ownership"
)
}
userPermission
} ?: return@let oldUsers.map { UserPermissionRequest(it.userId, it.permission) }
oldPermissions.filterValues { it == Permission.OWNER }
.forEach { (user, permission) ->
if (newUsers.none { it.user == user && it.permission == permission }
&& currentUserPermission != Permission.OWNER
) {
// The user is attempting to remove a previous owner
throw HttpException(
HttpStatusCode.Forbidden,
message = "You must be an owner to be able to modify other users' ownership"
)
}
}
oldUsers.forEach { oldUserPermission ->
if (newUsers.none { it.user == oldUserPermission.userId && it.permission == oldUserPermission.permission }) {
permissionRepository.delete(oldUserPermission)
}
}
newUsers
} ?: run {
val newUsers = request.users
?.toMutableList()
?: mutableListOf()
val currentUserPermission = newUsers.firstOrNull { it.user == userId }
if (currentUserPermission == null || currentUserPermission.permission != Permission.OWNER) {
newUsers.removeIf { it.user == userId }
newUsers.add(UserPermissionRequest(userId, Permission.OWNER))
}
newUsers
}
val savedBudget = budgetRepository.save(budget)
return BudgetResponse(
savedBudget,
users.map {
permissionRepository.save(
UserPermission(
budgetId = savedBudget.id,
userId = it.user,
permission = it.permission
)
)
}
)
}
override suspend fun delete(budgetId: String, userId: String) {
val budgetResponse = budgetPermissionRepository.budgetWithPermission(userId, budgetId, Permission.OWNER)
budgetRepository.delete(Budget(budgetResponse.id, budgetResponse.name, budgetResponse.description))
}
}

View file

@ -1,104 +0,0 @@
package com.wbrawner.twigs.service.category
import com.wbrawner.twigs.model.Category
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.service.HttpException
import com.wbrawner.twigs.service.requirePermission
import com.wbrawner.twigs.storage.CategoryRepository
import com.wbrawner.twigs.storage.PermissionRepository
import io.ktor.http.*
interface CategoryService {
suspend fun categories(
budgetIds: List<String>,
userId: String,
expense: Boolean? = null,
archived: Boolean? = null,
): List<CategoryResponse>
suspend fun category(categoryId: String, userId: String): CategoryResponse
suspend fun save(request: CategoryRequest, userId: String, categoryId: String? = null): CategoryResponse
suspend fun delete(categoryId: String, userId: String)
}
class DefaultCategoryService(
private val categoryRepository: CategoryRepository,
private val permissionRepository: PermissionRepository
) : CategoryService {
override suspend fun categories(
budgetIds: List<String>,
userId: String,
expense: Boolean?,
archived: Boolean?,
): List<CategoryResponse> {
val validBudgetIds = permissionRepository.findAll(
budgetIds = budgetIds,
userId = userId
).map { it.budgetId }
if (validBudgetIds.isEmpty()) {
return emptyList()
}
return categoryRepository.findAll(
budgetIds = budgetIds,
expense = expense,
archived = archived
).map { it.asResponse() }
}
override suspend fun category(categoryId: String, userId: String): CategoryResponse {
val budgetIds = permissionRepository.findAll(userId = userId).map { it.budgetId }
if (budgetIds.isEmpty()) {
throw HttpException(HttpStatusCode.NotFound)
}
return categoryRepository.findAll(
ids = listOf(categoryId),
budgetIds = budgetIds
)
.map { it.asResponse() }
.firstOrNull()
?: throw HttpException(HttpStatusCode.NotFound)
}
override suspend fun save(request: CategoryRequest, userId: String, categoryId: String?): CategoryResponse {
val category = categoryId?.let {
categoryRepository.findAll(ids = listOf(categoryId)).firstOrNull()
?: throw HttpException(HttpStatusCode.NotFound)
} ?: run {
if (request.title.isNullOrBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "title cannot be null or empty")
}
if (request.budgetId.isNullOrBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "budgetId cannot be null or empty")
}
Category(
title = request.title,
description = request.description,
amount = request.amount ?: 0L,
expense = request.expense ?: true,
budgetId = request.budgetId
)
}
permissionRepository.requirePermission(userId, category.budgetId, Permission.WRITE)
return categoryRepository.save(
category.copy(
title = request.title?.ifBlank { category.title } ?: category.title,
description = request.description ?: category.description,
amount = request.amount ?: category.amount,
expense = request.expense ?: category.expense,
archived = request.archived ?: category.archived,
budgetId = request.budgetId?.ifBlank { category.budgetId } ?: category.budgetId
)
).asResponse()
}
override suspend fun delete(categoryId: String, userId: String) {
val category = categoryRepository.findAll(ids = listOf(categoryId))
.firstOrNull()
?: throw HttpException(HttpStatusCode.NotFound)
permissionRepository.requirePermission(userId, category.budgetId, Permission.WRITE)
categoryRepository.delete(category)
}
}

View file

@ -1,110 +0,0 @@
package com.wbrawner.twigs.service.recurringtransaction
import com.wbrawner.twigs.asFrequency
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.RecurringTransaction
import com.wbrawner.twigs.service.HttpException
import com.wbrawner.twigs.service.requirePermission
import com.wbrawner.twigs.storage.PermissionRepository
import com.wbrawner.twigs.storage.RecurringTransactionRepository
import com.wbrawner.twigs.toInstant
import io.ktor.http.*
import java.time.Instant
interface RecurringTransactionService {
suspend fun recurringTransactions(
budgetId: String,
userId: String,
): List<RecurringTransactionResponse>
suspend fun recurringTransaction(recurringTransactionId: String, userId: String): RecurringTransactionResponse
suspend fun save(
request: RecurringTransactionRequest,
userId: String,
recurringTransactionId: String? = null
): RecurringTransactionResponse
suspend fun delete(recurringTransactionId: String, userId: String)
}
class DefaultRecurringTransactionService(
private val recurringTransactionRepository: RecurringTransactionRepository,
private val permissionRepository: PermissionRepository
) : RecurringTransactionService {
override suspend fun recurringTransactions(
budgetId: String,
userId: String
): List<RecurringTransactionResponse> {
permissionRepository.requirePermission(userId, budgetId, Permission.READ)
return recurringTransactionRepository.findAll(budgetId = budgetId)
.map { it.asResponse() }
}
override suspend fun recurringTransaction(
recurringTransactionId: String,
userId: String
): RecurringTransactionResponse {
val recurringTransaction = recurringTransactionRepository.findAll(ids = listOf(recurringTransactionId))
.firstOrNull()
?: throw HttpException(HttpStatusCode.NotFound)
permissionRepository.requirePermission(userId, recurringTransaction.budgetId, Permission.READ)
return recurringTransaction.asResponse()
}
override suspend fun save(
request: RecurringTransactionRequest,
userId: String,
recurringTransactionId: String?
): RecurringTransactionResponse {
val recurringTransaction = recurringTransactionId?.let {
recurringTransactionRepository.findAll(ids = listOf(it))
.firstOrNull()
?.also { recurringTransaction ->
permissionRepository.requirePermission(userId, recurringTransaction.budgetId, Permission.WRITE)
}
?: throw HttpException(HttpStatusCode.NotFound)
} ?: run {
if (request.title.isNullOrBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "title cannot be null or empty")
}
if (request.budgetId.isNullOrBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "budgetId cannot be null or empty")
}
RecurringTransaction(
title = request.title,
description = request.description,
amount = request.amount ?: 0L,
expense = request.expense ?: true,
budgetId = request.budgetId,
categoryId = request.categoryId,
createdBy = userId,
start = request.start?.toInstant() ?: Instant.now(),
finish = request.finish?.toInstant(),
frequency = request.frequency.asFrequency()
)
}
permissionRepository.requirePermission(userId, recurringTransaction.budgetId, Permission.WRITE)
return recurringTransactionRepository.save(
recurringTransaction.copy(
title = request.title?.ifBlank { recurringTransaction.title } ?: recurringTransaction.title,
description = request.description ?: recurringTransaction.description,
amount = request.amount ?: recurringTransaction.amount,
expense = request.expense ?: recurringTransaction.expense,
budgetId = request.budgetId?.ifBlank { recurringTransaction.budgetId } ?: recurringTransaction.budgetId,
categoryId = request.categoryId ?: recurringTransaction.categoryId,
start = request.start?.toInstant() ?: recurringTransaction.start,
finish = request.finish?.toInstant() ?: recurringTransaction.finish,
frequency = request.frequency.asFrequency()
)
).asResponse()
}
override suspend fun delete(recurringTransactionId: String, userId: String) {
val recurringTransaction = recurringTransactionRepository.findAll(ids = listOf(recurringTransactionId))
.firstOrNull()
?: throw HttpException(HttpStatusCode.NotFound)
permissionRepository.requirePermission(userId, recurringTransaction.budgetId, Permission.WRITE)
recurringTransactionRepository.delete(recurringTransaction)
}
}

View file

@ -1,167 +0,0 @@
package com.wbrawner.twigs.service.transaction
import com.wbrawner.twigs.endOfMonth
import com.wbrawner.twigs.firstOfMonth
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.model.Transaction
import com.wbrawner.twigs.service.HttpException
import com.wbrawner.twigs.service.requirePermission
import com.wbrawner.twigs.storage.CategoryRepository
import com.wbrawner.twigs.storage.PermissionRepository
import com.wbrawner.twigs.storage.TransactionRepository
import com.wbrawner.twigs.toInstant
import com.wbrawner.twigs.toInstantOrNull
import io.ktor.http.*
import java.time.Instant
interface TransactionService {
suspend fun transactions(
budgetIds: List<String>,
categoryIds: List<String>? = null,
from: Instant? = null,
to: Instant? = null,
expense: Boolean? = null,
userId: String,
): List<TransactionResponse>
suspend fun transaction(transactionId: String, userId: String): TransactionResponse
suspend fun sum(
userId: String,
budgetId: String? = null,
categoryId: String? = null,
from: Instant? = null,
to: Instant? = null,
): Long
suspend fun save(
request: TransactionRequest,
userId: String,
transactionId: String? = null
): TransactionResponse
suspend fun delete(transactionId: String, userId: String)
}
class DefaultTransactionService(
private val transactionRepository: TransactionRepository,
private val categoryRepository: CategoryRepository,
private val permissionRepository: PermissionRepository
) : TransactionService {
override suspend fun transactions(
budgetIds: List<String>,
categoryIds: List<String>?,
from: Instant?,
to: Instant?,
expense: Boolean?,
userId: String
): List<TransactionResponse> {
permissionRepository.requirePermission(userId, budgetIds, Permission.READ)
return transactionRepository.findAll(
budgetIds = budgetIds,
categoryIds = categoryIds,
from = from,
to = to,
expense = expense
).map { it.asResponse() }
}
override suspend fun transaction(
transactionId: String,
userId: String
): TransactionResponse {
val transaction = transactionRepository.findAll(ids = listOf(transactionId))
.firstOrNull()
?: throw HttpException(HttpStatusCode.NotFound)
permissionRepository.requirePermission(userId, transaction.budgetId, Permission.READ)
return transaction.asResponse()
}
override suspend fun sum(
userId: String,
budgetId: String?,
categoryId: String?,
from: Instant?,
to: Instant?
): Long {
if (budgetId.isNullOrBlank() && categoryId.isNullOrBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "budgetId or categoryId must be provided to sum")
}
if (budgetId?.isNotBlank() == true && categoryId?.isNotBlank() == true) {
throw HttpException(
HttpStatusCode.BadRequest,
message = "budgetId and categoryId cannot be provided together"
)
}
return if (!categoryId.isNullOrBlank()) {
val category = categoryRepository.findAll(ids = listOf(categoryId)).firstOrNull()
?: throw HttpException(HttpStatusCode.NotFound)
permissionRepository.requirePermission(
userId = userId,
budgetId = category.budgetId,
permission = Permission.READ
)
transactionRepository.sumByCategory(category.id, from ?: firstOfMonth, to ?: endOfMonth)
} else if (!budgetId.isNullOrBlank()) {
permissionRepository.requirePermission(userId = userId, budgetId = budgetId, permission = Permission.READ)
transactionRepository.sumByBudget(budgetId, from ?: firstOfMonth, to ?: endOfMonth)
} else {
error("Somehow we didn't return either a budget or category sum")
}
}
override suspend fun save(
request: TransactionRequest,
userId: String,
transactionId: String?
): TransactionResponse {
val transaction = transactionId?.let {
transactionRepository.findAll(ids = listOf(it))
.firstOrNull()
?.also { transaction ->
permissionRepository.requirePermission(userId, transaction.budgetId, Permission.WRITE)
}
?: throw HttpException(HttpStatusCode.NotFound)
} ?: run {
if (request.title.isNullOrBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "title cannot be null or empty")
}
if (request.budgetId.isNullOrBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "budgetId cannot be null or empty")
}
if (request.date?.toInstantOrNull() == null) {
throw HttpException(HttpStatusCode.BadRequest, message = "invalid date")
}
Transaction(
title = request.title,
description = request.description,
amount = request.amount ?: 0L,
expense = request.expense ?: true,
budgetId = request.budgetId,
categoryId = request.categoryId,
date = request.date.toInstant(),
createdBy = userId,
)
}
permissionRepository.requirePermission(userId, request.budgetId ?: transaction.budgetId, Permission.WRITE)
return transactionRepository.save(
transaction.copy(
title = request.title?.ifBlank { transaction.title } ?: transaction.title,
description = request.description ?: transaction.description,
amount = request.amount ?: transaction.amount,
expense = request.expense ?: transaction.expense,
budgetId = request.budgetId?.ifBlank { transaction.budgetId } ?: transaction.budgetId,
categoryId = request.categoryId ?: transaction.categoryId,
date = request.date?.toInstantOrNull() ?: transaction.date
)
).asResponse()
}
override suspend fun delete(transactionId: String, userId: String) {
val transaction = transactionRepository.findAll(ids = listOf(transactionId))
.firstOrNull()
?: throw HttpException(HttpStatusCode.NotFound)
permissionRepository.requirePermission(userId, transaction.budgetId, Permission.WRITE)
transactionRepository.delete(transaction)
}
}

View file

@ -1,178 +0,0 @@
package com.wbrawner.twigs.service.user
import com.wbrawner.twigs.EmailService
import com.wbrawner.twigs.model.PasswordResetToken
import com.wbrawner.twigs.model.Session
import com.wbrawner.twigs.model.User
import com.wbrawner.twigs.service.HttpException
import com.wbrawner.twigs.storage.*
import io.ktor.http.*
import java.time.Instant
interface UserService {
suspend fun login(request: LoginRequest): SessionResponse
suspend fun register(request: UserRequest): UserResponse
suspend fun requestPasswordResetEmail(request: ResetPasswordRequest)
suspend fun resetPassword(request: PasswordResetRequest)
suspend fun users(query: String?, budgetIds: List<String>?, requestingUserId: String): List<UserResponse>
suspend fun user(userId: String): UserResponse
suspend fun session(token: String): SessionResponse
suspend fun save(request: UserRequest, targetUserId: String, requestingUserId: String): UserResponse
suspend fun delete(targetUserId: String, requestingUserId: String)
}
class DefaultUserService(
private val emailService: EmailService,
private val passwordResetRepository: PasswordResetRepository,
private val permissionRepository: PermissionRepository,
private val sessionRepository: SessionRepository,
private val userRepository: UserRepository,
private val passwordHasher: PasswordHasher
) : UserService {
override suspend fun login(request: LoginRequest): SessionResponse {
val user = userRepository.findAll(
nameOrEmail = request.username,
password = passwordHasher.hash(request.password)
)
.firstOrNull()
?: throw HttpException(HttpStatusCode.Unauthorized, message = "Invalid credentials")
return sessionRepository.save(Session(userId = user.id)).asResponse()
}
override suspend fun register(request: UserRequest): UserResponse {
if (request.username.isNullOrBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "username must not be null or blank")
}
if (request.password.isNullOrBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "password must not be null or blank")
}
val existingUser = userRepository.findAll(nameOrEmail = request.username).firstOrNull()
?: request.email?.let {
if (it.isBlank()) {
null
} else {
userRepository.findAll(nameOrEmail = it).firstOrNull()
}
}
existingUser?.let {
throw HttpException(HttpStatusCode.BadRequest, message = "username or email already taken")
}
return userRepository.save(
User(
name = request.username,
password = passwordHasher.hash(request.password),
email = if (request.email.isNullOrBlank()) null else request.email
)
).asResponse()
}
override suspend fun requestPasswordResetEmail(request: ResetPasswordRequest) {
userRepository.findAll(nameOrEmail = request.username)
.firstOrNull()
?.let {
val email = it.email ?: return@let
val passwordResetToken = passwordResetRepository.save(PasswordResetToken(userId = it.id))
emailService.sendPasswordResetEmail(passwordResetToken, email)
}
}
override suspend fun resetPassword(request: PasswordResetRequest) {
val passwordResetToken = passwordResetRepository.findAll(listOf(request.token))
.firstOrNull()
?: throw HttpException(HttpStatusCode.Unauthorized, message = "Invalid token")
if (passwordResetToken.expiration.isBefore(Instant.now())) {
throw HttpException(HttpStatusCode.Unauthorized, message = "Token expired")
}
if (request.password.isBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "password cannot be empty")
}
userRepository.findAll(listOf(passwordResetToken.userId))
.firstOrNull()
?.let {
userRepository.save(it.copy(password = passwordHasher.hash(request.password)))
passwordResetRepository.delete(passwordResetToken)
}
?: throw HttpException(HttpStatusCode.InternalServerError, message = "Invalid token")
}
override suspend fun users(
query: String?,
budgetIds: List<String>?,
requestingUserId: String
): List<UserResponse> {
if (query != null) {
if (query.isBlank()) {
throw HttpException(HttpStatusCode.BadRequest, message = "query cannot be empty")
}
return userRepository.findAll(nameLike = query).map { it.asResponse() }
} else if (budgetIds == null || budgetIds.all { it.isBlank() }) {
throw HttpException(HttpStatusCode.BadRequest, message = "query or budgetId required but absent")
}
return permissionRepository.findAll(budgetIds = budgetIds, userId = requestingUserId)
.mapNotNull {
userRepository.findAll(ids = listOf(it.userId))
.firstOrNull()
?.asResponse()
}
}
override suspend fun user(
userId: String
): UserResponse {
return userRepository.findAll(ids = listOf(userId))
.firstOrNull()
?.asResponse()
?: throw HttpException(HttpStatusCode.NotFound)
}
override suspend fun session(token: String): SessionResponse {
return sessionRepository.findAll(token = token)
.firstOrNull()
?.asResponse()
?: throw HttpException(HttpStatusCode.Unauthorized)
}
override suspend fun save(
request: UserRequest,
targetUserId: String,
requestingUserId: String,
): UserResponse {
// TODO: Add some kind of admin denotation to allow admins to edit other users
if (targetUserId != requestingUserId) {
throw HttpException(HttpStatusCode.Forbidden)
}
return userRepository.save(
userRepository.findAll(ids = listOf(targetUserId))
.first()
.run {
val newPassword = if (request.password.isNullOrBlank()) {
password
} else {
passwordHasher.hash(request.password)
}
copy(
name = request.username ?: name,
password = newPassword,
email = request.email ?: email
)
}
).asResponse()
}
override suspend fun delete(targetUserId: String, requestingUserId: String) {
// TODO: Add some kind of admin denotation to allow admins to delete other users
if (targetUserId != requestingUserId) {
throw HttpException(HttpStatusCode.Forbidden)
}
userRepository.delete(userRepository.findAll(targetUserId).first())
}
}

View file

@ -1,4 +1,3 @@
rootProject.name = "twigs"
include("core", "api", "app", "storage", "db", "web")
include("testhelpers")
include("service")
include("testhelpers")

View file

@ -3,7 +3,7 @@ package com.wbrawner.twigs.storage
import com.wbrawner.twigs.model.UserPermission
interface PermissionRepository : Repository<UserPermission> {
suspend fun findAll(
fun findAll(
budgetIds: List<String>? = null,
userId: String? = null
): List<UserPermission>

View file

@ -5,7 +5,7 @@ import com.wbrawner.twigs.storage.PermissionRepository
class FakePermissionRepository : PermissionRepository {
val permissions: MutableList<UserPermission> = mutableListOf()
override suspend fun findAll(budgetIds: List<String>?, userId: String?): List<UserPermission> =
override fun findAll(budgetIds: List<String>?, userId: String?): List<UserPermission> =
permissions.filter { userPermission ->
budgetIds?.contains(userPermission.budgetId) ?: true
&& userId?.let { it == userPermission.userId } ?: true

View file

@ -1,3 +1,5 @@
import java.util.*
plugins {
`java-library`
alias(libs.plugins.kotlin.jvm)
@ -5,14 +7,45 @@ plugins {
dependencies {
implementation(kotlin("stdlib"))
implementation(project(":core"))
implementation(project(":service"))
api(libs.ktor.server.core)
api(libs.ktor.server.mustache)
testImplementation(libs.junit.jupiter.api)
testRuntimeOnly(libs.junit.jupiter.engine)
}
tasks.getByName<Test>("test") {
useJUnitPlatform()
}
}
// TODO: Replace this hack with either a git submodule or an internal Kotlin-based UI
tasks.register("package") {
doLast {
val built = File(rootProject.rootDir.parent, "twigs-web/dist/twigs")
if (built.exists()) {
built.deleteRecursively()
}
val dest = File(project.projectDir, "src/main/resources/twigs")
if (dest.exists()) {
dest.deleteRecursively()
}
var command = listOf(
"cd", "../../twigs-web", ";",
"npm", "i", ";",
"npm", "run", "package"
)
command = if (System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("windows")) {
listOf("powershell", "-Command") + command
} else {
listOf("bash", "-c", "\"${command.joinToString(" ")}\"")
}
exec {
commandLine(command)
}
if (!built.copyRecursively(dest, true) || !dest.isDirectory) {
throw GradleException("Failed to copy files from ${built.absolutePath} to ${dest.absolutePath}")
}
}
}
//tasks.getByName("processResources") {
// dependsOn.add("package")
//}

View file

@ -1,28 +0,0 @@
package com.wbrawner.twigs.web
import com.wbrawner.twigs.service.budget.BudgetResponse
import com.wbrawner.twigs.service.user.UserResponse
interface Page {
val title: String
val error: String?
}
interface AuthenticatedPage : Page {
val user: UserResponse
val budgets: List<BudgetListItem>
}
data class BudgetListItem(val id: String, val name: String, val description: String, val selected: Boolean)
fun BudgetResponse.toBudgetListItem(selectedId: String? = null) = BudgetListItem(
id = id,
name = name.orEmpty(),
description = description.orEmpty(),
selected = id == selectedId
)
object NotFoundPage : Page {
override val title: String = "404 Not Found"
override val error: String? = null
}

View file

@ -1,52 +1,21 @@
package com.wbrawner.twigs.web
import com.wbrawner.twigs.model.CookieSession
import com.wbrawner.twigs.service.HttpException
import com.wbrawner.twigs.service.budget.BudgetService
import com.wbrawner.twigs.service.category.CategoryService
import com.wbrawner.twigs.service.transaction.TransactionService
import com.wbrawner.twigs.service.user.UserService
import com.wbrawner.twigs.web.budget.budgetWebRoutes
import com.wbrawner.twigs.web.category.categoryWebRoutes
import com.wbrawner.twigs.web.transaction.transactionWebRoutes
import com.wbrawner.twigs.web.user.userWebRoutes
import io.ktor.server.application.*
import io.ktor.server.http.content.*
import io.ktor.server.mustache.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.sessions.*
fun Application.webRoutes(
budgetService: BudgetService,
categoryService: CategoryService,
transactionService: TransactionService,
userService: UserService
) {
fun Application.webRoutes() {
routing {
staticResources("/", "static")
get("/") {
call.sessions.get(CookieSession::class)
?.let {
try {
userService.session(it.token)
} catch (e: HttpException) {
application.environment.log.debug("Failed to retrieve session for user", e)
null
}
staticResources("/", "web")
intercept(ApplicationCallPipeline.Setup) {
if (!call.request.path().startsWith("/api") && !call.request.path().matches(Regex(".*\\.\\w+$"))) {
call.resolveResource("web/index.html")?.let {
call.respond(it)
return@intercept finish()
}
?.let { session ->
application.environment.log.info("Session found!")
budgetService.budgetsForUser(session.userId)
.firstOrNull()
?.let { budget ->
call.respondRedirect("/budgets/${budget.id}")
} ?: call.respondRedirect("/budgets")
} ?: call.respond(MustacheContent("index.mustache", null))
}
}
}
budgetWebRoutes(budgetService, categoryService, transactionService, userService)
categoryWebRoutes(budgetService, categoryService, transactionService, userService)
transactionWebRoutes(budgetService, categoryService, transactionService, userService)
userWebRoutes(userService)
}

View file

@ -1,33 +0,0 @@
package com.wbrawner.twigs.web
import io.ktor.http.*
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DateFormat
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.*
val currencyFormat = NumberFormat.getCurrencyInstance(Locale.US)
val decimalFormat = DecimalFormat.getNumberInstance(Locale.US).apply {
with(this as DecimalFormat) {
decimalFormatSymbols = decimalFormatSymbols.apply {
currencySymbol = ""
isGroupingUsed = false
}
}
}
val shortDateFormat = DateFormat.getDateInstance(DateFormat.SHORT, Locale.US)
fun Parameters.getAmount() = decimalFormat.parse(get("amount"))
?.toDouble()
?.toBigDecimal()
?.times(BigDecimal(100))
?.toLong()
?: 0L
fun Long?.toDecimalString(): String {
if (this == null) return ""
return decimalFormat.format(toBigDecimal().divide(BigDecimal(100), 2, RoundingMode.HALF_UP))
}

View file

@ -1,44 +0,0 @@
package com.wbrawner.twigs.web.budget
import com.wbrawner.twigs.service.budget.BudgetResponse
import com.wbrawner.twigs.service.user.UserResponse
import com.wbrawner.twigs.web.AuthenticatedPage
import com.wbrawner.twigs.web.BudgetListItem
import com.wbrawner.twigs.web.category.CategoryWithBalanceResponse
data class BudgetListPage(
override val budgets: List<BudgetListItem>,
override val user: UserResponse,
override val error: String? = null
) : AuthenticatedPage {
override val title: String = "Budgets"
}
data class BudgetDetailsPage(
val budget: BudgetResponse,
val balances: BudgetBalances,
val incomeCategories: List<CategoryWithBalanceResponse>,
val expenseCategories: List<CategoryWithBalanceResponse>,
val archivedIncomeCategories: List<CategoryWithBalanceResponse>,
val archivedExpenseCategories: List<CategoryWithBalanceResponse>,
val transactionCount: String,
val monthAndYear: String,
override val budgets: List<BudgetListItem>,
override val user: UserResponse,
override val error: String? = null
) : AuthenticatedPage {
override val title: String = budget.name.orEmpty()
}
data class BudgetFormPage(
val budget: BudgetResponse,
override val budgets: List<BudgetListItem>,
override val user: UserResponse,
override val error: String? = null
) : AuthenticatedPage {
override val title: String = if (budget.id.isBlank()) {
"New Budget"
} else {
"Edit Budget"
}
}

View file

@ -1,242 +0,0 @@
package com.wbrawner.twigs.web.budget
import com.wbrawner.twigs.endOfMonth
import com.wbrawner.twigs.firstOfMonth
import com.wbrawner.twigs.service.HttpException
import com.wbrawner.twigs.service.budget.BudgetRequest
import com.wbrawner.twigs.service.budget.BudgetResponse
import com.wbrawner.twigs.service.budget.BudgetService
import com.wbrawner.twigs.service.category.CategoryService
import com.wbrawner.twigs.service.requireSession
import com.wbrawner.twigs.service.transaction.TransactionService
import com.wbrawner.twigs.service.user.UserService
import com.wbrawner.twigs.toInstantOrNull
import com.wbrawner.twigs.web.NotFoundPage
import com.wbrawner.twigs.web.category.CategoryWithBalanceResponse
import com.wbrawner.twigs.web.toBudgetListItem
import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.mustache.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.util.*
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.NumberFormat
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.util.*
import kotlin.math.abs
fun Application.budgetWebRoutes(
budgetService: BudgetService,
categoryService: CategoryService,
transactionService: TransactionService,
userService: UserService
) {
routing {
authenticate(TWIGS_SESSION_COOKIE) {
route("/budgets") {
get {
val user = userService.user(requireSession().userId)
val budgets = budgetService.budgetsForUser(user.id).map { it.toBudgetListItem() }
call.respond(MustacheContent("budgets.mustache", BudgetListPage(budgets, user)))
}
route("/new") {
get {
val user = userService.user(requireSession().userId)
call.respond(
MustacheContent(
"budget-form.mustache",
BudgetFormPage(
budget = BudgetResponse(
id = "",
name = "",
description = "",
users = listOf()
),
budgets = budgetService.budgetsForUser(user.id).map { it.toBudgetListItem() },
user = user
)
)
)
}
post {
val user = userService.user(requireSession().userId)
try {
val request = call.receiveParameters().toBudgetRequest()
val budget = budgetService.save(request, user.id)
call.respondRedirect("/budgets/${budget.id}")
} catch (e: HttpException) {
call.respond(
status = e.statusCode,
MustacheContent(
"budget-form.mustache",
BudgetFormPage(
budget = BudgetResponse(
id = "",
name = call.parameters["name"].orEmpty(),
description = call.parameters["description"].orEmpty(),
users = listOf()
),
budgets = budgetService.budgetsForUser(user.id).map { it.toBudgetListItem() },
user = user,
error = e.message
)
)
)
}
}
}
route("/{id}") {
get {
val user = userService.user(requireSession().userId)
val budgetId = call.parameters.getOrFail("id")
val budgets = budgetService.budgetsForUser(userId = user.id).toMutableList()
val budget = budgets.firstOrNull { it.id == budgetId }
?: run {
call.respond(MustacheContent("404.mustache", NotFoundPage))
return@get
}
val numberFormat = NumberFormat.getCurrencyInstance(Locale.US)
val categories = categoryService.categories(budgetIds = listOf(budget.id), userId = user.id)
.map { category ->
val categoryBalance =
abs(transactionService.sum(categoryId = category.id, userId = user.id))
CategoryWithBalanceResponse(
category = category,
amountLabel = category.amount.toCurrencyString(numberFormat),
balance = categoryBalance,
balanceLabel = categoryBalance.toCurrencyString(numberFormat),
remainingAmountLabel = (category.amount - categoryBalance).toCurrencyString(
numberFormat
)
)
}
.toMutableSet()
val incomeCategories = categories.extractIf { !it.category.expense && !it.category.archived }
val archivedIncomeCategories =
categories.extractIf { !it.category.expense && it.category.archived }
val expenseCategories = categories.extractIf { it.category.expense && !it.category.archived }
val archivedExpenseCategories =
categories.extractIf { it.category.expense && it.category.archived }
val transactions = transactionService.transactions(
budgetIds = listOf(budget.id),
from = call.parameters["from"]?.toInstantOrNull() ?: firstOfMonth,
to = call.parameters["to"]?.toInstantOrNull() ?: endOfMonth,
userId = user.id
)
// TODO: Allow user-configurable locale
val budgetBalance = transactionService.sum(budgetId = budget.id, userId = user.id)
.toCurrencyString(numberFormat)
val expectedIncome = incomeCategories.sumOf { it.category.amount }
val actualIncome = transactions.sumOf { if (it.expense == false) it.amount ?: 0L else 0L }
val expectedExpenses = expenseCategories.sumOf { it.category.amount }
val actualExpenses = transactions.sumOf { if (it.expense == true) it.amount ?: 0L else 0L }
val balances = BudgetBalances(
cashFlow = budgetBalance,
expectedIncome = expectedIncome,
expectedIncomeLabel = expectedIncome.toCurrencyString(numberFormat),
actualIncome = actualIncome,
actualIncomeLabel = actualIncome.toCurrencyString(numberFormat),
expectedExpenses = expectedExpenses,
expectedExpensesLabel = expectedExpenses.toCurrencyString(numberFormat),
actualExpenses = actualExpenses,
actualExpensesLabel = actualExpenses.toCurrencyString(numberFormat),
)
call.respond(
MustacheContent(
"budget-details.mustache", BudgetDetailsPage(
budget = budget,
balances = balances,
incomeCategories = incomeCategories,
expenseCategories = expenseCategories,
archivedIncomeCategories = archivedIncomeCategories,
archivedExpenseCategories = archivedExpenseCategories,
transactionCount = NumberFormat.getNumberInstance(Locale.US)
.format(transactions.size),
monthAndYear = YearMonth.now().format(DateTimeFormatter.ofPattern("MMMM yyyy")),
budgets = budgets.map { it.toBudgetListItem(budgetId) }.sortedBy { it.name },
user = user
)
)
)
}
route("/edit") {
get {
val user = userService.user(requireSession().userId)
val budget = budgetService.budget(
budgetId = call.parameters.getOrFail("id"),
userId = user.id
)
call.respond(
MustacheContent(
"budget-form.mustache",
BudgetFormPage(
budget = budget,
budgets = budgetService.budgetsForUser(user.id)
.map { it.toBudgetListItem(budget.id) },
user = user
)
)
)
}
}
route("/delete") {
post {
val user = userService.user(requireSession().userId)
val budgetId = call.parameters.getOrFail("id")
budgetService.delete(budgetId = budgetId, userId = user.id)
call.respondRedirect("/")
}
}
}
}
}
}
}
data class BudgetBalances(
val cashFlow: String,
val expectedIncome: Long,
val expectedIncomeLabel: String,
val actualIncome: Long,
val actualIncomeLabel: String,
val expectedExpenses: Long,
val expectedExpensesLabel: String,
val actualExpenses: Long,
val actualExpensesLabel: String,
) {
val maxProgressBarValue: Long = maxOf(expectedExpenses, expectedIncome, actualIncome, actualExpenses)
}
private fun Parameters.toBudgetRequest() = BudgetRequest(
name = get("name"),
description = get("description"),
users = setOf() // TODO: Enable adding users at budget creation
)
private fun <T> MutableCollection<T>.extractIf(predicate: (T) -> Boolean): List<T> {
val extracted = mutableListOf<T>()
val iterator = iterator()
while (iterator.hasNext()) {
val item = iterator.next()
if (predicate(item)) {
extracted.add(item)
iterator.remove()
}
}
return extracted
}
fun Long.toCurrencyString(formatter: NumberFormat): String = formatter.format(
this.toBigDecimal().divide(BigDecimal(100), 2, RoundingMode.HALF_UP)
)

View file

@ -1,67 +0,0 @@
package com.wbrawner.twigs.web.category
import com.wbrawner.twigs.service.budget.BudgetResponse
import com.wbrawner.twigs.service.category.CategoryResponse
import com.wbrawner.twigs.service.transaction.TransactionResponse
import com.wbrawner.twigs.service.user.UserResponse
import com.wbrawner.twigs.web.AuthenticatedPage
import com.wbrawner.twigs.web.BudgetListItem
import com.wbrawner.twigs.web.budget.toCurrencyString
import java.text.NumberFormat
data class CategoryDetailsPage(
val category: CategoryWithBalanceResponse,
val budget: BudgetResponse,
val transactionCount: String,
val transactions: List<Map.Entry<String, List<TransactionListItem>>>,
override val budgets: List<BudgetListItem>,
override val user: UserResponse,
override val error: String? = null
) : AuthenticatedPage {
override val title: String = category.category.title
}
data class TransactionListItem(
val id: String,
val title: String,
val description: String,
val budgetId: String,
val expenseClass: String,
val amountLabel: String
)
fun TransactionResponse.toListItem(numberFormat: NumberFormat) = TransactionListItem(
id,
title.orEmpty(),
description.orEmpty(),
budgetId,
if (expense != false) "expense" else "income",
(amount ?: 0L).toCurrencyString(numberFormat)
)
data class CategoryFormPage(
val category: CategoryResponse,
val amountLabel: String,
val budget: BudgetResponse,
override val budgets: List<BudgetListItem>,
override val user: UserResponse,
override val error: String? = null
) : AuthenticatedPage {
override val title: String = if (category.id.isBlank()) {
"New Category"
} else {
"Edit Category"
}
val expenseChecked: String = if (category.expense) "checked" else ""
val incomeChecked: String = if (!category.expense) "checked" else ""
val archivedChecked: String = if (category.archived) "checked" else ""
}
data class CategoryWithBalanceResponse(
val category: CategoryResponse,
val amountLabel: String,
val balance: Long,
val balanceLabel: String,
val remainingAmountLabel: String,
)

View file

@ -1,245 +0,0 @@
package com.wbrawner.twigs.web.category
import com.wbrawner.twigs.endOfMonth
import com.wbrawner.twigs.firstOfMonth
import com.wbrawner.twigs.service.HttpException
import com.wbrawner.twigs.service.budget.BudgetService
import com.wbrawner.twigs.service.category.CategoryRequest
import com.wbrawner.twigs.service.category.CategoryResponse
import com.wbrawner.twigs.service.category.CategoryService
import com.wbrawner.twigs.service.requireSession
import com.wbrawner.twigs.service.transaction.TransactionService
import com.wbrawner.twigs.service.user.UserService
import com.wbrawner.twigs.toInstant
import com.wbrawner.twigs.toInstantOrNull
import com.wbrawner.twigs.web.*
import com.wbrawner.twigs.web.budget.toCurrencyString
import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.mustache.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.util.*
import io.ktor.util.date.*
import java.text.NumberFormat
import java.util.*
import kotlin.math.abs
fun Application.categoryWebRoutes(
budgetService: BudgetService,
categoryService: CategoryService,
transactionService: TransactionService,
userService: UserService
) {
routing {
authenticate(TWIGS_SESSION_COOKIE) {
route("/budgets/{budgetId}/categories") {
get {
val budgetId = call.parameters.getOrFail("budgetId")
call.respondRedirect("/budgets/$budgetId")
}
route("/new") {
get {
val user = userService.user(requireSession().userId)
val budgetId = call.parameters.getOrFail("budgetId")
val budgets = budgetService.budgetsForUser(user.id)
val budget = budgets.first { it.id == budgetId }
call.respond(
MustacheContent(
"category-form.mustache",
CategoryFormPage(
CategoryResponse(
id = "",
title = "",
description = "",
amount = 0,
budgetId = budgetId,
expense = call.request.queryParameters["expense"]?.toBoolean() ?: true,
archived = false,
),
amountLabel = 0L.toDecimalString(),
budget = budget,
budgets = budgets.map { it.toBudgetListItem(budgetId) },
user = user
)
)
)
}
post {
val user = userService.user(requireSession().userId)
val budgetId = call.parameters.getOrFail("budgetId")
val budgets = budgetService.budgetsForUser(user.id)
val budget = budgets.first { it.id == budgetId }
try {
val request = call.receiveParameters().toCategoryRequest(budgetId)
val category = categoryService.save(request, user.id)
call.respondRedirect("/budgets/${category.budgetId}/categories/${category.id}")
} catch (e: HttpException) {
call.respond(
status = e.statusCode,
MustacheContent(
"category-form.mustache",
CategoryFormPage(
CategoryResponse(
id = "",
title = call.parameters["title"].orEmpty(),
description = call.parameters["description"].orEmpty(),
amount = 0L,
expense = call.parameters["expense"]?.toBoolean() ?: false,
archived = call.parameters["archived"]?.toBoolean() ?: false,
budgetId = budgetId
),
amountLabel = call.parameters["amount"]?.toLongOrNull().toDecimalString(),
budget = budget,
budgets = budgets.map { it.toBudgetListItem(budgetId) },
user = user,
error = e.message
)
)
)
}
}
}
route("/{id}") {
get {
val user = userService.user(requireSession().userId)
val categoryId = call.parameters.getOrFail("id")
try {
val category = categoryService.category(categoryId = categoryId, userId = user.id)
val categoryBalance =
abs(transactionService.sum(categoryId = category.id, userId = user.id))
val categoryWithBalance = CategoryWithBalanceResponse(
category = category,
amountLabel = category.amount.toCurrencyString(currencyFormat),
balance = categoryBalance,
balanceLabel = categoryBalance.toCurrencyString(currencyFormat),
remainingAmountLabel = (category.amount - categoryBalance).toCurrencyString(
currencyFormat
)
)
val transactions = transactionService.transactions(
budgetIds = listOf(category.budgetId),
categoryIds = listOf(category.id),
from = call.parameters["from"]?.toInstantOrNull() ?: firstOfMonth,
to = call.parameters["to"]?.toInstantOrNull() ?: endOfMonth,
userId = user.id
)
val transactionCount = NumberFormat.getNumberInstance(Locale.US)
.format(transactions.size)
val transactionsByDate = transactions.groupBy {
shortDateFormat.format(it.date.toInstant().toGMTDate().toJvmDate())
}
.mapValues { (_, transactions) -> transactions.map { it.toListItem(currencyFormat) } }
.entries
.sortedByDescending { it.key }
val budgets = budgetService.budgetsForUser(user.id)
val budgetId = call.parameters.getOrFail("budgetId")
val budget = budgets.first { it.id == budgetId }
call.respond(
MustacheContent(
"category-details.mustache", CategoryDetailsPage(
category = categoryWithBalance,
transactions = transactionsByDate,
transactionCount = transactionCount,
budgets = budgets.map { it.toBudgetListItem(budgetId) },
budget = budget,
user = user
)
)
)
} catch (e: HttpException) {
call.respond(
status = e.statusCode,
MustacheContent("404.mustache", NotFoundPage)
)
}
}
route("/edit") {
get {
val user = userService.user(requireSession().userId)
val category = categoryService.category(
categoryId = call.parameters.getOrFail("id"),
userId = user.id
)
val budgetId = call.parameters.getOrFail("budgetId")
val budgets = budgetService.budgetsForUser(user.id)
call.respond(
MustacheContent(
"category-form.mustache",
CategoryFormPage(
category = category,
amountLabel = category.amount.toDecimalString(),
budget = budgets.first { it.id == budgetId },
budgets = budgets.map { it.toBudgetListItem(budgetId) },
user = user
)
)
)
}
post {
val user = userService.user(requireSession().userId)
val budgetId = call.parameters.getOrFail("budgetId")
val budgets = budgetService.budgetsForUser(user.id)
val categoryId = call.parameters.getOrFail("id")
try {
val request = call.receiveParameters().toCategoryRequest(budgetId)
val category = categoryService.save(request, userId = user.id, categoryId = categoryId)
call.respondRedirect("/budgets/${category.budgetId}/categories/${category.id}")
} catch (e: HttpException) {
call.respond(
status = e.statusCode,
MustacheContent(
"category-form.mustache",
CategoryFormPage(
CategoryResponse(
id = "",
title = call.parameters["title"].orEmpty(),
description = call.parameters["description"].orEmpty(),
amount = 0L,
expense = call.parameters["expense"]?.toBoolean() ?: false,
archived = call.parameters["archived"]?.toBoolean() ?: false,
budgetId = budgetId
),
amountLabel = call.parameters["amount"]?.toLongOrNull().toDecimalString(),
budget = budgets.first { it.id == budgetId },
budgets = budgets.map { it.toBudgetListItem(budgetId) },
user = user,
error = e.message
)
)
)
}
}
}
route("/delete") {
post {
val user = userService.user(requireSession().userId)
val categoryId = call.parameters.getOrFail("id")
categoryService.delete(categoryId = categoryId, userId = user.id)
val budgetId = call.parameters.getOrFail("budgetId")
call.respondRedirect("/budgets/$budgetId")
}
}
}
}
}
}
}
private fun Parameters.toCategoryRequest(budgetId: String) = CategoryRequest(
title = get("title"),
description = get("description"),
amount = getAmount(),
expense = get("expense")?.toBoolean(),
archived = get("archived") == "on",
budgetId = budgetId
)

View file

@ -1,66 +0,0 @@
package com.wbrawner.twigs.web.transaction
import com.wbrawner.twigs.service.budget.BudgetResponse
import com.wbrawner.twigs.service.category.CategoryResponse
import com.wbrawner.twigs.service.transaction.TransactionResponse
import com.wbrawner.twigs.service.user.UserResponse
import com.wbrawner.twigs.web.AuthenticatedPage
import com.wbrawner.twigs.web.BudgetListItem
import com.wbrawner.twigs.web.category.TransactionListItem
data class TransactionListPage(
val budget: BudgetResponse,
val transactions: List<Map.Entry<String, List<TransactionListItem>>>,
override val budgets: List<BudgetListItem>,
override val user: UserResponse,
override val error: String? = null
) : AuthenticatedPage {
override val title: String = "Transactions"
}
data class TransactionDetailsPage(
val transaction: TransactionResponse,
val category: CategoryResponse?,
val budget: BudgetResponse,
val amountLabel: String,
val dateLabel: String,
val createdBy: UserResponse,
override val budgets: List<BudgetListItem>,
override val user: UserResponse,
override val error: String? = null
) : AuthenticatedPage {
override val title: String = transaction.title.orEmpty()
}
data class TransactionFormPage(
val transaction: TransactionResponse,
val amountLabel: String,
val budget: BudgetResponse,
val categoryOptions: List<CategoryOption>,
override val budgets: List<BudgetListItem>,
override val user: UserResponse,
override val error: String? = null
) : AuthenticatedPage {
override val title: String = if (transaction.id.isBlank()) {
"New Transaction"
} else {
"Edit Transaction"
}
data class CategoryOption(
val id: String,
val title: String,
val isSelected: Boolean = false,
val isDisabled: Boolean = false
) {
val selected: String
get() = if (isSelected) "selected" else ""
val disabled: String
get() = if (isDisabled) "disabled" else ""
}
}
fun CategoryResponse.asOption(selectedCategoryId: String) =
TransactionFormPage.CategoryOption(id, title, id == selectedCategoryId)

View file

@ -1,362 +0,0 @@
package com.wbrawner.twigs.web.transaction
import com.wbrawner.twigs.endOfMonth
import com.wbrawner.twigs.firstOfMonth
import com.wbrawner.twigs.service.HttpException
import com.wbrawner.twigs.service.budget.BudgetService
import com.wbrawner.twigs.service.category.CategoryService
import com.wbrawner.twigs.service.requireSession
import com.wbrawner.twigs.service.transaction.TransactionRequest
import com.wbrawner.twigs.service.transaction.TransactionResponse
import com.wbrawner.twigs.service.transaction.TransactionService
import com.wbrawner.twigs.service.user.UserResponse
import com.wbrawner.twigs.service.user.UserService
import com.wbrawner.twigs.toInstant
import com.wbrawner.twigs.toInstantOrNull
import com.wbrawner.twigs.web.*
import com.wbrawner.twigs.web.budget.toCurrencyString
import com.wbrawner.twigs.web.category.toListItem
import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.mustache.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.util.*
import io.ktor.util.date.*
import java.time.Instant
import java.time.ZoneOffset.UTC
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
fun Application.transactionWebRoutes(
budgetService: BudgetService,
categoryService: CategoryService,
transactionService: TransactionService,
userService: UserService
) {
routing {
authenticate(TWIGS_SESSION_COOKIE) {
route("/budgets/{budgetId}/transactions") {
get {
val user = userService.user(requireSession().userId)
val budgetId = call.parameters.getOrFail("budgetId")
val budgets = budgetService.budgetsForUser(user.id)
val transactions = transactionService.transactions(
budgetIds = listOf(budgetId),
from = call.parameters["from"]?.toInstantOrNull() ?: firstOfMonth,
to = call.parameters["to"]?.toInstantOrNull() ?: endOfMonth,
userId = user.id
)
val transactionsByDate = transactions.groupBy {
shortDateFormat.format(it.date.toInstant().toGMTDate().toJvmDate())
}
.mapValues { (_, transactions) -> transactions.map { it.toListItem(currencyFormat) } }
.entries
.sortedByDescending { it.key }
call.respond(
MustacheContent(
"budget-transactions.mustache",
TransactionListPage(
budgets = budgets.map { it.toBudgetListItem(budgetId) },
budget = budgets.first { it.id == budgetId },
transactions = transactionsByDate,
user = user
)
)
)
}
route("/new") {
get {
val user = userService.user(requireSession().userId)
val budgetId = call.parameters.getOrFail("budgetId")
val budgets = budgetService.budgetsForUser(user.id)
val budget = budgets.first { it.id == budgetId }
val categoryId = call.request.queryParameters["categoryId"]
val transaction = TransactionResponse(
id = "",
title = "",
description = "",
amount = 0,
budgetId = budgetId,
expense = true,
date = Instant.now().toHtmlInputString(),
categoryId = categoryId,
createdBy = user.id
)
call.respond(
MustacheContent(
"transaction-form.mustache",
TransactionFormPage(
transaction = transaction,
amountLabel = 0L.toDecimalString(),
budget = budget,
categoryOptions = categoryOptions(
transaction = transaction,
categoryService = categoryService,
budgetId = budgetId,
user = user
),
budgets = budgets.map { it.toBudgetListItem(budgetId) },
user = user
)
)
)
}
post {
val user = userService.user(requireSession().userId)
val urlBudgetId = call.parameters.getOrFail("budgetId")
val budgets = budgetService.budgetsForUser(user.id)
val budget = budgets.first { it.id == urlBudgetId }
try {
val request = call.receiveParameters().toTransactionRequest()
.run {
copy(
date = "$date:00Z",
expense = categoryService.category(
categoryId = requireNotNull(categoryId),
userId = user.id
).expense,
budgetId = urlBudgetId
)
}
val transaction = transactionService.save(request, user.id)
call.respondRedirect("/budgets/${transaction.budgetId}/transactions/${transaction.id}")
} catch (e: HttpException) {
val transaction = TransactionResponse(
id = "",
title = call.parameters["title"],
description = call.parameters["description"],
amount = 0L,
budgetId = urlBudgetId,
expense = call.parameters["expense"]?.toBoolean() ?: true,
date = call.parameters["date"].orEmpty(),
categoryId = call.parameters["categoryId"],
createdBy = user.id
)
call.respond(
status = e.statusCode,
MustacheContent(
"transaction-form.mustache",
TransactionFormPage(
transaction = transaction,
amountLabel = call.parameters["amount"].orEmpty(),
budget = budget,
categoryOptions = categoryOptions(
transaction,
categoryService,
urlBudgetId,
user
),
budgets = budgets.map { it.toBudgetListItem(urlBudgetId) },
user = user,
error = e.message
)
)
)
}
}
}
route("/{id}") {
get {
val user = userService.user(requireSession().userId)
val transactionId = call.parameters.getOrFail("id")
val budgetId = call.parameters.getOrFail("budgetId")
// TODO: Allow user-configurable locale
try {
val transaction = transactionService.transaction(
transactionId = transactionId,
userId = user.id
)
check(transaction.budgetId == budgetId) {
// TODO: redirect instead of error?
"Attempted to fetch transaction from wrong budget"
}
val category = transaction.categoryId?.let {
categoryService.category(categoryId = it, userId = user.id)
}
val budgets = budgetService.budgetsForUser(user.id)
val budget = budgets.first { it.id == budgetId }
val dateFormat = DateTimeFormatter.ofPattern("H:mm a 'on' MMMM d, yyyy")
val transactionInstant = transaction.date.toInstant()
val transactionOffset = transactionInstant.atOffset(UTC)
val dateLabel = transactionOffset.format(dateFormat)
call.respond(
MustacheContent(
"transaction-details.mustache", TransactionDetailsPage(
transaction = transaction,
category = category,
budget = budget,
budgets = budgets.map { it.toBudgetListItem(budgetId) },
amountLabel = transaction.amount?.toCurrencyString(currencyFormat).orEmpty(),
dateLabel = dateLabel,
createdBy = userService.user(transaction.createdBy),
user = user
)
)
)
} catch (e: HttpException) {
call.respond(
status = e.statusCode,
MustacheContent("404.mustache", NotFoundPage)
)
}
}
route("/edit") {
get {
val user = userService.user(requireSession().userId)
val budgetId = call.parameters.getOrFail("budgetId")
val budgets = budgetService.budgetsForUser(user.id)
val budget = budgets.first { it.id == budgetId }
val transaction = transactionService.transaction(
transactionId = call.parameters.getOrFail("id"),
userId = user.id
)
call.respond(
MustacheContent(
"transaction-form.mustache",
TransactionFormPage(
transaction = transaction.copy(
date = transaction.date.toInstant().toHtmlInputString()
),
amountLabel = transaction.amount.toDecimalString(),
budget = budget,
categoryOptions = categoryOptions(transaction, categoryService, budgetId, user),
budgets = budgets.map { it.toBudgetListItem(budgetId) },
user = user
)
)
)
}
post {
val user = userService.user(requireSession().userId)
val transactionId = call.parameters.getOrFail("id")
val urlBudgetId = call.parameters.getOrFail("budgetId")
val budgets = budgetService.budgetsForUser(user.id)
val budget = budgets.first { it.id == urlBudgetId }
try {
val request = call.receiveParameters().toTransactionRequest()
.run {
copy(
date = "$date:00Z",
expense = categoryService.category(
categoryId = requireNotNull(categoryId),
userId = user.id
).expense,
budgetId = urlBudgetId
)
}
val transaction =
transactionService.save(request, userId = user.id, transactionId = transactionId)
call.respondRedirect("/budgets/${transaction.budgetId}/transactions/${transaction.id}")
} catch (e: HttpException) {
val transaction = TransactionResponse(
id = transactionId,
title = call.parameters["title"],
description = call.parameters["description"],
amount = 0L,
budgetId = urlBudgetId,
expense = call.parameters["expense"]?.toBoolean() ?: true,
date = call.parameters["date"].orEmpty(),
categoryId = call.parameters["categoryId"],
createdBy = user.id
)
call.respond(
status = e.statusCode,
MustacheContent(
"transaction-form.mustache",
TransactionFormPage(
transaction = transaction,
amountLabel = call.parameters["amount"].orEmpty(),
budget = budget,
categoryOptions = categoryOptions(
transaction,
categoryService,
urlBudgetId,
user
),
budgets = budgets.map { it.toBudgetListItem(urlBudgetId) },
user = user,
error = e.message
)
)
)
}
}
}
route("/delete") {
post {
val user = userService.user(requireSession().userId)
val transactionId = call.parameters.getOrFail("id")
val urlBudgetId = call.parameters.getOrFail("budgetId")
transactionService.delete(transactionId = transactionId, userId = user.id)
call.respondRedirect("/budgets/${urlBudgetId}")
}
}
}
}
}
}
}
private suspend fun categoryOptions(
transaction: TransactionResponse,
categoryService: CategoryService,
budgetId: String,
user: UserResponse
): List<TransactionFormPage.CategoryOption> {
val selectedCategoryId = transaction.categoryId.orEmpty()
val categoryOptions = listOf(
TransactionFormPage.CategoryOption(
"",
"Select a category",
isSelected = transaction.categoryId.isNullOrBlank(),
isDisabled = true
),
TransactionFormPage.CategoryOption("income", "Income", isDisabled = true),
)
.plus(
categoryService.categories(
budgetIds = listOf(budgetId),
userId = user.id,
expense = false,
archived = false
).map { category ->
category.asOption(selectedCategoryId)
}
)
.plus(
TransactionFormPage.CategoryOption("expense", "Expense", isDisabled = true),
)
.plus(
categoryService.categories(
budgetIds = listOf(budgetId),
userId = user.id,
expense = true,
archived = false
).map { category ->
category.asOption(selectedCategoryId)
}
)
return categoryOptions
}
private fun Parameters.toTransactionRequest() = TransactionRequest(
title = get("title"),
description = get("description"),
amount = getAmount(),
expense = false,
date = get("date"),
categoryId = get("categoryId"),
budgetId = get("budgetId"),
)
private fun Instant.toHtmlInputString() = truncatedTo(ChronoUnit.MINUTES).toString().substringBefore(":00Z")

View file

@ -1,11 +0,0 @@
package com.wbrawner.twigs.web.user
import com.wbrawner.twigs.web.Page
data class LoginPage(val username: String = "", override val error: String? = null) : Page {
override val title: String = "Login"
}
data class RegisterPage(val username: String = "", val email: String = "", override val error: String? = null) : Page {
override val title: String = "Register"
}

View file

@ -1,94 +0,0 @@
package com.wbrawner.twigs.web.user
import com.wbrawner.twigs.model.CookieSession
import com.wbrawner.twigs.service.HttpException
import com.wbrawner.twigs.service.user.LoginRequest
import com.wbrawner.twigs.service.user.UserRequest
import com.wbrawner.twigs.service.user.UserService
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.mustache.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.sessions.*
import io.ktor.server.util.*
const val TWIGS_SESSION_COOKIE = "twigsSession"
fun Application.userWebRoutes(userService: UserService) {
routing {
route("/login") {
get {
call.respond(MustacheContent("login.mustache", LoginPage()))
}
post {
val request = call.receiveParameters().toLoginRequest()
try {
val session = userService.login(request)
call.sessions.set(CookieSession(session.token))
call.respondRedirect("/")
} catch (e: Throwable) {
e.printStackTrace()
call.respond(
status = (e as? HttpException)?.statusCode ?: HttpStatusCode.InternalServerError,
MustacheContent("login.mustache", LoginPage(username = request.username, error = e.message))
)
}
}
}
route("/logout") {
post {
call.sessions.clear<CookieSession>()
call.respondRedirect("/")
}
}
route("/register") {
get {
call.respond(MustacheContent("register.mustache", RegisterPage()))
}
post {
val request = call.receiveParameters()
val userRequest = request.toUserRequest()
val confirmPassword = request.getOrFail("confirmPassword")
if (userRequest.password != confirmPassword) {
call.respond(
HttpStatusCode.BadRequest,
MustacheContent(
"register.mustache",
userRequest.toPage("passwords don't match")
)
)
return@post
}
try {
userService.register(userRequest)
val session = userService.login(
LoginRequest(
requireNotNull(userRequest.username),
requireNotNull(userRequest.password)
)
)
call.sessions.set(CookieSession(session.token))
call.respondRedirect("/")
} catch (e: HttpException) {
call.respond(
status = e.statusCode,
MustacheContent("register.mustache", userRequest.toPage(error = e.message))
)
}
}
}
}
}
private fun Parameters.toLoginRequest() = LoginRequest(get("username").orEmpty(), get("password").orEmpty())
private fun Parameters.toUserRequest() = UserRequest(get("username").orEmpty(), get("password").orEmpty(), get("email"))
private fun UserRequest.toPage(error: String? = null) =
RegisterPage(username = username.orEmpty(), email = email.orEmpty(), error = error)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 969 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 675 KiB

View file

@ -1,8 +0,0 @@
const forms = document.getElementsByTagName('form')
for (let i = 0; i < forms.length; i++) {
const form = forms[i]
form.onsubmit = () => {
form.querySelector('input[type="submit"]').disabled = true
}
}

View file

@ -1,99 +0,0 @@
{
"name": "Twigs",
"short_name": "Twigs",
"theme_color": "#000000",
"background_color": "#000000",
"display": "standalone",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "icons/icon-maskable-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "icons/icon-maskable-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "icons/icon-maskable-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "icons/icon-maskable-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "icons/icon-maskable-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/icon-maskable-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "icons/icon-maskable-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/icon-maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View file

@ -1,284 +0,0 @@
* {
box-sizing: border-box;
transition: 0.25s ease;
}
html, body {
margin: 0;
padding: 0;
font-family: "Segoe UI", "Product Sans", "Roboto", "San Francisco", sans-serif;
}
:root {
--color-accent: #004800;
--color-error: #a80000;
--color-on-accent: #FFFFFF;
--color-on-background: #000000;
--color-on-dim: #222222;
--logo: url("/img/logo-color.svg");
--background-color-primary: #ffffff;
--background-color-secondary: #bbbbbb;
--border-radius: 5px;
--input-padding: 10px;
}
@media all and (prefers-color-scheme: dark) {
:root {
--color-accent: #baff33;
--color-error: #ff4040;
--color-on-accent: #000000;
--color-on-background: #FFFFFF;
--color-on-dim: #888888;
--logo: url("/img/logo-white.svg");
--background-color-primary: #000000;
--background-color-secondary: #333333;
}
}
body {
background-image: linear-gradient(var(--background-color-primary), var(--background-color-secondary));
background-attachment: fixed;
height: 100vh;
width: 100vw;
}
h1, h2, h3, h4, h5, h6, p, ul {
margin: 0;
padding: 0;
}
h2, h3, h4, h5, h6, p, summary {
padding: 0.5rem;
}
#app {
box-sizing: border-box;
display: flex;
flex-direction: row;
height: 100%;
width: 100%;
}
main {
height: 100%;
overflow-y: auto;
flex-grow: 1;
padding: 1rem;
box-sizing: border-box;
}
.sidebar {
height: 100%;
width: 100%;
overflow-y: auto;
max-width: 300px;
}
#hamburger {
color: var(--color-on-background);
text-decoration: none;
}
.stacked-label {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
@media all and (min-width: 1200px) {
.columns {
display: flex;
flex-direction: row;
}
.columns > * {
flex-grow: 1;
}
}
.column {
display: flex;
flex-direction: column;
}
.row {
display: flex;
flex-direction: row;
justify-content: space-evenly;
}
header.row {
justify-content: space-between;
align-items: center;
}
.card header .button {
margin: 0.5rem;
}
.card {
background: var(--background-color-primary);
border-radius: var(--border-radius);
margin-bottom: 1rem;
}
.button {
border: 1px solid var(--color-accent);
border-radius: var(--border-radius);
cursor: pointer;
font-weight: bold;
padding: var(--input-padding);
text-decoration: none;
text-align: center;
}
.flex-full-width {
display: flex;
flex-direction: row;
}
.button-primary {
background-color: var(--color-accent);
color: var(--color-on-accent);
}
.button-secondary {
background-color: var(--background-color-primary);
color: var(--color-accent);
}
.button-danger {
background-color: var(--color-error);
color: var(--color-on-accent);
}
.center {
align-items: center;
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
width: 100%;
}
.logo {
background-image: var(--logo);
background-size: contain;
background-repeat: no-repeat;
height: 200px;
width: 200px;
}
.center form {
width: 100%;
max-width: 1200px;
}
form {
display: flex;
flex-direction: column;
justify-content: space-between;
}
input, select, textarea {
font-family: "Segoe UI", "Product Sans", "Roboto", "San Francisco", sans-serif;
font-size: 1rem;
margin-bottom: 10px;
border: 1px solid var(--color-accent);
border-radius: var(--border-radius);
padding: var(--input-padding);
}
input:disabled {
background-color: var(--background-color-secondary);
color: var(--color-on-background);
}
.inline-input {
display: flex;
flex-direction: row;
justify-content: start;
}
a {
color: var(--color-accent);
}
.list-item {
list-style: none;
}
.list-item > a {
display: flex;
flex-direction: column;
text-decoration: none;
border-radius: var(--border-radius);
padding: 0.5rem;
color: var(--color-on-background);
}
.list-item > a:hover {
background-color: rgba(0, 0, 0, 0.2);
}
.body-large {
font-size: 1.1rem;
}
.body-medium {
font-size: 1rem;
}
.body-small {
font-size: 0.9rem;
}
.error {
color: var(--color-error);
}
@media all and (max-width: 600px) {
.hide-small {
display: none;
}
}
@media all and (max-width: 900px) {
#sidebar {
background-color: var(--background-color-primary);
position: fixed;
transform: translateX(-100%);
}
#sidebar:target {
transform: translateX(0);
}
}
@media all and (min-width: 900px) {
#hamburger {
display: none;
}
}
@media all and (max-width: 400px) {
.button {
width: 100%;
}
.center {
padding: 10px;
}
.flex-full-width {
flex-direction: column;
padding: 5px;
width: 100%;
}
.flex-full-width .button {
flex-direction: column;
margin: 5px;
}
}

View file

@ -1,8 +0,0 @@
{{> partials/head }}
<div id="app">
<div class="center">
<div class="logo"></div>
<p>Looks like you got a little lost in the woods. Take a few steps back and try again!</p>
</div>
</div>
{{>partials/foot}}

View file

@ -1,108 +0,0 @@
{{> partials/head }}
<div id="app">
{{>partials/sidebar}}
<main>
<header class="row">
<a id="hamburger" href="#sidebar">☰</a>
<h1>{{title}}</h1>
<div class="row">
<a href="/budgets/{{budget.id}}/transactions/new"
class="button button-secondary">
<span aria-description="New Transaction">+</span> <span class="hide-small" aria-hidden="true">New Transaction</span>
</a>
<a href="/budgets/{{budget.id}}/edit"
class="button button-secondary" style="margin-right: 10px;">
<span aria-description="Edit Budget">✎</span> <span class="hide-small" aria-hidden="true">Edit Budget</span>
</a>
<form action="/budgets/{{budget.id}}/delete" method="post">
<!-- TODO: Show confirmation dialog before actually deleting -->
<button class="button button-danger" style="margin-right: 10px;">
<span aria-description="Delete Budget">🗑</span> <span class="hide-small" aria-hidden="true">Delete Budget</span>
</button>
</form>
</div>
</header>
<p>{{budget.description}}</p>
<div class="card">
<div class="row">
<div class="stacked-label">
<p class="body-small">Month</p>
<p class="body-large">{{monthAndYear}}</p>
</div>
<div class="stacked-label">
<p class="body-small">Cash Flow</p>
<p class="body-large">{{balances.cashFlow}}</p>
</div>
<div class="stacked-label">
<p class="body-small">Transactions</p>
<p class="body-large">{{transactionCount}}</p>
</div>
</div>
</div>
<div class="card">
<div class="column">
<p>Expected income: {{balances.expectedIncomeLabel}}</p>
<progress style="margin: 0 0.5rem;"
value="{{balances.expectedIncome}}"
max="{{balances.maxProgressBarValue}}">{{balances.expectedIncomeLabel}}</progress>
<p>Actual Income: {{balances.actualIncomeLabel}}</p>
<progress style="margin: 0 0.5rem;"
value="{{balances.actualIncome}}"
max="{{balances.maxProgressBarValue}}">{{balances.actualIncomeLabel}}</progress>
<p>Expected expenses: {{balances.expectedExpensesLabel}}</p>
<progress style="margin: 0 0.5rem;"
value="{{balances.expectedExpenses}}"
max="{{balances.maxProgressBarValue}}">{{balances.expectedExpensesLabel}}</progress>
<p>Actual Income: {{balances.actualIncomeLabel}}</p>
<progress style="margin: 0 0.5rem;"
value="{{balances.actualExpenses}}"
max="{{balances.maxProgressBarValue}}">{{balances.actualExpensesLabel}}</progress>
</div>
</div>
<div class="card">
<header class="row">
<h3>Income</h3>
<a href="/budgets/{{budget.id}}/categories/new?expense=false" class="button button-secondary">
<!-- TODO: Hide text on small widths -->
<span aria-description="New Category">+</span> <span class="hide-small" aria-hidden="true">New Category</span>
</a>
</header>
<ul>
{{#incomeCategories}}
{{>partials/category-list}}
{{/incomeCategories}}
</ul>
<details>
<summary>Archived</summary>
<ul>
{{#archivedIncomeCategories}}
{{>partials/category-list}}
{{/archivedIncomeCategories}}
</ul>
</details>
</div>
<div class="card">
<header class="row">
<h3>Expenses</h3>
<a href="/budgets/{{budget.id}}/categories/new?expense=true" class="button button-secondary">
<!-- TODO: Hide text on small widths -->
<span aria-description="New Category">+</span> <span class="hide-small" aria-hidden="true">New Category</span>
</a>
</header>
<ul>
{{#expenseCategories}}
{{>partials/category-list}}
{{/expenseCategories}}
</ul>
<details>
<summary>Archived</summary>
<ul>
{{#archivedExpenseCategories}}
{{>partials/category-list}}
{{/archivedExpenseCategories}}
</ul>
</details>
</div>
</main>
</div>
{{>partials/foot}}

View file

@ -1,19 +0,0 @@
{{> partials/head }}
<div id="app">
<main>
<h1>{{title}}</h1>
<div class="center">
{{#error }}
<p class="error">{{error}}</p>
{{/error}}
<form method="post">
<label for="name">Name</label>
<input id="name" type="text" name="name" value="{{ budget.name }}"/>
<label for="description">Description</label>
<textarea id="description" name="description">{{ budget.description }}</textarea>
<input id="submit" type="submit" class="button button-primary" value="Save"/>
</form>
</div>
</main>
</div>
{{>partials/foot}}

View file

@ -1,29 +0,0 @@
{{> partials/head }}
<div id="app">
{{>partials/sidebar}}
<main>
<div class="column">
<header class="row">
<a id="hamburger" href="#sidebar">☰</a>
<h1>{{title}}</h1>
<div class="row">
<a href="/budgets/{{budget.id}}/transactions/new"
class="button button-secondary">
<!-- TODO: Hide text on small widths -->
<span aria-description="New Transaction">+</span> <span
aria-hidden="true">New Transaction</span>
</a>
</div>
</header>
</div>
<div class="card">
<!-- TODO: Add a search bar to filter transactions by name/description -->
<ul>
{{#transactions}}
{{>partials/transaction-list}}
{{/transactions}}
</ul>
</div>
</main>
</div>
{{>partials/foot}}

View file

@ -1,9 +0,0 @@
{{> partials/head }}
<div id="app">
<div class="center">
<div class="flex-full-width">
{{>partials/budget-list}}
</div>
</div>
</div>
{{>partials/foot}}

View file

@ -1,60 +0,0 @@
{{> partials/head }}
<div id="app">
{{>partials/sidebar}}
<main>
<div class="column">
<header class="row">
<a id="hamburger" href="#sidebar">☰</a>
<h1>{{title}}</h1>
<div class="row">
<a href="/budgets/{{budget.id}}/transactions/new?categoryId={{category.category.id}}"
class="button button-secondary">
<!-- TODO: Hide text on small widths -->
<span aria-description="New Transaction">+</span> <span aria-hidden="true">New Transaction</span>
</a>
<a href="/budgets/{{budget.id}}/categories/{{category.category.id}}/edit"
class="button button-secondary" style="margin-right: 10px;">
<!-- TODO: Hide text on small widths -->
<span aria-description="Edit Category">✎</span> <span aria-hidden="true">Edit Category</span>
</a>
<form action="/budgets/{{budget.id}}/categories/{{category.category.id}}/delete" method="post">
<!-- TODO: Show confirmation dialog before actually deleting -->
<button class="button button-danger" style="margin-right: 10px;">
<span aria-description="Delete Category">🗑</span> <span class="hide-small"
aria-hidden="true">Delete Category</span>
</button>
</form>
</div>
</header>
<p>{{category.category.description}}</p>
</div>
<div class="card">
<div class="row">
<div class="stacked-label">
<p class="body-small">Budgeted</p>
<p class="body-large">{{category.amountLabel}}</p>
</div>
<div class="stacked-label">
<p class="body-small">Actual</p>
<p class="body-large">{{category.balanceLabel}}</p>
</div>
<div class="stacked-label">
<p class="body-small">Remaining</p>
<p class="body-large">{{category.remainingAmountLabel}}</p>
</div>
</div>
<progress value="{{category.balance}}"
max="{{category.category.amount}}">{{category.balanceLabel}}</progress>
</div>
<div class="card">
<!-- TODO: Add a search bar to filter transactions by name/description -->
<h3>Transactions</h3>
<ul>
{{#transactions}}
{{>partials/transaction-list}}
{{/transactions}}
</ul>
</div>
</main>
</div>
{{>partials/foot}}

View file

@ -1,31 +0,0 @@
{{> partials/head }}
<div id="app">
<main>
<h1>{{title}}</h1>
{{#error }}
<p class="error">{{error}}</p>
{{/error}}
<form method="post">
<label for="title">Name</label>
<input id="title" type="text" name="title" value="{{ category.title }}" required/>
<label for="description">Description</label>
<textarea id="description" name="description">{{ category.description }}</textarea>
<label for="amount">Amount</label>
<input id="amount" type="number" name="amount" value="{{ amountLabel }}" step="0.01" required/>
<div class="inline-input">
<input id="expense" type="radio" name="expense" value="true" {{expenseChecked}} />
<label for="expense">Expense</label>
</div>
<div class="inline-input">
<input id="income" type="radio" name="expense" value="false" {{incomeChecked}} />
<label for="income">Income</label>
</div>
<div class="inline-input">
<input id="archived" type="checkbox" name="archived" {{archivedChecked}} />
<label for="archived">Archived</label>
</div>
<input id="submit" type="submit" class="button button-primary" value="Save"/>
</form>
</main>
</div>
{{>partials/foot}}

View file

@ -1,11 +0,0 @@
{{> partials/head }}
<div id="app">
<div class="center">
<div class="logo"></div>
<div class="flex-full-width">
<a href="/login" class="button button-primary">Login</a>
<a href="/register" class="button button-secondary">Register</a>
</div>
</div>
</div>
{{>partials/foot}}

View file

@ -1,22 +0,0 @@
{{> partials/head }}
<div id="app">
<main>
<h1>{{title}}</h1>
<div class="center">
<div class="logo"></div>
{{#error }}
<p class="error">{{error}}</p>
{{/error}}
<form action="/login" method="post">
<label for="username">Username</label>
<input id="username" type="text" name="username" value="{{ username }}"/>
<label for="password">Password</label>
<input id="password" type="password" name="password"/>
<input id="submit" type="submit" class="button button-primary" value="Login"/>
</form>
<a href="/resetpassword" style="padding: var(--input-padding)">Forgot your password?</a>
<a href="/register" style="padding: var(--input-padding)">Create an account</a>
</div>
</main>
</div>
{{>partials/foot}}

View file

@ -1,20 +0,0 @@
<ul>
{{#budgets}}
<li class="list-item">
<a class="{{#selected}}selected{{/selected}}" href="/budgets/{{id}}">
<span class="body-medium">{{name}}</span>
<span class="body-small">{{description}}</span>
</a>
</li>
{{/budgets}}
<li class="list-item">
<a href="/budgets/new">
<span class="body-medium">+ New Budget</span>
</a>
</li>
</ul>
{{^budgets}}
<p style="text-align: center;">Welcome to Twigs! It looks like you haven't created any budgets yet. Budgets are the
foundation for planning and growing your finances. To get started with Twigs, <a href="/budgets/new">create a
new budget</a> now!</p>
{{/budgets}}

View file

@ -1,9 +0,0 @@
<li class="list-item">
<a href="/budgets/{{category.budgetId}}/categories/{{category.id}}">
<div class="row" style="justify-content: space-between">
<span class="body-large">{{category.title}}</span>
<span class="body-small">{{remainingAmountLabel}} remaining</span>
</div>
<progress value="{{balance}}" max="{{category.amount}}">{{balanceLabel}}</progress>
</a>
</li>

View file

@ -1,3 +0,0 @@
<script type="text/javascript" async src="/js/index.js"></script>
</body>
</html>

View file

@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="en-US"> <!-- TODO: Localization -->
<head>
<link rel="stylesheet" href="/style.css"/>
<title>{{ title }}</title>
<meta charset="utf-8">
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/touch-icon.png">
<link rel="icon" type="image/png" sizes="96x96" href="/icons/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png">
<meta name="apple-mobile-web-app-title" content="Twigs">
<meta name="application-name" content="Twigs">
<meta name="theme-color" content="#FFFFFF">
<link rel="manifest" href="manifest.json">
</head>
<body>

View file

@ -1,55 +0,0 @@
<aside id="sidebar" class="sidebar">
<div class="logo" style="height: 100px; width: 100px;"></div>
<ul>
<li class="list-item">
<a href="/budgets/{{budget.id}}">
Overview
</a>
</li>
<li class="list-item">
<a href="/budgets/{{budget.id}}/transactions">
Transactions
</a>
</li>
<li class="list-item">
<a href="/budgets/{{budget.id}}/recurring">
Recurring Transactions
</a>
</li>
</ul>
<hr/>
{{>budget-list}}
<hr/>
<ul>
<!--
TODO: Implement profile page
<li class="list-item">
<a href="/profile">
Profile
</a>
</li>
TODO: Implement settings page
<li class="list-item">
<a href="/settings">
Settings
</a>
</li>
TODO: Implement about page and move source code link there
<li class="list-item">
<a href="/about">
About Twigs
</a>
</li>
-->
<li class="list-item">
<form action="/logout" method="post">
<input type="submit" value="Logout"/>
</form>
</li>
<li class="list-item">
<a href="https://github.com/wbrawner/twigs">
Source Code
</a>
</li>
</ul>
</aside>

Some files were not shown because too many files have changed in this diff Show more