Convert code to Kotlin

Convert ErrorResponse to Kotlin

Convert TwigsServerApplication to Kotlin

Convert BudgetController to Kotlin

Convert CategoryController to Kotlin

Convert UserController to Kotlin

Convert TransactionController to Kotlin

Convert Budget models to Kotlin

Convert Category models to Kotlin

Convert PasswordResetRequest models to Kotlin

Convert Transaction models to Kotlin

Convert user models to Kotlin

Convert permission models to Kotlin

Conver session models to Kotlin

Finish Kotlin conversions

Fix more Kotlin conversion issues

Remove swagger

Use Kotlin DSL for Gradle scripts

Signed-off-by: William Brawner <me@wbrawner.com>

Resolve some warnings

Move source to src/main/kotlin

Move source to src/main/kotlin
This commit is contained in:
William Brawner 2021-03-29 18:31:22 -07:00
parent 9b3b4fac17
commit 8e6454cb72
84 changed files with 1595 additions and 2538 deletions

View file

@ -1,31 +0,0 @@
apply plugin: 'java'
apply plugin: 'application'
apply plugin: "io.spring.dependency-management"
apply plugin: "org.springframework.boot"
repositories {
mavenLocal()
mavenCentral()
maven {
url = "http://repo.maven.apache.org/maven2"
}
}
dependencies {
implementation "org.springframework.boot:spring-boot-starter-data-jpa"
implementation "org.springframework.boot:spring-boot-starter-security"
implementation "org.springframework.session:spring-session-jdbc"
implementation "org.springframework.boot:spring-boot-starter-web"
runtimeOnly "mysql:mysql-connector-java:8.0.15"
testImplementation "org.springframework.boot:spring-boot-starter-test"
testImplementation "org.springframework.security:spring-security-test:5.1.5.RELEASE"
}
jar {
description = "twigs-server"
}
mainClassName = "com.wbrawner.budgetserver.TwigsServerApplication"
sourceCompatibility = 14
targetCompatibility = 14

43
api/build.gradle.kts Normal file
View file

@ -0,0 +1,43 @@
import java.net.URI
plugins {
java
kotlin("jvm")
id("org.springframework.boot")
}
apply(plugin = "io.spring.dependency-management")
repositories {
mavenLocal()
mavenCentral()
maven {
url = URI("http://repo.maven.apache.org/maven2")
}
}
val kotlinVersion: String by rootProject.extra
dependencies {
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.session:spring-session-jdbc")
implementation("org.springframework.boot:spring-boot-starter-web")
runtimeOnly("mysql:mysql-connector-java:8.0.15")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test:5.1.5.RELEASE")
}
description = "twigs-server"
val twigsMain = "com.wbrawner.budgetserver.TwigsServerApplication"
tasks.bootJar {
mainClassName = twigsMain
}
tasks.bootRun {
main = twigsMain
}

View file

@ -1,13 +0,0 @@
package com.wbrawner.budgetserver;
public class ErrorResponse {
private final String message;
public ErrorResponse(String message) {
this.message = message;
}
public String getMessage() {
return message;
}
}

View file

@ -1,13 +0,0 @@
package com.wbrawner.budgetserver;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class TwigsServerApplication {
public static void main(String[] args) {
SpringApplication.run(TwigsServerApplication.class, args);
}
}

View file

@ -1,66 +0,0 @@
package com.wbrawner.budgetserver;
import com.wbrawner.budgetserver.user.User;
import org.springframework.security.core.context.SecurityContextHolder;
import java.security.SecureRandom;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.Random;
public final class Utils {
private static final int[] CALENDAR_FIELDS = new int[]{
Calendar.MILLISECOND,
Calendar.SECOND,
Calendar.MINUTE,
Calendar.HOUR_OF_DAY,
Calendar.DATE
};
public static Date getFirstOfMonth() {
GregorianCalendar calendar = new GregorianCalendar();
for (int field : CALENDAR_FIELDS) {
calendar.set(field, calendar.getActualMinimum(field));
}
return calendar.getTime();
}
public static Date getEndOfMonth() {
GregorianCalendar calendar = new GregorianCalendar();
for (int field : CALENDAR_FIELDS) {
calendar.set(field, calendar.getActualMaximum(field));
}
return calendar.getTime();
}
public static Date twoWeeksFromNow() {
GregorianCalendar calendar = new GregorianCalendar();
calendar.add(Calendar.DATE, 14);
return calendar.getTime();
}
public static User getCurrentUser() {
Object user = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (user instanceof User) {
return (User) user;
}
return null;
}
private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
private static final Random random = new SecureRandom();
public static String randomString(int length) {
StringBuilder id = new StringBuilder();
for (int i = 0; i < length; i++) {
id.append(CHARACTERS.charAt(random.nextInt(CHARACTERS.length())));
}
return id.toString();
}
public static String randomId() {
return randomString(32);
}
}

View file

