Initial commit
This commit is contained in:
commit
ae3eef0622
51 changed files with 2110 additions and 0 deletions
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
|
@ -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/
|
5
README.md
Normal file
5
README.md
Normal file
|
@ -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.
|
29
build.gradle
Normal file
29
build.gradle
Normal file
|
@ -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()
|
||||
}
|
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
|
@ -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
|
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
@ -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
|
234
gradlew
vendored
Normal file
234
gradlew
vendored
Normal file
|
@ -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" "$@"
|
89
gradlew.bat
vendored
Normal file
89
gradlew.bat
vendored
Normal file
|
@ -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
|
1
settings.gradle
Normal file
1
settings.gradle
Normal file
|
@ -0,0 +1 @@
|
|||
rootProject.name = 'recipes'
|
21
src/main/java/com/wbrawner/recipes/RecipesApplication.java
Normal file
21
src/main/java/com/wbrawner/recipes/RecipesApplication.java
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
29
src/main/java/com/wbrawner/recipes/Utils.java
Normal file
29
src/main/java/com/wbrawner/recipes/Utils.java
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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<Authentication> 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);
|
||||
}
|
||||
}
|
||||
}
|
59
src/main/java/com/wbrawner/recipes/config/Config.java
Normal file
59
src/main/java/com/wbrawner/recipes/config/Config.java
Normal file
|
@ -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<ServerResponse> 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();
|
||||
}
|
||||
}
|
|
@ -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<Void> save(ServerWebExchange exchange, SecurityContext context) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SecurityContext> 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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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<UserDetails> findByUsername(String username) {
|
||||
return userRepository.findByUsernameIgnoreCase(username)
|
||||
.switchIfEmpty(userRepository.findByEmailIgnoreCase(username))
|
||||
.map(user -> user);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> 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<ServerResponse> 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<Void> 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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<Recipe> getRecipes() {
|
||||
return recipeRepository.findAll();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseBody
|
||||
public Mono<ResponseEntity<?>> 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<ResponseEntity<?>> 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<Void> deleteRecipe(@PathVariable("id") String id) {
|
||||
return recipeRepository.deleteById(id);
|
||||
}
|
||||
}
|
|
@ -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<UserResponse> me() {
|
||||
return ReactiveSecurityContextHolder.getContext()
|
||||
.map(context -> (User) context.getAuthentication().getPrincipal())
|
||||
.map(UserResponse::of);
|
||||
}
|
||||
|
||||
@PostMapping("login")
|
||||
public Mono<ResponseEntity<Session>> 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<ResponseEntity<Session>> 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));
|
||||
}
|
||||
}
|
|
@ -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<String> staticRoutes = List.of("/", "/login", "/register");
|
||||
|
||||
@Override
|
||||
public Mono<Void> 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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
package com.wbrawner.recipes.model;
|
||||
|
||||
public record ImageResponse(String imageId) {
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
93
src/main/java/com/wbrawner/recipes/model/Recipe.java
Normal file
93
src/main/java/com/wbrawner/recipes/model/Recipe.java
Normal file
|
@ -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<String> images;
|
||||
private final List<String> ingredients;
|
||||
private final List<String> 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<String> images,
|
||||
List<String> ingredients,
|
||||
List<String> 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<String> getImages() {
|
||||
return images;
|
||||
}
|
||||
|
||||
public List<String> getIngredients() {
|
||||
return ingredients;
|
||||
}
|
||||
|
||||
public List<String> 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()
|
||||
);
|
||||
}
|
||||
}
|
14
src/main/java/com/wbrawner/recipes/model/RecipeRequest.java
Normal file
14
src/main/java/com/wbrawner/recipes/model/RecipeRequest.java
Normal file
|
@ -0,0 +1,14 @@
|
|||
package com.wbrawner.recipes.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record RecipeRequest(
|
||||
String name,
|
||||
String description,
|
||||
List<String> images,
|
||||
List<String> ingredients,
|
||||
List<String> instructions,
|
||||
int prepTime,
|
||||
int cookTime
|
||||
) {
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
45
src/main/java/com/wbrawner/recipes/model/Session.java
Normal file
45
src/main/java/com/wbrawner/recipes/model/Session.java
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
116
src/main/java/com/wbrawner/recipes/model/User.java
Normal file
116
src/main/java/com/wbrawner/recipes/model/User.java
Normal file
|
@ -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<GrantedAuthority> 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<GrantedAuthority> 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<? extends GrantedAuthority> 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;
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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<Recipe, String> {
|
||||
}
|
|
@ -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<Session, String> {
|
||||
}
|
|
@ -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<User, String> {
|
||||
Mono<User> findByUsernameIgnoreCase(String username);
|
||||
Mono<User> findByEmailIgnoreCase(String email);
|
||||
}
|
7
src/main/resources/application.properties
Normal file
7
src/main/resources/application.properties
Normal file
|
@ -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
|
5
src/main/resources/static/css/style.css
Normal file
5
src/main/resources/static/css/style.css
Normal file
|
@ -0,0 +1,5 @@
|
|||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: "Segoe UI", "Roboto Regular", "San Fransisco", sans-serif;
|
||||
}
|
14
src/main/resources/static/index.html
Normal file
14
src/main/resources/static/index.html
Normal file
|
@ -0,0 +1,14 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Recipes</title>
|
||||
<link rel="stylesheet" href="/css/style.css" />
|
||||
<script type="module" src="/js/store.js"></script>
|
||||
<script type="module" src="/js/components/stateful.js"></script>
|
||||
<script type="module" src="/js/components/recipe-app.js"></script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<recipe-app></recipe-app>
|
||||
</body>
|
||||
</html>
|
32
src/main/resources/static/js/actions/authentication.js
Normal file
32
src/main/resources/static/js/actions/authentication.js
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
50
src/main/resources/static/js/api-service.js
Normal file
50
src/main/resources/static/js/api-service.js
Normal file
|
@ -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();
|
16
src/main/resources/static/js/components/animated-loader.js
Normal file
16
src/main/resources/static/js/components/animated-loader.js
Normal file
|
@ -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`
|
||||
<p>Loading...</p>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('animated-loader', AnimatedLoader)
|
37
src/main/resources/static/js/components/header.js
Normal file
37
src/main/resources/static/js/components/header.js
Normal file
|
@ -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`<button class="link" @click="${this.#logout}">Logout</button> `
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<header>
|
||||
<h1>${this.state?.route?.title || 'Recipes'}</h1>
|
||||
${this.#logoutButton()}
|
||||
</header>`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('app-header', Header)
|
84
src/main/resources/static/js/components/login.js
Normal file
84
src/main/resources/static/js/components/login.js
Normal file
|
@ -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`<input type="email" @change="${(e) => this.email = e.target.value}" placeholder="Email" required
|
||||
.value="${this.email}"/>`
|
||||
}
|
||||
|
||||
#confirmPassword() {
|
||||
if (!this.registration) {
|
||||
return null;
|
||||
}
|
||||
return html`<input type="password" @change="${(e) => this.confirmPassword = e.target.value}"
|
||||
placeholder="Confirm Password" required .value="${this.confirmPassword}"/>`
|
||||
}
|
||||
|
||||
#toggleButton() {
|
||||
let message = "Need to create an account?"
|
||||
let action = "Register"
|
||||
if (this.registration) {
|
||||
message = "Already have an account?"
|
||||
action = "Login"
|
||||
}
|
||||
return html`<span>${message}</span>
|
||||
<button @click="${this.#toggleRegistration}" class="link">${action}</button>`
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.loading) {
|
||||
return html`
|
||||
<animated-loader></animated-loader>`
|
||||
}
|
||||
const error = this.error ? html`<p class="error">${this.error}</p>` : null
|
||||
return html`
|
||||
<form @submit="${this.#login}">
|
||||
${error}
|
||||
<input type="text" @change="${(e) => this.username = e.target.value}" placeholder="Username" required
|
||||
.value="${this.username}"/>
|
||||
${this.#email()}
|
||||
<input type="password" @change="${(e) => this.password = e.target.value}" placeholder="Password"
|
||||
required .value="${this.password}"/>
|
||||
${this.#confirmPassword()}
|
||||
<input type="submit" .value="${this.registration ? 'Register' : 'Login'}"/>
|
||||
</form>
|
||||
${this.#toggleButton()}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('login-form', LoginForm)
|
18
src/main/resources/static/js/components/recipe-app.js
Normal file
18
src/main/resources/static/js/components/recipe-app.js
Normal file
|
@ -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`
|
||||
<splash-screen .visible="${this.state?.route === undefined}"></splash-screen>
|
||||
<app-header></app-header>
|
||||
<app-router></app-router>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('recipe-app', RecipeApp)
|
41
src/main/resources/static/js/components/recipe-list.js
Normal file
41
src/main/resources/static/js/components/recipe-list.js
Normal file
|
@ -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`
|
||||
<animated-loader></animated-loader>`
|
||||
}
|
||||
if (this.error) {
|
||||
return html`<p class="error">${this.error}</p>`
|
||||
}
|
||||
if (this.state.recipes?.length === 0) {
|
||||
return html`<p>No recipes found</p>`
|
||||
}
|
||||
return html`
|
||||
<ul>
|
||||
${this.state.recipes.map(recipe => {
|
||||
html`
|
||||
<li>
|
||||
<span class="recipe-name">
|
||||
${recipe.name}
|
||||
</span>
|
||||
<span class="description">
|
||||
${recipe.description}
|
||||
</span>
|
||||
</li>`
|
||||
})}
|
||||
</ul>`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('recipe-list', RecipeList)
|
149
src/main/resources/static/js/components/register.js
Normal file
149
src/main/resources/static/js/components/register.js
Normal file
|
@ -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`<input type="email" @change="${(e) => this.email = e.target.value}" placeholder="Email" required
|
||||
.value="${this.email}"/>`
|
||||
}
|
||||
|
||||
#confirmPassword() {
|
||||
if (!this.registration) {
|
||||
return null;
|
||||
}
|
||||
return html`<input type="password" @change="${(e) => this.confirmPassword = e.target.value}"
|
||||
placeholder="Confirm Password" required .value="${this.confirmPassword}"/>`
|
||||
}
|
||||
|
||||
#toggleButton() {
|
||||
let message = "Need to create an account?"
|
||||
let action = "Register"
|
||||
if (this.registration) {
|
||||
message = "Already have an account?"
|
||||
action = "Login"
|
||||
}
|
||||
return html`<span>${message}</span>
|
||||
<button @click="${this.#toggleRegistration}" class="link">${action}</button>`
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.loading) {
|
||||
return html`
|
||||
<animated-loader></animated-loader>`
|
||||
}
|
||||
const error = this.error ? html`<p class="error">${this.error}</p>` : null
|
||||
return html`
|
||||
<form @submit="${this.#authenticate}">
|
||||
${error}
|
||||
<input type="text" @change="${(e) => this.username = e.target.value}" placeholder="Username" required
|
||||
.value="${this.username}"/>
|
||||
${this.#email()}
|
||||
<input type="password" @change="${(e) => this.password = e.target.value}" placeholder="Password"
|
||||
required .value="${this.password}"/>
|
||||
${this.#confirmPassword()}
|
||||
<input type="submit" .value="${this.registration ? 'Register' : 'Login'}"/>
|
||||
</form>
|
||||
${this.#toggleButton()}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('register-form', RegisterForm)
|
37
src/main/resources/static/js/components/router.js
Normal file
37
src/main/resources/static/js/components/router.js
Normal file
|
@ -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`<recipe-list></recipe-list>`, '/'),
|
||||
'/login': new Route('Login', html`<login-form></login-form>`, '/login'),
|
||||
'/register': new Route('Register', html`<register-form></register-form>`, '/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)
|
53
src/main/resources/static/js/components/splash.js
Normal file
53
src/main/resources/static/js/components/splash.js
Normal file
|
@ -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`
|
||||
<div class="splash-bg ${this.visible ? '' : 'fadeOut'}">
|
||||
<!-- TODO: Get an icon to show here -->
|
||||
<h1>Recipes</h1>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('splash-screen', SplashScreen)
|
35
src/main/resources/static/js/components/stateful.js
Normal file
35
src/main/resources/static/js/components/stateful.js
Normal file
|
@ -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)
|
24
src/main/resources/static/js/lit-core.min.js
vendored
Normal file
24
src/main/resources/static/js/lit-core.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
44
src/main/resources/static/js/model/recipe.js
Normal file
44
src/main/resources/static/js/model/recipe.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
export class Recipe {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id;
|
||||
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name;
|
||||
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description;
|
||||
|
||||
/**
|
||||
* array of images for the recipe
|
||||
* @type Array<string>
|
||||
*/
|
||||
images;
|
||||
|
||||
/**
|
||||
* @type Array<string>
|
||||
*/
|
||||
ingredients;
|
||||
|
||||
/**
|
||||
* @type Array<string>
|
||||
*/
|
||||
instructions;
|
||||
|
||||
/**
|
||||
* time (in seconds) to prepare the recipe
|
||||
* @type number
|
||||
*/
|
||||
prepTime;
|
||||
|
||||
/**
|
||||
* time (in seconds) to cook the recipe
|
||||
* @type number
|
||||
*/
|
||||
cookTime;
|
||||
}
|
14
src/main/resources/static/js/model/session.js
Normal file
14
src/main/resources/static/js/model/session.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
export class Session {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
token;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
userId;
|
||||
/**
|
||||
* @type number
|
||||
*/
|
||||
expiration;
|
||||
}
|
15
src/main/resources/static/js/model/user.js
Normal file
15
src/main/resources/static/js/model/user.js
Normal file
|
@ -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;
|
||||
}
|
200
src/main/resources/static/js/store.js
Normal file
200
src/main/resources/static/js/store.js
Normal file
|
@ -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<Recipe>
|
||||
*/
|
||||
recipes;
|
||||
}
|
||||
|
||||
class Store {
|
||||
|
||||
/**
|
||||
* @type State
|
||||
*/
|
||||
#state = new State();
|
||||
|
||||
/**
|
||||
* @type Array<StatefulMixin(LitElement)>
|
||||
*/
|
||||
#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()
|
|
@ -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() {
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in a new issue