diff --git a/.classpath b/.classpath new file mode 100644 index 0000000..25127bf --- /dev/null +++ b/.classpath @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.factorypath b/.factorypath new file mode 100644 index 0000000..38d6468 --- /dev/null +++ b/.factorypath @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87a2d61 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/logs/ +/run-local +*.log +/target/ \ No newline at end of file diff --git a/.project b/.project new file mode 100644 index 0000000..b019c30 --- /dev/null +++ b/.project @@ -0,0 +1,39 @@ + + + budget-api + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.springframework.ide.eclipse.boot.validation.springbootbuilder + + + + + org.eclipse.m2e.core.maven2Builder + + + + + + org.eclipse.jdt.core.javanature + org.eclipse.m2e.core.maven2Nature + + + + 1626184189329 + + 30 + + org.eclipse.core.resources.regexFilterMatcher + node_modules|.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ + + + + diff --git a/.settings/org.eclipse.core.resources.prefs b/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 0000000..abdea9a --- /dev/null +++ b/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,4 @@ +eclipse.preferences.version=1 +encoding//src/main/java=UTF-8 +encoding//src/main/resources=UTF-8 +encoding/=UTF-8 diff --git a/.settings/org.eclipse.jdt.apt.core.prefs b/.settings/org.eclipse.jdt.apt.core.prefs new file mode 100644 index 0000000..dfa4f3a --- /dev/null +++ b/.settings/org.eclipse.jdt.apt.core.prefs @@ -0,0 +1,4 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.apt.aptEnabled=true +org.eclipse.jdt.apt.genSrcDir=target/generated-sources/annotations +org.eclipse.jdt.apt.genTestSrcDir=target/generated-test-sources/test-annotations diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs new file mode 100644 index 0000000..47dcc0b --- /dev/null +++ b/.settings/org.eclipse.jdt.core.prefs @@ -0,0 +1,14 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.core.compiler.codegen.methodParameters=generate +org.eclipse.jdt.core.compiler.codegen.targetPlatform=17 +org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve +org.eclipse.jdt.core.compiler.compliance=17 +org.eclipse.jdt.core.compiler.debug.lineNumber=generate +org.eclipse.jdt.core.compiler.debug.localVariable=generate +org.eclipse.jdt.core.compiler.debug.sourceFile=generate +org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled +org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning +org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=warning +org.eclipse.jdt.core.compiler.processAnnotations=enabled +org.eclipse.jdt.core.compiler.release=disabled +org.eclipse.jdt.core.compiler.source=17 diff --git a/.settings/org.eclipse.m2e.core.prefs b/.settings/org.eclipse.m2e.core.prefs new file mode 100644 index 0000000..f897a7f --- /dev/null +++ b/.settings/org.eclipse.m2e.core.prefs @@ -0,0 +1,4 @@ +activeProfiles= +eclipse.preferences.version=1 +resolveWorkspaceProjects=true +version=1 diff --git a/.settings/org.springframework.ide.eclipse.prefs b/.settings/org.springframework.ide.eclipse.prefs new file mode 100644 index 0000000..a12794d --- /dev/null +++ b/.settings/org.springframework.ide.eclipse.prefs @@ -0,0 +1,2 @@ +boot.validation.initialized=true +eclipse.preferences.version=1 diff --git a/README.md b/README.md index be8f126..c198d2d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ -# com.stephenschafer.budget.api +# Budget REST API + +Provides a REST API to support my React Budget Application. + +Built from this example as a starting point: [Spring Boot Security JWT Authentication](http://www.devglan.com/spring-security/spring-boot-jwt-auth) -Back-end to budget react app \ No newline at end of file diff --git a/build b/build new file mode 100755 index 0000000..39d9343 --- /dev/null +++ b/build @@ -0,0 +1,11 @@ +#!/bin/sh +cd "$(dirname "${BASH_SOURCE[0]}")" +ROOT=$(pwd) +./stop +# export JAVA_HOME="/usr/lib/jvm/java-11-openjdk" +mkdir -p logs +if ! mvn clean package > logs/build.log 2> logs/build.err.log; then + echo "build failed" + exit 1 +fi +echo "success" \ No newline at end of file diff --git a/deploy b/deploy new file mode 100755 index 0000000..b3352c9 --- /dev/null +++ b/deploy @@ -0,0 +1,5 @@ +#!/bin/sh +cd "$(dirname "${BASH_SOURCE[0]}")" +ssh pi@raspi "./budget/backup" +scp $(find target -name "*.jar") pi@raspi:~/budget +ssh pi@raspi "./budget/start" diff --git a/eclipse b/eclipse new file mode 100755 index 0000000..748a886 --- /dev/null +++ b/eclipse @@ -0,0 +1,6 @@ +#!/bin/sh +cd "$(dirname "${BASH_SOURCE[0]}")" +LOG=./logs +mkdir -p $LOG +/home/eclipse/sts-4.30.0.RELEASE/SpringToolSuite4 -data .. \ + >$LOG/eclipse-sts.log 2>$LOG/eclipse-sts.err.log & diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..66c1755 --- /dev/null +++ b/pom.xml @@ -0,0 +1,98 @@ + + + 4.0.0 + + com.stephenschafer + budget + 0.0.1-SNAPSHOT + jar + + + org.springframework.boot + spring-boot-starter-parent + 3.4.7 + + + + + + joda-time + joda-time + 2.10.9 + + + + + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.springframework.boot + spring-boot-starter-security + + + org.thymeleaf.extras + thymeleaf-extras-springsecurity6 + + 3.1.1.RELEASE + + + org.projectlombok + lombok + + + io.jsonwebtoken + jjwt + 0.9.0 + + + com.mysql + mysql-connector-j + runtime + + + joda-time + joda-time + + + org.apache.commons + commons-csv + 1.5 + + + javax.xml.bind + jaxb-api + 2.3.0 + + + + + 17 + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/run b/run new file mode 100755 index 0000000..5956b43 --- /dev/null +++ b/run @@ -0,0 +1,33 @@ +#!/bin/bash +cd "$(dirname "${BASH_SOURCE[0]}")" +ROOT=$(pwd) +./stop +mkdir -p logs +rm -f $ROOT/logs/run-*.log +SUSPEND="n" +ARGS="" +while (( "$#" )); do + case $1 in + suspend) + SUSPEND="y" + ;; + init) + ARGS="init" + ;; + *) + echo "Unrecognized argument" + exit 1 + ;; + esac + shift +done +JVM_ARGS="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=$SUSPEND,address=8004" +# JAVA_HOME="/usr/lib/jvm/java-11-openjdk" +$JAVA_HOME/bin/java $JVM_ARGS -jar $(find target -name "*.jar") $ARGS\ + --server.port=$PORT\ + --spring.datasource.url=$DB_URL\ + --spring.datasource.username=$DB_USERNAME\ + --spring.datasource.password=$DB_PASSWORD\ + > $ROOT/logs/run-budget.log 2> $ROOT/logs/run-budget.err.log & +echo "$!" > $ROOT/logs/run-budget.pid +echo "running" \ No newline at end of file diff --git a/spring-boot-jwt.iml b/spring-boot-jwt.iml new file mode 100644 index 0000000..5e42dc2 --- /dev/null +++ b/spring-boot-jwt.iml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/java/com/stephenschafer/budget/ApiResponse.java b/src/main/java/com/stephenschafer/budget/ApiResponse.java new file mode 100644 index 0000000..76e47ed --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/ApiResponse.java @@ -0,0 +1,14 @@ +package com.stephenschafer.budget; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class ApiResponse { + private int status; + private String message; + private T result; +} diff --git a/src/main/java/com/stephenschafer/budget/Application.java b/src/main/java/com/stephenschafer/budget/Application.java new file mode 100644 index 0000000..360bc9b --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/Application.java @@ -0,0 +1,55 @@ +package com.stephenschafer.budget; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +@SpringBootApplication +public class Application { + @Autowired + private BCryptPasswordEncoder passwordEncoder; + + public static void main(final String[] args) { + SpringApplication.run(Application.class, args); + } + + @Bean + CommandLineRunner init(final UserDao userDao) { + return args -> { + if (args.length >= 1 && args[0].equals("init")) { + final UserEntity user1 = new UserEntity(); + user1.setFirstName("Devglan"); + user1.setLastName("Devglan"); + user1.setUsername("devglan"); + user1.setPassword(passwordEncoder.encode("devglan")); + userDao.save(user1); + final UserEntity user2 = new UserEntity(); + user2.setFirstName("John"); + user2.setLastName("Doe"); + user2.setUsername("john"); + user2.setPassword(passwordEncoder.encode("john")); + userDao.save(user2); + } + else { + } + }; + } + + @Bean + RegexDao getRegexDao() { + return new RegexDaoImpl(); + } + + @Bean + CategoryDao getCategoryDao() { + return new CategoryDaoImpl(); + } + + @Bean + TransactionDao getTransactionDao() { + return new TransactionDaoImpl(); + } +} diff --git a/src/main/java/com/stephenschafer/budget/AuthToken.java b/src/main/java/com/stephenschafer/budget/AuthToken.java new file mode 100644 index 0000000..edcce54 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/AuthToken.java @@ -0,0 +1,15 @@ +package com.stephenschafer.budget; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class AuthToken { + private String token; + private String username; +} diff --git a/src/main/java/com/stephenschafer/budget/AuthenticationController.java b/src/main/java/com/stephenschafer/budget/AuthenticationController.java new file mode 100644 index 0000000..a2a1a26 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/AuthenticationController.java @@ -0,0 +1,49 @@ +package com.stephenschafer.budget; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.AuthenticationException; +import org.springframework.web.bind.annotation.CrossOrigin; +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.RestController; + +@CrossOrigin +@RestController +@RequestMapping("/token") +public class AuthenticationController { + @Autowired + private AuthenticationManager authenticationManager; + @Autowired + private JwtTokenUtil jwtTokenUtil; + @Autowired + private UserService userService; + + @CrossOrigin + @PostMapping("/generate-token") + public ApiResponse register(@RequestBody final LoginUser loginUser) + throws AuthenticationException { + authenticationManager.authenticate(new UsernamePasswordAuthenticationToken( + loginUser.getUsername(), loginUser.getPassword())); + final UserEntity user = userService.findByUsername(loginUser.getUsername()); + final String token = jwtTokenUtil.generateToken(user); + return new ApiResponse<>(200, "success", new AuthToken(token, user.getUsername())); + } + + @CrossOrigin + @PostMapping("/register") + public ApiResponse saveUser(@RequestBody final UserDto loginUser) { + final UserEntity userEntity = userService.findByUsername(loginUser.getUsername()); + if (userEntity != null) { + return new ApiResponse<>(400, "username already exists", null); + } + userService.save(loginUser); + authenticationManager.authenticate(new UsernamePasswordAuthenticationToken( + loginUser.getUsername(), loginUser.getPassword())); + final UserEntity user = userService.findByUsername(loginUser.getUsername()); + final String token = jwtTokenUtil.generateToken(user); + return new ApiResponse<>(200, "success", new AuthToken(token, user.getUsername())); + } +} diff --git a/src/main/java/com/stephenschafer/budget/Category.java b/src/main/java/com/stephenschafer/budget/Category.java new file mode 100644 index 0000000..24c2992 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/Category.java @@ -0,0 +1,23 @@ +package com.stephenschafer.budget; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@AllArgsConstructor +@ToString +public class Category { + public Category() { + } + + public Category(final int intValue, final Category category) { + this(intValue, category.getParentCategoryId(), category.getName()); + } + + Integer id; + Integer parentCategoryId; + String name; +} diff --git a/src/main/java/com/stephenschafer/budget/CategoryController.java b/src/main/java/com/stephenschafer/budget/CategoryController.java new file mode 100644 index 0000000..46568a2 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/CategoryController.java @@ -0,0 +1,124 @@ +package com.stephenschafer.budget; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.CrossOrigin; +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.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@CrossOrigin(origins = "*", maxAge = 3600) +@RestController +public class CategoryController { + @Autowired + private CategoryDao categoryDao; + @Autowired + private UserService userService; + + @PostMapping("/categories") + @ResponseBody + public ApiResponse postCategory(@RequestBody final Category category, + final HttpServletRequest req) { + if (!userService.isAuthorized(req)) { + return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(), + "You are not authorized to do this", null); + } + return new ApiResponse<>(HttpStatus.OK.value(), "Category inserted successfully", + categoryDao.add(category)); + } + + @PutMapping("/categories") + @ResponseBody + public ApiResponse putCategory(@RequestBody final Category category, + final HttpServletRequest req) { + if (!userService.isAuthorized(req)) { + return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(), + "You are not authorized to do this", null); + } + categoryDao.update(category); + return new ApiResponse<>(HttpStatus.OK.value(), "Category updated successfully", category); + } + + @GetMapping("/categories") + @ResponseBody + public ApiResponse> getCategories(final HttpServletRequest request) { + log.info("GET /categories"); + if (!userService.isAuthorized(request)) { + return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(), + "You are not authorized to do this", null); + } + final List categories = new ArrayList<>(); + categoryDao.getAll(category -> { + categories.add(category); + }); + return new ApiResponse<>(HttpStatus.OK.value(), "Category list fetched successfully", + categories); + } + + @GetMapping("/categories/parent/{parentCategoryId}") + @ResponseBody + public ApiResponse> getCategoriesByCategory( + @PathVariable(required = true) final Integer parentCategoryId, + final HttpServletRequest request) { + log.info("GET /categories/parent/" + parentCategoryId); + if (!userService.isAuthorized(request)) { + return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(), + "You are not authorized to do this", null); + } + final Optional parentCategory = categoryDao.getById(parentCategoryId); + if (!parentCategory.isPresent()) { + return new ApiResponse<>(HttpStatus.NOT_FOUND.value(), "Parent category not found", + null); + } + final int categoryId = parentCategory.get().getId(); + final List categories = new ArrayList<>(); + categoryDao.getByParent(categoryId, category -> { + categories.add(category); + }); + return new ApiResponse<>(HttpStatus.OK.value(), "Category list fetched successfully", + categories); + } + + @GetMapping("/categories/root") + @ResponseBody + public ApiResponse> getRootCategories(final HttpServletRequest request) { + log.info("GET /categories/root"); + if (!userService.isAuthorized(request)) { + return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(), + "You are not authorized to do this", null); + } + final List categories = new ArrayList<>(); + categoryDao.getRoot(category -> { + categories.add(category); + }); + return new ApiResponse<>(HttpStatus.OK.value(), "Category list fetched successfully", + categories); + } + + @GetMapping("/categories/ancestry/{categoryId}") + @ResponseBody + public ApiResponse> getCategoryAncestry( + @PathVariable(required = true) final Integer categoryId, + final HttpServletRequest request) { + log.info("GET /categories/ancestry/" + categoryId); + if (!userService.isAuthorized(request)) { + return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(), + "You are not authorized to do this", null); + } + final List ancestry = categoryDao.getCategoryAncestry(categoryId); + return new ApiResponse<>(HttpStatus.OK.value(), "Category ancestry fetched successfully", + ancestry); + } +} diff --git a/src/main/java/com/stephenschafer/budget/CategoryDao.java b/src/main/java/com/stephenschafer/budget/CategoryDao.java new file mode 100644 index 0000000..69d5473 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/CategoryDao.java @@ -0,0 +1,25 @@ +package com.stephenschafer.budget; + +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +public interface CategoryDao { + int deleteById(int id); + + Optional getById(int id); + + Optional getByName(String name); + + List getCategoryAncestry(int categoryId); + + Category add(Category category); + + void update(Category category); + + void getAll(Consumer consumer); + + void getByParent(int parentCategoryId, Consumer consumer); + + void getRoot(Consumer consumer); +} diff --git a/src/main/java/com/stephenschafer/budget/CategoryDaoImpl.java b/src/main/java/com/stephenschafer/budget/CategoryDaoImpl.java new file mode 100644 index 0000000..798e387 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/CategoryDaoImpl.java @@ -0,0 +1,230 @@ +package com.stephenschafer.budget; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Types; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.PreparedStatementCreator; +import org.springframework.jdbc.core.PreparedStatementCreatorFactory; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class CategoryDaoImpl implements CategoryDao { + @Autowired + private JdbcTemplate jdbcTemplate; + + @Override + public int deleteById(final int id) { + return jdbcTemplate.update("delete from budget.category where id = ?", id); + } + + @Override + public Optional getById(final int id) { + final String sql = "select coalesce(parent_category_id, 0), name" + + " from budget.category where id = ?"; + try { + return jdbcTemplate.queryForObject(sql, (rs, rowNum) -> { + final int parentCategoryId = rs.getInt(1); + final boolean parentCategoryIdWasNull = rs.wasNull(); + final String name = rs.getString(2); + return Optional.of(new Category(id, + parentCategoryIdWasNull ? null : Integer.valueOf(parentCategoryId), name)); + }, id); + } + catch (final DataAccessException e) { + return Optional.empty(); + } + } + + @Override + public Optional getByName(final String name) { + final String[] parts = name.split("\\."); + Category category = null; + for (final String part : parts) { + Optional optional; + if (category == null) { + final String sql = "select id" + + " from budget.category where coalesce(parent_category_id, 0) = 0 and name = ?"; + optional = jdbcTemplate.queryForObject(sql, (rs, rowNum) -> { + final int id = rs.getInt(1); + return Optional.of(new Category(id, null, part)); + }, part); + } + else { + final Integer parentId = category.getId(); + final String sql = "select id" + + " from budget.category where coalesce(parent_category_id, 0) = ? and name = ?"; + optional = jdbcTemplate.queryForObject(sql, (rs, rowNum) -> { + final int id = rs.getInt(1); + return Optional.of(new Category(id, parentId, part)); + }, parentId, part); + } + if (!optional.isPresent()) { + return optional; + } + category = optional.get(); + } + return Optional.of(category); + } + + @Override + public List getCategoryAncestry(final int categoryId) { + return getCategoryAncestry(null, categoryId); + } + + private List getCategoryAncestry(final List ancestry, + final int categoryId) { + final Optional optionalCategory = getById(categoryId); + if (!optionalCategory.isPresent()) { + return ancestry; + } + final Category category = optionalCategory.get(); + final List result = new ArrayList<>(); + result.add(category); + if (ancestry != null) { + result.addAll(ancestry); + } + if (category.parentCategoryId == null) { + return result; + } + return getCategoryAncestry(result, category.parentCategoryId); + } + + @Override + public Category add(final Category category) { + log.info("CategoryDaoImpl.add " + category); + final String sql = "insert into budget.category" + " (parent_category_id, name)" + + " values (?, ?)"; + final PreparedStatementCreatorFactory factory = new PreparedStatementCreatorFactory(sql, + Types.INTEGER, Types.VARCHAR); + factory.setReturnGeneratedKeys(true); + factory.setGeneratedKeysColumnNames("id"); + final PreparedStatementCreator creator = factory.newPreparedStatementCreator( + new Object[] { category.getParentCategoryId(), category.getName() }); + final KeyHolder keyHolder = new GeneratedKeyHolder(); + final int rowCount = jdbcTemplate.update(creator, keyHolder); + if (rowCount == 0) { + return null; + } + final Number generatedId = keyHolder.getKey(); + return new Category(generatedId.intValue(), category); + } + + @Override + public void update(final Category category) { + log.info("CategoryDaoImpl.update " + category); + final String sql = "update budget.category" + " set parent_category_id = ?, name = ?" + + " where id = ?"; + final PreparedStatementCreatorFactory factory = new PreparedStatementCreatorFactory(sql, + Types.INTEGER, Types.VARCHAR, Types.INTEGER); + PreparedStatementCreator creator; + try { + creator = factory.newPreparedStatementCreator(new Object[] { + category.getParentCategoryId(), category.getName(), category.getId() }); + } + catch (final Exception e) { + log.error("failed to get creator"); + e.printStackTrace(); + return; + } + log.info("update creator = " + creator); + jdbcTemplate.update(creator); + } + + private static class PreparedStatementHolder { + PreparedStatement statement; + } + + @Override + public void getAll(final Consumer consumer) { + final PreparedStatementHolder holder = new PreparedStatementHolder(); + final String sql = "select" + " id, coalesce(parent_category_id, 0), name" + + " from budget.category" + " order by id"; + final PreparedStatementCreator creator = connection -> { + holder.statement = connection.prepareStatement(sql); + return holder.statement; + }; + try { + jdbcTemplate.query(creator, rs -> { + int i = 0; + final int id = rs.getInt(++i); + final int parentCategoryId = rs.getInt(++i); + final String name = rs.getString(++i); + consumer.accept(new Category(id, parentCategoryId, name)); + }); + } + catch (final StopException e) { + try { + holder.statement.cancel(); + } + catch (final SQLException e1) { + log.error("getByCategory failed", e1); + } + } + } + + @Override + public void getByParent(final int parentCategoryId, final Consumer consumer) { + final PreparedStatementHolder holder = new PreparedStatementHolder(); + final String sql = "select" + " id, name" + " from budget.category" + + " where coalesce(parent_category_id, 0) = ?" + " order by id"; + final PreparedStatementCreator creator = connection -> { + holder.statement = connection.prepareStatement(sql); + holder.statement.setInt(1, parentCategoryId); + return holder.statement; + }; + try { + jdbcTemplate.query(creator, rs -> { + int i = 0; + final int id = rs.getInt(++i); + final String name = rs.getString(++i); + consumer.accept(new Category(id, parentCategoryId, name)); + }); + } + catch (final StopException e) { + try { + holder.statement.cancel(); + } + catch (final SQLException e1) { + log.error("getByCategory failed", e1); + } + } + } + + @Override + public void getRoot(final Consumer consumer) { + final PreparedStatementHolder holder = new PreparedStatementHolder(); + final String sql = "select id, name from budget.category" + + " where coalesce(parent_category_id, 0) = 0 order by id"; + final PreparedStatementCreator creator = connection -> { + holder.statement = connection.prepareStatement(sql); + return holder.statement; + }; + try { + jdbcTemplate.query(creator, rs -> { + int i = 0; + final int id = rs.getInt(++i); + final String name = rs.getString(++i); + consumer.accept(new Category(id, null, name)); + }); + } + catch (final StopException e) { + try { + holder.statement.cancel(); + } + catch (final SQLException e1) { + log.error("getRoot failed", e1); + } + } + } +} diff --git a/src/main/java/com/stephenschafer/budget/Constants.java b/src/main/java/com/stephenschafer/budget/Constants.java new file mode 100644 index 0000000..8138ff5 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/Constants.java @@ -0,0 +1,8 @@ +package com.stephenschafer.budget; + +public class Constants { + public static final long ACCESS_TOKEN_VALIDITY_SECONDS = 24L * 60L * 60L; + public static final String SIGNING_KEY = "sschafer123r"; + public static final String TOKEN_PREFIX = "Bearer "; + public static final String HEADER_STRING = "Authorization"; +} diff --git a/src/main/java/com/stephenschafer/budget/CustomCorsConfiguration.java b/src/main/java/com/stephenschafer/budget/CustomCorsConfiguration.java new file mode 100644 index 0000000..14117b9 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/CustomCorsConfiguration.java @@ -0,0 +1,21 @@ +package com.stephenschafer.budget; + +import java.util.List; + +import org.springframework.stereotype.Component; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; + +import jakarta.servlet.http.HttpServletRequest; + +@Component +public class CustomCorsConfiguration implements CorsConfigurationSource { + @Override + public CorsConfiguration getCorsConfiguration(final HttpServletRequest request) { + final CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of("http://localhost:3000")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + return config; + } +} diff --git a/src/main/java/com/stephenschafer/budget/ExceptionAdvice.java b/src/main/java/com/stephenschafer/budget/ExceptionAdvice.java new file mode 100644 index 0000000..890064c --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/ExceptionAdvice.java @@ -0,0 +1,13 @@ +package com.stephenschafer.budget; + +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class ExceptionAdvice { + @ExceptionHandler(RuntimeException.class) + public ApiResponse handleNotFoundException(final RuntimeException ex) { + final ApiResponse apiResponse = new ApiResponse<>(400, "Bad request", null); + return apiResponse; + } +} diff --git a/src/main/java/com/stephenschafer/budget/FileDao.java b/src/main/java/com/stephenschafer/budget/FileDao.java new file mode 100644 index 0000000..62a717d --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/FileDao.java @@ -0,0 +1,9 @@ +package com.stephenschafer.budget; + +import java.util.Optional; + +import org.springframework.data.repository.CrudRepository; + +public interface FileDao extends CrudRepository { + Optional findByName(String filename); +} diff --git a/src/main/java/com/stephenschafer/budget/FileEntity.java b/src/main/java/com/stephenschafer/budget/FileEntity.java new file mode 100644 index 0000000..aa86ba1 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/FileEntity.java @@ -0,0 +1,29 @@ +package com.stephenschafer.budget; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Entity +@Table(name = "file") +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@ToString +public class FileEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + @Column + private String name; +} diff --git a/src/main/java/com/stephenschafer/budget/FindProjectResult.java b/src/main/java/com/stephenschafer/budget/FindProjectResult.java new file mode 100644 index 0000000..bf9241c --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/FindProjectResult.java @@ -0,0 +1,11 @@ +package com.stephenschafer.budget; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class FindProjectResult { + private int id; + private String code; +} \ No newline at end of file diff --git a/src/main/java/com/stephenschafer/budget/JwtAuthenticationEntryPoint.java b/src/main/java/com/stephenschafer/budget/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..650fcf1 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/JwtAuthenticationEntryPoint.java @@ -0,0 +1,26 @@ +package com.stephenschafer.budget; + +import java.io.IOException; +import java.io.Serial; +import java.io.Serializable; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable { + @Serial + private static final long serialVersionUID = 1L; + + @Override + public void commence(final HttpServletRequest request, final HttpServletResponse response, + final AuthenticationException authException) throws IOException { + // This is invoked when user tries to access a secured REST resource without supplying any credentials + // We should just send a 401 Unauthorized response because there is no 'login page' to redirect to + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); + } +} \ No newline at end of file diff --git a/src/main/java/com/stephenschafer/budget/JwtAuthenticationFilter.java b/src/main/java/com/stephenschafer/budget/JwtAuthenticationFilter.java new file mode 100644 index 0000000..ede2568 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/JwtAuthenticationFilter.java @@ -0,0 +1,69 @@ +package com.stephenschafer.budget; + +import static com.stephenschafer.budget.Constants.HEADER_STRING; +import static com.stephenschafer.budget.Constants.TOKEN_PREFIX; + +import java.io.IOException; +import java.util.Arrays; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.web.filter.OncePerRequestFilter; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.SignatureException; + +public class JwtAuthenticationFilter extends OncePerRequestFilter { + @Autowired + private UserDetailsService userDetailsService; + @Autowired + private JwtTokenUtil jwtTokenUtil; + + @Override + protected void doFilterInternal(final HttpServletRequest req, final HttpServletResponse res, + final FilterChain chain) throws IOException, ServletException { + final String header = req.getHeader(HEADER_STRING); + String username = null; + String authToken = null; + if (header != null && header.startsWith(TOKEN_PREFIX)) { + authToken = header.replace(TOKEN_PREFIX, ""); + try { + username = jwtTokenUtil.getUsernameFromToken(authToken); + } + catch (final IllegalArgumentException e) { + logger.error("an error occured during getting username from token", e); + } + catch (final ExpiredJwtException e) { + logger.warn("the token is expired and not valid anymore", e); + } + catch (final SignatureException e) { + logger.error("Authentication Failed. Username or Password not valid."); + } + } + else { + logger.warn("couldn't find bearer string, will ignore the header"); + } + req.setAttribute("username", username); + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + final UserDetails userDetails = userDetailsService.loadUserByUsername(username); + if (jwtTokenUtil.validateToken(authToken, userDetails)) { + final UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, Arrays.asList(new SimpleGrantedAuthority("ROLE_ADMIN"))); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(req)); + logger.info("authenticated user " + username + ", setting security context"); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } + chain.doFilter(req, res); + } +} \ No newline at end of file diff --git a/src/main/java/com/stephenschafer/budget/JwtTokenUtil.java b/src/main/java/com/stephenschafer/budget/JwtTokenUtil.java new file mode 100644 index 0000000..d69a4de --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/JwtTokenUtil.java @@ -0,0 +1,65 @@ +package com.stephenschafer.budget; + +import static com.stephenschafer.budget.Constants.ACCESS_TOKEN_VALIDITY_SECONDS; +import static com.stephenschafer.budget.Constants.SIGNING_KEY; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Arrays; +import java.util.Date; +import java.util.function.Function; + +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; + +@Component +public class JwtTokenUtil implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + public String getUsernameFromToken(final String token) { + return getClaimFromToken(token, Claims::getSubject); + } + + public Date getExpirationDateFromToken(final String token) { + return getClaimFromToken(token, Claims::getExpiration); + } + + public T getClaimFromToken(final String token, final Function claimsResolver) { + final Claims claims = getAllClaimsFromToken(token); + return claimsResolver.apply(claims); + } + + private Claims getAllClaimsFromToken(final String token) { + return Jwts.parser().setSigningKey(SIGNING_KEY).parseClaimsJws(token).getBody(); + } + + private Boolean isTokenExpired(final String token) { + final Date expiration = getExpirationDateFromToken(token); + return expiration.before(new Date()); + } + + public String generateToken(final UserEntity user) { + return doGenerateToken(user.getUsername()); + } + + private String doGenerateToken(final String subject) { + final Claims claims = Jwts.claims().setSubject(subject); + claims.put("scopes", Arrays.asList(new SimpleGrantedAuthority("ROLE_ADMIN"))); + return Jwts.builder().setClaims(claims).setIssuer("http://stephenschafer.com").setIssuedAt( + new Date(System.currentTimeMillis())).setExpiration( + new Date(System.currentTimeMillis() + + ACCESS_TOKEN_VALIDITY_SECONDS * 1000L)).signWith(SignatureAlgorithm.HS256, + SIGNING_KEY).compact(); + } + + public Boolean validateToken(final String token, final UserDetails userDetails) { + final String username = getUsernameFromToken(token); + return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); + } +} diff --git a/src/main/java/com/stephenschafer/budget/LoginUser.java b/src/main/java/com/stephenschafer/budget/LoginUser.java new file mode 100644 index 0000000..dbd59eb --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/LoginUser.java @@ -0,0 +1,11 @@ +package com.stephenschafer.budget; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class LoginUser { + private String username; + private String password; +} diff --git a/src/main/java/com/stephenschafer/budget/MyUserDetails.java b/src/main/java/com/stephenschafer/budget/MyUserDetails.java new file mode 100644 index 0000000..d5227fd --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/MyUserDetails.java @@ -0,0 +1,33 @@ +package com.stephenschafer.budget; + +import java.util.Arrays; +import java.util.Collection; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +public class MyUserDetails implements UserDetails { + private static final long serialVersionUID = 1L; + private final UserEntity user; + + public MyUserDetails(final UserEntity user) { + this.user = user; + } + + @Override + public Collection getAuthorities() { + final SimpleGrantedAuthority authority = new SimpleGrantedAuthority(user.getRole()); + return Arrays.asList(authority); + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getUsername(); + } +} diff --git a/src/main/java/com/stephenschafer/budget/PreparedStatementHolder.java b/src/main/java/com/stephenschafer/budget/PreparedStatementHolder.java new file mode 100644 index 0000000..8fc7172 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/PreparedStatementHolder.java @@ -0,0 +1,7 @@ +package com.stephenschafer.budget; + +import java.sql.PreparedStatement; + +public class PreparedStatementHolder { + PreparedStatement statement; +} \ No newline at end of file diff --git a/src/main/java/com/stephenschafer/budget/Regex.java b/src/main/java/com/stephenschafer/budget/Regex.java new file mode 100644 index 0000000..3262831 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/Regex.java @@ -0,0 +1,29 @@ +package com.stephenschafer.budget; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@AllArgsConstructor +@ToString +public class Regex { + public Regex() { + } + + public Regex(final int id, final Regex regex) { + this(id, regex.categoryId, regex.regex, regex.flags, regex.source, regex.priority, + regex.description, regex.year); + } + + Integer id; + Integer categoryId; + String regex; + int flags; + String source; + int priority; + String description; + Integer year; +} diff --git a/src/main/java/com/stephenschafer/budget/RegexController.java b/src/main/java/com/stephenschafer/budget/RegexController.java new file mode 100644 index 0000000..cde146c --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/RegexController.java @@ -0,0 +1,130 @@ +package com.stephenschafer.budget; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.CrossOrigin; +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.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@CrossOrigin(origins = "*", maxAge = 3600) +@RestController +public class RegexController { + @Autowired + private RegexDao regexDao; + @Autowired + private CategoryDao categoryDao; + @Autowired + private UserService userService; + + @PostMapping("/regexes") + @ResponseBody + public ApiResponse postRegex(@RequestBody final Regex regex, + final HttpServletRequest req) { + if (!userService.isAuthorized(req)) { + return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(), + "You are not authorized to do this", null); + } + return new ApiResponse<>(HttpStatus.OK.value(), "Regex inserted successfully", + regexDao.add(regex)); + } + + @PutMapping("/regexes") + @ResponseBody + public ApiResponse putRegex(@RequestBody final Regex regex, + final HttpServletRequest req) { + if (!userService.isAuthorized(req)) { + return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(), + "You are not authorized to do this", null); + } + regexDao.update(regex); + return new ApiResponse<>(HttpStatus.OK.value(), "Regex updated successfully", regex); + } + + @GetMapping("/regexes") + @ResponseBody + public ApiResponse> getRegexes(final HttpServletRequest request) { + log.info("GET /regexes"); + if (!userService.isAuthorized(request)) { + return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(), + "You are not authorized to do this", null); + } + final List regexDisplays = new ArrayList<>(); + regexDao.getAllDisplay(regexDisplay -> { + regexDisplays.add(regexDisplay); + }); + return new ApiResponse<>(HttpStatus.OK.value(), "Regex list fetched successfully", + regexDisplays); + } + + @GetMapping("/regex/{regexId}") + @ResponseBody + public ApiResponse getRegex(@PathVariable(required = true) final Integer regexId, + final HttpServletRequest request) { + log.info("GET /regex/" + regexId); + if (!userService.isAuthorized(request)) { + return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(), + "You are not authorized to do this", null); + } + if (regexId == null) { + return new ApiResponse<>(HttpStatus.BAD_REQUEST.value(), "Regex ID not specified", + null); + } + final Optional optionalRegex = regexDao.getById(regexId.intValue()); + if (!optionalRegex.isPresent()) { + return new ApiResponse<>(HttpStatus.NOT_FOUND.value(), "Regex ID not found", null); + } + final Regex regex = optionalRegex.get(); + return new ApiResponse<>(HttpStatus.OK.value(), "Regex retrieved successfully", regex); + } + + @GetMapping("/regexes/category/{categoryName}") + @ResponseBody + public ApiResponse> getRegexesByCategory( + @PathVariable(required = true) final String categoryName, + final HttpServletRequest request) { + log.info("GET /regexes/" + categoryName); + if (!userService.isAuthorized(request)) { + return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(), + "You are not authorized to do this", null); + } + final Optional category = categoryDao.getByName(categoryName); + if (!category.isPresent()) { + return new ApiResponse<>(HttpStatus.NOT_FOUND.value(), "Category not found", null); + } + final int categoryId = category.get().getId(); + final List regexes = new ArrayList<>(); + regexDao.getByCategory(categoryId, regex -> { + regexes.add(regex); + }); + return new ApiResponse<>(HttpStatus.OK.value(), "Regex list fetched successfully", regexes); + } + + @GetMapping("/regexes/source/{source}") + @ResponseBody + public ApiResponse> getRegexesBySource( + @PathVariable(required = true) final String source, final HttpServletRequest request) { + log.info("GET /regexes/source/" + source); + if (!userService.isAuthorized(request)) { + return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(), + "You are not authorized to do this", null); + } + final List regexes = new ArrayList<>(); + regexDao.getBySource(source, regex -> { + regexes.add(regex); + }); + return new ApiResponse<>(HttpStatus.OK.value(), "Regex list fetched successfully", regexes); + } +} diff --git a/src/main/java/com/stephenschafer/budget/RegexDao.java b/src/main/java/com/stephenschafer/budget/RegexDao.java new file mode 100644 index 0000000..3c8f62b --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/RegexDao.java @@ -0,0 +1,22 @@ +package com.stephenschafer.budget; + +import java.util.Optional; +import java.util.function.Consumer; + +public interface RegexDao { + int deleteById(int id); + + Optional getById(int id); + + Regex add(Regex regex); + + void update(Regex regex); + + void getAll(Consumer consumer); + + void getAllDisplay(Consumer consumer); + + void getByCategory(int categoryId, Consumer consumer); + + void getBySource(String source, Consumer consumer); +} diff --git a/src/main/java/com/stephenschafer/budget/RegexDaoImpl.java b/src/main/java/com/stephenschafer/budget/RegexDaoImpl.java new file mode 100644 index 0000000..fd839b5 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/RegexDaoImpl.java @@ -0,0 +1,228 @@ +package com.stephenschafer.budget; + +import java.sql.SQLException; +import java.sql.Types; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.PreparedStatementCreator; +import org.springframework.jdbc.core.PreparedStatementCreatorFactory; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class RegexDaoImpl implements RegexDao { + @Autowired + private JdbcTemplate jdbcTemplate; + @Autowired + private CategoryDao categoryDao; + + @Override + public int deleteById(final int id) { + return jdbcTemplate.update("delete from budget.regex where id = ?", id); + } + + @Override + public Optional getById(final int id) { + final String sql = "select" + + " category_id, regex, flags, source, priority, description, year" + + " from budget.regex where id = ?"; + return jdbcTemplate.queryForObject(sql, (rs, rowNum) -> { + int i = 0; + final int categoryId = rs.getInt(++i); + final String regex = rs.getString(++i); + final int flags = rs.getInt(++i); + final String source = rs.getString(++i); + final int priority = rs.getInt(++i); + final String description = rs.getString(++i); + final int yearInt = rs.getInt(++i); + final Integer year = rs.wasNull() ? null : Integer.valueOf(yearInt); + return Optional.of( + new Regex(id, categoryId, regex, flags, source, priority, description, year)); + }, id); + } + + @Override + public Regex add(final Regex regex) { + log.info("RegexDaoImpl.add " + regex); + final String sql = "insert into budget.regex" + + " (category_id, regex, flags, source, priority, description, year)" + + " values (?, ?, ?, ?, ?, ?, ?)"; + final PreparedStatementCreatorFactory factory = new PreparedStatementCreatorFactory(sql, + Types.INTEGER, Types.VARCHAR, Types.INTEGER, Types.VARCHAR, Types.INTEGER, + Types.VARCHAR, Types.INTEGER); + factory.setReturnGeneratedKeys(true); + factory.setGeneratedKeysColumnNames("id"); + final PreparedStatementCreator creator = factory.newPreparedStatementCreator( + new Object[] { regex.getCategoryId(), regex.getRegex(), regex.getFlags(), + regex.getSource(), regex.getPriority(), regex.getDescription(), regex.getYear() }); + final KeyHolder keyHolder = new GeneratedKeyHolder(); + final int rowCount = jdbcTemplate.update(creator, keyHolder); + if (rowCount == 0) { + return null; + } + final Number generatedId = keyHolder.getKey(); + return new Regex(generatedId.intValue(), regex); + } + + @Override + public void update(final Regex regex) { + log.info("RegexDaoImpl.update " + regex); + final String sql = "update budget.regex" + + " set category_id = ?, regex = ?, flags = ?, source = ?, priority = ?, description = ?, year = ?" + + " where id = ?"; + final PreparedStatementCreatorFactory factory = new PreparedStatementCreatorFactory(sql, + Types.INTEGER, Types.VARCHAR, Types.INTEGER, Types.VARCHAR, Types.INTEGER, + Types.VARCHAR, Types.INTEGER, Types.INTEGER); + PreparedStatementCreator creator; + try { + creator = factory.newPreparedStatementCreator(new Object[] { regex.getCategoryId(), + regex.getRegex(), regex.getFlags(), regex.getSource(), regex.getPriority(), + regex.getDescription(), regex.getYear(), regex.getId() }); + } + catch (final Exception e) { + log.error("failed to get creator"); + e.printStackTrace(); + return; + } + log.info("update creator = " + creator); + jdbcTemplate.update(creator); + } + + @Override + public void getAll(final Consumer consumer) { + final PreparedStatementHolder holder = new PreparedStatementHolder(); + final String sql = "select" + + " id, category_id, regex, flags, source, priority, description, year" + + " from budget.regex" + " order by id"; + final PreparedStatementCreator creator = connection -> { + holder.statement = connection.prepareStatement(sql); + return holder.statement; + }; + try { + jdbcTemplate.query(creator, rs -> { + int i = 0; + final int id = rs.getInt(++i); + final int categoryId = rs.getInt(++i); + final String regex = rs.getString(++i); + final int flags = rs.getInt(++i); + final String source = rs.getString(++i); + final int priority = rs.getInt(++i); + final String description = rs.getString(++i); + final int yearInt = rs.getInt(++i); + final Integer year = rs.wasNull() ? null : Integer.valueOf(yearInt); + consumer.accept( + new Regex(id, categoryId, regex, flags, source, priority, description, year)); + }); + } + catch (final StopException e) { + try { + holder.statement.cancel(); + } + catch (final SQLException e1) { + log.error("getAll failed", e1); + } + } + } + + @Override + public void getAllDisplay(final Consumer consumer) { + final List regexes = new ArrayList<>(); + getAll(regex -> { + regexes.add(regex); + }); + for (final Regex regex : regexes) { + final List categoryAncestry = categoryDao.getCategoryAncestry( + regex.categoryId); + final String fqCategoryName = getFQCategoryName(categoryAncestry); + consumer.accept(new RegexDisplay(regex, fqCategoryName)); + } + } + + private String getFQCategoryName(final List categoryAncestry) { + final StringBuilder sb = new StringBuilder(); + String sep = ""; + for (final Category category : categoryAncestry) { + sb.append(sep); + sep = "."; + sb.append(category.name); + } + return sb.toString(); + } + + @Override + public void getByCategory(final int categoryId, final Consumer consumer) { + final PreparedStatementHolder holder = new PreparedStatementHolder(); + final String sql = "select" + " id, regex, flags, source, priority, description, year" + + " from budget.regex" + " where category_id = ?" + " order by id"; + final PreparedStatementCreator creator = connection -> { + holder.statement = connection.prepareStatement(sql); + holder.statement.setInt(1, categoryId); + return holder.statement; + }; + try { + jdbcTemplate.query(creator, rs -> { + int i = 0; + final int id = rs.getInt(++i); + final String regex = rs.getString(++i); + final int flags = rs.getInt(++i); + final String source = rs.getString(++i); + final int priority = rs.getInt(++i); + final String description = rs.getString(++i); + final int yearInt = rs.getInt(++i); + final Integer year = rs.wasNull() ? null : Integer.valueOf(yearInt); + consumer.accept( + new Regex(id, categoryId, regex, flags, source, priority, description, year)); + }); + } + catch (final StopException e) { + try { + holder.statement.cancel(); + } + catch (final SQLException e1) { + log.error("getByCategory failed", e1); + } + } + } + + @Override + public void getBySource(final String source, final Consumer consumer) { + final PreparedStatementHolder holder = new PreparedStatementHolder(); + final String sql = "select" + " id, category_id, regex, flags, priority, description, year" + + " from budget.regex" + " where source = ?" + " order by id"; + final PreparedStatementCreator creator = connection -> { + holder.statement = connection.prepareStatement(sql); + holder.statement.setString(1, source); + return holder.statement; + }; + try { + jdbcTemplate.query(creator, rs -> { + int i = 0; + final int id = rs.getInt(++i); + final int categoryId = rs.getInt(++i); + final String regex = rs.getString(++i); + final int flags = rs.getInt(++i); + final int priority = rs.getInt(++i); + final String description = rs.getString(++i); + final int yearInt = rs.getInt(++i); + final Integer year = rs.wasNull() ? null : Integer.valueOf(yearInt); + consumer.accept( + new Regex(id, categoryId, regex, flags, source, priority, description, year)); + }); + } + catch (final StopException e) { + try { + holder.statement.cancel(); + } + catch (final SQLException e1) { + log.error("getBySource failed", e1); + } + } + } +} diff --git a/src/main/java/com/stephenschafer/budget/RegexDisplay.java b/src/main/java/com/stephenschafer/budget/RegexDisplay.java new file mode 100644 index 0000000..9c9e327 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/RegexDisplay.java @@ -0,0 +1,27 @@ +package com.stephenschafer.budget; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@AllArgsConstructor +@ToString +public class RegexDisplay { + public RegexDisplay(final Regex regex, final String fqCategoryName) { + this(regex.id, regex.categoryId, fqCategoryName, regex.regex, regex.flags, regex.source, + regex.priority, regex.description, regex.year); + } + + Integer id; + Integer categoryId; + String fqCategoryName; + String regex; + int flags; + String source; + int priority; + String description; + Integer year; +} diff --git a/src/main/java/com/stephenschafer/budget/RestExceptionHandler.java b/src/main/java/com/stephenschafer/budget/RestExceptionHandler.java new file mode 100644 index 0000000..87b9c9d --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/RestExceptionHandler.java @@ -0,0 +1,42 @@ +package com.stephenschafer.budget; + +import java.text.ParseException; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MultipartException; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestControllerAdvice +public class RestExceptionHandler { + @ExceptionHandler(RuntimeException.class) + @ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "Unknown error") + public ApiResponse handleNotFoundException(final RuntimeException ex) { + log.info("Exception: " + ex); + final ApiResponse apiResponse = new ApiResponse<>(400, "Unknown error", null); + return apiResponse; + } + + @ExceptionHandler(MultipartException.class) + @ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "Multipart exception") + public ApiResponse handleError1(final MultipartException e, + final RedirectAttributes redirectAttributes) { + redirectAttributes.addFlashAttribute("message", e.getCause().getMessage()); + log.info("Exception: " + e); + final ApiResponse apiResponse = new ApiResponse<>(400, "Multipart Exception", null); + return apiResponse; + } + + @ExceptionHandler(ParseException.class) + @ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "Bad date format") + public ApiResponse handleParseException(final ParseException ex) { + log.info("Exception: " + ex); + final ApiResponse apiResponse = new ApiResponse<>(400, "Bad date format", null); + return apiResponse; + } +} diff --git a/src/main/java/com/stephenschafer/budget/StopException.java b/src/main/java/com/stephenschafer/budget/StopException.java new file mode 100644 index 0000000..559bbf6 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/StopException.java @@ -0,0 +1,8 @@ +package com.stephenschafer.budget; + +import java.io.Serial; + +public class StopException extends RuntimeException { + @Serial + private static final long serialVersionUID = 1L; +} \ No newline at end of file diff --git a/src/main/java/com/stephenschafer/budget/Transaction.java b/src/main/java/com/stephenschafer/budget/Transaction.java new file mode 100644 index 0000000..dd40c62 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/Transaction.java @@ -0,0 +1,26 @@ +package com.stephenschafer.budget; + +import java.math.BigDecimal; +import java.sql.Date; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@AllArgsConstructor +@ToString +public class Transaction { + Integer id; + String source; + String uniqueIdentifier; + String type; + String description; + String extraDescription; + Date date; + BigDecimal amount; + Integer optional; + Integer regexId; +} diff --git a/src/main/java/com/stephenschafer/budget/TransactionController.java b/src/main/java/com/stephenschafer/budget/TransactionController.java new file mode 100644 index 0000000..23d5fb3 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/TransactionController.java @@ -0,0 +1,86 @@ +package com.stephenschafer.budget; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@CrossOrigin(origins = "*", maxAge = 3600) +@RestController +public class TransactionController { + @Autowired + private TransactionDao transactionDao; + @Autowired + private UserService userService; + + @GetMapping("/sources/{year}") + @ResponseBody + public ApiResponse> geSources(@PathVariable(required = true) final String year, + final HttpServletRequest request) { + log.info("GET /sources/" + year); + if (!userService.isAuthorized(request)) { + return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(), + "You are not authorized to do this", null); + } + if (year == null) { + return new ApiResponse<>(HttpStatus.BAD_REQUEST.value(), "Year not specified", null); + } + final List sources = new ArrayList<>(); + transactionDao.getSources(year, source -> { + sources.add(source); + }); + return new ApiResponse<>(HttpStatus.OK.value(), "Sources retrieved successfully", sources); + } + + @GetMapping("/transactions/{year}") + @ResponseBody + public ApiResponse> getTransactions( + @PathVariable(required = true) final String year, final HttpServletRequest request) { + log.info("GET /transactions/" + year); + if (!userService.isAuthorized(request)) { + return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(), + "You are not authorized to do this", null); + } + if (year == null) { + return new ApiResponse<>(HttpStatus.BAD_REQUEST.value(), "Year not specified", null); + } + final List transactions = new ArrayList<>(); + transactionDao.getAll(year, transaction -> { + transactions.add(transaction); + }); + return new ApiResponse<>(HttpStatus.OK.value(), "Transactions retrieved successfully", + transactions); + } + + @GetMapping("/transactionsByRegexId/{year}/{regexId}") + @ResponseBody + public ApiResponse> getTransactionsByRegexId( + @PathVariable(required = true) final String year, + @PathVariable(required = true) final Integer regexId, + final HttpServletRequest request) { + log.info("GET /transactions/" + year); + if (!userService.isAuthorized(request)) { + return new ApiResponse<>(HttpStatus.UNAUTHORIZED.value(), + "You are not authorized to do this", null); + } + if (year == null) { + return new ApiResponse<>(HttpStatus.BAD_REQUEST.value(), "Year not specified", null); + } + final List transactions = new ArrayList<>(); + transactionDao.getByRegexId(year, regexId, transaction -> { + transactions.add(transaction); + }); + return new ApiResponse<>(HttpStatus.OK.value(), "Transactions retrieved successfully", + transactions); + } +} diff --git a/src/main/java/com/stephenschafer/budget/TransactionDao.java b/src/main/java/com/stephenschafer/budget/TransactionDao.java new file mode 100644 index 0000000..e2854f0 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/TransactionDao.java @@ -0,0 +1,20 @@ +package com.stephenschafer.budget; + +import java.util.Optional; +import java.util.function.Consumer; + +public interface TransactionDao { + Optional getById(String year, int id); + + void getAll(String year, Consumer consumer); + + void getByCategory(String year, int categoryId, Consumer consumer); + + void getBySource(String year, String source, Consumer consumer); + + void getByRegexId(String year, Integer regexId, Consumer consumer); + + void getSources(String year, Consumer consumer); + + void update(String year, Transaction transaction); +} diff --git a/src/main/java/com/stephenschafer/budget/TransactionDaoImpl.java b/src/main/java/com/stephenschafer/budget/TransactionDaoImpl.java new file mode 100644 index 0000000..8317e71 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/TransactionDaoImpl.java @@ -0,0 +1,159 @@ +package com.stephenschafer.budget; + +import java.math.BigDecimal; +import java.sql.Date; +import java.sql.SQLException; +import java.util.Optional; +import java.util.function.Consumer; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.PreparedStatementCreator; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class TransactionDaoImpl implements TransactionDao { + @Autowired + private JdbcTemplate jdbcTemplate; + + @Override + public Optional getById(final String year, final int id) { + final String sql = ("select" + + " source, unique_identifier, type, description, extra_description, date, amount, optional, regex_id" + + " from budget_${year}.transaction where id = ?").replace("${year}", year); + return jdbcTemplate.queryForObject(sql, (rs, rowNum) -> { + int i = 0; + final String source = rs.getString(++i); + final String uniqueIdentifier = rs.getString(++i); + final String type = rs.getString(++i); + final String description = rs.getString(++i); + final String extraDescription = rs.getString(++i); + final Date date = rs.getDate(++i); + final BigDecimal amount = rs.getBigDecimal(++i); + final int optional = rs.getInt(++i); + final int regexId = rs.getInt(++i); + return Optional.of(new Transaction(id, source, uniqueIdentifier, type, description, + extraDescription, date, amount, optional, regexId)); + }, id); + } + + @Override + public void update(final String year, final Transaction transaction) { + // TODO Auto-generated method stub + } + + @Override + public void getAll(final String year, final Consumer consumer) { + final PreparedStatementHolder holder = new PreparedStatementHolder(); + final String sql = ("select" + + " id, source, unique_identifier, type, description, extra_description, date, amount, optional, regex_id" + + " from budget_${year}.transaction" + " order by id").replace("${year}", year); + final PreparedStatementCreator creator = connection -> { + holder.statement = connection.prepareStatement(sql); + return holder.statement; + }; + try { + jdbcTemplate.query(creator, rs -> { + int i = 0; + final int id = rs.getInt(++i); + final String source = rs.getString(++i); + final String uniqueIdentifier = rs.getString(++i); + final String type = rs.getString(++i); + final String description = rs.getString(++i); + final String extraDescription = rs.getString(++i); + final Date date = rs.getDate(++i); + final BigDecimal amount = rs.getBigDecimal(++i); + final int optional = rs.getInt(++i); + final int regexId = rs.getInt(++i); + consumer.accept(new Transaction(id, source, uniqueIdentifier, type, description, + extraDescription, date, amount, optional, regexId)); + }); + } + catch (final StopException e) { + try { + holder.statement.cancel(); + } + catch (final SQLException e1) { + log.error("getByCategory failed", e1); + } + } + } + + @Override + public void getByCategory(final String year, final int categoryId, + final Consumer consumer) { + // TODO Auto-generated method stub + } + + @Override + public void getBySource(final String year, final String source, + final Consumer consumer) { + // TODO Auto-generated method stub + } + + @Override + public void getByRegexId(final String year, final Integer regexId, + final Consumer consumer) { + final PreparedStatementHolder holder = new PreparedStatementHolder(); + final String sql = ("select" + + " id, source, unique_identifier, type, description, extra_description, date, amount, optional" + + " from budget_${year}.transaction where regex_id = ?" + " order by id").replace( + "${year}", year); + final PreparedStatementCreator creator = connection -> { + holder.statement = connection.prepareStatement(sql); + holder.statement.setInt(1, regexId.intValue()); + return holder.statement; + }; + try { + jdbcTemplate.query(creator, rs -> { + int i = 0; + final int id = rs.getInt(++i); + final String source = rs.getString(++i); + final String uniqueIdentifier = rs.getString(++i); + final String type = rs.getString(++i); + final String description = rs.getString(++i); + final String extraDescription = rs.getString(++i); + final Date date = rs.getDate(++i); + final BigDecimal amount = rs.getBigDecimal(++i); + final int optional = rs.getInt(++i); + consumer.accept(new Transaction(id, source, uniqueIdentifier, type, description, + extraDescription, date, amount, optional, regexId)); + }); + } + catch (final StopException e) { + try { + holder.statement.cancel(); + } + catch (final SQLException e1) { + log.error("getByCategory failed", e1); + } + } + } + + @Override + public void getSources(final String year, final Consumer consumer) { + final PreparedStatementHolder holder = new PreparedStatementHolder(); + final String sql = "select source from budget_${year}.transaction group by source order by source".replace( + "${year}", year); + final PreparedStatementCreator creator = connection -> { + holder.statement = connection.prepareStatement(sql); + return holder.statement; + }; + try { + jdbcTemplate.query(creator, rs -> { + int i = 0; + final String source = rs.getString(++i); + consumer.accept(source); + }); + } + catch (final StopException e) { + try { + holder.statement.cancel(); + } + catch (final SQLException e1) { + log.error("getSources failed", e1); + } + } + } +} diff --git a/src/main/java/com/stephenschafer/budget/UserController.java b/src/main/java/com/stephenschafer/budget/UserController.java new file mode 100644 index 0000000..3508197 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/UserController.java @@ -0,0 +1,53 @@ +package com.stephenschafer.budget; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.CrossOrigin; +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.RestController; + +@CrossOrigin(origins = "*", maxAge = 3600) +@RestController +@RequestMapping("/users") +public class UserController { + @Autowired + private UserService userService; + + @PostMapping + public ApiResponse saveUser(@RequestBody final UserDto user) { + return new ApiResponse<>(HttpStatus.OK.value(), "User saved successfully.", + userService.save(user)); + } + + @GetMapping + public ApiResponse> listUser() { + return new ApiResponse<>(HttpStatus.OK.value(), "User list fetched successfully.", + userService.findAll()); + } + + @GetMapping("/{id}") + public ApiResponse getOne(@PathVariable final int id) { + return new ApiResponse<>(HttpStatus.OK.value(), "User fetched successfully.", + userService.findById(id)); + } + + @PutMapping("/{id}") + public ApiResponse update(@RequestBody final UserDto userDto) { + return new ApiResponse<>(HttpStatus.OK.value(), "User updated successfully.", + userService.update(userDto)); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable final int id) { + userService.delete(id); + return new ApiResponse<>(HttpStatus.OK.value(), "User deleted successfully.", null); + } +} diff --git a/src/main/java/com/stephenschafer/budget/UserDao.java b/src/main/java/com/stephenschafer/budget/UserDao.java new file mode 100644 index 0000000..ca6f96c --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/UserDao.java @@ -0,0 +1,9 @@ +package com.stephenschafer.budget; + +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserDao extends CrudRepository { + UserEntity findByUsername(String username); +} diff --git a/src/main/java/com/stephenschafer/budget/UserDto.java b/src/main/java/com/stephenschafer/budget/UserDto.java new file mode 100644 index 0000000..530bfd4 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/UserDto.java @@ -0,0 +1,15 @@ +package com.stephenschafer.budget; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class UserDto { + private int id; + private String firstName; + private String lastName; + private String username; + private String password; + private String role; +} diff --git a/src/main/java/com/stephenschafer/budget/UserEntity.java b/src/main/java/com/stephenschafer/budget/UserEntity.java new file mode 100644 index 0000000..ac9e85e --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/UserEntity.java @@ -0,0 +1,33 @@ +package com.stephenschafer.budget; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "user_1") +@Getter +@Setter +public class UserEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + @Column + private String firstName; + @Column + private String lastName; + @Column + private String username; + @Column + private String role; + @Column + @JsonIgnore + private String password; +} diff --git a/src/main/java/com/stephenschafer/budget/UserService.java b/src/main/java/com/stephenschafer/budget/UserService.java new file mode 100644 index 0000000..6ce71a4 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/UserService.java @@ -0,0 +1,23 @@ +package com.stephenschafer.budget; + +import java.util.List; + +import org.springframework.security.core.userdetails.UserDetailsService; + +import jakarta.servlet.http.HttpServletRequest; + +public interface UserService extends UserDetailsService { + UserEntity save(UserDto user); + + List findAll(); + + void delete(int id); + + UserEntity findByUsername(String username); + + UserEntity findById(int id); + + UserDto update(UserDto userDto); + + boolean isAuthorized(final HttpServletRequest request); +} diff --git a/src/main/java/com/stephenschafer/budget/UserServiceImpl.java b/src/main/java/com/stephenschafer/budget/UserServiceImpl.java new file mode 100644 index 0000000..30f5987 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/UserServiceImpl.java @@ -0,0 +1,98 @@ +package com.stephenschafer.budget; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.transaction.Transactional; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Transactional +@Service(value = "userService") +public class UserServiceImpl implements UserDetailsService, UserService { + @Autowired + private UserDao userDao; + @Autowired + private BCryptPasswordEncoder bcryptEncoder; + + @Override + public UserDetails loadUserByUsername(final String username) throws UsernameNotFoundException { + final UserEntity user = userDao.findByUsername(username); + if (user == null) { + throw new UsernameNotFoundException("Invalid username or password."); + } + return new org.springframework.security.core.userdetails.User(user.getUsername(), + user.getPassword(), getAuthority()); + } + + private List getAuthority() { + return Arrays.asList(new SimpleGrantedAuthority("ROLE_ADMIN")); + } + + @Override + public List findAll() { + final List list = new ArrayList<>(); + userDao.findAll().iterator().forEachRemaining(list::add); + return list; + } + + @Override + public void delete(final int id) { + userDao.deleteById(id); + } + + @Override + public UserEntity findByUsername(final String username) { + return userDao.findByUsername(username); + } + + @Override + public UserEntity findById(final int id) { + final Optional optionalUser = userDao.findById(id); + return optionalUser.isPresent() ? optionalUser.get() : null; + } + + @Override + public UserDto update(final UserDto userDto) { + final UserEntity user = findById(userDto.getId()); + if (user != null) { + BeanUtils.copyProperties(userDto, user, "password", "username"); + userDao.save(user); + } + return userDto; + } + + @Override + public UserEntity save(final UserDto user) { + final UserEntity newUser = new UserEntity(); + newUser.setUsername(user.getUsername()); + newUser.setFirstName(user.getFirstName()); + newUser.setLastName(user.getLastName()); + newUser.setPassword(bcryptEncoder.encode(user.getPassword())); + newUser.setRole(user.getRole()); + return userDao.save(newUser); + } + + @Override + public boolean isAuthorized(final HttpServletRequest request) { + final String username = (String) request.getAttribute("username"); + log.info("username = " + username); + final UserEntity userEntity = findByUsername(username); + if (userEntity == null || !"administrator".equals(userEntity.getRole())) { + return false; + } + return true; + } +} diff --git a/src/main/java/com/stephenschafer/budget/Util.java b/src/main/java/com/stephenschafer/budget/Util.java new file mode 100644 index 0000000..283c36d --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/Util.java @@ -0,0 +1,4 @@ +package com.stephenschafer.budget; + +public class Util { +} diff --git a/src/main/java/com/stephenschafer/budget/WebSecurityConfig.java b/src/main/java/com/stephenschafer/budget/WebSecurityConfig.java new file mode 100644 index 0000000..dc7cada --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/WebSecurityConfig.java @@ -0,0 +1,75 @@ +package com.stephenschafer.budget; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +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.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +public class WebSecurityConfig { + @Autowired + private JwtAuthenticationEntryPoint unauthorizedHandler; + @Autowired + CustomCorsConfiguration customCorsConfiguration; + + @Bean + AuthenticationManager authenticationManager(final UserDetailsService userDetailsService, + final PasswordEncoder passwordEncoder) { + final var provider = new DaoAuthenticationProvider(); + provider.setUserDetailsService(userDetailsService); + provider.setPasswordEncoder(passwordEncoder); + return new ProviderManager(provider); + } + + @Bean + BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + JwtAuthenticationFilter authenticationTokenFilterBean() throws Exception { + return new JwtAuthenticationFilter(); + } + + @Bean + SecurityFilterChain filterChain(final HttpSecurity http) throws Exception { + http.cors(c -> c.configurationSource(customCorsConfiguration)) // + .csrf(AbstractHttpConfigurer::disable) // + .authorizeHttpRequests(requests -> { + requests.requestMatchers("/token/*", + "/signup").permitAll().anyRequest().authenticated(); + }) // + .exceptionHandling( + configurer -> configurer.authenticationEntryPoint(unauthorizedHandler)) // + .sessionManagement(configurer -> configurer.sessionCreationPolicy( + SessionCreationPolicy.STATELESS)); + http.addFilterBefore(authenticationTokenFilterBean(), + UsernamePasswordAuthenticationFilter.class); + /* + requests.requestMatchers("/token/*", + "/signup").permitAll().anyRequest().authenticated(); + */ + /* + http.cors().and().csrf().disable().authorizeRequests().antMatchers("/token/*", + "/signup").permitAll().anyRequest().authenticated().and().exceptionHandling().authenticationEntryPoint( + unauthorizedHandler).and().sessionManagement().sessionCreationPolicy( + SessionCreationPolicy.STATELESS); + // @formatter:on + http.addFilterBefore(authenticationTokenFilterBean(), + UsernamePasswordAuthenticationFilter.class); + */ + return http.build(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..04515c7 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,20 @@ +spring.datasource.driver-class-name=com.mysql.jdbc.Driver +spring.datasource.url=jdbc:mysql://localhost:3306?serverTimezone=UTC&useSSL=false +spring.datasource.username=elephant +# spring.datasource.password=CHANGEME + +spring.jpa.show-sql=true +# this unconditionally re-creates the table even if it's already populated +#spring.jpa.hibernate.ddl-auto=create-drop +# this will add columns to the table if they are missing but doesn't remove any +spring.jpa.hibernate.ddl-auto=update +# spring.user.datasource.driver-class-name=com.mysql.jdbc.Driver +spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl + +#server.port=8443 +#server.ssl.key-store=classpath:keystore.p12 +#server.ssl.key-store-password=foobar +#server.ssl.key-store-type=PKCS12 +#server.ssl.key-alias=timesheet +#server.ssl.key-password=foobar +#server.ssl.enabled=true diff --git a/stop b/stop new file mode 100755 index 0000000..7cecbe8 --- /dev/null +++ b/stop @@ -0,0 +1,24 @@ +#!/bin/sh +cd "$(dirname "${BASH_SOURCE[0]}")" +ROOT=$(pwd) +echo "$ROOT" +function k() { + if ! test -f "$1"; then + echo "nothing to stop" + return 1 + fi + PID=$(cat "$1") + if kill -9 $PID; then + echo "process $PID stopped" + else + echo "no such process" + fi + rm "$1" +} +if [ -z "$1" ]; then + for file in $ROOT/logs/run-*.pid; do + k $file + done +else + k "$ROOT/logs/run-$1.pid" +fi