├── www └── images │ ├── todo.png │ ├── infinite-scroll.png │ ├── input-widgets-1.png │ └── input-widgets-2.png ├── .mvn └── wrapper │ ├── maven-wrapper.jar │ ├── maven-wrapper.properties │ └── MavenWrapperDownloader.java ├── src ├── main │ ├── resources │ │ ├── favicon.ico │ │ ├── favicon.png │ │ ├── static │ │ │ └── public │ │ │ │ ├── logo.png │ │ │ │ ├── pinegrow.json │ │ │ │ ├── click-to-edit-default.html │ │ │ │ ├── click-to-edit-form.html │ │ │ │ ├── identity │ │ │ │ ├── password-reset.html │ │ │ │ ├── forgot-password.html │ │ │ │ ├── sign-up.html │ │ │ │ └── sign-in.html │ │ │ │ ├── private-index.html │ │ │ │ ├── infinite-scroll.html │ │ │ │ ├── index.html │ │ │ │ ├── click-to-edit.html │ │ │ │ ├── value-select.html │ │ │ │ ├── layout.html │ │ │ │ ├── todo.html │ │ │ │ └── input-catalog.html │ │ ├── META-INF │ │ │ └── additional-spring-configuration-metadata.json │ │ ├── application-h2.yaml │ │ ├── logback.xml │ │ └── application.yaml │ └── java │ │ └── com │ │ └── devhow │ │ ├── identity │ │ ├── user │ │ │ ├── TimeUtil.java │ │ │ ├── UserRepository.java │ │ │ ├── UserValidationRepository.java │ │ │ ├── IdentityServiceException.java │ │ │ ├── SecurityUserService.java │ │ │ ├── EmailSenderService.java │ │ │ ├── UserController.java │ │ │ └── UserService.java │ │ ├── entity │ │ │ ├── UserRole.java │ │ │ ├── UserValidation.java │ │ │ └── User.java │ │ └── config │ │ │ ├── PasswordEncoderConfig.java │ │ │ ├── DaoOverride.java │ │ │ └── WebSecurityConfig.java │ │ ├── htmxdemo │ │ ├── DemoOverview.java │ │ ├── TopSecret.java │ │ ├── ClickToEdit.java │ │ ├── ToDoList.java │ │ ├── Contact.java │ │ ├── InfiniteScroll.java │ │ ├── ValueSelect.java │ │ └── InputCatalog.java │ │ └── HtmxDemoApplication.java ├── site │ └── site.xml └── test │ ├── java │ └── com │ │ └── devhow │ │ ├── identity │ │ ├── UserDataAccessTests.java │ │ ├── UserUtils.java │ │ ├── UserControllerTests.java │ │ ├── WebSecurityConfigTests.java │ │ └── UserRegistrationTests.java │ │ └── htmxdemo │ │ └── SpringContextLoadTests.java │ └── resources │ └── test-logback.xml ├── .gitignore ├── mvnw.cmd ├── README.md ├── mvnw └── pom.xml /www/images/todo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/questlog/htmx-demo/main/www/images/todo.png -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/questlog/htmx-demo/main/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /src/main/resources/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/questlog/htmx-demo/main/src/main/resources/favicon.ico -------------------------------------------------------------------------------- /src/main/resources/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/questlog/htmx-demo/main/src/main/resources/favicon.png -------------------------------------------------------------------------------- /www/images/infinite-scroll.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/questlog/htmx-demo/main/www/images/infinite-scroll.png -------------------------------------------------------------------------------- /www/images/input-widgets-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/questlog/htmx-demo/main/www/images/input-widgets-1.png -------------------------------------------------------------------------------- /www/images/input-widgets-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/questlog/htmx-demo/main/www/images/input-widgets-2.png -------------------------------------------------------------------------------- /src/main/resources/static/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/questlog/htmx-demo/main/src/main/resources/static/public/logo.png -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.1/apache-maven-3.8.1-bin.zip 2 | wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar 3 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/additional-spring-configuration-metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "properties": [ 3 | { 4 | "name": "app.server.address", 5 | "type": "java.lang.String", 6 | "description": "Description for app.server.address." 7 | } 8 | ] } -------------------------------------------------------------------------------- /src/main/java/com/devhow/identity/user/TimeUtil.java: -------------------------------------------------------------------------------- 1 | package com.devhow.identity.user; 2 | 3 | import java.sql.Timestamp; 4 | import java.util.Calendar; 5 | 6 | public class TimeUtil { 7 | 8 | public Timestamp now() { 9 | return new java.sql.Timestamp(Calendar.getInstance().getTime().getTime()); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/com/devhow/identity/entity/UserRole.java: -------------------------------------------------------------------------------- 1 | package com.devhow.identity.entity; 2 | 3 | public enum UserRole { 4 | 5 | USER("USER"), VALIDATED("VALIDATED"); 6 | 7 | private final String label; 8 | 9 | UserRole(String label) { 10 | this.label = label; 11 | } 12 | 13 | public String getLabel() { 14 | return label; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/devhow/identity/user/UserRepository.java: -------------------------------------------------------------------------------- 1 | package com.devhow.identity.user; 2 | 3 | import com.devhow.identity.entity.User; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.stereotype.Repository; 6 | 7 | import java.util.Optional; 8 | 9 | @Repository 10 | public interface UserRepository extends JpaRepository { 11 | Optional findByUsername(String username); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/devhow/htmxdemo/DemoOverview.java: -------------------------------------------------------------------------------- 1 | package com.devhow.htmxdemo; 2 | 3 | import org.springframework.stereotype.Controller; 4 | import org.springframework.ui.Model; 5 | import org.springframework.web.bind.annotation.GetMapping; 6 | 7 | import java.util.Date; 8 | 9 | @Controller 10 | public class DemoOverview { 11 | 12 | @GetMapping("/") 13 | public String overview(Model model) { 14 | model.addAttribute("now", new Date()); 15 | return "index"; 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/devhow/identity/user/UserValidationRepository.java: -------------------------------------------------------------------------------- 1 | package com.devhow.identity.user; 2 | 3 | 4 | import com.devhow.identity.entity.UserValidation; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | import org.springframework.stereotype.Repository; 7 | 8 | import java.util.Optional; 9 | 10 | @Repository 11 | public 12 | interface UserValidationRepository extends JpaRepository { 13 | Optional findByToken(String token); 14 | } 15 | -------------------------------------------------------------------------------- /src/site/site.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | org.apache.maven.skins 6 | maven-fluido-skin 7 | 1.9 8 | 9 | 10 | 11 | 12 | true 13 | true 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/main/resources/static/public/pinegrow.json: -------------------------------------------------------------------------------- 1 | {"files":{"index.html":{"frameworks":["public","pg.bs5.lib","pg.insight.events","pg.svg.lib","pg.css.grid","pg.image.overlay","pg.code-validator","pg.project.items","pg.asset.manager","bs5","pg.html","pg.components"],"last_page_width":1024}},"breakpoints":["576px","768px","992px","1200px","1400px"],"frameworks":["public","pg.bs5.lib","pg.insight.events","pg.svg.lib","pg.css.grid","pg.image.overlay","pg.code-validator","pg.project.items","pg.asset.manager","bs5","pg.html","pg.components"],"urls":{"index.html":{"open-page-views":[{"w":1024,"h":0}]}}} -------------------------------------------------------------------------------- /src/main/resources/application-h2.yaml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1 4 | username: sa 5 | password: 6 | driverClassName: org.h2.Driver 7 | jpa: 8 | properties: 9 | hibernate: 10 | dialect: org.hibernate.dialect.H2Dialect 11 | jdbc: 12 | lob: 13 | non_contextual_creation: true 14 | ddl-auto: create 15 | open-in-view: true 16 | # show-sql: true 17 | h2: 18 | console: 19 | enabled: true 20 | path: /h2-console 21 | app: 22 | server: 23 | address: "http://localhost:8080" 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | .DS_Store 35 | **/_pgbackup/** 36 | src/main/resources/static/public/_pginfo/fonts.json 37 | -------------------------------------------------------------------------------- /src/test/java/com/devhow/identity/UserDataAccessTests.java: -------------------------------------------------------------------------------- 1 | package com.devhow.identity; 2 | 3 | import com.devhow.identity.user.UserRepository; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.context.SpringBootTest; 7 | 8 | import static org.assertj.core.api.Assertions.assertThat; 9 | 10 | @SpringBootTest 11 | public class UserDataAccessTests { 12 | 13 | @Autowired 14 | UserRepository userRepository; 15 | 16 | @Test 17 | public void dataAccessTest() { 18 | assertThat(userRepository.count()).isGreaterThan(-1); 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/devhow/HtmxDemoApplication.java: -------------------------------------------------------------------------------- 1 | package com.devhow; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.boot.context.properties.ConfigurationPropertiesScan; 6 | import org.springframework.cache.annotation.EnableCaching; 7 | import org.springframework.scheduling.annotation.EnableAsync; 8 | 9 | @SpringBootApplication 10 | @EnableCaching 11 | @EnableAsync 12 | @ConfigurationPropertiesScan 13 | public class HtmxDemoApplication { 14 | 15 | public static void main(String[] args) { 16 | SpringApplication.run(HtmxDemoApplication.class, args); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/main/resources/static/public/click-to-edit-default.html: -------------------------------------------------------------------------------- 1 |
2 |
:
3 |
:
4 |
:
5 | 6 | 7 | 8 | 9 | 10 | 13 |
-------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 12 | 13 | -------------------------------------------------------------------------------- /src/main/java/com/devhow/identity/user/IdentityServiceException.java: -------------------------------------------------------------------------------- 1 | package com.devhow.identity.user; 2 | 3 | public class IdentityServiceException extends Exception { 4 | 5 | private final Reason reason; 6 | 7 | public IdentityServiceException(Reason reason, String message) { 8 | super(message); 9 | this.reason = reason; 10 | } 11 | 12 | public IdentityServiceException(Reason reason) { 13 | super(); 14 | this.reason = reason; 15 | } 16 | 17 | public Reason getReason() { 18 | return reason; 19 | } 20 | 21 | public enum Reason { 22 | BAD_EMAIL, 23 | BAD_LOGIN, 24 | BAD_PASSWORD, 25 | BAD_PASSWORD_RESET, 26 | BAD_TOKEN 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/resources/static/public/click-to-edit-form.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 |
7 |
8 | 9 | 10 |
11 |
12 | 13 | 14 |
15 | 16 | 17 |
18 |
19 | -------------------------------------------------------------------------------- /src/main/java/com/devhow/htmxdemo/TopSecret.java: -------------------------------------------------------------------------------- 1 | package com.devhow.htmxdemo; 2 | 3 | import org.springframework.http.MediaType; 4 | import org.springframework.stereotype.Controller; 5 | import org.springframework.ui.Model; 6 | import org.springframework.web.bind.annotation.GetMapping; 7 | import org.springframework.web.bind.annotation.RequestMapping; 8 | import org.springframework.web.bind.annotation.ResponseBody; 9 | 10 | import java.util.Date; 11 | 12 | @Controller 13 | @RequestMapping("/private/") 14 | public class TopSecret { 15 | 16 | @GetMapping("/") 17 | public String index(Model model) { 18 | model.addAttribute("now", new Date()); 19 | return "private-index"; 20 | } 21 | 22 | @GetMapping(path = "/data", produces = MediaType.TEXT_HTML_VALUE) 23 | @ResponseBody 24 | public String data() { 25 | return "

hi! %s

".formatted(new Date().toString()); 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/test/resources/test-logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/main/java/com/devhow/identity/config/PasswordEncoderConfig.java: -------------------------------------------------------------------------------- 1 | package com.devhow.identity.config; 2 | 3 | import org.springframework.beans.factory.annotation.Qualifier; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 7 | import org.springframework.security.crypto.factory.PasswordEncoderFactories; 8 | import org.springframework.security.crypto.password.DelegatingPasswordEncoder; 9 | import org.springframework.security.crypto.password.PasswordEncoder; 10 | 11 | @Configuration 12 | public class PasswordEncoderConfig { 13 | 14 | @Bean 15 | @Qualifier("passwordEncoder") 16 | public PasswordEncoder passwordEncoder() { 17 | DelegatingPasswordEncoder x = 18 | (DelegatingPasswordEncoder) PasswordEncoderFactories.createDelegatingPasswordEncoder(); 19 | x.setDefaultPasswordEncoderForMatches(new BCryptPasswordEncoder()); 20 | return x; 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/test/java/com/devhow/htmxdemo/SpringContextLoadTests.java: -------------------------------------------------------------------------------- 1 | package com.devhow.htmxdemo; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.test.context.SpringBootTest; 8 | import org.springframework.context.annotation.Configuration; 9 | import org.springframework.core.env.Environment; 10 | import org.springframework.test.context.ContextConfiguration; 11 | 12 | @SpringBootTest 13 | @ContextConfiguration 14 | class SpringContextLoadTests { 15 | private static final Logger logger = LoggerFactory.getLogger(SpringContextLoadTests.class); 16 | @Autowired 17 | private Environment environment; 18 | 19 | @Test 20 | void contextLoads() { 21 | for (String profileName : environment.getActiveProfiles()) { 22 | logger.info("Currently active profile - " + profileName); 23 | } 24 | } 25 | 26 | @Configuration 27 | static class Config { 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/resources/static/public/identity/password-reset.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Password Reset 6 | 7 | 8 | 9 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/test/java/com/devhow/identity/UserUtils.java: -------------------------------------------------------------------------------- 1 | package com.devhow.identity; 2 | 3 | import com.devhow.identity.entity.User; 4 | import com.devhow.identity.user.IdentityServiceException; 5 | import com.devhow.identity.user.UserService; 6 | import jakarta.mail.AuthenticationFailedException; 7 | 8 | import java.util.Optional; 9 | import java.util.Random; 10 | 11 | public class UserUtils { 12 | 13 | private final static Random random = new Random(); 14 | 15 | static public User setupUser(UserService userService) throws IdentityServiceException, AuthenticationFailedException { 16 | String pass = "this-is-just-a-test"; 17 | return setupUser(userService, pass); 18 | } 19 | 20 | static public User setupUser(UserService userService, String pass) throws IdentityServiceException, AuthenticationFailedException { 21 | User user = userService.signUpUser("wiverson+" + random.nextInt() + "@gmail.com", pass, true); 22 | Optional confirmUser = userService.confirmUser(userService.validation(user).getToken()); 23 | 24 | return userService.signIn(confirmUser.orElseThrow().getUsername(), pass); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/resources/static/public/private-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Private Index 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 | Welcome 13 | ! 14 |
15 |
16 | Logout 17 | 18 | 19 |
20 |
21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /src/main/java/com/devhow/identity/user/SecurityUserService.java: -------------------------------------------------------------------------------- 1 | package com.devhow.identity.user; 2 | 3 | import org.springframework.security.core.userdetails.UserDetails; 4 | import org.springframework.security.core.userdetails.UserDetailsService; 5 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 6 | import org.springframework.security.crypto.password.PasswordEncoder; 7 | import org.springframework.stereotype.Service; 8 | 9 | import java.text.MessageFormat; 10 | 11 | @Service 12 | public class SecurityUserService implements UserDetailsService { 13 | 14 | final UserRepository userRepository; 15 | final PasswordEncoder passwordEncoder; 16 | 17 | public SecurityUserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { 18 | this.userRepository = userRepository; 19 | this.passwordEncoder = passwordEncoder; 20 | } 21 | 22 | @Override 23 | public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { 24 | return userRepository.findByUsername(email).orElseThrow(() 25 | -> new UsernameNotFoundException(MessageFormat.format("User with email {0} cannot be found.", email))) 26 | .securityUser(); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/devhow/htmxdemo/ClickToEdit.java: -------------------------------------------------------------------------------- 1 | package com.devhow.htmxdemo; 2 | 3 | import org.springframework.stereotype.Controller; 4 | import org.springframework.ui.Model; 5 | import org.springframework.web.bind.annotation.GetMapping; 6 | import org.springframework.web.bind.annotation.PathVariable; 7 | import org.springframework.web.bind.annotation.PostMapping; 8 | import org.springframework.web.bind.annotation.RequestMapping; 9 | 10 | import java.util.Date; 11 | 12 | @Controller 13 | @RequestMapping("/public/click-to-edit") 14 | public class ClickToEdit { 15 | 16 | @GetMapping 17 | public String start(Model model) { 18 | model.addAttribute("contact", Contact.demoContact()); 19 | model.addAttribute("now", new Date().toInstant()); 20 | 21 | return "click-to-edit"; 22 | } 23 | 24 | @PostMapping("/edit/{id}") 25 | public String editForm(Contact contact, Model model, @PathVariable String id) { 26 | model.addAttribute("contact", contact); 27 | model.addAttribute("id", id); 28 | return "click-to-edit-form"; 29 | } 30 | 31 | @PostMapping("/commit") 32 | public String editPost(Contact contact, Model model) { 33 | model.addAttribute("contact", contact); 34 | return "click-to-edit-default"; 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/main/resources/static/public/infinite-scroll.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Infinite Scroll 5 | 6 | 7 | 8 | 9 | 10 |
11 | 15 |
16 | 17 | 18 | 19 | 20 |
Loading...
21 |
22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /src/main/java/com/devhow/identity/user/EmailSenderService.java: -------------------------------------------------------------------------------- 1 | package com.devhow.identity.user; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | import org.springframework.beans.factory.annotation.Value; 6 | import org.springframework.mail.SimpleMailMessage; 7 | import org.springframework.mail.javamail.JavaMailSender; 8 | import org.springframework.scheduling.annotation.Async; 9 | import org.springframework.stereotype.Service; 10 | 11 | @Service 12 | public class EmailSenderService { 13 | 14 | private final Logger logger = LoggerFactory.getLogger(EmailSenderService.class); 15 | 16 | private final JavaMailSender javaMailSender; 17 | 18 | @Value("${mail.test:false}") 19 | private Boolean mailTest = false; 20 | 21 | public EmailSenderService(JavaMailSender javaMailSender) { 22 | this.javaMailSender = javaMailSender; 23 | } 24 | 25 | @Async 26 | public void sendEmail(SimpleMailMessage email) { 27 | if (mailTest) { 28 | logger.error(email.getText()); 29 | } else { 30 | try { 31 | javaMailSender.send(email); 32 | } catch (Exception e) { 33 | logger.error("Unable to send email! Future emails will be logged - no retry!", e); 34 | mailTest = true; 35 | logger.error(email.getText()); 36 | } 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /src/main/java/com/devhow/htmxdemo/ToDoList.java: -------------------------------------------------------------------------------- 1 | package com.devhow.htmxdemo; 2 | 3 | import org.springframework.http.MediaType; 4 | import org.springframework.stereotype.Controller; 5 | import org.springframework.ui.Model; 6 | import org.springframework.web.bind.annotation.*; 7 | 8 | import java.util.Date; 9 | 10 | @Controller 11 | @RequestMapping("/public/todo") 12 | public class ToDoList { 13 | 14 | @GetMapping 15 | public String start(Model model) { 16 | model.addAttribute("now", new Date().toInstant()); 17 | model.addAttribute("item", "Get Stuff Done"); 18 | return "todo"; 19 | } 20 | 21 | @DeleteMapping(path = "/delete", produces = MediaType.TEXT_HTML_VALUE) 22 | @ResponseBody 23 | public String delete() { 24 | return ""; 25 | } 26 | 27 | /** 28 | * Thymeleaf will let you use the fragment syntax in a controller, as shown below. 29 | * https://www.thymeleaf.org/doc/tutorials/2.1/usingthymeleaf.html#defining-and-referencing-fragments 30 | */ 31 | @PostMapping(path = "/create") 32 | public String create(@RequestParam("new-todo") String todo, Model model) { 33 | model.addAttribute("item", todo); 34 | 35 | // Currently, IntelliJ doesn't recognize a Thymeleaf fragment returned in a controller. 36 | // https://youtrack.jetbrains.com/issue/IDEA-276625 37 | // 38 | //noinspection SpringMVCViewInspection 39 | return "todo :: todo"; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/resources/static/public/identity/forgot-password.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sign In 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 30 |
31 | 32 | 33 | -------------------------------------------------------------------------------- /src/main/resources/application.yaml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: htmx-demo 4 | mvc: 5 | pathmatch: 6 | matching-strategy: ant_path_matcher 7 | main: 8 | banner-mode: "off" 9 | thymeleaf: 10 | cache: false 11 | prefix: classpath:/static/public/ 12 | jpa: 13 | open-in-view: true 14 | mail: 15 | scheduler: 16 | persistence: 17 | enabled: false 18 | redis: 19 | embedded: false 20 | enabled: false 21 | host: smtp.gmail.com 22 | port: 587 23 | username: name.surname@gmail.com 24 | password: V3ry_Str0ng_Password 25 | properties: 26 | mail: 27 | smtp: 28 | auth: true 29 | starttls: 30 | enable: true 31 | required: true 32 | server: 33 | error: 34 | whitelabel: 35 | enabled: true 36 | include-stacktrace: always 37 | address: localhost 38 | port: ${PORT:8080} 39 | logging: 40 | pattern: 41 | console: "%d{HH:mm:ss.SSS} %highlight(%-5level) %yellow(%logger{40}.%M\\(%class{0}.java:%line\\)) - %msg%throwable%n" 42 | level: 43 | root: WARN 44 | org: 45 | springframework: 46 | boot: 47 | test: 48 | context: 49 | SpringBootTestContextBootstrapper: WARN 50 | test: 51 | context: 52 | support: 53 | AbstractContextLoader: OFF 54 | AnnotationConfigContextLoaderUtils: OFF 55 | com: 56 | devhow: 57 | identity: 58 | user: 59 | EmailSenderService: INFO 60 | external: 61 | server: 62 | address: http://localhost:8080 # Do not include trailing slash -------------------------------------------------------------------------------- /src/main/java/com/devhow/htmxdemo/Contact.java: -------------------------------------------------------------------------------- 1 | package com.devhow.htmxdemo; 2 | 3 | import com.github.javafaker.Faker; 4 | 5 | import java.util.ArrayList; 6 | import java.util.List; 7 | 8 | public class Contact { 9 | private String firstName; 10 | private String lastName; 11 | private String email; 12 | 13 | public String getFirstName() { 14 | return firstName; 15 | } 16 | 17 | public void setFirstName(String firstName) { 18 | this.firstName = firstName; 19 | } 20 | 21 | public String getLastName() { 22 | return lastName; 23 | } 24 | 25 | public void setLastName(String lastName) { 26 | this.lastName = lastName; 27 | } 28 | 29 | public String getEmail() { 30 | return email; 31 | } 32 | 33 | public void setEmail(String email) { 34 | this.email = email; 35 | } 36 | 37 | static public Contact demoContact() { 38 | Contact contact = new Contact(); 39 | contact.firstName = "Bob"; 40 | contact.lastName = "Smith"; 41 | contact.email = "bsmith@example.com"; 42 | return contact; 43 | } 44 | 45 | private static final Faker faker = new Faker(); 46 | 47 | static public List randomContacts(int count) { 48 | 49 | List result = new ArrayList<>(); 50 | 51 | for (int i = 0; i < count; i++) { 52 | Contact contact = new Contact(); 53 | contact.firstName = faker.name().firstName(); 54 | contact.lastName = faker.name().lastName(); 55 | contact.email = faker.internet().safeEmailAddress(); 56 | result.add(contact); 57 | } 58 | 59 | return result; 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/com/devhow/identity/config/DaoOverride.java: -------------------------------------------------------------------------------- 1 | package com.devhow.identity.config; 2 | 3 | import org.springframework.security.authentication.BadCredentialsException; 4 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 5 | import org.springframework.security.authentication.dao.DaoAuthenticationProvider; 6 | import org.springframework.security.core.Authentication; 7 | import org.springframework.security.core.AuthenticationException; 8 | import org.springframework.security.core.userdetails.UserDetails; 9 | import org.springframework.security.crypto.password.PasswordEncoder; 10 | 11 | public class DaoOverride extends DaoAuthenticationProvider { 12 | 13 | PasswordEncoder passwordEncoder; 14 | 15 | @Override 16 | public Authentication authenticate(Authentication authentication) throws AuthenticationException { 17 | return super.authenticate(authentication); 18 | } 19 | 20 | @Override 21 | protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { 22 | if (authentication.getCredentials() == null) { 23 | this.logger.debug("Failed to authenticate since no credentials provided"); 24 | throw new BadCredentialsException(this.messages 25 | .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "No password")); 26 | } 27 | String presentedPassword = authentication.getCredentials().toString(); 28 | if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) { 29 | this.logger.debug("Failed to authenticate since password does not match stored value"); 30 | throw new BadCredentialsException(this.messages 31 | .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); 32 | } 33 | super.additionalAuthenticationChecks(userDetails, authentication); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/resources/static/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | HTMX-Demo 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 |
13 |
14 | If this looks ok... Bootstrap is working. 15 |
16 |
17 |
18 | Click To Edit 19 |
20 |
21 | Infinite Scroll 22 |
23 |
24 | To Do 25 |
26 | 29 |
30 | Value Select 31 |
32 | 35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/main/resources/static/public/identity/sign-up.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sign Up 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 41 |
42 | 43 | 44 | -------------------------------------------------------------------------------- /src/main/resources/static/public/click-to-edit.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Click To Edit 5 | 6 | 7 | 8 | 9 | 10 |
11 | 15 |
16 |
17 |
18 | : 19 | 20 |
21 |
22 | : 23 | 24 |
25 |
26 | : 27 | 28 |
29 | 30 | 31 | 32 | 35 |
36 |
37 |
38 | 39 | 40 | -------------------------------------------------------------------------------- /src/main/resources/static/public/value-select.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Value Select 5 | 6 | 7 | 8 | 9 | 10 |
11 | 15 |
16 |
17 | 18 | 33 | 34 | 39 |
40 |
41 |
42 | 43 | 44 | -------------------------------------------------------------------------------- /src/main/resources/static/public/identity/sign-in.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sign In 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 | 52 |
53 |
54 | 55 | 56 | -------------------------------------------------------------------------------- /src/main/resources/static/public/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 28 | 29 | 30 | 36 | 37 | 40 | 41 | 44 |
45 |

Page content goes here

46 |
47 | Loading Indicator 48 | 49 | -------------------------------------------------------------------------------- /src/main/java/com/devhow/htmxdemo/InfiniteScroll.java: -------------------------------------------------------------------------------- 1 | package com.devhow.htmxdemo; 2 | 3 | import org.intellij.lang.annotations.Language; 4 | import org.springframework.http.MediaType; 5 | import org.springframework.stereotype.Controller; 6 | import org.springframework.ui.Model; 7 | import org.springframework.web.bind.annotation.GetMapping; 8 | import org.springframework.web.bind.annotation.PathVariable; 9 | import org.springframework.web.bind.annotation.RequestMapping; 10 | import org.springframework.web.bind.annotation.ResponseBody; 11 | 12 | import java.util.Date; 13 | import java.util.List; 14 | 15 | /** 16 | * This demonstration uses HTML generated here (in the controller!) instead of just the HTML coming from Thymeleaf 17 | * templates. 18 | *

19 | * This is really intended to be a very primitive transitional demonstration, showing the basics of how HTMX could 20 | * serve as the starting point for a more component-oriented approach, or perhaps even used in combination with 21 | * WebSockets and Server-Side Events. 22 | * https://htmx.org/docs/#websockets-and-sse 23 | *

24 | * Put another way - this is a pretty messy, hacky mess... but it's also the kernel for starting what could be a 25 | * different approach. 26 | */ 27 | @Controller 28 | @RequestMapping("/public/infinite-scroll") 29 | public class InfiniteScroll { 30 | 31 | @GetMapping 32 | public String start(Model model) { 33 | model.addAttribute("now", new Date().toInstant()); 34 | return "infinite-scroll"; 35 | } 36 | 37 | @Language("html") 38 | final String contactHtml = """ 39 | 40 | %s 41 | %s 42 | %s 43 | 44 | """; 45 | 46 | @Language("html") 47 | final String loadHtml = """ 48 | 51 | %s 52 | %s 53 | %s 54 | 55 | """; 56 | 57 | @GetMapping(value = "/page/{id}", produces = MediaType.TEXT_HTML_VALUE) 58 | @ResponseBody 59 | public String nextPage(@PathVariable Integer id) { 60 | 61 | StringBuilder result = new StringBuilder(); 62 | 63 | List demoContacts = Contact.randomContacts(9); 64 | for (Contact c : demoContacts) { 65 | result.append(contactHtml.formatted(c.getFirstName(), c.getLastName(), c.getEmail())); 66 | } 67 | 68 | Contact last = Contact.randomContacts(1).get(0); 69 | 70 | result.append(loadHtml.formatted(id + 1, last.getFirstName(), last.getLastName(), last.getEmail())); 71 | 72 | // This is just to simulate a slow[er] server response, causing the HTMX wait indicator to display 73 | try { 74 | Thread.sleep(500); 75 | } catch (InterruptedException e) { 76 | e.printStackTrace(); 77 | } 78 | 79 | return result.toString(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main/java/com/devhow/identity/entity/UserValidation.java: -------------------------------------------------------------------------------- 1 | package com.devhow.identity.entity; 2 | 3 | 4 | import com.devhow.identity.user.TimeUtil; 5 | import jakarta.persistence.*; 6 | import org.hibernate.annotations.CreationTimestamp; 7 | 8 | import java.io.Serializable; 9 | import java.sql.Timestamp; 10 | import java.util.Calendar; 11 | import java.util.UUID; 12 | 13 | @Entity(name = "user_validation") 14 | @Table(name = "user_validation") 15 | public class UserValidation implements Serializable { 16 | private String token; 17 | private Timestamp tokenIssue; 18 | 19 | @Column(name = "pass_reset_token") 20 | private String passwordResetToken; 21 | 22 | @Column(name = "pass_reset_issue") 23 | private Timestamp passwordResetIssue; 24 | 25 | @Column(name = "creation") 26 | @CreationTimestamp 27 | private Timestamp creation; 28 | 29 | @Version 30 | @Column(name = "entity_version", nullable = false) 31 | private Long version; 32 | @Id 33 | @Column(name = "user_id", nullable = false) 34 | private Long user; 35 | 36 | public UserValidation(User user) { 37 | this.user = user.getId(); 38 | } 39 | 40 | public UserValidation() { 41 | 42 | } 43 | 44 | public Long getVersion() { 45 | return version; 46 | } 47 | 48 | public void setVersion(Long version) { 49 | this.version = version; 50 | } 51 | 52 | public String getToken() { 53 | return token; 54 | } 55 | 56 | public void setToken(String token) { 57 | this.token = token; 58 | } 59 | 60 | public void newToken() { 61 | setToken(UUID.randomUUID().toString()); 62 | setTokenIssue(new java.sql.Timestamp(Calendar.getInstance().getTime().getTime())); 63 | } 64 | 65 | public Timestamp getTokenIssue() { 66 | return tokenIssue; 67 | } 68 | 69 | public void setTokenIssue(Timestamp tokenIssue) { 70 | this.tokenIssue = tokenIssue; 71 | } 72 | 73 | public boolean tokenIsCurrent() { 74 | TimeUtil time = new TimeUtil(); 75 | return Math.abs(getTokenIssue().getTime() - time.now().getTime()) < 1000 * 60 * 60 * 24; 76 | } 77 | 78 | public boolean passwordValidationIsCurrent() { 79 | TimeUtil time = new TimeUtil(); 80 | return Math.abs(getPasswordResetIssue().getTime() - time.now().getTime()) < 1000 * 60 * 5; 81 | } 82 | 83 | public Long getUser() { 84 | return user; 85 | } 86 | 87 | public void setUser(Long user) { 88 | this.user = user; 89 | } 90 | 91 | public String getPasswordResetToken() { 92 | return passwordResetToken; 93 | } 94 | 95 | public void setPasswordResetToken(String passwordResetToken) { 96 | this.passwordResetToken = passwordResetToken; 97 | } 98 | 99 | public Timestamp getPasswordResetIssue() { 100 | return passwordResetIssue; 101 | } 102 | 103 | public void setPasswordResetIssue(Timestamp passwordResetIssue) { 104 | this.passwordResetIssue = passwordResetIssue; 105 | } 106 | 107 | public void newPasswordResetToken() { 108 | setPasswordResetToken(UUID.randomUUID().toString()); 109 | setPasswordResetIssue(new java.sql.Timestamp(Calendar.getInstance().getTime().getTime())); 110 | } 111 | 112 | 113 | public Timestamp getCreation() { 114 | return creation; 115 | } 116 | 117 | public void setCreation(Timestamp creation) { 118 | this.creation = creation; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/main/java/com/devhow/identity/config/WebSecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.devhow.identity.config; 2 | 3 | import com.devhow.identity.user.SecurityUserService; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.beans.factory.annotation.Value; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | import org.springframework.security.authentication.AuthenticationProvider; 9 | import org.springframework.security.authentication.dao.DaoAuthenticationProvider; 10 | import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; 11 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 12 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 13 | import org.springframework.security.crypto.password.PasswordEncoder; 14 | import org.springframework.security.web.SecurityFilterChain; 15 | import org.springframework.security.web.util.matcher.AntPathRequestMatcher; 16 | 17 | @Configuration 18 | @EnableWebSecurity 19 | public class WebSecurityConfig { 20 | 21 | private final SecurityUserService userService; 22 | 23 | private final PasswordEncoder passwordEncoder; 24 | 25 | @Value("spring.profiles.active") 26 | private String activeProfile; 27 | 28 | public WebSecurityConfig(SecurityUserService userService, PasswordEncoder passwordEncoder) { 29 | this.userService = userService; 30 | this.passwordEncoder = passwordEncoder; 31 | } 32 | 33 | @Bean 34 | public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { 35 | // @formatter:off 36 | http.csrf().disable(); 37 | 38 | // Only allow frames if using h2 as the database 39 | if(activeProfile.contains("h2")) 40 | http.headers().frameOptions().disable(); 41 | http.authorizeHttpRequests() 42 | .requestMatchers("/**/*.html").denyAll() 43 | .requestMatchers("/public/**", "/webjars/**", "/", "/logout", "/api/**", "/login", "/h2-console/**") 44 | .permitAll() 45 | .anyRequest() 46 | .authenticated() 47 | 48 | .and() 49 | .formLogin() 50 | .loginPage("/public/sign-in").permitAll() 51 | .loginProcessingUrl("/public/do-sign-in") 52 | // .defaultSuccessUrl("/") 53 | .failureUrl("/public/sign-in?error=true") 54 | .usernameParameter("username") 55 | .passwordParameter("password") 56 | //.failureHandler(userService) 57 | //.successHandler(userService) 58 | .and() 59 | .logout() 60 | .logoutRequestMatcher(new AntPathRequestMatcher("/public/logout")) 61 | .clearAuthentication(true) 62 | .invalidateHttpSession(true) 63 | .deleteCookies("JSESSIONID"); 64 | return http.build(); 65 | 66 | // .and() 67 | // .anonymous(); 68 | // @formatter:on 69 | } 70 | 71 | @Autowired 72 | public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { 73 | auth.userDetailsService(userService) 74 | .passwordEncoder(passwordEncoder); 75 | } 76 | 77 | @Bean 78 | public AuthenticationProvider authProvider() { 79 | DaoAuthenticationProvider authProvider = new DaoOverride(); 80 | authProvider.setUserDetailsService(userService); 81 | authProvider.setPasswordEncoder(passwordEncoder); 82 | return authProvider; 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /src/main/resources/static/public/todo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Click To Edit 5 | 6 | 7 | 8 | 14 | 15 | 16 | 23 |

24 | 27 |
28 | 29 | 30 | 31 | 33 | 35 | 37 | 38 | 39 | 46 | 47 | 48 | 49 | 53 | 57 | 58 | 59 | 60 |
32 | To Do 34 | Done 36 |  
Get Groceries 50 | 51 | 52 | 54 | 56 |
61 | 62 | 63 | 67 | 71 | 72 |
64 | 65 | 66 | 68 | 70 |
73 |
74 |
75 | 76 | 77 | -------------------------------------------------------------------------------- /src/test/java/com/devhow/identity/UserControllerTests.java: -------------------------------------------------------------------------------- 1 | package com.devhow.identity; 2 | 3 | import jakarta.mail.internet.MimeMessage; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.context.SpringBootTest; 7 | import org.springframework.mail.MailException; 8 | import org.springframework.mail.SimpleMailMessage; 9 | import org.springframework.mail.javamail.JavaMailSender; 10 | import org.springframework.mail.javamail.MimeMessagePreparator; 11 | import org.springframework.stereotype.Service; 12 | import org.springframework.test.web.servlet.MockMvc; 13 | import org.springframework.test.web.servlet.setup.MockMvcBuilders; 14 | import org.springframework.web.context.WebApplicationContext; 15 | 16 | import java.io.InputStream; 17 | 18 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 19 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 20 | 21 | @SpringBootTest(properties = {"mail.test=true"}) 22 | public class UserControllerTests { 23 | 24 | @Autowired 25 | private WebApplicationContext webApplicationContext; 26 | 27 | @Test 28 | public void noLogin() throws Exception { 29 | MockMvc mvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); 30 | mvc.perform(get("/")).andExpect(status().isOk()); 31 | mvc.perform(get("/public/sign-up")).andExpect(status().isOk()); 32 | mvc.perform(get("/public/forgot-password")).andExpect(status().isOk()); 33 | } 34 | 35 | JavaMailSender javaMailSender = new TestSender(); 36 | 37 | @Service 38 | private class TestSender implements JavaMailSender { 39 | @Override 40 | public MimeMessage createMimeMessage() { 41 | return null; 42 | } 43 | 44 | @Override 45 | public MimeMessage createMimeMessage(InputStream contentStream) throws MailException { 46 | return null; 47 | } 48 | 49 | @Override 50 | public void send(MimeMessage mimeMessage) throws MailException { 51 | 52 | } 53 | 54 | @Override 55 | public void send(MimeMessage... mimeMessages) throws MailException { 56 | 57 | } 58 | 59 | @Override 60 | public void send(MimeMessagePreparator mimeMessagePreparator) throws MailException { 61 | 62 | } 63 | 64 | @Override 65 | public void send(MimeMessagePreparator... mimeMessagePreparators) throws MailException { 66 | 67 | } 68 | 69 | @Override 70 | public void send(SimpleMailMessage simpleMessage) throws MailException { 71 | 72 | } 73 | 74 | @Override 75 | public void send(SimpleMailMessage... simpleMessages) throws MailException { 76 | 77 | } 78 | } 79 | 80 | // @PostMapping("/public/do-sign-in") 81 | // Perform a sign in 82 | 83 | // @GetMapping("/public/sign-up") 84 | // Start a sign up 85 | 86 | // @PostMapping("/public/sign-up") 87 | // Perform the sign-up 88 | 89 | // @GetMapping("public/sign-up/confirm") 90 | // URL sent via email to confirm the new account 91 | 92 | // Step 1: View the password reset form 93 | // @GetMapping("/public/forgot-password") 94 | // Start a password reset flow - view the form 95 | 96 | // Step 2: The password reset form submitted, email sent 97 | // @PostMapping("/public/forgot-password") 98 | // Submit the password reset request 99 | 100 | // Step 3: The link clicked on in the password reset email 101 | // @GetMapping("/public/password-reset") 102 | // Start a password reset action 103 | 104 | // Step 4: The password reset form submitted 105 | // @PostMapping("/public/password-reset") 106 | // Actually perform the password reset 107 | 108 | 109 | } 110 | -------------------------------------------------------------------------------- /src/main/java/com/devhow/htmxdemo/ValueSelect.java: -------------------------------------------------------------------------------- 1 | package com.devhow.htmxdemo; 2 | 3 | import com.github.jknack.handlebars.Handlebars; 4 | import com.github.jknack.handlebars.Template; 5 | import org.intellij.lang.annotations.Language; 6 | import org.springframework.http.MediaType; 7 | import org.springframework.stereotype.Controller; 8 | import org.springframework.ui.Model; 9 | import org.springframework.web.bind.annotation.GetMapping; 10 | import org.springframework.web.bind.annotation.RequestMapping; 11 | import org.springframework.web.bind.annotation.RequestParam; 12 | import org.springframework.web.bind.annotation.ResponseBody; 13 | 14 | import java.io.IOException; 15 | import java.util.Date; 16 | 17 | @Controller 18 | @RequestMapping("/public/value-select") 19 | public class ValueSelect { 20 | 21 | /** 22 | * IntelliJ has a plugin that supports handlebars inline (and file template) syntax highlighting. 23 | *

24 | * https://plugins.jetbrains.com/plugin/6884-handlebars-mustache 25 | */ 26 | @Language("handlebars") 27 | private final String handleBarTemplate = 28 | """ 29 | {{#each}} 30 | 31 | {{/each}} 32 | """; 33 | private final String[] java8 = {"lambdas", "collections", "streams"}; 34 | private final String[] java9 = {"collections", "streams", "optionals", "interfaces", "jshell"}; 35 | private final String[] java10 = {"var"}; 36 | private final String[] java11 = {"strings", "scripts", "lambda var"}; 37 | private final String[] java12 = {"unicode 11"}; 38 | private final String[] java13 = {"unicode 12"}; 39 | private final String[] java14 = {"switch", "better null pointer error messages"}; 40 | private final String[] java15 = {"text blocks", "Z garbage collector"}; 41 | private final String[] java16 = {"sockets", "records"}; 42 | private final String[] java17 = {"pattern matching for switch","sealed classes", "foreign function and memory api"}; 43 | private final String[] java18 = {"UTF-8 by default", "jwebserver"}; 44 | private final String[] java19 = {"virtual threads", "structured concurrency", "vector api"}; 45 | private final String[] java20 = {"scoped values", "record patterns"}; 46 | Template template; 47 | 48 | public ValueSelect() { 49 | Handlebars handlebars = new Handlebars(); 50 | try { 51 | template = handlebars.compileInline(handleBarTemplate); 52 | } catch (IOException e) { 53 | e.printStackTrace(); 54 | } 55 | } 56 | 57 | @GetMapping 58 | public String start(Model model) { 59 | model.addAttribute("now", new Date().toInstant()); 60 | return "value-select"; 61 | } 62 | 63 | @GetMapping(value = "/models", produces = MediaType.TEXT_HTML_VALUE) 64 | @ResponseBody 65 | public String models(@RequestParam("make") String make) throws IOException { 66 | if ("java8".equals(make)) 67 | return template.apply(java8); 68 | if ("java9".equals(make)) 69 | return template.apply(java9); 70 | if ("java10".equals(make)) 71 | return template.apply(java10); 72 | if ("java11".equals(make)) 73 | return template.apply(java11); 74 | if ("java12".equals(make)) 75 | return template.apply(java12); 76 | if ("java13".equals(make)) 77 | return template.apply(java13); 78 | if ("java14".equals(make)) 79 | return template.apply(java14); 80 | if ("java15".equals(make)) 81 | return template.apply(java15); 82 | if ("java16".equals(make)) 83 | return template.apply(java16); 84 | if ("java17".equals(make)) 85 | return template.apply(java17); 86 | if ("java18".equals(make)) 87 | return template.apply(java18); 88 | if ("java19".equals(make)) 89 | return template.apply(java19); 90 | if ("java20".equals(make)) 91 | return template.apply(java20); 92 | throw new IllegalArgumentException("Unknown make"); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/main/java/com/devhow/identity/entity/User.java: -------------------------------------------------------------------------------- 1 | package com.devhow.identity.entity; 2 | 3 | import com.devhow.identity.user.TimeUtil; 4 | import jakarta.persistence.*; 5 | import org.hibernate.annotations.CreationTimestamp; 6 | import org.springframework.security.core.Authentication; 7 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 8 | 9 | import java.sql.Timestamp; 10 | import java.util.ArrayList; 11 | import java.util.List; 12 | import java.util.Objects; 13 | 14 | @Entity(name = "users") 15 | @Table(name = "users") 16 | public class User { 17 | 18 | private String password; 19 | private String username; 20 | private Long version; 21 | private Long id; 22 | private boolean test; 23 | 24 | private Timestamp tokenValidation; 25 | 26 | private Timestamp creation; 27 | 28 | public User() { 29 | } 30 | 31 | public User(String username, String password) { 32 | this.username = username; 33 | this.password = password; 34 | } 35 | 36 | public User(String username, String password, boolean test) { 37 | this.username = username; 38 | this.password = password; 39 | this.test = test; 40 | } 41 | 42 | public User(boolean test) { 43 | this.test = test; 44 | } 45 | 46 | static public Long id(Authentication authentication) { 47 | return Long.parseLong(authentication.getAuthorities().toArray()[0].toString()); 48 | } 49 | 50 | public org.springframework.security.core.userdetails.User securityUser() { 51 | List grantedAuthorities = new ArrayList<>(); 52 | grantedAuthorities.add(new SimpleGrantedAuthority(id.toString())); 53 | grantedAuthorities.add(new SimpleGrantedAuthority("USER")); 54 | return new org.springframework.security.core.userdetails.User(this.getUsername(), this.getPassword(), grantedAuthorities); 55 | } 56 | 57 | @Column(name = "pass") 58 | public String getPassword() { 59 | return password; 60 | } 61 | 62 | public void setPassword(String pass) { 63 | this.password = pass; 64 | } 65 | 66 | 67 | public String getUsername() { 68 | return username; 69 | } 70 | 71 | public void setUsername(String user) { 72 | username = user; 73 | } 74 | 75 | public boolean validated() { 76 | return tokenValidation != null; 77 | } 78 | 79 | @Version 80 | @Column(name = "entity_version", nullable = false) 81 | public Long getVersion() { 82 | return version; 83 | } 84 | 85 | public void setVersion(Long version) { 86 | this.version = version; 87 | } 88 | 89 | @Id 90 | @GeneratedValue(strategy = GenerationType.IDENTITY) 91 | public Long getId() { 92 | return id; 93 | } 94 | 95 | public void setId(Long id) { 96 | this.id = id; 97 | } 98 | 99 | public Timestamp getTokenValidation() { 100 | return tokenValidation; 101 | } 102 | 103 | public void setTokenValidation(Timestamp tokenValidation) { 104 | this.tokenValidation = tokenValidation; 105 | } 106 | 107 | public boolean isTest() { 108 | return test; 109 | } 110 | 111 | public void setTest(boolean test) { 112 | this.test = test; 113 | } 114 | 115 | public void markTokenAsValid() { 116 | TimeUtil time = new TimeUtil(); 117 | setTokenValidation(time.now()); 118 | } 119 | 120 | @Override 121 | public String toString() { 122 | return "User{" + 123 | "password='" + password + '\'' + 124 | ", username='" + username + '\'' + 125 | ", version=" + version + 126 | ", id=" + id + 127 | ", test=" + test + 128 | ", tokenValidation=" + tokenValidation + 129 | ", creation=" + creation + 130 | '}'; 131 | } 132 | 133 | @Override 134 | public boolean equals(Object o) { 135 | if (this == o) return true; 136 | if (o == null || getClass() != o.getClass()) return false; 137 | User user = (User) o; 138 | return test == user.test && Objects.equals(password, user.password) && Objects.equals(username, user.username) && Objects.equals(version, user.version) && Objects.equals(id, user.id) && Objects.equals(tokenValidation, user.tokenValidation) && Objects.equals(creation, user.creation); 139 | } 140 | 141 | @Override 142 | public int hashCode() { 143 | return Objects.hash(password, username, version, id, test, tokenValidation, creation); 144 | } 145 | 146 | @CreationTimestamp 147 | public Timestamp getCreation() { 148 | return creation; 149 | } 150 | 151 | public void setCreation(Timestamp creation) { 152 | this.creation = creation; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /.mvn/wrapper/MavenWrapperDownloader.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2007-present the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import java.net.*; 17 | import java.io.*; 18 | import java.nio.channels.*; 19 | import java.util.Properties; 20 | 21 | public class MavenWrapperDownloader { 22 | 23 | private static final String WRAPPER_VERSION = "0.5.6"; 24 | /** 25 | * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided. 26 | */ 27 | private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/" 28 | + WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar"; 29 | 30 | /** 31 | * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to 32 | * use instead of the default one. 33 | */ 34 | private static final String MAVEN_WRAPPER_PROPERTIES_PATH = 35 | ".mvn/wrapper/maven-wrapper.properties"; 36 | 37 | /** 38 | * Path where the maven-wrapper.jar will be saved to. 39 | */ 40 | private static final String MAVEN_WRAPPER_JAR_PATH = 41 | ".mvn/wrapper/maven-wrapper.jar"; 42 | 43 | /** 44 | * Name of the property which should be used to override the default download url for the wrapper. 45 | */ 46 | private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; 47 | 48 | public static void main(String args[]) { 49 | System.out.println("- Downloader started"); 50 | File baseDirectory = new File(args[0]); 51 | System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath()); 52 | 53 | // If the maven-wrapper.properties exists, read it and check if it contains a custom 54 | // wrapperUrl parameter. 55 | File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH); 56 | String url = DEFAULT_DOWNLOAD_URL; 57 | if(mavenWrapperPropertyFile.exists()) { 58 | FileInputStream mavenWrapperPropertyFileInputStream = null; 59 | try { 60 | mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile); 61 | Properties mavenWrapperProperties = new Properties(); 62 | mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream); 63 | url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url); 64 | } catch (IOException e) { 65 | System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'"); 66 | } finally { 67 | try { 68 | if(mavenWrapperPropertyFileInputStream != null) { 69 | mavenWrapperPropertyFileInputStream.close(); 70 | } 71 | } catch (IOException e) { 72 | // Ignore ... 73 | } 74 | } 75 | } 76 | System.out.println("- Downloading from: " + url); 77 | 78 | File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH); 79 | if(!outputFile.getParentFile().exists()) { 80 | if(!outputFile.getParentFile().mkdirs()) { 81 | System.out.println( 82 | "- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'"); 83 | } 84 | } 85 | System.out.println("- Downloading to: " + outputFile.getAbsolutePath()); 86 | try { 87 | downloadFileFromURL(url, outputFile); 88 | System.out.println("Done"); 89 | System.exit(0); 90 | } catch (Throwable e) { 91 | System.out.println("- Error downloading"); 92 | e.printStackTrace(); 93 | System.exit(1); 94 | } 95 | } 96 | 97 | private static void downloadFileFromURL(String urlString, File destination) throws Exception { 98 | if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) { 99 | String username = System.getenv("MVNW_USERNAME"); 100 | char[] password = System.getenv("MVNW_PASSWORD").toCharArray(); 101 | Authenticator.setDefault(new Authenticator() { 102 | @Override 103 | protected PasswordAuthentication getPasswordAuthentication() { 104 | return new PasswordAuthentication(username, password); 105 | } 106 | }); 107 | } 108 | URL website = new URL(urlString); 109 | ReadableByteChannel rbc; 110 | rbc = Channels.newChannel(website.openStream()); 111 | FileOutputStream fos = new FileOutputStream(destination); 112 | fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); 113 | fos.close(); 114 | rbc.close(); 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /src/test/java/com/devhow/identity/WebSecurityConfigTests.java: -------------------------------------------------------------------------------- 1 | package com.devhow.identity; 2 | 3 | 4 | import com.devhow.identity.entity.User; 5 | import com.devhow.identity.user.IdentityServiceException; 6 | import com.devhow.identity.user.UserService; 7 | import jakarta.mail.AuthenticationFailedException; 8 | import org.assertj.core.util.Lists; 9 | import org.junit.jupiter.api.Test; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.test.context.SpringBootTest; 12 | import org.springframework.boot.test.web.client.TestRestTemplate; 13 | import org.springframework.boot.test.web.server.LocalServerPort; 14 | import org.springframework.http.*; 15 | import org.springframework.security.crypto.password.PasswordEncoder; 16 | import org.springframework.util.LinkedMultiValueMap; 17 | import org.springframework.util.MultiValueMap; 18 | 19 | import static org.assertj.core.api.Assertions.assertThat; 20 | 21 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {"mail.test=true"}) 22 | public class WebSecurityConfigTests { 23 | 24 | private final TestRestTemplate restTemplate = new TestRestTemplate(); 25 | private final String[] publicURLs = new String[]{"/", "/public/sign-in", "/public/forgot-password", "/public/sign-up", "/public/ping"}; 26 | private final String[] loginRequiredURLs = new String[]{"/private/"}; 27 | @Autowired 28 | UserService userService; 29 | @Autowired 30 | PasswordEncoder passwordEncoder; 31 | @LocalServerPort 32 | private int port; 33 | 34 | private String createURLWithPort(String uri) { 35 | return "http://localhost:" + port + uri; 36 | } 37 | 38 | @Test 39 | public void basicAccessChecks() { 40 | HttpHeaders headers = new HttpHeaders(); 41 | HttpEntity entity = new HttpEntity<>(null, headers); 42 | 43 | for (String publicURL : publicURLs) { 44 | ResponseEntity response = restTemplate.exchange( 45 | createURLWithPort(publicURL), 46 | HttpMethod.GET, entity, String.class); 47 | 48 | assertThat(response.getStatusCode().value()).isEqualTo(200).describedAs(publicURL); 49 | } 50 | } 51 | 52 | @Test 53 | public void basicLockAccessChecks() { 54 | HttpHeaders headers = new HttpHeaders(); 55 | HttpEntity entity = new HttpEntity<>(null, headers); 56 | 57 | for (String privateURL : loginRequiredURLs) { 58 | ResponseEntity response = restTemplate.exchange( 59 | createURLWithPort(privateURL), 60 | HttpMethod.GET, entity, String.class); 61 | 62 | assertThat(response.getBody()).contains("Sign In").describedAs(privateURL); 63 | } 64 | } 65 | 66 | @Test 67 | public void apiPing() { 68 | HttpHeaders headers = new HttpHeaders(); 69 | HttpEntity entity = new HttpEntity<>(null, headers); 70 | 71 | ResponseEntity response = restTemplate.exchange( 72 | createURLWithPort("/public/ping"), 73 | HttpMethod.GET, entity, String.class); 74 | 75 | assertThat(response.getStatusCode().value()).isEqualTo(200).describedAs("User Service Ping"); 76 | assertThat(response.getBody()).contains("OK"); 77 | } 78 | 79 | @Test 80 | public void loginTest() throws AuthenticationFailedException, IdentityServiceException { 81 | 82 | String password = "fancy-new-password"; 83 | 84 | User user = UserUtils.setupUser(userService, password); 85 | 86 | assertThat(passwordEncoder.matches(password, user.getPassword())).isTrue(); 87 | 88 | HttpHeaders headers = new HttpHeaders(); 89 | headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); 90 | headers.setAccept(Lists.list(MediaType.TEXT_HTML, MediaType.APPLICATION_XHTML_XML)); 91 | MultiValueMap parameters = new LinkedMultiValueMap<>(); 92 | parameters.add("username", user.getUsername()); 93 | parameters.add("password", password); 94 | HttpEntity> request = new HttpEntity<>(parameters, headers); 95 | 96 | ResponseEntity response = restTemplate.postForEntity( 97 | createURLWithPort("/public/do-sign-in"), request, String.class); 98 | 99 | assertThat(response.getStatusCode().value()).isEqualTo(302); 100 | assertThat(response.getHeaders().get("Location")).doesNotContain("error=true"); 101 | } 102 | 103 | @Test 104 | public void badLoginTest() throws AuthenticationFailedException, IdentityServiceException { 105 | 106 | String password = "fancy-new-password"; 107 | 108 | User user = UserUtils.setupUser(userService, password); 109 | 110 | assertThat(passwordEncoder.matches(password, user.getPassword())).isTrue(); 111 | 112 | HttpHeaders headers = new HttpHeaders(); 113 | headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); 114 | 115 | MultiValueMap parameters = new LinkedMultiValueMap<>(); 116 | parameters.add("username", user.getUsername()); 117 | parameters.add("password", "garbage"); 118 | HttpEntity> request = new HttpEntity<>(parameters, headers); 119 | 120 | ResponseEntity response = restTemplate.postForEntity( 121 | createURLWithPort("/public/do-sign-in"), request, String.class); 122 | 123 | assertThat(response.getStatusCode().value()).isEqualTo(302); 124 | assertThat(response.getHeaders().get("Location").get(0)).contains("error=true"); 125 | } 126 | } -------------------------------------------------------------------------------- /src/main/java/com/devhow/identity/user/UserController.java: -------------------------------------------------------------------------------- 1 | package com.devhow.identity.user; 2 | 3 | import com.devhow.identity.entity.User; 4 | import jakarta.mail.AuthenticationFailedException; 5 | import jakarta.servlet.http.HttpServletResponse; 6 | import org.springframework.stereotype.Controller; 7 | import org.springframework.ui.ModelMap; 8 | import org.springframework.web.bind.annotation.*; 9 | import org.springframework.web.servlet.view.RedirectView; 10 | 11 | import java.util.Date; 12 | 13 | import static com.devhow.identity.user.IdentityServiceException.Reason.BAD_PASSWORD_RESET; 14 | import static com.devhow.identity.user.IdentityServiceException.Reason.BAD_TOKEN; 15 | 16 | @Controller 17 | @RequestMapping("/public") 18 | public class UserController { 19 | 20 | static final String MESSAGE = "message"; 21 | static final String ERROR = "error"; 22 | private final UserService userService; 23 | 24 | public UserController(UserService userService) { 25 | this.userService = userService; 26 | } 27 | 28 | @RequestMapping(value = "/ping", produces = "text/plain") 29 | @ResponseBody 30 | String ping(@RequestParam(name = "debug", required = false) String debug) { 31 | if (debug != null && debug.length() > 0) { 32 | return "OK " + new Date() + " " + debug; 33 | } 34 | return "OK " + new Date(); 35 | } 36 | 37 | @RequestMapping(path = "/logout") 38 | public RedirectView logout(HttpServletResponse response) { 39 | response.addHeader("HX-Redirect", "/"); 40 | return new RedirectView("/?message=logout"); 41 | } 42 | 43 | @PostMapping("/do-sign-in") 44 | public String doSignIn(@RequestParam(name = "error", defaultValue = "") String error, ModelMap modelMap) { 45 | if (error.length() > 0) 46 | modelMap.addAttribute(ERROR, error); 47 | 48 | return "index"; 49 | } 50 | 51 | @GetMapping("/sign-in") 52 | String signIn(@RequestParam(name = "error", defaultValue = "") String error, 53 | @RequestParam(name = "message", defaultValue = "") String message, ModelMap modelMap, 54 | HttpServletResponse response) { 55 | if (message.length() > 0) 56 | modelMap.addAttribute(MESSAGE, message); 57 | if (error.length() > 0) 58 | modelMap.addAttribute(ERROR, "Invalid Login"); 59 | response.addHeader("HX-Redirect", "/"); 60 | return "identity/sign-in"; 61 | } 62 | 63 | @GetMapping("/password-reset") 64 | String passwordResetKey(@RequestParam(name = "token") String key, ModelMap modelMap) { 65 | modelMap.put("key", key); 66 | return "identity/password-reset"; 67 | } 68 | 69 | @PostMapping("/password-reset") 70 | String updatePassword(@RequestParam(name = "key") String key, @RequestParam(name = "email") String email, 71 | @RequestParam(name = "password1") String password1, 72 | @RequestParam(name = "password2") String password2, ModelMap modelMap, 73 | HttpServletResponse response) { 74 | 75 | try { 76 | if (password1.compareTo(password2) != 0) 77 | throw new IdentityServiceException(BAD_PASSWORD_RESET, "Passwords don't match"); 78 | userService.updatePassword(email, key, password1); 79 | return signIn("", "Password successfully updated.", modelMap, response); 80 | } catch (IdentityServiceException e) { 81 | modelMap.addAttribute(MESSAGE, e.getMessage()); 82 | } 83 | return signIn("", "", modelMap, response); 84 | } 85 | 86 | @GetMapping("/forgot-password") 87 | String forgotPassword() { 88 | return "identity/forgot-password"; 89 | } 90 | 91 | @PostMapping("/forgot-password") 92 | String resetPassword(@RequestParam(name = "email", defaultValue = "") String email, 93 | ModelMap modelMap, HttpServletResponse response) { 94 | 95 | try { 96 | userService.requestPasswordReset(email); 97 | return signIn("", "Check your email for password reset link.", modelMap, response); 98 | } catch (IdentityServiceException e) { 99 | if (e.getReason().equals(BAD_TOKEN)) 100 | modelMap.addAttribute(MESSAGE, "Unknown Token"); 101 | else 102 | modelMap.addAttribute(MESSAGE, e.getMessage()); 103 | } catch (AuthenticationFailedException authenticationFailedException) { 104 | modelMap.addAttribute(MESSAGE, "Unable to send email right now..."); 105 | } 106 | 107 | return "identity/forgot-password"; 108 | } 109 | 110 | 111 | @GetMapping("/sign-up") 112 | String signUpPage(User user) { 113 | return "identity/sign-up"; 114 | } 115 | 116 | @PostMapping("/sign-up") 117 | String signUp(User user, @RequestParam(name = "password-confirm") String confirm, ModelMap modelMap) { 118 | try { 119 | if (!user.getPassword().equals(confirm)) 120 | throw new IdentityServiceException(IdentityServiceException.Reason.BAD_PASSWORD, "Passwords do not match"); 121 | userService.signUpUser(user.getUsername(), user.getPassword(), false); 122 | return "redirect:/public/sign-in?message=Check%20your%20email%20to%20confirm%20your%20account%21"; 123 | } catch (IdentityServiceException e) { 124 | modelMap.addAttribute(ERROR, e.getMessage()); 125 | } catch (AuthenticationFailedException authenticationFailedException) { 126 | modelMap.addAttribute(ERROR, "Can't send email - email server is down/unreachable."); 127 | authenticationFailedException.printStackTrace(); 128 | } 129 | return "identity/sign-up"; 130 | } 131 | 132 | @GetMapping("/sign-up/confirm") 133 | String confirmMail(@RequestParam("token") String token, ModelMap modelMap, HttpServletResponse response) { 134 | try { 135 | userService.confirmUser(token).orElseThrow(() -> new IdentityServiceException(BAD_TOKEN)); 136 | return signIn("", "Email Address Confirmed!", modelMap, response); 137 | } catch (IdentityServiceException e) { 138 | return signIn("", "Unknown Token", modelMap, response); 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/main/java/com/devhow/htmxdemo/InputCatalog.java: -------------------------------------------------------------------------------- 1 | package com.devhow.htmxdemo; 2 | 3 | import j2html.tags.ContainerTag; 4 | import j2html.tags.specialized.PTag; 5 | import org.springframework.http.MediaType; 6 | import org.springframework.stereotype.Controller; 7 | import org.springframework.ui.Model; 8 | import org.springframework.web.bind.annotation.*; 9 | import org.springframework.web.multipart.MultipartFile; 10 | 11 | import java.awt.*; 12 | import java.time.LocalDate; 13 | import java.time.LocalDateTime; 14 | import java.time.LocalTime; 15 | import java.util.Date; 16 | import java.util.Map; 17 | 18 | import static j2html.TagCreator.*; 19 | 20 | @Controller 21 | @RequestMapping("/public/input") 22 | public class InputCatalog { 23 | 24 | @GetMapping 25 | public String start(Model model) { 26 | model.addAttribute("now", new Date().toInstant()); 27 | return "input-catalog"; 28 | } 29 | 30 | @DeleteMapping(path = "/delete", produces = MediaType.TEXT_HTML_VALUE) 31 | @ResponseBody 32 | public String delete() { 33 | return ""; 34 | } 35 | 36 | @PostMapping(path = "/button", produces = MediaType.TEXT_HTML_VALUE) 37 | @ResponseBody 38 | public String button(@RequestParam("demo-button") String button) { 39 | return p("Button " + button + " clicked.").render(); 40 | } 41 | 42 | @PostMapping(path = "/checkbox", produces = MediaType.TEXT_HTML_VALUE) 43 | @ResponseBody 44 | public String checkbox(@RequestParam Map parameters) { 45 | 46 | if (parameters.containsKey("checkbox")) 47 | if (parameters.containsKey(parameters.get("checkbox"))) 48 | return p("Checkbox " + parameters.get("checkbox") + " checked.").render(); 49 | 50 | return p("Checkbox " + parameters.get("checkbox") + " unchecked.").render(); 51 | } 52 | 53 | @PostMapping(path = "/radio", produces = MediaType.TEXT_HTML_VALUE) 54 | @ResponseBody 55 | public String radio(@RequestParam("demo-radio") String selection) { 56 | return p("Radio " + selection + " selected.").render(); 57 | } 58 | 59 | @PostMapping(path = "/slider", produces = MediaType.TEXT_HTML_VALUE) 60 | @ResponseBody 61 | public String slider(@RequestParam("demo-range") Integer selection) { 62 | return p("Slider " + selection + " value.").render(); 63 | } 64 | 65 | @PostMapping(path = "/select-single", produces = MediaType.TEXT_HTML_VALUE) 66 | @ResponseBody 67 | public String selectSingle(@RequestParam("demo-select-single") String selection) { 68 | return p("Selected " + selection + ".").render(); 69 | } 70 | 71 | @PostMapping(path = "/select-multiple", produces = MediaType.TEXT_HTML_VALUE) 72 | @ResponseBody 73 | public String selectMultiple(@RequestParam("demo-select-multiple") String[] selection) { 74 | ContainerTag p = p("Selected"); 75 | 76 | for (String s : selection) 77 | p.with(span(" " + s)); 78 | return p.render(); 79 | } 80 | 81 | @PostMapping(path = "/date", produces = MediaType.TEXT_HTML_VALUE) 82 | @ResponseBody 83 | public String date(@RequestParam("demo-date") String date) { 84 | LocalDate parse = LocalDate.parse(date); 85 | return p("Selected " + parse + ".").render(); 86 | } 87 | 88 | @PostMapping(path = "/time", produces = MediaType.TEXT_HTML_VALUE) 89 | @ResponseBody 90 | public String time(@RequestParam("demo-time") String time) { 91 | LocalTime parse = LocalTime.parse(time); 92 | return p("Selected " + parse + ".").render(); 93 | } 94 | 95 | @PostMapping(path = "/datetime", produces = MediaType.TEXT_HTML_VALUE) 96 | @ResponseBody 97 | public String datetime(@RequestParam("demo-date-time-local") String datetime) { 98 | LocalDateTime parse = LocalDateTime.parse(datetime); 99 | return p("Selected " + parse + ".").render(); 100 | } 101 | 102 | @PostMapping(path = "/color", produces = MediaType.TEXT_HTML_VALUE) 103 | @ResponseBody 104 | public String color(@RequestParam("demo-color") String color) { 105 | 106 | Color c = Color.decode(color); 107 | 108 | return p(join( 109 | "Hex:", 110 | color, 111 | " RGB:", 112 | c.getRed() + "", 113 | c.getGreen() + "", 114 | c.getBlue() + "" 115 | )).render(); 116 | } 117 | 118 | @PostMapping(path = "/number", produces = MediaType.TEXT_HTML_VALUE) 119 | @ResponseBody 120 | public String color(@RequestParam("demo-number") Integer num) { 121 | return p("Number: " + num).render(); 122 | } 123 | 124 | @PostMapping(path = "/text", produces = MediaType.TEXT_HTML_VALUE) 125 | @ResponseBody 126 | public String text(@RequestHeader("HX-Trigger-Name") String trigger, @RequestParam Map parameters) { 127 | String target = parameters.get(trigger); 128 | if (!parameters.containsKey(trigger)) 129 | return ""; 130 | return p(trigger + " set to " + target).render(); 131 | } 132 | 133 | @PostMapping(path = "/file", produces = MediaType.TEXT_HTML_VALUE) 134 | @ResponseBody 135 | public String file( 136 | @RequestParam("demo-file") MultipartFile file, 137 | @RequestParam Map parameters) { 138 | ContainerTag p = p("File uploaded! ").with(join(br(), 139 | " File name: " + file.getName(), br(), 140 | " File length: " + file.getSize() + " bytes", br(), 141 | " File type: " + file.getContentType(), br(), 142 | " Original file name: " + file.getOriginalFilename() 143 | )); 144 | 145 | return p.render(); 146 | } 147 | 148 | @PostMapping(path = "/reset", produces = MediaType.TEXT_HTML_VALUE) 149 | @ResponseBody 150 | public String reset() { 151 | return p("Form reset!").render(); 152 | } 153 | 154 | @PostMapping(path = "/submit", produces = MediaType.TEXT_HTML_VALUE) 155 | @ResponseBody 156 | public String submit(@RequestParam Map parameters) { 157 | 158 | var p = p("Form submitted!"); 159 | 160 | for (String s : parameters.keySet()) 161 | p.with(join(br(), s + ":" + parameters.get(s))); 162 | 163 | return p.render(); 164 | } 165 | 166 | 167 | } 168 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | @REM ---------------------------------------------------------------------------- 2 | @REM Licensed to the Apache Software Foundation (ASF) under one 3 | @REM or more contributor license agreements. See the NOTICE file 4 | @REM distributed with this work for additional information 5 | @REM regarding copyright ownership. The ASF licenses this file 6 | @REM to you under the Apache License, Version 2.0 (the 7 | @REM "License"); you may not use this file except in compliance 8 | @REM with the License. You may obtain a copy of the License at 9 | @REM 10 | @REM https://www.apache.org/licenses/LICENSE-2.0 11 | @REM 12 | @REM Unless required by applicable law or agreed to in writing, 13 | @REM software distributed under the License is distributed on an 14 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | @REM KIND, either express or implied. See the License for the 16 | @REM specific language governing permissions and limitations 17 | @REM under the License. 18 | @REM ---------------------------------------------------------------------------- 19 | 20 | @REM ---------------------------------------------------------------------------- 21 | @REM Maven Start Up Batch script 22 | @REM 23 | @REM Required ENV vars: 24 | @REM JAVA_HOME - location of a JDK home dir 25 | @REM 26 | @REM Optional ENV vars 27 | @REM M2_HOME - location of maven2's installed home dir 28 | @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands 29 | @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending 30 | @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven 31 | @REM e.g. to debug Maven itself, use 32 | @REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 33 | @REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files 34 | @REM ---------------------------------------------------------------------------- 35 | 36 | @REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' 37 | @echo off 38 | @REM set title of command window 39 | title %0 40 | @REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' 41 | @if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% 42 | 43 | @REM set %HOME% to equivalent of $HOME 44 | if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") 45 | 46 | @REM Execute a user defined script before this one 47 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre 48 | @REM check for pre script, once with legacy .bat ending and once with .cmd ending 49 | if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" 50 | if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" 51 | :skipRcPre 52 | 53 | @setlocal 54 | 55 | set ERROR_CODE=0 56 | 57 | @REM To isolate internal variables from possible post scripts, we use another setlocal 58 | @setlocal 59 | 60 | @REM ==== START VALIDATION ==== 61 | if not "%JAVA_HOME%" == "" goto OkJHome 62 | 63 | echo. 64 | echo Error: JAVA_HOME not found in your environment. >&2 65 | echo Please set the JAVA_HOME variable in your environment to match the >&2 66 | echo location of your Java installation. >&2 67 | echo. 68 | goto error 69 | 70 | :OkJHome 71 | if exist "%JAVA_HOME%\bin\java.exe" goto init 72 | 73 | echo. 74 | echo Error: JAVA_HOME is set to an invalid directory. >&2 75 | echo JAVA_HOME = "%JAVA_HOME%" >&2 76 | echo Please set the JAVA_HOME variable in your environment to match the >&2 77 | echo location of your Java installation. >&2 78 | echo. 79 | goto error 80 | 81 | @REM ==== END VALIDATION ==== 82 | 83 | :init 84 | 85 | @REM Find the project base dir, i.e. the directory that contains the folder ".mvn". 86 | @REM Fallback to current working directory if not found. 87 | 88 | set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% 89 | IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir 90 | 91 | set EXEC_DIR=%CD% 92 | set WDIR=%EXEC_DIR% 93 | :findBaseDir 94 | IF EXIST "%WDIR%"\.mvn goto baseDirFound 95 | cd .. 96 | IF "%WDIR%"=="%CD%" goto baseDirNotFound 97 | set WDIR=%CD% 98 | goto findBaseDir 99 | 100 | :baseDirFound 101 | set MAVEN_PROJECTBASEDIR=%WDIR% 102 | cd "%EXEC_DIR%" 103 | goto endDetectBaseDir 104 | 105 | :baseDirNotFound 106 | set MAVEN_PROJECTBASEDIR=%EXEC_DIR% 107 | cd "%EXEC_DIR%" 108 | 109 | :endDetectBaseDir 110 | 111 | IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig 112 | 113 | @setlocal EnableExtensions EnableDelayedExpansion 114 | for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a 115 | @endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% 116 | 117 | :endReadAdditionalConfig 118 | 119 | SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" 120 | set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" 121 | set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 122 | 123 | set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" 124 | 125 | FOR /F "tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 126 | IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B 127 | ) 128 | 129 | @REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 130 | @REM This allows using the maven wrapper in projects that prohibit checking in binary data. 131 | if exist %WRAPPER_JAR% ( 132 | if "%MVNW_VERBOSE%" == "true" ( 133 | echo Found %WRAPPER_JAR% 134 | ) 135 | ) else ( 136 | if not "%MVNW_REPOURL%" == "" ( 137 | SET DOWNLOAD_URL="%MVNW_REPOURL%/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" 138 | ) 139 | if "%MVNW_VERBOSE%" == "true" ( 140 | echo Couldn't find %WRAPPER_JAR%, downloading it ... 141 | echo Downloading from: %DOWNLOAD_URL% 142 | ) 143 | 144 | powershell -Command "&{"^ 145 | "$webclient = new-object System.Net.WebClient;"^ 146 | "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ 147 | "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ 148 | "}"^ 149 | "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ 150 | "}" 151 | if "%MVNW_VERBOSE%" == "true" ( 152 | echo Finished downloading %WRAPPER_JAR% 153 | ) 154 | ) 155 | @REM End of extension 156 | 157 | @REM Provide a "standardized" way to retrieve the CLI args that will 158 | @REM work with both Windows and non-Windows executions. 159 | set MAVEN_CMD_LINE_ARGS=%* 160 | 161 | %MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* 162 | if ERRORLEVEL 1 goto error 163 | goto end 164 | 165 | :error 166 | set ERROR_CODE=1 167 | 168 | :end 169 | @endlocal & set ERROR_CODE=%ERROR_CODE% 170 | 171 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost 172 | @REM check for post script, once with legacy .bat ending and once with .cmd ending 173 | if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" 174 | if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" 175 | :skipRcPost 176 | 177 | @REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' 178 | if "%MAVEN_BATCH_PAUSE%" == "on" pause 179 | 180 | if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% 181 | 182 | exit /B %ERROR_CODE% 183 | -------------------------------------------------------------------------------- /src/test/java/com/devhow/identity/UserRegistrationTests.java: -------------------------------------------------------------------------------- 1 | package com.devhow.identity; 2 | 3 | import com.devhow.identity.entity.User; 4 | import com.devhow.identity.entity.UserValidation; 5 | import com.devhow.identity.user.IdentityServiceException; 6 | import com.devhow.identity.user.UserService; 7 | import jakarta.mail.AuthenticationFailedException; 8 | import org.junit.jupiter.api.Test; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.boot.test.context.SpringBootTest; 11 | import org.springframework.security.crypto.password.PasswordEncoder; 12 | 13 | import java.sql.Timestamp; 14 | import java.util.Calendar; 15 | import java.util.Optional; 16 | import java.util.Random; 17 | 18 | import static org.assertj.core.api.Assertions.assertThat; 19 | import static org.junit.jupiter.api.Assertions.assertThrows; 20 | 21 | @SpringBootTest(properties = {"mail.test=true"}) 22 | public class UserRegistrationTests { 23 | 24 | final private String BCRYPT_TOKEN = "{bcrypt}$2a$"; 25 | private final Random random = new Random(); 26 | @Autowired 27 | UserService userService; 28 | @Autowired 29 | PasswordEncoder passwordEncoder; 30 | private User user = new User(true); 31 | private UserValidation userValidation; 32 | 33 | @Test 34 | public void CheckCurrentTokenCalc() { 35 | assertThat(user.validated()).isFalse(); 36 | user.markTokenAsValid(); 37 | assertThat(user.validated()).isTrue(); 38 | } 39 | 40 | @Test 41 | public void HappyPathRegistration() throws IdentityServiceException, AuthenticationFailedException { 42 | String pass = "this-is-just-a-test"; 43 | String username = "wiverson+" + random.nextInt() + "@gmail.com"; 44 | 45 | assertThrows(IdentityServiceException.class, () -> userService.signIn(username, pass)); 46 | 47 | User signedUpUser = userService.signUpUser(username, pass, true); 48 | 49 | assertThat(userService.validation(signedUpUser).getToken()).isNotNull(); 50 | assertThat(userService.validation(signedUpUser).getToken()).isNotEmpty(); 51 | assertThat(signedUpUser.validated()).isFalse(); 52 | 53 | String encryptedPass = signedUpUser.getPassword(); 54 | assertThat(encryptedPass).contains(BCRYPT_TOKEN); 55 | 56 | Optional confirmUser = userService.confirmUser(userService.validation(signedUpUser).getToken()); 57 | 58 | assertThat(confirmUser.isPresent()).isTrue(); 59 | assertThat(confirmUser.get().validated()).isTrue(); 60 | assertThat(confirmUser.get().getPassword()).isEqualTo(encryptedPass); 61 | 62 | User secondFormUser = new User(user.getUsername(), pass, true); 63 | assertThat(secondFormUser.getPassword()).doesNotContain(BCRYPT_TOKEN); 64 | 65 | User signIn = userService.signIn(username, pass); 66 | assertThat(signIn).isNotNull(); 67 | assertThat(signIn.getPassword()).contains(BCRYPT_TOKEN); 68 | } 69 | 70 | /** 71 | * Existing confirmed user tries to sign up again 72 | */ 73 | @Test 74 | public void ExistingUserTriesToSignUpAgain() throws IdentityServiceException, AuthenticationFailedException { 75 | String username = "wiverson+" + random.nextInt() + "@gmail.com"; 76 | String password = "test-this-is-just-for-testing"; 77 | 78 | user = userService.signUpUser(username, password, true); 79 | assertThat(user.validated()).isFalse(); 80 | 81 | Optional confirmUser = userService.confirmUser(userService.validation(user).getToken()); 82 | assertThat(confirmUser.isPresent()).isTrue(); 83 | assertThat(confirmUser.get().validated()).isTrue(); 84 | 85 | User secondSignup = new User(user.getUsername(), user.getPassword(), true); 86 | 87 | // This is the key flow here - if a user tries to sign up again but is already confirmed, 88 | // the returned user will show up as isEnabled. 89 | assertThrows(IdentityServiceException.class, () -> 90 | userService.signUpUser(username, password, true)); 91 | } 92 | 93 | /** 94 | * Invalid token path 95 | */ 96 | @Test 97 | public void InvalidToken() throws IdentityServiceException, AuthenticationFailedException { 98 | String username = "wiverson+" + random.nextInt() + "@gmail.com"; 99 | String pass = "test-is-just-for-a-test"; 100 | 101 | user = userService.signUpUser(username, pass, true); 102 | assertThat(user.validated()).isFalse(); 103 | 104 | assertThrows(IdentityServiceException.class, () -> userService.confirmUser("garbage token")); 105 | 106 | user = userService.findUser(user.getUsername()).orElseThrow(); 107 | assertThat(user.validated()).isFalse(); 108 | } 109 | 110 | /** 111 | * Invalid email address 112 | */ 113 | @Test() 114 | public void InvalidEmailAddress() { 115 | assertThrows(IdentityServiceException.class, () -> 116 | user = userService.signUpUser("garbage email", "test-this-is-just-for-testing", true)); 117 | assertThrows(IdentityServiceException.class, () -> 118 | user = userService.signUpUser("", "test-this-is-just-for-testing", true)); 119 | assertThrows(IdentityServiceException.class, () -> 120 | user = userService.signUpUser("a", "test-this-is-just-for-testing", true)); 121 | assertThrows(IdentityServiceException.class, () -> 122 | user = userService.signUpUser("a.c", "test-this-is-just-for-testing", true)); 123 | } 124 | 125 | /** 126 | * Invalid password 127 | */ 128 | @Test 129 | public void InvalidPassword() { 130 | assertThrows(IdentityServiceException.class, () -> 131 | userService.signUpUser("wiverson+test@gmail.com", "", true), 132 | "Empty password"); 133 | 134 | assertThrows(IdentityServiceException.class, () -> 135 | userService.signUpUser("wiverson+test@gmail.com", "123", true), 136 | "Too short password"); 137 | 138 | assertThrows(IdentityServiceException.class, () -> 139 | userService.signUpUser("wiverson+test@gmail.com", "299 929 2929", true), 140 | "Password has spaces"); 141 | } 142 | 143 | /** 144 | * Expired token 145 | */ 146 | @Test 147 | public void ExpiredToken() throws IdentityServiceException, AuthenticationFailedException { 148 | user = userService.signUpUser("wiverson+" + random.nextInt() + "@gmail.com", "test-is-just-for-a-test", true); 149 | assertThat(user.validated()).isFalse(); 150 | 151 | long timeInMillis = System.currentTimeMillis(); 152 | Calendar cal = Calendar.getInstance(); 153 | cal.setTimeInMillis(timeInMillis); 154 | cal.set(Calendar.DATE, cal.get(Calendar.DATE) - 5); 155 | 156 | UserValidation userValidation = userService.validation(user); 157 | userValidation.setTokenIssue(new Timestamp(cal.getTimeInMillis())); 158 | 159 | userService.update(userValidation); 160 | 161 | assertThrows(IdentityServiceException.class, () -> userService.confirmUser(userService.validation(user).getToken())); 162 | 163 | assertThat(userService.findUser(user.getUsername()).orElseThrow().validated()).isFalse(); 164 | } 165 | 166 | @Test 167 | public void ResetPassword() throws IdentityServiceException, AuthenticationFailedException { 168 | String startingPassword = "test-is-just-for-a-test"; 169 | String username = "wiverson+" + random.nextInt() + "@gmail.com"; 170 | 171 | // password reset is requested but email doesn't exist 172 | assertThrows(IdentityServiceException.class, () -> userValidation = userService.requestPasswordReset(user.getUsername())); 173 | 174 | user = userService.signUpUser(username, startingPassword, true); 175 | 176 | // password reset is requested but token has not been validated 177 | assertThrows(IdentityServiceException.class, () -> userService.requestPasswordReset(user.getUsername())); 178 | 179 | userService.signIn(user.getUsername(), startingPassword); 180 | 181 | // Confirm user with token 182 | userService.confirmUser(userService.validation(user).getToken()); 183 | 184 | userValidation = userService.requestPasswordReset(user.getUsername()); 185 | 186 | assertThat(userValidation.getPasswordResetIssue()).isNotNull(); 187 | assertThat(userValidation.getPasswordResetToken()).isNotNull(); 188 | 189 | // password reset is requested for valid account but password reset token expired 190 | 191 | userService.requestPasswordReset(user.getUsername()); 192 | 193 | UserValidation userValidation = userService.validation(user); 194 | 195 | String newPassword = "this-is-a-fancy-new-password"; 196 | 197 | assertThrows(IdentityServiceException.class, () -> userService.signIn(user.getUsername(), newPassword)); 198 | user = userService.signIn(user.getUsername(), startingPassword); 199 | 200 | user = userService.updatePassword(user.getUsername(), userValidation.getPasswordResetToken(), newPassword); 201 | assertThat(user.getPassword()).contains(BCRYPT_TOKEN); 202 | 203 | assertThrows(IdentityServiceException.class, () -> userService.signIn(user.getUsername(), startingPassword)); 204 | userService.signIn(user.getUsername(), newPassword); 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spring Boot & [htmx](https://htmx.org/) Demo 2 | 3 | **It's possible to be a full stack Java developer and provide dynamic, rich application functionality without complex 4 | tools or JavaScript frameworks.** 5 | 6 | **Please note that you should check out my other GitHub template, [spring-boot-supabase](https://github.com/ChangeNode/spring-boot-supabase) if 7 | you are interested in modern full stack Java. It has a Spring Security 8 | implementation based on [Supabase.io](https://supabase.io/)** 9 | 10 | [YouTube video](https://youtu.be/38WAVRfxPxI) walking through this repo! 11 | 12 | Very simple demonstration of the use of [htmx](https://htmx.org) 13 | with [Spring Boot](https://spring.io/projects/spring-boot) 14 | and [Thymeleaf](https://www.thymeleaf.org). In addition to Thymeleaf, a few examples also use 15 | [Handlebars/Mustache](https://github.com/jknack/handlebars.java) and [j2html](https://j2html.com/). 16 | 17 | Build a [Single Page Application (SPA)](https://en.wikipedia.org/wiki/Single-page_application) or just progressively add 18 | dynamic JavaScript functionality to your app without a complicated JavaScript framework. 19 | 20 | HTMX + Spring Boot is also a great way to add dynamic HTML functionality to an existing website, perhaps on a completely 21 | different stack (such as WordPress or SquareSpace). 22 | 23 | ## Using This Project 24 | 25 | Requirements: [JDK](https://adoptopenjdk.net/?variant=openjdk17) 17 and [Apache Maven](https://maven.apache.org/). 26 | 27 | That's it. No npm, no JavaScript frameworks, no REST services generating JSON that is then converted back to HTML in the 28 | browser. 29 | 30 | Once Java & Maven are installed, you can clone off this project, run `mvn spring-boot:run` 31 | and go to [http://localhost:8080](http://localhost:8080). You'll see an index page with links to the various demos. 32 | 33 | You don't need to install Node.js, npm, or any other tooling to get rich, dynamic UIs - just html, css, htmx, bootstrap 34 | and a bit of hyperscript. 35 | 36 | If you want to be fancy, tell folks that you 37 | are [migrating back to server-side rendering for your SPA applications](https://blog.asayer.io/server-side-rendering-ssr-with-react) 38 | for performance and security reasons. 39 | 40 | ## Dependencies 41 | 42 | [WebJars](https://www.webjars.org) are used to install and manage Bootstrap, htmx and hyperscript. More information 43 | on [using WebJars with Spring Boot](https://www.webjars.org/documentation#springboot). 44 | 45 | You can add the WebJars for [htmx](https://htmx.org/) and [hyperscript](https://hyperscript.org/) to your pom.xml like 46 | this: 47 | 48 | ```xml 49 | 50 | 51 | 52 | org.webjars.npm 53 | htmx.org 54 | (get the current version from [htmx.org](https://htmx.org)) 55 | 56 | 57 | org.webjars.npm 58 | hyperscript.org 59 | (get the current version from [hyperscript.org](https://hyperscript.org)) 60 | 61 | 62 | ``` 63 | 64 | Add the following to your application Thymeleaf/HTML to use htmx & hyperscript: 65 | 66 | ```xml 67 | 68 | 69 | 70 | 71 | 72 | ``` 73 | 74 | You don't specify the version number for htmx or hyperscript in the HTML declaration - the versions are are being 75 | managed automatically by the embedded library `org.webjars:webjars-locator-core`. 76 | 77 | ## Layout 78 | 79 | I don't like to repeat myself, so I use Thymeleaf layouts to keep the copy & pasting down to a minimum. Each page uses 80 | a `layout:fragment="content"` declaration to pull in a layout which includes the standard `` 81 | 82 | To see an example of 83 | this, [index.html](https://github.com/wiverson/htmx-demo/blob/master/src/main/resources/templates/index.html) 84 | includes the line `

` which instructs the 85 | [Thymeleaf Layout Dialect](https://github.com/ultraq/thymeleaf-layout-dialect) to wrap the section with 86 | [layout.html](https://github.com/wiverson/htmx-demo/blob/master/src/main/resources/templates/layout.html). 87 | 88 | ## Visual Design 89 | 90 | This project has been tweaked slightly to make it work better with visual design tools 91 | like [Pinegrow](https://pinegrow.com/). Pinegrow (and other similar tools) expect to find the html templates and all 92 | related assets in a single directory. By default, Spring Boot splits Thymeleaf html templates into one directory 93 | (templates) and static public assets in another (static/public). This completely breaks all visual design tools. 94 | 95 | In this project, the Thymeleaf html templates AND the static assets all live in the same folder (static/public). Spring 96 | Security is configured to block paths with the .html extension, so the raw templates aren't visible to users. 97 | 98 | Considering that one of the main features of Thymeleaf is the use of valid, complete html, this is a big productivity 99 | advantage for swapping this configuration around. There shouldn't be anything in your html files but basic markup 100 | anyways - we are long, long past the days when JDBC/RDBMS passwords would be stored in JSP files. 101 | 102 | ## Logging 103 | 104 | The application.yaml and logback.xml files are set up to dramatically reduce the log noise for a typical Spring Boot 105 | project. 106 | 107 | ## Spring Security 108 | 109 | This project includes a demo illustrating the use of Spring Security with htmx. When you access a path with /private/ 110 | Spring Security will send you to a login page. To create an account, use the sign up page and check the console for a 111 | link you can copy and paste into your browser. 112 | 113 | This implementation of Spring Security includes a pretty complete workflow for an email/password based user registration 114 | system. If you pop in a valid SMTP server into the configuration you will have a working: 115 | 116 | - User login 117 | - User sign up 118 | - User email validation 119 | - User forgot password/email reset 120 | - RDBMS backed store with properly salted & encrypted passwords 121 | 122 | You will find Spring Security implementation details in /htmx-demo/src/main/java/com/devhow/identity path. 123 | 124 | The main fancy feature involving htmx is that if a user is logged in to the app with two pages open - let's say page A 125 | and page B. If the user logs out on page A, the session is now invalid for page B. In this demo, page B will now 126 | correctly bounce to the login page if htmx requests any new data. 127 | 128 | ## Screenshots 129 | 130 | The mandatory basic web-front end to do list sample app. Here are the 131 | [Java controller](https://github.com/wiverson/htmx-demo/blob/master/src/main/java/com/devhow/htmxdemo/demo/ToDoList.java) 132 | and the [Thymeleaf template](https://github.com/wiverson/htmx-demo/blob/master/src/main/resources/templates/todo.html). 133 | You may notice that there is a very small amount of [hyperscript](https://hyperscript.org) added to the page to address 134 | event edge cases not handled by htmx alone. 135 | 136 | ![To Do](/www/images/todo.png) 137 | 138 | This infinite scroll demo uses the [java-faker](https://github.com/DiUS/java-faker) library to generate an endless 139 | stream of fake data. The more you scroll, the more data you'll see. In this case, the 140 | [Java controller](https://github.com/wiverson/htmx-demo/blob/master/src/main/java/com/devhow/htmxdemo/demo/InfiniteScroll.java) 141 | is just using Java text blocks to return the data. While very, very simple (and fast!) this isn't really a great idea, 142 | in particular due to potential issues around HTML escaping. For most situations you are much better off using either 143 | Thymeleaf templates, [Handlebars/Mustache](https://github.com/jknack/handlebars.java) or [j2html](https://j2html.com/) 144 | for these fragments. 145 | 146 | ![Infinite Scroll](/www/images/infinite-scroll.png) 147 | 148 | The next two screenshots are for a single 149 | [Java controller](https://github.com/wiverson/htmx-demo/blob/master/src/main/java/com/devhow/htmxdemo/demo/InputCatalog.java) 150 | and [Thymeleaf template](https://github.com/wiverson/htmx-demo/blob/master/src/main/resources/templates/input-catalog.html) 151 | . 152 | 153 | Every single input immediately posts back data to the controller. In this case, the response is sent back as an element 154 | to append to the messages block, but it's easy to imagine the response updating other elements - or perhaps just 155 | instantly preserving the user's data with no explicit form submit required. 156 | 157 | All the widgets shown are just standard HTML elements. A few of them - like file input - can be a bit tricky to handle 158 | correctly - you want to make sure the submission encoding is set right and that you have right configuration on the 159 | server. 160 | 161 | Fun fact: the checkbox can only be set to an indeterminate state via JavaScript (in this case, I'm just 162 | using [hyperscript](https://hyperscript.org)). 163 | 164 | ![Standard HTML Input Widgets](/www/images/input-widgets-1.png) 165 | 166 | These are mostly various text input widgets. In addition to things like automatically showing special keyboards on 167 | various mobile devices, certain fields such as Search are often used for live updates in response to user typing. There 168 | that functionality can be added trivially, without any JavaScript. 169 | 170 | ![Standard HTML Input Widgets](/www/images/input-widgets-2.png) 171 | -------------------------------------------------------------------------------- /src/main/java/com/devhow/identity/user/UserService.java: -------------------------------------------------------------------------------- 1 | package com.devhow.identity.user; 2 | 3 | import com.devhow.identity.entity.User; 4 | import com.devhow.identity.entity.UserValidation; 5 | import jakarta.mail.AuthenticationFailedException; 6 | import org.springframework.beans.factory.annotation.Value; 7 | import org.springframework.mail.SimpleMailMessage; 8 | import org.springframework.security.crypto.password.PasswordEncoder; 9 | import org.springframework.stereotype.Service; 10 | 11 | import java.util.Optional; 12 | 13 | import static com.devhow.identity.user.IdentityServiceException.Reason.*; 14 | 15 | @Service 16 | public class UserService { 17 | 18 | private final UserRepository userRepository; 19 | private final UserValidationRepository userValidationRepository; 20 | private final PasswordEncoder passwordEncoder; 21 | private final EmailSenderService emailSenderService; 22 | 23 | @Value("${external.server.address}") 24 | private String serverAddress; 25 | 26 | @Value("${spring.mail.username}") 27 | private String emailSender; 28 | 29 | public UserService(UserRepository userRepository, UserValidationRepository userValidationRepository, 30 | PasswordEncoder passwordEncoder, EmailSenderService emailSenderService) { 31 | this.userRepository = userRepository; 32 | this.userValidationRepository = userValidationRepository; 33 | this.passwordEncoder = passwordEncoder; 34 | this.emailSenderService = emailSenderService; 35 | } 36 | 37 | public UserValidation validation(User user) { 38 | return userValidationRepository.findById(user.getId()).orElseThrow(() -> 39 | new IllegalArgumentException("Need to save the user before using validation")); 40 | } 41 | 42 | private void sendConfirmationMail(User user) { 43 | final SimpleMailMessage mailMessage = new SimpleMailMessage(); 44 | mailMessage.setTo(user.getUsername()); 45 | mailMessage.setSubject("ChangeNode: Finish Setting Up Your Account"); 46 | mailMessage.setFrom(emailSender); 47 | mailMessage.setText( 48 | "Thank you for registering!\n" + 49 | "Please click on the below link to activate your account.\n\n" + 50 | serverAddress + "/public/sign-up/confirm?token=" 51 | + validation(user).getToken()); 52 | 53 | emailSenderService.sendEmail(mailMessage); 54 | } 55 | 56 | private void sendPasswordResetLink(User user) { 57 | final SimpleMailMessage mailMessage = new SimpleMailMessage(); 58 | mailMessage.setTo(user.getUsername()); 59 | mailMessage.setSubject("ChangeNode: Password Reset Link"); 60 | mailMessage.setFrom(emailSender); 61 | mailMessage.setText( 62 | "Here's your password reset link. Only valid for apx two hours.\n" + 63 | "Please click on the below link to reset your account password.\n\n" + 64 | serverAddress + "/public/password-reset?token=" 65 | + validation(user).getPasswordResetToken()); 66 | 67 | emailSenderService.sendEmail(mailMessage); 68 | } 69 | 70 | 71 | private void checkEmailAddress(String address) throws IdentityServiceException { 72 | // Really, really basic validation to ensure the email address has an @ symbol that's not at the start or end 73 | if (!address.contains("@")) 74 | throw new IdentityServiceException(BAD_EMAIL, "Invalid email address (1)"); 75 | if (address.endsWith("@")) 76 | throw new IdentityServiceException(BAD_EMAIL, "Invalid email address (2)"); 77 | if (address.startsWith("@")) 78 | throw new IdentityServiceException(BAD_EMAIL, "Invalid email address (3)"); 79 | if (address.length() < 5) 80 | throw new IdentityServiceException(BAD_EMAIL, "Invalid email address (4)"); 81 | if (!address.contains(".")) 82 | throw new IdentityServiceException(BAD_EMAIL, "Invalid email address (5)"); 83 | } 84 | 85 | private void checkPassword(String password) throws IdentityServiceException { 86 | if (password == null) 87 | throw new IdentityServiceException(BAD_PASSWORD, "No password set."); 88 | 89 | if (password.length() < 12) 90 | throw new IdentityServiceException(BAD_PASSWORD, "Password is too short."); 91 | 92 | if (password.length() > 200) 93 | throw new IdentityServiceException(BAD_PASSWORD, "Password is too long."); 94 | 95 | if (!password.trim().equals(password)) 96 | throw new IdentityServiceException(BAD_PASSWORD, "No spaces in password."); 97 | 98 | if (password.contains(" ")) 99 | throw new IdentityServiceException(BAD_PASSWORD, "No spaces in password."); 100 | 101 | String clean = password.replaceAll("[^\\n\\r\\t\\p{Print}]", ""); 102 | if (!password.equals(clean)) 103 | throw new IdentityServiceException(BAD_PASSWORD, "No non-printable characters in password."); 104 | } 105 | 106 | public User signIn(String username, String pass) throws IdentityServiceException { 107 | User user = userRepository.findByUsername(username).orElseThrow(() -> new IdentityServiceException(BAD_LOGIN, "Unknown Email")); 108 | 109 | if (!passwordEncoder.matches(pass, user.getPassword())) 110 | throw new IdentityServiceException(BAD_LOGIN, "Invalid Login (1)"); 111 | 112 | return user; 113 | } 114 | 115 | public User signUpUser(String username, String pass, boolean isTest) throws IdentityServiceException, AuthenticationFailedException { 116 | 117 | // First normalize user email 118 | checkEmailAddress(username); 119 | checkPassword(pass); 120 | 121 | // First verify user doesn't already exist 122 | Optional foundUser = userRepository.findByUsername(username); 123 | 124 | if (foundUser.isPresent()) { 125 | throw new IdentityServiceException(BAD_EMAIL, "Email already exists."); 126 | } 127 | 128 | User newUser = new User(); 129 | newUser.setUsername(username.trim().toLowerCase()); 130 | newUser.setPassword(passwordEncoder.encode(pass)); 131 | newUser.setTest(isTest); 132 | 133 | if (!passwordEncoder.matches(pass, newUser.getPassword())) 134 | throw new IllegalArgumentException("The passwordEncoder just failed to match an encoded password!"); 135 | 136 | newUser = userRepository.save(newUser); 137 | 138 | UserValidation userValidation = new UserValidation(newUser); 139 | userValidation.newToken(); 140 | userValidationRepository.save(userValidation); 141 | 142 | sendConfirmationMail(newUser); 143 | 144 | return newUser; 145 | } 146 | 147 | 148 | public void deleteUser(User user) { 149 | if (!user.isTest()) 150 | throw new IllegalArgumentException("Can only delete test users!"); 151 | 152 | userValidationRepository.deleteById(user.getId()); 153 | userRepository.delete(user); 154 | } 155 | 156 | private User existingUserSignup(User user) throws AuthenticationFailedException { 157 | if (user.getTokenValidation() != null) 158 | return user; 159 | if (validation(user).tokenIsCurrent()) { 160 | sendConfirmationMail(user); 161 | } else { 162 | validation(user).newToken(); 163 | userValidationRepository.save(validation(user)); 164 | } 165 | return user; 166 | } 167 | 168 | public Optional confirmUser(String confirmationToken) throws IdentityServiceException { 169 | 170 | UserValidation userValidation = userValidationRepository.findByToken(confirmationToken).orElseThrow(() -> 171 | new IdentityServiceException(BAD_TOKEN, "Invalid Token (21)")); 172 | 173 | User user = userRepository.findById(userValidation.getUser()).orElseThrow(() -> 174 | new IdentityServiceException(BAD_TOKEN, "Invalid Token (22)")); 175 | 176 | if (!validation(user).tokenIsCurrent()) 177 | throw new IdentityServiceException(BAD_TOKEN, ""); 178 | 179 | user.markTokenAsValid(); 180 | User savedUser = userRepository.save(user); 181 | 182 | return Optional.of(savedUser); 183 | } 184 | 185 | public Optional findUser(String username) { 186 | return userRepository.findByUsername(username); 187 | } 188 | 189 | public User update(User user) { 190 | return userRepository.save(user); 191 | } 192 | 193 | public UserValidation update(UserValidation userValidation) { 194 | return userValidationRepository.save(userValidation); 195 | } 196 | 197 | public UserValidation requestPasswordReset(String username) throws IdentityServiceException, AuthenticationFailedException { 198 | User user = userRepository.findByUsername(username).orElseThrow(() 199 | -> new IdentityServiceException(BAD_PASSWORD_RESET, "Missing email address. (a)")); 200 | 201 | if (!user.validated()) 202 | throw new IdentityServiceException(BAD_TOKEN, "User never activated (should resend activation email)"); 203 | 204 | UserValidation uv = userValidationRepository.findById(user.getId()).orElseThrow(() 205 | -> new IdentityServiceException(BAD_PASSWORD_RESET, "No validation token found. (b)")); 206 | 207 | if (uv.getPasswordResetIssue() != null) 208 | if (uv.passwordValidationIsCurrent()) { 209 | return uv; 210 | } 211 | 212 | uv.newPasswordResetToken(); 213 | uv = userValidationRepository.save(uv); 214 | 215 | sendPasswordResetLink(user); 216 | 217 | return uv; 218 | } 219 | 220 | public User updatePassword(String username, String passwordResetToken, String newPassword) throws IdentityServiceException { 221 | User user = userRepository.findByUsername(username).orElseThrow(() -> 222 | new IdentityServiceException(BAD_PASSWORD_RESET, "No user found with this email. (c)")); 223 | UserValidation userValidation = userValidationRepository.findById(user.getId()).orElseThrow(() 224 | -> new IdentityServiceException(BAD_PASSWORD_RESET, "No user validation token[s] found. (d)")); 225 | 226 | if (!userValidation.getPasswordResetToken().equals(passwordResetToken)) 227 | throw new IdentityServiceException(BAD_PASSWORD_RESET, "Invalid/expired token. (e)"); 228 | if (!userValidation.passwordValidationIsCurrent()) 229 | throw new IdentityServiceException(BAD_PASSWORD_RESET, "Token expired. (f)"); 230 | 231 | user.setPassword(passwordEncoder.encode(newPassword)); 232 | 233 | // Clear the now no longer useful tokens. 234 | userValidation.setPasswordResetIssue(null); 235 | userValidation.setPasswordResetToken(null); 236 | userValidationRepository.save(userValidation); 237 | 238 | return userRepository.save(user); 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # https://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Maven Start Up Batch script 23 | # 24 | # Required ENV vars: 25 | # ------------------ 26 | # JAVA_HOME - location of a JDK home dir 27 | # 28 | # Optional ENV vars 29 | # ----------------- 30 | # M2_HOME - location of maven2's installed home dir 31 | # MAVEN_OPTS - parameters passed to the Java VM when running Maven 32 | # e.g. to debug Maven itself, use 33 | # set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 34 | # MAVEN_SKIP_RC - flag to disable loading of mavenrc files 35 | # ---------------------------------------------------------------------------- 36 | 37 | if [ -z "$MAVEN_SKIP_RC" ] ; then 38 | 39 | if [ -f /etc/mavenrc ] ; then 40 | . /etc/mavenrc 41 | fi 42 | 43 | if [ -f "$HOME/.mavenrc" ] ; then 44 | . "$HOME/.mavenrc" 45 | fi 46 | 47 | fi 48 | 49 | # OS specific support. $var _must_ be set to either true or false. 50 | cygwin=false; 51 | darwin=false; 52 | mingw=false 53 | case "`uname`" in 54 | CYGWIN*) cygwin=true ;; 55 | MINGW*) mingw=true;; 56 | Darwin*) darwin=true 57 | # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home 58 | # See https://developer.apple.com/library/mac/qa/qa1170/_index.html 59 | if [ -z "$JAVA_HOME" ]; then 60 | if [ -x "/usr/libexec/java_home" ]; then 61 | export JAVA_HOME="`/usr/libexec/java_home`" 62 | else 63 | export JAVA_HOME="/Library/Java/Home" 64 | fi 65 | fi 66 | ;; 67 | esac 68 | 69 | if [ -z "$JAVA_HOME" ] ; then 70 | if [ -r /etc/gentoo-release ] ; then 71 | JAVA_HOME=`java-config --jre-home` 72 | fi 73 | fi 74 | 75 | if [ -z "$M2_HOME" ] ; then 76 | ## resolve links - $0 may be a link to maven's home 77 | PRG="$0" 78 | 79 | # need this for relative symlinks 80 | while [ -h "$PRG" ] ; do 81 | ls=`ls -ld "$PRG"` 82 | link=`expr "$ls" : '.*-> \(.*\)$'` 83 | if expr "$link" : '/.*' > /dev/null; then 84 | PRG="$link" 85 | else 86 | PRG="`dirname "$PRG"`/$link" 87 | fi 88 | done 89 | 90 | saveddir=`pwd` 91 | 92 | M2_HOME=`dirname "$PRG"`/.. 93 | 94 | # make it fully qualified 95 | M2_HOME=`cd "$M2_HOME" && pwd` 96 | 97 | cd "$saveddir" 98 | # echo Using m2 at $M2_HOME 99 | fi 100 | 101 | # For Cygwin, ensure paths are in UNIX format before anything is touched 102 | if $cygwin ; then 103 | [ -n "$M2_HOME" ] && 104 | M2_HOME=`cygpath --unix "$M2_HOME"` 105 | [ -n "$JAVA_HOME" ] && 106 | JAVA_HOME=`cygpath --unix "$JAVA_HOME"` 107 | [ -n "$CLASSPATH" ] && 108 | CLASSPATH=`cygpath --path --unix "$CLASSPATH"` 109 | fi 110 | 111 | # For Mingw, ensure paths are in UNIX format before anything is touched 112 | if $mingw ; then 113 | [ -n "$M2_HOME" ] && 114 | M2_HOME="`(cd "$M2_HOME"; pwd)`" 115 | [ -n "$JAVA_HOME" ] && 116 | JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" 117 | fi 118 | 119 | if [ -z "$JAVA_HOME" ]; then 120 | javaExecutable="`which javac`" 121 | if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then 122 | # readlink(1) is not available as standard on Solaris 10. 123 | readLink=`which readlink` 124 | if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then 125 | if $darwin ; then 126 | javaHome="`dirname \"$javaExecutable\"`" 127 | javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" 128 | else 129 | javaExecutable="`readlink -f \"$javaExecutable\"`" 130 | fi 131 | javaHome="`dirname \"$javaExecutable\"`" 132 | javaHome=`expr "$javaHome" : '\(.*\)/bin'` 133 | JAVA_HOME="$javaHome" 134 | export JAVA_HOME 135 | fi 136 | fi 137 | fi 138 | 139 | if [ -z "$JAVACMD" ] ; then 140 | if [ -n "$JAVA_HOME" ] ; then 141 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 142 | # IBM's JDK on AIX uses strange locations for the executables 143 | JAVACMD="$JAVA_HOME/jre/sh/java" 144 | else 145 | JAVACMD="$JAVA_HOME/bin/java" 146 | fi 147 | else 148 | JAVACMD="`which java`" 149 | fi 150 | fi 151 | 152 | if [ ! -x "$JAVACMD" ] ; then 153 | echo "Error: JAVA_HOME is not defined correctly." >&2 154 | echo " We cannot execute $JAVACMD" >&2 155 | exit 1 156 | fi 157 | 158 | if [ -z "$JAVA_HOME" ] ; then 159 | echo "Warning: JAVA_HOME environment variable is not set." 160 | fi 161 | 162 | CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher 163 | 164 | # traverses directory structure from process work directory to filesystem root 165 | # first directory with .mvn subdirectory is considered project base directory 166 | find_maven_basedir() { 167 | 168 | if [ -z "$1" ] 169 | then 170 | echo "Path not specified to find_maven_basedir" 171 | return 1 172 | fi 173 | 174 | basedir="$1" 175 | wdir="$1" 176 | while [ "$wdir" != '/' ] ; do 177 | if [ -d "$wdir"/.mvn ] ; then 178 | basedir=$wdir 179 | break 180 | fi 181 | # workaround for JBEAP-8937 (on Solaris 10/Sparc) 182 | if [ -d "${wdir}" ]; then 183 | wdir=`cd "$wdir/.."; pwd` 184 | fi 185 | # end of workaround 186 | done 187 | echo "${basedir}" 188 | } 189 | 190 | # concatenates all lines of a file 191 | concat_lines() { 192 | if [ -f "$1" ]; then 193 | echo "$(tr -s '\n' ' ' < "$1")" 194 | fi 195 | } 196 | 197 | BASE_DIR=`find_maven_basedir "$(pwd)"` 198 | if [ -z "$BASE_DIR" ]; then 199 | exit 1; 200 | fi 201 | 202 | ########################################################################################## 203 | # Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 204 | # This allows using the maven wrapper in projects that prohibit checking in binary data. 205 | ########################################################################################## 206 | if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then 207 | if [ "$MVNW_VERBOSE" = true ]; then 208 | echo "Found .mvn/wrapper/maven-wrapper.jar" 209 | fi 210 | else 211 | if [ "$MVNW_VERBOSE" = true ]; then 212 | echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." 213 | fi 214 | if [ -n "$MVNW_REPOURL" ]; then 215 | jarUrl="$MVNW_REPOURL/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" 216 | else 217 | jarUrl="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" 218 | fi 219 | while IFS="=" read key value; do 220 | case "$key" in (wrapperUrl) jarUrl="$value"; break ;; 221 | esac 222 | done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" 223 | if [ "$MVNW_VERBOSE" = true ]; then 224 | echo "Downloading from: $jarUrl" 225 | fi 226 | wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" 227 | if $cygwin; then 228 | wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` 229 | fi 230 | 231 | if command -v wget > /dev/null; then 232 | if [ "$MVNW_VERBOSE" = true ]; then 233 | echo "Found wget ... using wget" 234 | fi 235 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 236 | wget "$jarUrl" -O "$wrapperJarPath" 237 | else 238 | wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" 239 | fi 240 | elif command -v curl > /dev/null; then 241 | if [ "$MVNW_VERBOSE" = true ]; then 242 | echo "Found curl ... using curl" 243 | fi 244 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 245 | curl -o "$wrapperJarPath" "$jarUrl" -f 246 | else 247 | curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f 248 | fi 249 | 250 | else 251 | if [ "$MVNW_VERBOSE" = true ]; then 252 | echo "Falling back to using Java to download" 253 | fi 254 | javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" 255 | # For Cygwin, switch paths to Windows format before running javac 256 | if $cygwin; then 257 | javaClass=`cygpath --path --windows "$javaClass"` 258 | fi 259 | if [ -e "$javaClass" ]; then 260 | if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then 261 | if [ "$MVNW_VERBOSE" = true ]; then 262 | echo " - Compiling MavenWrapperDownloader.java ..." 263 | fi 264 | # Compiling the Java class 265 | ("$JAVA_HOME/bin/javac" "$javaClass") 266 | fi 267 | if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then 268 | # Running the downloader 269 | if [ "$MVNW_VERBOSE" = true ]; then 270 | echo " - Running MavenWrapperDownloader.java ..." 271 | fi 272 | ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") 273 | fi 274 | fi 275 | fi 276 | fi 277 | ########################################################################################## 278 | # End of extension 279 | ########################################################################################## 280 | 281 | export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} 282 | if [ "$MVNW_VERBOSE" = true ]; then 283 | echo $MAVEN_PROJECTBASEDIR 284 | fi 285 | MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" 286 | 287 | # For Cygwin, switch paths to Windows format before running java 288 | if $cygwin; then 289 | [ -n "$M2_HOME" ] && 290 | M2_HOME=`cygpath --path --windows "$M2_HOME"` 291 | [ -n "$JAVA_HOME" ] && 292 | JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` 293 | [ -n "$CLASSPATH" ] && 294 | CLASSPATH=`cygpath --path --windows "$CLASSPATH"` 295 | [ -n "$MAVEN_PROJECTBASEDIR" ] && 296 | MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` 297 | fi 298 | 299 | # Provide a "standardized" way to retrieve the CLI args that will 300 | # work with both Windows and non-Windows executions. 301 | MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" 302 | export MAVEN_CMD_LINE_ARGS 303 | 304 | WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 305 | 306 | exec "$JAVACMD" \ 307 | $MAVEN_OPTS \ 308 | -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ 309 | "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ 310 | ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" 311 | -------------------------------------------------------------------------------- /src/main/resources/static/public/input-catalog.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | HTML Input Gallery 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 16 |
17 |
18 |
Server Generated Messages
19 |
20 |
21 | 24 |
25 |
26 |
27 |
28 |

Core UI

29 |
30 | 31 |
32 |
33 |
34 | 35 | 36 |
37 |
38 | 39 | 40 |
41 |
42 |
43 |
44 | 47 |
48 |
49 |
50 | 53 | Clickable SVG Font Icon 54 |
55 |
56 |
57 | 60 |
61 |
62 | 65 |
66 |
67 | 70 |
71 |
72 |
73 |
74 | 75 |
76 |
77 | 78 |
79 |
80 | 81 |
82 |
83 |
84 |
85 | 86 |
87 |
88 | 94 |
95 |
96 |
97 |
98 | 99 |
100 |
101 | 108 |
109 |
110 |

Date & Time Controls

111 |
112 |
113 | 116 |
117 |
118 | 121 |
122 |
123 | 126 |
127 |
128 |

Special Input

129 |
130 | 131 | 132 |
133 |
134 | 135 |
136 |
137 | 138 | 139 |
140 |

Text Input

141 |
142 | 143 | 144 |
145 |
146 | 147 | 148 |
149 |

The controls below provide additional hints for built-in validation on desktop and mobile. On mobile some 150 | of these also will bring up special keyboards (e.g. Safari).

151 |
152 | 153 | 154 |
155 |
156 | 157 | 158 |
159 |
160 | 161 | 162 |
163 |

Unlike the other text input fields, this Search field uses a 500ms keyup delay to query the server.

164 |
165 | 166 | 167 |
168 |
169 | 170 | 171 |
172 |

Form Controls

173 |

The Reset button will return the form above to the state it was in when it was rendered. Note that Submit 174 | will not actually submit if the values above (e.g. email or URL) are invalid.

175 |
176 | 177 |
178 |
179 | 180 |
181 |
182 |
183 |
184 | 185 | 186 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | org.springframework.boot 7 | spring-boot-starter-parent 8 | 3.1.1 9 | 10 | 11 | com.devhow 12 | htmx-demo 13 | 0.0.1-SNAPSHOT 14 | htmx-demo 15 | Demonstration of htmx and Spring Boot 16 | https://www.devhow.com/ 17 | 18 | UTF-8 19 | UTF-8 20 | 17 21 | 22 | 23 | 24 | 25 | 26 | org.springframework.boot 27 | spring-boot-starter-security 28 | 29 | 30 | org.thymeleaf.extras 31 | thymeleaf-extras-springsecurity5 32 | 3.1.1.RELEASE 33 | 34 | 35 | org.springframework.security 36 | spring-security-test 37 | test 38 | 39 | 40 | 41 | 42 | org.springframework.boot 43 | spring-boot-starter-data-jpa 44 | 45 | 46 | com.h2database 47 | h2 48 | runtime 49 | 50 | 51 | 52 | org.springframework.boot 53 | spring-boot-starter-thymeleaf 54 | 55 | 56 | org.springframework.boot 57 | spring-boot-starter-web 58 | 59 | 60 | 61 | org.springframework.boot 62 | spring-boot-devtools 63 | runtime 64 | true 65 | 66 | 67 | 68 | 69 | com.github.javafaker 70 | javafaker 71 | 1.0.2 72 | 73 | 74 | 75 | 76 | com.j2html 77 | j2html 78 | 1.5.0 79 | 80 | 81 | 82 | 83 | com.github.jknack 84 | handlebars 85 | 4.2.0 86 | 87 | 88 | 89 | org.jetbrains 90 | annotations 91 | 22.0.0 92 | compile 93 | 94 | 95 | 96 | org.springframework.boot 97 | spring-boot-starter-mail 98 | 99 | 100 | 101 | 102 | nz.net.ultraq.thymeleaf 103 | thymeleaf-layout-dialect 104 | 105 | 106 | 107 | 108 | org.springframework.boot 109 | spring-boot-starter-test 110 | test 111 | 112 | 113 | org.junit.vintage 114 | junit-vintage-engine 115 | 116 | 117 | 118 | 119 | 120 | org.assertj 121 | assertj-core 122 | 3.20.2 123 | test 124 | 125 | 126 | 127 | 128 | org.webjars 129 | webjars-locator-core 130 | 131 | 132 | org.webjars 133 | bootstrap 134 | 5.1.0 135 | 136 | 137 | org.webjars.npm 138 | bootstrap-icons 139 | 1.5.0 140 | 141 | 142 | org.webjars.npm 143 | htmx.org 144 | 1.5.0 145 | 146 | 147 | org.webjars.npm 148 | hyperscript.org 149 | 0.8.1 150 | 151 | 152 | 153 | 154 | ${project.artifactId} 155 | 156 | 159 | 160 | 161 | 162 | 163 | org.apache.maven.plugins 164 | maven-site-plugin 165 | 3.9.1 166 | 167 | 168 | org.apache.maven.plugins 169 | maven-surefire-plugin 170 | 3.0.0-M5 171 | 172 | 173 | org.apache.maven.plugins 174 | maven-jar-plugin 175 | 3.2.0 176 | 177 | 178 | org.apache.maven.plugins 179 | maven-antrun-plugin 180 | 1.8 181 | 182 | 183 | org.apache.maven.plugins 184 | maven-assembly-plugin 185 | 3.3.0 186 | 187 | 188 | org.apache.maven.plugins 189 | maven-release-plugin 190 | 3.0.0-M1 191 | 192 | 193 | org.apache.maven.plugins 194 | maven-project-info-reports-plugin 195 | 3.1.1 196 | 197 | 198 | org.apache.maven.plugins 199 | maven-dependency-plugin 200 | 3.1.2 201 | 202 | 203 | org.apache.maven.plugins 204 | maven-clean-plugin 205 | 3.1.0 206 | 207 | 208 | org.apache.maven.plugins 209 | maven-resources-plugin 210 | 3.2.0 211 | 212 | 213 | org.apache.maven.plugins 214 | maven-deploy-plugin 215 | 3.0.0-M1 216 | 217 | 218 | org.apache.maven.plugins 219 | maven-install-plugin 220 | 3.0.0-M1 221 | 222 | 223 | org.apache.maven.plugins 224 | maven-failsafe-plugin 225 | 3.0.0-M5 226 | 227 | 228 | org.apache.maven.plugins 229 | maven-shade-plugin 230 | 3.2.4 231 | 232 | 233 | 234 | 235 | 236 | org.springframework.boot 237 | spring-boot-maven-plugin 238 | 239 | 240 | org.apache.maven.plugins 241 | maven-compiler-plugin 242 | 243 | ${java.version} 244 | 245 | 246 | 247 | org.apache.maven.plugins 248 | maven-resources-plugin 249 | 3.2.0 250 | 251 | ${project.build.sourceEncoding} 252 | 253 | 254 | 255 | 256 | 257 | src/main/resources 258 | true 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | org.apache.maven.plugins 268 | maven-project-info-reports-plugin 269 | 3.1.1 270 | 271 | 272 | 273 | dependencies 274 | index 275 | summary 276 | 277 | 278 | 279 | 280 | 281 | 282 | org.apache.maven.plugins 283 | maven-surefire-report-plugin 284 | 3.0.0-M5 285 | 286 | 287 | 288 | report-only 289 | 290 | 291 | 292 | 293 | false 294 | 295 | 296 | 297 | 298 | 299 | org.codehaus.mojo 300 | versions-maven-plugin 301 | 2.8.1 302 | 303 | 304 | 305 | dependency-updates-report 306 | plugin-updates-report 307 | property-updates-report 308 | 309 | 310 | 311 | 312 | false 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 323 | h2 324 | 325 | true 326 | 327 | 328 | 329 | 330 | org.apache.maven.plugins 331 | maven-surefire-plugin 332 | 3.0.0-M5 333 | 334 | 335 | h2 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | spring-boot-debug 345 | 346 | false 347 | 348 | 349 | 350 | 351 | org.springframework.boot 352 | spring-boot-maven-plugin 353 | 2.3.5.RELEASE 354 | 355 | 356 | 357 | org.springframework.boot 358 | spring-boot-configuration-processor 359 | 360 | 361 | 362 | -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005 363 | 364 | 365 | h2 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | --------------------------------------------------------------------------------