Compare commits

...
1 Commits
Author SHA1 Message Date
koka 9791d178ab beta.157: require article photos with a scrollable screenshot gallery
Build Sanctuary / build (push) Waiting to run
2026-09-22 22:43:19 +02:00
23 changed files with 622 additions and 37 deletions
+6
View File
@@ -1,5 +1,11 @@
# Sanctuary
## beta.157 — photo obligatoire dans la Gazette (pack local)
Galerie de captures défilante, aperçu et assignation à un article. Publication
avec photo obligatoire, stockage fichier ou MariaDB partagé avec le site.
[Contrat de migration et vérifications](docs/gazette-photos-beta157.md).
## beta.156 — reprendre la partie sous la colonne communautaire (pack local)
Le bouton Reprendre la partie est aligné sous la Gazette et le tableau
+12
View File
@@ -1,5 +1,8 @@
# Communauté Sanctuary — contrat v1
> Depuis beta.157, le schéma actif est la v2 avec photo obligatoire pour les nouveaux
> articles. Voir la section « Évolution v2 » en fin de document avant installation.
Ticket beta.154, branche `codex/community-beta154`, socle beta.151 `b97ec6c`.
Ce contrat décrit le comportement cible et les échanges implémentés par ce ticket.
La note de livraison indique séparément les vérifications effectivement réussies.
@@ -158,3 +161,12 @@ Sur le site, le rendu par UUID utilise le prestataire mc-api.io déjà employé
par la page de compte ; chargement différé, sans référent, initiale locale en
cas d’erreur. Aucune image ni URL arbitraire n’entre dans le contrat des posts.
Documentation du rendu : https://mc-api.io/docs (GET `/render/{uuid}?size=64`).
## Évolution v2 (beta.157)
Le champ photo et sa migration explicite, avec conservation des anciens articles,
sont décrits dans [Photos de Gazette](gazette-photos-beta157.md).
Pour une nouvelle base partagée avec le site, utiliser les migrations Laravel.
Pour une base autonome administrée par SQL, appliquer schema-v1.sql puis
schema-v1-to-v2.sql avant de connecter le mod beta.157.
+96
View File
@@ -0,0 +1,96 @@
# beta.157 — photo obligatoire et galerie de captures
## Contrat de migration communautaire v2
La génération, les chunks et les sauvegardes Minecraft ne changent pas.
Seul le document communautaire évolue : champ `photo`, JPEG encodé en base64,
48 Kio binaires maximum, dimensions de 1 × 1 à 960 × 540 pixels.
Les captures originales ne sont jamais modifiées. Les copies destinées à la Gazette
sont redimensionnées et réencodées, sans métadonnées, avant transmission.
Les nouveaux articles exigent une photo valide. Les articles v1 restent lisibles ;
leur prochaine modification exige une photo. Avis, intendance et réponses restent
textuels. La modération ne nécessite pas d'ajouter une photo à un ancien article.
La photo est enregistrée atomiquement avec le texte, auteur et révision ; pas de
référence à un chemin local, d'URL distante ou de média orphelin persistant.
Fichier : v1 est lu sans modification. À la première écriture réussie, une copie
exacte `sanctuary-community.json.v1.bak` est créée sans écrasement, puis le document
v2 est écrit atomiquement. Un backup différent bloque la migration. La limite de
64 Mio du document reste appliquée, photos comprises. Un retour à beta.156 exige
de restaurer la copie v1 et perd les changements communautaires postérieurs.
Ne pas restaurer pendant que le serveur tourne.
MariaDB : arrêter les écritures du site et du serveur, sauvegarder la base, exécuter
explicitement `community/schema-v1-to-v2.sql` ou la migration Laravel équivalente,
puis lancer les deux applications v2. Migration additive : colonne `photo` nullable,
aucune suppression ni réécriture des articles existants. Le mod n'exécute aucune
migration SQL au démarrage. Les applications v1 refusent le schéma v2. Pas de
rollback automatique destructif ; restaurer la sauvegarde avec les deux logiciels
v1 si nécessaire. Aucun changement n'est appliqué aux bases ou mondes personnels
par la préparation de cette livraison.
Réseau : canaux v1 conservés, action `photo` par fragments de 8 000 caractères,
au plus 9 fragments et 65 536 caractères, accusés un par un. Une seule photo
transitoire par joueur, liée à l'UUID de publication, expire après deux minutes ;
limite globale de 128 transferts. Les listes ne transportent pas les photos ;
seul le détail d'un article les contient. Le plafond des réponses
passe à 262 144 caractères pour une photo et une discussion complète ; les
clients et le serveur doivent être mis à jour ensemble vers beta.157. Les
anciens clients ne peuvent plus créer d'article sans photo.
Galerie : grille de 2 ou 3 colonnes selon la taille d'interface, molette et barre
native, 24 captures par page parmi les 4 096 PNG les plus récents par nom, lecture
en arrière-plan. Aperçu puis assignation explicite ; Annuler conserve le brouillon.
Les captures illisibles restent visibles mais désactivées. Limites sources :
32 Mio, 8 192 pixels par axe et 32 millions de pixels. Textures libérées à la
fermeture. Le site accepte PNG/JPEG et nécessite PHP GD ; le mod propose les PNG
du dossier `screenshots` de son instance.
## Vérifications
- Tests natifs Vulkan Minecraft 26.3 : galerie FR/EN aux échelles 2/3/4,
molette, pagination, aperçu, annulation avec brouillon conservé, bouton Publier
désactivé sans photo, envoi fragmenté, photo visible et relecture après
réouverture de l'adaptateur. Parcours réussi en fichier et MariaDB.
- Tests métier : photo manquante/invalide, révision, auteur, conservation après
modification, reprise idempotente, redémarrage fichier/SQL, ancien article v1,
copie de migration exacte, fragments hors ordre/incomplets/expirés.
- Site : 17 tests HTTP, 97 assertions réussies ; photo obligatoire, faux fichier,
dimensions refusées, réduction, lecture, isolement serveur, auteur, masquage,
anciennes publications et conservation après modification.
- Échange réel Java/PHP sur une MariaDB isolée : création en jeu lue par le site,
modification et réponse Web relues en Java, article/photo du site décodés par
Java. Migration Laravel et alternative SQL testées sur bases de développement.
- `./gradlew check build assemblePack` : 252 GameTests serveur terminés en
3,457 minutes, 229 réussis et les mêmes 23 échecs que la baseline beta.154/155
(liste comparée intégralement, aucun nouvel identifiant en échec). La commande
générale échoue donc après 6 min 59 s ; cette limite préexistante reste ouverte.
- Régression native du menu pause : FR/EN, échelles 2/3/4, carte, défilements
indépendants, pagination, article et avis longs, réponse, options et reprise :
réussie avec les articles munis d'une photo.
- Construction et assemblage séparés, sans relancer les 23 GameTests connus :
`check build assemblePack -x :sanctuary:runGameTest` réussit (avec le parcours
client natif du menu pause), 2 min 56 s.
Les captures d'essai sont des mires générées dans une instance de développement,
sans accès aux captures ou sauvegardes personnelles. Pas de déploiement sur le
site public, serveur personnel, canal packwiz ou Prism. Les JAR beta.154–156
restent présents ; JAR et MRpack beta.156 gardent leurs empreintes antérieures.
Le site correspondant est sur `codex/community-photos`, commits `e3ae3eb` et
`9626e95`, sans push ni publication. Logs de développement :
`build/photos157-client-file.log`, `build/photos157-client-database.log`,
`build/photos157-interop-*.log`, `build/photos157-check-build.log` et
`build/photos157-final-build.log`.
## Artefacts locaux
JAR `mods/sanctuary/build/libs/sanctuary-beta.157.jar` et pack
`build/Sanctuary-beta.157.mrpack`, versions et JAR embarqué vérifiés.
Les 29 ressources de shaders sont identiques à beta.156 (socle beta.151).
Reçu complet : `build/photos157-validation.json`.
- SHA-256 mods/sanctuary/build/libs/sanctuary-beta.157.jar: `96ff6d22ab593eb113cde63239ff4839f6a75b9a165fbec93a0aaab3c09dee7c`.
- SHA-256 build/Sanctuary-beta.157.mrpack: `b03da5f67a3dcf8051666641dccd379a52b8c3c09308e1654b00440a37693e96`.
+2 -2
View File
@@ -9,8 +9,8 @@ loom_version=1.17.20
fabric_api_version=0.160.5+26.3
# Release counter: beta.001, .002, .003, ... (see docs/versioning.md).
mod_version=beta.156
pack_version=beta.156
mod_version=beta.157
pack_version=beta.157
resource_pack_version=beta.121
maven_group=fr.koka.sanctuary
jei_version=30.32.0-sanctuary.3
+3
View File
@@ -258,6 +258,9 @@ tasks.named('processGametestResources') {
def multiblocks084Client = providers.gradleProperty('sanctuaryMultiblocks084ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryMultiblocks084ClientTests', multiblocks084Client)
if (multiblocks084Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Multiblocks084ClientChecks') }
def photos157Client = providers.gradleProperty('sanctuaryPhotos157ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPhotos157ClientTests', photos157Client)
if (photos157Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Photos157ClientChecks') }
def pause155Client = providers.gradleProperty('sanctuaryPause155ClientTests').map { it.toBoolean() }.getOrElse(false)
inputs.property('sanctuaryPause155ClientTests', pause155Client)
if (pause155Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Pause155ClientChecks') }
@@ -69,7 +69,7 @@ public final class Community154ClientChecks implements FabricClientGameTest {
c.waitTicks(25);
c.runOnClient(m -> { press(m.gui.screen(),"sanctuary.community.actions"); press(m.gui.screen(),"sanctuary.community.close"); });
c.waitFor(m -> !CommunityClient.pending() && has(m.gui.screen(),"sanctuary.community.reopen"),300);
c.runOnClient(m -> { check(!has(m.gui.screen(),"sanctuary.community.reply"),"Closed announcement hides reply action"); CommunityClient.open(new PauseScreen(true),"article","",true); edit(m.gui.screen()).setValue("Brouillon"); body(m.gui.screen()).setValue("§Texte refusé"); press(m.gui.screen(),"sanctuary.community.publish"); });
c.runOnClient(m -> { check(!has(m.gui.screen(),"sanctuary.community.reply"),"Closed announcement hides reply action"); CommunityClient.DRAFTS.computeIfAbsent("new:article",unused->new CommunityClient.Draft()).photo=testPhoto(); CommunityClient.open(new PauseScreen(true),"article","",true); edit(m.gui.screen()).setValue("Brouillon"); body(m.gui.screen()).setValue("§Texte refusé"); press(m.gui.screen(),"sanctuary.community.publish"); });
c.waitFor(m -> !CommunityClient.pending() && (has(m.gui.screen(),"sanctuary.community.invalid") || has(m.gui.screen(),"sanctuary.community.busy")),300);
c.runOnClient(m -> { check(body(m.gui.screen()).getValue().equals("§Texte refusé"),"Refused draft retained"); m.gui.screen().onClose(); CommunityClient.open(m.gui.screen(),"article","",true); check(edit(m.gui.screen()).getValue().equals("Brouillon"),"Draft survives closing screen"); m.gui.screen().onClose(); });
c.runOnClient(m -> { CommunityClient.open(m.gui.screen(),"article","",false); }); c.waitTicks(4);
@@ -80,6 +80,7 @@ public final class Community154ClientChecks implements FabricClientGameTest {
c.runOnClient(m -> m.gui.setScreen(null));
} finally { intro.set(false); }
}
static String testPhoto() {try {return fr.koka.sanctuary.community.CommunityPhoto.encode(new java.awt.image.BufferedImage(16,9,java.awt.image.BufferedImage.TYPE_INT_RGB),960,540);}catch(Exception e){throw new AssertionError(e);}}
static java.util.stream.Stream<GuiEventListener> widgets(GuiEventListener e) { return java.util.stream.Stream.concat(java.util.stream.Stream.of(e), e instanceof ContainerEventHandler h ? h.children().stream().flatMap(Community154ClientChecks::widgets) : java.util.stream.Stream.empty()); }
static boolean has(Screen s,String key) { return s!=null && widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w -> w.getMessage().getContents() instanceof TranslatableContents t && t.getKey().equals(key)); }
static boolean text(Screen s,String value) { return widgets(s).filter(AbstractWidget.class::isInstance).map(AbstractWidget.class::cast).anyMatch(w -> w.getMessage().getString().equals(value)); }
@@ -16,6 +16,7 @@ import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Native pause regions, independently scrollable feeds, paginated posts and long conversations. */
public final class Pause155ClientChecks implements FabricClientGameTest {
static String photo() throws Exception {return CommunityPhoto.encode(new java.awt.image.BufferedImage(32,18,java.awt.image.BufferedImage.TYPE_INT_RGB),960,540);}
public void runTest(ClientGameTestContext c) {
boolean database=Boolean.parseBoolean(System.getenv("SANCTUARY_COMMUNITY_CLIENT_DATABASE"));
var intro=new AtomicBoolean(true);
@@ -41,7 +42,7 @@ public final class Pause155ClientChecks implements FabricClientGameTest {
var config=CommunityConfig.load(net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
CommunityStore store=database?new SqlCommunityStore(config.jdbcUrl(),config.user(),System.getenv(config.passwordEnv()),config.serverId()):new FileCommunityStore(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"),config.serverId());
var actor=new Community.Actor(p.getStringUUID(),"Koka",true);
for(String kind:List.of("article","notice")) for(int i=0;i<13;i++)store.change(actor,new Community.Change("create",UUID.randomUUID().toString(),"",kind,"work",(kind.equals("article")?"Gazette — ":"La passerelle — ")+String.format("%02d",i),"Rendez-vous sur la place du village.\n".repeat(60)+"FIN DU MESSAGE",0));
for(String kind:List.of("article","notice")) for(int i=0;i<13;i++)store.change(actor,new Community.Change("create",UUID.randomUUID().toString(),"",kind,"work",(kind.equals("article")?"Gazette — ":"La passerelle — ")+String.format("%02d",i),"Rendez-vous sur la place du village.\n".repeat(60)+"FIN DU MESSAGE",0,kind.equals("article")?photo():null));
store.change(actor,new Community.Change("create",UUID.randomUUID().toString(),"","bulletin","info","Bienvenue au village","Le marché ouvrira ce soir à 20 h.",0));
} catch(Exception e) {throw new AssertionError(e);}
});
@@ -0,0 +1,84 @@
package fr.koka.sanctuary.client;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import fr.koka.sanctuary.community.*;
import fr.koka.sanctuary.gametest.Starter039TestSupport;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest;
import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.screens.*;
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
import static fr.koka.sanctuary.client.Community154ClientChecks.*;
/** Screenshot selection and article round trip through actual client/server packets. */
public final class Photos157ClientChecks implements FabricClientGameTest {
public void runTest(ClientGameTestContext c) {
boolean database=Boolean.parseBoolean(System.getenv("SANCTUARY_COMMUNITY_CLIENT_DATABASE"));
var intro=new AtomicBoolean(true);
var folder=new java.util.concurrent.atomic.AtomicReference<Path>();
try {
var path=net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json");
var config=new CommunityConfig(database?"database":"file",UUID.randomUUID().toString(),database?System.getenv("SANCTUARY_COMMUNITY_TEST_JDBC"):"jdbc:mariadb://localhost/sanctuary","root","SANCTUARY_COMMUNITY_DB_PASSWORD");
Files.createDirectories(path.getParent());Files.writeString(path,new com.google.gson.Gson().toJson(config));
} catch(Exception e){throw new AssertionError(e);}
c.runOnClient(m->{m.options.renderDistance().set(5);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);folder.set(m.gameDirectory.toPath().resolve("screenshots"));});
try {
Files.createDirectories(folder.get());
var image=new java.awt.image.BufferedImage(1200,675,java.awt.image.BufferedImage.TYPE_INT_RGB);
var random=new Random(157);
for(int y=0;y<675;y++)for(int x=0;x<1200;x++)image.setRGB(x,y,((x/50)%2==0 && (y/50)%2==0)?0x2a6b52:random.nextInt());
for(int i=0;i<28;i++)javax.imageio.ImageIO.write(image,"png",folder.get().resolve(String.format("z-photo157-%02d.png",i)).toFile());
Files.writeString(folder.get().resolve("z-photo157-29.png"),"not a PNG");
}catch(Exception e){throw new AssertionError(e);}
ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get() && m.gui.screen() instanceof HelloWorldScreen screen){
Starter039TestSupport.selectFirst(screen);
screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter());
press(screen,"sanctuary.progression.enter");intro.set(false);
}});
try(var world=c.worldBuilder().adjustSettings(s->{s.setName("Photos157 "+UUID.randomUUID());s.setSeed("157");s.setAllowCommands(false);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL);s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()) {
c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);
world.getServer().runOnServer(s->s.getPlayerList().getPlayers().getFirst().getAbilities().invulnerable=true);
for(String locale:List.of("fr_fr","en_us"))for(int scale:List.of(2,3,4)) {
c.runOnClient(m->{
m.getWindow().setWindowed(1280,960);m.options.guiScale().set(scale);m.resizeGui();m.getLanguageManager().setSelected(locale);m.getLanguageManager().onResourceManagerReload(m.getResourceManager());
CommunityClient.open(new PauseScreen(true),"article","",true);
check(!button(m.gui.screen(),"publish").active,"Photo is mandatory in the composer");
edit(m.gui.screen()).setValue("Une journée au village");body(m.gui.screen()).setValue("La photo rejoint la Gazette.");
press(m.gui.screen(),"sanctuary.community.photo_choose");
});
c.waitFor(m->widgets(m.gui.screen()).anyMatch(CommunityPhotoWidget.class::isInstance),500);
c.runOnClient(m->{
var s=m.gui.screen();check(s instanceof CommunityScreenshotsScreen,"Dedicated screenshot view");
var a=widgets(s).filter(AbstractScrollArea.class::isInstance).map(AbstractScrollArea.class::cast).findFirst().orElseThrow();
check(a.maxScrollAmount()>0,"Gallery scrolls");s.mouseScrolled(a.getX()+5,a.getY()+5,0,-4);check(a.scrollAmount()>0,"Wheel moves screenshots");a.setScrollAmount(0);
for(var child:s.children())if(child instanceof AbstractWidget w)check(w.getX()>=0&&w.getRight()<=s.width&&w.getY()>=0&&w.getBottom()<=s.height,"Gallery bounds "+w.getMessage());
});
c.takeScreenshot("photos157-gallery-"+locale+"-"+scale+(database?"-database":"-file"));
c.runOnClient(m->m.gui.screen().onClose());
c.runOnClient(m->{check(edit(m.gui.screen()).getValue().equals("Une journée au village"),"Cancel retains text");check(!button(m.gui.screen(),"publish").active,"Cancel leaves photo unassigned");});
}
c.runOnClient(m->{m.options.guiScale().set(2);m.resizeGui();m.getLanguageManager().setSelected("fr_fr");m.getLanguageManager().onResourceManagerReload(m.getResourceManager());press(m.gui.screen(),"sanctuary.community.photo_choose");});
c.waitFor(m->widgets(m.gui.screen()).anyMatch(CommunityPhotoWidget.class::isInstance),500);
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.next"));
c.waitFor(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).count()>0&&!button(m.gui.screen(),"next").active,500);
c.runOnClient(m->widgets(m.gui.screen()).filter(CommunityPhotoWidget.class::isInstance).map(CommunityPhotoWidget.class::cast).findFirst().orElseThrow().onPress(enter()));
c.takeScreenshot("photos157-preview");
c.runOnClient(m->press(m.gui.screen(),"sanctuary.community.photo_assign"));
c.runOnClient(m->{check(button(m.gui.screen(),"publish").active,"Photo enables publishing");check(edit(m.gui.screen()).getValue().equals("Une journée au village"),"Selection retains draft");press(m.gui.screen(),"sanctuary.community.publish");});
c.waitFor(m->has(m.gui.screen(),"sanctuary.community.reply")&&!CommunityClient.pending(),600);
c.runOnClient(m->check(widgets(m.gui.screen()).anyMatch(CommunityPhotoWidget.class::isInstance),"Published photo displayed"));
c.takeScreenshot("photos157-article"+(database?"-database":"-file"));
world.getServer().runOnServer(s->{try {
var config=CommunityConfig.load(net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir().resolve("sanctuary-community.json"));
CommunityStore store=database?new SqlCommunityStore(config.jdbcUrl(),config.user(),"",config.serverId()):new FileCommunityStore(s.getWorldPath(net.minecraft.world.level.storage.LevelResource.ROOT).resolve("data/sanctuary-community.json"),config.serverId());
var posts=store.list("article","",0).entries();check(posts.size()==1,"Single publication");check(CommunityPhoto.validate(posts.getFirst().photo()).length()>8000,"Photo transported in multiple chunks and survives adapter restart");
}catch(Exception e){throw new AssertionError(e);}});
c.runOnClient(m->m.gui.setScreen(null));
System.out.println("PHOTOS157_CLIENT_PASS mode="+(database?"database":"file"));
} finally {intro.set(false);}
}
private static AbstractButton button(Screen screen,String key) {return widgets(screen).filter(AbstractButton.class::isInstance).map(AbstractButton.class::cast).filter(b->b.getMessage().getContents() instanceof net.minecraft.network.chat.contents.TranslatableContents t&&t.getKey().equals("sanctuary.community."+key)).findFirst().orElseThrow();}
}
@@ -19,7 +19,7 @@ public final class CommunityClient {
private static Consumer<JsonObject> callback;
private static boolean overviewRequest;
static final Map<String,Draft> DRAFTS = new HashMap<>();
static final class Draft { String operation = UUID.randomUUID().toString(), title = "", body = "", category = "info"; }
static final class Draft { String operation = UUID.randomUUID().toString(), title = "", body = "", category = "info", photo = ""; }
private CommunityClient() { }
public static net.minecraft.network.chat.MutableComponent text(String key, Object... args) { return Component.translatable("sanctuary.community." + key,args); }
public static boolean available() { return ClientPlayNetworking.canSend(CommunityNetwork.Request.TYPE); }
@@ -0,0 +1,43 @@
package fr.koka.sanctuary.client;
import com.mojang.blaze3d.platform.NativeImage;
import java.awt.image.BufferedImage;
import java.util.UUID;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.AbstractButton;
import net.minecraft.client.gui.narration.NarrationElementOutput;
import net.minecraft.client.input.InputWithModifiers;
import net.minecraft.client.renderer.RenderPipelines;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
/** A locally owned texture; never installed under a user-controlled resource identifier. */
final class CommunityPhotoWidget extends AbstractButton implements AutoCloseable {
private final Identifier texture=Identifier.fromNamespaceAndPath("sanctuary","community/"+UUID.randomUUID());
private final int imageWidth,imageHeight;
private final Runnable action;
private final boolean caption;
private boolean closed;
CommunityPhotoWidget(int width,int height,BufferedImage image,Component label,boolean caption,Runnable action) {
super(0,0,width,height,label);this.action=action;this.caption=caption;
imageWidth=image.getWidth();imageHeight=image.getHeight();
var nativeImage=new NativeImage(imageWidth,imageHeight,false);
for(int y=0;y<imageHeight;y++)for(int x=0;x<imageWidth;x++)nativeImage.setPixel(x,y,0xff000000|image.getRGB(x,y));
Minecraft.getInstance().getTextureManager().register(texture,new DynamicTexture(()->"Gazette photo",nativeImage));
active=action!=null;
}
@Override public void onPress(InputWithModifiers input) {if(action!=null)action.run();}
@Override protected void extractContents(GuiGraphicsExtractor g,int mx,int my,float delta) {
if(closed)return;
int x=getX(),y=getY(),available=height-(caption?18:0);
g.fill(x,y,x+width,y+height,isHoveredOrFocused()?0xff536957:0xff1b2028);
double scale=Math.min((double)(width-4)/imageWidth,(double)(available-4)/imageHeight);
int w=(int)(imageWidth*scale),h=(int)(imageHeight*scale);
g.blit(RenderPipelines.GUI_TEXTURED,texture,x+(width-w)/2,y+(available-h)/2,0,0,w,h,imageWidth,imageHeight,imageWidth,imageHeight);
if(caption)g.text(Minecraft.getInstance().font,Minecraft.getInstance().font.plainSubstrByWidth(getMessage().getString(),width-10),x+5,y+height-13,0xffeeeeee,false);
}
@Override protected void updateWidgetNarration(NarrationElementOutput output) {defaultButtonNarrationText(output);}
@Override public void close() {if(!closed){closed=true;Minecraft.getInstance().getTextureManager().release(texture);}}
}
@@ -9,7 +9,7 @@ import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import static fr.koka.sanctuary.client.CommunityClient.*;
/** Native text-only pages: user content is always a literal Minecraft component. */
/** Native community pages: user content is always a literal Minecraft component. */
public final class CommunityScreen extends BlocodexFrame {
private final String kind;
private String id, editingId = "", message = "", expandedId = "";
@@ -18,6 +18,14 @@ public final class CommunityScreen extends BlocodexFrame {
private JsonObject data = new JsonObject();
private Draft draft;
private int ticks;
private final java.util.List<CommunityPhotoWidget> photos=new java.util.ArrayList<>();
private boolean publishing;
private int photoPart, sendAfter;
private String outgoingPhoto="";
private Runnable commit;
private void closePhotos() {photos.forEach(CommunityPhotoWidget::close);photos.clear();}
@Override public void removed() {closePhotos();publishing=false;commit=null;}
public CommunityScreen(Screen parent,String kind,String id,boolean compose) {
super(parent); this.kind = kind; this.id = id; this.composing = compose;
moderator = flag(overview,"moderator");
@@ -25,7 +33,7 @@ public final class CommunityScreen extends BlocodexFrame {
}
private void useDraft(String key) { draft = DRAFTS.computeIfAbsent(key, unused -> new Draft()); }
@Override protected void init() {
initFrame("history",text(kind)); contentWidth=Math.min(520,contentWidth); contentX=(width-contentWidth)/2; int w = contentWidth - 28;
closePhotos();initFrame("history",text(kind)); contentWidth=Math.min(520,contentWidth); contentX=(width-contentWidth)/2; int w = contentWidth - 28;
var layout = LinearLayout.vertical().spacing(8);
if (!message.isEmpty()) line(layout,w,text(message));
if (!available()) line(layout,w,text("unavailable"));
@@ -59,6 +67,7 @@ public final class CommunityScreen extends BlocodexFrame {
button(toolbar,90,"back",() -> { id=""; page=0; data=new JsonObject(); needsLoad=true; rebuildWidgets(); });
button(toolbar,90,"refresh",() -> needsLoad=true);
line(layout,w,Component.literal(str(entry,"title")).withStyle(net.minecraft.ChatFormatting.BOLD));
photo(layout,w,str(entry,"photo"));
message(layout,w,entry);
if (kind.equals("notice")) line(layout,w,text("category."+str(entry,"category")).append(" · ").append(text(str(entry,"status"))));
if (!kind.equals("bulletin")) {
@@ -99,7 +108,7 @@ public final class CommunityScreen extends BlocodexFrame {
var row=layout.addChild(LinearLayout.horizontal().spacing(4)); int size=Math.min(100,(w-12)/4);
if (own) button(row,size,"edit",() -> {
editingId=str(entry,"id"); revision=number(entry,"revision"); composing=true; useDraft("edit:"+editingId);
if (draft.body.isEmpty()) { draft.title=str(entry,"title"); draft.body=str(entry,"body"); draft.category=str(entry,"category"); }
if (draft.body.isEmpty()) { draft.title=str(entry,"title"); draft.body=str(entry,"body"); draft.category=str(entry,"category"); draft.photo=str(entry,"photo"); }
rebuildWidgets();
});
if (own || moderator) button(row,size,"hide",() -> minecraft.gui.setScreen(new net.minecraft.client.gui.screens.ConfirmScreen(
@@ -119,21 +128,39 @@ public final class CommunityScreen extends BlocodexFrame {
draft.category=categories[(i+1)%categories.length]; rebuildWidgets();
});
}
if(!reply && kind.equals("article")) {
photo(layout,w,draft.photo);
button(layout,w,draft.photo.isEmpty()?"photo_choose":"photo_change",()->minecraft.gui.setScreen(new CommunityScreenshotsScreen(this,value->draft.photo=value)));
if(draft.photo.isEmpty())line(layout,w,text("photo_required"));
}
line(layout,w,text("body_label"));
var bodyBox=layout.addChild(MultiLineEditBox.builder().setPlaceholder(text("body_hint")).build(font,w,Math.max(60,Math.min(150,height/3)),text("body_label")));
bodyBox.setCharacterLimit(reply?1000:4000); bodyBox.setValue(draft.body); bodyBox.setValueListener(value -> draft.body=value);
button(layout,110,editingId.isEmpty()?"publish":"save",() -> {
var publish=layout.addChild(Button.builder(text(publishing?"photo_uploading":editingId.isEmpty()?"publish":"save"),b -> {
if(pending() || publishing)return;
String action=editingId.isEmpty()?"create":"edit";
request(this,action,editingId.isEmpty()?draft.operation:editingId,reply?id:"",reply?"reply":kind,draft.category,
commit=()->request(this,action,editingId.isEmpty()?draft.operation:editingId,reply?id:"",reply?"reply":kind,draft.category,
reply?"":draft.title,draft.body,revision,0,result -> {
publishing=false;commit=null;
if (result.has("error")) { message=str(result,"error"); rebuildWidgets(); return; }
String saved=str(result.getAsJsonObject("result"),"id");
DRAFTS.values().removeIf(value -> value==draft); composing=false; editingId="";
if (!reply) id=saved; page=0; needsLoad=true; message="saved"; stale=true; rebuildWidgets();
});
});
if(!reply && kind.equals("article")) {outgoingPhoto=draft.photo;photoPart=0;publishing=true;sendAfter=ticks+4;rebuildWidgets();}
else {var actionToRun=commit;commit=null;actionToRun.run();}
}).width(150).build());
publish.active=!publishing && (reply || !kind.equals("article") || !draft.photo.isEmpty());
button(layout,Math.min(w,220),"back_draft",() -> { composing=false; editingId=""; needsLoad=true; rebuildWidgets(); });
}
private void photo(LinearLayout layout,int w,String value) {
if(value.isEmpty())return;
try {
var image=fr.koka.sanctuary.community.CommunityPhoto.decode(java.util.Base64.getDecoder().decode(value),960,540);
var widget=new CommunityPhotoWidget(w,Math.max(24,Math.min(200,w*image.getHeight()/image.getWidth())),image,text("photo"),false,null);
photos.add(widget);layout.addChild(widget);
} catch(Exception e) {line(layout,w,text("photo_invalid"));}
}
private void mutate(String action,JsonObject entry) {
request(this,action,str(entry,"id"),"",str(entry,"kind"),str(entry,"category"),"","",number(entry,"revision"),0,result -> {
message=result.has("error")?str(result,"error"):"saved";
@@ -151,11 +178,21 @@ public final class CommunityScreen extends BlocodexFrame {
}
private void line(LinearLayout layout,int w,Component value) { layout.addChild(new MultiLineTextWidget(value,font).setMaxWidth(w)); }
private void button(LinearLayout layout,int w,String key,Runnable action) {
layout.addChild(Button.builder(text(key),b -> { if (!pending()) action.run(); }).width(w).build());
layout.addChild(Button.builder(text(key),b -> { if (!pending() && !publishing) action.run(); }).width(w).build());
}
private static String date(JsonObject e) { return DateTimeFormatter.ofPattern("dd/MM HH:mm 'UTC'").withZone(ZoneOffset.UTC).format(Instant.ofEpochMilli(e.get("createdAt").getAsLong())); }
@Override public void tick() {
super.tick(); ticks++;
if(publishing && !pending() && ticks>=sendAfter) {
int parts=(outgoingPhoto.length()+7999)/8000;
if(photoPart<parts) {
int part=photoPart;
request(this,"photo",editingId.isEmpty()?draft.operation:editingId,"","article","info","",outgoingPhoto.substring(part*8000,Math.min(outgoingPhoto.length(),(part+1)*8000)),parts,part,result->{
if(result.has("error")){publishing=false;commit=null;message=str(result,"error");rebuildWidgets();}
else {photoPart++;sendAfter=ticks+4;}
});
} else if(commit!=null) {var actionToRun=commit;commit=null;actionToRun.run();}
}
if (!composing && needsLoad && ticks%4==0 && request(this,id.isEmpty()?"list":"thread",id,"",kind,"info","","",0,page,result -> {
if (result.has("error")) message=str(result,"error");
else { data=result; moderator=flag(result,"moderator"); }
@@ -0,0 +1,108 @@
package fr.koka.sanctuary.client;
import fr.koka.sanctuary.community.CommunityPhoto;
import java.awt.image.BufferedImage;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Consumer;
import net.minecraft.client.gui.components.*;
import net.minecraft.client.gui.layouts.LinearLayout;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import static fr.koka.sanctuary.client.CommunityClient.text;
/** Read-only screenshot library, paged and scrollable; no disk or image decoding on the render thread. */
public final class CommunityScreenshotsScreen extends BlocodexFrame {
private static final int PAGE_SIZE=24;
private static final ExecutorService LOADER=Executors.newSingleThreadExecutor(r->{var t=new Thread(r,"Sanctuary screenshots");t.setDaemon(true);return t;});
private record Shot(String name,String photo,BufferedImage thumbnail) { }
private record Library(List<Path> paths,List<Shot> shots,String error) { }
private final Consumer<String> assign;
private final List<CommunityPhotoWidget> textures=new ArrayList<>();
private List<Path> paths=List.of();
private List<Shot> shots=List.of();
private CompletableFuture<Library> loading;
private int page;
private boolean loaded;
private String error="";
private Shot selected;
public CommunityScreenshotsScreen(Screen parent,Consumer<String> assign) {super(parent);this.assign=assign;}
private void load(boolean refresh) {
loaded=true;
Path folder=minecraft.gameDirectory.toPath().resolve("screenshots");
List<Path> known=paths;int offset=page*PAGE_SIZE;
loading=CompletableFuture.supplyAsync(()->{
try {
List<Path> all=known;
if(refresh) {
if(!Files.isDirectory(folder))return new Library(List.of(),List.of(),"");
try(var files=Files.list(folder)) {
all=files.filter(p->p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".png")&&Files.isRegularFile(p,LinkOption.NOFOLLOW_LINKS))
.sorted(Comparator.comparing((Path p)->p.getFileName().toString()).reversed()).limit(4096).toList();
}
}
var found=new ArrayList<Shot>();
for(Path file:all.stream().skip(offset).limit(PAGE_SIZE).toList()) {
try {
if(!Files.isRegularFile(file,LinkOption.NOFOLLOW_LINKS) || Files.size(file)>32*1024*1024)throw new java.io.IOException("Screenshot size");
byte[] bytes;
try(var in=Files.newInputStream(file,LinkOption.NOFOLLOW_LINKS)) {bytes=in.readNBytes(32*1024*1024+1);}
if(bytes.length>32*1024*1024)throw new java.io.IOException("Screenshot size");
var source=CommunityPhoto.decode(bytes,8192,8192);
String photo=CommunityPhoto.encode(source,CommunityPhoto.WIDTH,CommunityPhoto.HEIGHT);
String thumb=CommunityPhoto.encode(source,240,135);
found.add(new Shot(file.getFileName().toString(),photo,CommunityPhoto.decode(Base64.getDecoder().decode(thumb),240,135)));
} catch(Exception e) {found.add(new Shot(file.getFileName().toString(),"",null));}
}
return new Library(all,found,"");
} catch(Exception e) {return new Library(List.of(),List.of(),"screenshots_error");}
},LOADER);
}
@Override protected void init() {
closeTextures();initFrame("history",text("screenshots"));
if(!loaded)load(true);
if(selected!=null) {preview();return;}
int w=contentWidth-24;
var controls=LinearLayout.horizontal().spacing(4);
controls.addChild(Button.builder(text("previous"),b->{page--;load(false);rebuildWidgets();}).width(72).build()).active=page>0 && loading==null;
controls.addChild(new StringWidget(Math.max(30,w-228),20,text("page",page+1),font));
controls.addChild(Button.builder(text("next"),b->{page++;load(false);rebuildWidgets();}).width(72).build()).active=(page+1)*PAGE_SIZE<paths.size() && loading==null;
controls.addChild(Button.builder(text("refresh"),b->{page=0;load(true);rebuildWidgets();}).width(76).build()).active=loading==null;
controls.setPosition(contentX+8,contentBottom-20);controls.arrangeElements();controls.visitWidgets(this::addRenderableWidget);
var layout=LinearLayout.vertical().spacing(8);
if(loading!=null)layout.addChild(new StringWidget(text("loading"),font));
else if(!error.isEmpty() || shots.isEmpty())layout.addChild(new MultiLineTextWidget(text(error.isEmpty()?"screenshots_empty":error),font).setMaxWidth(w));
else {
int columns=w>=450?3:2,cell=(w-(columns-1)*8)/columns;
for(int i=0;i<shots.size();i+=columns) {
var row=layout.addChild(LinearLayout.horizontal().spacing(8));
for(int j=i;j<Math.min(i+columns,shots.size());j++) {
Shot shot=shots.get(j);
if(shot.thumbnail()==null)row.addChild(Button.builder(Component.literal(shot.name()),b->{}).size(cell,cell*9/16+18).tooltip(Tooltip.create(text("photo_invalid"))).build()).active=false;
else {var widget=new CommunityPhotoWidget(cell,cell*9/16+18,shot.thumbnail(),Component.literal(shot.name()),true,()->{selected=shot;rebuildWidgets();});textures.add(widget);row.addChild(widget);}
}
}
}
var scroll=new ScrollableLayout(minecraft,layout,Math.max(24,contentBottom-contentTop-28),ScrollableLayout.ReserveStrategy.RIGHT);
scroll.setPosition(contentX+8,contentTop);scroll.arrangeElements();scroll.visitWidgets(this::addRenderableWidget);
}
private void preview() {
int w=contentWidth-16,h=Math.max(24,contentBottom-contentTop-34);
try {
var image=CommunityPhoto.decode(Base64.getDecoder().decode(selected.photo()),CommunityPhoto.WIDTH,CommunityPhoto.HEIGHT);
var widget=new CommunityPhotoWidget(w,h,image,Component.literal(selected.name()),false,null);
textures.add(widget);widget.setPosition(contentX+8,contentTop);addRenderableWidget(widget);
} catch(Exception e) {error="photo_invalid";selected=null;return;}
addRenderableWidget(Button.builder(text("screenshots_back"),b->{selected=null;rebuildWidgets();}).bounds(contentX+8,contentBottom-24,(w-6)/2,20).build());
addRenderableWidget(Button.builder(text("photo_assign"),b->{assign.accept(selected.photo());minecraft.gui.setScreen(parent);}).bounds(contentX+8+(w+6)/2,contentBottom-24,(w-6)/2,20).build());
}
@Override public void tick() {
if(loading!=null && loading.isDone()) {
var result=loading.join();loading=null;paths=result.paths();shots=result.shots();error=result.error();rebuildWidgets();
}
}
private void closeTextures() {textures.forEach(CommunityPhotoWidget::close);textures.clear();}
@Override public void removed() {closeTextures();}
@Override public boolean isPauseScreen() {return false;}
}
@@ -4,7 +4,7 @@ import java.util.*;
/** Versioned community rules shared by both persistence adapters. */
public final class Community {
public static final int VERSION = 1, PAGE_SIZE = 10;
public static final int VERSION = 2, PAGE_SIZE = 10;
public static final Set<String> KINDS = Set.of("article", "notice", "bulletin", "reply");
public static final Set<String> CATEGORIES = Set.of("info", "work", "need", "event");
private Community() { }
@@ -13,8 +13,10 @@ public final class Community {
}
public record Entry(String id, String parentId, String kind, String category,
String authorId, String authorName, String title, String body,
String status, boolean pinned, int revision, long createdAt, long updatedAt) {
String status, boolean pinned, int revision, long createdAt, long updatedAt, String photo) {
public Entry {
photo = Objects.requireNonNullElse(photo, "");
if (photo.length() > CommunityPhoto.MAX_TEXT || (!photo.isEmpty() && !kind.equals("article"))) fail("photo_invalid");
uuid(id); uuid(authorId); if (!parentId.isEmpty()) uuid(parentId);
if (!KINDS.contains(kind) || !CATEGORIES.contains(category)
|| !Set.of("open", "closed", "hidden").contains(status) || revision < 1
@@ -25,7 +27,11 @@ public final class Community {
}
}
public record Change(String action, String id, String parentId, String kind, String category,
String title, String body, int revision) { }
String title, String body, int revision, String photo) {
public Change(String action,String id,String parentId,String kind,String category,String title,String body,int revision) {
this(action,id,parentId,kind,category,title,body,revision,null);
}
}
public record Page(List<Entry> entries, int page, boolean more) { }
public record ThreadView(Entry entry, Page replies) { }
public static final class Refused extends RuntimeException {
@@ -59,21 +65,22 @@ public final class Community {
if (existing != null) {
if (!existing.authorId().equals(actor.id()) || !existing.kind().equals(change.kind())
|| !existing.parentId().equals(parent) || !existing.category().equals(change.category())
|| !existing.title().equals(change.title()) || !existing.body().equals(change.body())) fail("conflict");
|| !existing.title().equals(change.title()) || !existing.body().equals(change.body())
|| !existing.photo().equals(Objects.requireNonNullElse(change.photo(),""))) fail("conflict");
return visible(existing);
}
if (!parent.isEmpty() && !tx.get(parent).status().equals("open")) fail("closed");
Entry entry = new Entry(change.id(), parent, change.kind(), change.category(), actor.id(), actor.name(),
change.title(), change.body(), "open", false, 1, now, now);
change.title(), change.body(), "open", false, 1, now, now, photo(change.kind(),change.photo()));
tx.save(entry, true); return entry;
}
Entry old = visible(tx.get(change.id()));
if (!old.parentId().isEmpty()) visible(tx.get(old.parentId()));
if (old.revision() != change.revision()) fail("conflict");
boolean own = old.authorId().equals(actor.id());
String title = old.title(), body = old.body(), status = old.status(); boolean pinned = old.pinned();
String title = old.title(), body = old.body(), status = old.status(), photo = old.photo(); boolean pinned = old.pinned();
switch (change.action()) {
case "edit" -> { if (!own) fail("forbidden"); title = change.title(); body = change.body(); }
case "edit" -> { if (!own) fail("forbidden"); title = change.title(); body = change.body(); photo = photo(old.kind(),change.photo()==null?old.photo():change.photo()); }
case "hide" -> { if (!own && !actor.moderator()) fail("forbidden"); status = "hidden"; }
case "close", "reopen" -> {
if ((!own && !actor.moderator()) || !old.kind().equals("notice")) fail("forbidden");
@@ -83,9 +90,14 @@ public final class Community {
default -> fail("invalid");
}
Entry next = new Entry(old.id(), old.parentId(), old.kind(), old.category(), old.authorId(), old.authorName(),
title, body, status, pinned, Math.addExact(old.revision(), 1), old.createdAt(), Math.max(now, old.updatedAt()));
title, body, status, pinned, Math.addExact(old.revision(), 1), old.createdAt(), Math.max(now, old.updatedAt()), photo);
tx.save(next, false); return next;
}
private static String photo(String kind,String photo) {
if(kind.equals("article"))return CommunityPhoto.validate(photo);
if(photo!=null && !photo.isEmpty())fail("photo_invalid");
return "";
}
public static Entry visible(Entry entry) {
if (entry == null || entry.status().equals("hidden")) fail("missing");
return entry;
@@ -33,7 +33,7 @@ public final class CommunityNetwork {
public record Response(int sequence, String json) implements CustomPacketPayload {
public static final Type<Response> TYPE = new Type<>(SanctuaryMod.id("community_response_v1"));
public static final StreamCodec<RegistryFriendlyByteBuf, Response> CODEC = CustomPacketPayload.codec(
(p,b) -> { b.writeVarInt(p.sequence); b.writeUtf(p.json,131072); }, b -> new Response(b.readVarInt(),b.readUtf(131072)));
(p,b) -> { b.writeVarInt(p.sequence); b.writeUtf(p.json,262144); }, b -> new Response(b.readVarInt(),b.readUtf(262144)));
public Type<Response> type() { return TYPE; }
}
private CommunityNetwork() { }
@@ -43,7 +43,7 @@ public final class CommunityNetwork {
ServerPlayNetworking.registerGlobalReceiver(Request.TYPE, (r,c) -> c.server().execute(() -> request(c.player(),r)));
ServerLifecycleEvents.SERVER_STOPPED.register(server -> { var runtime = RUNTIMES.remove(server); if (runtime != null) runtime.stop(); });
ServerPlayConnectionEvents.DISCONNECT.register((handler,server) -> {
var runtime = RUNTIMES.get(server); if (runtime != null) runtime.rates.remove(handler.player.getUUID());
var runtime = RUNTIMES.get(server); if (runtime != null) { runtime.rates.remove(handler.player.getUUID()); runtime.photos.remove(handler.player.getUUID()); }
});
}
private static void send(ServerPlayer player, Request request, JsonObject data) {
@@ -54,7 +54,7 @@ public final class CommunityNetwork {
private static void request(ServerPlayer player, Request request) {
var server = player.level().getServer();
var runtime = RUNTIMES.computeIfAbsent(server, Runtime::new);
long now = System.nanoTime(); boolean reading = Set.of("overview","list","thread").contains(request.action());
long now = System.nanoTime(); boolean reading = Set.of("overview","list","thread","photo").contains(request.action());
long[] rates = runtime.rates.computeIfAbsent(player.getUUID(), key -> new long[2]); int index = reading ? 0 : 1;
if (runtime.pending.contains(player.getUUID()) || now - rates[index] < (reading ? 100_000_000L : 1_000_000_000L)) { send(player,request,error("busy")); return; }
rates[index] = now;
@@ -76,9 +76,22 @@ public final class CommunityNetwork {
}
case "list" -> result.add("page",JSON.toJsonTree(store.list(request.kind(),"",request.page())));
case "thread" -> result.add("thread",JSON.toJsonTree(store.thread(request.id(),request.page())));
case "photo" -> {
long instant=System.currentTimeMillis();
runtime.photos.values().removeIf(p -> instant-p.started>120000);
if(request.page()==0) {
if(runtime.photos.size()>=128 && !runtime.photos.containsKey(player.getUUID()))fail("busy");
runtime.photos.put(player.getUUID(),new CommunityPhoto.Transfer(request.id(),request.revision(),instant));
}
var transfer=runtime.photos.get(player.getUUID());
if(transfer==null)fail("photo_required");
transfer.append(request.id(),request.page(),request.revision(),request.body(),instant);
result.addProperty("uploaded",request.page()+1);
}
case "link" -> { store.link(actor,request.body()); result.addProperty("linked",true); }
default -> result.add("result",JSON.toJsonTree(store.change(actor,new Change(request.action(),request.id(),request.parentId(),request.kind(),request.category(),request.title(),request.body(),request.revision()))));
default -> result.add("result",JSON.toJsonTree(store.change(actor,new Change(request.action(),request.id(),request.parentId(),request.kind(),request.category(),request.title(),request.body(),request.revision(),runtime.photo(player.getUUID(),request)))));
}
if(request.action().equals("list") || request.action().equals("overview"))omitPhotos(result);
} catch (Refused e) { result = error(e.code); }
catch (Exception e) { result = error("unavailable"); LOG.warn("Community operation unavailable ({})",e.getClass().getSimpleName()); }
JsonObject response = result;
@@ -89,12 +102,27 @@ public final class CommunityNetwork {
});
} catch (RejectedExecutionException e) { runtime.pending.remove(player.getUUID()); send(player,request,error("busy")); }
}
public static void omitPhotos(JsonElement value) {
if(value.isJsonArray())value.getAsJsonArray().forEach(CommunityNetwork::omitPhotos);
else if(value.isJsonObject()) {
var object=value.getAsJsonObject();
if(object.has("photo")) {object.addProperty("hasPhoto",!object.get("photo").getAsString().isEmpty());object.remove("photo");}
object.entrySet().forEach(e -> omitPhotos(e.getValue()));
}
}
private static final class Runtime {
final MinecraftServer server;
final ThreadPoolExecutor executor = new ThreadPoolExecutor(1,1,0,TimeUnit.SECONDS,new ArrayBlockingQueue<>(32),
task -> { var thread = new Thread(task,"Sanctuary community"); thread.setDaemon(true); return thread; });
final Map<UUID,long[]> rates = new HashMap<>();
final Set<UUID> pending = new HashSet<>();
final Map<UUID,CommunityPhoto.Transfer> photos=new ConcurrentHashMap<>();
String photo(UUID player,Request request) {
if(!Set.of("create","edit").contains(request.action()) || !request.kind().equals("article"))return null;
var transfer=photos.get(player);
if(transfer==null || !transfer.id.equals(request.id()))return null;
return transfer.value(request.id(),System.currentTimeMillis());
}
CommunityConfig config;
CommunityStore store;
Runtime(MinecraftServer server) { this.server = server; }
@@ -0,0 +1,82 @@
package fr.koka.sanctuary.community;
import java.awt.image.BufferedImage;
import java.io.*;
import java.util.Base64;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.stream.MemoryCacheImageInputStream;
import javax.imageio.stream.MemoryCacheImageOutputStream;
import static fr.koka.sanctuary.community.Community.fail;
/** Bounded, portable JPEG copies; originals and their metadata never leave the screenshot folder. */
public final class CommunityPhoto {
public static final int MAX_BYTES=48*1024, MAX_TEXT=65536, WIDTH=960, HEIGHT=540;
private CommunityPhoto() { }
public static final class Transfer {
public final String id;
public final long started;
private final int parts;
private int next;
private final StringBuilder data=new StringBuilder();
public Transfer(String id,int parts,long now) {
this.id=Community.uuid(id);this.parts=parts;this.started=now;
if(parts<1 || parts>9)fail("photo_invalid");
}
public void append(String id,int part,int total,String value,long now) {
if(!this.id.equals(id) || part!=next || part>=parts || total!=parts || now-started>120000
|| value.isEmpty() || value.length()>8000 || data.length()+value.length()>MAX_TEXT)fail("photo_invalid");
data.append(value);next++;
}
public String value(String id,long now) {
if(!this.id.equals(id) || next!=parts || now-started>120000)fail("photo_required");
return data.toString();
}
}
public static BufferedImage decode(byte[] bytes,int maxWidth,int maxHeight) throws IOException {
try(var stream=new MemoryCacheImageInputStream(new ByteArrayInputStream(bytes))) {
var readers=ImageIO.getImageReaders(stream);
if(!readers.hasNext())throw new IOException("Invalid image");
var reader=readers.next();
try {
reader.setInput(stream,true,true);
int w=reader.getWidth(0),h=reader.getHeight(0);
if(w<1 || h<1 || w>maxWidth || h>maxHeight || (long)w*h>32_000_000)throw new IOException("Image dimensions");
var image=reader.read(0);
if(image==null)throw new IOException("Invalid image");
return image;
} finally {reader.dispose();}
}
}
public static String validate(String value) {
if(value==null || value.isEmpty())fail("photo_required");
try {
if(value.length()>MAX_TEXT)throw new IOException("Image size");
byte[] bytes=Base64.getDecoder().decode(value);
if(bytes.length>MAX_BYTES || bytes.length<4 || bytes[0]!=(byte)0xff || bytes[1]!=(byte)0xd8
|| bytes[bytes.length-2]!=(byte)0xff || bytes[bytes.length-1]!=(byte)0xd9
|| !Base64.getEncoder().encodeToString(bytes).equals(value))throw new IOException("Invalid JPEG");
decode(bytes,WIDTH,HEIGHT);
return value;
} catch(IOException | IllegalArgumentException e) {fail("photo_invalid");return "";}
}
public static String encode(BufferedImage source,int maxWidth,int maxHeight) throws IOException {
double scale=Math.min(1,Math.min((double)maxWidth/source.getWidth(),(double)maxHeight/source.getHeight()));
var image=new BufferedImage(Math.max(1,(int)(source.getWidth()*scale)),Math.max(1,(int)(source.getHeight()*scale)),BufferedImage.TYPE_INT_RGB);
var graphics=image.createGraphics();
graphics.setRenderingHint(java.awt.RenderingHints.KEY_INTERPOLATION,java.awt.RenderingHints.VALUE_INTERPOLATION_BILINEAR);
graphics.drawImage(source,0,0,image.getWidth(),image.getHeight(),null);graphics.dispose();
for(float quality:new float[]{.85f,.7f,.55f,.4f,.25f}) {
var bytes=new ByteArrayOutputStream();
var writer=ImageIO.getImageWritersByFormatName("jpeg").next();
try(var out=new MemoryCacheImageOutputStream(bytes)) {
writer.setOutput(out);var params=writer.getDefaultWriteParam();
params.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);params.setCompressionQuality(quality);
writer.write(null,new javax.imageio.IIOImage(image,null,null),params);
} finally {writer.dispose();}
if(bytes.size()<=MAX_BYTES)return Base64.getEncoder().encodeToString(bytes.toByteArray());
}
if(maxWidth>240)return encode(image,maxWidth/2,maxHeight/2);
throw new IOException("Image size");
}
}
@@ -15,6 +15,7 @@ public final class FileCommunityStore implements CommunityStore {
private final String serverId;
private final Clock clock;
private byte[] previous;
private boolean legacy;
private Map<String, Entry> entries = new LinkedHashMap<>();
public FileCommunityStore(Path path, String serverId) throws Exception { this(path, serverId, Clock.systemUTC()); }
public FileCommunityStore(Path path, String serverId, Clock clock) throws Exception {
@@ -23,7 +24,8 @@ public final class FileCommunityStore implements CommunityStore {
if (previous != null) {
var root = AtomicJson.parse(new String(previous, StandardCharsets.UTF_8)).getAsJsonObject();
AtomicJson.fields(root, "version", "serverId", "entries");
if (root.get("version").getAsInt() != VERSION || !root.get("serverId").getAsString().equals(serverId)) fail("schema");
legacy = root.get("version").getAsInt()==1;
if ((!legacy && root.get("version").getAsInt() != VERSION) || !root.get("serverId").getAsString().equals(serverId)) fail("schema");
for (var value : root.getAsJsonArray("entries")) {
Entry entry = JSON.fromJson(value, Entry.class);
if (entries.put(entry.id(), entry) != null) fail("invalid");
@@ -59,8 +61,14 @@ public final class FileCommunityStore implements CommunityStore {
if (dirty[0]) {
var root = new JsonObject(); root.addProperty("version", VERSION); root.addProperty("serverId", serverId);
root.add("entries", JSON.toJsonTree(candidate.values()));
if (legacy) {
Path backup=path.resolveSibling(path.getFileName()+".v1.bak");
if (Files.exists(backup)) {
if (!Arrays.equals(AtomicJson.read(backup,LIMIT),previous)) throw new java.io.IOException("Different migration backup");
} else Files.write(backup,previous,StandardOpenOption.CREATE_NEW);
}
previous = AtomicJson.write(path, previous, root.toString(), LIMIT);
entries = candidate;
entries = candidate; legacy=false;
}
return result;
}
@@ -35,7 +35,7 @@ public final class SqlCommunityStore implements CommunityStore {
return new Entry(r.getString("id"), Objects.requireNonNullElse(r.getString("parent_id"), ""), r.getString("kind"),
r.getString("category"), r.getString("author_id"), r.getString("author_name"), r.getString("title"),
r.getString("body"), r.getString("status"), r.getBoolean("pinned"), r.getInt("revision"),
r.getLong("created_at"), r.getLong("updated_at"));
r.getLong("created_at"), r.getLong("updated_at"), r.getString("photo"));
}
private Entry get(Connection c, String id, boolean lock) throws SQLException {
try (var s = query(c, "SELECT * FROM sanctuary_community_entries WHERE server_id=? AND id=?" + (lock ? " FOR UPDATE" : ""), serverId, id);
@@ -72,11 +72,11 @@ public final class SqlCommunityStore implements CommunityStore {
public Entry get(String id) throws Exception { return SqlCommunityStore.this.get(c, id, true); }
public void save(Entry e, boolean insert) throws Exception {
if (insert) {
try (var s = query(c, "INSERT INTO sanctuary_community_entries (server_id,id,parent_id,kind,category,author_id,author_name,title,body,status,pinned,revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
serverId, e.id(), e.parentId().isEmpty() ? null : e.parentId(), e.kind(), e.category(), e.authorId(), e.authorName(), e.title(), e.body(), e.status(), e.pinned(), e.revision(), e.createdAt(), e.updatedAt())) { s.executeUpdate(); }
try (var s = query(c, "INSERT INTO sanctuary_community_entries (server_id,id,parent_id,kind,category,author_id,author_name,title,body,status,pinned,revision,created_at,updated_at,photo) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
serverId, e.id(), e.parentId().isEmpty() ? null : e.parentId(), e.kind(), e.category(), e.authorId(), e.authorName(), e.title(), e.body(), e.status(), e.pinned(), e.revision(), e.createdAt(), e.updatedAt(), e.photo().isEmpty()?null:e.photo())) { s.executeUpdate(); }
} else {
try (var s = query(c, "UPDATE sanctuary_community_entries SET title=?,body=?,status=?,pinned=?,revision=?,updated_at=? WHERE server_id=? AND id=? AND revision=?",
e.title(), e.body(), e.status(), e.pinned(), e.revision(), e.updatedAt(), serverId, e.id(), e.revision() - 1)) {
try (var s = query(c, "UPDATE sanctuary_community_entries SET photo=?,title=?,body=?,status=?,pinned=?,revision=?,updated_at=? WHERE server_id=? AND id=? AND revision=?",
e.photo().isEmpty()?null:e.photo(), e.title(), e.body(), e.status(), e.pinned(), e.revision(), e.updatedAt(), serverId, e.id(), e.revision() - 1)) {
if (s.executeUpdate() != 1) fail("conflict");
}
}
@@ -2394,5 +2394,16 @@
"sanctuary.pause.map": "World map",
"sanctuary.pause.map_hint": "Open the map and explore",
"sanctuary.community.more": "Load more",
"sanctuary.community.browse": "All publications"
"sanctuary.community.browse": "All publications",
"sanctuary.community.photo": "Article photo",
"sanctuary.community.photo_choose": "Choose a screenshot…",
"sanctuary.community.photo_change": "Change photo…",
"sanctuary.community.photo_assign": "Assign to article",
"sanctuary.community.photo_required": "Choose a photo to publish this article.",
"sanctuary.community.photo_invalid": "Photo cannot be read or is too large.",
"sanctuary.community.photo_uploading": "Uploading photo…",
"sanctuary.community.screenshots": "Screenshots",
"sanctuary.community.screenshots_back": "Back to screenshots",
"sanctuary.community.screenshots_empty": "No screenshots. Take a photo in game with F2, then return here.",
"sanctuary.community.screenshots_error": "Could not read screenshots."
}
@@ -2394,5 +2394,16 @@
"sanctuary.pause.map": "Carte du monde",
"sanctuary.pause.map_hint": "Ouvrir la carte et explorer",
"sanctuary.community.more": "Afficher la suite",
"sanctuary.community.browse": "Toutes les publications"
"sanctuary.community.browse": "Toutes les publications",
"sanctuary.community.photo": "Photo de l’article",
"sanctuary.community.photo_choose": "Choisir une capture…",
"sanctuary.community.photo_change": "Changer la photo…",
"sanctuary.community.photo_assign": "Assigner à l’article",
"sanctuary.community.photo_required": "Choisissez une photo pour publier cet article.",
"sanctuary.community.photo_invalid": "Photo illisible ou trop grande.",
"sanctuary.community.photo_uploading": "Envoi de la photo…",
"sanctuary.community.screenshots": "Captures d’écran",
"sanctuary.community.screenshots_back": "Retour aux captures",
"sanctuary.community.screenshots_empty": "Aucune capture. Prenez une photo en jeu avec F2, puis revenez ici.",
"sanctuary.community.screenshots_error": "Impossible de lire les captures."
}
@@ -0,0 +1,3 @@
-- Stop both applications and back up the database first. Apply once to schema v1.
ALTER TABLE sanctuary_community_entries ADD COLUMN photo MEDIUMTEXT CHARACTER SET ascii COLLATE ascii_bin NULL;
UPDATE sanctuary_community_schema SET version=2 WHERE id=1 AND version=1;
@@ -17,7 +17,7 @@ public final class CommunityInterop {
fixture=new JsonObject();
for(String key:new String[]{"server","player","article"})fixture.addProperty(key,UUID.randomUUID().toString());
var store=new SqlCommunityStore(url,"root","",fixture.get("server").getAsString());
store.change(new Actor(fixture.get("player").getAsString(),"Minecraft 🌳",false), new Change("create",fixture.get("article").getAsString(),"","article","info","Depuis Minecraft","Un pont vers le site 🌳",0));
store.change(new Actor(fixture.get("player").getAsString(),"Minecraft 🌳",false), new Change("create",fixture.get("article").getAsString(),"","article","info","Depuis Minecraft","Un pont vers le site 🌳",0,CommunitySmoke.PHOTO));
Files.writeString(file,json.toJson(fixture));
} else {
fixture=JsonParser.parseString(Files.readString(file)).getAsJsonObject();
@@ -32,6 +32,8 @@ public final class CommunityInterop {
} else if(phase.equals("verify")) {
var thread=store.thread(fixture.get("article").getAsString(),0);
CommunitySmoke.check(thread.entry().revision()==2 && thread.entry().body().equals("Modifié depuis le site 🌳"),"Website edit visible in Java");
CommunityPhoto.validate(store.thread(fixture.get("web_article").getAsString(),0).entry().photo());
CommunitySmoke.check(!thread.entry().photo().isEmpty(),"Photo preserved by website edit");
CommunitySmoke.check(thread.replies().entries().size()==1 && thread.replies().entries().getFirst().body().equals("Réponse depuis le site"),"Website reply visible in Java");
CommunitySmoke.refuse("conflict",()->store.change(actor,new Change("edit",thread.entry().id(),"","article","info","Depuis Minecraft","Ancien brouillon",1)));
} else throw new IllegalArgumentException(phase);
@@ -8,7 +8,14 @@ public final class CommunitySmoke {
static Actor owner=new Actor(UUID.randomUUID().toString(),"Auteur",false);
static Actor other=new Actor(UUID.randomUUID().toString(),"Voisin",false);
static Actor moderator=new Actor(UUID.randomUUID().toString(),"Admin",true);
static Change create(String id,String parent,String kind,String title,String body) { return new Change("create",id,parent,kind,"info",title,body,0); }
public static String photo() {
try {var image=new java.awt.image.BufferedImage(960,540,java.awt.image.BufferedImage.TYPE_INT_RGB);
var random=new Random(157);for(int y=0;y<540;y++)for(int x=0;x<960;x++)image.setRGB(x,y,random.nextInt());
return CommunityPhoto.encode(image,960,540);
} catch(Exception e) {throw new AssertionError(e);}
}
static final String PHOTO=photo();
static Change create(String id,String parent,String kind,String title,String body) { return new Change("create",id,parent,kind,"info",title,body,0,kind.equals("article")?PHOTO:null); }
static Change action(String action,Entry e) { return new Change(action,e.id(),e.parentId(),e.kind(),e.category(),e.title(),e.body(),e.revision()); }
static void check(boolean ok,String why) { if(!ok)throw new AssertionError(why); }
interface Work { void run() throws Exception; }
@@ -37,9 +44,19 @@ public final class CommunitySmoke {
refuse("missing",()->store.change(other,action("edit",reply)));
refuse("invalid",()->store.change(owner,create(UUID.randomUUID().toString(),"","article","A","§ccommand")));
refuse("invalid",()->store.change(owner,create(UUID.randomUUID().toString(),"","article","A","x".repeat(4001))));
refuse("photo_required",()->store.change(owner,new Change("create",UUID.randomUUID().toString(),"","article","info","A","B",0)));
refuse("photo_invalid",()->store.change(owner,new Change("create",UUID.randomUUID().toString(),"","article","info","A","B",0,"not an image")));
var article=store.change(owner,create(UUID.randomUUID().toString(),"","article","🌳".repeat(120),"Récit"));
check(store.thread(article.id(),0).entry().equals(article),"Unicode text intact");
return article;
check(article.photo().equals(PHOTO),"Assigned photo persisted");
check(store.change(owner,new Change("create",article.id(),"","article","info",article.title(),article.body(),0,PHOTO)).equals(article),"Photo retry is idempotent");
refuse("conflict",()->store.change(owner,new Change("create",article.id(),"","article","info",article.title(),article.body(),0,"changed")));
refuse("photo_invalid",()->CommunityPhoto.validate("A".repeat(CommunityPhoto.MAX_TEXT+1)));
var edited=store.change(owner,new Change("edit",article.id(),"","article","info",article.title(),"Edition",1));
check(edited.photo().equals(PHOTO),"Edit retains photo");
refuse("photo_required",()->store.change(owner,new Change("edit",article.id(),"","article","info",article.title(),"Edition",2,"")));
refuse("forbidden",()->store.change(other,new Change("edit",article.id(),"","article","info",article.title(),"Edition",2,PHOTO)));
return edited;
}
public static void main(String[] args) throws Exception {
Path dir=Files.createTempDirectory("sanctuary-community-check-");
@@ -72,6 +89,26 @@ public final class CommunitySmoke {
refuse("already_linked",()->sql.link(owner,reassignment));
System.out.println("COMMUNITY154_SQL_PASS");
}
String operation=UUID.randomUUID().toString();
var transfer=new CommunityPhoto.Transfer(operation,2,1000);
transfer.append(operation,0,2,"abc",1000);
refuse("photo_invalid",()->transfer.append(operation,0,2,"abc",1001));
refuse("photo_required",()->transfer.value(operation,1001));
transfer.append(operation,1,2,"def",1002);
check(transfer.value(operation,1003).equals("abcdef"),"Ordered transfer");
refuse("photo_required",()->transfer.value(UUID.randomUUID().toString(),1003));
refuse("photo_required",()->transfer.value(operation,121001));
Path legacy=dir.resolve("legacy.json");
var root=new com.google.gson.JsonObject();root.addProperty("version",1);root.addProperty("serverId",server);
var entries=new com.google.gson.JsonArray();var old=new com.google.gson.Gson().toJsonTree(article).getAsJsonObject();old.remove("photo");entries.add(old);root.add("entries",entries);
String before=root.toString();Files.writeString(legacy,before);
var migrated=new FileCommunityStore(legacy,server);
check(migrated.thread(article.id(),0).entry().photo().isEmpty(),"Old text article readable");
refuse("photo_required",()->migrated.change(owner,action("edit",migrated.thread(article.id(),0).entry())));
migrated.change(owner,new Change("edit",article.id(),"","article","info",article.title(),article.body(),article.revision(),PHOTO));
check(Files.readString(dir.resolve("legacy.json.v1.bak")).equals(before),"Exact migration backup");
check(new FileCommunityStore(legacy,server).thread(article.id(),0).entry().photo().equals(PHOTO),"Migrated photo persists");
System.out.println("COMMUNITY157_PHOTOS_PASS");
System.out.println("COMMUNITY154_FILE_PASS");
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
name = "Sanctuary"
author = "KOKA99CAB"
version = "beta.156"
version = "beta.157"
pack-format = "packwiz:1.1.0"
[index]