Browse Source

Initial Commit

jayfella 5 years ago
commit
c97084e992
100 changed files with 7342 additions and 0 deletions
  1. 77 0
      build.gradle
  2. BIN
      gradle/wrapper/gradle-wrapper.jar
  3. 5 0
      gradle/wrapper/gradle-wrapper.properties
  4. 188 0
      gradlew
  5. 100 0
      gradlew.bat
  6. 2 0
      settings.gradle
  7. 103 0
      src/main/java/com/jayfella/website/Main.java
  8. 31 0
      src/main/java/com/jayfella/website/component/StartupComponent.java
  9. 32 0
      src/main/java/com/jayfella/website/config/DatabaseConfig.java
  10. 99 0
      src/main/java/com/jayfella/website/config/EmailConfig.java
  11. 41 0
      src/main/java/com/jayfella/website/config/ResourcesConfig.java
  12. 71 0
      src/main/java/com/jayfella/website/config/ThymeLeafConfig.java
  13. 41 0
      src/main/java/com/jayfella/website/config/external/DatabaseConfig.java
  14. 38 0
      src/main/java/com/jayfella/website/config/external/SecurityConfig.java
  15. 92 0
      src/main/java/com/jayfella/website/config/external/ServerConfig.java
  16. 22 0
      src/main/java/com/jayfella/website/config/external/WebsiteConfig.java
  17. 29 0
      src/main/java/com/jayfella/website/controller/SitemapController.java
  18. 298 0
      src/main/java/com/jayfella/website/controller/api/ApiApprovalController.java
  19. 116 0
      src/main/java/com/jayfella/website/controller/api/ApiAvatarController.java
  20. 218 0
      src/main/java/com/jayfella/website/controller/api/ApiBadgeController.java
  21. 49 0
      src/main/java/com/jayfella/website/controller/api/ApiBlobController.java
  22. 171 0
      src/main/java/com/jayfella/website/controller/api/ApiCategoryController.java
  23. 178 0
      src/main/java/com/jayfella/website/controller/api/ApiMessagesController.java
  24. 121 0
      src/main/java/com/jayfella/website/controller/api/ApiRejectionController.java
  25. 242 0
      src/main/java/com/jayfella/website/controller/api/ApiReviewController.java
  26. 111 0
      src/main/java/com/jayfella/website/controller/api/ApiReviewJobController.java
  27. 163 0
      src/main/java/com/jayfella/website/controller/api/ApiSearchController.java
  28. 219 0
      src/main/java/com/jayfella/website/controller/api/ApiUserController.java
  29. 21 0
      src/main/java/com/jayfella/website/controller/api/ApiUserPreferencesController.java
  30. 361 0
      src/main/java/com/jayfella/website/controller/api/ApiValidationController.java
  31. 235 0
      src/main/java/com/jayfella/website/controller/api/page/ApiAmendmentController.java
  32. 323 0
      src/main/java/com/jayfella/website/controller/api/page/ApiDraftController.java
  33. 337 0
      src/main/java/com/jayfella/website/controller/api/page/ApiLivePageController.java
  34. 82 0
      src/main/java/com/jayfella/website/controller/api/page/ApiPageController.java
  35. 116 0
      src/main/java/com/jayfella/website/controller/http/AdminController.java
  36. 17 0
      src/main/java/com/jayfella/website/controller/http/BlogController.java
  37. 25 0
      src/main/java/com/jayfella/website/controller/http/CategoryController.java
  38. 27 0
      src/main/java/com/jayfella/website/controller/http/ContactUsController.java
  39. 52 0
      src/main/java/com/jayfella/website/controller/http/CreatePageController.java
  40. 126 0
      src/main/java/com/jayfella/website/controller/http/EditPageController.java
  41. 18 0
      src/main/java/com/jayfella/website/controller/http/ExceptionController.java
  42. 35 0
      src/main/java/com/jayfella/website/controller/http/GeneralController.java
  43. 68 0
      src/main/java/com/jayfella/website/controller/http/ImageController.java
  44. 63 0
      src/main/java/com/jayfella/website/controller/http/IndexPageController.java
  45. 40 0
      src/main/java/com/jayfella/website/controller/http/LegalController.java
  46. 51 0
      src/main/java/com/jayfella/website/controller/http/MessageController.java
  47. 15 0
      src/main/java/com/jayfella/website/controller/http/OAuthCallbackController.java
  48. 101 0
      src/main/java/com/jayfella/website/controller/http/PreviewPageController.java
  49. 95 0
      src/main/java/com/jayfella/website/controller/http/RejectionController.java
  50. 53 0
      src/main/java/com/jayfella/website/controller/http/StoreSearchController.java
  51. 16 0
      src/main/java/com/jayfella/website/controller/http/TestController.java
  52. 237 0
      src/main/java/com/jayfella/website/controller/http/UserController.java
  53. 23 0
      src/main/java/com/jayfella/website/core/AccountValidationType.java
  54. 76 0
      src/main/java/com/jayfella/website/core/ApiResponses.java
  55. 65 0
      src/main/java/com/jayfella/website/core/DatabaseType.java
  56. 22 0
      src/main/java/com/jayfella/website/core/EnumUtils.java
  57. 34 0
      src/main/java/com/jayfella/website/core/GitRepository.java
  58. 14 0
      src/main/java/com/jayfella/website/core/HtmlResponses.java
  59. 82 0
      src/main/java/com/jayfella/website/core/ImageDownloader.java
  60. 64 0
      src/main/java/com/jayfella/website/core/JsonMapper.java
  61. 24 0
      src/main/java/com/jayfella/website/core/PageRequirements.java
  62. 62 0
      src/main/java/com/jayfella/website/core/RandomString.java
  63. 20 0
      src/main/java/com/jayfella/website/core/ResponseStrings.java
  64. 70 0
      src/main/java/com/jayfella/website/core/ServerAdvice.java
  65. 17 0
      src/main/java/com/jayfella/website/core/ServerUtilities.java
  66. 64 0
      src/main/java/com/jayfella/website/core/StoreHtmlFilePaths.java
  67. 24 0
      src/main/java/com/jayfella/website/core/VersionState.java
  68. 16 0
      src/main/java/com/jayfella/website/core/controller/http/user/MessagesController.java
  69. 23 0
      src/main/java/com/jayfella/website/core/page/PageState.java
  70. 10 0
      src/main/java/com/jayfella/website/core/page/ReviewState.java
  71. 9 0
      src/main/java/com/jayfella/website/core/page/SoftwareType.java
  72. 63 0
      src/main/java/com/jayfella/website/database/entity/Badge.java
  73. 46 0
      src/main/java/com/jayfella/website/database/entity/Category.java
  74. 35 0
      src/main/java/com/jayfella/website/database/entity/WebsiteImage.java
  75. 60 0
      src/main/java/com/jayfella/website/database/entity/message/Message.java
  76. 43 0
      src/main/java/com/jayfella/website/database/entity/message/MessageReply.java
  77. 17 0
      src/main/java/com/jayfella/website/database/entity/page/Editable.java
  78. 59 0
      src/main/java/com/jayfella/website/database/entity/page/PageReview.java
  79. 58 0
      src/main/java/com/jayfella/website/database/entity/page/StaffPageReview.java
  80. 114 0
      src/main/java/com/jayfella/website/database/entity/page/StorePage.java
  81. 39 0
      src/main/java/com/jayfella/website/database/entity/page/embedded/BuildData.java
  82. 37 0
      src/main/java/com/jayfella/website/database/entity/page/embedded/Details.java
  83. 24 0
      src/main/java/com/jayfella/website/database/entity/page/embedded/ExternalLinks.java
  84. 50 0
      src/main/java/com/jayfella/website/database/entity/page/embedded/MediaLinks.java
  85. 48 0
      src/main/java/com/jayfella/website/database/entity/page/embedded/OpenSourceData.java
  86. 22 0
      src/main/java/com/jayfella/website/database/entity/page/embedded/PaymentData.java
  87. 90 0
      src/main/java/com/jayfella/website/database/entity/page/embedded/SoftwareRating.java
  88. 35 0
      src/main/java/com/jayfella/website/database/entity/page/embedded/VersionData.java
  89. 46 0
      src/main/java/com/jayfella/website/database/entity/page/stages/LivePage.java
  90. 48 0
      src/main/java/com/jayfella/website/database/entity/page/stages/PageAmendment.java
  91. 32 0
      src/main/java/com/jayfella/website/database/entity/page/stages/PageDraft.java
  92. 99 0
      src/main/java/com/jayfella/website/database/entity/user/User.java
  93. 50 0
      src/main/java/com/jayfella/website/database/entity/user/UserSession.java
  94. 56 0
      src/main/java/com/jayfella/website/database/entity/user/UserValidation.java
  95. 8 0
      src/main/java/com/jayfella/website/database/repository/BadgeRepository.java
  96. 20 0
      src/main/java/com/jayfella/website/database/repository/CategoryRepository.java
  97. 11 0
      src/main/java/com/jayfella/website/database/repository/MessageReplyRepository.java
  98. 11 0
      src/main/java/com/jayfella/website/database/repository/MessagesRepository.java
  99. 19 0
      src/main/java/com/jayfella/website/database/repository/ReviewRepository.java
  100. 12 0
      src/main/java/com/jayfella/website/database/repository/SessionRepository.java

+ 77 - 0
build.gradle

@@ -0,0 +1,77 @@
+import org.springframework.boot.gradle.plugin.SpringBootPlugin
+
+plugins {
+    id 'java'
+    id 'application'
+    id 'idea'
+    id 'org.springframework.boot' version '2.2.6.RELEASE'
+}
+
+apply plugin: 'io.spring.dependency-management'
+
+dependencyManagement {
+    imports {
+        mavenBom SpringBootPlugin.BOM_COORDINATES
+    }
+}
+
+repositories {
+    mavenCentral()
+}
+
+group 'com.jayfella'
+version '1.0.54'
+
+sourceCompatibility = 11
+targetCompatibility = 11
+
+mainClassName = "com.jayfella.website.Main"
+
+project.ext {
+    version_thymeleaf_dialect = "2.4.1"
+    version_jackson = "2.11.0"
+
+    version_mysql_connector = "8.0.20"
+
+    version_httpcomponents = "4.5.12"
+    version_validator = "1.6"
+}
+
+dependencies {
+
+    //compile fileTree(include: ['*.jar'], dir: 'plugins')
+
+    // SPRING
+    implementation "org.springframework.boot:spring-boot-starter"
+    implementation "org.springframework.boot:spring-boot-starter-web"
+    implementation "org.springframework.boot:spring-boot-starter-thymeleaf"
+    implementation "org.springframework.boot:spring-boot-starter-data-jpa"
+    implementation "org.springframework.boot:spring-boot-starter-jdbc"
+    //implementation "org.springframework.boot:spring-boot-starter-security"
+    // implementation "org.springframework.boot:spring-boot-starter-websocket"
+    // implementation "org.springframework.boot:spring-boot-starter-activemq"
+    implementation "org.springframework.boot:spring-boot-starter-mail"
+    //implementation "org.springframework.boot:spring-boot-devtools"
+
+    // TEMPLATING - Thymeleaf
+    implementation "nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:$version_thymeleaf_dialect"
+
+    // DATABASE - MySQL Driver
+    implementation "mysql:mysql-connector-java:$version_mysql_connector"
+
+    // JSON - jackson
+    implementation "com.fasterxml.jackson.core:jackson-core:$version_jackson"
+    implementation "com.fasterxml.jackson.core:jackson-databind:$version_jackson"
+    implementation "com.fasterxml.jackson.core:jackson-annotations:$version_jackson"
+    
+    // HTTP - client
+    implementation "org.apache.httpcomponents:httpclient:$version_httpcomponents"
+
+    // Validators (email, etc)
+    implementation "commons-validator:commons-validator:$version_validator"
+
+    // sitemap
+    compile group: 'com.github.dfabulich', name: 'sitemapgen4j', version: '1.1.2'
+
+
+}

BIN
gradle/wrapper/gradle-wrapper.jar


+ 5 - 0
gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.3-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists

+ 188 - 0
gradlew

