WIP: Migrate to Ktor
This commit is contained in:
parent
b882d8b089
commit
ed1dde49bf
40 changed files with 432 additions and 798 deletions
21
api/build.gradle.kts
Normal file
21
api/build.gradle.kts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
plugins {
|
||||||
|
kotlin("jvm")
|
||||||
|
`java-library`
|
||||||
|
}
|
||||||
|
|
||||||
|
val ktorVersion: String by rootProject.extra
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(kotlin("stdlib"))
|
||||||
|
api(project(":core"))
|
||||||
|
implementation(project(":storage"))
|
||||||
|
api("io.ktor:ktor-server-core:$ktorVersion")
|
||||||
|
api("io.ktor:ktor-auth:$ktorVersion")
|
||||||
|
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
|
||||||
|
testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0")
|
||||||
|
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.getByName<Test>("test") {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
32
api/src/main/kotlin/com/wbrawner/twigs/BudgetApi.kt
Normal file
32
api/src/main/kotlin/com/wbrawner/twigs/BudgetApi.kt
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
package com.wbrawner.twigs
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.model.Budget
|
||||||
|
import com.wbrawner.twigs.model.UserPermission
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
data class BudgetRequest(
|
||||||
|
val name: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val users: Set<UserPermissionRequest>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BudgetResponse(
|
||||||
|
val id: String,
|
||||||
|
val name: String?,
|
||||||
|
val description: String?,
|
||||||
|
private val users: List<UserPermissionResponse>
|
||||||
|
) {
|
||||||
|
constructor(budget: Budget, users: Iterable<UserPermission>) : this(
|
||||||
|
Objects.requireNonNull<String>(budget.id),
|
||||||
|
budget.name,
|
||||||
|
budget.description,
|
||||||
|
users.map { userPermission: UserPermission ->
|
||||||
|
UserPermissionResponse(
|
||||||
|
userPermission.userId,
|
||||||
|
userPermission.permission
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BudgetBalanceResponse(val id: String, val balance: Long)
|
124
api/src/main/kotlin/com/wbrawner/twigs/BudgetRoutes.kt
Normal file
124
api/src/main/kotlin/com/wbrawner/twigs/BudgetRoutes.kt
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
package com.wbrawner.twigs
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.model.Budget
|
||||||
|
import com.wbrawner.twigs.model.Permission
|
||||||
|
import com.wbrawner.twigs.model.UserPermission
|
||||||
|
import com.wbrawner.twigs.storage.BudgetRepository
|
||||||
|
import com.wbrawner.twigs.storage.PermissionRepository
|
||||||
|
import io.ktor.application.*
|
||||||
|
import io.ktor.auth.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
import io.ktor.request.*
|
||||||
|
import io.ktor.response.*
|
||||||
|
import io.ktor.routing.*
|
||||||
|
import io.ktor.util.pipeline.*
|
||||||
|
|
||||||
|
fun Application.budgetRoutes(
|
||||||
|
budgetRepository: BudgetRepository,
|
||||||
|
permissionRepository: PermissionRepository
|
||||||
|
) {
|
||||||
|
suspend fun PipelineContext<Unit, ApplicationCall>.budgetWithPermission(
|
||||||
|
budgetId: String,
|
||||||
|
permission: Permission,
|
||||||
|
block: suspend (Budget) -> Unit
|
||||||
|
) {
|
||||||
|
val session = call.principal<Session>()!!
|
||||||
|
val userPermission =
|
||||||
|
permissionRepository.findAllByUserId(session.userId).firstOrNull { it.budgetId == budgetId }
|
||||||
|
if (userPermission?.permission?.isNotAtLeast(permission) != true) {
|
||||||
|
call.respond(HttpStatusCode.Forbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
block(budgetRepository.findAllByIds(listOf(budgetId)).first())
|
||||||
|
}
|
||||||
|
|
||||||
|
routing {
|
||||||
|
authenticate(optional = false) {
|
||||||
|
get("/") {
|
||||||
|
val session = call.principal<Session>()!!
|
||||||
|
val budgetIds = permissionRepository.findAllByUserId(session.userId).map { it.budgetId }
|
||||||
|
val budgets = budgetRepository.findAllByIds(budgetIds).map {
|
||||||
|
BudgetResponse(it, permissionRepository.findAllByBudgetId(it.id))
|
||||||
|
}
|
||||||
|
if (call.request.contentType() == ContentType.Application.Json) {
|
||||||
|
|
||||||
|
} else {
|
||||||
|
call.respondHtml()
|
||||||
|
}
|
||||||
|
call.respond(budgets)
|
||||||
|
}
|
||||||
|
|
||||||
|
get("/{id}") {
|
||||||
|
budgetWithPermission(budgetId = call.parameters["id"]!!, Permission.READ) { budget ->
|
||||||
|
val users = permissionRepository.findAllByBudgetId(budget.id)
|
||||||
|
call.respond(BudgetResponse(budget, users))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post("/{id}") {
|
||||||
|
val session = call.principal<Session>()!!
|
||||||
|
val request = call.receive<BudgetRequest>()
|
||||||
|
if (request.name.isNullOrBlank()) {
|
||||||
|
call.respond(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}") {
|
||||||
|
budgetWithPermission(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.findAllByBudgetId(budget.id)
|
||||||
|
permissionRepository.findAllByBudgetId(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}") {
|
||||||
|
budgetWithPermission(budgetId = call.parameters["id"]!!, Permission.READ) { budget ->
|
||||||
|
budgetRepository.delete(budget)
|
||||||
|
call.respond(HttpStatusCode.NoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,23 +1,6 @@
|
||||||
package com.wbrawner.twigs.server.category
|
package com.wbrawner.twigs
|
||||||
|
|
||||||
import com.wbrawner.twigs.server.budget.Budget
|
import com.wbrawner.twigs.model.Category
|
||||||
import com.wbrawner.twigs.server.randomString
|
|
||||||
import javax.persistence.*
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
data class Category(
|
|
||||||
@Id
|
|
||||||
val id: String = randomString(),
|
|
||||||
var title: String= "",
|
|
||||||
var description: String? = null,
|
|
||||||
var amount: Long = 0L,
|
|
||||||
@field:ManyToOne
|
|
||||||
@field:JoinColumn(nullable = false)
|
|
||||||
var budget: Budget? = null,
|
|
||||||
var expense: Boolean = true,
|
|
||||||
@field:Column(nullable = false, columnDefinition = "boolean default false")
|
|
||||||
var archived: Boolean = false
|
|
||||||
)
|
|
||||||
|
|
||||||
data class NewCategoryRequest(
|
data class NewCategoryRequest(
|
||||||
val title: String,
|
val title: String,
|
|
@ -1,3 +1,3 @@
|
||||||
package com.wbrawner.twigs.server
|
package com.wbrawner.twigs
|
||||||
|
|
||||||
data class ErrorResponse(val message: String)
|
data class ErrorResponse(val message: String)
|
12
api/src/main/kotlin/com/wbrawner/twigs/Session.kt
Normal file
12
api/src/main/kotlin/com/wbrawner/twigs/Session.kt
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
package com.wbrawner.twigs
|
||||||
|
|
||||||
|
import io.ktor.auth.*
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
data class Session(
|
||||||
|
val userId: String = "",
|
||||||
|
val id: String = randomString(),
|
||||||
|
val token: String = randomString(255),
|
||||||
|
var expiration: Date = twoWeeksFromNow
|
||||||
|
) : Principal
|
||||||
|
|
|
@ -1,28 +1,6 @@
|
||||||
package com.wbrawner.twigs.server.transaction
|
package com.wbrawner.twigs
|
||||||
|
|
||||||
import com.wbrawner.twigs.server.budget.Budget
|
import com.wbrawner.twigs.model.Transaction
|
||||||
import com.wbrawner.twigs.server.category.Category
|
|
||||||
import com.wbrawner.twigs.server.randomString
|
|
||||||
import com.wbrawner.twigs.server.user.User
|
|
||||||
import java.time.Instant
|
|
||||||
import javax.persistence.Entity
|
|
||||||
import javax.persistence.Id
|
|
||||||
import javax.persistence.JoinColumn
|
|
||||||
import javax.persistence.ManyToOne
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
data class Transaction(
|
|
||||||
@Id
|
|
||||||
val id: String = randomString(),
|
|
||||||
var title: String? = null,
|
|
||||||
var description: String? = null,
|
|
||||||
var date: Instant? = null,
|
|
||||||
var amount: Long? = null,
|
|
||||||
@field:ManyToOne var category: Category? = null,
|
|
||||||
var expense: Boolean? = null,
|
|
||||||
@field:JoinColumn(nullable = false) @field:ManyToOne val createdBy: User? = null,
|
|
||||||
@field:JoinColumn(nullable = false) @field:ManyToOne var budget: Budget? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class NewTransactionRequest(
|
data class NewTransactionRequest(
|
||||||
val title: String,
|
val title: String,
|
41
api/src/main/kotlin/com/wbrawner/twigs/UserApi.kt
Normal file
41
api/src/main/kotlin/com/wbrawner/twigs/UserApi.kt
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
package com.wbrawner.twigs
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.model.Permission
|
||||||
|
import com.wbrawner.twigs.model.User
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
data class NewUserRequest(
|
||||||
|
val username: String,
|
||||||
|
val password: String,
|
||||||
|
val email: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UpdateUserRequest(
|
||||||
|
val username: String? = null,
|
||||||
|
val password: String? = null,
|
||||||
|
val email: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LoginRequest(val username: String? = null, val password: String? = null)
|
||||||
|
|
||||||
|
data class UserResponse(val id: String, val username: String, val email: String?) {
|
||||||
|
constructor(user: User) : this(user.id, user.name, user.email)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class UserPermissionRequest(
|
||||||
|
val user: String,
|
||||||
|
val permission: Permission = Permission.READ
|
||||||
|
)
|
||||||
|
|
||||||
|
data class UserPermissionResponse(val user: String, val permission: Permission?)
|
||||||
|
|
||||||
|
data class SessionResponse(val token: String, val expiration: String) {
|
||||||
|
constructor(session: Session) : this(session.token, session.expiration.toInstant().toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PasswordResetRequest(
|
||||||
|
val userId: Long,
|
||||||
|
val id: String = randomString(),
|
||||||
|
private val date: Calendar = GregorianCalendar(),
|
||||||
|
private val token: String = randomString()
|
||||||
|
)
|
|
@ -21,6 +21,9 @@ val kotlinVersion: String by rootProject.extra
|
||||||
val ktorVersion: String by rootProject.extra
|
val ktorVersion: String by rootProject.extra
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation(project(":api"))
|
||||||
|
implementation(project(":core"))
|
||||||
|
implementation(project(":storage"))
|
||||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
|
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
|
||||||
implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")
|
implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")
|
||||||
implementation("io.ktor:ktor-server-core:$ktorVersion")
|
implementation("io.ktor:ktor-server-core:$ktorVersion")
|
||||||
|
|
|
@ -1,15 +1,13 @@
|
||||||
package com.wbrawner.twigs.server
|
package com.wbrawner.twigs.server
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.budgetRoutes
|
||||||
import io.ktor.application.*
|
import io.ktor.application.*
|
||||||
import io.ktor.response.*
|
import io.ktor.auth.*
|
||||||
import io.ktor.routing.*
|
|
||||||
|
|
||||||
fun main(args: Array<String>): Unit = io.ktor.server.cio.EngineMain.main(args)
|
fun main(args: Array<String>): Unit = io.ktor.server.cio.EngineMain.main(args)
|
||||||
|
|
||||||
fun Application.module(testing: Boolean = false) {
|
fun Application.module(budgetReposi) {
|
||||||
routing {
|
|
||||||
get("/") {
|
install(Authentication)
|
||||||
call.respondText("Hello, world!")
|
budgetRoutes()
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -1,16 +0,0 @@
|
||||||
package com.wbrawner.twigs.server
|
|
||||||
|
|
||||||
import org.springframework.boot.SpringApplication
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling
|
|
||||||
|
|
||||||
@SpringBootApplication
|
|
||||||
@EnableScheduling
|
|
||||||
open class TwigsServerApplication {
|
|
||||||
companion object {
|
|
||||||
@JvmStatic
|
|
||||||
fun main(args: Array<String>) {
|
|
||||||
SpringApplication.run(TwigsServerApplication::class.java, *args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,74 +0,0 @@
|
||||||
package com.wbrawner.twigs.server
|
|
||||||
|
|
||||||
import com.wbrawner.twigs.server.budget.Budget
|
|
||||||
import com.wbrawner.twigs.server.permission.Permission
|
|
||||||
import com.wbrawner.twigs.server.permission.UserPermissionRepository
|
|
||||||
import com.wbrawner.twigs.server.transaction.TransactionRepository
|
|
||||||
import com.wbrawner.twigs.server.user.User
|
|
||||||
import org.springframework.http.HttpStatus
|
|
||||||
import org.springframework.http.ResponseEntity
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
private val CALENDAR_FIELDS = intArrayOf(
|
|
||||||
Calendar.MILLISECOND,
|
|
||||||
Calendar.SECOND,
|
|
||||||
Calendar.MINUTE,
|
|
||||||
Calendar.HOUR_OF_DAY,
|
|
||||||
Calendar.DATE
|
|
||||||
)
|
|
||||||
|
|
||||||
val firstOfMonth: Date
|
|
||||||
get() = GregorianCalendar().run {
|
|
||||||
for (calField in CALENDAR_FIELDS) {
|
|
||||||
set(calField, getActualMinimum(calField))
|
|
||||||
}
|
|
||||||
time
|
|
||||||
}
|
|
||||||
|
|
||||||
val endOfMonth: Date
|
|
||||||
get() = GregorianCalendar().run {
|
|
||||||
for (calField in CALENDAR_FIELDS) {
|
|
||||||
set(calField, getActualMaximum(calField))
|
|
||||||
}
|
|
||||||
time
|
|
||||||
}
|
|
||||||
|
|
||||||
val twoWeeksFromNow: Date
|
|
||||||
get() = GregorianCalendar().run {
|
|
||||||
add(Calendar.DATE, 14)
|
|
||||||
time
|
|
||||||
}
|
|
||||||
|
|
||||||
val currentUser: User?
|
|
||||||
get() = SecurityContextHolder.getContext().authentication.principal as? User
|
|
||||||
|
|
||||||
private const val CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
|
||||||
|
|
||||||
fun randomString(length: Int = 32): String {
|
|
||||||
val id = StringBuilder()
|
|
||||||
for (i in 0 until length) {
|
|
||||||
id.append(CHARACTERS.random())
|
|
||||||
}
|
|
||||||
return id.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun <T> getBudgetWithPermission(
|
|
||||||
transactionRepository: TransactionRepository,
|
|
||||||
userPermissionsRepository: UserPermissionRepository,
|
|
||||||
transactionId: String,
|
|
||||||
permission: Permission,
|
|
||||||
action: (Budget) -> ResponseEntity<T>
|
|
||||||
): ResponseEntity<T> {
|
|
||||||
val transaction = transactionRepository.findById(transactionId).orElse(null)
|
|
||||||
?: return ResponseEntity.notFound().build()
|
|
||||||
val userPermission = userPermissionsRepository.findByUserAndBudget_Id(
|
|
||||||
currentUser,
|
|
||||||
transaction.budget!!.id
|
|
||||||
).orElse(null)
|
|
||||||
?: return ResponseEntity.notFound().build()
|
|
||||||
if (userPermission.permission.isNotAtLeast(permission)) {
|
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
|
|
||||||
}
|
|
||||||
return action(userPermission.budget!!)
|
|
||||||
}
|
|
|
@ -1,49 +0,0 @@
|
||||||
package com.wbrawner.twigs.server.budget
|
|
||||||
|
|
||||||
import com.wbrawner.twigs.server.category.Category
|
|
||||||
import com.wbrawner.twigs.server.permission.UserPermission
|
|
||||||
import com.wbrawner.twigs.server.permission.UserPermissionRequest
|
|
||||||
import com.wbrawner.twigs.server.permission.UserPermissionResponse
|
|
||||||
import com.wbrawner.twigs.server.randomString
|
|
||||||
import com.wbrawner.twigs.server.transaction.Transaction
|
|
||||||
import java.util.*
|
|
||||||
import javax.persistence.Entity
|
|
||||||
import javax.persistence.Id
|
|
||||||
import javax.persistence.OneToMany
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
data class Budget(
|
|
||||||
@Id
|
|
||||||
var id: String = randomString(),
|
|
||||||
var name: String? = null,
|
|
||||||
var description: String? = null,
|
|
||||||
var currencyCode: String? = "USD",
|
|
||||||
@OneToMany(mappedBy = "budget")
|
|
||||||
val transactions: Set<Transaction> = TreeSet(),
|
|
||||||
@OneToMany(mappedBy = "budget")
|
|
||||||
val categories: Set<Category> = TreeSet(),
|
|
||||||
@OneToMany(mappedBy = "budget")
|
|
||||||
val users: Set<Transaction> = HashSet()
|
|
||||||
)
|
|
||||||
|
|
||||||
data class BudgetRequest(
|
|
||||||
val name: String = "",
|
|
||||||
val description: String = "",
|
|
||||||
val users: Set<UserPermissionRequest> = emptySet()
|
|
||||||
)
|
|
||||||
|
|
||||||
data class BudgetResponse(
|
|
||||||
val id: String,
|
|
||||||
val name: String?,
|
|
||||||
val description: String?,
|
|
||||||
private val users: List<UserPermissionResponse>
|
|
||||||
) {
|
|
||||||
constructor(budget: Budget, users: List<UserPermission>) : this(
|
|
||||||
Objects.requireNonNull<String>(budget.id),
|
|
||||||
budget.name,
|
|
||||||
budget.description,
|
|
||||||
users.map { userPermission: UserPermission -> UserPermissionResponse(userPermission) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class BudgetBalanceResponse(val id: String, val balance: Long)
|
|
|
@ -1,165 +0,0 @@
|
||||||
package com.wbrawner.twigs.server.budget
|
|
||||||
|
|
||||||
import com.wbrawner.twigs.server.currentUser
|
|
||||||
import com.wbrawner.twigs.server.permission.Permission
|
|
||||||
import com.wbrawner.twigs.server.permission.UserPermission
|
|
||||||
import com.wbrawner.twigs.server.permission.UserPermissionRepository
|
|
||||||
import com.wbrawner.twigs.server.permission.UserPermissionRequest
|
|
||||||
import com.wbrawner.twigs.server.transaction.TransactionRepository
|
|
||||||
import com.wbrawner.twigs.server.user.User
|
|
||||||
import com.wbrawner.twigs.server.user.UserRepository
|
|
||||||
import org.slf4j.LoggerFactory
|
|
||||||
import org.springframework.data.domain.PageRequest
|
|
||||||
import org.springframework.http.HttpStatus
|
|
||||||
import org.springframework.http.MediaType
|
|
||||||
import org.springframework.http.ResponseEntity
|
|
||||||
import org.springframework.web.bind.annotation.*
|
|
||||||
import java.time.Instant
|
|
||||||
import java.util.function.Consumer
|
|
||||||
import java.util.function.Function
|
|
||||||
import javax.transaction.Transactional
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping(value = ["/budgets"])
|
|
||||||
@Transactional
|
|
||||||
open class BudgetController(
|
|
||||||
private val budgetRepository: BudgetRepository,
|
|
||||||
private val transactionRepository: TransactionRepository,
|
|
||||||
private val userRepository: UserRepository,
|
|
||||||
private val userPermissionsRepository: UserPermissionRepository
|
|
||||||
) {
|
|
||||||
private val logger = LoggerFactory.getLogger(BudgetController::class.java)
|
|
||||||
|
|
||||||
@GetMapping(value = [""], produces = [MediaType.APPLICATION_JSON_VALUE])
|
|
||||||
open fun getBudgets(page: Int?, count: Int?): ResponseEntity<List<BudgetResponse>> {
|
|
||||||
val user = currentUser ?: return ResponseEntity.status(401).build()
|
|
||||||
val budgets: List<BudgetResponse> = userPermissionsRepository.findAllByUser(
|
|
||||||
user,
|
|
||||||
PageRequest.of(
|
|
||||||
page ?: 0,
|
|
||||||
count ?: 1000
|
|
||||||
)
|
|
||||||
).mapNotNull { userPermission: UserPermission ->
|
|
||||||
val budget = userPermission.budget ?: return@mapNotNull null
|
|
||||||
BudgetResponse(budget, userPermissionsRepository.findAllByBudget(budget, null))
|
|
||||||
}
|
|
||||||
return ResponseEntity.ok(budgets)
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping(value = ["/{id}"], produces = [MediaType.APPLICATION_JSON_VALUE])
|
|
||||||
open fun getBudget(@PathVariable id: String): ResponseEntity<BudgetResponse> {
|
|
||||||
return getBudgetWithPermission(id, Permission.READ) { budget: Budget ->
|
|
||||||
ResponseEntity.ok(BudgetResponse(budget, userPermissionsRepository.findAllByBudget(budget, null)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping(value = ["/{id}/balance"], produces = [MediaType.APPLICATION_JSON_VALUE])
|
|
||||||
open fun getBudgetBalance(
|
|
||||||
@PathVariable id: String,
|
|
||||||
@RequestParam(value = "from", required = false) from: String? = null,
|
|
||||||
@RequestParam(value = "to", required = false) to: String? = null
|
|
||||||
): ResponseEntity<BudgetBalanceResponse> {
|
|
||||||
return getBudgetWithPermission(id, Permission.READ) { budget: Budget ->
|
|
||||||
val fromInstant: Instant = try {
|
|
||||||
Instant.parse(from)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (e !is NullPointerException) logger.error(
|
|
||||||
"Failed to parse '$from' to Instant for 'from' parameter",
|
|
||||||
e
|
|
||||||
)
|
|
||||||
Instant.ofEpochSecond(0)
|
|
||||||
}
|
|
||||||
val toInstant: Instant = try {
|
|
||||||
Instant.parse(to)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (e !is NullPointerException) logger.error("Failed to parse '$to' to Instant for 'to' parameter", e)
|
|
||||||
Instant.now()
|
|
||||||
}
|
|
||||||
val balance = transactionRepository.sumBalanceByBudgetId(budget.id, fromInstant, toInstant)
|
|
||||||
ResponseEntity.ok(BudgetBalanceResponse(budget.id, balance))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping(
|
|
||||||
value = [""],
|
|
||||||
consumes = [MediaType.APPLICATION_JSON_VALUE],
|
|
||||||
produces = [MediaType.APPLICATION_JSON_VALUE]
|
|
||||||
)
|
|
||||||
open fun newBudget(@RequestBody request: BudgetRequest): ResponseEntity<BudgetResponse> {
|
|
||||||
val budget = budgetRepository.save(Budget(request.name, request.description))
|
|
||||||
val users: MutableSet<UserPermission> = request.users
|
|
||||||
.mapNotNull { userPermissionRequest: UserPermissionRequest ->
|
|
||||||
val user = userRepository.findById(userPermissionRequest.user!!).orElse(null) ?: return@mapNotNull null
|
|
||||||
userPermissionsRepository.save(
|
|
||||||
UserPermission(budget, user, userPermissionRequest.permission)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.toMutableSet()
|
|
||||||
val currentUserIncluded = users.any { userPermission: UserPermission -> userPermission.user!!.id == currentUser!!.id }
|
|
||||||
if (!currentUserIncluded) {
|
|
||||||
users.add(
|
|
||||||
userPermissionsRepository.save(
|
|
||||||
UserPermission(budget, currentUser!!, Permission.OWNER)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return ResponseEntity.ok(BudgetResponse(budget, ArrayList(users)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@PutMapping(
|
|
||||||
value = ["/{id}"],
|
|
||||||
consumes = [MediaType.APPLICATION_JSON_VALUE],
|
|
||||||
produces = [MediaType.APPLICATION_JSON_VALUE]
|
|
||||||
)
|
|
||||||
open fun updateBudget(
|
|
||||||
@PathVariable id: String,
|
|
||||||
@RequestBody request: BudgetRequest
|
|
||||||
): ResponseEntity<BudgetResponse> {
|
|
||||||
return getBudgetWithPermission(id, Permission.MANAGE) { budget: Budget ->
|
|
||||||
budget.name = request.name
|
|
||||||
budget.description = request.description
|
|
||||||
val users = ArrayList<UserPermission>()
|
|
||||||
if (request.users.isNotEmpty()) {
|
|
||||||
request.users.forEach(Consumer { userPermissionRequest: UserPermissionRequest ->
|
|
||||||
userRepository.findById(userPermissionRequest.user!!).ifPresent { requestedUser: User ->
|
|
||||||
users.add(
|
|
||||||
userPermissionsRepository.save(
|
|
||||||
UserPermission(
|
|
||||||
budget,
|
|
||||||
requestedUser,
|
|
||||||
userPermissionRequest.permission
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
users.addAll(userPermissionsRepository.findAllByBudget(budget, null))
|
|
||||||
}
|
|
||||||
ResponseEntity.ok(BudgetResponse(budgetRepository.save(budget), users))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping(value = ["/{id}"], produces = [MediaType.TEXT_PLAIN_VALUE])
|
|
||||||
open fun deleteBudget(@PathVariable id: String): ResponseEntity<Void?> {
|
|
||||||
return getBudgetWithPermission(id, Permission.MANAGE) { budget: Budget ->
|
|
||||||
budgetRepository.delete(budget)
|
|
||||||
ResponseEntity.ok().build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun <T> getBudgetWithPermission(
|
|
||||||
budgetId: String,
|
|
||||||
permission: Permission,
|
|
||||||
callback: Function<Budget, ResponseEntity<T>>
|
|
||||||
): ResponseEntity<T> {
|
|
||||||
val user = currentUser ?: return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()
|
|
||||||
val userPermission = userPermissionsRepository.findByUserAndBudget_Id(user, budgetId).orElse(null)
|
|
||||||
?: return ResponseEntity.notFound().build()
|
|
||||||
if (userPermission.permission.isNotAtLeast(permission)) {
|
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
|
|
||||||
}
|
|
||||||
val budget = userPermission.budget ?: return ResponseEntity.notFound().build()
|
|
||||||
return callback.apply(budget)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
package com.wbrawner.twigs.server.budget
|
|
||||||
|
|
||||||
import org.springframework.data.repository.PagingAndSortingRepository
|
|
||||||
|
|
||||||
interface BudgetRepository : PagingAndSortingRepository<Budget, String>
|
|
|
@ -1,6 +1,6 @@
|
||||||
package com.wbrawner.twigs.server.category
|
package com.wbrawner.twigs.server.category
|
||||||
|
|
||||||
import com.wbrawner.twigs.server.ErrorResponse
|
import com.wbrawner.twigs.ErrorResponse
|
||||||
import com.wbrawner.twigs.server.currentUser
|
import com.wbrawner.twigs.server.currentUser
|
||||||
import com.wbrawner.twigs.server.firstOfMonth
|
import com.wbrawner.twigs.server.firstOfMonth
|
||||||
import com.wbrawner.twigs.server.permission.Permission
|
import com.wbrawner.twigs.server.permission.Permission
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
package com.wbrawner.twigs.server.config
|
|
||||||
|
|
||||||
import com.wbrawner.twigs.server.user.UserRepository
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
|
||||||
import org.springframework.security.core.userdetails.UserDetails
|
|
||||||
import org.springframework.security.core.userdetails.UserDetailsService
|
|
||||||
import org.springframework.security.core.userdetails.UsernameNotFoundException
|
|
||||||
import org.springframework.stereotype.Component
|
|
||||||
|
|
||||||
@Component
|
|
||||||
open class JdbcUserDetailsService @Autowired constructor(private val userRepository: UserRepository) : UserDetailsService {
|
|
||||||
@Throws(UsernameNotFoundException::class)
|
|
||||||
override fun loadUserByUsername(username: String): UserDetails {
|
|
||||||
var userDetails: UserDetails?
|
|
||||||
userDetails = userRepository.findByName(username).orElse(null)
|
|
||||||
if (userDetails != null) {
|
|
||||||
return userDetails
|
|
||||||
}
|
|
||||||
userDetails = userRepository.findByEmail(username).orElse(null)
|
|
||||||
if (userDetails != null) {
|
|
||||||
return userDetails
|
|
||||||
}
|
|
||||||
throw UsernameNotFoundException("Unable to find user with username \$username")
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
package com.wbrawner.twigs.server.config
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Configuration
|
|
||||||
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
|
|
||||||
import org.springframework.security.config.annotation.method.configuration.GlobalMethodSecurityConfiguration
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
@EnableGlobalMethodSecurity(prePostEnabled = true)
|
|
||||||
open class MethodSecurity : GlobalMethodSecurityConfiguration()
|
|
|
@ -1,92 +0,0 @@
|
||||||
package com.wbrawner.twigs.server.config
|
|
||||||
|
|
||||||
import com.wbrawner.twigs.server.passwordresetrequest.PasswordResetRequestRepository
|
|
||||||
import com.wbrawner.twigs.server.session.UserSessionRepository
|
|
||||||
import com.wbrawner.twigs.server.user.UserRepository
|
|
||||||
import org.springframework.context.annotation.Bean
|
|
||||||
import org.springframework.context.annotation.Configuration
|
|
||||||
import org.springframework.core.env.Environment
|
|
||||||
import org.springframework.http.HttpMethod
|
|
||||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider
|
|
||||||
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
|
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
|
||||||
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
|
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy
|
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
|
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder
|
|
||||||
import org.springframework.security.provisioning.JdbcUserDetailsManager
|
|
||||||
import org.springframework.web.cors.CorsConfiguration
|
|
||||||
import java.util.*
|
|
||||||
import javax.sql.DataSource
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
@EnableWebSecurity
|
|
||||||
open class SecurityConfig(
|
|
||||||
private val env: Environment,
|
|
||||||
private val datasource: DataSource,
|
|
||||||
private val userSessionRepository: UserSessionRepository,
|
|
||||||
private val userRepository: UserRepository,
|
|
||||||
private val passwordResetRequestRepository: PasswordResetRequestRepository,
|
|
||||||
private val userDetailsService: JdbcUserDetailsService,
|
|
||||||
private val environment: Environment
|
|
||||||
) : WebSecurityConfigurerAdapter() {
|
|
||||||
@get:Bean
|
|
||||||
open val userDetailsManager: JdbcUserDetailsManager
|
|
||||||
get() {
|
|
||||||
val userDetailsManager = JdbcUserDetailsManager()
|
|
||||||
userDetailsManager.dataSource = datasource
|
|
||||||
return userDetailsManager
|
|
||||||
}
|
|
||||||
|
|
||||||
@get:Bean
|
|
||||||
open val authenticationProvider: DaoAuthenticationProvider
|
|
||||||
get() {
|
|
||||||
val authProvider = TokenAuthenticationProvider(userSessionRepository, userRepository)
|
|
||||||
authProvider.setPasswordEncoder(passwordEncoder)
|
|
||||||
authProvider.setUserDetailsService(userDetailsService)
|
|
||||||
return authProvider
|
|
||||||
}
|
|
||||||
|
|
||||||
@get:Bean
|
|
||||||
open val passwordEncoder: PasswordEncoder
|
|
||||||
get() = BCryptPasswordEncoder()
|
|
||||||
|
|
||||||
public override fun configure(auth: AuthenticationManagerBuilder) {
|
|
||||||
auth.authenticationProvider(authenticationProvider)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(Exception::class)
|
|
||||||
public override fun configure(http: HttpSecurity) {
|
|
||||||
http.authorizeRequests()
|
|
||||||
.antMatchers("/users/new", "/users/login")
|
|
||||||
.permitAll()
|
|
||||||
.anyRequest()
|
|
||||||
.authenticated()
|
|
||||||
.and()
|
|
||||||
.httpBasic()
|
|
||||||
.authenticationEntryPoint(SilentAuthenticationEntryPoint())
|
|
||||||
.and()
|
|
||||||
.cors()
|
|
||||||
.configurationSource {
|
|
||||||
val corsConfig = CorsConfiguration()
|
|
||||||
corsConfig.applyPermitDefaultValues()
|
|
||||||
val corsDomains = environment.getProperty("twigs.cors.domains", "*")
|
|
||||||
corsConfig.allowedOrigins = Arrays.asList(*corsDomains.split(",").toTypedArray())
|
|
||||||
corsConfig.allowedMethods = listOf(
|
|
||||||
HttpMethod.GET,
|
|
||||||
HttpMethod.POST,
|
|
||||||
HttpMethod.PUT,
|
|
||||||
HttpMethod.DELETE,
|
|
||||||
HttpMethod.OPTIONS
|
|
||||||
).map { obj: HttpMethod -> obj.name }
|
|
||||||
corsConfig.allowCredentials = true
|
|
||||||
corsConfig
|
|
||||||
}
|
|
||||||
.and()
|
|
||||||
.csrf()
|
|
||||||
.disable()
|
|
||||||
.addFilter(TokenAuthenticationFilter(authenticationManager()))
|
|
||||||
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
package com.wbrawner.twigs.server.config
|
|
||||||
|
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
|
||||||
import org.springframework.security.core.GrantedAuthority
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a token with the supplied array of authorities.
|
|
||||||
*
|
|
||||||
* @param authorities the collection of <tt>GrantedAuthority</tt>s for the principal
|
|
||||||
* represented by this authentication object.
|
|
||||||
* @param credentials
|
|
||||||
* @param principal
|
|
||||||
*/
|
|
||||||
class SessionAuthenticationToken(
|
|
||||||
principal: Any?,
|
|
||||||
credentials: Any?,
|
|
||||||
authorities: Collection<GrantedAuthority>
|
|
||||||
) : UsernamePasswordAuthenticationToken(principal, credentials, authorities)
|
|
|
@ -1,22 +0,0 @@
|
||||||
package com.wbrawner.twigs.server.config
|
|
||||||
|
|
||||||
import org.springframework.security.core.AuthenticationException
|
|
||||||
import org.springframework.security.web.AuthenticationEntryPoint
|
|
||||||
import java.io.IOException
|
|
||||||
import javax.servlet.ServletException
|
|
||||||
import javax.servlet.http.HttpServletRequest
|
|
||||||
import javax.servlet.http.HttpServletResponse
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Used to avoid browser prompts for authentication
|
|
||||||
*/
|
|
||||||
class SilentAuthenticationEntryPoint : AuthenticationEntryPoint {
|
|
||||||
@Throws(IOException::class, ServletException::class)
|
|
||||||
override fun commence(
|
|
||||||
request: HttpServletRequest,
|
|
||||||
response: HttpServletResponse,
|
|
||||||
authException: AuthenticationException
|
|
||||||
) {
|
|
||||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.message)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,26 +0,0 @@
|
||||||
package com.wbrawner.twigs.server.config
|
|
||||||
|
|
||||||
import org.springframework.security.authentication.AuthenticationManager
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder
|
|
||||||
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter
|
|
||||||
import java.io.IOException
|
|
||||||
import javax.servlet.FilterChain
|
|
||||||
import javax.servlet.ServletException
|
|
||||||
import javax.servlet.http.HttpServletRequest
|
|
||||||
import javax.servlet.http.HttpServletResponse
|
|
||||||
|
|
||||||
class TokenAuthenticationFilter(authenticationManager: AuthenticationManager?) :
|
|
||||||
BasicAuthenticationFilter(authenticationManager) {
|
|
||||||
@Throws(IOException::class, ServletException::class)
|
|
||||||
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
|
|
||||||
val authHeader = request.getHeader("Authorization")
|
|
||||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
|
||||||
chain.doFilter(request, response)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val token = authHeader.substring(7)
|
|
||||||
val authentication = authenticationManager.authenticate(SessionAuthenticationToken(null, token, emptyList()))
|
|
||||||
SecurityContextHolder.getContext().authentication = authentication
|
|
||||||
chain.doFilter(request, response)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,53 +0,0 @@
|
||||||
package com.wbrawner.twigs.server.config
|
|
||||||
|
|
||||||
import com.wbrawner.twigs.server.session.UserSessionRepository
|
|
||||||
import com.wbrawner.twigs.server.twoWeeksFromNow
|
|
||||||
import com.wbrawner.twigs.server.user.UserRepository
|
|
||||||
import org.springframework.security.authentication.BadCredentialsException
|
|
||||||
import org.springframework.security.authentication.InternalAuthenticationServiceException
|
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
|
||||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider
|
|
||||||
import org.springframework.security.core.Authentication
|
|
||||||
import org.springframework.security.core.AuthenticationException
|
|
||||||
import org.springframework.security.core.userdetails.UserDetails
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class TokenAuthenticationProvider(
|
|
||||||
private val userSessionRepository: UserSessionRepository,
|
|
||||||
private val userRepository: UserRepository
|
|
||||||
) : DaoAuthenticationProvider() {
|
|
||||||
@Throws(AuthenticationException::class)
|
|
||||||
override fun additionalAuthenticationChecks(
|
|
||||||
userDetails: UserDetails,
|
|
||||||
authentication: UsernamePasswordAuthenticationToken
|
|
||||||
) {
|
|
||||||
if (authentication !is SessionAuthenticationToken) {
|
|
||||||
// Additional checks aren't needed since they've already been handled
|
|
||||||
super.additionalAuthenticationChecks(userDetails, authentication)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(AuthenticationException::class)
|
|
||||||
override fun authenticate(authentication: Authentication): Authentication {
|
|
||||||
return if (authentication is SessionAuthenticationToken) {
|
|
||||||
val session = userSessionRepository.findByToken(authentication.getCredentials() as String)
|
|
||||||
if (session!!.isEmpty || session.get().expiration.before(Date())) {
|
|
||||||
throw BadCredentialsException("Credentials expired")
|
|
||||||
}
|
|
||||||
val user = userRepository.findById(session.get().userId)
|
|
||||||
if (user.isEmpty) {
|
|
||||||
throw InternalAuthenticationServiceException("Failed to find user for token")
|
|
||||||
}
|
|
||||||
Thread {
|
|
||||||
|
|
||||||
// Update the session on a background thread to avoid holding up the request longer than necessary
|
|
||||||
val updatedSession = session.get()
|
|
||||||
updatedSession.expiration = twoWeeksFromNow
|
|
||||||
userSessionRepository.save(updatedSession)
|
|
||||||
}.start()
|
|
||||||
SessionAuthenticationToken(user.get(), authentication.getCredentials(), authentication.getAuthorities())
|
|
||||||
} else {
|
|
||||||
super.authenticate(authentication)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,17 +0,0 @@
|
||||||
package com.wbrawner.twigs.server.passwordresetrequest
|
|
||||||
|
|
||||||
import com.wbrawner.twigs.server.randomString
|
|
||||||
import com.wbrawner.twigs.server.user.User
|
|
||||||
import java.util.*
|
|
||||||
import javax.persistence.Entity
|
|
||||||
import javax.persistence.Id
|
|
||||||
import javax.persistence.ManyToOne
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
data class PasswordResetRequest(
|
|
||||||
@Id
|
|
||||||
val id: String = randomString(),
|
|
||||||
@field:ManyToOne private val user: User? = null,
|
|
||||||
private val date: Calendar = GregorianCalendar(),
|
|
||||||
private val token: String = randomString()
|
|
||||||
)
|
|
|
@ -1,87 +0,0 @@
|
||||||
package com.wbrawner.twigs.server.permission
|
|
||||||
|
|
||||||
import com.wbrawner.twigs.server.budget.Budget
|
|
||||||
import com.wbrawner.twigs.server.user.User
|
|
||||||
import com.wbrawner.twigs.server.user.UserResponse
|
|
||||||
import java.io.Serializable
|
|
||||||
import javax.persistence.*
|
|
||||||
|
|
||||||
enum class Permission {
|
|
||||||
/**
|
|
||||||
* The user can read the content but cannot make any modifications.
|
|
||||||
*/
|
|
||||||
READ,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The user can read and write the content but cannot make any modifications to the container of the content.
|
|
||||||
*/
|
|
||||||
WRITE,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The user can read and write the content, and make modifications to the container of the content including things like name, description, and other users' permissions (with the exception of the owner user, whose role can never be removed by a user with only MANAGE permissions).
|
|
||||||
*/
|
|
||||||
MANAGE,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The user has complete control over the resource. There can only be a single owner user at any given time.
|
|
||||||
*/
|
|
||||||
OWNER;
|
|
||||||
|
|
||||||
fun isNotAtLeast(wanted: Permission): Boolean {
|
|
||||||
return ordinal < wanted.ordinal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
data class UserPermission(
|
|
||||||
@field:EmbeddedId
|
|
||||||
val id: UserPermissionKey? = null,
|
|
||||||
@field:JoinColumn(
|
|
||||||
nullable = false,
|
|
||||||
name = "budget_id"
|
|
||||||
)
|
|
||||||
@field:MapsId(
|
|
||||||
"budgetId"
|
|
||||||
)
|
|
||||||
@field:ManyToOne
|
|
||||||
val budget: Budget? = null,
|
|
||||||
@field:JoinColumn(
|
|
||||||
nullable = false,
|
|
||||||
name = "user_id"
|
|
||||||
)
|
|
||||||
@field:MapsId("userId")
|
|
||||||
@field:ManyToOne
|
|
||||||
val user: User? = null,
|
|
||||||
@field:Enumerated(
|
|
||||||
EnumType.STRING
|
|
||||||
)
|
|
||||||
@field:JoinColumn(
|
|
||||||
nullable = false
|
|
||||||
)
|
|
||||||
val permission: Permission = Permission.READ
|
|
||||||
) {
|
|
||||||
constructor(budget: Budget, user: User, permission: Permission) : this(
|
|
||||||
UserPermissionKey(budget.id, user.id),
|
|
||||||
budget,
|
|
||||||
user,
|
|
||||||
permission
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Embeddable
|
|
||||||
data class UserPermissionKey(
|
|
||||||
private val budgetId: String? = null,
|
|
||||||
private val userId: String? = null
|
|
||||||
) : Serializable
|
|
||||||
|
|
||||||
data class UserPermissionRequest(
|
|
||||||
val user: String? = null,
|
|
||||||
val permission: Permission = Permission.READ
|
|
||||||
)
|
|
||||||
|
|
||||||
data class UserPermissionResponse(val user: UserResponse, val permission: Permission?) {
|
|
||||||
constructor(userPermission: UserPermission) : this(
|
|
||||||
UserResponse(userPermission.user!!),
|
|
||||||
userPermission.permission
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
package com.wbrawner.twigs.server.session
|
|
||||||
|
|
||||||
import com.wbrawner.twigs.server.randomString
|
|
||||||
import com.wbrawner.twigs.server.twoWeeksFromNow
|
|
||||||
import javax.persistence.Entity
|
|
||||||
import javax.persistence.Id
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
data class Session(val userId: String = "") {
|
|
||||||
@Id
|
|
||||||
val id = randomString()
|
|
||||||
val token = randomString(255)
|
|
||||||
var expiration = twoWeeksFromNow
|
|
||||||
}
|
|
||||||
|
|
||||||
data class SessionResponse(val token: String, val expiration: String) {
|
|
||||||
constructor(session: Session) : this(session.token, session.expiration.toInstant().toString())
|
|
||||||
}
|
|
|
@ -1,6 +1,6 @@
|
||||||
package com.wbrawner.twigs.server.transaction
|
package com.wbrawner.twigs.server.transaction
|
||||||
|
|
||||||
import com.wbrawner.twigs.server.ErrorResponse
|
import com.wbrawner.twigs.ErrorResponse
|
||||||
import com.wbrawner.twigs.server.category.Category
|
import com.wbrawner.twigs.server.category.Category
|
||||||
import com.wbrawner.twigs.server.category.CategoryRepository
|
import com.wbrawner.twigs.server.category.CategoryRepository
|
||||||
import com.wbrawner.twigs.server.currentUser
|
import com.wbrawner.twigs.server.currentUser
|
||||||
|
|
|
@ -1,66 +0,0 @@
|
||||||
package com.wbrawner.twigs.server.user
|
|
||||||
|
|
||||||
import com.wbrawner.twigs.server.randomString
|
|
||||||
import org.springframework.security.core.GrantedAuthority
|
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
|
||||||
import org.springframework.security.core.userdetails.UserDetails
|
|
||||||
import javax.persistence.Column
|
|
||||||
import javax.persistence.Entity
|
|
||||||
import javax.persistence.Id
|
|
||||||
import javax.persistence.Transient
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
data class User(
|
|
||||||
@Id
|
|
||||||
val id: String = randomString(),
|
|
||||||
@field:Column(name = "username")
|
|
||||||
var name: String = "",
|
|
||||||
@field:Column(name = "password")
|
|
||||||
var passphrase: String = "",
|
|
||||||
@Transient
|
|
||||||
private val _authorities: Collection<GrantedAuthority> = listOf(SimpleGrantedAuthority("USER")),
|
|
||||||
var email: String? = null
|
|
||||||
) : UserDetails {
|
|
||||||
|
|
||||||
override fun getUsername(): String = name
|
|
||||||
|
|
||||||
override fun getPassword(): String = passphrase
|
|
||||||
|
|
||||||
override fun isAccountNonExpired(): Boolean {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isAccountNonLocked(): Boolean {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isCredentialsNonExpired(): Boolean {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isEnabled(): Boolean {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAuthorities(): Collection<GrantedAuthority> {
|
|
||||||
return _authorities
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class NewUserRequest(
|
|
||||||
val username: String,
|
|
||||||
val password: String,
|
|
||||||
val email: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class UpdateUserRequest(
|
|
||||||
val username: String? = null,
|
|
||||||
val password: String? = null,
|
|
||||||
val email: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class LoginRequest(val username: String? = null, val password: String? = null)
|
|
||||||
|
|
||||||
data class UserResponse(val id: String, val username: String, val email: String?) {
|
|
||||||
constructor(user: User) : this(user.id, user.username, user.email)
|
|
||||||
}
|
|
|
@ -1,6 +1,6 @@
|
||||||
package com.wbrawner.twigs.server.user
|
package com.wbrawner.twigs.server.user
|
||||||
|
|
||||||
import com.wbrawner.twigs.server.ErrorResponse
|
import com.wbrawner.twigs.ErrorResponse
|
||||||
import com.wbrawner.twigs.server.budget.BudgetRepository
|
import com.wbrawner.twigs.server.budget.BudgetRepository
|
||||||
import com.wbrawner.twigs.server.currentUser
|
import com.wbrawner.twigs.server.currentUser
|
||||||
import com.wbrawner.twigs.server.permission.UserPermission
|
import com.wbrawner.twigs.server.permission.UserPermission
|
||||||
|
|
16
core/build.gradle.kts
Normal file
16
core/build.gradle.kts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
plugins {
|
||||||
|
kotlin("jvm")
|
||||||
|
`java-library`
|
||||||
|
}
|
||||||
|
|
||||||
|
val ktorVersion: String by rootProject.extra
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(kotlin("stdlib"))
|
||||||
|
testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0")
|
||||||
|
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.getByName<Test>("test") {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
43
core/src/main/kotlin/com/wbrawner/twigs/Utils.kt
Normal file
43
core/src/main/kotlin/com/wbrawner/twigs/Utils.kt
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
package com.wbrawner.twigs
|
||||||
|
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
private val CALENDAR_FIELDS = intArrayOf(
|
||||||
|
Calendar.MILLISECOND,
|
||||||
|
Calendar.SECOND,
|
||||||
|
Calendar.MINUTE,
|
||||||
|
Calendar.HOUR_OF_DAY,
|
||||||
|
Calendar.DATE
|
||||||
|
)
|
||||||
|
|
||||||
|
val firstOfMonth: Date
|
||||||
|
get() = GregorianCalendar().run {
|
||||||
|
for (calField in CALENDAR_FIELDS) {
|
||||||
|
set(calField, getActualMinimum(calField))
|
||||||
|
}
|
||||||
|
time
|
||||||
|
}
|
||||||
|
|
||||||
|
val endOfMonth: Date
|
||||||
|
get() = GregorianCalendar().run {
|
||||||
|
for (calField in CALENDAR_FIELDS) {
|
||||||
|
set(calField, getActualMaximum(calField))
|
||||||
|
}
|
||||||
|
time
|
||||||
|
}
|
||||||
|
|
||||||
|
val twoWeeksFromNow: Date
|
||||||
|
get() = GregorianCalendar().run {
|
||||||
|
add(Calendar.DATE, 14)
|
||||||
|
time
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
|
||||||
|
fun randomString(length: Int = 32): String {
|
||||||
|
val id = StringBuilder()
|
||||||
|
for (i in 0 until length) {
|
||||||
|
id.append(CHARACTERS.random())
|
||||||
|
}
|
||||||
|
return id.toString()
|
||||||
|
}
|
10
core/src/main/kotlin/com/wbrawner/twigs/model/Budget.kt
Normal file
10
core/src/main/kotlin/com/wbrawner/twigs/model/Budget.kt
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
package com.wbrawner.twigs.model
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.randomString
|
||||||
|
|
||||||
|
data class Budget(
|
||||||
|
var id: String = randomString(),
|
||||||
|
var name: String? = null,
|
||||||
|
var description: String? = null,
|
||||||
|
var currencyCode: String? = "USD",
|
||||||
|
)
|
13
core/src/main/kotlin/com/wbrawner/twigs/model/Category.kt
Normal file
13
core/src/main/kotlin/com/wbrawner/twigs/model/Category.kt
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
package com.wbrawner.twigs.model
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.randomString
|
||||||
|
|
||||||
|
data class Category(
|
||||||
|
val id: String = randomString(),
|
||||||
|
var title: String = "",
|
||||||
|
var description: String? = null,
|
||||||
|
var amount: Long = 0L,
|
||||||
|
var budget: Budget? = null,
|
||||||
|
var expense: Boolean = true,
|
||||||
|
var archived: Boolean = false
|
||||||
|
)
|
16
core/src/main/kotlin/com/wbrawner/twigs/model/Transaction.kt
Normal file
16
core/src/main/kotlin/com/wbrawner/twigs/model/Transaction.kt
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
package com.wbrawner.twigs.model
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.randomString
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
data class Transaction(
|
||||||
|
val id: String = randomString(),
|
||||||
|
val title: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val date: Instant? = null,
|
||||||
|
val amount: Long? = null,
|
||||||
|
val category: Category? = null,
|
||||||
|
val expense: Boolean? = null,
|
||||||
|
val createdBy: User? = null,
|
||||||
|
val budget: Budget? = null
|
||||||
|
)
|
42
core/src/main/kotlin/com/wbrawner/twigs/model/User.kt
Normal file
42
core/src/main/kotlin/com/wbrawner/twigs/model/User.kt
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
package com.wbrawner.twigs.model
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.randomString
|
||||||
|
|
||||||
|
data class User(
|
||||||
|
val id: String = randomString(),
|
||||||
|
val name: String = "",
|
||||||
|
val password: String = "",
|
||||||
|
val email: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class Permission {
|
||||||
|
/**
|
||||||
|
* The user can read the content but cannot make any modifications.
|
||||||
|
*/
|
||||||
|
READ,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user can read and write the content but cannot make any modifications to the container of the content.
|
||||||
|
*/
|
||||||
|
WRITE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user can read and write the content, and make modifications to the container of the content including things like name, description, and other users' permissions (with the exception of the owner user, whose role can never be removed by a user with only MANAGE permissions).
|
||||||
|
*/
|
||||||
|
MANAGE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user has complete control over the resource. There can only be a single owner user at any given time.
|
||||||
|
*/
|
||||||
|
OWNER;
|
||||||
|
|
||||||
|
fun isNotAtLeast(wanted: Permission): Boolean {
|
||||||
|
return ordinal < wanted.ordinal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class UserPermission(
|
||||||
|
val budgetId: String,
|
||||||
|
val userId: String,
|
||||||
|
val permission: Permission = Permission.READ
|
||||||
|
)
|
|
@ -1,2 +1,3 @@
|
||||||
rootProject.name = "twigs"
|
rootProject.name = "twigs"
|
||||||
include(":app")
|
include("core", "api", "app")
|
||||||
|
include("storage")
|
||||||
|
|
17
storage/build.gradle.kts
Normal file
17
storage/build.gradle.kts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
plugins {
|
||||||
|
kotlin("jvm")
|
||||||
|
`java-library`
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(kotlin("stdlib"))
|
||||||
|
implementation(project(":core"))
|
||||||
|
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
|
||||||
|
implementation("org.postgresql:postgresql:42.2.23")
|
||||||
|
testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0")
|
||||||
|
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.getByName<Test>("test") {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.wbrawner.twigs.storage
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.model.Budget
|
||||||
|
|
||||||
|
interface BudgetRepository : Repository<Budget>
|
|
@ -0,0 +1,8 @@
|
||||||
|
package com.wbrawner.twigs.storage
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.model.UserPermission
|
||||||
|
|
||||||
|
interface PermissionRepository : Repository<UserPermission> {
|
||||||
|
fun findAllByBudgetId(budgetId: String): List<UserPermission>
|
||||||
|
fun findAllByUserId(userId: String): List<UserPermission>
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
package com.wbrawner.twigs.storage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base interface for an entity repository that provides basic CRUD methods
|
||||||
|
*
|
||||||
|
* @param T The type of the object supported by this repository
|
||||||
|
*/
|
||||||
|
interface Repository<T> {
|
||||||
|
suspend fun findAll(): List<T>
|
||||||
|
suspend fun findAllByIds(id: List<String>): List<T>
|
||||||
|
suspend fun save(item: T): T
|
||||||
|
suspend fun delete(item: T): Boolean
|
||||||
|
}
|
Loading…
Reference in a new issue