commit bbf28141e2de599f3ad7bdae83a8d8ed69130875 Author: Adrian Victor Date: Fri Feb 27 13:55:49 2026 -0300 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1fac4d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ +.kotlin + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..d226b71 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,12 @@ + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:livingroom.db + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml new file mode 100644 index 0000000..7db62a1 --- /dev/null +++ b/.idea/data_source_mapping.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..ce1c62c --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,16 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..48bfea1 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..b50c980 --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..c041f2a --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + java + application + id("com.gradleup.shadow") version "9.3.0" +} + +group = "org.adrianvictor.livingroom" +version = "1.0-SNAPSHOT" + +application { + mainClass = "org.adrianvictor.livingroom.Main" +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.xerial:sqlite-jdbc:3.51.2.0") + implementation("com.googlecode.json-simple:json-simple:1.1.1") + implementation("org.freemarker:freemarker:2.3.32") + + testImplementation(platform("org.junit:junit-bom:5.10.0")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..7de7b1d --- /dev/null +++ b/config.json @@ -0,0 +1 @@ +{"library":"library.db","port":8080,"users":{"admin":{"password":"admin","role":"admin"}}} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..9c4ec41 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon Feb 09 18:15:20 BRT 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/library.db b/library.db new file mode 100644 index 0000000..b311264 Binary files /dev/null and b/library.db differ diff --git a/livingroom.db b/livingroom.db new file mode 100644 index 0000000..03c974e Binary files /dev/null and b/livingroom.db differ diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..cac06bb --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "LivingRoom" \ No newline at end of file diff --git a/src/main/java/org/adrianvictor/livingroom/Logger.java b/src/main/java/org/adrianvictor/livingroom/Logger.java new file mode 100644 index 0000000..b87f1d6 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/Logger.java @@ -0,0 +1,25 @@ +package org.adrianvictor.livingroom; + +public class Logger { + public static final String ANSI_RESET = "\u001B[0m"; + public static final String ANSI_BLACK = "\u001B[30m"; + public static final String ANSI_RED = "\u001B[31m"; + public static final String ANSI_GREEN = "\u001B[32m"; + public static final String ANSI_YELLOW = "\u001B[33m"; + public static final String ANSI_BLUE = "\u001B[34m"; + public static final String ANSI_PURPLE = "\u001B[35m"; + public static final String ANSI_CYAN = "\u001B[36m"; + public static final String ANSI_WHITE = "\u001B[37m"; + + public static void info(String t) { + System.out.println(ANSI_BLUE + "[Info] " + ANSI_RESET + t); + } + + public static void warning(String t) { + System.out.println(ANSI_YELLOW + "[Warning] " + ANSI_RESET + t); + } + + public static void error(String t) { + System.out.println(ANSI_RED + "[Error] " + ANSI_RESET + t); + } +} diff --git a/src/main/java/org/adrianvictor/livingroom/Main.java b/src/main/java/org/adrianvictor/livingroom/Main.java new file mode 100644 index 0000000..c8374de --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/Main.java @@ -0,0 +1,49 @@ +package org.adrianvictor.livingroom; + +import org.adrianvictor.livingroom.config.AppConfig; +import org.adrianvictor.livingroom.config.ConfigLoader; +import org.adrianvictor.livingroom.http.Handler; +import org.adrianvictor.livingroom.http.Handlers; +import org.adrianvictor.livingroom.http.Server; +import org.adrianvictor.livingroom.data.Indexer; + +import java.io.File; +import java.util.Map; + +public class Main { + private static String directoryPath; + private static final Indexer indexer = Indexer.getInstance(); + private static AppConfig config; + private static String configPath; + + public static void main(String[] args) { + for (int i = 0; i < args.length; i++) { + if ("--config".equals(args[i]) && i + 1 < args.length) { + configPath = args[i + 1]; + } + } + + try { + config = ConfigLoader.load("config.json"); + config.hashPlaintextPasswords(); + } catch (Exception e) { + Logger.error("Error loading config: " + e.getMessage()); + System.exit(1); + } + + directoryPath = config.getLibrary(); + Server server = Server.getInstance(); + indexer.scanAsync(new File(directoryPath)); + indexer.collectGarbage(); + + for (Map.Entry e : Handlers.getAll().entrySet()) { + server.registerHandler(e.getKey(), e.getValue()); + } + + server.start(); + } + + public static AppConfig getConfig() { + return config; + } +} \ No newline at end of file diff --git a/src/main/java/org/adrianvictor/livingroom/auth/Role.java b/src/main/java/org/adrianvictor/livingroom/auth/Role.java new file mode 100644 index 0000000..c5848c3 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/auth/Role.java @@ -0,0 +1,6 @@ +package org.adrianvictor.livingroom.auth; + +public enum Role { + USER, + ADMIN +} diff --git a/src/main/java/org/adrianvictor/livingroom/auth/Session.java b/src/main/java/org/adrianvictor/livingroom/auth/Session.java new file mode 100644 index 0000000..34a9471 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/auth/Session.java @@ -0,0 +1,34 @@ +package org.adrianvictor.livingroom.auth; + +import java.util.UUID; + +public class Session { + private final String sessionId; + private final String username; + private final long createdAt; + private long lastAccessedAt; + private static final long SESSION_TIMEOUT = 86400000; // 24 hours in milliseconds + + public Session(String username) { + this.sessionId = UUID.randomUUID().toString(); + this.username = username; + this.createdAt = System.currentTimeMillis(); + this.lastAccessedAt = createdAt; + } + + public String getSessionId() { + return sessionId; + } + + public String getUsername() { + return username; + } + + public void updateLastAccessed() { + this.lastAccessedAt = System.currentTimeMillis(); + } + + public boolean isExpired() { + return System.currentTimeMillis() - lastAccessedAt > SESSION_TIMEOUT; + } +} \ No newline at end of file diff --git a/src/main/java/org/adrianvictor/livingroom/auth/SessionManager.java b/src/main/java/org/adrianvictor/livingroom/auth/SessionManager.java new file mode 100644 index 0000000..8d3092b --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/auth/SessionManager.java @@ -0,0 +1,40 @@ +package org.adrianvictor.livingroom.auth; + +import org.adrianvictor.livingroom.Main; +import org.adrianvictor.livingroom.services.UserService; + +import java.util.HashMap; +import java.util.Map; + +public class SessionManager { + private static final SessionManager instance = new SessionManager(); + private final Map sessions = new HashMap<>(); + + private SessionManager() {} + + public static SessionManager getInstance() { + return instance; + } + + public Session createSession(String username) { + Session session = new Session(username); + sessions.put(session.getSessionId(), session); + return session; + } + + public Session getSession(String sessionId) { + Session session = sessions.get(sessionId); + if (session != null && !session.isExpired()) { + session.updateLastAccessed(); + return session; + } + if (session != null) { + sessions.remove(sessionId); + } + return null; + } + + public void destroySession(String sessionId) { + sessions.remove(sessionId); + } +} \ No newline at end of file diff --git a/src/main/java/org/adrianvictor/livingroom/auth/User.java b/src/main/java/org/adrianvictor/livingroom/auth/User.java new file mode 100644 index 0000000..1c30a28 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/auth/User.java @@ -0,0 +1,9 @@ +package org.adrianvictor.livingroom.auth; + +import org.adrianvictor.livingroom.utils.PasswordUtils; + +public record User(String username, String hashedPassword, String salt, Role role) { + public boolean auth(String password) { + return PasswordUtils.verifyPassword(password, salt, hashedPassword); + } +} diff --git a/src/main/java/org/adrianvictor/livingroom/config/AppConfig.java b/src/main/java/org/adrianvictor/livingroom/config/AppConfig.java new file mode 100644 index 0000000..e453c9b --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/config/AppConfig.java @@ -0,0 +1,94 @@ +package org.adrianvictor.livingroom.config; + +import org.adrianvictor.livingroom.Logger; +import org.adrianvictor.livingroom.utils.PasswordUtils; +import org.json.simple.JSONObject; + +import java.io.FileWriter; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +public class AppConfig { + private String library; + private int port; + private Map users; + private final String path; + + public AppConfig(String path) { + this.path = path; + this.users = new HashMap<>(); + } + + public String getLibrary() { return library; } + public void setLibrary(String library) { this.library = library; } + + public int getPort() { return port; } + public void setPort(int port) { this.port = port; } + + public Map getUsers() { return users; } + public void setUsers(Map users) { + this.users = (users != null) ? users : new HashMap<>(); + } + + public void hashPlaintextPasswords() { + if (users == null) return; // safety check + for (Map.Entry entry : users.entrySet()) { + Logger.info("Hashing user password for %s.".formatted(entry.getKey())); + UserConfig user = entry.getValue(); + if (user.getHash() == null && user.getPassword() != null) { + String salt = PasswordUtils.generateSalt(); + String hash = PasswordUtils.hashPassword(user.getPassword(), salt); + + user.setSalt(salt); + user.setHash(hash); + user.setPassword(null); // clear plaintext + } + } + } + + @SuppressWarnings("unchecked") + public void saveConfig() throws IOException { + JSONObject json = new JSONObject(); + json.put("library", getLibrary()); + json.put("port", getPort()); + + JSONObject usersJson = new JSONObject(); + if (users != null) { + for (Map.Entry entry : users.entrySet()) { + String username = entry.getKey(); + UserConfig user = entry.getValue(); + + JSONObject userJson = new JSONObject(); + userJson.put("role", user.getRole()); + userJson.put("salt", user.getSalt()); + userJson.put("hash", user.getHash()); + usersJson.put(username, userJson); + } + } + json.put("users", usersJson); + + try (FileWriter writer = new FileWriter(path)) { + writer.write(json.toJSONString()); + } + } + + public static class UserConfig { + private String role; + private String password; + private String salt; + private String hash; + + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + + public String getRole() { return role; } + public void setRole(String role) { this.role = role; } + + public String getSalt() { return salt; } + public void setSalt(String salt) { this.salt = salt; } + + public String getHash() { return hash; } + public void setHash(String hash) { this.hash = hash; } + } +} \ No newline at end of file diff --git a/src/main/java/org/adrianvictor/livingroom/config/ConfigLoader.java b/src/main/java/org/adrianvictor/livingroom/config/ConfigLoader.java new file mode 100644 index 0000000..c9110b7 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/config/ConfigLoader.java @@ -0,0 +1,114 @@ +package org.adrianvictor.livingroom.config; + +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; + +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.util.HashMap; +import java.util.Map; + +public class ConfigLoader { + + private static AppConfig config; + + @SuppressWarnings("unchecked") + public static AppConfig load(String path) throws Exception { + if (config != null) return config; // singleton + + File file = new File(path); + AppConfig appConfig; + + if (!file.exists()) { + appConfig = createDefaultConfig(path); + + saveConfig(appConfig, path); + } else { + JSONParser parser = new JSONParser(); + JSONObject obj = (JSONObject) parser.parse(new FileReader(path)); + + appConfig = new AppConfig(path); + appConfig.setLibrary((String) obj.get("library")); + + // port may be a long in org.json.simple + Object portObj = obj.get("port"); + if (portObj instanceof Long) { + appConfig.setPort(((Long) portObj).intValue()); + } else if (portObj instanceof String) { + appConfig.setPort(Integer.parseInt((String) portObj)); + } else { + throw new IllegalStateException("Invalid port value in config"); + } + + // users + JSONObject usersObj = (JSONObject) obj.get("users"); + Map users = new HashMap<>(); + for (Object key : usersObj.keySet()) { + String username = (String) key; + JSONObject userJson = (JSONObject) usersObj.get(username); + + AppConfig.UserConfig user = new AppConfig.UserConfig(); + user.setRole((String) userJson.get("role")); + user.setPassword((String) userJson.get("password")); + + users.put(username, user); + } + appConfig.setUsers(users); + } + + validate(appConfig); + + config = appConfig; + return config; + } + + private static AppConfig createDefaultConfig(String path) { + AppConfig defaultConfig = new AppConfig(path); + defaultConfig.setLibrary("/srv/games"); + defaultConfig.setPort(8080); + + Map users = new HashMap<>(); + AppConfig.UserConfig admin = new AppConfig.UserConfig(); + admin.setRole("admin"); + admin.setPassword("admin"); + users.put("admin", admin); + + defaultConfig.setUsers(users); + return defaultConfig; + } + + @SuppressWarnings("unchecked") + private static void saveConfig(AppConfig appConfig, String path) throws Exception { + JSONObject obj = new JSONObject(); + obj.put("library", appConfig.getLibrary()); + obj.put("port", appConfig.getPort()); + + JSONObject usersObj = new JSONObject(); + for (Map.Entry entry : appConfig.getUsers().entrySet()) { + JSONObject userJson = new JSONObject(); + userJson.put("role", entry.getValue().getRole()); + userJson.put("password", entry.getValue().getPassword()); + usersObj.put(entry.getKey(), userJson); + } + obj.put("users", usersObj); + + try (FileWriter writer = new FileWriter(path)) { + writer.write(obj.toJSONString()); + } + } + + private static void validate(AppConfig appConfig) { + if (appConfig.getLibrary() == null || appConfig.getLibrary().isEmpty()) + throw new IllegalStateException("Library path must be set"); + if (appConfig.getPort() <= 0) + throw new IllegalStateException("Port must be > 0"); + if (appConfig.getUsers() == null || appConfig.getUsers().isEmpty()) + throw new IllegalStateException("At least one user must be defined"); + } + + public static AppConfig get() { + if (config == null) throw new IllegalStateException("Config not loaded yet"); + return config; + } +} \ No newline at end of file diff --git a/src/main/java/org/adrianvictor/livingroom/data/Database.java b/src/main/java/org/adrianvictor/livingroom/data/Database.java new file mode 100644 index 0000000..f0a3e02 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/data/Database.java @@ -0,0 +1,195 @@ +package org.adrianvictor.livingroom.data; + +import org.adrianvictor.livingroom.Logger; +import org.adrianvictor.livingroom.Main; +import org.adrianvictor.livingroom.config.AppConfig; +import org.adrianvictor.livingroom.data.catalog.Item; +import org.adrianvictor.livingroom.data.catalog.Property; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; + +import java.io.File; +import java.sql.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class Database { + static String url; + static Database instance; + + private Database() { + String library = Main.getConfig().getLibrary(); + File dbFile = new File(library, "livingroom.db"); + url = "jdbc:sqlite:" + dbFile.getAbsolutePath(); + Logger.info("Loading DB at %s.".formatted(url)); + setup(); + } + + public static Database getInstance() { + if (instance == null) { + instance = new Database(); + } + + return instance; + } + + public void setup() { + var sql = """ + CREATE TABLE IF NOT EXISTS games ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + location TEXT NOT NULL, + properties TEXT NOT NULL + ) + """; + + try (var conn = DriverManager.getConnection(url); var stmt = conn.createStatement()) { + stmt.execute(sql); + } catch (SQLException e) { + Logger.error(e.getMessage()); + } + } + + + public void add(Item item) { + String sql = "INSERT INTO games(name, location, properties) VALUES(?,?,?)"; + + try (var conn = DriverManager.getConnection(url); + var pstmt = conn.prepareStatement(sql)) { + pstmt.setString(1, item.getPropertiesString().get("name")); + pstmt.setString(2, item.location().toString()); + pstmt.setString(3, item.getPropertiesJSON().toJSONString()); + pstmt.executeUpdate(); + } catch (SQLException e) { + Logger.error(e.getMessage()); + } + } + + public void remove(String gameID) { + String sql = "DELETE FROM games WHERE id=?"; + + try (var conn = DriverManager.getConnection(url); + var pstmt = conn.prepareStatement(sql)) { + pstmt.setString(1, gameID); + pstmt.executeUpdate(); + } catch (SQLException e) { + Logger.error(e.getMessage()); + } + } + + public void nuke() { + String sql = "DROP TABLE IF EXISTS games;"; + + try (var conn = DriverManager.getConnection(url); + var stmt = conn.createStatement()) { + stmt.execute(sql); + setup(); + } catch (SQLException e) { + Logger.error(e.getMessage()); + } + } + + public boolean has(String name) { + String sql = "SELECT id FROM games WHERE name=?"; + + try (Connection conn = DriverManager.getConnection(url); + PreparedStatement stmt = conn.prepareStatement(sql);) { + stmt.setString(1, name); + try (ResultSet rs = stmt.executeQuery()) { + return rs.next(); + } + } catch (SQLException e) { + Logger.error(e.getMessage()); + return false; + } + } + + public Item getGame(int id) { + String sql = "SELECT * FROM games WHERE id=?"; + + try (Connection conn = DriverManager.getConnection(url); + PreparedStatement stmt = conn.prepareStatement(sql);) { + stmt.setString(1, String.valueOf(id)); + try (ResultSet rs = stmt.executeQuery()) { + List processed = processResultSet(rs); + if (processed.isEmpty()) { + return null; + } + + return processed.getFirst(); + } + } catch (SQLException e) { + Logger.error(e.getMessage()); + return null; + } + } + + public List getAllGames() { + String sql = "SELECT * FROM games"; + JSONParser parser = new JSONParser(); + List result = new ArrayList<>(); + + try (Connection conn = DriverManager.getConnection(url); + PreparedStatement stmt = conn.prepareStatement(sql); + ResultSet rs = stmt.executeQuery()) { + result.addAll(processResultSet(rs)); + } catch (SQLException e) { + Logger.error(e.getMessage()); + } + + return result; + } + + private List processResultSet(ResultSet rs) throws SQLException { + List result = new ArrayList<>(); + JSONParser parser = new JSONParser(); + while (rs.next()) { + long id = rs.getLong("id"); + String name = rs.getString("name"); + String location = rs.getString("location"); + String json = rs.getString("properties"); + + JSONObject props; + try { + props = (JSONObject) parser.parse(json); + } catch (ParseException pe) { + Logger.error("Failed to parse JSON for game id " + id + ": " + pe.getMessage()); + continue; + } + + props.put("name", name); + props.put("id", id); + + File file; + + try { + file = new File(location); + } catch (Exception e) { + Logger.error("Cannot find game path for id " + id + ": " + e.getMessage()); + continue; + } + + HashMap properties = new HashMap<>(); + + for (Object propEntry : props.entrySet()) { + Map.Entry entry = (Map.Entry) propEntry; + + String value = String.valueOf(entry.getValue()); + + try { + Property prop = Property.valueOf(entry.getKey().toString().toUpperCase()); + properties.put(prop, value); + } catch (IllegalArgumentException ignored) { + Logger.warning("Unknown property: %s".formatted(entry.getKey())); + } + } + + Item item = new Item(file, properties); + result.add(item); + } + return result; + } +} diff --git a/src/main/java/org/adrianvictor/livingroom/data/Indexer.java b/src/main/java/org/adrianvictor/livingroom/data/Indexer.java new file mode 100644 index 0000000..ff51fa7 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/data/Indexer.java @@ -0,0 +1,106 @@ +package org.adrianvictor.livingroom.data; + +import org.adrianvictor.livingroom.Logger; +import org.adrianvictor.livingroom.data.catalog.Item; +import org.adrianvictor.livingroom.data.catalog.Property; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; + +import javax.xml.crypto.Data; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class Indexer { + private static Indexer instance; + private Indexer() {}; + + public static synchronized Indexer getInstance() { + if (instance == null) { + instance = new Indexer(); + } + return instance; + } + + public void scan(File directory) {; + File[] files = directory.listFiles(); + + for (File f : files) { + if (f.isDirectory()) { + File[] dir = f.listFiles(); + for (File file : dir) { + if (file.isFile() && file.toPath().toString().endsWith(".json")) { + Logger.info("Found json: %s".formatted(file.toPath().getFileName())); + try (FileReader fr = new FileReader(file)) { + Object obj = new JSONParser().parse(fr); + + if (obj instanceof JSONObject) { + processObject((JSONObject) obj, file); + } else if (obj instanceof JSONArray) { + for (Object element : (JSONArray) obj) { + if (element instanceof JSONObject) { + processObject((JSONObject) element, file); + } + } + } + } catch (FileNotFoundException e) { + Logger.error("File not found: %s".formatted(file.getPath()) + e); + } catch (IOException e) { + Logger.error("IO Error reading file: %s".formatted(file.getPath()) + e); + } catch (ParseException e) { + Logger.error("JSON Parsing error in file: %s".formatted(file.getPath()) + e); + } + } + } + } + } + } + + private void processObject(JSONObject jobj, File file) { + HashMap properties = new HashMap<>(); + Logger.info("Adding %s to catalog.".formatted(file.getPath())); + + for (Object propEntry : jobj.entrySet()) { + Map.Entry entry = (Map.Entry) propEntry; + + String value = String.valueOf(entry.getValue()); + + try { + Property prop = Property.valueOf(entry.getKey().toString().toUpperCase()); + properties.put(prop, value); + } catch (IllegalArgumentException ignored) { + Logger.warning("Unknown property: %s".formatted(entry.getKey())); + } + } + + if (!Database.getInstance().has(properties.get(Property.NAME))) { + Database.getInstance().add(new Item(file, properties)); + } + } + + public void collectGarbage() { + List games = Database.getInstance().getAllGames(); + for (Item game : games) { + if (!game.location().exists()) { + Logger.warning("Game %s was caught by garbage collection because it's file does not exist anymore at %s." + .formatted(game.properties().get(Property.NAME), game.location().getPath()) + ); + Database.getInstance().remove(game.properties().get(Property.ID)); + } + } + } + + public void scanAsync(File directory) { + Thread asyncThread = new Thread(() -> { + scan(directory); + }); + + asyncThread.start(); + } +} diff --git a/src/main/java/org/adrianvictor/livingroom/data/catalog/Item.java b/src/main/java/org/adrianvictor/livingroom/data/catalog/Item.java new file mode 100644 index 0000000..a3f6c90 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/data/catalog/Item.java @@ -0,0 +1,63 @@ +package org.adrianvictor.livingroom.data.catalog; + +import org.adrianvictor.livingroom.Logger; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +public record Item(File location, HashMap properties) { + public HashMap getPropertiesString() { + HashMap result = new HashMap<>(); + properties.forEach((key, value) -> { + result.put(key.name().toLowerCase(), value); + }); + return result; + } + + public JSONObject getPropertiesJSON() { + return new JSONObject(getPropertiesString()); + } + + public List getVersions() { + List result = new ArrayList<>(); + + if (getPropertiesString().get("files") == null) { + return result; + } + + try { + JSONParser parser = new JSONParser(); + JSONObject jsonObject = (JSONObject) parser.parse(getPropertiesString().get("files")); + + for (Object o : jsonObject.keySet()) { + if (o instanceof String s) { + result.add(s); + } + } + } catch (ParseException e) { + Logger.error(e.getMessage()); + } + + return result; + } + + public String getVersionFile(String version) { + if (getPropertiesString().get("files") != null) { + try { + JSONParser parser = new JSONParser(); + JSONObject jsonObject = (JSONObject) parser.parse(getPropertiesString().get("files")); + JSONObject versionObject = (JSONObject) jsonObject.get(version); + return (String) versionObject.get("file"); + } catch (ParseException e) { + Logger.error(e.getMessage()); + } catch (ClassCastException ignored) {} + } + + return null; + } +} \ No newline at end of file diff --git a/src/main/java/org/adrianvictor/livingroom/data/catalog/Property.java b/src/main/java/org/adrianvictor/livingroom/data/catalog/Property.java new file mode 100644 index 0000000..788edf2 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/data/catalog/Property.java @@ -0,0 +1,15 @@ +package org.adrianvictor.livingroom.data.catalog; + +public enum Property { + ID, + NAME, + PUBLISHER, + AUTHOR, + YEAR, + DATE, + DESCRIPTION, + MEDIUM, + ARTWORK, + FILES, + OPERATING_SYSTEM; +} diff --git a/src/main/java/org/adrianvictor/livingroom/http/Handler.java b/src/main/java/org/adrianvictor/livingroom/http/Handler.java new file mode 100644 index 0000000..d85d4f8 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/http/Handler.java @@ -0,0 +1,7 @@ +package org.adrianvictor.livingroom.http; + +import com.sun.net.httpserver.HttpExchange; + +public interface Handler { + HttpResponse result(String baseAddress, String path, HttpExchange exchange); +} diff --git a/src/main/java/org/adrianvictor/livingroom/http/Handlers.java b/src/main/java/org/adrianvictor/livingroom/http/Handlers.java new file mode 100644 index 0000000..8d61bdb --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/http/Handlers.java @@ -0,0 +1,21 @@ +package org.adrianvictor.livingroom.http; + +import org.adrianvictor.livingroom.http.handlers.*; + +import java.util.HashMap; + +public class Handlers { + private static HashMap map = new HashMap<>(); + + static { + map.put(new CatalogHandler(), "/catalog"); + map.put(new ImageHandler(), "/pic"); + map.put(new FileHandler(), "/download"); + map.put(new WebHandler(), "/web"); + map.put(new StaticWebHandler(), "/static"); + } + + public static HashMap getAll() { + return map; + } +} diff --git a/src/main/java/org/adrianvictor/livingroom/http/HttpResponse.java b/src/main/java/org/adrianvictor/livingroom/http/HttpResponse.java new file mode 100644 index 0000000..c42e0b0 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/http/HttpResponse.java @@ -0,0 +1,30 @@ +package org.adrianvictor.livingroom.http; + +import java.util.HashMap; +import java.util.Map; + +public record HttpResponse( + int status, + byte[] body, + Map headers +) { + public static HttpResponse ok(byte[] body, String contentType, Map headers) { + return new HttpResponse( + 200, + body, + Map.of("Content-Type", contentType) + ); + } + + public static HttpResponse ok(byte[] body, String contentType) { + return ok(body, contentType, new HashMap<>()); + } + + public static HttpResponse redirect(String location) { + return new HttpResponse( + 302, + new byte[0], + Map.of("Location", location) + ); + } +} \ No newline at end of file diff --git a/src/main/java/org/adrianvictor/livingroom/http/Server.java b/src/main/java/org/adrianvictor/livingroom/http/Server.java new file mode 100644 index 0000000..d186e55 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/http/Server.java @@ -0,0 +1,114 @@ +package org.adrianvictor.livingroom.http; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.adrianvictor.livingroom.Logger; +import org.adrianvictor.livingroom.Main; +import org.adrianvictor.livingroom.http.handlers.FileHandler; +import org.adrianvictor.livingroom.http.handlers.ImageHandler; +import org.adrianvictor.livingroom.http.handlers.StaticWebHandler; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.util.Map; + +public class Server { + static Server instance; + HttpServer httpServer; + + private Server() { + try { + httpServer = HttpServer.create(new InetSocketAddress(Main.getConfig().getPort()), 0); + + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static Server getInstance() { + if (instance == null) { + instance = new Server(); + } + + return instance; + } + + public void start() { + httpServer.start(); + } + + public void registerHandler(Handler handler, String path) { + httpServer.createContext(path, new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + String fullPath = exchange.getRequestURI().getPath(); + + String afterBase = ""; + if (fullPath.length() > path.length()) { + afterBase = fullPath.substring(path.length() + 1); + } + String decoded = java.net.URLDecoder.decode(afterBase, java.nio.charset.StandardCharsets.UTF_8); + + int thirdSlash = nthIndexOf(fullPath, '/', 3); + String baseAddress; + if (thirdSlash == -1) { + baseAddress = fullPath; + } else { + baseAddress = fullPath.substring(0, thirdSlash); + } + + exchange.getResponseHeaders().add("X-Content-Type-Options", "nosniff"); + exchange.getResponseHeaders().add("X-Frame-Option", "DENY"); + exchange.getResponseHeaders().add("Referrer-Policy", "no-referrer"); + exchange.getResponseHeaders().add("Content-Security-Policy", ""); + exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*"); + exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); + exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "Content-Type"); + + if (handler instanceof FileHandler) { + ((FileHandler) handler).handleStream(exchange, decoded); + } else if (handler instanceof ImageHandler) { + ((ImageHandler) handler).handleStream(exchange, decoded); + } else if (handler instanceof StaticWebHandler) { + ((StaticWebHandler) handler).handleStream(exchange, decoded); + } else { + HttpResponse result = handler.result(baseAddress, decoded, exchange); + + for (Map.Entry header : result.headers().entrySet()) { + exchange.getResponseHeaders().set(header.getKey(), header.getValue()); + } + + exchange.sendResponseHeaders(result.status(), result.body().length); + + OutputStream os = exchange.getResponseBody(); + os.write(result.body()); + os.close(); + } + } catch (IOException ignored) { + } catch (Exception e) { + Logger.error("Handler error: " + e.getMessage()); + e.printStackTrace(); + try { + exchange.sendResponseHeaders(500, 0); + exchange.close(); + } catch (Exception ignored) { + } + } finally { + exchange.close(); + } + } + }); + } + + private static int nthIndexOf(String str, char c, int n) { + int pos = -1; + for (int i = 0; i < n; i++) { + pos = str.indexOf(c, pos + 1); + if (pos == -1) break; + } + return pos; + } +} \ No newline at end of file diff --git a/src/main/java/org/adrianvictor/livingroom/http/handlers/CatalogHandler.java b/src/main/java/org/adrianvictor/livingroom/http/handlers/CatalogHandler.java new file mode 100644 index 0000000..fc3afae --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/http/handlers/CatalogHandler.java @@ -0,0 +1,25 @@ +package org.adrianvictor.livingroom.http.handlers; + +import com.sun.net.httpserver.HttpExchange; +import org.adrianvictor.livingroom.http.Handler; +import org.adrianvictor.livingroom.http.HttpResponse; +import org.adrianvictor.livingroom.data.Database; +import org.adrianvictor.livingroom.data.catalog.Item; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; + +import java.util.List; + +public record CatalogHandler() implements Handler { + @Override + public HttpResponse result(String baseAddress, String path, HttpExchange exchange) { + List rawCatalog = Database.getInstance().getAllGames(); + JSONArray jsonArray = new JSONArray(); + for (Item item : rawCatalog) { + jsonArray.add(new JSONObject(item.getPropertiesString())); + } + String response = jsonArray.toJSONString(); + + return HttpResponse.ok(response.getBytes(), "application/json"); + } +} diff --git a/src/main/java/org/adrianvictor/livingroom/http/handlers/FileHandler.java b/src/main/java/org/adrianvictor/livingroom/http/handlers/FileHandler.java new file mode 100644 index 0000000..9a12a55 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/http/handlers/FileHandler.java @@ -0,0 +1,103 @@ +package org.adrianvictor.livingroom.http.handlers; + +import com.sun.net.httpserver.HttpExchange; +import org.adrianvictor.livingroom.http.Handler; +import org.adrianvictor.livingroom.http.HttpResponse; +import org.adrianvictor.livingroom.Logger; +import org.adrianvictor.livingroom.data.Database; +import org.adrianvictor.livingroom.data.catalog.Item; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.List; + +public class FileHandler implements Handler { + + public void handleStream(HttpExchange exchange, String path) { + String[] parts = path.split("/"); + + if (parts.length < 2) { + send404(exchange, "Usage: .../game_id/DownloadName"); + return; + } + + int id; + try { + id = Integer.parseInt(parts[0]); + } catch (NumberFormatException e) { + send404(exchange, "The provided game ID %s is not a valid number.".formatted(parts[0])); + return; + } + + String platform = URLDecoder.decode(parts[1], StandardCharsets.UTF_8); + Item game = Database.getInstance().getGame(id); + if (game == null) { + send404(exchange, "Game not found."); + return; + } + + List files = game.getVersions(); + if (files.isEmpty() || !files.contains(platform)) { + send404(exchange, "This game has no such download: %s.".formatted(platform)); + return; + } + + File gameFile = new File(game.location().getParent() + '/' + game.getVersionFile(platform)); + + if (!gameFile.exists()) { + Logger.info(gameFile.getPath()); + send404(exchange, "Could not find the file you're looking for. Sorry."); + return; + } + + Logger.info("Providing game file: " + gameFile.getPath()); + + exchange.getResponseHeaders().add("Content-Type", contentType()); + exchange.getResponseHeaders().add( + "Content-Disposition", + "attachment; filename=\"" + gameFile.getName() + "\"" + ); + + try (FileInputStream fis = new FileInputStream(gameFile); + OutputStream os = exchange.getResponseBody()) { + exchange.sendResponseHeaders(200, gameFile.length()); + + byte[] buffer = new byte[8192]; // 8 KB chunks + int bytesRead; + while ((bytesRead = fis.read(buffer)) != -1) { + os.write(buffer, 0, bytesRead); + } + } catch (Exception e) { + send404(exchange); + e.printStackTrace(); + } + } + + private void send404(HttpExchange exchange, String message) { + byte[] notFound = message.getBytes(); + try { + exchange.sendResponseHeaders(404, notFound.length); + exchange.getResponseBody().write(notFound); + exchange.getResponseBody().close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + private void send404(HttpExchange exchange) { + send404(exchange, "Not found."); + } + + @Override + public HttpResponse result(String baseAddress, String path, HttpExchange exchange) { + return HttpResponse.ok(new byte[0], ""); + } + + public String contentType() { + return "application/octet-stream"; + } +} \ No newline at end of file diff --git a/src/main/java/org/adrianvictor/livingroom/http/handlers/ImageHandler.java b/src/main/java/org/adrianvictor/livingroom/http/handlers/ImageHandler.java new file mode 100644 index 0000000..8df6bd2 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/http/handlers/ImageHandler.java @@ -0,0 +1,53 @@ +package org.adrianvictor.livingroom.http.handlers; + +import com.sun.net.httpserver.HttpExchange; +import org.adrianvictor.livingroom.http.Handler; +import org.adrianvictor.livingroom.http.HttpResponse; +import org.adrianvictor.livingroom.data.Database; +import org.adrianvictor.livingroom.data.catalog.Item; +import org.adrianvictor.livingroom.data.catalog.Property; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +public class ImageHandler implements Handler { + public void handleStream(HttpExchange exchange, String path) throws IOException { + int id = Integer.parseInt(path); + Item game = Database.getInstance().getGame(id); + + if (game.properties().get(Property.ARTWORK) == null || game.properties().get(Property.ARTWORK).isEmpty()) { + byte[] notFound = "Not found".getBytes(); + exchange.sendResponseHeaders(404, notFound.length); + exchange.getResponseBody().write(notFound); + exchange.getResponseBody().close(); + return; + } + + File image = new File(game.location().getParent() + '/' + game.properties().get(Property.ARTWORK)); + + exchange.getResponseHeaders().add("Content-Type", "image/png"); + exchange.sendResponseHeaders(200, image.length()); + + try (FileInputStream fis = new FileInputStream(image)) { + byte[] buffer = new byte[8192]; // 8 KB chunks + int bytesRead; + while ((bytesRead = fis.read(buffer)) != -1) { + exchange.getResponseBody().write(buffer, 0, bytesRead); + } + } catch (IOException e) { + throw new RuntimeException(e); + } finally { + exchange.getResponseBody().close(); + } + } + + @Override + public HttpResponse result(String baseAddress, String path, HttpExchange exchange) { + return HttpResponse.ok(new byte[0], ""); + } + + public String contentType() { + return "image/png"; + } +} \ No newline at end of file diff --git a/src/main/java/org/adrianvictor/livingroom/http/handlers/StaticWebHandler.java b/src/main/java/org/adrianvictor/livingroom/http/handlers/StaticWebHandler.java new file mode 100644 index 0000000..462556d --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/http/handlers/StaticWebHandler.java @@ -0,0 +1,52 @@ +package org.adrianvictor.livingroom.http.handlers; + +import com.sun.net.httpserver.HttpExchange; +import org.adrianvictor.livingroom.http.Handler; +import org.adrianvictor.livingroom.http.HttpResponse; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class StaticWebHandler implements Handler { + + private static final String STATIC_ROOT = "static"; + + public void handleStream(HttpExchange exchange, String path) throws IOException { + if (path.contains("..")) { + exchange.sendResponseHeaders(403, -1); + return; + } + + String resourcePath = "static/" + path.replace("\\", "/"); + InputStream resourceStream = getClass().getClassLoader().getResourceAsStream(resourcePath); + + if (resourceStream == null) { + exchange.sendResponseHeaders(404, -1); + return; + } + + String contentType; + if (path.endsWith(".css")) contentType = "text/css"; + else if (path.endsWith(".js")) contentType = "application/javascript"; + else if (path.endsWith(".png")) contentType = "image/png"; + else if (path.endsWith(".jpg") || path.endsWith(".jpeg")) contentType = "image/jpeg"; + else contentType = "application/octet-stream"; + + exchange.getResponseHeaders().add("Content-Type", contentType); + exchange.sendResponseHeaders(200, 0); + + try (resourceStream; OutputStream os = exchange.getResponseBody()) { + resourceStream.transferTo(os); + } + } + + @Override + public HttpResponse result(String baseAddress, String path, HttpExchange exchange) { + return HttpResponse.ok(new byte[0], ""); + } + + public String contentType() { + return "application/octet-stream"; + } +} \ No newline at end of file diff --git a/src/main/java/org/adrianvictor/livingroom/http/handlers/WebHandler.java b/src/main/java/org/adrianvictor/livingroom/http/handlers/WebHandler.java new file mode 100644 index 0000000..38f833c --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/http/handlers/WebHandler.java @@ -0,0 +1,120 @@ +package org.adrianvictor.livingroom.http.handlers; + +import com.sun.net.httpserver.HttpExchange; +import freemarker.template.Configuration; +import org.adrianvictor.livingroom.auth.User; +import org.adrianvictor.livingroom.http.Handler; +import org.adrianvictor.livingroom.http.HttpResponse; +import org.adrianvictor.livingroom.Logger; +import org.adrianvictor.livingroom.Main; +import org.adrianvictor.livingroom.services.UserService; +import org.adrianvictor.livingroom.utils.net.Cookie; +import org.adrianvictor.livingroom.web.Page; +import org.adrianvictor.livingroom.web.Pages; +import org.adrianvictor.livingroom.auth.Session; +import org.adrianvictor.livingroom.auth.SessionManager; +import org.adrianvictor.livingroom.web.pages.Login; +import org.adrianvictor.livingroom.web.pages.NotFound; + +import java.util.HashMap; +import java.util.Map; + +public class WebHandler implements Handler { + + private final Configuration cfg; + + public WebHandler() { + cfg = new Configuration(Configuration.VERSION_2_3_32); + cfg.setClassForTemplateLoading( + Main.class, + "/templates" + ); + } + + @Override + public HttpResponse result(String baseAddress, String path, HttpExchange exchange) { + Map data = new HashMap<>(); + String firstPath = path.split("/")[0]; + String remainingPath = path.substring(firstPath.length()); + if (remainingPath.startsWith("/")) { + remainingPath = remainingPath.substring(1); + } + + data.put("webpref", "web"); + + String sessionId = Cookie.getSessionIdFromCookies(exchange); + Session session = SessionManager.getInstance().getSession(sessionId); + + if (firstPath.equals("login")) { + if ("POST".equalsIgnoreCase(exchange.getRequestMethod())) { + return handleLoginPage(baseAddress, remainingPath, exchange); + } + + Page page = Pages.get(firstPath); + if (page == null) { + page = new Login(); + } + return HttpResponse.ok(page.result(cfg, baseAddress, remainingPath, data, exchange).getBytes(), "text/html"); + } + + if (session == null) { + return HttpResponse.ok(new Login().result(cfg, baseAddress, "", data, exchange).getBytes(), "text/html"); + } + + Page page = Pages.get(firstPath); + + User user = UserService.getInstance().getUser(session.getUsername()); + + data.put("username", session.getUsername()); + data.put("userRole", user.role().toString().toLowerCase()); + + if (page == null) { + return HttpResponse.ok(new NotFound().result(cfg, baseAddress, path, data, exchange).getBytes(), "text/html"); + } + + return HttpResponse.ok(page.result(cfg, baseAddress, remainingPath, data, exchange).getBytes(), "text/html"); + } + + private HttpResponse handleLoginPage(String baseAddress, String path, HttpExchange exchange) { + String method = exchange.getRequestMethod(); + + Map data = new HashMap<>(); + data.put("webpref", "web"); + + if ("POST".equals(method)) { + try { + // Read POST body + String body = new String(exchange.getRequestBody().readAllBytes()); + String[] params = body.split("&"); + String username = null; + String password = null; + + + for (String param : params) { + String[] kv = param.split("="); + if (kv.length == 2) { + if ("username".equals(kv[0])) { + username = java.net.URLDecoder.decode(kv[1], java.nio.charset.StandardCharsets.UTF_8); + } else if ("password".equals(kv[0])) { + password = java.net.URLDecoder.decode(kv[1], java.nio.charset.StandardCharsets.UTF_8); + } + } + } + + if (UserService.getInstance().getUser(username).auth(password)) { + Session session = SessionManager.getInstance().createSession(username); + Cookie.setSessionCookie(exchange, session.getSessionId()); + + return HttpResponse.redirect("/web/"); + } else { + return HttpResponse.ok(new Login().result(cfg, baseAddress, "", data, exchange).getBytes(), "text/html"); + } + } catch (Exception e) { + Logger.error("Login error: " + e.getMessage()); + return HttpResponse.ok(new NotFound().result(cfg, baseAddress, path, data, exchange).getBytes(), "text/html"); + } + } + + return HttpResponse.ok(new NotFound().result(cfg, baseAddress, path, data, exchange).getBytes(), "text/html"); + } +} \ No newline at end of file diff --git a/src/main/java/org/adrianvictor/livingroom/services/UserService.java b/src/main/java/org/adrianvictor/livingroom/services/UserService.java new file mode 100644 index 0000000..b892b2e --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/services/UserService.java @@ -0,0 +1,47 @@ +package org.adrianvictor.livingroom.services; + +import org.adrianvictor.livingroom.Main; +import org.adrianvictor.livingroom.auth.Role; +import org.adrianvictor.livingroom.auth.User; +import org.adrianvictor.livingroom.config.AppConfig; + +import java.util.Map; + +public class UserService { + private static UserService instance; + private static Map users; + + private UserService() { + users = Main.getConfig().getUsers(); + } + + public static UserService getInstance() { + if (instance == null) { + instance = new UserService(); + } + return instance; + } + + public User getUser(String user) { + AppConfig.UserConfig raw = users.get(user); + + if (user == null) { + return null; + } + + Role role; + + try { + role = Role.valueOf(raw.getRole()); + } catch (IllegalArgumentException e) { + role = Role.USER; + } + + return new User( + user, + raw.getHash(), + raw.getSalt(), + role + ); + } +} diff --git a/src/main/java/org/adrianvictor/livingroom/utils/PasswordUtils.java b/src/main/java/org/adrianvictor/livingroom/utils/PasswordUtils.java new file mode 100644 index 0000000..c6336ea --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/utils/PasswordUtils.java @@ -0,0 +1,40 @@ +package org.adrianvictor.livingroom.utils; + +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; +import java.security.SecureRandom; +import java.util.Base64; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; + +public class PasswordUtils { + private static final int ITERATIONS = 65536; + private static final int KEY_LENGTH = 256; + + public static String generateSalt() { + byte[] salt = new byte[16]; + new SecureRandom().nextBytes(salt); + return Base64.getEncoder().encodeToString(salt); + } + + public static String hashPassword(String password, String salt) { + try { + PBEKeySpec spec = new PBEKeySpec( + password.toCharArray(), + Base64.getDecoder().decode(salt), + ITERATIONS, + KEY_LENGTH + ); + SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + byte[] hash = skf.generateSecret(spec).getEncoded(); + return Base64.getEncoder().encodeToString(hash); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new RuntimeException("Error hashing password", e); + } + } + + public static boolean verifyPassword(String password, String salt, String expectedHash) { + String hash = hashPassword(password, salt); + return hash.equals(expectedHash); + } +} \ No newline at end of file diff --git a/src/main/java/org/adrianvictor/livingroom/utils/image/ColorExtractor.java b/src/main/java/org/adrianvictor/livingroom/utils/image/ColorExtractor.java new file mode 100644 index 0000000..2c35cc0 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/utils/image/ColorExtractor.java @@ -0,0 +1,53 @@ +package org.adrianvictor.livingroom.utils.image; + +import org.adrianvictor.livingroom.data.catalog.Item; +import org.adrianvictor.livingroom.data.catalog.Property; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.File; +import java.util.HashMap; +import java.util.Map; + +public class ColorExtractor { + public static String getDominantColor(File imageFile) throws Exception { + BufferedImage image = ImageIO.read(imageFile); + + // Downsample + BufferedImage resized = new BufferedImage(150, 150, BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = resized.createGraphics(); + g2d.drawImage(image, 0, 0, 150, 150, null); + g2d.dispose(); + + // Score by saturation, skip neutrals + Map colorScore = new HashMap<>(); + for (int y = 0; y < resized.getHeight(); y++) { + for (int x = 0; x < resized.getWidth(); x++) { + int rgb = resized.getRGB(x, y); + +// if (isNeutralColor(rgb)) { +// continue; +// } + + float[] hsb = Color.RGBtoHSB((rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF, null); + double score = colorScore.getOrDefault(rgb, 0.0) + hsb[1]; + colorScore.put(rgb, score); + } + } + + int dominantColor = colorScore.entrySet().stream() + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey) + .orElse(0); + + return String.format("#%06X", dominantColor & 0xFFFFFF); + } + + public static String getDominantColor(Item game) throws Exception { + String image = game.properties().get(Property.ARTWORK); + File imageFile = new File(game.location().getParent() + '/' + image); + return getDominantColor(imageFile); + } + +} \ No newline at end of file diff --git a/src/main/java/org/adrianvictor/livingroom/utils/net/Cookie.java b/src/main/java/org/adrianvictor/livingroom/utils/net/Cookie.java new file mode 100644 index 0000000..acf8e27 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/utils/net/Cookie.java @@ -0,0 +1,33 @@ +package org.adrianvictor.livingroom.utils.net; + +import com.sun.net.httpserver.HttpExchange; + +public class Cookie { + private static final String SESSION_COOKIE_NAME = "SESSIONID"; + + public static String getSessionIdFromCookies(HttpExchange exchange) { + String cookieHeader = exchange.getRequestHeaders().getFirst("Cookie"); + if (cookieHeader == null) { + return null; + } + + String[] cookies = cookieHeader.split(";"); + for (String cookie : cookies) { + cookie = cookie.trim(); + if (cookie.startsWith(SESSION_COOKIE_NAME + "=")) { + return cookie.substring((SESSION_COOKIE_NAME + "=").length()); + } + } + return null; + } + + public static void setSessionCookie(HttpExchange exchange, String sessionId) { + String cookie = SESSION_COOKIE_NAME + "=" + sessionId + "; Path=/; HttpOnly; SameSite=Strict"; + exchange.getResponseHeaders().add("Set-Cookie", cookie); + } + + public static void clearSessionCookie(HttpExchange exchange) { + String cookie = SESSION_COOKIE_NAME + "=; Path=/; Max-Age=0; HttpOnly"; + exchange.getResponseHeaders().add("Set-Cookie", cookie); + } +} \ No newline at end of file diff --git a/src/main/java/org/adrianvictor/livingroom/web/Page.java b/src/main/java/org/adrianvictor/livingroom/web/Page.java new file mode 100644 index 0000000..74cac8f --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/web/Page.java @@ -0,0 +1,10 @@ +package org.adrianvictor.livingroom.web; + +import com.sun.net.httpserver.HttpExchange; +import freemarker.template.Configuration; + +import java.util.Map; + +public interface Page { + String result(Configuration cfg, String baseAddress, String path, Map data, HttpExchange exchange); +} \ No newline at end of file diff --git a/src/main/java/org/adrianvictor/livingroom/web/Pages.java b/src/main/java/org/adrianvictor/livingroom/web/Pages.java new file mode 100644 index 0000000..8c508b8 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/web/Pages.java @@ -0,0 +1,22 @@ +package org.adrianvictor.livingroom.web; + +import org.adrianvictor.livingroom.web.pages.*; + +import java.util.HashMap; + +public class Pages { + private static HashMap map = new HashMap<>(); + + static { + Index index = new Index(); + map.put("", index); + map.put("game", new Game()); + map.put("logout", new Logout()); + map.put("scan", new Scan()); + } + + public static HashMap getAll() { + return map; + } + public static Page get(String path) { return map.get(path); } +} diff --git a/src/main/java/org/adrianvictor/livingroom/web/QuickResponses.java b/src/main/java/org/adrianvictor/livingroom/web/QuickResponses.java new file mode 100644 index 0000000..dc9b3f4 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/web/QuickResponses.java @@ -0,0 +1,7 @@ +package org.adrianvictor.livingroom.web; + +public class QuickResponses { + public static String notFound() { + return "Not Found"; + } +} \ No newline at end of file diff --git a/src/main/java/org/adrianvictor/livingroom/web/pages/Game.java b/src/main/java/org/adrianvictor/livingroom/web/pages/Game.java new file mode 100644 index 0000000..a65d4f8 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/web/pages/Game.java @@ -0,0 +1,57 @@ +package org.adrianvictor.livingroom.web.pages; + +import com.sun.net.httpserver.HttpExchange; +import freemarker.template.Configuration; +import freemarker.template.Template; +import org.adrianvictor.livingroom.Logger; +import org.adrianvictor.livingroom.data.Database; +import org.adrianvictor.livingroom.data.catalog.Item; +import org.adrianvictor.livingroom.data.catalog.Property; +import org.adrianvictor.livingroom.utils.image.ColorExtractor; +import org.adrianvictor.livingroom.web.Page; +import org.adrianvictor.livingroom.web.QuickResponses; + +import java.io.File; +import java.io.StringWriter; +import java.util.Map; + +public class Game implements Page { + @Override + public String result(Configuration cfg, String baseAddress, String path, Map data, HttpExchange exchange) { + try { + Template template = cfg.getTemplate("game.ftl"); + + String[] args = path.split("/"); + + int id; + try { + id = Integer.parseInt(args[0]); + } catch (NumberFormatException e) { + return QuickResponses.notFound(); + // send404(exchange, "The provided game ID %s is not a valid number.".formatted(args[0])); + } + + Item game = Database.getInstance().getGame(id); + + Map gameMap = game.getPropertiesString(); + + try { + String accentHEX = ColorExtractor.getDominantColor(game); + data.put("accentColor", accentHEX); + } catch (Exception e) { + data.put("accentColor", "#FFFFFF"); + } + + data.put("game", gameMap); + data.put("versions", game.getVersions()); + + StringWriter writer = new StringWriter(); + template.process(data, writer); + + return writer.toString(); + } catch (Exception e) { + Logger.error(e.getMessage()); + return "

Error

"; + } + } +} diff --git a/src/main/java/org/adrianvictor/livingroom/web/pages/Index.java b/src/main/java/org/adrianvictor/livingroom/web/pages/Index.java new file mode 100644 index 0000000..8c5e9ea --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/web/pages/Index.java @@ -0,0 +1,52 @@ +package org.adrianvictor.livingroom.web.pages; + +import com.sun.net.httpserver.HttpExchange; +import freemarker.template.Configuration; +import freemarker.template.Template; +import org.adrianvictor.livingroom.Logger; +import org.adrianvictor.livingroom.data.Database; +import org.adrianvictor.livingroom.data.catalog.Item; +import org.adrianvictor.livingroom.data.catalog.Property; +import org.adrianvictor.livingroom.utils.image.ColorExtractor; +import org.adrianvictor.livingroom.web.Page; + +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class Index implements Page { + @Override + public String result(Configuration cfg, String baseAddress, String path, Map data, HttpExchange exchange) { + try { + Template template = cfg.getTemplate("games.ftl"); + + List> gamesList = new ArrayList<>(); + + for (Item item : Database.getInstance().getAllGames()) { + Map map = new HashMap<>(); + for (Map.Entry e : item.properties().entrySet()) { + map.put(e.getKey().name(), e.getValue()); + } + map.put("versions", item.getVersions()); + String accent = "#FFFFFF"; + try { + accent = ColorExtractor.getDominantColor(item); + } catch (Exception ignored) {} + map.put("accentColor", accent); + gamesList.add(map); + } + + data.put("games", gamesList); + + StringWriter writer = new StringWriter(); + template.process(data, writer); + + return writer.toString(); + } catch (Exception e) { + Logger.error(e.getMessage()); + return "

Error

"; + } + } +} diff --git a/src/main/java/org/adrianvictor/livingroom/web/pages/Login.java b/src/main/java/org/adrianvictor/livingroom/web/pages/Login.java new file mode 100644 index 0000000..19d0aed --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/web/pages/Login.java @@ -0,0 +1,29 @@ +package org.adrianvictor.livingroom.web.pages; + +import com.sun.net.httpserver.HttpExchange; +import freemarker.template.Configuration; +import org.adrianvictor.livingroom.Logger; +import org.adrianvictor.livingroom.web.Page; + +import java.io.StringWriter; +import java.util.Map; + +public class Login implements Page { + @Override + public String result(Configuration cfg, String baseAddress, String path, Map data, HttpExchange exchange) { + try { + // Check if this is a POST request + // For now, just show the login form + + freemarker.template.Template template = cfg.getTemplate("login.ftl"); + + StringWriter writer = new StringWriter(); + template.process(data, writer); + + return writer.toString(); + } catch (Exception e) { + Logger.error(e.getMessage()); + return "

Error

"; + } + } +} diff --git a/src/main/java/org/adrianvictor/livingroom/web/pages/Logout.java b/src/main/java/org/adrianvictor/livingroom/web/pages/Logout.java new file mode 100644 index 0000000..3a152d8 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/web/pages/Logout.java @@ -0,0 +1,21 @@ +package org.adrianvictor.livingroom.web.pages; + +import com.sun.net.httpserver.HttpExchange; +import freemarker.template.Configuration; +import org.adrianvictor.livingroom.auth.SessionManager; +import org.adrianvictor.livingroom.utils.net.Cookie; +import org.adrianvictor.livingroom.web.Page; + +import java.util.Map; + +public class Logout implements Page { + + @Override + public String result(Configuration cfg, String baseAddress, String path, Map data, HttpExchange exchange) { + String sessionId = Cookie.getSessionIdFromCookies(exchange); + SessionManager sm = SessionManager.getInstance(); + sm.destroySession(sessionId); + data.remove("username"); + return new Login().result(cfg, baseAddress, path, data, exchange); + } +} diff --git a/src/main/java/org/adrianvictor/livingroom/web/pages/NotFound.java b/src/main/java/org/adrianvictor/livingroom/web/pages/NotFound.java new file mode 100644 index 0000000..5c68216 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/web/pages/NotFound.java @@ -0,0 +1,14 @@ +package org.adrianvictor.livingroom.web.pages; + +import com.sun.net.httpserver.HttpExchange; +import freemarker.template.Configuration; +import org.adrianvictor.livingroom.web.Page; + +import java.util.Map; + +public class NotFound implements Page { + @Override + public String result(Configuration cfg, String baseAddress, String path, Map data, HttpExchange exchange) { + return "Not found"; + } +} diff --git a/src/main/java/org/adrianvictor/livingroom/web/pages/Remove.java b/src/main/java/org/adrianvictor/livingroom/web/pages/Remove.java new file mode 100644 index 0000000..d1b135f --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/web/pages/Remove.java @@ -0,0 +1,31 @@ +package org.adrianvictor.livingroom.web.pages; + +import com.sun.net.httpserver.HttpExchange; +import freemarker.template.Configuration; +import org.adrianvictor.livingroom.Main; +import org.adrianvictor.livingroom.data.Database; +import org.adrianvictor.livingroom.data.Indexer; +import org.adrianvictor.livingroom.web.Page; +import org.adrianvictor.livingroom.web.QuickResponses; + +import java.io.File; +import java.util.Map; + +public class Remove implements Page { + + @Override + public String result(Configuration cfg, String baseAddress, String path, Map data, HttpExchange exchange) { + String arg = path.split("/")[0]; + if (arg == null) { + return QuickResponses.notFound(); + } + int id; + try { + id = Integer.parseInt(arg); + } catch (NumberFormatException e) { + return QuickResponses.notFound(); + } + Database.getInstance().remove(String.valueOf(id)); + return "Success"; + } +} diff --git a/src/main/java/org/adrianvictor/livingroom/web/pages/Scan.java b/src/main/java/org/adrianvictor/livingroom/web/pages/Scan.java new file mode 100644 index 0000000..2a82023 --- /dev/null +++ b/src/main/java/org/adrianvictor/livingroom/web/pages/Scan.java @@ -0,0 +1,19 @@ +package org.adrianvictor.livingroom.web.pages; + +import com.sun.net.httpserver.HttpExchange; +import freemarker.template.Configuration; +import org.adrianvictor.livingroom.Main; +import org.adrianvictor.livingroom.data.Indexer; +import org.adrianvictor.livingroom.web.Page; + +import java.io.File; +import java.util.Map; + +public class Scan implements Page { + + @Override + public String result(Configuration cfg, String baseAddress, String path, Map data, HttpExchange exchange) { + Indexer.getInstance().scanAsync(new File(Main.getConfig().getLibrary())); + return ""; + } +} diff --git a/src/main/resources/config.json b/src/main/resources/config.json new file mode 100644 index 0000000..6b04b36 --- /dev/null +++ b/src/main/resources/config.json @@ -0,0 +1,9 @@ +{ + "library": "/home/adrian/Desktop/testing/livingroom", + "users": { + "admin": { + "role": "admin", + "password": "test" + } + } +} \ No newline at end of file diff --git a/src/main/resources/static/images/dvd.png b/src/main/resources/static/images/dvd.png new file mode 100644 index 0000000..8e34588 Binary files /dev/null and b/src/main/resources/static/images/dvd.png differ diff --git a/src/main/resources/static/styles.css b/src/main/resources/static/styles.css new file mode 100644 index 0000000..7283ed7 --- /dev/null +++ b/src/main/resources/static/styles.css @@ -0,0 +1,275 @@ +:root { + --accent-color-darker: rgba(255, 255, 255, 0.6); + --accent-color: white; + --default-box-shadow: 2px 7px 5px rgba(0,0,0,0.3), 0px -4px 10px rgba(0,0,0,0.3); + --alternative-bg: rgb(20, 20, 20); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background-color: black; + color: white; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + user-select: none; + display: flex; + flex-direction: column; + gap: 1em; +} + +header { + padding: 1em; + border-bottom: thick solid var(--accent-color); + background-color: var(--alternative-bg); + position: relative; +} + +header::before { + content: ""; + position: absolute; + inset: 0; + background: var(--accent-color); + filter: drop-shadow(0 0 50vh var(--accent-color)); + z-index: -1; +} + +header h1 { + margin-bottom: 0; +} + +main { + padding: 1em; +} + +h1, h2 { + margin-bottom: .4em; +} + +li { + margin-left: 1em; +} + +a { + transition: .2s; + color: gainsboro; + text-decoration: none; +} + +a:hover { + color: gray; +} + +p { + margin-bottom: .4em; +} + +input { + border: none; + border-bottom: medium solid var(--accent-color-darker); + background-color: transparent; + color: white; + transition: .4s; +} + +input:focus { + border-bottom-color: var(--accent-color); + outline: none; +} + +button, .fakeButton { + font-size: .9em; + border: medium solid var(--accent-color); + padding: .6rem; + background-color: black; + color: white; + transition: .2s; + box-shadow: var(--default-box-shadow); + height: 100%; + display: inline-block; +} + +.fakeButton { + /* border-color: var(--accent-color-darker); */ + border-style: dotted; +} + +button:hover, button:focus { + color: black; + background-color: var(--accent-color); +} + +#games { + display: flex; + flex-wrap: wrap; + gap: 1em; + padding: 1em; +} + +.gameCard { + width: 8em; + transition: .4s; +} + +.gameCard:hover { + opacity: .6; +} + +.gameCard .gameImage { + border: thin solid var(--accent-color); + width: 100%; + box-shadow: var(--default-box-shadow); +} + +.gameCard .gameTitle { + overflow: hidden; + text-overflow: ellipsis; +} + +.gameCard .versionsList { + list-style-type: none; +} + +/* Game page */ + +body#gamePage { + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +body#gamePage header { + flex-shrink: 0; +} + +body#gamePage .gameImage { + border: medium solid var(--accent-color); +} + +body#gamePage main { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; +} + +body#gamePage h2 { + flex-shrink: 0; + margin-bottom: 1em; +} + +body#gamePage .game { + display: flex; + flex: 1; + overflow: hidden; + gap: 1em; +} + +body#gamePage .gameImageContainer { + position: relative; + height: 100%; + width: auto; + display: flex; + align-items: center; + justify-content: center; +} + +body#gamePage .gameImage { + height: 100%; + width: auto; + object-fit: contain; + z-index: 1; + box-shadow: var(--default-box-shadow); +} + +body#gamePage .gameDisk { + position: absolute; + height: 60%; + width: auto; + object-fit: contain; + bottom: 0; + right: 0; + transition: .4s; + pointer-events: none; + filter: drop-shadow(2px 7px 5px rgba(0,0,0,0.3)); +} + +body#gamePage .gameInfo { + flex: 1; + overflow-y: auto; + padding: 0 1em; + display: flex; + flex-direction: column; + gap: 1em; + user-select: text; +} + +body#gamePage .gameDownloads { + position: absolute; + z-index: 2; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.9); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + overflow: auto; + text-wrap: wrap; + transition: opacity 0.3s, visibility 0.3s; +} + +body#gamePage .gameImageContainer.showDownloads:hover .gameDisk { + transform: translateX(60%); +} + +body#gamePage .gameImageContainer.showDownloads .gameDownloads { + opacity: 1; +} + +body#gamePage .gameInfo .gameTitle .gameAuthorText { + font-weight: lighter; +} + +body#gamePage .gameInfo .gameDescription { + padding: .4em; + background-color: rgb(40, 40, 40); + box-shadow: var(--default-box-shadow); + border: medium solid var(--accent-color); +} + +body#gamePage .gameInfo .gameDescription p { + margin-bottom: 0; +} + +body#gamePage .gameInfo .gameTitle h2 { + margin-bottom: 0; +} + +/* Login Page */ +.loginForm { + display: flex; + flex-direction: column; + gap: .6em; +} + +.loginForm input { + display: block; +} + +.loginForm .formGroup { + display: flex; + flex-direction: column; + gap: .4em; + width: fit-content; +} + +.loginForm .formSubmit { + margin-top: 1em; +} \ No newline at end of file diff --git a/src/main/resources/static/svg/disc.svg b/src/main/resources/static/svg/disc.svg new file mode 100644 index 0000000..9778284 --- /dev/null +++ b/src/main/resources/static/svg/disc.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/base.ftl b/src/main/resources/templates/base.ftl new file mode 100644 index 0000000..7bac010 --- /dev/null +++ b/src/main/resources/templates/base.ftl @@ -0,0 +1,14 @@ + + + + + + <#if pageTitle??>${pageTitle}<#else>LivingRoom</#if> + +id="${bodyID}"<#if accentColor??> style="--accent-color: ${accentColor};"> +<#include "header.ftl"> +
+ <#if pageContent??>${pageContent} +
+ + \ No newline at end of file diff --git a/src/main/resources/templates/game.ftl b/src/main/resources/templates/game.ftl new file mode 100644 index 0000000..0f8c7ad --- /dev/null +++ b/src/main/resources/templates/game.ftl @@ -0,0 +1,54 @@ +<#assign pageTitle = game["name"]> +<#assign bodyID = "gamePage"> +<#assign pageContent> +
+
+ + +
+ <#if versions?has_content> +
+

Available Downloads

+ +
+ +
+
+
+
+

${game["name"]}<#if game["author"]?has_content> by ${game["author"]}

+ <#if game["year"]?has_content> +

${game["year"]}

+ +
+ +
+ +
+ +
+ <#if game["publisher"]?has_content> +

Published by ${game["publisher"]}

+ + <#if game["date"]?has_content> +

Publishing date: ${game["date"]}

+ + <#if game["operating_system"]?has_content> +

Made for ${game["operating_system"]}

+ + <#if game["description"]?has_content> +

Description:

+
+

${game["description"]}

+
+ + <#if userRole == "admin">Admin actions: deindex, trigger new scan +
+
+
+ +<#include "base.ftl"> \ No newline at end of file diff --git a/src/main/resources/templates/games.ftl b/src/main/resources/templates/games.ftl new file mode 100644 index 0000000..5e3b252 --- /dev/null +++ b/src/main/resources/templates/games.ftl @@ -0,0 +1,15 @@ +<#assign pageTitle = "Games"> +<#assign pageContent> +

Games

+
+<#list games as game> + + +
+ +<#include "base.ftl"> \ No newline at end of file diff --git a/src/main/resources/templates/header.ftl b/src/main/resources/templates/header.ftl new file mode 100644 index 0000000..103038c --- /dev/null +++ b/src/main/resources/templates/header.ftl @@ -0,0 +1,4 @@ +
+

LivingRoom

+

<#if username??>${username} (logout)

+
\ No newline at end of file diff --git a/src/main/resources/templates/login.ftl b/src/main/resources/templates/login.ftl new file mode 100644 index 0000000..c8eb9bb --- /dev/null +++ b/src/main/resources/templates/login.ftl @@ -0,0 +1,20 @@ +<#assign pageTitle = "Login"> +<#assign pageContent> +
+

Login

+
+
+ + +
+
+ + +
+
+ +
+
+
+ +<#include "base.ftl"> \ No newline at end of file