@@ -0,0 +1,188 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or 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 UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+    ls=`ls -ld "$PRG"`
+    link=`expr "$ls" : '.*-> \(.*\)$'`
+    if expr "$link" : '/.*' > /dev/null; then
+        PRG="$link"
+    else
+        PRG=`dirname "$PRG"`"/$link"
+    fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$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 "$*"
+}
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+}
+
+# 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
+    ;;
+  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" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+    MAX_FD_LIMIT=`ulimit -H -n`
+    if [ $? -eq 0 ] ; then
+        if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+            MAX_FD="$MAX_FD_LIMIT"
+        fi
+        ulimit -n $MAX_FD
+        if [ $? -ne 0 ] ; then
+            warn "Could not set maximum file descriptor limit: $MAX_FD"
+        fi
+    else
+        warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+    fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+    GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+    APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+    CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+    JAVACMD=`cygpath --unix "$JAVACMD"`
+
+    # We build the pattern for arguments to be converted via cygpath
+    ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+    SEP=""
+    for dir in $ROOTDIRSRAW ; do
+        ROOTDIRS="$ROOTDIRS$SEP$dir"
+        SEP="|"
+    done
+    OURCYGPATTERN="(^($ROOTDIRS))"
+    # Add a user-defined pattern to the cygpath arguments
+    if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+        OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+    fi
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    i=0
+    for arg in "$@" ; do
+        CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+        CHECK2=`echo "$arg"|egrep -c "^-"`                                 ### Determine if an option
+
+        if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then                    ### Added a condition
+            eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+        else
+            eval `echo args$i`="\"$arg\""
+        fi
+        i=$((i+1))
+    done
+    case $i in
+        (0) set -- ;;
+        (1) set -- "$args0" ;;
+        (2) set -- "$args0" "$args1" ;;
+        (3) set -- "$args0" "$args1" "$args2" ;;
+        (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+        (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+        (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+        (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+        (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+        (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+    esac
+fi
+
+# Escape application args
+save () {
+    for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+    echo " "
+}
+APP_ARGS=$(save "$@")
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
+if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
+  cd "$(dirname "$0")"
+fi
+
+exec "$JAVACMD" "$@"

+ 100 - 0
gradlew.bat

@@ -0,0 +1,100 @@
+@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 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 init
+
+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 init
+
+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
+
+:init
+@rem Get command-line arguments, handling Windows variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+
+: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 %CMD_LINE_ARGS%
+
+: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

+ 2 - 0
settings.gradle

@@ -0,0 +1,2 @@
+rootProject.name = 'JmeStore'
+

+ 103 - 0
src/main/java/com/jayfella/website/Main.java

@@ -0,0 +1,103 @@
+package com.jayfella.website;
+
+import com.jayfella.website.config.external.ServerConfig;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.builder.SpringApplicationBuilder;
+import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.scheduling.annotation.EnableAsync;
+
+import java.util.Properties;
+
+/*
+    @TODO:
+        - implement LICENSES for paid and sponsored.
+        - experimental: implement a "blob" api endpoint that take a javascript key/val object and returns requested objects.
+
+ */
+
+@SpringBootApplication
+@ComponentScan(basePackages = { "com.jayfella.website" })
+@EnableAsync
+public class Main extends SpringBootServletInitializer {
+
+    public static void main(String... args) {
+
+        SpringApplication app = new SpringApplication(Main.class);
+
+        // complete settings
+        // https://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html
+
+        Properties properties = new Properties();
+        //properties.put("debug", true);
+
+
+        properties.put("server.port", ServerConfig.getInstance().getPort());
+        // properties.put("server.error.whitelabel.enabled", false);
+        // properties.put("spring.mvc.throw-exception-if-no-handler-found", true);
+        // properties.put("spring.resources.add-mappings", false);
+
+        //properties.put("spring.thymeleaf.cache", false);
+        properties.put("server.error.include-stacktrace", "always");
+        properties.put("logging.level.web", "INFO");
+
+        // we want this on the first start, but not once "installation" is complete.
+        properties.put("spring.jpa.generate-ddl", true);
+        properties.put("spring.jpa.hibernate.ddl-auto", "update");
+
+        //properties.put("spring.jackson.serialization.indent_output", true);
+
+        properties.put("spring.profiles.active", "debug");
+        properties.put("spring.devtools.add-properties", true);
+        // properties.put("spring.jpa.open-in-view", false);
+
+        // multi-part forms & uploading
+        properties.put("spring.servlet.multipart.enabled", true);
+        properties.put("spring.servlet.multipart.max-file-size", "10MB");
+        properties.put("spring.servlet.multipart.max-request-size", "12MB");
+
+        // remove the error stating it cannot find the template location.
+        // properties.put("spring.thymeleaf.check-template-location", false);
+
+        // SSL
+        // properties.put("server.port", 8443);
+        // properties.put("server.ssl.key-alias", "selfsigned_localhost_sslserver");
+        // properties.put("server.ssl.key-store-password", "indiegamer");
+        // properties.put("server.ssl.key-store", "classpath:ssl-server.jks");
+        // properties.put("server.ssl.key-store-provider", "SUN");
+        // properties.put("server.ssl.key-store-type", "JKS");
+
+        // response compression
+        // properties.put("server.compression.enabled", "true");
+        // properties.put("server.compression.mime-types", "text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json");
+        // properties.put("server.compression.min-response-size", "1024");
+
+        // Enable HTTP/2 support, if the current environment supports it
+        properties.put("server.http2.enabled", true);
+
+        // caching
+        // properties.put("spring.resources.cache.cachecontrol.max-age", 120); // Maximum time the response should be cached (in seconds)
+        // properties.put("spring.resources.cache.cachecontrol.must-revalidate", true); // The cache must re-validate stale resources with the server. Any expired resources must not be used without re-validating.
+
+        /*
+        <property name="hibernate.connection.CharSet">utf8</property>
+        <property name="hibernate.connection.characterEncoding">utf8</property>
+        <property name="hibernate.connection.useUnicode">true</property>
+         */
+
+        //properties.put("hibernate.connection.CharSet", "utf8");
+        //properties.put("hibernate.connection.characterEncoding", "utf8");
+        //properties.put("hibernate.connection.useUnicode", "true");
+
+        app.setDefaultProperties(properties);
+
+        app.run(args);
+    }
+
+    @Override
+    protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
+        return builder.sources(Main.class);
+    }
+
+}

+ 31 - 0
src/main/java/com/jayfella/website/component/StartupComponent.java

@@ -0,0 +1,31 @@
+package com.jayfella.website.component;
+
+import com.jayfella.website.service.SitemapService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.event.ContextRefreshedEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+
+@Component
+public class StartupComponent {
+
+    private static final Logger log = LoggerFactory.getLogger(StartupComponent.class);
+
+    @Autowired
+    private SitemapService sitemapService;
+
+    @EventListener({ContextRefreshedEvent.class})
+    void contextRefreshedEvent() throws IOException {
+
+        if (!SitemapService.SITEMAP_FULL_FILE.exists()) {
+            log.info("No sitemap.xml found, so one has been generated.");
+            sitemapService.generateSiteMap();
+        }
+
+    }
+
+}

+ 32 - 0
src/main/java/com/jayfella/website/config/DatabaseConfig.java

@@ -0,0 +1,32 @@
+package com.jayfella.website.config;
+
+import com.jayfella.website.config.external.ServerConfig;
+import com.jayfella.website.core.DatabaseType;
+import org.springframework.boot.autoconfigure.domain.EntityScan;
+import org.springframework.boot.jdbc.DataSourceBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+import javax.sql.DataSource;
+
+@Configuration
+@EnableTransactionManagement
+@EntityScan( basePackages = {"com.jayfella.website"} )
+public class DatabaseConfig {
+
+    @Bean
+    public DataSource dataSource() {
+
+        DatabaseType dbType = DatabaseType.MYSQL;
+
+        return DataSourceBuilder
+                .create()
+                .username(ServerConfig.getInstance().getDatabaseConfig().getUsername())
+                .password(ServerConfig.getInstance().getDatabaseConfig().getPassword())
+                .url(dbType.constructDatabaseUrl())
+                .driverClassName(dbType.getDriver())
+                .build();
+    }
+
+}

+ 99 - 0
src/main/java/com/jayfella/website/config/EmailConfig.java

@@ -0,0 +1,99 @@
+package com.jayfella.website.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.support.ResourceBundleMessageSource;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.mail.javamail.JavaMailSenderImpl;
+import org.thymeleaf.TemplateEngine;
+import org.thymeleaf.spring5.SpringTemplateEngine;
+import org.thymeleaf.templatemode.TemplateMode;
+import org.thymeleaf.templateresolver.FileTemplateResolver;
+import org.thymeleaf.templateresolver.ITemplateResolver;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Properties;
+
+@Configuration
+public class EmailConfig {
+
+    private static final String EMAIL_TEMPLATE_ENCODING = StandardCharsets.UTF_8.name();
+
+    @Bean
+    public JavaMailSender getJavaMailSender() {
+
+        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
+        mailSender.setHost("smtp.zoho.eu");
+        mailSender.setPort(587);
+
+        mailSender.setUsername("[email protected]");
+        mailSender.setPassword("1MNMQDEVT3HS");
+
+        Properties props = mailSender.getJavaMailProperties();
+        props.put("mail.transport.protocol", "smtp");
+        props.put("mail.smtp.auth", "true");
+        props.put("mail.smtp.starttls.enable", "true");
+        props.put("mail.debug", "false");
+
+        return mailSender;
+    }
+
+    @Bean
+    public ResourceBundleMessageSource emailMessageSource() {
+        final ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
+        messageSource.setBasename("/mail/MailMessages");
+        return messageSource;
+    }
+
+    @Bean
+    public TemplateEngine emailTemplateEngine() {
+        final SpringTemplateEngine templateEngine = new SpringTemplateEngine();
+        // Resolver for TEXT emails
+        templateEngine.addTemplateResolver(textTemplateResolver());
+        // Resolver for HTML emails (except the editable one)
+        templateEngine.addTemplateResolver(htmlTemplateResolver());
+        // Resolver for HTML editable emails (which will be treated as a String)
+        templateEngine.addTemplateResolver(stringTemplateResolver());
+        // Message source, internationalization specific to emails
+        templateEngine.setTemplateEngineMessageSource(emailMessageSource());
+        return templateEngine;
+    }
+
+    private ITemplateResolver textTemplateResolver() {
+        // final ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver();
+        FileTemplateResolver templateResolver = new FileTemplateResolver();
+        templateResolver.setOrder(1);
+        templateResolver.setResolvablePatterns(Collections.singleton("text/*"));
+        templateResolver.setPrefix("/mail/");
+        templateResolver.setSuffix(".txt");
+        templateResolver.setTemplateMode(TemplateMode.TEXT);
+        templateResolver.setCharacterEncoding(EMAIL_TEMPLATE_ENCODING);
+        templateResolver.setCacheable(false);
+        return templateResolver;
+    }
+
+    private ITemplateResolver htmlTemplateResolver() {
+        // final ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver();
+        FileTemplateResolver templateResolver = new FileTemplateResolver();
+        templateResolver.setOrder(2);
+        templateResolver.setResolvablePatterns(Collections.singleton("html/*"));
+        templateResolver.setPrefix("/mail/");
+        templateResolver.setSuffix(".html");
+        templateResolver.setTemplateMode(TemplateMode.HTML);
+        templateResolver.setCharacterEncoding(EMAIL_TEMPLATE_ENCODING);
+        templateResolver.setCacheable(false);
+        return templateResolver;
+    }
+
+    private ITemplateResolver stringTemplateResolver() {
+        // final StringTemplateResolver templateResolver = new StringTemplateResolver();
+        FileTemplateResolver templateResolver = new FileTemplateResolver();
+        templateResolver.setOrder(3);
+        // No resolvable pattern, will simply process as a String template everything not previously matched
+        templateResolver.setTemplateMode(TemplateMode.HTML);
+        templateResolver.setCacheable(false);
+        return templateResolver;
+    }
+
+}

+ 41 - 0
src/main/java/com/jayfella/website/config/ResourcesConfig.java

@@ -0,0 +1,41 @@
+package com.jayfella.website.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.CacheControl;
+import org.springframework.web.multipart.MultipartResolver;
+import org.springframework.web.multipart.support.StandardServletMultipartResolver;
+import org.springframework.web.servlet.config.annotation.EnableWebMvc;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+import java.util.concurrent.TimeUnit;
+
+@Configuration
+@EnableWebMvc
+public class ResourcesConfig implements WebMvcConfigurer {
+
+    @Override
+    public void addResourceHandlers(ResourceHandlerRegistry registry) {
+
+        registry.addResourceHandler(
+                "/images/**",
+                "/libs/**",
+                "/css/**",
+                "/js/**")
+                .addResourceLocations(
+                        "file:./www/images/",
+                        "file:./www/libs/",
+                        "file:./www/css/",
+                        "file:./www/js/"
+                )
+                .setCacheControl(CacheControl.maxAge(30, TimeUnit.MINUTES));
+
+    }
+
+    @Bean
+    public MultipartResolver multipartResolver() {
+        return new StandardServletMultipartResolver();
+    }
+
+}

+ 71 - 0
src/main/java/com/jayfella/website/config/ThymeLeafConfig.java

@@ -0,0 +1,71 @@
+package com.jayfella.website.config;
+
+import nz.net.ultraq.thymeleaf.LayoutDialect;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Description;
+import org.springframework.web.servlet.ViewResolver;
+import org.springframework.web.servlet.config.annotation.EnableWebMvc;
+import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+import org.thymeleaf.spring5.SpringTemplateEngine;
+import org.thymeleaf.spring5.view.ThymeleafViewResolver;
+import org.thymeleaf.templatemode.TemplateMode;
+import org.thymeleaf.templateresolver.FileTemplateResolver;
+import org.thymeleaf.templateresolver.ITemplateResolver;
+
+import java.nio.charset.StandardCharsets;
+
+@Configuration
+@EnableWebMvc
+public class ThymeLeafConfig implements WebMvcConfigurer {
+
+    @Autowired
+    public SpringTemplateEngine templateEngine;
+
+    @Bean
+    @Description("Thymeleaf template resolver serving HTML 5")
+    public ITemplateResolver templateResolver() {
+
+        FileTemplateResolver templateResolver = new FileTemplateResolver();
+
+        templateResolver.setPrefix("www/");
+        templateResolver.setCacheable(false);
+        templateResolver.setSuffix(".html");
+        templateResolver.setTemplateMode(TemplateMode.HTML);
+        templateResolver.setCharacterEncoding(StandardCharsets.UTF_8.name());
+        templateResolver.setOrder(1);
+
+        return templateResolver;
+    }
+
+    @Bean
+    @Description("Thymeleaf template engine with Spring integration")
+    public SpringTemplateEngine templateEngine() {
+
+        SpringTemplateEngine templateEngine = new SpringTemplateEngine();
+        templateEngine.addDialect(new LayoutDialect());
+        templateEngine.setTemplateResolver(templateResolver());
+
+        return templateEngine;
+    }
+
+    @Bean
+    @Description("Thymeleaf view resolver")
+    public ViewResolver viewResolver() {
+
+        ThymeleafViewResolver viewResolver = new ThymeleafViewResolver();
+
+        viewResolver.setTemplateEngine(templateEngine());
+        viewResolver.setCharacterEncoding(StandardCharsets.UTF_8.name());
+
+        return viewResolver;
+    }
+
+    @Override
+    public void addViewControllers(ViewControllerRegistry registry) {
+        registry.addViewController("/").setViewName("index");
+    }
+
+}

+ 41 - 0
src/main/java/com/jayfella/website/config/external/DatabaseConfig.java

@@ -0,0 +1,41 @@
+package com.jayfella.website.config.external;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class DatabaseConfig {
+
+    private String type = "mysql";
+    private String address = "127.0.0.1";
+    private int port = 3306;
+    private String name;
+    private String username;
+    private String password;
+
+    DatabaseConfig() {
+    }
+
+    @JsonProperty("type")
+    public String getType() { return type; }
+    public void setType(String type) { this.type = type; }
+
+    @JsonProperty("address")
+    public String getAddress() { return address; }
+    public void setAddress(String address) { this.address = address; }
+
+    @JsonProperty("port")
+    public int getPort() { return port; }
+    public void setPort(int port) { this.port = port; }
+
+    @JsonProperty("name")
+    public String getName() { return name; }
+    public void setName(String name) { this.name = name; }
+
+    @JsonProperty("username")
+    public String getUsername() { return username; }
+    public void setUsername(String username) { this.username = username; }
+
+    @JsonProperty("password")
+    public String getPassword() { return password; }
+    public void setPassword(String password) { this.password = password; }
+
+}

+ 38 - 0
src/main/java/com/jayfella/website/config/external/SecurityConfig.java

@@ -0,0 +1,38 @@
+package com.jayfella.website.config.external;
+
+public class SecurityConfig {
+
+    // 80,000 as of march 2019
+    private int pbkdfIterations = 80000;
+
+    // A good rule of thumb is to use a salt that is the same size as the output of the hash function.
+    private int passwordHashLength = 512; // in bits
+    private int saltHashLength = 512;
+
+    SecurityConfig() {
+    }
+
+    public int getPbkdfIterations() {
+        return pbkdfIterations;
+    }
+
+    public void setPbkdfIterations(int pbkdfIterations) {
+        this.pbkdfIterations = pbkdfIterations;
+    }
+
+    public int getPasswordHashLength() {
+        return passwordHashLength;
+    }
+
+    public void setPasswordHashLength(int passwordHashLength) {
+        this.passwordHashLength = passwordHashLength;
+    }
+
+    public int getSaltHashLength() {
+        return saltHashLength;
+    }
+
+    public void setSaltHashLength(int saltHashLength) {
+        this.saltHashLength = saltHashLength;
+    }
+}

+ 92 - 0
src/main/java/com/jayfella/website/config/external/ServerConfig.java

@@ -0,0 +1,92 @@
+package com.jayfella.website.config.external;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.jayfella.website.core.JsonMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.nio.file.Paths;
+
+public class ServerConfig {
+
+    private static final Logger log = LoggerFactory.getLogger(ServerConfig.class);
+
+    // private static String CONFIG_FILE = "./config/server-config.json";
+    private static String CONFIG_FILE = Paths.get(".", "config", "server-config.json").toString();
+
+    private static ServerConfig INSTANCE;
+
+    private int port = 8080;
+    private String siteName = "My Website";
+
+    // we use an nginx proxy on the server because it's easier to deal with SSL certificates.
+    // this setting only sets the cookie values currently.
+    private boolean httpsEnabled = false;
+
+    private DatabaseConfig databaseConfig = new DatabaseConfig();
+    private SecurityConfig securityConfig = new SecurityConfig();
+    private WebsiteConfig websiteConfig = new WebsiteConfig();
+
+    public static ServerConfig getInstance() {
+        if (INSTANCE == null) {
+            INSTANCE = load();
+        }
+
+        return INSTANCE;
+    }
+
+    private ServerConfig() {
+    }
+
+    @JsonProperty("port")
+    public int getPort() { return port; }
+    public void setPort(int port) { this.port = port; }
+
+    @JsonProperty("site-name")
+    public String getSiteName() { return siteName; }
+    public void setSiteName(String siteName) { this.siteName = siteName; }
+
+    @JsonProperty("https-enabled")
+    public boolean isHttpsEnabled() { return httpsEnabled; }
+    public void setHttpsEnabled(boolean httpsEnabled) { this.httpsEnabled = httpsEnabled; }
+
+    @JsonProperty("database")
+    public DatabaseConfig getDatabaseConfig() { return databaseConfig; }
+    public void setDatabaseConfig(DatabaseConfig databaseConfig) { this.databaseConfig = databaseConfig; }
+
+    @JsonProperty("security")
+    public SecurityConfig getSecurityConfig() { return securityConfig; }
+    public void setSecurityConfig(SecurityConfig securityConfig) { this.securityConfig = securityConfig; }
+
+    @JsonProperty("website")
+    public WebsiteConfig getWebsiteConfig() { return websiteConfig; }
+    public void setWebsiteConfig(WebsiteConfig websiteConfig) { this.websiteConfig = websiteConfig; }
+
+    public void save() {
+        JsonMapper.writeFile(CONFIG_FILE, this);
+    }
+
+    public static ServerConfig load() {
+
+        File configFile = new File(CONFIG_FILE);
+        ServerConfig serverConfig;
+
+        if (!configFile.exists()) {
+            serverConfig = new ServerConfig();
+            serverConfig.save();
+
+            log.info("A new configuration has been created. You must edit './config/server-config.json' and restart.");
+            System.exit(0);
+        }
+        else {
+            serverConfig = JsonMapper.readFile(CONFIG_FILE, ServerConfig.class);
+        }
+
+        return serverConfig;
+    }
+
+
+
+
+}

+ 22 - 0
src/main/java/com/jayfella/website/config/external/WebsiteConfig.java

@@ -0,0 +1,22 @@
+package com.jayfella.website.config.external;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class WebsiteConfig {
+
+    private boolean registrationDisabled = false;
+
+    // determines whether or not emails get sent out.
+    private boolean emailEnabled = true;
+
+    WebsiteConfig() {
+    }
+
+    public boolean isRegistrationDisabled() { return registrationDisabled; }
+    public void setRegistrationDisabled(boolean registrationDisabled) { this.registrationDisabled = registrationDisabled; }
+
+    @JsonProperty("email-enabled")
+    public boolean isEmailEnabled() { return emailEnabled; }
+    public void setEmailEnabled(boolean emailEnabled) { this.emailEnabled = emailEnabled; }
+
+}

+ 29 - 0
src/main/java/com/jayfella/website/controller/SitemapController.java

@@ -0,0 +1,29 @@
+package com.jayfella.website.controller;
+
+import com.jayfella.website.service.SitemapService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import java.io.IOException;
+import java.nio.file.Files;
+
+import static org.springframework.http.MediaType.APPLICATION_XML_VALUE;
+
+@Controller
+public class SitemapController {
+
+
+    @Autowired
+    private SitemapService sitemapService;
+
+    @RequestMapping(path = "/sitemap.xml", produces = APPLICATION_XML_VALUE)
+    public @ResponseBody String get() throws IOException {
+
+        String sitemap = Files.readString(SitemapService.SITEMAP_FULL_FILE.toPath());
+        return sitemap;
+
+    }
+
+}

+ 298 - 0
src/main/java/com/jayfella/website/controller/api/ApiApprovalController.java

@@ -0,0 +1,298 @@
+package com.jayfella.website.controller.api;
+
+import com.jayfella.website.core.ApiResponses;
+import com.jayfella.website.core.page.PageState;
+import com.jayfella.website.core.page.ReviewState;
+import com.jayfella.website.database.entity.page.stages.LivePage;
+import com.jayfella.website.database.entity.page.stages.PageAmendment;
+import com.jayfella.website.database.entity.page.stages.PageDraft;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.repository.CategoryRepository;
+import com.jayfella.website.database.repository.StaffPageReviewRepository;
+import com.jayfella.website.database.repository.page.LivePageRepository;
+import com.jayfella.website.database.repository.page.PageAmendmentRepository;
+import com.jayfella.website.database.repository.page.PageDraftRepository;
+import com.jayfella.website.http.request.SimplePageRequest;
+import com.jayfella.website.http.response.SimpleApiResponse;
+import com.jayfella.website.service.EmailService;
+import com.jayfella.website.service.ImageService;
+import com.jayfella.website.service.PageService;
+import com.jayfella.website.service.SitemapService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.ui.ModelMap;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.mail.MessagingException;
+import javax.validation.Valid;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.util.List;
+
+import static com.jayfella.website.core.ServerAdvice.KEY_USER;
+
+@RestController
+@RequestMapping("/api/page/approve/")
+public class ApiApprovalController {
+
+    @Autowired private PageDraftRepository draftRepository;
+    @Autowired private LivePageRepository livePageRepository;
+    @Autowired private PageAmendmentRepository amendmentRepository;
+
+    @Autowired private StaffPageReviewRepository staffPageReviewRepository;
+
+    @Autowired private EmailService emailService;
+    @Autowired private PageService pageService;
+    @Autowired private ImageService imageService;
+    @Autowired private SitemapService sitemapService;
+
+    @Autowired private CategoryRepository categoryRepository;
+
+    @PostMapping("/draft/")
+    public ResponseEntity<?> userRequestDraftReview(ModelMap model, @ModelAttribute @Valid SimplePageRequest approveRequest, BindingResult bindingResult) throws IOException {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        PageDraft draft = draftRepository.findById(approveRequest.getPageId()).orElse(null);
+
+        if (draft == null) {
+            return ApiResponses.pageNotFound(PageState.Draft, approveRequest.getPageId());
+        }
+
+        if (!pageService.isOwnerOrModerator(user, draft)) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        List<String> errors = pageService.validatePage(draft);
+
+        if (errors.isEmpty()) {
+
+            if (user.isModerator() || user.isAdministrator() || user.getTrustLevel() == 2) {
+                LivePage livePage = approveDraft(draft);
+
+                return ResponseEntity.ok()
+                        .body(livePage);
+                        // .body(new SimpleApiResponse("Your page has been automatically accepted."));
+            }
+            else {
+
+                draft.setReviewState(ReviewState.Review_Requested);
+                draftRepository.save(draft);
+
+                // email staff..
+                emailService.notifyStaffReviewRequested(draft);
+
+                return ResponseEntity.ok()
+                        .body(new SimpleApiResponse("Your page is now under review."));
+            }
+
+        } else {
+            return ResponseEntity.badRequest()
+                    .body(new SimpleApiResponse("Unable to process review request:", errors));
+        }
+
+    }
+
+    @PostMapping("/draft/accept/")
+    public ResponseEntity staffApproveDraft(ModelMap model,
+                                                @ModelAttribute @Valid SimplePageRequest approveRequest, BindingResult bindingResult) throws IOException {
+
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (!(user.isAdministrator() || user.isModerator())) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        PageDraft draft = draftRepository.findById(approveRequest.getPageId()).orElse(null);
+
+        if (draft == null) {
+            return ApiResponses.pageNotFound(PageState.Draft, approveRequest.getPageId());
+        }
+
+        /*
+        LivePage livePage = new LivePage(draft, imageService, categoryRepository);
+        livePageRepository.save(livePage);
+
+        pageService.delete(draft);
+
+        // delete all staff reviews.
+        staffPageReviewRepository.deleteByPageId(draft.getId());
+
+        try {
+            emailService.sendApprovalEmail(livePage, false);
+        } catch (MessagingException | UnsupportedEncodingException e) {
+
+            // @TODO: notify an administrator that an email triggered an exception.
+            e.printStackTrace();
+        }
+
+         */
+
+        approveDraft(draft);
+
+        return ApiResponses.staffApprovalSuccess(draft);
+
+    }
+
+    private LivePage approveDraft(PageDraft draft) throws IOException {
+
+        LivePage livePage = new LivePage(draft, imageService, categoryRepository);
+        livePageRepository.save(livePage);
+
+        pageService.delete(draft);
+
+        // delete all staff reviews.
+        staffPageReviewRepository.deleteByPageId(draft.getId());
+
+        try {
+            emailService.sendApprovalEmail(livePage, false);
+        } catch (MessagingException | UnsupportedEncodingException e) {
+
+            // @TODO: notify an administrator that an email triggered an exception.
+            e.printStackTrace();
+        }
+
+        // update the sitemap. We only do this when NEW pages have been added.
+        sitemapService.generateSiteMap();
+
+        return livePage;
+    }
+
+    @PostMapping("/amendment/")
+    public ResponseEntity userRequestAmendmentReview(ModelMap model,
+                                                     @ModelAttribute @Valid SimplePageRequest approveRequest,
+                                                     BindingResult bindingResult) throws IOException {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        PageAmendment amendment = amendmentRepository.findById(approveRequest.getPageId()).orElse(null);
+
+        if (amendment == null) {
+            return ApiResponses.pageNotFound(PageState.Amendment, approveRequest.getPageId());
+        }
+
+        if (!pageService.isOwnerOrModerator(user, amendment)) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        List<String> errors = pageService.validatePage(amendment);
+
+        if (errors.isEmpty()) {
+
+            if (user.isModerator() || user.isAdministrator() || user.getTrustLevel() > 0) {
+
+                LivePage livePage = approveAmendment(amendment, approveRequest);
+
+                return ResponseEntity.ok()
+                        .body(livePage);
+            }
+            else {
+
+                amendment.setReviewState(ReviewState.Review_Requested);
+                amendmentRepository.save(amendment);
+
+                // email staff..
+                emailService.notifyStaffReviewRequested(amendment);
+
+                return ResponseEntity.ok()
+                        .body(new SimpleApiResponse("Your page is now under review."));
+            }
+
+        } else {
+            return ResponseEntity.badRequest()
+                    .body(new SimpleApiResponse("Unable to process review request:", errors));
+        }
+    }
+
+    @PostMapping("/amendment/accept/")
+    public ResponseEntity staffApproveAmendment(ModelMap model,
+                                                @ModelAttribute @Valid SimplePageRequest approveRequest,
+                                                BindingResult bindingResult) throws IOException {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (!(user.isAdministrator() || user.isModerator())) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        PageAmendment amendment = amendmentRepository.findById(approveRequest.getPageId()).orElse(null);
+
+        if (amendment == null) {
+            return ApiResponses.pageNotFound(PageState.Amendment, approveRequest.getPageId());
+        }
+
+        LivePage livePage = approveAmendment(amendment, approveRequest);
+
+        if (livePage == null) {
+            return ApiResponses.parentPageNotFound(approveRequest.getPageId());
+        }
+
+        return ApiResponses.staffApprovalSuccess(livePage);
+
+    }
+
+    private LivePage approveAmendment(PageAmendment amendment, SimplePageRequest approveRequest) throws IOException {
+
+        LivePage livePage = livePageRepository.findById(amendment.getParentPageId()).orElse(null);
+
+        if (livePage == null) {
+            // return ApiResponses.parentPageNotFound(approveRequest.getPageId());
+            return null;
+        }
+
+        livePage.updateFrom(amendment, imageService, categoryRepository);
+        livePageRepository.save(livePage);
+        pageService.delete(amendment);
+
+        // delete all staff reviews.
+        staffPageReviewRepository.deleteByPageId(amendment.getId());
+
+        try {
+            emailService.sendApprovalEmail(livePage, true);
+        }
+        catch (MessagingException | UnsupportedEncodingException e) {
+            // @TODO: notify an administrator that an email triggered an exception.
+            e.printStackTrace();
+        }
+
+        return livePage;
+    }
+
+}

+ 116 - 0
src/main/java/com/jayfella/website/controller/api/ApiAvatarController.java

@@ -0,0 +1,116 @@
+package com.jayfella.website.controller.api;
+
+import com.jayfella.website.core.ApiResponses;
+import com.jayfella.website.core.ImageDownloader;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.repository.UserRepository;
+import com.jayfella.website.exception.InvalidImageException;
+import com.jayfella.website.http.request.user.avatar.ChangeGravatarAvatarRequest;
+import com.jayfella.website.http.request.user.avatar.ChangeSystemManagedAvatarRequest;
+import com.jayfella.website.http.response.SimpleApiResponse;
+import com.jayfella.website.service.ImageService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.ui.ModelMap;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+
+import static com.jayfella.website.core.ServerAdvice.KEY_USER;
+
+@RestController
+@RequestMapping("/api/avatar/")
+public class ApiAvatarController {
+
+    @Autowired
+    private UserRepository userRepository;
+    @Autowired private ImageService imageService;
+
+    @PostMapping(path = "/system-managed/")
+    public ResponseEntity updateSystemManagedAvatar(ModelMap model,
+                                                    @RequestBody ChangeSystemManagedAvatarRequest csmaRequest) throws IOException {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        String usedName = (user.getName() == null || user.getName().isEmpty())
+                ? user.getUsername()
+                : user.getName();
+
+        byte[] imageData = ImageDownloader.downloadUiAvatar(
+                128,
+                0.5f,
+                2,
+                usedName,
+                false,
+                csmaRequest.isBold(),
+                csmaRequest.getBackgroundColor(),
+                csmaRequest.getForegroundColor(),
+                csmaRequest.isUppercase());
+
+        return updateUserAvatar(user, imageData);
+    }
+
+    @PostMapping(path = "/gravatar/")
+    public ResponseEntity updateGravatarAvatar(ModelMap model,
+                                               @RequestBody ChangeGravatarAvatarRequest cgaRequest) throws IOException {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        byte[] imageData = ImageDownloader.downloadGravatarAvatar(cgaRequest.getEmailHash());
+        return updateUserAvatar(user, imageData);
+    }
+
+    @PostMapping(path = "/custom/")
+    public ResponseEntity updateCustomAvatar(@RequestParam(value = "avatar") MultipartFile multipartFile,
+                                             ModelMap model) throws IOException, InvalidImageException {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        boolean isValidImage = imageService.isValidImageMultipartFile(multipartFile);
+
+        if (isValidImage) {
+            int[] dimensions = imageService.getDimensions(multipartFile);
+
+            if (dimensions[0] > 320 || dimensions[1] > 320) {
+                throw new InvalidImageException("Image dimensions must be 1280x720.");
+            }
+        }
+
+        byte[] imageData = multipartFile.getBytes();
+        return updateUserAvatar(user, imageData);
+    }
+
+    private ResponseEntity<?> updateUserAvatar(User user, byte[] imageData) throws IOException {
+
+        // every time we update the avatar, we need a new ID to avoid any caching issues.
+        // we're not re-using the table because we want a new ID.
+        // if we don't, the image may be cached on the client browser and cause all manner of weird behavior.
+
+        if (user.getAvatarId() != null && !user.getAvatarId().isEmpty()) {
+            imageService.deleteImage(user.getAvatarId() + ImageService.IMAGE_EXT);
+        }
+
+        String newImageFilename = imageService.create(imageData);
+
+        user.setAvatarId(imageService.removeImageExtension(newImageFilename));
+        userRepository.save(user);
+
+        return ResponseEntity.ok()
+                .body(new SimpleApiResponse(newImageFilename));
+
+    }
+
+}

+ 218 - 0
src/main/java/com/jayfella/website/controller/api/ApiBadgeController.java

@@ -0,0 +1,218 @@
+package com.jayfella.website.controller.api;
+
+import com.jayfella.website.core.ApiResponses;
+import com.jayfella.website.database.entity.Badge;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.repository.BadgeRepository;
+import com.jayfella.website.database.repository.UserRepository;
+import com.jayfella.website.http.request.badge.CreateBadgeRequest;
+import com.jayfella.website.http.request.badge.DeleteBadgeRequest;
+import com.jayfella.website.http.request.badge.UpdateBadgeRequest;
+import com.jayfella.website.http.request.badge.UserBadgeRequest;
+import com.jayfella.website.http.response.SimpleApiResponse;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.ui.ModelMap;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.*;
+
+import javax.validation.Valid;
+import java.util.ArrayList;
+
+import static com.jayfella.website.core.ServerAdvice.KEY_USER;
+
+/**
+ * CRUD Repository for Badges
+ */
+@RestController
+@RequestMapping("/api/badges/")
+public class ApiBadgeController {
+
+    @Autowired private BadgeRepository badgeRepository;
+    @Autowired private UserRepository userRepository;
+
+    @PostMapping
+    public ResponseEntity create(ModelMap model,
+                                 @ModelAttribute @Valid CreateBadgeRequest createBadgeRequest,
+                                 BindingResult bindingResult) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (!user.isAdministrator()) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        Badge badge = new Badge(createBadgeRequest);
+        badgeRepository.save(badge);
+
+        return ResponseEntity.ok()
+                .body(badge);
+    }
+
+    @GetMapping
+    public ResponseEntity read() {
+
+        Iterable<Badge> badges = badgeRepository.findAll();
+        return new ResponseEntity<>(badges, HttpStatus.OK);
+    }
+
+    @PutMapping
+    public ResponseEntity update(ModelMap model,
+                                 @ModelAttribute @Valid UpdateBadgeRequest updateBadgeRequest,
+                                 BindingResult bindingResult) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (!user.isAdministrator()) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        Badge badge = badgeRepository.findById(updateBadgeRequest.getId()).orElse(null);
+
+        if (badge == null) {
+            return new ResponseEntity<>(new SimpleApiResponse("Cannot locate badge with id: " + updateBadgeRequest.getId()), HttpStatus.BAD_REQUEST);
+        }
+
+        badge.setName(updateBadgeRequest.getName());
+        badge.setDescription(updateBadgeRequest.getDescription());
+        badge.setIcon(updateBadgeRequest.getIcon());
+
+        badgeRepository.save(badge);
+
+        return ResponseEntity.ok()
+                .body(badge);
+    }
+
+    @DeleteMapping
+    public ResponseEntity delete(ModelMap model,
+                                 @ModelAttribute @Valid DeleteBadgeRequest deleteBadgeRequest,
+                                 BindingResult bindingResult) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (!user.isAdministrator()) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        Badge badge = badgeRepository.findById(deleteBadgeRequest.getBadgeId()).orElse(null);
+
+        if (badge == null) {
+            return new ResponseEntity<>(new SimpleApiResponse("Cannot find badge with id: " + deleteBadgeRequest.getBadgeId()), HttpStatus.BAD_REQUEST);
+        }
+
+        badgeRepository.delete(badge);
+
+        return ResponseEntity.ok()
+                .body(new SimpleApiResponse("Badge deleted."));
+    }
+
+    @PostMapping("/grant/")
+    public ResponseEntity grantBadge(ModelMap model,
+                                     @ModelAttribute @Valid UserBadgeRequest grantRequest,
+                                     BindingResult bindingResult) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (!user.isAdministrator()) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        Badge badge = badgeRepository.findById(grantRequest.getBadgeId()).orElse(null);
+
+        if (badge == null) {
+            return new ResponseEntity<>(new SimpleApiResponse("Cannot find badge with id: " + grantRequest.getBadgeId()), HttpStatus.BAD_REQUEST);
+        }
+
+        User userToGrant = userRepository.findById(grantRequest.getUserId()).orElse(null);
+
+        if (userToGrant == null) {
+            return ApiResponses.userNotFound(grantRequest.getUserId());
+        }
+
+        if (userToGrant.getBadges() == null) {
+            userToGrant.setBadges(new ArrayList<>());
+        }
+
+        userToGrant.getBadges().add(badge);
+        userRepository.save(userToGrant);
+
+        return ResponseEntity.ok()
+                .body(badge);
+    }
+
+    @PostMapping("/revoke/")
+    public ResponseEntity revokeBadge(ModelMap model,
+                                      @ModelAttribute @Valid UserBadgeRequest revokeRequest,
+                                      BindingResult bindingResult) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (!user.isAdministrator()) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        Badge badge = badgeRepository.findById(revokeRequest.getBadgeId()).orElse(null);
+
+        if (badge == null) {
+            return new ResponseEntity<>(new SimpleApiResponse("Cannot find badge with id: " + revokeRequest.getBadgeId()), HttpStatus.BAD_REQUEST);
+        }
+
+        User userToRevoke = userRepository.findById(revokeRequest.getUserId()).orElse(null);
+
+        if (userToRevoke == null) {
+            return ApiResponses.userNotFound(revokeRequest.getUserId());
+        }
+
+        if (userToRevoke.getBadges() == null || !userToRevoke.getBadges().contains(badge)) {
+            return new ResponseEntity<>(new SimpleApiResponse("User does not have badge: " + badge.getName()), HttpStatus.BAD_REQUEST);
+        }
+
+        userToRevoke.getBadges().remove(badge);
+        userRepository.save(userToRevoke);
+
+        return ResponseEntity.ok()
+                .body(badge);
+
+    }
+}

+ 49 - 0
src/main/java/com/jayfella/website/controller/api/ApiBlobController.java

@@ -0,0 +1,49 @@
+package com.jayfella.website.controller.api;
+
+import com.jayfella.website.database.entity.Category;
+import com.jayfella.website.database.repository.CategoryRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.ui.ModelMap;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.*;
+
+@RestController
+@RequestMapping("/api/blob/")
+public class ApiBlobController {
+
+    @Autowired private CategoryRepository categoryRepository;
+
+    @GetMapping
+    public ResponseEntity<?> getBlob(ModelMap model, HttpServletRequest request) {
+
+        Map<String, Object> data = new HashMap<>();
+
+        processCategories(data, request);
+
+        return ResponseEntity.ok()
+                .body(data);
+    }
+
+    private void processCategories(Map<String, Object> data, HttpServletRequest request) {
+
+        String[] categories = request.getParameterValues("category");
+        List<Category> found = new ArrayList<>();
+
+        boolean getAll = Arrays.stream(categories).anyMatch(cat -> cat.equalsIgnoreCase("all"));
+
+        if (getAll) {
+            found.addAll(categoryRepository.findAll());
+        }
+        else {
+            found.addAll(categoryRepository.findByNameIgnoreCaseIn(categories));
+        }
+
+        data.put("categories", found);
+    }
+
+}

+ 171 - 0
src/main/java/com/jayfella/website/controller/api/ApiCategoryController.java

@@ -0,0 +1,171 @@
+package com.jayfella.website.controller.api;
+
+import com.jayfella.website.core.ApiResponses;
+import com.jayfella.website.database.entity.Category;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.repository.CategoryRepository;
+import com.jayfella.website.http.request.category.SimpleCategoryRequest;
+import com.jayfella.website.http.response.SimpleApiResponse;
+import org.apache.commons.codec.binary.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.ui.ModelMap;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.*;
+
+import javax.validation.Valid;
+
+import static com.jayfella.website.core.ServerAdvice.KEY_USER;
+
+@RestController
+@RequestMapping("/api/category/")
+public class ApiCategoryController {
+
+    @Autowired private CategoryRepository categoryRepository;
+
+    @PostMapping()
+    public ResponseEntity<?> createCategory(ModelMap model,
+                                            @ModelAttribute @Valid SimpleCategoryRequest request,
+                                            BindingResult bindingResult) {
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (!(user.isAdministrator() || user.isModerator())) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        if (request.getName().isBlank()) {
+            return ResponseEntity.badRequest()
+                    .body(new SimpleApiResponse("You must provide a name."));
+        }
+
+        Category category = new Category(request.getName());
+
+        if (request.getParentId() > 0) {
+            Category parent = categoryRepository.findById(request.getParentId()).orElse(null);
+
+            if (parent != null) {
+                category.setParent(parent);
+            }
+            else {
+                return ResponseEntity.badRequest()
+                        .body(new SimpleApiResponse("Could not find parent with ID: " + request.getParentId()));
+            }
+        }
+
+        categoryRepository.save(category);
+
+        return ResponseEntity.ok()
+                .body(category);
+    }
+
+    @DeleteMapping
+    public ResponseEntity<?> deleteCategory(ModelMap model,
+                                            @ModelAttribute  @Valid SimpleCategoryRequest request,
+                                            BindingResult bindingResult) {
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (!(user.isAdministrator() || user.isModerator())) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        Category category = categoryRepository.findById(request.getId()).orElse(null);
+
+        if (category == null) {
+            return ResponseEntity.badRequest()
+                    .body(new SimpleApiResponse("Category not found."));
+        }
+
+        categoryRepository.delete(category);
+
+        return ResponseEntity.ok()
+                .body(category);
+    }
+
+    @PutMapping
+    public ResponseEntity<?> updateCategory(ModelMap model,
+                                            @ModelAttribute @Valid SimpleCategoryRequest request,
+                                            BindingResult bindingResult) {
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (!(user.isAdministrator() || user.isModerator())) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        Category category = categoryRepository.findById(request.getId()).orElse(null);
+
+        if (category == null) {
+            return ResponseEntity.badRequest()
+                    .body(new SimpleApiResponse("Cannot find category with ID: " + request.getId()));
+        }
+
+        if (!StringUtils.equals(request.getName(), category.getName())) {
+            category.setName(request.getName());
+        }
+
+        if (request.getParentId() > 0) {
+            Category parent = categoryRepository.findById(request.getParentId()).orElse(null);
+
+            if (parent != null) {
+                category.setParent(parent);
+            }
+            else {
+                return ResponseEntity.badRequest()
+                        .body(new SimpleApiResponse("Could not find parent with ID: " + request.getParentId()));
+            }
+
+            if (isCircularReference(category, parent)) {
+                return ResponseEntity.badRequest()
+                        .body(new SimpleApiResponse("Circular reference."));
+            }
+        }
+
+        categoryRepository.save(category);
+
+        return ResponseEntity.ok()
+                .body(category);
+    }
+
+    // https://stackoverflow.com/a/30958018
+    private boolean isCircularReference(Category child, Category parent)
+    {
+        // $node = $parent;
+        Category node = parent;
+        while(node != null)
+        {
+            if(node.getId() == child.getId()) {
+                return true;  //Looped back around to the child
+            }
+
+            node = node.getParent();
+        }
+        return false;  //No loops found
+    }
+
+}

+ 178 - 0
src/main/java/com/jayfella/website/controller/api/ApiMessagesController.java

