Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import de.eintosti.buildsystem.util.TaskScheduler;
import de.eintosti.buildsystem.util.WorldFlush;
import de.eintosti.buildsystem.world.WorldServiceImpl;
import de.eintosti.buildsystem.world.download.DownloadProgress;
import de.eintosti.buildsystem.world.download.ExportProgressBar;
import de.eintosti.buildsystem.world.download.WorldDownloadService;
import de.eintosti.buildsystem.world.download.WorldExporter;
Expand All @@ -38,6 +39,7 @@
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;
Expand Down Expand Up @@ -109,14 +111,18 @@ public void execute(Player player, String worldName, String[] args) {
messages.sendMessage(player, "worlds_download_preparing", worldPlaceholder);
buildWorld.getWorld().ifPresent(WorldFlush::saveAndFlush);

AtomicLong packedBytes = new AtomicLong();
AtomicLong doneBytes = new AtomicLong();
AtomicLong totalBytes = new AtomicLong();
BukkitTask animation = startProgressAnimation(player, buildWorld, packedBytes, totalBytes);
AtomicReference<DownloadProgress.Phase> phase = new AtomicReference<>(DownloadProgress.Phase.PACKING);
BukkitTask animation = startProgressAnimation(player, buildWorld, phase, doneBytes, totalBytes);

downloadService
.prepare(buildWorld, (packed, total) -> {
packedBytes.set(packed);
.prepare(buildWorld, (currentPhase, done, total) -> {
// Set the phase last: it is what the animation reads to pick its message, so flipping it before
// the counters would draw the new phase with the old one's numbers.
doneBytes.set(done);
totalBytes.set(total);
phase.set(currentPhase);
})
.whenCompleteAsync(
(url, throwable) -> {
Expand All @@ -135,25 +141,30 @@ public void execute(Player player, String worldName, String[] args) {
}

/**
* Drives the action bar while the export runs. The frame counter advances every tick of this task, so the bar
* keeps moving even while a single large region file is being packed.
* Drives the action bar while the download is prepared. The frame counter advances every tick of this task, so the
* bar keeps moving even while a single large region file is being packed, and the message follows the phase so a
* full bar is never left standing over work that is still running.
*/
private BukkitTask startProgressAnimation(
Player player, BuildWorld buildWorld, AtomicLong packedBytes, AtomicLong totalBytes) {
Player player,
BuildWorld buildWorld,
AtomicReference<DownloadProgress.Phase> phase,
AtomicLong doneBytes,
AtomicLong totalBytes) {
AtomicInteger frame = new AtomicInteger();
return scheduler.runTimer(
() -> {
if (!player.isOnline()) {
return;
}
long total = totalBytes.get();
double fraction = total <= 0 ? 0 : (double) packedBytes.get() / total;
double fraction = total <= 0 ? 0 : (double) doneBytes.get() / total;
int currentFrame = frame.getAndIncrement();

sendActionBar(
player,
messages.getString(
"worlds_download_progress",
messageKey(phase.get()),
player,
Placeholders.of()
.add("%world%", buildWorld.getName())
Expand All @@ -166,6 +177,13 @@ private BukkitTask startProgressAnimation(
ANIMATION_PERIOD_TICKS);
}

private static String messageKey(DownloadProgress.Phase phase) {
return switch (phase) {
case PACKING -> "worlds_download_progress";
case PUBLISHING -> "worlds_download_uploading";
};
}

private static void sendActionBar(Player player, String message) {
player.spigot().sendMessage(ChatMessageType.ACTION_BAR, TextComponent.fromLegacyText(message));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ public void execute(Player player, String worldName, String[] args) {

buildWorld.setIcon(itemStack.getType());
if (itemStack.getItemMeta() instanceof SkullMeta) {
buildWorld.setIconSkullTexture(skullTexture(itemStack));
// Only overwrite a stored texture with one we actually read. A head whose profile needs a Mojang lookup
// reads as null here, and taking that as "no texture" would wipe whatever was configured before.
String texture = skullTexture(itemStack);
if (texture != null) {
buildWorld.setIconSkullTexture(texture);
}
}
messages.sendMessage(player, "worlds_setitem_set", Placeholders.of("%world%", buildWorld.getName()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,12 @@ public void saveConfig() {
* @return The parsed {@link PluginConfig}
*/
static PluginConfig parse(FileConfiguration config, Logger logger, XMaterial worldEditWand) {
return new PluginConfig(parseSettings(config, worldEditWand), parseWorld(config, logger), parseFolder(config));
PluginConfig.Storage storage = StorageSettingsFactory.fromConfig(config);
return new PluginConfig(
parseSettings(config, worldEditWand),
storage,
parseWorld(config, storage, logger),
parseFolder(config));
}

private static PluginConfig.Settings parseSettings(FileConfiguration config, XMaterial worldEditWand) {
Expand Down Expand Up @@ -139,7 +144,8 @@ private static PluginConfig.Settings parseSettings(FileConfiguration config, XMa
navigator);
}

private static PluginConfig.World parseWorld(FileConfiguration config, Logger logger) {
private static PluginConfig.World parseWorld(
FileConfiguration config, PluginConfig.Storage storage, Logger logger) {
PluginConfig.World.VoidBlock voidBlock = parseVoidBlock(config, logger);

PluginConfig.World.Limits limits = new PluginConfig.World.Limits(
Expand Down Expand Up @@ -195,14 +201,28 @@ private static PluginConfig.World parseWorld(FileConfiguration config, Logger lo

PluginConfig.World.Backup backup = new PluginConfig.World.Backup(
Math.min(config.getInt("world.backup.max-backups-per-world", 5), 18),
StorageSettingsFactory.fromConfig(config, logger),
StorageSettingsFactory.typeAt(
config,
"world.backup.storage",
storage,
EnumSet.allOf(PluginConfig.Storage.Type.class),
logger),
Objects.requireNonNullElse(config.getString("world.backup.path"), "backups/worlds/"),
autoBackup);

int downloadPort = config.getInt("world.download.port", 8080);
PluginConfig.World.Download download = new PluginConfig.World.Download(
config.getBoolean("world.download.enabled", false),
// Downloads hand out a link, which SFTP has no way to produce.
StorageSettingsFactory.typeAt(
config,
"world.download.storage",
storage,
EnumSet.of(PluginConfig.Storage.Type.LOCAL, PluginConfig.Storage.Type.S3),
logger),
downloadPort,
Objects.requireNonNullElse(config.getString("world.download.url"), "http://localhost:" + downloadPort),
config.getBoolean("world.download.behind-proxy", false),
Math.max(1, config.getInt("world.download.expiration-minutes", 30)),
Math.max(1, config.getInt("world.download.max-size-mb", 2048)),
Math.max(1, config.getInt("world.download.max-storage-mb", 8192)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,105 @@
import org.jspecify.annotations.Nullable;

@NullMarked
public record PluginConfig(Settings settings, World world, Folder folder) {
public record PluginConfig(Settings settings, Storage storage, World world, Folder folder) {

/**
* Credentials for the external services the plugin can talk to, defined once at the root rather than inside the
* feature that happened to need them first. Backups and world downloads both select a backend by name and read it
* from here, so a bucket configured for one is the same bucket for the other.
*
* @param s3 The S3 or S3-compatible service
* @param sftp The SFTP server
*/
public record Storage(S3 s3, Sftp sftp) {

/**
* Which backend a feature stores its files on.
*/
public enum Type {

/**
* The server's own disk, under the plugin folder.
*/
LOCAL,

/**
* The bucket configured in {@code storage.s3}.
*/
S3,

/**
* The server configured in {@code storage.sftp}.
*/
SFTP
}

public record Sftp(
@Nullable String host,
int port,
@Nullable String username,
@Nullable String password) {

/**
* {@return the password, preferring {@code BUILDSYSTEM_SFTP_PASSWORD}} Lets operators keep the secret out
* of config.yml.
*/
public @Nullable String resolvedPassword() {
return envOrConfig("BUILDSYSTEM_SFTP_PASSWORD", password);
}

/**
* Overridden so the password never appears in a log or pasted support output.
*/
@Override
public String toString() {
return "Sftp[host=%s, port=%d, username=%s, password=%s]"
.formatted(host, port, username, password == null ? null : "***");
}
}

public record S3(
@Nullable String url,
@Nullable String accessKey,
@Nullable String secretKey,
@Nullable String region,
@Nullable String bucket) {

/**
* {@return the access key, preferring {@code AWS_ACCESS_KEY_ID}} Lets operators keep the secret out of
* config.yml.
*/
public @Nullable String resolvedAccessKey() {
return envOrConfig("AWS_ACCESS_KEY_ID", accessKey);
}

/**
* {@return the secret key, preferring {@code AWS_SECRET_ACCESS_KEY}}
*/
public @Nullable String resolvedSecretKey() {
return envOrConfig("AWS_SECRET_ACCESS_KEY", secretKey);
}

/**
* Overridden so the access key and secret key never appear in a log or pasted support output.
*/
@Override
public String toString() {
return "S3[url=%s, accessKey=%s, secretKey=%s, region=%s, bucket=%s]"
.formatted(
url,
accessKey == null ? null : "***",
secretKey == null ? null : "***",
region,
bucket);
}
}

private static @Nullable String envOrConfig(String envKey, @Nullable String configValue) {
String env = System.getenv(envKey);
return env == null || env.isBlank() ? configValue : env;
}
}

public record Settings(
boolean updateChecker,
Expand Down Expand Up @@ -130,54 +228,13 @@ public record Unload(boolean enabled, String timeUntilUnload, Set<String> blackl
}
}

public record Backup(int maxBackupsPerWorld, StorageSettings storage, AutoBackup autoBackup) {

public sealed interface StorageSettings permits Local, Sftp, S3 {}

public record Local() implements StorageSettings {}

public record Sftp(
@Nullable String host,
int port,
@Nullable String username,
@Nullable String password,
@Nullable String path)
implements StorageSettings {

/**
* Overridden so the password never appears in a log or pasted support output.
*/
@Override
public String toString() {
return "Sftp[host=%s, port=%d, username=%s, password=%s, path=%s]"
.formatted(host, port, username, password == null ? null : "***", path);
}
}

public record S3(
@Nullable String url,
@Nullable String accessKey,
@Nullable String secretKey,
@Nullable String region,
@Nullable String bucket,
@Nullable String path)
implements StorageSettings {

/**
* Overridden so the access key and secret key never appear in a log or pasted support output.
*/
@Override
public String toString() {
return "S3[url=%s, accessKey=%s, secretKey=%s, region=%s, bucket=%s, path=%s]"
.formatted(
url,
accessKey == null ? null : "***",
secretKey == null ? null : "***",
region,
bucket,
path);
}
}
/**
* @param maxBackupsPerWorld How many backups are kept per world
* @param storage Which backend backups are written to, configured under the root {@code storage} section
* @param path Where backups live within that backend
* @param autoBackup The scheduled backup settings
*/
public record Backup(int maxBackupsPerWorld, Storage.Type storage, String path, AutoBackup autoBackup) {

public record AutoBackup(boolean enabled, boolean onlyActiveWorlds, int interval) {}
}
Expand All @@ -187,14 +244,18 @@ public record AutoBackup(boolean enabled, boolean onlyActiveWorlds, int interval
* hands out world archives, so it stays an explicit decision by the operator.
*
* @param enabled Whether the download server runs at all
* @param storage Where a prepared archive is served from
* @param port The port the server listens on
* @param url The base URL players are sent, for servers reached through a proxy or a domain
* @param behindProxy Whether to take the client's address from {@code X-Forwarded-For} rather than the socket
* @param expirationMinutes How long a download link stays valid before the archive is deleted
*/
public record Download(
boolean enabled,
Storage.Type storage,
int port,
String url,
boolean behindProxy,
int expirationMinutes,
int maxSizeMb,
int maxStorageMb,
Expand Down
Loading