Added skin management, added authorization, improved UI

This commit is contained in:
2025-06-12 04:30:52 +02:00
parent ed0a16068a
commit 542686ffd5
9 changed files with 499 additions and 22 deletions

View File

@ -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<Profile> profiles = new ArrayList<Profile>();
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;

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -13,4 +13,23 @@ api = {
data.append("label", label);
await fetch("api/skin", {"method": "POST", body: data});
},
renameSkin: async function(skin_hash, label) {
await fetch(`api/skin/${skin_hash}`, {method: "PATCH", body: label});
},
deleteSkin: async function(skin_hash) {
await fetch(`api/skin/${skin_hash}`, {method: "DELETE"});
},
changeSkin: async function(username, password, skin_hash) {
let res = await fetch(
`api/profile?skin=${encodeURI(skin_hash)}`,
{
method: "PATCH",
headers: {
"Authorization": "Basic " + btoa(`${username}:${password}`),
},
}
)
if (res.status != 200)
throw new Error("Invalid credentials");
}
};

View File

@ -34,7 +34,7 @@
left: 0;
margin: 0;
background-color: var(--bg);
transform: translate(50%, 50%);
transform: translate(50%, 25%);
min-width: 50vw;
aspect-ratio: 1.5/1;
}
@ -69,19 +69,24 @@
margin-left: 1.666%;
margin-right: 1.666%;
}
.skin-img
{
max-width: 100%;
max-height: 100%;
}
</style>
<title>Skinner</title>
</head>
<body x-data="{add_dialog: false, blur: false, skins: api.getSkins()}">
<body x-data="{skin_dialog: false, add_dialog: false, skins: api.getSkins(), blur: false}" x-effect="blur = skin_dialog || add_dialog">
<main x-data="{search: ''}" x-bind:style="blur ? 'filter: blur(10px);' : ''">
<h1>Skinner</h1>
<div class="flex-container">
<input class="flex" type="search" placeholder="Search skins..." x-model="search">
<button @click="add_dialog = true; blur = true;">Add new</button>
<button @click="add_dialog = true;">Add new</button>
</div>
<div id="skin-cards">
<template x-for="skin in skins">
<article class="skin-card" x-show="(skin.label || '').toLowerCase().includes(search.toLowerCase())">
<article class="skin-card" x-show="(skin.label || '').toLowerCase().includes(search.toLowerCase())" @click="skin_dialog = skin">
<img x-bind:src="await renderSkin(skin.png)">
<big x-text="skin.label || 'Unnamed'"></big>
</article>
@ -90,13 +95,33 @@
</main>
<div class="screenblock" x-show="blur"><!-- Block to prevent mouse clicks --></div>
<dialog x-bind:open="add_dialog">
<form method="POST" action="api/skin" enctype="multipart/form-data" x-data="{files: null, label: '', processing: false}" @submit.prevent="processing = true; api.addSkin(files, label).then(async ()=>{skins = await api.getSkins(); blur = false; add_dialog = false; processing = false;})">
<form method="POST" action="api/skin" enctype="multipart/form-data" x-data="{files: null, label: '', processing: false}" @submit.prevent="processing = true; api.addSkin(files, label).then(async ()=>{skins = await api.getSkins(); add_dialog = false; processing = false;})">
<h1>Add Skin</h1><br>
<input type="text" placeholder="Skin name" x-model="label">
<input type="file" accept="image/png" @change="files = $el.files[0]" required><br><br><br>
<input type="submit" value="Add new skin" x-bind:disabled="processing">
<button @click="blur = false; add_dialog = false;" x-bind:disabled="processing">Cancel</button>
<button @click="add_dialog = false" x-bind:disabled="processing">Cancel</button>
</form>
</dialog>
<dialog x-bind:open="skin_dialog" x-data="{username: '', password: '', label: '', processing: false}" x-effect="label = skin_dialog ? skin_dialog.label : ''">
<div style="width: 100%; height: 100%; text-align: center;">
<img class="skin-img" x-bind:src="skin_dialog ? await renderSkin(skin_dialog.png) : ''">
</div>
<div style="display: grid;" x-data="{delete_confirm: false}" x-effect="delete_confirm = skin_dialog && delete_confirm">
<input style="grid-area: 1/1/2/5;" x-model="label" placeholder="Unnamed" x-bind:disabled="processing">
<button style="grid-area: 1/5;" x-bind:disabled="processing" @click="processing = true; await api.renameSkin(skin_dialog.hash, label).then(async ()=>{skins = await api.getSkins();}); processing = false;">Rename</button>
<input style="grid-area: 2/1/3/3;" x-model="username" placeholder="Username" type="text" x-bind:disabled="processing">
<input style="grid-area: 2/3/3/5;" x-model="password" placeholder="Password" type="password" x-bind:disabled="processing">
<button style="grid-area: 2/5;" x-bind:disabled="processing" @click="processing = true; await api.changeSkin(username, password, skin_dialog.hash).then(async ()=>{skins = await api.getSkins();}).catch(()=>{alert('Invalid credentials');}); processing = false;">Apply</button>
<button style="grid-area: 3/1;" x-bind:disabled="!delete_confirm || processing" @click="processing = true; await api.deleteSkin(skin_dialog.hash).then(async ()=>{skins = await api.getSkins();}); processing = false; skin_dialog = false; delete_confirm = false;">Delete</button>
<div style="grid-area: 3/2; align-content: center;">
<label>
<input type="checkbox" x-bind:checked="delete_confirm" @click="delete_confirm = $el.checked" x-bind:disabled="processing">
Confirm
</label>
</div>
<button style="grid-area: 3/5;" x-bind:disabled="processing" @click="skin_dialog = false">Close</button>
</div>
</dialog>
</body>
</html>