From 39da801a61012f5106259531b0e9bfec77d92798 Mon Sep 17 00:00:00 2001 From: 0xb00bface <0xboobface@gmail.com> Date: Wed, 19 Apr 2023 19:48:29 +0200 Subject: [PATCH] Store model portraits on the server in client/server mode --- .../java/ctbrec/ui/CamrecApplication.java | 13 ++ .../ui/action/AbstractPortraitAction.java | 34 +++--- .../ctbrec/ui/action/SetPortraitAction.java | 52 +++----- .../ui/action/SetThumbAsPortraitAction.java | 44 ++++--- .../recorded/AbstractRecordedModelsTab.java | 61 ++++++---- .../ui/tabs/recorded/ModelImportExport.java | 36 ++++-- .../ui/tabs/recorded/PortraitStore.java | 47 -------- .../java/ctbrec/image/LocalPortraitStore.java | 63 ++++++++++ .../main/java/ctbrec/image/PortraitStore.java | 18 +++ .../ctbrec/image/RemotePortraitStore.java | 84 +++++++++++++ .../main/java/ctbrec/io/HttpConstants.java | 1 + .../server/AbstractCtbrecServlet.java | 37 +++--- .../ctbrec/recorder/server/HttpServer.java | 34 ++++-- .../ctbrec/recorder/server/ImageServlet.java | 113 ++++++++++++++++++ 14 files changed, 463 insertions(+), 174 deletions(-) delete mode 100644 client/src/main/java/ctbrec/ui/tabs/recorded/PortraitStore.java create mode 100644 common/src/main/java/ctbrec/image/LocalPortraitStore.java create mode 100644 common/src/main/java/ctbrec/image/PortraitStore.java create mode 100644 common/src/main/java/ctbrec/image/RemotePortraitStore.java create mode 100644 server/src/main/java/ctbrec/recorder/server/ImageServlet.java diff --git a/client/src/main/java/ctbrec/ui/CamrecApplication.java b/client/src/main/java/ctbrec/ui/CamrecApplication.java index d8bf8e23..641c375f 100644 --- a/client/src/main/java/ctbrec/ui/CamrecApplication.java +++ b/client/src/main/java/ctbrec/ui/CamrecApplication.java @@ -12,6 +12,9 @@ import ctbrec.event.Event; import ctbrec.event.EventBusHolder; import ctbrec.event.EventHandler; import ctbrec.event.EventHandlerConfiguration; +import ctbrec.image.LocalPortraitStore; +import ctbrec.image.PortraitStore; +import ctbrec.image.RemotePortraitStore; import ctbrec.io.BandwidthMeter; import ctbrec.io.ByteUnitFormatter; import ctbrec.io.HttpClient; @@ -94,6 +97,7 @@ public class CamrecApplication extends Application { private final TabPane tabPane = new TabPane(); private final List sites = new ArrayList<>(); public static HttpClient httpClient; + public static PortraitStore portraitStore; public static String title; private Stage primaryStage; private RecordingsTab recordingsTab; @@ -122,12 +126,21 @@ public class CamrecApplication extends Application { createRecorder(); initSites(); startOnlineMonitor(); + createPortraitStore(); createGui(primaryStage); checkForUpdates(); registerClipboardListener(); registerTrayIconListener(); } + private void createPortraitStore() { + if (config.getSettings().localRecording) { + portraitStore = new LocalPortraitStore(config); + } else { + portraitStore = new RemotePortraitStore(httpClient, config); + } + } + private void registerTrayIconListener() { EventBusHolder.BUS.register(new Object() { @Subscribe diff --git a/client/src/main/java/ctbrec/ui/action/AbstractPortraitAction.java b/client/src/main/java/ctbrec/ui/action/AbstractPortraitAction.java index 0ba64ba5..f9aeef04 100644 --- a/client/src/main/java/ctbrec/ui/action/AbstractPortraitAction.java +++ b/client/src/main/java/ctbrec/ui/action/AbstractPortraitAction.java @@ -1,23 +1,19 @@ package ctbrec.ui.action; -import java.awt.Graphics2D; -import java.awt.image.BufferedImage; -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; - -import javax.imageio.ImageIO; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import ctbrec.Config; import ctbrec.Model; import ctbrec.event.EventBusHolder; +import ctbrec.ui.CamrecApplication; import javafx.scene.Node; +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; + public abstract class AbstractPortraitAction { - private static final Logger LOG = LoggerFactory.getLogger(AbstractPortraitAction.class); public static final String FORMAT = "jpg"; protected Node source; @@ -32,11 +28,11 @@ public abstract class AbstractPortraitAction { return bimage; } - protected boolean copyToCacheAsJpg(String portraitId, BufferedImage portrait) throws IOException { - File output = getPortraitFile(portraitId); - Files.createDirectories(output.getParentFile().toPath()); - LOG.debug("Writing scaled portrait to {}", output); - return ImageIO.write(portrait, FORMAT, output); + protected boolean store(String modelUrl, BufferedImage portrait) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ImageIO.write(portrait, FORMAT, bytes); + CamrecApplication.portraitStore.writePortrait(modelUrl, bytes.toByteArray()); + return true; } protected File getPortraitFile(String portraitId) { @@ -63,7 +59,7 @@ public abstract class AbstractPortraitAction { protected BufferedImage cropTopAndBottom(BufferedImage img) { int overlap = img.getHeight() - img.getWidth(); - return img.getSubimage(0, overlap/2, img.getWidth(), img.getWidth()); + return img.getSubimage(0, overlap / 2, img.getWidth(), img.getWidth()); } protected void firePortraitChanged() { @@ -71,7 +67,7 @@ public abstract class AbstractPortraitAction { } public static class PortraitChangedEvent { - private Model mdl; + private final Model mdl; public PortraitChangedEvent(Model model) { this.mdl = model; diff --git a/client/src/main/java/ctbrec/ui/action/SetPortraitAction.java b/client/src/main/java/ctbrec/ui/action/SetPortraitAction.java index 8747ea46..1bccd140 100644 --- a/client/src/main/java/ctbrec/ui/action/SetPortraitAction.java +++ b/client/src/main/java/ctbrec/ui/action/SetPortraitAction.java @@ -1,22 +1,8 @@ package ctbrec.ui.action; -import java.awt.image.BufferedImage; -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.Arrays; -import java.util.UUID; -import java.util.function.Consumer; - -import javax.imageio.ImageIO; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import ctbrec.Config; import ctbrec.Model; import ctbrec.StringUtil; +import ctbrec.ui.CamrecApplication; import ctbrec.ui.controls.Dialogs; import ctbrec.ui.controls.FileSelectionBox; import javafx.geometry.Insets; @@ -24,11 +10,20 @@ import javafx.scene.Cursor; import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.layout.GridPane; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.util.Arrays; +import java.util.function.Consumer; public class SetPortraitAction extends AbstractPortraitAction { private static final Logger LOG = LoggerFactory.getLogger(SetPortraitAction.class); - private Consumer callback; + private final Consumer callback; public SetPortraitAction(Node source, Model selectedModel, Consumer callback) { this.source = source; @@ -38,9 +33,6 @@ public class SetPortraitAction extends AbstractPortraitAction { public void execute() { source.setCursor(Cursor.WAIT); - String portraitId = Config.getInstance().getSettings().modelPortraits.getOrDefault(model.getUrl(), - UUID.nameUUIDFromBytes(model.getUrl().getBytes(StandardCharsets.UTF_8)).toString()); - GridPane pane = new GridPane(); Label l = new Label("Select a portrait image. Leave empty to remove a portrait again."); pane.add(l, 0, 0); @@ -56,17 +48,15 @@ public class SetPortraitAction extends AbstractPortraitAction { String selectedFile = portraitSelectionBox.fileProperty().getValue(); if (StringUtil.isBlank(selectedFile)) { - removePortrait(portraitId); + removePortrait(model.getUrl()); } else { LOG.debug("User selected {}", selectedFile); - boolean success = processImageFile(portraitId, selectedFile); + boolean success = processImageFile(selectedFile); if (success) { - Config.getInstance().getSettings().modelPortraits.put(model.getUrl(), portraitId); try { - Config.getInstance().save(); firePortraitChanged(); runCallback(); - } catch (IOException e) { + } catch (Exception e) { Dialogs.showError("Set Portrait", "Couldn't change portrait image: ", e); } } @@ -74,14 +64,10 @@ public class SetPortraitAction extends AbstractPortraitAction { source.setCursor(Cursor.DEFAULT); } - private void removePortrait(String portraitId) { - File portraitFile = getPortraitFile(portraitId); + private void removePortrait(String modelUrl) { try { - if (portraitFile.exists()) { - Files.delete(portraitFile.toPath()); - } - Config.getInstance().getSettings().modelPortraits.remove(model.getUrl()); - Config.getInstance().save(); + CamrecApplication.portraitStore.removePortrait(modelUrl); + firePortraitChanged(); runCallback(); } catch (IOException e) { Dialogs.showError("Remove Portrait", "Couldn't remove portrait image: ", e); @@ -98,12 +84,12 @@ public class SetPortraitAction extends AbstractPortraitAction { } } - private boolean processImageFile(String portraitId, String selectedFile) { + private boolean processImageFile(String selectedFile) { try { BufferedImage original = ImageIO.read(new File(selectedFile)); BufferedImage croppedImage = cropImage(original); BufferedImage portrait = convertToScaledJpg(croppedImage); - boolean success = copyToCacheAsJpg(portraitId, portrait); + boolean success = store(model.getUrl(), portrait); if (!success) { LOG.debug("Available formats: {}", Arrays.toString(ImageIO.getWriterFormatNames())); throw new IOException("No suitable writer found for image format " + FORMAT); diff --git a/client/src/main/java/ctbrec/ui/action/SetThumbAsPortraitAction.java b/client/src/main/java/ctbrec/ui/action/SetThumbAsPortraitAction.java index 7b23686c..308f00f0 100644 --- a/client/src/main/java/ctbrec/ui/action/SetThumbAsPortraitAction.java +++ b/client/src/main/java/ctbrec/ui/action/SetThumbAsPortraitAction.java @@ -1,25 +1,23 @@ package ctbrec.ui.action; -import java.awt.image.BufferedImage; -import java.nio.charset.StandardCharsets; -import java.util.UUID; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import ctbrec.Config; +import ctbrec.GlobalThreadPool; import ctbrec.Model; import ctbrec.ui.controls.Dialogs; +import javafx.application.Platform; import javafx.embed.swing.SwingFXUtils; import javafx.scene.Cursor; import javafx.scene.Node; import javafx.scene.image.Image; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.image.BufferedImage; public class SetThumbAsPortraitAction extends AbstractPortraitAction { private static final Logger LOG = LoggerFactory.getLogger(SetThumbAsPortraitAction.class); - private Image image; + private final Image image; public SetThumbAsPortraitAction(Node source, Model model, Image image) { this.source = source; @@ -29,20 +27,20 @@ public class SetThumbAsPortraitAction extends AbstractPortraitAction { public void execute() { source.setCursor(Cursor.WAIT); - try { - BufferedImage bufferedImage = convertFxImageToAwt(image); - BufferedImage croppedImage = cropImage(bufferedImage); - BufferedImage portrait = convertToScaledJpg(croppedImage); - String portraitId = UUID.nameUUIDFromBytes(model.getUrl().getBytes(StandardCharsets.UTF_8)).toString(); - copyToCacheAsJpg(portraitId, portrait); - Config.getInstance().getSettings().modelPortraits.put(model.getUrl(), portraitId); - Config.getInstance().save(); - firePortraitChanged(); - } catch (Exception e) { - LOG.error("Error while changing portrait image", e); - Dialogs.showError("Set Portrait", "Couldn't change portrait image: ", e); - } - source.setCursor(Cursor.DEFAULT); + GlobalThreadPool.submit(() -> { + try { + BufferedImage bufferedImage = convertFxImageToAwt(image); + BufferedImage croppedImage = cropImage(bufferedImage); + BufferedImage portrait = convertToScaledJpg(croppedImage); + store(model.getUrl(), portrait); + firePortraitChanged(); + } catch (Exception e) { + LOG.error("Error while changing portrait image", e); + Platform.runLater(() -> Dialogs.showError("Set Portrait", "Couldn't change portrait image: ", e)); + } finally { + Platform.runLater(() -> source.setCursor(Cursor.DEFAULT)); + } + }); } private BufferedImage convertFxImageToAwt(Image img) { diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/AbstractRecordedModelsTab.java b/client/src/main/java/ctbrec/ui/tabs/recorded/AbstractRecordedModelsTab.java index a33b3b87..968c48c3 100644 --- a/client/src/main/java/ctbrec/ui/tabs/recorded/AbstractRecordedModelsTab.java +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/AbstractRecordedModelsTab.java @@ -4,11 +4,17 @@ import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import com.google.common.eventbus.Subscribe; -import ctbrec.*; +import ctbrec.Config; +import ctbrec.GlobalThreadPool; +import ctbrec.Model; +import ctbrec.StringUtil; import ctbrec.event.EventBusHolder; +import ctbrec.image.PortraitStore; +import ctbrec.io.HttpException; import ctbrec.recorder.Recorder; import ctbrec.sites.Site; import ctbrec.ui.AutosizeAlert; +import ctbrec.ui.CamrecApplication; import ctbrec.ui.JavaFxModel; import ctbrec.ui.PreviewPopupHandler; import ctbrec.ui.action.AbstractPortraitAction.PortraitChangedEvent; @@ -48,9 +54,9 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.stage.FileChooser; import javafx.util.Callback; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; +import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.time.Instant; @@ -60,8 +66,8 @@ import java.util.Objects; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; +@Slf4j public abstract class AbstractRecordedModelsTab extends Tab implements TabSelectionListener { - private static final Logger LOG = LoggerFactory.getLogger(AbstractRecordedModelsTab.class); private static final Image SILHOUETTE = new Image(AbstractRecordedModelsTab.class.getResourceAsStream("/silhouette_256.png")); protected static final String STYLE_ALIGN_CENTER = "-fx-alignment: CENTER;"; @@ -100,7 +106,7 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect AbstractRecordedModelsTab(String text, String stateStorePrefix) { super(text); config = Config.getInstance(); - portraitStore = new PortraitStore(config); + portraitStore = CamrecApplication.portraitStore; tableStateStore = new SettingTableViewStateStore(config, stateStorePrefix); table = new StatePersistingTableView<>(tableStateStore); registerPortraitListener(); @@ -112,6 +118,7 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect @Subscribe public void portraitChanged(PortraitChangedEvent e) { + log.debug("Invalidate cache for {}", e.getModel()); portraitCache.invalidate(e.getModel()); if (table != null) { table.refresh(); @@ -230,7 +237,7 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect } catch (IOException e) { String msg = "An error occurred while exporting the model list"; Dialogs.showError(getTabPane().getScene(), "Export models", msg, e); - LOG.error(msg, e); + log.error(msg, e); } } } @@ -244,7 +251,7 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect try { recorder.addModel(model); } catch (Exception e) { - LOG.error("Couldn't add model to recording list", e); + log.error("Couldn't add model to recording list", e); } } return null; @@ -269,7 +276,7 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect } catch (IOException e) { String msg = "An error occurred while importing the model list"; Dialogs.showError(getTabPane().getScene(), "Import models", msg, e); - LOG.error(msg, e); + log.error(msg, e); } } } @@ -370,14 +377,14 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect ContextMenu menu = new CustomMouseBehaviorContextMenu(); ModelMenuContributor.newContributor(getTabPane(), Config.getInstance(), recorder) // - .withStartStopCallback(m -> Platform.runLater(this::reload)) // - .removeModelAfterIgnore(true) // - .withPortraitCallback(m -> Platform.runLater(() -> { - portraitCache.invalidate(m); - table.refresh(); - })) - .afterwards(() -> Platform.runLater(this::reload)) - .contributeToMenu(selectedModels, menu); + .withStartStopCallback(m -> Platform.runLater(this::reload)) // + .removeModelAfterIgnore(true) // + // .withPortraitCallback(m -> Platform.runLater(() -> { + // portraitCache.invalidate(m); + // table.refresh(); + // })) + .afterwards(() -> Platform.runLater(this::reload)) + .contributeToMenu(selectedModels, menu); return menu; } @@ -408,8 +415,8 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect new MarkForLaterRecordingAction(modelInputField, List.of(newModel), true, recorder).execute(m -> Platform.runLater(this::reload)); } else { new StartRecordingAction(modelInputField, List.of(newModel), recorder) - .execute() - .whenComplete((r, ex) -> Platform.runLater(this::reload)); + .execute() + .whenComplete((r, ex) -> Platform.runLater(this::reload)); } return; } @@ -437,8 +444,8 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect new MarkForLaterRecordingAction(modelInputField, List.of(newModel), true, recorder).execute(m -> Platform.runLater(this::reload)); } else { new StartRecordingAction(modelInputField, List.of(newModel), recorder) - .execute() - .whenComplete((r, ex) -> Platform.runLater(this::reload)); + .execute() + .whenComplete((r, ex) -> Platform.runLater(this::reload)); } return; } @@ -558,6 +565,18 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect } protected Image loadModelPortrait(Model model) { - return portraitStore.loadModelPortrait(model.getUrl()).orElse(SILHOUETTE); + try { + return portraitStore + .loadModelPortraitByModelUrl(model.getUrl()) + .map(bytes -> new Image(new ByteArrayInputStream(bytes))) + .orElse(SILHOUETTE); + } catch (HttpException e) { + if (e.getResponseCode() != 404) { + log.debug("Could not load portrait from server", e); + } + } catch (IOException e) { + log.debug("Could not load portrait from server", e); + } + return SILHOUETTE; } } diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/ModelImportExport.java b/client/src/main/java/ctbrec/ui/tabs/recorded/ModelImportExport.java index c2c867c0..790c82a1 100644 --- a/client/src/main/java/ctbrec/ui/tabs/recorded/ModelImportExport.java +++ b/client/src/main/java/ctbrec/ui/tabs/recorded/ModelImportExport.java @@ -5,10 +5,10 @@ import com.squareup.moshi.JsonReader.Token; import ctbrec.Config; import ctbrec.Model; import ctbrec.ModelGroup; -import ctbrec.io.FileJsonAdapter; -import ctbrec.io.LocalTimeJsonAdapter; -import ctbrec.io.ModelJsonAdapter; -import ctbrec.io.UuidJSonAdapter; +import ctbrec.image.LocalPortraitStore; +import ctbrec.image.PortraitStore; +import ctbrec.image.RemotePortraitStore; +import ctbrec.io.*; import ctbrec.sites.Site; import okio.Buffer; import okio.Okio; @@ -66,14 +66,25 @@ public class ModelImportExport { } if (exportOptions.includes().contains(ExportIncludes.PORTRAITS)) { var portraits = config.getSettings().modelPortraits; - var portraitLoader = new PortraitStore(config); + PortraitStore portraitLoader; + if (config.getSettings().localRecording) { + portraitLoader = new LocalPortraitStore(config); + } else { + var httpClient = new HttpClient("camrec", config) { + @Override + public boolean login() { + return false; + } + }; + portraitLoader = new RemotePortraitStore(httpClient, config); + } if (portraits != null && !portraits.isEmpty()) { writer.name("portraits"); writer.beginArray(); for (Map.Entry entry : config.getSettings().modelPortraits.entrySet()) { String modelUrl = entry.getKey(); String portraitId = entry.getValue(); - Optional portrait = portraitLoader.loadModelPortraitFile(modelUrl); + Optional portrait = portraitLoader.loadModelPortraitByModelUrl(modelUrl); if (portrait.isPresent()) { writer.beginObject(); writer.name("url").value(modelUrl); @@ -122,7 +133,18 @@ public class ModelImportExport { } private static void importPortraits(JsonReader reader, Config config) throws IOException { - PortraitStore portraitStore = new PortraitStore(config); + PortraitStore portraitStore; + if (config.getSettings().localRecording) { + portraitStore = new LocalPortraitStore(config); + } else { + var httpClient = new HttpClient("camrec", config) { + @Override + public boolean login() { + return false; + } + }; + portraitStore = new RemotePortraitStore(httpClient, config); + } reader.beginArray(); while (reader.hasNext()) { reader.beginObject(); diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/PortraitStore.java b/client/src/main/java/ctbrec/ui/tabs/recorded/PortraitStore.java deleted file mode 100644 index 596f94af..00000000 --- a/client/src/main/java/ctbrec/ui/tabs/recorded/PortraitStore.java +++ /dev/null @@ -1,47 +0,0 @@ -package ctbrec.ui.tabs.recorded; - -import ctbrec.Config; -import ctbrec.StringUtil; -import javafx.scene.image.Image; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.util.Optional; - -import static ctbrec.ui.action.AbstractPortraitAction.FORMAT; - -public record PortraitStore(Config config) { - - private static final Logger LOG = LoggerFactory.getLogger(PortraitStore.class); - - public Optional loadModelPortrait(String modelUrl) { - return loadModelPortraitFile(modelUrl).map(bytes -> new Image(new ByteArrayInputStream(bytes))); - } - - public Optional loadModelPortraitFile(String modelUrl) { - String portraitId = config.getSettings().modelPortraits.get(modelUrl); - if (StringUtil.isNotBlank(portraitId)) { - File configDir = config.getConfigDir(); - File portraitDir = new File(configDir, "portraits"); - File portraitFile = new File(portraitDir, portraitId + '.' + FORMAT); - try { - return Optional.of(Files.readAllBytes(portraitFile.toPath())); - } catch (IOException e) { - LOG.error("Couldn't load portrait file {}", portraitFile, e); - } - } - return Optional.empty(); - } - - public void writePortrait(String id, byte[] data) throws IOException { - File configDir = config.getConfigDir(); - File portraitDir = new File(configDir, "portraits"); - File portraitFile = new File(portraitDir, id + '.' + FORMAT); - Files.createDirectories(portraitDir.toPath()); - Files.write(portraitFile.toPath(), data); - } -} diff --git a/common/src/main/java/ctbrec/image/LocalPortraitStore.java b/common/src/main/java/ctbrec/image/LocalPortraitStore.java new file mode 100644 index 00000000..456c7838 --- /dev/null +++ b/common/src/main/java/ctbrec/image/LocalPortraitStore.java @@ -0,0 +1,63 @@ +package ctbrec.image; + +import ctbrec.Config; +import ctbrec.StringUtil; +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Optional; +import java.util.UUID; + +import static java.nio.charset.StandardCharsets.UTF_8; + +@Slf4j +public record LocalPortraitStore(Config config) implements PortraitStore { + + @Override + public Optional loadModelPortraitByModelUrl(String modelUrl) { + String portraitId = config.getSettings().modelPortraits.get(modelUrl); + return loadModelPortraitById(portraitId); + } + + @Override + public Optional loadModelPortraitById(String portraitId) { + if (StringUtil.isNotBlank(portraitId)) { + File configDir = config.getConfigDir(); + File portraitDir = new File(configDir, "portraits"); + File portraitFile = new File(portraitDir, portraitId + '.' + FORMAT); + try { + return Optional.of(Files.readAllBytes(portraitFile.toPath())); + } catch (IOException e) { + log.error("Couldn't load portrait file {}", portraitFile, e); + } + } + return Optional.empty(); + } + + @Override + public void writePortrait(String modelUrl, byte[] data) throws IOException { + String portraitId = config.getSettings().modelPortraits.getOrDefault(modelUrl, UUID.nameUUIDFromBytes(modelUrl.getBytes(UTF_8)).toString()); + File portraitFile = getPortraitFile(portraitId); + Files.write(portraitFile.toPath(), data); + config.getSettings().modelPortraits.put(modelUrl, portraitId); + config.save(); + } + + @Override + public void removePortrait(String modelUrl) throws IOException { + String portraitId = config.getSettings().modelPortraits.get(modelUrl); + File portraitFile = getPortraitFile(portraitId); + Files.delete(portraitFile.toPath()); + config.getSettings().modelPortraits.remove(modelUrl); + config.save(); + } + + private File getPortraitFile(String portraitId) throws IOException { + File configDir = config.getConfigDir(); + File portraitDir = new File(configDir, "portraits"); + Files.createDirectories(portraitDir.toPath()); + return new File(portraitDir, portraitId + '.' + FORMAT); + } +} diff --git a/common/src/main/java/ctbrec/image/PortraitStore.java b/common/src/main/java/ctbrec/image/PortraitStore.java new file mode 100644 index 00000000..4666fb23 --- /dev/null +++ b/common/src/main/java/ctbrec/image/PortraitStore.java @@ -0,0 +1,18 @@ +package ctbrec.image; + +import java.io.IOException; +import java.util.Optional; + +public interface PortraitStore { + + String FORMAT = "jpg"; + + Optional loadModelPortraitById(String id) throws IOException; + + Optional loadModelPortraitByModelUrl(String modelUrl) throws IOException; + + //void writePortrait(String id, byte[] data) throws IOException; + void writePortrait(String modelUrl, byte[] data) throws IOException; + + void removePortrait(String modelUrl) throws IOException; +} diff --git a/common/src/main/java/ctbrec/image/RemotePortraitStore.java b/common/src/main/java/ctbrec/image/RemotePortraitStore.java new file mode 100644 index 00000000..9db28ae7 --- /dev/null +++ b/common/src/main/java/ctbrec/image/RemotePortraitStore.java @@ -0,0 +1,84 @@ +package ctbrec.image; + +import ctbrec.Config; +import ctbrec.io.HttpClient; +import ctbrec.io.HttpException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +import java.io.IOException; +import java.net.URLEncoder; +import java.util.Optional; + +import static ctbrec.io.HttpConstants.MIMETYPE_IMAGE_JPG; +import static java.nio.charset.StandardCharsets.UTF_8; + +@Slf4j +@RequiredArgsConstructor +public class RemotePortraitStore implements PortraitStore { + + private final HttpClient httpClient; + private final Config config; + + private String getEndpoint() { + return config.getServerUrl() + "/image/portrait"; + } + + private String getModelUrlEndpoint() { + return getEndpoint() + "/url/"; + } + + @Override + public Optional loadModelPortraitById(String id) throws IOException { + return load(getEndpoint() + '/' + id); + } + + @Override + public Optional loadModelPortraitByModelUrl(String modelUrl) throws IOException { + return load(getModelUrlEndpoint() + URLEncoder.encode(modelUrl, UTF_8)); + } + + private Optional load(String url) throws IOException { + Request req = new Request.Builder() + .url(url) + .build(); + try (Response resp = httpClient.execute(req)) { + if (resp.isSuccessful()) { + return Optional.of(resp.body().bytes()); + } else { + throw new HttpException(resp.code(), resp.message()); + } + } + } + + @Override + public void writePortrait(String modelUrl, byte[] data) throws IOException { + RequestBody body = RequestBody.create(data, MediaType.parse(MIMETYPE_IMAGE_JPG)); + Request req = new Request.Builder() + .url(getModelUrlEndpoint() + URLEncoder.encode(modelUrl, UTF_8)) + .post(body) + .build(); + try (Response resp = httpClient.execute(req)) { + if (!resp.isSuccessful()) { + throw new HttpException(resp.code(), resp.message()); + } + } + } + + @Override + public void removePortrait(String modelUrl) throws IOException { + Request req = new Request.Builder() + .url(getModelUrlEndpoint() + URLEncoder.encode(modelUrl, UTF_8)) + .delete() + .build(); + try (Response resp = httpClient.execute(req)) { + if (!resp.isSuccessful()) { + throw new HttpException(resp.code(), resp.message()); + } + } + } +} diff --git a/common/src/main/java/ctbrec/io/HttpConstants.java b/common/src/main/java/ctbrec/io/HttpConstants.java index 10c27868..aa71d868 100644 --- a/common/src/main/java/ctbrec/io/HttpConstants.java +++ b/common/src/main/java/ctbrec/io/HttpConstants.java @@ -16,6 +16,7 @@ public class HttpConstants { public static final String FORM_ENCODED = "application/x-www-form-urlencoded; charset=UTF-8"; public static final String KEEP_ALIVE = "keep-alive"; public static final String MIMETYPE_APPLICATION_JSON = "application/json"; + public static final String MIMETYPE_IMAGE_JPG = "image/jpeg"; public static final String MIMETYPE_TEXT_HTML = "text/html"; public static final String NO_CACHE = "no-cache"; public static final String ORIGIN = "Origin"; diff --git a/server/src/main/java/ctbrec/recorder/server/AbstractCtbrecServlet.java b/server/src/main/java/ctbrec/recorder/server/AbstractCtbrecServlet.java index 100fe6b2..13db9f6b 100644 --- a/server/src/main/java/ctbrec/recorder/server/AbstractCtbrecServlet.java +++ b/server/src/main/java/ctbrec/recorder/server/AbstractCtbrecServlet.java @@ -1,21 +1,20 @@ package ctbrec.recorder.server; -import static javax.servlet.http.HttpServletResponse.*; - -import java.io.BufferedReader; -import java.io.IOException; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; +import ctbrec.Config; +import ctbrec.Hmac; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import ctbrec.Config; -import ctbrec.Hmac; +import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR; public abstract class AbstractCtbrecServlet extends HttpServlet { @@ -23,17 +22,17 @@ public abstract class AbstractCtbrecServlet extends HttpServlet { boolean checkAuthentication(HttpServletRequest req, String body) throws IOException, InvalidKeyException, NoSuchAlgorithmException { boolean authenticated = false; - if(Config.getInstance().getSettings().key != null) { + if (Config.getInstance().getSettings().key != null) { String reqParamHmac = req.getParameter("hmac"); String httpHeaderHmac = req.getHeader("CTBREC-HMAC"); String hmac = null; String url = req.getRequestURI(); url = url.substring(getServletContext().getContextPath().length()); - if(reqParamHmac != null) { + if (reqParamHmac != null) { hmac = reqParamHmac; } - if(httpHeaderHmac != null) { + if (httpHeaderHmac != null) { hmac = httpHeaderHmac; } @@ -50,13 +49,19 @@ public abstract class AbstractCtbrecServlet extends HttpServlet { String body(HttpServletRequest req) throws IOException { StringBuilder body = new StringBuilder(); BufferedReader br = req.getReader(); - String line= null; - while( (line = br.readLine()) != null ) { + String line = null; + while ((line = br.readLine()) != null) { body.append(line).append("\n"); } return body.toString().trim(); } + byte[] bodyAsByteArray(HttpServletRequest req) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(req.getContentLength()); + req.getInputStream().transferTo(bos); + return bos.toByteArray(); + } + void sendResponse(HttpServletResponse resp, int httpStatus, String message) { try { resp.setStatus(httpStatus); diff --git a/server/src/main/java/ctbrec/recorder/server/HttpServer.java b/server/src/main/java/ctbrec/recorder/server/HttpServer.java index df325a4d..80e566d5 100644 --- a/server/src/main/java/ctbrec/recorder/server/HttpServer.java +++ b/server/src/main/java/ctbrec/recorder/server/HttpServer.java @@ -7,6 +7,7 @@ import ctbrec.Version; import ctbrec.event.EventBusHolder; import ctbrec.event.EventHandler; import ctbrec.event.EventHandlerConfiguration; +import ctbrec.image.LocalPortraitStore; import ctbrec.recorder.NextGenLocalRecorder; import ctbrec.recorder.OnlineMonitor; import ctbrec.recorder.Recorder; @@ -59,6 +60,7 @@ import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; public class HttpServer { + private static final int MiB = 1024 * 1024; // NOSONAR private static final Logger LOG = LoggerFactory.getLogger(HttpServer.class); private final Recorder recorder; private final OnlineMonitor onlineMonitor; @@ -188,7 +190,7 @@ public class HttpServer { sslContextFactory.setTrustStorePassword(keyStorePassword); try (ServerConnector http = new ServerConnector(server, httpConnectionFactory); - ServerConnector https = new ServerConnector(server, sslContextFactory, httpConnectionFactory)) { + ServerConnector https = new ServerConnector(server, sslContextFactory, httpConnectionFactory)) { // connector for http http.setPort(this.config.getSettings().httpPort); @@ -219,6 +221,22 @@ public class HttpServer { holder = new ServletHolder(hlsServlet); defaultContext.addServlet(holder, "/hls/*"); + LocalPortraitStore portraitStore = new LocalPortraitStore(config); + ImageServlet imageServlet = new ImageServlet(portraitStore, config); + holder = new ServletHolder(imageServlet); + String location; + try { + location = File.createTempFile("upload", "").getParentFile().toString(); + } catch (IOException e) { + location = "."; + } + long maxFileSize = 10L * MiB; + long maxRequestSize = 10L * MiB; + int fileSizeThreshold = MiB; + MultipartConfigElement multipartConfig = new MultipartConfigElement(location, maxFileSize, maxRequestSize, fileSizeThreshold); + holder.getRegistration().setMultipartConfig(multipartConfig); + defaultContext.addServlet(holder, ImageServlet.BASE_URL + "/*"); + if (this.config.getSettings().webinterface) { startWebInterface(defaultContext, basicAuthContext); } @@ -227,9 +245,9 @@ public class HttpServer { HandlerList handlers = new HandlerList(); if (this.config.getSettings().transportLayerSecurity) { server.addConnector(https); - handlers.setHandlers(new Handler[] { new SecuredRedirectHandler(), basicAuthContext, defaultContext }); + handlers.setHandlers(new Handler[]{new SecuredRedirectHandler(), basicAuthContext, defaultContext}); } else { - handlers.setHandlers(new Handler[] { basicAuthContext, defaultContext }); + handlers.setHandlers(new Handler[]{basicAuthContext, defaultContext}); } server.setHandler(handlers); @@ -255,7 +273,7 @@ public class HttpServer { ServletHolder holder = new ServletHolder(staticFileServlet); String staticFileContext = "/static/*"; defaultContext.addServlet(holder, staticFileContext); - LOG.info("Register static file servlet under {}", defaultContext.getContextPath()+staticFileContext); + LOG.info("Register static file servlet under {}", defaultContext.getContextPath() + staticFileContext); // servlet to retrieve the HMAC (secured by basic auth if a hmac key is set in the config) String username = this.config.getSettings().webinterfaceUsername; @@ -298,7 +316,7 @@ public class HttpServer { @Override protected void handleErrorPage(HttpServletRequest request, Writer writer, int code, String message) throws IOException { if (code == 404) { - writer.write("404

404

Looking for CTB Recorder?

"); + writer.write("404

404

Looking for CTB Recorder?

"); } else { super.handleErrorPage(request, writer, code, message); } @@ -315,7 +333,7 @@ public class HttpServer { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { - ((HttpServletResponse)response).addHeader("Server", "CTB Recorder/" + getVersion()); + ((HttpServletResponse) response).addHeader("Server", "CTB Recorder/" + getVersion()); chain.doFilter(request, response); } @@ -330,14 +348,14 @@ public class HttpServer { private static SecurityHandler basicAuth(String username, String password) { String realm = "CTB Recorder"; UserStore userStore = new UserStore(); - userStore.addUser(username, Credential.getCredential(password), new String[] { "user" }); + userStore.addUser(username, Credential.getCredential(password), new String[]{"user"}); HashLoginService l = new HashLoginService(); l.setUserStore(userStore); l.setName(realm); Constraint constraint = new Constraint(); constraint.setName(Constraint.__BASIC_AUTH); - constraint.setRoles(new String[] { "user" }); + constraint.setRoles(new String[]{"user"}); constraint.setAuthenticate(true); ConstraintMapping cm = new ConstraintMapping(); diff --git a/server/src/main/java/ctbrec/recorder/server/ImageServlet.java b/server/src/main/java/ctbrec/recorder/server/ImageServlet.java new file mode 100644 index 00000000..efb0792c --- /dev/null +++ b/server/src/main/java/ctbrec/recorder/server/ImageServlet.java @@ -0,0 +1,113 @@ +package ctbrec.recorder.server; + +import ctbrec.Config; +import ctbrec.image.PortraitStore; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLDecoder; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static ctbrec.io.HttpConstants.MIMETYPE_IMAGE_JPG; +import static java.nio.charset.StandardCharsets.UTF_8; +import static javax.servlet.http.HttpServletResponse.*; + +@Slf4j +@RequiredArgsConstructor +public class ImageServlet extends AbstractCtbrecServlet { + + public static final String BASE_URL = "/image"; + public static final String INTERNAL_SERVER_ERROR = "Internal Server Error"; + private static final Pattern URL_PATTERN_PORTRAIT_BY_ID = Pattern.compile(BASE_URL + "/portrait/([0-9a-fA-F]{8}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{12})"); + private static final Pattern URL_PATTERN_PORTRAIT_BY_URL = Pattern.compile(BASE_URL + "/portrait/url/(.*)"); + private final PortraitStore portraitStore; + private final Config config; + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) { + String requestURI = req.getRequestURI(); + try { + boolean authenticated = checkAuthentication(req, body(req)); + if (!authenticated) { + sendResponse(resp, SC_UNAUTHORIZED, "HMAC does not match"); + return; + } + + Matcher m; + if ((m = URL_PATTERN_PORTRAIT_BY_ID.matcher(requestURI)).matches()) { + String portraitId = m.group(1); + servePortrait(resp, portraitId); + } else if ((m = URL_PATTERN_PORTRAIT_BY_URL.matcher(requestURI)).matches()) { + String modelUrl = URLDecoder.decode(m.group(1), UTF_8); + String portraitId = config.getSettings().modelPortraits.get(modelUrl); + servePortrait(resp, portraitId); + } + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR, e); + sendResponse(resp, SC_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR); + } + } + + private void servePortrait(HttpServletResponse resp, String portraitId) throws IOException { + log.debug("serving portrait {}", portraitId); + Optional imageData = portraitStore.loadModelPortraitById(portraitId); + if (imageData.isPresent()) { + resp.setStatus(SC_OK); + resp.setContentType(MIMETYPE_IMAGE_JPG); + byte[] b = imageData.get(); + resp.setContentLength(b.length); + resp.getOutputStream().write(b, 0, b.length); + resp.getOutputStream().flush(); + } else { + sendResponse(resp, SC_NOT_FOUND, "Portrait with ID " + portraitId + " not found"); + } + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) { + String requestURI = req.getRequestURI(); + try { + // boolean authenticated = checkAuthentication(req, body(req)); + // if (!authenticated) { + // sendResponse(resp, SC_UNAUTHORIZED, "HMAC does not match"); + // return; + // } + + Matcher m; + if ((m = URL_PATTERN_PORTRAIT_BY_URL.matcher(requestURI)).matches()) { + String modelUrl = URLDecoder.decode(m.group(1), UTF_8); + byte[] data = bodyAsByteArray(req); + portraitStore.writePortrait(modelUrl, data); + } + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR, e); + sendResponse(resp, SC_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR); + } + } + + @Override + protected void doDelete(HttpServletRequest req, HttpServletResponse resp) { + String requestURI = req.getRequestURI(); + try { + // boolean authenticated = checkAuthentication(req, body(req)); + // if (!authenticated) { + // sendResponse(resp, SC_UNAUTHORIZED, "HMAC does not match"); + // return; + // } + + Matcher m; + if ((m = URL_PATTERN_PORTRAIT_BY_URL.matcher(requestURI)).matches()) { + String modelUrl = URLDecoder.decode(m.group(1), UTF_8); + portraitStore.removePortrait(modelUrl); + } + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR, e); + sendResponse(resp, SC_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR); + } + } +}