@@ -0,0 +1,178 @@
+package com.jayfella.website.controller.api;
+
+import com.jayfella.website.core.ApiResponses;
+import com.jayfella.website.database.entity.message.Message;
+import com.jayfella.website.database.entity.message.MessageReply;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.repository.MessageReplyRepository;
+import com.jayfella.website.database.repository.MessagesRepository;
+import com.jayfella.website.database.repository.UserRepository;
+import com.jayfella.website.http.request.message.NewMessageRequest;
+import com.jayfella.website.http.request.message.NewReplyRequest;
+import com.jayfella.website.http.response.SimpleApiResponse;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.ui.ModelMap;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.*;
+
+import javax.validation.Valid;
+
+import static com.jayfella.website.core.ServerAdvice.KEY_USER;
+
+@RestController
+@RequestMapping(value = "/api/messages/", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
+public class ApiMessagesController {
+
+    @Autowired private UserRepository userRepository;
+    @Autowired private MessagesRepository messagesRepository;
+    @Autowired private MessageReplyRepository replyRepository;
+
+    @GetMapping("/{messageId}")
+    public ResponseEntity getSingleMessage(ModelMap model,
+                                           @PathVariable("messageId") long messageId) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        Message message = messagesRepository.findById(messageId).orElse(null);
+
+        if (message == null) {
+            return new ResponseEntity<>(new SimpleApiResponse("The requested message does not exist."), HttpStatus.NOT_FOUND);
+        }
+
+        if (!user.equals(message.getSender()) && !user.equals(message.getRecipient())) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        if (!message.isDelivered()) {
+            message.setDelivered(true);
+            messagesRepository.save(message);
+        }
+
+        return new ResponseEntity<>(message, HttpStatus.OK);
+    }
+
+    // USER get all messages
+    @GetMapping
+    public ResponseEntity getUserMessages(ModelMap model) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        Iterable<Message> messages = messagesRepository.findByRecipientOrderByDateDesc(user);
+
+        return new ResponseEntity<>(messages, HttpStatus.OK);
+    }
+
+    // USER post new message
+    @PostMapping
+    public ResponseEntity createMessage(ModelMap model,
+                                        @ModelAttribute @Valid NewMessageRequest newMessageRequest,
+                                        BindingResult bindingResult) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        User recipient = userRepository.findByUsernameIgnoreCase(newMessageRequest.getRecipient()).orElse(null);
+
+        if (recipient == null) {
+            return new ResponseEntity<>(new SimpleApiResponse("Could not locate username: " + newMessageRequest.getRecipient()), HttpStatus.NOT_FOUND);
+        }
+
+        Message message = new Message();
+        message.setDate(System.currentTimeMillis());
+        message.setTitle(newMessageRequest.getTitle());
+        message.setMessage(newMessageRequest.getContent());
+
+        message.setSender(user);
+        message.setRecipient(recipient);
+
+        messagesRepository.save(message);
+
+        return new ResponseEntity<>(new SimpleApiResponse("Message sent successfully."), HttpStatus.OK);
+
+    }
+
+    // USER create new reply
+    @PostMapping("/reply/")
+    public ResponseEntity createReply(ModelMap model,
+                                      @ModelAttribute @Valid NewReplyRequest newReplyRequest,
+                                      BindingResult bindingResult) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        // check if the request has errors first so we can avoid a database lookup if it's wrong.
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        Message message = messagesRepository.findById(newReplyRequest.getMessageId()).orElse(null);
+
+        if (message == null) {
+            return new ResponseEntity<>(new SimpleApiResponse("The specified message does not exist."), HttpStatus.NOT_FOUND);
+        }
+
+        if (!user.equals(message.getSender()) || !user.equals(message.getRecipient())) {
+            return new ResponseEntity<>(new SimpleApiResponse("You do not have permission to participate in this message."), HttpStatus.FORBIDDEN);
+        }
+
+        MessageReply reply = new MessageReply();
+        reply.setContent(newReplyRequest.getContent());
+        reply.setDate(System.currentTimeMillis());
+        reply.setMessage(message);
+        reply.setUser(user);
+
+        replyRepository.save(reply);
+
+        return new ResponseEntity<>(reply, HttpStatus.OK);
+
+    }
+
+    // USER get replies to a message
+    @GetMapping("/replies/{messageId}")
+    public ResponseEntity getReplies(ModelMap model,
+                                     @PathVariable("messageId") long messageId) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        Message message = messagesRepository.findById(messageId).orElse(null);
+
+        if (message == null) {
+            return new ResponseEntity<>(new SimpleApiResponse("This message does not exist."), HttpStatus.NOT_FOUND);
+        }
+
+        if (!user.equals(message.getSender()) || !user.equals(message.getRecipient())) {
+            return new ResponseEntity<>(new SimpleApiResponse("You do not have permission to view the replies to this message"), HttpStatus.FORBIDDEN);
+        }
+
+        //List<MessageReply> replies = message.getReplies();
+        Iterable<MessageReply> replies = replyRepository.findByMessage(message);
+
+        return new ResponseEntity<>(replies, HttpStatus.OK);
+    }
+
+}

+ 121 - 0
src/main/java/com/jayfella/website/controller/api/ApiRejectionController.java

@@ -0,0 +1,121 @@
+package com.jayfella.website.controller.api;
+
+import com.jayfella.website.core.ApiResponses;
+import com.jayfella.website.core.page.PageState;
+import com.jayfella.website.core.page.ReviewState;
+import com.jayfella.website.database.entity.page.StaffPageReview;
+import com.jayfella.website.database.entity.page.stages.PageAmendment;
+import com.jayfella.website.database.entity.page.stages.PageDraft;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.repository.StaffPageReviewRepository;
+import com.jayfella.website.database.repository.page.LivePageRepository;
+import com.jayfella.website.database.repository.page.PageAmendmentRepository;
+import com.jayfella.website.database.repository.page.PageDraftRepository;
+import com.jayfella.website.http.request.StaffRejectionRequest;
+import com.jayfella.website.service.EmailService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.ui.ModelMap;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.mail.MessagingException;
+import javax.validation.Valid;
+import java.io.UnsupportedEncodingException;
+
+import static com.jayfella.website.core.ServerAdvice.KEY_USER;
+
+@RestController
+@RequestMapping("/api/reject/")
+public class ApiRejectionController {
+
+    private static final Logger log = LoggerFactory.getLogger(ApiRejectionController.class);
+
+    @Autowired private PageDraftRepository draftRepository;
+    @Autowired private LivePageRepository livePageRepository;
+    @Autowired private PageAmendmentRepository amendmentRepository;
+
+    @Autowired private StaffPageReviewRepository staffPageReviewRepository;
+
+    @Autowired private EmailService emailService;
+
+    // STAFF reject draft submission
+    @PostMapping("/draft/")
+    public ResponseEntity rejectDraft(ModelMap model, @ModelAttribute @Valid StaffRejectionRequest rejectRequest, BindingResult bindingResult) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        if (!(user.isAdministrator() || user.isModerator())) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        PageDraft draft = draftRepository.findById(rejectRequest.getPageId()).orElse(null);
+
+        if (draft == null) {
+            return ApiResponses.pageNotFound(PageState.Draft, rejectRequest.getPageId());
+        }
+
+        StaffPageReview staffReview = new StaffPageReview(user, rejectRequest.getReason(), draft.getId(), PageState.Draft);
+        staffPageReviewRepository.save(staffReview);
+
+        draft.setReviewState(ReviewState.Rejected);
+        draft.setReviewer(null);
+        draftRepository.save(draft);
+
+        try {
+            emailService.sendRejectionEmail(draft, rejectRequest.getReason());
+        } catch (UnsupportedEncodingException | MessagingException e) {
+            e.printStackTrace();
+        }
+
+        return ApiResponses.pageRejected(draft);
+    }
+
+    @PostMapping("/amendment/")
+    public ResponseEntity rejectAmendment(ModelMap model, @ModelAttribute @Valid StaffRejectionRequest rejectRequest, BindingResult bindingResult) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        if (!(user.isAdministrator() || user.isModerator())) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        PageAmendment amendment = amendmentRepository.findById(rejectRequest.getPageId()).orElse(null);
+
+        if (amendment == null) {
+            return ApiResponses.pageNotFound(PageState.Amendment, rejectRequest.getPageId());
+        }
+
+        StaffPageReview staffReview = new StaffPageReview(user, rejectRequest.getReason(), amendment.getId(), PageState.Amendment);
+        staffPageReviewRepository.save(staffReview);
+
+        amendment.setReviewState(ReviewState.Rejected);
+        amendment.setReviewer(null);
+        amendmentRepository.save(amendment);
+
+
+        return ApiResponses.pageRejected(amendment);
+
+    }
+}

+ 242 - 0
src/main/java/com/jayfella/website/controller/api/ApiReviewController.java

@@ -0,0 +1,242 @@
+package com.jayfella.website.controller.api;
+
+import com.jayfella.website.core.ApiResponses;
+import com.jayfella.website.core.page.PageState;
+import com.jayfella.website.database.entity.page.PageReview;
+import com.jayfella.website.database.entity.page.stages.LivePage;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.repository.ReviewRepository;
+import com.jayfella.website.database.repository.UserRepository;
+import com.jayfella.website.database.repository.page.LivePageRepository;
+import com.jayfella.website.http.request.SimplePageRequest;
+import com.jayfella.website.http.request.review.ReviewRequest;
+import com.jayfella.website.http.response.SimpleApiResponse;
+import com.jayfella.website.service.PageService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.ui.ModelMap;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.*;
+
+import javax.validation.Valid;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Collections;
+import java.util.List;
+
+import static com.jayfella.website.core.ServerAdvice.KEY_USER;
+
+/**
+ * CRUD Repository for Reviews
+ */
+
+@RestController()
+@RequestMapping("/api/review/")
+public class ApiReviewController {
+
+    // @Autowired private OpenSourceAssetRepository openSourceAssetRepository;
+    @Autowired private LivePageRepository livePageRepository;
+    @Autowired private UserRepository userRepository;
+    @Autowired private ReviewRepository reviewRepository;
+
+    @Autowired private PageService pageService;
+
+    @GetMapping("/{reviewId}")
+    public ResponseEntity getReview(@PathVariable("reviewId") long reviewId) {
+
+        PageReview review = reviewRepository.findById(reviewId).orElse(null);
+
+        if (review != null) {
+            return ResponseEntity.ok()
+                    .body(review);
+        }
+        else {
+            return ResponseEntity.notFound().build();
+        }
+    }
+
+    @GetMapping("/page/{pageId}")
+    public ResponseEntity getAssetReviews(@PathVariable("pageId") String pageId) {
+
+        List<PageReview> pageReviews = reviewRepository.findByPageId(pageId);
+
+        if (pageReviews == null) {
+            pageReviews = Collections.emptyList();
+        }
+        else if (pageReviews.size() > 1) {
+            pageReviews.sort((o1, o2) -> o2.getDateCreated().compareTo(o1.getDateCreated()));
+        }
+
+        return ResponseEntity.ok()
+                .body(pageReviews);
+    }
+
+    @GetMapping("/user/")
+    public ResponseEntity getUserReviews(ModelMap model) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            ApiResponses.notLoggedIn();
+        }
+
+        List<PageReview> pageReviews = reviewRepository.findByUser(user);
+
+        if (pageReviews == null) {
+            pageReviews = Collections.emptyList();
+        }
+        else if (pageReviews.size() > 1) {
+            pageReviews.sort((o1, o2) -> o2.getDateCreated().compareTo(o1.getDateCreated()));
+        }
+
+        return ResponseEntity.ok()
+                .body(pageReviews);
+    }
+
+    @GetMapping("/user/{userId}")
+    public ResponseEntity getUserReviews(@PathVariable("userId") long userId) {
+
+        User user = userRepository.findById(userId).orElse(null);
+
+        if (user == null) {
+            ApiResponses.notLoggedIn();
+        }
+
+        List<PageReview> pageReviews = reviewRepository.findByUser(user);
+
+        if (pageReviews == null) {
+            pageReviews = Collections.emptyList();
+        }
+        else if (pageReviews.size() > 1) {
+            pageReviews.sort((o1, o2) -> o2.getDateCreated().compareTo(o1.getDateCreated()));
+        }
+
+        return ResponseEntity.ok()
+                .body(pageReviews);
+    }
+
+    @PostMapping
+    public ResponseEntity create(ModelMap model, @RequestBody ReviewRequest reviewRequest) throws URISyntaxException {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            ApiResponses.notLoggedIn();
+        }
+
+        LivePage livePage = livePageRepository.findById(reviewRequest.getPageId()).orElse(null);
+
+        if (livePage == null) {
+            return ApiResponses.pageNotFound(PageState.Live, reviewRequest.getPageId());
+        }
+
+        // has this user already left a pageReview?
+        PageReview pageReview = reviewRepository.findByPageIdAndUser(reviewRequest.getPageId(), user).orElse(null);
+
+        if (pageReview != null) {
+            return new ResponseEntity<>(new SimpleApiResponse("You have already reviewed this asset."), HttpStatus.FORBIDDEN);
+        }
+
+        pageReview = new PageReview();
+
+        pageReview.setPageId(livePage.getId());
+        pageReview.setContent(reviewRequest.getReviewContent());
+        pageReview.setRating(reviewRequest.getRating());
+        pageReview.setUser(user);
+
+        reviewRepository.save(pageReview);
+
+        livePage.getRating().addRating(reviewRequest.getRating());
+        livePageRepository.save(livePage);
+
+        return ResponseEntity.created(new URI("/api/review/" + pageReview.getId()))
+                .body(pageReview);
+    }
+
+    @PutMapping
+    public ResponseEntity update(ModelMap model, @RequestBody ReviewRequest reviewRequest) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        // LiveOpenSourceAsset liveOpenSourceAsset = openSourceAssetRepository.findById(reviewRequest.getAssetId()).orElse(null);
+        LivePage livePage = livePageRepository.findById(reviewRequest.getPageId()).orElse(null);
+
+        if (livePage == null) {
+            return ApiResponses.pageNotFound(PageState.Live, reviewRequest.getPageId());
+        }
+
+        PageReview pageReview = reviewRepository.findByPageIdAndUser(reviewRequest.getPageId(), user).orElse(null);
+
+        // the pageReview is found by searching
+        if (pageReview == null) {
+            return new ResponseEntity<>(new SimpleApiResponse("The pageReview your are attempting to edit does not exist or does not belong to you."), HttpStatus.NOT_FOUND);
+        }
+
+        if ( !(user.equals(pageReview.getUser()) || user.isAdministrator() || user.isModerator()) ) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        // only do the work if something actually changed.
+        if (!pageReview.getContent().equals(reviewRequest.getReviewContent()) || pageReview.getRating() != reviewRequest.getRating()) {
+
+            // set the new rating on the liveOpenSourceAsset
+            livePage.getRating().removeRating(pageReview.getRating());
+            livePage.getRating().addRating(reviewRequest.getRating());
+            livePageRepository.save(livePage);
+
+            // set the data for the new pageReview
+            pageReview.setRating(reviewRequest.getRating());
+            pageReview.setContent(reviewRequest.getReviewContent());
+            reviewRepository.save(pageReview);
+        }
+
+        return ResponseEntity.ok()
+                .body(pageReview);
+    }
+
+    @DeleteMapping
+    public ResponseEntity delete(ModelMap model,
+                                 @ModelAttribute @Valid SimplePageRequest deleteRequest,
+                                 BindingResult bindingResult) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        LivePage livePage = livePageRepository.findById(deleteRequest.getPageId()).orElse(null);
+
+        if (livePage == null) {
+            return ApiResponses.pageNotFound(PageState.Live, deleteRequest.getPageId());
+        }
+
+        PageReview pageReview = reviewRepository.findByPageIdAndUser(deleteRequest.getPageId(), user).orElse(null);
+
+        // the pageReview is found by searching
+        if (pageReview == null) {
+            return new ResponseEntity<>(new SimpleApiResponse("The pageReview your are attempting to remove does not exist."), HttpStatus.NOT_FOUND);
+        }
+
+        if ( !(user.equals(pageReview.getUser()) || user.isAdministrator() || user.isModerator()) ) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        livePage.getRating().removeRating(pageReview.getRating());
+        reviewRepository.delete(pageReview);
+        livePageRepository.save(livePage);
+
+        return ResponseEntity.ok()
+                .body(pageReview);
+    }
+
+}

+ 111 - 0
src/main/java/com/jayfella/website/controller/api/ApiReviewJobController.java

@@ -0,0 +1,111 @@
+package com.jayfella.website.controller.api;
+
+import com.jayfella.website.core.ApiResponses;
+import com.jayfella.website.core.EnumUtils;
+import com.jayfella.website.core.page.PageState;
+import com.jayfella.website.core.page.ReviewState;
+import com.jayfella.website.database.entity.page.Editable;
+import com.jayfella.website.database.entity.page.StorePage;
+import com.jayfella.website.database.entity.page.stages.PageAmendment;
+import com.jayfella.website.database.entity.page.stages.PageDraft;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.repository.page.PageAmendmentRepository;
+import com.jayfella.website.database.repository.page.PageDraftRepository;
+import com.jayfella.website.http.request.SimplePageRequest;
+import com.jayfella.website.http.response.SimpleApiResponse;
+import com.jayfella.website.service.EmailService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.ui.ModelMap;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.*;
+
+import javax.mail.MessagingException;
+import javax.validation.Valid;
+
+import java.io.UnsupportedEncodingException;
+
+import static com.jayfella.website.core.ServerAdvice.KEY_USER;
+
+@RestController
+@RequestMapping("/api/approve/job/")
+public class ApiReviewJobController {
+
+    @Autowired private PageDraftRepository draftRepository;
+    @Autowired private PageAmendmentRepository amendmentRepository;
+
+    @Autowired private EmailService emailService;
+
+    @PostMapping("/{pageState}/")
+    public ResponseEntity<?> takeJob(ModelMap model,
+                                     @PathVariable("pageState") String pageState,
+                                     @ModelAttribute @Valid SimplePageRequest jobRequest,
+                                     BindingResult bindingResult) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (!(user.isAdministrator() || user.isModerator())) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        PageState state = EnumUtils.fromString(PageState.class, pageState);
+
+        if (state == null || state == PageState.Live) {
+            return ResponseEntity.badRequest()
+                    .body(new SimpleApiResponse("Unsupported PageState: " + pageState));
+        }
+
+        // final StorePage storePage;
+        final Editable page;
+
+        switch (state) {
+            case Draft: page = draftRepository.findById(jobRequest.getPageId()).orElse(null); break;
+            case Amendment: page = amendmentRepository.findById(jobRequest.getPageId()).orElse(null); break;
+            default: page = null;
+        }
+
+        if (page == null) {
+            return ApiResponses.pageNotFound(state, jobRequest.getPageId());
+        }
+
+        // check if there is already a job;
+        if (page.getReviewer() != null) {
+            return ResponseEntity.badRequest()
+                    .body(new SimpleApiResponse("This page is already being reviewed by: " + page.getReviewer().getUsername()));
+        }
+
+        page.setReviewState(ReviewState.Under_Review);
+        page.setReviewer(user);
+
+        switch (state) {
+            case Draft: {
+                draftRepository.save((PageDraft) page);
+                break;
+            }
+            case Amendment: {
+                amendmentRepository.save((PageAmendment) page);
+                break;
+            }
+        }
+
+        try {
+            emailService.sentUnderReviewEmail((StorePage) page);
+        } catch (MessagingException | UnsupportedEncodingException e) {
+            // @TODO: notify an administrator that an email triggered an exception.
+            e.printStackTrace();
+        }
+
+        return ResponseEntity.ok()
+                .body(jobRequest);
+    }
+
+
+}

+ 163 - 0
src/main/java/com/jayfella/website/controller/api/ApiSearchController.java

@@ -0,0 +1,163 @@
+package com.jayfella.website.controller.api;
+
+import com.jayfella.website.database.entity.page.stages.LivePage;
+import com.jayfella.website.database.repository.CategoryRepository;
+import com.jayfella.website.database.repository.page.LivePageRepository;
+import com.jayfella.website.http.response.SimpleApiResponse;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/search/")
+public class ApiSearchController {
+
+    @Autowired private CategoryRepository categoryRepository;
+    @Autowired private LivePageRepository livePageRepository;
+
+    @GetMapping
+    public ResponseEntity<?> searchParam(@RequestParam(required = false, defaultValue = "-1", value = "categoryId") int categoryId,
+                                         @RequestParam(required = false, defaultValue = "", value = "title") String title,
+                                         @RequestParam(required = false, defaultValue = "", value = "tag") String tag,
+                                         @RequestParam(required = false, defaultValue = "", value = "author") String author,
+                                         @RequestParam(required = false, defaultValue = "0", value = "page") int pageNum,
+                                         @RequestParam(required = false, defaultValue = "newest", value="orderBy") String orderBy,
+                                         @RequestParam(required = false, defaultValue = "descending", value = "direction") String direction) {
+
+        // order by
+        Map<String, String> allowedProps = new HashMap<>();
+        allowedProps.put("title", "details.title");
+        allowedProps.put("created", "dateCreated");
+        allowedProps.put("updated", "dateUpdated");
+        allowedProps.put("rating", "rating.averageRating");
+
+        boolean orderByFound = false;
+
+        for (Map.Entry<String, String> entry : allowedProps.entrySet()) {
+
+            String key = entry.getKey();
+
+            if (orderBy.equalsIgnoreCase(key)) {
+                orderBy = entry.getValue();
+                orderByFound = true;
+                break;
+            }
+        }
+
+        if (!orderByFound) {
+            orderBy = allowedProps.get("title");
+        }
+
+        // sort direction
+        final Sort.Direction sortDir = direction.equalsIgnoreCase("ascending")
+                ? Sort.Direction.ASC
+                : Sort.Direction.DESC;
+
+        final int itemsPerPage = 25;
+        Pageable pageable = PageRequest.of(pageNum, itemsPerPage, Sort.by(sortDir, orderBy));
+
+        boolean useCategory = categoryId > 0;
+        boolean useTitle = !title.isBlank();
+        boolean useTag = !tag.isBlank();
+        boolean useAuthor = !author.isBlank();
+
+        Page<LivePage> page = null;
+
+        // category specified
+        if (useCategory) {
+
+            // category only
+            if (!useTitle && !useTag && !useAuthor) {
+                page = livePageRepository.findByCategoryId(categoryId, pageable);
+            }
+
+            // category AND x
+            else if (useTitle && !useTag && !useAuthor) {
+                page = livePageRepository.findByCategoryIdAndDetailsTitleContainingIgnoreCase(categoryId, title, pageable);
+            }
+            else if (!useTitle && useTag && !useAuthor) {
+                page = livePageRepository.findByCategoryIdAndDetailsTagsContainingIgnoreCase(categoryId, tag, pageable);
+            }
+            else if (!useTitle && !useTag && useAuthor) {
+                page = livePageRepository.findByCategoryIdAndOwnerUsernameContainingIgnoreCase(categoryId, author, pageable);
+            }
+
+            // category AND x AND y
+            else if (useTitle && useTag && !useAuthor) {
+                page = livePageRepository.findByCategoryIdAndDetailsTitleContainingIgnoreCaseAndDetailsTagsContainingIgnoreCase(categoryId, title, tag, pageable);
+            }
+            else if (useTitle && !useTag && useAuthor) {
+                page = livePageRepository.findByCategoryIdAndDetailsTitleContainingIgnoreCaseAndOwnerUsernameContainingIgnoreCase(categoryId, title, author, pageable);
+            }
+            else if (!useTitle && useTag && useAuthor) {
+                page = livePageRepository.findByCategoryIdAndDetailsTagsContainingIgnoreCaseAndOwnerUsernameContainingIgnoreCase(categoryId, tag, author, pageable);
+            }
+
+            // category AND x AND y AND z
+            else if (useTitle && useTag && useAuthor) {
+                page = livePageRepository.findByCategoryIdAndDetailsTitleContainingIgnoreCaseAndDetailsTagsContainingIgnoreCaseAndOwnerUsernameContainingIgnoreCase(categoryId, title, tag, author, pageable);
+            }
+        }
+
+        // no category specified
+        else {
+
+            // singular
+            if (useTitle && !useTag && !useAuthor) {
+                page = livePageRepository.findByDetailsTitleContainingIgnoreCase(title, pageable);
+            }
+            else if (!useTitle && useTag && !useAuthor) {
+                page = livePageRepository.findByDetailsTagsContainingIgnoreCase(tag, pageable);
+            }
+            else if (!useTitle && !useTag && useAuthor) {
+                page = livePageRepository.findByOwnerUsernameContainingIgnoreCase(author, pageable);
+            }
+
+            // x AND y
+            else if (useTitle && useTag && !useAuthor) {
+                page = livePageRepository.findByDetailsTitleContainingIgnoreCaseAndDetailsTagsContainingIgnoreCase(title, tag, pageable);
+            }
+            else if (useTitle && !useTag && useAuthor) {
+                page = livePageRepository.findByDetailsTitleContainingIgnoreCaseAndOwnerUsernameContainingIgnoreCase(title, author, pageable);
+            }
+            else if (!useTitle && useTag && useAuthor) {
+                page = livePageRepository.findByDetailsTagsContainingIgnoreCaseAndOwnerUsernameContainingIgnoreCase(tag, author, pageable);
+            }
+
+            // x AND y AND z
+            else if (useTitle && useTag && useAuthor) {
+                page = livePageRepository.findByDetailsTitleContainingIgnoreCaseAndDetailsTagsContainingIgnoreCaseAndOwnerUsernameContainingIgnoreCase(title, tag, author, pageable);
+            }
+
+            else {
+                // nothing at all specified.
+                page = livePageRepository.findAll(pageable);
+            }
+        }
+
+        // String debugOut = "Search: Category: %b Title: %b Tags: %b Author: %b";
+        // System.out.println(String.format(debugOut, useCategory, useTitle, useTag, useAuthor));
+
+        if (page != null) {
+            return ResponseEntity.ok()
+                    .body(page);
+        }
+        else {
+            return ResponseEntity.badRequest()
+                    .body(new SimpleApiResponse("Unknown search mix."));
+        }
+
+    }
+
+
+}

+ 219 - 0
src/main/java/com/jayfella/website/controller/api/ApiUserController.java

@@ -0,0 +1,219 @@
+package com.jayfella.website.controller.api;
+
+import com.jayfella.website.core.AccountValidationType;
+import com.jayfella.website.core.ApiResponses;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.entity.user.UserValidation;
+import com.jayfella.website.database.repository.SessionRepository;
+import com.jayfella.website.database.repository.UserRepository;
+import com.jayfella.website.database.repository.UserValidationRepository;
+import com.jayfella.website.http.request.user.AdminCreateUserRequest;
+import com.jayfella.website.http.request.user.NameUpdateRequest;
+import com.jayfella.website.http.request.user.UsernameUpdateRequest;
+import com.jayfella.website.http.response.SimpleApiResponse;
+import com.jayfella.website.service.EmailService;
+import com.jayfella.website.service.UserService;
+import org.apache.commons.codec.binary.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.ui.ModelMap;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.*;
+
+import javax.mail.MessagingException;
+import javax.validation.Valid;
+import java.io.IOException;
+import java.util.Date;
+import java.util.List;
+
+import static com.jayfella.website.core.ServerAdvice.KEY_USER;
+
+@RestController
+@RequestMapping(path = "/api/user/")
+public class ApiUserController {
+
+    @Autowired private SessionRepository sessionRepository;
+    @Autowired private UserRepository userRepository;
+    @Autowired private UserValidationRepository userValidationRepository;
+
+    @Autowired private UserService userService;
+    @Autowired private EmailService emailService;
+
+    @GetMapping()
+    public User getUser(ModelMap model) {
+        return (User) model.get(KEY_USER);
+    }
+
+    @GetMapping("/{username}")
+    public User getUser(ModelMap model, @PathVariable("username") String username) {
+        return userRepository.findByUsernameIgnoreCase(username).orElse(null);
+    }
+
+    @GetMapping("/id/{userId}")
+    public User getUser(ModelMap model, @PathVariable("userId") long userId) {
+        return userRepository.findById(userId).orElse(null);
+    }
+
+    @GetMapping("/since/{timeLength}")
+    public ResponseEntity getUsersSince(ModelMap model, @PathVariable("timeLength") long since) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (!(user.isAdministrator() || user.isModerator())) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        Iterable<User> users = userRepository.findByRegisterDateGreaterThan(new Date(since));
+
+        return ResponseEntity.ok()
+                .body(users);
+    }
+
+    @GetMapping("/search/{searchTerm}")
+    public ResponseEntity getUsers(ModelMap model, @PathVariable("searchTerm") String searchTerm) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        Iterable<User> users = userRepository.findByUsernameContaining(searchTerm);
+
+        return ResponseEntity.ok()
+                .body(users);
+    }
+
+    // allow the user to view their email address.
+    @GetMapping("/email/")
+    public ResponseEntity getMyEmailAddress(ModelMap model) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        return ResponseEntity.ok()
+                .body(new SimpleApiResponse(user.getEmail()));
+    }
+
+    // allows administrators to view users email addresses.
+    @GetMapping("/email/{userId}")
+    public ResponseEntity getUserEmailAddress(ModelMap model, @PathVariable("userId") long id) {
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        User searchedUser = userRepository.findById(id).orElse(null);
+
+
+        if (!(user.equals(searchedUser) || user.isAdministrator() || user.isModerator())) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        if (searchedUser == null) {
+            return ApiResponses.userNotFound(id);
+        }
+
+        return ResponseEntity.ok()
+                .body(new SimpleApiResponse(searchedUser.getEmail()));
+    }
+
+    @PostMapping("/create/")
+    public ResponseEntity createUser(ModelMap model,
+                                     @ModelAttribute @Valid AdminCreateUserRequest createUserRequest,
+                                     BindingResult bindingResult) throws IOException {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (!user.isAdministrator()) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        List<String> errors;
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        errors = userService.isValidDetails(createUserRequest);
+
+        if (!errors.isEmpty()) {
+            return ResponseEntity.badRequest()
+                    .body(new SimpleApiResponse("Unable to create user.", errors));
+        }
+
+        User newUser = userService.createUser(createUserRequest);
+
+        if (createUserRequest.isSendEmail()) {
+
+            // create validation requirement
+            UserValidation userValidation = new UserValidation(newUser.getId(), AccountValidationType.Account, "");
+            userValidationRepository.save(userValidation);
+
+            // send validation email.
+            try {
+                emailService.sendRegistrationEmail(newUser, userValidation);
+            } catch (MessagingException e) {
+                // @TODO: report this to the administrator group.
+                e.printStackTrace();
+            }
+        }
+
+        return ResponseEntity.ok()
+                .body(newUser);
+    }
+
+    @PutMapping("/username/")
+    public ResponseEntity updateUsername(ModelMap model, @ModelAttribute @Valid UsernameUpdateRequest updateRequest) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (StringUtils.equals(user.getUsername(), updateRequest.getUsername())) {
+            return ApiResponses.noChangesDetected();
+        }
+
+        user.setUsername(updateRequest.getUsername());
+        userRepository.save(user);
+
+        return new ResponseEntity<>(new SimpleApiResponse("Username updated successfully."), HttpStatus.OK);
+    }
+
+    @PutMapping("/name/")
+    public ResponseEntity updateName(ModelMap model, @ModelAttribute @Valid NameUpdateRequest updateRequest) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (StringUtils.equals(user.getName(), updateRequest.getName())) {
+            return ApiResponses.noChangesDetected();
+        }
+
+        user.setName(updateRequest.getName());
+        userRepository.save(user);
+
+        return new ResponseEntity<>(new SimpleApiResponse("Name updated successfully."), HttpStatus.OK);
+    }
+
+
+
+}