@ -1,82 +0,0 @@
package com.wbrawner.budgetserver.budget;
import com.wbrawner.budgetserver.category.Category;
import com.wbrawner.budgetserver.transaction.Transaction;
import javax.persistence.*;
import java.util.HashSet;
import java.util.Set;
import java.util.TreeSet;
import static com.wbrawner.budgetserver.Utils.randomId;
@Entity
public class Budget {
@Id
private String id = randomId();
private String name;
private String description;
private String currencyCode;
@OneToMany(mappedBy = "budget")
private final Set<Transaction> transactions = new TreeSet<>();
@OneToMany(mappedBy = "budget")
private final Set<Category> categories = new TreeSet<>();
@OneToMany(mappedBy = "budget")
private final Set<Transaction> users = new HashSet<>();
public Budget() {}
public Budget(String name, String description) {
this(name, description, "USD");
}
public Budget(String name, String description, String currencyCode) {
this.name = name;
this.description = description;
this.currencyCode = currencyCode;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getCurrencyCode() {
return currencyCode;
}
public void setCurrencyCode(String currencyCode) {
this.currencyCode = currencyCode;
}
public Set<Transaction> getTransactions() {
return transactions;
}
public Set<Category> getCategories() {
return categories;
}
public Set<Transaction> getUsers() {
return users;
}
}

View file

@ -1,11 +0,0 @@
package com.wbrawner.budgetserver.budget;
public class BudgetBalanceResponse {
public final String id;
public final long balance;
public BudgetBalanceResponse(String id, long balance) {
this.id = id;
this.balance = balance;
}
}

View file

@ -1,206 +0,0 @@
package com.wbrawner.budgetserver.budget;
import com.wbrawner.budgetserver.permission.Permission;
import com.wbrawner.budgetserver.permission.UserPermission;
import com.wbrawner.budgetserver.permission.UserPermissionRepository;
import com.wbrawner.budgetserver.transaction.TransactionRepository;
import com.wbrawner.budgetserver.user.User;
import com.wbrawner.budgetserver.user.UserRepository;
import org.slf4j.Logger;
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 javax.transaction.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
import static com.wbrawner.budgetserver.Utils.getCurrentUser;
@RestController
@RequestMapping(value = "/budgets")
@Transactional
public class BudgetController {
private final BudgetRepository budgetRepository;
private final TransactionRepository transactionRepository;
private final UserRepository userRepository;
private final UserPermissionRepository userPermissionsRepository;
private final Logger logger = LoggerFactory.getLogger(BudgetController.class);
public BudgetController(
BudgetRepository budgetRepository,
TransactionRepository transactionRepository,
UserRepository userRepository,
UserPermissionRepository userPermissionsRepository
) {
this.budgetRepository = budgetRepository;
this.transactionRepository = transactionRepository;
this.userRepository = userRepository;
this.userPermissionsRepository = userPermissionsRepository;
}
@GetMapping(value = "", produces = {MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<List<BudgetResponse>> getBudgets(Integer page, Integer count) {
User user = getCurrentUser();
if (user == null) {
return ResponseEntity.status(401).build();
}
List<BudgetResponse> budgets = userPermissionsRepository.findAllByUser(
getCurrentUser(),
PageRequest.of(
page != null ? page : 0,
count != null ? count : 1000
)
)
.stream()
.map(userPermission -> {
Budget budget = userPermission.getBudget();
if (budget == null) {
return null;
}
// Hibernate.initialize(budget);
return new BudgetResponse(budget, userPermissionsRepository.findAllByBudget(budget, null));
})
.collect(Collectors.toList());
return ResponseEntity.ok(budgets);
}
@GetMapping(value = "/{id}", produces = {MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<BudgetResponse> getBudget(@PathVariable String id) {
return getBudgetWithPermission(id, Permission.READ, (budget) ->
ResponseEntity.ok(new BudgetResponse(budget, userPermissionsRepository.findAllByBudget(budget, null)))
);
}
@GetMapping(value = "/{id}/balance", produces = {MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<BudgetBalanceResponse> getBudgetBalance(
@PathVariable String id,
@RequestParam(value = "from", required = false) String from,
@RequestParam(value = "to", required = false) String to
) {
return getBudgetWithPermission(id, Permission.READ, (budget) -> {
Instant fromInstant;
try {
fromInstant = Instant.parse(from);
} catch (Exception e) {
if (!(e instanceof NullPointerException))
logger.error("Failed to parse '" + from + "' to Instant for 'from' parameter", e);
fromInstant = Instant.ofEpochSecond(0);
}
Instant toInstant;
try {
toInstant = Instant.parse(to);
} catch (Exception e) {
if (!(e instanceof NullPointerException))
logger.error("Failed to parse '" + to + "' to Instant for 'to' parameter", e);
toInstant = Instant.now();
}
var balance = transactionRepository.sumBalanceByBudgetId(budget.getId(), fromInstant, toInstant);
return ResponseEntity.ok(new BudgetBalanceResponse(budget.getId(), balance));
});
}
@PostMapping(value = "", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<BudgetResponse> newBudget(@RequestBody BudgetRequest request) {
final var budget = budgetRepository.save(new Budget(request.name, request.description));
var users = request.getUsers()
.stream()
.map(userPermissionRequest -> {
var user = userRepository.findById(userPermissionRequest.getUser()).orElse(null);
if (user == null) {
return null;
}
return userPermissionsRepository.save(
new UserPermission(budget, user, userPermissionRequest.getPermission())
);
})
.collect(Collectors.toSet());
var currentUserIncluded = users.stream().anyMatch(userPermission ->
userPermission.getUser().getId().equals(getCurrentUser().getId())
);
if (!currentUserIncluded) {
users.add(
userPermissionsRepository.save(
new UserPermission(budget, getCurrentUser(), Permission.OWNER)
)
);
}
return ResponseEntity.ok(new BudgetResponse(budget, new ArrayList<>(users)));
}
@PutMapping(value = "/{id}", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<BudgetResponse> updateBudget(@PathVariable String id, @RequestBody BudgetRequest request) {
return getBudgetWithPermission(id, Permission.MANAGE, (budget) -> {
if (request.name != null) {
budget.setName(request.name);
}
if (request.description != null) {
budget.setDescription(request.description);
}
var users = new ArrayList<UserPermission>();
if (!request.getUsers().isEmpty()) {
request.getUsers().forEach(userPermissionRequest ->
userRepository.findById(userPermissionRequest.getUser()).ifPresent(requestedUser ->
users.add(userPermissionsRepository.save(
new UserPermission(
budget,
requestedUser,
userPermissionRequest.getPermission()
)
))
));
} else {
users.addAll(userPermissionsRepository.findAllByBudget(budget, null));
}
return ResponseEntity.ok(new BudgetResponse(budgetRepository.save(budget), users));
});
}
@DeleteMapping(value = "/{id}", produces = {MediaType.TEXT_PLAIN_VALUE})
public ResponseEntity<Void> deleteBudget(@PathVariable String id) {
return getBudgetWithPermission(id, Permission.MANAGE, (budget) -> {
budgetRepository.delete(budget);
return ResponseEntity.ok().build();
});
}
private <T> ResponseEntity<T> getBudgetWithPermission(
String budgetId,
Permission permission,
Function<Budget, ResponseEntity<T>> callback
) {
var user = getCurrentUser();
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
var userPermission = userPermissionsRepository.findByUserAndBudget_Id(user, budgetId).orElse(null);
if (userPermission == null) {
return ResponseEntity.notFound().build();
}
if (userPermission.getPermission().isNotAtLeast(permission)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
var budget = userPermission.getBudget();
if (budget == null) {
return ResponseEntity.notFound().build();
}
return callback.apply(budget);
}
}

View file

@ -1,6 +0,0 @@
package com.wbrawner.budgetserver.budget;
import org.springframework.data.repository.PagingAndSortingRepository;
public interface BudgetRepository extends PagingAndSortingRepository<Budget, String> {
}

View file

@ -1,30 +0,0 @@
package com.wbrawner.budgetserver.budget;
import com.wbrawner.budgetserver.permission.UserPermissionRequest;
import javax.validation.constraints.NotNull;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
public class BudgetRequest {
public final String name;
public final String description;
private final Set<UserPermissionRequest> users = new HashSet<>();
public BudgetRequest() {
// Required empty constructor
this("", "", Collections.emptySet());
}
public BudgetRequest(String name, String description, Set<UserPermissionRequest> users) {
this.name = name;
this.description = description;
this.users.addAll(users);
}
@NotNull
public Set<UserPermissionRequest> getUsers() {
return Set.copyOf(users);
}
}

View file

@ -1,38 +0,0 @@
package com.wbrawner.budgetserver.budget;
import com.wbrawner.budgetserver.permission.UserPermission;
import com.wbrawner.budgetserver.permission.UserPermissionResponse;
import com.wbrawner.budgetserver.user.User;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
public class BudgetResponse {
public final String id;
public final String name;
public final String description;
private final List<UserPermissionResponse> users;
public BudgetResponse(String id, String name, String description, List<UserPermissionResponse> users) {
this.id = id;
this.name = name;
this.description = description;
this.users = users;
}
public BudgetResponse(Budget budget, List<UserPermission> users) {
this(
Objects.requireNonNull(budget.getId()),
budget.getName(),
budget.getDescription(),
users.stream()
.map(UserPermissionResponse::new)
.collect(Collectors.toList())
);
}
public List<UserPermissionResponse> getUsers() {
return List.copyOf(users);
}
}

View file

@ -1,98 +0,0 @@
package com.wbrawner.budgetserver.category;
import com.wbrawner.budgetserver.budget.Budget;
import javax.persistence.*;
import java.util.UUID;
import static com.wbrawner.budgetserver.Utils.randomId;
@Entity
public class Category implements Comparable<Category> {
@Id
private final String id = randomId();
private String title;
private String description;
private long amount;
@JoinColumn(nullable = false)
@ManyToOne
private Budget budget;
private boolean expense;
@Column(nullable = false, columnDefinition = "boolean default false")
private boolean archived;
public Category() {
this(null, null, 0L, null, true);
}
public Category(
String title,
String description,
Long amount,
Budget budget,
boolean expense
) {
this.title = title;
this.description = description;
this.amount = amount;
this.budget = budget;
this.expense = expense;
}
@Override
public int compareTo(Category other) {
return title.compareTo(other.title);
}
public String getId() {
return id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public long getAmount() {
return amount;
}
public void setAmount(long amount) {
this.amount = amount;
}
public Budget getBudget() {
return budget;
}
public void setBudget(Budget budget) {
this.budget = budget;
}
public boolean isExpense() {
return expense;
}
public void setExpense(boolean expense) {
this.expense = expense;
}
public boolean isArchived() {
return archived;
}
public void setArchived(boolean archived) {
this.archived = archived;
}
}

View file

@ -1,19 +0,0 @@
package com.wbrawner.budgetserver.category;
public class CategoryBalanceResponse {
private final String id;
private final long balance;
public CategoryBalanceResponse(String id, long balance) {
this.id = id;
this.balance = balance;
}
public String getId() {
return id;
}
public long getBalance() {
return balance;
}
}

View file

@ -1,166 +0,0 @@
package com.wbrawner.budgetserver.category;
import com.wbrawner.budgetserver.ErrorResponse;
import com.wbrawner.budgetserver.permission.Permission;
import com.wbrawner.budgetserver.permission.UserPermission;
import com.wbrawner.budgetserver.permission.UserPermissionRepository;
import com.wbrawner.budgetserver.transaction.TransactionRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.transaction.Transactional;
import java.util.List;
import java.util.stream.Collectors;
import static com.wbrawner.budgetserver.Utils.getCurrentUser;
import static com.wbrawner.budgetserver.Utils.getFirstOfMonth;
@RestController
@RequestMapping(path = "/categories")
@Transactional
class CategoryController {
private final CategoryRepository categoryRepository;
private final TransactionRepository transactionRepository;
private final UserPermissionRepository userPermissionsRepository;
CategoryController(CategoryRepository categoryRepository,
TransactionRepository transactionRepository,
UserPermissionRepository userPermissionsRepository) {
this.categoryRepository = categoryRepository;
this.transactionRepository = transactionRepository;
this.userPermissionsRepository = userPermissionsRepository;
}
@GetMapping(path = "", produces = {MediaType.APPLICATION_JSON_VALUE})
ResponseEntity<List<CategoryResponse>> getCategories(
@RequestParam(name = "budgetIds", required = false) List<String> budgetIds,
@RequestParam(name = "isExpense", required = false) Boolean isExpense,
@RequestParam(name = "includeArchived", required = false) Boolean includeArchived,
@RequestParam(name = "count", required = false) Integer count,
@RequestParam(name = "page", required = false) Integer page,
@RequestParam(name = "false", required = false) String sortBy,
@RequestParam(name = "sortOrder", required = false) Sort.Direction sortOrder
) {
List<UserPermission> userPermissions;
if (budgetIds != null && !budgetIds.isEmpty()) {
userPermissions = userPermissionsRepository.findAllByUserAndBudget_IdIn(
getCurrentUser(),
budgetIds,
PageRequest.of(page != null ? page : 0, count != null ? count : 1000)
);
} else {
userPermissions = userPermissionsRepository.findAllByUser(getCurrentUser(), null);
}
var budgets = userPermissions.stream()
.map(UserPermission::getBudget)
.collect(Collectors.toList());
var pageRequest = PageRequest.of(
Math.min(0, page != null ? page - 1 : 0),
count != null ? count : 1000,
sortOrder != null ? sortOrder : Sort.Direction.ASC,
sortBy != null ? sortBy : "title"
);
Boolean archived = includeArchived == null || includeArchived == false ? false : null;
List<Category> categories = categoryRepository.findAllByBudgetIn(budgets, isExpense, archived, pageRequest);
return ResponseEntity.ok(
categories.stream()
.map(CategoryResponse::new)
.collect(Collectors.toList())
);
}
@GetMapping(path = "/{id}", produces = {MediaType.APPLICATION_JSON_VALUE})
ResponseEntity<CategoryResponse> getCategory(@PathVariable String id) {
var budgets = userPermissionsRepository.findAllByUser(getCurrentUser(), null)
.stream()
.map(UserPermission::getBudget)
.collect(Collectors.toList());
var category = categoryRepository.findByBudgetInAndId(budgets, id).orElse(null);
if (category == null) return ResponseEntity.notFound().build();
return ResponseEntity.ok(new CategoryResponse(category));
}
@GetMapping(path = "/{id}/balance", produces = {MediaType.APPLICATION_JSON_VALUE})
ResponseEntity<CategoryBalanceResponse> getCategoryBalance(@PathVariable String id) {
var budgets = userPermissionsRepository.findAllByUser(getCurrentUser(), null)
.stream()
.map(UserPermission::getBudget)
.collect(Collectors.toList());
var category = categoryRepository.findByBudgetInAndId(budgets, id).orElse(null);
if (category == null) {
return ResponseEntity.notFound().build();
}
var sum = transactionRepository.sumBalanceByCategoryId(category.getId(), getFirstOfMonth());
return ResponseEntity.ok(new CategoryBalanceResponse(category.getId(), sum));
}
@PostMapping(path = "", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE})
ResponseEntity<Object> newCategory(@RequestBody NewCategoryRequest request) {
var userResponse = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), request.getBudgetId())
.orElse(null);
if (userResponse == null) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid budget ID"));
}
if (userResponse.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
var budget = userResponse.getBudget();
return ResponseEntity.ok(new CategoryResponse(categoryRepository.save(new Category(
request.getTitle(),
request.getDescription(),
request.getAmount(),
budget,
request.getExpense()
))));
}
@PutMapping(path = "/{id}", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE})
ResponseEntity<CategoryResponse> updateCategory(@PathVariable String id, @RequestBody UpdateCategoryRequest request) {
var category = categoryRepository.findById(id).orElse(null);
if (category == null) return ResponseEntity.notFound().build();
var userPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), category.getBudget().getId()).orElse(null);
if (userPermission == null) return ResponseEntity.notFound().build();
if (userPermission.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
if (request.getTitle() != null) {
category.setTitle(request.getTitle());
}
if (request.getDescription() != null) {
category.setDescription(request.getDescription());
}
if (request.getAmount() != null) {
category.setAmount(request.getAmount());
}
if (request.getExpense() != null) {
category.setExpense(request.getExpense());
}
if (request.getArchived() != null) {
category.setArchived(request.getArchived());
}
return ResponseEntity.ok(new CategoryResponse(categoryRepository.save(category)));
}
@DeleteMapping(path = "/{id}", produces = {MediaType.TEXT_PLAIN_VALUE})
ResponseEntity<Void> deleteCategory(@PathVariable String id) {
var category = categoryRepository.findById(id).orElse(null);
if (category == null) return ResponseEntity.notFound().build();
var userPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), category.getBudget().getId()).orElse(null);
if (userPermission == null) return ResponseEntity.notFound().build();
if (userPermission.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
transactionRepository.findAllByBudgetAndCategory(userPermission.getBudget(), category)
.forEach(transaction -> {
transaction.setCategory(null);
transactionRepository.save(transaction);
});
categoryRepository.delete(category);
return ResponseEntity.ok().build();
}
}

View file

@ -1,23 +0,0 @@
package com.wbrawner.budgetserver.category;
import com.wbrawner.budgetserver.budget.Budget;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.util.List;
import java.util.Optional;
public interface CategoryRepository extends PagingAndSortingRepository<Category, String> {
List<Category> findAllByBudget(Budget budget, Pageable pageable);
@Query("SELECT c FROM Category c where c.budget IN (:budgets) AND (:expense IS NULL OR c.expense = :expense) AND (:archived IS NULL OR c.archived = :archived)")
List<Category> findAllByBudgetIn(List<Budget> budgets, Boolean expense, Boolean archived, Pageable pageable);
Optional<Category> findByBudgetInAndId(List<Budget> budgets, String id);
Optional<Category> findByBudgetAndId(Budget budget, String id);
List<Category> findAllByBudgetInAndIdIn(List<Budget> budgets, List<String> ids, Pageable pageable);
}

View file

@ -1,63 +0,0 @@
package com.wbrawner.budgetserver.category;
import java.util.Objects;
public class CategoryResponse {
private final String id;
private final String title;
private final String description;
private final long amount;
private final String budgetId;
private final boolean expense;
private final boolean archived;
public CategoryResponse(Category category) {
this(
Objects.requireNonNull(category.getId()),
category.getTitle(),
category.getDescription(),
category.getAmount(),
Objects.requireNonNull(category.getBudget()).getId(),
category.isExpense(),
category.isArchived()
);
}
public CategoryResponse(String id, String title, String description, long amount, String budgetId, boolean expense, boolean archived) {
this.id = id;
this.title = title;
this.description = description;
this.amount = amount;
this.budgetId = budgetId;
this.expense = expense;
this.archived = archived;
}
public String getId() {
return id;
}
public String getTitle() {
return title;
}
public String getDescription() {
return description;
}
public long getAmount() {
return amount;
}
public String getBudgetId() {
return budgetId;
}
public boolean isExpense() {
return expense;
}
public boolean isArchived() {
return archived;
}
}

View file

@ -1,41 +0,0 @@
package com.wbrawner.budgetserver.category;
public class NewCategoryRequest {
private final String title;
private final String description;
private final Long amount;
private final String budgetId;
private final Boolean expense;
public NewCategoryRequest() {
this(null, null, null, null, null);
}
public NewCategoryRequest(String title, String description, Long amount, String budgetId, Boolean expense) {
this.title = title;
this.description = description;
this.amount = amount;
this.budgetId = budgetId;
this.expense = expense;
}
public String getTitle() {
return title;
}
public String getDescription() {
return description;
}
public Long getAmount() {
return amount;
}
public String getBudgetId() {
return budgetId;
}
public Boolean getExpense() {
return expense;
}
}

View file

@ -1,41 +0,0 @@
package com.wbrawner.budgetserver.category;
public class UpdateCategoryRequest {
private final String title;
private final String description;
private final Long amount;
private final Boolean expense;
private final Boolean archived;
public UpdateCategoryRequest() {
this(null, null, null, null, null);
}
public UpdateCategoryRequest(String title, String description, Long amount, Boolean expense, Boolean archived) {
this.title = title;
this.description = description;
this.amount = amount;
this.expense = expense;
this.archived = archived;
}
public String getTitle() {
return title;
}
public String getDescription() {
return description;
}
public Long getAmount() {
return amount;
}
public Boolean getExpense() {
return expense;
}
public Boolean getArchived() {
return archived;
}
}

View file

@ -1,32 +0,0 @@
package com.wbrawner.budgetserver.config;
import com.wbrawner.budgetserver.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
public class JdbcUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Autowired
public JdbcUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserDetails userDetails;
userDetails = userRepository.findByUsername(username).orElse(null);
if (userDetails != null) {
return userDetails;
}
userDetails = userRepository.findByEmail(username).orElse(null);
if (userDetails != null) {
return userDetails;
}
throw new UsernameNotFoundException("Unable to find user with username $username");
}
}

View file

@ -1,10 +0,0 @@
package com.wbrawner.budgetserver.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)
public class MethodSecurity extends GlobalMethodSecurityConfiguration {
}

View file

@ -1,118 +0,0 @@
package com.wbrawner.budgetserver.config;
import com.wbrawner.budgetserver.passwordresetrequest.PasswordResetRequestRepository;
import com.wbrawner.budgetserver.session.UserSessionRepository;
import com.wbrawner.budgetserver.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 javax.sql.DataSource;
import java.util.Arrays;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final Environment env;
private final DataSource datasource;
private final UserSessionRepository userSessionRepository;
private final UserRepository userRepository;
private final PasswordResetRequestRepository passwordResetRequestRepository;
private final JdbcUserDetailsService userDetailsService;
private final Environment environment;
public SecurityConfig(Environment env,
DataSource datasource,
UserSessionRepository userSessionRepository,
UserRepository userRepository,
PasswordResetRequestRepository passwordResetRequestRepository,
JdbcUserDetailsService userDetailsService,
Environment environment) {
this.env = env;
this.datasource = datasource;
this.userSessionRepository = userSessionRepository;
this.userRepository = userRepository;
this.passwordResetRequestRepository = passwordResetRequestRepository;
this.userDetailsService = userDetailsService;
this.environment = environment;
}
@Bean
public JdbcUserDetailsManager getUserDetailsManager() {
var userDetailsManager = new JdbcUserDetailsManager();
userDetailsManager.setDataSource(datasource);
return userDetailsManager;
}
@Bean
public DaoAuthenticationProvider getAuthenticationProvider() {
var authProvider = new TokenAuthenticationProvider(userSessionRepository, userRepository);
authProvider.setPasswordEncoder(getPasswordEncoder());
authProvider.setUserDetailsService(userDetailsService);
return authProvider;
}
@Bean
public PasswordEncoder getPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
public void configure(AuthenticationManagerBuilder auth) {
auth.authenticationProvider(getAuthenticationProvider());
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/users/new", "/users/login")
.permitAll()
.anyRequest()
.authenticated()
.and()
.httpBasic()
.authenticationEntryPoint(new SilentAuthenticationEntryPoint())
.and()
.cors()
.configurationSource(request -> {
var corsConfig = new CorsConfiguration();
corsConfig.applyPermitDefaultValues();
var corsDomains = environment.getProperty("twigs.cors.domains", "*");
corsConfig.setAllowedOrigins(Arrays.asList(corsDomains.split(",")));
corsConfig.setAllowedMethods(
Stream.of(
HttpMethod.GET,
HttpMethod.POST,
HttpMethod.PUT,
HttpMethod.DELETE,
HttpMethod.OPTIONS
)
.map(Enum::name)
.collect(Collectors.toList())
);
corsConfig.setAllowCredentials(true);
return corsConfig;
})
.and()
.csrf()
.disable()
.addFilter(new TokenAuthenticationFilter(authenticationManager()))
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}

View file

@ -1,20 +0,0 @@
package com.wbrawner.budgetserver.config;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
public class SessionAuthenticationToken extends UsernamePasswordAuthenticationToken {
/**
* 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
*/
public SessionAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(principal, credentials, authorities);
}
}

View file

@ -1,19 +0,0 @@
package com.wbrawner.budgetserver.config;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* Used to avoid browser prompts for authentication
*/
public class SilentAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
}
}

View file

@ -1,32 +0,0 @@
package com.wbrawner.budgetserver.config;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
public class TokenAuthenticationFilter extends BasicAuthenticationFilter {
public TokenAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
var authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
var token = authHeader.substring(7);
var authentication = getAuthenticationManager().authenticate(new SessionAuthenticationToken(null, token, Collections.emptyList()));
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
}

View file

@ -1,56 +0,0 @@
package com.wbrawner.budgetserver.config;
import com.wbrawner.budgetserver.session.UserSessionRepository;
import com.wbrawner.budgetserver.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.Date;
import static com.wbrawner.budgetserver.Utils.twoWeeksFromNow;
public class TokenAuthenticationProvider extends DaoAuthenticationProvider {
private final UserSessionRepository userSessionRepository;
private final UserRepository userRepository;
public TokenAuthenticationProvider(UserSessionRepository userSessionRepository, UserRepository userRepository) {
this.userSessionRepository = userSessionRepository;
this.userRepository = userRepository;
}
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (!(authentication instanceof SessionAuthenticationToken)) {
// Additional checks aren't needed since they've already been handled
super.additionalAuthenticationChecks(userDetails, authentication);
}
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if (authentication instanceof SessionAuthenticationToken) {
var session = userSessionRepository.findByToken((String) authentication.getCredentials());
if (session.isEmpty() || session.get().getExpiration().before(new Date())) {
throw new BadCredentialsException("Credentials expired");
}
var user = userRepository.findById(session.get().getUserId());
if (user.isEmpty()) {
throw new InternalAuthenticationServiceException("Failed to find user for token");
}
new Thread(() -> {
// Update the session on a background thread to avoid holding up the request longer than necessary
var updatedSession = session.get();
updatedSession.setExpiration(twoWeeksFromNow());
userSessionRepository.save(updatedSession);
}).start();
return new SessionAuthenticationToken(user.get(), authentication.getCredentials(), authentication.getAuthorities());
} else {
return super.authenticate(authentication);
}
}
}

View file

@ -1,38 +0,0 @@
package com.wbrawner.budgetserver.passwordresetrequest;
import com.wbrawner.budgetserver.user.User;
import javax.persistence.*;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.UUID;
import static com.wbrawner.budgetserver.Utils.randomId;
@Entity
public class PasswordResetRequest {
@Id
private final String id = randomId();
@ManyToOne
private final User user;
private final Calendar date;
private final String token;
public PasswordResetRequest() {
this(null);
}
public PasswordResetRequest(User user) {
this(user, new GregorianCalendar(), randomId());
}
public PasswordResetRequest(
User user,
Calendar date,
String token
) {
this.user = user;
this.date = date;
this.token = token;
}
}

View file

@ -1,6 +0,0 @@
package com.wbrawner.budgetserver.passwordresetrequest;
import org.springframework.data.repository.PagingAndSortingRepository;
public interface PasswordResetRequestRepository extends PagingAndSortingRepository<PasswordResetRequest, Long> {
}

View file

@ -1,24 +0,0 @@
package com.wbrawner.budgetserver.permission;
public enum 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;
public boolean isNotAtLeast(Permission wanted) {
return ordinal() < wanted.ordinal();
}
}

View file

@ -1,54 +0,0 @@
package com.wbrawner.budgetserver.permission;
import com.wbrawner.budgetserver.budget.Budget;
import com.wbrawner.budgetserver.user.User;
import javax.persistence.*;
@Entity
public class UserPermission {
@EmbeddedId
private UserPermissionKey id;
@ManyToOne
@MapsId("budgetId")
@JoinColumn(nullable = false, name = "budget_id")
private Budget budget;
@ManyToOne
@MapsId("userId")
@JoinColumn(nullable = false, name = "user_id")
private User user;
@JoinColumn(nullable = false)
@Enumerated(EnumType.STRING)
private Permission permission;
public UserPermission() {
this(null, null, null, null);
}
public UserPermission(Budget budget, User user, Permission permission) {
this(new UserPermissionKey(budget.getId(), user.getId()), budget, user, permission);
}
public UserPermission(UserPermissionKey userPermissionKey, Budget budget, User user, Permission permission) {
this.id = userPermissionKey;
this.budget = budget;
this.user = user;
this.permission = permission;
}
public UserPermissionKey getId() {
return id;
}
public Budget getBudget() {
return budget;
}
public User getUser() {
return user;
}
public Permission getPermission() {
return permission;
}
}

View file

@ -1,19 +0,0 @@
package com.wbrawner.budgetserver.permission;
import javax.persistence.Embeddable;
import java.io.Serializable;
@Embeddable
public class UserPermissionKey implements Serializable {
private final String budgetId;
private final String userId;
public UserPermissionKey() {
this(null, null);
}
public UserPermissionKey(String budgetId, String userId) {
this.budgetId = budgetId;
this.userId = userId;
}
}

