commit ae3eef06227d40cf1276b19e04e3484d2296d3bf Author: William Brawner Date: Thu May 5 21:19:54 2022 -0600 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f4c7f1 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Recipe App + +## Lit + +The bundled lit-core.min.js file comes from [here](https://lit.dev/docs/getting-started/#use-bundles). This is to avoid using npm or webjars in the build. \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..90d2a0d --- /dev/null +++ b/build.gradle @@ -0,0 +1,29 @@ +plugins { + id 'org.springframework.boot' version '2.6.7' + id 'io.spring.dependency-management' version '1.0.11.RELEASE' + id 'java' +} + +group = 'com.wbrawner' +version = '0.0.1-SNAPSHOT' +sourceCompatibility = '17' + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.session:spring-session-core' + compileOnly 'org.springframework.boot:spring-boot-devtools' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'de.flapdoodle.embed:de.flapdoodle.embed.mongo' + testImplementation 'io.projectreactor:reactor-test' + testImplementation 'org.springframework.security:spring-security-test' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5754dd3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +version: '3.8' +services: + db: + image: mongo + ports: + - "27017:27017" + environment: + MONGO_INITDB_ROOT_USERNAME: recipes + MONGO_INITDB_ROOT_PASSWORD: recipes diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..41d9927 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..00e33ed --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..a68aa28 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'recipes' diff --git a/src/main/java/com/wbrawner/recipes/RecipesApplication.java b/src/main/java/com/wbrawner/recipes/RecipesApplication.java new file mode 100644 index 0000000..46339c1 --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/RecipesApplication.java @@ -0,0 +1,21 @@ +package com.wbrawner.recipes; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.reactive.config.EnableWebFlux; + +@SpringBootApplication +public class RecipesApplication { + + public static void main(String[] args) { + SpringApplication.run(RecipesApplication.class, args); + } + + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/wbrawner/recipes/Utils.java b/src/main/java/com/wbrawner/recipes/Utils.java new file mode 100644 index 0000000..f117003 --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/Utils.java @@ -0,0 +1,29 @@ +package com.wbrawner.recipes; + +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Random; + +public final class Utils { + private Utils() {} + + private static final Random random; + + static { + try { + random = SecureRandom.getInstanceStrong(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + + public static String randomString(int length) { + var s = new StringBuilder(); + for (int i = 0; i < length; i++) { + s.append(CHARACTERS.charAt(random.nextInt(CHARACTERS.length()))); + } + return s.toString(); + } +} diff --git a/src/main/java/com/wbrawner/recipes/config/AuthenticationManager.java b/src/main/java/com/wbrawner/recipes/config/AuthenticationManager.java new file mode 100644 index 0000000..0426864 --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/config/AuthenticationManager.java @@ -0,0 +1,40 @@ +package com.wbrawner.recipes.config; + +import com.wbrawner.recipes.model.TokenAuthentication; +import com.wbrawner.recipes.repository.SessionRepository; +import com.wbrawner.recipes.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.RememberMeAuthenticationToken; +import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +@Component +public class AuthenticationManager extends UserDetailsRepositoryReactiveAuthenticationManager { + private final SessionRepository sessionRepository; + private final UserRepository userRepository; + + @Autowired + public AuthenticationManager(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder, SessionRepository sessionRepository, UserRepository userRepository) { + super(userDetailsService); + this.sessionRepository = sessionRepository; + this.userRepository = userRepository; + setPasswordEncoder(passwordEncoder); + } + + @Override + public Mono authenticate(Authentication authentication) { + if (authentication instanceof RememberMeAuthenticationToken) { + return Mono.just(authentication); + } else if (authentication instanceof TokenAuthentication) { + final var token = (String) authentication.getCredentials(); + return sessionRepository.findById(token) + .flatMap(session -> userRepository.findById(session.getUserId())) + .flatMap(user -> Mono.just(new TokenAuthentication(token, user))); + } else { + return super.authenticate(authentication); + } + } +} diff --git a/src/main/java/com/wbrawner/recipes/config/Config.java b/src/main/java/com/wbrawner/recipes/config/Config.java new file mode 100644 index 0000000..1436c0b --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/config/Config.java @@ -0,0 +1,59 @@ +package com.wbrawner.recipes.config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.session.config.annotation.web.server.EnableSpringWebSession; +import org.springframework.web.reactive.config.EnableWebFlux; +import org.springframework.web.reactive.config.WebFluxConfigurer; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; + +@EnableWebFluxSecurity +@EnableWebFlux +@Configuration +public class Config implements WebFluxConfigurer { + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + private final AuthenticationManager authenticationManager; + private final UserDetailsService userDetailsService; + private final SecurityContextRepository securityContextRepository; + + @Autowired + public Config(AuthenticationManager authenticationManager, UserDetailsService userDetailsService, SecurityContextRepository securityContextRepository) { + this.authenticationManager = authenticationManager; + this.userDetailsService = userDetailsService; + this.securityContextRepository = securityContextRepository; + } + + @Bean + RouterFunction staticResourceRouter() { + logger.info("created staticResourceRouter"); + return RouterFunctions.resources("/**", new ClassPathResource("static/")); + } + + @Bean + public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { + return http.authorizeExchange() + .pathMatchers("/api/users/login", "/api/users/register") + .permitAll() + .pathMatchers("/api/**") + .authenticated() + .anyExchange() + .permitAll() + .and() + .csrf() + .disable() + .authenticationManager(authenticationManager) + .securityContextRepository(securityContextRepository) + .build(); + } +} diff --git a/src/main/java/com/wbrawner/recipes/config/SecurityContextRepository.java b/src/main/java/com/wbrawner/recipes/config/SecurityContextRepository.java new file mode 100644 index 0000000..f0c2463 --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/config/SecurityContextRepository.java @@ -0,0 +1,39 @@ +package com.wbrawner.recipes.config; + +import com.wbrawner.recipes.model.TokenAuthentication; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.web.server.context.ServerSecurityContextRepository; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +@Component +public class SecurityContextRepository implements ServerSecurityContextRepository { + private final ReactiveAuthenticationManager authenticationManager; + + @Autowired + public SecurityContextRepository(ReactiveAuthenticationManager authenticationManager) { + this.authenticationManager = authenticationManager; + } + + @Override + public Mono save(ServerWebExchange exchange, SecurityContext context) { + return Mono.empty(); + } + + @Override + public Mono load(ServerWebExchange exchange) { + return Mono.justOrEmpty(exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION)) + .filter(header -> header.startsWith("Bearer ")) + .flatMap(header -> { + var token = header.substring(7); + var tokenAuth = new TokenAuthentication(token); + return authenticationManager.authenticate(tokenAuth) + .map(SecurityContextImpl::new); + }); + } +} diff --git a/src/main/java/com/wbrawner/recipes/config/UserDetailsService.java b/src/main/java/com/wbrawner/recipes/config/UserDetailsService.java new file mode 100644 index 0000000..e0c561a --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/config/UserDetailsService.java @@ -0,0 +1,26 @@ +package com.wbrawner.recipes.config; + +import com.wbrawner.recipes.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +@Component +public class UserDetailsService implements ReactiveUserDetailsService { + private final UserRepository userRepository; + + @Autowired + public UserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public Mono findByUsername(String username) { + return userRepository.findByUsernameIgnoreCase(username) + .switchIfEmpty(userRepository.findByEmailIgnoreCase(username)) + .map(user -> user); + } +} + diff --git a/src/main/java/com/wbrawner/recipes/controller/ImageController.java b/src/main/java/com/wbrawner/recipes/controller/ImageController.java new file mode 100644 index 0000000..aae67d1 --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/controller/ImageController.java @@ -0,0 +1,71 @@ +package com.wbrawner.recipes.controller; + +import com.wbrawner.recipes.model.ImageResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.codec.multipart.FilePart; +import org.springframework.security.access.annotation.Secured; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +@Secured("USER") +@RestController +@RequestMapping("/api/images") +public class ImageController { + private final File imagesDir; + private static final List imageExtensions = Arrays.asList( + "png", + "jpg", + "jpeg" + ); + + @Autowired + public ImageController(@Value("${recipes.storage.images}") String imagesDir) { + this.imagesDir = new File(imagesDir); + if (!this.imagesDir.exists() && !this.imagesDir.mkdirs()) { + throw new RuntimeException("Unable to create images dir: " + imagesDir); + } + if (!this.imagesDir.canRead()) { + throw new RuntimeException("Unable to read images dir: " + imagesDir); + } + if (!this.imagesDir.canWrite()) { + throw new RuntimeException("Unable to write to images dir: " + imagesDir); + } + } + + @PostMapping + public Mono saveImage(@RequestParam("image") FilePart image) { + if (image == null) { + return ServerResponse.badRequest().bodyValue("No image provided"); + } + var imageParts = image.filename().split("\\."); + if (!imageExtensions.contains(imageParts[imageParts.length - 1])) { + return ServerResponse.badRequest().bodyValue("File must be an image"); + } + var imageId = UUID.randomUUID().toString(); + File imageFile = new File(imagesDir, imageId); + return image.transferTo(imageFile) + .then(ServerResponse.ok().bodyValue(new ImageResponse(imageId))); + } + + @DeleteMapping("/{id}") + public Mono deleteImage(@PathVariable("id") String imageId) { + return Mono.fromRunnable(() -> { + try { + var imageFile = new File(imagesDir, imageId); + //noinspection BlockingMethodInNonBlockingContext + Files.deleteIfExists(imageFile.toPath()); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } +} diff --git a/src/main/java/com/wbrawner/recipes/controller/RecipeController.java b/src/main/java/com/wbrawner/recipes/controller/RecipeController.java new file mode 100644 index 0000000..8f2a194 --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/controller/RecipeController.java @@ -0,0 +1,63 @@ +package com.wbrawner.recipes.controller; + +import com.wbrawner.recipes.model.Recipe; +import com.wbrawner.recipes.model.RecipeRequest; +import com.wbrawner.recipes.repository.RecipeRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.annotation.Secured; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Secured("USER") +@RestController +@RequestMapping("/api/recipes") +public class RecipeController { + private final RecipeRepository recipeRepository; + + @Autowired + public RecipeController(RecipeRepository recipeRepository) { + this.recipeRepository = recipeRepository; + } + + @GetMapping + @ResponseBody + public Flux getRecipes() { + return recipeRepository.findAll(); + } + + @PostMapping + @ResponseBody + public Mono> newRecipe(@RequestBody RecipeRequest request) { + if (request.name() == null || request.name().isBlank()) { + return Mono.just(ResponseEntity.badRequest().body("name is required")); + } + return recipeRepository.save(Recipe.from(request)) + .map(recipe -> ResponseEntity.ok().body(recipe)); + } + + @PutMapping("/{id}") + @ResponseBody + public Mono> updateRecipe( + @PathVariable(value = "id", required = false) String id, + @RequestBody RecipeRequest request + ) { + return recipeRepository.existsById(id) + .flatMap(exists -> { + if (!exists) { + return Mono.just(ResponseEntity.notFound().build()); + } + if (request.name() == null || request.name().isBlank()) { + return Mono.just(ResponseEntity.badRequest().body("name is required")); + } + return recipeRepository.save(Recipe.from(request, id)) + .map(ResponseEntity::ok); + }); + } + + @DeleteMapping("/{id}") + public Mono deleteRecipe(@PathVariable("id") String id) { + return recipeRepository.deleteById(id); + } +} diff --git a/src/main/java/com/wbrawner/recipes/controller/UserController.java b/src/main/java/com/wbrawner/recipes/controller/UserController.java new file mode 100644 index 0000000..cd670a0 --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/controller/UserController.java @@ -0,0 +1,79 @@ +package com.wbrawner.recipes.controller; + +import com.wbrawner.recipes.model.*; +import com.wbrawner.recipes.repository.SessionRepository; +import com.wbrawner.recipes.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/api/users") +public class UserController { + private final Logger logger = LoggerFactory.getLogger(getClass()); + private final ReactiveAuthenticationManager authenticationManager; + private final SessionRepository sessionRepository; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Autowired + public UserController(ReactiveAuthenticationManager authenticationManager, SessionRepository sessionRepository, UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.authenticationManager = authenticationManager; + this.sessionRepository = sessionRepository; + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + @GetMapping("me") + public Mono me() { + return ReactiveSecurityContextHolder.getContext() + .map(context -> (User) context.getAuthentication().getPrincipal()) + .map(UserResponse::of); + } + + @PostMapping("login") + public Mono> login(@RequestBody LoginRequest request) { + return authenticationManager.authenticate(request.authentication()) + .doOnSuccess(authentication -> SecurityContextHolder.getContext().setAuthentication(authentication)) + .doOnError(throwable -> logger.error("Login failed for " + request.username(), throwable)) + .flatMap(authentication -> { + var user = (User) authentication.getPrincipal(); + var session = new Session(user.getId()); + return sessionRepository.save(session); + }) + .map(ResponseEntity::ok) + .onErrorMap(t -> { + logger.error("Post-login session persistence failed for " + request.username(), t); + return new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Login failed", t); + }); + } + + @PostMapping("register") + public Mono> register(@RequestBody RegisterRequest request) { + return userRepository.save(request.user(passwordEncoder)) + .doOnError(throwable -> logger.error("Registration failed for " + request.username(), throwable)) + .flatMap(user -> sessionRepository.save(new Session(user.getId()))) + .doOnError(throwable -> logger.error("Session creation failed for " + request.username(), throwable)) + .map(ResponseEntity::ok) + .doOnError(throwable -> logger.error("Failed to map session to response entity for user " + request.username(), throwable)) + .onErrorMap(t -> { + logger.error("Registration failed", t); + var message = "Registration failed"; + if (t instanceof DuplicateKeyException) { + message += ": username or email already taken"; + } + return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + }) + .doOnError(throwable -> logger.error("Other registration error for " + request.username(), throwable)); + } +} diff --git a/src/main/java/com/wbrawner/recipes/filter/SinglePageApplicationFilter.java b/src/main/java/com/wbrawner/recipes/filter/SinglePageApplicationFilter.java new file mode 100644 index 0000000..9b79a3e --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/filter/SinglePageApplicationFilter.java @@ -0,0 +1,26 @@ +package com.wbrawner.recipes.filter; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +import java.util.List; + +@Component +public class SinglePageApplicationFilter implements WebFilter { + private final Logger logger = LoggerFactory.getLogger(getClass()); + private static final List staticRoutes = List.of("/", "/login", "/register"); + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + var path = exchange.getRequest().getURI().getPath(); + if (staticRoutes.contains(path) || path.startsWith("/recipes")) { + return chain.filter(exchange.mutate().request(exchange.getRequest().mutate().path("/index.html").build()).build()); + } + return chain.filter(exchange); + } +} diff --git a/src/main/java/com/wbrawner/recipes/model/ImageResponse.java b/src/main/java/com/wbrawner/recipes/model/ImageResponse.java new file mode 100644 index 0000000..94f20f6 --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/model/ImageResponse.java @@ -0,0 +1,4 @@ +package com.wbrawner.recipes.model; + +public record ImageResponse(String imageId) { +} diff --git a/src/main/java/com/wbrawner/recipes/model/LoginRequest.java b/src/main/java/com/wbrawner/recipes/model/LoginRequest.java new file mode 100644 index 0000000..38db737 --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/model/LoginRequest.java @@ -0,0 +1,9 @@ +package com.wbrawner.recipes.model; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; + +public record LoginRequest(String username, String password) { + public UsernamePasswordAuthenticationToken authentication() { + return new UsernamePasswordAuthenticationToken(username, password); + } +} diff --git a/src/main/java/com/wbrawner/recipes/model/Recipe.java b/src/main/java/com/wbrawner/recipes/model/Recipe.java new file mode 100644 index 0000000..f5ac42e --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/model/Recipe.java @@ -0,0 +1,93 @@ +package com.wbrawner.recipes.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.Collections; +import java.util.List; + +@Document +public class Recipe { + @Id + private final String id; + private final String name; + private final String description; + private final List images; + private final List ingredients; + private final List instructions; + private final int prepTime; + private final int cookTime; + + public Recipe() { + this(null, "", null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), 0, 0); + } + + public Recipe( + String id, + String name, + String description, + List images, + List ingredients, + List instructions, + int prepTime, + int cookTime + ) { + this.id = id; + this.name = name; + this.description = description; + this.images = images; + this.ingredients = ingredients; + this.instructions = instructions; + this.prepTime = prepTime; + this.cookTime = cookTime; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public List getImages() { + return images; + } + + public List getIngredients() { + return ingredients; + } + + public List getInstructions() { + return instructions; + } + + public int getPrepTime() { + return prepTime; + } + + public int getCookTime() { + return cookTime; + } + + public static Recipe from(RecipeRequest request) { + return from(request, null); + } + + public static Recipe from(RecipeRequest request, String id) { + return new Recipe( + id, + request.name(), + request.description(), + request.images(), + request.ingredients(), + request.instructions(), + request.prepTime(), + request.cookTime() + ); + } +} diff --git a/src/main/java/com/wbrawner/recipes/model/RecipeRequest.java b/src/main/java/com/wbrawner/recipes/model/RecipeRequest.java new file mode 100644 index 0000000..2e372d7 --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/model/RecipeRequest.java @@ -0,0 +1,14 @@ +package com.wbrawner.recipes.model; + +import java.util.List; + +public record RecipeRequest( + String name, + String description, + List images, + List ingredients, + List instructions, + int prepTime, + int cookTime +) { +} diff --git a/src/main/java/com/wbrawner/recipes/model/RegisterRequest.java b/src/main/java/com/wbrawner/recipes/model/RegisterRequest.java new file mode 100644 index 0000000..cd0556c --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/model/RegisterRequest.java @@ -0,0 +1,14 @@ +package com.wbrawner.recipes.model; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.crypto.password.PasswordEncoder; + +public record RegisterRequest(String username, String password, String email) { + public UsernamePasswordAuthenticationToken authentication() { + return new UsernamePasswordAuthenticationToken(username, password); + } + + public User user(PasswordEncoder passwordEncoder) { + return new User(username, passwordEncoder.encode(password), email); + } +} diff --git a/src/main/java/com/wbrawner/recipes/model/Session.java b/src/main/java/com/wbrawner/recipes/model/Session.java new file mode 100644 index 0000000..c394f33 --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/model/Session.java @@ -0,0 +1,45 @@ +package com.wbrawner.recipes.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +import static com.wbrawner.recipes.Utils.randomString; + +@Document +public class Session { + @Id + private String token; + private String userId; + private Instant expiration; + + public Session() { + // Needed for Spring Data, shouldn't be called otherwise + } + + public Session(String userId) { + this.token = randomString(64); + this.userId = userId; + this.expiration = Instant.now().plus(14, ChronoUnit.DAYS); + } + + public Session(String token, String userId, Instant expiration) { + this.token = token; + this.userId = userId; + this.expiration = expiration; + } + + public String getToken() { + return token; + } + + public String getUserId() { + return userId; + } + + public Instant getExpiration() { + return expiration; + } +} \ No newline at end of file diff --git a/src/main/java/com/wbrawner/recipes/model/TokenAuthentication.java b/src/main/java/com/wbrawner/recipes/model/TokenAuthentication.java new file mode 100644 index 0000000..7e065af --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/model/TokenAuthentication.java @@ -0,0 +1,32 @@ +package com.wbrawner.recipes.model; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.List; + +public class TokenAuthentication extends AbstractAuthenticationToken { + private final String token; + private final User user; + + public TokenAuthentication(String token) { + this(token, null); + } + + public TokenAuthentication(String token, User user) { + super(List.of(new SimpleGrantedAuthority("USER"))); + this.token = token; + this.user = user; + setAuthenticated(user != null); + } + + @Override + public Object getCredentials() { + return token; + } + + @Override + public Object getPrincipal() { + return user; + } +} diff --git a/src/main/java/com/wbrawner/recipes/model/User.java b/src/main/java/com/wbrawner/recipes/model/User.java new file mode 100644 index 0000000..510d986 --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/model/User.java @@ -0,0 +1,116 @@ +package com.wbrawner.recipes.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Set; + +import static com.wbrawner.recipes.Utils.randomString; + +@Document +public class User implements UserDetails { + @Id + private String id; + @Indexed(unique=true) + private String username; + private String password; + @Indexed(unique=true) + private String email; + private Set authorities; + private boolean accountExpired = false; + private boolean credentialsExpired = false; + private boolean enabled = false; + private boolean locked = false; + + public User() { + // Needed for mongo + } + + public User( + String username, + String password, + String email + ) { + this( + randomString(32), + username, + password, + email, + Set.of(new SimpleGrantedAuthority("USER")), + false, + false, + true, + false + ); + } + + public User( + String id, + String username, + String password, + String email, + Set authorities, + boolean accountExpired, + boolean credentialsExpired, + boolean enabled, + boolean locked + ) { + this.id = id; + this.username = username; + this.password = password; + this.email = email; + this.authorities = authorities; + this.accountExpired = accountExpired; + this.credentialsExpired = credentialsExpired; + this.enabled = enabled; + this.locked = locked; + } + + public String getId() { + return id; + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public boolean isAccountNonExpired() { + return !accountExpired; + } + + @Override + public boolean isAccountNonLocked() { + return !locked; + } + + @Override + public boolean isCredentialsNonExpired() { + return !credentialsExpired; + } + + @Override + public boolean isEnabled() { + return enabled; + } + + public String getEmail() { + return email; + } +} diff --git a/src/main/java/com/wbrawner/recipes/model/UserResponse.java b/src/main/java/com/wbrawner/recipes/model/UserResponse.java new file mode 100644 index 0000000..ba230e2 --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/model/UserResponse.java @@ -0,0 +1,7 @@ +package com.wbrawner.recipes.model; + +public record UserResponse(String id, String username, String email) { + public static UserResponse of(User user) { + return new UserResponse(user.getId(), user.getUsername(), user.getEmail()); + } +} diff --git a/src/main/java/com/wbrawner/recipes/repository/RecipeRepository.java b/src/main/java/com/wbrawner/recipes/repository/RecipeRepository.java new file mode 100644 index 0000000..49cba25 --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/repository/RecipeRepository.java @@ -0,0 +1,7 @@ +package com.wbrawner.recipes.repository; + +import com.wbrawner.recipes.model.Recipe; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; + +public interface RecipeRepository extends ReactiveCrudRepository { +} diff --git a/src/main/java/com/wbrawner/recipes/repository/SessionRepository.java b/src/main/java/com/wbrawner/recipes/repository/SessionRepository.java new file mode 100644 index 0000000..f06b7fb --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/repository/SessionRepository.java @@ -0,0 +1,8 @@ +package com.wbrawner.recipes.repository; + +import com.wbrawner.recipes.model.Session; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Mono; + +public interface SessionRepository extends ReactiveCrudRepository { +} diff --git a/src/main/java/com/wbrawner/recipes/repository/UserRepository.java b/src/main/java/com/wbrawner/recipes/repository/UserRepository.java new file mode 100644 index 0000000..05a601b --- /dev/null +++ b/src/main/java/com/wbrawner/recipes/repository/UserRepository.java @@ -0,0 +1,11 @@ +package com.wbrawner.recipes.repository; + +import com.wbrawner.recipes.model.Recipe; +import com.wbrawner.recipes.model.User; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import reactor.core.publisher.Mono; + +public interface UserRepository extends ReactiveCrudRepository { + Mono findByUsernameIgnoreCase(String username); + Mono findByEmailIgnoreCase(String email); +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..0049094 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,7 @@ +spring.data.mongodb.host=localhost +spring.data.mongodb.database=recipes +spring.data.mongodb.password=recipes +spring.data.mongodb.username=recipes +spring.data.mongodb.authentication-database = admin +spring.data.mongodb.auto-index-creation=true +recipes.storage.images=images diff --git a/src/main/resources/static/css/style.css b/src/main/resources/static/css/style.css new file mode 100644 index 0000000..3c5e78c --- /dev/null +++ b/src/main/resources/static/css/style.css @@ -0,0 +1,5 @@ +html, body { + margin: 0; + padding: 0; + font-family: "Segoe UI", "Roboto Regular", "San Fransisco", sans-serif; +} \ No newline at end of file diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..8b7d7c7 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,14 @@ + + + + Recipes + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/static/js/actions/authentication.js b/src/main/resources/static/js/actions/authentication.js new file mode 100644 index 0000000..4537fb4 --- /dev/null +++ b/src/main/resources/static/js/actions/authentication.js @@ -0,0 +1,32 @@ +import {Session} from "../model/session.js" +import {User} from "../model/user.js" + +export class Action { + constructor(name) { + this.name = name; + } +} +export const ACTION_VIEW_RECIPES = "view-recipes"; +export const ACTION_VIEW_LOGIN = "view-login"; +export const ACTION_VIEW_REGISTER = "view-register"; +export const ACTION_LOGIN = 'login' +export const ACTION_LOGOUT = 'logout' +export const ACTION_REGISTER = 'register' + +export class LoginAction extends Action { + constructor(username, password) { + super(ACTION_LOGIN); + this.username = username; + this.password = password; + } +} + +export class RegisterAction extends Action { + constructor(username, email, password, confirmPassword) { + super(ACTION_REGISTER); + this.username = username; + this.email = email; + this.password = password; + this.confirmPassword = confirmPassword; + } +} diff --git a/src/main/resources/static/js/api-service.js b/src/main/resources/static/js/api-service.js new file mode 100644 index 0000000..454d4a5 --- /dev/null +++ b/src/main/resources/static/js/api-service.js @@ -0,0 +1,50 @@ +class ApiService { + /** + * @type string + */ + sessionToken; + + async login(username, password) { + return this.#request('/api/users/login', 'POST', { + username: username, + password: password + }) + } + + async getProfile() { + return this.#request('/api/users/me') + } + + async getRecipes() { + return this.#request('/api/recipes') + } + + async #request(url, method, body) { + let headers = {} + if (this.sessionToken) { + headers['Authorization'] = `Bearer ${this.sessionToken}` + } + if (body && typeof body !== "string") { + body = JSON.stringify(body) + headers['Content-Type'] = 'application/json' + } + return fetch(url, { + method: method, + headers: headers, + body: body + }).then(async response => { + if (response.status === 401) { + console.error('401', await response.json()) + throw new Error('Invalid credentials'); + } + if (!response.ok) { + const errorResponse = await response.json() + console.error('Request failed', errorResponse) + throw new Error(errorResponse.message); + } + return await response.json() + }) + } +} + +export const apiService = new ApiService(); \ No newline at end of file diff --git a/src/main/resources/static/js/components/animated-loader.js b/src/main/resources/static/js/components/animated-loader.js new file mode 100644 index 0000000..700c2c3 --- /dev/null +++ b/src/main/resources/static/js/components/animated-loader.js @@ -0,0 +1,16 @@ +import {css, html, LitElement} from "../lit-core.min.js"; + +export class AnimatedLoader extends LitElement { + static get styles() { + return css` + ` + } + + render() { + return html` +

