diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/README.md b/README.md index 01d412a..ad7936d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,49 @@ -# FPSMS-backend +# TSMS Backend +## Getting started +1. Create a schema named `tsmsdb` in MySQL workbench +2. Create a `launch.json` file and put it into the `.vscode` folder +```json +{ + "version": "0.2.0", + "configurations": [ + { + "type": "java", + "name": "TsmsApplication", + "request": "launch", + "mainClass": "com.ffii.tsms.TsmsApplication", + "projectName": "TSMS-backend" + }, + { + "type": "java", + "name": "Launch Local", + "request": "launch", + "mainClass": "com.ffii.tsms.TsmsApplication", + "console": "internalConsole", + "projectName": "TSMS-backend", + "args": "--spring.profiles.active=db-local,ldap-local" + } + ] +} +``` +3. Create a `settings.json` file and put it into the `.vscode` folder + *(You may need to change some settings depending on your development environment)* +```json +{ + "java.configuration.updateBuildConfiguration": "interactive", + "java.jdt.ls.java.home": "C:\\java\\jdk-17.0.8", + "java.jdt.ls.vmargs": "-XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Dsun.zip.disableMemoryMapping=true -Xmx2G -Xms100m -Xlog:disable" +} +``` + +4. Run and Debug "Launch Local" + +## Using gradle + +This project can also be run using gradle. + +### Running the application +After creating the table in MySQL, run +```shell +./gradlew bootRun --args='--spring.profiles.active=db-local' +``` diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..43a8eea --- /dev/null +++ b/build.gradle @@ -0,0 +1,56 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.1.1' + id 'io.spring.dependency-management' version '1.1.0' +} + +group = 'com.ffii' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = '17' +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-ldap' + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-log4j2' + implementation 'org.springframework.security:spring-security-ldap' + implementation 'org.liquibase:liquibase-core' + + implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.12.0' + implementation group: 'org.apache.poi', name: 'poi', version: '5.2.3' + implementation group: 'org.apache.poi', name: 'poi-ooxml', version: '5.2.3' + + implementation group: 'jakarta.persistence', name: 'jakarta.persistence-api', version: '3.1.0' + implementation group: 'jakarta.annotation', name: 'jakarta.annotation-api', version: '2.1.1' + implementation group: 'jakarta.validation', name: 'jakarta.validation-api', version: '3.0.2' + implementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-yaml', version: '2.15.2' + implementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.15.2' + + implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' + implementation group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' + implementation group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + + compileOnly group: 'jakarta.servlet', name: 'jakarta.servlet-api', version: '6.0.0' + + runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'com.unboundid:unboundid-ldapsdk:6.0.9' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' +} + +configurations { + all { + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging' + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..c1962a7 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37aef8d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..a69d9cb --- /dev/null +++ b/gradlew @@ -0,0 +1,240 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..f127cfd --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..211d3ce --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'TSMS' diff --git a/src/main/java/com/ffii/core/entity/BaseEntity.java b/src/main/java/com/ffii/core/entity/BaseEntity.java new file mode 100644 index 0000000..b8dc60b --- /dev/null +++ b/src/main/java/com/ffii/core/entity/BaseEntity.java @@ -0,0 +1,121 @@ +package com.ffii.core.entity; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.Optional; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Version; +import jakarta.validation.constraints.NotNull; + +import org.springframework.security.core.context.SecurityContextHolder; + +/** @author Terence */ +@MappedSuperclass +public abstract class BaseEntity extends IdEntity { + + @NotNull + @Version + @Column + private Integer version; + + @NotNull + @Column(updatable = false) + private LocalDateTime created; + + @Column(updatable = false) + private String createdBy; + + @NotNull + @Column + private LocalDateTime modified; + + @Column + private String modifiedBy; + + @NotNull + @Column + private Boolean deleted; + + @PrePersist + public void autoSetCreated() { + this.setCreated(LocalDateTime.now()); + this.setModified(LocalDateTime.now()); + this.setDeleted(Boolean.FALSE); + + Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()) + .ifPresentOrElse( + authentication -> { + this.setCreatedBy(authentication.getName()); + this.setModifiedBy(authentication.getName()); + }, + () -> { + this.setCreatedBy(null); + this.setModifiedBy(null); + }); + } + + @PreUpdate + public void autoSetModified() { + this.setModified(LocalDateTime.now()); + Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()).ifPresentOrElse( + authentication -> this.setModifiedBy(authentication.getName()), + () -> this.setModifiedBy(null)); + } + + public Integer getVersion() { + return this.version; + } + + public void setVersion(Integer version) { + this.version = version; + } + + public LocalDateTime getCreated() { + return this.created; + } + + public void setCreated(LocalDateTime created) { + this.created = created; + } + + public String getCreatedBy() { + return this.createdBy; + } + + public void setCreatedBy(String createdBy) { + this.createdBy = createdBy; + } + + public LocalDateTime getModified() { + return this.modified; + } + + public void setModified(LocalDateTime modified) { + this.modified = modified; + } + + public String getModifiedBy() { + return this.modifiedBy; + } + + public void setModifiedBy(String modifiedBy) { + this.modifiedBy = modifiedBy; + } + + public Boolean isDeleted() { + return this.deleted; + } + + public Boolean getDeleted() { + return this.deleted; + } + + public void setDeleted(Boolean deleted) { + this.deleted = deleted; + } + +} \ No newline at end of file diff --git a/src/main/java/com/ffii/core/entity/IdEntity.java b/src/main/java/com/ffii/core/entity/IdEntity.java new file mode 100644 index 0000000..210fe41 --- /dev/null +++ b/src/main/java/com/ffii/core/entity/IdEntity.java @@ -0,0 +1,49 @@ +package com.ffii.core.entity; + +import java.io.Serializable; + +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.PostLoad; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Transient; + +import org.springframework.data.domain.Persistable; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** @author Terence */ +@MappedSuperclass +public abstract class IdEntity implements Persistable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private ID id; + + @Transient + private boolean isNew = true; + + @JsonIgnore + @Override + public boolean isNew() { + return isNew; + } + + @PrePersist + @PostLoad + void markNotNew() { + this.isNew = false; + } + + // getter and setter + + public ID getId() { + return id; + } + + public void setId(ID id) { + this.id = id; + } +} \ No newline at end of file diff --git a/src/main/java/com/ffii/core/exception/BadRequestException.java b/src/main/java/com/ffii/core/exception/BadRequestException.java new file mode 100644 index 0000000..7ac98c0 --- /dev/null +++ b/src/main/java/com/ffii/core/exception/BadRequestException.java @@ -0,0 +1,15 @@ +package com.ffii.core.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public class BadRequestException extends ResponseStatusException { + + public BadRequestException() { + super(HttpStatus.BAD_REQUEST); + } + + public BadRequestException(String reason) { + super(HttpStatus.BAD_REQUEST, reason); + } +} diff --git a/src/main/java/com/ffii/core/exception/ConflictException.java b/src/main/java/com/ffii/core/exception/ConflictException.java new file mode 100644 index 0000000..cc1f9c5 --- /dev/null +++ b/src/main/java/com/ffii/core/exception/ConflictException.java @@ -0,0 +1,16 @@ +package com.ffii.core.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +/* e.g. sub record not under record */ +public class ConflictException extends ResponseStatusException { + + public ConflictException() { + super(HttpStatus.CONFLICT); + } + + public ConflictException(String reason) { + super(HttpStatus.CONFLICT, reason); + } +} diff --git a/src/main/java/com/ffii/core/exception/InternalServerErrorException.java b/src/main/java/com/ffii/core/exception/InternalServerErrorException.java new file mode 100644 index 0000000..5587158 --- /dev/null +++ b/src/main/java/com/ffii/core/exception/InternalServerErrorException.java @@ -0,0 +1,19 @@ +package com.ffii.core.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public class InternalServerErrorException extends ResponseStatusException { + + public InternalServerErrorException() { + super(HttpStatus.INTERNAL_SERVER_ERROR); + } + + public InternalServerErrorException(String reason) { + super(HttpStatus.INTERNAL_SERVER_ERROR, reason); + } + + public InternalServerErrorException(String reason, Throwable e) { + super(HttpStatus.INTERNAL_SERVER_ERROR, reason, e); + } +} diff --git a/src/main/java/com/ffii/core/exception/NotFoundException.java b/src/main/java/com/ffii/core/exception/NotFoundException.java new file mode 100644 index 0000000..f41d0a3 --- /dev/null +++ b/src/main/java/com/ffii/core/exception/NotFoundException.java @@ -0,0 +1,13 @@ +package com.ffii.core.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +/* main record not found (e.g. item record) */ +public class NotFoundException extends ResponseStatusException{ + + public NotFoundException() { + super(HttpStatus.NOT_FOUND); + } + +} diff --git a/src/main/java/com/ffii/core/exception/UnprocessableEntityException.java b/src/main/java/com/ffii/core/exception/UnprocessableEntityException.java new file mode 100644 index 0000000..d099908 --- /dev/null +++ b/src/main/java/com/ffii/core/exception/UnprocessableEntityException.java @@ -0,0 +1,37 @@ +package com.ffii.core.exception; + +import java.util.Map; + +import jakarta.validation.constraints.NotNull; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +/* sub record not found (e.g. item_line record) */ +public class UnprocessableEntityException extends ResponseStatusException { + + public UnprocessableEntityException() { + super(HttpStatus.UNPROCESSABLE_ENTITY); + } + + public UnprocessableEntityException(@NotNull Map map) { + super(HttpStatus.UNPROCESSABLE_ENTITY, map2Str(map)); + } + + public UnprocessableEntityException(String reason) { + super(HttpStatus.UNPROCESSABLE_ENTITY, reason); + } + + private static String map2Str(@NotNull Map map) { + try { + return new ObjectMapper().writeValueAsString(map); + } catch (JsonProcessingException e) { + e.printStackTrace(); + return ""; + } + } + +} diff --git a/src/main/java/com/ffii/core/response/DataRes.java b/src/main/java/com/ffii/core/response/DataRes.java new file mode 100644 index 0000000..d4edb54 --- /dev/null +++ b/src/main/java/com/ffii/core/response/DataRes.java @@ -0,0 +1,21 @@ +package com.ffii.core.response; + +public class DataRes { + private T data; + + public DataRes() { + } + + public DataRes(T data) { + this.data = data; + } + + public T getData() { + return data; + } + + public void setData(T data) { + this.data = data; + } + +} diff --git a/src/main/java/com/ffii/core/response/ErrorRes.java b/src/main/java/com/ffii/core/response/ErrorRes.java new file mode 100644 index 0000000..40f2a66 --- /dev/null +++ b/src/main/java/com/ffii/core/response/ErrorRes.java @@ -0,0 +1,36 @@ +package com.ffii.core.response; + +import java.time.LocalDateTime; + +public class ErrorRes { + + private LocalDateTime timestamp; + + private String traceId; + + public ErrorRes() { + this.timestamp = LocalDateTime.now(); + } + + public ErrorRes(String traceId) { + this.timestamp = LocalDateTime.now(); + this.traceId = traceId; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } + + public void setTimestamp(LocalDateTime timestamp) { + this.timestamp = timestamp; + } + + public String getTraceId() { + return traceId; + } + + public void setTraceId(String traceId) { + this.traceId = traceId; + } + +} diff --git a/src/main/java/com/ffii/core/response/FailureRes.java b/src/main/java/com/ffii/core/response/FailureRes.java new file mode 100644 index 0000000..838b2b4 --- /dev/null +++ b/src/main/java/com/ffii/core/response/FailureRes.java @@ -0,0 +1,39 @@ +package com.ffii.core.response; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonInclude; + +public class FailureRes { + + private LocalDateTime timestamp; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private String error; + + public FailureRes() { + this.timestamp = LocalDateTime.now(); + } + + public FailureRes(String error) { + this.timestamp = LocalDateTime.now(); + this.error = error; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } + + public void setTimestamp(LocalDateTime timestamp) { + this.timestamp = timestamp; + } + + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } + +} diff --git a/src/main/java/com/ffii/core/response/IdRes.java b/src/main/java/com/ffii/core/response/IdRes.java new file mode 100644 index 0000000..95f72bd --- /dev/null +++ b/src/main/java/com/ffii/core/response/IdRes.java @@ -0,0 +1,21 @@ +package com.ffii.core.response; + +public class IdRes { + private long id; + + public IdRes() { + } + + public IdRes(long id) { + this.id = id; + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + +} diff --git a/src/main/java/com/ffii/core/response/RecordsRes.java b/src/main/java/com/ffii/core/response/RecordsRes.java new file mode 100644 index 0000000..7798b9e --- /dev/null +++ b/src/main/java/com/ffii/core/response/RecordsRes.java @@ -0,0 +1,41 @@ +package com.ffii.core.response; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +public class RecordsRes { + private List records; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private Integer total; + + public RecordsRes() { + } + + public RecordsRes(List records) { + this.records = records; + } + + public RecordsRes(List records, int total) { + this.records = records; + this.total = total; + } + + public List getRecords() { + return records; + } + + public void setRecords(List records) { + this.records = records; + } + + public Integer getTotal() { + return total; + } + + public void setTotal(Integer total) { + this.total = total; + } + +} diff --git a/src/main/java/com/ffii/core/support/AbstractBaseEntityService.java b/src/main/java/com/ffii/core/support/AbstractBaseEntityService.java new file mode 100644 index 0000000..1a75e6e --- /dev/null +++ b/src/main/java/com/ffii/core/support/AbstractBaseEntityService.java @@ -0,0 +1,42 @@ +package com.ffii.core.support; + +import java.io.Serializable; +import java.util.Optional; + +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.Assert; + +import com.ffii.core.entity.BaseEntity; +import com.ffii.core.exception.ConflictException; + +/** @author Alex */ +public abstract class AbstractBaseEntityService, ID extends Serializable, R extends AbstractRepository> + extends AbstractIdEntityService { + + public AbstractBaseEntityService(JdbcDao jdbcDao, R repository) { + super(jdbcDao, repository); + } + + /** find and check versionId */ + public Optional find(ID id, int version) { + Assert.notNull(id, "id must not be null"); + return repository.findById(id) + .map(entity -> { + if (entity.getVersion() != version) throw new ConflictException("OPTIMISTIC_LOCK"); + return entity; + }); + } + + @Transactional(rollbackFor = Exception.class) + public void markDelete(ID id) { + Assert.notNull(id, "id must not be null"); + find(id).ifPresent(t -> markDelete(t)); + } + + @Transactional(rollbackFor = Exception.class) + public void markDelete(T entity) { + Assert.notNull(entity, "entity must not be null"); + entity.setDeleted(Boolean.TRUE); + save(entity); + } +} diff --git a/src/main/java/com/ffii/core/support/AbstractIdEntityService.java b/src/main/java/com/ffii/core/support/AbstractIdEntityService.java new file mode 100644 index 0000000..30612b1 --- /dev/null +++ b/src/main/java/com/ffii/core/support/AbstractIdEntityService.java @@ -0,0 +1,65 @@ +package com.ffii.core.support; + +import java.io.Serializable; +import java.util.List; +import java.util.Optional; + +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.Assert; + +import com.ffii.core.entity.IdEntity; + +/** @author Alex */ +public abstract class AbstractIdEntityService, ID extends Serializable, R extends AbstractRepository> + extends AbstractService { + + protected R repository; + + public AbstractIdEntityService(JdbcDao jdbcDao, R repository) { + super(jdbcDao); + this.repository = repository; + } + + @Transactional(rollbackFor = Exception.class) + public T save(T entity) { + Assert.notNull(entity, "entity must not be null"); + return this.repository.save(entity); + } + + @Transactional(rollbackFor = Exception.class) + public T saveAndFlush(T entity) { + Assert.notNull(entity, "entity must not be null"); + return this.repository.saveAndFlush(entity); + } + + public List listAll() { + return this.repository.findAll(); + } + + public Optional find(ID id) { + Assert.notNull(id, "id must not be null"); + return this.repository.findById(id); + } + + public boolean existsById(ID id) { + Assert.notNull(id, "id must not be null"); + return this.repository.existsById(id); + } + + public List findAllByIds(List ids) { + Assert.notNull(ids, "ids must not be null"); + return this.repository.findAllById(ids); + } + + @Transactional(rollbackFor = Exception.class) + public void delete(ID id) { + Assert.notNull(id, "id must not be null"); + this.repository.deleteById(id); + } + + @Transactional(rollbackFor = Exception.class) + public void delete(T entity) { + Assert.notNull(entity, "entity must not be null"); + this.repository.delete(entity); + } +} diff --git a/src/main/java/com/ffii/core/support/AbstractRepository.java b/src/main/java/com/ffii/core/support/AbstractRepository.java new file mode 100644 index 0000000..3606539 --- /dev/null +++ b/src/main/java/com/ffii/core/support/AbstractRepository.java @@ -0,0 +1,16 @@ +package com.ffii.core.support; + +import java.io.Serializable; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.NoRepositoryBean; + +import com.ffii.core.entity.IdEntity; + +/** + * @author Alex + * @see https://docs.spring.io/spring-data/jpa/docs/2.7.0/reference/html/#jpa.query-methods.query-creation + */ +@NoRepositoryBean +public interface AbstractRepository, ID extends Serializable> extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/ffii/core/support/AbstractService.java b/src/main/java/com/ffii/core/support/AbstractService.java new file mode 100644 index 0000000..855e504 --- /dev/null +++ b/src/main/java/com/ffii/core/support/AbstractService.java @@ -0,0 +1,15 @@ +package com.ffii.core.support; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +/** @author Terence */ +public abstract class AbstractService { + protected final Log logger = LogFactory.getLog(getClass()); + + protected JdbcDao jdbcDao; + + public AbstractService(JdbcDao jdbcDao) { + this.jdbcDao = jdbcDao; + } +} \ No newline at end of file diff --git a/src/main/java/com/ffii/core/support/ErrorHandler.java b/src/main/java/com/ffii/core/support/ErrorHandler.java new file mode 100644 index 0000000..c8f5c10 --- /dev/null +++ b/src/main/java/com/ffii/core/support/ErrorHandler.java @@ -0,0 +1,44 @@ +package com.ffii.core.support; + +import java.util.UUID; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +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.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import com.ffii.core.exception.ConflictException; +import com.ffii.core.exception.InternalServerErrorException; +import com.ffii.core.response.ErrorRes; +import com.ffii.core.response.FailureRes; + +@RestControllerAdvice +public class ErrorHandler extends ResponseEntityExceptionHandler { + private final Log logger = LogFactory.getLog(getClass()); + + @ExceptionHandler({ ConflictException.class, ResponseStatusException.class }) + public ResponseEntity error409422(final Exception ex) { + ResponseStatusException e = (ResponseStatusException) ex; + return new ResponseEntity<>(new FailureRes(e.getReason()), e.getStatusCode()); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity error403(final Exception ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + @ExceptionHandler({ InternalServerErrorException.class, Exception.class }) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ResponseEntity error500(final Exception ex) { + UUID traceId = UUID.randomUUID(); + logger.error("traceId: " + traceId, ex); + return new ResponseEntity<>(new ErrorRes(traceId.toString()), HttpStatus.INTERNAL_SERVER_ERROR); + } + +} diff --git a/src/main/java/com/ffii/core/support/JdbcDao.java b/src/main/java/com/ffii/core/support/JdbcDao.java new file mode 100644 index 0000000..c6854ff --- /dev/null +++ b/src/main/java/com/ffii/core/support/JdbcDao.java @@ -0,0 +1,437 @@ +package com.ffii.core.support; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import javax.sql.DataSource; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.jdbc.BadSqlGrammarException; +import org.springframework.jdbc.IncorrectResultSetColumnCountException; +import org.springframework.jdbc.core.BeanPropertyRowMapper; +import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSourceUtils; + +/** @author Terence */ +public class JdbcDao { + + private NamedParameterJdbcTemplate template; + + public JdbcDao(DataSource dataSource) { + this.template = new NamedParameterJdbcTemplate(dataSource); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws IncorrectResultSetColumnCountException Incorrect column count + * @throws IncorrectResultSizeDataAccessException: Incorrect result size + */ + public String queryForString(String sql) { + return this.queryForString(sql, (Map) null); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + * @throws IncorrectResultSetColumnCountException Incorrect column count + * @throws IncorrectResultSizeDataAccessException: Incorrect result size + */ + public String queryForString(String sql, Map paramMap) { + try { + return this.template.queryForObject(sql, paramMap, String.class); + } catch (EmptyResultDataAccessException e) { + return StringUtils.EMPTY; + } + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + * @throws IncorrectResultSetColumnCountException Incorrect column count + * @throws IncorrectResultSizeDataAccessException: Incorrect result size + */ + public String queryForString(String sql, Object paramObj) { + try { + return this.template.queryForObject(sql, new BeanPropertySqlParameterSource(paramObj), String.class); + } catch (EmptyResultDataAccessException e) { + return StringUtils.EMPTY; + } + } + + /** + * @return {@code true} if non-zero, {@code false} if zero + * @throws BadSqlGrammarException sql error + * @throws IncorrectResultSetColumnCountException Incorrect column count + * @throws IncorrectResultSizeDataAccessException: Incorrect result size + */ + public boolean queryForBoolean(String sql) { + return this.queryForBoolean(sql, (Map) null); + } + + /** + * @return {@code true} if non-zero, {@code false} if zero + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + * @throws IncorrectResultSetColumnCountException Incorrect column count + * @throws IncorrectResultSizeDataAccessException: Incorrect result size + */ + public boolean queryForBoolean(String sql, Map paramMap) { + try { + var rs = this.template.queryForObject(sql, paramMap, Boolean.class); + return rs == null ? false : rs; + } catch (EmptyResultDataAccessException e) { + return false; + } + } + + /** + * @return {@code true} if non-zero, {@code false} if zero + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + * @throws IncorrectResultSetColumnCountException Incorrect column count + * @throws IncorrectResultSizeDataAccessException: Incorrect result size + */ + public boolean queryForBoolean(String sql, Object paramObj) { + try { + var rs = this.template.queryForObject(sql, new BeanPropertySqlParameterSource(paramObj), Boolean.class); + return rs == null ? false : rs; + } catch (EmptyResultDataAccessException e) { + return false; + } + } + + /** + * @throws BadSqlGrammarException sql error + * @throws IncorrectResultSetColumnCountException Incorrect column count + * @throws IncorrectResultSizeDataAccessException: Incorrect result size + */ + public int queryForInt(String sql) { + return this.queryForInt(sql, (Map) null); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + * @throws IncorrectResultSetColumnCountException Incorrect column count + * @throws IncorrectResultSizeDataAccessException: Incorrect result size + */ + public int queryForInt(String sql, Map paramMap) { + try { + var rs = this.template.queryForObject(sql, paramMap, Integer.class); + return rs == null ? 0 : rs; + } catch (EmptyResultDataAccessException e) { + return 0; + } + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + * @throws IncorrectResultSetColumnCountException Incorrect column count + * @throws IncorrectResultSizeDataAccessException: Incorrect result size + */ + public int queryForInt(String sql, Object paramObj) { + try { + var rs = this.template.queryForObject(sql, + new BeanPropertySqlParameterSource(paramObj), Integer.class); + return rs == null ? 0 : rs; + } catch (EmptyResultDataAccessException e) { + return 0; + } + } + + /** + * @throws BadSqlGrammarException sql error + * @throws IncorrectResultSetColumnCountException Incorrect column count + * @throws IncorrectResultSizeDataAccessException: Incorrect result size + */ + public BigDecimal queryForDecimal(String sql) { + return this.queryForDecimal(sql, (Map) null); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + * @throws IncorrectResultSetColumnCountException Incorrect column count + * @throws IncorrectResultSizeDataAccessException: Incorrect result size + */ + public BigDecimal queryForDecimal(String sql, Map paramMap) { + try { + return this.template.queryForObject(sql, paramMap, BigDecimal.class); + } catch (EmptyResultDataAccessException e) { + return BigDecimal.ZERO; + } + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + * @throws IncorrectResultSetColumnCountException Incorrect column count + * @throws IncorrectResultSizeDataAccessException: Incorrect result size + */ + public BigDecimal queryForDecimal(String sql, Object paramObj) { + try { + return this.template.queryForObject(sql, + new BeanPropertySqlParameterSource(paramObj), BigDecimal.class); + } catch (EmptyResultDataAccessException e) { + return BigDecimal.ZERO; + } + } + + /** + * @throws BadSqlGrammarException sql error + * @throws IncorrectResultSizeDataAccessException: Incorrect result size + */ + public Optional queryForEntity(String sql, Class entity) { + return this.queryForEntity(sql, (Map) null, entity); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + * @throws IncorrectResultSizeDataAccessException: Incorrect result size + */ + public Optional queryForEntity(String sql, Map paramMap, Class entity) { + try { + return Optional.of(this.template.queryForObject(sql, paramMap, + new BeanPropertyRowMapper(entity))); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + * @throws IncorrectResultSizeDataAccessException: Incorrect result size + */ + public Optional queryForEntity(String sql, Object paramObj, Class entity) { + try { + return Optional.of(this.template.queryForObject(sql, + new BeanPropertySqlParameterSource(paramObj), new BeanPropertyRowMapper(entity))); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + /** + * @throws BadSqlGrammarException sql error + */ + public List queryForList(String sql, Class entity) { + return this.queryForList(sql, (Map) null, entity); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + */ + public List queryForList(String sql, Map paramMap, Class entity) { + return this.template.query(sql, paramMap, new BeanPropertyRowMapper(entity)); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + */ + public List queryForList(String sql, Object paramObj, Class entity) { + return this.template.query(sql, new BeanPropertySqlParameterSource(paramObj), + new BeanPropertyRowMapper(entity)); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws IncorrectResultSetColumnCountException Incorrect column count + */ + public List queryForInts(String sql) { + return this.queryForInts(sql, (Map) null); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + * @throws IncorrectResultSetColumnCountException Incorrect column count + */ + public List queryForInts(String sql, Map paramMap) { + return this.template.queryForList(sql, paramMap, Integer.class); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + * @throws IncorrectResultSetColumnCountException Incorrect column count + */ + public List queryForInts(String sql, Object paramObj) { + return this.template.queryForList(sql, new BeanPropertySqlParameterSource(paramObj), Integer.class); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws IncorrectResultSetColumnCountException Incorrect column count + */ + public List queryForDates(String sql) { + return this.queryForDates(sql, (Map) null); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + * @throws IncorrectResultSetColumnCountException Incorrect column count + */ + public List queryForDates(String sql, Map paramMap) { + return this.template.queryForList(sql, paramMap, LocalDate.class); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + * @throws IncorrectResultSetColumnCountException Incorrect column count + */ + public List queryForDates(String sql, Object paramObj) { + return this.template.queryForList(sql, new BeanPropertySqlParameterSource(paramObj), LocalDate.class); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws IncorrectResultSetColumnCountException Incorrect column count + */ + public List queryForDatetimes(String sql) { + return this.queryForDatetimes(sql, (Map) null); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + * @throws IncorrectResultSetColumnCountException Incorrect column count + */ + public List queryForDatetimes(String sql, Map paramMap) { + return this.template.queryForList(sql, paramMap, LocalDateTime.class); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + * @throws IncorrectResultSetColumnCountException Incorrect column count + */ + public List queryForDatetimes(String sql, Object paramObj) { + return this.template.queryForList(sql, new BeanPropertySqlParameterSource(paramObj), LocalDateTime.class); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws IncorrectResultSetColumnCountException Incorrect column count + */ + public List queryForStrings(String sql) { + return this.queryForStrings(sql, (Map) null); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + * @throws IncorrectResultSetColumnCountException Incorrect column count + */ + public List queryForStrings(String sql, Map paramMap) { + return this.template.queryForList(sql, paramMap, String.class); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + * @throws IncorrectResultSetColumnCountException Incorrect column count + */ + public List queryForStrings(String sql, Object paramObj) { + return this.template.queryForList(sql, new BeanPropertySqlParameterSource(paramObj), String.class); + } + + /** + * @throws BadSqlGrammarException sql error + */ + public List> queryForList(String sql) { + return this.queryForList(sql, (Map) null); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + */ + public List> queryForList(String sql, Map paramMap) { + return this.template.queryForList(sql, paramMap); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + */ + public List> queryForList(String sql, Object paramObj) { + return this.template.queryForList(sql, new BeanPropertySqlParameterSource(paramObj)); + } + + /** + * @throws BadSqlGrammarException sql error + */ + public Optional> queryForMap(String sql) { + return this.queryForMap(sql, (Map) null); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + */ + public Optional> queryForMap(String sql, Map paramMap) { + try { + return Optional.of(this.template.queryForMap(sql, paramMap)); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + */ + public Optional> queryForMap(String sql, Object paramObj) { + try { + return Optional.of(this.template.queryForMap(sql, new BeanPropertySqlParameterSource(paramObj))); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + /** + * @throws BadSqlGrammarException sql error + */ + public int executeUpdate(String sql) { + return this.executeUpdate(sql, (Map) null); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + */ + public int executeUpdate(String sql, Map paramMap) { + return this.template.update(sql, paramMap); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + */ + public int executeUpdate(String sql, Object paramObj) { + return this.template.update(sql, new BeanPropertySqlParameterSource(paramObj)); + } + + /** + * @throws BadSqlGrammarException sql error + * @throws InvalidDataAccessApiUsageException params missing when needed + */ + public int[] batchUpdate(String sql, List paramsMapOrObject) { + return this.template.batchUpdate(sql, SqlParameterSourceUtils.createBatch(paramsMapOrObject)); + } +} diff --git a/src/main/java/com/ffii/core/utils/AES.java b/src/main/java/com/ffii/core/utils/AES.java new file mode 100644 index 0000000..89830e7 --- /dev/null +++ b/src/main/java/com/ffii/core/utils/AES.java @@ -0,0 +1,85 @@ +package com.ffii.core.utils; + +import java.io.UnsupportedEncodingException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +public class AES { + + protected final Log logger = LogFactory.getLog(getClass()); + + private static SecretKeySpec secretKey; + private static byte[] key; + + public static void setKey(String myKey) { + MessageDigest sha = null; + try { + key = myKey.getBytes("UTF-8"); + sha = MessageDigest.getInstance("SHA-1"); + key = sha.digest(key); + key = Arrays.copyOf(key, 16); + secretKey = new SecretKeySpec(key, "AES"); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + } + } + + public static String encrypt(String strToEncrypt, String secret) { + try { + setKey(secret); + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE, secretKey); + return Base64.getEncoder().encodeToString(cipher.doFinal(strToEncrypt.getBytes("UTF-8"))); + } catch (Exception e) { + System.out.println("Error while encrypting: " + e.toString()); + } + return null; + } + + public static String urlEncrypt(String strToEncrypt, String secret) { + try { + setKey(secret); + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE, secretKey); + return Base64.getUrlEncoder().encodeToString(cipher.doFinal(strToEncrypt.getBytes("UTF-8"))); + } catch (Exception e) { + System.out.println("Error while encrypting: " + e.toString()); + } + return null; + } + + public static String decrypt(String strToDecrypt, String secret) { + try { + setKey(secret); + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5PADDING"); + cipher.init(Cipher.DECRYPT_MODE, secretKey); + return new String(cipher.doFinal(Base64.getDecoder().decode(strToDecrypt)), "UTF-8"); + } catch (Exception e) { + System.out.println("Error while decrypting: " + e.toString()); + } + return null; + } + + public static String urlDecrypt(String strToDecrypt, String secret) { + try { + setKey(secret); + System.out.println("strToDecrypt: " + strToDecrypt); + Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5PADDING"); + cipher.init(Cipher.DECRYPT_MODE, secretKey); + return new String(cipher.doFinal(Base64.getUrlDecoder().decode(strToDecrypt)), "UTF-8"); + } catch (Exception e) { + System.out.println("Error while decrypting: " + e.toString()); + } + return null; + } +} diff --git a/src/main/java/com/ffii/core/utils/CriteriaArgsBuilder.java b/src/main/java/com/ffii/core/utils/CriteriaArgsBuilder.java new file mode 100644 index 0000000..138b755 --- /dev/null +++ b/src/main/java/com/ffii/core/utils/CriteriaArgsBuilder.java @@ -0,0 +1,242 @@ +package com.ffii.core.utils; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.web.bind.ServletRequestBindingException; +import org.springframework.web.bind.ServletRequestUtils; + +/** @author Alex */ +public class CriteriaArgsBuilder { + + private HttpServletRequest request; + private Map args; + + private CriteriaArgsBuilder(HttpServletRequest request, Map args) { + this.args = args; + this.request = request; + } + + public static CriteriaArgsBuilder withRequest(HttpServletRequest request) { + return new CriteriaArgsBuilder(request, new HashMap()); + } + + public static CriteriaArgsBuilder withRequestNMap(HttpServletRequest request, Map args) { + return new CriteriaArgsBuilder(request, args); + } + + public CriteriaArgsBuilder addStringExact(String paramName) throws ServletRequestBindingException { + String value = StringUtils.trimToNull(ServletRequestUtils.getStringParameter(this.request, paramName)); + if (value != null) + args.put(paramName, value); + return this; + } + + public CriteriaArgsBuilder addStringLike(String paramName) throws ServletRequestBindingException { + String value = StringUtils.trimToNull(ServletRequestUtils.getStringParameter(this.request, paramName)); + if (value != null) + args.put(paramName, "%" + value + "%"); + return this; + } + + public CriteriaArgsBuilder addString(String paramName) throws ServletRequestBindingException { + return this.addStringExact(paramName); + } + + public CriteriaArgsBuilder addStringStartsWith(String paramName) throws ServletRequestBindingException { + String value = StringUtils.trimToNull(ServletRequestUtils.getStringParameter(this.request, paramName)); + if (value != null) + args.put(paramName, value + "%"); + return this; + } + + public CriteriaArgsBuilder addStringEndsWith(String paramName) throws ServletRequestBindingException { + String value = StringUtils.trimToNull(ServletRequestUtils.getStringParameter(this.request, paramName)); + if (value != null) + args.put(paramName, "%" + value); + return this; + } + + public CriteriaArgsBuilder addStringList(String paramName) throws ServletRequestBindingException { + String[] params = ServletRequestUtils.getStringParameters(this.request, paramName); + if (params.length > 0) { + List value = new ArrayList(params.length); + for (String param : params) + if (StringUtils.isNotBlank(param)) + value.add(param); + if (value.size() > 0) + args.put(paramName, value); + } + return this; + } + + public CriteriaArgsBuilder addStringCsv(String paramName) throws ServletRequestBindingException { + String text = ServletRequestUtils.getStringParameter(this.request, paramName); + if (text != null && StringUtils.isNotEmpty(text)) + args.put(paramName, Arrays.asList(text.split(","))); + return this; + } + + public CriteriaArgsBuilder addInteger(String paramName) throws ServletRequestBindingException { + Integer value = StringUtils.isNotBlank(this.request.getParameter(paramName)) + ? ServletRequestUtils.getRequiredIntParameter(request, paramName) + : null; + if (value != null) + args.put(paramName, value); + return this; + } + + public CriteriaArgsBuilder addNonZeroInteger(String paramName) throws ServletRequestBindingException { + Integer value = StringUtils.isNotBlank(this.request.getParameter(paramName)) + ? ServletRequestUtils.getRequiredIntParameter(request, paramName) + : null; + if (value != null && value.intValue() != 0) + args.put(paramName, value); + return this; + } + + public CriteriaArgsBuilder addIntegerList(String paramName) throws ServletRequestBindingException { + int[] params = ServletRequestUtils.getIntParameters(request, paramName); + if (params.length > 0) { + List values = new ArrayList(); + for (int param : params) + values.add(param); + args.put(paramName, values); + } + return this; + } + + public CriteriaArgsBuilder addNonZeroIntegerList(String paramName) throws ServletRequestBindingException { + int[] params = ServletRequestUtils.getIntParameters(request, paramName); + if (params.length > 0) { + List values = new ArrayList(); + for (int param : params) + if (param != 0) + values.add(param); + args.put(paramName, values); + } + return this; + } + + public CriteriaArgsBuilder addLong(String paramName) throws ServletRequestBindingException { + Long value = StringUtils.isNotBlank(this.request.getParameter(paramName)) + ? ServletRequestUtils.getRequiredLongParameter(request, paramName) + : null; + if (value != null) + args.put(paramName, value); + return this; + } + + public CriteriaArgsBuilder addNonZeroLong(String paramName) throws ServletRequestBindingException { + Long value = StringUtils.isNotBlank(this.request.getParameter(paramName)) + ? ServletRequestUtils.getRequiredLongParameter(request, paramName) + : null; + if (value != null && value.longValue() != 0L) + args.put(paramName, value); + return this; + } + + public CriteriaArgsBuilder addDatetime(String paramName) throws ServletRequestBindingException { + String value = ServletRequestUtils.getStringParameter(request, paramName); + if (StringUtils.isNotBlank(value)) { + try { + args.put(paramName, LocalDateTime.parse(value)); + } catch (DateTimeParseException e) { + throw new ServletRequestBindingException(paramName); + } + } + return this; + } + + public CriteriaArgsBuilder addDatetime(String paramName, DateTimeFormatter formatter) + throws ServletRequestBindingException { + String value = ServletRequestUtils.getStringParameter(request, paramName); + if (StringUtils.isNotBlank(value)) { + try { + args.put(paramName, LocalDateTime.parse(value, formatter)); + } catch (DateTimeParseException e) { + throw new ServletRequestBindingException(paramName); + } + } + return this; + } + + public CriteriaArgsBuilder addDate(String paramName) throws ServletRequestBindingException { + String value = ServletRequestUtils.getStringParameter(request, paramName); + if (StringUtils.isNotBlank(value)) { + try { + args.put(paramName, LocalDate.parse(value)); + } catch (DateTimeParseException e) { + throw new ServletRequestBindingException(paramName); + } + } + return this; + } + + public CriteriaArgsBuilder addDate(String paramName, DateTimeFormatter formatter) + throws ServletRequestBindingException { + String value = ServletRequestUtils.getStringParameter(request, paramName); + if (StringUtils.isNotBlank(value)) { + try { + args.put(paramName, LocalDate.parse(value, formatter)); + } catch (DateTimeParseException e) { + throw new ServletRequestBindingException(paramName); + } + } + return this; + } + + public CriteriaArgsBuilder addDateTo(String paramName) throws ServletRequestBindingException { + String value = ServletRequestUtils.getStringParameter(request, paramName); + if (StringUtils.isNotBlank(value)) { + try { + args.put(paramName, LocalDate.parse(value).plusDays(1)); + } catch (DateTimeParseException e) { + throw new ServletRequestBindingException(paramName); + } + } + return this; + } + + public CriteriaArgsBuilder addDateTo(String paramName, DateTimeFormatter formatter) + throws ServletRequestBindingException { + String value = ServletRequestUtils.getStringParameter(request, paramName); + if (StringUtils.isNotBlank(value)) { + try { + args.put(paramName, LocalDate.parse(value, formatter).plusDays(1)); + } catch (DateTimeParseException e) { + throw new ServletRequestBindingException(paramName); + } + } + return this; + } + + public CriteriaArgsBuilder addBoolean(String paramName) throws ServletRequestBindingException { + + if (request.getParameter(paramName) == null || request.getParameter(paramName).isEmpty()) { + return this; + } + Boolean value = ServletRequestUtils.getBooleanParameter(request, paramName); + args.put(paramName, value); + return this; + } + + public CriteriaArgsBuilder put(String key, Object value) { + args.put(key, value); + return this; + } + + public Map build() { + return this.args; + } +} diff --git a/src/main/java/com/ffii/core/utils/ExcelUtils.java b/src/main/java/com/ffii/core/utils/ExcelUtils.java new file mode 100644 index 0000000..b750b63 --- /dev/null +++ b/src/main/java/com/ffii/core/utils/ExcelUtils.java @@ -0,0 +1,778 @@ +package com.ffii.core.utils; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; +import org.apache.poi.openxml4j.exceptions.InvalidFormatException; +import org.apache.poi.openxml4j.opc.OPCPackage; +import org.apache.poi.poifs.crypt.EncryptionInfo; +import org.apache.poi.poifs.crypt.EncryptionMode; +import org.apache.poi.poifs.crypt.Encryptor; +import org.apache.poi.poifs.filesystem.POIFSFileSystem; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.DateUtil; +import org.apache.poi.ss.usermodel.RichTextString; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; + +import jakarta.servlet.http.HttpServletResponse; + +public abstract class ExcelUtils { + + /** + * static A to Z char array + */ + private static final char[] A2Z = { + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', + 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', + 'U', 'V', 'W', 'X', 'Y', 'Z' }; + + private static final DataFormatter DATA_FORMATTER = new DataFormatter(); + + /** max rows limit of .xls **/ + public static final int MAX_ROWS = 65536; + /** max columns limit of .xls **/ + public static final int MAX_COLS = 256; + + /** + * Column reference to index (0-based) map, support up to 256 columns (compatible with .xls format) + */ + public static final Map COL_IDX = new HashMap(MAX_COLS, 1.0f); + + static { + for (int columnIndex = 0; columnIndex < MAX_COLS; columnIndex++) { + int tempColumnCount = columnIndex; + StringBuilder sb = new StringBuilder(2); + do { + sb.insert(0, A2Z[tempColumnCount % 26]); + tempColumnCount = (tempColumnCount / 26) - 1; + } while (tempColumnCount >= 0); + COL_IDX.put(sb.toString(), Integer.valueOf(columnIndex)); + } + } + + /** + * Load XSSF workbook (xlsx file) from template source. + * + * @param url + * the relative path to the template source, e.g. "WEB-INF/excel/exampleReportTemplate.xlsx" + * + * @return the workbook, or null if the template file cannot be loaded + */ + public static final Workbook loadXSSFWorkbookFromTemplateSource(ResourceLoader resourceLoader, String url) { + Resource resource = resourceLoader.getResource(url); + try { + return new XSSFWorkbook(resource.getInputStream()); + } catch (IOException e) { + return null; + } + } + + /** + * Write the workbook to byte array. + * + * @param workbook + * The Excel workbook (cannot be null) + * + * @return the byte[], or null if IO exception occurred + */ + public static final byte[] toByteArray(Workbook workbook) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + workbook.write(baos); + } catch (IOException e) { + return null; + } + return baos.toByteArray(); + } + + /** + * Check if the cell exists in the given sheet, row and column. + * + * @param sheet + * the Sheet (cannot be null) + * @param rowIndex + * 0-based row index + * @param colIndex + * 0-based column index + * + * @return {@code true} if cell exists, else {@code false} + */ + public static final boolean isCellExists(Sheet sheet, int rowIndex, int colIndex) { + Row row = sheet.getRow(rowIndex); + if (row != null) { + Cell cell = row.getCell(colIndex); + return cell != null; + } + return false; + } + + /** + * Convenient method to obtain the cell in the given sheet, row and column. + *

