This commit is contained in:
天クマ 2026-02-27 13:55:49 -03:00
commit bbf28141e2
61 changed files with 2576 additions and 0 deletions

View file

@ -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);
}
}

View file

@ -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<Handler, String> e : Handlers.getAll().entrySet()) {
server.registerHandler(e.getKey(), e.getValue());
}
server.start();
}
public static AppConfig getConfig() {
return config;
}
}

View file

@ -0,0 +1,6 @@
package org.adrianvictor.livingroom.auth;
public enum Role {
USER,
ADMIN
}

View file

@ -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;
}
}

View file

@ -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<String, Session> 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);
}
}

View file

@ -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);
}
}

View file

@ -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<String, UserConfig> 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<String, UserConfig> getUsers() { return users; }
public void setUsers(Map<String, UserConfig> users) {
this.users = (users != null) ? users : new HashMap<>();
}
public void hashPlaintextPasswords() {
if (users == null) return; // safety check
for (Map.Entry<String, UserConfig> 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<String, UserConfig> 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; }
}
}

View file

@ -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<String, AppConfig.UserConfig> 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<String, AppConfig.UserConfig> 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<String, AppConfig.UserConfig> 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;
}
}

View file

@ -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<Item> processed = processResultSet(rs);
if (processed.isEmpty()) {
return null;
}
return processed.getFirst();
}
} catch (SQLException e) {
Logger.error(e.getMessage());
return null;
}
}
public List<Item> getAllGames() {
String sql = "SELECT * FROM games";
JSONParser parser = new JSONParser();
List<Item> 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<Item> processResultSet(ResultSet rs) throws SQLException {
List<Item> 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<Property, String> 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;
}
}

View file

@ -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<Property, String> 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<Item> 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();
}
}

View file

@ -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<Property, String> properties) {
public HashMap<String, String> getPropertiesString() {
HashMap<String, String> result = new HashMap<>();
properties.forEach((key, value) -> {
result.put(key.name().toLowerCase(), value);
});
return result;
}
public JSONObject getPropertiesJSON() {
return new JSONObject(getPropertiesString());
}
public List<String> getVersions() {
List<String> 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;
}
}

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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<Handler, String> 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<Handler, String> getAll() {
return map;
}
}

View file

@ -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<String, String> headers
) {
public static HttpResponse ok(byte[] body, String contentType, Map<String, String> 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)
);
}
}

View file

@ -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<String, String> 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;
}
}

View file

@ -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<Item> 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");
}
}

View file

@ -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<String> 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";
}
}

View file

@ -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";
}
}

View file

@ -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";
}
}

View file

@ -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<String, Object> 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<String, Object> 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");
}
}

View file

@ -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<String, AppConfig.UserConfig> 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
);
}
}

View file

@ -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);
}
}

View file

@ -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<Integer, Double> 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);
}
}

View file

@ -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);
}
}

View file

@ -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<String, Object> data, HttpExchange exchange);
}

View file

@ -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<String, Page> 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<String, Page> getAll() {
return map;
}
public static Page get(String path) { return map.get(path); }
}

View file

@ -0,0 +1,7 @@
package org.adrianvictor.livingroom.web;
public class QuickResponses {
public static String notFound() {
return "Not Found";
}
}

View file

@ -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<String, Object> 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<String, String> 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 "<h1>Error</h1>";
}
}
}

View file

@ -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<String, Object> data, HttpExchange exchange) {
try {
Template template = cfg.getTemplate("games.ftl");
List<Map<String, Object>> gamesList = new ArrayList<>();
for (Item item : Database.getInstance().getAllGames()) {
Map<String, Object> map = new HashMap<>();
for (Map.Entry<Property, String> 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 "<h1>Error</h1>";
}
}
}

View file

@ -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<String, Object> 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 "<h1>Error</h1>";
}
}
}

View file

@ -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<String, Object> 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);
}
}

View file

@ -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<String, Object> data, HttpExchange exchange) {
return "Not found";
}
}

View file

@ -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<String, Object> 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";
}
}

View file

@ -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<String, Object> data, HttpExchange exchange) {
Indexer.getInstance().scanAsync(new File(Main.getConfig().getLibrary()));
return "";
}
}

View file

@ -0,0 +1,9 @@
{
"library": "/home/adrian/Desktop/testing/livingroom",
"users": {
"admin": {
"role": "admin",
"password": "test"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 KiB

View file

@ -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;
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path fill="#444" d="M8 0c-4.4 0-8 3.6-8 8s3.6 8 8 8 8-3.6 8-8-3.6-8-8-8zM15 8c0 1.1-0.2 2.1-0.7 3l-2.7-1.2c0.2-0.6 0.4-1.2 0.4-1.8 0-2.2-1.8-4-4-4-0.5 0-0.9 0.1-1.4 0.3l-1.2-2.8c0.6-0.2 1.2-0.4 1.8-0.5l0.3 3h0.5v-3c3.9 0 7 3.1 7 7zM8 5c1.7 0 3 1.3 3 3s-1.3 3-3 3-3-1.3-3-3 1.3-3 3-3zM1 8c0-1.1 0.2-2.1 0.7-3l2.7 1.2c-0.2 0.6-0.4 1.2-0.4 1.8 0 2.2 1.8 4 4 4 0.5 0 0.9-0.1 1.4-0.3l1.2 2.8c-0.6 0.2-1.2 0.4-1.8 0.5l-0.3-3h-0.5v3c-3.9 0-7-3.1-7-7z"></path>
<path fill="#444" d="M10 8c0 1.105-0.895 2-2 2s-2-0.895-2-2c0-1.105 0.895-2 2-2s2 0.895 2 2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 929 B

View file

@ -0,0 +1,14 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/static/styles.css">
<title><#if pageTitle??>${pageTitle}<#else>LivingRoom</#if></title>
</head>
<body <#if bodyID??>id="${bodyID}"</#if><#if accentColor??> style="--accent-color: ${accentColor};"</#if>>
<#include "header.ftl">
<main>
<#if pageContent??>${pageContent}</#if>
</main>
</body>
</html>

View file

@ -0,0 +1,54 @@
<#assign pageTitle = game["name"]>
<#assign bodyID = "gamePage">
<#assign pageContent>
<div class="game">
<div class="gameImageContainer">
<img class="gameImage" src="/pic/${game["id"]}">
<img class="gameDisk" src="/static/images/dvd.png">
<div class="gameDownloads">
<#if versions?has_content>
<div class="downloads">
<h3>Available Downloads</h3>
<ul>
<#list versions as version>
<li><a href="/download/${game["id"]}/${version}">${version}</a></li>
</#list>
</ul>
</div>
</#if>
</div>
</div>
<div class="gameInfo">
<div class="gameTitle">
<h2>${game["name"]}<#if game["author"]?has_content><span class="gameAuthorText"> by ${game["author"]}</span></#if></h2>
<#if game["year"]?has_content>
<p>${game["year"]}</p>
</#if>
</div>
<div class="downloadsButton">
<button onclick="document.querySelector('.gameImageContainer').classList.toggle('showDownloads');">Downloads</button>
</div>
<div class="gameProperties">
<#if game["publisher"]?has_content>
<p><b>Published by</b> ${game["publisher"]}</p>
</#if>
<#if game["date"]?has_content>
<p><b>Publishing date:</b> ${game["date"]}</p>
</#if>
<#if game["operating_system"]?has_content>
<p><b>Made for</b> ${game["operating_system"]}</p>
</#if>
<#if game["description"]?has_content>
<p><b>Description: </b></p>
<div class="gameDescription">
<p>${game["description"]}</p>
</div>
</#if>
<#if userRole == "admin">Admin actions: <a href="/${webpref}/remove/${game["id"]}">deindex</a>, <a href="/${webpref}/scan">trigger new scan</a></#if>
</div>
</div>
</div>
</#assign>
<#include "base.ftl">

View file

@ -0,0 +1,15 @@
<#assign pageTitle = "Games">
<#assign pageContent>
<h2>Games</h2>
<div id="games">
<#list games as game>
<div class="gameCard" style="--accent-color: ${game["accentColor"]};">
<a href="/${webpref}/game/${game["ID"]}">
<img class="gameImage" src="/pic/${game["ID"]}">
<p>${game["NAME"]}</p>
</a>
</div>
</#list>
</div>
</#assign>
<#include "base.ftl">

View file

@ -0,0 +1,4 @@
<header>
<h1><a href="/${webpref}/">LivingRoom</a></h1>
<p><#if username??>${username} (<a href="/${webpref}/logout">logout</a>)</#if></p>
</header>

View file

@ -0,0 +1,20 @@
<#assign pageTitle = "Login">
<#assign pageContent>
<div class="loginContainer">
<h2>Login</h2>
<form class="loginForm" method="POST" action="/${webpref}/login">
<div class="formGroup username">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required>
</div>
<div class="formGroup password">
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
</div>
<div>
<button type="submit" class="formSubmit">Login</button>
</div>
</form>
</div>
</#assign>
<#include "base.ftl">