+ 21 - 0
src/main/java/com/jayfella/website/controller/api/ApiUserPreferencesController.java

@@ -0,0 +1,21 @@
+package com.jayfella.website.controller.api;
+
+import com.jayfella.website.database.repository.SessionRepository;
+import com.jayfella.website.database.repository.UserRepository;
+import com.jayfella.website.database.repository.UserValidationRepository;
+import com.jayfella.website.service.EmailService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController()
+@RequestMapping("/api/user/preferences/")
+public class ApiUserPreferencesController {
+
+    @Autowired private UserRepository userRepository;
+    @Autowired private SessionRepository sessionRepository;
+    @Autowired private UserValidationRepository userValidationRepository;
+
+    @Autowired private EmailService emailService;
+
+}

+ 361 - 0
src/main/java/com/jayfella/website/controller/api/ApiValidationController.java

@@ -0,0 +1,361 @@
+package com.jayfella.website.controller.api;
+
+import com.jayfella.website.core.AccountValidationType;
+import com.jayfella.website.core.ApiResponses;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.entity.user.UserValidation;
+import com.jayfella.website.database.repository.UserRepository;
+import com.jayfella.website.database.repository.UserValidationRepository;
+import com.jayfella.website.http.request.user.ChangeDetailsRequest;
+import com.jayfella.website.http.request.user.ResetPasswordRequest;
+import com.jayfella.website.http.request.user.ValidationRequest;
+import com.jayfella.website.http.response.SimpleApiResponse;
+import com.jayfella.website.service.EmailService;
+import com.jayfella.website.service.UserService;
+import org.apache.commons.validator.routines.EmailValidator;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.ui.ModelMap;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.*;
+import org.thymeleaf.util.StringUtils;
+
+import javax.mail.MessagingException;
+import javax.servlet.http.HttpServletRequest;
+import javax.validation.Valid;
+import java.io.UnsupportedEncodingException;
+
+import static com.jayfella.website.core.ServerAdvice.KEY_USER;
+
+/**
+ * Validates new accounts, email changes and password changes.
+ */
+
+@RestController
+@RequestMapping("/api/validate/")
+public class ApiValidationController {
+
+    @Autowired private UserValidationRepository userValidationRepository;
+    @Autowired private UserRepository userRepository;
+
+    @Autowired private EmailService emailService;
+    @Autowired private UserService userService;
+
+    @GetMapping
+    public ResponseEntity getValidation(ModelMap model) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        UserValidation validation = userValidationRepository.findByUserId(user.getId()).orElse(null);
+
+        return ResponseEntity.ok()
+                .body(validation);
+    }
+
+    // validates a given code
+    @PostMapping
+    public ResponseEntity validateCode(@ModelAttribute @Valid ValidationRequest validationRequest, BindingResult bindingResult) {
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        UserValidation userValidation = userValidationRepository.findById(validationRequest.getCode()).orElse(null);
+
+        if (userValidation != null) {
+
+            User user = userRepository.findById(userValidation.getUserId()).orElse(null);
+
+            if (user == null) {
+                userValidationRepository.delete(userValidation);
+                return ApiResponses.userNotFound(userValidation.getUserId());
+            }
+
+            switch (userValidation.getValidationType()) {
+                case Email:
+                    user.setEmail(userValidation.getValue());
+                    userRepository.save(user);
+                    break;
+
+                case Password:
+                    user.setPassword(userValidation.getValue());
+                    userRepository.save(user);
+                    break;
+                case Account:
+                default:
+            }
+
+            userValidationRepository.delete(userValidation);
+            return ResponseEntity.ok()
+                    .body(new SimpleApiResponse("Validation successful."));
+        }
+
+        return ResponseEntity.badRequest()
+                .body(new SimpleApiResponse("Invalid validation code."));
+    }
+
+    // sends a new email validation email to the user.
+    @PostMapping("/resend/")
+    public ResponseEntity sendNewEmailValidation(ModelMap model) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        UserValidation validation = userValidationRepository.findByUserId(user.getId()).orElse(null);
+
+        // if there is no validation request pending, we can't send an email.
+        if (validation == null) {
+            return ResponseEntity.status(HttpStatus.NOT_FOUND)
+                    .body(new SimpleApiResponse("You have no validation requests pending."));
+        }
+
+        // the validation request exists so we check how long ago they requested it.
+        // this avoids people spamming emails from our server.
+
+        if (isTooQuick(validation)) {
+            return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                    .body(new SimpleApiResponse("You must wait at least 5 minutes between requesting validation emails."));
+        }
+
+        // delete the old request and generate a new one.
+        UserValidation newValidation = new UserValidation(user.getId(), validation.getValidationType(), validation.getValue());
+        userValidationRepository.delete(validation);
+        userValidationRepository.save(newValidation);
+
+        try {
+            emailService.sendAccountValidationEmail(user, newValidation);
+        } catch (UnsupportedEncodingException | MessagingException e) {
+            e.printStackTrace();
+        }
+
+        return ResponseEntity.ok()
+                .body(new SimpleApiResponse("An email has been sent to your email address."));
+    }
+
+    // USER requests a change in their details (email, password).
+    @PostMapping("/details/")
+    public ResponseEntity changeDetails(ModelMap model, @ModelAttribute @Valid ChangeDetailsRequest changeDetailsRequest, BindingResult bindingResult) {
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        UserValidation userValidation = userValidationRepository.findByUserId(user.getId()).orElse(null);
+
+        if (userValidation != null) {
+            return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                    .body(new SimpleApiResponse("You already have a validation pending."));
+        }
+
+        AccountValidationType validation = AccountValidationType.fromString(changeDetailsRequest.getType());
+
+        if (validation == null) {
+            return ResponseEntity.badRequest()
+                    .body(new SimpleApiResponse("Unknown type: " + changeDetailsRequest.getType()));
+        }
+
+        switch (validation) {
+            case Email:
+
+                // preliminary checks
+                // - is it a valid email?
+                // - has the email changed?
+                // - does someone else own this email address?
+
+                // we're opting for apache commons over spring @Email because apparently spring allows all kinds of bad inputs.
+                boolean validEmail = EmailValidator.getInstance(false).isValid(changeDetailsRequest.getValue());
+                if (!validEmail) {
+                    return ResponseEntity.badRequest()
+                            .body(new SimpleApiResponse("Invalid email format specified."));
+                }
+
+                if (user.getEmail().equalsIgnoreCase(changeDetailsRequest.getValue())) {
+                    return ResponseEntity.status(HttpStatus.CONFLICT)
+                            .body(new SimpleApiResponse("The email address is the same as the stored address."));
+                }
+
+                User emailDuplicate = userRepository.findByEmailIgnoreCase(changeDetailsRequest.getValue()).orElse(null);
+
+                if (emailDuplicate != null && emailDuplicate.getId() != user.getId()) {
+                    return ResponseEntity.status(HttpStatus.CONFLICT)
+                            .body(new SimpleApiResponse("An account with this email already exists."));
+                }
+
+                userValidation = new UserValidation(user.getId(), AccountValidationType.Email, changeDetailsRequest.getValue());
+                userValidationRepository.save(userValidation);
+                break;
+
+            case Password:
+
+                // preliminary checks
+                // - is it long enough?
+                // - is it the same?
+
+                if (changeDetailsRequest.getValue().length() < UserService.PASSWORD_MIN_LENGTH) {
+                    return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                            .body(new SimpleApiResponse("Your password must be at least " + UserService.PASSWORD_MIN_LENGTH + " characters long."));
+                }
+
+                if (userService.passwordsMatch(user, changeDetailsRequest.getValue())) {
+                    return ResponseEntity.status(HttpStatus.CONFLICT)
+                            .body(new SimpleApiResponse("The given password is identical to the stored password."));
+                }
+
+                String hashAndSalt = userService.createNewHashAndSalt(changeDetailsRequest.getValue());
+
+                userValidation = new UserValidation(user.getId(), AccountValidationType.Password, hashAndSalt);
+                userValidationRepository.save(userValidation);
+
+                break;
+            case Account:
+            default:
+                return ResponseEntity.badRequest()
+                        .body(new SimpleApiResponse("Unsupported type: " + changeDetailsRequest.getType()));
+        }
+
+        try {
+            emailService.sendAccountValidationEmail(user, userValidation);
+        } catch (UnsupportedEncodingException | MessagingException e) {
+            e.printStackTrace();
+        }
+
+        return ResponseEntity.ok()
+                .body(new SimpleApiResponse("Please check your email for a validation code."));
+    }
+
+    @PostMapping("/cancel/")
+    public ResponseEntity cancelValidationRequest(ModelMap model) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        UserValidation userValidation = userValidationRepository.findByUserId(user.getId()).orElse(null);
+
+        if (userValidation == null) {
+            return ResponseEntity.badRequest()
+                    .body(new SimpleApiResponse("There are no validation requests to cancel."));
+        }
+
+        if (userValidation.getValidationType() == AccountValidationType.Account) {
+            return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                    .body(new SimpleApiResponse("You cannot cancel an account validation request."));
+        }
+
+        userValidationRepository.delete(userValidation);
+
+        return ResponseEntity.ok()
+                .body(new SimpleApiResponse("Validation request deleted."));
+    }
+
+    // USER requests a new password (locked out)
+    @PostMapping("/reset-password")
+    public ResponseEntity resetPassword(HttpServletRequest request,
+                                        @ModelAttribute @Valid ResetPasswordRequest resetRequest,
+                                        BindingResult bindingResult) {
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        if (!StringUtils.equals(resetRequest.getPassword1(), resetRequest.getPassword2())) {
+            return ResponseEntity.badRequest()
+                    .body(new SimpleApiResponse("Both passwords must match."));
+        }
+
+        if (resetRequest.getPassword1().length() < UserService.PASSWORD_MIN_LENGTH) {
+            return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                    .body(new SimpleApiResponse("Your password must be at least " + UserService.PASSWORD_MIN_LENGTH + " characters long."));
+        }
+
+        // check if this is a valid username
+        User user = userRepository.findByUsernameIgnoreCase(resetRequest.getUsername()).orElse(null);
+
+        if (user == null) {
+            return ResponseEntity.badRequest()
+                    .body(new SimpleApiResponse("Username not found."));
+        }
+
+        if (userService.passwordsMatch(user, resetRequest.getPassword1())) {
+            return ResponseEntity.status(HttpStatus.CONFLICT)
+                    .body(new SimpleApiResponse("The given password is identical to the stored password."));
+        }
+
+        // check if one already exists, and if it has already been sent < 5 minutes ago.
+
+        UserValidation userValidation = userValidationRepository.findByUserId(user.getId()).orElse(null);
+
+        if (userValidation != null) {
+            if (isTooQuick(userValidation)) {
+                return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                        .body(new SimpleApiResponse("You must wait at least 5 minutes between requesting validation emails."));
+            }
+
+            userValidationRepository.delete(userValidation);
+        }
+
+        String hashAndSalt = userService.createNewHashAndSalt(resetRequest.getPassword1());
+        userValidation = new UserValidation(user.getId(), AccountValidationType.Password, hashAndSalt);
+        userValidationRepository.save(userValidation);
+
+        try {
+            // emailService.sendAccountValidationEmail(user, userValidation);
+
+            String ipAddress = getClientIp(request);
+
+            if (ipAddress.isBlank()) {
+                ipAddress = "UNKNOWN";
+            }
+
+            emailService.sendResetPasswordEmail(user, userValidation.getId(), ipAddress);
+        } catch (UnsupportedEncodingException | MessagingException e) {
+            e.printStackTrace();
+        }
+
+        return ResponseEntity.ok()
+                .body(new SimpleApiResponse("An email has been sent to your email address."));
+    }
+
+    private boolean isTooQuick(UserValidation userValidation) {
+        long minTimeBetweenRequests = 300000; // 5 minutes
+
+        long timeNow = System.currentTimeMillis();
+        long lastReq = userValidation.getCreationDate().getTime();
+
+        long timeDiff = timeNow - lastReq;
+
+        return (timeDiff < minTimeBetweenRequests);
+    }
+
+    // https://www.mkyong.com/spring-mvc/spring-mvc-how-to-get-client-ip-address/
+    private String getClientIp(HttpServletRequest request) {
+
+        String remoteAddr = "";
+
+        if (request != null) {
+            remoteAddr = request.getHeader("X-FORWARDED-FOR");
+            if (remoteAddr == null || "".equals(remoteAddr)) {
+                remoteAddr = request.getRemoteAddr();
+            }
+        }
+
+        return remoteAddr;
+    }
+
+}

+ 235 - 0
src/main/java/com/jayfella/website/controller/api/page/ApiAmendmentController.java

@@ -0,0 +1,235 @@
+package com.jayfella.website.controller.api.page;
+
+import com.jayfella.website.core.ApiResponses;
+import com.jayfella.website.core.page.PageState;
+import com.jayfella.website.core.page.ReviewState;
+import com.jayfella.website.database.entity.page.StaffPageReview;
+import com.jayfella.website.database.entity.page.stages.LivePage;
+import com.jayfella.website.database.entity.page.stages.PageAmendment;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.repository.StaffPageReviewRepository;
+import com.jayfella.website.database.repository.page.LivePageRepository;
+import com.jayfella.website.database.repository.page.PageAmendmentRepository;
+import com.jayfella.website.exception.InvalidImageException;
+import com.jayfella.website.http.request.SimplePageRequest;
+import com.jayfella.website.http.response.PageUpdateResponse;
+import com.jayfella.website.http.response.SimpleApiResponse;
+import com.jayfella.website.service.ImageService;
+import com.jayfella.website.service.PageService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.ui.ModelMap;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartHttpServletRequest;
+
+import javax.validation.Valid;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.List;
+
+import static com.jayfella.website.core.ServerAdvice.KEY_USER;
+
+/**
+ * CRUD Repository for Page Amendments
+ */
+
+@RestController
+@RequestMapping("/api/page/amendment/")
+public class ApiAmendmentController {
+
+    @Autowired private LivePageRepository livePageRepository;
+    @Autowired private PageAmendmentRepository amendmentRepository;
+
+    @Autowired private PageService pageService;
+    @Autowired private StaffPageReviewRepository staffPageReviewRepository;
+    @Autowired private ImageService imageService;
+
+    @PostMapping
+    public ResponseEntity create(ModelMap model, @ModelAttribute @Valid SimplePageRequest createRequest, BindingResult bindingResult) throws URISyntaxException, IOException {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        PageAmendment amendment = amendmentRepository.findByParentPageId(createRequest.getPageId()).orElse(null);
+
+        if (amendment != null) {
+            return new ResponseEntity<>(new SimpleApiResponse(String.format("The Asset '%s' already has an amendment open.", amendment.getDetails().getTitle())),
+                    HttpStatus.FORBIDDEN);
+        }
+
+        // final Asset asset;
+        LivePage livePage = livePageRepository.findById(createRequest.getPageId()).orElse(null);
+
+        if (livePage == null) {
+            return ApiResponses.pageNotFound(PageState.Live, createRequest.getPageId());
+        }
+
+        if (!pageService.isOwnerOrModerator(user, livePage)) {
+            return ApiResponses.insufficientPermission();
+
+        }
+
+        amendment = new PageAmendment(livePage, imageService);
+        amendmentRepository.save(amendment);
+
+        return ResponseEntity.created(new URI("/api/page/amendment/" + amendment.getId()))
+                .body(amendment);
+
+    }
+
+    @GetMapping("/{pageId}")
+    public ResponseEntity read(ModelMap model, @PathVariable("pageId") String pageId) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        PageAmendment amendment = amendmentRepository.findById(pageId).orElse(null);
+
+        if (amendment == null) {
+            return ApiResponses.pageNotFound(PageState.Amendment, pageId);
+        }
+
+        if (!pageService.isOwnerOrModerator(user, amendment)) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        return ResponseEntity.ok()
+                .body(amendment);
+    }
+
+    @PutMapping
+    public ResponseEntity update(ModelMap model, MultipartHttpServletRequest request) throws IOException, InvalidImageException {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        String amendmentId = request.getParameter("page-id");
+
+        if (amendmentId == null || amendmentId.trim().isEmpty()) {
+            return ApiResponses.noPageIdSpecified();
+        }
+
+        PageAmendment amendment = amendmentRepository.findById(amendmentId).orElse(null);
+
+        if (amendment == null) {
+            return ApiResponses.pageNotFound(PageState.Amendment, amendmentId);
+        }
+
+        if (!pageService.isOwnerOrModerator(user, amendment)) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        List<String> information = pageService.updateAndNotifyChanges(amendment, PageState.Amendment, request);
+
+        return ResponseEntity.ok()
+                .body(new PageUpdateResponse(amendment, information));
+    }
+
+    @DeleteMapping
+    public ResponseEntity delete(ModelMap model, @ModelAttribute @Valid SimplePageRequest deleteRequest, BindingResult bindingResult) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        PageAmendment amendment = amendmentRepository.findById(deleteRequest.getPageId()).orElse(null);
+
+        if (amendment == null) {
+            return ApiResponses.pageNotFound(PageState.Amendment, deleteRequest.getPageId());
+        }
+
+        if (!pageService.isOwnerOrModerator(user, amendment)) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        pageService.delete(amendment);
+
+        return ApiResponses.pageDeleted(amendment);
+    }
+
+    @GetMapping("/all/")
+    public ResponseEntity getAllAmendments(ModelMap model){
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (!(user.isAdministrator() || user.isModerator())) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        List<PageAmendment> amendments = amendmentRepository.findAll();
+
+        return ResponseEntity.ok()
+                .body(amendments);
+    }
+
+    @GetMapping("/pending/")
+    public ResponseEntity<?> getPendingDrafts(ModelMap model) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (!(user.isAdministrator() || user.isModerator())) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        Iterable<PageAmendment> amendments = amendmentRepository.findByReviewStateOrReviewState(ReviewState.Review_Requested, ReviewState.Under_Review);
+
+        return ResponseEntity.ok()
+                .body(amendments);
+    }
+
+    @GetMapping("/rejections/{pageId}")
+    public ResponseEntity<?> getRejections(ModelMap model, @PathVariable("pageId") String pageId) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        PageAmendment amendment = amendmentRepository.findById(pageId).orElse(null);
+
+        if (amendment == null) {
+            return ApiResponses.pageNotFound(PageState.Draft, pageId);
+        }
+
+        if (!pageService.isOwnerOrModerator(user, amendment)) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        Iterable<StaffPageReview> staffReviews = staffPageReviewRepository.findByPageId(amendment.getId());
+
+        return ResponseEntity.ok()
+                .body(staffReviews);
+    }
+
+}

+ 323 - 0
src/main/java/com/jayfella/website/controller/api/page/ApiDraftController.java

@@ -0,0 +1,323 @@
+package com.jayfella.website.controller.api.page;
+
+import com.jayfella.website.core.*;
+import com.jayfella.website.core.page.PageState;
+import com.jayfella.website.core.page.ReviewState;
+import com.jayfella.website.core.page.SoftwareType;
+import com.jayfella.website.database.entity.page.StaffPageReview;
+import com.jayfella.website.database.entity.page.embedded.OpenSourceData;
+import com.jayfella.website.database.entity.page.embedded.PaymentData;
+import com.jayfella.website.database.entity.page.stages.PageDraft;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.repository.StaffPageReviewRepository;
+import com.jayfella.website.database.repository.page.LivePageRepository;
+import com.jayfella.website.database.repository.page.PageAmendmentRepository;
+import com.jayfella.website.database.repository.page.PageDraftRepository;
+import com.jayfella.website.exception.InvalidImageException;
+import com.jayfella.website.http.request.SimplePageRequest;
+import com.jayfella.website.http.request.page.CreatePageDraftRequest;
+import com.jayfella.website.http.response.PageUpdateResponse;
+import com.jayfella.website.http.response.SimpleApiResponse;
+import com.jayfella.website.service.PageService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.ui.ModelMap;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartHttpServletRequest;
+
+import javax.validation.Valid;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.List;
+
+import static com.jayfella.website.core.ServerAdvice.KEY_USER;
+
+/**
+ * CRUD Repository for Potential Assets
+ */
+
+@RestController
+@RequestMapping("/api/page/draft/")
+public class ApiDraftController {
+
+    @Autowired private PageDraftRepository draftRepository;
+    @Autowired private LivePageRepository livePageRepository;
+    @Autowired private PageAmendmentRepository amendmentRepository;
+
+    @Autowired private PageService pageService;
+    @Autowired private StaffPageReviewRepository staffPageReviewRepository;
+
+    @PostMapping
+    public ResponseEntity<?> create(ModelMap model, @ModelAttribute @Valid CreatePageDraftRequest createPageDraftRequest, BindingResult bindingResult) throws URISyntaxException {
+
+        User user = (User) model.get(ServerAdvice.KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        // count how many drafts the user has
+        long count = draftRepository.countByOwner(user);
+
+        if (count > PageRequirements.MAX_POTENTIAL_ASSETS) {
+
+            return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                    .body(new SimpleApiResponse("You can only create a maximum of " + PageRequirements.MAX_POTENTIAL_ASSETS + " potential assets."));
+        }
+
+        List<String> errors = new ArrayList<>();
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        SoftwareType softwareType = EnumUtils.fromString(SoftwareType.class, createPageDraftRequest.getSoftwareType());
+
+        if (softwareType == null) {
+            errors.add("Unknown software type: " + createPageDraftRequest.getSoftwareType());
+            return ResponseEntity.badRequest()
+                    .body(new SimpleApiResponse("An error occured processing your request", errors));
+        }
+
+        String title = createPageDraftRequest.getTitle().trim();
+        if (title.length() < PageRequirements.TITLE_MIN_LENGTH || title.length() > PageRequirements.TITLE_MAX_LENGTH) {
+            errors.add(String.format("The title must be a minimum of %d characters and a maximum of %d characters in length.",
+                    PageRequirements.TITLE_MIN_LENGTH,
+                    PageRequirements.TITLE_MAX_LENGTH));
+        }
+
+        String gitRepo = "", forkedRepo = "";
+        boolean isFork = false;
+
+        if (softwareType == SoftwareType.OpenSource || softwareType == SoftwareType.Sponsored) {
+
+            gitRepo = createPageDraftRequest.getGitRepo().trim();
+            if (gitRepo.isEmpty()) {
+                errors.add("You must specify a git repository.");
+            }
+            else if(!GitRepository.startsWithAny(gitRepo)) {
+                errors.add("The git repository must be hosted on a supported git host.");
+            }
+
+            isFork = createPageDraftRequest.getForked().equalsIgnoreCase("on");
+            forkedRepo = createPageDraftRequest.getForkedRepo().trim();
+
+            if (isFork) {
+                if (forkedRepo.isEmpty()) {
+                    errors.add("You must specify a forked git repository.");
+                }
+                else if ( !GitRepository.startsWithAny(forkedRepo)) {
+                    errors.add("The forked repository must be hosted on a supported git host.");
+                }
+            }
+        }
+
+        boolean termsAccepted = createPageDraftRequest.getTermsAccepted().equalsIgnoreCase("on");
+        if (!termsAccepted) {
+            errors.add("You must accept the Terms of Service.");
+        }
+
+        // @TODO: An existing asset could already exist with the same name.
+        // A potential asset could also exist with the same name. This is now a race for both users.
+        // I'm not certain what to do about this....
+        // for now we'll reject any titles clashes, potential or not.
+
+        boolean assetExists = pageExistsWithTitle(title);
+
+        if (assetExists) {
+            errors.add("An asset with that title already exists.");
+        }
+
+        if (!errors.isEmpty()) {
+            return ResponseEntity.badRequest()
+                    .body(new SimpleApiResponse("Error processing your request", errors));
+        }
+
+        PageDraft draft = new PageDraft();
+        draft.setSoftwareType(softwareType);
+        draft.getDetails().setTitle(createPageDraftRequest.getTitle());
+        draft.setOwner(user);
+
+        // opensource and sponsored
+        OpenSourceData openSourceData = new OpenSourceData();
+        openSourceData.setGitRepository(gitRepo);
+        openSourceData.setFork(isFork);
+
+        if (isFork) {
+            openSourceData.setForkRepository(forkedRepo);
+        }
+
+        draft.setOpenSourceData(openSourceData);
+
+        // paid assets
+        PaymentData paymentData = new PaymentData();
+        draft.setPaymentData(paymentData);
+
+        draftRepository.save(draft);
+
+        return ResponseEntity.created(new URI("/api/page/draft/" + draft.getId()))
+                .body(draft);
+    }
+
+    @GetMapping("/{pageId}")
+    public ResponseEntity read(ModelMap model, @PathVariable("pageId") String pageId) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        PageDraft draft = draftRepository.findById(pageId).orElse(null);
+
+        if (draft == null) {
+            return ApiResponses.pageNotFound(PageState.Draft, pageId);
+        }
+
+        if (!pageService.isOwnerOrModerator(user, draft)) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        return new ResponseEntity<>(draft, HttpStatus.OK);
+    }
+
+    @PutMapping
+    public ResponseEntity update(ModelMap model, MultipartHttpServletRequest request) throws IOException, InvalidImageException {
+
+        // we don't check for minimum requirements here (e.g. description length, required fields)
+        // because they are editing. They may want to remove things and they should be allowed.
+        // we will check the minimum requirements when then user attempts to submit the changes for approval.
+
+        // if a form item that has been posted is empty it will be 'null'. We need to account for that sometimes.
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        String pageId = request.getParameter("page-id");
+
+        if (pageId == null || pageId.trim().isEmpty()) {
+            return ApiResponses.noPageIdSpecified();
+        }
+
+        PageDraft draft = draftRepository.findById(pageId).orElse(null);
+
+        if (draft == null) {
+            return ApiResponses.pageNotFound(PageState.Draft, pageId);
+        }
+
+        if (!pageService.isOwnerOrModerator(user, draft)) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        List<String> information = pageService.updateAndNotifyChanges(draft, PageState.Draft, request);
+
+        return ResponseEntity.ok()
+                .body(new PageUpdateResponse(draft, information));
+    }
+
+    @DeleteMapping
+    public ResponseEntity delete(ModelMap model, @ModelAttribute @Valid SimplePageRequest deleteRequest, BindingResult bindingResult) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        PageDraft draft = draftRepository.findById(deleteRequest.getPageId()).orElse(null);
+
+        if (draft == null) {
+            return ApiResponses.pageNotFound(PageState.Draft, deleteRequest.getPageId());
+        }
+
+        if (!pageService.isOwnerOrModerator(user, draft)) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        pageService.delete(draft);
+
+        return ApiResponses.pageDeleted(draft);
+    }
+
+    @GetMapping("/all/")
+    public ResponseEntity getAllDrafts(ModelMap model){
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (!(user.isAdministrator() || user.isModerator())) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        return ResponseEntity.ok()
+                .body(draftRepository.findAll());
+    }
+
+    @GetMapping("/pending/")
+    public ResponseEntity<?> getPendingDrafts(ModelMap model) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (!(user.isAdministrator() || user.isModerator())) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        Iterable<PageDraft> drafts = draftRepository.findByReviewStateOrReviewState(ReviewState.Review_Requested, ReviewState.Under_Review);
+
+        return ResponseEntity.ok()
+                .body(drafts);
+    }
+
+    @GetMapping("/rejections/{pageId}")
+    public ResponseEntity<?> getRejections(ModelMap model, @PathVariable("pageId") String pageId) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        PageDraft draft = draftRepository.findById(pageId).orElse(null);
+
+        if (draft == null) {
+            return ApiResponses.pageNotFound(PageState.Draft, pageId);
+        }
+
+        if (!pageService.isOwnerOrModerator(user, draft)) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        Iterable<StaffPageReview> staffReviews = staffPageReviewRepository.findByPageId(draft.getId());
+
+        return ResponseEntity.ok()
+                .body(staffReviews);
+    }
+
+    private boolean pageExistsWithTitle(String title) {
+
+        if (livePageRepository.findByDetailsTitleIgnoreCase(title).orElse(null) != null) {
+            return true;
+        }
+
+        return draftRepository.findByDetailsTitleIgnoreCase(title).orElse(null) != null;
+    }
+
+}

+ 337 - 0
src/main/java/com/jayfella/website/controller/api/page/ApiLivePageController.java

