diff --git a/build.gradle b/build.gradle index 5da0166..cb157df 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,8 @@ plugins { id 'fabric-loom' version "${loom_version}" id 'maven-publish' + id("io.freefair.lombok") version "9.0.0-rc2" + } version = project.mod_version @@ -38,7 +40,8 @@ dependencies { // Fabric API. This is technically optional, but you probably want it anyway. modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}" - + implementation("io.minio:minio:8.5.17") + } processResources { diff --git a/src/client/java/com/example/ExampleModClient.java b/src/client/java/com/example/ExampleModClient.java deleted file mode 100644 index e2b0436..0000000 --- a/src/client/java/com/example/ExampleModClient.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.example; - -import net.fabricmc.api.ClientModInitializer; - -public class ExampleModClient implements ClientModInitializer { - @Override - public void onInitializeClient() { - // This entrypoint is suitable for setting up client-specific logic, such as rendering. - } -} \ No newline at end of file diff --git a/src/client/java/com/example/mixin/client/ExampleClientMixin.java b/src/client/java/com/example/mixin/client/ExampleClientMixin.java deleted file mode 100644 index 7ee50d1..0000000 --- a/src/client/java/com/example/mixin/client/ExampleClientMixin.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.example.mixin.client; - -import net.minecraft.client.MinecraftClient; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; - -@Mixin(MinecraftClient.class) -public class ExampleClientMixin { - @Inject(at = @At("HEAD"), method = "run") - private void init(CallbackInfo info) { - // This code is injected into the start of MinecraftClient.run()V - } -} \ No newline at end of file diff --git a/src/client/resources/modid.client.mixins.json b/src/client/resources/modid.client.mixins.json deleted file mode 100644 index 9341450..0000000 --- a/src/client/resources/modid.client.mixins.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "required": true, - "package": "com.example.mixin.client", - "compatibilityLevel": "JAVA_21", - "client": [ - "ExampleClientMixin" - ], - "injectors": { - "defaultRequire": 1 - } -} \ No newline at end of file diff --git a/src/main/java/com/example/ExampleMod.java b/src/main/java/com/example/ExampleMod.java deleted file mode 100644 index 215cfbb..0000000 --- a/src/main/java/com/example/ExampleMod.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.example; - -import net.fabricmc.api.ModInitializer; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class ExampleMod implements ModInitializer { - public static final String MOD_ID = "modid"; - - // This logger is used to write text to the console and the log file. - // It is considered best practice to use your mod id as the logger's name. - // That way, it's clear which mod wrote info, warnings, and errors. - public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); - - @Override - public void onInitialize() { - // This code runs as soon as Minecraft is in a mod-load-ready state. - // However, some things (like resources) may still be uninitialized. - // Proceed with mild caution. - - LOGGER.info("Hello Fabric world!"); - } -} \ No newline at end of file diff --git a/src/main/java/com/example/mixin/ExampleMixin.java b/src/main/java/com/example/mixin/ExampleMixin.java deleted file mode 100644 index 3c4212c..0000000 --- a/src/main/java/com/example/mixin/ExampleMixin.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.example.mixin; - -import net.minecraft.server.MinecraftServer; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; - -@Mixin(MinecraftServer.class) -public class ExampleMixin { - @Inject(at = @At("HEAD"), method = "loadWorld") - private void init(CallbackInfo info) { - // This code is injected into the start of MinecraftServer.loadWorld()V - } -} \ No newline at end of file diff --git a/src/main/java/io/ib67/sfcraft/JustBackupMod.java b/src/main/java/io/ib67/sfcraft/JustBackupMod.java new file mode 100644 index 0000000..6ba142c --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/JustBackupMod.java @@ -0,0 +1,134 @@ +package io.ib67.sfcraft; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import io.ib67.sfcraft.config.JustBackupConfig; +import io.ib67.sfcraft.config.StorageOption; +import io.ib67.sfcraft.config.serializer.StorageOptionSerializer; +import io.ib67.sfcraft.mixin.LevelStorageAccessor; +import io.ib67.sfcraft.mixin.MixinMinecraftServer; +import lombok.SneakyThrows; +import net.fabricmc.api.ModInitializer; + +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.world.ServerWorld; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.FileInputStream; +import java.io.RandomAccessFile; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +public class JustBackupMod implements ModInitializer { + public static final String MOD_ID = "justbackup"; + public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); + public static final AtomicBoolean PERFORMING_BACKUP = new AtomicBoolean(false); + private static final Gson GSON = new GsonBuilder().setPrettyPrinting() + .registerTypeAdapter(StorageOption.class, new StorageOptionSerializer( + Map.of("s3", StorageOption.S3.class, + "local", StorageOption.Local.class) + )) + .create(); + protected ScheduledExecutorService scheduledBackupExecutor; + protected JustBackupConfig config; + protected volatile MinecraftServer server; + + @Override + @SneakyThrows + public void onInitialize() { + LOGGER.info("Loading backup configuration"); + var configPath = Path.of("config").resolve("just_backup.json"); + if (Files.notExists(configPath.getParent())) { + Files.createDirectories(configPath.getParent()); + } + if (Files.notExists(configPath)) { + saveConfig(configPath); + } + config = GSON.fromJson(Files.readString(configPath), JustBackupConfig.class); + LOGGER.info("Configuration has been loaded. Using storage option: {}", config.option().type()); + scheduledBackupExecutor = Executors.newSingleThreadScheduledExecutor(); + ServerLifecycleEvents.SERVER_STARTED.register(server -> { + LOGGER.info("Launching background threads..."); + this.server = server; + scheduledBackupExecutor.scheduleAtFixedRate(this::performBackup, + config.backupIntervalMinutes(), config.backupIntervalMinutes(), TimeUnit.MINUTES); + }); + } + + @SneakyThrows + private void performBackup() { + if (server == null) { + LOGGER.warn("MinecraftServer is not initialized yet, not doing backup"); + return; + } + int i = 0; + while (!PERFORMING_BACKUP.compareAndSet(false, true)) { + if (i++ % 10 == 0) LOGGER.info("Server is saving world while performing backup! waiting..."); + Thread.sleep(2000); + } + // performing backup is now true + try { + if (i == 0) { + // not saved yet. + server.saveAll(true, true, false); + } + var session = ((LevelStorageAccessor) server).getSession(); + var path = session.getDirectory().getRootPath(); + // !compress + if (!config.compress()) { + performBackupUncompressed(new WorldDir(Path.of(path))); + } + } finally { + PERFORMING_BACKUP.set(false); + } + } + + @SneakyThrows + private void performBackupUncompressed(WorldDir worldDir) { + var everything = worldDir.everything(); + var opt = (StorageOption.Local) config.option(); + var dst = Path.of(opt.saveDir()).resolve("Backup_" + DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(LocalDateTime.now())); + Files.createDirectories(dst); + try(var f = Files.walk(worldDir.region())){ + bundleUncompressed(dst.resolve("regions.gz"), f.toList()); + } + try(var f = Files.walk(worldDir.poi())) { + bundleUncompressed(dst.resolve("poi.gz"), f.toList()); + } + var other = dst.resolve("other.zip", ) + } + + @SneakyThrows + private static void bundleUncompressed(Path dst, Collection toBundle){ + try(var fileOut = new RandomAccessFile(dst.toFile(), "rw");){ + var fileOutDst = fileOut.getChannel(); + for (Path path : toBundle) { + var file = path.toFile(); + try(var in = new FileInputStream(path.toFile())){ + in.getChannel().transferTo(0, file.length(), fileOutDst); + } + } + } + } + + @SneakyThrows + private void saveConfig(Path configPath) { + LOGGER.info("Saving default configuration"); + var cfg = new JustBackupConfig( + 60, + 5, + true, + new StorageOption.Local("backups") + ); + Files.writeString(configPath, GSON.toJson(cfg)); + } +} \ No newline at end of file diff --git a/src/main/java/io/ib67/sfcraft/WorldDir.java b/src/main/java/io/ib67/sfcraft/WorldDir.java new file mode 100644 index 0000000..10c58bd --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/WorldDir.java @@ -0,0 +1,67 @@ +package io.ib67.sfcraft; + +import lombok.SneakyThrows; + +import java.nio.file.FileVisitOption; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public record WorldDir( + Path root +) { + public Path region() { + return root.resolve("region"); + } + + public Path poi() { + return root.resolve("poi"); + } + + public Path entities() { + return root.resolve("entities"); + } + + public Path playerdata() { + return root.resolve("playerdata"); + } + + @SneakyThrows + public Map otherDimensions() { + var begin = root.resolve("dimensions").toAbsolutePath(); + try (var w = Files.walk(begin, 3)) { + var _r = w.filter(Files::isDirectory) + .filter(it -> Files.exists(it.resolve("region")) && Files.exists(it.resolve("poi"))) + .collect(Collectors.groupingBy(it -> it.relativize(root).toString(), + Collectors.mapping(WorldDir::new, Collectors.toList()))); + var r = new HashMap(); + _r.forEach((k, v) -> { + assert v.size() <= 1; + r.put(k, v.getFirst()); + }); + return r; + } + } + + /** + * @return Files expect poi, region, entities, playerdata and things in dimension folder. + */ + @SneakyThrows + public List otherFiles() { + var exclude = List.of(playerdata(), entities(), region(), poi(), root.resolve("dimensions")); + try (var f = Files.walk(root)) { + return f.filter(it -> exclude.stream().noneMatch(it::startsWith)).toList(); + } + } + + @SneakyThrows + public List everything() { + try (var f = Files.walk(root)) { + return f.toList(); + } + } +} diff --git a/src/main/java/io/ib67/sfcraft/config/JustBackupConfig.java b/src/main/java/io/ib67/sfcraft/config/JustBackupConfig.java new file mode 100644 index 0000000..0c2af63 --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/config/JustBackupConfig.java @@ -0,0 +1,18 @@ +package io.ib67.sfcraft.config; + +import org.apache.commons.lang3.Validate; + +import java.util.List; + +public record JustBackupConfig( + long backupIntervalMinutes, + int keepBackups, + boolean compress, + StorageOption option +) { + public JustBackupConfig { + Validate.isTrue(backupIntervalMinutes > 0, "backup interval must be positive"); + Validate.isTrue(keepBackups > 0, "keep backups must be positive"); + Validate.notNull(option, "option cannot be null"); + } +} diff --git a/src/main/java/io/ib67/sfcraft/config/StorageOption.java b/src/main/java/io/ib67/sfcraft/config/StorageOption.java new file mode 100644 index 0000000..bdfc27a --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/config/StorageOption.java @@ -0,0 +1,27 @@ +package io.ib67.sfcraft.config; + +public sealed interface StorageOption { + String type(); + + record S3( + String region, + String accessKey, + String secretKey, + String endpoint, + String bucket + ) implements StorageOption { + @Override + public String type() { + return "s3"; + } + } + + record Local( + String saveDir + ) implements StorageOption { + @Override + public String type() { + return "local"; + } + } +} diff --git a/src/main/java/io/ib67/sfcraft/config/serializer/StorageOptionSerializer.java b/src/main/java/io/ib67/sfcraft/config/serializer/StorageOptionSerializer.java new file mode 100644 index 0000000..0775e5c --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/config/serializer/StorageOptionSerializer.java @@ -0,0 +1,29 @@ +package io.ib67.sfcraft.config.serializer; + +import com.google.gson.*; +import io.ib67.sfcraft.config.StorageOption; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.lang.reflect.Type; +import java.util.Map; + +@RequiredArgsConstructor +@Getter +public class StorageOptionSerializer implements JsonDeserializer, JsonSerializer { + protected final Map> subTypes; + + @Override + public StorageOption deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + if (!(json instanceof JsonObject jo)) throw new JsonParseException("Storage option must be a json object"); + var type = jo.get("type").getAsString().toLowerCase(); + return context.deserialize(json, subTypes.get(type)); + } + + @Override + public JsonElement serialize(StorageOption src, Type typeOfSrc, JsonSerializationContext context) { + var jo = context.serialize(src).getAsJsonObject(); + jo.addProperty("type", src.type()); + return jo; + } +} diff --git a/src/main/java/io/ib67/sfcraft/mixin/LevelStorageAccessor.java b/src/main/java/io/ib67/sfcraft/mixin/LevelStorageAccessor.java new file mode 100644 index 0000000..5de4390 --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/mixin/LevelStorageAccessor.java @@ -0,0 +1,12 @@ +package io.ib67.sfcraft.mixin; + +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.level.storage.LevelStorage; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(MinecraftServer.class) +public interface LevelStorageAccessor { + @Accessor + LevelStorage.Session getSession(); +} diff --git a/src/main/java/io/ib67/sfcraft/mixin/MixinMinecraftServer.java b/src/main/java/io/ib67/sfcraft/mixin/MixinMinecraftServer.java new file mode 100644 index 0000000..3da0a6a --- /dev/null +++ b/src/main/java/io/ib67/sfcraft/mixin/MixinMinecraftServer.java @@ -0,0 +1,33 @@ +package io.ib67.sfcraft.mixin; + +import io.ib67.sfcraft.JustBackupMod; +import lombok.Getter; +import net.minecraft.server.MinecraftServer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import javax.security.auth.callback.Callback; +import java.util.concurrent.atomic.AtomicBoolean; + +@Mixin(MinecraftServer.class) +public abstract class MixinMinecraftServer { + + @Shadow private int ticksUntilAutosave; + + @Shadow protected abstract int getAutosaveInterval(); + + @Inject(method = "runAutosave", at = @At("HEAD"), cancellable = true) + private void backup$checkSaving(CallbackInfo ci) { + // is already backing up + this.ticksUntilAutosave = getAutosaveInterval(); + if (!JustBackupMod.PERFORMING_BACKUP.compareAndSet(false, true)) ci.cancel(); + } + @Inject(method="runAutosave", at=@At("RETURN")) + private void backup$disableProtect(CallbackInfo ci){ + JustBackupMod.PERFORMING_BACKUP.set(false); + } +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 94f428b..c3f0f27 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -1,41 +1,27 @@ { "schemaVersion": 1, - "id": "modid", + "id": "justbackup", "version": "${version}", - "name": "Example mod", - "description": "This is an example description! Tell everyone what your mod is about!", + "name": "JustBackup", + "description": "Just backup", "authors": [ - "Me!" + "iceBear67" ], - "contact": { - "homepage": "https://fabricmc.net/", - "sources": "https://github.com/FabricMC/fabric-example-mod" - }, "license": "CC0-1.0", "icon": "assets/modid/icon.png", "environment": "*", "entrypoints": { "main": [ - "com.example.ExampleMod" - ], - "client": [ - "com.example.ExampleModClient" + "io.ib67.sfcraft.JustBackupMod" ] }, "mixins": [ - "modid.mixins.json", - { - "config": "modid.client.mixins.json", - "environment": "client" - } + "justbackup.mixins.json" ], "depends": { "fabricloader": ">=0.16.14", "minecraft": "~1.21.8", "java": ">=21", "fabric-api": "*" - }, - "suggests": { - "another-mod": "*" } } \ No newline at end of file diff --git a/src/main/resources/modid.mixins.json b/src/main/resources/justbackup.mixins.json similarity index 65% rename from src/main/resources/modid.mixins.json rename to src/main/resources/justbackup.mixins.json index f48035e..2c3a2b7 100644 --- a/src/main/resources/modid.mixins.json +++ b/src/main/resources/justbackup.mixins.json @@ -1,9 +1,10 @@ { "required": true, - "package": "com.example.mixin", + "package": "io.ib67.sfcraft.mixin", "compatibilityLevel": "JAVA_21", "mixins": [ - "ExampleMixin" + "LevelStorageAccessor", + "MixinMinecraftServer" ], "injectors": { "defaultRequire": 1