Store model portraits on the server in client/server mode

This commit is contained in:
0xb00bface 2023-04-19 19:48:29 +02:00
parent f293f511f1
commit 39da801a61
14 changed files with 463 additions and 174 deletions

View File

@ -12,6 +12,9 @@ import ctbrec.event.Event;
import ctbrec.event.EventBusHolder; import ctbrec.event.EventBusHolder;
import ctbrec.event.EventHandler; import ctbrec.event.EventHandler;
import ctbrec.event.EventHandlerConfiguration; import ctbrec.event.EventHandlerConfiguration;
import ctbrec.image.LocalPortraitStore;
import ctbrec.image.PortraitStore;
import ctbrec.image.RemotePortraitStore;
import ctbrec.io.BandwidthMeter; import ctbrec.io.BandwidthMeter;
import ctbrec.io.ByteUnitFormatter; import ctbrec.io.ByteUnitFormatter;
import ctbrec.io.HttpClient; import ctbrec.io.HttpClient;
@ -94,6 +97,7 @@ public class CamrecApplication extends Application {
private final TabPane tabPane = new TabPane(); private final TabPane tabPane = new TabPane();
private final List<Site> sites = new ArrayList<>(); private final List<Site> sites = new ArrayList<>();
public static HttpClient httpClient; public static HttpClient httpClient;
public static PortraitStore portraitStore;
public static String title; public static String title;
private Stage primaryStage; private Stage primaryStage;
private RecordingsTab recordingsTab; private RecordingsTab recordingsTab;
@ -122,12 +126,21 @@ public class CamrecApplication extends Application {
createRecorder(); createRecorder();
initSites(); initSites();
startOnlineMonitor(); startOnlineMonitor();
createPortraitStore();
createGui(primaryStage); createGui(primaryStage);
checkForUpdates(); checkForUpdates();
registerClipboardListener(); registerClipboardListener();
registerTrayIconListener(); registerTrayIconListener();
} }
private void createPortraitStore() {
if (config.getSettings().localRecording) {
portraitStore = new LocalPortraitStore(config);
} else {
portraitStore = new RemotePortraitStore(httpClient, config);
}
}
private void registerTrayIconListener() { private void registerTrayIconListener() {
EventBusHolder.BUS.register(new Object() { EventBusHolder.BUS.register(new Object() {
@Subscribe @Subscribe

View File

@ -1,23 +1,19 @@
package ctbrec.ui.action; 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.Config;
import ctbrec.Model; import ctbrec.Model;
import ctbrec.event.EventBusHolder; import ctbrec.event.EventBusHolder;
import ctbrec.ui.CamrecApplication;
import javafx.scene.Node; 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 { public abstract class AbstractPortraitAction {
private static final Logger LOG = LoggerFactory.getLogger(AbstractPortraitAction.class);
public static final String FORMAT = "jpg"; public static final String FORMAT = "jpg";
protected Node source; protected Node source;
@ -32,11 +28,11 @@ public abstract class AbstractPortraitAction {
return bimage; return bimage;
} }
protected boolean copyToCacheAsJpg(String portraitId, BufferedImage portrait) throws IOException { protected boolean store(String modelUrl, BufferedImage portrait) throws IOException {
File output = getPortraitFile(portraitId); ByteArrayOutputStream bytes = new ByteArrayOutputStream();
Files.createDirectories(output.getParentFile().toPath()); ImageIO.write(portrait, FORMAT, bytes);
LOG.debug("Writing scaled portrait to {}", output); CamrecApplication.portraitStore.writePortrait(modelUrl, bytes.toByteArray());
return ImageIO.write(portrait, FORMAT, output); return true;
} }
protected File getPortraitFile(String portraitId) { protected File getPortraitFile(String portraitId) {
@ -63,7 +59,7 @@ public abstract class AbstractPortraitAction {
protected BufferedImage cropTopAndBottom(BufferedImage img) { protected BufferedImage cropTopAndBottom(BufferedImage img) {
int overlap = img.getHeight() - img.getWidth(); 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() { protected void firePortraitChanged() {
@ -71,7 +67,7 @@ public abstract class AbstractPortraitAction {
} }
public static class PortraitChangedEvent { public static class PortraitChangedEvent {
private Model mdl; private final Model mdl;
public PortraitChangedEvent(Model model) { public PortraitChangedEvent(Model model) {
this.mdl = model; this.mdl = model;

View File

@ -1,22 +1,8 @@
package ctbrec.ui.action; 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.Model;
import ctbrec.StringUtil; import ctbrec.StringUtil;
import ctbrec.ui.CamrecApplication;
import ctbrec.ui.controls.Dialogs; import ctbrec.ui.controls.Dialogs;
import ctbrec.ui.controls.FileSelectionBox; import ctbrec.ui.controls.FileSelectionBox;
import javafx.geometry.Insets; import javafx.geometry.Insets;
@ -24,11 +10,20 @@ import javafx.scene.Cursor;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.layout.GridPane; 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 { public class SetPortraitAction extends AbstractPortraitAction {
private static final Logger LOG = LoggerFactory.getLogger(SetPortraitAction.class); private static final Logger LOG = LoggerFactory.getLogger(SetPortraitAction.class);
private Consumer<Model> callback; private final Consumer<Model> callback;
public SetPortraitAction(Node source, Model selectedModel, Consumer<Model> callback) { public SetPortraitAction(Node source, Model selectedModel, Consumer<Model> callback) {
this.source = source; this.source = source;
@ -38,9 +33,6 @@ public class SetPortraitAction extends AbstractPortraitAction {
public void execute() { public void execute() {
source.setCursor(Cursor.WAIT); 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(); GridPane pane = new GridPane();
Label l = new Label("Select a portrait image. Leave empty to remove a portrait again."); Label l = new Label("Select a portrait image. Leave empty to remove a portrait again.");
pane.add(l, 0, 0); pane.add(l, 0, 0);
@ -56,17 +48,15 @@ public class SetPortraitAction extends AbstractPortraitAction {
String selectedFile = portraitSelectionBox.fileProperty().getValue(); String selectedFile = portraitSelectionBox.fileProperty().getValue();
if (StringUtil.isBlank(selectedFile)) { if (StringUtil.isBlank(selectedFile)) {
removePortrait(portraitId); removePortrait(model.getUrl());
} else { } else {
LOG.debug("User selected {}", selectedFile); LOG.debug("User selected {}", selectedFile);
boolean success = processImageFile(portraitId, selectedFile); boolean success = processImageFile(selectedFile);
if (success) { if (success) {
Config.getInstance().getSettings().modelPortraits.put(model.getUrl(), portraitId);
try { try {
Config.getInstance().save();
firePortraitChanged(); firePortraitChanged();
runCallback(); runCallback();
} catch (IOException e) { } catch (Exception e) {
Dialogs.showError("Set Portrait", "Couldn't change portrait image: ", e); Dialogs.showError("Set Portrait", "Couldn't change portrait image: ", e);
} }
} }
@ -74,14 +64,10 @@ public class SetPortraitAction extends AbstractPortraitAction {
source.setCursor(Cursor.DEFAULT); source.setCursor(Cursor.DEFAULT);
} }
private void removePortrait(String portraitId) { private void removePortrait(String modelUrl) {
File portraitFile = getPortraitFile(portraitId);
try { try {
if (portraitFile.exists()) { CamrecApplication.portraitStore.removePortrait(modelUrl);
Files.delete(portraitFile.toPath()); firePortraitChanged();
}
Config.getInstance().getSettings().modelPortraits.remove(model.getUrl());
Config.getInstance().save();
runCallback(); runCallback();
} catch (IOException e) { } catch (IOException e) {
Dialogs.showError("Remove Portrait", "Couldn't remove portrait image: ", 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 { try {
BufferedImage original = ImageIO.read(new File(selectedFile)); BufferedImage original = ImageIO.read(new File(selectedFile));
BufferedImage croppedImage = cropImage(original); BufferedImage croppedImage = cropImage(original);
BufferedImage portrait = convertToScaledJpg(croppedImage); BufferedImage portrait = convertToScaledJpg(croppedImage);
boolean success = copyToCacheAsJpg(portraitId, portrait); boolean success = store(model.getUrl(), portrait);
if (!success) { if (!success) {
LOG.debug("Available formats: {}", Arrays.toString(ImageIO.getWriterFormatNames())); LOG.debug("Available formats: {}", Arrays.toString(ImageIO.getWriterFormatNames()));
throw new IOException("No suitable writer found for image format " + FORMAT); throw new IOException("No suitable writer found for image format " + FORMAT);

View File

@ -1,25 +1,23 @@
package ctbrec.ui.action; package ctbrec.ui.action;
import java.awt.image.BufferedImage; import ctbrec.GlobalThreadPool;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ctbrec.Config;
import ctbrec.Model; import ctbrec.Model;
import ctbrec.ui.controls.Dialogs; import ctbrec.ui.controls.Dialogs;
import javafx.application.Platform;
import javafx.embed.swing.SwingFXUtils; import javafx.embed.swing.SwingFXUtils;
import javafx.scene.Cursor; import javafx.scene.Cursor;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.image.Image; import javafx.scene.image.Image;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.image.BufferedImage;
public class SetThumbAsPortraitAction extends AbstractPortraitAction { public class SetThumbAsPortraitAction extends AbstractPortraitAction {
private static final Logger LOG = LoggerFactory.getLogger(SetThumbAsPortraitAction.class); private static final Logger LOG = LoggerFactory.getLogger(SetThumbAsPortraitAction.class);
private Image image; private final Image image;
public SetThumbAsPortraitAction(Node source, Model model, Image image) { public SetThumbAsPortraitAction(Node source, Model model, Image image) {
this.source = source; this.source = source;
@ -29,20 +27,20 @@ public class SetThumbAsPortraitAction extends AbstractPortraitAction {
public void execute() { public void execute() {
source.setCursor(Cursor.WAIT); source.setCursor(Cursor.WAIT);
try { GlobalThreadPool.submit(() -> {
BufferedImage bufferedImage = convertFxImageToAwt(image); try {
BufferedImage croppedImage = cropImage(bufferedImage); BufferedImage bufferedImage = convertFxImageToAwt(image);
BufferedImage portrait = convertToScaledJpg(croppedImage); BufferedImage croppedImage = cropImage(bufferedImage);
String portraitId = UUID.nameUUIDFromBytes(model.getUrl().getBytes(StandardCharsets.UTF_8)).toString(); BufferedImage portrait = convertToScaledJpg(croppedImage);
copyToCacheAsJpg(portraitId, portrait); store(model.getUrl(), portrait);
Config.getInstance().getSettings().modelPortraits.put(model.getUrl(), portraitId); firePortraitChanged();
Config.getInstance().save(); } catch (Exception e) {
firePortraitChanged(); LOG.error("Error while changing portrait image", e);
} catch (Exception e) { Platform.runLater(() -> Dialogs.showError("Set Portrait", "Couldn't change portrait image: ", e));
LOG.error("Error while changing portrait image", e); } finally {
Dialogs.showError("Set Portrait", "Couldn't change portrait image: ", e); Platform.runLater(() -> source.setCursor(Cursor.DEFAULT));
} }
source.setCursor(Cursor.DEFAULT); });
} }
private BufferedImage convertFxImageToAwt(Image img) { private BufferedImage convertFxImageToAwt(Image img) {

View File

@ -4,11 +4,17 @@ import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader; import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache; import com.google.common.cache.LoadingCache;
import com.google.common.eventbus.Subscribe; 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.event.EventBusHolder;
import ctbrec.image.PortraitStore;
import ctbrec.io.HttpException;
import ctbrec.recorder.Recorder; import ctbrec.recorder.Recorder;
import ctbrec.sites.Site; import ctbrec.sites.Site;
import ctbrec.ui.AutosizeAlert; import ctbrec.ui.AutosizeAlert;
import ctbrec.ui.CamrecApplication;
import ctbrec.ui.JavaFxModel; import ctbrec.ui.JavaFxModel;
import ctbrec.ui.PreviewPopupHandler; import ctbrec.ui.PreviewPopupHandler;
import ctbrec.ui.action.AbstractPortraitAction.PortraitChangedEvent; import ctbrec.ui.action.AbstractPortraitAction.PortraitChangedEvent;
@ -48,9 +54,9 @@ import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority; import javafx.scene.layout.Priority;
import javafx.stage.FileChooser; import javafx.stage.FileChooser;
import javafx.util.Callback; import javafx.util.Callback;
import org.slf4j.Logger; import lombok.extern.slf4j.Slf4j;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.time.Instant; import java.time.Instant;
@ -60,8 +66,8 @@ import java.util.Objects;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
@Slf4j
public abstract class AbstractRecordedModelsTab extends Tab implements TabSelectionListener { 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")); private static final Image SILHOUETTE = new Image(AbstractRecordedModelsTab.class.getResourceAsStream("/silhouette_256.png"));
protected static final String STYLE_ALIGN_CENTER = "-fx-alignment: CENTER;"; 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) { AbstractRecordedModelsTab(String text, String stateStorePrefix) {
super(text); super(text);
config = Config.getInstance(); config = Config.getInstance();
portraitStore = new PortraitStore(config); portraitStore = CamrecApplication.portraitStore;
tableStateStore = new SettingTableViewStateStore(config, stateStorePrefix); tableStateStore = new SettingTableViewStateStore(config, stateStorePrefix);
table = new StatePersistingTableView<>(tableStateStore); table = new StatePersistingTableView<>(tableStateStore);
registerPortraitListener(); registerPortraitListener();
@ -112,6 +118,7 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect
@Subscribe @Subscribe
public void portraitChanged(PortraitChangedEvent e) { public void portraitChanged(PortraitChangedEvent e) {
log.debug("Invalidate cache for {}", e.getModel());
portraitCache.invalidate(e.getModel()); portraitCache.invalidate(e.getModel());
if (table != null) { if (table != null) {
table.refresh(); table.refresh();
@ -230,7 +237,7 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect
} catch (IOException e) { } catch (IOException e) {
String msg = "An error occurred while exporting the model list"; String msg = "An error occurred while exporting the model list";
Dialogs.showError(getTabPane().getScene(), "Export models", msg, e); 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 { try {
recorder.addModel(model); recorder.addModel(model);
} catch (Exception e) { } 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; return null;
@ -269,7 +276,7 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect
} catch (IOException e) { } catch (IOException e) {
String msg = "An error occurred while importing the model list"; String msg = "An error occurred while importing the model list";
Dialogs.showError(getTabPane().getScene(), "Import models", msg, e); 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(); ContextMenu menu = new CustomMouseBehaviorContextMenu();
ModelMenuContributor.newContributor(getTabPane(), Config.getInstance(), recorder) // ModelMenuContributor.newContributor(getTabPane(), Config.getInstance(), recorder) //
.withStartStopCallback(m -> Platform.runLater(this::reload)) // .withStartStopCallback(m -> Platform.runLater(this::reload)) //
.removeModelAfterIgnore(true) // .removeModelAfterIgnore(true) //
.withPortraitCallback(m -> Platform.runLater(() -> { // .withPortraitCallback(m -> Platform.runLater(() -> {
portraitCache.invalidate(m); // portraitCache.invalidate(m);
table.refresh(); // table.refresh();
})) // }))
.afterwards(() -> Platform.runLater(this::reload)) .afterwards(() -> Platform.runLater(this::reload))
.contributeToMenu(selectedModels, menu); .contributeToMenu(selectedModels, menu);
return 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)); new MarkForLaterRecordingAction(modelInputField, List.of(newModel), true, recorder).execute(m -> Platform.runLater(this::reload));
} else { } else {
new StartRecordingAction(modelInputField, List.of(newModel), recorder) new StartRecordingAction(modelInputField, List.of(newModel), recorder)
.execute() .execute()
.whenComplete((r, ex) -> Platform.runLater(this::reload)); .whenComplete((r, ex) -> Platform.runLater(this::reload));
} }
return; 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)); new MarkForLaterRecordingAction(modelInputField, List.of(newModel), true, recorder).execute(m -> Platform.runLater(this::reload));
} else { } else {
new StartRecordingAction(modelInputField, List.of(newModel), recorder) new StartRecordingAction(modelInputField, List.of(newModel), recorder)
.execute() .execute()
.whenComplete((r, ex) -> Platform.runLater(this::reload)); .whenComplete((r, ex) -> Platform.runLater(this::reload));
} }
return; return;
} }
@ -558,6 +565,18 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect
} }
protected Image loadModelPortrait(Model model) { 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;
} }
} }

View File

@ -5,10 +5,10 @@ import com.squareup.moshi.JsonReader.Token;
import ctbrec.Config; import ctbrec.Config;
import ctbrec.Model; import ctbrec.Model;
import ctbrec.ModelGroup; import ctbrec.ModelGroup;
import ctbrec.io.FileJsonAdapter; import ctbrec.image.LocalPortraitStore;
import ctbrec.io.LocalTimeJsonAdapter; import ctbrec.image.PortraitStore;
import ctbrec.io.ModelJsonAdapter; import ctbrec.image.RemotePortraitStore;
import ctbrec.io.UuidJSonAdapter; import ctbrec.io.*;
import ctbrec.sites.Site; import ctbrec.sites.Site;
import okio.Buffer; import okio.Buffer;
import okio.Okio; import okio.Okio;
@ -66,14 +66,25 @@ public class ModelImportExport {
} }
if (exportOptions.includes().contains(ExportIncludes.PORTRAITS)) { if (exportOptions.includes().contains(ExportIncludes.PORTRAITS)) {
var portraits = config.getSettings().modelPortraits; 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()) { if (portraits != null && !portraits.isEmpty()) {
writer.name("portraits"); writer.name("portraits");
writer.beginArray(); writer.beginArray();
for (Map.Entry<String, String> entry : config.getSettings().modelPortraits.entrySet()) { for (Map.Entry<String, String> entry : config.getSettings().modelPortraits.entrySet()) {
String modelUrl = entry.getKey(); String modelUrl = entry.getKey();
String portraitId = entry.getValue(); String portraitId = entry.getValue();
Optional<byte[]> portrait = portraitLoader.loadModelPortraitFile(modelUrl); Optional<byte[]> portrait = portraitLoader.loadModelPortraitByModelUrl(modelUrl);
if (portrait.isPresent()) { if (portrait.isPresent()) {
writer.beginObject(); writer.beginObject();
writer.name("url").value(modelUrl); writer.name("url").value(modelUrl);
@ -122,7 +133,18 @@ public class ModelImportExport {
} }
private static void importPortraits(JsonReader reader, Config config) throws IOException { 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(); reader.beginArray();
while (reader.hasNext()) { while (reader.hasNext()) {
reader.beginObject(); reader.beginObject();

View File

@ -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<Image> loadModelPortrait(String modelUrl) {
return loadModelPortraitFile(modelUrl).map(bytes -> new Image(new ByteArrayInputStream(bytes)));
}
public Optional<byte[]> 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);
}
}

View File

@ -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<byte[]> loadModelPortraitByModelUrl(String modelUrl) {
String portraitId = config.getSettings().modelPortraits.get(modelUrl);
return loadModelPortraitById(portraitId);
}
@Override
public Optional<byte[]> 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);
}
}

View File

@ -0,0 +1,18 @@
package ctbrec.image;
import java.io.IOException;
import java.util.Optional;
public interface PortraitStore {
String FORMAT = "jpg";
Optional<byte[]> loadModelPortraitById(String id) throws IOException;
Optional<byte[]> 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;
}

View File

@ -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<byte[]> loadModelPortraitById(String id) throws IOException {
return load(getEndpoint() + '/' + id);
}
@Override
public Optional<byte[]> loadModelPortraitByModelUrl(String modelUrl) throws IOException {
return load(getModelUrlEndpoint() + URLEncoder.encode(modelUrl, UTF_8));
}
private Optional<byte[]> 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());
}
}
}
}

View File

@ -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 FORM_ENCODED = "application/x-www-form-urlencoded; charset=UTF-8";
public static final String KEEP_ALIVE = "keep-alive"; public static final String KEEP_ALIVE = "keep-alive";
public static final String MIMETYPE_APPLICATION_JSON = "application/json"; 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 MIMETYPE_TEXT_HTML = "text/html";
public static final String NO_CACHE = "no-cache"; public static final String NO_CACHE = "no-cache";
public static final String ORIGIN = "Origin"; public static final String ORIGIN = "Origin";

View File

@ -1,21 +1,20 @@
package ctbrec.recorder.server; package ctbrec.recorder.server;
import static javax.servlet.http.HttpServletResponse.*; import ctbrec.Config;
import ctbrec.Hmac;
import java.io.BufferedReader; import org.slf4j.Logger;
import java.io.IOException; import org.slf4j.LoggerFactory;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; 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 static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
import org.slf4j.LoggerFactory;
import ctbrec.Config;
import ctbrec.Hmac;
public abstract class AbstractCtbrecServlet extends HttpServlet { 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 checkAuthentication(HttpServletRequest req, String body) throws IOException, InvalidKeyException, NoSuchAlgorithmException {
boolean authenticated = false; boolean authenticated = false;
if(Config.getInstance().getSettings().key != null) { if (Config.getInstance().getSettings().key != null) {
String reqParamHmac = req.getParameter("hmac"); String reqParamHmac = req.getParameter("hmac");
String httpHeaderHmac = req.getHeader("CTBREC-HMAC"); String httpHeaderHmac = req.getHeader("CTBREC-HMAC");
String hmac = null; String hmac = null;
String url = req.getRequestURI(); String url = req.getRequestURI();
url = url.substring(getServletContext().getContextPath().length()); url = url.substring(getServletContext().getContextPath().length());
if(reqParamHmac != null) { if (reqParamHmac != null) {
hmac = reqParamHmac; hmac = reqParamHmac;
} }
if(httpHeaderHmac != null) { if (httpHeaderHmac != null) {
hmac = httpHeaderHmac; hmac = httpHeaderHmac;
} }
@ -50,13 +49,19 @@ public abstract class AbstractCtbrecServlet extends HttpServlet {
String body(HttpServletRequest req) throws IOException { String body(HttpServletRequest req) throws IOException {
StringBuilder body = new StringBuilder(); StringBuilder body = new StringBuilder();
BufferedReader br = req.getReader(); BufferedReader br = req.getReader();
String line= null; String line = null;
while( (line = br.readLine()) != null ) { while ((line = br.readLine()) != null) {
body.append(line).append("\n"); body.append(line).append("\n");
} }
return body.toString().trim(); 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) { void sendResponse(HttpServletResponse resp, int httpStatus, String message) {
try { try {
resp.setStatus(httpStatus); resp.setStatus(httpStatus);

View File

@ -7,6 +7,7 @@ import ctbrec.Version;
import ctbrec.event.EventBusHolder; import ctbrec.event.EventBusHolder;
import ctbrec.event.EventHandler; import ctbrec.event.EventHandler;
import ctbrec.event.EventHandlerConfiguration; import ctbrec.event.EventHandlerConfiguration;
import ctbrec.image.LocalPortraitStore;
import ctbrec.recorder.NextGenLocalRecorder; import ctbrec.recorder.NextGenLocalRecorder;
import ctbrec.recorder.OnlineMonitor; import ctbrec.recorder.OnlineMonitor;
import ctbrec.recorder.Recorder; import ctbrec.recorder.Recorder;
@ -59,6 +60,7 @@ import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
public class HttpServer { public class HttpServer {
private static final int MiB = 1024 * 1024; // NOSONAR
private static final Logger LOG = LoggerFactory.getLogger(HttpServer.class); private static final Logger LOG = LoggerFactory.getLogger(HttpServer.class);
private final Recorder recorder; private final Recorder recorder;
private final OnlineMonitor onlineMonitor; private final OnlineMonitor onlineMonitor;
@ -188,7 +190,7 @@ public class HttpServer {
sslContextFactory.setTrustStorePassword(keyStorePassword); sslContextFactory.setTrustStorePassword(keyStorePassword);
try (ServerConnector http = new ServerConnector(server, httpConnectionFactory); try (ServerConnector http = new ServerConnector(server, httpConnectionFactory);
ServerConnector https = new ServerConnector(server, sslContextFactory, httpConnectionFactory)) { ServerConnector https = new ServerConnector(server, sslContextFactory, httpConnectionFactory)) {
// connector for http // connector for http
http.setPort(this.config.getSettings().httpPort); http.setPort(this.config.getSettings().httpPort);
@ -219,6 +221,22 @@ public class HttpServer {
holder = new ServletHolder(hlsServlet); holder = new ServletHolder(hlsServlet);
defaultContext.addServlet(holder, "/hls/*"); 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) { if (this.config.getSettings().webinterface) {
startWebInterface(defaultContext, basicAuthContext); startWebInterface(defaultContext, basicAuthContext);
} }
@ -227,9 +245,9 @@ public class HttpServer {
HandlerList handlers = new HandlerList(); HandlerList handlers = new HandlerList();
if (this.config.getSettings().transportLayerSecurity) { if (this.config.getSettings().transportLayerSecurity) {
server.addConnector(https); server.addConnector(https);
handlers.setHandlers(new Handler[] { new SecuredRedirectHandler(), basicAuthContext, defaultContext }); handlers.setHandlers(new Handler[]{new SecuredRedirectHandler(), basicAuthContext, defaultContext});
} else { } else {
handlers.setHandlers(new Handler[] { basicAuthContext, defaultContext }); handlers.setHandlers(new Handler[]{basicAuthContext, defaultContext});
} }
server.setHandler(handlers); server.setHandler(handlers);
@ -255,7 +273,7 @@ public class HttpServer {
ServletHolder holder = new ServletHolder(staticFileServlet); ServletHolder holder = new ServletHolder(staticFileServlet);
String staticFileContext = "/static/*"; String staticFileContext = "/static/*";
defaultContext.addServlet(holder, staticFileContext); 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) // servlet to retrieve the HMAC (secured by basic auth if a hmac key is set in the config)
String username = this.config.getSettings().webinterfaceUsername; String username = this.config.getSettings().webinterfaceUsername;
@ -298,7 +316,7 @@ public class HttpServer {
@Override @Override
protected void handleErrorPage(HttpServletRequest request, Writer writer, int code, String message) throws IOException { protected void handleErrorPage(HttpServletRequest request, Writer writer, int code, String message) throws IOException {
if (code == 404) { if (code == 404) {
writer.write("<html><head><title>404</title><style>* {font-family: sans-serif}</style></head><body><h1>404</h1><p>Looking for <a href=\""+contextPath+"/static/index.html\">CTB Recorder</a>?</p></body>"); writer.write("<html><head><title>404</title><style>* {font-family: sans-serif}</style></head><body><h1>404</h1><p>Looking for <a href=\"" + contextPath + "/static/index.html\">CTB Recorder</a>?</p></body>");
} else { } else {
super.handleErrorPage(request, writer, code, message); super.handleErrorPage(request, writer, code, message);
} }
@ -315,7 +333,7 @@ public class HttpServer {
@Override @Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { 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); chain.doFilter(request, response);
} }
@ -330,14 +348,14 @@ public class HttpServer {
private static SecurityHandler basicAuth(String username, String password) { private static SecurityHandler basicAuth(String username, String password) {
String realm = "CTB Recorder"; String realm = "CTB Recorder";
UserStore userStore = new UserStore(); 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(); HashLoginService l = new HashLoginService();
l.setUserStore(userStore); l.setUserStore(userStore);
l.setName(realm); l.setName(realm);
Constraint constraint = new Constraint(); Constraint constraint = new Constraint();
constraint.setName(Constraint.__BASIC_AUTH); constraint.setName(Constraint.__BASIC_AUTH);
constraint.setRoles(new String[] { "user" }); constraint.setRoles(new String[]{"user"});
constraint.setAuthenticate(true); constraint.setAuthenticate(true);
ConstraintMapping cm = new ConstraintMapping(); ConstraintMapping cm = new ConstraintMapping();

View File

@ -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<byte[]> 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);
}
}
}