View file

@ -1,21 +0,0 @@
package com.wbrawner.budgetserver.permission;
import com.wbrawner.budgetserver.budget.Budget;
import com.wbrawner.budgetserver.user.User;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.util.List;
import java.util.Optional;
public interface UserPermissionRepository extends PagingAndSortingRepository<UserPermission, UserPermissionKey> {
Optional<UserPermission> findByUserAndBudget_Id(User user, String budgetId);
List<UserPermission> findAllByUser(User user, Pageable pageable);
List<UserPermission> findAllByBudget(Budget budget, Pageable pageable);
List<UserPermission> findAllByUserAndBudget(User user, Budget budget, Pageable pageable);
List<UserPermission> findAllByUserAndBudget_IdIn(User user, List<String> budgetIds, Pageable pageable);
}

View file

@ -1,23 +0,0 @@
package com.wbrawner.budgetserver.permission;
public class UserPermissionRequest {
private final String user;
private final Permission permission;
public UserPermissionRequest() {
this(null, Permission.READ);
}
public UserPermissionRequest(String user, Permission permission) {
this.user = user;
this.permission = permission;
}
public String getUser() {
return user;
}
public Permission getPermission() {
return permission;
}
}

View file

@ -1,25 +0,0 @@
package com.wbrawner.budgetserver.permission;
import com.wbrawner.budgetserver.user.UserResponse;
public class UserPermissionResponse {
private final UserResponse user;
private final Permission permission;
public UserPermissionResponse(UserPermission userPermission) {
this(new UserResponse(userPermission.getUser()), userPermission.getPermission());
}
public UserPermissionResponse(UserResponse userResponse, Permission permission) {
this.user = userResponse;
this.permission = permission;
}
public UserResponse getUser() {
return user;
}
public Permission getPermission() {
return permission;
}
}

View file

@ -1,44 +0,0 @@
package com.wbrawner.budgetserver.session;
import javax.persistence.Entity;
import javax.persistence.Id;
import java.util.Date;
import static com.wbrawner.budgetserver.Utils.*;
@Entity
public class Session {
@Id
private final String id = randomId();
private final String userId;
private final String token = randomString(255);
private Date expiration = twoWeeksFromNow();
public Session() {
this("");
}
public Session(String userId) {
this.userId = userId;
}
public String getId() {
return id;
}
public String getUserId() {
return userId;
}
public String getToken() {
return token;
}
public Date getExpiration() {
return expiration;
}
public void setExpiration(Date expiration) {
this.expiration = expiration;
}
}

View file

@ -1,22 +0,0 @@
package com.wbrawner.budgetserver.session;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
public class SessionCleanupTask {
private final UserSessionRepository sessionRepository;
@Autowired
public SessionCleanupTask(UserSessionRepository sessionRepository) {
this.sessionRepository = sessionRepository;
}
@Scheduled(cron = "0 0 * * * *")
public void cleanup() {
sessionRepository.deleteAllByExpirationBefore(new Date());
}
}

View file

@ -1,25 +0,0 @@
package com.wbrawner.budgetserver.session;
import java.util.Date;
public class SessionResponse {
private final String token;
private final String expiration;
public SessionResponse(Session session) {
this(session.getToken(), session.getExpiration());
}
public SessionResponse(String token, Date expiration) {
this.token = token;
this.expiration = expiration.toInstant().toString();
}
public String getToken() {
return token;
}
public String getExpiration() {
return expiration;
}
}

View file

@ -1,17 +0,0 @@
package com.wbrawner.budgetserver.session;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.util.Date;
import java.util.List;
import java.util.Optional;
public interface UserSessionRepository extends PagingAndSortingRepository<Session, String> {
List<Session> findByUserId(String userId);
Optional<Session> findByToken(String token);
Optional<Session> findByUserIdAndToken(String userId, String token);
void deleteAllByExpirationBefore(Date expiration);
}

View file

@ -1,53 +0,0 @@
package com.wbrawner.budgetserver.transaction;
class NewTransactionRequest {
private final String title;
private final String description;
private final String date;
private final Long amount;
private final String categoryId;
private final Boolean expense;
private final String budgetId;
NewTransactionRequest() {
this(null, null, null, null, null, null, null);
}
NewTransactionRequest(String title, String description, String date, Long amount, String categoryId, Boolean expense, String budgetId) {
this.title = title;
this.description = description;
this.date = date;
this.amount = amount;
this.categoryId = categoryId;
this.expense = expense;
this.budgetId = budgetId;
}
public String getTitle() {
return title;
}
public String getDescription() {
return description;
}
public String getDate() {
return date;
}
public Long getAmount() {
return amount;
}
public String getCategoryId() {
return categoryId;
}
public Boolean getExpense() {
return expense;
}
public String getBudgetId() {
return budgetId;
}
}

View file

@ -1,122 +0,0 @@
package com.wbrawner.budgetserver.transaction;
import com.wbrawner.budgetserver.budget.Budget;
import com.wbrawner.budgetserver.category.Category;
import com.wbrawner.budgetserver.user.User;
import javax.persistence.*;
import java.time.Instant;
import static com.wbrawner.budgetserver.Utils.randomId;
@Entity
public class Transaction implements Comparable<Transaction> {
@Id
private final String id = randomId();
@ManyToOne
@JoinColumn(nullable = false)
private final User createdBy;
private String title;
private String description;
private Instant date;
private Long amount;
@ManyToOne
private Category category;
private Boolean expense;
@ManyToOne
@JoinColumn(nullable = false)
private Budget budget;
public Transaction() {
this(null, null, null, null, null, null, null, null);
}
public Transaction(String title,
String description,
Instant date,
Long amount,
Category category,
Boolean expense,
User createdBy,
Budget budget) {
this.title = title;
this.description = description;
this.date = date;
this.amount = amount;
this.category = category;
this.expense = expense;
this.createdBy = createdBy;
this.budget = budget;
}
public String getId() {
// This should only be set from Hibernate so it shouldn't actually be null ever
//noinspection ConstantConditions
return id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Instant getDate() {
return date;
}
public void setDate(Instant date) {
this.date = date;
}
public Long getAmount() {
return amount;
}
public void setAmount(Long amount) {
this.amount = amount;
}
public Category getCategory() {
return category;
}
public void setCategory(Category category) {
this.category = category;
}
public Boolean getExpense() {
return expense;
}
public void setExpense(Boolean expense) {
this.expense = expense;
}
public User getCreatedBy() {
return createdBy;
}
public Budget getBudget() {
return budget;
}
public void setBudget(Budget budget) {
this.budget = budget;
}
@Override
public int compareTo(Transaction other) {
return this.date.compareTo(other.date);
}
}

View file

@ -1,209 +0,0 @@
package com.wbrawner.budgetserver.transaction;
import com.wbrawner.budgetserver.ErrorResponse;
import com.wbrawner.budgetserver.category.Category;
import com.wbrawner.budgetserver.category.CategoryRepository;
import com.wbrawner.budgetserver.permission.Permission;
import com.wbrawner.budgetserver.permission.UserPermission;
import com.wbrawner.budgetserver.permission.UserPermissionRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.transaction.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.stream.Collectors;
import static com.wbrawner.budgetserver.Utils.*;
@RestController
@RequestMapping(path = "/transactions")
@Transactional
public class TransactionController {
private final CategoryRepository categoryRepository;
private final TransactionRepository transactionRepository;
private final UserPermissionRepository userPermissionsRepository;
private final Logger logger = LoggerFactory.getLogger(TransactionController.class);
public TransactionController(CategoryRepository categoryRepository,
TransactionRepository transactionRepository,
UserPermissionRepository userPermissionsRepository) {
this.categoryRepository = categoryRepository;
this.transactionRepository = transactionRepository;
this.userPermissionsRepository = userPermissionsRepository;
}
@GetMapping(path = "", produces = {MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<List<TransactionResponse>> getTransactions(
@RequestParam(value = "categoryIds", required = false) List<String> categoryIds,
@RequestParam(value = "budgetIds", required = false) List<String> budgetIds,
@RequestParam(value = "from", required = false) String from,
@RequestParam(value = "to", required = false) String to,
@RequestParam(value = "count", required = false) Integer count,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "sortBy", required = false) String sortBy,
@RequestParam(value = "sortOrder", required = false) Sort.Direction sortOrder
) {
List<UserPermission> userPermissions;
if (budgetIds != null && !budgetIds.isEmpty()) {
userPermissions = userPermissionsRepository.findAllByUserAndBudget_IdIn(
getCurrentUser(),
budgetIds,
PageRequest.of(page != null ? page : 0, count != null ? count : 1000)
);
} else {
userPermissions = userPermissionsRepository.findAllByUser(getCurrentUser(), null);
}
var budgets = userPermissions.stream()
.map(UserPermission::getBudget)
.collect(Collectors.toList());
List<Category> categories = null;
if (categoryIds != null && !categoryIds.isEmpty()) {
categories = categoryRepository.findAllByBudgetInAndIdIn(budgets, categoryIds, null);
}
var pageRequest = PageRequest.of(
Math.min(0, page != null ? page - 1 : 0),
count != null ? count : 1000,
sortOrder != null ? sortOrder : Sort.Direction.DESC,
sortBy != null ? sortBy : "date"
);
Instant fromInstant;
try {
fromInstant = Instant.parse(from);
} catch (Exception e) {
if (!(e instanceof NullPointerException))
logger.error("Failed to parse '" + from + "' to Instant for 'from' parameter", e);
fromInstant = getFirstOfMonth().toInstant();
}
Instant toInstant;
try {
toInstant = Instant.parse(to);
} catch (Exception e) {
if (!(e instanceof NullPointerException))
logger.error("Failed to parse '" + to + "' to Instant for 'to' parameter", e);
toInstant = getEndOfMonth().toInstant();
}
var query = categories == null ? transactionRepository.findAllByBudgetInAndDateGreaterThanAndDateLessThan(
budgets,
fromInstant,
toInstant,
pageRequest
) : transactionRepository.findAllByBudgetInAndCategoryInAndDateGreaterThanAndDateLessThan(
budgets,
categories,
fromInstant,
toInstant,
pageRequest
);
var transactions = query
.stream()
.map(TransactionResponse::new)
.collect(Collectors.toList());
return ResponseEntity.ok(transactions);
}
@GetMapping(path = "/{id}", produces = {MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<TransactionResponse> getTransaction(@PathVariable String id) {
var budgets = userPermissionsRepository.findAllByUser(getCurrentUser(), null)
.stream()
.map(UserPermission::getBudget)
.collect(Collectors.toList());
var transaction = transactionRepository.findByIdAndBudgetIn(id, budgets).orElse(null);
if (transaction == null) return ResponseEntity.notFound().build();
return ResponseEntity.ok(new TransactionResponse(transaction));
}
@PostMapping(path = "", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<Object> newTransaction(@RequestBody NewTransactionRequest request) {
var userResponse = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), request.getBudgetId())
.orElse(null);
if (userResponse == null) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid budget ID"));
}
if (userResponse.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
var budget = userResponse.getBudget();
Category category = null;
if (request.getCategoryId() != null) {
category = categoryRepository.findByBudgetAndId(budget, request.getCategoryId()).orElse(null);
}
return ResponseEntity.ok(new TransactionResponse(transactionRepository.save(new Transaction(
request.getTitle(),
request.getDescription(),
Instant.parse(request.getDate()),
request.getAmount(),
category,
request.getExpense(),
getCurrentUser(),
budget
))));
}
@PutMapping(path = "/{id}", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<Object> updateTransaction(@PathVariable String id, @RequestBody UpdateTransactionRequest request) {
var transaction = transactionRepository.findById(id).orElse(null);
if (transaction == null) return ResponseEntity.notFound().build();
var userPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), transaction.getBudget().getId()).orElse(null);
if (userPermission == null) return ResponseEntity.notFound().build();
if (userPermission.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
if (request.getTitle() != null) {
transaction.setTitle(request.getTitle());
}
if (request.getDescription() != null) {
transaction.setDescription(request.getDescription());
}
if (request.getDate() != null) {
transaction.setDate(Instant.parse(request.getDate()));
}
if (request.getAmount() != null) {
transaction.setAmount(request.getAmount());
}
if (request.getExpense() != null) {
transaction.setExpense(request.getExpense());
}
if (request.getBudgetId() != null) {
var newUserPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), request.getBudgetId()).orElse(null);
if (newUserPermission == null || newUserPermission.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity
.badRequest()
.body(new ErrorResponse("Invalid budget"));
}
transaction.setBudget(newUserPermission.getBudget());
}
if (request.getCategoryId() != null) {
var category = categoryRepository.findByBudgetAndId(transaction.getBudget(), request.getCategoryId()).orElse(null);
if (category == null) {
return ResponseEntity
.badRequest()
.body(new ErrorResponse("Invalid category"));
}
transaction.setCategory(category);
}
return ResponseEntity.ok(new TransactionResponse(transactionRepository.save(transaction)));
}
@DeleteMapping(path = "/{id}", produces = {MediaType.TEXT_PLAIN_VALUE})
public ResponseEntity<Void> deleteTransaction(@PathVariable String id) {
var transaction = transactionRepository.findById(id).orElse(null);
if (transaction == null) return ResponseEntity.notFound().build();
// Check that the transaction belongs to an budget that the user has access to before deleting it
var userPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), transaction.getBudget().getId()).orElse(null);
if (userPermission == null) return ResponseEntity.notFound().build();
if (userPermission.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
transactionRepository.delete(transaction);
return ResponseEntity.ok().build();
}
}

View file

@ -1,45 +0,0 @@
package com.wbrawner.budgetserver.transaction;
import com.wbrawner.budgetserver.budget.Budget;
import com.wbrawner.budgetserver.category.Category;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import java.util.Optional;
public interface TransactionRepository extends PagingAndSortingRepository<Transaction, String> {
Optional<Transaction> findByIdAndBudgetIn(String id, List<Budget> budgets);
List<Transaction> findAllByBudgetInAndCategoryInAndDateGreaterThanAndDateLessThan(
List<Budget> budgets,
List<Category> categories,
Instant start,
Instant end,
Pageable pageable
);
List<Transaction> findAllByBudgetInAndDateGreaterThanAndDateLessThan(
List<Budget> budgets,
Instant start,
Instant end,
Pageable pageable
);
List<Transaction> findAllByBudgetAndCategory(Budget budget, Category category);
@Query(
nativeQuery = true,
value = "SELECT (COALESCE((SELECT SUM(amount) from transaction WHERE Budget_id = :BudgetId AND expense = 0 AND date >= :from AND date <= :to), 0)) - (COALESCE((SELECT SUM(amount) from transaction WHERE Budget_id = :BudgetId AND expense = 1 AND date >= :from AND date <= :to), 0));"
)
Long sumBalanceByBudgetId(String BudgetId, Instant from, Instant to);
@Query(
nativeQuery = true,
value = "SELECT (COALESCE((SELECT SUM(amount) from transaction WHERE category_id = :categoryId AND expense = 0 AND date > :start), 0)) - (COALESCE((SELECT SUM(amount) from transaction WHERE category_id = :categoryId AND expense = 1 AND date > :start), 0));"
)
Long sumBalanceByCategoryId(String categoryId, Date start);
}

View file

@ -1,83 +0,0 @@
package com.wbrawner.budgetserver.transaction;
class TransactionResponse {
private final String id;
private final String title;
private final String description;
private final String date;
private final Long amount;
private final Boolean expense;
private final String budgetId;
private final String categoryId;
private final String createdBy;
TransactionResponse(String id,
String title,
String description,
String date,
Long amount,
Boolean expense,
String budgetId,
String categoryId,
String createdBy) {
this.id = id;
this.title = title;
this.description = description;
this.date = date;
this.amount = amount;
this.expense = expense;
this.budgetId = budgetId;
this.categoryId = categoryId;
this.createdBy = createdBy;
}
TransactionResponse(Transaction transaction) {
this(
transaction.getId(),
transaction.getTitle(),
transaction.getDescription(),
transaction.getDate().toString(),
transaction.getAmount(),
transaction.getExpense(),
transaction.getBudget().getId(),
transaction.getCategory() != null ? transaction.getCategory().getId() : null,
transaction.getCreatedBy().getId()
);
}
public String getId() {
return id;
}
public String getTitle() {
return title;
}
public String getDescription() {
return description;
}
public String getDate() {
return date;
}
public Long getAmount() {
return amount;
}
public Boolean getExpense() {
return expense;
}
public String getBudgetId() {
return budgetId;
}
public String getCategoryId() {
return categoryId;
}
public String getCreatedBy() {
return createdBy;
}
}

View file

@ -1,59 +0,0 @@
package com.wbrawner.budgetserver.transaction;
class UpdateTransactionRequest {
private final String title;
private final String description;
private final String date;
private final Long amount;
private final String categoryId;
private final Boolean expense;
private final String budgetId;
private final String createdBy;
UpdateTransactionRequest() {
this(null, null, null, null, null, null, null, null);
}
UpdateTransactionRequest(String title, String description, String date, Long amount, String categoryId, Boolean expense, String budgetId, String createdBy) {
this.title = title;
this.description = description;
this.date = date;
this.amount = amount;
this.categoryId = categoryId;
this.expense = expense;
this.budgetId = budgetId;
this.createdBy = createdBy;
}
public String getTitle() {
return title;
}
public String getDescription() {
return description;
}
public String getDate() {
return date;
}
public Long getAmount() {
return amount;
}
public String getCategoryId() {
return categoryId;
}
public Boolean getExpense() {
return expense;
}
public String getBudgetId() {
return budgetId;
}
public String getCreatedBy() {
return createdBy;
}
}

View file

@ -1,23 +0,0 @@
package com.wbrawner.budgetserver.user;
public class LoginRequest {
private final String username;
private final String password;
public LoginRequest() {
this(null, null);
}
public LoginRequest(String username, String password) {
this.username = username;
this.password = password;
}
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
}

View file

@ -1,29 +0,0 @@
package com.wbrawner.budgetserver.user;
public class NewUserRequest {
private final String username;
private final String password;
private final String email;
public NewUserRequest() {
this(null, null, null);
}
public NewUserRequest(String username, String password, String email) {
this.username = username;
this.password = password;
this.email = email;
}
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
public String getEmail() {
return email;
}
}

View file

@ -1,29 +0,0 @@
package com.wbrawner.budgetserver.user;
public class UpdateUserRequest {
private final String username;
private final String password;
private final String email;
public UpdateUserRequest() {
this(null, null, null);
}
public UpdateUserRequest(String username, String password, String email) {
this.username = username;
this.password = password;
this.email = email;
}
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
public String getEmail() {
return email;
}
}

View file

@ -1,96 +0,0 @@
package com.wbrawner.budgetserver.user;
import org.springframework.lang.NonNull;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Transient;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import static com.wbrawner.budgetserver.Utils.randomId;
@Entity
public class User implements UserDetails {
@Id
private final String id = randomId();
@Transient
private final List<GrantedAuthority> authorities = Collections.singletonList(new SimpleGrantedAuthority("USER"));
private String username;
private String password;
private String email;
public User() {
this(null, null, null);
}
public User(String username, String password, String email) {
this.username = username;
this.password = password;
this.email = email;
}
@NonNull
public String getId() {
// This shouldn't ever need to be set manually, only through Hibernate
//noinspection ConstantConditions
return id;
}
@Override
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
@Override
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
}

View file

@ -1,157 +0,0 @@
package com.wbrawner.budgetserver.user;
import com.wbrawner.budgetserver.ErrorResponse;
import com.wbrawner.budgetserver.budget.BudgetRepository;
import com.wbrawner.budgetserver.permission.UserPermissionRepository;
import com.wbrawner.budgetserver.permission.UserPermissionResponse;
import com.wbrawner.budgetserver.session.Session;
import com.wbrawner.budgetserver.session.SessionResponse;
import com.wbrawner.budgetserver.session.UserSessionRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
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.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import javax.transaction.Transactional;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import static com.wbrawner.budgetserver.Utils.getCurrentUser;
@RestController
@RequestMapping("/users")
@Transactional
public class UserController {
private final BudgetRepository budgetRepository;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final UserPermissionRepository userPermissionsRepository;
private final UserSessionRepository userSessionRepository;
private final DaoAuthenticationProvider authenticationProvider;
@Autowired
public UserController(BudgetRepository budgetRepository,
UserRepository userRepository,
UserSessionRepository userSessionRepository,
PasswordEncoder passwordEncoder,
UserPermissionRepository userPermissionsRepository,
DaoAuthenticationProvider authenticationProvider) {
this.budgetRepository = budgetRepository;
this.userRepository = userRepository;
this.userSessionRepository = userSessionRepository;
this.passwordEncoder = passwordEncoder;
this.userPermissionsRepository = userPermissionsRepository;
this.authenticationProvider = authenticationProvider;
}
@GetMapping(path = "", produces = {MediaType.APPLICATION_JSON_VALUE})
ResponseEntity<List<UserPermissionResponse>> getUsers(String budgetId) {
var budget = budgetRepository.findById(budgetId).orElse(null);
if (budget == null) {
return ResponseEntity.notFound().build();
}
var userPermissions = userPermissionsRepository.findAllByBudget(budget, null);
var userInBudget = userPermissions.stream()
.anyMatch(userPermission ->
userPermission.getUser().getId().equals(getCurrentUser().getId()));
if (!userInBudget) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(userPermissions.stream().map(UserPermissionResponse::new).collect(Collectors.toList()));
}
@PostMapping(path = "/login", produces = {MediaType.APPLICATION_JSON_VALUE})
ResponseEntity<SessionResponse> login(@RequestBody LoginRequest request) {
var authReq = new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword());
Authentication auth;
try {
auth = authenticationProvider.authenticate(authReq);
} catch (AuthenticationException e) {
return ResponseEntity.notFound().build();
}
SecurityContextHolder.getContext().setAuthentication(auth);
var user = Objects.requireNonNull(getCurrentUser());
var session = userSessionRepository.save(new Session(user.getId()));
return ResponseEntity.ok(new SessionResponse(session));
}
@GetMapping(path = "/me", produces = {MediaType.APPLICATION_JSON_VALUE})
ResponseEntity<UserResponse> getProfile() {
var user = getCurrentUser();
if (user == null) return ResponseEntity.status(401).build();
return ResponseEntity.ok(new UserResponse(user));
}
@GetMapping(path = "/search", produces = {MediaType.APPLICATION_JSON_VALUE})
ResponseEntity<List<UserResponse>> searchUsers(String query) {
return ResponseEntity.ok(
userRepository.findByUsernameContains(query)
.stream()
.map(UserResponse::new)
.collect(Collectors.toList())
);
}
@GetMapping(path = "/{id}")
ResponseEntity<UserResponse> getUser(@PathVariable String id) {
var user = userRepository.findById(id).orElse(null);
if (user == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(new UserResponse(user));
}
@PostMapping(path = "", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE})
ResponseEntity<Object> newUser(@RequestBody NewUserRequest request) {
if (userRepository.findByUsername(request.getUsername()).isPresent())
return ResponseEntity.badRequest().body(new ErrorResponse("Username taken"));
if (userRepository.findByEmail(request.getEmail()).isPresent())
return ResponseEntity.badRequest().body(new ErrorResponse("Email taken"));
if (request.getPassword().isBlank())
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid password"));
return ResponseEntity.ok(new UserResponse(userRepository.save(new User(
request.getUsername(),
passwordEncoder.encode(request.getPassword()),
request.getEmail()
))));
}
@PutMapping(path = "/{id}", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE})
ResponseEntity<Object> updateUser(@PathVariable Long id, @RequestBody UpdateUserRequest request) {
if (!getCurrentUser().getId().equals(id)) return ResponseEntity.status(403).build();
var user = userRepository.findById(getCurrentUser().getId()).orElse(null);
if (user == null) return ResponseEntity.notFound().build();
if (request.getUsername() != null) {
if (userRepository.findByUsername(request.getUsername()).isPresent())
return ResponseEntity.badRequest().body(new ErrorResponse("Username taken"));
user.setUsername(request.getUsername());
}
if (request.getEmail() != null) {
if (userRepository.findByEmail(request.getEmail()).isPresent())
return ResponseEntity.badRequest().body(new ErrorResponse("Email taken"));
user.setEmail(request.getEmail());
}
if (request.getPassword() != null) {
if (request.getPassword().isBlank())
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid password"));
user.setPassword(passwordEncoder.encode(request.getPassword()));
}
return ResponseEntity.ok(new UserResponse(userRepository.save(user)));
}
@DeleteMapping(path = "/{id}", produces = {MediaType.TEXT_PLAIN_VALUE})
ResponseEntity<Void> deleteUser(@PathVariable String id) {
if (!getCurrentUser().getId().equals(id)) return ResponseEntity.status(403).build();
userRepository.deleteById(id);
return ResponseEntity.ok().build();
}
}