@@ -0,0 +1,337 @@
+package com.jayfella.website.controller.api.page;
+
+import com.jayfella.website.core.ApiResponses;
+import com.jayfella.website.core.PageRequirements;
+import com.jayfella.website.core.page.PageState;
+import com.jayfella.website.core.page.SoftwareType;
+import com.jayfella.website.database.entity.page.StorePage;
+import com.jayfella.website.database.entity.page.stages.LivePage;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.repository.ReviewRepository;
+import com.jayfella.website.database.repository.UserRepository;
+import com.jayfella.website.database.repository.page.LivePageRepository;
+import com.jayfella.website.database.repository.page.PageAmendmentRepository;
+import com.jayfella.website.database.repository.page.PageDraftRepository;
+import com.jayfella.website.http.request.SimplePageRequest;
+import com.jayfella.website.service.PageService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.http.CacheControl;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.ui.ModelMap;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.*;
+
+import javax.validation.Valid;
+import java.util.*;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+
+import static com.jayfella.website.core.ServerAdvice.KEY_USER;
+
+/**
+ * JSON-ONLY
+ * An endpoint that returns LIVE asset collections based on various filters.
+ */
+@RestController
+@RequestMapping("/api/page/")
+public class ApiLivePageController {
+
+    @Autowired private PageDraftRepository draftRepository;
+    @Autowired private LivePageRepository livePageRepository;
+    @Autowired private PageAmendmentRepository amendmentRepository;
+
+    @Autowired private ReviewRepository reviewRepository;
+
+    @Autowired private UserRepository userRepository;
+    @Autowired private PageService pageService;
+
+    @GetMapping("/{assetId}")
+    public ResponseEntity<?> getPage(@PathVariable("assetId") String assetId) {
+
+        LivePage livePage = livePageRepository.findById(assetId).orElse(null);
+
+        if (livePage == null) {
+            return ApiResponses.pageNotFound(PageState.Live, assetId);
+        }
+
+        return ResponseEntity.ok()
+                .cacheControl(CacheControl.noCache())
+                .body(livePage);
+    }
+
+    // ALL returns the top ten highest rated assets.
+    @GetMapping("/highest-rated/")
+    public Iterable<LivePage> getHighestRated() {
+
+        Pageable sortByRating = PageRequest.of(
+                0,
+                10,
+                Sort.by("ratings")
+                        .descending());
+
+        return livePageRepository.findAll(sortByRating);
+
+    }
+
+    // ALL search by title, returns 10 results
+    @GetMapping("/search/title/{title}")
+    public ResponseEntity<?> searchByTitle(@PathVariable("title") String title) {
+
+
+        if (title.length() < PageRequirements.MIN_SEARCH_LENGTH) {
+            return ApiResponses.searchTermTooShort();
+        }
+
+        Iterable<LivePage> foundItems = livePageRepository.findByDetailsTitleContaining(title, PageRequest.of(0, 10));
+
+        return ResponseEntity.ok()
+                .body(foundItems);
+    }
+
+    // ALL search by title/page/size (limit size (amount per page) = 50)
+    @GetMapping("/search/title/{title}/{page}/{size}")
+    public ResponseEntity<?> searchByTitlePaged(@PathVariable("title") String title,
+                                             @PathVariable("page") int page,
+                                             @PathVariable("size") int size) {
+
+        if (title.length() < PageRequirements.MIN_SEARCH_LENGTH) {
+            return ApiResponses.searchTermTooShort();
+        }
+
+        if (size > 50) {
+            size = 50;
+        }
+
+        Iterable<LivePage> foundItems = livePageRepository.findByDetailsTitleContaining(title, PageRequest.of(page, size));
+
+        return ResponseEntity.ok()
+                .body(foundItems);
+    }
+
+    /* removed because they should use the search api
+    @GetMapping("/search/tag/{tag}")
+    public ResponseEntity searchByTag(@PathVariable("tag") String tag) {
+
+        if (tag.length() < PageRequirements.MIN_SEARCH_LENGTH) {
+            return ApiResponses.searchTermTooShort();
+        }
+
+        Iterable<LivePage> foundItems = livePageRepository.findByDetailsTagsContainingIgnoreCase(tag);
+
+        return ResponseEntity.ok()
+                .body(foundItems);
+    }
+
+    @GetMapping("/search/author/{author}")
+    public ResponseEntity searchByAuthor(@PathVariable("author") String author) {
+
+        if (author.length() < PageRequirements.MIN_SEARCH_LENGTH) {
+            return ApiResponses.searchTermTooShort();
+        }
+
+        Iterable<LivePage> foundItems = livePageRepository.findByOwnerUsernameContainingIgnoreCase(author);
+        return ResponseEntity.ok()
+                .body(foundItems);
+    }
+
+    @GetMapping("/search/user/{userId}")
+    public ResponseEntity searchByUserId(@PathVariable("userId") long userId) {
+
+        Iterable<LivePage> foundItems = livePageRepository.findByOwnerId(userId);
+
+        return ResponseEntity.ok()
+                .body(foundItems);
+    }
+
+     */
+
+    @GetMapping("/top/")
+    public ResponseEntity getTopAssets() {
+
+        // I'm not sure how to optimize this. I want to choose a random
+        // list of 10 assets but I don't want to hammer the database.
+        // we could choose ten and keep them cached somewhere and change
+        // them over time. I'm not certain about this....
+
+        Map<String, Iterable<? extends LivePage>> assets = new HashMap<>();
+
+        // showcase assets
+        Iterable<LivePage> showcaseAssets = livePageRepository.getRandom(10);
+        assets.put("showcase", showcaseAssets);
+        // model.addAttribute("showcaseAssets", showcaseAssets);
+
+        // highest rated
+        Pageable highestRatedPageable = PageRequest.of(0, 10,
+                Sort.by("rating.oneStarCount",
+                        "rating.twoStarCount",
+                        "rating.threeStarCount",
+                        "rating.fourStarCount",
+                        "rating.fiveStarCount")
+                        .descending());
+
+        Iterable<LivePage> highestRatedAssets = livePageRepository.findAll(highestRatedPageable);
+        assets.put("highest_rated", highestRatedAssets);
+        // model.addAttribute("highestRatedAssets", highestRatedAssets);
+
+
+        // new additions
+        Pageable newestAdditionsPageable = PageRequest.of(0, 10, Sort.by("dateCreated").descending());
+        Iterable<LivePage> newAdditions = livePageRepository.findAll(newestAdditionsPageable);
+        assets.put("new_additions", newAdditions);
+        //model.addAttribute("newAdditions", newAdditions);
+
+        // recently updated updated
+        Pageable recentUpdatesPageable = PageRequest.of(0, 10, Sort.by("dateUpdated").descending());
+        Iterable<LivePage> recentUpdates = livePageRepository.findAll(recentUpdatesPageable);
+        assets.put("recently_updated", recentUpdates);
+        // model.addAttribute("recentUpdates", recentUpdates);
+
+        return new ResponseEntity<>(assets, HttpStatus.OK);
+    }
+
+    @GetMapping("/forks/{assetId}")
+    public ResponseEntity getForks(@PathVariable("assetId") String assetId) {
+
+        LivePage livePage = livePageRepository.findById(assetId).orElse(null);
+
+        if (livePage == null || livePage.getSoftwareType() == SoftwareType.Paid) {
+            return new ResponseEntity<>(Collections.emptyList(), HttpStatus.NOT_FOUND);
+        }
+
+        Iterable<LivePage> otherForks = livePageRepository.findByOpenSourceDataForkRepositoryIgnoreCase(livePage.getOpenSourceData().getForkRepository());
+
+        // remove this repository from the list
+        Iterator<LivePage> iterator = otherForks.iterator();
+
+        while (iterator.hasNext()) {
+            LivePage other = iterator.next();
+
+            if (other.equals(livePage)) {
+                iterator.remove();
+                break;
+            }
+        }
+
+        return new ResponseEntity<>(otherForks, HttpStatus.OK);
+    }
+
+    @GetMapping("/dependencies/{pageId}")
+    public ResponseEntity<Iterable<LivePage>> getPageStoreDependencies(@PathVariable("pageId") String pageId) {
+
+        Iterable<LivePage> storeDependencies = livePageRepository.findByBuildDataStoreDependenciesContaining(pageId);
+        return ResponseEntity.ok()
+                .body(storeDependencies);
+    }
+
+    @GetMapping("/data/{pageId}")
+    public ResponseEntity<?> getPageData(@PathVariable("pageId") String pageId) {
+
+        LivePage livePage = livePageRepository.findById(pageId).orElse(null);
+
+        if (livePage == null) {
+            return ApiResponses.pageNotFound(PageState.Live, pageId);
+        }
+
+        Map<String, Iterable<LivePage>> data = new HashMap<>();
+
+        // store dependencies: pages that this software depends on.
+        if (livePage.getBuildData().getStoreDependencies().length() > 0) {
+            String[] pages = livePage.getBuildData().getStoreDependencies().split(",");
+            data.put("dependsOn", livePageRepository.findByIdIn(pages));
+        }
+        else {
+            data.put("dependsOn", Collections.emptyList());
+        }
+
+        // forks: pages that fork this repository
+        if (livePage.getSoftwareType() == SoftwareType.OpenSource || livePage.getSoftwareType() == SoftwareType.Sponsored) {
+            data.put("forks", livePageRepository.findByOpenSourceDataForkRepositoryIgnoreCase(livePage.getOpenSourceData().getGitRepository()));
+        }
+        else {
+            data.put("forks", Collections.emptyList());
+        }
+
+        // addons: pages that have listed this page as a store dependency.
+        data.put("addons", livePageRepository.findByBuildDataStoreDependenciesContaining(pageId));
+
+        return ResponseEntity.ok()
+                .body(data);
+    }
+
+    @GetMapping("/stats/{userId}")
+    public ResponseEntity<?> getUserStats(ModelMap model, @PathVariable("userId") long userId) {
+
+        // User user = (User) model.get(KEY_USER);
+
+        // if (user == null) {
+            // return ApiResponses.notLoggedIn();
+        // }
+
+        User user = userRepository.findById(userId).orElse(null);
+
+        if (user == null) {
+            return ApiResponses.userNotFound(-1);
+        }
+
+        Iterable<LivePage> livePages = livePageRepository.findByOwner(user);
+
+        List<String> pageIds = StreamSupport.stream(livePages.spliterator(), false)
+                .map(StorePage::getId)
+                .collect(Collectors.toList());
+
+        long reviewCount = reviewRepository.countByPageIdIn(pageIds);
+        long pageCount = livePages.spliterator().getExactSizeIfKnown();
+
+        final double averageRating;
+        if (reviewCount > 0) {
+            averageRating = StreamSupport.stream(livePages.spliterator(), false)
+                    .mapToDouble(page -> page.getRating().getAverageRating())
+                    .sum() / reviewCount;
+        }
+        else {
+            averageRating = 0;
+        }
+
+        Map<String, Object> data = new HashMap<>();
+        data.put("userProfile", user);
+        // data.put("registerDate", user.getRegisterDate());
+        data.put("pageCount", pageCount);
+        data.put("reviewCount", reviewCount);
+        data.put("averateRating", averageRating);
+
+        return ResponseEntity.ok()
+                .body(data);
+    }
+
+    @DeleteMapping
+    public ResponseEntity deletePage(ModelMap model, @ModelAttribute @Valid SimplePageRequest deleteRequest, BindingResult bindingResult) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (bindingResult.hasErrors()) {
+            return ApiResponses.badRequest(bindingResult);
+        }
+
+        if (!(user.isAdministrator() || user.isModerator())) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        LivePage livePage = livePageRepository.findById(deleteRequest.getPageId()).orElse(null);
+
+        if (livePage == null) {
+            return ApiResponses.pageNotFound(PageState.Live, deleteRequest.getPageId());
+        }
+
+        pageService.delete(livePage);
+
+        return ApiResponses.pageDeleted(livePage);
+    }
+}

+ 82 - 0
src/main/java/com/jayfella/website/controller/api/page/ApiPageController.java

@@ -0,0 +1,82 @@
+package com.jayfella.website.controller.api.page;
+
+import com.jayfella.website.core.ApiResponses;
+import com.jayfella.website.database.entity.page.StorePage;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.repository.UserRepository;
+import com.jayfella.website.database.repository.page.LivePageRepository;
+import com.jayfella.website.database.repository.page.PageAmendmentRepository;
+import com.jayfella.website.database.repository.page.PageDraftRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.ui.ModelMap;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static com.jayfella.website.core.ServerAdvice.KEY_USER;
+
+@RestController
+@RequestMapping("/api/page/")
+public class ApiPageController {
+
+    @Autowired private PageDraftRepository draftRepository;
+    @Autowired private LivePageRepository livePageRepository;
+    @Autowired private PageAmendmentRepository amendmentRepository;
+
+    @Autowired private UserRepository userRepository;
+
+    @GetMapping("/user/all/")
+    public ResponseEntity getAllUserPages(ModelMap model) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        Map<String, Iterable<? extends StorePage>> pages = new HashMap<>();
+
+        pages.put("drafts", draftRepository.findByOwner(user));
+        pages.put("live", livePageRepository.findByOwner(user));
+        pages.put("amendments", amendmentRepository.findByOwner(user));
+
+        return ResponseEntity.ok()
+                .body(pages);
+    }
+
+    @GetMapping("/user/all/{userId}")
+    public ResponseEntity getAllSelectedUserPages(ModelMap model,
+                                                  @PathVariable("userId") long userId) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return ApiResponses.notLoggedIn();
+        }
+
+        if (!(user.isAdministrator() || user.isModerator())) {
+            return ApiResponses.insufficientPermission();
+        }
+
+        User selectedUser = userRepository.findById(userId).orElse(null);
+
+        if (selectedUser == null) {
+            return ApiResponses.userNotFound(userId);
+        }
+
+        Map<String, Iterable<? extends StorePage>> pages = new HashMap<>();
+
+        pages.put("drafts", draftRepository.findByOwner(selectedUser));
+        pages.put("live", livePageRepository.findByOwner(selectedUser));
+        pages.put("amendments", amendmentRepository.findByOwner(selectedUser));
+
+        return ResponseEntity.ok()
+                .body(pages);
+    }
+
+}

+ 116 - 0
src/main/java/com/jayfella/website/controller/http/AdminController.java

@@ -0,0 +1,116 @@
+package com.jayfella.website.controller.http;
+
+import com.jayfella.website.core.ServerAdvice;
+import com.jayfella.website.core.StoreHtmlFilePaths;
+import com.jayfella.website.core.page.SoftwareType;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.repository.UserRepository;
+import com.jayfella.website.database.repository.page.LivePageRepository;
+import com.jayfella.website.database.repository.page.PageAmendmentRepository;
+import com.jayfella.website.database.repository.page.PageDraftRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.ModelMap;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+@Controller
+@RequestMapping(value = "/admin/", produces = MediaType.TEXT_HTML_VALUE)
+public class AdminController {
+
+    @Autowired private UserRepository userRepository;
+    @Autowired private LivePageRepository livePageRepository;
+    @Autowired private PageDraftRepository draftRepository;
+    @Autowired private PageAmendmentRepository amendmentRepository;
+
+    @GetMapping()
+    public String getIndex(HttpServletResponse response, ModelMap model) throws IOException {
+
+        User user = (User) model.get(ServerAdvice.KEY_USER);
+
+        if (user == null || !user.isAdministrator()) {
+            response.sendRedirect("/");
+            return null;
+        }
+
+        model.put("userCount", userRepository.count());
+
+        model.put("opensourceCount", livePageRepository.countBySoftwareType(SoftwareType.OpenSource));
+        model.put("sponsoredCount", livePageRepository.countBySoftwareType(SoftwareType.Sponsored));
+        model.put("paidCount", livePageRepository.countBySoftwareType(SoftwareType.Paid));
+
+        model.put("draftCount", draftRepository.count());
+        model.put("amendmentCount", amendmentRepository.count());
+
+        return StoreHtmlFilePaths.Admin.INDEX.getHtmlFilePath();
+    }
+
+    @GetMapping("/badges/")
+    public String getBadgesPage(HttpServletResponse response, ModelMap model) throws IOException {
+        User user = (User) model.get(ServerAdvice.KEY_USER);
+
+        if (user == null || !user.isAdministrator()) {
+            response.sendRedirect("/");
+            return null;
+        }
+
+        return StoreHtmlFilePaths.Admin.BADGES.getHtmlFilePath();
+    }
+
+    @GetMapping("/user/{username}")
+    public String getUserPage(HttpServletResponse response, ModelMap model, @PathVariable("username") String username) throws IOException {
+        User user = (User) model.get(ServerAdvice.KEY_USER);
+
+        if (user == null || !user.isAdministrator()) {
+            response.sendRedirect("/");
+            return null;
+        }
+
+        model.addAttribute("username", username);
+
+        return StoreHtmlFilePaths.Admin.USER.getHtmlFilePath();
+    }
+
+    @GetMapping("/users/")
+    public String getUsersPage(HttpServletResponse response, ModelMap model) throws IOException {
+        User user = (User) model.get(ServerAdvice.KEY_USER);
+
+        if (user == null || !user.isAdministrator()) {
+            response.sendRedirect("/");
+            return null;
+        }
+
+        return StoreHtmlFilePaths.Admin.USERS.getHtmlFilePath();
+    }
+
+    @GetMapping("/pages/")
+    public String getAssetsPage(ModelMap model, HttpServletResponse response) throws IOException {
+
+        User user = (User) model.get(ServerAdvice.KEY_USER);
+
+        if (user == null || !user.isAdministrator()) {
+            response.sendRedirect("/");
+            return null;
+        }
+
+        return StoreHtmlFilePaths.Admin.PAGES.getHtmlFilePath();
+    }
+
+    @GetMapping("/categories")
+    public String getCategoriesPage(ModelMap model, HttpServletResponse response) throws IOException {
+        User user = (User) model.get(ServerAdvice.KEY_USER);
+
+        if (user == null || !user.isAdministrator()) {
+            response.sendRedirect("/");
+            return null;
+        }
+
+        return StoreHtmlFilePaths.Admin.CATEGORIES.getHtmlFilePath();
+    }
+
+}

+ 17 - 0
src/main/java/com/jayfella/website/controller/http/BlogController.java

@@ -0,0 +1,17 @@
+package com.jayfella.website.controller.http;
+
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+@Controller
+@RequestMapping(path = "/blog/", produces = MediaType.TEXT_HTML_VALUE)
+public class BlogController {
+
+    @GetMapping()
+    public String get() {
+        return "/blog/index.html";
+    }
+
+}

+ 25 - 0
src/main/java/com/jayfella/website/controller/http/CategoryController.java

@@ -0,0 +1,25 @@
+package com.jayfella.website.controller.http;
+
+import com.jayfella.website.database.repository.CategoryRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.ModelMap;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.io.IOException;
+
+@Controller
+@RequestMapping(path = "/category/", produces = MediaType.TEXT_HTML_VALUE)
+public class CategoryController {
+
+    @Autowired private CategoryRepository categoryRepository;
+
+    @RequestMapping("/{categoryName}/")
+    public String listCategory(ModelMap model, @PathVariable("categoryName") String categoryName) throws IOException {
+        model.put("category", categoryName);
+        return "/category/index.html";
+    }
+
+}

+ 27 - 0
src/main/java/com/jayfella/website/controller/http/ContactUsController.java

@@ -0,0 +1,27 @@
+package com.jayfella.website.controller.http;
+
+import com.jayfella.website.core.StoreHtmlFilePaths;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+@Controller
+@RequestMapping(path = "/contact/", produces = MediaType.TEXT_HTML_VALUE)
+public class ContactUsController {
+
+    @GetMapping
+    public String displayContactUsPage() {
+        return StoreHtmlFilePaths.Contact.INDEX.getHtmlFilePath();
+    }
+
+    @PostMapping
+    public String postContactUsPage() {
+
+        // @TODO: email [email protected] with the details.
+
+        return StoreHtmlFilePaths.Contact.SUCCESS.getHtmlFilePath();
+    }
+
+}

+ 52 - 0
src/main/java/com/jayfella/website/controller/http/CreatePageController.java

@@ -0,0 +1,52 @@
+package com.jayfella.website.controller.http;
+
+import com.jayfella.website.core.PageRequirements;
+import com.jayfella.website.core.ServerAdvice;
+import com.jayfella.website.core.StoreHtmlFilePaths;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.repository.page.PageDraftRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.ModelMap;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+@Controller
+@RequestMapping(value = "/", produces = MediaType.TEXT_HTML_VALUE)
+public class CreatePageController {
+
+    @Autowired private PageDraftRepository draftRepository;
+
+    /**
+     * AUTH: USER
+     * Displays the "create potential asset" web page.
+     */
+    @GetMapping("/create/")
+    public String createPotentialAsset(HttpServletResponse response, ModelMap model) throws IOException {
+
+        User user = (User) model.get(ServerAdvice.KEY_USER);
+
+        if (user == null) {
+            response.sendRedirect(StoreHtmlFilePaths.User.LOGIN.getUrlPath());
+        }
+
+        // count how many potential assets the user has
+        long count = draftRepository.countByOwner(user);
+
+        if (count >= PageRequirements.MAX_POTENTIAL_ASSETS) {
+            response.sendError(HttpStatus.FORBIDDEN.value(), "You can only create a maximum of " + PageRequirements.MAX_POTENTIAL_ASSETS + " potential assets.");
+            return null;
+        }
+
+        return StoreHtmlFilePaths.Store.CREATE_PAGE.getHtmlFilePath();
+    }
+
+
+
+
+}

+ 126 - 0
src/main/java/com/jayfella/website/controller/http/EditPageController.java

@@ -0,0 +1,126 @@
+package com.jayfella.website.controller.http;
+
+import com.jayfella.website.core.HtmlResponses;
+import com.jayfella.website.core.ResponseStrings;
+import com.jayfella.website.core.ServerAdvice;
+import com.jayfella.website.core.StoreHtmlFilePaths;
+import com.jayfella.website.core.page.PageState;
+import com.jayfella.website.database.entity.page.stages.PageAmendment;
+import com.jayfella.website.database.entity.page.stages.PageDraft;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.repository.page.LivePageRepository;
+import com.jayfella.website.database.repository.page.PageAmendmentRepository;
+import com.jayfella.website.database.repository.page.PageDraftRepository;
+import com.jayfella.website.license.LicenseTypes;
+import com.jayfella.website.license.OpenSourceLicense;
+import com.jayfella.website.service.PageService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.ModelMap;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Controller
+@RequestMapping(value = "/edit/", produces = MediaType.TEXT_HTML_VALUE)
+public class EditPageController {
+
+    @Autowired private PageDraftRepository draftRepository;
+    @Autowired private LivePageRepository livePageRepository;
+    @Autowired private PageAmendmentRepository amendmentRepository;
+
+    @Autowired private PageService pageService;
+
+    @GetMapping("/draft/{pageId}")
+    public String editAsset(HttpServletResponse response, ModelMap model,
+                            @PathVariable("pageId") String pageId) throws IOException {
+
+        User user = (User) model.get(ServerAdvice.KEY_USER);
+
+        if (user == null) {
+            response.sendRedirect(StoreHtmlFilePaths.User.LOGIN.getUrlPath());
+            return null;
+        }
+
+        PageDraft draft = draftRepository.findById(pageId).orElse(null);
+
+        if (draft == null) {
+            response.sendError(HttpStatus.NOT_FOUND.value(), HtmlResponses.pageNotFound(PageState.Draft, pageId));
+            return null;
+        }
+
+        if (!pageService.isOwnerOrModerator(user, draft)) {
+            response.sendError(HttpStatus.FORBIDDEN.value(), ResponseStrings.INSUFFICIENT_PERMISSION);
+            return null;
+        }
+
+        model.addAttribute("pageId", draft.getId());
+        model.addAttribute("pageState", PageState.Draft.name());
+
+        List<OpenSourceLicense> softwareLicenses = Arrays.stream(OpenSourceLicense.values())
+                .filter(license -> license.getLicenseType() == LicenseTypes.Software)
+                .collect(Collectors.toList());
+
+        List<OpenSourceLicense> mediaLicenses = Arrays.stream(OpenSourceLicense.values())
+                .filter(license -> license.getLicenseType() == LicenseTypes.Media)
+                .collect(Collectors.toList());
+
+        model.addAttribute("softwareLicenses", softwareLicenses);
+        model.addAttribute("mediaLicenses", mediaLicenses);
+
+        // model.addAttribute(potentialAmendment.getId());
+
+        return StoreHtmlFilePaths.Store.EDIT_PAGE.getHtmlFilePath();
+    }
+
+    @GetMapping("/amendment/{pageId}")
+    public String editAmendment(HttpServletResponse response, ModelMap model,
+                            @PathVariable("pageId") String pageId) throws IOException {
+
+        User user = (User) model.get(ServerAdvice.KEY_USER);
+
+        if (user == null) {
+            response.sendRedirect(StoreHtmlFilePaths.User.LOGIN.getUrlPath());
+            return null;
+        }
+
+        PageAmendment amendment = amendmentRepository.findById(pageId).orElse(null);
+
+        if (amendment == null) {
+            response.sendError(HttpStatus.NOT_FOUND.value(), HtmlResponses.pageNotFound(PageState.Draft, pageId));
+            return null;
+        }
+
+        if (!pageService.isOwnerOrModerator(user, amendment)) {
+            response.sendError(HttpStatus.FORBIDDEN.value(), ResponseStrings.INSUFFICIENT_PERMISSION);
+            return null;
+        }
+
+        model.addAttribute("pageId", amendment.getId());
+        model.addAttribute("pageState", PageState.Amendment.name());
+
+        List<OpenSourceLicense> softwareLicenses = Arrays.stream(OpenSourceLicense.values())
+                .filter(license -> license.getLicenseType() == LicenseTypes.Software)
+                .collect(Collectors.toList());
+
+        List<OpenSourceLicense> mediaLicenses = Arrays.stream(OpenSourceLicense.values())
+                .filter(license -> license.getLicenseType() == LicenseTypes.Media)
+                .collect(Collectors.toList());
+
+        model.addAttribute("softwareLicenses", softwareLicenses);
+        model.addAttribute("mediaLicenses", mediaLicenses);
+
+        return StoreHtmlFilePaths.Store.EDIT_PAGE.getHtmlFilePath();
+    }
+
+
+
+}

+ 18 - 0
src/main/java/com/jayfella/website/controller/http/ExceptionController.java

@@ -0,0 +1,18 @@
+package com.jayfella.website.controller.http;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.servlet.NoHandlerFoundException;
+
+@ControllerAdvice
+public class ExceptionController {
+
+    @ExceptionHandler({NoHandlerFoundException.class})
+    @ResponseStatus(value = HttpStatus.NOT_FOUND)
+    public String handle404Exception() {
+        return "error/404.html";
+    }
+
+}

+ 35 - 0
src/main/java/com/jayfella/website/controller/http/GeneralController.java

@@ -0,0 +1,35 @@
+package com.jayfella.website.controller.http;
+
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+
+@Controller
+public class GeneralController {
+
+    private byte[] favicon;
+
+    @GetMapping(path = "/favicon.ico", produces = "image/x-icon")
+    @ResponseBody
+    public byte[] getFavicon() throws IOException {
+
+        if (favicon == null) {
+
+            File faviconFile = new File("./www/favicon.ico");
+            favicon = Files.readAllBytes(faviconFile.toPath());
+        }
+
+        return favicon;
+    }
+
+    @GetMapping(path = "/robots.txt", produces = MediaType.TEXT_PLAIN_VALUE)
+    public String getRobots() {
+        return "/robots.txt";
+    }
+
+}

+ 68 - 0
src/main/java/com/jayfella/website/controller/http/ImageController.java

@@ -0,0 +1,68 @@
+package com.jayfella.website.controller.http;
+
+import com.jayfella.website.service.ImageService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.CacheControl;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+
+@Controller
+@RequestMapping(path = "/image/", produces = MediaType.IMAGE_JPEG_VALUE)
+public class ImageController {
+
+    private static final Logger log = LoggerFactory.getLogger(ImageController.class);
+
+    private final byte[] image404 = loadImage404();
+
+    @Autowired private ImageService imageService;
+
+    private byte[] loadImage404() {
+        File imageFile = new File("./www/images/image-not-found.jpg");
+
+        try {
+            return Files.readAllBytes(imageFile.toPath());
+        } catch (IOException e) {
+            e.printStackTrace();
+        }
+
+        // should never happen since we call a static image.
+        return null;
+    }
+
+    @GetMapping(value = "/{imageId}")
+    @ResponseBody
+    public ResponseEntity<byte[]> getImage(@PathVariable("imageId") String imageId) {
+
+        try {
+
+            byte[] imageData = imageService.read(imageId + ImageService.IMAGE_EXT);
+
+            if (imageData.length != 0) {
+                return ResponseEntity.ok()
+                        .cacheControl(CacheControl.noCache())
+                        .body(imageData);
+            }
+
+        } catch (IOException e) {
+            // e.printStackTrace();
+            log.info("Requested image not found: " + imageId);
+        }
+
+        return ResponseEntity.status(HttpStatus.NOT_FOUND)
+                .body(image404);
+
+    }
+
+}

+ 63 - 0
src/main/java/com/jayfella/website/controller/http/IndexPageController.java

@@ -0,0 +1,63 @@
+package com.jayfella.website.controller.http;
+
+import com.jayfella.website.core.StoreHtmlFilePaths;
+import com.jayfella.website.core.page.PageState;
+import com.jayfella.website.database.entity.page.stages.LivePage;
+import com.jayfella.website.database.repository.page.LivePageRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+@Controller
+@RequestMapping(path = "/", produces = MediaType.TEXT_HTML_VALUE)
+public class IndexPageController {
+
+    @Autowired private LivePageRepository livePageRepository;
+
+    /**
+     * AUTH: PUBLIC
+     * Displays the store index page.
+     */
+    @GetMapping
+    public String index() {
+        return StoreHtmlFilePaths.Store.INDEX.getHtmlFilePath();
+    }
+
+    /**
+     * AUTH: PUBLIC
+     * Displays a live page.
+     */
+    @GetMapping(value = "/{pageId}")
+    public String viewAsset(Model model, HttpServletResponse response, @PathVariable("pageId") String pageId) throws IOException {
+
+        LivePage livePage = livePageRepository.findById(pageId).orElse(null);
+
+        if (livePage == null) {
+            response.sendError(HttpStatus.NOT_FOUND.value(), "The requested page could not be found.");
+            return null;
+        }
+
+        model.addAttribute("pageId", livePage.getId());
+        model.addAttribute("assetType", PageState.Live);
+        model.addAttribute("preview", false);
+
+        String previewImageId = livePage.getMediaLinks().getImageIds().split(",")[0];
+
+        // for previews, we need to transfer the preview data.
+        model.addAttribute("previewTitle", livePage.getDetails().getTitle());
+        model.addAttribute("previewDescription", livePage.getDetails().getShortDescription());
+        model.addAttribute("previewUrl", "https://jmonkeystore.com/" + livePage.getId());
+        model.addAttribute("previewImage", "https://jmonkeystore.com/image/" + previewImageId + ".jpg");
+
+        return StoreHtmlFilePaths.Store.VIEW_PAGE.getHtmlFilePath();
+    }
+
+}

