This commit is contained in:
Minecon724 2024-12-07 11:00:27 +01:00
parent a1e69b84fa
commit dca5ad1d93
Signed by: Minecon724
GPG key ID: 3CCC4D267742C8E8
15 changed files with 275 additions and 360 deletions

View file

@ -1,54 +1,3 @@
# mstats - `/api/server/heartbeat` - server heartbeat
- `/api/plugin/{id}` - plugin info
This project uses Quarkus, the Supersonic Subatomic Java Framework. - `/api/plugin/find/{name}` - find a plugin id by name
If you want to learn more about Quarkus, please visit its website: <https://quarkus.io/>.
## Running the application in dev mode
You can run your application in dev mode that enables live coding using:
```shell script
./mvnw compile quarkus:dev
```
> **_NOTE:_** Quarkus now ships with a Dev UI, which is available in dev mode only at <http://localhost:8080/q/dev/>.
## Packaging and running the application
The application can be packaged using:
```shell script
./mvnw package
```
It produces the `quarkus-run.jar` file in the `target/quarkus-app/` directory.
Be aware that its not an _über-jar_ as the dependencies are copied into the `target/quarkus-app/lib/` directory.
The application is now runnable using `java -jar target/quarkus-app/quarkus-run.jar`.
If you want to build an _über-jar_, execute the following command:
```shell script
./mvnw package -Dquarkus.package.jar.type=uber-jar
```
The application, packaged as an _über-jar_, is now runnable using `java -jar target/*-runner.jar`.
## Creating a native executable
You can create a native executable using:
```shell script
./mvnw package -Dnative
```
Or, if you don't have GraalVM installed, you can run the native executable build in a container using:
```shell script
./mvnw package -Dnative -Dquarkus.native.container-build=true
```
You can then execute your native executable with: `./target/mstats-0.0.1-SNAPSHOT-runner`
If you want to learn more about building native executables, please consult <https://quarkus.io/guides/maven-tooling>.

View file

@ -1,7 +1,6 @@
package eu.m724.mstats; package eu.m724.mstats;
import eu.m724.mstats.auth.AuthService; import eu.m724.mstats.api.service.PluginService;
import eu.m724.mstats.orm.Administrator;
import io.quarkus.runtime.StartupEvent; import io.quarkus.runtime.StartupEvent;
import jakarta.enterprise.event.Observes; import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject; import jakarta.inject.Inject;
@ -9,11 +8,10 @@ import jakarta.transaction.Transactional;
public class Startup { public class Startup {
@Inject @Inject
AuthService authService; PluginService pluginService;
@Transactional @Transactional
public void onStartup(@Observes StartupEvent event) { public void onStartup(@Observes StartupEvent event) {
Administrator administrator = Administrator.createAdministrator(); pluginService.createPlugin("ploogin");
System.out.println(administrator.getTokenEncoded());
} }
} }

View file

@ -0,0 +1,95 @@
package eu.m724.mstats.api.resource;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import eu.m724.mstats.api.service.PluginService;
import eu.m724.mstats.orm.Plugin;
import eu.m724.mstats.orm.Server;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Path("/api/plugin")
@Produces(MediaType.APPLICATION_JSON)
public class PluginApiResource {
@Inject
PluginService pluginService;
@Path("/{id}")
@GET
public Response stats(Long id) {
Plugin plugin = pluginService.getPlugin(id);
if (plugin == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
Map<String, Integer> servers = new HashMap<>();
int serversTotal = 0;
Map<String, Integer> versions = new HashMap<>();
for (Server server : plugin.servers) {
String pluginVersion = server.plugins.stream().filter(p -> p.plugin.equals(plugin)).findFirst().get().version;
serversTotal++;
versions.put(pluginVersion, versions.getOrDefault(pluginVersion, 0) + 1);
servers.put(server.serverVersion, servers.getOrDefault(server.serverVersion, 0) + 1);
}
StatsResponse statsResponse = new StatsResponse();
statsResponse.id = id;
statsResponse.name = plugin.name;
statsResponse.servers = serversTotal;
statsResponse.serverVersions = servers.entrySet().stream().map(e -> new Version(e.getKey(), e.getValue())).toList();
statsResponse.pluginVersions = versions.entrySet().stream().map(e -> new Version(e.getKey(), e.getValue())).toList();
return Response.ok(statsResponse).build();
}
@Path("/find/{name}")
@GET
public Response find(String name) {
Plugin plugin = Plugin.find("name", name).firstResult();
if (plugin == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
return Response.ok(new ObjectMapper().createObjectNode().put("id", plugin.id)).build();
}
class StatsResponse {
@JsonProperty("id")
public Long id;
@JsonProperty("name")
public String name;
@JsonProperty("servers")
public int servers;
@JsonProperty("serverVersions")
public List<Version> serverVersions;
@JsonProperty("pluginVersions")
public List<Version> pluginVersions;
}
class Version {
public Version(String version, int servers) {
this.version = version;
this.servers = servers;
}
@JsonProperty("version")
public String version;
@JsonProperty("servers")
public int servers;
}
}

View file

@ -0,0 +1,100 @@
package eu.m724.mstats.api.resource;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSetter;
import com.fasterxml.jackson.annotation.Nulls;
import eu.m724.mstats.orm.Server;
import eu.m724.mstats.api.service.ServerService;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.ArrayList;
import java.util.List;
@Path("/api/server")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class ServerApiResource {
@Inject
ServerService serverService;
@Inject
SecurityIdentity identity;
@Path("/heartbeat")
@POST
public Response heartbeat(HeartbeatRequest heartbeatRequest) {
if (heartbeatRequest == null)
heartbeatRequest = new HeartbeatRequest();
HeartbeatResponse heartbeatResponse = new HeartbeatResponse();
Server server;
if (identity.isAnonymous()) {
server = serverService.createServer();
heartbeatResponse.token = server.getTokenEncoded();
} else {
server = identity.getAttribute("server");
}
if (heartbeatRequest.statsVersion != 1)
heartbeatResponse.version = 1;
serverService.heartbeat(server, heartbeatRequest);
return Response.ok(heartbeatResponse).build();
}
public static class HeartbeatRequest {
/** List of registered plugins, this is sent only on boot */
@JsonProperty("plugins")
public List<RunningPlugin> plugins = new ArrayList<>();
/** Server version like 1.21.1, this is sent only on boot */
@JsonProperty("serverVersion")
public String serverVersion;
/** Client version */
@JsonProperty("statsVersion")
public int statsVersion;
public HeartbeatRequest() {}
}
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class HeartbeatResponse {
/** New token assigned to the server, usually after first request */
@JsonProperty("token")
@JsonSetter(nulls = Nulls.SKIP)
public String token = null;
/** A message that will be displayed to server console */
@JsonProperty("message")
@JsonSetter(nulls = Nulls.SKIP)
public String message = null;
/** Server version, used only if it doesn't match the client version */
@JsonProperty("version")
@JsonSetter(nulls = Nulls.SKIP)
public Integer version = null;
public HeartbeatResponse() {}
}
public static class RunningPlugin {
public RunningPlugin() {}
@JsonProperty("id")
public Long id;
@JsonProperty("version")
public String version;
}
}

View file

@ -1,4 +1,4 @@
package eu.m724.mstats.resource.api; package eu.m724.mstats.api.service;
import eu.m724.mstats.orm.Plugin; import eu.m724.mstats.orm.Plugin;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
@ -7,8 +7,8 @@ import jakarta.transaction.Transactional;
@ApplicationScoped @ApplicationScoped
public class PluginService { public class PluginService {
@Transactional @Transactional
public Plugin createPlugin(String name, String description) { public Plugin createPlugin(String name) {
return Plugin.createPlugin(name, description); return Plugin.createPlugin(name);
} }
@Transactional @Transactional
@ -29,7 +29,7 @@ public class PluginService {
} }
@Transactional @Transactional
public Plugin editPlugin(long id, String name, String description) { public Plugin editPlugin(long id, String name) {
Plugin plugin = Plugin.findById(id); Plugin plugin = Plugin.findById(id);
if (plugin == null) if (plugin == null)
@ -38,9 +38,6 @@ public class PluginService {
if (name != null) if (name != null)
plugin.name = name; plugin.name = name;
if (description != null)
plugin.description = description;
plugin.persistAndFlush(); plugin.persistAndFlush();
return plugin; return plugin;
} }

View file

@ -0,0 +1,36 @@
package eu.m724.mstats.api.service;
import eu.m724.mstats.api.resource.ServerApiResource;
import eu.m724.mstats.orm.Server;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import java.time.Instant;
@ApplicationScoped
public class ServerService {
@Transactional
public Server createServer() {
return Server.createServer();
}
@Transactional
public void modifyServer(Server server) {
server.persistAndFlush();
}
@Transactional
public void heartbeat(Server server, ServerApiResource.HeartbeatRequest heartbeatRequest) {
server = Server.findById(server.id); // this is necessary for some reason
for (ServerApiResource.RunningPlugin plugin : heartbeatRequest.plugins) {
server.addPlugin(plugin.id, plugin.version);
}
if (heartbeatRequest.serverVersion != null)
server.serverVersion = heartbeatRequest.serverVersion;
server.heartbeat = Instant.now();
server.persistAndFlush();
}
}

View file

@ -1,6 +1,5 @@
package eu.m724.mstats.auth; package eu.m724.mstats.auth;
import eu.m724.mstats.orm.Administrator;
import eu.m724.mstats.orm.Server; import eu.m724.mstats.orm.Server;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
@ -9,17 +8,9 @@ import java.util.Base64;
@ApplicationScoped @ApplicationScoped
public class AuthService { public class AuthService {
public final Base64.Decoder decoder = Base64.getDecoder();
@Transactional
Administrator getAdministratorByToken(String encoded) {
byte[] token = decoder.decode(encoded);
return Administrator.find("token", (Object) token).firstResult();
}
@Transactional @Transactional
Server getServerByToken(String encoded) { Server getServerByToken(String encoded) {
byte[] token = decoder.decode(encoded); byte[] token = Base64.getDecoder().decode(encoded);
return Server.find("token", (Object) token).firstResult(); return Server.find("token", (Object) token).firstResult();
} }
} }

View file

@ -1,7 +1,5 @@
package eu.m724.mstats.auth; package eu.m724.mstats.auth;
import eu.m724.mstats.orm.Administrator;
import eu.m724.mstats.orm.Server; import eu.m724.mstats.orm.Server;
import io.quarkus.security.identity.IdentityProviderManager; import io.quarkus.security.identity.IdentityProviderManager;
import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.identity.SecurityIdentity;
@ -16,7 +14,6 @@ import jakarta.annotation.Priority;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Alternative; import jakarta.enterprise.inject.Alternative;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.ws.rs.core.Response;
import java.util.function.Supplier; import java.util.function.Supplier;
@ -40,29 +37,6 @@ public class MyHttpAuthenticationMechanism implements HttpAuthenticationMechanis
.addRole("server") .addRole("server")
.addAttribute("server", server) .addAttribute("server", server)
.build(); .build();
} else {
context.response()
.setStatusCode(Response.Status.UNAUTHORIZED.getStatusCode())
.setStatusMessage("{\"message\": \"X-Server-Token is invalid\"}");
return null;
}
} else {
String adminTokenEncoded = context.request().getHeader("X-Administrator-Token");
if (adminTokenEncoded != null) {
Administrator administrator = authService.getAdministratorByToken(adminTokenEncoded);
if (administrator != null) {
return QuarkusSecurityIdentity.builder()
.setPrincipal(new QuarkusPrincipal("Administrator " + administrator.id.toString()))
.addRole("administrator")
.addRoles(administrator.roles)
.addAttribute("administrator", administrator)
.build();
} else {
context.response()
.setStatusCode(Response.Status.UNAUTHORIZED.getStatusCode())
.setStatusMessage("{\"message\": \"X-Administrator-Token is invalid\"}");
return null;
}
} }
} }

View file

@ -1,39 +0,0 @@
package eu.m724.mstats.orm;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.*;
import jakarta.transaction.Transactional;
import java.util.Base64;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
@Entity
@Table(
uniqueConstraints = @UniqueConstraint(columnNames = "token"),
indexes = @Index(columnList = "token")
)
public class Administrator extends PanacheEntity {
@Column(nullable = false)
public byte[] token;
public Set<String> roles;
public String getTokenEncoded() {
return Base64.getEncoder().encodeToString(token);
}
@Transactional
public static Administrator createAdministrator(String... roles) {
byte[] token = new byte[32];
ThreadLocalRandom.current().nextBytes(token);
Administrator administrator = new Administrator();
administrator.token = token;
administrator.roles = Set.of(roles);
administrator.persistAndFlush();
return administrator;
}
}

View file

@ -1,37 +1,30 @@
package eu.m724.mstats.orm; package eu.m724.mstats.orm;
import io.quarkus.hibernate.orm.panache.PanacheEntity; import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column; import jakarta.persistence.*;
import jakarta.persistence.Entity;
import jakarta.persistence.ManyToMany;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@Entity @Entity
@Table(
indexes = @Index(columnList = "name")
)
public class Plugin extends PanacheEntity { public class Plugin extends PanacheEntity {
@Column(unique = true, nullable = false) @Column(unique = true, nullable = false)
public String name; public String name;
public String description; @ManyToMany(fetch = FetchType.EAGER)
@ManyToMany
public List<Server> servers = new ArrayList<>(); public List<Server> servers = new ArrayList<>();
@Transactional @Transactional
public static Plugin createPlugin(String name, String description) { public static Plugin createPlugin(String name) {
Plugin plugin = new Plugin(); Plugin plugin = new Plugin();
plugin.name = name; plugin.name = name;
plugin.description = description;
plugin.persistAndFlush(); plugin.persistAndFlush();
return plugin; return plugin;
} }
@Transactional
public static Plugin createPlugin(String name) {
return createPlugin(name, null);
}
} }

View file

@ -0,0 +1,19 @@
package eu.m724.mstats.orm;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.*;
@Entity
public class PluginWithVersion extends PanacheEntity {
public PluginWithVersion() {}
public PluginWithVersion(Plugin plugin, String version) {
this.plugin = plugin;
this.version = version;
}
@ManyToOne
public Plugin plugin;
public String version;
}

View file

@ -19,13 +19,10 @@ public class Server extends PanacheEntity {
@Column(nullable = false) @Column(nullable = false)
public byte[] token; public byte[] token;
/** @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
* ISO 3166-1 numeric public List<PluginWithVersion> plugins = new ArrayList<>();
*/
public int countryCode;
@ManyToMany(cascade = CascadeType.ALL) public String serverVersion;
public List<Plugin> plugins = new ArrayList<>();
public Instant heartbeat = Instant.now(); public Instant heartbeat = Instant.now();
@ -34,13 +31,16 @@ public class Server extends PanacheEntity {
} }
@Transactional @Transactional
public Plugin addPlugin(long id) { public Plugin addPlugin(long id, String version) {
Plugin plugin = Plugin.findById(id); Plugin plugin = Plugin.findById(id);
if (plugin == null) if (plugin == null)
return null; return null;
plugins.add(plugin); if (plugins.stream().anyMatch(pwv -> pwv.plugin.id == id))
return null;
plugins.add(new PluginWithVersion(plugin, version));
plugin.servers.add(this); plugin.servers.add(this);
this.persistAndFlush(); this.persistAndFlush();

View file

@ -1,90 +0,0 @@
package eu.m724.mstats.resource.api;
import eu.m724.mstats.auth.AuthService;
import eu.m724.mstats.orm.Plugin;
import io.quarkus.security.identity.SecurityIdentity;
import io.vertx.core.json.JsonObject;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/api/admin")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@RolesAllowed("administrator")
public class AdminApiResource {
@Inject
AuthService authService;
@Inject
PluginService pluginService;
@Inject
SecurityIdentity identity;
@Path("/plugin")
@GET
public Response getPlugin(@QueryParam("id") long id) {
Plugin plugin = pluginService.getPlugin(id);
if (plugin == null)
return Response.ok(new JsonObject().put("id", id)).status(Response.Status.NOT_FOUND).build();
JsonObject response = new JsonObject()
.put("id", plugin.id)
.put("name", plugin.name)
.put("description", plugin.description);
return Response.ok(response).build();
}
@Path("/plugin")
@PUT
public Response createPlugin(JsonObject data) {
String name = data.getString("name");
String description = data.getString("description");
Plugin plugin = pluginService.createPlugin(name, description);
JsonObject response = new JsonObject()
.put("id", plugin.id);
return Response.ok(response).build();
}
@Path("/plugin")
@PATCH
public Response editPlugin(JsonObject data) {
long id = data.getLong("id");
String name = data.getString("name");
String description = data.getString("description");
Plugin plugin = pluginService.editPlugin(id, name, description);
if (plugin == null)
return Response.ok(new JsonObject().put("id", id)).status(Response.Status.NOT_FOUND).build();
JsonObject response = new JsonObject()
.put("id", plugin.id);
return Response.ok(response).build();
}
@Path("/plugin")
@DELETE
public Response deletePlugin(JsonObject data) {
long id = data.getLong("id");
Plugin plugin = pluginService.deletePlugin(id);
if (plugin == null)
return Response.ok(new JsonObject().put("id", id)).status(Response.Status.NOT_FOUND).build();
JsonObject response = new JsonObject()
.put("id", plugin.id);
return Response.ok(response).build();
}
}

View file

@ -1,82 +0,0 @@
package eu.m724.mstats.resource.api;
import com.fasterxml.jackson.annotation.JsonProperty;
import eu.m724.mstats.orm.Plugin;
import eu.m724.mstats.orm.Server;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.ArrayList;
import java.util.List;
@Path("/api/server")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class ServerApiResource {
@Inject
ServerService serverService;
@Inject
SecurityIdentity identity;
@Path("/boot")
@POST
public Response boot(BootRequest bootRequest) {
BootResponse bootResponse = new BootResponse();
Server server;
if (identity.isAnonymous()) {
server = serverService.createServer();
bootResponse.token = server.getTokenEncoded();
} else {
server = identity.getAttribute("server");
}
List<Long> invalidIds = new ArrayList<>();
for (Long pluginId : bootRequest.plugins) {
Plugin plugin = server.addPlugin(pluginId);
if (plugin == null)
invalidIds.add(pluginId);
}
if (!invalidIds.isEmpty()) {
bootResponse.invalidPlugins = invalidIds;
}
serverService.
return Response.ok(bootResponse).build();
}
@Path("/heartbeat")
@POST
@RolesAllowed("server")
public void heartbeat() {
}
public static class BootRequest {
@JsonProperty("plugins")
public List<Long> plugins;
public BootRequest() {}
}
public static class BootResponse {
@JsonProperty("token")
public String token;
@JsonProperty("invalidPlugins")
public List<Long> invalidPlugins;
public BootResponse() {}
}
}

View file

@ -1,26 +0,0 @@
package eu.m724.mstats.resource.api;
import eu.m724.mstats.orm.Server;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import java.util.List;
@ApplicationScoped
public class ServerService {
@Transactional
public Server createServer() {
return Server.createServer();
}
@Transactional
public void modifyServer(Server server) {
server.persistAndFlush();
}
@Transactional
public void heartbeat(Server server, List<Long> plugins) {
server.heartbeat =
server.persistAndFlush();
}
}