View file

@ -1,16 +0,0 @@
package com.wbrawner.budgetserver.user;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.util.List;
import java.util.Optional;
public interface UserRepository extends PagingAndSortingRepository<User, String> {
Optional<User> findByUsername(String username);
Optional<User> findByUsernameAndPassword(String username, String password);
List<User> findByUsernameContains(String username);
Optional<User> findByEmail(String email);
}

View file

@ -1,29 +0,0 @@
package com.wbrawner.budgetserver.user;
public class UserResponse {
private final String id;
private final String username;
private final String email;
public UserResponse(User user) {
this(user.getId(), user.getUsername(), user.getEmail());
}
public UserResponse(String id, String username, String email) {
this.id = id;
this.username = username;
this.email = email;
}
public String getId() {
return id;
}
public String getUsername() {
return username;
}
public String getEmail() {
return email;
}
}

View file

@ -0,0 +1,3 @@
package com.wbrawner.budgetserver
data class ErrorResponse(val message: String)

View file

@ -0,0 +1,16 @@
package com.wbrawner.budgetserver
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)
}
}
}

View file

@ -0,0 +1,74 @@
package com.wbrawner.budgetserver
import com.wbrawner.budgetserver.budget.Budget
import com.wbrawner.budgetserver.permission.Permission
import com.wbrawner.budgetserver.permission.UserPermissionRepository
import com.wbrawner.budgetserver.transaction.TransactionRepository
import com.wbrawner.budgetserver.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!!)
}

View file

@ -0,0 +1,49 @@
package com.wbrawner.budgetserver.budget
import com.wbrawner.budgetserver.category.Category
import com.wbrawner.budgetserver.permission.UserPermission
import com.wbrawner.budgetserver.permission.UserPermissionRequest
import com.wbrawner.budgetserver.permission.UserPermissionResponse
import com.wbrawner.budgetserver.randomString
import com.wbrawner.budgetserver.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)

View file

@ -0,0 +1,165 @@
package com.wbrawner.budgetserver.budget
import com.wbrawner.budgetserver.currentUser
import com.wbrawner.budgetserver.permission.Permission
import com.wbrawner.budgetserver.permission.UserPermission
import com.wbrawner.budgetserver.permission.UserPermissionRepository
import com.wbrawner.budgetserver.permission.UserPermissionRequest
import com.wbrawner.budgetserver.transaction.TransactionRepository
import com.wbrawner.budgetserver.user.User
import com.wbrawner.budgetserver.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)
}
}

View file

@ -0,0 +1,5 @@
package com.wbrawner.budgetserver.budget
import org.springframework.data.repository.PagingAndSortingRepository
interface BudgetRepository : PagingAndSortingRepository<Budget, String>

View file

@ -0,0 +1,58 @@
package com.wbrawner.budgetserver.category
import com.wbrawner.budgetserver.budget.Budget
import com.wbrawner.budgetserver.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(
val title: String,
val description: String? = null,
val amount: Long,
val budgetId: String,
val expense: Boolean
)
data class UpdateCategoryRequest(
val title: String? = null,
val description: String? = null,
val amount: Long? = null,
val expense: Boolean? = null,
val archived: Boolean? = null
)
data class CategoryResponse(
val id: String,
val title: String,
val description: String?,
val amount: Long,
val budgetId: String,
val isExpense: Boolean,
val isArchived: Boolean
) {
constructor(category: Category) : this(
category.id,
category.title,
category.description,
category.amount,
category.budget!!.id,
category.expense,
category.archived
)
}
data class CategoryBalanceResponse(val id: String, val balance: Long)

View file

@ -0,0 +1,171 @@
package com.wbrawner.budgetserver.category
import com.wbrawner.budgetserver.ErrorResponse
import com.wbrawner.budgetserver.currentUser
import com.wbrawner.budgetserver.firstOfMonth
import com.wbrawner.budgetserver.permission.Permission
import com.wbrawner.budgetserver.permission.UserPermission
import com.wbrawner.budgetserver.permission.UserPermissionRepository
import com.wbrawner.budgetserver.transaction.Transaction
import com.wbrawner.budgetserver.transaction.TransactionRepository
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import java.util.function.Consumer
import java.util.stream.Collectors
import javax.transaction.Transactional
@RestController
@RequestMapping(path = ["/categories"])
@Transactional
open class CategoryController(
private val categoryRepository: CategoryRepository,
private val transactionRepository: TransactionRepository,
private val userPermissionsRepository: UserPermissionRepository
) {
@GetMapping(path = [""], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun getCategories(
@RequestParam(name = "budgetIds", required = false) budgetIds: List<String?>?,
@RequestParam(name = "isExpense", required = false) isExpense: Boolean?,
@RequestParam(name = "includeArchived", required = false) includeArchived: Boolean?,
@RequestParam(name = "count", required = false) count: Int?,
@RequestParam(name = "page", required = false) page: Int?,
@RequestParam(name = "false", required = false) sortBy: String?,
@RequestParam(name = "sortOrder", required = false) sortOrder: Sort.Direction?
): ResponseEntity<List<CategoryResponse>> {
val userPermissions: List<UserPermission>
userPermissions = if (budgetIds != null && !budgetIds.isEmpty()) {
userPermissionsRepository.findAllByUserAndBudget_IdIn(
currentUser,
budgetIds,
PageRequest.of(page ?: 0, count ?: 1000)
)
} else {
userPermissionsRepository.findAllByUser(currentUser, null)
}
val budgets = userPermissions.stream()
.map { obj: UserPermission -> obj.budget }
.collect(Collectors.toList())
val pageRequest = PageRequest.of(
Math.min(0, if (page != null) page - 1 else 0),
count ?: 1000,
sortOrder ?: Sort.Direction.ASC,
sortBy ?: "title"
)
val archived = if (includeArchived == null || includeArchived == false) false else null
val categories = categoryRepository.findAllByBudgetIn(budgets, isExpense, archived, pageRequest)
return ResponseEntity.ok(
categories.stream()
.map { category: Category -> CategoryResponse(category) }
.collect(Collectors.toList())
)
}
@GetMapping(path = ["/{id}"], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun getCategory(@PathVariable id: String?): ResponseEntity<CategoryResponse> {
val budgets = userPermissionsRepository.findAllByUser(currentUser, null)
.stream()
.map { obj: UserPermission -> obj.budget }
.collect(Collectors.toList())
val category = categoryRepository.findByBudgetInAndId(budgets, id).orElse(null)
?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(CategoryResponse(category))
}
@GetMapping(path = ["/{id}/balance"], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun getCategoryBalance(@PathVariable id: String?): ResponseEntity<CategoryBalanceResponse> {
val budgets = userPermissionsRepository.findAllByUser(currentUser, null)
.stream()
.map { obj: UserPermission -> obj.budget }
.collect(Collectors.toList())
val category = categoryRepository.findByBudgetInAndId(budgets, id).orElse(null)
?: return ResponseEntity.notFound().build()
val sum = transactionRepository.sumBalanceByCategoryId(category.id, firstOfMonth)
return ResponseEntity.ok(CategoryBalanceResponse(category.id, sum))
}
@PostMapping(
path = [""],
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = [MediaType.APPLICATION_JSON_VALUE]
)
open fun newCategory(@RequestBody request: NewCategoryRequest): ResponseEntity<Any> {
val userResponse = userPermissionsRepository.findByUserAndBudget_Id(currentUser, request.budgetId)
.orElse(null) ?: return ResponseEntity.badRequest().body(ErrorResponse("Invalid budget ID"))
if (userResponse.permission.isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
val budget = userResponse.budget
return ResponseEntity.ok(
CategoryResponse(
categoryRepository.save(
Category(
title = request.title,
description = request.description,
amount = request.amount,
budget = budget,
expense = request.expense
)
)
)
)
}
@PutMapping(
path = ["/{id}"],
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = [MediaType.APPLICATION_JSON_VALUE]
)
open fun updateCategory(
@PathVariable id: String,
@RequestBody request: UpdateCategoryRequest
): ResponseEntity<CategoryResponse> {
val category = categoryRepository.findById(id).orElse(null)
?: return ResponseEntity.notFound().build()
val userPermission = userPermissionsRepository.findByUserAndBudget_Id(
currentUser,
category.budget!!.id
).orElse(null)
?: return ResponseEntity.notFound().build()
if (userPermission.permission.isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
if (request.title != null) {
category.title = request.title
}
if (request.description != null) {
category.description = request.description
}
if (request.amount != null) {
category.amount = request.amount
}
if (request.expense != null) {
category.expense = request.expense
}
if (request.archived != null) {
category.archived = request.archived
}
return ResponseEntity.ok(CategoryResponse(categoryRepository.save(category)))
}
@DeleteMapping(path = ["/{id}"], produces = [MediaType.TEXT_PLAIN_VALUE])
open fun deleteCategory(@PathVariable id: String): ResponseEntity<Void> {
val category = categoryRepository.findById(id).orElse(null) ?: return ResponseEntity.notFound().build()
val userPermission =
userPermissionsRepository.findByUserAndBudget_Id(currentUser, category.budget!!.id).orElse(null)
?: return ResponseEntity.notFound().build()
if (userPermission.permission.isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
transactionRepository.findAllByBudgetAndCategory(userPermission.budget, category)
.forEach(Consumer { transaction: Transaction ->
transaction.category = null
transactionRepository.save(transaction)
})
categoryRepository.delete(category)
return ResponseEntity.ok().build()
}
}

View file

@ -0,0 +1,23 @@
package com.wbrawner.budgetserver.category
import com.wbrawner.budgetserver.budget.Budget
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.PagingAndSortingRepository
import java.util.*
interface CategoryRepository : PagingAndSortingRepository<Category, String> {
fun findAllByBudget(budget: Budget?, pageable: Pageable?): List<Category>
@Query("SELECT c FROM Category c where c.budget IN (:budgets) AND (:expense IS NULL OR c.expense = :expense) AND (:archived IS NULL OR c.archived = :archived)")
fun findAllByBudgetIn(
budgets: List<Budget?>?,
expense: Boolean?,
archived: Boolean?,
pageable: Pageable?
): List<Category>
fun findByBudgetInAndId(budgets: List<Budget?>?, id: String?): Optional<Category>
fun findByBudgetAndId(budget: Budget?, id: String?): Optional<Category>
fun findAllByBudgetInAndIdIn(budgets: List<Budget?>?, ids: List<String?>?, pageable: Pageable?): List<Category>
}

View file

@ -0,0 +1,25 @@
package com.wbrawner.budgetserver.config
import com.wbrawner.budgetserver.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")
}
}

View file

@ -0,0 +1,9 @@
package com.wbrawner.budgetserver.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()

View file

@ -0,0 +1,92 @@
package com.wbrawner.budgetserver.config
import com.wbrawner.budgetserver.passwordresetrequest.PasswordResetRequestRepository
import com.wbrawner.budgetserver.session.UserSessionRepository
import com.wbrawner.budgetserver.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)
}
}

View file

@ -0,0 +1,18 @@
package com.wbrawner.budgetserver.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)