+ 40 - 0
src/main/java/com/jayfella/website/controller/http/LegalController.java

@@ -0,0 +1,40 @@
+package com.jayfella.website.controller.http;
+
+import com.jayfella.website.license.LicenseConditions;
+import com.jayfella.website.license.LicenseLimitations;
+import com.jayfella.website.license.LicensePermissions;
+import com.jayfella.website.license.OpenSourceLicense;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+@Controller
+@RequestMapping("/legal/")
+public class LegalController {
+
+    @GetMapping("/cookies/")
+    public String getCookiesPage() {
+        return "/legal/cookies.html";
+    }
+
+    @GetMapping("/terms/")
+    public String getTermsOfService() {
+        return "/legal/tos.html";
+    }
+
+    @GetMapping("/license/opensource/")
+    public String getOpenSourcLicenseChooser(Model model) {
+
+        model.addAttribute("licenses", OpenSourceLicense.values());
+        model.addAttribute("licenseTypes", OpenSourceLicense.values());
+        model.addAttribute("licensePermissions", LicensePermissions.values());
+        model.addAttribute("licenseConditions", LicenseConditions.values());
+        model.addAttribute("licenseLimitations", LicenseLimitations.values());
+
+        // return "/choose-license.html";
+
+        return "/legal/license/os-license-chooser.html";
+    }
+
+}

+ 51 - 0
src/main/java/com/jayfella/website/controller/http/MessageController.java

@@ -0,0 +1,51 @@
+package com.jayfella.website.controller.http;
+
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.repository.MessagesRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.ModelMap;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+import static com.jayfella.website.core.ServerAdvice.KEY_USER;
+
+@Controller
+@RequestMapping("/messages/")
+public class MessageController {
+
+    @Autowired private MessagesRepository messagesRepository;
+
+    // USER display inbox
+    @GetMapping("/")
+    public String displayInbox(ModelMap model) {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return "redirect:/user/login/";
+        }
+
+        return "/messages/index.html";
+    }
+
+    // USER display a particular mesage.
+    @GetMapping("/{messageId}")
+    public String displayMessage(HttpServletResponse response,
+                                 ModelMap model,
+                                 @PathVariable("messageId") long messageId) throws IOException {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            return "redirect:/user/login/";
+        }
+
+        return "/messages/view-message.html";
+    }
+
+}

+ 15 - 0
src/main/java/com/jayfella/website/controller/http/OAuthCallbackController.java

@@ -0,0 +1,15 @@
+package com.jayfella.website.controller.http;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+@Controller
+@RequestMapping("/callback/")
+public class OAuthCallbackController {
+
+    @GetMapping("/github/")
+    public void auth() {
+
+    }
+}

+ 101 - 0
src/main/java/com/jayfella/website/controller/http/PreviewPageController.java

@@ -0,0 +1,101 @@
+package com.jayfella.website.controller.http;
+
+import com.jayfella.website.core.HtmlResponses;
+import com.jayfella.website.core.ResponseStrings;
+import com.jayfella.website.core.ServerAdvice;
+import com.jayfella.website.core.StoreHtmlFilePaths;
+import com.jayfella.website.core.page.PageState;
+import com.jayfella.website.database.entity.page.stages.PageAmendment;
+import com.jayfella.website.database.entity.page.stages.PageDraft;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.repository.page.LivePageRepository;
+import com.jayfella.website.database.repository.page.PageAmendmentRepository;
+import com.jayfella.website.database.repository.page.PageDraftRepository;
+import com.jayfella.website.service.PageService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.ModelMap;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+@Controller
+@RequestMapping(value = "/preview/", produces = MediaType.TEXT_HTML_VALUE)
+public class PreviewPageController {
+
+    @Autowired private PageDraftRepository draftRepository;
+    @Autowired private LivePageRepository livePageRepository;
+    @Autowired private PageAmendmentRepository amendmentRepository;
+
+    @Autowired private PageService pageService;
+
+    @GetMapping("/draft/{pageId}")
+    public String previewDraft(HttpServletResponse response, ModelMap model,
+                                   @PathVariable("pageId") String pageId) throws IOException {
+
+        User user = (User) model.get(ServerAdvice.KEY_USER);
+
+        if (user == null) {
+            response.sendError(HttpStatus.FORBIDDEN.value(), ResponseStrings.NOT_LOGGED_IN);
+            return null;
+        }
+
+        PageDraft draft = draftRepository.findById(pageId).orElse(null);
+
+        if (draft == null) {
+            response.sendError(HttpStatus.NOT_FOUND.value(), HtmlResponses.pageNotFound(PageState.Draft, pageId));
+            return null;
+        }
+
+        if (!pageService.isOwnerOrModerator(user, draft)) {
+            response.sendError(HttpStatus.FORBIDDEN.value(), ResponseStrings.INSUFFICIENT_PERMISSION);
+            return null;
+        }
+
+        model.addAttribute("pageId", draft.getId());
+        model.addAttribute("pageState", PageState.Draft);
+        model.addAttribute("preview", true);
+
+        return StoreHtmlFilePaths.Store.VIEW_PAGE.getHtmlFilePath();
+    }
+
+    @GetMapping("/amendment/{pageId}")
+    public String previewAmendment(HttpServletResponse response, ModelMap model,
+                                        @PathVariable("pageId") String pageId) throws IOException {
+
+        User user = (User) model.get(ServerAdvice.KEY_USER);
+
+        if (user == null) {
+            response.sendError(HttpStatus.FORBIDDEN.value(), ResponseStrings.NOT_LOGGED_IN);
+            return null;
+        }
+
+        PageAmendment amendment = amendmentRepository.findById(pageId).orElse(null);
+
+        if (amendment == null) {
+            response.sendError(HttpStatus.NOT_FOUND.value(), HtmlResponses.pageNotFound(PageState.Amendment, pageId));
+            return null;
+        }
+
+        if (!pageService.isOwnerOrModerator(user, amendment)) {
+            response.sendError(HttpStatus.FORBIDDEN.value(), ResponseStrings.INSUFFICIENT_PERMISSION);
+            return null;
+        }
+
+        // model.addAttribute("asset", amendment);
+        model.addAttribute("pageId", amendment.getId());
+        model.addAttribute("preview", true);
+        model.addAttribute("pageState", PageState.Amendment.name());
+
+        return StoreHtmlFilePaths.Store.VIEW_PAGE.getHtmlFilePath();
+
+    }
+
+
+
+}

+ 95 - 0
src/main/java/com/jayfella/website/controller/http/RejectionController.java

@@ -0,0 +1,95 @@
+package com.jayfella.website.controller.http;
+
+import com.jayfella.website.core.HtmlResponses;
+import com.jayfella.website.core.ResponseStrings;
+import com.jayfella.website.core.page.PageState;
+import com.jayfella.website.database.entity.page.stages.PageAmendment;
+import com.jayfella.website.database.entity.page.stages.PageDraft;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.repository.StaffPageReviewRepository;
+import com.jayfella.website.database.repository.page.PageAmendmentRepository;
+import com.jayfella.website.database.repository.page.PageDraftRepository;
+import com.jayfella.website.service.PageService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.ModelMap;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+import static com.jayfella.website.core.ServerAdvice.KEY_USER;
+
+@Controller
+@RequestMapping("/rejections/")
+public class RejectionController {
+
+    @Autowired private PageDraftRepository draftRepository;
+    @Autowired private PageAmendmentRepository amendmentRepository;
+
+    @Autowired private StaffPageReviewRepository staffReviewRepository;
+
+    @Autowired private PageService pageService;
+
+    @GetMapping("/draft/{pageId}")
+    public String getDraftPageRejections(ModelMap model,
+                                               HttpServletResponse response,
+                                               @PathVariable("pageId") String pageId) throws IOException {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            response.sendError(HttpStatus.FORBIDDEN.value(), ResponseStrings.NOT_LOGGED_IN);
+            return null;
+        }
+
+        PageDraft draft = draftRepository.findById(pageId).orElse(null);
+
+        if (draft == null) {
+            response.sendError(HttpStatus.NOT_FOUND.value(), HtmlResponses.pageNotFound(PageState.Draft, pageId));
+            return null;
+        }
+
+        if (!pageService.isOwnerOrModerator(user, draft)) {
+            response.sendError(HttpStatus.FORBIDDEN.value(), ResponseStrings.INSUFFICIENT_PERMISSION);
+        }
+
+        model.addAttribute("pageId", pageId);
+        model.addAttribute("pageState", PageState.Draft.name());
+
+        return "/rejection/rejections.html";
+    }
+
+    @GetMapping("/amendment/{pageId}")
+    public String getAmendmentPageRejections(ModelMap model,
+                                    HttpServletResponse response,
+                                    @PathVariable("pageId") String pageId) throws IOException {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            response.sendError(HttpStatus.FORBIDDEN.value(), ResponseStrings.NOT_LOGGED_IN);
+            return null;
+        }
+
+        PageAmendment amendment = amendmentRepository.findById(pageId).orElse(null);
+
+        if (amendment == null) {
+            response.sendError(HttpStatus.NOT_FOUND.value(), HtmlResponses.pageNotFound(PageState.Amendment, pageId));
+            return null;
+        }
+
+        if (!pageService.isOwnerOrModerator(user, amendment)) {
+            response.sendError(HttpStatus.FORBIDDEN.value(), ResponseStrings.INSUFFICIENT_PERMISSION);
+        }
+
+        model.addAttribute("pageId", pageId);
+        model.addAttribute("pageState", PageState.Amendment.name());
+
+        return "/rejection/rejections.html";
+    }
+
+}

+ 53 - 0
src/main/java/com/jayfella/website/controller/http/StoreSearchController.java

@@ -0,0 +1,53 @@
+package com.jayfella.website.controller.http;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+@Controller
+@RequestMapping("/search/")
+public class StoreSearchController {
+
+    @GetMapping
+    public String get(Model model) {
+
+        model.addAttribute("categoryId", "-1");
+        model.addAttribute("secondary", "title");
+        model.addAttribute("term", "");
+
+        model.addAttribute("searchOnLoad", false);
+
+        return "/search/index.html";
+    }
+
+    @GetMapping("/{categoryId}/")
+    public String getWithCategory(Model model,
+                                              @PathVariable("categoryId") int categoryId) {
+
+        model.addAttribute("categoryId", categoryId);
+        model.addAttribute("secondary", "title");
+        model.addAttribute("term", "");
+
+        model.addAttribute("searchOnLoad", true);
+
+        return "/search/index.html";
+    }
+
+    @GetMapping("/{categoryId}/{secondary}/{term}")
+    public String getWithCategoryAndSecondary(Model model,
+                                  @PathVariable("categoryId") int categoryId,
+                                  @PathVariable(value = "secondary") String secondary,
+                                  @PathVariable(value = "term") String term) {
+
+        model.addAttribute("categoryId", categoryId);
+        model.addAttribute("secondary", secondary);
+        model.addAttribute("term", term == null ? "" : term);
+
+        model.addAttribute("searchOnLoad", true);
+
+        return "/search/index.html";
+    }
+
+}

+ 16 - 0
src/main/java/com/jayfella/website/controller/http/TestController.java

@@ -0,0 +1,16 @@
+package com.jayfella.website.controller.http;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+@Controller
+@RequestMapping("/test/")
+public class TestController {
+
+    @GetMapping
+    public String showTestPage() {
+        return "/test.html";
+    }
+
+}

+ 237 - 0
src/main/java/com/jayfella/website/controller/http/UserController.java

@@ -0,0 +1,237 @@
+package com.jayfella.website.controller.http;
+
+import com.jayfella.website.config.external.ServerConfig;
+import com.jayfella.website.core.AccountValidationType;
+import com.jayfella.website.core.ServerAdvice;
+import com.jayfella.website.core.ServerUtilities;
+import com.jayfella.website.core.StoreHtmlFilePaths;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.database.entity.user.UserValidation;
+import com.jayfella.website.database.repository.SessionRepository;
+import com.jayfella.website.database.repository.UserRepository;
+import com.jayfella.website.database.repository.UserValidationRepository;
+import com.jayfella.website.http.request.user.LoginRequest;
+import com.jayfella.website.http.request.user.RegisterRequest;
+import com.jayfella.website.service.EmailService;
+import com.jayfella.website.service.UserService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.ui.ModelMap;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.*;
+
+import javax.mail.MessagingException;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletResponse;
+import javax.validation.Valid;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import static com.jayfella.website.core.ServerAdvice.KEY_SESSION;
+import static com.jayfella.website.core.ServerAdvice.KEY_USER;
+
+@Controller
+@RequestMapping(value = "/user/", produces = MediaType.TEXT_HTML_VALUE)
+public class UserController {
+
+    @Autowired private UserValidationRepository userValidationRepository;
+
+    @Autowired private UserRepository userRepository;
+    @Autowired private SessionRepository sessionRepository;
+
+    @Autowired private EmailService emailService;
+    @Autowired private UserService userService;
+
+    @GetMapping()
+    public String getIndexPage(HttpServletResponse response, ModelMap model) throws IOException {
+
+        User user = (User) model.get(KEY_USER);
+
+        if (user == null) {
+            response.sendRedirect(StoreHtmlFilePaths.User.LOGIN.getUrlPath());
+            return null;
+        }
+
+        /*
+        userValidationRepository.findByUserId(user.getId())
+                .ifPresent(validation -> model.addAttribute("validation", validation));
+         */
+
+        return StoreHtmlFilePaths.User.INDEX.getHtmlFilePath();
+    }
+
+    @GetMapping("/login/")
+    public String getLoginPage() {
+        return StoreHtmlFilePaths.User.LOGIN.getHtmlFilePath();
+    }
+
+    @PostMapping("/login/")
+    public String postLogin(HttpServletResponse response, Model model,
+                            @ModelAttribute @Valid LoginRequest loginRequest,
+                            BindingResult result) throws IOException {
+
+        if (result.hasErrors()) {
+            List<String> errors = ServerUtilities.getErrorMessages(result);
+            model.addAttribute("error", errors);
+            return StoreHtmlFilePaths.User.LOGIN.getHtmlFilePath();
+        }
+
+        User user = userRepository.findByUsernameIgnoreCase(loginRequest.getUsername()).orElse(null);
+
+        if (user == null) {
+            model.addAttribute("error", new String[] { "Invalid username or password specified." });
+            return StoreHtmlFilePaths.User.LOGIN.getHtmlFilePath();
+        }
+
+        boolean passwordMatch = userService.passwordsMatch(user, loginRequest.getPassword());
+
+        if (!passwordMatch) {
+            model.addAttribute("error", new String[] { "Invalid username or password specified." });
+            return StoreHtmlFilePaths.User.LOGIN.getHtmlFilePath();
+        }
+
+        Cookie cookie = userService.createSessionCookie(user);
+        response.addCookie(cookie);
+
+        response.sendRedirect(StoreHtmlFilePaths.Store.INDEX.getUrlPath());
+        return null;
+    }
+
+    @PostMapping("/logout/")
+    public String postLogout(ModelMap model,
+                             HttpServletResponse response,
+                             @CookieValue(value = KEY_SESSION) String session) throws IOException {
+
+        User user = (User) model.get(ServerAdvice.KEY_USER);
+
+        if (user != null) {
+            sessionRepository.findBySession(session).ifPresent(s -> sessionRepository.delete(s));
+        }
+
+        response.sendRedirect(StoreHtmlFilePaths.Store.INDEX.getUrlPath());
+        return null;
+    }
+
+    @GetMapping("/register/")
+    public String getRegisterPage(HttpServletResponse response) throws IOException {
+
+        if (ServerConfig.getInstance().getWebsiteConfig().isRegistrationDisabled()) {
+            response.sendError(HttpStatus.FORBIDDEN.value(), "Registration is currently disabled.");
+            return null;
+        }
+
+        return StoreHtmlFilePaths.User.REGISTER.getHtmlFilePath();
+    }
+
+    @GetMapping("/registered/")
+    public String getRegisteredPage() {
+        return StoreHtmlFilePaths.User.REGISTERED.getHtmlFilePath();
+    }
+
+    @PostMapping("/register/")
+    public String postRegisterPage(HttpServletResponse response, Model model,
+                                   @ModelAttribute @Valid RegisterRequest registerRequest,
+                                   BindingResult result) throws IOException {
+
+        if (ServerConfig.getInstance().getWebsiteConfig().isRegistrationDisabled()) {
+            response.sendError(HttpStatus.FORBIDDEN.value(), "Registration is currently disabled.");
+            return null;
+        }
+
+        List<String> errors = new ArrayList<>();
+
+        if (result.hasErrors()) {
+            errors.addAll(ServerUtilities.getErrorMessages(result));
+            model.addAttribute("The following errors occurred:", errors);
+            return StoreHtmlFilePaths.User.REGISTER.getHtmlFilePath();
+        }
+
+        errors = userService.isValidDetails(registerRequest);
+
+        if (!errors.isEmpty()) {
+            model.addAttribute("error", errors);
+            return StoreHtmlFilePaths.User.REGISTER.getHtmlFilePath();
+        }
+
+        User user = userService.createUser(registerRequest);
+        model.addAttribute(KEY_USER, user);
+
+        // create a session
+        Cookie cookie = userService.createSessionCookie(user);
+        response.addCookie(cookie);
+
+        // create validation requirement
+        UserValidation userValidation = new UserValidation(user.getId(), AccountValidationType.Account, "");
+        userValidationRepository.save(userValidation);
+
+        // send validation email.
+        try {
+            emailService.sendRegistrationEmail(user, userValidation);
+        } catch (MessagingException e) {
+            // @TODO: report this to the administrator group.
+            e.printStackTrace();
+        }
+
+        response.sendRedirect(StoreHtmlFilePaths.User.REGISTERED.getUrlPath());
+        return null;
+    }
+
+    @GetMapping("/my-pages/")
+    public String displayUserAssets(HttpServletResponse response, ModelMap model) throws IOException {
+
+        User user = (User) model.get(ServerAdvice.KEY_USER);
+
+        if (user == null) {
+            response.sendRedirect(StoreHtmlFilePaths.User.LOGIN.getUrlPath());
+            return null;
+        }
+
+        return StoreHtmlFilePaths.User.MY_PAGES.getHtmlFilePath();
+    }
+
+    @GetMapping("/profile/{userId}")
+    public String getUserProfile(Model model, HttpServletResponse response, @PathVariable("userId")long userId) throws IOException {
+
+        model.addAttribute("requested_user", userId);
+        return StoreHtmlFilePaths.User.PROFILE.getHtmlFilePath();
+    }
+
+    @GetMapping("/reset-password/")
+    public String getResetPassword() {
+        return "/user/reset-password.html";
+    }
+
+    @GetMapping("/reset-password/{validationCode}")
+    public String resetPasswordWithCode(HttpServletResponse response, @PathVariable("validationCode") String validationCode) throws IOException {
+
+        UserValidation userValidation = userValidationRepository.findById(validationCode).orElse(null);
+
+        if (userValidation == null || userValidation.getValidationType() != AccountValidationType.Password) {
+            response.sendError(HttpServletResponse.SC_NOT_FOUND, "Invalid code specified.");
+            return null;
+        }
+
+        User user = userRepository.findById(userValidation.getUserId()).orElse(null);
+
+        if (user == null) {
+            userValidationRepository.delete(userValidation);
+            response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+                    "The specified code was valid but the user no longer exists.");
+            return null;
+        }
+
+        user.setPassword(userValidation.getValue());
+        userRepository.save(user);
+
+        userValidationRepository.delete(userValidation);
+
+        return "/user/password-validated.html";
+    }
+
+
+
+}

+ 23 - 0
src/main/java/com/jayfella/website/core/AccountValidationType.java

@@ -0,0 +1,23 @@
+package com.jayfella.website.core;
+
+public enum AccountValidationType {
+
+    Email,          // user wants to change email addresses.
+    Password,       // user wants to change password.
+    Account;        // user needs to validate their account via an email link.
+
+    public static AccountValidationType fromString(String input) {
+        if (input == null || input.trim().isEmpty()) {
+            return null;
+        }
+
+        for (AccountValidationType validation : values()) {
+            if (validation.name().equalsIgnoreCase(input.trim())) {
+                return validation;
+            }
+        }
+
+        return null;
+    }
+
+}

+ 76 - 0
src/main/java/com/jayfella/website/core/ApiResponses.java

@@ -0,0 +1,76 @@
+package com.jayfella.website.core;
+
+import com.jayfella.website.core.page.PageState;
+import com.jayfella.website.database.entity.page.StorePage;
+import com.jayfella.website.http.response.SimpleApiResponse;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.BindingResult;
+
+import static com.jayfella.website.core.ResponseStrings.*;
+
+public class ApiResponses {
+
+
+
+    public static final ResponseEntity<SimpleApiResponse> noChangesDetected() {
+        return ResponseEntity.badRequest()
+                .body(new SimpleApiResponse(NO_CHANGES_DETECTED));
+    }
+
+    public static ResponseEntity<SimpleApiResponse> userNotFound(long userId) {
+        return ResponseEntity.status(HttpStatus.NOT_FOUND)
+                .body(new SimpleApiResponse(String.format(USER_NOT_FOUND, userId)));
+    }
+
+    public static ResponseEntity<SimpleApiResponse> pageRejected(StorePage page) {
+        return ResponseEntity.ok()
+                .body(new SimpleApiResponse(String.format(PAGE_REJECTED, page.getDetails().getTitle())));
+    }
+
+    public static ResponseEntity<SimpleApiResponse> searchTermTooShort() {
+        return ResponseEntity.status(HttpStatus.EXPECTATION_FAILED)
+                .body(new SimpleApiResponse(String.format(SEARCH_TOO_SHORT, PageRequirements.MIN_SEARCH_LENGTH)));
+    }
+
+    public static ResponseEntity<SimpleApiResponse> pageDeleted(StorePage page) {
+        return ResponseEntity.ok()
+                .body(new SimpleApiResponse(String.format(PAGE_DELETED, page.getDetails().getTitle())));
+    }
+
+    public static ResponseEntity<SimpleApiResponse> noPageIdSpecified() {
+        return ResponseEntity.badRequest()
+                .body(new SimpleApiResponse(NO_PAGE_ID_SPECIFIED));
+    }
+
+    public static ResponseEntity<SimpleApiResponse> notLoggedIn() {
+        return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                .body(new SimpleApiResponse(NOT_LOGGED_IN));
+    }
+
+    public static ResponseEntity<SimpleApiResponse> badRequest(BindingResult bindingResult) {
+        return ResponseEntity.badRequest()
+                .body(new SimpleApiResponse(BAD_REQUEST, ServerUtilities.getErrorMessages(bindingResult)));
+    }
+
+    public static ResponseEntity<SimpleApiResponse> pageNotFound(PageState pageState, String pageId) {
+        return ResponseEntity.status(HttpStatus.NOT_FOUND)
+                .body(new SimpleApiResponse(String.format(PAGE_NOT_FOUND, pageState.name(), pageId)));
+    }
+
+    public static ResponseEntity<SimpleApiResponse> insufficientPermission() {
+        return ResponseEntity.status(HttpStatus.FORBIDDEN)
+                .body(new SimpleApiResponse(INSUFFICIENT_PERMISSION));
+    }
+
+    public static ResponseEntity<SimpleApiResponse> staffApprovalSuccess(StorePage page) {
+        return ResponseEntity.ok()
+                .body(new SimpleApiResponse(String.format(STAFF_PAGE_APPROVED, page.getDetails().getTitle())));
+    }
+
+    public static ResponseEntity<SimpleApiResponse> parentPageNotFound(String pageId) {
+        return ResponseEntity.status(HttpStatus.NOT_FOUND)
+                .body(new SimpleApiResponse(String.format(PARENT_PAGE_NOT_FOUND, pageId)));
+    }
+
+}

+ 65 - 0
src/main/java/com/jayfella/website/core/DatabaseType.java

@@ -0,0 +1,65 @@
+package com.jayfella.website.core;
+
+import com.jayfella.website.config.external.ServerConfig;
+
+public enum DatabaseType
+{
+    MYSQL("com.mysql.cj.jdbc.Driver", "mysql://", true),
+    POSTGRESQL("org.postgresql.Driver", "postgresql://", true),
+    SQLITE("org.sqlite.JDBC", "sqlite:", false);
+
+    private final String driver;
+    private final String prefix;
+    private final boolean requiresPort;
+
+    DatabaseType(String driver, String prefix, boolean requiresPort) {
+        this.driver = driver;
+        this.prefix = prefix;
+        this.requiresPort = requiresPort;
+    }
+
+    public String getDriver()
+    {
+        return this.driver;
+    }
+
+    public String getPrefix()
+    {
+        return this.prefix;
+    }
+
+    public boolean requiresPort()
+    {
+        return this.requiresPort;
+    }
+
+    public String constructDatabaseUrl() {
+
+        StringBuilder connectionString = new StringBuilder()
+                .append("jdbc:")
+                .append(getPrefix())
+                .append(ServerConfig.getInstance().getDatabaseConfig().getAddress());
+
+        if (requiresPort())
+        {
+            connectionString
+                    .append(":")
+                    .append(ServerConfig.getInstance().getDatabaseConfig().getPort())
+                    .append("/")
+                    .append(ServerConfig.getInstance().getDatabaseConfig().getName());
+        }
+        else
+        {
+            connectionString
+                    .append(ServerConfig.getInstance().getDatabaseConfig().getName());
+        }
+
+        connectionString.append("?serverTimezone=GMT");
+        //connectionString.append("&useUnicode=true");
+        //connectionString.append("&characterEncoding=utf8");
+
+
+        return connectionString.toString();
+    }
+
+}

+ 22 - 0
src/main/java/com/jayfella/website/core/EnumUtils.java

@@ -0,0 +1,22 @@
+package com.jayfella.website.core;
+
+public class EnumUtils {
+
+    public static <T extends Enum<T>> T fromString(Class<T> enumerator, String input) {
+
+        if (input == null || input.isEmpty()) {
+            return null;
+        }
+
+        input = input.trim();
+
+        for (T val : enumerator.getEnumConstants()) {
+            if (val.name().equalsIgnoreCase(input)) {
+                return val;
+            }
+        }
+
+        return null;
+    }
+
+}

+ 34 - 0
src/main/java/com/jayfella/website/core/GitRepository.java

