Compare commits

...

9 commits
main ... java

Author SHA1 Message Date
eff486898f More ktor parity work 2023-08-05 20:59:27 -06:00
e84dedf675 fixup! Implement /api/transactions/sum 2023-07-17 21:29:25 -06:00
c97c9b2c67 Implement /api/transactions/sum
This endpoint is only included for legacy support. Ideally, the client would just pull the list of transactions and calculate the sum locally for faster results.
2023-07-17 21:25:38 -06:00
95ca0ec9fc Update categories routes to match Ktor 2023-06-05 11:38:03 -06:00
89b1b6ccc7 Update budgets routes to match Ktor 2023-05-02 21:22:16 -06:00
09ad68a528 Update users routes to match Ktor 2023-04-25 20:02:39 -06:00
6f68065b95 Upgrade to Spring Boot 3
Signed-off-by: William Brawner <me@wbrawner.com>
2023-03-04 09:03:36 -07:00
41466a1134 Bump gradle wrapper version 2023-03-02 20:51:13 -07:00
808945cd78 Change package name to com.wbrawner.twigs 2023-03-02 20:50:17 -07:00
64 changed files with 969 additions and 538 deletions

View file

@ -1,31 +1,18 @@
apply plugin: 'java' plugins {
apply plugin: 'application' id 'java'
apply plugin: "io.spring.dependency-management" id 'application'
apply plugin: "org.springframework.boot" id "io.spring.dependency-management"
id "org.springframework.boot"
repositories {
mavenLocal()
mavenCentral()
maven {
url = "http://repo.maven.apache.org/maven2"
}
} }
dependencies { dependencies {
implementation project(':core')
implementation "org.springframework.boot:spring-boot-starter-data-jpa" 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" implementation "org.springframework.boot:spring-boot-starter-web"
runtimeOnly "mysql:mysql-connector-java:8.0.15" implementation "org.springframework.boot:spring-boot-starter-security"
testImplementation "org.springframework.boot:spring-boot-starter-test" testImplementation "org.springframework.boot:spring-boot-starter-test"
testImplementation "org.springframework.security:spring-security-test:5.1.5.RELEASE" testImplementation "org.springframework.security:spring-security-test"
} }
jar { sourceCompatibility = 17
description = "twigs-server" targetCompatibility = 17
}
mainClassName = "com.wbrawner.budgetserver.TwigsServerApplication"
sourceCompatibility = 14
targetCompatibility = 14

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

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,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,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,4 +1,4 @@
package com.wbrawner.budgetserver; package com.wbrawner.twigs;
public class ErrorResponse { public class ErrorResponse {
private final String message; private final String message;

View file

@ -1,11 +1,13 @@
package com.wbrawner.budgetserver.budget; package com.wbrawner.twigs.budget;
import com.wbrawner.budgetserver.permission.Permission; import com.wbrawner.twigs.category.CategoryRepository;
import com.wbrawner.budgetserver.permission.UserPermission; import com.wbrawner.twigs.permission.Permission;
import com.wbrawner.budgetserver.permission.UserPermissionRepository; import com.wbrawner.twigs.permission.UserPermission;
import com.wbrawner.budgetserver.transaction.TransactionRepository; import com.wbrawner.twigs.permission.UserPermissionRepository;
import com.wbrawner.budgetserver.user.User; import com.wbrawner.twigs.transaction.TransactionRepository;
import com.wbrawner.budgetserver.user.UserRepository; import com.wbrawner.twigs.user.User;
import com.wbrawner.twigs.user.UserRepository;
import jakarta.transaction.Transactional;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
@ -14,20 +16,20 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import javax.transaction.Transactional;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static com.wbrawner.budgetserver.Utils.getCurrentUser; import static com.wbrawner.twigs.Utils.getCurrentUser;
@RestController @RestController
@RequestMapping(value = "/budgets") @RequestMapping(value = "/api/budgets")
@Transactional @Transactional
public class BudgetController { public class BudgetController {
private final BudgetRepository budgetRepository; private final BudgetRepository budgetRepository;
private final CategoryRepository categoryRepository;
private final TransactionRepository transactionRepository; private final TransactionRepository transactionRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final UserPermissionRepository userPermissionsRepository; private final UserPermissionRepository userPermissionsRepository;
@ -35,11 +37,13 @@ public class BudgetController {
public BudgetController( public BudgetController(
BudgetRepository budgetRepository, BudgetRepository budgetRepository,
CategoryRepository categoryRepository,
TransactionRepository transactionRepository, TransactionRepository transactionRepository,
UserRepository userRepository, UserRepository userRepository,
UserPermissionRepository userPermissionsRepository UserPermissionRepository userPermissionsRepository
) { ) {
this.budgetRepository = budgetRepository; this.budgetRepository = budgetRepository;
this.categoryRepository = categoryRepository;
this.transactionRepository = transactionRepository; this.transactionRepository = transactionRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.userPermissionsRepository = userPermissionsRepository; this.userPermissionsRepository = userPermissionsRepository;
@ -53,12 +57,12 @@ public class BudgetController {
} }
List<BudgetResponse> budgets = userPermissionsRepository.findAllByUser( List<BudgetResponse> budgets = userPermissionsRepository.findAllByUser(
getCurrentUser(), getCurrentUser(),
PageRequest.of( PageRequest.of(
page != null ? page : 0, page != null ? page : 0,
count != null ? count : 1000 count != null ? count : 1000
)
) )
)
.stream() .stream()
.map(userPermission -> { .map(userPermission -> {
Budget budget = userPermission.getBudget(); Budget budget = userPermission.getBudget();
@ -80,35 +84,11 @@ public class BudgetController {
); );
} }
@GetMapping(value = "/{id}/balance", produces = {MediaType.APPLICATION_JSON_VALUE}) @PostMapping(
public ResponseEntity<BudgetBalanceResponse> getBudgetBalance( value = "",
@PathVariable String id, consumes = {MediaType.APPLICATION_JSON_VALUE},
@RequestParam(value = "from", required = false) String from, produces = {MediaType.APPLICATION_JSON_VALUE}
@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) { public ResponseEntity<BudgetResponse> newBudget(@RequestBody BudgetRequest request) {
final var budget = budgetRepository.save(new Budget(request.name, request.description)); final var budget = budgetRepository.save(new Budget(request.name, request.description));
var users = request.getUsers() var users = request.getUsers()
@ -138,8 +118,13 @@ public class BudgetController {
return ResponseEntity.ok(new BudgetResponse(budget, new ArrayList<>(users))); return ResponseEntity.ok(new BudgetResponse(budget, new ArrayList<>(users)));
} }
@PutMapping(value = "/{id}", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE}) @PutMapping(
value = "/{id}",
consumes = {MediaType.APPLICATION_JSON_VALUE},
produces = {MediaType.APPLICATION_JSON_VALUE}
)
public ResponseEntity<BudgetResponse> updateBudget(@PathVariable String id, @RequestBody BudgetRequest request) { public ResponseEntity<BudgetResponse> updateBudget(@PathVariable String id, @RequestBody BudgetRequest request) {
// TODO: Make sure no changes in ownership are being attempted (except by the owner)
return getBudgetWithPermission(id, Permission.MANAGE, (budget) -> { return getBudgetWithPermission(id, Permission.MANAGE, (budget) -> {
if (request.name != null) { if (request.name != null) {
budget.setName(request.name); budget.setName(request.name);
@ -161,7 +146,8 @@ public class BudgetController {
) )
)) ))
)); ));
} else { }
if (users.isEmpty()) {
users.addAll(userPermissionsRepository.findAllByBudget(budget, null)); users.addAll(userPermissionsRepository.findAllByBudget(budget, null));
} }
@ -169,11 +155,14 @@ public class BudgetController {
}); });
} }
@DeleteMapping(value = "/{id}", produces = {MediaType.TEXT_PLAIN_VALUE}) @DeleteMapping(value = "/{id}")
public ResponseEntity<Void> deleteBudget(@PathVariable String id) { public ResponseEntity<Void> deleteBudget(@PathVariable String id) {
return getBudgetWithPermission(id, Permission.MANAGE, (budget) -> { return getBudgetWithPermission(id, Permission.MANAGE, (budget) -> {
categoryRepository.deleteAllByBudget(budget);
transactionRepository.deleteAllByBudget(budget);
userPermissionsRepository.deleteAllByBudget(budget);
budgetRepository.delete(budget); budgetRepository.delete(budget);
return ResponseEntity.ok().build(); return ResponseEntity.noContent().build();
}); });
} }