View file

@ -0,0 +1,22 @@
package com.wbrawner.budgetserver.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)
}
}

View file

@ -0,0 +1,26 @@
package com.wbrawner.budgetserver.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)
}
}

View file

@ -0,0 +1,53 @@
package com.wbrawner.budgetserver.config
import com.wbrawner.budgetserver.session.UserSessionRepository
import com.wbrawner.budgetserver.twoWeeksFromNow
import com.wbrawner.budgetserver.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)
}
}
}

View file

@ -0,0 +1,17 @@
package com.wbrawner.budgetserver.passwordresetrequest
import com.wbrawner.budgetserver.randomString
import com.wbrawner.budgetserver.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()
)

View file

@ -0,0 +1,5 @@
package com.wbrawner.budgetserver.passwordresetrequest
import org.springframework.data.repository.PagingAndSortingRepository
interface PasswordResetRequestRepository : PagingAndSortingRepository<PasswordResetRequest, Long>

View file

@ -0,0 +1,87 @@
package com.wbrawner.budgetserver.permission
import com.wbrawner.budgetserver.budget.Budget
import com.wbrawner.budgetserver.user.User
import com.wbrawner.budgetserver.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
)
}

View file

@ -0,0 +1,15 @@
package com.wbrawner.budgetserver.permission
import com.wbrawner.budgetserver.budget.Budget
import com.wbrawner.budgetserver.user.User
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.PagingAndSortingRepository
import java.util.*
interface UserPermissionRepository : PagingAndSortingRepository<UserPermission, UserPermissionKey> {
fun findByUserAndBudget_Id(user: User?, budgetId: String?): Optional<UserPermission>
fun findAllByUser(user: User?, pageable: Pageable?): List<UserPermission>
fun findAllByBudget(budget: Budget?, pageable: Pageable?): List<UserPermission>
fun findAllByUserAndBudget(user: User?, budget: Budget?, pageable: Pageable?): List<UserPermission>
fun findAllByUserAndBudget_IdIn(user: User?, budgetIds: List<String?>?, pageable: Pageable?): List<UserPermission>
}

View file

@ -0,0 +1,18 @@
package com.wbrawner.budgetserver.session
import com.wbrawner.budgetserver.randomString
import com.wbrawner.budgetserver.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())
}

View file

@ -0,0 +1,14 @@
package com.wbrawner.budgetserver.session
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import java.util.*
@Component
open class SessionCleanupTask @Autowired constructor(private val sessionRepository: UserSessionRepository) {
@Scheduled(cron = "0 0 * * * *")
open fun cleanup() {
sessionRepository.deleteAllByExpirationBefore(Date())
}
}

View file

@ -0,0 +1,11 @@
package com.wbrawner.budgetserver.session
import org.springframework.data.repository.PagingAndSortingRepository
import java.util.*
interface UserSessionRepository : PagingAndSortingRepository<Session, String> {
fun findByUserId(userId: String?): List<Session?>?
fun findByToken(token: String?): Optional<Session?>?
fun findByUserIdAndToken(userId: String?, token: String?): Optional<Session?>?
fun deleteAllByExpirationBefore(expiration: Date?)
}

View file

@ -0,0 +1,70 @@
package com.wbrawner.budgetserver.transaction
import com.wbrawner.budgetserver.budget.Budget
import com.wbrawner.budgetserver.category.Category
import com.wbrawner.budgetserver.randomString
import com.wbrawner.budgetserver.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(
val title: String,
val description: String? = null,
val date: String,
val amount: Long,
val categoryId: String? = null,
val expense: Boolean,
val budgetId: String
)
data class UpdateTransactionRequest(
val title: String? = null,
val description: String? = null,
val date: String? = null,
val amount: Long? = null,
val categoryId: String? = null,
val expense: Boolean? = null,
val budgetId: String? = null,
val createdBy: String? = null
)
data class TransactionResponse(
val id: String,
val title: String?,
val description: String?,
val date: String,
val amount: Long?,
val expense: Boolean?,
val budgetId: String,
val categoryId: String?,
val createdBy: String
) {
constructor(transaction: Transaction) : this(
transaction.id,
transaction.title,
transaction.description,
transaction.date.toString(),
transaction.amount,
transaction.expense,
transaction.budget!!.id,
transaction.category?.id,
transaction.createdBy!!.id
)
}

View file

@ -0,0 +1,209 @@
package com.wbrawner.budgetserver.transaction
import com.wbrawner.budgetserver.ErrorResponse
import com.wbrawner.budgetserver.category.Category
import com.wbrawner.budgetserver.category.CategoryRepository
import com.wbrawner.budgetserver.currentUser
import com.wbrawner.budgetserver.endOfMonth
import com.wbrawner.budgetserver.firstOfMonth
import com.wbrawner.budgetserver.permission.Permission
import com.wbrawner.budgetserver.permission.UserPermission
import com.wbrawner.budgetserver.permission.UserPermissionRepository
import org.slf4j.LoggerFactory
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
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.stream.Collectors
import javax.transaction.Transactional
import kotlin.math.min
@RestController
@RequestMapping(path = ["/transactions"])
@Transactional
open class TransactionController(
private val categoryRepository: CategoryRepository,
private val transactionRepository: TransactionRepository,
private val userPermissionsRepository: UserPermissionRepository
) {
private val logger = LoggerFactory.getLogger(TransactionController::class.java)
@GetMapping(path = [""], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun getTransactions(
@RequestParam(value = "categoryIds", required = false) categoryIds: List<String>?,
@RequestParam(value = "budgetIds", required = false) budgetIds: List<String>?,
@RequestParam(value = "from", required = false) from: String?,
@RequestParam(value = "to", required = false) to: String?,
@RequestParam(value = "count", required = false) count: Int?,
@RequestParam(value = "page", required = false) page: Int?,
@RequestParam(value = "sortBy", required = false) sortBy: String?,
@RequestParam(value = "sortOrder", required = false) sortOrder: Sort.Direction?
): ResponseEntity<List<TransactionResponse>> {
val userPermissions: List<UserPermission> = if (budgetIds != null && budgetIds.isNotEmpty()) {
userPermissionsRepository.findAllByUserAndBudget_IdIn(
currentUser,
budgetIds,
PageRequest.of(page ?: 0, count ?: 1000)
)
} else {
userPermissionsRepository.findAllByUser(currentUser, null)
}
val budgets = userPermissions.stream()
.map { obj: UserPermission -> obj.budget }
.collect(Collectors.toList())
var categories: List<Category?>? = null
if (categoryIds != null && categoryIds.isNotEmpty()) {
categories = categoryRepository.findAllByBudgetInAndIdIn(budgets, categoryIds, null)
}
val pageRequest = PageRequest.of(
min(0, if (page != null) page - 1 else 0),
count ?: 1000,
sortOrder ?: Sort.Direction.DESC,
sortBy ?: "date"
)
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)
firstOfMonth.toInstant()
}
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)
endOfMonth.toInstant()
}
val query = if (categories == null) {
transactionRepository.findAllByBudgetInAndDateGreaterThanAndDateLessThan(
budgets,
fromInstant,
toInstant,
pageRequest
)
} else {
transactionRepository.findAllByBudgetInAndCategoryInAndDateGreaterThanAndDateLessThan(
budgets,
categories,
fromInstant,
toInstant,
pageRequest
)
}
val transactions = query.map { transaction: Transaction -> TransactionResponse(transaction) }
return ResponseEntity.ok(transactions)
}
@GetMapping(path = ["/{id}"], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun getTransaction(@PathVariable id: String?): ResponseEntity<TransactionResponse> {
val budgets = userPermissionsRepository.findAllByUser(currentUser, null)
.stream()
.map { obj: UserPermission -> obj.budget }
.collect(Collectors.toList())
val transaction = transactionRepository.findByIdAndBudgetIn(id, budgets).orElse(null)
?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(TransactionResponse(transaction))
}
@PostMapping(
path = [""],
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = [MediaType.APPLICATION_JSON_VALUE]
)
open fun newTransaction(@RequestBody request: NewTransactionRequest): ResponseEntity<Any> {
val userResponse = userPermissionsRepository.findByUserAndBudget_Id(currentUser, request.budgetId)
.orElse(null) ?: return ResponseEntity.badRequest().body(ErrorResponse("Invalid budget ID"))
if (userResponse.permission.isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
val budget = userResponse.budget
var category: Category? = null
if (request.categoryId != null) {
category = categoryRepository.findByBudgetAndId(budget, request.categoryId).orElse(null)
}
return ResponseEntity.ok(
TransactionResponse(
transactionRepository.save(
Transaction(
title = request.title,
description = request.description,
date = Instant.parse(request.date),
amount = request.amount,
category = category,
expense = request.expense,
createdBy = currentUser,
budget = budget
)
)
)
)
}
@PutMapping(
path = ["/{id}"],
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = [MediaType.APPLICATION_JSON_VALUE]
)
open fun updateTransaction(
@PathVariable id: String,
@RequestBody request: UpdateTransactionRequest
): ResponseEntity<Any> {
val transaction = transactionRepository.findById(id).orElse(null)
?: return ResponseEntity.notFound().build()
userPermissionsRepository.findByUserAndBudget_Id(
currentUser,
transaction.budget!!.id
).orElse(null)
?: return ResponseEntity.notFound().build()
if (request.title != null) {
transaction.title = request.title
}
if (request.description != null) {
transaction.description = request.description
}
if (request.date != null) {
transaction.date = Instant.parse(request.date)
}
if (request.amount != null) {
transaction.amount = request.amount
}
if (request.expense != null) {
transaction.expense = request.expense
}
if (request.budgetId != null) {
val newUserPermission =
userPermissionsRepository.findByUserAndBudget_Id(currentUser, request.budgetId).orElse(null)
if (newUserPermission == null || newUserPermission.permission.isNotAtLeast(Permission.WRITE)) {
return ResponseEntity
.badRequest()
.body(ErrorResponse("Invalid budget"))
}
transaction.budget = newUserPermission.budget
}
if (request.categoryId != null) {
val category = categoryRepository.findByBudgetAndId(transaction.budget, request.categoryId).orElse(null)
?: return ResponseEntity
.badRequest()
.body(ErrorResponse("Invalid category"))
transaction.category = category
}
return ResponseEntity.ok(TransactionResponse(transactionRepository.save(transaction)))
}
@DeleteMapping(path = ["/{id}"], produces = [MediaType.TEXT_PLAIN_VALUE])
open fun deleteTransaction(@PathVariable id: String): ResponseEntity<Void> {
val transaction = transactionRepository.findById(id).orElse(null) ?: return ResponseEntity.notFound().build()
// Check that the transaction belongs to an budget that the user has access to before deleting it
val userPermission = userPermissionsRepository.findByUserAndBudget_Id(
currentUser,
transaction.budget!!.id
).orElse(null)
?: return ResponseEntity.notFound().build()
if (userPermission.permission.isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
transactionRepository.delete(transaction)
return ResponseEntity.ok().build()
}
}

View file

@ -0,0 +1,41 @@
package com.wbrawner.budgetserver.transaction
import com.wbrawner.budgetserver.budget.Budget
import com.wbrawner.budgetserver.category.Category
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.PagingAndSortingRepository
import java.time.Instant
import java.util.*
interface TransactionRepository : PagingAndSortingRepository<Transaction, String> {
fun findByIdAndBudgetIn(id: String?, budgets: List<Budget?>?): Optional<Transaction>
fun findAllByBudgetInAndCategoryInAndDateGreaterThanAndDateLessThan(
budgets: List<Budget?>?,
categories: List<Category?>?,
start: Instant?,
end: Instant?,
pageable: Pageable?
): List<Transaction>
fun findAllByBudgetInAndDateGreaterThanAndDateLessThan(
budgets: List<Budget?>?,
start: Instant?,
end: Instant?,
pageable: Pageable?
): List<Transaction>
fun findAllByBudgetAndCategory(budget: Budget?, category: Category?): List<Transaction>
@Query(
nativeQuery = true,
value = "SELECT (COALESCE((SELECT SUM(amount) from transaction WHERE Budget_id = :BudgetId AND expense = 0 AND date >= :from AND date <= :to), 0)) - (COALESCE((SELECT SUM(amount) from transaction WHERE Budget_id = :BudgetId AND expense = 1 AND date >= :from AND date <= :to), 0));"
)
fun sumBalanceByBudgetId(BudgetId: String?, from: Instant?, to: Instant?): Long
@Query(
nativeQuery = true,
value = "SELECT (COALESCE((SELECT SUM(amount) from transaction WHERE category_id = :categoryId AND expense = 0 AND date > :start), 0)) - (COALESCE((SELECT SUM(amount) from transaction WHERE category_id = :categoryId AND expense = 1 AND date > :start), 0));"
)
fun sumBalanceByCategoryId(categoryId: String?, start: Date?): Long
}

View file

@ -0,0 +1,66 @@
package com.wbrawner.budgetserver.user
import com.wbrawner.budgetserver.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)
}