@@ -0,0 +1,34 @@
+package com.jayfella.website.core;
+
+public enum GitRepository {
+
+    GitHub("https://github.com/"),
+    BitBucket("https://bitbucket.org/"),
+    GitLab("https://gitlab.com/");
+
+    private final String domain;
+
+    GitRepository(String domain) {
+        this.domain = domain;
+    }
+
+    public String getDomain() {
+        return domain;
+    }
+
+    public static boolean startsWithAny(String input) {
+
+        if (input == null || input.isEmpty()) {
+            return false;
+        }
+
+        for (GitRepository repo : values()) {
+            if (input.toLowerCase().startsWith(input.toLowerCase())) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+}

+ 14 - 0
src/main/java/com/jayfella/website/core/HtmlResponses.java

@@ -0,0 +1,14 @@
+package com.jayfella.website.core;
+
+import com.jayfella.website.core.page.PageState;
+
+import static com.jayfella.website.core.ResponseStrings.*;
+
+public class HtmlResponses {
+
+    public static String pageNotFound(PageState pageState, String pageId) {
+        return String.format(PAGE_NOT_FOUND, pageState, pageId);
+    }
+
+
+}

+ 82 - 0
src/main/java/com/jayfella/website/core/ImageDownloader.java

@@ -0,0 +1,82 @@
+package com.jayfella.website.core;
+
+import javax.imageio.ImageIO;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.URL;
+
+public class ImageDownloader {
+
+    public static byte[] downloadUiAvatar(
+            int size,
+            float fontSize,
+            int length,
+            String name,
+            boolean rounded,
+            boolean bold,
+            String backgroundColor,
+            String fontColor,
+            boolean uppercase) throws IOException {
+
+        String url = "https://ui-avatars.com/api/?name=" + name;
+
+        url += "&size=" + size;
+        url += "&font-size=" + fontSize;
+        url += "&length=" + length;
+        url += "&rounded=" + rounded;
+        url += "&bold=" + bold;
+        url += "&background=" + backgroundColor;
+        url += "&color=" + fontColor;
+        url += "&uppercase=" + uppercase;
+
+        return downloadImage(url);
+    }
+
+    public static byte[] downloadGravatarAvatar(String emailHash) throws IOException {
+
+        String gravatarUrl = "https://www.gravatar.com/avatar/";
+        gravatarUrl += emailHash.trim().toLowerCase();
+
+        return downloadImage(gravatarUrl);
+    }
+
+    public static byte[] downloadImage(String urlString) throws IOException {
+
+        URL url = new URL(urlString);
+
+        BufferedImage imageIn;
+
+        try {
+            imageIn = ImageIO.read(url);
+        }
+        catch (IOException ex) {
+            ex.printStackTrace();
+            return null;
+        }
+
+        if (imageIn == null) {
+            System.out.println("Downloaded UIAvatar image is null");
+            return null;
+        }
+
+        BufferedImage imageOut = new BufferedImage(
+                imageIn.getWidth(),
+                imageIn.getHeight(),
+                BufferedImage.TYPE_INT_RGB);
+
+        imageOut.createGraphics().drawImage(imageIn, 0, 0, Color.WHITE, null);
+
+        byte[] imageData;
+
+        try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+            ImageIO.write(imageOut, "jpg", baos);
+            imageData = baos.toByteArray();
+        }
+
+        return imageData;
+
+    }
+
+}

+ 64 - 0
src/main/java/com/jayfella/website/core/JsonMapper.java

@@ -0,0 +1,64 @@
+package com.jayfella.website.core;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+import java.io.IOException;
+
+public class JsonMapper {
+
+    private static final Logger log = LoggerFactory.getLogger(JsonMapper.class);
+
+    private static final ObjectMapper objectMapper;
+
+    static {
+        objectMapper = new ObjectMapper()
+                .enable(SerializationFeature.INDENT_OUTPUT);
+    }
+
+    public static ObjectMapper getObjectMapper() {
+        return objectMapper;
+    }
+
+    public static <T> T readFile(File file, Class<T> fileClass) {
+
+        if (log.isDebugEnabled()) {
+            log.debug("Reading json file: " + file);
+        }
+
+        try {
+            return objectMapper.readValue(file, fileClass);
+        } catch (IOException ex) {
+            log.error("Unable to read file: " + file, ex);
+        }
+
+        return null;
+    }
+
+    public static <T> T readFile(String filename, Class<T> fileClass) {
+        return readFile(new File(filename), fileClass);
+    }
+
+    public static boolean writeFile(File file, Object value) {
+        try {
+            objectMapper.writeValue(file, value);
+            return true;
+        } catch (IOException ex) {
+            log.error("Unable to write file: " + file, ex);
+        }
+
+        if (log.isDebugEnabled()) {
+            log.debug("Saved json file: " + file);
+        }
+
+        return false;
+    }
+
+    public static boolean writeFile(String filename, Object value) {
+        return writeFile(new File(filename), value);
+    }
+
+}

+ 24 - 0
src/main/java/com/jayfella/website/core/PageRequirements.java

@@ -0,0 +1,24 @@
+package com.jayfella.website.core;
+
+import java.math.BigDecimal;
+
+public class PageRequirements {
+
+    public static final int TITLE_MIN_LENGTH = 6;
+    public static final int TITLE_MAX_LENGTH = 64;
+
+    public static final int SHORTDESC_MIN_LENGTH = 10;
+    public static final int DESC_MIN_LENGTH = 50;
+
+    public static final int VERSION_MIN_LENGTH = 5;
+    public static final int VIDEOS_MIN_COUNT = 0;
+    public static final int IMAGES_MIN_COUNT = 1;
+
+    public static final int MAX_SCREENSHOTS = 9;
+    public static final int MAX_POTENTIAL_ASSETS = 5;
+
+    public static final BigDecimal PRICE_MINIMUM = new BigDecimal(5.0);
+
+    public static final int MIN_SEARCH_LENGTH = 3;
+
+}

+ 62 - 0
src/main/java/com/jayfella/website/core/RandomString.java

@@ -0,0 +1,62 @@
+package com.jayfella.website.core;
+
+import java.security.SecureRandom;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Random;
+
+/**
+ * @link https://stackoverflow.com/a/41156
+ */
+
+public class RandomString {
+
+    /**
+     * Generate a random string.
+     */
+    public String nextString() {
+        for (int idx = 0; idx < buf.length; ++idx)
+            buf[idx] = symbols[random.nextInt(symbols.length)];
+        return new String(buf);
+    }
+
+    public static final String upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+    public static final String lower = upper.toLowerCase(Locale.ROOT);
+    public static final String digits = "0123456789";
+    public static final String alphanum = upper + lower + digits;
+
+    private final Random random;
+
+    private final char[] symbols;
+    private final char[] buf;
+
+    public RandomString(int length, Random random, String symbols) {
+        if (length < 1) throw new IllegalArgumentException();
+        if (symbols.length() < 2) throw new IllegalArgumentException();
+        this.random = Objects.requireNonNull(random);
+        this.symbols = symbols.toCharArray();
+        this.buf = new char[length];
+    }
+
+    /**
+     * Create an alphanumeric string generator.
+     */
+    public RandomString(int length, Random random) {
+        this(length, random, alphanum);
+    }
+
+    /**
+     * Create an alphanumeric strings from a secure generator.
+     */
+    public RandomString(int length) {
+        this(length, new SecureRandom());
+    }
+
+    /**
+     * Create session identifiers.
+     */
+    public RandomString() {
+        this(21);
+    }
+
+}

+ 20 - 0
src/main/java/com/jayfella/website/core/ResponseStrings.java

@@ -0,0 +1,20 @@
+package com.jayfella.website.core;
+
+public class ResponseStrings {
+
+    public static final String NOT_LOGGED_IN = "You must be logged in to perform this action.";
+    public static final String USER_NOT_FOUND = "User not found with id: %s";
+
+    public static final String BAD_REQUEST = "An error occurred processing your request.";
+    public static final String INSUFFICIENT_PERMISSION = "You do not have permission to perform this action.";
+    public static final String STAFF_PAGE_APPROVED = "The page was approved successfully: %s";
+    public static final String PARENT_PAGE_NOT_FOUND = "The live page could not be found for amendment with id: %s";
+    public static final String SEARCH_TOO_SHORT = "The search term must be at least %d characters long.";
+    public static final String NO_CHANGES_DETECTED = "No changes detected.";
+
+    public static final String NO_PAGE_ID_SPECIFIED = "You must specify a Page ID.";
+    public static final String PAGE_REJECTED = "The page %s was rejected successfully.";
+    public static final String PAGE_DELETED = "The page '%s' was deleted successfully.";
+    public static final String PAGE_NOT_FOUND = "Page of type '%s' not found with id: %s";
+
+}

+ 70 - 0
src/main/java/com/jayfella/website/core/ServerAdvice.java

@@ -0,0 +1,70 @@
+package com.jayfella.website.core;
+
+import com.jayfella.website.config.external.ServerConfig;
+import com.jayfella.website.database.entity.Category;
+import com.jayfella.website.database.entity.user.UserSession;
+import com.jayfella.website.database.repository.CategoryRepository;
+import com.jayfella.website.database.repository.MessagesRepository;
+import com.jayfella.website.database.repository.SessionRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.ui.Model;
+import org.springframework.ui.ModelMap;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.CookieValue;
+import org.springframework.web.bind.annotation.ModelAttribute;
+
+import java.util.List;
+
+/**
+ * Common attributes to add to every webpage.
+ * Such as "website title", a "user" model, etc.
+ */
+@ControllerAdvice
+public class ServerAdvice {
+
+    private static final String KEY_TITLE_PREFIX = "pageTitle";
+    public static final String KEY_USER = "user";
+    private static final String STORE_VERSION = "0.1.0";
+
+    public static final String KEY_SESSION = "session";
+    private static final int SESSION_LENGTH = 32;
+
+    @Autowired private SessionRepository sessionRepository;
+    @Autowired private MessagesRepository messagesRepository;
+
+    @Autowired private CategoryRepository categoryRepository;
+
+    @ModelAttribute
+    public void getTitle(Model model) {
+        model.addAttribute(KEY_TITLE_PREFIX, ServerConfig.getInstance().getSiteName());
+    }
+
+    @ModelAttribute
+    public void getStoreVersion(Model model) {
+        model.addAttribute("storeVersion", STORE_VERSION);
+    }
+
+    @ModelAttribute
+    public void getUser(@CookieValue(value = KEY_SESSION, required = false) String session, Model model) throws Exception {
+
+        if (session != null) {
+            session = session.trim();
+
+            if (session.length() == SESSION_LENGTH) {
+                UserSession userSession = sessionRepository.findBySession(session).orElse(null);
+
+                if (userSession != null) {
+                    model.addAttribute(KEY_USER, userSession.getUser());
+                }
+            }
+        }
+    }
+
+    @ModelAttribute
+    public void getCategories(ModelMap model) {
+
+        List<Category> categories = categoryRepository.findAll();
+        model.put("categories", categories);
+    }
+
+}

+ 17 - 0
src/main/java/com/jayfella/website/core/ServerUtilities.java

@@ -0,0 +1,17 @@
+package com.jayfella.website.core;
+
+import org.springframework.context.support.DefaultMessageSourceResolvable;
+import org.springframework.validation.BindingResult;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+public class ServerUtilities {
+
+    public static List<String> getErrorMessages(BindingResult bindingResult) {
+        return bindingResult.getFieldErrors().stream()
+                .map(DefaultMessageSourceResolvable::getDefaultMessage)
+                .collect(Collectors.toList());
+    }
+
+}

+ 64 - 0
src/main/java/com/jayfella/website/core/StoreHtmlFilePaths.java

@@ -0,0 +1,64 @@
+package com.jayfella.website.core;
+
+public class StoreHtmlFilePaths {
+
+    public static class StorePath {
+
+        private final String urlPath, htmlFilePath;
+
+        StorePath(String urlPath, String htmlFilePath) {
+            this.urlPath = urlPath;
+            this.htmlFilePath = htmlFilePath;
+        }
+
+        public String getUrlPath() { return urlPath; }
+        public String getHtmlFilePath() { return htmlFilePath; }
+    }
+
+    // public static final String ACCOUNT_VALIDATION_RESPONSE = "/user/account-validation-response.html";
+
+
+    public static class Admin {
+        private static final String ADMIN_DIR = "/admin/";
+        public static final StorePath INDEX = new StorePath(ADMIN_DIR, ADMIN_DIR + "index.html");
+        public static final StorePath PAGES = new StorePath(ADMIN_DIR + "assets/", ADMIN_DIR + "pages.html");
+        public static final StorePath USER = new StorePath(ADMIN_DIR + "user/", ADMIN_DIR + "user.html");
+        public static final StorePath USERS = new StorePath(ADMIN_DIR + "users/", ADMIN_DIR + "users.html");
+        public static final StorePath BADGES = new StorePath(ADMIN_DIR + "badges/", ADMIN_DIR + "badges.html");
+        public static final StorePath CATEGORIES = new StorePath(ADMIN_DIR + "categories/", ADMIN_DIR + "categories.html");
+
+    }
+
+    public static class Store {
+        private static final String STORE_DIR = "/";
+
+        public static final StorePath INDEX = new StorePath(STORE_DIR, STORE_DIR + "index.html");
+        public static final StorePath VIEW_PAGE = new StorePath(STORE_DIR, STORE_DIR + "view-page.html");
+        public static final StorePath CREATE_PAGE = new StorePath(STORE_DIR + "create/", STORE_DIR + "create-page.html");
+        public static final StorePath EDIT_PAGE = new StorePath(STORE_DIR + "edit/", STORE_DIR + "edit-page.html");
+    }
+
+    public static class User {
+        private static final String USER_DIR = "/user/";
+
+        public static final StorePath INDEX = new StorePath(USER_DIR, USER_DIR + "index.html");
+
+        public static final StorePath LOGIN = new StorePath(USER_DIR + "login/", USER_DIR + "login.html");
+        public static final StorePath REGISTER = new StorePath(USER_DIR + "register/", USER_DIR + "register.html");
+        public static final StorePath REGISTERED = new StorePath(USER_DIR + "registered/", USER_DIR + "registered.html");
+
+        public static final StorePath MY_PAGES = new StorePath(USER_DIR + "my-pages/", USER_DIR + "my-pages.html");
+
+        public static final StorePath VALIDATION_RESPONSE = new StorePath(USER_DIR + "validate/", USER_DIR + "validation-response.html");
+
+        public static final StorePath PROFILE = new StorePath(USER_DIR + "profile/", USER_DIR + "profile.html");
+    }
+
+    public static class Contact {
+        private static final String CONTACT_DIR = "/contact/";
+
+        public static final StorePath INDEX = new StorePath(CONTACT_DIR, CONTACT_DIR + "index.html");
+        public static final StorePath SUCCESS = new StorePath(CONTACT_DIR, CONTACT_DIR + "success.html");
+    }
+
+}

+ 24 - 0
src/main/java/com/jayfella/website/core/VersionState.java

@@ -0,0 +1,24 @@
+package com.jayfella.website.core;
+
+public enum VersionState {
+    Alpha,
+    Beta,
+    Release;
+
+    public static VersionState fromString(String input) {
+
+        if (input == null) {
+            return null;
+        }
+
+        for (VersionState val : values()) {
+            if (val.toString().toLowerCase().equals(input.toLowerCase())) {
+                return val;
+            }
+        }
+
+        return null;
+
+    }
+
+}

+ 16 - 0
src/main/java/com/jayfella/website/core/controller/http/user/MessagesController.java

@@ -0,0 +1,16 @@
+package com.jayfella.website.core.controller.http.user;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+@Controller
+@RequestMapping("/user/messages/")
+public class MessagesController {
+
+    @GetMapping()
+    public String getSummary() {
+        return "/partial/user/messages.html";
+    }
+
+}

+ 23 - 0
src/main/java/com/jayfella/website/core/page/PageState.java

@@ -0,0 +1,23 @@
+package com.jayfella.website.core.page;
+
+import com.jayfella.website.database.entity.page.StorePage;
+import com.jayfella.website.database.entity.page.stages.LivePage;
+import com.jayfella.website.database.entity.page.stages.PageAmendment;
+import com.jayfella.website.database.entity.page.stages.PageDraft;
+
+public enum PageState {
+
+    Draft,
+    Live,
+    Amendment;
+
+    public static PageState fromPage(StorePage page) {
+
+        if (page instanceof PageDraft) return PageState.Draft;
+        else if (page instanceof LivePage) return PageState.Live;
+        else if (page instanceof PageAmendment) return PageState.Amendment;
+
+        return null;
+    }
+
+}

+ 10 - 0
src/main/java/com/jayfella/website/core/page/ReviewState.java

@@ -0,0 +1,10 @@
+package com.jayfella.website.core.page;
+
+public enum ReviewState {
+
+    None,
+    Review_Requested,
+    Under_Review,
+    Rejected,
+
+}

+ 9 - 0
src/main/java/com/jayfella/website/core/page/SoftwareType.java

@@ -0,0 +1,9 @@
+package com.jayfella.website.core.page;
+
+public enum SoftwareType {
+
+    OpenSource,
+    Sponsored,
+    Paid,
+
+}

+ 63 - 0
src/main/java/com/jayfella/website/database/entity/Badge.java

@@ -0,0 +1,63 @@
+package com.jayfella.website.database.entity;
+
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.http.request.badge.CreateBadgeRequest;
+
+import javax.persistence.*;
+import java.util.List;
+
+@Entity
+@Table(name = "Badges")
+public class Badge {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    @Column(unique = true, nullable = false)
+    private long id;
+    public long getId() { return id; }
+    public void setId(long id) { this.id = id; }
+
+    @Column(nullable = false)
+    private String name;
+    public String getName() { return name; }
+    public void setName(String name) { this.name = name; }
+
+    @Column(nullable = false)
+    private String description;
+    public String getDescription() { return description; }
+    public void setDescription(String description) { this.description = description; }
+
+    @Column(nullable = false)
+    private String icon;
+    public String getIcon() { return icon; }
+    public void setIcon(String icon) { this.icon = icon; }
+
+    @ManyToMany(targetEntity = User.class, fetch = FetchType.EAGER)
+    private List<User> users;
+    public List<User> getUsers() { return users; }
+    public void setUsers(List<User> users) { this.users = users; }
+
+    public Badge() {
+    }
+
+    public Badge(CreateBadgeRequest request) {
+        this.name = request.getName();
+        this.description = request.getDescription();
+        this.icon = request.getIcon();
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj instanceof Badge) {
+
+            Badge other = (Badge)obj;
+            return id == other.id;
+        }
+        return false;
+    }
+
+    @Override
+    public int hashCode() {
+        return ("" + id).hashCode();
+    }
+}

+ 46 - 0
src/main/java/com/jayfella/website/database/entity/Category.java

@@ -0,0 +1,46 @@
+package com.jayfella.website.database.entity;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.jayfella.website.database.entity.page.stages.LivePage;
+
+import javax.persistence.*;
+import java.util.List;
+
+@Entity
+@Table(name = "Categories")
+public class Category {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.AUTO)
+    private int id;
+    public int getId() { return id; }
+    public void setId(int id) { this.id = id; }
+
+    @Column(length = 128, nullable = false)
+    private String name = "";
+    public String getName() { return name; }
+    public void setName(String name) { this.name = name; }
+
+    @OneToMany(mappedBy="category", fetch = FetchType.LAZY)
+    private List<LivePage> pages;
+    @JsonIgnore public List<LivePage> getPages() { return pages; }
+    @JsonIgnore public void setPages(List<LivePage> pages) { this.pages = pages; }
+
+    @ManyToOne(targetEntity = Category.class, fetch = FetchType.EAGER)
+    private Category parent;
+    public Category getParent() { return parent; }
+    public void setParent(Category parent) { this.parent = parent; }
+
+    @OneToMany(mappedBy = "parent", orphanRemoval = true, cascade = CascadeType.ALL)
+    private List<Category> children;
+    @JsonIgnore public List<Category> getChildren() { return children; }
+    @JsonIgnore public void setChildren(List<Category> children) { this.children = children; }
+
+    public Category() {
+    }
+
+    public Category(String name) {
+        this.name = name;
+    }
+
+}

+ 35 - 0
src/main/java/com/jayfella/website/database/entity/WebsiteImage.java

@@ -0,0 +1,35 @@
+package com.jayfella.website.database.entity;
+
+import com.jayfella.website.database.entity.user.User;
+import org.hibernate.annotations.GenericGenerator;
+
+import javax.persistence.*;
+
+@Entity
+@Table(name = "IMAGES")
+@Deprecated
+public class WebsiteImage {
+
+    private String id;
+    private byte[] data;
+
+    private User user;
+
+    @Id
+    @GeneratedValue(generator = "uuid")
+    @GenericGenerator(name = "uuid", strategy = "uuid2")
+    public String getId() { return id; }
+    public void setId(String id) { this.id = id; }
+
+    /*
+    @JsonIgnore
+    @Lob
+    public byte[] getData() { return data; }
+    public void setData(byte[] data) { this.data = data; }
+    */
+
+    @ManyToOne(targetEntity=User.class)
+    @JoinColumn(name = "USER_ID", nullable = false)
+    public User getUser() { return user; }
+    public void setUser(User user) { this.user = user; }
+}

+ 60 - 0
src/main/java/com/jayfella/website/database/entity/message/Message.java

@@ -0,0 +1,60 @@
+package com.jayfella.website.database.entity.message;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.jayfella.website.database.entity.user.User;
+
+import javax.persistence.*;
+import javax.validation.constraints.Size;
+import java.util.List;
+
+@Entity
+@Table(name = "MESSAGES")
+public class Message {
+
+    private long id;
+
+    private long date;
+    private String title;
+    private String message;
+
+    private User sender;
+    private User recipient;
+
+    private boolean delivered;
+
+    private List<MessageReply> replies;
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    @Column(name = "ID", unique = true, nullable = false)
+    public long getId() { return id; }
+    public void setId(long id) { this.id = id; }
+
+    public long getDate() { return date; }
+    public void setDate(long date) { this.date = date; }
+
+    @Size(min = 2, max = 128)
+    public String getTitle() { return title; }
+    public void setTitle(String title) { this.title = title; }
+
+    @Size(min = 2, max = 10000)
+    public String getMessage() { return message; }
+    public void setMessage(String message) { this.message = message; }
+
+    @OneToOne
+    public User getSender() { return sender; }
+    public void setSender(User sender) { this.sender = sender; }
+
+    @OneToOne
+    public User getRecipient() { return recipient; }
+    public void setRecipient(User recipient) { this.recipient = recipient; }
+
+    public boolean isDelivered() { return delivered; }
+    public void setDelivered(boolean delivered) { this.delivered = delivered; }
+
+    @JsonIgnore
+    @OneToMany(mappedBy = "message", cascade = CascadeType.ALL, orphanRemoval = true)
+    public List<MessageReply> getReplies() { return replies; }
+    public void setReplies(List<MessageReply> replies) { this.replies = replies; }
+
+}

+ 43 - 0
src/main/java/com/jayfella/website/database/entity/message/MessageReply.java

@@ -0,0 +1,43 @@
+package com.jayfella.website.database.entity.message;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.jayfella.website.database.entity.user.User;
+
+import javax.persistence.*;
+import javax.validation.constraints.Size;
+
+@Entity
+@Table(name = "MESSAGE_REPLIES")
+public class MessageReply {
+
+    private long id;
+
+    private long date;
+    private String content;
+
+    private User user;
+    private Message message;
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    @Column(name = "ID", unique = true, nullable = false)
+    public long getId() { return id; }
+    public void setId(long id) { this.id = id; }
+
+    public long getDate() { return date; }
+    public void setDate(long date) { this.date = date; }
+
+    @Size(min = 2, max = 10000)
+    public String getContent() { return content; }
+    public void setContent(String content) { this.content = content; }
+
+    @OneToOne(targetEntity=User.class)
+    public User getUser() { return user; }
+    public void setUser(User user) { this.user = user; }
+
+    @JsonIgnore
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "MESSAGE_ID")
+    public Message getMessage() { return message; }
+    public void setMessage(Message message) { this.message = message; }
+}

+ 17 - 0
src/main/java/com/jayfella/website/database/entity/page/Editable.java

@@ -0,0 +1,17 @@
+package com.jayfella.website.database.entity.page;
+
+import com.jayfella.website.core.page.ReviewState;
+import com.jayfella.website.database.entity.user.User;
+
+public interface Editable {
+
+    ReviewState getReviewState();
+    void setReviewState(ReviewState reviewState);
+
+    User getReviewer();
+    void setReviewer(User user);
+
+    Integer getCategoryId();
+    void setCategoryId(Integer categoryId);
+
+}

+ 59 - 0
src/main/java/com/jayfella/website/database/entity/page/PageReview.java

@@ -0,0 +1,59 @@
+package com.jayfella.website.database.entity.page;
+
+import com.jayfella.website.database.entity.user.User;
+import org.hibernate.annotations.CreationTimestamp;
+import org.hibernate.annotations.UpdateTimestamp;
+
+import javax.persistence.*;
+import javax.validation.constraints.Max;
+import javax.validation.constraints.Min;
+import java.util.Date;
+
+@Entity
+@Table(name = "Reviews")
+public class PageReview {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    @Column(name = "ID", unique = true, nullable = false)
+    private long id;
+    public long getId() { return id; }
+    public void setId(long id) { this.id = id; }
+
+    @Column(length = 64)
+    private String pageId;
+    public String getPageId() { return pageId; }
+    public void setPageId(String pageId) { this.pageId = pageId; }
+
+    @Column(name = "CONTENT", nullable = false, length = 1000)
+    private String content;
+    public String getContent() { return content; }
+    public void setContent(String content) { this.content = content; }
+
+    @Min(0)
+    @Max(10)
+    private int rating; // out of 10
+    public int getRating() { return rating; }
+    public void setRating(int rating) { this.rating = rating; }
+
+    @CreationTimestamp
+    @Temporal(TemporalType.TIMESTAMP)
+    @Column(nullable = false, length = 32)
+    private Date dateCreated;
+    public Date getDateCreated() { return dateCreated; }
+    public void setDateCreated(Date dateCreated) { this.dateCreated = dateCreated; }
+
+    @UpdateTimestamp
+    @Temporal(TemporalType.TIMESTAMP)
+    @Column(nullable = false, length = 32)
+    private Date dateUpdated;
+    public Date getDateUpdated() { return dateUpdated; }
+    public void setDateUpdated(Date dateUpdated) { this.dateUpdated = dateUpdated; }
+
+    @ManyToOne(targetEntity=User.class)
+    @JoinColumn(name = "USER_ID", nullable = false)
+    private User user;
+    public User getUser() { return user; }
+    public void setUser(User user) { this.user = user; }
+
+}

+ 58 - 0
src/main/java/com/jayfella/website/database/entity/page/StaffPageReview.java

@@ -0,0 +1,58 @@
+package com.jayfella.website.database.entity.page;
+
+import com.jayfella.website.core.page.PageState;
+import com.jayfella.website.database.entity.user.User;
+import org.hibernate.annotations.CreationTimestamp;
+
+import javax.persistence.*;
+import java.util.Date;
+
+@Entity
+@Table(name = "StaffReviews")
+public class StaffPageReview {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    @Column(name = "ID", unique = true, nullable = false)
+    private long id;
+    public long getId() { return id; }
+    public void setId(long id) { this.id = id; }
+
+    @OneToOne(targetEntity = User.class, fetch = FetchType.EAGER)
+    private User reviewer;
+    public User getReviewer() { return reviewer; }
+    public void setReviewer(User reviewer) { this.reviewer = reviewer; }
+
+    @CreationTimestamp
+    @Temporal(TemporalType.TIMESTAMP)
+    @Column(nullable = false, length = 32)
+    private Date dateReviewed;
+    public Date getDateReviewed() { return dateReviewed; }
+    public void setDateReviewed(Date dateReviewed) { this.dateReviewed = dateReviewed; }
+
+    @Column(length = 10000)
+    private String review;
+    public String getReview() { return review; }
+    public void setReview(String review) { this.review = review; }
+
+    @Column(nullable = false, length = 64)
+    private String pageId;
+
+    @Enumerated(EnumType.STRING)
+    private PageState pageState;
+    public PageState getPageState() { return pageState; }
+    public void setPageState(PageState pageState) { this.pageState = pageState; }
+
+    public StaffPageReview() {
+    }
+
+    public StaffPageReview(User reviewer, String review, String pageId, PageState pageState) {
+
+        this.reviewer = reviewer;
+        this.review = review;
+
+        this.pageId = pageId;
+        this.pageState = pageState;
+
+    }
+}

+ 114 - 0
src/main/java/com/jayfella/website/database/entity/page/StorePage.java

@@ -0,0 +1,114 @@
+package com.jayfella.website.database.entity.page;
+
+import com.jayfella.website.core.page.SoftwareType;
+import com.jayfella.website.database.entity.page.embedded.*;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.service.ImageService;
+import org.hibernate.annotations.CreationTimestamp;
+import org.hibernate.annotations.GenericGenerator;
+import org.hibernate.annotations.UpdateTimestamp;
+
+import javax.persistence.*;
+import java.io.IOException;
+import java.util.Date;
+
+/**
+ * A superclass that all store page stages (draft, live, amendment) will inherit.
+ */
+
+@MappedSuperclass
+public abstract class StorePage {
+
+    @Id
+    @GeneratedValue(generator = "uuid")
+    @GenericGenerator(name = "uuid", strategy = "uuid2")
+    @Column(name = "id", unique = true, nullable = false, length = 64)
+    private String id;
+    public String getId() { return id; }
+    public void setId(String id) { this.id = id; }
+
+    @OneToOne(targetEntity = User.class, fetch = FetchType.EAGER)
+    private User owner;
+    public User getOwner() { return owner; }
+    public void setOwner(User owner) { this.owner = owner; }
+
+    @CreationTimestamp
+    @Temporal(TemporalType.TIMESTAMP)
+    @Column(nullable = false, length = 32)
+    private Date dateCreated;
+    public Date getDateCreated() { return dateCreated; }
+    public void setDateCreated(Date dateCreated) { this.dateCreated = dateCreated; }
+
+    @UpdateTimestamp
+    @Temporal(TemporalType.TIMESTAMP)
+    @Column(nullable = false, length = 32)
+    private Date dateUpdated;
+    public Date getDateUpdated() { return dateUpdated; }
+    public void setDateUpdated(Date dateUpdated) { this.dateUpdated = dateUpdated; }
+
+
+    @Enumerated(EnumType.STRING)
+    private SoftwareType softwareType;
+    public SoftwareType getSoftwareType() { return softwareType; }
+    public void setSoftwareType(SoftwareType softwareType) { this.softwareType = softwareType; }
+
+    @Embedded
+    private OpenSourceData openSourceData = new OpenSourceData();
+    public OpenSourceData getOpenSourceData() { return openSourceData; }
+    public void setOpenSourceData(OpenSourceData openSourceData) { this.openSourceData = openSourceData; }
+
+    // title, short desc, long desc
+    @Embedded
+    private Details details = new Details();
+    public Details getDetails() { return details; }
+    public void setDetails(Details details) { this.details = details; }
+
+    // the version of the software
+    @Embedded
+    private VersionData versionData = new VersionData();
+    public VersionData getVersionData() { return versionData; }
+    public void setVersionData(VersionData versionData) { this.versionData = versionData; }
+
+    // documentation, publisher website
+    @Embedded
+    private ExternalLinks externalLinks = new ExternalLinks();
+    public ExternalLinks getExternalLinks() { return externalLinks; }
+    public void setExternalLinks(ExternalLinks externalLinks) { this.externalLinks = externalLinks; }
+
+    @Embedded
+    private BuildData buildData = new BuildData();
+    public BuildData getBuildData() { return buildData; }
+    public void setBuildData(BuildData buildData) { this.buildData = buildData; }
+
+    // screenshots and videos
+    // @todo: provide vimeo support? Maybe we should just implement support for "other" providers other than youtube.
+    // this would get us out of a sticky situation if support for other providers was requested.
+    // then again we could just have a comma-seperated list and parse the input links.... I'm not certain..
+    // this would certainly take the effort away from the server/db and onto the user locally via javascript.
+    @Embedded
+    private MediaLinks mediaLinks = new MediaLinks();
+    public MediaLinks getMediaLinks() { return mediaLinks; }
+    public void setMediaLinks(MediaLinks mediaLinks) { this.mediaLinks = mediaLinks; }
+
+    @Embedded
+    private PaymentData paymentData = new PaymentData();
+    public PaymentData getPaymentData() { return paymentData; }
+    public void setPaymentData(PaymentData paymentData) { this.paymentData = paymentData; }
+
+
+
+    public void copyTo(StorePage storePage, ImageService imageService) throws IOException {
+        storePage.setOwner(owner);
+
+        storePage.setSoftwareType(softwareType);
+
+        openSourceData.copyTo(storePage.getOpenSourceData());
+        details.copyTo(storePage.getDetails());
+        versionData.copyTo(storePage.getVersionData());
+        externalLinks.copyTo(storePage.getExternalLinks());
+        buildData.copyTo(storePage.getBuildData());
+        mediaLinks.copyTo(storePage.getMediaLinks(), imageService);
+        paymentData.copyTo(storePage.getPaymentData());
+    }
+
+}