View file

@ -1,8 +1,7 @@
package com.wbrawner.budgetserver.budget; package com.wbrawner.twigs.budget;
import com.wbrawner.budgetserver.permission.UserPermissionRequest; import com.wbrawner.twigs.permission.UserPermissionRequest;
import javax.validation.constraints.NotNull;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet; import java.util.HashSet;
import java.util.Set; import java.util.Set;
@ -23,7 +22,6 @@ public class BudgetRequest {
this.users.addAll(users); this.users.addAll(users);
} }
@NotNull
public Set<UserPermissionRequest> getUsers() { public Set<UserPermissionRequest> getUsers() {
return Set.copyOf(users); return Set.copyOf(users);
} }

View file

@ -1,8 +1,7 @@
package com.wbrawner.budgetserver.budget; package com.wbrawner.twigs.budget;
import com.wbrawner.budgetserver.permission.UserPermission; import com.wbrawner.twigs.permission.UserPermission;
import com.wbrawner.budgetserver.permission.UserPermissionResponse; import com.wbrawner.twigs.permission.UserPermissionResponse;
import com.wbrawner.budgetserver.user.User;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;

View file

@ -1,10 +1,11 @@
package com.wbrawner.budgetserver.category; package com.wbrawner.twigs.category;
import com.wbrawner.budgetserver.ErrorResponse; import com.wbrawner.twigs.ErrorResponse;
import com.wbrawner.budgetserver.permission.Permission; import com.wbrawner.twigs.permission.Permission;
import com.wbrawner.budgetserver.permission.UserPermission; import com.wbrawner.twigs.permission.UserPermission;
import com.wbrawner.budgetserver.permission.UserPermissionRepository; import com.wbrawner.twigs.permission.UserPermissionRepository;
import com.wbrawner.budgetserver.transaction.TransactionRepository; import com.wbrawner.twigs.transaction.TransactionRepository;
import jakarta.transaction.Transactional;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -12,24 +13,24 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import javax.transaction.Transactional;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static com.wbrawner.budgetserver.Utils.getCurrentUser; import static com.wbrawner.twigs.Utils.getCurrentUser;
import static com.wbrawner.budgetserver.Utils.getFirstOfMonth;
@RestController @RestController
@RequestMapping(path = "/categories") @RequestMapping(path = "/api/categories")
@Transactional @Transactional
class CategoryController { class CategoryController {
private final CategoryRepository categoryRepository; private final CategoryRepository categoryRepository;
private final TransactionRepository transactionRepository; private final TransactionRepository transactionRepository;
private final UserPermissionRepository userPermissionsRepository; private final UserPermissionRepository userPermissionsRepository;
CategoryController(CategoryRepository categoryRepository, CategoryController(
TransactionRepository transactionRepository, CategoryRepository categoryRepository,
UserPermissionRepository userPermissionsRepository) { TransactionRepository transactionRepository,
UserPermissionRepository userPermissionsRepository
) {
this.categoryRepository = categoryRepository; this.categoryRepository = categoryRepository;
this.transactionRepository = transactionRepository; this.transactionRepository = transactionRepository;
this.userPermissionsRepository = userPermissionsRepository; this.userPermissionsRepository = userPermissionsRepository;
@ -76,17 +77,6 @@ class CategoryController {
@GetMapping(path = "/{id}", produces = {MediaType.APPLICATION_JSON_VALUE}) @GetMapping(path = "/{id}", produces = {MediaType.APPLICATION_JSON_VALUE})
ResponseEntity<CategoryResponse> getCategory(@PathVariable String id) { 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) var budgets = userPermissionsRepository.findAllByUser(getCurrentUser(), null)
.stream() .stream()
.map(UserPermission::getBudget) .map(UserPermission::getBudget)
@ -95,11 +85,14 @@ class CategoryController {
if (category == null) { if (category == null) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
var sum = transactionRepository.sumBalanceByCategoryId(category.getId(), getFirstOfMonth()); return ResponseEntity.ok(new CategoryResponse(category));
return ResponseEntity.ok(new CategoryBalanceResponse(category.getId(), sum));
} }
@PostMapping(path = "", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE}) @PostMapping(
path = "",
consumes = {MediaType.APPLICATION_JSON_VALUE},
produces = {MediaType.APPLICATION_JSON_VALUE}
)
ResponseEntity<Object> newCategory(@RequestBody NewCategoryRequest request) { ResponseEntity<Object> newCategory(@RequestBody NewCategoryRequest request) {
var userResponse = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), request.getBudgetId()) var userResponse = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), request.getBudgetId())
.orElse(null); .orElse(null);
@ -119,12 +112,25 @@ class CategoryController {
)))); ))));
} }
@PutMapping(path = "/{id}", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE}) @PutMapping(
ResponseEntity<CategoryResponse> updateCategory(@PathVariable String id, @RequestBody UpdateCategoryRequest request) { 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); var category = categoryRepository.findById(id).orElse(null);
if (category == null) return ResponseEntity.notFound().build(); if (category == null) {
var userPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), category.getBudget().getId()).orElse(null); return ResponseEntity.notFound().build();
if (userPermission == 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)) { if (userPermission.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
} }
@ -149,9 +155,16 @@ class CategoryController {
@DeleteMapping(path = "/{id}", produces = {MediaType.TEXT_PLAIN_VALUE}) @DeleteMapping(path = "/{id}", produces = {MediaType.TEXT_PLAIN_VALUE})
ResponseEntity<Void> deleteCategory(@PathVariable String id) { ResponseEntity<Void> deleteCategory(@PathVariable String id) {
var category = categoryRepository.findById(id).orElse(null); var category = categoryRepository.findById(id).orElse(null);
if (category == null) return ResponseEntity.notFound().build(); if (category == null) {
var userPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), category.getBudget().getId()).orElse(null); return ResponseEntity.notFound().build();
if (userPermission == 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)) { if (userPermission.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
} }

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.category; package com.wbrawner.twigs.category;
import java.util.Objects; import java.util.Objects;
@ -23,7 +23,9 @@ public class CategoryResponse {
); );
} }
public CategoryResponse(String id, String title, String description, long amount, String budgetId, boolean expense, boolean archived) { public CategoryResponse(
String id, String title, String description, long amount, String budgetId, boolean expense, boolean archived
) {
this.id = id; this.id = id;
this.title = title; this.title = title;
this.description = description; this.description = description;

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.category; package com.wbrawner.twigs.category;
public class NewCategoryRequest { public class NewCategoryRequest {
private final String title; private final String title;

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.category; package com.wbrawner.twigs.category;
public class UpdateCategoryRequest { public class UpdateCategoryRequest {
private final String title; private final String title;

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.permission; package com.wbrawner.twigs.permission;
public class UserPermissionRequest { public class UserPermissionRequest {
private final String user; private final String user;

View file

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

View file

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

View file

@ -1,10 +1,10 @@
package com.wbrawner.budgetserver.category; package com.wbrawner.twigs.transaction;
public class CategoryBalanceResponse { public class BalanceResponse {
private final String id; private final String id;
private final long balance; private final long balance;
public CategoryBalanceResponse(String id, long balance) { public BalanceResponse(String id, long balance) {
this.id = id; this.id = id;
this.balance = balance; this.balance = balance;
} }

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.transaction; package com.wbrawner.twigs.transaction;
class NewTransactionRequest { class NewTransactionRequest {
private final String title; private final String title;
@ -13,7 +13,10 @@ class NewTransactionRequest {
this(null, null, null, null, null, null, null); this(null, null, null, null, null, null, null);
} }
NewTransactionRequest(String title, String description, String date, Long amount, String categoryId, Boolean expense, String budgetId) { NewTransactionRequest(
String title, String description, String date, Long amount, String categoryId, Boolean expense,
String budgetId
) {
this.title = title; this.title = title;
this.description = description; this.description = description;
this.date = date; this.date = date;

View file

@ -1,11 +1,12 @@
package com.wbrawner.budgetserver.transaction; package com.wbrawner.twigs.transaction;
import com.wbrawner.budgetserver.ErrorResponse; import com.wbrawner.twigs.ErrorResponse;
import com.wbrawner.budgetserver.category.Category; import com.wbrawner.twigs.category.Category;
import com.wbrawner.budgetserver.category.CategoryRepository; import com.wbrawner.twigs.category.CategoryRepository;
import com.wbrawner.budgetserver.permission.Permission; import com.wbrawner.twigs.permission.Permission;
import com.wbrawner.budgetserver.permission.UserPermission; import com.wbrawner.twigs.permission.UserPermission;
import com.wbrawner.budgetserver.permission.UserPermissionRepository; import com.wbrawner.twigs.permission.UserPermissionRepository;
import jakarta.transaction.Transactional;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
@ -15,15 +16,16 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import javax.transaction.Transactional;
import java.time.Instant; import java.time.Instant;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static com.wbrawner.budgetserver.Utils.*; import static com.wbrawner.twigs.Utils.*;
@RestController @RestController
@RequestMapping(path = "/transactions") @RequestMapping(path = "/api/transactions")
@Transactional @Transactional
public class TransactionController { public class TransactionController {
private final CategoryRepository categoryRepository; private final CategoryRepository categoryRepository;
@ -32,9 +34,11 @@ public class TransactionController {
private final Logger logger = LoggerFactory.getLogger(TransactionController.class); private final Logger logger = LoggerFactory.getLogger(TransactionController.class);
public TransactionController(CategoryRepository categoryRepository, public TransactionController(
TransactionRepository transactionRepository, CategoryRepository categoryRepository,
UserPermissionRepository userPermissionsRepository) { TransactionRepository transactionRepository,
UserPermissionRepository userPermissionsRepository
) {
this.categoryRepository = categoryRepository; this.categoryRepository = categoryRepository;
this.transactionRepository = transactionRepository; this.transactionRepository = transactionRepository;
this.userPermissionsRepository = userPermissionsRepository; this.userPermissionsRepository = userPermissionsRepository;
@ -79,16 +83,18 @@ public class TransactionController {
try { try {
fromInstant = Instant.parse(from); fromInstant = Instant.parse(from);
} catch (Exception e) { } catch (Exception e) {
if (!(e instanceof NullPointerException)) if (!(e instanceof NullPointerException)) {
logger.error("Failed to parse '" + from + "' to Instant for 'from' parameter", e); logger.error("Failed to parse '" + from + "' to Instant for 'from' parameter", e);
}
fromInstant = getFirstOfMonth().toInstant(); fromInstant = getFirstOfMonth().toInstant();
} }
Instant toInstant; Instant toInstant;
try { try {
toInstant = Instant.parse(to); toInstant = Instant.parse(to);
} catch (Exception e) { } catch (Exception e) {
if (!(e instanceof NullPointerException)) if (!(e instanceof NullPointerException)) {
logger.error("Failed to parse '" + to + "' to Instant for 'to' parameter", e); logger.error("Failed to parse '" + to + "' to Instant for 'to' parameter", e);
}
toInstant = getEndOfMonth().toInstant(); toInstant = getEndOfMonth().toInstant();
} }
var query = categories == null ? transactionRepository.findAllByBudgetInAndDateGreaterThanAndDateLessThan( var query = categories == null ? transactionRepository.findAllByBudgetInAndDateGreaterThanAndDateLessThan(
@ -117,11 +123,17 @@ public class TransactionController {
.map(UserPermission::getBudget) .map(UserPermission::getBudget)
.collect(Collectors.toList()); .collect(Collectors.toList());
var transaction = transactionRepository.findByIdAndBudgetIn(id, budgets).orElse(null); var transaction = transactionRepository.findByIdAndBudgetIn(id, budgets).orElse(null);
if (transaction == null) return ResponseEntity.notFound().build(); if (transaction == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(new TransactionResponse(transaction)); return ResponseEntity.ok(new TransactionResponse(transaction));
} }
@PostMapping(path = "", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE}) @PostMapping(
path = "",
consumes = {MediaType.APPLICATION_JSON_VALUE},
produces = {MediaType.APPLICATION_JSON_VALUE}
)
public ResponseEntity<Object> newTransaction(@RequestBody NewTransactionRequest request) { public ResponseEntity<Object> newTransaction(@RequestBody NewTransactionRequest request) {
var userResponse = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), request.getBudgetId()) var userResponse = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), request.getBudgetId())
.orElse(null); .orElse(null);
@ -148,12 +160,25 @@ public class TransactionController {
)))); ))));
} }
@PutMapping(path = "/{id}", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE}) @PutMapping(
public ResponseEntity<Object> updateTransaction(@PathVariable String id, @RequestBody UpdateTransactionRequest request) { 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); var transaction = transactionRepository.findById(id).orElse(null);
if (transaction == null) return ResponseEntity.notFound().build(); if (transaction == null) {
var userPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), transaction.getBudget().getId()).orElse(null); return ResponseEntity.notFound().build();
if (userPermission == 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)) { if (userPermission.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
} }
@ -173,7 +198,10 @@ public class TransactionController {
transaction.setExpense(request.getExpense()); transaction.setExpense(request.getExpense());
} }
if (request.getBudgetId() != null) { if (request.getBudgetId() != null) {
var newUserPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), request.getBudgetId()).orElse(null); var newUserPermission = userPermissionsRepository.findByUserAndBudget_Id(
getCurrentUser(),
request.getBudgetId()
).orElse(null);
if (newUserPermission == null || newUserPermission.getPermission().isNotAtLeast(Permission.WRITE)) { if (newUserPermission == null || newUserPermission.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity return ResponseEntity
.badRequest() .badRequest()
@ -182,7 +210,8 @@ public class TransactionController {
transaction.setBudget(newUserPermission.getBudget()); transaction.setBudget(newUserPermission.getBudget());
} }
if (request.getCategoryId() != null) { if (request.getCategoryId() != null) {
var category = categoryRepository.findByBudgetAndId(transaction.getBudget(), request.getCategoryId()).orElse(null); var category = categoryRepository.findByBudgetAndId(transaction.getBudget(), request.getCategoryId())
.orElse(null);
if (category == null) { if (category == null) {
return ResponseEntity return ResponseEntity
.badRequest() .badRequest()
@ -196,14 +225,111 @@ public class TransactionController {
@DeleteMapping(path = "/{id}", produces = {MediaType.TEXT_PLAIN_VALUE}) @DeleteMapping(path = "/{id}", produces = {MediaType.TEXT_PLAIN_VALUE})
public ResponseEntity<Void> deleteTransaction(@PathVariable String id) { public ResponseEntity<Void> deleteTransaction(@PathVariable String id) {
var transaction = transactionRepository.findById(id).orElse(null); var transaction = transactionRepository.findById(id).orElse(null);
if (transaction == null) return ResponseEntity.notFound().build(); if (transaction == null) {
return ResponseEntity.notFound().build();
}
// Check that the transaction belongs to an budget that the user has access to before deleting it // 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); var userPermission = userPermissionsRepository.findByUserAndBudget_Id(
if (userPermission == null) return ResponseEntity.notFound().build(); getCurrentUser(),
transaction.getBudget().getId()
).orElse(null);
if (userPermission == null) {
return ResponseEntity.notFound().build();
}
if (userPermission.getPermission().isNotAtLeast(Permission.WRITE)) { if (userPermission.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
} }
transactionRepository.delete(transaction); transactionRepository.delete(transaction);
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }
@GetMapping(value = "/sum", produces = {MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<BalanceResponse> getSum(
@RequestParam(value = "budgetId", required = false) String budgetId,
@RequestParam(value = "categoryId", required = false) String categoryId,
@RequestParam(value = "from", required = false) String from,
@RequestParam(value = "to", required = false) String to
) {
var user = getCurrentUser();
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
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();
}
if (((budgetId == null || budgetId.isBlank())
&& (categoryId == null || categoryId.isBlank()))
|| (budgetId != null && !budgetId.isEmpty() && categoryId != null && !categoryId.isBlank())) {
return ResponseEntity.badRequest().build();
}
List<Transaction> transactions;
if (categoryId != null) {
var budgets = userPermissionsRepository.findAllByUser(user, null)
.stream()
.map(UserPermission::getBudget)
.collect(Collectors.toList());
var category = categoryRepository.findByBudgetInAndId(budgets, categoryId).orElse(null);
if (category == null) {
return ResponseEntity.notFound().build();
}
transactions = transactionRepository.findAllByBudgetInAndCategoryInAndDateGreaterThanAndDateLessThan(
List.of(category.getBudget()),
List.of(category),
fromInstant,
toInstant,
null
);
AtomicLong balance = new AtomicLong(0L);
transactions.forEach(transaction -> {
if (transaction.getExpense()) {
balance.addAndGet(transaction.getAmount() * -1);
} else {
balance.addAndGet(transaction.getAmount());
}
});
return ResponseEntity.ok(new BalanceResponse(categoryId, balance.get()));
} else {
var userPermission = userPermissionsRepository.findByUserAndBudget_Id(user, budgetId).orElse(null);
if (userPermission == null) {
return ResponseEntity.notFound().build();
}
if (userPermission.getPermission().isNotAtLeast(Permission.READ)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
transactions = transactionRepository.findAllByBudgetInAndDateGreaterThanAndDateLessThan(
List.of(userPermission.getBudget()),
fromInstant,
toInstant,
null
);
AtomicLong balance = new AtomicLong(0L);
transactions.forEach(transaction -> {
if (transaction.getExpense()) {
balance.addAndGet(transaction.getAmount() * -1);
} else {
balance.addAndGet(transaction.getAmount());
}
});
return ResponseEntity.ok(new BalanceResponse(budgetId, balance.get()));
}
}
} }

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.transaction; package com.wbrawner.twigs.transaction;
class TransactionResponse { class TransactionResponse {
private final String id; private final String id;
@ -11,15 +11,17 @@ class TransactionResponse {
private final String categoryId; private final String categoryId;
private final String createdBy; private final String createdBy;
TransactionResponse(String id, TransactionResponse(
String title, String id,
String description, String title,
String date, String description,
Long amount, String date,
Boolean expense, Long amount,
String budgetId, Boolean expense,
String categoryId, String budgetId,
String createdBy) { String categoryId,
String createdBy
) {
this.id = id; this.id = id;
this.title = title; this.title = title;
this.description = description; this.description = description;

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.transaction; package com.wbrawner.twigs.transaction;
class UpdateTransactionRequest { class UpdateTransactionRequest {
private final String title; private final String title;
@ -14,7 +14,10 @@ class UpdateTransactionRequest {
this(null, null, null, null, null, null, null, null); 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) { UpdateTransactionRequest(
String title, String description, String date, Long amount, String categoryId, Boolean expense,
String budgetId, String createdBy
) {
this.title = title; this.title = title;
this.description = description; this.description = description;
this.date = date; this.date = date;

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.user; package com.wbrawner.twigs.user;
public class LoginRequest { public class LoginRequest {
private final String username; private final String username;

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.user; package com.wbrawner.twigs.user;
public class NewUserRequest { public class NewUserRequest {
private final String username; private final String username;

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.user; package com.wbrawner.twigs.user;
public class UpdateUserRequest { public class UpdateUserRequest {
private final String username; private final String username;

View file

@ -1,13 +1,15 @@
package com.wbrawner.budgetserver.user; package com.wbrawner.twigs.user;
import com.wbrawner.budgetserver.ErrorResponse; import com.wbrawner.twigs.ErrorResponse;
import com.wbrawner.budgetserver.budget.BudgetRepository; import com.wbrawner.twigs.budget.BudgetRepository;
import com.wbrawner.budgetserver.permission.UserPermissionRepository; import com.wbrawner.twigs.permission.UserPermissionRepository;
import com.wbrawner.budgetserver.permission.UserPermissionResponse; import com.wbrawner.twigs.permission.UserPermissionResponse;
import com.wbrawner.budgetserver.session.Session; import com.wbrawner.twigs.session.Session;
import com.wbrawner.budgetserver.session.SessionResponse; import com.wbrawner.twigs.session.SessionResponse;
import com.wbrawner.budgetserver.session.UserSessionRepository; import com.wbrawner.twigs.session.UserSessionRepository;
import jakarta.transaction.Transactional;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@ -18,15 +20,14 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import javax.transaction.Transactional;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static com.wbrawner.budgetserver.Utils.getCurrentUser; import static com.wbrawner.twigs.Utils.getCurrentUser;
@RestController @RestController
@RequestMapping("/users") @RequestMapping("/api/users")
@Transactional @Transactional
public class UserController { public class UserController {
private final BudgetRepository budgetRepository; private final BudgetRepository budgetRepository;
@ -34,20 +35,24 @@ public class UserController {
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final UserPermissionRepository userPermissionsRepository; private final UserPermissionRepository userPermissionsRepository;
private final UserSessionRepository userSessionRepository; private final UserSessionRepository userSessionRepository;
private final UserService userService;
private final DaoAuthenticationProvider authenticationProvider; private final DaoAuthenticationProvider authenticationProvider;
@Autowired @Autowired
public UserController(BudgetRepository budgetRepository, public UserController(
UserRepository userRepository, BudgetRepository budgetRepository,
UserSessionRepository userSessionRepository, UserRepository userRepository,
PasswordEncoder passwordEncoder, UserSessionRepository userSessionRepository,
UserPermissionRepository userPermissionsRepository, PasswordEncoder passwordEncoder,
DaoAuthenticationProvider authenticationProvider) { UserPermissionRepository userPermissionsRepository,
UserService userService, DaoAuthenticationProvider authenticationProvider
) {
this.budgetRepository = budgetRepository; this.budgetRepository = budgetRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.userSessionRepository = userSessionRepository; this.userSessionRepository = userSessionRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.userPermissionsRepository = userPermissionsRepository; this.userPermissionsRepository = userPermissionsRepository;
this.userService = userService;
this.authenticationProvider = authenticationProvider; this.authenticationProvider = authenticationProvider;
} }
@ -66,28 +71,28 @@ public class UserController {
if (!userInBudget) { if (!userInBudget) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
return ResponseEntity.ok(userPermissions.stream().map(UserPermissionResponse::new).collect(Collectors.toList())); return ResponseEntity.ok(userPermissions.stream()
.map(UserPermissionResponse::new)
.collect(Collectors.toList()));
} }
@PostMapping(path = "/login", produces = {MediaType.APPLICATION_JSON_VALUE}) @PostMapping(path = "/login", produces = {MediaType.APPLICATION_JSON_VALUE})
ResponseEntity<SessionResponse> login(@RequestBody LoginRequest request) { ResponseEntity<SessionResponse> login(@RequestBody LoginRequest request) {
var authReq = new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword());
Authentication auth;
try { try {
auth = authenticationProvider.authenticate(authReq); return ResponseEntity.ok(new SessionResponse(
userService.login(request.getUsername(), request.getPassword())
));
} catch (AuthenticationException e) { } catch (AuthenticationException e) {
return ResponseEntity.notFound().build(); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).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}) @GetMapping(path = "/me", produces = {MediaType.APPLICATION_JSON_VALUE})
ResponseEntity<UserResponse> getProfile() { ResponseEntity<UserResponse> getProfile() {
var user = getCurrentUser(); var user = getCurrentUser();
if (user == null) return ResponseEntity.status(401).build(); if (user == null) {
return ResponseEntity.status(401).build();
}
return ResponseEntity.ok(new UserResponse(user)); return ResponseEntity.ok(new UserResponse(user));
} }
@ -110,14 +115,21 @@ public class UserController {
return ResponseEntity.ok(new UserResponse(user)); return ResponseEntity.ok(new UserResponse(user));
} }
@PostMapping(path = "", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE}) @PostMapping(
path = "/register",
consumes = {MediaType.APPLICATION_JSON_VALUE},
produces = {MediaType.APPLICATION_JSON_VALUE}
)
ResponseEntity<Object> newUser(@RequestBody NewUserRequest request) { ResponseEntity<Object> newUser(@RequestBody NewUserRequest request) {
if (userRepository.findByUsername(request.getUsername()).isPresent()) if (userRepository.findByUsername(request.getUsername()).isPresent()) {
return ResponseEntity.badRequest().body(new ErrorResponse("Username taken")); return ResponseEntity.badRequest().body(new ErrorResponse("Username taken"));
if (userRepository.findByEmail(request.getEmail()).isPresent()) }
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
return ResponseEntity.badRequest().body(new ErrorResponse("Email taken")); return ResponseEntity.badRequest().body(new ErrorResponse("Email taken"));
if (request.getPassword().isBlank()) }
if (request.getPassword().isBlank()) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid password")); return ResponseEntity.badRequest().body(new ErrorResponse("Invalid password"));
}
return ResponseEntity.ok(new UserResponse(userRepository.save(new User( return ResponseEntity.ok(new UserResponse(userRepository.save(new User(
request.getUsername(), request.getUsername(),
passwordEncoder.encode(request.getPassword()), passwordEncoder.encode(request.getPassword()),
@ -125,24 +137,36 @@ public class UserController {
)))); ))));
} }
@PutMapping(path = "/{id}", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE}) @PutMapping(
ResponseEntity<Object> updateUser(@PathVariable Long id, @RequestBody UpdateUserRequest request) { path = "/{id}",
if (!getCurrentUser().getId().equals(id)) return ResponseEntity.status(403).build(); consumes = {MediaType.APPLICATION_JSON_VALUE},
produces = {MediaType.APPLICATION_JSON_VALUE}
)
ResponseEntity<Object> updateUser(@PathVariable String id, @RequestBody UpdateUserRequest request) {
var currentUser = getCurrentUser();
if (currentUser == null || !currentUser.getId().equals(id)) {
return ResponseEntity.status(403).build();
}
var user = userRepository.findById(getCurrentUser().getId()).orElse(null); var user = userRepository.findById(getCurrentUser().getId()).orElse(null);
if (user == null) return ResponseEntity.notFound().build(); if (user == null) {
return ResponseEntity.notFound().build();
}
if (request.getUsername() != null) { if (request.getUsername() != null) {
if (userRepository.findByUsername(request.getUsername()).isPresent()) if (userRepository.findByUsername(request.getUsername()).isPresent()) {
return ResponseEntity.badRequest().body(new ErrorResponse("Username taken")); return ResponseEntity.badRequest().body(new ErrorResponse("Username taken"));
}
user.setUsername(request.getUsername()); user.setUsername(request.getUsername());
} }
if (request.getEmail() != null) { if (request.getEmail() != null) {
if (userRepository.findByEmail(request.getEmail()).isPresent()) if (userRepository.findByEmail(request.getEmail()).isPresent()) {
return ResponseEntity.badRequest().body(new ErrorResponse("Email taken")); return ResponseEntity.badRequest().body(new ErrorResponse("Email taken"));
}
user.setEmail(request.getEmail()); user.setEmail(request.getEmail());
} }
if (request.getPassword() != null) { if (request.getPassword() != null) {
if (request.getPassword().isBlank()) if (request.getPassword().isBlank()) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid password")); return ResponseEntity.badRequest().body(new ErrorResponse("Invalid password"));
}
user.setPassword(passwordEncoder.encode(request.getPassword())); user.setPassword(passwordEncoder.encode(request.getPassword()));
} }
return ResponseEntity.ok(new UserResponse(userRepository.save(user))); return ResponseEntity.ok(new UserResponse(userRepository.save(user)));
@ -150,7 +174,10 @@ public class UserController {
@DeleteMapping(path = "/{id}", produces = {MediaType.TEXT_PLAIN_VALUE}) @DeleteMapping(path = "/{id}", produces = {MediaType.TEXT_PLAIN_VALUE})
ResponseEntity<Void> deleteUser(@PathVariable String id) { ResponseEntity<Void> deleteUser(@PathVariable String id) {
if (!getCurrentUser().getId().equals(id)) return ResponseEntity.status(403).build(); var currentUser = getCurrentUser();
if (currentUser == null || !currentUser.getId().equals(id)) {
return ResponseEntity.status(403).build();
}
userRepository.deleteById(id); userRepository.deleteById(id);
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.user; package com.wbrawner.twigs.user;
public class UserResponse { public class UserResponse {
private final String id; private final String id;

View file

@ -1,10 +1,6 @@
spring.jpa.hibernate.ddl-auto=none spring.jpa.hibernate.ddl-auto=update
spring.datasource.url=jdbc:mysql://localhost:3306/budget spring.datasource.url=jdbc:h2:./twigs.db
spring.datasource.username=budget
spring.datasource.password=budget
spring.profiles.active=prod spring.profiles.active=prod
spring.session.jdbc.initialize-schema=always spring.session.jdbc.initialize-schema=always
spring.datasource.testWhileIdle=true twigs.cors.domains=http://localhost:4200
spring.datasource.timeBetweenEvictionRunsMillis=60000 logging.level.org.springframework.security=DEBUG
spring.datasource.validationQuery=SELECT 1
twigs.cors.domains=*

25
app/build.gradle Normal file
View file

@ -0,0 +1,25 @@
plugins {
id 'java'
id 'application'
id "io.spring.dependency-management"
id "org.springframework.boot"
id 'org.graalvm.buildtools.native' version '0.9.18'
}
dependencies {
implementation project(':core')
implementation project(':api')
implementation "org.springframework.boot:spring-boot-starter-data-jpa"
implementation "org.springframework.boot:spring-boot-starter-security"
implementation "org.springframework.boot:spring-boot-starter-web"
implementation "org.springframework.session:spring-session-jdbc"
runtimeOnly 'org.postgresql:postgresql:42.2.27'
runtimeOnly 'com.h2database:h2:2.2.220'
testImplementation "org.springframework.boot:spring-boot-starter-test"
}
jar {
description = "twigs"
}
mainClassName = "com.wbrawner.twigs.TwigsServerApplication"

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver; package com.wbrawner.twigs;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;

View file

@ -1,6 +1,6 @@
package com.wbrawner.budgetserver.config; package com.wbrawner.twigs.config;
import com.wbrawner.budgetserver.user.UserRepository; import com.wbrawner.twigs.user.UserRepository;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UserDetailsService;

View file

@ -0,0 +1,127 @@
package com.wbrawner.twigs.config;
import com.wbrawner.twigs.user.PasswordResetRequestRepository;
import com.wbrawner.twigs.session.UserSessionRepository;
import com.wbrawner.twigs.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.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
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
public class SecurityConfig {
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();
}
@Bean
public SecurityFilterChain filterChain(
HttpSecurity httpSecurity, AuthenticationManager authenticationManager
) throws Exception {
return httpSecurity.authorizeHttpRequests((authz) -> {
try {
authz
.requestMatchers(
"/api/^(users/register|users/login)"
)
.authenticated()
.anyRequest()
.permitAll()
.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(HttpMethod::name)
.collect(Collectors.toList())
);
corsConfig.setAllowCredentials(true);
return corsConfig;
})
.and()
.csrf()
.disable()
.addFilter(new TokenAuthenticationFilter(authenticationManager));
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.build();
}
@Bean
public AuthenticationManager getAuthenticationManager(AuthenticationConfiguration auth) throws Exception {
return auth.getAuthenticationManager();
}
}

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.config; package com.wbrawner.twigs.config;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.GrantedAuthority;
@ -14,7 +14,9 @@ public class SessionAuthenticationToken extends UsernamePasswordAuthenticationTo
* @param credentials * @param credentials
* @param principal * @param principal
*/ */
public SessionAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) { public SessionAuthenticationToken(
Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities
) {
super(principal, credentials, authorities); super(principal, credentials, authorities);
} }
} }

View file

@ -1,11 +1,10 @@
package com.wbrawner.budgetserver.config; package com.wbrawner.twigs.config;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.AuthenticationEntryPoint;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
/** /**
@ -13,7 +12,9 @@ import java.io.IOException;
*/ */
public class SilentAuthenticationEntryPoint implements AuthenticationEntryPoint { public class SilentAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override @Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { public void commence(
HttpServletRequest request, HttpServletResponse response, AuthenticationException authException
) throws IOException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()); response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
} }
} }

View file

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

View file

@ -1,7 +1,8 @@
package com.wbrawner.budgetserver.config; package com.wbrawner.twigs.config;
import com.wbrawner.budgetserver.session.UserSessionRepository; import com.wbrawner.twigs.Utils;
import com.wbrawner.budgetserver.user.UserRepository; import com.wbrawner.twigs.session.UserSessionRepository;
import com.wbrawner.twigs.user.UserRepository;
import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InternalAuthenticationServiceException; import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@ -12,7 +13,7 @@ import org.springframework.security.core.userdetails.UserDetails;
import java.util.Date; import java.util.Date;
import static com.wbrawner.budgetserver.Utils.twoWeeksFromNow; import static com.wbrawner.twigs.Utils.twoWeeksFromNow;
public class TokenAuthenticationProvider extends DaoAuthenticationProvider { public class TokenAuthenticationProvider extends DaoAuthenticationProvider {
private final UserSessionRepository userSessionRepository; private final UserSessionRepository userSessionRepository;
@ -24,7 +25,9 @@ public class TokenAuthenticationProvider extends DaoAuthenticationProvider {
} }
@Override @Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { protected void additionalAuthenticationChecks(
UserDetails userDetails, UsernamePasswordAuthenticationToken authentication
) throws AuthenticationException {
if (!(authentication instanceof SessionAuthenticationToken)) { if (!(authentication instanceof SessionAuthenticationToken)) {
// Additional checks aren't needed since they've already been handled // Additional checks aren't needed since they've already been handled
super.additionalAuthenticationChecks(userDetails, authentication); super.additionalAuthenticationChecks(userDetails, authentication);
@ -45,10 +48,14 @@ public class TokenAuthenticationProvider extends DaoAuthenticationProvider {
new Thread(() -> { new Thread(() -> {
// Update the session on a background thread to avoid holding up the request longer than necessary // Update the session on a background thread to avoid holding up the request longer than necessary
var updatedSession = session.get(); var updatedSession = session.get();
updatedSession.setExpiration(twoWeeksFromNow()); updatedSession.setExpiration(Utils.twoWeeksFromNow());
userSessionRepository.save(updatedSession); userSessionRepository.save(updatedSession);
}).start(); }).start();
return new SessionAuthenticationToken(user.get(), authentication.getCredentials(), authentication.getAuthorities()); return new SessionAuthenticationToken(
user.get(),
authentication.getCredentials(),
authentication.getAuthorities()
);
} else { } else {
return super.authenticate(authentication); return super.authenticate(authentication);
} }

View file

@ -0,0 +1,22 @@
//package com.wbrawner.twigs.config;
//
//import org.springframework.context.annotation.Configuration;
//import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
//import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
//import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
//
//@Configuration
//public class WebConfig implements WebMvcConfigurer {
//
// @Override
// public void addViewControllers(ViewControllerRegistry registry) {
// registry.addViewController("/").setViewName("forward:/index.html");
// registry.addViewController("/{path:\\w*}").setViewName("forward:/index.html");
// registry.addViewController("/(^api)/**").setViewName("forward:/index.html");
// }
//
// @Override
// public void addResourceHandlers(ResourceHandlerRegistry registry) {
// registry.addResourceHandler("/**").addResourceLocations("classpath:/webapp/");
// }
//}

View file

@ -2,12 +2,12 @@ buildscript {
repositories { repositories {
mavenLocal() mavenLocal()
mavenCentral() mavenCentral()
maven { url "http://repo.spring.io/snapshot" } maven { url "https://repo.spring.io/snapshot" }
maven { url "http://repo.spring.io/milestone" } maven { url "https://repo.spring.io/milestone" }
} }
dependencies { dependencies {
classpath "org.springframework.boot:spring-boot-gradle-plugin:2.2.2.RELEASE" classpath "org.springframework.boot:spring-boot-gradle-plugin:3.0.3"
} }
} }
@ -17,9 +17,9 @@ allprojects {
repositories { repositories {
mavenLocal() mavenLocal()
mavenCentral() mavenCentral()
maven { url "http://repo.spring.io/snapshot" } maven { url "https://repo.spring.io/snapshot" }
maven { url "http://repo.spring.io/milestone" } maven { url "https://repo.spring.io/milestone" }
maven { url "http://repo.maven.apache.org/maven2" } maven { url "https://repo.maven.apache.org/maven2" }
} }
jar { jar {

12
core/build.gradle Normal file
View file

@ -0,0 +1,12 @@
plugins {
id 'java'
id 'application'
id "io.spring.dependency-management"
id "org.springframework.boot"
}
dependencies {
implementation "org.springframework.boot:spring-boot-starter-security"
implementation "org.springframework.boot:spring-boot-starter-web"
implementation "org.springframework.boot:spring-boot-starter-data-jpa"
}

View file

@ -1,6 +1,6 @@
package com.wbrawner.budgetserver; package com.wbrawner.twigs;
import com.wbrawner.budgetserver.user.User; import com.wbrawner.twigs.user.User;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import java.security.SecureRandom; import java.security.SecureRandom;

View file

@ -1,19 +1,22 @@
package com.wbrawner.budgetserver.budget; package com.wbrawner.twigs.budget;
import com.wbrawner.budgetserver.category.Category; import com.wbrawner.twigs.Utils;
import com.wbrawner.budgetserver.transaction.Transaction; import com.wbrawner.twigs.category.Category;
import com.wbrawner.twigs.transaction.Transaction;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import javax.persistence.*;
import java.util.HashSet; import java.util.HashSet;
import java.util.Set; import java.util.Set;
import java.util.TreeSet; import java.util.TreeSet;
import static com.wbrawner.budgetserver.Utils.randomId; import static com.wbrawner.twigs.Utils.randomId;
@Entity @Entity
public class Budget { public class Budget {
@Id @Id
private String id = randomId(); private String id = Utils.randomId();
private String name; private String name;
private String description; private String description;
private String currencyCode; private String currencyCode;
@ -24,7 +27,8 @@ public class Budget {
@OneToMany(mappedBy = "budget") @OneToMany(mappedBy = "budget")
private final Set<Transaction> users = new HashSet<>(); private final Set<Transaction> users = new HashSet<>();
public Budget() {} public Budget() {
}
public Budget(String name, String description) { public Budget(String name, String description) {
this(name, description, "USD"); this(name, description, "USD");

View file

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

View file

@ -1,11 +1,9 @@
package com.wbrawner.budgetserver.category; package com.wbrawner.twigs.category;
import com.wbrawner.budgetserver.budget.Budget; import com.wbrawner.twigs.budget.Budget;
import jakarta.persistence.*;
import javax.persistence.*; import static com.wbrawner.twigs.Utils.randomId;
import java.util.UUID;
import static com.wbrawner.budgetserver.Utils.randomId;
@Entity @Entity
public class Category implements Comparable<Category> { public class Category implements Comparable<Category> {

View file

@ -1,15 +1,16 @@
package com.wbrawner.budgetserver.category; package com.wbrawner.twigs.category;
import com.wbrawner.budgetserver.budget.Budget;
import com.wbrawner.twigs.budget.Budget;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.PagingAndSortingRepository; import org.springframework.data.repository.PagingAndSortingRepository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface CategoryRepository extends PagingAndSortingRepository<Category, String> { public interface CategoryRepository extends CrudRepository<Category, String>,
PagingAndSortingRepository<Category, String> {
List<Category> findAllByBudget(Budget budget, Pageable pageable); 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)") @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)")
@ -20,4 +21,6 @@ public interface CategoryRepository extends PagingAndSortingRepository<Category,
Optional<Category> findByBudgetAndId(Budget budget, String id); Optional<Category> findByBudgetAndId(Budget budget, String id);
List<Category> findAllByBudgetInAndIdIn(List<Budget> budgets, List<String> ids, Pageable pageable); List<Category> findAllByBudgetInAndIdIn(List<Budget> budgets, List<String> ids, Pageable pageable);
void deleteAllByBudget(Budget budget);
} }

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.permission; package com.wbrawner.twigs.permission;
public enum Permission { public enum Permission {
/** /**

View file

@ -1,9 +1,8 @@
package com.wbrawner.budgetserver.permission; package com.wbrawner.twigs.permission;
import com.wbrawner.budgetserver.budget.Budget; import com.wbrawner.twigs.budget.Budget;
import com.wbrawner.budgetserver.user.User; import com.wbrawner.twigs.user.User;
import jakarta.persistence.*;
import javax.persistence.*;
@Entity @Entity
public class UserPermission { public class UserPermission {

View file

@ -1,6 +1,7 @@
package com.wbrawner.budgetserver.permission; package com.wbrawner.twigs.permission;
import jakarta.persistence.Embeddable;
import javax.persistence.Embeddable;
import java.io.Serializable; import java.io.Serializable;
@Embeddable @Embeddable

View file

@ -1,14 +1,16 @@
package com.wbrawner.budgetserver.permission; package com.wbrawner.twigs.permission;
import com.wbrawner.budgetserver.budget.Budget; import com.wbrawner.twigs.budget.Budget;
import com.wbrawner.budgetserver.user.User; import com.wbrawner.twigs.user.User;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.PagingAndSortingRepository; import org.springframework.data.repository.PagingAndSortingRepository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface UserPermissionRepository extends PagingAndSortingRepository<UserPermission, UserPermissionKey> { public interface UserPermissionRepository extends CrudRepository<UserPermission, UserPermissionKey>,
PagingAndSortingRepository<UserPermission, UserPermissionKey> {
Optional<UserPermission> findByUserAndBudget_Id(User user, String budgetId); Optional<UserPermission> findByUserAndBudget_Id(User user, String budgetId);
List<UserPermission> findAllByUser(User user, Pageable pageable); List<UserPermission> findAllByUser(User user, Pageable pageable);
@ -18,4 +20,6 @@ public interface UserPermissionRepository extends PagingAndSortingRepository<Use
List<UserPermission> findAllByUserAndBudget(User user, Budget budget, Pageable pageable); List<UserPermission> findAllByUserAndBudget(User user, Budget budget, Pageable pageable);
List<UserPermission> findAllByUserAndBudget_IdIn(User user, List<String> budgetIds, Pageable pageable); List<UserPermission> findAllByUserAndBudget_IdIn(User user, List<String> budgetIds, Pageable pageable);
void deleteAllByBudget(Budget budget);
} }

View file

@ -0,0 +1,127 @@
package com.wbrawner.twigs.recurringtransaction;
import com.wbrawner.twigs.budget.Budget;
import com.wbrawner.twigs.category.Category;
import com.wbrawner.twigs.user.User;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import java.time.Instant;
import static com.wbrawner.twigs.Utils.randomId;
@Entity
public class RecurringTransaction implements Comparable<RecurringTransaction> {
@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 RecurringTransaction() {
this(null, null, null, null, null, null, null, null);
}
public RecurringTransaction(
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(RecurringTransaction other) {
return this.date.compareTo(other.date);
}
}

View file

@ -0,0 +1,36 @@
package com.wbrawner.twigs.recurringtransaction;
import com.wbrawner.twigs.budget.Budget;
import com.wbrawner.twigs.category.Category;
import com.wbrawner.twigs.transaction.Transaction;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
public interface RecurringTransactionRepository extends CrudRepository<RecurringTransaction, String>,
PagingAndSortingRepository<RecurringTransaction, 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);
void deleteAllByBudget(Budget budget);
}

View file

@ -1,10 +1,11 @@
package com.wbrawner.budgetserver.session; package com.wbrawner.twigs.session;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import javax.persistence.Entity;
import javax.persistence.Id;
import java.util.Date; import java.util.Date;
import static com.wbrawner.budgetserver.Utils.*; import static com.wbrawner.twigs.Utils.*;
@Entity @Entity
public class Session { public class Session {

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.session; package com.wbrawner.twigs.session;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;

View file

@ -1,12 +1,14 @@
package com.wbrawner.budgetserver.session; package com.wbrawner.twigs.session;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.PagingAndSortingRepository; import org.springframework.data.repository.PagingAndSortingRepository;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface UserSessionRepository extends PagingAndSortingRepository<Session, String> { public interface UserSessionRepository extends CrudRepository<Session, String>,
PagingAndSortingRepository<Session, String> {
List<Session> findByUserId(String userId); List<Session> findByUserId(String userId);
Optional<Session> findByToken(String token); Optional<Session> findByToken(String token);

View file

@ -1,13 +1,16 @@
package com.wbrawner.budgetserver.transaction; package com.wbrawner.twigs.transaction;
import com.wbrawner.budgetserver.budget.Budget; import com.wbrawner.twigs.budget.Budget;
import com.wbrawner.budgetserver.category.Category; import com.wbrawner.twigs.category.Category;
import com.wbrawner.budgetserver.user.User; import com.wbrawner.twigs.user.User;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import javax.persistence.*;
import java.time.Instant; import java.time.Instant;
import static com.wbrawner.budgetserver.Utils.randomId; import static com.wbrawner.twigs.Utils.randomId;
@Entity @Entity
public class Transaction implements Comparable<Transaction> { public class Transaction implements Comparable<Transaction> {
@ -31,14 +34,16 @@ public class Transaction implements Comparable<Transaction> {
this(null, null, null, null, null, null, null, null); this(null, null, null, null, null, null, null, null);
} }
public Transaction(String title, public Transaction(
String description, String title,
Instant date, String description,
Long amount, Instant date,
Category category, Long amount,
Boolean expense, Category category,
User createdBy, Boolean expense,
Budget budget) { User createdBy,
Budget budget
) {
this.title = title; this.title = title;
this.description = description; this.description = description;
this.date = date; this.date = date;

View file

@ -0,0 +1,37 @@
package com.wbrawner.twigs.transaction;
import com.wbrawner.twigs.budget.Budget;
import com.wbrawner.twigs.category.Category;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
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 CrudRepository<Transaction, String>,
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);
void deleteAllByBudget(Budget budget);
}

View file

@ -1,13 +1,14 @@
package com.wbrawner.budgetserver.passwordresetrequest; package com.wbrawner.twigs.user;
import com.wbrawner.budgetserver.user.User; import com.wbrawner.twigs.user.User;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import javax.persistence.*;
import java.util.Calendar; import java.util.Calendar;
import java.util.GregorianCalendar; import java.util.GregorianCalendar;
import java.util.UUID;
import static com.wbrawner.budgetserver.Utils.randomId; import static com.wbrawner.twigs.Utils.randomId;
@Entity @Entity
public class PasswordResetRequest { public class PasswordResetRequest {

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.passwordresetrequest; package com.wbrawner.twigs.user;
import org.springframework.data.repository.PagingAndSortingRepository; import org.springframework.data.repository.PagingAndSortingRepository;

View file

@ -1,20 +1,20 @@
package com.wbrawner.budgetserver.user; package com.wbrawner.twigs.user;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Transient;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails; 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.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import static com.wbrawner.budgetserver.Utils.randomId; import static com.wbrawner.twigs.Utils.randomId;
@Entity @Entity(name = "users")
public class User implements UserDetails { public class User implements UserDetails {
@Id @Id
private final String id = randomId(); private final String id = randomId();

View file

@ -1,11 +1,13 @@
package com.wbrawner.budgetserver.user; package com.wbrawner.twigs.user;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.PagingAndSortingRepository; import org.springframework.data.repository.PagingAndSortingRepository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface UserRepository extends PagingAndSortingRepository<User, String> { public interface UserRepository extends CrudRepository<User, String>,
PagingAndSortingRepository<User, String> {
Optional<User> findByUsername(String username); Optional<User> findByUsername(String username);
Optional<User> findByUsernameAndPassword(String username, String password); Optional<User> findByUsernameAndPassword(String username, String password);

View file

@ -0,0 +1,37 @@
package com.wbrawner.twigs.user;
import com.wbrawner.twigs.session.Session;
import com.wbrawner.twigs.session.UserSessionRepository;
import org.springframework.beans.factory.annotation.Autowired;
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.stereotype.Service;
import java.util.Objects;
import static com.wbrawner.twigs.Utils.getCurrentUser;
@Service
public class UserService {
private final DaoAuthenticationProvider authenticationProvider;
private final UserRepository userRepository;
private final UserSessionRepository userSessionRepository;
@Autowired
public UserService(DaoAuthenticationProvider authenticationProvider, UserRepository userRepository, UserSessionRepository userSessionRepository) {
this.authenticationProvider = authenticationProvider;
this.userRepository = userRepository;
this.userSessionRepository = userSessionRepository;
}
public Session login(String username, String password) throws AuthenticationException {
var authReq = new UsernamePasswordAuthenticationToken(username, password);
Authentication auth = authenticationProvider.authenticate(authReq);
SecurityContextHolder.getContext().setAuthentication(auth);
var user = Objects.requireNonNull(getCurrentUser());
return userSessionRepository.save(new Session(user.getId()));
}
}

View file

@ -8,7 +8,7 @@ services:
depends_on: depends_on:
- db - db
environment: environment:
SPRING_DATASOURCE_URL: "jdbc:mysql://db:3306/budget?useSSL=false" SPRING_DATASOURCE_URL: "jdbc:postgres://db:5432/twigs"
SPRING_JPA_HIBERNATE_DDL-AUTO: update SPRING_JPA_HIBERNATE_DDL-AUTO: update
SERVER_TOMCAT_MAX-THREADS: 5 SERVER_TOMCAT_MAX-THREADS: 5
TWIGS_CORS_DOMAINS: "http://localhost:4200" TWIGS_CORS_DOMAINS: "http://localhost:4200"
@ -17,14 +17,13 @@ services:
command: sh -c "sleep 5 && /opt/java/openjdk/bin/java $JVM_ARGS -jar /twigs-api.jar" command: sh -c "sleep 5 && /opt/java/openjdk/bin/java $JVM_ARGS -jar /twigs-api.jar"
db: db:
image: mysql:5.7 image: postgres:13
ports: ports:
- "3306:3306" - "5432:5432"
environment: environment:
MYSQL_RANDOM_ROOT_PASSWORD: "yes" POSTGRES_DB: twigs
MYSQL_DATABASE: budget POSTGRES_USER: twigs
MYSQL_USER: budget POSTGRES_PASSWORD: twigs
MYSQL_PASSWORD: budget
networks: networks:
- twigs - twigs
hostname: db hostname: db

View file

@ -1,5 +1,5 @@
#Fri Feb 07 18:11:46 CST 2020 #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-8.0.1-all.zip
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

View file

@ -4,3 +4,6 @@
rootProject.name = 'twigs' rootProject.name = 'twigs'
include ':api' include ':api'
include 'app'
include 'core'