View file

@ -0,0 +1,140 @@
package com.wbrawner.budgetserver.user
import com.wbrawner.budgetserver.ErrorResponse
import com.wbrawner.budgetserver.budget.BudgetRepository
import com.wbrawner.budgetserver.currentUser
import com.wbrawner.budgetserver.permission.UserPermission
import com.wbrawner.budgetserver.permission.UserPermissionRepository
import com.wbrawner.budgetserver.permission.UserPermissionResponse
import com.wbrawner.budgetserver.session.Session
import com.wbrawner.budgetserver.session.SessionResponse
import com.wbrawner.budgetserver.session.UserSessionRepository
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
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.context.SecurityContextHolder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.web.bind.annotation.*
import javax.transaction.Transactional
@RestController
@RequestMapping("/users")
@Transactional
open class UserController @Autowired constructor(
private val budgetRepository: BudgetRepository,
private val userRepository: UserRepository,
private val userSessionRepository: UserSessionRepository,
private val passwordEncoder: PasswordEncoder,
private val userPermissionsRepository: UserPermissionRepository,
private val authenticationProvider: DaoAuthenticationProvider
) {
@GetMapping(path = [""], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun getUsers(budgetId: String): ResponseEntity<List<UserPermissionResponse>> {
val budget = budgetRepository.findById(budgetId).orElse(null)
?: return ResponseEntity.notFound().build()
val userPermissions = userPermissionsRepository.findAllByBudget(budget, null)
val userInBudget = userPermissions.stream()
.anyMatch { userPermission: UserPermission -> userPermission.user!!.id == currentUser!!.id }
return if (!userInBudget) {
ResponseEntity.notFound().build()
} else ResponseEntity.ok(
userPermissions.map { userPermission: UserPermission -> UserPermissionResponse(userPermission) }
)
}
@PostMapping(path = ["/login"], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun login(@RequestBody request: LoginRequest): ResponseEntity<SessionResponse> {
val authReq = UsernamePasswordAuthenticationToken(request.username, request.password)
val auth: Authentication
auth = try {
authenticationProvider.authenticate(authReq)
} catch (e: AuthenticationException) {
return ResponseEntity.notFound().build()
}
SecurityContextHolder.getContext().authentication = auth
val session = userSessionRepository.save(Session(currentUser!!.id))
return ResponseEntity.ok(SessionResponse(session))
}
@GetMapping(path = ["/me"], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun getProfile(): ResponseEntity<UserResponse> {
val user = currentUser
?: return ResponseEntity.status(401).build()
return ResponseEntity.ok(UserResponse(user))
}
@GetMapping(path = ["/search"], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun searchUsers(query: String?): ResponseEntity<List<UserResponse>> {
return ResponseEntity.ok(
userRepository.findByNameContains(query)
.map { user: User -> UserResponse(user) }
)
}
@GetMapping(path = ["/{id}"])
open fun getUser(@PathVariable id: String): ResponseEntity<UserResponse> {
val user = userRepository.findById(id).orElse(null) ?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(UserResponse(user))
}
@PostMapping(
path = [""],
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = [MediaType.APPLICATION_JSON_VALUE]
)
fun newUser(@RequestBody request: NewUserRequest): ResponseEntity<Any> {
if (userRepository.findByName(request.username).isPresent) return ResponseEntity.badRequest()
.body(ErrorResponse("Username taken"))
if (userRepository.findByEmail(request.email).isPresent) return ResponseEntity.badRequest()
.body(ErrorResponse("Email taken"))
return if (request.password.isBlank()) ResponseEntity.badRequest()
.body(ErrorResponse("Invalid password")) else ResponseEntity.ok(
UserResponse(
userRepository.save(
User(
name = request.username,
passphrase = passwordEncoder.encode(request.password),
email = request.email
)
)
)
)
}
@PutMapping(
path = ["/{id}"],
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = [MediaType.APPLICATION_JSON_VALUE]
)
fun updateUser(@PathVariable id: String, @RequestBody request: UpdateUserRequest): ResponseEntity<Any> {
if (currentUser?.id != id) return ResponseEntity.status(403).build()
var user = userRepository.findById(currentUser!!.id).orElse(null)
?: return ResponseEntity.notFound().build()
if (request.username != null) {
if (userRepository.findByName(request.username).isPresent) return ResponseEntity.badRequest()
.body(ErrorResponse("Username taken"))
user = user.copy(name = request.username)
}
if (request.email != null) {
if (userRepository.findByEmail(request.email).isPresent) return ResponseEntity.badRequest()
.body(ErrorResponse("Email taken"))
user = user.copy(email = request.email)
}
if (request.password != null) {
if (request.password.isBlank()) return ResponseEntity.badRequest().body(ErrorResponse("Invalid password"))
user = user.copy(passphrase = passwordEncoder.encode(request.password))
}
return ResponseEntity.ok(UserResponse(userRepository.save(user)))
}
@DeleteMapping(path = ["/{id}"], produces = [MediaType.TEXT_PLAIN_VALUE])
fun deleteUser(@PathVariable id: String): ResponseEntity<Void> {
if (currentUser?.id != id) return ResponseEntity.status(403).build()
userRepository.deleteById(id)
return ResponseEntity.ok().build()
}
}

View file

@ -0,0 +1,10 @@
package com.wbrawner.budgetserver.user
import org.springframework.data.repository.PagingAndSortingRepository
import java.util.*
interface UserRepository : PagingAndSortingRepository<User, String> {
fun findByName(name: String?): Optional<User>
fun findByNameContains(name: String?): List<User>
fun findByEmail(email: String?): Optional<User>
}

View file

@ -1,29 +0,0 @@
buildscript {
repositories {
mavenLocal()
mavenCentral()
maven { url "http://repo.spring.io/snapshot" }
maven { url "http://repo.spring.io/milestone" }
}
dependencies {
classpath "org.springframework.boot:spring-boot-gradle-plugin:2.2.2.RELEASE"
}
}
apply plugin: 'java'
allprojects {
repositories {
mavenLocal()
mavenCentral()
maven { url "http://repo.spring.io/snapshot" }
maven { url "http://repo.spring.io/milestone" }
maven { url "http://repo.maven.apache.org/maven2" }
}
jar {
group = "com.wbrawner"
archiveVersion.set("0.0.1-SNAPSHOT")
}
}

37
build.gradle.kts Normal file
View file

@ -0,0 +1,37 @@
import java.net.URI
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
buildscript {
val kotlinVersion: String by extra("1.5.10")
repositories {
mavenLocal()
mavenCentral()
maven { url = java.net.URI("https://repo.spring.io/snapshot") }
maven { url = java.net.URI("https://repo.spring.io/milestone") }
}
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
classpath("org.springframework.boot:spring-boot-gradle-plugin:2.2.4.RELEASE")
}
}
plugins {
java
kotlin("jvm") version "1.5.10"
}
allprojects {
repositories {
mavenLocal()
mavenCentral()
maven {
url = URI("http://repo.maven.apache.org/maven2")
}
}
group = "com.wbrawner"
version = "0.0.1-SNAPSHOT"
tasks.withType<KotlinCompile> {
kotlinOptions.jvmTarget = "14"
}
}

View file

@ -1,5 +1,5 @@
#Fri Feb 07 18:11:46 CST 2020
distributionUrl=https\://services.gradle.org/distributions/gradle-6.4-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-all.zip
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStorePath=wrapper/dists

View file

@ -1,6 +0,0 @@
/*
* This file was generated by the Gradle 'init' task.
*/
rootProject.name = 'twigs'
include ':api'

2
settings.gradle.kts Normal file
View file

@ -0,0 +1,2 @@
rootProject.name = "twigs"
include(":api")