+ 39 - 0
src/main/java/com/jayfella/website/database/entity/page/embedded/BuildData.java

@@ -0,0 +1,39 @@
+package com.jayfella.website.database.entity.page.embedded;
+
+import javax.persistence.Column;
+import javax.persistence.Embeddable;
+
+@Embeddable
+public class BuildData {
+
+    // we will provide: mavenCentral(), jcenter()
+    // custom: maven { url "https://jitpack.io" }
+    // the user may need to specify their own.
+    @Column(length = 1000)
+    private String repositories = "";
+    public String getRepositories() { return repositories; }
+    public void setRepositories(String customRepositories) { this.repositories = customRepositories; }
+
+    // dependencies that are in-store.
+    @Column(length = 650) // enough for 10 store links.
+    private String storeDependencies = "";
+    public String getStoreDependencies() { return storeDependencies; }
+    public void setStoreDependencies(String storeDependencies) { this.storeDependencies = storeDependencies; }
+
+    // if jitpack.io is allowed(github repo) we can generate and just ask for version.
+    // implementation "com.github.riccardobl:jme3-bullet-vhacd:master-SNAPSHOT"
+    // else ask for a dependency string.
+    @Column(length = 1000)
+    private String hostedDependencies = "";
+    public String getHostedDependencies() { return hostedDependencies; }
+    public void setHostedDependencies(String hostedDependencies) { this.hostedDependencies = hostedDependencies; }
+
+    public void copyTo(BuildData buildData) {
+
+        buildData.setRepositories(repositories);
+
+        buildData.setStoreDependencies(storeDependencies);
+        buildData.setHostedDependencies(hostedDependencies);
+    }
+
+}

+ 37 - 0
src/main/java/com/jayfella/website/database/entity/page/embedded/Details.java

@@ -0,0 +1,37 @@
+package com.jayfella.website.database.entity.page.embedded;
+
+import javax.persistence.Column;
+import javax.persistence.Embeddable;
+
+@Embeddable
+public class Details {
+
+    @Column(nullable = false, length = 64)
+    private String title = "";
+    public String getTitle() { return title; }
+    public void setTitle(String title) { this.title = title; }
+
+    @Column(nullable = false, length = 255)
+    private String shortDescription = "";
+    public String getShortDescription() { return shortDescription; }
+    public void setShortDescription(String shortDescription) { this.shortDescription = shortDescription; }
+
+    @Column(nullable = false, length = 10000)
+    private String description = "";
+    public String getDescription() { return description; }
+    public void setDescription(String description) { this.description = description; }
+
+    // Comma-separated string. Makes the most sense for storage and searching through them to find pages with tags.
+    @Column(length = 255)
+    private String tags = "";
+    public String getTags() { return tags; }
+    public void setTags(String tags) { this.tags = tags; }
+
+    public void copyTo(Details details) {
+        details.setTitle(title);
+        details.setShortDescription(shortDescription);
+        details.setDescription(description);
+        details.setTags(tags);
+    }
+
+}

+ 24 - 0
src/main/java/com/jayfella/website/database/entity/page/embedded/ExternalLinks.java

@@ -0,0 +1,24 @@
+package com.jayfella.website.database.entity.page.embedded;
+
+import javax.persistence.Column;
+import javax.persistence.Embeddable;
+
+@Embeddable
+public class ExternalLinks {
+
+    @Column(length = 255)
+    private String docsWebsite = "";
+    public String getDocsWebsite() { return docsWebsite; }
+    public void setDocsWebsite(String docsWebsite) { this.docsWebsite = docsWebsite; }
+
+    @Column(length = 255)
+    private String publisherWebsite = "";
+    public String getPublisherWebsite() { return publisherWebsite; }
+    public void setPublisherWebsite(String publisherWebsite) { this.publisherWebsite = publisherWebsite; }
+
+    public void copyTo(ExternalLinks externalLinks) {
+        externalLinks.setDocsWebsite(docsWebsite);
+        externalLinks.setPublisherWebsite(publisherWebsite);
+    }
+
+}

+ 50 - 0
src/main/java/com/jayfella/website/database/entity/page/embedded/MediaLinks.java

@@ -0,0 +1,50 @@
+package com.jayfella.website.database.entity.page.embedded;
+
+import com.jayfella.website.service.ImageService;
+
+import javax.persistence.Column;
+import javax.persistence.Embeddable;
+import java.io.IOException;
+
+@Embeddable
+public class MediaLinks {
+
+    @Column(length = 1000)
+    private String imageIds = "";
+    public String getImageIds() { return imageIds; }
+    public void setImageIds(String imageIds) { this.imageIds = imageIds; }
+
+    @Column(columnDefinition = "int default -1")
+    private int backgroundImageIndex = -1;
+    public int getBackgroundImageIndex() { return backgroundImageIndex; }
+    public void setBackgroundImageIndex(int backgroundImageIndex) { this.backgroundImageIndex = backgroundImageIndex; }
+
+    @Column(length = 1000)
+    private String videoIds = "";
+    public String getVideoIds() { return videoIds; }
+    public void setVideoIds(String videoIds) { this.videoIds = videoIds; }
+
+    public void copyTo(MediaLinks mediaLinks, ImageService imageService) throws IOException {
+        mediaLinks.setVideoIds(videoIds);
+        mediaLinks.setBackgroundImageIndex(backgroundImageIndex);
+        mediaLinks.setImageIds(cloneImages(imageService));
+    }
+
+    private String cloneImages(ImageService imageService) throws IOException {
+
+        if (!imageIds.isEmpty()) {
+            String[] ids = imageIds.split(",");
+            String[] newIds = new String[ids.length];
+
+            for (int i = 0; i < ids.length; i++) {
+                String newImageId = imageService.duplicate(ids[i] + ImageService.IMAGE_EXT);
+                newIds[i] = imageService.removeImageExtension(imageService.removeImageExtension(newImageId));
+            }
+
+            return String.join(",", newIds);
+        }
+
+        return "";
+    }
+
+}

+ 48 - 0
src/main/java/com/jayfella/website/database/entity/page/embedded/OpenSourceData.java

@@ -0,0 +1,48 @@
+package com.jayfella.website.database.entity.page.embedded;
+
+import com.jayfella.website.license.OpenSourceLicense;
+
+import javax.persistence.Column;
+import javax.persistence.Embeddable;
+import javax.persistence.EnumType;
+import javax.persistence.Enumerated;
+
+@Embeddable
+public class OpenSourceData {
+
+    @Column(length = 1024)
+    private String gitRepository = "";
+    public String getGitRepository() { return gitRepository; }
+    public void setGitRepository(String gitRepository) { this.gitRepository = gitRepository; }
+
+    private boolean fork = false;
+    public boolean isFork() { return fork; }
+    public void setFork(boolean fork) { this.fork = fork; }
+
+    @Column(length = 1024)
+    private String forkRepository = "";
+    public String getForkRepository() { return forkRepository; }
+    public void setForkRepository(String forkRepository) { this.forkRepository = forkRepository; }
+
+
+    // used in OPENSOURCE and SPONSORED SoftwareTypes.
+    @Enumerated(EnumType.STRING)
+    private OpenSourceLicense softwareLicense;
+    public OpenSourceLicense getSoftwareLicense() { return softwareLicense; }
+    public void setSoftwareLicense(OpenSourceLicense softwareLicense) { this.softwareLicense = softwareLicense; }
+
+    // used in OPENSOURCE and SPONSORED SoftwareTypes.
+    @Enumerated(EnumType.STRING)
+    private OpenSourceLicense mediaLicense;
+    public OpenSourceLicense getMediaLicense() { return mediaLicense; }
+    public void setMediaLicense(OpenSourceLicense mediaLicense) { this.mediaLicense = mediaLicense; }
+
+    public void copyTo(OpenSourceData openSourceData) {
+        openSourceData.setGitRepository(gitRepository);
+        openSourceData.setFork(fork);
+        openSourceData.setForkRepository(forkRepository);
+        openSourceData.setSoftwareLicense(softwareLicense);
+        openSourceData.setMediaLicense(mediaLicense);
+    }
+
+}

+ 22 - 0
src/main/java/com/jayfella/website/database/entity/page/embedded/PaymentData.java

@@ -0,0 +1,22 @@
+package com.jayfella.website.database.entity.page.embedded;
+
+import javax.persistence.Embeddable;
+import java.math.BigDecimal;
+
+@Embeddable
+public class PaymentData {
+
+    private BigDecimal price = new BigDecimal(5.0);
+    public BigDecimal getPrice() { return price; }
+    public void setPrice(BigDecimal price) { this.price = price; }
+
+    private long purchaseCount = 0;
+    public long getPurchaseCount() { return purchaseCount; }
+    public void setPurchaseCount(long purchaseCount) { this.purchaseCount = purchaseCount; }
+
+    public void copyTo(PaymentData paymentData) {
+        paymentData.setPrice(price);
+        paymentData.setPurchaseCount(purchaseCount);
+    }
+
+}

+ 90 - 0
src/main/java/com/jayfella/website/database/entity/page/embedded/SoftwareRating.java

@@ -0,0 +1,90 @@
+package com.jayfella.website.database.entity.page.embedded;
+
+import javax.persistence.*;
+
+@Embeddable
+public class SoftwareRating {
+
+    private int oneStarCount = 0;
+    private int twoStarCount = 0;
+    private int threeStarCount = 0;
+    private int fourStarCount = 0;
+    private int fiveStarCount = 0;
+
+    private int ratingCount = 0;
+    private float averageRating = 0;
+
+
+    public int getOneStarCount() { return oneStarCount; }
+    public void setOneStarCount(int oneStarCount) { this.oneStarCount = oneStarCount; }
+
+    public int getTwoStarCount() { return twoStarCount; }
+    public void setTwoStarCount(int twoStarCount) { this.twoStarCount = twoStarCount; }
+
+    public int getThreeStarCount() { return threeStarCount; }
+    public void setThreeStarCount(int threeStarCount) { this.threeStarCount = threeStarCount; }
+
+    public int getFourStarCount() { return fourStarCount; }
+    public void setFourStarCount(int fourStarCount) { this.fourStarCount = fourStarCount; }
+
+    public int getFiveStarCount() { return fiveStarCount; }
+    public void setFiveStarCount(int fiveStarCount) { this.fiveStarCount = fiveStarCount; }
+
+    public int getRatingCount() { return ratingCount; }
+    public void setRatingCount(int ratingCount) { this.ratingCount = ratingCount; }
+
+    public float getAverageRating() { return averageRating; }
+    public void setAverageRating(float averageRating) { this.averageRating = averageRating; }
+
+    @Transient
+    public void addRating(int rating) {
+
+        switch (rating) {
+            case 1: setOneStarCount(getOneStarCount() + 1); break;
+            case 2: setTwoStarCount(getTwoStarCount() + 1); break;
+            case 3: setThreeStarCount(getThreeStarCount() + 1); break;
+            case 4: setFourStarCount(getFourStarCount() + 1); break;
+            case 5: setFiveStarCount(getFiveStarCount() + 1); break;
+        }
+
+        recalcRatings();
+    }
+
+    @Transient
+    public void removeRating(int rating) {
+
+        switch (rating) {
+            case 1: setOneStarCount(getOneStarCount() - 1); break;
+            case 2: setTwoStarCount(getTwoStarCount() - 1); break;
+            case 3: setThreeStarCount(getThreeStarCount() - 1); break;
+            case 4: setFourStarCount(getFourStarCount() - 1); break;
+            case 5: setFiveStarCount(getFiveStarCount() - 1); break;
+        }
+
+        recalcRatings();
+    }
+
+    private void recalcRatings() {
+        int totalRatings = getOneStarCount();
+        totalRatings += getTwoStarCount();
+        totalRatings += getThreeStarCount();
+        totalRatings += getFourStarCount();
+        totalRatings += getFiveStarCount();
+
+        int averageRating = 0;
+
+        if (totalRatings > 0) {
+            averageRating += (getOneStarCount());
+            averageRating += (getTwoStarCount() * 2);
+            averageRating += (getThreeStarCount() * 3);
+            averageRating += (getFourStarCount() * 4);
+            averageRating += (getFiveStarCount() * 5);
+
+            averageRating /= totalRatings;
+        }
+
+        setRatingCount(totalRatings);
+        setAverageRating(averageRating);
+    }
+    
+}

+ 35 - 0
src/main/java/com/jayfella/website/database/entity/page/embedded/VersionData.java

@@ -0,0 +1,35 @@
+package com.jayfella.website.database.entity.page.embedded;
+
+import com.jayfella.website.core.VersionState;
+
+import javax.persistence.Column;
+import javax.persistence.Embeddable;
+import javax.persistence.EnumType;
+import javax.persistence.Enumerated;
+
+@Embeddable
+public class VersionData {
+
+    @Column(nullable = false, length = 128)
+    private String version = "1.0.0";
+    public String getVersion() { return version; }
+    public void setVersion(String version) { this.version = version; }
+
+    @Column(nullable = false)
+    @Enumerated(EnumType.STRING)
+    private VersionState state = VersionState.Alpha;
+    public VersionState getState() { return state; }
+    public void setState(VersionState state) { this.state = state; }
+
+    @Column(length = 255)
+    private String engineCompatibility = "";
+    public String getEngineCompatibility() { return engineCompatibility; }
+    public void setEngineCompatibility(String engineCompatibility) { this.engineCompatibility = engineCompatibility; }
+
+    public void copyTo(VersionData versionData) {
+        versionData.setVersion(version);
+        versionData.setState(state);
+        versionData.setEngineCompatibility(engineCompatibility);
+    }
+
+}

+ 46 - 0
src/main/java/com/jayfella/website/database/entity/page/stages/LivePage.java

@@ -0,0 +1,46 @@
+package com.jayfella.website.database.entity.page.stages;
+
+import com.jayfella.website.database.entity.Category;
+import com.jayfella.website.database.entity.page.StorePage;
+import com.jayfella.website.database.entity.page.embedded.SoftwareRating;
+import com.jayfella.website.database.repository.CategoryRepository;
+import com.jayfella.website.service.ImageService;
+
+import javax.persistence.Embedded;
+import javax.persistence.Entity;
+import javax.persistence.ManyToOne;
+import javax.persistence.Table;
+import java.io.IOException;
+
+@Entity
+@Table(name = "LivePages")
+public class LivePage extends StorePage {
+
+    public LivePage() {
+    }
+
+    @Embedded
+    private SoftwareRating rating = new SoftwareRating();
+    public SoftwareRating getRating() { return this.rating; }
+    public void setRating(SoftwareRating rating) { this.rating = rating; }
+
+    @ManyToOne
+    private Category category;
+    public Category getCategory() { return category; }
+    public void setCategory(Category category) { this.category = category; }
+
+    public LivePage(PageDraft pageDraft, ImageService imageService, CategoryRepository categoryRepository) throws IOException {
+        pageDraft.copyTo(this, imageService);
+
+        Category category = categoryRepository.findById(pageDraft.getCategoryId()).orElse(null);
+        setCategory(category);
+    }
+
+    public void updateFrom(PageAmendment amendment, ImageService imageService, CategoryRepository categoryRepository) throws IOException {
+        amendment.copyTo(this, imageService);
+
+        Category category = categoryRepository.findById(amendment.getCategoryId()).orElse(null);
+        setCategory(category);
+    }
+
+}

+ 48 - 0
src/main/java/com/jayfella/website/database/entity/page/stages/PageAmendment.java

@@ -0,0 +1,48 @@
+package com.jayfella.website.database.entity.page.stages;
+
+import com.jayfella.website.core.page.ReviewState;
+import com.jayfella.website.database.entity.page.Editable;
+import com.jayfella.website.database.entity.page.StorePage;
+import com.jayfella.website.database.entity.user.User;
+import com.jayfella.website.service.ImageService;
+
+import javax.persistence.*;
+import java.io.IOException;
+
+@Entity
+@Table(name = "PageAmendments")
+public class PageAmendment extends StorePage implements Editable {
+
+    public PageAmendment() {
+    }
+
+    @Column(nullable = false, length = 64)
+    private String parentPageId;
+    public String getParentPageId() { return parentPageId; }
+    public void setParentPageId(String parentPageId) { this.parentPageId = parentPageId; }
+
+    @Enumerated(EnumType.STRING)
+    private ReviewState reviewState = ReviewState.None;
+    @Override public ReviewState getReviewState() { return reviewState; }
+    @Override public void setReviewState(ReviewState reviewState) { this.reviewState = reviewState; }
+
+    @OneToOne(targetEntity = User.class, fetch = FetchType.EAGER)
+    private User reviewer;
+    @Override public User getReviewer() { return reviewer; }
+    @Override public void setReviewer(User reviewer) { this.reviewer = reviewer; }
+
+    private Integer categoryId;
+    @Override public Integer getCategoryId() { return categoryId; }
+    @Override public void setCategoryId(Integer categoryId) { this.categoryId = categoryId; }
+
+    public PageAmendment(LivePage livePage, ImageService imageService) throws IOException {
+        livePage.copyTo(this, imageService);
+        parentPageId = livePage.getId();
+
+        if (livePage.getCategory() != null) {
+            setCategoryId(livePage.getCategory().getId());
+        }
+
+    }
+
+}

+ 32 - 0
src/main/java/com/jayfella/website/database/entity/page/stages/PageDraft.java

@@ -0,0 +1,32 @@
+package com.jayfella.website.database.entity.page.stages;
+
+import com.jayfella.website.core.page.ReviewState;
+import com.jayfella.website.database.entity.page.Editable;
+import com.jayfella.website.database.entity.page.StorePage;
+import com.jayfella.website.database.entity.user.User;
+
+import javax.persistence.*;
+
+@Entity
+@Table(name = "PageDrafts")
+public class PageDraft extends StorePage implements Editable {
+
+    @Enumerated(EnumType.STRING)
+    private ReviewState reviewState = ReviewState.None;
+    @Override public ReviewState getReviewState() { return reviewState; }
+    @Override public void setReviewState(ReviewState reviewState) { this.reviewState = reviewState; }
+
+    @OneToOne(targetEntity = User.class, fetch = FetchType.EAGER)
+    private User reviewer;
+    @Override public User getReviewer() { return reviewer; }
+    @Override public void setReviewer(User reviewer) { this.reviewer = reviewer; }
+
+    private Integer categoryId;
+    @Override public Integer getCategoryId() { return categoryId; }
+    @Override public void setCategoryId(Integer categoryId) { this.categoryId = categoryId; }
+
+    public PageDraft() {
+
+    }
+
+}

+ 99 - 0
src/main/java/com/jayfella/website/database/entity/user/User.java

@@ -0,0 +1,99 @@
+package com.jayfella.website.database.entity.user;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.jayfella.website.database.entity.Badge;
+import org.hibernate.annotations.CreationTimestamp;
+
+import javax.persistence.*;
+import java.util.Date;
+import java.util.List;
+import java.util.Objects;
+
+@Entity
+@Table(name = "USERS")
+public class User {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    @Column(name = "ID", unique = true, nullable = false)
+    private long id;
+    public long getId() { return id; }
+    public void setId(long id) { this.id = id; }
+
+    @Column(name = "USERNAME", unique = true, nullable = false, length = 64)
+    private String username;
+    public String getUsername() { return username; }
+    public void setUsername(String name) { this.username = name; }
+
+    @Column(name = "NAME", length = 64)
+    private String name;
+    public String getName() { return name; }
+    public void setName(String name) { this.name = name; }
+
+
+    @Column(name = "EMAIL", unique = true, nullable = false, length = 256)
+    private String email;
+    @JsonIgnore
+    public String getEmail() { return email; }
+    public void setEmail(String email) { this.email = email; }
+
+    // 512bit pass = 128 chars
+    // 512bit salt = 128 chars
+    // ":" = 1 char
+    // total: 257 chars/length
+    @Column(name = "PASSWORD", unique = true, nullable = false, length = 257)
+    private String password;
+    @JsonIgnore
+    public String getPassword() { return password; }
+    public void setPassword(String password) { this.password = password; }
+
+    @Column(name = "AVATAR_ID", unique = true, nullable = true, length = 256)
+    private String avatarId;
+    public String getAvatarId() { return avatarId; }
+    public void setAvatarId(String avatarId) { this.avatarId = avatarId; }
+
+    @CreationTimestamp
+    @Temporal(TemporalType.TIMESTAMP)
+    @Column(nullable = false, length = 32)
+    private Date registerDate;
+    public Date getRegisterDate() { return registerDate; }
+    public void setRegisterDate(Date registerDate) { this.registerDate = registerDate; }
+
+    @Column(name = "ADMIN", unique = false, nullable = false)
+    private boolean administrator = false;
+    public boolean isAdministrator() { return administrator; }
+    public void setAdministrator(boolean administrator) { this.administrator = administrator; }
+
+    @Column(name = "MODERATOR", unique = false, nullable = false)
+    private boolean moderator = false;
+    public boolean isModerator() { return moderator; }
+    public void setModerator(boolean moderator) { this.moderator = moderator; }
+
+    @ManyToMany(targetEntity = Badge.class, fetch = FetchType.EAGER)
+    private List<Badge> badges;
+    public List<Badge> getBadges() { return badges; }
+    public void setBadges(List<Badge> badges) { this.badges = badges; }
+
+    /*
+        Specifies the level of trust a user has:
+        0 = All new pages and amendments must be approved.
+        1 = Only new pages must be approved.
+        2 = New pages and amendments do not require approval.
+     */
+    private int trustLevel = 0;
+    public int getTrustLevel() { return trustLevel; }
+    public void setTrustLevel(int trustLevel) { this.trustLevel = trustLevel; }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        User user = (User) o;
+        return id == user.id;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(id);
+    }
+}

+ 50 - 0
src/main/java/com/jayfella/website/database/entity/user/UserSession.java

@@ -0,0 +1,50 @@
+package com.jayfella.website.database.entity.user;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+
+import javax.persistence.*;
+
+@Entity
+@Table(name = "SESSIONS")
+public class UserSession {
+
+    private long id;
+    //private long userId;
+    private String session;
+
+    private String ipAddress;
+    private String userAgent;
+
+    @JsonIgnore
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    @Column(name = "ID", unique = true, nullable = false)
+    public long getId() { return id; }
+    public void setId(long id) { this.id = id; }
+
+    //@JsonIgnore
+    //@Column(name = "USER_ID", unique = false, nullable = false, length = 32)
+    //public long getUserId() { return userId; }
+    //public void setUserId(long userId) { this.userId = userId; }
+
+    @JsonIgnore
+    @Column(name = "SESSION", unique = true, nullable = false, length = 32)
+    public String getSession() { return session; }
+    public void setSession(String session) { this.session = session; }
+
+    @Column(name = "IP_ADDRESS", unique = false, nullable = false, length = 32)
+    public String getIpAddress() { return ipAddress; }
+    public void setIpAddress(String ipAddress) { this.ipAddress = ipAddress; }
+
+    @Column(name = "AGENT", unique = false, nullable = false, length = 128)
+    public String getUserAgent() { return userAgent; }
+    public void setUserAgent(String userAgent) { this.userAgent = userAgent; }
+
+    private User user;
+
+    @ManyToOne(targetEntity=User.class)
+    @JoinColumn(name = "USER_ID", nullable = false)
+    public User getUser() { return user; }
+    public void setUser(User user) { this.user = user; }
+
+}

+ 56 - 0
src/main/java/com/jayfella/website/database/entity/user/UserValidation.java

@@ -0,0 +1,56 @@
+package com.jayfella.website.database.entity.user;
+
+import com.jayfella.website.core.AccountValidationType;
+import org.hibernate.annotations.CreationTimestamp;
+import org.hibernate.annotations.GenericGenerator;
+
+import javax.persistence.*;
+import java.util.Date;
+
+@Entity
+@Table(name = "UserValidation")
+public class UserValidation {
+
+    @Id
+    @GeneratedValue(generator = "uuid")
+    @GenericGenerator(name = "uuid", strategy = "uuid2")
+    @Column(name = "id", unique = true, nullable = false, length = 64)
+    private String id;
+    public String getId() { return id; }
+    public void setId(String id) { this.id = id; }
+
+    private long userId;
+    public long getUserId() { return userId; }
+    public void setUserId(long userId) { this.userId = userId; }
+
+    @Enumerated(EnumType.STRING)
+    private AccountValidationType validationType;
+    public AccountValidationType getValidationType() { return validationType; }
+    public void setValidationType(AccountValidationType validationType) { this.validationType = validationType; }
+
+    // this could be the new email address requested, the new password, or empty (if the user is just validating their account.)
+    @Column(nullable = false, length = 257)
+    private String value;
+    public String getValue() { return value; }
+    public void setValue(String value) { this.value = value; }
+
+    @CreationTimestamp
+    @Temporal(TemporalType.TIMESTAMP)
+    @Column(nullable = false, length = 32)
+    private Date creationDate;
+    public Date getCreationDate() { return creationDate; }
+    public void setCreationDate(Date creationDate) { this.creationDate = creationDate; }
+
+    public UserValidation() {
+
+    }
+
+    public UserValidation(long userId, AccountValidationType validationType, String value) {
+        this.userId = userId;
+        this.validationType = validationType;
+        this.value = value;
+    }
+
+}
+
+

+ 8 - 0
src/main/java/com/jayfella/website/database/repository/BadgeRepository.java

@@ -0,0 +1,8 @@
+package com.jayfella.website.database.repository;
+
+import com.jayfella.website.database.entity.Badge;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface BadgeRepository extends JpaRepository<Badge, Long> {
+
+}

+ 20 - 0
src/main/java/com/jayfella/website/database/repository/CategoryRepository.java

@@ -0,0 +1,20 @@
+package com.jayfella.website.database.repository;
+
+import com.jayfella.website.database.entity.Category;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Collection;
+import java.util.Optional;
+import java.util.Set;
+
+public interface CategoryRepository extends JpaRepository<Category, Integer> {
+
+    Set<Category> findAllByIdIn(int[] ids);
+
+    Optional<Category> findByName(String name);
+    Optional<Category> findByNameIgnoreCase(String name);
+
+    Iterable<Category> findByNameContainingIgnoreCase(String name);
+    Collection<Category> findByNameIgnoreCaseIn(String[] names);
+
+}

+ 11 - 0
src/main/java/com/jayfella/website/database/repository/MessageReplyRepository.java

@@ -0,0 +1,11 @@
+package com.jayfella.website.database.repository;
+
+import com.jayfella.website.database.entity.message.Message;
+import com.jayfella.website.database.entity.message.MessageReply;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface MessageReplyRepository extends JpaRepository<MessageReply, Long> {
+
+    Iterable<MessageReply> findByMessage(Message message);
+
+}

+ 11 - 0
src/main/java/com/jayfella/website/database/repository/MessagesRepository.java

@@ -0,0 +1,11 @@
+package com.jayfella.website.database.repository;
+
+import com.jayfella.website.database.entity.message.Message;
+import com.jayfella.website.database.entity.user.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface MessagesRepository extends JpaRepository<Message, Long> {
+
+    Iterable<Message> findByRecipientOrderByDateDesc(User user);
+    Iterable<Message> findByDeliveredFalseAndRecipient(User user);
+}

+ 19 - 0
src/main/java/com/jayfella/website/database/repository/ReviewRepository.java

@@ -0,0 +1,19 @@
+package com.jayfella.website.database.repository;
+
+import com.jayfella.website.database.entity.page.PageReview;
+import com.jayfella.website.database.entity.user.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface ReviewRepository extends JpaRepository<PageReview, Long> {
+
+    // we want a list in this instance, not an iterable because we want to sort reviews by date (usually).
+    List<PageReview> findByPageId(String id);
+    List<PageReview> findByUser(User user);
+    Optional<PageReview> findByPageIdAndUser(String id, User user);
+
+    Iterable<PageReview> findByPageIdIn(List<String> pageIds);
+    long countByPageIdIn(List<String> pageIds);
+}

+ 12 - 0
src/main/java/com/jayfella/website/database/repository/SessionRepository.java

@@ -0,0 +1,12 @@
+package com.jayfella.website.database.repository;
+
+import com.jayfella.website.database.entity.user.UserSession;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface SessionRepository extends JpaRepository<UserSession, Long> {
+
+    Optional<UserSession> findBySession(String session);
+
+}

Some files were not shown because too many files changed in this diff