+ * Creates the row and the cell if not already exist. + * + * @param sheet + * the Sheet (cannot be null) + * @param rowIndex + * 0-based row index + * @param colIndex + * 0-based column index + * + * @return the Cell (never null) + */ + public static final Cell getCell(Sheet sheet, int rowIndex, int colIndex) { + Row row = sheet.getRow(rowIndex); + if (row == null) { + row = sheet.createRow(rowIndex); + } + Cell cell = row.getCell(colIndex); + if (cell == null) { + cell = row.createCell(colIndex); + } + return cell; + } + + /** + * Get column index by column reference (support up to 256 columns) + * + * @param columnRef + * column reference such as "A", "B", "AA", "AB"... + * + * @return the column index + * + * @throws NullPointerException + * if column reference is invalid or the index exceeds 256 + */ + public static final int getColumnIndex(String columnRef) { + return COL_IDX.get(columnRef); + } + + /** + * Get column reference by column index + * + * @param columnIndex + * 0-based column index + * + * @return the column reference such as "A", "B", "AA", "AB"... + */ + public static final String getColumnRef(int columnIndex) { + StringBuilder sb = new StringBuilder(); + int tempColumnCount = columnIndex; + do { + sb.insert(0, A2Z[tempColumnCount % 26]); + tempColumnCount = (tempColumnCount / 26) - 1; + } while (tempColumnCount >= 0); + return sb.toString(); + } + + /** + * Get the Excel Cell Ref String by columnIndex and rowIndex + * + * @param columnIndex + * 0-based column index + * @param rowIndex + * 0-based row index + */ + public static final String getCellRefString(int columnIndex, int rowIndex) { + StringBuilder sb = new StringBuilder(); + int tempColumnCount = columnIndex; + do { + sb.insert(0, A2Z[tempColumnCount % 26]); + tempColumnCount = (tempColumnCount / 26) - 1; + } while (tempColumnCount >= 0); + sb.append(rowIndex + 1); + return sb.toString(); + } + + /** + * Get Cell value as String + */ + public static String getStringValue(Cell cell) { + if (cell != null && cell.getCellType() == CellType.FORMULA) { + try { + return cell.getStringCellValue(); + } catch (Exception e) { + return ""; + } + } + return DATA_FORMATTER.formatCellValue(cell); + } + + /** + * Get Cell value as BigDecimal, with a fallback value + *

+ * Only support {@link CellType#NUMERIC} and {@link CellType#STRING} + * + * @return the BigDecimal value, or the default value if cell is null or cell type is {@link CellType#BLANK} + */ + public static BigDecimal getDecimalValue(Cell cell, BigDecimal defaultValue) { + if (cell == null || cell.getCellType() == CellType.BLANK) return defaultValue; + if (cell.getCellType() == CellType.STRING) { + return new BigDecimal(cell.getStringCellValue()); + } else { + return BigDecimal.valueOf(cell.getNumericCellValue()); + } + } + + /** + * Get Cell value as BigDecimal + *

+ * Only support {@link CellType#NUMERIC} and {@link CellType#STRING} + * + * @return the BigDecimal value, or BigDecimal.ZERO if cell is null or cell type is {@link CellType#BLANK} + */ + public static BigDecimal getDecimalValue(Cell cell) { + return getDecimalValue(cell, BigDecimal.ZERO); + } + + /** + * Get Cell value as double + *

+ * Only support {@link CellType#NUMERIC} and {@link CellType#STRING} + */ + public static double getDoubleValue(Cell cell) { + if (cell == null) return 0.0; + if (cell.getCellType() == CellType.STRING) { + return NumberUtils.toDouble(cell.getStringCellValue()); + } else { + return cell.getNumericCellValue(); + } + } + + /** + * Get Cell value as int (rounded half-up to the nearest integer) + *

+ * Only support {@link CellType#NUMERIC} and {@link CellType#STRING} + */ + public static int getIntValue(Cell cell) { + return BigDecimal.valueOf(getDoubleValue(cell)).setScale(0, RoundingMode.HALF_UP).intValue(); + } + + /** + * Get Cell Integer value (truncated) + */ + public static Integer getIntValue(Cell cell, Integer defaultValue) { + if (cell == null) return defaultValue; + if (cell.getCellType() == CellType.STRING) { + return NumberUtils.toInt(cell.getStringCellValue(), defaultValue); + } else { + return (int) cell.getNumericCellValue(); + } + } + + public static LocalDate getDateValue(Cell cell, DateTimeFormatter formatter) { + if (cell == null) return null; + if (cell.getCellType() == CellType.STRING) { + try { + return LocalDate.parse(cell.getStringCellValue(), formatter); + } catch (DateTimeParseException e) { + return null; + } + } + if (DateUtil.isCellDateFormatted(cell)) { + try { + return DateUtil.getJavaDate(cell.getNumericCellValue()).toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDate(); + } catch (NumberFormatException e) { + return null; + } + } else { + return null; + } + } + + public static LocalDateTime getDatetimeValue(Cell cell, DateTimeFormatter formatter) { + if (cell == null) return null; + if (cell.getCellType() == CellType.STRING) { + try { + return LocalDateTime.parse(cell.getStringCellValue(), formatter); + } catch (DateTimeParseException e) { + return null; + } + } + if (DateUtil.isCellDateFormatted(cell)) { + try { + return DateUtil.getJavaDate(cell.getNumericCellValue()).toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + } catch (NumberFormatException e) { + return null; + } + } else { + return null; + } + } + + /** + * Convenient method to set Cell value + * + * @param cell + * the Cell (cannot be null) + * @param value + * the value to set + */ + public static void setCellValue(Cell cell, Object value) { + if (value instanceof String) + cell.setCellValue((String) value); + else if (value instanceof RichTextString) + cell.setCellValue((RichTextString) value); + else if (value instanceof Number) + cell.setCellValue(((Number) value).doubleValue()); + else if (value instanceof Boolean) + cell.setCellValue(((Boolean) value).booleanValue()); + else if (value instanceof Calendar) + cell.setCellValue((Calendar) value); + else if (value instanceof Date) + cell.setCellValue((Date) value); + else if (value instanceof LocalDate) + cell.setCellValue((LocalDate) value); + else if (value instanceof LocalTime) + cell.setCellValue(((LocalTime) value).toString()); + else if (value instanceof LocalDateTime) + cell.setCellValue((LocalDateTime) value); + else if (value == null) + cell.setCellValue(""); + else + throw new IllegalArgumentException(value.getClass().toString() + " is not supported"); + } + + /** + * Convenient method to set Cell value by Sheet, row index, and column index + * + * @param sheet + * the Sheet (cannot be null) + * @param rowIndex + * 0-based row index + * @param colIndex + * 0-based column index + * @param value + * the value to set + */ + public static void setCellValue(Sheet sheet, int rowIndex, int colIndex, Object value) { + setCellValue(getCell(sheet, rowIndex, colIndex), value); + } + + /** + * Increase Row Height (if necessary, but never decrease it) by counting the no. of lines in a String value + * + * @param sheet + * The Excel worksheet + * @param row + * The row index (0-based) + * @param value + * The (multi-line) String value to count for the no. of lines + * @param heightInPoints + * The height (in points) for 1 line of text + */ + public static void increaseRowHeight(Sheet sheet, int row, String value, int heightInPoints) { + int lines = StringUtils.countMatches(value, "\n") + 1; // count no. of lines + float newHeight = heightInPoints * lines; + + Row r = sheet.getRow(row); + if (r == null) r = sheet.createRow(row); + + // increase the row height if necessary, but never decrease it + if (r.getHeightInPoints() < newHeight) { + r.setHeightInPoints(newHeight); + } + } + + /** + * Add merged region (i.e. merge cells) + * + * @param sheet + * The Excel worksheet + * @param firstRowIdx + * The first row index (0-based) + * @param lastRowIdx + * The last row index (0-based) + * @param firstColIdx + * The first column index (0-based) + * @param lastColIdx + * The last column index (0-based) + */ + public static void addMergedRegion(Sheet sheet, int firstRowIdx, int lastRowIdx, int firstColIdx, int lastColIdx) { + CellRangeAddress cellRangeAddress = new CellRangeAddress(firstRowIdx, lastRowIdx, firstColIdx, lastColIdx); + sheet.addMergedRegion(cellRangeAddress); + } + + /** + * Copy and Insert Row + * + * @param workbook + * The Excel workbook + * @param sourceSheet + * The source Excel worksheet + * @param destinationSheet + * The destination Excel worksheet + * @param sourceRowNum + * The source row index (0-based) to copy from + * @param destinationRowNum + * The destination row index (0-based) to insert into (from the copied row) + */ + public static void copyAndInsertRow(Workbook workbook, Sheet sourceSheet, Sheet destinationSheet, int sourceRowNum, int destinationRowNum) { + // get the source / destination row + Row sourceRow = sourceSheet.getRow(sourceRowNum); + Row destRow = destinationSheet.getRow(destinationRowNum); + + // if the row exist in destination, push down all rows by 1 + if (destRow != null) { + destinationSheet.shiftRows(destinationRowNum, destinationSheet.getLastRowNum(), 1, true, false); + } + // create a new row + destRow = destinationSheet.createRow(destinationRowNum); + + // loop through source columns to add to new row + for (int i = 0; i < sourceRow.getLastCellNum(); i++) { + // grab a copy of the old cell + Cell oldCell = sourceRow.getCell(i); + + // if the old cell is null jump to next cell + if (oldCell == null) continue; + + // create a new cell in destination row + Cell newCell = destRow.createCell(i); + + // apply cell style to new cell from old cell + newCell.setCellStyle(oldCell.getCellStyle()); + + // if there is a cell comment, copy + if (oldCell.getCellComment() != null) { + newCell.setCellComment(oldCell.getCellComment()); + } + + // if there is a cell hyperlink, copy + if (oldCell.getHyperlink() != null) { + newCell.setHyperlink(oldCell.getHyperlink()); + } + + // copy the cell data value + switch (oldCell.getCellType()) { + case NUMERIC: + newCell.setCellValue(oldCell.getNumericCellValue()); + break; + case STRING: + newCell.setCellValue(oldCell.getRichStringCellValue()); + break; + case FORMULA: + newCell.setCellFormula(oldCell.getCellFormula()); + break; + case BLANK: + newCell.setCellValue(oldCell.getStringCellValue()); + break; + case BOOLEAN: + newCell.setCellValue(oldCell.getBooleanCellValue()); + break; + case ERROR: + newCell.setCellErrorValue(oldCell.getErrorCellValue()); + break; + default: + break; + } + } + + // if there are any merged regions in the source row, copy to new row + for (int i = 0; i < sourceSheet.getNumMergedRegions(); i++) { + CellRangeAddress cellRangeAddress = sourceSheet.getMergedRegion(i); + if (cellRangeAddress.getFirstRow() == sourceRow.getRowNum()) { + addMergedRegion( + destinationSheet, + destRow.getRowNum(), + (destRow.getRowNum() + (cellRangeAddress.getLastRow() - cellRangeAddress.getFirstRow())), + cellRangeAddress.getFirstColumn(), + cellRangeAddress.getLastColumn()); + } + } + + // copy row height + destRow.setHeight(sourceRow.getHeight()); + } + + /** + * Copy and Insert Row + * + * @param workbook + * The Excel workbook + * @param sheet + * The Excel worksheet + * @param sourceRowNum + * The source row index (0-based) to copy from + * @param destinationRowNum + * The destination row index (0-based) to insert into (from the copied row) + */ + public static void copyAndInsertRow(Workbook workbook, Sheet sheet, int sourceRowNum, int destinationRowNum) { + copyAndInsertRow(workbook, sheet, sheet, sourceRowNum, destinationRowNum); + } + + public static void copyAndInsertRow(Workbook workbook, Sheet sourceSheet, int sourceRowNum, int destinationRowNum, int times) { + // get the source / destination row + Row sourceRow = sourceSheet.getRow(sourceRowNum); + + Row[] destRows = new Row[times]; + for (int j = 0; j < times; j++) { + Row destRow = sourceSheet.getRow(destinationRowNum + j); + // if the row exist in destination, push down all rows by 1 + if (destRow != null) { + sourceSheet.shiftRows(destinationRowNum + j, sourceSheet.getLastRowNum(), 1, true, false); + } + // create a new row + destRows[j] = sourceSheet.createRow(destinationRowNum + j); + // copy row height + destRows[j].setHeight(sourceRow.getHeight()); + } + + // loop through source columns to add to new row + for (int i = 0; i < sourceRow.getLastCellNum(); i++) { + // grab a copy of the old cell + Cell oldCell = sourceRow.getCell(i); + + // if the old cell is null jump to next cell + if (oldCell == null) continue; + + for (int k = 0; k < times; k++) { + // create a new cell in destination row + Cell newCell = destRows[k].createCell(i); + + // apply cell style to new cell from old cell + newCell.setCellStyle(oldCell.getCellStyle()); + } + } + } + + /** + * Copy Column + * + * @param workbook + * The Excel workbook + * @param sourceSheet + * The source Excel worksheet + * @param destinationSheet + * The destination Excel worksheet + * @param rowStart + * The source row start index (0-based) to copy from + * @param rowEnd + * The source row end index (0-based) to copy from + * @param sourceColumnNum + * The source column index (0-based) to copy from + * @param destinationColumnNum + * The destination column index (0-based) to copy into (from the copied row) + */ + public static void copyColumn(Workbook workbook, Sheet sourceSheet, Sheet destinationSheet, int rowStart, int rowEnd, int sourceColumnNum, + int destinationColumnNum) { + for (int i = rowStart; i <= rowEnd; i++) { + Row sourceRow = sourceSheet.getRow(i); + if (sourceRow == null) continue; + + Row destinationRow = destinationSheet.getRow(i); + if (destinationRow == null) destinationRow = destinationSheet.createRow(i); + + Cell oldCell = sourceRow.getCell(sourceColumnNum); + if (oldCell == null) continue; + + Cell newCell = destinationRow.createCell(destinationColumnNum); + + newCell.setCellStyle(oldCell.getCellStyle()); + + if (oldCell.getCellComment() != null) { + newCell.setCellComment(oldCell.getCellComment()); + } + + if (oldCell.getHyperlink() != null) { + newCell.setHyperlink(oldCell.getHyperlink()); + } + + switch (oldCell.getCellType()) { + case NUMERIC: + newCell.setCellValue(oldCell.getNumericCellValue()); + break; + case STRING: + newCell.setCellValue(oldCell.getRichStringCellValue()); + break; + case FORMULA: + newCell.setCellFormula(oldCell.getCellFormula()); + break; + case BLANK: + newCell.setCellValue(oldCell.getStringCellValue()); + break; + case BOOLEAN: + newCell.setCellValue(oldCell.getBooleanCellValue()); + break; + case ERROR: + newCell.setCellErrorValue(oldCell.getErrorCellValue()); + break; + default: + break; + } + + for (int ii = 0; ii < sourceSheet.getNumMergedRegions(); ii++) { + CellRangeAddress cellRangeAddress = sourceSheet.getMergedRegion(ii); + if (cellRangeAddress.getFirstRow() == sourceRow.getRowNum()) { + addMergedRegion( + destinationSheet, + cellRangeAddress.getFirstRow(), + cellRangeAddress.getLastRow(), + destinationColumnNum, + (destinationColumnNum + (cellRangeAddress.getLastColumn() - cellRangeAddress.getFirstColumn()))); + } + } + } + + destinationSheet.setColumnWidth(destinationColumnNum, sourceSheet.getColumnWidth(sourceColumnNum)); + } + + /** + * Copy Column + * + * @param workbook + * The Excel workbook + * @param sheet + * The Excel worksheet + * @param rowStart + * The source row start index (0-based) to copy from + * @param rowEnd + * The source row end index (0-based) to copy from + * @param sourceColumnNum + * The source column index (0-based) to copy from + * @param destinationColumnNum + * The destination column index (0-based) to copy into (from the copied row) + */ + public static void copyColumn(Workbook workbook, Sheet sheet, int rowStart, int rowEnd, int sourceColumnNum, int destinationColumnNum) { + copyColumn(workbook, sheet, sheet, rowStart, rowEnd, sourceColumnNum, destinationColumnNum); + } + + public static void shiftColumns(Row row, int startingIndex, int shiftCount) { + for (int i = row.getPhysicalNumberOfCells() - 1; i >= startingIndex; i--) { + Cell oldCell = row.getCell(i); + Cell newCell = row.createCell(i + shiftCount); + + // apply cell style to new cell from old cell + newCell.setCellStyle(oldCell.getCellStyle()); + + // if there is a cell comment, copy + if (oldCell.getCellComment() != null) { + newCell.setCellComment(oldCell.getCellComment()); + } + + // if there is a cell hyperlink, copy + if (oldCell.getHyperlink() != null) { + newCell.setHyperlink(oldCell.getHyperlink()); + } + + // copy the cell data value + switch (oldCell.getCellType()) { + case NUMERIC: + newCell.setCellValue(oldCell.getNumericCellValue()); + break; + case STRING: + newCell.setCellValue(oldCell.getRichStringCellValue()); + break; + case FORMULA: + newCell.setCellFormula(oldCell.getCellFormula()); + break; + case BLANK: + newCell.setCellValue(oldCell.getStringCellValue()); + break; + case BOOLEAN: + newCell.setCellValue(oldCell.getBooleanCellValue()); + break; + case ERROR: + newCell.setCellErrorValue(oldCell.getErrorCellValue()); + break; + default: + break; + } + } + } + + /** handle some invalid char included ( /\*[]:? ) */ + public static void setSheetName(Workbook workbook, Sheet sheet, String name) { + if (workbook != null && sheet != null && StringUtils.isNotBlank(name)) + workbook.setSheetName(workbook.getSheetIndex(sheet), name.replaceAll("[/\\\\*\\[\\]:\\?]", "_")); + } + + /** delete row */ + public static void deleteRow(Sheet sheet, int rowIndex) { + if (sheet != null) { + sheet.removeRow(sheet.getRow(rowIndex)); + if (rowIndex < sheet.getLastRowNum()) + sheet.shiftRows(rowIndex, sheet.getLastRowNum(), -1); + } + } + + public static byte[] encrypt(Workbook workbook, String password) { + return encrypt(toByteArray(workbook), password); + } + + public static byte[] encrypt(byte[] bytes, String password) { + try { + POIFSFileSystem fs = new POIFSFileSystem(); + EncryptionInfo info = new EncryptionInfo(EncryptionMode.agile); + // EncryptionInfo info = new EncryptionInfo(EncryptionMode.agile, CipherAlgorithm.aes192, HashAlgorithm.sha384, -1, -1, null); + + Encryptor enc = info.getEncryptor(); + enc.confirmPassword(password); + + // Read in an existing OOXML file and write to encrypted output stream + // don't forget to close the output stream otherwise the padding bytes aren't added + OPCPackage opc = OPCPackage.open(new ByteArrayInputStream(bytes)); + OutputStream os = enc.getDataStream(fs); + opc.save(os); + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + fs.writeFilesystem(bos); + + return bos.toByteArray(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static Workbook loadTemplate(String templateClasspath) throws InvalidFormatException, IOException { + return loadTemplateFile(templateClasspath); + } + + public static Workbook loadTemplateFile(String templateClasspath) throws InvalidFormatException, IOException { + ClassPathResource r = new ClassPathResource(templateClasspath + "_" + ".xlsx"); + if (!r.exists()) r = new ClassPathResource(templateClasspath + ".xlsx"); + + try (InputStream in = r.getInputStream()) { + return new XSSFWorkbook(in); + } + } + + public static void send(HttpServletResponse response, Workbook workbook, String filename) throws IOException { + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setHeader("Content-Disposition", String.format("attachment; filename=\"%s\"", + response.encodeURL(filename + ".xlsx"))); + try (OutputStream out = response.getOutputStream()) { + workbook.write(out); + } + } +} diff --git a/src/main/java/com/ffii/core/utils/JsonUtils.java b/src/main/java/com/ffii/core/utils/JsonUtils.java new file mode 100644 index 0000000..4efd35a --- /dev/null +++ b/src/main/java/com/ffii/core/utils/JsonUtils.java @@ -0,0 +1,47 @@ +package com.ffii.core.utils; + +import java.io.IOException; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +/** + * JSON Utils + * + * @author Patrick + */ +public abstract class JsonUtils { + + // Default mapper instance + private static final ObjectMapper mapper = new ObjectMapper(); + + /** + * Method that can be used to serialize any Java value as a JSON String. + */ + public static String toJsonString(Object obj) { + try { + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return mapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + return null; + } + } + + /** + * Read from JSON String. + * + * @param content + * JSON String content + * @param valueType + * the return type + */ + public static T fromJsonString(String content, Class valueType) throws JsonParseException, JsonMappingException, IOException { + return mapper.readValue(content, valueType); + } + +} \ No newline at end of file diff --git a/src/main/java/com/ffii/core/utils/JwtTokenUtil.java b/src/main/java/com/ffii/core/utils/JwtTokenUtil.java new file mode 100644 index 0000000..b058f71 --- /dev/null +++ b/src/main/java/com/ffii/core/utils/JwtTokenUtil.java @@ -0,0 +1,114 @@ +package com.ffii.core.utils; + +import java.io.Serializable; +import java.security.Key; +import java.time.Instant; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import com.ffii.tsms.model.RefreshToken; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; + +@Component +@Scope(value = ConfigurableBeanFactory. SCOPE_SINGLETON) +public class JwtTokenUtil implements Serializable { + + Logger logger = LoggerFactory.getLogger(JwtTokenUtil.class); + + private static final long serialVersionUID = -2550185165626007488L; + + // * 60000 = 1 Min + public static final long JWT_TOKEN_EXPIRED_TIME = 60000 * 14400; + public static final String AES_SECRET = "ffii"; + public static final String TOKEN_SEPARATOR = "@@"; + + // @Value("${jwt.secret}") + // private String secret; + + private static final Key secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS512); + + // retrieve username from jwt token + public String getUsernameFromToken(String token) { + return getClaimFromToken(token, Claims::getSubject); + } + + // retrieve expiration date from jwt token + public Date getExpirationDateFromToken(String token) { + return getClaimFromToken(token, Claims::getExpiration); + } + + public T getClaimFromToken(String token, Function claimsResolver) { + final Claims claims = getAllClaimsFromToken(token); + return claimsResolver.apply(claims); + } + + // for retrieveing any information from token we will need the secret key + private Claims getAllClaimsFromToken(String token) { + return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody(); + } + + // check if the token has expired + private Boolean isTokenExpired(String token) { + final Date expiration = getExpirationDateFromToken(token); + return expiration.before(new Date()); + } + + // generate token for user + public String generateToken(UserDetails userDetails) { + Map claims = new HashMap<>(); + return doGenerateToken(claims, userDetails.getUsername()); + } + + // while creating the token - + // 1. Define claims of the token, like Issuer, Expiration, Subject, and the ID + // 2. Sign the JWT using the HS512 algorithm and secret key. + // 3. According to JWS Compact + // Serialization(https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-41#section-3.1) + // compaction of the JWT to a URL-safe string + private String doGenerateToken(Map claims, String subject) { + logger.info((new Date(System.currentTimeMillis() + JWT_TOKEN_EXPIRED_TIME)).toString()); + return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + JWT_TOKEN_EXPIRED_TIME)) + .signWith(secretKey).compact(); + } + + // validate token + public Boolean validateToken(String token, UserDetails userDetails) { + final String username = getUsernameFromToken(token); + return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); + } + + public RefreshToken createRefreshToken(String username) { + RefreshToken refreshToken = new RefreshToken(); + refreshToken.setUserName(username); + refreshToken.setExpiryDate(Instant.now().plusMillis(JWT_TOKEN_EXPIRED_TIME * 60 * 24)); + long instantNum = Instant.now().plusMillis(JWT_TOKEN_EXPIRED_TIME * 60 * 24).toEpochMilli(); + refreshToken.setToken(AES.encrypt(username + TOKEN_SEPARATOR + instantNum, AES_SECRET)); + return refreshToken; + } + + public boolean verifyExpiration(RefreshToken token) throws Exception { + if (token.getExpiryDate().compareTo(Instant.now()) < 0) { + return false; + } + + return true; + } + + public String getUsernameFromRefreshToken(String refreshToken) { + return AES.decrypt(refreshToken, AES_SECRET); + } +} diff --git a/src/main/java/com/ffii/core/utils/MapUtils.java b/src/main/java/com/ffii/core/utils/MapUtils.java new file mode 100644 index 0000000..733bda9 --- /dev/null +++ b/src/main/java/com/ffii/core/utils/MapUtils.java @@ -0,0 +1,35 @@ +package com.ffii.core.utils; + +import java.util.HashMap; +import java.util.Map; + +/** + * MapUtils + * + * @author Patrick + */ +public class MapUtils { + + /** + * Convert key-value pairs to HashMap + * + * @param keyValuePairs + * Keys and values must be in pairs + * + * @return Map + */ + @SuppressWarnings("unchecked") + public static Map toHashMap(Object... keyValuePairs) { + if (keyValuePairs.length % 2 != 0) + throw new IllegalArgumentException("Keys and values must be in pairs"); + + Map map = new HashMap(keyValuePairs.length / 2); + + for (int i = 0; i < keyValuePairs.length; i += 2) { + map.put((K) keyValuePairs[i], (V) keyValuePairs[i + 1]); + } + + return map; + } + +} diff --git a/src/main/java/com/ffii/core/utils/Params.java b/src/main/java/com/ffii/core/utils/Params.java new file mode 100644 index 0000000..5fa0b23 --- /dev/null +++ b/src/main/java/com/ffii/core/utils/Params.java @@ -0,0 +1,42 @@ +package com.ffii.core.utils; + +/** @author Alex */ +public abstract class Params { + public static final String ERROR = "error"; + + public static final String SUCCESS = "success"; + public static final String DATA = "data"; + public static final String RECORDS = "records"; + public static final String TOTAL = "total"; + + public static final String ID = "id"; + public static final String CODE = "code"; + public static final String NAME = "name"; + public static final String TYPE = "type"; + public static final String MSG = "msg"; + public static final String MSG_CODE = "msgCode"; + public static final String MESSAGES = "messages"; + public static final String FROM = "from"; + public static final String TO = "to"; + + // sql + public static final String QUERY = "query"; + + // pagin + public static final String PAGE = "page"; + public static final String START = "start"; + public static final String LIMIT = "limit"; + + // filter + public static final String FILTER = "filter"; + public static final String OPERATOR = "operator"; + public static final String LIKE = "like"; + public static final String PROPERTY = "property"; + + // sort + public static final String SORT = "sort"; + public static final String DIRECTION = "direction"; + + public static final String VALUE = "value"; + +} diff --git a/src/main/java/com/ffii/core/utils/PasswordUtils.java b/src/main/java/com/ffii/core/utils/PasswordUtils.java new file mode 100644 index 0000000..4c5c94f --- /dev/null +++ b/src/main/java/com/ffii/core/utils/PasswordUtils.java @@ -0,0 +1,83 @@ +/******************************************************************************* + * Copyright 2Fi Business Solutions Ltd. + * + * This code is copyrighted. Under no circumstances should any party, people, + * or organization should redistribute any portions of this code in any form, + * either verbatim or through electronic media, to any third parties, unless + * under explicit written permission by 2Fi Business Solutions Ltd. + ******************************************************************************/ +package com.ffii.core.utils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.regex.Pattern; + +public abstract class PasswordUtils { + + private static final Pattern PATTERN_DIGITS = Pattern.compile("[0-9]"); + private static final Pattern PATTERN_A2Z_LOWER = Pattern.compile("[a-z]"); + private static final Pattern PATTERN_A2Z_UPPER = Pattern.compile("[A-Z]"); + + private static final String A2Z_LOWER = "abcdefghijklmnopqrstuvwxyz"; + private static final String A2Z_UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private static final String DIGITS = "0123456789"; + + /* + * Ref: https://www.owasp.org/index.php/Password_special_characters + * without space character + */ + private static final String SPECIAL_CHARS = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"; + private static Pattern PATTERN_SPECIAL_CHARS = Pattern.compile("[!\"#$%&'()*+,-./:;<=>?@\\[\\\\\\]^_`{|}~]"); + + public static final boolean checkPwd(String pwd, IPasswordRule rule) { + if (pwd == null) return false; + if (pwd.length() < rule.getMin()) return false; + if (pwd.length() > rule.getMax()) return false; + + if (rule.needNumberChar() && !PATTERN_DIGITS.matcher(pwd).find()) return false; + if (rule.needUpperEngChar() && !PATTERN_A2Z_UPPER.matcher(pwd).find()) return false; + if (rule.needLowerEngChar() && !PATTERN_A2Z_LOWER.matcher(pwd).find()) return false; + if (rule.needSpecialChar() && !PATTERN_SPECIAL_CHARS.matcher(pwd).find()) return false; + + return true; + } + + public static String genPwd(IPasswordRule rule) { + int length = rule.getMin(); + + StringBuilder password = new StringBuilder(length); + Random random = new Random(System.nanoTime()); + + List charCategories = new ArrayList<>(4); + if (rule.needLowerEngChar()) charCategories.add(A2Z_LOWER); + if (rule.needUpperEngChar()) charCategories.add(A2Z_UPPER); + if (rule.needNumberChar()) charCategories.add(DIGITS); + if (rule.needSpecialChar()) charCategories.add(SPECIAL_CHARS); + + for (int i = 0; i < length; i++) { + String charCategory = charCategories.get(i % charCategories.size()); + char randomChar = charCategory.charAt(random.nextInt(charCategory.length())); + if (password.length() > 0) + password.insert(random.nextInt(password.length()), randomChar); + else + password.append(randomChar); + } + + return password.toString(); + } + + public static interface IPasswordRule { + public int getMin(); + + public int getMax(); + + public boolean needNumberChar(); + + public boolean needUpperEngChar(); + + public boolean needLowerEngChar(); + + public boolean needSpecialChar(); + } +} diff --git a/src/main/java/com/ffii/tsms/TsmsApplication.java b/src/main/java/com/ffii/tsms/TsmsApplication.java new file mode 100644 index 0000000..939668e --- /dev/null +++ b/src/main/java/com/ffii/tsms/TsmsApplication.java @@ -0,0 +1,13 @@ +package com.ffii.tsms; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class TsmsApplication { + + public static void main(String[] args) { + SpringApplication.run(TsmsApplication.class, args); + } + +} diff --git a/src/main/java/com/ffii/tsms/config/AppConfig.java b/src/main/java/com/ffii/tsms/config/AppConfig.java new file mode 100644 index 0000000..2286aed --- /dev/null +++ b/src/main/java/com/ffii/tsms/config/AppConfig.java @@ -0,0 +1,36 @@ +package com.ffii.tsms.config; + +import javax.sql.DataSource; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +import com.ffii.core.support.JdbcDao; + +/** @author Terence */ +@Configuration +// @EnableJpaRepositories("com.ffii.ars.*") +// @ComponentScan(basePackages = { "com.ffii.core.*" }) +@ComponentScan(basePackages = { "com.ffii.core.*","com.ffii.tsms.*"}) +// @EntityScan("com.ffii.ars.*") +@EnableScheduling +@EnableAsync +public class AppConfig { + + @Bean + @ConfigurationProperties(prefix = "spring.datasource") + public DataSource dataSource() { + return DataSourceBuilder.create().build(); + } + + @Bean + public JdbcDao jdbcDao(DataSource dataSource) { + return new JdbcDao(dataSource); + } + +} diff --git a/src/main/java/com/ffii/tsms/config/WebConfig.java b/src/main/java/com/ffii/tsms/config/WebConfig.java new file mode 100644 index 0000000..bf3c622 --- /dev/null +++ b/src/main/java/com/ffii/tsms/config/WebConfig.java @@ -0,0 +1,29 @@ +package com.ffii.tsms.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.view.InternalResourceViewResolver; + +@Configuration +@EnableWebMvc +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedHeaders("*") + .allowedOrigins("*") + .exposedHeaders("filename") + .allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD"); + + } + + @Bean + public InternalResourceViewResolver defaultViewResolver() { + return new InternalResourceViewResolver(); + } + +} diff --git a/src/main/java/com/ffii/tsms/config/security/SecurityConfig.java b/src/main/java/com/ffii/tsms/config/security/SecurityConfig.java new file mode 100644 index 0000000..6a92d38 --- /dev/null +++ b/src/main/java/com/ffii/tsms/config/security/SecurityConfig.java @@ -0,0 +1,80 @@ +package com.ffii.tsms.config.security; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.ldap.core.support.BaseLdapPathContextSource; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.config.ldap.LdapBindAuthenticationManagerFactory; +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; + +import com.ffii.tsms.config.security.jwt.JwtRequestFilter; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + public static final String INDEX_URL = "/"; + public static final String LOGIN_URL = "/login"; + public static final String LDAP_LOGIN_URL = "/ldap-login"; + + public static final String[] URL_WHITELIST = { + INDEX_URL, + LOGIN_URL, + LDAP_LOGIN_URL + }; + + @Lazy + @Autowired + private JwtRequestFilter jwtRequestFilter; + + @Bean + @Qualifier("AuthenticationManager") + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) + throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + @Qualifier("LdapAuthenticationManager") + public AuthenticationManager ldapAuthenticationManager(BaseLdapPathContextSource contextSource) { + LdapBindAuthenticationManagerFactory factory = new LdapBindAuthenticationManagerFactory(contextSource); + factory.setUserSearchFilter("cn={0}"); + return factory.createAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + @Order(1) + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .cors(Customizer.withDefaults()).csrf(csrf -> csrf.disable()) + .requestCache(requestCache -> requestCache.disable()) + .authorizeHttpRequests( + authRequest -> authRequest.requestMatchers(URL_WHITELIST).permitAll().anyRequest().authenticated()) + .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( + (request, response, authException) -> response.sendError(HttpStatus.UNAUTHORIZED.value()))) + .sessionManagement( + sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } +} diff --git a/src/main/java/com/ffii/tsms/config/security/jwt/JwtRequestFilter.java b/src/main/java/com/ffii/tsms/config/security/jwt/JwtRequestFilter.java new file mode 100644 index 0000000..b035eec --- /dev/null +++ b/src/main/java/com/ffii/tsms/config/security/jwt/JwtRequestFilter.java @@ -0,0 +1,75 @@ +package com.ffii.tsms.config.security.jwt; + +import java.io.IOException; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.ffii.core.utils.JwtTokenUtil; +import com.ffii.tsms.config.security.jwt.service.JwtUserDetailsService; + +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@Component +public class JwtRequestFilter extends OncePerRequestFilter { + + @Autowired + private JwtUserDetailsService jwtUserDetailsService; + + @Autowired + private JwtTokenUtil jwtTokenUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + + final String requestTokenHeader = request.getHeader("Authorization"); + + String username = null; + String jwtToken = null; + // JWT Token is in the form "Bearer token". Remove Bearer word and get + // only the Token + if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) { + jwtToken = requestTokenHeader.substring(7).replaceAll("\"", ""); + try { + username = jwtTokenUtil.getUsernameFromToken(jwtToken); + } catch (IllegalArgumentException e) { + logger.error("Unable to get JWT Token"); + } catch (ExpiredJwtException e) { + logger.error("JWT Token has expired"); + } + } else { + logger.warn("JWT Token does not begin with Bearer String"); + } + + // Once we get the token validate it. + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + + UserDetails userDetails = jwtUserDetailsService.loadUserByUsername(username); + + // if token is valid configure Spring Security to manually set + // authentication + if (jwtTokenUtil.validateToken(jwtToken, userDetails)) { + + UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + // After setting the Authentication in the context, we specify + // that the current user is authenticated. So it passes the + // Spring Security Configurations successfully. + SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); + } + } + chain.doFilter(request, response); + } + +} \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/config/security/jwt/service/JwtUserDetailsService.java b/src/main/java/com/ffii/tsms/config/security/jwt/service/JwtUserDetailsService.java new file mode 100644 index 0000000..c335928 --- /dev/null +++ b/src/main/java/com/ffii/tsms/config/security/jwt/service/JwtUserDetailsService.java @@ -0,0 +1,31 @@ +package com.ffii.tsms.config.security.jwt.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import com.ffii.tsms.modules.user.entity.User; +import com.ffii.tsms.modules.user.entity.UserRepository; +import com.ffii.tsms.modules.user.service.UserAuthorityService; +import com.ffii.tsms.modules.user.service.UserService; + +@Service +public class JwtUserDetailsService implements UserDetailsService { + + @Autowired + UserRepository userRepository; + + @Autowired + UserAuthorityService userAuthService; + + @Autowired + UserService userService; + + + @Override + public User loadUserByUsername(String username) throws UsernameNotFoundException { + return userService.loadUserOptByUsername(username).orElseThrow(() -> new UsernameNotFoundException(username)); + } + +} diff --git a/src/main/java/com/ffii/tsms/config/security/jwt/web/JwtAuthenticationController.java b/src/main/java/com/ffii/tsms/config/security/jwt/web/JwtAuthenticationController.java new file mode 100644 index 0000000..ef2a78a --- /dev/null +++ b/src/main/java/com/ffii/tsms/config/security/jwt/web/JwtAuthenticationController.java @@ -0,0 +1,151 @@ +package com.ffii.tsms.config.security.jwt.web; + +import java.time.Instant; +import java.util.HashSet; +import java.util.Set; + +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.userdetails.UserDetails; +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.RestController; +import org.springframework.web.server.ResponseStatusException; + +import com.ffii.core.utils.AES; +import com.ffii.core.utils.JwtTokenUtil; +import com.ffii.tsms.config.security.jwt.service.JwtUserDetailsService; +import com.ffii.tsms.config.security.service.LoginLogService; +import com.ffii.tsms.model.AbilityModel; +import com.ffii.tsms.model.ExceptionResponse; +import com.ffii.tsms.model.JwtRequest; +import com.ffii.tsms.model.JwtResponse; +import com.ffii.tsms.model.RefreshToken; +import com.ffii.tsms.model.TokenRefreshRequest; +import com.ffii.tsms.model.TokenRefreshResponse; +import com.ffii.tsms.modules.common.SecurityUtils; +import com.ffii.tsms.modules.user.entity.User; +import com.ffii.tsms.modules.user.entity.UserRepository; +import com.ffii.tsms.modules.user.service.UserAuthorityService; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; + +@RestController +@CrossOrigin(origins = "*", allowedHeaders = "*") +public class JwtAuthenticationController { + + @Autowired + @Qualifier("AuthenticationManager") + private AuthenticationManager authenticationManager; + + @Autowired + @Qualifier("LdapAuthenticationManager") + private AuthenticationManager ldapAuthenticationManager; + + @Autowired + private JwtTokenUtil jwtTokenUtil; + + @Autowired + private JwtUserDetailsService userDetailsService; + + @Autowired + private UserRepository userRepository; + + @Autowired + UserAuthorityService userAuthorityService; + + @Autowired + LoginLogService loginLogService; + + @PostMapping("/login") + public ResponseEntity login(@RequestBody JwtRequest authenticationRequest, HttpServletRequest request) throws Exception { + String username = authenticationRequest.getUsername(); + try { + boolean success = authenticate(authenticationRequest.getUsername(), authenticationRequest.getPassword()); + loginLogService.createLoginLog(username, request.getRemoteAddr(), success); + } catch (Exception e) { + if (username != null) { + loginLogService.createLoginLog(username, request.getRemoteAddr(), false); + } + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ExceptionResponse("Unauthorized", ExceptionUtils.getStackTrace(e))); + } + return createAuthTokenResponse(authenticationRequest); + } + + @PostMapping("/ldap-login") + public ResponseEntity ldapLogin(@RequestBody JwtRequest authenticationRequest, HttpServletRequest request) throws Exception { + String username = authenticationRequest.getUsername(); + try { + boolean success = ldapAuthenticate(authenticationRequest.getUsername(), authenticationRequest.getPassword()); + loginLogService.createLoginLog(username, request.getRemoteAddr(), success); + } catch (Exception e) { + loginLogService.createLoginLog(username, request.getRemoteAddr(), false); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ExceptionResponse("Unauthorized", ExceptionUtils.getStackTrace(e))); + } + return createAuthTokenResponse(authenticationRequest); + } + + private boolean authenticate(String username, String password) throws Exception { + authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password)); + return true; + } + + private boolean ldapAuthenticate(String username, String password) throws Exception { + ldapAuthenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password)); + return true; + } + + private ResponseEntity createAuthTokenResponse(JwtRequest authenticationRequest) { + final UserDetails userDetails = userDetailsService.loadUserByUsername(authenticationRequest.getUsername()); + if (userDetails == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ExceptionResponse(authenticationRequest.getUsername() + " not yet register in the system.", null)); + } + + final String accessToken = jwtTokenUtil.generateToken(userDetails); + final String refreshToken = jwtTokenUtil.createRefreshToken(userDetails.getUsername()).getToken(); + + User user = userRepository.findByName(authenticationRequest.getUsername()).get(0); + + Set abilities = new HashSet<>(); + userAuthorityService.getUserAuthority(user).forEach(auth -> abilities.add(new AbilityModel(auth.getAuthority()))); + + return ResponseEntity.ok(new JwtResponse(accessToken, refreshToken, null, user, abilities)); + } + + @PostMapping("/refresh-token") + public ResponseEntity refreshtoken(@Valid @RequestBody TokenRefreshRequest request) + throws Exception { + String requestRefreshToken = request.getRefreshToken(); + + requestRefreshToken = requestRefreshToken.replaceAll("\"", ""); + String[] decryptStringList = AES.decrypt(requestRefreshToken, JwtTokenUtil.AES_SECRET) + .split(JwtTokenUtil.TOKEN_SEPARATOR); + RefreshToken instance = new RefreshToken(); + String username = decryptStringList[0]; + instance.setExpiryDate(Instant.ofEpochMilli(Long.valueOf(decryptStringList[1]))); + instance.setToken(requestRefreshToken); + instance.setUserName(decryptStringList[0]); + + if (!jwtTokenUtil.verifyExpiration(instance)) { + throw new ResponseStatusException(HttpStatus.EXPECTATION_FAILED, + "Refresh token was expired. Please make a new signin request"); + } + + final UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + String accessToken = jwtTokenUtil.generateToken(userDetails); + String refreshToken = jwtTokenUtil.createRefreshToken(username).getToken(); + return ResponseEntity.ok(new TokenRefreshResponse(accessToken, refreshToken)); + } + +} diff --git a/src/main/java/com/ffii/tsms/config/security/service/LoginLogService.java b/src/main/java/com/ffii/tsms/config/security/service/LoginLogService.java new file mode 100644 index 0000000..ae953b3 --- /dev/null +++ b/src/main/java/com/ffii/tsms/config/security/service/LoginLogService.java @@ -0,0 +1,43 @@ +package com.ffii.tsms.config.security.service; + +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import com.ffii.core.support.AbstractService; +import com.ffii.core.support.JdbcDao; +import com.ffii.core.utils.MapUtils; + +@Service +public class LoginLogService extends AbstractService { + + public LoginLogService(JdbcDao jdbcDao) { + super(jdbcDao); + } + + @Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = Exception.class, readOnly = false) + public boolean createLoginLog(String username, String remoteAddr, boolean success) { + String sql = "INSERT INTO user_login_log (`username`, `loginTime`, `ipAddr`, `success`) " + +"VALUES (:username, :loginTime, :ipAddr, :success)"; + Map args = new HashMap<>(4); + args.put("username", username); + args.put("loginTime", new Date()); + args.put("ipAddr", remoteAddr); + args.put("success", success); + + return (jdbcDao.executeUpdate(sql, args) == 1); + } + + @Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = Exception.class, readOnly = true) + public List> listLastLog(String username, int limit) { + return jdbcDao.queryForList("SELECT success FROM user_login_log where username = :username ORDER BY loginTime DESC LIMIT " + limit, + MapUtils.toHashMap("username", username)); + } + +} diff --git a/src/main/java/com/ffii/tsms/model/AbilityModel.java b/src/main/java/com/ffii/tsms/model/AbilityModel.java new file mode 100644 index 0000000..79d8ab0 --- /dev/null +++ b/src/main/java/com/ffii/tsms/model/AbilityModel.java @@ -0,0 +1,13 @@ +package com.ffii.tsms.model; + +public class AbilityModel { + + private final String actionSubjectCombo; + public AbilityModel(String actionSubjectCombo) { + this.actionSubjectCombo = actionSubjectCombo; + } + + public String getActionSubjectCombo() { + return actionSubjectCombo; + } +} diff --git a/src/main/java/com/ffii/tsms/model/ExceptionResponse.java b/src/main/java/com/ffii/tsms/model/ExceptionResponse.java new file mode 100644 index 0000000..f22c306 --- /dev/null +++ b/src/main/java/com/ffii/tsms/model/ExceptionResponse.java @@ -0,0 +1,28 @@ +package com.ffii.tsms.model; + +public class ExceptionResponse { + private String message; + private String exception; + + public ExceptionResponse(String message, String exception) { + this.message = message; + this.exception = exception; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getException() { + return exception; + } + + public void setException(String exception) { + this.exception = exception; + } + +} diff --git a/src/main/java/com/ffii/tsms/model/JwtRequest.java b/src/main/java/com/ffii/tsms/model/JwtRequest.java new file mode 100644 index 0000000..2d2b2cc --- /dev/null +++ b/src/main/java/com/ffii/tsms/model/JwtRequest.java @@ -0,0 +1,40 @@ +package com.ffii.tsms.model; + + +import java.io.Serializable; + +public class JwtRequest implements Serializable { + + private static final long serialVersionUID = 5926468583005150707L; + + private String username; + private String password; + + //need default constructor for JSON Parsing + public JwtRequest() + { + + } + + public JwtRequest(String username, String password) { + this.setUsername(username); + this.setPassword(password); + } + + public String getUsername() { + return this.username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return this.password; + } + + public void setPassword(String password) { + this.password = password; + } + +} \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/model/JwtResponse.java b/src/main/java/com/ffii/tsms/model/JwtResponse.java new file mode 100644 index 0000000..9b0d4e7 --- /dev/null +++ b/src/main/java/com/ffii/tsms/model/JwtResponse.java @@ -0,0 +1,56 @@ +package com.ffii.tsms.model; + +import java.io.Serializable; +import java.util.Set; + +import com.ffii.tsms.modules.user.entity.User; + +public class JwtResponse implements Serializable { + + private static final long serialVersionUID = -8091879091924046844L; + private final Long id; + private final String name; + private final String email; + private final String accessToken; + private final String refreshToken; + private final String role; + private final Set abilities; + + public JwtResponse(String accessToken, String refreshToken, String role, User user, Set abilities) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.role = role; + this.id = user.getId(); + this.name = user.getName(); + this.email = user.getEmail(); + this.abilities = abilities; + } + + public String getAccessToken() { + return this.accessToken; + } + + public String getRole() { + return role; + } + + public String getRefreshToken() { + return refreshToken; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getEmail() { + return email; + } + + public Set getAbilities() { + return abilities; + } +} \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/model/RefreshToken.java b/src/main/java/com/ffii/tsms/model/RefreshToken.java new file mode 100644 index 0000000..159113f --- /dev/null +++ b/src/main/java/com/ffii/tsms/model/RefreshToken.java @@ -0,0 +1,39 @@ +package com.ffii.tsms.model; + +import java.time.Instant; + +public class RefreshToken { + + private String userName; + + private String token; + + private Instant expiryDate; + + + + public String getUserName() { + return userName; + } + + public void setUserName(String userName) { + this.userName = userName; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public Instant getExpiryDate() { + return expiryDate; + } + + public void setExpiryDate(Instant expiryDate) { + this.expiryDate = expiryDate; + } + +} diff --git a/src/main/java/com/ffii/tsms/model/TokenRefreshRequest.java b/src/main/java/com/ffii/tsms/model/TokenRefreshRequest.java new file mode 100644 index 0000000..23d18ae --- /dev/null +++ b/src/main/java/com/ffii/tsms/model/TokenRefreshRequest.java @@ -0,0 +1,16 @@ +package com.ffii.tsms.model; +import jakarta.validation.constraints.NotBlank; + +public class TokenRefreshRequest { + @NotBlank + private String refreshToken; + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + +} diff --git a/src/main/java/com/ffii/tsms/model/TokenRefreshResponse.java b/src/main/java/com/ffii/tsms/model/TokenRefreshResponse.java new file mode 100644 index 0000000..983cc9a --- /dev/null +++ b/src/main/java/com/ffii/tsms/model/TokenRefreshResponse.java @@ -0,0 +1,37 @@ +package com.ffii.tsms.model; + +public class TokenRefreshResponse { + private String accessToken; + private String refreshToken; + private String tokenType = "Bearer"; + + public TokenRefreshResponse(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public String getTokenType() { + return tokenType; + } + + public void setTokenType(String tokenType) { + this.tokenType = tokenType; + } + +} \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/modules/common/ErrorCodes.java b/src/main/java/com/ffii/tsms/modules/common/ErrorCodes.java new file mode 100644 index 0000000..fd95d5f --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/common/ErrorCodes.java @@ -0,0 +1,17 @@ +package com.ffii.tsms.modules.common; + +public class ErrorCodes { + + public static final String FILE_UPLOAD_ERROR = "FILE_UPLOAD_ERROR"; + + public static final String STOCK_IN_WRONG_POST = "STOCK_IN_WRONG_POST"; + + public static final String USER_WRONG_NEW_PWD = "USER_WRONG_NEW_PWD"; + + public static final String SEND_EMAIL_ERROR = "SEND_EMAIL_ERROR"; + public static final String USERNAME_NOT_AVAILABLE = "USERNAME_NOT_AVAILABLE"; + + public static final String INIT_EXCEL_ERROR = "INIT_EXCEL_ERROR"; + + public static final String CHANGE_MAIN_CUSTOMER_ERROR = "CHANGE_MAIN_CUSTOMER_ERROR"; +} diff --git a/src/main/java/com/ffii/tsms/modules/common/PasswordRule.java b/src/main/java/com/ffii/tsms/modules/common/PasswordRule.java new file mode 100644 index 0000000..f5fc7ce --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/common/PasswordRule.java @@ -0,0 +1,116 @@ +package com.ffii.tsms.modules.common; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.ffii.core.utils.PasswordUtils.IPasswordRule; +import com.ffii.tsms.modules.settings.service.SettingsService; + + +public class PasswordRule implements IPasswordRule { + private Integer min; + private Integer max; + + private Boolean number; + private Boolean upperEng; + private Boolean lowerEng; + private Boolean specialChar; + + public PasswordRule(SettingsService settingsService) { + if (settingsService == null){ + throw new IllegalArgumentException("settingsService"); + } + + this.min = settingsService.getInt(SettingNames.SYS_PASSWORD_RULE_MIN); + this.max = settingsService.getInt(SettingNames.SYS_PASSWORD_RULE_MAX); + this.number = settingsService.getBoolean(SettingNames.SYS_PASSWORD_RULE_NUMBER); + this.upperEng = settingsService.getBoolean(SettingNames.SYS_PASSWORD_RULE_UPPER_ENG); + this.lowerEng = settingsService.getBoolean(SettingNames.SYS_PASSWORD_RULE_LOWER_ENG); + this.specialChar = settingsService.getBoolean(SettingNames.SYS_PASSWORD_RULE_SPECIAL); + } + + @Override + public int getMin() { + return min; + } + + @Override + public int getMax() { + return max; + } + + @Override + public boolean needNumberChar() { + return number; + } + + @Override + public boolean needUpperEngChar() { + return upperEng; + } + + @Override + public boolean needLowerEngChar() { + return lowerEng; + } + + @Override + public boolean needSpecialChar() { + return specialChar; + } + + public void setMin(Integer min) { + this.min = min; + } + + public void setMax(Integer max) { + this.max = max; + } + + public Boolean getNumber() { + return number; + } + + public void setNumber(Boolean number) { + this.number = number; + } + + public Boolean getUpperEng() { + return upperEng; + } + + public void setUpperEng(Boolean upperEng) { + this.upperEng = upperEng; + } + + public Boolean getLowerEng() { + return lowerEng; + } + + public void setLowerEng(Boolean lowerEng) { + this.lowerEng = lowerEng; + } + + public Boolean getSpecialChar() { + return specialChar; + } + + public void setSpecialChar(Boolean specialChar) { + this.specialChar = specialChar; + } + + @JsonIgnore + public String getWrongMsg() { + StringBuilder msg = new StringBuilder("Please Following Password Rule.\n"); + msg.append("Minimum " + getMin() + " Characters\n"); + msg.append("Maximum " + getMax() + " Characters\n"); + if (needNumberChar()) + msg.append("Numbers\n"); + if (needLowerEngChar()) + msg.append("Lower-Case Letters\n"); + if (needUpperEngChar()) + msg.append("Capital Letters\n"); + if (needSpecialChar()) + msg.append("Symbols\n"); + return msg.toString(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/modules/common/SecurityUtils.java b/src/main/java/com/ffii/tsms/modules/common/SecurityUtils.java new file mode 100644 index 0000000..2930fb4 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/common/SecurityUtils.java @@ -0,0 +1,146 @@ +package com.ffii.tsms.modules.common; + +import java.util.Optional; + +import org.springframework.dao.DataAccessException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +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.core.userdetails.UsernameNotFoundException; + +import com.ffii.tsms.modules.user.entity.User; + +/** + * Security Utils - for Spring Security + * + * @author Patrick + */ +public class SecurityUtils { + + /** + * Obtains the current {@code SecurityContext}. + * + * @return the security context (never {@code null}) + */ + public static final SecurityContext getSecurityContext() { + return SecurityContextHolder.getContext(); + } + + /** + * @return the authenticated {@code Principal}) + * @see Authentication#getPrincipal() + */ + public static final Optional getUser() { + try { + return Optional.of((User) getSecurityContext().getAuthentication().getPrincipal()); + } catch (ClassCastException e) { + // no authenticated principal + return Optional.empty(); + } catch (NullPointerException e) { + // no authentication information is available + return Optional.empty(); + } + } + + /** + * Updates the Authentication Token with the user (e.g. user changed the password) + * + * @see SecurityContext#setAuthentication(Authentication) + */ + public static final void updateUserAuthentication(final UserDetails user) { + getSecurityContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities())); + } + + /** + * Checks if the current user is GRANTED the {@code role} + * + * @param role + * the {@code role} to check for + * @return {@code true} if the current user is GRANTED the {@code role}, else {@code false} + */ + public static final boolean isGranted(String role) { + Authentication authentication = getSecurityContext().getAuthentication(); + if (authentication == null) return false; + for (GrantedAuthority auth : authentication.getAuthorities()) { + if (role.equals(auth.getAuthority())) return true; + } + return false; + } + + /** + * Checks if the current user is NOT GRANTED the {@code role} + * + * @param role + * the {@code role} to check for + * @return {@code true} if the current user is NOT GRANTED the {@code role}, else {@code false} + */ + public static final boolean isNotGranted(String role) { + return !isGranted(role); + } + + /** + * Checks if the current user is GRANTED ANY of the {@code role}s + * + * @param roles + * the {@code role}s to check for + * @return {@code true} if the current user is GRANTED ANY of the {@code role}s, else {@code false} + */ + public static final boolean isGrantedAny(String... roles) { + for (int i = 0; i < roles.length; i++) { + if (isGranted(roles[i])) return true; + } + return false; + } + + /** + * Checks if the current user is NOT GRANTED ANY of the {@code role}s + * + * @param roles + * the {@code role}s to check for + * @return {@code true} if the current user is NOT GRANTED ANY of the {@code role}s, else {@code false} + */ + public static final boolean isNotGrantedAny(String... roles) { + return !isGrantedAny(roles); + } + + /** + * Checks if the current user is GRANTED ALL of the {@code role}s + * + * @param roles + * the {@code role}s to check for + * @return {@code true} if the current user is GRANTED ALL of the {@code role}s, else {@code false} + */ + public static final boolean isGrantedAll(String... roles) { + for (int i = 0; i < roles.length; i++) { + if (isNotGranted(roles[i])) return false; + } + return true; + } + + /** + * Login a user non-interactively + * + * @param userService + * any implementation of {@link UserDetailsService} + * @param username + * the username + * + * @throws UsernameNotFoundException + * if the user could not be found or the user has no GrantedAuthority + * @throws DataAccessException + * if user could not be found for a repository-specific reason + */ + public static final void loginUser(UserDetailsService userService, String username) { + /* load the user, throw exception if user not found */ + UserDetails userDetails = userService.loadUserByUsername(username); + + /* create authentication token for the specified user */ + Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities()); + getSecurityContext().setAuthentication(authentication); + } + +} diff --git a/src/main/java/com/ffii/tsms/modules/common/SettingNames.java b/src/main/java/com/ffii/tsms/modules/common/SettingNames.java new file mode 100644 index 0000000..674e04d --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/common/SettingNames.java @@ -0,0 +1,61 @@ +package com.ffii.tsms.modules.common; + +public abstract class SettingNames { + /* + * System-wide settings + */ + + /** Define all available language names as comma separated string */ + public static final String SYS_AVAILABLE_LANGUAGES = "SYS.availableLanguages"; + + /** Define all available locales as comma separated string */ + public static final String SYS_AVAILABLE_LOCALES = "SYS.availableLocales"; + + /** Define the system default locale as string */ + public static final String SYS_DEFAULT_LOCALE = "SYS.defaultLocale"; + + /** Define the system available currencies as comma separated string */ + public static final String SYS_CURRENCIES = "SYS.currencies"; + + /** Define the system modules (authorities.module) */ + public static final String SYS_ROLE_MODULES = "SYS.modules"; + + /* + * Mail settings + */ + + /** Mail - SMTP host */ + public static final String MAIL_SMTP_HOST = "MAIL.smtp.host"; + + /** Mail - SMTP port */ + public static final String MAIL_SMTP_PORT = "MAIL.smtp.port"; + + /** Mail - SMTP username */ + public static final String MAIL_SMTP_USERNAME = "MAIL.smtp.username"; + + /** Mail - SMTP password */ + public static final String MAIL_SMTP_PASSWORD = "MAIL.smtp.password"; + + public static final String MAIL_SMTP_RECIPIENTS = "MAIL.smtp.recipients"; + + public static final String JS_VERSION = "JS.version"; + + public static final String REPORT_DAILYMAINT_RECIPIENTS_MECH = "REPORT.dailyMaint.recipients.mech"; + public static final String REPORT_DAILYMAINT_RECIPIENTS_VOGUE = "REPORT.dailyMaint.recipients.vogue"; + public static final String REPORT_DAILYMAINT_RECIPIENTS_VOGUE_CC = "REPORT.dailyMaint.recipients.vogue.cc"; + + public static final String SYS_PASSWORD_RULE_MIN = "SYS.password.rule.length.min"; + public static final String SYS_PASSWORD_RULE_MAX = "SYS.password.rule.length.max"; + public static final String SYS_PASSWORD_RULE_NUMBER = "SYS.password.rule.number"; + public static final String SYS_PASSWORD_RULE_UPPER_ENG = "SYS.password.rule.upper.eng"; + public static final String SYS_PASSWORD_RULE_LOWER_ENG = "SYS.password.rule.lower.eng"; + public static final String SYS_PASSWORD_RULE_SPECIAL = "SYS.password.rule.special"; + + public static final String AUTO_SCHEDULE_MAX_SCHEDULE_DATE = "AUTO_SCHEDULE.maxScheduleDate"; + + /** PM_CHECKLIST - vogue's signature */ + public static final String PM_CHECKLIST_USER_SIGN_ID = "PM_CHECKLIST.vogueSign"; + + public static final String LCTS_FLOOR = "LCTS.floor"; + +} diff --git a/src/main/java/com/ffii/tsms/modules/common/service/AuditLogService.java b/src/main/java/com/ffii/tsms/modules/common/service/AuditLogService.java new file mode 100644 index 0000000..55aa3a4 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/common/service/AuditLogService.java @@ -0,0 +1,48 @@ +package com.ffii.tsms.modules.common.service; + +import java.util.Date; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import com.ffii.core.support.AbstractService; +import com.ffii.core.support.JdbcDao; +import com.ffii.core.utils.MapUtils; +import jakarta.annotation.Nullable; + +@Service +public class AuditLogService extends AbstractService { + + public AuditLogService(JdbcDao jdbcDao) { + super(jdbcDao); + } + + private static final String SQL_INSERT_AUDIT_LOG = "INSERT INTO audit_log (`tableName`, `recordId`, `modifiedBy`, `modified`, `oldData`, `newData`) " + + "VALUES (:tableName, :recordId, :modifiedBy, :modified, :oldData, :newData)"; + + @Transactional(isolation = Isolation.SERIALIZABLE, rollbackFor = Exception.class, readOnly = false) + public int save(String tableName, Long recordId, Long modifiedBy, Date modified, @Nullable String oldData, String newData) { + return jdbcDao.executeUpdate(SQL_INSERT_AUDIT_LOG,MapUtils.toHashMap("tableName", tableName, "recordId", recordId, + "modifiedBy", modifiedBy, "modified", modified, + "oldData", oldData, "newData", newData)); + } + + @Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = Exception.class, readOnly = true) + public List> search(String tableName, Integer recordId) { + + String sql = "SELECT * FROM audit_log WHERE tableName = :tableName AND recordId = :recordId ORDER BY modified"; + + return jdbcDao.queryForList(sql, Map.of("tableName", tableName, "recordId", recordId)); + } + + @Transactional(isolation = Isolation.READ_COMMITTED, rollbackFor = Exception.class, readOnly = true) + public List> getTables() { + String sql = "SELECT DISTINCT tableName FROM audit_log"; + + return jdbcDao.queryForList(sql, ""); + } + +} diff --git a/src/main/java/com/ffii/tsms/modules/settings/entity/Settings.java b/src/main/java/com/ffii/tsms/modules/settings/entity/Settings.java new file mode 100644 index 0000000..52e8d39 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/settings/entity/Settings.java @@ -0,0 +1,74 @@ +package com.ffii.tsms.modules.settings.entity; + +import com.ffii.core.entity.IdEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; + +@Entity +@Table(name = "settings") +public class Settings extends IdEntity { + public static String TYPE_STRING = "string"; + public static String TYPE_INT = "integer"; + public static String TYPE_FLOAT = "float"; + public static String TYPE_BOOLEAN = "boolean"; + public static String TYPE_DATE = "date"; + public static String TYPE_TIME = "time"; + public static String TYPE_DATETIME = "datetime"; + // other "A/B" value must "A" or "B" + + //lowercase + public static String VALUE_BOOLEAN_TRUE = "true"; + public static String VALUE_BOOLEAN_FALSE = "false"; + + // TODO: pattern?? + + @NotNull + @Column + private String name; + + @NotNull + @Column + private String value; + + @Column + private String category; + + @Column + private String type; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public String getCategory() { + return category; + } + + public void setCategory(String category) { + this.category = category; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + +} diff --git a/src/main/java/com/ffii/tsms/modules/settings/entity/SettingsRepository.java b/src/main/java/com/ffii/tsms/modules/settings/entity/SettingsRepository.java new file mode 100644 index 0000000..fdf27ef --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/settings/entity/SettingsRepository.java @@ -0,0 +1,12 @@ +package com.ffii.tsms.modules.settings.entity; + +import java.util.Optional; + +import org.springframework.data.repository.query.Param; + +import com.ffii.core.support.AbstractRepository; + +public interface SettingsRepository extends AbstractRepository { + + Optional findByName(@Param("name") String name); +} diff --git a/src/main/java/com/ffii/tsms/modules/settings/service/SettingsService.java b/src/main/java/com/ffii/tsms/modules/settings/service/SettingsService.java new file mode 100644 index 0000000..c1a8d19 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/settings/service/SettingsService.java @@ -0,0 +1,208 @@ +package com.ffii.tsms.modules.settings.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Optional; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ffii.core.exception.InternalServerErrorException; +import com.ffii.core.support.AbstractIdEntityService; +import com.ffii.core.support.JdbcDao; +import com.ffii.tsms.modules.settings.entity.Settings; +import com.ffii.tsms.modules.settings.entity.SettingsRepository; + + +@Service +public class SettingsService extends AbstractIdEntityService { + + public SettingsService(JdbcDao jdbcDao, SettingsRepository repository) { + super(jdbcDao, repository); + } + + public Optional findByName(String name) { + return this.repository.findByName(name); + } + + public boolean validateType(String type, String value) { + if (StringUtils.isBlank(type)) return true; + + if (Settings.TYPE_STRING.equals(type)) return true; + + if (Settings.TYPE_BOOLEAN.equals(type)) { + return Settings.VALUE_BOOLEAN_TRUE.equals(value) || Settings.VALUE_BOOLEAN_FALSE.equals(value); + } + + if (Settings.TYPE_INT.equals(type)) { + try { + Integer.parseInt(value); + return true; + } catch (NumberFormatException e) { + return false; + } + } + if (Settings.TYPE_FLOAT.equals(type)) { + try { + Float.parseFloat(value); + return true; + } catch (NumberFormatException e) { + return false; + } + } + if (Settings.TYPE_DATE.equals(type)) { + try { + LocalDate.parse(value, DateTimeFormatter.ISO_DATE); + return true; + } catch (DateTimeParseException e) { + return false; + } + } + if (Settings.TYPE_TIME.equals(type)) { + try { + LocalTime.parse(value, DateTimeFormatter.ISO_TIME); + return true; + } catch (DateTimeParseException e) { + return false; + } + } + if (Settings.TYPE_DATETIME.equals(type)) { + try { + LocalDateTime.parse(value, DateTimeFormatter.ISO_DATE_TIME); + return true; + } catch (DateTimeParseException e) { + return false; + } + } + + if (StringUtils.indexOf(type, "/") >= 0) { + for (String t : type.split("/")) { + if (t.equals(value)) return true; + } + return false; + } + + return false; + } + + @Transactional(rollbackFor = Exception.class) + public void update(String name, String value) { + Settings settings = this.findByName(name) + .orElseThrow(InternalServerErrorException::new); + if (!validateType(settings.getType(), value)) { + throw new InternalServerErrorException(); + } + settings.setValue(value); + this.save(settings); + } + + @Transactional(rollbackFor = Exception.class) + public void update(String name, LocalDate date) { + this.update(name, date.format(DateTimeFormatter.ISO_DATE)); + } + + @Transactional(rollbackFor = Exception.class) + public void update(String name, LocalDateTime datetime) { + this.update(name, datetime.format(DateTimeFormatter.ISO_DATE_TIME)); + } + + @Transactional(rollbackFor = Exception.class) + public void update(String name, LocalTime time) { + this.update(name, time.format(DateTimeFormatter.ISO_TIME)); + } + + public String getString(String name) { + return this.findByName(name) + .map(Settings::getValue) + .orElseThrow(InternalServerErrorException::new); + } + + public int getInt(String name) { + return this.findByName(name) + .map(Settings::getValue) + .map(v -> { + try { + return Integer.parseInt(v); + } catch (final NumberFormatException nfe) { + return null; + } + }) + .orElseThrow(InternalServerErrorException::new); + } + + public double getDouble(String name) { + return this.findByName(name) + .map(Settings::getValue) + .map(v -> { + try { + return Double.parseDouble(v); + } catch (final NumberFormatException nfe) { + return null; + } + }) + .orElseThrow(InternalServerErrorException::new); + } + + public boolean getBoolean(String name) { + return this.findByName(name) + .map(Settings::getValue) + .map(Settings.VALUE_BOOLEAN_TRUE::equals) + .orElseThrow(InternalServerErrorException::new); + } + + public LocalDate getDate(String name) { + return this.getDate(name, DateTimeFormatter.ISO_DATE); + } + + private LocalDate getDate(String name, DateTimeFormatter formatter) { + return this.findByName(name) + .map(Settings::getValue) + .map(v -> { + try { + return LocalDate.parse(v, formatter); + } catch (DateTimeParseException e) { + return null; + } + }) + .orElseThrow(InternalServerErrorException::new); + } + + public LocalDateTime getDatetime(String name) { + return this.getDatetime(name, DateTimeFormatter.ISO_DATE_TIME); + } + + private LocalDateTime getDatetime(String name, DateTimeFormatter formatter) { + return this.findByName(name) + .map(Settings::getValue) + .map(v -> { + try { + return LocalDateTime.parse(v, formatter); + } catch (DateTimeParseException e) { + return null; + } + }) + .orElseThrow(InternalServerErrorException::new); + } + + public LocalTime getTime(String name) { + return this.getTime(name, DateTimeFormatter.ISO_TIME); + } + + private LocalTime getTime(String name, DateTimeFormatter formatter) { + return this.findByName(name) + .map(Settings::getValue) + .map(v -> { + try { + return LocalTime.parse(v, formatter); + } catch (DateTimeParseException e) { + return null; + } + }) + .orElseThrow(InternalServerErrorException::new); + } + +} diff --git a/src/main/java/com/ffii/tsms/modules/settings/web/SettingsController.java b/src/main/java/com/ffii/tsms/modules/settings/web/SettingsController.java new file mode 100644 index 0000000..3a76fcb --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/settings/web/SettingsController.java @@ -0,0 +1,66 @@ +package com.ffii.tsms.modules.settings.web; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.ffii.core.exception.BadRequestException; +import com.ffii.core.exception.NotFoundException; +import com.ffii.tsms.modules.settings.entity.Settings; +import com.ffii.tsms.modules.settings.service.SettingsService; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; + +@RestController +@RequestMapping("/settings") +public class SettingsController{ + + private SettingsService settingsService; + + public SettingsController(SettingsService settingsService) { + this.settingsService = settingsService; + } + + // @Operation(summary = "list system settings") + @GetMapping + // @PreAuthorize("hasAuthority('ADMIN')") + public List listAll() { + return this.settingsService.listAll(); + } + + // @Operation(summary = "update system setting") + @PatchMapping("/{name}") + // @PreAuthorize("hasAuthority('ADMIN')") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void update(@PathVariable String name, @RequestBody @Valid UpdateReq body) { + Settings entity = this.settingsService.findByName(name) + .orElseThrow(NotFoundException::new); + if (!this.settingsService.validateType(entity.getType(), body.value)) { + throw new BadRequestException(); + } + + entity.setValue(body.value); + this.settingsService.save(entity); + } + + public static class UpdateReq { + @NotBlank + private String value; + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + } +} diff --git a/src/main/java/com/ffii/tsms/modules/user/entity/Group.java b/src/main/java/com/ffii/tsms/modules/user/entity/Group.java new file mode 100644 index 0000000..e6d7bd2 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/user/entity/Group.java @@ -0,0 +1,37 @@ +package com.ffii.tsms.modules.user.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; + +import com.ffii.core.entity.BaseEntity; + +@Entity +@Table(name = "`group`") +public class Group extends BaseEntity { + + @NotNull + @Column + private String name; + + @Column + private String description; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + +} \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/modules/user/entity/GroupRepository.java b/src/main/java/com/ffii/tsms/modules/user/entity/GroupRepository.java new file mode 100644 index 0000000..966f7f8 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/user/entity/GroupRepository.java @@ -0,0 +1,6 @@ +package com.ffii.tsms.modules.user.entity; + +import com.ffii.core.support.AbstractRepository; + +public interface GroupRepository extends AbstractRepository { +} diff --git a/src/main/java/com/ffii/tsms/modules/user/entity/User.java b/src/main/java/com/ffii/tsms/modules/user/entity/User.java new file mode 100644 index 0000000..e319e6f --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/user/entity/User.java @@ -0,0 +1,254 @@ +package com.ffii.tsms.modules.user.entity; + +import java.time.LocalDate; +import java.util.Collection; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.ffii.core.entity.BaseEntity; + +/** @author Terence */ +@Entity +@Table(name = "user") +public class User extends BaseEntity implements UserDetails { + + @NotBlank + @Column(unique = true) + private String username; + + @JsonIgnore + @NotBlank + @Column + private String password; + + // @NotNull + @Column + private Boolean locked = Boolean.FALSE; + + @NotBlank + @Column + private String name; + + @Column + private LocalDate expiryDate; + + @JsonIgnore + @Transient + private Collection authorities; + + @Column + private String locale; + + @Column + private String fullname; + + @Column + private String firstname; + + @Column + private String lastname; + + @Column + private String department; + + @Column + private String title; + + @Column + private String email; + + @Column + private String phone1; + + @Column + private String phone2; + + @Column + private String remarks; + + @Column + private boolean lotusNotesUser = false; + + public boolean isLocked() { + return this.locked == null ? false : this.locked; + } + + // getter & setter + + public void setUsername(String username) { + this.username = username; + } + + public void setPassword(String password) { + this.password = password; + } + + public Boolean getLocked() { + return locked; + } + + public void setLocked(Boolean locked) { + this.locked = locked; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public LocalDate getExpiryDate() { + return expiryDate; + } + + public void setExpiryDate(LocalDate expiryDate) { + this.expiryDate = expiryDate; + } + + public void setAuthorities(Collection authorities) { + this.authorities = authorities; + } + + public String getLocale() { + return locale; + } + + public void setLocale(String locale) { + this.locale = locale; + } + + public String getFullname() { + return fullname; + } + + public void setFullname(String fullname) { + this.fullname = fullname; + } + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public String getLastname() { + return lastname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone1() { + return phone1; + } + + public void setPhone1(String phone1) { + this.phone1 = phone1; + } + + public String getPhone2() { + return phone2; + } + + public void setPhone2(String phone2) { + this.phone2 = phone2; + } + + public String getRemarks() { + return remarks; + } + + public void setRemarks(String remarks) { + this.remarks = remarks; + } + + // override + + @Override + public Collection getAuthorities() { + return this.authorities; + } + + @Override + public String getPassword() { + return this.password; + } + + @Override + public String getUsername() { + return this.username; + } + + @Override + public boolean isAccountNonExpired() { + return this.getExpiryDate() == null || this.getExpiryDate().isAfter(LocalDate.now()); + } + + @Override + public boolean isAccountNonLocked() { + return !this.isLocked(); + } + + @JsonIgnore + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @JsonIgnore + @Override + public boolean isEnabled() { + return true; + } + + public String getDepartment() { + return department; + } + + public void setDepartment(String department) { + this.department = department; + } + + public boolean isLotusNotesUser() { + return this.lotusNotesUser; + } + + public boolean getLotusNotesUser() { + return this.lotusNotesUser; + } + + public void setLotusNotesUser(boolean lotusNotesUser) { + this.lotusNotesUser = lotusNotesUser; + } + +} diff --git a/src/main/java/com/ffii/tsms/modules/user/entity/UserRepository.java b/src/main/java/com/ffii/tsms/modules/user/entity/UserRepository.java new file mode 100644 index 0000000..1abe93e --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/user/entity/UserRepository.java @@ -0,0 +1,15 @@ +package com.ffii.tsms.modules.user.entity; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.repository.query.Param; + +import com.ffii.core.support.AbstractRepository; + +public interface UserRepository extends AbstractRepository { + + List findByName(@Param("name") String name); + + Optional findByUsernameAndDeletedFalse(String username); +} diff --git a/src/main/java/com/ffii/tsms/modules/user/req/NewPublicUserReq.java b/src/main/java/com/ffii/tsms/modules/user/req/NewPublicUserReq.java new file mode 100644 index 0000000..076573d --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/user/req/NewPublicUserReq.java @@ -0,0 +1,29 @@ +package com.ffii.tsms.modules.user.req; + +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** @author Alex */ +public class NewPublicUserReq extends UpdateUserReq { + + @Size(max = 30) + @Pattern(regexp = "^[A-Za-z0-9]+$") + private String username; + private String password; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return this.password; + } + + public void setPassword(String password) { + this.password = password; + } +} diff --git a/src/main/java/com/ffii/tsms/modules/user/req/NewUserReq.java b/src/main/java/com/ffii/tsms/modules/user/req/NewUserReq.java new file mode 100644 index 0000000..53f5429 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/user/req/NewUserReq.java @@ -0,0 +1,21 @@ +package com.ffii.tsms.modules.user.req; + +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** @author Alex */ +public class NewUserReq extends UpdateUserReq { + + @Size(max = 30) + @Pattern(regexp = "^[A-Za-z0-9]+$") + private String username; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + +} diff --git a/src/main/java/com/ffii/tsms/modules/user/req/SaveGroupReq.java b/src/main/java/com/ffii/tsms/modules/user/req/SaveGroupReq.java new file mode 100644 index 0000000..c2517a8 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/user/req/SaveGroupReq.java @@ -0,0 +1,80 @@ +package com.ffii.tsms.modules.user.req; + +import java.util.List; + +import jakarta.validation.constraints.NotNull; + +public class SaveGroupReq { + private Long id; + + @NotNull + private String name; + private String description; + + @NotNull + private List addUserIds; + @NotNull + private List removeUserIds; + + @NotNull + private List addAuthIds; + @NotNull + private List removeAuthIds; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public List getAddUserIds() { + return addUserIds; + } + + public void setAddUserIds(List addUserIds) { + this.addUserIds = addUserIds; + } + + public List getRemoveUserIds() { + return removeUserIds; + } + + public void setRemoveUserIds(List removeUserIds) { + this.removeUserIds = removeUserIds; + } + + public List getAddAuthIds() { + return addAuthIds; + } + + public void setAddAuthIds(List addAuthIds) { + this.addAuthIds = addAuthIds; + } + + public List getRemoveAuthIds() { + return removeAuthIds; + } + + public void setRemoveAuthIds(List removeAuthIds) { + this.removeAuthIds = removeAuthIds; + } + +} diff --git a/src/main/java/com/ffii/tsms/modules/user/req/SearchUserReq.java b/src/main/java/com/ffii/tsms/modules/user/req/SearchUserReq.java new file mode 100644 index 0000000..bf5dd59 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/user/req/SearchUserReq.java @@ -0,0 +1,69 @@ +package com.ffii.tsms.modules.user.req; + +public class SearchUserReq { + private Integer id; + private Integer groupId; + private String username; + private String name; + private Boolean locked; + + private Integer start; + private Integer limit; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public Integer getGroupId() { + return groupId; + } + + public void setGroupId(Integer groupId) { + this.groupId = groupId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Integer getStart() { + return start; + } + + public void setStart(Integer start) { + this.start = start; + } + + public Integer getLimit() { + return limit; + } + + public void setLimit(Integer limit) { + this.limit = limit; + } + + public Boolean getLocked() { + return locked; + } + + public void setLocked(Boolean locked) { + this.locked = locked; + } + +} diff --git a/src/main/java/com/ffii/tsms/modules/user/req/UpdateUserReq.java b/src/main/java/com/ffii/tsms/modules/user/req/UpdateUserReq.java new file mode 100644 index 0000000..531eba6 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/user/req/UpdateUserReq.java @@ -0,0 +1,151 @@ +package com.ffii.tsms.modules.user.req; + +import java.time.LocalDate; +import java.util.List; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** @author Alex */ +public class UpdateUserReq { + + @NotNull + private Boolean locked; + + @Size(max = 90) + @NotBlank + private String name; + + private String firstname; + private String lastname; + private LocalDate expiryDate; + private String locale; + private String remarks; + + @NotBlank + @Email + private String email; + @NotBlank + private String department; + + // @NotNull + private List addGroupIds; + // @NotNull + private List removeGroupIds; + + // @NotNull + private List addAuthIds; + // @NotNull + private List removeAuthIds; + + public Boolean getLocked() { + return locked; + } + + public void setLocked(Boolean locked) { + this.locked = locked; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public LocalDate getExpiryDate() { + return expiryDate; + } + + public void setExpiryDate(LocalDate expiryDate) { + this.expiryDate = expiryDate; + } + + public String getFirstname() { + return firstname; + } + + public void setFirstName(String firstname) { + this.firstname = firstname; + } + + public String getLastname() { + return lastname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; + } + + public String getLocale() { + return locale; + } + + public void setLocale(String locale) { + this.locale = locale; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public List getAddGroupIds() { + return addGroupIds; + } + + public void setAddGroupIds(List addGroupIds) { + this.addGroupIds = addGroupIds; + } + + public List getRemoveGroupIds() { + return removeGroupIds; + } + + public void setRemoveGroupIds(List removeGroupIds) { + this.removeGroupIds = removeGroupIds; + } + + public List getAddAuthIds() { + return addAuthIds; + } + + public void setAddAuthIds(List addAuthIds) { + this.addAuthIds = addAuthIds; + } + + public List getRemoveAuthIds() { + return removeAuthIds; + } + + public void setRemoveAuthIds(List removeAuthIds) { + this.removeAuthIds = removeAuthIds; + } + + public String getRemarks() { + return remarks; + } + + public void setRemarks(String remarks) { + this.remarks = remarks; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getDepartment() { + return department; + } + + public void setDepartment(String department) { + this.department = department; + } + +} diff --git a/src/main/java/com/ffii/tsms/modules/user/service/GroupService.java b/src/main/java/com/ffii/tsms/modules/user/service/GroupService.java new file mode 100644 index 0000000..f7cc523 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/user/service/GroupService.java @@ -0,0 +1,176 @@ +package com.ffii.tsms.modules.user.service; + +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ffii.core.exception.NotFoundException; +import com.ffii.core.support.AbstractBaseEntityService; +import com.ffii.core.support.JdbcDao; +import com.ffii.core.utils.JsonUtils; +import com.ffii.core.utils.Params; +import com.ffii.tsms.modules.common.SecurityUtils; +import com.ffii.tsms.modules.common.service.AuditLogService; +import com.ffii.tsms.modules.user.entity.Group; +import com.ffii.tsms.modules.user.entity.GroupRepository; +import com.ffii.tsms.modules.user.req.SaveGroupReq; + +import jakarta.persistence.Table; +import jakarta.validation.Valid; + + +@Service +public class GroupService extends AbstractBaseEntityService { + + @Autowired + private AuditLogService auditLogService; + + public GroupService(JdbcDao jdbcDao, GroupRepository repository) { + super(jdbcDao, repository); + } + + public List> search(Map args) { + StringBuilder sql = new StringBuilder("SELECT" + + " g.*" + + " FROM `group` g" + + " WHERE g.deleted = FALSE"); + + if (args != null) { + if (args.containsKey(Params.QUERY)) sql.append(" AND (g.name LIKE :query)"); + if (args.containsKey(Params.ID)) sql.append(" AND g.id = :id"); + if (args.containsKey(Params.NAME)) sql.append(" AND g.name LIKE :name"); + } + + sql.append(" ORDER BY g.name"); + + return jdbcDao.queryForList(sql.toString(), args); + } + + public List> searchForCombo(Map args) { + StringBuilder sql = new StringBuilder("SELECT" + + " g.id," + + " g.name" + + " FROM `group` g" + + " WHERE g.deleted = FALSE"); + + if (args != null) { + if (args.containsKey(Params.QUERY)) sql.append(" AND (g.name LIKE :query)"); + if (args.containsKey(Params.ID)) sql.append(" AND g.id = :id"); + } + + sql.append(" ORDER BY g.name"); + + return jdbcDao.queryForList(sql.toString(), args); + } + + @Transactional(rollbackFor = Exception.class) + public void delete(Group instance) { + Map args = Map.of("groupId", instance.getId()); + jdbcDao.executeUpdate("DELETE FROM user_group WHERE groupId = :groupId;", args); + jdbcDao.executeUpdate("DELETE FROM group_authority WHERE groupId = :groupId;", args); + markDelete(instance); + } + + @Transactional(rollbackFor = Exception.class) + public Group saveOrUpdate(@Valid SaveGroupReq req ) { + Group instance; + + if (req.getId() != null) { + instance = find(req.getId()).orElseThrow(NotFoundException::new); + } else { + instance = new Group(); + } + BeanUtils.copyProperties(req, instance); + + String tableName = instance.getClass().getAnnotation(Table.class).name(); + StringBuilder sql = new StringBuilder("SELECT * FROM " + tableName + " WHERE id = :id"); + String oldValueJson = null; + String newValueJson = null; + + if (instance != null && instance.getId() != null && instance.getId() > 0) { + oldValueJson = JsonUtils.toJsonString(jdbcDao.queryForMap(sql.toString(), Map.of("id", instance.getId())).orElseThrow(NotFoundException::new)); + } + + instance = saveAndFlush(instance); + Long id = instance.getId(); + + List> userBatchInsertValues = req.getAddUserIds().stream() + .map(userId -> Map.of("groupId", id, "userId", userId)) + .collect(Collectors.toList()); + List> userBatchDeleteValues = req.getRemoveUserIds().stream() + .map(userId -> Map.of("groupId", id, "userId", userId)) + .collect(Collectors.toList()); + + if (!userBatchInsertValues.isEmpty()) { + jdbcDao.batchUpdate( + "INSERT IGNORE INTO user_group (groupId,userId)" + + " VALUES (:groupId, :userId)", + userBatchInsertValues); + } + if (!userBatchDeleteValues.isEmpty()) { + jdbcDao.batchUpdate( + "DELETE FROM user_group" + + " WHERE groupId = :groupId AND userId = :userId", + userBatchDeleteValues); + } + + List> authBatchInsertValues = req.getAddAuthIds().stream() + .map(authId -> Map.of("groupId", id, "authId", authId)) + .collect(Collectors.toList()); + List> authBatchDeleteValues = req.getRemoveAuthIds().stream() + .map(authId -> Map.of("groupId", id, "authId", authId)) + .collect(Collectors.toList()); + + if (!authBatchInsertValues.isEmpty()) { + jdbcDao.batchUpdate( + "INSERT IGNORE INTO group_authority (groupId, authId)" + + " VALUES (:groupId, :authId)", + authBatchInsertValues); + } + if (!authBatchDeleteValues.isEmpty()) { + jdbcDao.batchUpdate( + "DELETE FROM group_authority" + + " WHERE groupId = :groupId AND authId = :authId", + authBatchDeleteValues); + } + + if (instance != null && instance.getId() != null && instance.getId() > 0) { + newValueJson = JsonUtils.toJsonString(jdbcDao.queryForMap(sql.toString(), Map.of("id", instance.getId())).orElseThrow(NotFoundException::new)); + } + + auditLogService.save( + tableName, + id, + SecurityUtils.getUser() != null ? SecurityUtils.getUser().get().getId() : null, + new Date(), + oldValueJson, + newValueJson); + return instance; + } + + public List listGroupAuthId(Long id) { + return jdbcDao.queryForInts( + "SELECT" + + " ga.authId" + + " FROM group_authority ga" + + " WHERE ga.groupId = :id", + Map.of(Params.ID, id)); + } + + public List listGroupUserId(Long id) { + return jdbcDao.queryForInts( + "SELECT" + + " gu.userId" + + " FROM user_group gu" + + " INNER JOIN user u ON u.deleted = FALSE AND gu.userId = u.id" + + " WHERE gu.groupId = :id", + Map.of(Params.ID, id)); + } + +} diff --git a/src/main/java/com/ffii/tsms/modules/user/service/UserAuthorityService.java b/src/main/java/com/ffii/tsms/modules/user/service/UserAuthorityService.java new file mode 100644 index 0000000..6d2e4f7 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/user/service/UserAuthorityService.java @@ -0,0 +1,48 @@ +package com.ffii.tsms.modules.user.service; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ffii.core.support.AbstractService; +import com.ffii.core.support.JdbcDao; +import com.ffii.tsms.modules.user.entity.User; + +@Service +public class UserAuthorityService extends AbstractService { + private static final String USER_AUTH_SQL = "SELECT a.authority" + + " FROM `user` u" + + " JOIN user_authority ua ON ua.userId = u.id" + + " JOIN authority a ON a.id = ua.authId" + + " WHERE u.deleted = 0" + + " AND u.id = :userId"; + private static final String UNION_SQL = " UNION "; + private static final String GROUP_AUTH_SQL = "SELECT a.authority" + + " FROM `user` u" + + " JOIN user_group ug ON ug.userId = u.id" + + " JOIN `group` g ON g.deleted = 0 AND g.id = ug.groupId" + + " JOIN group_authority ga ON ga.groupId = g.id" + + " JOIN authority a ON a.id = ga.authId" + + " WHERE u.deleted = 0" + + " AND u.id = :userId"; + + public UserAuthorityService(JdbcDao jdbcDao) { + super(jdbcDao); + } + + @Transactional(rollbackFor = Exception.class) + public Set getUserAuthority(User user) { + Set auths = new HashSet<>(); + List> records = jdbcDao.queryForList(USER_AUTH_SQL + UNION_SQL + GROUP_AUTH_SQL, + Map.of("userId", user.getId())); + + records.forEach(item -> auths.add(new SimpleGrantedAuthority((String) item.get("authority")))); + return auths; + } + +} diff --git a/src/main/java/com/ffii/tsms/modules/user/service/UserService.java b/src/main/java/com/ffii/tsms/modules/user/service/UserService.java new file mode 100644 index 0000000..3bb0e39 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/user/service/UserService.java @@ -0,0 +1,269 @@ +package com.ffii.tsms.modules.user.service; + +import java.io.UnsupportedEncodingException; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.LocaleUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ffii.core.exception.NotFoundException; +import com.ffii.core.exception.UnprocessableEntityException; +import com.ffii.core.support.AbstractBaseEntityService; +import com.ffii.core.support.JdbcDao; +import com.ffii.core.utils.Params; +import com.ffii.core.utils.PasswordUtils; +import com.ffii.tsms.modules.common.ErrorCodes; +import com.ffii.tsms.modules.common.PasswordRule; +import com.ffii.tsms.modules.settings.service.SettingsService; +import com.ffii.tsms.modules.user.entity.User; +import com.ffii.tsms.modules.user.entity.UserRepository; +import com.ffii.tsms.modules.user.req.NewPublicUserReq; +import com.ffii.tsms.modules.user.req.NewUserReq; +import com.ffii.tsms.modules.user.req.SearchUserReq; +import com.ffii.tsms.modules.user.req.UpdateUserReq; +import com.ffii.tsms.modules.user.service.pojo.UserRecord; + +import jakarta.mail.internet.InternetAddress; + +@Service +public class UserService extends AbstractBaseEntityService { + private static final String USER_AUTH_SQL = "SELECT a.authority" + + " FROM `user` u" + + " JOIN user_authority ua ON ua.userId = u.id" + + " JOIN authority a ON a.id = ua.authId" + + " WHERE u.deleted = 0" + + " AND u.id = :userId"; + private static final String UNION_SQL = " UNION "; + private static final String GROUP_AUTH_SQL = "SELECT a.authority" + + " FROM `user` u" + + " JOIN user_group ug ON ug.userId = u.id" + + " JOIN `group` g ON g.deleted = 0 AND g.id = ug.groupId" + + " JOIN group_authority ga ON ga.groupId = g.id" + + " JOIN authority a ON a.id = ga.authId" + + " WHERE u.deleted = 0" + + " AND u.id = :userId"; + + @Autowired + private SettingsService settingsService; + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + UserRepository userRepository; + + public UserService(JdbcDao jdbcDao, UserRepository userRepository) { + super(jdbcDao, userRepository); + } + + public Optional loadUserOptByUsername(String username) { + return findByUsername(username) + .map(user -> { + Set auths = new LinkedHashSet(); + auths.add(new SimpleGrantedAuthority("ROLE_USER")); + jdbcDao.queryForList(USER_AUTH_SQL + UNION_SQL + GROUP_AUTH_SQL, Map.of("userId", user.getId())) + .forEach(item -> auths.add(new SimpleGrantedAuthority((String) item.get("authority")))); + + user.setAuthorities(auths); + return user; + }); + } + + public Optional findByUsername(String username) { + return userRepository.findByUsernameAndDeletedFalse(username); + } + + // @Transactional(rollbackFor = Exception.class) + public List search(SearchUserReq req) { + StringBuilder sql = new StringBuilder("SELECT" + + " u.id," + + " u.created," + + " u.createdBy," + + " u.version," + + " u.modified," + + " u.modifiedBy," + + " u.username," + + " u.locked," + + " u.name," + + " u.locale," + + " u.firstname," + + " u.lastname," + + " u.title," + + " u.department," + + " u.email," + + " u.phone1," + + " u.phone2," + + " u.remarks " + + " FROM `user` u" + + " left join user_group ug on u.id = ug.userId" + + " where u.deleted = false"); + + if (req != null) { + if (req.getId() != null) + sql.append(" AND u.id = :id"); + + if (req.getGroupId() != null) + sql.append(" AND ug.groupId = :groupId"); + if (StringUtils.isNotBlank(req.getUsername())) { + req.setUsername("%" + req.getUsername() + "%"); + sql.append(" AND u.username LIKE :username"); + } + if (StringUtils.isNotBlank(req.getName())) { + req.setName("%" + req.getName() + "%"); + sql.append(" AND u.name LIKE :name"); + } + if (req.getLocked() != null) { + sql.append(" AND u.locked = :locked"); + } + } + sql.append(" ORDER BY u.name"); + + if (req != null) { + if (req.getStart() != null && req.getLimit() != null) + sql.append(" LIMIT :start, :limit"); + } + + return jdbcDao.queryForList(sql.toString(), req, UserRecord.class); + } + + public List listUserAuthId(long id) { + return jdbcDao.queryForInts( + "SELECT" + + " ua.authId" + + " FROM user_authority ua" + + " WHERE ua.userId = :id", + Map.of(Params.ID, id)); + } + + public List listUserGroupId(long id) { + return jdbcDao.queryForInts( + "SELECT" + + " gu.groupId" + + " FROM user_group gu" + + " INNER JOIN `group` g ON g.deleted = FALSE AND g.id = gu.groupId" + + " WHERE gu.userId = :id", + Map.of(Params.ID, id)); + } + + private User saveOrUpdate(User instance, UpdateUserReq req) { + + if (instance.getId() == null){ + req.setLocked(false); + } + BeanUtils.copyProperties(req,instance); + instance = save(instance); + // long id = instance.getId(); + + // List> groupBatchInsertValues = req.getAddGroupIds().stream() + // .map(groupId -> Map.of("userId", (int) id, "groupId", groupId)) + // .collect(Collectors.toList()); + // List> groupBatchDeleteValues = req.getRemoveGroupIds().stream() + // .map(groupId -> Map.of("userId", (int) id, "groupId", groupId)) + // .collect(Collectors.toList()); + + // if (!groupBatchInsertValues.isEmpty()) { + // jdbcDao.batchUpdate( + // "INSERT IGNORE INTO user_group (groupId,userId)" + // + " VALUES (:groupId, :userId)", + // groupBatchInsertValues); + // } + // if (!groupBatchDeleteValues.isEmpty()) { + // jdbcDao.batchUpdate( + // "DELETE FROM user_group" + // + " WHERE groupId = :groupId AND userId = :userId", + // groupBatchDeleteValues); + // } + + // List> authBatchInsertValues = req.getAddAuthIds().stream() + // .map(authId -> Map.of("userId", (int)id, "authId", authId)) + // .collect(Collectors.toList()); + // List> authBatchDeleteValues = req.getRemoveAuthIds().stream() + // .map(authId -> Map.of("userId", (int)id, "authId", authId)) + // .collect(Collectors.toList()); + // if (!authBatchInsertValues.isEmpty()) { + // jdbcDao.batchUpdate( + // "INSERT IGNORE INTO user_authority (userId, authId)" + // + " VALUES (:userId, :authId)", + // authBatchInsertValues); + // } + + // if (!authBatchDeleteValues.isEmpty()) { + // jdbcDao.batchUpdate( + // "DELETE FROM user_authority" + // + " WHERE userId = :userId AND authId = :authId", + // authBatchDeleteValues); + // } + return instance; + } + + @Transactional(rollbackFor = Exception.class) + public User newRecord(NewUserReq req) throws UnsupportedEncodingException { + if (findByUsername(req.getUsername()).isPresent()) { + throw new UnprocessableEntityException(ErrorCodes.USERNAME_NOT_AVAILABLE); + } + + String randomPassword = PasswordUtils.genPwd(new PasswordRule(settingsService)); + String pwdHash = passwordEncoder.encode(randomPassword); + + User instance = new User(); + instance.setPassword(pwdHash); + instance = saveOrUpdate(instance, req); + + // Locale locale = instance.getLocale() != null ? LocaleUtils.from(instance.getLocale()) : Locale.ENGLISH; + // mailService.send( + // MailRequest.builder() + // .subject(messageSource.getMessage("USER.newAc.subject", null, locale)) + // .template("mail/newUser") + // .args(Map.of("username", instance.getUsername(), "password", StringEscapeUtils.escapeHtml4(randomPassword))) + // .addTo(new InternetAddress(instance.getEmail(), instance.getName())) + // .build(), + // locale); + return instance; + } + + @Transactional(rollbackFor = Exception.class) + public User newPublicUserRecord(NewPublicUserReq req) throws UnsupportedEncodingException { + if (findByUsername(req.getUsername()).isPresent()) { + throw new UnprocessableEntityException(ErrorCodes.USERNAME_NOT_AVAILABLE); + } + + String submitedPassword = req.getPassword(); + String pwdHash = passwordEncoder.encode(submitedPassword); + req.setPassword(pwdHash); + User instance = new User(); + + instance = saveOrUpdate(instance, req); + return instance; + } + + @Transactional(rollbackFor = Exception.class) + public void updateRecord(long id, UpdateUserReq req) { + saveOrUpdate( + find(id).orElseThrow(NotFoundException::new), + req); + } + + @Transactional(rollbackFor = Exception.class) + public String resetPassword(long id) throws UnsupportedEncodingException { + User instance = find(id).orElseThrow(NotFoundException::new); + String randomPassword = PasswordUtils.genPwd(new PasswordRule(settingsService)); + + instance.setPassword(passwordEncoder.encode(randomPassword)); + instance = save(instance); + return randomPassword; + } + + +} diff --git a/src/main/java/com/ffii/tsms/modules/user/service/pojo/AuthRecord.java b/src/main/java/com/ffii/tsms/modules/user/service/pojo/AuthRecord.java new file mode 100644 index 0000000..33ad0b5 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/user/service/pojo/AuthRecord.java @@ -0,0 +1,41 @@ +package com.ffii.tsms.modules.user.service.pojo; + +public class AuthRecord { + private Integer id; + private String module; + private String authority; + private String name; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getModule() { + return module; + } + + public void setModule(String module) { + this.module = module; + } + + public String getAuthority() { + return authority; + } + + public void setAuthority(String authority) { + this.authority = authority; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + +} diff --git a/src/main/java/com/ffii/tsms/modules/user/service/pojo/UserRecord.java b/src/main/java/com/ffii/tsms/modules/user/service/pojo/UserRecord.java new file mode 100644 index 0000000..6af65aa --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/user/service/pojo/UserRecord.java @@ -0,0 +1,155 @@ +package com.ffii.tsms.modules.user.service.pojo; + +import java.time.LocalDateTime; + +public class UserRecord { + private Integer id; + private LocalDateTime created; + private String createdBy; + private String modified; + private String modifiedBy; + private String username; + private Boolean locked; + private String name; + private Integer companyId; + private Integer customerId; + private String locale; + private String fullname; + private String firstname; + private String lastname; + private String title; + private String department; + private String deptId; + private String email; + private String phone1; + private String phone2; + private String remarks; + + public Integer getId() { + return id; + } + public void setId(Integer id) { + this.id = id; + } + public LocalDateTime getCreated() { + return created; + } + public void setCreated(LocalDateTime created) { + this.created = created; + } + public String getCreatedBy() { + return createdBy; + } + public void setCreatedBy(String createdBy) { + this.createdBy = createdBy; + } + public String getModified() { + return modified; + } + public void setModified(String modified) { + this.modified = modified; + } + public String getModifiedBy() { + return modifiedBy; + } + public void setModifiedBy(String modifiedBy) { + this.modifiedBy = modifiedBy; + } + public String getUsername() { + return username; + } + public void setUsername(String username) { + this.username = username; + } + public Boolean getLocked() { + return locked; + } + public void setLocked(Boolean locked) { + this.locked = locked; + } + public String getName() { + return name; + } + public void setName(String name) { + this.name = name; + } + public Integer getCompanyId() { + return companyId; + } + public void setCompanyId(Integer companyId) { + this.companyId = companyId; + } + public Integer getCustomerId() { + return customerId; + } + public void setCustomerId(Integer customerId) { + this.customerId = customerId; + } + public String getLocale() { + return locale; + } + public void setLocale(String locale) { + this.locale = locale; + } + public String getFullname() { + return fullname; + } + public void setFullname(String fullname) { + this.fullname = fullname; + } + public String getFirstname() { + return firstname; + } + public void setFirstname(String firstname) { + this.firstname = firstname; + } + public String getLastname() { + return lastname; + } + public void setLastname(String lastname) { + this.lastname = lastname; + } + public String getTitle() { + return title; + } + public void setTitle(String title) { + this.title = title; + } + public String getDepartment() { + return department; + } + public void setDepartment(String department) { + this.department = department; + } + public String getDeptId() { + return deptId; + } + public void setDeptId(String deptId) { + this.deptId = deptId; + } + public String getEmail() { + return email; + } + public void setEmail(String email) { + this.email = email; + } + public String getPhone1() { + return phone1; + } + public void setPhone1(String phone1) { + this.phone1 = phone1; + } + public String getPhone2() { + return phone2; + } + public void setPhone2(String phone2) { + this.phone2 = phone2; + } + public String getRemarks() { + return remarks; + } + public void setRemarks(String remarks) { + this.remarks = remarks; + } + +} diff --git a/src/main/java/com/ffii/tsms/modules/user/service/res/LoadUserRes.java b/src/main/java/com/ffii/tsms/modules/user/service/res/LoadUserRes.java new file mode 100644 index 0000000..c2a47c6 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/user/service/res/LoadUserRes.java @@ -0,0 +1,45 @@ +package com.ffii.tsms.modules.user.service.res; + +import java.util.List; + +import com.ffii.tsms.modules.user.entity.User; + +public class LoadUserRes { + private User data; + private List authIds; + private List groupIds; + + public LoadUserRes() { + } + + public LoadUserRes(User data, List authIds, List groupIds) { + this.data = data; + this.authIds = authIds; + this.groupIds = groupIds; + } + + public User getData() { + return data; + } + + public void setData(User data) { + this.data = data; + } + + public List getAuthIds() { + return authIds; + } + + public void setAuthIds(List authIds) { + this.authIds = authIds; + } + + public List getGroupIds() { + return groupIds; + } + + public void setGroupIds(List groupIds) { + this.groupIds = groupIds; + } + +} diff --git a/src/main/java/com/ffii/tsms/modules/user/web/GroupController.java b/src/main/java/com/ffii/tsms/modules/user/web/GroupController.java new file mode 100644 index 0000000..89752b4 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/user/web/GroupController.java @@ -0,0 +1,80 @@ +package com.ffii.tsms.modules.user.web; + +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.ServletRequestBindingException; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.ffii.core.exception.NotFoundException; +import com.ffii.core.response.IdRes; +import com.ffii.core.response.RecordsRes; +import com.ffii.core.utils.CriteriaArgsBuilder; +import com.ffii.core.utils.Params; +import com.ffii.tsms.modules.user.req.SaveGroupReq; +import com.ffii.tsms.modules.user.service.GroupService; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; + +@RestController +@RequestMapping("/group") +public class GroupController{ + + private final Log logger = LogFactory.getLog(getClass()); + private GroupService groupService; + + public GroupController( + GroupService groupService + ) { + this.groupService = groupService; + } + + @PostMapping("/save") + public IdRes saveOrUpdate(@RequestBody @Valid SaveGroupReq req) { + return new IdRes(groupService.saveOrUpdate(req).getId()); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable Long id) { + groupService.delete(groupService.find(id).orElseThrow(NotFoundException::new)); + } + + @GetMapping("/{id}") + public Map load(@PathVariable Long id) { + return Map.of( + Params.DATA, groupService.find(id).orElseThrow(NotFoundException::new), + "authIds", groupService.listGroupAuthId(id), + "userIds", groupService.listGroupUserId(id)); + } + + @GetMapping("/combo") + public RecordsRes> comboJson(HttpServletRequest request) throws ServletRequestBindingException { + return new RecordsRes<>(groupService.searchForCombo( + CriteriaArgsBuilder.withRequest(request) + .addInteger(Params.ID) + .addStringLike(Params.QUERY) + .build())); + } + + @GetMapping + public RecordsRes> listJson(HttpServletRequest request) throws ServletRequestBindingException { + return new RecordsRes<>(groupService.search( + CriteriaArgsBuilder.withRequest(request) + .addInteger(Params.ID) + .addStringLike(Params.NAME) + .addInteger("userId") + .build())); + } + +} diff --git a/src/main/java/com/ffii/tsms/modules/user/web/TestController.java b/src/main/java/com/ffii/tsms/modules/user/web/TestController.java new file mode 100644 index 0000000..dd8a382 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/user/web/TestController.java @@ -0,0 +1,21 @@ +package com.ffii.tsms.modules.user.web; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.annotation.Secured; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class TestController { + + private final Log logger = LogFactory.getLog(getClass()); + + @GetMapping("/test") + @Secured("ROLE_USER") + public ResponseEntity test() throws Exception { + logger.info("hihihihihii"); + return ResponseEntity.ok("hihi"); + } +} diff --git a/src/main/java/com/ffii/tsms/modules/user/web/UserController.java b/src/main/java/com/ffii/tsms/modules/user/web/UserController.java new file mode 100644 index 0000000..8ab3cdf --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/user/web/UserController.java @@ -0,0 +1,193 @@ +package com.ffii.tsms.modules.user.web; + +import java.io.UnsupportedEncodingException; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PatchMapping; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.ffii.core.exception.BadRequestException; +import com.ffii.core.exception.NotFoundException; +import com.ffii.core.exception.UnprocessableEntityException; +import com.ffii.core.response.IdRes; +import com.ffii.core.utils.PasswordUtils; +import com.ffii.tsms.modules.common.ErrorCodes; +import com.ffii.tsms.modules.common.PasswordRule; +import com.ffii.tsms.modules.common.SecurityUtils; +import com.ffii.tsms.modules.settings.service.SettingsService; +import com.ffii.tsms.modules.user.entity.User; +import com.ffii.tsms.modules.user.req.NewPublicUserReq; +import com.ffii.tsms.modules.user.req.NewUserReq; +import com.ffii.tsms.modules.user.req.SearchUserReq; +import com.ffii.tsms.modules.user.req.UpdateUserReq; +import com.ffii.tsms.modules.user.service.UserService; +import com.ffii.tsms.modules.user.service.res.LoadUserRes; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; + +@RestController +@RequestMapping("/user") +public class UserController{ + + private final Log logger = LogFactory.getLog(getClass()); + private UserService userService; + private PasswordEncoder passwordEncoder; + private SettingsService settingsService; + + public UserController( + UserService userService, + PasswordEncoder passwordEncoder, + SettingsService settingsService) { + this.userService = userService; + this.passwordEncoder = passwordEncoder; + this.settingsService = settingsService; + } + + // @Operation(summary = "list user", responses = { @ApiResponse(responseCode = "200"), + // @ApiResponse(responseCode = "404", content = @Content) }) + @GetMapping + @PreAuthorize("hasAuthority('VIEW_USER')") + public ResponseEntity list(@ModelAttribute @Valid SearchUserReq req) { + logger.info("Test List user"); + return ResponseEntity.ok(userService.search(req)); + } + + // @Operation(summary = "load user data", responses = { @ApiResponse(responseCode = "200"), + // @ApiResponse(responseCode = "404", content = @Content) }) + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('VIEW_USER')") + public LoadUserRes load(@PathVariable long id) { + LoadUserRes test = new LoadUserRes( + userService.find(id).orElseThrow(NotFoundException::new), + userService.listUserAuthId(id), + userService.listUserGroupId(id)); + logger.info("Test List user2"); + logger.info(test); + return test; + } + + // @Operation(summary = "delete user", responses = { @ApiResponse(responseCode = "204"), + // @ApiResponse(responseCode = "404", content = @Content) }) + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PreAuthorize("hasAuthority('MAINTAIN_USER')") + public void delete(@PathVariable long id) { + userService.markDelete(userService.find(id).orElseThrow(NotFoundException::new)); + } + + // @Operation(summary = "new user") + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("hasAuthority('MAINTAIN_USER')") + public IdRes newRecord(@RequestBody @Valid NewUserReq req) throws UnsupportedEncodingException { + return new IdRes(userService.newRecord(req).getId()); + } + + // @Operation(summary = "new user by public user") + @PostMapping("/registry") + @ResponseStatus(HttpStatus.CREATED) + // @PreAuthorize("hasAuthority('MAINTAIN_USER')") + public ResponseEntity createPublicUserRecord(@RequestBody NewPublicUserReq req) throws UnsupportedEncodingException { + logger.info("Create user request:"); + return ResponseEntity.ok(new IdRes(userService.newPublicUserRecord(req).getId())) ; + } + + // @Operation(summary = "update user", responses = { + // @ApiResponse(responseCode = "204"), + // @ApiResponse(responseCode = "400", content = @Content), + // @ApiResponse(responseCode = "404", content = @Content), + // }) + + @PutMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PreAuthorize("hasAuthority('MAINTAIN_USER')") + public void updateRecord(@PathVariable int id, @RequestBody @Valid UpdateUserReq req) { + userService.updateRecord(id, req); + } + + // @Operation(summary = "current user change password", description = "error: USER_WRONG_NEW_PWD = new password not available", responses = { + // @ApiResponse(responseCode = "204"), + // @ApiResponse(responseCode = "400", content = @Content), + // @ApiResponse(responseCode = "404", content = @Content), + // @ApiResponse(responseCode = "422", content = @Content(schema = @Schema(implementation = FailureRes.class))), + // }) + @PatchMapping("/change-password") + @ResponseStatus(HttpStatus.NO_CONTENT) + // @PreAuthorize("hasAuthority('MAINTAIN_USER')") + public void changePassword(@RequestBody @Valid ChangePwdReq req) { + long id = SecurityUtils.getUser().get().getId(); + User instance = userService.find(id).orElseThrow(NotFoundException::new); + + logger.info("TEST req: "+req.getPassword()); + logger.info("TEST instance: "+instance.getPassword()); + if (!passwordEncoder.matches(req.getPassword(), instance.getPassword())) { + throw new BadRequestException(); + } + + PasswordRule rule = new PasswordRule(settingsService); + if (!PasswordUtils.checkPwd(req.getNewPassword(), rule)) { + throw new UnprocessableEntityException(ErrorCodes.USER_WRONG_NEW_PWD); + } + + instance.setPassword(passwordEncoder.encode(req.getNewPassword())); + userService.save(instance); + } + + // @Operation(summary = "reset password", responses = { + // @ApiResponse(responseCode = "204"), + // @ApiResponse(responseCode = "404", content = @Content), + // }) + @PostMapping("/{id}/reset-password") + @PreAuthorize("hasAuthority('MAINTAIN_USER')") + @ResponseStatus(HttpStatus.NO_CONTENT) + public ResponseEntity resetPassword(@PathVariable long id) throws UnsupportedEncodingException { + String password = userService.resetPassword(id); + return ResponseEntity.ok(password); + } + + // @Operation(summary = "get password rules") + @GetMapping("/password-rule") + public PasswordRule passwordRlue() { + return new PasswordRule(settingsService); + } + + public static class ChangePwdReq { + @NotBlank + private String password; + @NotBlank + private String newPassword; + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getNewPassword() { + return newPassword; + } + + public void setNewPassword(String newPassword) { + this.newPassword = newPassword; + } + + } + +} diff --git a/src/main/resources/application-db-2fi.yml b/src/main/resources/application-db-2fi.yml new file mode 100644 index 0000000..661776e --- /dev/null +++ b/src/main/resources/application-db-2fi.yml @@ -0,0 +1,5 @@ +spring: + datasource: + jdbc-url: jdbc:mysql://192.168.1.81:3306/arsdb?useUnicode=true&characterEncoding=UTF8&serverTimezone=GMT%2B8 + username: root + password: secret \ No newline at end of file diff --git a/src/main/resources/application-db-local.yml b/src/main/resources/application-db-local.yml new file mode 100644 index 0000000..0e698ad --- /dev/null +++ b/src/main/resources/application-db-local.yml @@ -0,0 +1,5 @@ +spring: + datasource: + jdbc-url: jdbc:mysql://127.0.0.1:3306/tsmsdb?useUnicode=true&characterEncoding=UTF8&serverTimezone=GMT%2B8 + username: root + password: secret \ No newline at end of file diff --git a/src/main/resources/application-ldap-local.yml b/src/main/resources/application-ldap-local.yml new file mode 100644 index 0000000..6974913 --- /dev/null +++ b/src/main/resources/application-ldap-local.yml @@ -0,0 +1,9 @@ +spring: + ldap: + embedded: + port: 8389 + base-dn: dc=springframework,dc=org + ldif: classpath:ldap-test-users.ldif + validation: + enabled: false + urls: ldap://localhost:8389 \ No newline at end of file diff --git a/src/main/resources/application-prod-linux.yml b/src/main/resources/application-prod-linux.yml new file mode 100644 index 0000000..41ac797 --- /dev/null +++ b/src/main/resources/application-prod-linux.yml @@ -0,0 +1,2 @@ +logging: + config: 'classpath:log4j2-prod-linux.yml' \ No newline at end of file diff --git a/src/main/resources/application-prod-win.yml b/src/main/resources/application-prod-win.yml new file mode 100644 index 0000000..b7b358c --- /dev/null +++ b/src/main/resources/application-prod-win.yml @@ -0,0 +1,2 @@ +logging: + config: 'classpath:log4j2-prod-win.yml' \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..8fd5034 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,28 @@ +server: + servlet: + contextPath: /api + encoding: + charset: UTF-8 + enabled: true + force: true + port: 8090 + error: + include-message: always + +spring: + servlet: + multipart: + max-file-size: 500MB + max-request-size: 600MB + jpa: + hibernate: + naming: + physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl + database-platform: org.hibernate.dialect.MySQL8Dialect + properties: + hibernate: + dialect: + storage_engine: innodb + +logging: + config: 'classpath:log4j2.yml' \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20230720_01_alex/01_base.sql b/src/main/resources/db/changelog/changes/20230720_01_alex/01_base.sql new file mode 100644 index 0000000..263c542 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20230720_01_alex/01_base.sql @@ -0,0 +1,77 @@ +--liquibase formatted sql + +--changeset alex:user +--comment: core table +CREATE TABLE `user` ( + `id` int NOT NULL AUTO_INCREMENT, + `created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `createdBy` varchar(30) DEFAULT NULL, + `version` int NOT NULL DEFAULT '0', + `modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modifiedBy` varchar(30) DEFAULT NULL, + `deleted` tinyint(1) NOT NULL DEFAULT '0', + `username` varchar(30) NOT NULL, + `password` varchar(60) DEFAULT NULL, + `locked` tinyint(1) NOT NULL DEFAULT '0', + `expiryDate` date DEFAULT NULL, + `name` varchar(50) NOT NULL, + `locale` varchar(5) DEFAULT NULL, + `fullname` varchar(90) DEFAULT NULL, + `firstname` varchar(45) DEFAULT NULL, + `lastname` varchar(30) DEFAULT NULL, + `title` varchar(60) DEFAULT NULL, + `department` varchar(60) DEFAULT NULL, + `email` varchar(120) DEFAULT NULL, + `phone1` varchar(30) DEFAULT NULL, + `phone2` varchar(30) DEFAULT NULL, + `remarks` varchar(600) DEFAULT NULL, + `lotusNotesUser` tinyint(1) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `username` (`username`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO `user`(`name`,`username`, `password`)VALUES ('2fi','2fi','$2a$10$65S7/AhKn8MldlYmvFN5JOfr1yaULwFNDIhTskLTuUCKgbbs8sFAi'); +INSERT INTO `user`(`name`,`username`, `password`,`lotusNotesUser`)VALUES ('user1','user1',null,1); + +CREATE TABLE `authority` ( + `id` int NOT NULL AUTO_INCREMENT, + `authority` varchar(255) NOT NULL, + `name` varchar(100) NOT NULL, + `module` varchar(50) DEFAULT NULL, + `description` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `authority` (`authority`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +INSERT INTO `authority` VALUES (1,'MAINTAIN_USER','Maintain User',NULL,NULL),(2,'MAINTAIN_GROUP','Maintain group',NULL,NULL),(3,'VIEW_USER','view user',NULL,NULL),(4,'VIEW_GROUP','view group',NULL,NULL); +CREATE TABLE `user_authority` ( + `userId` int NOT NULL, + `authId` int NOT NULL, + PRIMARY KEY (`userId`,`authId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +INSERT INTO `user_authority` VALUES (1,3); + +--changeset alex:group +--comment: group table +CREATE TABLE `group` ( + `id` int NOT NULL AUTO_INCREMENT, + `created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `createdBy` varchar(30) DEFAULT NULL, + `version` int NOT NULL DEFAULT '0', + `modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modifiedBy` varchar(30) DEFAULT NULL, + `deleted` tinyint(1) NOT NULL DEFAULT '0', + `name` varchar(50) NOT NULL, + `description` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_bin DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +CREATE TABLE `user_group` ( + `groupId` int NOT NULL, + `userId` int NOT NULL, + PRIMARY KEY (`groupId`,`userId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +CREATE TABLE `group_authority` ( + `groupId` int NOT NULL, + `authId` int NOT NULL, + PRIMARY KEY (`groupId`,`authId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/src/main/resources/db/changelog/changes/20230720_01_alex/02_settings.sql b/src/main/resources/db/changelog/changes/20230720_01_alex/02_settings.sql new file mode 100644 index 0000000..7ce6b27 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20230720_01_alex/02_settings.sql @@ -0,0 +1,13 @@ +--liquibase formatted sql + +--changeset alex:settings +--comment: settings table +CREATE TABLE `settings` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `value` varchar(1000) NOT NULL, + `category` varchar(50), + `type` varchar(10), + + INDEX `name_idx` (`name`) +); \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20230720_01_alex/03_settings_data.sql b/src/main/resources/db/changelog/changes/20230720_01_alex/03_settings_data.sql new file mode 100644 index 0000000..b0565f7 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20230720_01_alex/03_settings_data.sql @@ -0,0 +1,10 @@ +--liquibase formatted sql + +--changeset alex:settings_data +INSERT INTO `settings` (`name`, `value`,`type`) VALUES + ('SYS.password.rule.length.max', '20', 'integer'), + ('SYS.password.rule.length.min', '8', 'integer'), + ('SYS.password.rule.lower.eng', 'true', 'boolean'), + ('SYS.password.rule.number', 'true', 'boolean'), + ('SYS.password.rule.special', 'true', 'boolean'), + ('SYS.password.rule.upper.eng', 'true', 'boolean'); \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20230720_01_alex/04_update_user_authority.sql b/src/main/resources/db/changelog/changes/20230720_01_alex/04_update_user_authority.sql new file mode 100644 index 0000000..6336b37 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20230720_01_alex/04_update_user_authority.sql @@ -0,0 +1,5 @@ +--liquibase formatted sql + +--changeset alex:update_user_authority +INSERT INTO `tsmsdb`.`user_authority` (`userId`, `authId`) VALUES ('1', '1'); +INSERT INTO `tsmsdb`.`user_authority` (`userId`, `authId`) VALUES ('1', '2'); \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20230725_01_alex/01_audit_log.sql b/src/main/resources/db/changelog/changes/20230725_01_alex/01_audit_log.sql new file mode 100644 index 0000000..8aa0ef6 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20230725_01_alex/01_audit_log.sql @@ -0,0 +1,13 @@ +--liquibase formatted sql + +--changeset alex:audit_log +--comment: audit log +CREATE TABLE `audit_log` ( + `tableName` varchar(30) NOT NULL, + `recordId` int(11) NOT NULL, + `modifiedBy` int(11) DEFAULT NULL, + `modified` datetime DEFAULT NULL, + `oldData` json DEFAULT NULL, + `newData` json DEFAULT NULL, + KEY `idx_tableName_recordId` (`tableName`,`recordId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20230725_01_alex/02_user_login_log.sql b/src/main/resources/db/changelog/changes/20230725_01_alex/02_user_login_log.sql new file mode 100644 index 0000000..c611b38 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20230725_01_alex/02_user_login_log.sql @@ -0,0 +1,11 @@ +--liquibase formatted sql + +--changeset alex:user_login_log +--comment: user login log +CREATE TABLE `user_login_log` ( + `username` varchar(32) NOT NULL, + `loginTime` datetime NOT NULL, + `ipAddr` varchar(45) NOT NULL, + `success` tinyint(1) NOT NULL, + PRIMARY KEY (`username`,`loginTime`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; \ No newline at end of file diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml new file mode 100644 index 0000000..b5832ba --- /dev/null +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -0,0 +1,3 @@ +databaseChangeLog: + - includeAll: + path: classpath:/db/changelog/changes \ No newline at end of file diff --git a/src/main/resources/ldap-test-users.ldif b/src/main/resources/ldap-test-users.ldif new file mode 100644 index 0000000..f368cda --- /dev/null +++ b/src/main/resources/ldap-test-users.ldif @@ -0,0 +1,14 @@ +dn: dc=springframework,dc=org +objectClass: top +objectClass: domain +dc: springframework + +dn: uid=user1,dc=springframework,dc=org +objectClass: top +cn: user1 +userPassword: userPass1 + +dn: uid=user2,dc=springframework,dc=org +objectClass: top +cn: user2 +userPassword: userPass2 \ No newline at end of file diff --git a/src/main/resources/log4j2-prod-linux.yml b/src/main/resources/log4j2-prod-linux.yml new file mode 100644 index 0000000..1859476 --- /dev/null +++ b/src/main/resources/log4j2-prod-linux.yml @@ -0,0 +1,23 @@ +Configutation: + name: Prod-Default + Properties: + Property: + name: log_location + value: /usr/springboot/logs/ + Appenders: + RollingFile: + name: RollingFile_Appender + fileName: ${log_location}tsms-all.log + filePattern: ${log_location}tsms-all.log.%i.gz + PatternLayout: + Pattern: "%d %p [%l] - %m%n" + Policies: + SizeBasedTriggeringPolicy: + size: 4096KB + DefaultRollOverStrategy: + max: 99 + Loggers: + Root: + level: info + AppenderRef: + - ref: RollingFile_Appender \ No newline at end of file diff --git a/src/main/resources/log4j2-prod-win.yml b/src/main/resources/log4j2-prod-win.yml new file mode 100644 index 0000000..8e770f5 --- /dev/null +++ b/src/main/resources/log4j2-prod-win.yml @@ -0,0 +1,23 @@ +Configutation: + name: Prod-Default + Properties: + Property: + name: log_location + value: C:/workspace/ + Appenders: + RollingFile: + name: RollingFile_Appender + fileName: ${log_location}tsms-all.log + filePattern: ${log_location}tsms-all.log.%i.gz + PatternLayout: + Pattern: "%d %p [%l] - %m%n" + Policies: + SizeBasedTriggeringPolicy: + size: 4096KB + DefaultRollOverStrategy: + max: 99 + Loggers: + Root: + level: info + AppenderRef: + - ref: RollingFile_Appender \ No newline at end of file diff --git a/src/main/resources/log4j2.yml b/src/main/resources/log4j2.yml new file mode 100644 index 0000000..1d9a0cd --- /dev/null +++ b/src/main/resources/log4j2.yml @@ -0,0 +1,17 @@ +Configutation: + name: Default + Properties: + Property: + name: log_pattern + value: "%d{yyyy-MM-dd HH:mm:ss.SSS} %5p ${hostName} --- [%15.15t] %-40.40c{1.} : %m%n%ex" + Appenders: + Console: + name: Console_Appender + target: SYSTEM_OUT + PatternLayout: + pattern: ${log_pattern} + Loggers: + Root: + level: info + AppenderRef: + - ref: Console_Appender \ No newline at end of file diff --git a/src/test/java/com/ffii/tsms/ArsApplicationTests.java b/src/test/java/com/ffii/tsms/ArsApplicationTests.java new file mode 100644 index 0000000..01e63dd --- /dev/null +++ b/src/test/java/com/ffii/tsms/ArsApplicationTests.java @@ -0,0 +1,13 @@ +package com.ffii.tsms; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class TsmsApplicationTests { + + @Test + void contextLoads() { + } + +}