diff --git a/.gitignore b/.gitignore
index 37fdfda..8c660d3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
HELP.md
target/
+/docs/superpowers
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
diff --git a/pom.xml b/pom.xml
index df740a9..83dc34c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -27,7 +27,7 @@
- 17
+ 21
@@ -38,6 +38,15 @@
org.springframework.boot
spring-boot-starter-security
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ com.bucket4j
+ bucket4j-core
+ 8.10.1
+
org.springframework.boot
spring-boot-starter-oauth2-resource-server
@@ -71,6 +80,11 @@
spring-boot-starter-test
test
+
+ com.h2database
+ h2
+ test
+
org.springframework.security
spring-security-test
diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java b/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java
index 597d733..7bb5e8a 100644
--- a/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java
+++ b/src/main/java/de/zendric/app/xpensely_server/controller/AppUserController.java
@@ -1,35 +1,35 @@
package de.zendric.app.xpensely_server.controller;
-import org.springframework.beans.factory.annotation.Autowired;
+import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.DeleteMapping;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.server.ResponseStatusException;
import de.zendric.app.xpensely_server.model.AppUser;
import de.zendric.app.xpensely_server.model.AppUserCreateRequest;
import de.zendric.app.xpensely_server.model.Exception.UsernameAlreadyExistsException;
+import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver;
import de.zendric.app.xpensely_server.services.UserService;
@RestController
@RequestMapping("/api/users")
public class AppUserController {
- private UserService userService;
+ private final UserService userService;
+ private final AuthenticatedUserResolver authenticatedUserResolver;
- @Autowired
- public AppUserController(UserService userService) {
+ public AppUserController(UserService userService, AuthenticatedUserResolver authenticatedUserResolver) {
this.userService = userService;
+ this.authenticatedUserResolver = authenticatedUserResolver;
}
@GetMapping
- public AppUser getUser(@RequestParam Long id) {
- return userService.getUser(id);
+ public ResponseEntity getUser(@RequestParam Long id, Authentication authentication) {
+ AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication);
+ assertSelf(self, id);
+ return ResponseEntity.ok(userService.getUser(id));
}
@GetMapping("/byName")
@@ -38,23 +38,17 @@ public class AppUserController {
}
@GetMapping("/byGoogleId")
- public ResponseEntity getUserByGoogleId(@RequestParam String id) {
- try {
- AppUser userByGoogleId = userService.getUserByGoogleId(id);
- return new ResponseEntity<>(userByGoogleId, HttpStatus.OK);
-
- } catch (IllegalArgumentException e) {
- return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST);
- } catch (Exception e) {
- return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
- }
+ public ResponseEntity getUserByGoogleId(@RequestParam String id, Authentication authentication) {
+ AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication);
+ if (!self.getGoogleId().equals(id))
+ throw new ResponseStatusException(HttpStatus.FORBIDDEN);
+ return ResponseEntity.ok(self);
}
@PostMapping("/createUser")
- public ResponseEntity createUser(@RequestBody AppUserCreateRequest userRequest) {
+ public ResponseEntity createUser(@RequestBody @Valid AppUserCreateRequest userRequest) {
try {
AppUser convertedUser = userRequest.convertToAppUser();
-
AppUser nUser = userService.createUser(convertedUser);
return new ResponseEntity<>(nUser, HttpStatus.CREATED);
} catch (UsernameAlreadyExistsException e) {
@@ -62,12 +56,18 @@ public class AppUserController {
} catch (Exception e) {
return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST);
}
-
}
@DeleteMapping
- public String deleteUser(@RequestParam Long id) {
+ public ResponseEntity deleteUser(@RequestParam Long id, Authentication authentication) {
+ AppUser self = authenticatedUserResolver.resolveCurrentUser(authentication);
+ assertSelf(self, id);
AppUser user = userService.deleteUserById(id);
- return "User deleted : " + user.getUsername();
+ return ResponseEntity.ok("User deleted: " + user.getUsername());
+ }
+
+ private void assertSelf(AppUser authenticated, Long requestedId) {
+ if (!authenticated.getId().equals(requestedId))
+ throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
}
diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java
index efaa067..7d86cf7 100644
--- a/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java
+++ b/src/main/java/de/zendric/app/xpensely_server/controller/ExpenseListController.java
@@ -1,125 +1,71 @@
package de.zendric.app.xpensely_server.controller;
import java.time.LocalDateTime;
-import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
-import org.springframework.beans.factory.annotation.Autowired;
+import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.DeleteMapping;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.PutMapping;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.server.ResponseStatusException;
-import de.zendric.app.xpensely_server.model.AppUser;
-import de.zendric.app.xpensely_server.model.Expense;
-import de.zendric.app.xpensely_server.model.ExpenseChangeRequest;
-import de.zendric.app.xpensely_server.model.ExpenseInput;
-import de.zendric.app.xpensely_server.model.ExpenseList;
-import de.zendric.app.xpensely_server.model.InviteRequest;
-import de.zendric.app.xpensely_server.model.XpenselyStandardCategories;
+import de.zendric.app.xpensely_server.model.*;
+import de.zendric.app.xpensely_server.security.AuthenticatedUserResolver;
import de.zendric.app.xpensely_server.services.CategoryService;
import de.zendric.app.xpensely_server.services.ExpenseListService;
import de.zendric.app.xpensely_server.services.UserService;
@RestController
@RequestMapping("/api/expenselist")
-class ExpenseListController {
+public class ExpenseListController {
- private ExpenseListService expenseListService;
- private UserService userService;
- private CategoryService categoryService;
+ private final ExpenseListService expenseListService;
+ private final UserService userService;
+ private final CategoryService categoryService;
+ private final AuthenticatedUserResolver authenticatedUserResolver;
- @Autowired
public ExpenseListController(ExpenseListService expenseListService, UserService userService,
- CategoryService categoryService) {
+ CategoryService categoryService, AuthenticatedUserResolver authenticatedUserResolver) {
this.expenseListService = expenseListService;
this.userService = userService;
this.categoryService = categoryService;
+ this.authenticatedUserResolver = authenticatedUserResolver;
}
- @GetMapping("/all")
- public ResponseEntity> getAll() {
- try {
- List items = new ArrayList<>();
-
- expenseListService.findAll().forEach(items::add);
-
- if (items.isEmpty())
- return new ResponseEntity<>(HttpStatus.NO_CONTENT);
-
- return new ResponseEntity<>(items, HttpStatus.OK);
- } catch (Exception e) {
- return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
- }
- }
-
- @GetMapping("/byUser")
- public ResponseEntity> getByUser(@RequestParam Long userId) {
- try {
- List items = expenseListService.findByUserId(userId);
-
- if (items.isEmpty())
- return new ResponseEntity<>(HttpStatus.NO_CONTENT);
-
- return new ResponseEntity<>(items, HttpStatus.OK);
- } catch (Exception e) {
- return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
- }
- }
-
- @GetMapping("/byUsername")
- public ResponseEntity> getByUser(@RequestParam String username) {
- try {
- List items = expenseListService.findByUsername(username);
-
- if (items.isEmpty())
- return new ResponseEntity<>(HttpStatus.NO_CONTENT);
-
- return new ResponseEntity<>(items, HttpStatus.OK);
- } catch (Exception e) {
- return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
- }
+ @GetMapping("/mine")
+ public ResponseEntity> getMine(Authentication authentication) {
+ AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication);
+ List items = expenseListService.findByUserId(user.getId());
+ if (items.isEmpty())
+ return new ResponseEntity<>(HttpStatus.NO_CONTENT);
+ return new ResponseEntity<>(items, HttpStatus.OK);
}
@GetMapping("/byId")
- public ResponseEntity getById(@RequestParam Long id) {
+ public ResponseEntity getById(@RequestParam Long id, Authentication authentication) {
+ AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication);
Optional existingItemOptional = expenseListService.findById(id);
-
- if (existingItemOptional.isPresent()) {
- return new ResponseEntity<>(existingItemOptional.get(), HttpStatus.OK);
- } else {
+ if (existingItemOptional.isEmpty())
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
- }
+ assertMember(user, existingItemOptional.get());
+ return new ResponseEntity<>(existingItemOptional.get(), HttpStatus.OK);
}
@PostMapping("/create")
- // TODO add handling of categories by using DTO
- public ResponseEntity create(@RequestBody ExpenseList expenseList) {
+ public ResponseEntity create(@RequestBody ExpenseList expenseList,
+ Authentication authentication) {
try {
- if (expenseList.getOwner() != null) {
- AppUser existingOwner = userService.getUser(expenseList.getOwner().getId());
- if (existingOwner == null) {
- throw new IllegalArgumentException("Owner does not exist.");
- }
- expenseList.setOwner(existingOwner);
- XpenselyStandardCategories standardCategories = categoryService.getDefaultCategories();
- expenseList.setXpenselyStandardCategories(standardCategories);
- } else {
- throw new IllegalArgumentException("Owner is required.");
- }
-
+ AppUser authenticatedUser = authenticatedUserResolver.resolveCurrentUser(authentication);
+ expenseList.setOwner(authenticatedUser);
+ XpenselyStandardCategories standardCategories = categoryService.getDefaultCategories();
+ expenseList.setXpenselyStandardCategories(standardCategories);
expenseList.setSharedWith(null);
-
ExpenseList savedItem = expenseListService.createList(expenseList);
return new ResponseEntity<>(savedItem, HttpStatus.CREATED);
+ } catch (ResponseStatusException e) {
+ throw e;
} catch (Exception e) {
e.printStackTrace();
return new ResponseEntity<>(null, HttpStatus.EXPECTATION_FAILED);
@@ -127,7 +73,12 @@ class ExpenseListController {
}
@DeleteMapping("{id}")
- public ResponseEntity delete(@PathVariable("id") Long id) {
+ public ResponseEntity delete(@PathVariable("id") Long id, Authentication authentication) {
+ AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication);
+ Optional listOpt = expenseListService.findById(id);
+ if (listOpt.isEmpty())
+ return new ResponseEntity<>(HttpStatus.NOT_FOUND);
+ assertOwner(user, listOpt.get());
try {
expenseListService.deleteById(id);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
@@ -139,11 +90,16 @@ class ExpenseListController {
@PostMapping("/{id}/add")
public ResponseEntity addExpenseToList(
@PathVariable("id") Long expenseListId,
- @RequestBody ExpenseInput expenseInput) {
+ @RequestBody @Valid ExpenseInput expenseInput,
+ Authentication authentication) {
+ AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication);
+ Optional listOpt = expenseListService.findById(expenseListId);
+ if (listOpt.isEmpty())
+ return new ResponseEntity<>(HttpStatus.NOT_FOUND);
+ assertMember(user, listOpt.get());
try {
AppUser expenseOwner = userService.getUserByName(expenseInput.getOwner());
Expense expense = expenseInput.convertToExpense(expenseOwner.getId());
-
Expense addedExpense = expenseListService.addExpenseToList(expenseListId, expense);
return new ResponseEntity<>(addedExpense, HttpStatus.CREATED);
} catch (Exception e) {
@@ -154,18 +110,18 @@ class ExpenseListController {
@PutMapping("/{id}/update")
public ResponseEntity updateExpenseInList(
@PathVariable("id") Long expenseListId,
- @RequestBody ExpenseChangeRequest expenseChangeRequest) {
+ @RequestBody @Valid ExpenseChangeRequest expenseChangeRequest,
+ Authentication authentication) {
+ AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication);
+ Optional expenseListOpt = expenseListService.findById(expenseListId);
+ if (expenseListOpt.isEmpty())
+ return new ResponseEntity<>(null, HttpStatus.NOT_FOUND);
+ assertMember(user, expenseListOpt.get());
try {
AppUser expenseOwner = userService.getUserByName(expenseChangeRequest.getOwnerName());
- Optional expenseList = expenseListService.findById(expenseListId);
- if (expenseList.isPresent()) {
- Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseList.get());
-
- Expense addedExpense = expenseListService.updateExpense(expenseListId, expense);
- return new ResponseEntity<>(addedExpense, HttpStatus.CREATED);
- }
- return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST);
-
+ Expense expense = expenseChangeRequest.convertToExpense(expenseOwner.getId(), expenseListOpt.get());
+ Expense updatedExpense = expenseListService.updateExpense(expenseListId, expense);
+ return new ResponseEntity<>(updatedExpense, HttpStatus.OK);
} catch (Exception e) {
return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST);
}
@@ -174,7 +130,13 @@ class ExpenseListController {
@DeleteMapping("/{id}/delete")
public ResponseEntity deleteExpenseFromList(
@PathVariable("id") Long expenseListId,
- @RequestParam("expenseId") Long expenseId) {
+ @RequestParam("expenseId") Long expenseId,
+ Authentication authentication) {
+ AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication);
+ Optional listOpt = expenseListService.findById(expenseListId);
+ if (listOpt.isEmpty())
+ return new ResponseEntity<>(HttpStatus.NOT_FOUND);
+ assertMember(user, listOpt.get());
try {
expenseListService.deleteExpenseFromList(expenseListId, expenseId);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
@@ -184,13 +146,20 @@ class ExpenseListController {
}
@PostMapping("/{listId}/invite")
- public ResponseEntity generateInvite(@PathVariable Long listId) {
+ public ResponseEntity generateInvite(@PathVariable Long listId, Authentication authentication) {
+ AppUser user = authenticatedUserResolver.resolveCurrentUser(authentication);
+ Optional listOpt = expenseListService.findById(listId);
+ if (listOpt.isEmpty())
+ return new ResponseEntity<>(HttpStatus.NOT_FOUND);
+ assertOwner(user, listOpt.get());
String inviteCode = expenseListService.generateInviteCode(listId);
return ResponseEntity.ok(inviteCode);
}
@PostMapping("/accept-invite")
- public ResponseEntity> acceptInvite(@RequestBody InviteRequest inviteRequest) {
+ public ResponseEntity> acceptInvite(@RequestBody @Valid InviteRequest inviteRequest,
+ Authentication authentication) {
+ AppUser authenticatedUser = authenticatedUserResolver.resolveCurrentUser(authentication);
ExpenseList list = expenseListService.findByInviteCode(inviteRequest.getInviteCode());
if (list == null || list.getInviteCodeExpiration() == null ||
@@ -200,21 +169,24 @@ class ExpenseListController {
if (list.getSharedWith() != null) {
return ResponseEntity.status(HttpStatus.IM_USED).body("List has already been shared");
}
- if (list.getOwner().getId() == inviteRequest.getUserId()) {
- return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("You cant join your own List");
- }
- AppUser user = null;
- try {
- user = userService.getUser(inviteRequest.getUserId());
- } catch (Exception e) {
- throw new RuntimeException("User not found");
- }
- if (user != null) {
- list.setSharedWith(user);
- expenseListService.save(list);
- } else {
- throw new RuntimeException("User not found");
+ if (list.getOwner().getId().equals(authenticatedUser.getId())) {
+ return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("You cannot join your own list");
}
+ list.setSharedWith(authenticatedUser);
+ expenseListService.save(list);
return ResponseEntity.ok("User added to the list");
}
+
+ private void assertOwner(AppUser authenticated, ExpenseList list) {
+ if (!list.getOwner().getId().equals(authenticated.getId()))
+ throw new ResponseStatusException(HttpStatus.FORBIDDEN);
+ }
+
+ private void assertMember(AppUser authenticated, ExpenseList list) {
+ boolean isOwner = list.getOwner().getId().equals(authenticated.getId());
+ boolean isShared = list.getSharedWith() != null
+ && list.getSharedWith().getId().equals(authenticated.getId());
+ if (!isOwner && !isShared)
+ throw new ResponseStatusException(HttpStatus.FORBIDDEN);
+ }
}
diff --git a/src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java b/src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java
new file mode 100644
index 0000000..efbc72d
--- /dev/null
+++ b/src/main/java/de/zendric/app/xpensely_server/controller/GlobalExceptionHandler.java
@@ -0,0 +1,30 @@
+package de.zendric.app.xpensely_server.controller;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity