init
This commit is contained in:
+30
@@ -0,0 +1,30 @@
|
||||
# Build output
|
||||
target/
|
||||
out/
|
||||
build/
|
||||
*.class
|
||||
*.jar
|
||||
dependency-reduced-pom.xml
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
*.iws
|
||||
*.ipr
|
||||
.vscode/
|
||||
.project
|
||||
.classpath
|
||||
.settings/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local config / secrets
|
||||
config.json.local
|
||||
*.env
|
||||
.env
|
||||
@@ -0,0 +1,37 @@
|
||||
# Ranks
|
||||
|
||||
Ranks, groups, grants, and chat prefixes for KweebecNet. Replaces Hytale's default file-backed permission provider with a MySQL-backed one.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Ranks (= Hytale groups)**: create, delete, edit, set parents (inheritance), add/remove permission nodes.
|
||||
- **Grants**: give a rank to a player permanently or for a fixed duration. Multiple ranks per player allowed; one is "primary" for display.
|
||||
- **Permission provider**: implements `com.hypixel.hytale.server.core.permissions.provider.PermissionProvider`. After we register, **every** `PermissionsModule.get().hasPermission(...)` call (yours, Staff's, Hytale's own /op) routes through our MySQL.
|
||||
- **Chat prefixes**: rewrites `PlayerChatEvent`'s formatter to inject the player's primary rank prefix.
|
||||
- **Cross-server propagation**: every write invalidates per-user / per-rank caches network-wide via NetworkCore's MessageBus, so a `/grant` on one server takes effect everywhere within milliseconds.
|
||||
|
||||
## Requires
|
||||
|
||||
- **NetworkCore** with `mysql.enabled: true` and a healthy MySQL connection.
|
||||
- Working `network.auth_token` (so invalidations propagate cleanly).
|
||||
- Hytale permission nodes assigned for management commands - see [docs/03_Commands.md](docs/03_Commands.md#permissions).
|
||||
|
||||
## Why this is critical
|
||||
|
||||
This plugin owns *all* permissions on the network. If it boots in a broken state:
|
||||
|
||||
- Players may have wrong perms (too many → security issue; too few → game broken).
|
||||
- Hytale's own commands (`/op`, `/perm group`, etc.) silently route through us; if our writes drop, those actions vanish.
|
||||
- The chat prefix listener fires on every chat message; a bug there spams logs.
|
||||
|
||||
Treat changes to [RanksPermissionProvider](src/main/java/net/kewwbec/ranks/provider/RanksPermissionProvider.java) and [SchemaBootstrap](src/main/java/net/kewwbec/ranks/db/SchemaBootstrap.java) like changes to NetworkCore: read the docs first, ask a higher-up before merging.
|
||||
|
||||
## Read before changing
|
||||
|
||||
- [docs/01_Overview.md](docs/01_Overview.md) - architecture, how the pieces fit
|
||||
- [docs/02_Configuration.md](docs/02_Configuration.md) - config.json reference
|
||||
- [docs/03_Commands.md](docs/03_Commands.md) - every command + permission nodes
|
||||
- [docs/04_Schema.md](docs/04_Schema.md) - MySQL tables, built-in group seeding
|
||||
- [docs/05_Provider_Integration.md](docs/05_Provider_Integration.md) - what "replacing Hytale's provider" actually means at runtime
|
||||
- [docs/06_Cross_Server_Caching.md](docs/06_Cross_Server_Caching.md) - cache + invalidation flow
|
||||
- [docs/07_Operations.md](docs/07_Operations.md) - ops scenarios, debugging
|
||||
@@ -0,0 +1,70 @@
|
||||
# Overview
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
RanksPlugin (entry)
|
||||
+-- RanksPermissionProvider implements Hytale's PermissionProvider SPI
|
||||
| +-- RankRepository SQL: ranks, ranks_perms
|
||||
| +-- GrantRepository SQL: ranks_grants
|
||||
| +-- UserPermRepository SQL: ranks_user_perms
|
||||
| +-- RanksCache in-memory per-user effective perms + group memberships
|
||||
+-- RankService high-level business API used by commands
|
||||
+-- ChatPrefixListener PlayerChatEvent -> rewrites formatter with primary rank prefix
|
||||
+-- Commands: /rank, /grant, /tempgrant, /revoke, /primary, /ranks
|
||||
```
|
||||
|
||||
External:
|
||||
- **NetworkCore.getDatabase()** for the MySQL pool
|
||||
- **NetworkCore.getMessageBus()** for cross-server cache invalidation
|
||||
- **PermissionsModule.get()** for `addProvider`/`removeProvider`/`getProviders`
|
||||
|
||||
## Mental model: rank vs group
|
||||
|
||||
In Hytale's permission API the unit is a **group**. We call ours a **rank** in user-facing speak but they're the same thing under the hood. A `Rank` row in the DB IS a Hytale group. Every method on `PermissionProvider` operates on group IDs - we just pass `Rank.id()` through.
|
||||
|
||||
A player can hold multiple ranks at once via `Grant` rows. Hytale's `getGroupsForUser(uuid)` returns the set of active rank IDs (not revoked, not expired). The "primary" flag is OUR extension - Hytale doesn't have a notion of primary. We use it for chat prefix selection.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- `setup()` - load config.
|
||||
- `start()`:
|
||||
1. Require NetworkCore's DB + bus.
|
||||
2. Bootstrap schema; seed Hytale's built-in groups (`hytale:Adventurer`, `hytale:Admin`, etc.) if absent.
|
||||
3. Build cache + provider.
|
||||
4. If `provider.replace_default`: remove every existing provider from `PermissionsModule`, remember them, add ours.
|
||||
5. Register the chat prefix listener and all commands.
|
||||
- `shutdown()`:
|
||||
1. Remove our provider.
|
||||
2. Re-add any providers we removed at start (so we leave the module in the state we found it).
|
||||
3. Stop the cache (closes the bus subscription).
|
||||
|
||||
## File map
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| [RanksPlugin.java](../src/main/java/net/kewwbec/ranks/RanksPlugin.java) | Entry point. Lifecycle, provider registration. |
|
||||
| [RanksPermissionsNodes.java](../src/main/java/net/kewwbec/ranks/RanksPermissionsNodes.java) | String constants for `networkranks.*` nodes. |
|
||||
| [config/](../src/main/java/net/kewwbec/ranks/config/) | Config record + JSON loader. |
|
||||
| [model/Rank.java](../src/main/java/net/kewwbec/ranks/model/Rank.java) | `(id, parentId, displayName, prefix, priority, builtIn)`. |
|
||||
| [model/Grant.java](../src/main/java/net/kewwbec/ranks/model/Grant.java) | One row of `ranks_grants`. |
|
||||
| [db/SchemaBootstrap.java](../src/main/java/net/kewwbec/ranks/db/SchemaBootstrap.java) | `CREATE TABLE IF NOT EXISTS` + seeds built-in groups. |
|
||||
| [db/RankRepository.java](../src/main/java/net/kewwbec/ranks/db/RankRepository.java) | CRUD on `ranks` + `ranks_perms`, includes inheritance walk. |
|
||||
| [db/GrantRepository.java](../src/main/java/net/kewwbec/ranks/db/GrantRepository.java) | CRUD on `ranks_grants`. |
|
||||
| [db/UserPermRepository.java](../src/main/java/net/kewwbec/ranks/db/UserPermRepository.java) | CRUD on `ranks_user_perms`. |
|
||||
| [provider/RanksPermissionProvider.java](../src/main/java/net/kewwbec/ranks/provider/RanksPermissionProvider.java) | Implements `PermissionProvider`. Reads cache, writes through to MySQL. |
|
||||
| [service/RanksCache.java](../src/main/java/net/kewwbec/ranks/service/RanksCache.java) | In-memory cache + cross-server invalidation publisher/subscriber. |
|
||||
| [service/InvalidationPayload.java](../src/main/java/net/kewwbec/ranks/service/InvalidationPayload.java) | Wire payload on the invalidation channel. |
|
||||
| [service/RankService.java](../src/main/java/net/kewwbec/ranks/service/RankService.java) | High-level business API used by commands. |
|
||||
| [listener/ChatPrefixListener.java](../src/main/java/net/kewwbec/ranks/listener/ChatPrefixListener.java) | Sets `PlayerChatEvent` formatter using primary rank's prefix. |
|
||||
| [command/rank/RankCommand.java](../src/main/java/net/kewwbec/ranks/command/rank/RankCommand.java) | `/rank create|delete|addperm|removeperm|setparent|setprefix|list|info`. |
|
||||
| [command/GrantCommands.java](../src/main/java/net/kewwbec/ranks/command/GrantCommands.java) | `/grant`, `/tempgrant`, `/revoke`, `/primary`, `/ranks`. |
|
||||
| [util/DurationParser.java](../src/main/java/net/kewwbec/ranks/util/DurationParser.java) | Parses `30d`, `1mo`, `1y`, etc. |
|
||||
| [util/TargetResolver.java](../src/main/java/net/kewwbec/ranks/util/TargetResolver.java) | Resolves a name (online players only) or UUID string to a UUID. |
|
||||
|
||||
## Why this shape
|
||||
|
||||
- **One provider per server, single source of truth**: replacing Hytale's default removes the risk of two providers giving conflicting answers.
|
||||
- **Cache around the provider**: hasPermission gets called many times per tick (chat enforcement, command parsing, world actions). Hitting MySQL each time would be a disaster; caching by UUID with bus-based invalidation is the standard pattern.
|
||||
- **Repositories return models, the service orchestrates**: keeps SQL out of the provider and command handlers.
|
||||
- **`built_in` flag on ranks**: stops `/rank delete hytale:Admin` from breaking the world.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Configuration
|
||||
|
||||
## Location
|
||||
|
||||
`<plugin data dir>/config.json`. Written with defaults on first boot.
|
||||
|
||||
## Full config
|
||||
|
||||
```json
|
||||
{
|
||||
"tables": {
|
||||
"ranks": "ranks",
|
||||
"rank_perms": "ranks_perms",
|
||||
"user_perms": "ranks_user_perms",
|
||||
"grants": "ranks_grants"
|
||||
},
|
||||
"provider": {
|
||||
"replace_default": true
|
||||
},
|
||||
"chat": {
|
||||
"inject_prefix": true,
|
||||
"prefix_format": "{prefix} "
|
||||
},
|
||||
"cache": {
|
||||
"invalidate_channel": "ranks.invalidate"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Fields
|
||||
|
||||
### tables
|
||||
|
||||
Table names. Change only if you must coexist with another schema that already uses these names.
|
||||
|
||||
### provider
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `replace_default` | `true` | When true, on `start()` we remove every existing PermissionProvider from `PermissionsModule` (including Hytale's default file-backed one) and register ours as the sole authority. When false, we add ours alongside - useful for migration, but expect duplicate answers from both providers (Hytale OR's results across providers). On `shutdown()` we restore whichever providers we removed. |
|
||||
|
||||
### chat
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `inject_prefix` | `true` | Toggle the PlayerChatEvent formatter rewrite. False = no prefix injection regardless of rank prefix. |
|
||||
| `prefix_format` | `"{prefix} "` | Format string. `{prefix}` is replaced with the player's primary rank prefix. Result is prepended to `<username>: <message>`. |
|
||||
|
||||
### cache
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `invalidate_channel` | `"ranks.invalidate"` | NetworkCore MessageBus channel for cross-server cache-invalidation events. Don't share this with another plugin. |
|
||||
|
||||
## No env var overrides
|
||||
|
||||
This plugin doesn't load any secrets from env. MySQL credentials live in NetworkCore (`MYSQL_PASSWORD`).
|
||||
@@ -0,0 +1,97 @@
|
||||
# Commands
|
||||
|
||||
Player-only (no console support yet). Every command is gated by a Hytale permission node via `requirePermission(...)` in the constructor.
|
||||
|
||||
## Permissions
|
||||
|
||||
| Command | Required permission |
|
||||
|---|---|
|
||||
| `/rank create` | `networkranks.rank.create` |
|
||||
| `/rank delete` | `networkranks.rank.delete` |
|
||||
| `/rank addperm` | `networkranks.rank.edit` |
|
||||
| `/rank removeperm` | `networkranks.rank.edit` |
|
||||
| `/rank setparent` | `networkranks.rank.edit` |
|
||||
| `/rank setprefix` | `networkranks.rank.edit` |
|
||||
| `/rank list` | `networkranks.rank.list` |
|
||||
| `/rank info` | `networkranks.rank.info` |
|
||||
| `/grant` | `networkranks.grant` |
|
||||
| `/tempgrant` | `networkranks.grant` |
|
||||
| `/revoke` | `networkranks.revoke` |
|
||||
| `/primary` | `networkranks.primary` |
|
||||
| `/ranks` | `networkranks.lookup` |
|
||||
|
||||
Grant `networkranks.*` to your admin group for full control.
|
||||
|
||||
## /rank
|
||||
|
||||
Subcommand tree for managing rank definitions (not memberships).
|
||||
|
||||
### /rank create <id> <prefix> <priority>
|
||||
|
||||
Creates a new rank. `id` should be short and lowercase (e.g. `vip`, `mvp`). `prefix` is a single token (`[VIP]`, `[Mod]`). `priority` is an integer - higher means "more important" for sorting purposes.
|
||||
|
||||
The new rank has no parent and no permissions until you add them with `/rank setparent` and `/rank addperm`.
|
||||
|
||||
### /rank delete <id>
|
||||
|
||||
Deletes a rank. Built-in groups (`hytale:Adventurer`, `hytale:Admin`, etc.) cannot be deleted - the command will report "not found or is built-in".
|
||||
|
||||
Active grants of the deleted rank become orphaned. They stay in the DB but the rank lookup returns nothing, so they have no effect on permissions.
|
||||
|
||||
### /rank addperm <id> <permission>
|
||||
|
||||
Adds a permission node to the rank's direct permissions. Wildcards (`*`, `someplugin.*`) work the same way they do in Hytale.
|
||||
|
||||
### /rank removeperm <id> <permission>
|
||||
|
||||
Removes one permission. Doesn't touch inherited permissions.
|
||||
|
||||
### /rank setparent <id> <parent|none>
|
||||
|
||||
Sets a rank's parent. Permissions are inherited up the parent chain: `Admin` parent `ServerEditor` parent `WorldEditor` ... means an Admin has every permission those parents have. Pass `none` to clear the parent.
|
||||
|
||||
### /rank setprefix <id> <prefix>
|
||||
|
||||
Updates the rank's chat prefix. Takes effect immediately (cache invalidation broadcasts to all servers).
|
||||
|
||||
### /rank list
|
||||
|
||||
Lists all ranks with their priority, prefix, parent, and built-in flag.
|
||||
|
||||
### /rank info <id>
|
||||
|
||||
Shows a rank's metadata + the full list of direct permissions (not the inherited ones).
|
||||
|
||||
## Grants
|
||||
|
||||
### /grant <player> <rank>
|
||||
|
||||
Permanently grants `rank` to `player`. Player can be an online name or a UUID string (UUIDs are required for offline players, since we don't track every name we've ever seen).
|
||||
|
||||
The grant adds to the player's set of ranks; it doesn't replace existing ranks. Use `/primary` to change which one is used for display.
|
||||
|
||||
### /tempgrant <player> <rank> <duration>
|
||||
|
||||
Same as `/grant` but the grant expires after `duration`. Duration syntax: `30s`, `5m`, `2h`, `7d`, `1w`, `1mo`, `1y`. Expired grants don't count toward effective permissions.
|
||||
|
||||
### /revoke <player> <rank>
|
||||
|
||||
Marks all active grants of `(player, rank)` as revoked. Doesn't delete the row - revoked grants stay for history.
|
||||
|
||||
### /primary <player> <rank>
|
||||
|
||||
Marks `rank` as the player's primary (display) rank. Player must already have an active grant of it. Used by the chat prefix listener.
|
||||
|
||||
### /ranks <player>
|
||||
|
||||
Shows the player's active rank IDs, primary rank, and the 10 most recent grants (active, expired, and revoked).
|
||||
|
||||
## Hytale's built-in /perm and /op commands
|
||||
|
||||
Still work as before. Hytale routes them through `PermissionsModule.get()`, which routes through our provider. `/op Bob` becomes a `setUserGroup(uuid, "hytale:Admin")` call, which we implement as a fresh grant of the `hytale:Admin` rank.
|
||||
|
||||
So you have two ways to manage permissions:
|
||||
- **Hytale's commands** (`/op`, `/perm group add`, etc.) - work with the built-in `hytale:*` groups
|
||||
- **Our commands** (`/rank create vip ...`, `/grant Bob vip`) - work with both built-ins AND custom ranks you create
|
||||
|
||||
Either set of commands updates the same MySQL tables. Use whichever feels right.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Schema
|
||||
|
||||
Four MySQL tables, created on `start()` via `CREATE TABLE IF NOT EXISTS`. Prefixed `ranks_` (except for `ranks` itself).
|
||||
|
||||
## ranks
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ranks (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
parent_id VARCHAR(64) NULL,
|
||||
display_name VARCHAR(64) NOT NULL,
|
||||
prefix VARCHAR(64) NOT NULL DEFAULT '',
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
built_in TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_priority (priority)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
```
|
||||
|
||||
- `id` is the canonical group name. Hytale's built-ins use the `hytale:` prefix (e.g. `hytale:Admin`); your custom ranks should just be lowercase (e.g. `vip`).
|
||||
- `parent_id` is null or another rank id. Inheritance walks the chain.
|
||||
- `built_in = 1` rows can't be deleted via `/rank delete`. Set by the bootstrap seeding.
|
||||
|
||||
## ranks_perms
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ranks_perms (
|
||||
rank_id VARCHAR(64) NOT NULL,
|
||||
permission VARCHAR(255) NOT NULL,
|
||||
PRIMARY KEY (rank_id, permission),
|
||||
KEY idx_rank (rank_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
```
|
||||
|
||||
Direct permissions for each rank. `*` is the wildcard (`hytale:Admin` has it by default).
|
||||
|
||||
## ranks_user_perms
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ranks_user_perms (
|
||||
user_uuid CHAR(36) NOT NULL,
|
||||
permission VARCHAR(255) NOT NULL,
|
||||
PRIMARY KEY (user_uuid, permission)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
```
|
||||
|
||||
Direct (per-user) permissions, bypassing ranks. We don't expose commands for this in v1 - it exists because the `PermissionProvider` SPI requires it and Hytale's `/perm user add` writes here through `addUserPermissions`.
|
||||
|
||||
## ranks_grants
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ranks_grants (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
user_uuid CHAR(36) NOT NULL,
|
||||
rank_id VARCHAR(64) NOT NULL,
|
||||
granted_at BIGINT NOT NULL,
|
||||
granted_by_uuid CHAR(36) NOT NULL,
|
||||
granted_by_name VARCHAR(64) NOT NULL,
|
||||
expires_at BIGINT NULL,
|
||||
primary_rank TINYINT(1) NOT NULL DEFAULT 0,
|
||||
revoked TINYINT(1) NOT NULL DEFAULT 0,
|
||||
revoked_at BIGINT NULL,
|
||||
revoked_by_uuid CHAR(36) NULL,
|
||||
revoked_by_name VARCHAR(64) NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_user (user_uuid),
|
||||
KEY idx_user_active (user_uuid, revoked, expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
```
|
||||
|
||||
Each grant is a separate row. Revokes don't delete - they flip `revoked = 1` and fill the `revoked_*` columns. Useful for "who took my rank away" forensics.
|
||||
|
||||
A user can have multiple active rows of the same rank. `getActiveRankIds` uses `DISTINCT rank_id` so the set is unique.
|
||||
|
||||
`primary_rank` flag is OUR extension on top of Hytale's group model. Only one row per user should have it set; `RankService.setPrimary` enforces this by clearing first.
|
||||
|
||||
## Built-in group seeding
|
||||
|
||||
On first boot, `SchemaBootstrap.seedBuiltInGroups` inserts these rows if absent:
|
||||
|
||||
| id | parent_id | display | built_in |
|
||||
|---|---|---|---|
|
||||
| `hytale:None` | null | None | 1 |
|
||||
| `hytale:Adventurer` | `hytale:None` | Adventurer | 1 |
|
||||
| `hytale:Builder` | `hytale:Adventurer` | Builder | 1 |
|
||||
| `hytale:WorldEditor` | `hytale:Builder` | WorldEditor | 1 |
|
||||
| `hytale:ServerEditor` | `hytale:WorldEditor` | ServerEditor | 1 |
|
||||
| `hytale:Admin` | `hytale:ServerEditor` | Admin | 1 |
|
||||
|
||||
And one permission row: `hytale:Admin` → `*` (wildcard).
|
||||
|
||||
This mirrors what Hytale's `HytalePermissionsProvider` ships with - so `/op` continuing to grant Admin and Admin having all perms keeps working without any extra setup.
|
||||
|
||||
## Migrations
|
||||
|
||||
There aren't any yet. If you change the schema, add a `migrations/00X_what.sql` and a small runner in `SchemaBootstrap` that tracks applied migrations in a `ranks_schema_version` table. Don't reach for Flyway/Liquibase until you have at least three migrations.
|
||||
|
||||
## Useful queries
|
||||
|
||||
Active ranks of a player:
|
||||
|
||||
```sql
|
||||
SELECT DISTINCT rank_id
|
||||
FROM ranks_grants
|
||||
WHERE user_uuid = ?
|
||||
AND revoked = 0
|
||||
AND (expires_at IS NULL OR expires_at > UNIX_TIMESTAMP() * 1000)
|
||||
ORDER BY primary_rank DESC, rank_id;
|
||||
```
|
||||
|
||||
How many players hold each rank right now:
|
||||
|
||||
```sql
|
||||
SELECT rank_id, COUNT(DISTINCT user_uuid) AS holders
|
||||
FROM ranks_grants
|
||||
WHERE revoked = 0
|
||||
AND (expires_at IS NULL OR expires_at > UNIX_TIMESTAMP() * 1000)
|
||||
GROUP BY rank_id
|
||||
ORDER BY holders DESC;
|
||||
```
|
||||
|
||||
All-time grants by staff member (audit):
|
||||
|
||||
```sql
|
||||
SELECT granted_at, user_uuid, rank_id, expires_at
|
||||
FROM ranks_grants
|
||||
WHERE granted_by_name = 'StaffName'
|
||||
ORDER BY granted_at DESC;
|
||||
```
|
||||
@@ -0,0 +1,95 @@
|
||||
# Provider Integration
|
||||
|
||||
How we plug into Hytale's permission system without Hytale knowing we're there.
|
||||
|
||||
## The SPI
|
||||
|
||||
```java
|
||||
package com.hypixel.hytale.server.core.permissions.provider;
|
||||
|
||||
public interface PermissionProvider {
|
||||
String getName();
|
||||
void addUserPermissions(UUID, Set<String>);
|
||||
void removeUserPermissions(UUID, Set<String>);
|
||||
Set<String> getUserPermissions(UUID);
|
||||
void addGroupPermissions(String, Set<String>);
|
||||
void removeGroupPermissions(String, Set<String>);
|
||||
Set<String> getGroupPermissions(String);
|
||||
void addUserToGroup(UUID, String);
|
||||
void removeUserFromGroup(UUID, String);
|
||||
Set<String> getGroupsForUser(UUID);
|
||||
void setUserGroup(UUID, String);
|
||||
String getGroupParent(String);
|
||||
Set<String> getAllRegisteredGroups();
|
||||
Set<String> getEffectiveGroupPermissions(String);
|
||||
}
|
||||
```
|
||||
|
||||
That's the contract. Our [RanksPermissionProvider](../src/main/java/net/kewwbec/ranks/provider/RanksPermissionProvider.java) implements every method.
|
||||
|
||||
## The module's API
|
||||
|
||||
`PermissionsModule.get()` exposes:
|
||||
|
||||
```java
|
||||
void addProvider(PermissionProvider);
|
||||
void removeProvider(PermissionProvider);
|
||||
List<PermissionProvider> getProviders();
|
||||
PermissionProvider getFirstPermissionProvider();
|
||||
boolean areProvidersTampered();
|
||||
```
|
||||
|
||||
When a `hasPermission(uuid, node)` call comes in, the module consults the providers in order. If you've replaced the default with ours, only ours answers. If you've left ours alongside the default, the module's logic combines them (typically OR for "has the permission").
|
||||
|
||||
## How we register
|
||||
|
||||
`RanksPlugin.start()`:
|
||||
|
||||
```java
|
||||
PermissionsModule perms = PermissionsModule.get();
|
||||
|
||||
if (config.provider.replace_default) {
|
||||
for (PermissionProvider existing : new ArrayList<>(perms.getProviders())) {
|
||||
if (!RanksPermissionProvider.NAME.equals(existing.getName())) {
|
||||
perms.removeProvider(existing);
|
||||
removedProviders.add(existing); // remember to restore on shutdown
|
||||
}
|
||||
}
|
||||
}
|
||||
perms.addProvider(provider);
|
||||
```
|
||||
|
||||
On `shutdown()` we reverse: remove ours, re-add anything we removed. This is so a clean reload of our plugin doesn't leave the server permission-less.
|
||||
|
||||
## What "replace" actually changes
|
||||
|
||||
When `replace_default = true`:
|
||||
|
||||
- Hytale's file-backed provider (which stores groups/users in JSON on disk) is removed.
|
||||
- Its data is **not** copied into MySQL automatically. If you had pre-existing OPs from the file, they're gone the moment we register (until you reassign them via `/op` or `/grant`, which now writes to MySQL).
|
||||
- If you ever uninstall this plugin, the file-backed provider is restored - but your MySQL data stays in MySQL, so it won't be visible to that provider.
|
||||
|
||||
If you have existing OPs you need to preserve:
|
||||
|
||||
1. Before installing Ranks, write down who's in which group from the Hytale file.
|
||||
2. Install Ranks, boot once - this creates the schema and seeds the built-in groups.
|
||||
3. Run `/op Bob`, `/op Alice` etc. once for each existing OP - this writes to our DB.
|
||||
|
||||
For larger migrations (lots of users, custom groups), a one-off SQL import is the right move; we don't ship one because every server's pre-existing state is different.
|
||||
|
||||
## Hytale's `/op`, `/perm` commands
|
||||
|
||||
Look at the `commands/` package under `com.hypixel.hytale.server.core.permissions` in the SDK. Each one calls into `PermissionsModule` which routes to providers. With our provider registered, those commands now read/write our MySQL.
|
||||
|
||||
This is the main reason "replace default" is the recommended mode: it means there's only one source of truth, and all the existing Hytale tooling keeps working.
|
||||
|
||||
## Conflicts to watch
|
||||
|
||||
- If you set `areProvidersTampered()` somehow gets flagged, Hytale might warn about non-default providers. We are intentionally replacing; the warning is expected.
|
||||
- If another plugin also registers a `PermissionProvider`, the load order matters. We register in `start()`. If a later-loading plugin also calls `addProvider`, we coexist as providers 1 and 2. The module decides who answers first.
|
||||
|
||||
## Caching considerations
|
||||
|
||||
Every `hasPermission` call goes through our provider. We aggressively cache (see [06_Cross_Server_Caching.md](06_Cross_Server_Caching.md)). The first call after a cache miss hits MySQL; subsequent calls are in-memory.
|
||||
|
||||
If the cache gets stale (e.g. the bus drops an invalidation message), permissions can drift for up to whatever interval the player stays connected. To force-refresh: revoke and re-grant their rank (which invalidates), or restart the server.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Cross-Server Caching
|
||||
|
||||
How a `/grant` on lobby-1 takes effect on arena-3 within milliseconds.
|
||||
|
||||
## The cache
|
||||
|
||||
[RanksCache](../src/main/java/net/kewwbec/ranks/service/RanksCache.java) keeps two maps:
|
||||
|
||||
- `userPermsCache: UUID -> Set<String>` - effective permissions per user (direct + all rank perms walked via inheritance)
|
||||
- `userGroupsCache: UUID -> Set<String>` - active rank IDs per user
|
||||
|
||||
`RanksPermissionProvider.getUserPermissions(uuid)` checks the cache first, falls back to MySQL, then populates the cache. `getGroupsForUser(uuid)` works the same way.
|
||||
|
||||
The cache is unbounded. In practice it holds an entry per online player plus a few minutes worth of recent logouts (they age out only via invalidation).
|
||||
|
||||
## Invalidation
|
||||
|
||||
Three reasons to invalidate:
|
||||
|
||||
1. **One user changed** - e.g. `/grant Bob vip`. Drop Bob's cached entries. Other users untouched.
|
||||
2. **A rank changed** - e.g. `/rank addperm vip mycommand.use`, or `/rank setparent`. Anyone holding `vip` (transitively) is affected. Targeted invalidation is hard because of inheritance, so we drop the whole cache.
|
||||
3. **Bulk reset** - e.g. a full reload after manual SQL edits. Drop everything.
|
||||
|
||||
Each `service.grant/revoke/setPrimary/createRank/...` call funnels through methods like `cache.invalidateUser(uuid)` or `cache.invalidateRank(id)`.
|
||||
|
||||
## Cross-server propagation
|
||||
|
||||
The cache also publishes an [InvalidationPayload](../src/main/java/net/kewwbec/ranks/service/InvalidationPayload.java) on NetworkCore's `ranks.invalidate` channel for every invalidation. Other servers subscribe, receive the message, and apply the same drop locally.
|
||||
|
||||
```
|
||||
server A: /grant Bob vip
|
||||
-> grants table UPDATE
|
||||
-> cache.invalidateUser(bob)
|
||||
-> local: userPermsCache.remove(bob)
|
||||
-> publish on bus: {userUuid: bob, fromServerId: 'lobby-1'}
|
||||
|
||||
server B: receives bus message
|
||||
-> filter: fromServerId != self, so process
|
||||
-> local: userPermsCache.remove(bob)
|
||||
```
|
||||
|
||||
`fromServerId` filtering prevents servers from re-applying their own invalidations.
|
||||
|
||||
## Timing
|
||||
|
||||
| Step | Approximate latency |
|
||||
|---|---|
|
||||
| Local cache drop | microseconds |
|
||||
| MySQL UPDATE for the grant | ~5 ms (varies with DB) |
|
||||
| MessageBus publish (Redis pub/sub) | sub-millisecond |
|
||||
| Receiver processes invalidation | ~1 ms |
|
||||
| Receiver's next hasPermission call | hits MySQL once (cache miss), ~5-10 ms |
|
||||
|
||||
Total time from `/grant` to "Bob's hasPermission on server B returns true": typically under 20 ms.
|
||||
|
||||
## Failure modes
|
||||
|
||||
| What | What happens | Recovery |
|
||||
|---|---|---|
|
||||
| MySQL is down on issuing server | The grant write fails; nothing happens. Tell the player. | Bring MySQL back, retry. |
|
||||
| MessageBus is down on issuing server | Local cache invalidated but bus publish fails silently. Other servers don't drop their cache. Other servers serve stale perms until their cache entry is naturally replaced (or until a separate event invalidates them). | Bring Redis back. To force-resync remote caches: restart them (drains the cache). |
|
||||
| Receiver MessageBus is down | The receiver misses invalidations. Same drift as above. | Same. |
|
||||
| Receiver applies invalidation but the next `getUserPermissions` query against MySQL is racy (transaction not yet committed) | Receiver caches a slightly-stale set. Next invalidation arrives, drops it again, fresh read. Eventually consistent. | None needed - self-corrects on next change. |
|
||||
|
||||
The "stale until next change" failure mode is the only inconsistency window. It's narrow (sub-second usually) and self-heals.
|
||||
|
||||
## No periodic refresh
|
||||
|
||||
We do NOT do "expire cache entries after N seconds" - that would mean every player's perms are re-fetched constantly. The cache is purely invalidation-driven. This is a tradeoff: a permanently-lost invalidation message leaves stale state forever. Mitigation: NetworkCore's `network.auth_token` and reliable Redis pub/sub mean lost messages are rare.
|
||||
|
||||
If you ever need belt-and-suspenders, add a periodic full-cache-clear (e.g. every 10 min) somewhere in `start()`. Not in v1.
|
||||
|
||||
## Looking under the hood
|
||||
|
||||
`SELECT * FROM ranks_grants WHERE user_uuid = ? AND revoked = 0 ORDER BY granted_at DESC` shows you the source of truth for a user.
|
||||
|
||||
For a live look at what the cache currently holds, you'd need to add a debug command - we don't ship one. Add a `/rank cache <player>` subcommand later if it becomes useful.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Operations
|
||||
|
||||
## Smoke test after deploy
|
||||
|
||||
1. `/networkcore status` shows "Database: ok" and "Network: connected".
|
||||
2. Boot log shows `Replaced default provider: <something>` and `Registered Ranks provider (kewwbec-ranks)`.
|
||||
3. `/rank list` returns at least the seven built-in groups (`hytale:None`, `hytale:Adventurer`, ..., `hytale:Admin`).
|
||||
4. `/rank create vip [VIP] 50` then `/rank info vip` shows it back.
|
||||
5. `/grant <your-alt> vip`. From the alt, send a chat message - prefix `[VIP] ` should appear.
|
||||
6. `/primary <your-alt> vip` (only needed if the alt has multiple ranks; with just `vip` it's already primary).
|
||||
7. `/revoke <your-alt> vip` - prefix disappears.
|
||||
|
||||
## Permissions don't seem to apply
|
||||
|
||||
1. `/ranks <player>` - is the rank in the active list? If no, the grant didn't take.
|
||||
2. `/rank info <rank-id>` - does the rank actually have the permission you expected?
|
||||
3. Check inheritance: if `vip` has no perm but its parent `default` does, the player should still have it via inheritance.
|
||||
4. The cache. After a `/grant`, the player's cache entry is invalidated locally and a bus message is sent. If the server they're on missed the bus message, their cache is stale. Quick fix: have them reconnect, or restart that server.
|
||||
|
||||
## `/op Bob` did nothing visible
|
||||
|
||||
It writes to MySQL but doesn't tell anyone. Verify:
|
||||
|
||||
```sql
|
||||
SELECT * FROM ranks_grants
|
||||
WHERE user_uuid = '<bob-uuid>' AND rank_id = 'hytale:Admin'
|
||||
ORDER BY granted_at DESC LIMIT 1;
|
||||
```
|
||||
|
||||
If you see the row, `/op` succeeded - Bob needs to reconnect for client-side perm changes to apply (Hytale reads perms on connect for many things).
|
||||
|
||||
## Chat prefixes aren't showing
|
||||
|
||||
1. `inject_prefix` is true in config?
|
||||
2. The player has a primary rank? `/ranks <name>` and check the "Primary:" line.
|
||||
3. The primary rank has a non-empty `prefix`? `/rank info <rank>`.
|
||||
4. The chat event isn't being cancelled by something else (e.g. mute from Staff). If muted, the formatter rewrite is irrelevant - the message never sends.
|
||||
|
||||
## A rank's permissions don't apply to existing holders
|
||||
|
||||
You added a perm with `/rank addperm`. Existing holders need their cache invalidated to pick it up. `addRankPermission` does `cache.invalidateRank(id)` which clears all caches and broadcasts. If they STILL don't see it:
|
||||
|
||||
1. Check the boot log - did our cache subscription succeed?
|
||||
2. `redis-cli -h <host> MONITOR` while you re-run `/rank addperm`. You should see a PUBLISH on `ranks.invalidate`. If not, the publish failed.
|
||||
3. Worst case: bounce the server they're on. Cache rebuilds on next call.
|
||||
|
||||
## Built-in groups missing
|
||||
|
||||
If `/rank list` doesn't show the `hytale:*` groups, schema bootstrap didn't seed them (or someone deleted them via raw SQL). To re-seed:
|
||||
|
||||
```sql
|
||||
INSERT IGNORE INTO ranks (id, parent_id, display_name, prefix, priority, built_in, created_at) VALUES
|
||||
('hytale:None', NULL, 'None', '', 0, 1, UNIX_TIMESTAMP() * 1000),
|
||||
('hytale:Adventurer', 'hytale:None', 'Adventurer', '', 10, 1, UNIX_TIMESTAMP() * 1000),
|
||||
('hytale:Builder', 'hytale:Adventurer', 'Builder', '', 20, 1, UNIX_TIMESTAMP() * 1000),
|
||||
('hytale:WorldEditor', 'hytale:Builder', 'WorldEditor', '', 30, 1, UNIX_TIMESTAMP() * 1000),
|
||||
('hytale:ServerEditor', 'hytale:WorldEditor', 'ServerEditor', '', 40, 1, UNIX_TIMESTAMP() * 1000),
|
||||
('hytale:Admin', 'hytale:ServerEditor', 'Admin', '', 100, 1, UNIX_TIMESTAMP() * 1000);
|
||||
|
||||
INSERT IGNORE INTO ranks_perms (rank_id, permission) VALUES ('hytale:Admin', '*');
|
||||
```
|
||||
|
||||
Then invalidate all caches by restarting any one server (it'll broadcast nothing useful but its own cache rebuilds, and you can run `/rank reload` if we ever add one).
|
||||
|
||||
## Auditing
|
||||
|
||||
Recent grants by any moderator:
|
||||
|
||||
```sql
|
||||
SELECT granted_at, granted_by_name, user_uuid, rank_id, expires_at
|
||||
FROM ranks_grants
|
||||
ORDER BY granted_at DESC LIMIT 50;
|
||||
```
|
||||
|
||||
All grants of a specific rank to anyone, ever:
|
||||
|
||||
```sql
|
||||
SELECT * FROM ranks_grants WHERE rank_id = 'vip' ORDER BY granted_at DESC;
|
||||
```
|
||||
|
||||
Players with active permanent grants of `hytale:Admin`:
|
||||
|
||||
```sql
|
||||
SELECT DISTINCT user_uuid FROM ranks_grants
|
||||
WHERE rank_id = 'hytale:Admin' AND revoked = 0 AND expires_at IS NULL;
|
||||
```
|
||||
|
||||
## Things this plugin deliberately doesn't do
|
||||
|
||||
- **No /perm reload-style admin command.** Cache invalidation is implicit on every write; a manual reload is rarely needed. If you need one, add a small command that calls `cache.invalidateAll()`.
|
||||
- **No per-world permissions.** Hytale's group model is global. If you need per-world, you'd need a separate layer that filters by world before checking - out of scope.
|
||||
- **No GUI for managing ranks.** Could be a Staff-style HyUI page. Not in v1.
|
||||
- **No automatic OP backup before replacing default.** If you have important OPs in the file provider, document them yourself before installing.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Ranks Docs
|
||||
|
||||
1. [01_Overview.md](01_Overview.md) - architecture and pieces
|
||||
2. [02_Configuration.md](02_Configuration.md) - config.json + env vars
|
||||
3. [03_Commands.md](03_Commands.md) - every command + permission nodes
|
||||
4. [04_Schema.md](04_Schema.md) - MySQL tables, built-in group seeding
|
||||
5. [05_Provider_Integration.md](05_Provider_Integration.md) - how we replace Hytale's permission provider
|
||||
6. [06_Cross_Server_Caching.md](06_Cross_Server_Caching.md) - cache + invalidation flow
|
||||
7. [07_Operations.md](07_Operations.md) - ops scenarios, debugging
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>net.kewwbec</groupId>
|
||||
<artifactId>ranks</artifactId>
|
||||
<version>0.1.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>Ranks</name>
|
||||
<description>KweebecNet ranks: permissions provider, groups, grants, chat prefixes</description>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<hytale.server.jar>${user.home}/AppData/Roaming/Hytale/install/release/package/game/latest/Server/HytaleServer.jar</hytale.server.jar>
|
||||
<networkcore.jar>${project.basedir}/../Core/target/NetworkCore-0.1.0.jar</networkcore.jar>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.hypixel.hytale</groupId>
|
||||
<artifactId>hytale-server</artifactId>
|
||||
<version>local</version>
|
||||
<scope>system</scope>
|
||||
<systemPath>${hytale.server.jar}</systemPath>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>net.kewwbec</groupId>
|
||||
<artifactId>networkcore</artifactId>
|
||||
<version>0.1.0</version>
|
||||
<scope>system</scope>
|
||||
<systemPath>${networkcore.jar}</systemPath>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.google.code.findbugs</groupId>
|
||||
<artifactId>jsr305</artifactId>
|
||||
<version>3.0.2</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>${project.artifactId}-${project.version}</finalName>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,22 @@
|
||||
package net.kewwbec.ranks;
|
||||
|
||||
public final class RanksPermissionsNodes {
|
||||
|
||||
public static final String ROOT = "networkranks";
|
||||
|
||||
// Rank/group management
|
||||
public static final String RANK_CREATE = ROOT + ".rank.create";
|
||||
public static final String RANK_DELETE = ROOT + ".rank.delete";
|
||||
public static final String RANK_EDIT = ROOT + ".rank.edit";
|
||||
public static final String RANK_LIST = ROOT + ".rank.list";
|
||||
public static final String RANK_INFO = ROOT + ".rank.info";
|
||||
|
||||
// Grants
|
||||
public static final String GRANT = ROOT + ".grant";
|
||||
public static final String REVOKE = ROOT + ".revoke";
|
||||
public static final String PRIMARY = ROOT + ".primary";
|
||||
public static final String RANKS_LOOKUP = ROOT + ".lookup";
|
||||
|
||||
private RanksPermissionsNodes() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package net.kewwbec.ranks;
|
||||
|
||||
import com.hypixel.hytale.logger.HytaleLogger;
|
||||
import com.hypixel.hytale.server.core.event.events.player.PlayerChatEvent;
|
||||
import com.hypixel.hytale.server.core.permissions.PermissionsModule;
|
||||
import com.hypixel.hytale.server.core.permissions.provider.PermissionProvider;
|
||||
import com.hypixel.hytale.server.core.plugin.JavaPlugin;
|
||||
import com.hypixel.hytale.server.core.plugin.JavaPluginInit;
|
||||
import net.kewwbec.networkcore.NetworkCore;
|
||||
import net.kewwbec.networkcore.api.DatabaseService;
|
||||
import net.kewwbec.networkcore.api.MessageBus;
|
||||
import net.kewwbec.ranks.command.GrantCommands;
|
||||
import net.kewwbec.ranks.command.rank.RankCommand;
|
||||
import net.kewwbec.ranks.config.RanksConfig;
|
||||
import net.kewwbec.ranks.config.RanksConfigLoader;
|
||||
import net.kewwbec.ranks.db.GrantRepository;
|
||||
import net.kewwbec.ranks.db.RankRepository;
|
||||
import net.kewwbec.ranks.db.SchemaBootstrap;
|
||||
import net.kewwbec.ranks.db.UserPermRepository;
|
||||
import net.kewwbec.ranks.listener.ChatPrefixListener;
|
||||
import net.kewwbec.ranks.provider.RanksPermissionProvider;
|
||||
import net.kewwbec.ranks.service.RankService;
|
||||
import net.kewwbec.ranks.service.RanksCache;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.io.IOException;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.logging.Level;
|
||||
|
||||
public final class RanksPlugin extends JavaPlugin {
|
||||
|
||||
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||
|
||||
private RanksConfig config;
|
||||
private RanksCache cache;
|
||||
private RanksPermissionProvider provider;
|
||||
private final List<PermissionProvider> removedProviders = new ArrayList<>();
|
||||
|
||||
public RanksPlugin(@Nonnull JavaPluginInit init) {
|
||||
super(init);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setup() {
|
||||
try {
|
||||
this.config = RanksConfigLoader.loadOrCreate(getDataDirectory());
|
||||
} catch (IOException e) {
|
||||
((HytaleLogger.Api) LOGGER.at(Level.SEVERE).withCause(e)).log("Failed to load Ranks config");
|
||||
this.config = new RanksConfig();
|
||||
}
|
||||
LOGGER.at(Level.INFO).log("Ranks setup complete");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void start() {
|
||||
NetworkCore core = NetworkCore.getInstance();
|
||||
DatabaseService db = core.getDatabase();
|
||||
if (db == null || !db.isHealthy()) {
|
||||
LOGGER.at(Level.SEVERE).log("Ranks requires NetworkCore's MySQL DatabaseService. Set mysql.enabled=true.");
|
||||
return;
|
||||
}
|
||||
MessageBus bus = core.getMessageBus();
|
||||
if (bus == null) {
|
||||
LOGGER.at(Level.SEVERE).log("Ranks requires NetworkCore's MessageBus.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
SchemaBootstrap.apply(db.getDataSource(), config.tables);
|
||||
} catch (SQLException e) {
|
||||
((HytaleLogger.Api) LOGGER.at(Level.SEVERE).withCause(e)).log("Failed to bootstrap Ranks schema");
|
||||
return;
|
||||
}
|
||||
|
||||
RankRepository rankRepo = new RankRepository(db.getDataSource(), config.tables.ranks, config.tables.rank_perms);
|
||||
GrantRepository grantRepo = new GrantRepository(db.getDataSource(), config.tables.grants);
|
||||
UserPermRepository userPermRepo = new UserPermRepository(db.getDataSource(), config.tables.user_perms);
|
||||
|
||||
this.cache = new RanksCache(bus, config.cache, core.getServerId());
|
||||
cache.start();
|
||||
|
||||
this.provider = new RanksPermissionProvider(rankRepo, grantRepo, userPermRepo, cache);
|
||||
|
||||
PermissionsModule perms = PermissionsModule.get();
|
||||
if (perms == null) {
|
||||
LOGGER.at(Level.SEVERE).log("PermissionsModule.get() is null; cannot register provider");
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.provider.replace_default) {
|
||||
List<PermissionProvider> existing = new ArrayList<>(perms.getProviders());
|
||||
for (PermissionProvider p : existing) {
|
||||
if (!RanksPermissionProvider.NAME.equals(p.getName())) {
|
||||
perms.removeProvider(p);
|
||||
removedProviders.add(p);
|
||||
LOGGER.at(Level.INFO).log("Replaced default provider: %s", p.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
perms.addProvider(provider);
|
||||
LOGGER.at(Level.INFO).log("Registered Ranks provider (%s)", provider.getName());
|
||||
|
||||
RankService service = new RankService(rankRepo, grantRepo, userPermRepo, cache);
|
||||
|
||||
ChatPrefixListener prefixListener = new ChatPrefixListener(service, config.chat);
|
||||
getEventRegistry().registerGlobal(PlayerChatEvent.class, prefixListener::onChat);
|
||||
|
||||
getCommandRegistry().registerCommand(new RankCommand(service));
|
||||
getCommandRegistry().registerCommand(new GrantCommands.Grant(service));
|
||||
getCommandRegistry().registerCommand(new GrantCommands.TempGrant(service));
|
||||
getCommandRegistry().registerCommand(new GrantCommands.Revoke(service));
|
||||
getCommandRegistry().registerCommand(new GrantCommands.SetPrimary(service));
|
||||
getCommandRegistry().registerCommand(new GrantCommands.Ranks(service));
|
||||
|
||||
LOGGER.at(Level.INFO).log("Ranks started");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void shutdown() {
|
||||
PermissionsModule perms = PermissionsModule.get();
|
||||
if (perms != null) {
|
||||
if (provider != null) {
|
||||
try { perms.removeProvider(provider); } catch (RuntimeException ignored) {}
|
||||
}
|
||||
for (PermissionProvider p : removedProviders) {
|
||||
try { perms.addProvider(p); } catch (RuntimeException ignored) {}
|
||||
}
|
||||
removedProviders.clear();
|
||||
}
|
||||
provider = null;
|
||||
if (cache != null) {
|
||||
try { cache.stop(); } catch (RuntimeException ignored) {}
|
||||
cache = null;
|
||||
}
|
||||
LOGGER.at(Level.INFO).log("Ranks shutdown complete");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package net.kewwbec.ranks.command;
|
||||
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg;
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import net.kewwbec.ranks.RanksPermissionsNodes;
|
||||
import net.kewwbec.ranks.model.Grant;
|
||||
import net.kewwbec.ranks.model.Rank;
|
||||
import net.kewwbec.ranks.service.RankService;
|
||||
import net.kewwbec.ranks.util.DurationParser;
|
||||
import net.kewwbec.ranks.util.TargetResolver;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.awt.Color;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class GrantCommands {
|
||||
|
||||
private GrantCommands() {
|
||||
}
|
||||
|
||||
public static final class Grant extends AbstractPlayerCommand {
|
||||
private final RankService service;
|
||||
private final RequiredArg<String> targetArg = withRequiredArg("player", "Player name (online) or UUID", ArgTypes.STRING);
|
||||
private final RequiredArg<String> rankArg = withRequiredArg("rank", "Rank id", ArgTypes.STRING);
|
||||
|
||||
public Grant(@Nonnull RankService service) {
|
||||
super("grant", "Permanently grant a rank to a player");
|
||||
this.service = service;
|
||||
requirePermission(RanksPermissionsNodes.GRANT);
|
||||
}
|
||||
@Override protected boolean canGeneratePermission() { return false; }
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef issuer, @Nonnull World world) {
|
||||
String t = targetArg.get(ctx);
|
||||
String r = rankArg.get(ctx);
|
||||
if (t == null || r == null) {
|
||||
ctx.sendMessage(Message.raw("Usage: /grant <player> <rank>").color(Color.YELLOW));
|
||||
return;
|
||||
}
|
||||
UUID uuid = TargetResolver.resolve(t);
|
||||
if (uuid == null) {
|
||||
ctx.sendMessage(Message.raw("Couldn't resolve '" + t + "' to a player. Use a UUID for offline players.").color(Color.RED));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
long id = service.grant(uuid, r, issuer.getUuid(), issuer.getUsername(), null);
|
||||
if (id < 0) ctx.sendMessage(Message.raw("Rank '" + r + "' does not exist.").color(Color.RED));
|
||||
else ctx.sendMessage(Message.raw("Granted '" + r + "' to " + uuid + " (permanent).").color(Color.GREEN));
|
||||
} catch (SQLException e) {
|
||||
ctx.sendMessage(Message.raw("DB error: " + e.getMessage()).color(Color.RED));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class TempGrant extends AbstractPlayerCommand {
|
||||
private final RankService service;
|
||||
private final RequiredArg<String> targetArg = withRequiredArg("player", "Player name (online) or UUID", ArgTypes.STRING);
|
||||
private final RequiredArg<String> rankArg = withRequiredArg("rank", "Rank id", ArgTypes.STRING);
|
||||
private final RequiredArg<String> durArg = withRequiredArg("duration", "e.g. 30d, 1mo, 1y", ArgTypes.STRING);
|
||||
|
||||
public TempGrant(@Nonnull RankService service) {
|
||||
super("tempgrant", "Grant a rank for a fixed duration");
|
||||
this.service = service;
|
||||
requirePermission(RanksPermissionsNodes.GRANT);
|
||||
}
|
||||
@Override protected boolean canGeneratePermission() { return false; }
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef issuer, @Nonnull World world) {
|
||||
String t = targetArg.get(ctx);
|
||||
String r = rankArg.get(ctx);
|
||||
String d = durArg.get(ctx);
|
||||
if (t == null || r == null || d == null) {
|
||||
ctx.sendMessage(Message.raw("Usage: /tempgrant <player> <rank> <duration>").color(Color.YELLOW));
|
||||
return;
|
||||
}
|
||||
UUID uuid = TargetResolver.resolve(t);
|
||||
if (uuid == null) {
|
||||
ctx.sendMessage(Message.raw("Couldn't resolve '" + t + "'.").color(Color.RED));
|
||||
return;
|
||||
}
|
||||
Long dur = DurationParser.parseMillis(d);
|
||||
if (dur == null) {
|
||||
ctx.sendMessage(Message.raw("Bad duration. Use 30s, 5m, 2h, 7d, 1mo, 1y.").color(Color.RED));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
long id = service.grant(uuid, r, issuer.getUuid(), issuer.getUsername(), dur);
|
||||
if (id < 0) ctx.sendMessage(Message.raw("Rank '" + r + "' does not exist.").color(Color.RED));
|
||||
else ctx.sendMessage(Message.raw("Granted '" + r + "' to " + uuid + " for " + DurationParser.formatRemaining(dur) + ".").color(Color.GREEN));
|
||||
} catch (SQLException e) {
|
||||
ctx.sendMessage(Message.raw("DB error: " + e.getMessage()).color(Color.RED));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Revoke extends AbstractPlayerCommand {
|
||||
private final RankService service;
|
||||
private final RequiredArg<String> targetArg = withRequiredArg("player", "Player name (online) or UUID", ArgTypes.STRING);
|
||||
private final RequiredArg<String> rankArg = withRequiredArg("rank", "Rank id", ArgTypes.STRING);
|
||||
|
||||
public Revoke(@Nonnull RankService service) {
|
||||
super("revoke", "Revoke a rank from a player");
|
||||
this.service = service;
|
||||
requirePermission(RanksPermissionsNodes.REVOKE);
|
||||
}
|
||||
@Override protected boolean canGeneratePermission() { return false; }
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef issuer, @Nonnull World world) {
|
||||
String t = targetArg.get(ctx);
|
||||
String r = rankArg.get(ctx);
|
||||
if (t == null || r == null) {
|
||||
ctx.sendMessage(Message.raw("Usage: /revoke <player> <rank>").color(Color.YELLOW));
|
||||
return;
|
||||
}
|
||||
UUID uuid = TargetResolver.resolve(t);
|
||||
if (uuid == null) {
|
||||
ctx.sendMessage(Message.raw("Couldn't resolve '" + t + "'.").color(Color.RED));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int n = service.revoke(uuid, r, issuer.getUuid(), issuer.getUsername());
|
||||
if (n == 0) ctx.sendMessage(Message.raw("No active grant of '" + r + "' for that player.").color(Color.YELLOW));
|
||||
else ctx.sendMessage(Message.raw("Revoked " + n + " active grant(s) of '" + r + "'.").color(Color.GREEN));
|
||||
} catch (SQLException e) {
|
||||
ctx.sendMessage(Message.raw("DB error: " + e.getMessage()).color(Color.RED));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class SetPrimary extends AbstractPlayerCommand {
|
||||
private final RankService service;
|
||||
private final RequiredArg<String> targetArg = withRequiredArg("player", "Player name (online) or UUID", ArgTypes.STRING);
|
||||
private final RequiredArg<String> rankArg = withRequiredArg("rank", "Rank id to make primary", ArgTypes.STRING);
|
||||
|
||||
public SetPrimary(@Nonnull RankService service) {
|
||||
super("primary", "Set a player's primary (display) rank");
|
||||
this.service = service;
|
||||
requirePermission(RanksPermissionsNodes.PRIMARY);
|
||||
}
|
||||
@Override protected boolean canGeneratePermission() { return false; }
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef issuer, @Nonnull World world) {
|
||||
String t = targetArg.get(ctx);
|
||||
String r = rankArg.get(ctx);
|
||||
if (t == null || r == null) {
|
||||
ctx.sendMessage(Message.raw("Usage: /primary <player> <rank>").color(Color.YELLOW));
|
||||
return;
|
||||
}
|
||||
UUID uuid = TargetResolver.resolve(t);
|
||||
if (uuid == null) {
|
||||
ctx.sendMessage(Message.raw("Couldn't resolve '" + t + "'.").color(Color.RED));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
boolean ok = service.setPrimary(uuid, r);
|
||||
if (ok) ctx.sendMessage(Message.raw("Set primary rank to '" + r + "'.").color(Color.GREEN));
|
||||
else ctx.sendMessage(Message.raw("Player doesn't have an active grant of '" + r + "'.").color(Color.RED));
|
||||
} catch (SQLException e) {
|
||||
ctx.sendMessage(Message.raw("DB error: " + e.getMessage()).color(Color.RED));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Ranks extends AbstractPlayerCommand {
|
||||
private final RankService service;
|
||||
private final RequiredArg<String> targetArg = withRequiredArg("player", "Player name (online) or UUID", ArgTypes.STRING);
|
||||
|
||||
public Ranks(@Nonnull RankService service) {
|
||||
super("ranks", "Show a player's active and historical ranks");
|
||||
this.service = service;
|
||||
requirePermission(RanksPermissionsNodes.RANKS_LOOKUP);
|
||||
}
|
||||
@Override protected boolean canGeneratePermission() { return false; }
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef issuer, @Nonnull World world) {
|
||||
String t = targetArg.get(ctx);
|
||||
if (t == null) {
|
||||
ctx.sendMessage(Message.raw("Usage: /ranks <player>").color(Color.YELLOW));
|
||||
return;
|
||||
}
|
||||
UUID uuid = TargetResolver.resolve(t);
|
||||
if (uuid == null) {
|
||||
ctx.sendMessage(Message.raw("Couldn't resolve '" + t + "'.").color(Color.RED));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Set<String> active = service.getActiveRankIds(uuid);
|
||||
String primary = service.getPrimaryRankId(uuid);
|
||||
ctx.sendMessage(Message.raw("=== Ranks for " + uuid + " ===").color(Color.YELLOW));
|
||||
if (active.isEmpty()) {
|
||||
ctx.sendMessage(Message.raw("Active: none").color(Color.GRAY));
|
||||
} else {
|
||||
ctx.sendMessage(Message.raw("Active: " + String.join(", ", active)).color(Color.WHITE));
|
||||
}
|
||||
ctx.sendMessage(Message.raw("Primary: " + (primary == null ? "none" : primary)).color(Color.WHITE));
|
||||
|
||||
List<net.kewwbec.ranks.model.Grant> recent = service.getRecentGrants(uuid, 10);
|
||||
if (!recent.isEmpty()) {
|
||||
ctx.sendMessage(Message.raw("Recent grants:").color(Color.WHITE));
|
||||
long now = System.currentTimeMillis();
|
||||
for (net.kewwbec.ranks.model.Grant g : recent) {
|
||||
String state;
|
||||
if (g.revoked()) state = "REVOKED";
|
||||
else if (g.expiresAt() != null && g.expiresAt() <= now) state = "EXPIRED";
|
||||
else state = "ACTIVE";
|
||||
String expires = g.expiresAt() == null ? "permanent"
|
||||
: "exp " + Instant.ofEpochMilli(g.expiresAt());
|
||||
ctx.sendMessage(Message.raw(
|
||||
" #" + g.id() + " " + state + " " + g.rankId() + " by " + g.grantedByName()
|
||||
+ " @ " + Instant.ofEpochMilli(g.grantedAt()) + " | " + expires
|
||||
).color(state.equals("ACTIVE") ? Color.GREEN : Color.GRAY));
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
ctx.sendMessage(Message.raw("DB error: " + e.getMessage()).color(Color.RED));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package net.kewwbec.ranks.command.rank;
|
||||
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg;
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.World;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import net.kewwbec.ranks.RanksPermissionsNodes;
|
||||
import net.kewwbec.ranks.model.Rank;
|
||||
import net.kewwbec.ranks.service.RankService;
|
||||
import net.kewwbec.ranks.util.ColorParser;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.awt.Color;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
public final class RankCommand extends AbstractCommandCollection {
|
||||
|
||||
public RankCommand(@Nonnull RankService service) {
|
||||
super("rank", "Manage ranks (create, delete, perms, hierarchy)");
|
||||
addSubCommand(new Create(service));
|
||||
addSubCommand(new Delete(service));
|
||||
addSubCommand(new AddPerm(service));
|
||||
addSubCommand(new RemovePerm(service));
|
||||
addSubCommand(new SetParent(service));
|
||||
addSubCommand(new SetPrefix(service));
|
||||
addSubCommand(new SetColor(service));
|
||||
addSubCommand(new ListAll(service));
|
||||
addSubCommand(new Info(service));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean canGeneratePermission() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- subcommands ----
|
||||
|
||||
public static final class Create extends AbstractPlayerCommand {
|
||||
private final RankService service;
|
||||
private final RequiredArg<String> idArg = withRequiredArg("id", "Rank id (no spaces, e.g. vip)", ArgTypes.STRING);
|
||||
private final RequiredArg<String> prefixArg = withRequiredArg("prefix", "Visual prefix (e.g. [VIP])", ArgTypes.STRING);
|
||||
private final RequiredArg<Integer> prioArg = withRequiredArg("priority", "Higher = shows first", ArgTypes.INTEGER);
|
||||
|
||||
public Create(@Nonnull RankService service) {
|
||||
super("create", "Create a new rank");
|
||||
this.service = service;
|
||||
requirePermission(RanksPermissionsNodes.RANK_CREATE);
|
||||
}
|
||||
@Override protected boolean canGeneratePermission() { return false; }
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef issuer, @Nonnull World world) {
|
||||
String id = idArg.get(ctx);
|
||||
String prefix = prefixArg.get(ctx);
|
||||
Integer prio = prioArg.get(ctx);
|
||||
if (id == null || prefix == null || prio == null) {
|
||||
ctx.sendMessage(Message.raw("Usage: /rank create <id> <prefix> <priority>").color(Color.YELLOW));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
boolean ok = service.createRank(id, null, id, prefix, null, prio);
|
||||
if (ok) ctx.sendMessage(Message.raw("Created rank '" + id + "' with prefix '" + prefix + "'. Use /rank setcolor to tint it.").color(Color.GREEN));
|
||||
else ctx.sendMessage(Message.raw("Rank '" + id + "' already exists.").color(Color.RED));
|
||||
} catch (SQLException e) {
|
||||
ctx.sendMessage(Message.raw("DB error: " + e.getMessage()).color(Color.RED));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Delete extends AbstractPlayerCommand {
|
||||
private final RankService service;
|
||||
private final RequiredArg<String> idArg = withRequiredArg("id", "Rank id", ArgTypes.STRING);
|
||||
|
||||
public Delete(@Nonnull RankService service) {
|
||||
super("delete", "Delete a rank (built-in groups can't be deleted)");
|
||||
this.service = service;
|
||||
requirePermission(RanksPermissionsNodes.RANK_DELETE);
|
||||
}
|
||||
@Override protected boolean canGeneratePermission() { return false; }
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef issuer, @Nonnull World world) {
|
||||
String id = idArg.get(ctx);
|
||||
if (id == null) {
|
||||
ctx.sendMessage(Message.raw("Usage: /rank delete <id>").color(Color.YELLOW));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
boolean ok = service.deleteRank(id);
|
||||
if (ok) ctx.sendMessage(Message.raw("Deleted rank '" + id + "'.").color(Color.GREEN));
|
||||
else ctx.sendMessage(Message.raw("Rank not found or is built-in.").color(Color.RED));
|
||||
} catch (SQLException e) {
|
||||
ctx.sendMessage(Message.raw("DB error: " + e.getMessage()).color(Color.RED));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class AddPerm extends AbstractPlayerCommand {
|
||||
private final RankService service;
|
||||
private final RequiredArg<String> idArg = withRequiredArg("id", "Rank id", ArgTypes.STRING);
|
||||
private final RequiredArg<String> permArg = withRequiredArg("permission", "Permission node (e.g. mycommand.use)", ArgTypes.STRING);
|
||||
|
||||
public AddPerm(@Nonnull RankService service) {
|
||||
super("addperm", "Grant a permission node to a rank");
|
||||
this.service = service;
|
||||
requirePermission(RanksPermissionsNodes.RANK_EDIT);
|
||||
}
|
||||
@Override protected boolean canGeneratePermission() { return false; }
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef issuer, @Nonnull World world) {
|
||||
String id = idArg.get(ctx);
|
||||
String perm = permArg.get(ctx);
|
||||
if (id == null || perm == null) {
|
||||
ctx.sendMessage(Message.raw("Usage: /rank addperm <id> <permission>").color(Color.YELLOW));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
service.addRankPermission(id, perm);
|
||||
ctx.sendMessage(Message.raw("Added '" + perm + "' to '" + id + "'.").color(Color.GREEN));
|
||||
} catch (SQLException e) {
|
||||
ctx.sendMessage(Message.raw("DB error: " + e.getMessage()).color(Color.RED));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class RemovePerm extends AbstractPlayerCommand {
|
||||
private final RankService service;
|
||||
private final RequiredArg<String> idArg = withRequiredArg("id", "Rank id", ArgTypes.STRING);
|
||||
private final RequiredArg<String> permArg = withRequiredArg("permission", "Permission node", ArgTypes.STRING);
|
||||
|
||||
public RemovePerm(@Nonnull RankService service) {
|
||||
super("removeperm", "Remove a permission node from a rank");
|
||||
this.service = service;
|
||||
requirePermission(RanksPermissionsNodes.RANK_EDIT);
|
||||
}
|
||||
@Override protected boolean canGeneratePermission() { return false; }
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef issuer, @Nonnull World world) {
|
||||
String id = idArg.get(ctx);
|
||||
String perm = permArg.get(ctx);
|
||||
if (id == null || perm == null) {
|
||||
ctx.sendMessage(Message.raw("Usage: /rank removeperm <id> <permission>").color(Color.YELLOW));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
service.removeRankPermission(id, perm);
|
||||
ctx.sendMessage(Message.raw("Removed '" + perm + "' from '" + id + "'.").color(Color.GREEN));
|
||||
} catch (SQLException e) {
|
||||
ctx.sendMessage(Message.raw("DB error: " + e.getMessage()).color(Color.RED));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class SetParent extends AbstractPlayerCommand {
|
||||
private final RankService service;
|
||||
private final RequiredArg<String> idArg = withRequiredArg("id", "Rank id", ArgTypes.STRING);
|
||||
private final RequiredArg<String> parentArg = withRequiredArg("parent", "Parent rank id (or 'none' to clear)", ArgTypes.STRING);
|
||||
|
||||
public SetParent(@Nonnull RankService service) {
|
||||
super("setparent", "Set a rank's parent for permission inheritance");
|
||||
this.service = service;
|
||||
requirePermission(RanksPermissionsNodes.RANK_EDIT);
|
||||
}
|
||||
@Override protected boolean canGeneratePermission() { return false; }
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef issuer, @Nonnull World world) {
|
||||
String id = idArg.get(ctx);
|
||||
String parent = parentArg.get(ctx);
|
||||
if (id == null || parent == null) {
|
||||
ctx.sendMessage(Message.raw("Usage: /rank setparent <id> <parent|none>").color(Color.YELLOW));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String resolved = "none".equalsIgnoreCase(parent) ? null : parent;
|
||||
boolean ok = service.setParent(id, resolved);
|
||||
if (ok) ctx.sendMessage(Message.raw("Set parent of '" + id + "' to '" + (resolved == null ? "none" : resolved) + "'.").color(Color.GREEN));
|
||||
else ctx.sendMessage(Message.raw("Rank '" + id + "' not found.").color(Color.RED));
|
||||
} catch (SQLException e) {
|
||||
ctx.sendMessage(Message.raw("DB error: " + e.getMessage()).color(Color.RED));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class SetPrefix extends AbstractPlayerCommand {
|
||||
private final RankService service;
|
||||
private final RequiredArg<String> idArg = withRequiredArg("id", "Rank id", ArgTypes.STRING);
|
||||
private final RequiredArg<String> prefixArg = withRequiredArg("prefix", "New prefix", ArgTypes.STRING);
|
||||
|
||||
public SetPrefix(@Nonnull RankService service) {
|
||||
super("setprefix", "Update a rank's chat prefix");
|
||||
this.service = service;
|
||||
requirePermission(RanksPermissionsNodes.RANK_EDIT);
|
||||
}
|
||||
@Override protected boolean canGeneratePermission() { return false; }
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef issuer, @Nonnull World world) {
|
||||
String id = idArg.get(ctx);
|
||||
String prefix = prefixArg.get(ctx);
|
||||
if (id == null || prefix == null) {
|
||||
ctx.sendMessage(Message.raw("Usage: /rank setprefix <id> <prefix>").color(Color.YELLOW));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
boolean ok = service.setPrefix(id, prefix);
|
||||
if (ok) ctx.sendMessage(Message.raw("Updated prefix for '" + id + "'.").color(Color.GREEN));
|
||||
else ctx.sendMessage(Message.raw("Rank '" + id + "' not found.").color(Color.RED));
|
||||
} catch (SQLException e) {
|
||||
ctx.sendMessage(Message.raw("DB error: " + e.getMessage()).color(Color.RED));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class SetColor extends AbstractPlayerCommand {
|
||||
private final RankService service;
|
||||
private final RequiredArg<String> idArg = withRequiredArg("id", "Rank id", ArgTypes.STRING);
|
||||
private final RequiredArg<String> colorArg = withRequiredArg("color", "Hex (#FFAA00 or FFAA00) or 'none' to clear", ArgTypes.STRING);
|
||||
|
||||
public SetColor(@Nonnull RankService service) {
|
||||
super("setcolor", "Set the chat color of a rank's prefix");
|
||||
this.service = service;
|
||||
requirePermission(RanksPermissionsNodes.RANK_EDIT);
|
||||
}
|
||||
@Override protected boolean canGeneratePermission() { return false; }
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef issuer, @Nonnull World world) {
|
||||
String id = idArg.get(ctx);
|
||||
String raw = colorArg.get(ctx);
|
||||
if (id == null || raw == null) {
|
||||
ctx.sendMessage(Message.raw("Usage: /rank setcolor <id> <#hex|none>").color(Color.YELLOW));
|
||||
return;
|
||||
}
|
||||
String normalized;
|
||||
if (raw.equalsIgnoreCase("none") || raw.equalsIgnoreCase("null") || raw.isBlank()) {
|
||||
normalized = null;
|
||||
} else {
|
||||
normalized = ColorParser.normalize(raw);
|
||||
if (normalized == null) {
|
||||
ctx.sendMessage(Message.raw("Invalid color '" + raw + "'. Use a hex like #FFAA00 or 'none'.").color(Color.RED));
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
boolean ok = service.setColor(id, normalized);
|
||||
if (!ok) {
|
||||
ctx.sendMessage(Message.raw("Rank '" + id + "' not found.").color(Color.RED));
|
||||
return;
|
||||
}
|
||||
if (normalized == null) {
|
||||
ctx.sendMessage(Message.raw("Cleared color on '" + id + "'.").color(Color.GREEN));
|
||||
} else {
|
||||
Color preview = ColorParser.parse(normalized);
|
||||
ctx.sendMessage(Message.empty()
|
||||
.insert(Message.raw("Set color on '" + id + "' to "))
|
||||
.insert(Message.raw(normalized).color(preview == null ? Color.WHITE : preview)));
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
ctx.sendMessage(Message.raw("DB error: " + e.getMessage()).color(Color.RED));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class ListAll extends AbstractPlayerCommand {
|
||||
private final RankService service;
|
||||
|
||||
public ListAll(@Nonnull RankService service) {
|
||||
super("list", "List all ranks");
|
||||
this.service = service;
|
||||
requirePermission(RanksPermissionsNodes.RANK_LIST);
|
||||
}
|
||||
@Override protected boolean canGeneratePermission() { return false; }
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef issuer, @Nonnull World world) {
|
||||
try {
|
||||
Map<String, Rank> all = service.getAllRanks();
|
||||
if (all.isEmpty()) {
|
||||
ctx.sendMessage(Message.raw("No ranks defined.").color(Color.YELLOW));
|
||||
return;
|
||||
}
|
||||
ctx.sendMessage(Message.raw("=== Ranks ===").color(Color.YELLOW));
|
||||
for (Rank r : all.values()) {
|
||||
String parent = r.parentId() == null ? "" : " (parent: " + r.parentId() + ")";
|
||||
String builtIn = r.builtIn() ? " [built-in]" : "";
|
||||
String color = r.color() == null ? "" : " color=" + r.color();
|
||||
ctx.sendMessage(Message.raw(
|
||||
r.id() + " prio=" + r.priority() + " prefix='" + r.prefix() + "'" + color + parent + builtIn
|
||||
).color(Color.WHITE));
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
ctx.sendMessage(Message.raw("DB error: " + e.getMessage()).color(Color.RED));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Info extends AbstractPlayerCommand {
|
||||
private final RankService service;
|
||||
private final RequiredArg<String> idArg = withRequiredArg("id", "Rank id", ArgTypes.STRING);
|
||||
|
||||
public Info(@Nonnull RankService service) {
|
||||
super("info", "Show a rank's details and permissions");
|
||||
this.service = service;
|
||||
requirePermission(RanksPermissionsNodes.RANK_INFO);
|
||||
}
|
||||
@Override protected boolean canGeneratePermission() { return false; }
|
||||
|
||||
@Override
|
||||
protected void execute(@Nonnull CommandContext ctx, @Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef issuer, @Nonnull World world) {
|
||||
String id = idArg.get(ctx);
|
||||
if (id == null) {
|
||||
ctx.sendMessage(Message.raw("Usage: /rank info <id>").color(Color.YELLOW));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Optional<Rank> r = service.getRank(id);
|
||||
if (r.isEmpty()) {
|
||||
ctx.sendMessage(Message.raw("Rank '" + id + "' not found.").color(Color.RED));
|
||||
return;
|
||||
}
|
||||
Rank rank = r.get();
|
||||
ctx.sendMessage(Message.raw("=== Rank: " + rank.id() + " ===").color(Color.YELLOW));
|
||||
ctx.sendMessage(Message.raw("Display: " + rank.displayName()).color(Color.WHITE));
|
||||
ctx.sendMessage(Message.raw("Prefix: " + rank.prefix()).color(Color.WHITE));
|
||||
if (rank.color() != null) {
|
||||
Color preview = ColorParser.parse(rank.color());
|
||||
ctx.sendMessage(Message.empty()
|
||||
.insert(Message.raw("Color: "))
|
||||
.insert(Message.raw(rank.color()).color(preview == null ? Color.WHITE : preview)));
|
||||
} else {
|
||||
ctx.sendMessage(Message.raw("Color: none").color(Color.WHITE));
|
||||
}
|
||||
ctx.sendMessage(Message.raw("Priority: " + rank.priority()).color(Color.WHITE));
|
||||
ctx.sendMessage(Message.raw("Parent: " + (rank.parentId() == null ? "none" : rank.parentId())).color(Color.WHITE));
|
||||
ctx.sendMessage(Message.raw("Built-in: " + rank.builtIn()).color(Color.WHITE));
|
||||
|
||||
Set<String> perms = service.getRankPermissions(id);
|
||||
if (perms.isEmpty()) {
|
||||
ctx.sendMessage(Message.raw("Direct perms: none").color(Color.GRAY));
|
||||
} else {
|
||||
ctx.sendMessage(Message.raw("Direct perms (" + perms.size() + "):").color(Color.WHITE));
|
||||
for (String perm : perms) {
|
||||
ctx.sendMessage(Message.raw(" " + perm).color(Color.WHITE));
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
ctx.sendMessage(Message.raw("DB error: " + e.getMessage()).color(Color.RED));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package net.kewwbec.ranks.config;
|
||||
|
||||
public final class RanksConfig {
|
||||
|
||||
public TableSection tables = new TableSection();
|
||||
public ProviderSection provider = new ProviderSection();
|
||||
public ChatSection chat = new ChatSection();
|
||||
public CacheSection cache = new CacheSection();
|
||||
|
||||
public static final class TableSection {
|
||||
public String ranks = "ranks";
|
||||
public String rank_perms = "ranks_perms";
|
||||
public String user_perms = "ranks_user_perms";
|
||||
public String grants = "ranks_grants";
|
||||
}
|
||||
|
||||
public static final class ProviderSection {
|
||||
/**
|
||||
* If true, our provider is the only one registered: Hytale's default file-backed provider is
|
||||
* removed during start. All permission state lives in MySQL.
|
||||
*
|
||||
* If false, our provider is added alongside Hytale's default. Useful for migration / dual-write.
|
||||
*/
|
||||
public boolean replace_default = true;
|
||||
}
|
||||
|
||||
public static final class ChatSection {
|
||||
/**
|
||||
* When true, every PlayerChatEvent's formatter is rewritten to prepend "<prefix> ".
|
||||
* Set false to disable chat prefix injection.
|
||||
*/
|
||||
public boolean inject_prefix = true;
|
||||
public String prefix_format = "{prefix} ";
|
||||
}
|
||||
|
||||
public static final class CacheSection {
|
||||
/**
|
||||
* Channel used to broadcast cache-invalidation messages across servers.
|
||||
*/
|
||||
public String invalidate_channel = "ranks.invalidate";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package net.kewwbec.ranks.config;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
public final class RanksConfigLoader {
|
||||
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
private RanksConfigLoader() {
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public static RanksConfig loadOrCreate(@Nonnull Path dataDirectory) throws IOException {
|
||||
Path file = dataDirectory.resolve("config.json");
|
||||
if (!Files.exists(file)) {
|
||||
Files.createDirectories(dataDirectory);
|
||||
RanksConfig fresh = new RanksConfig();
|
||||
Files.writeString(file, GSON.toJson(fresh), StandardCharsets.UTF_8);
|
||||
return fresh;
|
||||
}
|
||||
String json = Files.readString(file, StandardCharsets.UTF_8);
|
||||
RanksConfig parsed = GSON.fromJson(json, RanksConfig.class);
|
||||
if (parsed == null) parsed = new RanksConfig();
|
||||
if (parsed.tables == null) parsed.tables = new RanksConfig.TableSection();
|
||||
if (parsed.provider == null) parsed.provider = new RanksConfig.ProviderSection();
|
||||
if (parsed.chat == null) parsed.chat = new RanksConfig.ChatSection();
|
||||
if (parsed.cache == null) parsed.cache = new RanksConfig.CacheSection();
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package net.kewwbec.ranks.db;
|
||||
|
||||
import net.kewwbec.ranks.model.Grant;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class GrantRepository {
|
||||
|
||||
private final DataSource ds;
|
||||
private final String table;
|
||||
|
||||
public GrantRepository(@Nonnull DataSource ds, @Nonnull String table) {
|
||||
this.ds = ds;
|
||||
this.table = table;
|
||||
}
|
||||
|
||||
public long insert(@Nonnull UUID user, @Nonnull String rankId,
|
||||
@Nonnull UUID grantedByUuid, @Nonnull String grantedByName,
|
||||
@Nullable Long expiresAt, boolean primary) throws SQLException {
|
||||
String sql = "INSERT INTO " + table +
|
||||
" (user_uuid, rank_id, granted_at, granted_by_uuid, granted_by_name, expires_at, primary_rank) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
try (Connection c = ds.getConnection();
|
||||
PreparedStatement p = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
|
||||
p.setString(1, user.toString());
|
||||
p.setString(2, rankId);
|
||||
p.setLong(3, System.currentTimeMillis());
|
||||
p.setString(4, grantedByUuid.toString());
|
||||
p.setString(5, grantedByName);
|
||||
if (expiresAt != null) p.setLong(6, expiresAt); else p.setNull(6, Types.BIGINT);
|
||||
p.setBoolean(7, primary);
|
||||
p.executeUpdate();
|
||||
try (ResultSet keys = p.getGeneratedKeys()) {
|
||||
if (keys.next()) return keys.getLong(1);
|
||||
}
|
||||
return -1L;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all active grants of (user, rankId) as revoked. Returns rows affected.
|
||||
*/
|
||||
public int revokeActive(@Nonnull UUID user, @Nonnull String rankId,
|
||||
@Nonnull UUID revokerUuid, @Nonnull String revokerName) throws SQLException {
|
||||
String sql = "UPDATE " + table +
|
||||
" SET revoked = 1, revoked_at = ?, revoked_by_uuid = ?, revoked_by_name = ? " +
|
||||
"WHERE user_uuid = ? AND rank_id = ? AND revoked = 0";
|
||||
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
|
||||
p.setLong(1, System.currentTimeMillis());
|
||||
p.setString(2, revokerUuid.toString());
|
||||
p.setString(3, revokerName);
|
||||
p.setString(4, user.toString());
|
||||
p.setString(5, rankId);
|
||||
return p.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the primary flag from all of user's grants. Used before setting a new primary.
|
||||
*/
|
||||
public int clearPrimary(@Nonnull UUID user) throws SQLException {
|
||||
String sql = "UPDATE " + table + " SET primary_rank = 0 WHERE user_uuid = ? AND primary_rank = 1";
|
||||
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
|
||||
p.setString(1, user.toString());
|
||||
return p.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public int setPrimary(@Nonnull UUID user, @Nonnull String rankId) throws SQLException {
|
||||
String sql = "UPDATE " + table + " SET primary_rank = 1 " +
|
||||
"WHERE user_uuid = ? AND rank_id = ? AND revoked = 0 AND (expires_at IS NULL OR expires_at > ?)";
|
||||
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
|
||||
p.setString(1, user.toString());
|
||||
p.setString(2, rankId);
|
||||
p.setLong(3, System.currentTimeMillis());
|
||||
return p.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active rank IDs for a user (not revoked, not expired). Ordered by primary first then by id.
|
||||
*/
|
||||
@Nonnull
|
||||
public Set<String> getActiveRankIds(@Nonnull UUID user) throws SQLException {
|
||||
Set<String> out = new LinkedHashSet<>();
|
||||
long now = System.currentTimeMillis();
|
||||
String sql = "SELECT DISTINCT rank_id FROM " + table +
|
||||
" WHERE user_uuid = ? AND revoked = 0 AND (expires_at IS NULL OR expires_at > ?) " +
|
||||
"ORDER BY primary_rank DESC, rank_id ASC";
|
||||
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
|
||||
p.setString(1, user.toString());
|
||||
p.setLong(2, now);
|
||||
try (ResultSet rs = p.executeQuery()) {
|
||||
while (rs.next()) out.add(rs.getString(1));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getPrimaryRankId(@Nonnull UUID user) throws SQLException {
|
||||
long now = System.currentTimeMillis();
|
||||
String sql = "SELECT rank_id FROM " + table +
|
||||
" WHERE user_uuid = ? AND primary_rank = 1 AND revoked = 0 AND (expires_at IS NULL OR expires_at > ?) LIMIT 1";
|
||||
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
|
||||
p.setString(1, user.toString());
|
||||
p.setLong(2, now);
|
||||
try (ResultSet rs = p.executeQuery()) {
|
||||
if (rs.next()) return rs.getString(1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the most recent N grants for a user (active or historical).
|
||||
*/
|
||||
@Nonnull
|
||||
public List<Grant> getRecent(@Nonnull UUID user, int limit) throws SQLException {
|
||||
List<Grant> out = new ArrayList<>();
|
||||
String sql = "SELECT * FROM " + table + " WHERE user_uuid = ? ORDER BY granted_at DESC LIMIT ?";
|
||||
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
|
||||
p.setString(1, user.toString());
|
||||
p.setInt(2, limit);
|
||||
try (ResultSet rs = p.executeQuery()) {
|
||||
while (rs.next()) out.add(map(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private Grant map(@Nonnull ResultSet rs) throws SQLException {
|
||||
long expRaw = rs.getLong("expires_at");
|
||||
Long expires = rs.wasNull() ? null : expRaw;
|
||||
long revRaw = rs.getLong("revoked_at");
|
||||
Long revokedAt = rs.wasNull() ? null : revRaw;
|
||||
String revUuidStr = rs.getString("revoked_by_uuid");
|
||||
UUID revUuid = revUuidStr == null ? null : UUID.fromString(revUuidStr);
|
||||
return new Grant(
|
||||
rs.getLong("id"),
|
||||
UUID.fromString(rs.getString("user_uuid")),
|
||||
rs.getString("rank_id"),
|
||||
rs.getLong("granted_at"),
|
||||
UUID.fromString(rs.getString("granted_by_uuid")),
|
||||
rs.getString("granted_by_name"),
|
||||
expires,
|
||||
rs.getBoolean("primary_rank"),
|
||||
rs.getBoolean("revoked"),
|
||||
revokedAt,
|
||||
revUuid,
|
||||
rs.getString("revoked_by_name")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package net.kewwbec.ranks.db;
|
||||
|
||||
import net.kewwbec.ranks.model.Rank;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Types;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
public final class RankRepository {
|
||||
|
||||
private final DataSource ds;
|
||||
private final String ranksTable;
|
||||
private final String permsTable;
|
||||
|
||||
public RankRepository(@Nonnull DataSource ds, @Nonnull String ranksTable, @Nonnull String permsTable) {
|
||||
this.ds = ds;
|
||||
this.ranksTable = ranksTable;
|
||||
this.permsTable = permsTable;
|
||||
}
|
||||
|
||||
public boolean create(@Nonnull String id, @Nullable String parentId, @Nonnull String displayName,
|
||||
@Nonnull String prefix, @Nullable String color, int priority) throws SQLException {
|
||||
String sql = "INSERT IGNORE INTO " + ranksTable +
|
||||
" (id, parent_id, display_name, prefix, color, priority, built_in, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?)";
|
||||
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
|
||||
p.setString(1, id);
|
||||
if (parentId != null) p.setString(2, parentId); else p.setNull(2, Types.VARCHAR);
|
||||
p.setString(3, displayName);
|
||||
p.setString(4, prefix);
|
||||
if (color != null) p.setString(5, color); else p.setNull(5, Types.VARCHAR);
|
||||
p.setInt(6, priority);
|
||||
p.setLong(7, System.currentTimeMillis());
|
||||
return p.executeUpdate() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean delete(@Nonnull String id) throws SQLException {
|
||||
String sql = "DELETE FROM " + ranksTable + " WHERE id = ? AND built_in = 0";
|
||||
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
|
||||
p.setString(1, id);
|
||||
return p.executeUpdate() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean setParent(@Nonnull String id, @Nullable String parentId) throws SQLException {
|
||||
String sql = "UPDATE " + ranksTable + " SET parent_id = ? WHERE id = ?";
|
||||
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
|
||||
if (parentId != null) p.setString(1, parentId); else p.setNull(1, Types.VARCHAR);
|
||||
p.setString(2, id);
|
||||
return p.executeUpdate() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean setPrefix(@Nonnull String id, @Nonnull String prefix) throws SQLException {
|
||||
String sql = "UPDATE " + ranksTable + " SET prefix = ? WHERE id = ?";
|
||||
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
|
||||
p.setString(1, prefix);
|
||||
p.setString(2, id);
|
||||
return p.executeUpdate() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean setColor(@Nonnull String id, @Nullable String color) throws SQLException {
|
||||
String sql = "UPDATE " + ranksTable + " SET color = ? WHERE id = ?";
|
||||
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
|
||||
if (color != null) p.setString(1, color); else p.setNull(1, Types.VARCHAR);
|
||||
p.setString(2, id);
|
||||
return p.executeUpdate() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Optional<Rank> find(@Nonnull String id) throws SQLException {
|
||||
String sql = "SELECT * FROM " + ranksTable + " WHERE id = ?";
|
||||
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
|
||||
p.setString(1, id);
|
||||
try (ResultSet rs = p.executeQuery()) {
|
||||
if (rs.next()) return Optional.of(map(rs));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Map<String, Rank> findAll() throws SQLException {
|
||||
Map<String, Rank> out = new LinkedHashMap<>();
|
||||
String sql = "SELECT * FROM " + ranksTable + " ORDER BY priority DESC, id ASC";
|
||||
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql);
|
||||
ResultSet rs = p.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
Rank r = map(rs);
|
||||
out.put(r.id(), r);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public void addPermissions(@Nonnull String rankId, @Nonnull Set<String> perms) throws SQLException {
|
||||
String sql = "INSERT IGNORE INTO " + permsTable + " (rank_id, permission) VALUES (?, ?)";
|
||||
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
|
||||
for (String perm : perms) {
|
||||
p.setString(1, rankId);
|
||||
p.setString(2, perm);
|
||||
p.addBatch();
|
||||
}
|
||||
p.executeBatch();
|
||||
}
|
||||
}
|
||||
|
||||
public void removePermissions(@Nonnull String rankId, @Nonnull Set<String> perms) throws SQLException {
|
||||
String sql = "DELETE FROM " + permsTable + " WHERE rank_id = ? AND permission = ?";
|
||||
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
|
||||
for (String perm : perms) {
|
||||
p.setString(1, rankId);
|
||||
p.setString(2, perm);
|
||||
p.addBatch();
|
||||
}
|
||||
p.executeBatch();
|
||||
}
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Set<String> getPermissions(@Nonnull String rankId) throws SQLException {
|
||||
Set<String> out = new LinkedHashSet<>();
|
||||
String sql = "SELECT permission FROM " + permsTable + " WHERE rank_id = ?";
|
||||
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
|
||||
p.setString(1, rankId);
|
||||
try (ResultSet rs = p.executeQuery()) {
|
||||
while (rs.next()) out.add(rs.getString(1));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Set<String> getEffectivePermissions(@Nonnull String rankId, @Nonnull Map<String, Rank> allRanks) throws SQLException {
|
||||
Set<String> out = new LinkedHashSet<>();
|
||||
Set<String> visited = new HashSet<>();
|
||||
String current = rankId;
|
||||
while (current != null && visited.add(current)) {
|
||||
out.addAll(getPermissions(current));
|
||||
Rank r = allRanks.get(current);
|
||||
current = (r == null) ? null : r.parentId();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private Rank map(@Nonnull ResultSet rs) throws SQLException {
|
||||
return new Rank(
|
||||
rs.getString("id"),
|
||||
rs.getString("parent_id"),
|
||||
rs.getString("display_name"),
|
||||
rs.getString("prefix"),
|
||||
rs.getString("color"),
|
||||
rs.getInt("priority"),
|
||||
rs.getBoolean("built_in")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package net.kewwbec.ranks.db;
|
||||
|
||||
import com.hypixel.hytale.logger.HytaleLogger;
|
||||
import net.kewwbec.ranks.config.RanksConfig;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Types;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.logging.Level;
|
||||
|
||||
public final class SchemaBootstrap {
|
||||
|
||||
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||
|
||||
private SchemaBootstrap() {
|
||||
}
|
||||
|
||||
public static void apply(@Nonnull DataSource ds, @Nonnull RanksConfig.TableSection t) throws SQLException {
|
||||
try (Connection c = ds.getConnection(); Statement s = c.createStatement()) {
|
||||
s.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS %s (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
parent_id VARCHAR(64) NULL,
|
||||
display_name VARCHAR(64) NOT NULL,
|
||||
prefix VARCHAR(64) NOT NULL DEFAULT '',
|
||||
color VARCHAR(7) NULL,
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
built_in TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_priority (priority)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""".formatted(t.ranks));
|
||||
|
||||
addColumnIfMissing(c, t.ranks, "color", "VARCHAR(7) NULL");
|
||||
|
||||
s.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS %s (
|
||||
rank_id VARCHAR(64) NOT NULL,
|
||||
permission VARCHAR(255) NOT NULL,
|
||||
PRIMARY KEY (rank_id, permission),
|
||||
KEY idx_rank (rank_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""".formatted(t.rank_perms));
|
||||
|
||||
s.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS %s (
|
||||
user_uuid CHAR(36) NOT NULL,
|
||||
permission VARCHAR(255) NOT NULL,
|
||||
PRIMARY KEY (user_uuid, permission)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""".formatted(t.user_perms));
|
||||
|
||||
s.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS %s (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
user_uuid CHAR(36) NOT NULL,
|
||||
rank_id VARCHAR(64) NOT NULL,
|
||||
granted_at BIGINT NOT NULL,
|
||||
granted_by_uuid CHAR(36) NOT NULL,
|
||||
granted_by_name VARCHAR(64) NOT NULL,
|
||||
expires_at BIGINT NULL,
|
||||
primary_rank TINYINT(1) NOT NULL DEFAULT 0,
|
||||
revoked TINYINT(1) NOT NULL DEFAULT 0,
|
||||
revoked_at BIGINT NULL,
|
||||
revoked_by_uuid CHAR(36) NULL,
|
||||
revoked_by_name VARCHAR(64) NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_user (user_uuid),
|
||||
KEY idx_user_active (user_uuid, revoked, expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""".formatted(t.grants));
|
||||
|
||||
LOGGER.at(Level.INFO).log("Ranks schema bootstrap complete (%s, %s, %s, %s)",
|
||||
t.ranks, t.rank_perms, t.user_perms, t.grants);
|
||||
|
||||
seedBuiltInGroups(c, t);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addColumnIfMissing(@Nonnull Connection c, @Nonnull String table,
|
||||
@Nonnull String column, @Nonnull String typeClause) throws SQLException {
|
||||
String check = "SELECT COUNT(*) FROM information_schema.COLUMNS " +
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?";
|
||||
try (PreparedStatement p = c.prepareStatement(check)) {
|
||||
p.setString(1, table);
|
||||
p.setString(2, column);
|
||||
try (ResultSet rs = p.executeQuery()) {
|
||||
if (rs.next() && rs.getInt(1) > 0) return;
|
||||
}
|
||||
}
|
||||
try (Statement s = c.createStatement()) {
|
||||
s.executeUpdate("ALTER TABLE " + table + " ADD COLUMN " + column + " " + typeClause);
|
||||
LOGGER.at(Level.INFO).log("Migrated %s: added column %s", table, column);
|
||||
}
|
||||
}
|
||||
|
||||
private static void seedBuiltInGroups(@Nonnull Connection c, @Nonnull RanksConfig.TableSection t) throws SQLException {
|
||||
Map<String, BuiltIn> builtIns = new LinkedHashMap<>();
|
||||
builtIns.put("hytale:None", new BuiltIn(null, "None", "", null, 0));
|
||||
builtIns.put("hytale:Adventurer", new BuiltIn("hytale:None", "Adventurer", "", null, 10));
|
||||
builtIns.put("hytale:Builder", new BuiltIn("hytale:Adventurer", "Builder", "", "#55FF55", 20));
|
||||
builtIns.put("hytale:WorldEditor", new BuiltIn("hytale:Builder", "WorldEditor", "", "#55FFFF", 30));
|
||||
builtIns.put("hytale:ServerEditor", new BuiltIn("hytale:WorldEditor", "ServerEditor", "", "#FFAA00", 40));
|
||||
builtIns.put("hytale:Admin", new BuiltIn("hytale:ServerEditor","Admin", "", "#FF5555", 100));
|
||||
|
||||
String insertRank = "INSERT IGNORE INTO " + t.ranks +
|
||||
" (id, parent_id, display_name, prefix, color, priority, built_in, created_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?)";
|
||||
String insertPerm = "INSERT IGNORE INTO " + t.rank_perms + " (rank_id, permission) VALUES (?, ?)";
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
try (PreparedStatement r = c.prepareStatement(insertRank);
|
||||
PreparedStatement p = c.prepareStatement(insertPerm)) {
|
||||
|
||||
for (Map.Entry<String, BuiltIn> e : builtIns.entrySet()) {
|
||||
BuiltIn b = e.getValue();
|
||||
r.setString(1, e.getKey());
|
||||
if (b.parent != null) r.setString(2, b.parent); else r.setNull(2, Types.VARCHAR);
|
||||
r.setString(3, b.display);
|
||||
r.setString(4, b.prefix);
|
||||
if (b.color != null) r.setString(5, b.color); else r.setNull(5, Types.VARCHAR);
|
||||
r.setInt(6, b.priority);
|
||||
r.setLong(7, now);
|
||||
r.executeUpdate();
|
||||
}
|
||||
|
||||
p.setString(1, "hytale:Admin");
|
||||
p.setString(2, "*");
|
||||
p.executeUpdate();
|
||||
}
|
||||
|
||||
try (PreparedStatement count = c.prepareStatement("SELECT COUNT(*) FROM " + t.ranks + " WHERE built_in = 1")) {
|
||||
ResultSet rs = count.executeQuery();
|
||||
if (rs.next()) {
|
||||
LOGGER.at(Level.INFO).log("Built-in groups in DB: %d", rs.getInt(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record BuiltIn(String parent, String display, String prefix, String color, int priority) {}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package net.kewwbec.ranks.db;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class UserPermRepository {
|
||||
|
||||
private final DataSource ds;
|
||||
private final String table;
|
||||
|
||||
public UserPermRepository(@Nonnull DataSource ds, @Nonnull String table) {
|
||||
this.ds = ds;
|
||||
this.table = table;
|
||||
}
|
||||
|
||||
public void addPermissions(@Nonnull UUID user, @Nonnull Set<String> perms) throws SQLException {
|
||||
String sql = "INSERT IGNORE INTO " + table + " (user_uuid, permission) VALUES (?, ?)";
|
||||
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
|
||||
for (String perm : perms) {
|
||||
p.setString(1, user.toString());
|
||||
p.setString(2, perm);
|
||||
p.addBatch();
|
||||
}
|
||||
p.executeBatch();
|
||||
}
|
||||
}
|
||||
|
||||
public void removePermissions(@Nonnull UUID user, @Nonnull Set<String> perms) throws SQLException {
|
||||
String sql = "DELETE FROM " + table + " WHERE user_uuid = ? AND permission = ?";
|
||||
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
|
||||
for (String perm : perms) {
|
||||
p.setString(1, user.toString());
|
||||
p.setString(2, perm);
|
||||
p.addBatch();
|
||||
}
|
||||
p.executeBatch();
|
||||
}
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Set<String> getPermissions(@Nonnull UUID user) throws SQLException {
|
||||
Set<String> out = new LinkedHashSet<>();
|
||||
String sql = "SELECT permission FROM " + table + " WHERE user_uuid = ?";
|
||||
try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) {
|
||||
p.setString(1, user.toString());
|
||||
try (ResultSet rs = p.executeQuery()) {
|
||||
while (rs.next()) out.add(rs.getString(1));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package net.kewwbec.ranks.listener;
|
||||
|
||||
import com.hypixel.hytale.logger.HytaleLogger;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.event.events.player.PlayerChatEvent;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import net.kewwbec.ranks.config.RanksConfig;
|
||||
import net.kewwbec.ranks.model.Rank;
|
||||
import net.kewwbec.ranks.service.RankService;
|
||||
import net.kewwbec.ranks.util.ColorParser;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.awt.Color;
|
||||
import java.sql.SQLException;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* Rewrites the chat formatter to render "<colored-prefix> <username>: <msg>".
|
||||
* If the sender's rank has a color, only the prefix segment is tinted; the username and
|
||||
* body stay default-colored. If color is null, prefix is rendered uncolored.
|
||||
*/
|
||||
public final class ChatPrefixListener {
|
||||
|
||||
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||
|
||||
private final RankService service;
|
||||
private final RanksConfig.ChatSection cfg;
|
||||
|
||||
public ChatPrefixListener(@Nonnull RankService service, @Nonnull RanksConfig.ChatSection cfg) {
|
||||
this.service = service;
|
||||
this.cfg = cfg;
|
||||
}
|
||||
|
||||
public void onChat(@Nonnull PlayerChatEvent event) {
|
||||
if (!cfg.inject_prefix) return;
|
||||
PlayerRef sender = event.getSender();
|
||||
if (sender == null) return;
|
||||
|
||||
Rank primary;
|
||||
try {
|
||||
primary = service.getPrimaryRank(sender.getUuid());
|
||||
} catch (SQLException e) {
|
||||
LOGGER.at(Level.WARNING).log("Prefix lookup failed for %s: %s", sender.getUuid(), e.getMessage());
|
||||
return;
|
||||
}
|
||||
if (primary == null) return;
|
||||
|
||||
String prefix = primary.prefix();
|
||||
if (prefix == null || prefix.isEmpty()) return;
|
||||
|
||||
String renderedPrefix = cfg.prefix_format.replace("{prefix}", prefix);
|
||||
Color color = ColorParser.parse(primary.color());
|
||||
|
||||
event.setFormatter((ref, content) -> {
|
||||
Message prefixMsg = Message.raw(renderedPrefix);
|
||||
if (color != null) prefixMsg = prefixMsg.color(color);
|
||||
return Message.empty()
|
||||
.insert(prefixMsg)
|
||||
.insert(Message.raw(ref.getUsername() + ": " + content));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package net.kewwbec.ranks.model;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.UUID;
|
||||
|
||||
public record Grant(
|
||||
long id,
|
||||
@Nonnull UUID userUuid,
|
||||
@Nonnull String rankId,
|
||||
long grantedAt,
|
||||
@Nonnull UUID grantedByUuid,
|
||||
@Nonnull String grantedByName,
|
||||
@Nullable Long expiresAt,
|
||||
boolean primaryRank,
|
||||
boolean revoked,
|
||||
@Nullable Long revokedAt,
|
||||
@Nullable UUID revokedByUuid,
|
||||
@Nullable String revokedByName
|
||||
) {
|
||||
public boolean isCurrentlyActive(long now) {
|
||||
if (revoked) return false;
|
||||
if (expiresAt != null && expiresAt <= now) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package net.kewwbec.ranks.model;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public record Rank(
|
||||
@Nonnull String id,
|
||||
@Nullable String parentId,
|
||||
@Nonnull String displayName,
|
||||
@Nonnull String prefix,
|
||||
@Nullable String color,
|
||||
int priority,
|
||||
boolean builtIn
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package net.kewwbec.ranks.provider;
|
||||
|
||||
import com.hypixel.hytale.logger.HytaleLogger;
|
||||
import com.hypixel.hytale.server.core.permissions.provider.PermissionProvider;
|
||||
import net.kewwbec.ranks.db.GrantRepository;
|
||||
import net.kewwbec.ranks.db.RankRepository;
|
||||
import net.kewwbec.ranks.db.UserPermRepository;
|
||||
import net.kewwbec.ranks.model.Rank;
|
||||
import net.kewwbec.ranks.service.RanksCache;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* MySQL-backed PermissionProvider. Replaces Hytale's default file-backed provider.
|
||||
*
|
||||
* - Group operations write to our ranks/rank_perms tables.
|
||||
* - User-group operations write grants.
|
||||
* - User-perm operations write to ranks_user_perms.
|
||||
* - Reads consult the per-user cache first, fall back to MySQL.
|
||||
*/
|
||||
public final class RanksPermissionProvider implements PermissionProvider {
|
||||
|
||||
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||
public static final String NAME = "kewwbec-ranks";
|
||||
|
||||
private final RankRepository ranks;
|
||||
private final GrantRepository grants;
|
||||
private final UserPermRepository userPerms;
|
||||
private final RanksCache cache;
|
||||
|
||||
public RanksPermissionProvider(@Nonnull RankRepository ranks,
|
||||
@Nonnull GrantRepository grants,
|
||||
@Nonnull UserPermRepository userPerms,
|
||||
@Nonnull RanksCache cache) {
|
||||
this.ranks = ranks;
|
||||
this.grants = grants;
|
||||
this.userPerms = userPerms;
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nonnull
|
||||
public String getName() {
|
||||
return NAME;
|
||||
}
|
||||
|
||||
// ---- user permissions ----
|
||||
|
||||
@Override
|
||||
public void addUserPermissions(@Nonnull UUID uuid, @Nonnull Set<String> perms) {
|
||||
try {
|
||||
userPerms.addPermissions(uuid, perms);
|
||||
cache.invalidateUser(uuid);
|
||||
} catch (SQLException e) {
|
||||
log(e, "addUserPermissions(%s)", uuid);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeUserPermissions(@Nonnull UUID uuid, @Nonnull Set<String> perms) {
|
||||
try {
|
||||
userPerms.removePermissions(uuid, perms);
|
||||
cache.invalidateUser(uuid);
|
||||
} catch (SQLException e) {
|
||||
log(e, "removeUserPermissions(%s)", uuid);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getUserPermissions(@Nonnull UUID uuid) {
|
||||
Set<String> cached = cache.getCachedPerms(uuid);
|
||||
if (cached != null) return cached;
|
||||
try {
|
||||
Set<String> direct = userPerms.getPermissions(uuid);
|
||||
Set<String> groups = getGroupsForUser(uuid);
|
||||
Map<String, Rank> all = ranks.findAll();
|
||||
|
||||
Set<String> effective = new LinkedHashSet<>(direct);
|
||||
for (String g : groups) {
|
||||
effective.addAll(ranks.getEffectivePermissions(g, all));
|
||||
}
|
||||
cache.putPerms(uuid, effective);
|
||||
return effective;
|
||||
} catch (SQLException e) {
|
||||
log(e, "getUserPermissions(%s)", uuid);
|
||||
return Collections.emptySet();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- group permissions ----
|
||||
|
||||
@Override
|
||||
public void addGroupPermissions(@Nonnull String groupId, @Nonnull Set<String> perms) {
|
||||
try {
|
||||
ranks.addPermissions(groupId, perms);
|
||||
cache.invalidateRank(groupId);
|
||||
} catch (SQLException e) {
|
||||
log(e, "addGroupPermissions(%s)", groupId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeGroupPermissions(@Nonnull String groupId, @Nonnull Set<String> perms) {
|
||||
try {
|
||||
ranks.removePermissions(groupId, perms);
|
||||
cache.invalidateRank(groupId);
|
||||
} catch (SQLException e) {
|
||||
log(e, "removeGroupPermissions(%s)", groupId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getGroupPermissions(@Nonnull String groupId) {
|
||||
try {
|
||||
return ranks.getPermissions(groupId);
|
||||
} catch (SQLException e) {
|
||||
log(e, "getGroupPermissions(%s)", groupId);
|
||||
return Collections.emptySet();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- user <-> group ----
|
||||
|
||||
@Override
|
||||
public void addUserToGroup(@Nonnull UUID uuid, @Nonnull String groupId) {
|
||||
try {
|
||||
Optional<Rank> r = ranks.find(groupId);
|
||||
if (r.isEmpty()) {
|
||||
LOGGER.at(Level.WARNING).log("addUserToGroup(%s, %s): unknown group", uuid, groupId);
|
||||
return;
|
||||
}
|
||||
grants.insert(uuid, groupId, uuid, "system", null, false);
|
||||
cache.invalidateUser(uuid);
|
||||
} catch (SQLException e) {
|
||||
log(e, "addUserToGroup(%s, %s)", uuid, groupId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeUserFromGroup(@Nonnull UUID uuid, @Nonnull String groupId) {
|
||||
try {
|
||||
grants.revokeActive(uuid, groupId, uuid, "system");
|
||||
cache.invalidateUser(uuid);
|
||||
} catch (SQLException e) {
|
||||
log(e, "removeUserFromGroup(%s, %s)", uuid, groupId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getGroupsForUser(@Nonnull UUID uuid) {
|
||||
Set<String> cached = cache.getCachedGroups(uuid);
|
||||
if (cached != null) return cached;
|
||||
try {
|
||||
Set<String> active = grants.getActiveRankIds(uuid);
|
||||
cache.putGroups(uuid, active);
|
||||
return active;
|
||||
} catch (SQLException e) {
|
||||
log(e, "getGroupsForUser(%s)", uuid);
|
||||
return Collections.emptySet();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setUserGroup(@Nonnull UUID uuid, @Nonnull String groupId) {
|
||||
try {
|
||||
Optional<Rank> r = ranks.find(groupId);
|
||||
if (r.isEmpty()) {
|
||||
LOGGER.at(Level.WARNING).log("setUserGroup(%s, %s): unknown group", uuid, groupId);
|
||||
return;
|
||||
}
|
||||
for (String existing : grants.getActiveRankIds(uuid)) {
|
||||
grants.revokeActive(uuid, existing, uuid, "system");
|
||||
}
|
||||
grants.insert(uuid, groupId, uuid, "system", null, true);
|
||||
cache.invalidateUser(uuid);
|
||||
} catch (SQLException e) {
|
||||
log(e, "setUserGroup(%s, %s)", uuid, groupId);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- group metadata ----
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public String getGroupParent(@Nonnull String groupId) {
|
||||
try {
|
||||
return ranks.find(groupId).map(Rank::parentId).orElse(null);
|
||||
} catch (SQLException e) {
|
||||
log(e, "getGroupParent(%s)", groupId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nonnull
|
||||
public Set<String> getAllRegisteredGroups() {
|
||||
try {
|
||||
return ranks.findAll().keySet();
|
||||
} catch (SQLException e) {
|
||||
log(e, "getAllRegisteredGroups");
|
||||
return Collections.emptySet();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nonnull
|
||||
public Set<String> getEffectiveGroupPermissions(@Nonnull String groupId) {
|
||||
try {
|
||||
return ranks.getEffectivePermissions(groupId, ranks.findAll());
|
||||
} catch (SQLException e) {
|
||||
log(e, "getEffectiveGroupPermissions(%s)", groupId);
|
||||
return Collections.emptySet();
|
||||
}
|
||||
}
|
||||
|
||||
private static void log(@Nonnull SQLException e, @Nonnull String op, @Nonnull Object... args) {
|
||||
((HytaleLogger.Api) LOGGER.at(Level.WARNING).withCause(e)).log("Ranks provider: " + op + " failed", args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package net.kewwbec.ranks.service;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Sent over the bus when one server's state changes and others need to drop caches.
|
||||
* One of these fields is set:
|
||||
* - userUuid set: invalidate that one user's cached perms
|
||||
* - rankId set: a rank's perms or hierarchy changed; invalidate everyone holding that rank (we drop the whole cache for simplicity)
|
||||
* - allUsers true: drop all caches (rare; on /rank reload)
|
||||
*/
|
||||
public final class InvalidationPayload {
|
||||
|
||||
@Nullable public String userUuid;
|
||||
@Nullable public String rankId;
|
||||
public boolean allUsers;
|
||||
public String fromServerId;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package net.kewwbec.ranks.service;
|
||||
|
||||
import com.hypixel.hytale.logger.HytaleLogger;
|
||||
import net.kewwbec.ranks.db.GrantRepository;
|
||||
import net.kewwbec.ranks.db.RankRepository;
|
||||
import net.kewwbec.ranks.db.UserPermRepository;
|
||||
import net.kewwbec.ranks.model.Grant;
|
||||
import net.kewwbec.ranks.model.Rank;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* High-level rank management. Wraps repositories and invalidates caches after writes.
|
||||
*/
|
||||
public final class RankService {
|
||||
|
||||
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||
|
||||
private final RankRepository ranks;
|
||||
private final GrantRepository grants;
|
||||
private final UserPermRepository userPerms;
|
||||
private final RanksCache cache;
|
||||
|
||||
public RankService(@Nonnull RankRepository ranks,
|
||||
@Nonnull GrantRepository grants,
|
||||
@Nonnull UserPermRepository userPerms,
|
||||
@Nonnull RanksCache cache) {
|
||||
this.ranks = ranks;
|
||||
this.grants = grants;
|
||||
this.userPerms = userPerms;
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
// ---- ranks ----
|
||||
|
||||
public boolean createRank(@Nonnull String id, @Nullable String parentId,
|
||||
@Nonnull String displayName, @Nonnull String prefix,
|
||||
@Nullable String color, int priority) throws SQLException {
|
||||
boolean created = ranks.create(id, parentId, displayName, prefix, color, priority);
|
||||
if (created) cache.invalidateAll();
|
||||
return created;
|
||||
}
|
||||
|
||||
public boolean deleteRank(@Nonnull String id) throws SQLException {
|
||||
boolean deleted = ranks.delete(id);
|
||||
if (deleted) cache.invalidateRank(id);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public boolean setParent(@Nonnull String id, @Nullable String parentId) throws SQLException {
|
||||
boolean ok = ranks.setParent(id, parentId);
|
||||
if (ok) cache.invalidateRank(id);
|
||||
return ok;
|
||||
}
|
||||
|
||||
public boolean setPrefix(@Nonnull String id, @Nonnull String prefix) throws SQLException {
|
||||
boolean ok = ranks.setPrefix(id, prefix);
|
||||
if (ok) cache.invalidateAll();
|
||||
return ok;
|
||||
}
|
||||
|
||||
public boolean setColor(@Nonnull String id, @Nullable String color) throws SQLException {
|
||||
boolean ok = ranks.setColor(id, color);
|
||||
if (ok) cache.invalidateAll();
|
||||
return ok;
|
||||
}
|
||||
|
||||
public void addRankPermission(@Nonnull String id, @Nonnull String permission) throws SQLException {
|
||||
ranks.addPermissions(id, Set.of(permission));
|
||||
cache.invalidateRank(id);
|
||||
}
|
||||
|
||||
public void removeRankPermission(@Nonnull String id, @Nonnull String permission) throws SQLException {
|
||||
ranks.removePermissions(id, Set.of(permission));
|
||||
cache.invalidateRank(id);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Optional<Rank> getRank(@Nonnull String id) throws SQLException {
|
||||
return ranks.find(id);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Map<String, Rank> getAllRanks() throws SQLException {
|
||||
return ranks.findAll();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Set<String> getRankPermissions(@Nonnull String id) throws SQLException {
|
||||
return ranks.getPermissions(id);
|
||||
}
|
||||
|
||||
// ---- grants ----
|
||||
|
||||
public long grant(@Nonnull UUID target, @Nonnull String rankId,
|
||||
@Nonnull UUID grantedBy, @Nonnull String grantedByName,
|
||||
@Nullable Long durationMs) throws SQLException {
|
||||
if (ranks.find(rankId).isEmpty()) return -1L;
|
||||
Long expiresAt = (durationMs == null) ? null : System.currentTimeMillis() + durationMs;
|
||||
long id = grants.insert(target, rankId, grantedBy, grantedByName, expiresAt, false);
|
||||
cache.invalidateUser(target);
|
||||
return id;
|
||||
}
|
||||
|
||||
public int revoke(@Nonnull UUID target, @Nonnull String rankId,
|
||||
@Nonnull UUID revokedBy, @Nonnull String revokedByName) throws SQLException {
|
||||
int n = grants.revokeActive(target, rankId, revokedBy, revokedByName);
|
||||
if (n > 0) cache.invalidateUser(target);
|
||||
return n;
|
||||
}
|
||||
|
||||
public boolean setPrimary(@Nonnull UUID target, @Nonnull String rankId) throws SQLException {
|
||||
Set<String> active = grants.getActiveRankIds(target);
|
||||
if (!active.contains(rankId)) return false;
|
||||
grants.clearPrimary(target);
|
||||
grants.setPrimary(target, rankId);
|
||||
cache.invalidateUser(target);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public Set<String> getActiveRankIds(@Nonnull UUID user) throws SQLException {
|
||||
return grants.getActiveRankIds(user);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getPrimaryRankId(@Nonnull UUID user) throws SQLException {
|
||||
return grants.getPrimaryRankId(user);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public List<Grant> getRecentGrants(@Nonnull UUID user, int limit) throws SQLException {
|
||||
return grants.getRecent(user, limit);
|
||||
}
|
||||
|
||||
// ---- player utility ----
|
||||
|
||||
@Nullable
|
||||
public Rank getPrimaryRank(@Nonnull UUID user) throws SQLException {
|
||||
String primaryId = grants.getPrimaryRankId(user);
|
||||
if (primaryId == null) return null;
|
||||
return ranks.find(primaryId).orElse(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package net.kewwbec.ranks.service;
|
||||
|
||||
import com.hypixel.hytale.logger.HytaleLogger;
|
||||
import net.kewwbec.networkcore.api.MessageBus;
|
||||
import net.kewwbec.ranks.config.RanksConfig;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* Caches per-user effective permissions to avoid hitting MySQL on every hasPermission call.
|
||||
* Invalidation is cross-server via NetworkCore MessageBus.
|
||||
*/
|
||||
public final class RanksCache {
|
||||
|
||||
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||
|
||||
private final MessageBus bus;
|
||||
private final String channel;
|
||||
private final String serverId;
|
||||
|
||||
private final Map<UUID, Set<String>> userPermsCache = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Set<String>> userGroupsCache = new ConcurrentHashMap<>();
|
||||
|
||||
@Nullable private MessageBus.Subscription subscription;
|
||||
|
||||
public RanksCache(@Nonnull MessageBus bus, @Nonnull RanksConfig.CacheSection cfg, @Nonnull String serverId) {
|
||||
this.bus = bus;
|
||||
this.channel = cfg.invalidate_channel;
|
||||
this.serverId = serverId;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
subscription = bus.subscribe(channel, InvalidationPayload.class, this::onRemote);
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if (subscription != null) {
|
||||
try { subscription.close(); } catch (RuntimeException ignored) {}
|
||||
subscription = null;
|
||||
}
|
||||
userPermsCache.clear();
|
||||
userGroupsCache.clear();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Set<String> getCachedPerms(@Nonnull UUID user) {
|
||||
return userPermsCache.get(user);
|
||||
}
|
||||
|
||||
public void putPerms(@Nonnull UUID user, @Nonnull Set<String> perms) {
|
||||
userPermsCache.put(user, new HashSet<>(perms));
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Set<String> getCachedGroups(@Nonnull UUID user) {
|
||||
return userGroupsCache.get(user);
|
||||
}
|
||||
|
||||
public void putGroups(@Nonnull UUID user, @Nonnull Set<String> groups) {
|
||||
userGroupsCache.put(user, new HashSet<>(groups));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a single user's caches and broadcast so other servers do the same.
|
||||
*/
|
||||
public void invalidateUser(@Nonnull UUID user) {
|
||||
userPermsCache.remove(user);
|
||||
userGroupsCache.remove(user);
|
||||
InvalidationPayload p = new InvalidationPayload();
|
||||
p.userUuid = user.toString();
|
||||
p.fromServerId = serverId;
|
||||
bus.publish(channel, p);
|
||||
}
|
||||
|
||||
/**
|
||||
* A rank changed - drop every cached user (any of them might hold that rank, and inheritance
|
||||
* makes targeted invalidation hairier than just clearing everything).
|
||||
*/
|
||||
public void invalidateRank(@Nonnull String rankId) {
|
||||
userPermsCache.clear();
|
||||
userGroupsCache.clear();
|
||||
InvalidationPayload p = new InvalidationPayload();
|
||||
p.rankId = rankId;
|
||||
p.fromServerId = serverId;
|
||||
bus.publish(channel, p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop all caches everywhere (used by /perm reload or after bulk edits).
|
||||
*/
|
||||
public void invalidateAll() {
|
||||
userPermsCache.clear();
|
||||
userGroupsCache.clear();
|
||||
InvalidationPayload p = new InvalidationPayload();
|
||||
p.allUsers = true;
|
||||
p.fromServerId = serverId;
|
||||
bus.publish(channel, p);
|
||||
}
|
||||
|
||||
private void onRemote(@Nonnull InvalidationPayload p) {
|
||||
if (p.fromServerId != null && p.fromServerId.equals(serverId)) return;
|
||||
if (p.allUsers || p.rankId != null) {
|
||||
userPermsCache.clear();
|
||||
userGroupsCache.clear();
|
||||
return;
|
||||
}
|
||||
if (p.userUuid != null) {
|
||||
try {
|
||||
UUID u = UUID.fromString(p.userUuid);
|
||||
userPermsCache.remove(u);
|
||||
userGroupsCache.remove(u);
|
||||
} catch (IllegalArgumentException e) {
|
||||
LOGGER.at(Level.WARNING).log("Bad UUID in invalidation: %s", p.userUuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package net.kewwbec.ranks.util;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.awt.Color;
|
||||
|
||||
/**
|
||||
* Parses and normalizes hex color strings stored on Rank.color().
|
||||
*
|
||||
* Accepts: "#RGB", "#RRGGBB", "RGB", "RRGGBB", or any standard color name java.awt.Color knows.
|
||||
* Stored form is canonical "#RRGGBB" (upper-case) so equality comparisons are simple.
|
||||
*/
|
||||
public final class ColorParser {
|
||||
|
||||
private ColorParser() {
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String normalize(@Nullable String raw) {
|
||||
if (raw == null) return null;
|
||||
String s = raw.trim();
|
||||
if (s.isEmpty() || s.equalsIgnoreCase("none") || s.equalsIgnoreCase("null")) return null;
|
||||
Color c = parse(s);
|
||||
if (c == null) return null;
|
||||
return String.format("#%02X%02X%02X", c.getRed(), c.getGreen(), c.getBlue());
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static Color parse(@Nullable String raw) {
|
||||
if (raw == null) return null;
|
||||
String s = raw.trim();
|
||||
if (s.isEmpty()) return null;
|
||||
|
||||
String hex = s.startsWith("#") ? s.substring(1) : s;
|
||||
if (hex.length() == 3 || hex.length() == 6) {
|
||||
try {
|
||||
if (hex.length() == 3) {
|
||||
char r = hex.charAt(0), g = hex.charAt(1), b = hex.charAt(2);
|
||||
hex = "" + r + r + g + g + b + b;
|
||||
}
|
||||
return new Color(Integer.parseInt(hex, 16));
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
|
||||
try {
|
||||
java.lang.reflect.Field field = Color.class.getField(s.toUpperCase());
|
||||
Object value = field.get(null);
|
||||
if (value instanceof Color color) return color;
|
||||
} catch (ReflectiveOperationException ignored) {}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package net.kewwbec.ranks.util;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public final class DurationParser {
|
||||
|
||||
private DurationParser() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses durations like "30s", "5m", "2h", "7d", "1w", "3mo", "1y". Returns millis, or null if invalid.
|
||||
*/
|
||||
@Nullable
|
||||
public static Long parseMillis(@Nonnull String input) {
|
||||
if (input == null || input.isBlank()) return null;
|
||||
String s = input.trim().toLowerCase();
|
||||
|
||||
int splitAt = -1;
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
if (!Character.isDigit(s.charAt(i))) { splitAt = i; break; }
|
||||
}
|
||||
if (splitAt <= 0) return null;
|
||||
long n;
|
||||
try {
|
||||
n = Long.parseLong(s.substring(0, splitAt));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
if (n <= 0) return null;
|
||||
String unit = s.substring(splitAt);
|
||||
return switch (unit) {
|
||||
case "s" -> n * 1_000L;
|
||||
case "m" -> n * 60_000L;
|
||||
case "h" -> n * 3_600_000L;
|
||||
case "d" -> n * 86_400_000L;
|
||||
case "w" -> n * 7L * 86_400_000L;
|
||||
case "mo" -> n * 30L * 86_400_000L;
|
||||
case "y" -> n * 365L * 86_400_000L;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public static String formatRemaining(long millis) {
|
||||
if (millis <= 0) return "expired";
|
||||
long s = millis / 1000L;
|
||||
long days = s / 86400L; s %= 86400L;
|
||||
long hours = s / 3600L; s %= 3600L;
|
||||
long mins = s / 60L; s %= 60L;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (days > 0) sb.append(days).append("d ");
|
||||
if (hours > 0) sb.append(hours).append("h ");
|
||||
if (mins > 0) sb.append(mins).append("m ");
|
||||
if (sb.length() == 0) sb.append(s).append("s");
|
||||
return sb.toString().trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package net.kewwbec.ranks.util;
|
||||
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.Universe;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class TargetResolver {
|
||||
|
||||
private TargetResolver() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a target to a UUID. Accepts:
|
||||
* - Plain UUID string (works for any player, online or offline)
|
||||
* - Online player name (case-insensitive)
|
||||
*
|
||||
* Returns null if neither matches.
|
||||
*/
|
||||
@Nullable
|
||||
public static UUID resolve(@Nonnull String input) {
|
||||
if (input == null || input.isBlank()) return null;
|
||||
String s = input.trim();
|
||||
|
||||
try {
|
||||
return UUID.fromString(s);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
}
|
||||
|
||||
Universe universe = Universe.get();
|
||||
if (universe == null) return null;
|
||||
for (PlayerRef ref : universe.getPlayers()) {
|
||||
if (ref.getUsername().equalsIgnoreCase(s)) return ref.getUuid();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String resolveName(@Nonnull UUID uuid) {
|
||||
Universe universe = Universe.get();
|
||||
if (universe == null) return null;
|
||||
PlayerRef ref = universe.getPlayer(uuid);
|
||||
return ref == null ? null : ref.getUsername();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"Group": "kewwbec",
|
||||
"Name": "Ranks",
|
||||
"Version": "0.1.0",
|
||||
"Description": "Ranks, groups, grants, and chat prefixes. Replaces Hytale's default permission provider.",
|
||||
"IncludesAssetPack": false,
|
||||
"Authors": [
|
||||
{"Name": "kewwbec"}
|
||||
],
|
||||
"ServerVersion": "*",
|
||||
"Dependencies": {
|
||||
"kewwbec:NetworkCore": "*"
|
||||
},
|
||||
"OptionalDependencies": {},
|
||||
"DisabledByDefault": false,
|
||||
"Main": "net.kewwbec.ranks.RanksPlugin"
|
||||
}
|
||||
Reference in New Issue
Block a user