From 542686ffd5ee77411ffd55ada88c006fc553f599 Mon Sep 17 00:00:00 2001 From: Tomuxs Date: Thu, 12 Jun 2025 04:30:52 +0200 Subject: [PATCH] Added skin management, added authorization, improved UI --- app/src/main/java/org/skinner/Database.java | 181 +++++++++++++++++- .../java/org/skinner/DatabaseException.java | 40 ++++ app/src/main/java/org/skinner/Profile.java | 74 ++++++- app/src/main/java/org/skinner/RestAPI.java | 160 +++++++++++++++- app/src/main/java/org/skinner/Skin.java | 4 + app/src/main/resources/sql/init.sql | 6 +- app/src/main/resources/steve.png | Bin 0 -> 1324 bytes app/src/main/resources/www/api.js | 19 ++ app/src/main/resources/www/index.html | 37 +++- 9 files changed, 499 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/org/skinner/DatabaseException.java create mode 100644 app/src/main/resources/steve.png diff --git a/app/src/main/java/org/skinner/Database.java b/app/src/main/java/org/skinner/Database.java index ff3e5a8..809509b 100644 --- a/app/src/main/java/org/skinner/Database.java +++ b/app/src/main/java/org/skinner/Database.java @@ -1,6 +1,7 @@ package org.skinner; import java.io.*; +import java.security.NoSuchAlgorithmException; import java.sql.*; import java.util.*; import java.util.List; @@ -122,7 +123,7 @@ public class Database { } public static void addSkin(Skin skin) throws SQLException { - PreparedStatement stmt = getConnection().prepareStatement( + PreparedStatement stmt = getConnection().prepareStatement( "INSERT INTO skin(_hash, label, slim, png, png_old) VALUES(?, ?, ?, ?, ?);" ); @@ -136,20 +137,192 @@ public class Database { stmt.close(); } + public static void updateSkinLabel(String hash, String label) throws SQLException { + PreparedStatement stmt = getConnection().prepareStatement("UPDATE skin SET label = ? WHERE _hash = ?;"); + + stmt.setString(1, label); + stmt.setString(2, hash); + + stmt.execute(); + stmt.close(); + } + + public static void deleteSkin(String hash) throws SQLException { + Skin skin = getSkin(hash); + Skin defaultSkin = ensureDefaultSkin(); + + PreparedStatement stmt = getConnection().prepareStatement("DELETE FROM skin WHERE _hash = ?;"); + + stmt.setString(1, hash); + + stmt.execute(); + stmt.close(); + + stmt = getConnection().prepareStatement("UPDATE profile SET skin = ? WHERE skin = ?;"); + + stmt.setInt(1, defaultSkin.getId()); + stmt.setInt(2, skin.getId()); + + stmt.execute(); + stmt.close(); + } + public static Profile[] getProfiles() throws SQLException { - assert(false); Statement stmt = getConnection().createStatement(); - ResultSet resultSet = stmt.executeQuery("SELECT profile.* FROM profile AS profile JOIN skin AS skin ON profile.skin = skin.id;"); + ResultSet resultSet = stmt.executeQuery("SELECT profile.*, skin.* FROM profile AS profile JOIN skin AS skin ON profile.skin = skin.id;"); List profiles = new ArrayList(); while (resultSet.next()) { - // TODO + Skin skin = new Skin( + resultSet.getInt("skin.id"), + resultSet.getString("skin._hash"), + resultSet.getString("skin.label"), + resultSet.getBoolean("skin.slim"), + resultSet.getBytes("skin.png"), + resultSet.getBytes("skin.png_old") + ); + Profile profile = new Profile( + resultSet.getInt("profile.id"), + resultSet.getString("profile.username"), + resultSet.getString("profile.password"), + resultSet.getString("profile.salt"), + resultSet.getString("profile.uuid"), + skin + ); + profiles.add(profile); } stmt.close(); return profiles.toArray(new Profile[0]); } + public static Profile getProfileByName(String username) throws SQLException { + PreparedStatement stmt = getConnection().prepareStatement("SELECT profile.*, skin.* FROM profile AS profile JOIN skin AS skin ON profile.skin = skin.id WHERE profile.username = ?;"); + stmt.setString(1, username); + + ResultSet resultSet = stmt.executeQuery(); + + if (!resultSet.next()) + return null; + + Skin skin = new Skin( + resultSet.getInt("skin.id"), + resultSet.getString("skin._hash"), + resultSet.getString("skin.label"), + resultSet.getBoolean("skin.slim"), + resultSet.getBytes("skin.png"), + resultSet.getBytes("skin.png_old") + ); + Profile profile = new Profile( + resultSet.getInt("profile.id"), + resultSet.getString("profile.username"), + resultSet.getString("profile.password"), + resultSet.getString("profile.salt"), + resultSet.getString("profile.uuid"), + skin + ); + + stmt.close(); + + return profile; + } + + public static Profile getProfileByUUID(String uuid) throws SQLException { + PreparedStatement stmt = getConnection().prepareStatement("SELECT profile.*, skin.* FROM profile AS profile JOIN skin AS skin ON profile.skin = skin.id WHERE profile.uuid = ?;"); + stmt.setString(1, uuid); + + ResultSet resultSet = stmt.executeQuery(); + + if (!resultSet.next()) + return null; + + Skin skin = new Skin( + resultSet.getInt("skin.id"), + resultSet.getString("skin._hash"), + resultSet.getString("skin.label"), + resultSet.getBoolean("skin.slim"), + resultSet.getBytes("skin.png"), + resultSet.getBytes("skin.png_old") + ); + Profile profile = new Profile( + resultSet.getInt("profile.id"), + resultSet.getString("profile.username"), + resultSet.getString("profile.password"), + resultSet.getString("profile.salt"), + resultSet.getString("profile.uuid"), + skin + ); + + stmt.close(); + + return profile; + } + + public static void addProfile(Profile profile) throws SQLException { + Skin skin = ensureDefaultSkin(); + + PreparedStatement stmt = getConnection().prepareStatement( + "INSERT INTO profile(username, password, salt, uuid, skin) VALUES(?, ?, ?, ?, ?);" + ); + + stmt.setString(1, profile.getUsername()); + stmt.setString(2, profile.getPassword()); + stmt.setString(3, profile.getSalt()); + stmt.setString(4, profile.getUUID()); + stmt.setInt(5, skin.getId()); + + stmt.execute(); + stmt.close(); + } + + public static void setProfileSkin(Profile profile, Skin skin) throws DatabaseException, SQLException { + if (profile.getId() == -1) + throw new DatabaseException("Profile provided does not have a database ID"); + if (skin.getId() == -1) + throw new DatabaseException("Skin provided does not have a database ID"); + + PreparedStatement stmt = getConnection().prepareStatement("UPDATE profile SET skin = ? WHERE id = ?;"); + stmt.setInt(1, skin.getId()); + stmt.setInt(2, profile.getId()); + + stmt.execute(); + stmt.close(); + } + + public static Skin getDefaultSkin() throws SQLException { + Statement stmt = getConnection().createStatement(); + ResultSet resultSet = stmt.executeQuery("SELECT * FROM skin ORDER BY id ASC LIMIT 1;"); + if (!resultSet.next()) + return null; + + return new Skin( + resultSet.getInt("skin.id"), + resultSet.getString("skin._hash"), + resultSet.getString("skin.label"), + resultSet.getBoolean("skin.slim"), + resultSet.getBytes("skin.png"), + resultSet.getBytes("skin.png_old") + ); + } + + private static Skin ensureDefaultSkin() throws SQLException { + // TODO: Optimise + Skin skin = getDefaultSkin(); + if (skin != null) + return skin; + try { + // It must succeed + addSkin(Skin.createDefaultSkin()); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + } catch (SkinException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + return getDefaultSkin(); + } + private boolean errored = false; private SQLException exception; private Connection connection; diff --git a/app/src/main/java/org/skinner/DatabaseException.java b/app/src/main/java/org/skinner/DatabaseException.java new file mode 100644 index 0000000..c9c4cde --- /dev/null +++ b/app/src/main/java/org/skinner/DatabaseException.java @@ -0,0 +1,40 @@ +package org.skinner; + +import java.sql.SQLException; + +public class DatabaseException extends SQLException { + + private static final long serialVersionUID = 1026873157210070017L; + + public DatabaseException() { + } + + public DatabaseException(String reason) { + super(reason); + } + + public DatabaseException(Throwable cause) { + super(cause); + } + + public DatabaseException(String reason, String SQLState) { + super(reason, SQLState); + } + + public DatabaseException(String reason, Throwable cause) { + super(reason, cause); + } + + public DatabaseException(String reason, String SQLState, int vendorCode) { + super(reason, SQLState, vendorCode); + } + + public DatabaseException(String reason, String sqlState, Throwable cause) { + super(reason, sqlState, cause); + } + + public DatabaseException(String reason, String sqlState, int vendorCode, Throwable cause) { + super(reason, sqlState, vendorCode, cause); + } + +} diff --git a/app/src/main/java/org/skinner/Profile.java b/app/src/main/java/org/skinner/Profile.java index 0a98412..0d32fc8 100644 --- a/app/src/main/java/org/skinner/Profile.java +++ b/app/src/main/java/org/skinner/Profile.java @@ -1,10 +1,22 @@ package org.skinner; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Random; +import java.util.UUID; + import org.skinner.json.*; public class Profile implements JSON { - public Profile(int id, String uuid, Skin skin) { + + public Profile(int id, String username, String password, String salt, String uuid, Skin skin) { this.id = id; + this.username = username; + this.password = password; + this.salt = salt; this.uuid = uuid; this.skin = skin; } @@ -13,7 +25,19 @@ public class Profile implements JSON { return id; } - public String getUuid() { + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public String getSalt() { + return salt; + } + + public String getUUID() { return uuid; } @@ -28,8 +52,54 @@ public class Profile implements JSON { object.put("skin", skin.toJSON()); return object; } + + static public String generateUUID() { + return UUID.randomUUID().toString().replace("-", ""); + } + + static public String generateSalt() { + byte[] salt_bytes = new byte[4]; + new Random().nextBytes(salt_bytes); + return toHexString(salt_bytes); + } + + static public String generateHash(String password, String salt) throws IOException, NoSuchAlgorithmException { + + byte[] password_bytes = password.getBytes(StandardCharsets.UTF_8); + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + bos.write(password_bytes); + bos.write(fromHexString(salt)); + + byte[] hash_bytes = MessageDigest.getInstance("SHA-256").digest(bos.toByteArray()); + return toHexString(hash_bytes); + } + + private static final char[] HEX_ARRAY = "0123456789abcdef".toCharArray(); + private static String toHexString(byte[] bytes) { + char[] hexChars = new char[bytes.length * 2]; + for (int j = 0; j < bytes.length; j++) { + int v = bytes[j] & 0xFF; + hexChars[j * 2] = HEX_ARRAY[v >>> 4]; + hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F]; + } + return new String(hexChars); + } + + private static byte[] fromHexString(String s) { + int len = s.length(); + byte[] data = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + + Character.digit(s.charAt(i+1), 16)); + } + return data; + } private int id; + private String username; + private String password; + private String salt; private String uuid; private Skin skin; } diff --git a/app/src/main/java/org/skinner/RestAPI.java b/app/src/main/java/org/skinner/RestAPI.java index b85a292..89e1e4b 100644 --- a/app/src/main/java/org/skinner/RestAPI.java +++ b/app/src/main/java/org/skinner/RestAPI.java @@ -2,6 +2,9 @@ package org.skinner; import java.io.*; import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; import org.skinner.json.*; @@ -24,6 +27,10 @@ public class RestAPI extends SafeHttpHandler { getSkins(exchange); return; + case "/profiles": + profiles(exchange); + return; + // Template-ish routes default: if (path.startsWith("/skin/")) { @@ -40,12 +47,42 @@ public class RestAPI extends SafeHttpHandler { case "/skin": addSkin(exchange); return; + + case "/profile": + register(exchange); + return; // Template-ish routes default: break; } break; + + case "PATCH": + switch (path) + { + case "/profile": + updateProfile(exchange, uri); + return; + + default: + if (path.startsWith("/skin/")) { + renameSkin(exchange, uri); + return; + } + break; + } + + case "DELETE": + switch (path) + { + default: + if (path.startsWith("/skin/")) { + deleteSkin(exchange, uri); + return; + } + break; + } } Headers headers = exchange.getResponseHeaders(); headers.add("Content-Type", "text/plain"); @@ -102,13 +139,8 @@ public class RestAPI extends SafeHttpHandler { Skin[] skins = Database.getSkins(); JSONArray array = new JSONArray(); - for (Skin skin : skins) { - JSONObject object = new JSONObject(); - object.put("hash", JSON.from(skin.getHash())); - object.put("png", JSON.from(getRoot() + "skin/"+skin.getHash()+".png")); - object.put("png_old", JSON.from(getRoot() + "skin/"+skin.getHash()+".png?legacy=true")); - array.add(object); - } + for (Skin skin : skins) + array.add(skinToResponseJSON(skin)); byte[] response = array.toString().getBytes(); exchange.getResponseHeaders().add("Content-Type", "application/json"); @@ -121,7 +153,7 @@ public class RestAPI extends SafeHttpHandler { MultipartForm form = new MultipartForm(exchange); if (!form.contains("skin")) { - fail(exchange, "Expected \"skin\" field not provided"); + text(exchange, 400, "Expected \"skin\" field not provided"); return; } @@ -131,4 +163,116 @@ public class RestAPI extends SafeHttpHandler { ok(exchange); } + protected void renameSkin(HttpExchange exchange, URI uri) throws Exception { + String[] pathParts = uri.getPath().split("/"); + String hash = pathParts[pathParts.length-1]; + + String label = new String(exchange.getRequestBody().readAllBytes()); + + Database.updateSkinLabel(hash, label); + ok(exchange); + } + + protected void deleteSkin(HttpExchange exchange, URI uri) throws Exception { + String[] pathParts = uri.getPath().split("/"); + String hash = pathParts[pathParts.length-1]; + + Database.deleteSkin(hash); + ok(exchange); + } + + protected void profiles(HttpExchange exchange) throws Exception { + Profile[] profiles = Database.getProfiles(); + + JSONArray array = new JSONArray(); + for (Profile profile : profiles) + array.add(profileToResponseJSON(profile)); + + byte[] response = array.toString().getBytes(); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + } + + protected void register(HttpExchange exchange) throws Exception { + MultipartForm form = new MultipartForm(exchange); + + if (!form.contains("username")) { + text(exchange, 400, "Expected \"username\" field not provided"); + return; + } + if (!form.contains("password")) { + text(exchange, 400, "Expected \"password\" field not provided"); + return; + } + + String username = form.getString("username"); + String password = form.getString("password"); + + String salt = Profile.generateSalt(); + String hash = Profile.generateHash(password, salt); + String uuid = Profile.generateUUID(); + + Database.addProfile(new Profile( + -1, + username, + hash, + salt, + uuid, + null + )); + ok(exchange); + } + + protected void updateProfile(HttpExchange exchange, URI uri) throws Exception { + Headers headers = exchange.getRequestHeaders(); + if (!headers.containsKey("Authorization")) { + String response = "Unauthorized"; + exchange.getResponseHeaders().add("WWW-Authenticate", "Basic realm=\"Skinner\""); + exchange.sendResponseHeaders(401, response.length()); + exchange.getResponseBody().write(response.getBytes()); + exchange.close(); + return; + } + String auth = headers.getFirst("Authorization"); + String[] authParts = auth.trim().split(" "); + + if (!authParts[0].equals("Basic")) { + text(exchange, 401, "Invalid authorization method"); + return; + } + + String[] cerd = new String(Base64.getDecoder().decode(authParts[1])).split(":"); + Profile profile = Database.getProfileByName(cerd[0]); + + if (profile == null || !Profile.generateHash(cerd[1], profile.getSalt()).equals(profile.getPassword())) { + text(exchange, 401, "Failed to authorize"); + return; + } + + Query query = new Query(uri.getQuery()); + + if (query.containsKey("skin")) + Database.setProfileSkin(profile, Database.getSkin(query.get("skin"))); + + ok(exchange); + } + + private JSONObject skinToResponseJSON(Skin skin) { + JSONObject object = new JSONObject(); + object.put("hash", JSON.from(skin.getHash())); + object.put("png", JSON.from(getRoot() + "skin/"+skin.getHash()+".png")); + object.put("png_old", JSON.from(getRoot() + "skin/"+skin.getHash()+".png?legacy=true")); + return object; + } + + private JSONObject profileToResponseJSON(Profile profile) { + JSONObject object = new JSONObject(); + object.put("username", JSON.from(profile.getUsername())); + object.put("uuid", JSON.from(profile.getUUID())); + object.put("skin", skinToResponseJSON(profile.getSkin())); + return object; + } + } diff --git a/app/src/main/java/org/skinner/Skin.java b/app/src/main/java/org/skinner/Skin.java index 8bcf3e0..5d82e10 100644 --- a/app/src/main/java/org/skinner/Skin.java +++ b/app/src/main/java/org/skinner/Skin.java @@ -153,6 +153,10 @@ public class Skin implements JSON { return new Skin(-1, hash, label, slim, png, png_old); } + public static Skin createDefaultSkin() throws SkinException, IOException, NoSuchAlgorithmException { + return loadFromImage(WebApp.class.getResourceAsStream("/steve.png"), "Steve"); + } + private static boolean matchAreaRGB(BufferedImage image, int rgb, int x, int y, int w, int h) { int endX = x + w; int endY = y + h; diff --git a/app/src/main/resources/sql/init.sql b/app/src/main/resources/sql/init.sql index 0216227..07e0d68 100644 --- a/app/src/main/resources/sql/init.sql +++ b/app/src/main/resources/sql/init.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS skin( id INTEGER PRIMARY KEY AUTO_INCREMENT, - _hash VARCHAR(64) UNIQUE NOT NULL, + _hash CHAR(64) UNIQUE NOT NULL, label TEXT, slim BOOLEAN NOT NULL, png BLOB NOT NULL, @@ -10,7 +10,9 @@ CREATE TABLE IF NOT EXISTS skin( CREATE TABLE IF NOT EXISTS profile( id INTEGER PRIMARY KEY AUTO_INCREMENT, username TEXT NOT NULL, - uuid VARCHAR(36) UNIQUE NOT NULL, + password CHAR(64) NOT NULL, + salt CHAR(8) NOT NULL, + uuid CHAR(32) UNIQUE NOT NULL, skin INTEGER NOT NULL, FOREIGN KEY (skin) REFERENCES skin(id) ); diff --git a/app/src/main/resources/steve.png b/app/src/main/resources/steve.png new file mode 100644 index 0000000000000000000000000000000000000000..665018569a0d17018420e240256cce089edf8686 GIT binary patch literal 1324 zcmV+{1=IS8P)2ks9~cvwiKb7RrrxOI8Tof=wEx@2l3%#}`kyIjzcHcv0idz& z3&}0~QjPCugPq@=lb%g0<>!5C;u3YV!zQ%2u}ltvDZZ(GW+H%0CL`7WWl{j(>T~5$ z5(-L2z@!CW#UcV=b~}6)006-k&wP*spflSvIko`BL-6wX)e2(a?5a2+5&-tRZ<8Vi z004zE3YZL;1DNqcRl)=m{nL+_l__|J-4J*{=9rrp5N%x@=1T64CQ~6 zHv@SYI=x#BKJdjDj71&a!Z-1rc8{1C0OCAQEt$D9$l%Nc1}}SI6yhZ|l;yOdFosUk(7FYcbl&0)XO+ z<>w8+MSxv>h5WHkIU*qGzd>0dz}o^J4Pa$9FR-S?U0QBy`(u>ZKEJ=MbwHvkSN^xI zE{X2lljz|?O_(?;nLN>^X8ln|8${8bK9vZ@_auM%`28HuGA$w~@IwFqmI~_bE*R>Z zco6~ss7Ox8`eO|I{YhH@{5HSOK9mG%fB!=PFcQWS0SX{AK~54AL_}DT)*tndcpC*F zz;=w^|H%O$>?sO$I{^Z)ZAtzBMR2z63$)SnsUpDU0qlEgDjuR}m^=kQ5bJEWz66E{ z2?229aVewSbeAvu|FDKO>*M#QrGO}u0Eo3K>;njGk+J|N1?atF&?Y?p1i*V**7mYEBYSNh3DJu4BLFaLj$pstB4Y-SB#`Y% z{WeG7GUeYR@UAyT0|0*Z;>E Skinner - +

Skinner

- +