Loading...

+ ` + } +} + +customElements.define('animated-loader', AnimatedLoader) \ No newline at end of file diff --git a/src/main/resources/static/js/components/header.js b/src/main/resources/static/js/components/header.js new file mode 100644 index 0000000..17923f8 --- /dev/null +++ b/src/main/resources/static/js/components/header.js @@ -0,0 +1,37 @@ +import {css, html} from "../lit-core.min.js"; +import {StatefulElement} from "./stateful.js"; +import {Action, ACTION_LOGOUT} from "../actions/authentication.js"; + +export class Header extends StatefulElement { + static get styles() { + return css` + header { + background: orange; + padding: 1em; + } + + h1 { + display: inline-block; + margin: 0; + } ` + } + + #logout = (e) => this.dispatch(new Action(ACTION_LOGOUT)) + + #logoutButton() { + if (!this.state.user) { + return null + } + return html` ` + } + + render() { + return html` +
+

${this.state?.route?.title || 'Recipes'}

+ ${this.#logoutButton()} +
` + } +} + +customElements.define('app-header', Header) \ No newline at end of file diff --git a/src/main/resources/static/js/components/login.js b/src/main/resources/static/js/components/login.js new file mode 100644 index 0000000..1b118ef --- /dev/null +++ b/src/main/resources/static/js/components/login.js @@ -0,0 +1,84 @@ +import {html} from "../lit-core.min.js"; +import {StatefulElement} from "./stateful.js"; +import {LoginAction} from "../actions/authentication.js"; + +export class LoginForm extends StatefulElement { + static get properties() { + return { + registration: {type: Boolean}, + loading: {type: Boolean}, + error: {type: String}, + username: {type: String}, + email: {type: String}, + password: {type: String}, + confirmPassword: {type: String} + } + } + + constructor() { + super(); + this.username = ''; + this.email = ''; + this.password = ''; + this.confirmPassword = ''; + } + + #login(e) { + e.preventDefault() + this.dispatch(new LoginAction(this.username, this.password)) + } + + #toggleRegistration() { + this.registration = !this.registration; + } + + #email() { + if (!this.registration) { + return null; + } + return html`` + } + + #confirmPassword() { + if (!this.registration) { + return null; + } + return html`` + } + + #toggleButton() { + let message = "Need to create an account?" + let action = "Register" + if (this.registration) { + message = "Already have an account?" + action = "Login" + } + return html`${message} + ` + } + + render() { + if (this.state.loading) { + return html` + ` + } + const error = this.error ? html`

${this.error}

` : null + return html` +
+ ${error} + + ${this.#email()} + + ${this.#confirmPassword()} + +
+ ${this.#toggleButton()} + ` + } +} + +customElements.define('login-form', LoginForm) \ No newline at end of file diff --git a/src/main/resources/static/js/components/recipe-app.js b/src/main/resources/static/js/components/recipe-app.js new file mode 100644 index 0000000..26bf4d0 --- /dev/null +++ b/src/main/resources/static/js/components/recipe-app.js @@ -0,0 +1,18 @@ +import {html} from '../lit-core.min.js' +import {StatefulElement} from "./stateful.js"; +import {Header} from "./header.js"; +import {SplashScreen} from "./splash.js"; +import {Router} from "./router.js"; + +export class RecipeApp extends StatefulElement { + render() { + console.log('recipe-app state', this.state) + return html` + + + + ` + } +} + +customElements.define('recipe-app', RecipeApp) \ No newline at end of file diff --git a/src/main/resources/static/js/components/recipe-list.js b/src/main/resources/static/js/components/recipe-list.js new file mode 100644 index 0000000..ac2db81 --- /dev/null +++ b/src/main/resources/static/js/components/recipe-list.js @@ -0,0 +1,41 @@ +import {css, html, LitElement} from "../lit-core.min.js"; +import {AnimatedLoader} from "./animated-loader.js"; +import {User} from "../model/user.js"; +import {StatefulElement} from "./stateful.js"; + +export class RecipeList extends StatefulElement { + static get styles() { + return css` + + ` + } + + render() { + if (this.state.loading || !this.state.recipes) { + return html` + ` + } + if (this.error) { + return html`

${this.error}

` + } + if (this.state.recipes?.length === 0) { + return html`

No recipes found

` + } + return html` +
    + ${this.state.recipes.map(recipe => { + html` +
  • + + ${recipe.name} + + + ${recipe.description} + +
  • ` + })} +
` + } +} + +customElements.define('recipe-list', RecipeList) \ No newline at end of file diff --git a/src/main/resources/static/js/components/register.js b/src/main/resources/static/js/components/register.js new file mode 100644 index 0000000..1034b3b --- /dev/null +++ b/src/main/resources/static/js/components/register.js @@ -0,0 +1,149 @@ +import {html} from "../lit-core.min.js"; +import {StatefulElement} from "./stateful.js"; +import {store} from "../store.js"; + +export class RegisterForm extends StatefulElement { + static get properties() { + return { + registration: {type: Boolean}, + loading: {type: Boolean}, + error: {type: String}, + username: {type: String}, + email: {type: String}, + password: {type: String}, + confirmPassword: {type: String} + } + } + + constructor() { + super(); + this.username = ''; + this.email = ''; + this.password = ''; + this.confirmPassword = ''; + } + + #toggleRegistration() { + this.registration = !this.registration; + } + + async #authenticate(e) { + e.preventDefault() + this.error = null; + this.loading = true; + if (this.registration && this.password !== this.confirmPassword) { + this.error = 'Passwords must match' + this.loading = false + return + } + const endpoint = this.registration ? 'register' : 'login' + let body = { + username: this.username, + password: this.password + } + if (this.registration) { + body = { + ...body, + email: this.email + } + } + try { + const response = await fetch(`/api/users/${endpoint}`, { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json' + } + }) + if (response.status === 401) { + console.error('401', await response.json()) + this.error = 'Invalid credentials' + return + } + if (!response.ok) { + const errorResponse = await response.json() + this.error = errorResponse.message + console.error('Request failed', errorResponse) + return + } + const session = await response.json() + console.log('session', session) + await this.#getProfile(session) + } catch (e) { + console.error(e) + this.error = e; + } finally { + this.loading = false; + } + } + + async #getProfile(session) { + const response = await fetch('/api/users/me', { + headers: { + 'Authorization': `Bearer ${session.token}` + } + }) + if (response.status === 401) { + this.error = 'Invalid credentials' + return + } + if (!response.ok) { + const errorResponse = await response.json() + this.error = errorResponse.message + console.error('Request failed', errorResponse) + return + } + const user = await response.json() + document.dispatchEvent(new AuthenticationEvent(session, user)) + } + + #email() { + if (!this.registration) { + return null; + } + return html`` + } + + #confirmPassword() { + if (!this.registration) { + return null; + } + return html`` + } + + #toggleButton() { + let message = "Need to create an account?" + let action = "Register" + if (this.registration) { + message = "Already have an account?" + action = "Login" + } + return html`${message} + ` + } + + render() { + if (this.loading) { + return html` + ` + } + const error = this.error ? html`

${this.error}

` : null + return html` +
+ ${error} + + ${this.#email()} + + ${this.#confirmPassword()} + +
+ ${this.#toggleButton()} + ` + } +} + +customElements.define('register-form', RegisterForm) \ No newline at end of file diff --git a/src/main/resources/static/js/components/router.js b/src/main/resources/static/js/components/router.js new file mode 100644 index 0000000..ceb3c33 --- /dev/null +++ b/src/main/resources/static/js/components/router.js @@ -0,0 +1,37 @@ +import {StatefulElement} from "./stateful.js"; +import {LoginForm} from "./login.js"; +import {RecipeList} from "./recipe-list.js"; +import {RegisterForm} from "./register.js"; +import {html, LitElement} from "../lit-core.min.js"; + +class Route { + constructor(title, component, path) { + this.title = title; + this.component = component; + this.path = path; + } +} + +export const routes = { + '/': new Route(null, html``, '/'), + '/login': new Route('Login', html``, '/login'), + '/register': new Route('Register', html``, '/register'), +}; + +export class Router extends StatefulElement { + onStateChange(state) { + super.onStateChange(state); + let title = 'Recipes' + if (state.route?.title) { + title += ' - ' + state.route.title + } + document.title = title; + window.history.pushState(null, null, state.route?.path) + } + + render() { + return this.state.route?.component + } +} + +customElements.define('app-router', Router) \ No newline at end of file diff --git a/src/main/resources/static/js/components/splash.js b/src/main/resources/static/js/components/splash.js new file mode 100644 index 0000000..3b9433f --- /dev/null +++ b/src/main/resources/static/js/components/splash.js @@ -0,0 +1,53 @@ +import {css, html, LitElement} from "../lit-core.min.js"; + +export class SplashScreen extends LitElement { + static get properties() { + return { + visible: {type: Boolean}, + hide: {type: Boolean} + } + } + + static get styles() { + return css` + .splash-bg { + width: 100vw; + height: 100vh; + box-sizing: border-box; + background: orange; + padding: 1em; + display: flex; + justify-content: center; + align-items: center; + position: absolute; + top: 0; + left: 0; + opacity: 1; + transition: all 0.25s ease; + } + + .fadeOut { + opacity: 0; + } + ` + } + + render() { + if (this.hide) { + return null + } + if (!this.visible) { + setTimeout(() => { + this.hide = true + }, 250) + } + return html` +
+ +

Recipes

+
+ ` + } +} + +customElements.define('splash-screen', SplashScreen) \ No newline at end of file diff --git a/src/main/resources/static/js/components/stateful.js b/src/main/resources/static/js/components/stateful.js new file mode 100644 index 0000000..9f0517e --- /dev/null +++ b/src/main/resources/static/js/components/stateful.js @@ -0,0 +1,35 @@ +import {LitElement} from "../lit-core.min.js"; +import {State, store} from "../store.js"; + +export const StatefulMixin = (superClass) => class extends superClass{ + + constructor() { + super(); + this.state = store.currentState() + } + + connectedCallback() { + super.connectedCallback(); + console.log('connected', this) + store.subscribe(this) + } + + disconnectedCallback() { + console.log('disconnected', this) + store.unsubscribe(this) + super.disconnectedCallback(); + } + + /** + * @param {Action} action + */ + dispatch(action) { + store.dispatch(action) + } + + onStateChange(state) { + this.state = state; + } +} + +export const StatefulElement = StatefulMixin(LitElement) \ No newline at end of file diff --git a/src/main/resources/static/js/lit-core.min.js b/src/main/resources/static/js/lit-core.min.js new file mode 100644 index 0000000..17be07a --- /dev/null +++ b/src/main/resources/static/js/lit-core.min.js @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const t=window.ShadowRoot&&(void 0===window.ShadyCSS||window.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,i=Symbol(),s=new Map;class e{constructor(t,s){if(this._$cssResult$=!0,s!==i)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t}get styleSheet(){let i=s.get(this.cssText);return t&&void 0===i&&(s.set(this.cssText,i=new CSSStyleSheet),i.replaceSync(this.cssText)),i}toString(){return this.cssText}}const n=t=>new e("string"==typeof t?t:t+"",i),o=(t,...s)=>{const n=1===t.length?t[0]:s.reduce(((i,s,e)=>i+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(s)+t[e+1]),t[0]);return new e(n,i)},h=(i,s)=>{t?i.adoptedStyleSheets=s.map((t=>t instanceof CSSStyleSheet?t:t.styleSheet)):s.forEach((t=>{const s=document.createElement("style"),e=window.litNonce;void 0!==e&&s.setAttribute("nonce",e),s.textContent=t.cssText,i.appendChild(s)}))},l=t?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let i="";for(const s of t.cssRules)i+=s.cssText;return n(i)})(t):t +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */;var r;const a=window.trustedTypes,u=a?a.emptyScript:"",d=window.reactiveElementPolyfillSupport,c={toAttribute(t,i){switch(i){case Boolean:t=t?u:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,i){let s=t;switch(i){case Boolean:s=null!==t;break;case Number:s=null===t?null:Number(t);break;case Object:case Array:try{s=JSON.parse(t)}catch(t){s=null}}return s}},v=(t,i)=>i!==t&&(i==i||t==t),p={attribute:!0,type:String,converter:c,reflect:!1,hasChanged:v};class f extends HTMLElement{constructor(){super(),this.t=new Map,this.isUpdatePending=!1,this.hasUpdated=!1,this.i=null,this.o()}static addInitializer(t){var i;null!==(i=this.l)&&void 0!==i||(this.l=[]),this.l.push(t)}static get observedAttributes(){this.finalize();const t=[];return this.elementProperties.forEach(((i,s)=>{const e=this.u(s,i);void 0!==e&&(this.g.set(e,s),t.push(e))})),t}static createProperty(t,i=p){if(i.state&&(i.attribute=!1),this.finalize(),this.elementProperties.set(t,i),!i.noAccessor&&!this.prototype.hasOwnProperty(t)){const s="symbol"==typeof t?Symbol():"__"+t,e=this.getPropertyDescriptor(t,s,i);void 0!==e&&Object.defineProperty(this.prototype,t,e)}}static getPropertyDescriptor(t,i,s){return{get(){return this[i]},set(e){const n=this[t];this[i]=e,this.requestUpdate(t,n,s)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)||p}static finalize(){if(this.hasOwnProperty("finalized"))return!1;this.finalized=!0;const t=Object.getPrototypeOf(this);if(t.finalize(),this.elementProperties=new Map(t.elementProperties),this.g=new Map,this.hasOwnProperty("properties")){const t=this.properties,i=[...Object.getOwnPropertyNames(t),...Object.getOwnPropertySymbols(t)];for(const s of i)this.createProperty(s,t[s])}return this.elementStyles=this.finalizeStyles(this.styles),!0}static finalizeStyles(t){const i=[];if(Array.isArray(t)){const s=new Set(t.flat(1/0).reverse());for(const t of s)i.unshift(l(t))}else void 0!==t&&i.push(l(t));return i}static u(t,i){const s=i.attribute;return!1===s?void 0:"string"==typeof s?s:"string"==typeof t?t.toLowerCase():void 0}o(){var t;this._=new Promise((t=>this.enableUpdating=t)),this._$AL=new Map,this.A(),this.requestUpdate(),null===(t=this.constructor.l)||void 0===t||t.forEach((t=>t(this)))}addController(t){var i,s;(null!==(i=this.U)&&void 0!==i?i:this.U=[]).push(t),void 0!==this.renderRoot&&this.isConnected&&(null===(s=t.hostConnected)||void 0===s||s.call(t))}removeController(t){var i;null===(i=this.U)||void 0===i||i.splice(this.U.indexOf(t)>>>0,1)}A(){this.constructor.elementProperties.forEach(((t,i)=>{this.hasOwnProperty(i)&&(this.t.set(i,this[i]),delete this[i])}))}createRenderRoot(){var t;const i=null!==(t=this.shadowRoot)&&void 0!==t?t:this.attachShadow(this.constructor.shadowRootOptions);return h(i,this.constructor.elementStyles),i}connectedCallback(){var t;void 0===this.renderRoot&&(this.renderRoot=this.createRenderRoot()),this.enableUpdating(!0),null===(t=this.U)||void 0===t||t.forEach((t=>{var i;return null===(i=t.hostConnected)||void 0===i?void 0:i.call(t)}))}enableUpdating(t){}disconnectedCallback(){var t;null===(t=this.U)||void 0===t||t.forEach((t=>{var i;return null===(i=t.hostDisconnected)||void 0===i?void 0:i.call(t)}))}attributeChangedCallback(t,i,s){this._$AK(t,s)}q(t,i,s=p){var e,n;const o=this.constructor.u(t,s);if(void 0!==o&&!0===s.reflect){const h=(null!==(n=null===(e=s.converter)||void 0===e?void 0:e.toAttribute)&&void 0!==n?n:c.toAttribute)(i,s.type);this.i=t,null==h?this.removeAttribute(o):this.setAttribute(o,h),this.i=null}}_$AK(t,i){var s,e,n;const o=this.constructor,h=o.g.get(t);if(void 0!==h&&this.i!==h){const t=o.getPropertyOptions(h),l=t.converter,r=null!==(n=null!==(e=null===(s=l)||void 0===s?void 0:s.fromAttribute)&&void 0!==e?e:"function"==typeof l?l:null)&&void 0!==n?n:c.fromAttribute;this.i=h,this[h]=r(i,t.type),this.i=null}}requestUpdate(t,i,s){let e=!0;void 0!==t&&(((s=s||this.constructor.getPropertyOptions(t)).hasChanged||v)(this[t],i)?(this._$AL.has(t)||this._$AL.set(t,i),!0===s.reflect&&this.i!==t&&(void 0===this.J&&(this.J=new Map),this.J.set(t,s))):e=!1),!this.isUpdatePending&&e&&(this._=this.K())}async K(){this.isUpdatePending=!0;try{await this._}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){var t;if(!this.isUpdatePending)return;this.hasUpdated,this.t&&(this.t.forEach(((t,i)=>this[i]=t)),this.t=void 0);let i=!1;const s=this._$AL;try{i=this.shouldUpdate(s),i?(this.willUpdate(s),null===(t=this.U)||void 0===t||t.forEach((t=>{var i;return null===(i=t.hostUpdate)||void 0===i?void 0:i.call(t)})),this.update(s)):this.G()}catch(t){throw i=!1,this.G(),t}i&&this._$AE(s)}willUpdate(t){}_$AE(t){var i;null===(i=this.U)||void 0===i||i.forEach((t=>{var i;return null===(i=t.hostUpdated)||void 0===i?void 0:i.call(t)})),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}G(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._}shouldUpdate(t){return!0}update(t){void 0!==this.J&&(this.J.forEach(((t,i)=>this.q(i,this[i],t))),this.J=void 0),this.G()}updated(t){}firstUpdated(t){}} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +var g;f.finalized=!0,f.elementProperties=new Map,f.elementStyles=[],f.shadowRootOptions={mode:"open"},null==d||d({ReactiveElement:f}),(null!==(r=globalThis.reactiveElementVersions)&&void 0!==r?r:globalThis.reactiveElementVersions=[]).push("1.3.2");const y=globalThis.trustedTypes,b=y?y.createPolicy("lit-html",{createHTML:t=>t}):void 0,w=`lit$${(Math.random()+"").slice(9)}$`,S="?"+w,$=`<${S}>`,m=document,C=(t="")=>m.createComment(t),_=t=>null===t||"object"!=typeof t&&"function"!=typeof t,A=Array.isArray,E=t=>{var i;return A(t)||"function"==typeof(null===(i=t)||void 0===i?void 0:i[Symbol.iterator])},T=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,U=/-->/g,x=/>/g,M=/>|[ \n \r](?:([^\s"'>=/]+)([ \n \r]*=[ \n \r]*(?:[^ \n \r"'`<>=]|("|')|))|$)/g,k=/'/g,R=/"/g,N=/^(?:script|style|textarea|title)$/i,O=t=>(i,...s)=>({_$litType$:t,strings:i,values:s}),L=O(1),j=O(2),z=Symbol.for("lit-noChange"),I=Symbol.for("lit-nothing"),P=new WeakMap,H=(t,i,s)=>{var e,n;const o=null!==(e=null==s?void 0:s.renderBefore)&&void 0!==e?e:i;let h=o._$litPart$;if(void 0===h){const t=null!==(n=null==s?void 0:s.renderBefore)&&void 0!==n?n:null;o._$litPart$=h=new K(i.insertBefore(C(),t),t,void 0,null!=s?s:{})}return h._$AI(t),h},B=m.createTreeWalker(m,129,null,!1),D=(t,i)=>{const s=t.length-1,e=[];let n,o=2===i?"":"",h=T;for(let i=0;i"===r[0]?(h=null!=n?n:T,a=-1):void 0===r[1]?a=-2:(a=h.lastIndex-r[2].length,l=r[1],h=void 0===r[3]?M:'"'===r[3]?R:k):h===R||h===k?h=M:h===U||h===x?h=T:(h=M,n=void 0);const d=h===M&&t[i+1].startsWith("/>")?" ":"";o+=h===T?s+$:a>=0?(e.push(l),s.slice(0,a)+"$lit$"+s.slice(a)+w+d):s+w+(-2===a?(e.push(void 0),i):d)}const l=o+(t[s]||"")+(2===i?"":"");if(!Array.isArray(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return[void 0!==b?b.createHTML(l):l,e]};class Z{constructor({strings:t,_$litType$:i},s){let e;this.parts=[];let n=0,o=0;const h=t.length-1,l=this.parts,[r,a]=D(t,i);if(this.el=Z.createElement(r,s),B.currentNode=this.el.content,2===i){const t=this.el.content,i=t.firstChild;i.remove(),t.append(...i.childNodes)}for(;null!==(e=B.nextNode())&&l.length0){e.textContent=y?y.emptyScript:"";for(let s=0;s2||""!==s[0]||""!==s[1]?(this._$AH=Array(s.length-1).fill(new String),this.strings=s):this._$AH=I}get tagName(){return this.element.tagName}get _$AU(){return this._$AM._$AU}_$AI(t,i=this,s,e){const n=this.strings;let o=!1;if(void 0===n)t=q(this,t,i,0),o=!_(t)||t!==this._$AH&&t!==z,o&&(this._$AH=t);else{const e=t;let h,l;for(t=n[0],h=0;h{t._$AK(i,s)},_$AL:t=>t._$AL};(null!==(st=globalThis.litElementVersions)&&void 0!==st?st:globalThis.litElementVersions=[]).push("3.2.0");export{e as CSSResult,nt as LitElement,f as ReactiveElement,et as UpdatingElement,ht as _$LE,Y as _$LH,h as adoptStyles,o as css,c as defaultConverter,l as getCompatibleStyle,L as html,z as noChange,v as notEqual,I as nothing,H as render,t as supportsAdoptingStyleSheets,j as svg,n as unsafeCSS}; +//# sourceMappingURL=lit-core.min.js.map diff --git a/src/main/resources/static/js/model/recipe.js b/src/main/resources/static/js/model/recipe.js new file mode 100644 index 0000000..5bb97ac --- /dev/null +++ b/src/main/resources/static/js/model/recipe.js @@ -0,0 +1,44 @@ +export class Recipe { + /** + * @type string + */ + id; + + /** + * @type string + */ + name; + + /** + * @type string + */ + description; + + /** + * array of images for the recipe + * @type Array + */ + images; + + /** + * @type Array + */ + ingredients; + + /** + * @type Array + */ + instructions; + + /** + * time (in seconds) to prepare the recipe + * @type number + */ + prepTime; + + /** + * time (in seconds) to cook the recipe + * @type number + */ + cookTime; +} \ No newline at end of file diff --git a/src/main/resources/static/js/model/session.js b/src/main/resources/static/js/model/session.js new file mode 100644 index 0000000..a076b9a --- /dev/null +++ b/src/main/resources/static/js/model/session.js @@ -0,0 +1,14 @@ +export class Session { + /** + * @type string + */ + token; + /** + * @type string + */ + userId; + /** + * @type number + */ + expiration; +} \ No newline at end of file diff --git a/src/main/resources/static/js/model/user.js b/src/main/resources/static/js/model/user.js new file mode 100644 index 0000000..313ab99 --- /dev/null +++ b/src/main/resources/static/js/model/user.js @@ -0,0 +1,15 @@ +export class User { + /** + * @type string + */ + id; + /** + * @type string + */ + username; + /** + * Only set for the current user. Other user objects will not contain an email for privacy reasons + * @type string + */ + email; +} \ No newline at end of file diff --git a/src/main/resources/static/js/store.js b/src/main/resources/static/js/store.js new file mode 100644 index 0000000..f5f54db --- /dev/null +++ b/src/main/resources/static/js/store.js @@ -0,0 +1,200 @@ +import {StatefulElement} from "./components/stateful.js"; // needed for the routes to work +import {User} from "./model/user.js"; +import { + Action, ACTION_LOGIN, ACTION_LOGOUT, ACTION_REGISTER, ACTION_VIEW_LOGIN, ACTION_VIEW_RECIPES, ACTION_VIEW_REGISTER, +} from "./actions/authentication.js"; +import {routes} from "./components/router.js"; +import {apiService} from "./api-service.js"; + +export class State { + /** + * @type Route + */ + route; + + /** + * @type User + */ + user; + + /** + * @type boolean + */ + loading = false; + + /** + * @type string + */ + error; + + /** + * @type Array + */ + recipes; +} + +class Store { + + /** + * @type State + */ + #state = new State(); + + /** + * @type Array + */ + #subscribers = []; + + constructor() { + const sessionToken = window.localStorage.getItem('session') + if (!sessionToken) { + this.dispatch(new Action(ACTION_VIEW_LOGIN)) + return + } + apiService.sessionToken = sessionToken + this.#updateState({ + loading: true + }) + apiService.getProfile().then(user => { + this.#updateState({ + user: user, + }) + this.dispatch(new Action(ACTION_VIEW_RECIPES)) + }) + console.log('starting location', window.location.href) + } + + /** + * @param {Action} action + */ + dispatch(action) { + switch (action.name) { + case ACTION_VIEW_RECIPES: + this.#updateState({ + route: routes['/'], + loading: true + }) + this.#getRecipes() + break; + case ACTION_VIEW_LOGIN: + this.#updateState({ + ...this.#state, + route: routes['/login'] + }) + break; + case ACTION_VIEW_REGISTER: + this.#updateState({ + ...this.#state, + route: routes['/register'] + }) + break; + case ACTION_LOGIN: + this.#login(action) + break; + case ACTION_LOGOUT: + window.localStorage.removeItem('session') + this.#updateState(new State()) + this.dispatch(new Action(ACTION_VIEW_LOGIN)) + break; + case ACTION_REGISTER: + this.#register(action) + break; + default: + console.warn("Unhandled action!", action) + } + } + + /** + * @param {LoginAction} action + */ + #login(action) { + this.#updateState({ + loading: true, + error: undefined, + }) + apiService.login(action.username, action.password) + .then(async (session) => { + window.localStorage.setItem('session', session.token) + apiService.sessionToken = session.token + const user = await apiService.getProfile() + this.#updateState({ + user: user, + }) + this.dispatch(new Action(ACTION_VIEW_RECIPES)) + }) + .catch(e => { + console.error('login error', e) + this.#updateState({ + loading: false, + error: e + }) + }) + } + + /** + * + * @param {RegisterAction} action + */ + #register(action) { + if (action.password !== action.confirmPassword) { + this.#updateState({ + error: 'Passwords don\'t match' + }) + return + } + this.#updateState({ + loading: true, + error: undefined + }) + } + + #getRecipes() { + apiService.getRecipes() + .then(recipes => { + this.#updateState({ + recipes: recipes, + loading: false + }) + }) + .catch(e => { + console.error('error fetching recipes', e) + this.#updateState({ + loading: false, + error: e + }) + }) + } + + #updateState(updates) { + this.#state = {...this.#state, ...updates} + this.#subscribers.forEach(subscriber => { + console.log('updating state for subscriber', subscriber) + subscriber.onStateChange({...this.#state}) + subscriber.requestUpdate() + }) + } + + currentState() { + return {...this.#state}; + } + + /** + * Subscribe to updates to the state + * @param {StatefulMixin(LitElement)} subscriber + */ + subscribe(subscriber) { + this.#subscribers = [...this.#subscribers, subscriber] + subscriber.onStateChange(this.#state) + subscriber.requestUpdate() + } + + /** + * Unsubscribe from state updates + * @param {StatefulMixin(LitElement)} subscriber + */ + unsubscribe(subscriber) { + this.#subscribers = this.#subscribers.filter(s => s !== subscriber) + } +} + +export const store = new Store() \ No newline at end of file diff --git a/src/test/java/com/wbrawner/recipes/RecipesApplicationTests.java b/src/test/java/com/wbrawner/recipes/RecipesApplicationTests.java new file mode 100644 index 0000000..20a233a --- /dev/null +++ b/src/test/java/com/wbrawner/recipes/RecipesApplicationTests.java @@ -0,0 +1,13 @@ +package com.wbrawner.recipes; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class RecipesApplicationTests { + + @Test + void contextLoads() { + } + +}