diff --git a/src/main/java/com/codedead/opal/OpalApplication.java b/src/main/java/com/codedead/opal/OpalApplication.java index 6e5591d..8245bfe 100644 --- a/src/main/java/com/codedead/opal/OpalApplication.java +++ b/src/main/java/com/codedead/opal/OpalApplication.java @@ -1,5 +1,6 @@ package com.codedead.opal; +import com.codedead.opal.controller.LanguageController; import com.codedead.opal.controller.ThemeController; import com.codedead.opal.controller.UpdateController; import com.codedead.opal.utils.FxUtils; @@ -35,6 +36,11 @@ public class OpalApplication extends Application { * @param args The application arguments */ public static void main(final String[] args) { + // The JDK disables Basic authentication for HTTPS proxy tunneling by default + if (System.getProperty("jdk.http.auth.tunneling.disabledSchemes") == null) { + System.setProperty("jdk.http.auth.tunneling.disabledSchemes", ""); + } + System.setProperty("logBasePath", SharedVariables.PROPERTIES_BASE_PATH); logger = LogManager.getLogger(OpalApplication.class); @@ -85,7 +91,7 @@ public void start(final Stage primaryStage) { ThemeController.setTheme(properties.getProperty("theme", "light")); - final Locale locale = Locale.forLanguageTag(languageTag); + final Locale locale = LanguageController.getLocaleFromLanguageTag(languageTag); final ResourceBundle translationBundle = ResourceBundle.getBundle("translations.OpalApplication", locale); final FXMLLoader loader = new FXMLLoader(getClass().getResource("/windows/MainWindow.fxml"), translationBundle); diff --git a/src/main/java/com/codedead/opal/controller/LanguageController.java b/src/main/java/com/codedead/opal/controller/LanguageController.java index ef46658..0c0e6b1 100644 --- a/src/main/java/com/codedead/opal/controller/LanguageController.java +++ b/src/main/java/com/codedead/opal/controller/LanguageController.java @@ -1,5 +1,7 @@ package com.codedead.opal.controller; +import java.util.Locale; + import static com.codedead.opal.utils.SharedVariables.DEFAULT_LOCALE; public final class LanguageController { @@ -23,7 +25,7 @@ public static int getLanguageIndexFromLocale(final String locale) { case "es-es" -> 2; case "fr-fr" -> 3; case "hi-in" -> 4; - case "jp-jp" -> 5; + case "ja-jp", "jp-jp" -> 5; case "nl-nl" -> 6; case "ru-ru" -> 7; case "tr-tr" -> 8; @@ -33,6 +35,17 @@ public static int getLanguageIndexFromLocale(final String locale) { }; } + /** + * Get the locale from the language tag, migrating the legacy Japanese tag + * + * @param languageTag The language tag + * @return The locale + */ + public static Locale getLocaleFromLanguageTag(final String languageTag) { + final String normalizedLanguageTag = "jp-JP".equalsIgnoreCase(languageTag) ? "ja-JP" : languageTag; + return Locale.forLanguageTag(normalizedLanguageTag); + } + /** * Get the locale from the language index * @@ -45,7 +58,7 @@ public static String getLocaleFromLanguageIndex(final int index) { case 2 -> "es-ES"; case 3 -> "fr-FR"; case 4 -> "hi-in"; - case 5 -> "jp-JP"; + case 5 -> "ja-JP"; case 6 -> "nl-NL"; case 7 -> "ru-RU"; case 8 -> "tr-TR"; diff --git a/src/main/java/com/codedead/opal/controller/MainWindowController.java b/src/main/java/com/codedead/opal/controller/MainWindowController.java index 456bdab..4f4f547 100644 --- a/src/main/java/com/codedead/opal/controller/MainWindowController.java +++ b/src/main/java/com/codedead/opal/controller/MainWindowController.java @@ -6,6 +6,7 @@ import com.codedead.opal.interfaces.TrayIconListener; import com.codedead.opal.utils.FxUtils; import com.codedead.opal.utils.HelpUtils; +import com.codedead.opal.utils.PasswordCipher; import com.codedead.opal.utils.RunnableFileOpener; import com.codedead.opal.utils.SharedVariables; import javafx.application.Platform; @@ -34,9 +35,9 @@ import java.io.File; import java.io.IOException; import java.io.InputStreamReader; -import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; +import java.security.GeneralSecurityException; import java.util.*; import static com.codedead.opal.utils.SharedVariables.DEFAULT_LOCALE; @@ -197,6 +198,7 @@ public final class MainWindowController implements IAudioTimer, TrayIconListener private TimerTask countDownTask; private final String platformName; private final HelpUtils helpUtils; + private final PasswordCipher passwordCipher; private final JsonMapper jsonMapper; private final Timer timer; private final Timer countDownTimer; @@ -215,6 +217,7 @@ public MainWindowController() { platformName = OsCheck.getOperatingSystemType().name(); helpUtils = new HelpUtils(); + passwordCipher = new PasswordCipher(); this.timer = new Timer(); this.countDownTimer = new Timer(); @@ -255,7 +258,7 @@ public void setControllers(final SettingsController settingsController, final Up logger.info("Attempting to load the ResourceBundle for locale {}", languageTag); - final Locale locale = Locale.forLanguageTag(languageTag); + final Locale locale = LanguageController.getLocaleFromLanguageTag(languageTag); resourceFactory.setResources(ResourceBundle.getBundle("translations.OpalApplication", locale)); final boolean mediaButtons = Boolean.parseBoolean(properties.getProperty("mediaButtons", "true")); @@ -364,6 +367,37 @@ public void loadMediaButtonVisibility(final boolean visible) { getSoundPanes(grpCustom).forEach(s -> s.setMediaButton(visible)); } + /** + * Configure the proxy that should be used when checking for updates + */ + private void configureUpdateProxy() { + final Properties properties = settingsController.getProperties(); + final boolean proxyEnabled = Boolean.parseBoolean(properties.getProperty("updateProxyEnabled", "false")); + final boolean authenticationEnabled = proxyEnabled && Boolean.parseBoolean(properties.getProperty("updateProxyAuthEnabled", "false")); + final String proxyPort = properties.getProperty("updateProxyPort", "8080").trim(); + final int port = proxyPort.isBlank() ? 8080 : Integer.parseInt(proxyPort); + + updateController.setProxy(proxyEnabled, properties.getProperty("updateProxyHost", ""), port); + updateController.setProxyAuthentication( + authenticationEnabled, + properties.getProperty("updateProxyUsername", ""), + authenticationEnabled ? decryptProxyPassword(properties.getProperty("updateProxyPassword", "")) : ""); + } + + /** + * Decrypt the stored proxy password + * + * @param storedPassword The stored password + * @return The decrypted password + */ + private String decryptProxyPassword(final String storedPassword) { + try { + return passwordCipher.decrypt(storedPassword); + } catch (final IOException | GeneralSecurityException ex) { + throw new IllegalArgumentException("Unable to decrypt the proxy password", ex); + } + } + /** * Check for application updates * @@ -374,6 +408,8 @@ private void checkForUpdates(final boolean showNoUpdates, final boolean showErro logger.info("Attempting to check for updates"); try { + configureUpdateProxy(); + final Optional platformUpdate = updateController.checkForUpdates(platformName, SharedVariables.PORTABLE); if (platformUpdate.isPresent()) { final PlatformUpdate update = platformUpdate.get(); @@ -413,7 +449,7 @@ private void checkForUpdates(final boolean showNoUpdates, final boolean showErro FxUtils.showInformationAlert(resourceFactory.getResources().getString("NoUpdateAvailable"), getClass().getResourceAsStream(SharedVariables.ICON_URL)); } } - } catch (final InterruptedException | IOException | InvalidHttpResponseCodeException | URISyntaxException ex) { + } catch (final InterruptedException | IOException | InvalidHttpResponseCodeException | IllegalArgumentException ex) { if (ex instanceof InterruptedException) Thread.currentThread().interrupt(); @@ -461,6 +497,33 @@ private List getSoundPanes(final GridPane parent) { return elements; } + /** + * Filter the {@link SoundPane} objects in a {@link GridPane} based on a search query + * + * @param gridPane The {@link GridPane} containing the {@link SoundPane} objects + * @param query The trimmed, lower-case query to filter on + */ + private void filterSoundPanes(final GridPane gridPane, final String query) { + getSoundPanes(gridPane).forEach(e -> { + e.setVisible(e.getName().toLowerCase().contains(query)); + e.setManaged(e.isVisible()); + }); + } + + /** + * Update the visibility of a {@link TitledPane} based on whether any of its {@link SoundPane} children are visible + * + * @param titledPane The {@link TitledPane} to update + * @param gridPane The {@link GridPane} containing the {@link SoundPane} objects + */ + private void updateTitledPaneVisibility(final TitledPane titledPane, final GridPane gridPane) { + final boolean hasVisibleSoundPane = getSoundPanes(gridPane).stream().anyMatch(Node::isVisible); + + titledPane.setExpanded(hasVisibleSoundPane); + titledPane.setVisible(hasVisibleSoundPane); + titledPane.setManaged(hasVisibleSoundPane); + } + /** * Open a file on the local filesystem * @@ -559,86 +622,25 @@ private void initialize() { } Platform.runLater(() -> { - getAllSoundPanes() - .forEach(e -> { - e.setVisible(e.getName().toLowerCase().contains(newValue.trim().toLowerCase())); - e.setManaged(e.isVisible()); - }); + final String query = newValue.trim().toLowerCase(); - getSoundPanes(grpCustom) - .forEach(e -> { - e.setVisible(e.getName().toLowerCase().contains(newValue.trim().toLowerCase())); - e.setManaged(e.isVisible()); - }); + filterSoundPanes(grpNature, query); + filterSoundPanes(grpOffice, query); + filterSoundPanes(grpAudiences, query); + filterSoundPanes(grpRadioFrequencyStatic, query); + filterSoundPanes(grpOther, query); + filterSoundPanes(grpCustom, query); - // Check if there are still active sound panes on pneCustom + // Hide the TitledPanes that no longer contain any visible SoundPane final List customSoundPanes = getSoundPanes(grpCustom); if (!customSoundPanes.isEmpty()) { - customSoundPanes.stream().filter(Node::isVisible).findFirst().ifPresentOrElse(_ -> { - pneCustom.setExpanded(true); - pneCustom.setVisible(true); - pneCustom.setManaged(true); - }, () -> { - pneCustom.setExpanded(false); - pneCustom.setVisible(false); - pneCustom.setManaged(false); - }); + updateTitledPaneVisibility(pneCustom, grpCustom); } - - // Check if there are still active sound panes on pneNature - getSoundPanes(grpNature).stream().filter(Node::isVisible).findFirst().ifPresentOrElse(_ -> { - pneNature.setExpanded(true); - pneNature.setVisible(true); - pneNature.setManaged(true); - }, () -> { - pneNature.setExpanded(false); - pneNature.setVisible(false); - pneNature.setManaged(false); - }); - - // Check if there are still active sound panes on pneOffice - getSoundPanes(grpOffice).stream().filter(Node::isVisible).findFirst().ifPresentOrElse(_ -> { - pneOffice.setExpanded(true); - pneOffice.setVisible(true); - pneOffice.setManaged(true); - }, () -> { - pneOffice.setExpanded(false); - pneOffice.setVisible(false); - pneOffice.setManaged(false); - }); - - // Check if there are still active sound panes on pneAudiences - getSoundPanes(grpAudiences).stream().filter(Node::isVisible).findFirst().ifPresentOrElse(_ -> { - pneAudiences.setExpanded(true); - pneAudiences.setVisible(true); - pneAudiences.setManaged(true); - }, () -> { - pneAudiences.setExpanded(false); - pneAudiences.setVisible(false); - pneAudiences.setManaged(false); - }); - - // Check if there are still active sound panes on pneRadioFrequencyStatic - getSoundPanes(grpRadioFrequencyStatic).stream().filter(Node::isVisible).findFirst().ifPresentOrElse(_ -> { - pneRadioFrequencyStatic.setExpanded(true); - pneRadioFrequencyStatic.setVisible(true); - pneRadioFrequencyStatic.setManaged(true); - }, () -> { - pneRadioFrequencyStatic.setExpanded(false); - pneRadioFrequencyStatic.setVisible(false); - pneRadioFrequencyStatic.setManaged(false); - }); - - // Check if there are still active sound panes on pneOther - getSoundPanes(grpOther).stream().filter(Node::isVisible).findFirst().ifPresentOrElse(_ -> { - pneOther.setExpanded(true); - pneOther.setVisible(true); - pneOther.setManaged(true); - }, () -> { - pneOther.setExpanded(false); - pneOther.setVisible(false); - pneOther.setManaged(false); - }); + updateTitledPaneVisibility(pneNature, grpNature); + updateTitledPaneVisibility(pneOffice, grpOffice); + updateTitledPaneVisibility(pneAudiences, grpAudiences); + updateTitledPaneVisibility(pneRadioFrequencyStatic, grpRadioFrequencyStatic); + updateTitledPaneVisibility(pneOther, grpOther); btnClearSearch.setVisible(true); btnClearSearch.setManaged(true); @@ -1019,16 +1021,17 @@ public void fired() { try { final ProcessBuilder p = new ProcessBuilder(command.split(" ")); - try (final BufferedReader reader = new BufferedReader(new InputStreamReader(p.start().getInputStream()))) { - final StringBuilder builder = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - builder.append(line); - builder.append(System.lineSeparator()); + try (final Process process = p.start()) { + try (final BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + final StringBuilder output = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append(System.lineSeparator()); + } + logger.info("Shutdown command result: {}", output.toString()); } - final String result = builder.toString(); - logger.info("Shutdown command result: {}", result); } + exitAction(); } catch (final IOException ex) { logger.error("Unable to execute shutdown command", ex); diff --git a/src/main/java/com/codedead/opal/controller/SettingsWindowController.java b/src/main/java/com/codedead/opal/controller/SettingsWindowController.java index f90390b..dfc2e9f 100644 --- a/src/main/java/com/codedead/opal/controller/SettingsWindowController.java +++ b/src/main/java/com/codedead/opal/controller/SettingsWindowController.java @@ -5,6 +5,7 @@ import com.codedead.opal.domain.OSType; import com.codedead.opal.domain.OsCheck; import com.codedead.opal.utils.FxUtils; +import com.codedead.opal.utils.PasswordCipher; import com.codedead.opal.utils.SharedVariables; import javafx.application.Application; import javafx.collections.FXCollections; @@ -14,7 +15,9 @@ import javafx.scene.control.Button; import javafx.scene.control.CheckBox; import javafx.scene.control.ComboBox; +import javafx.scene.control.PasswordField; import javafx.scene.control.Slider; +import javafx.scene.control.TextField; import javafx.stage.Stage; import org.apache.logging.log4j.Level; import org.apache.logging.log4j.LogManager; @@ -22,6 +25,7 @@ import org.apache.logging.log4j.core.config.Configurator; import java.io.IOException; +import java.security.GeneralSecurityException; import java.util.Locale; import java.util.Objects; import java.util.ResourceBundle; @@ -55,11 +59,24 @@ public final class SettingsWindowController { private ComboBox cboLanguage; @FXML private ComboBox cboLogLevel; + @FXML + private CheckBox chbUpdateProxy; + @FXML + private TextField txtProxyHost; + @FXML + private NumberTextField numProxyPort; + @FXML + private CheckBox chbProxyAuthentication; + @FXML + private TextField txtProxyUsername; + @FXML + private PasswordField pfProxyPassword; private MainWindowController mainWindowController; private SettingsController settingsController; private ResourceBundle translationBundle; + private final PasswordCipher passwordCipher; private final Logger logger; /** @@ -68,6 +85,7 @@ public final class SettingsWindowController { public SettingsWindowController() { logger = LogManager.getLogger(SettingsWindowController.class); logger.info("Initializing new SettingsWindowController object"); + passwordCipher = new PasswordCipher(); } /** @@ -82,6 +100,32 @@ private void initialize() { if (Objects.requireNonNull(OsCheck.getOperatingSystemType()) == OSType.OTHER) { chbTimerComputerShutdown.setDisable(true); } + + chbUpdateProxy.selectedProperty() + .addListener((options, oldValue, newValue) -> updateProxyControls()); + chbProxyAuthentication.selectedProperty() + .addListener((options, oldValue, newValue) -> updateProxyAuthenticationControls()); + updateProxyControls(); + } + + /** + * Enable or disable the proxy controls based on the checkbox state + */ + private void updateProxyControls() { + final boolean disabled = !chbUpdateProxy.isSelected(); + txtProxyHost.setDisable(disabled); + numProxyPort.setDisable(disabled); + chbProxyAuthentication.setDisable(disabled); + updateProxyAuthenticationControls(); + } + + /** + * Enable or disable the proxy authentication controls based on the checkbox states + */ + private void updateProxyAuthenticationControls() { + final boolean disabled = !chbUpdateProxy.isSelected() || !chbProxyAuthentication.isSelected(); + txtProxyUsername.setDisable(disabled); + pfProxyPassword.setDisable(disabled); } /** @@ -98,7 +142,7 @@ public void setSettingsController(final SettingsController settingsController) { logger.info("Attempting to load the ResourceBundle for locale {}", languageTag); - final Locale locale = Locale.forLanguageTag(languageTag); + final Locale locale = LanguageController.getLocaleFromLanguageTag(languageTag); translationBundle = ResourceBundle.getBundle("translations.OpalApplication", locale); final ObservableList options = FXCollections.observableArrayList( @@ -152,6 +196,12 @@ private void loadSettings() { }; chbAutoUpdate.setSelected(Boolean.parseBoolean(settingsController.getProperties().getProperty("autoUpdate", "true"))); + chbUpdateProxy.setSelected(Boolean.parseBoolean(settingsController.getProperties().getProperty("updateProxyEnabled", "false"))); + txtProxyHost.setText(settingsController.getProperties().getProperty("updateProxyHost", "")); + numProxyPort.setText(settingsController.getProperties().getProperty("updateProxyPort", "8080")); + chbProxyAuthentication.setSelected(Boolean.parseBoolean(settingsController.getProperties().getProperty("updateProxyAuthEnabled", "false"))); + txtProxyUsername.setText(settingsController.getProperties().getProperty("updateProxyUsername", "")); + pfProxyPassword.setText(decryptProxyPassword(settingsController.getProperties().getProperty("updateProxyPassword", ""))); chbMediaButtons.setSelected(Boolean.parseBoolean(settingsController.getProperties().getProperty("mediaButtons", "true"))); chbDragDrop.setSelected(Boolean.parseBoolean(settingsController.getProperties().getProperty("dragDrop", "true"))); chbTrayIcon.setSelected(Boolean.parseBoolean(settingsController.getProperties().getProperty("trayIcon", "false"))); @@ -164,6 +214,21 @@ private void loadSettings() { sldAudioBalance.setValue(Double.parseDouble(settingsController.getProperties().getProperty("audioBalance", "0.0"))); } + /** + * Decrypt the stored proxy password + * + * @param storedPassword The stored password + * @return The decrypted password, or an empty {@link String} if it could not be decrypted + */ + private String decryptProxyPassword(final String storedPassword) { + try { + return passwordCipher.decrypt(storedPassword); + } catch (final IOException | GeneralSecurityException ex) { + logger.error("Unable to decrypt the proxy password", ex); + return ""; + } + } + /** * Reset all settings to their default values after user confirmation */ @@ -176,7 +241,7 @@ private void resetSettingsAction() { settingsController.createDefaultProperties(); settingsController.setProperties(settingsController.readPropertiesFile()); - final Locale locale = Locale.forLanguageTag(DEFAULT_LOCALE); + final Locale locale = LanguageController.getLocaleFromLanguageTag(DEFAULT_LOCALE); final ResourceBundle newTranslationBundle = ResourceBundle.getBundle("translations.OpalApplication", locale); this.translationBundle = newTranslationBundle; @@ -203,13 +268,60 @@ private void resetSettingsAction() { private void saveSettingsAction() { logger.info("Attempting to save all settings"); + final boolean updateProxyEnabled = chbUpdateProxy.isSelected(); + final String updateProxyHost = txtProxyHost.getText() == null ? "" : txtProxyHost.getText().trim(); + final String updateProxyPortValue = numProxyPort.getText() == null ? "" : numProxyPort.getText().trim(); + final boolean updateProxyAuthEnabled = chbProxyAuthentication.isSelected(); + final String updateProxyUsername = txtProxyUsername.getText() == null ? "" : txtProxyUsername.getText().trim(); + final String updateProxyPassword = pfProxyPassword.getText() == null ? "" : pfProxyPassword.getText(); + int updateProxyPort = 8080; + + if (!updateProxyPortValue.isBlank()) { + try { + updateProxyPort = Integer.parseInt(updateProxyPortValue); + } catch (final NumberFormatException ex) { + if (updateProxyEnabled) { + logger.warn("Invalid proxy port: {}", updateProxyPortValue); + FxUtils.showErrorAlert(translationBundle.getString("SaveSettingsError"), translationBundle.getString("InvalidProxySettings"), getClass().getResourceAsStream(SharedVariables.ICON_URL)); + return; + } + } + } + + if (updateProxyEnabled && (updateProxyHost.isBlank() || updateProxyPort < 1 || updateProxyPort > 65535)) { + logger.warn("Invalid proxy settings: {}:{}", updateProxyHost, updateProxyPort); + FxUtils.showErrorAlert(translationBundle.getString("SaveSettingsError"), translationBundle.getString("InvalidProxySettings"), getClass().getResourceAsStream(SharedVariables.ICON_URL)); + return; + } + + if (updateProxyEnabled && updateProxyAuthEnabled && (updateProxyUsername.isBlank() || updateProxyPassword.isEmpty())) { + logger.warn("Invalid proxy authentication settings"); + FxUtils.showErrorAlert(translationBundle.getString("SaveSettingsError"), translationBundle.getString("InvalidProxyAuthentication"), getClass().getResourceAsStream(SharedVariables.ICON_URL)); + return; + } + + final String storedProxyPassword; + try { + storedProxyPassword = passwordCipher.encrypt(updateProxyPassword); + } catch (final IOException | GeneralSecurityException ex) { + logger.error("Unable to encrypt the proxy password", ex); + FxUtils.showErrorAlert(translationBundle.getString("SaveSettingsError"), ex.toString(), getClass().getResourceAsStream(SharedVariables.ICON_URL)); + return; + } + settingsController.getProperties().setProperty("autoUpdate", Boolean.toString(chbAutoUpdate.isSelected())); + settingsController.getProperties().setProperty("updateProxyEnabled", Boolean.toString(updateProxyEnabled)); + settingsController.getProperties().setProperty("updateProxyHost", updateProxyHost); + settingsController.getProperties().setProperty("updateProxyPort", String.valueOf(updateProxyPort)); + settingsController.getProperties().setProperty("updateProxyAuthEnabled", Boolean.toString(updateProxyAuthEnabled)); + settingsController.getProperties().setProperty("updateProxyUsername", updateProxyUsername); + settingsController.getProperties().setProperty("updateProxyPassword", storedProxyPassword); settingsController.getProperties().setProperty("mediaButtons", Boolean.toString(chbMediaButtons.isSelected())); settingsController.getProperties().setProperty("dragDrop", Boolean.toString(chbDragDrop.isSelected())); settingsController.getProperties().setProperty("trayIcon", Boolean.toString(chbTrayIcon.isSelected())); final String languageTag = LanguageController.getLocaleFromLanguageIndex(cboLanguage.getSelectionModel().getSelectedIndex()); - final Locale locale = Locale.forLanguageTag(languageTag); + final Locale locale = LanguageController.getLocaleFromLanguageTag(languageTag); final ResourceBundle newTranslationBundle = ResourceBundle.getBundle("translations.OpalApplication", locale); this.translationBundle = newTranslationBundle; diff --git a/src/main/java/com/codedead/opal/controller/UpdateController.java b/src/main/java/com/codedead/opal/controller/UpdateController.java index 07c418e..2d04326 100644 --- a/src/main/java/com/codedead/opal/controller/UpdateController.java +++ b/src/main/java/com/codedead/opal/controller/UpdateController.java @@ -7,17 +7,19 @@ import tools.jackson.databind.DeserializationFeature; import tools.jackson.databind.json.JsonMapper; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.net.Authenticator; +import java.net.InetSocketAddress; +import java.net.PasswordAuthentication; +import java.net.ProxySelector; import java.net.URI; -import java.net.URISyntaxException; -import java.net.URL; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.nio.channels.Channels; -import java.nio.channels.ReadableByteChannel; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Arrays; import java.util.List; import java.util.Optional; @@ -26,6 +28,10 @@ public final class UpdateController { private String updateUrl; private String currentVersion; + private ProxySelector proxySelector; + private boolean proxyAuthEnabled; + private String proxyUsername; + private char[] proxyPassword; private final Logger logger; private final JsonMapper jsonMapper; @@ -127,7 +133,7 @@ private int versionCompare(final String v1, final String v2) { public List getUpdates() throws IOException, InterruptedException, InvalidHttpResponseCodeException { logger.info("Attempting to retrieve List of PlatformUpdate objects from {}", getUpdateUrl()); - try (final HttpClient client = HttpClient.newHttpClient()) { + try (final HttpClient client = createHttpClient()) { final HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(getUpdateUrl())) .build(); @@ -146,10 +152,11 @@ public List getUpdates() throws IOException, InterruptedExceptio * * @param url The URL of the file that should be downloaded * @param path The path on the local filesystem where the file should be stored - * @throws IOException If the transfer failed - * @throws URISyntaxException If the URL is invalid + * @throws IOException If the transfer failed + * @throws InterruptedException If the transfer was interrupted + * @throws InvalidHttpResponseCodeException When the HTTP response status code is not OK */ - public void downloadFile(final String url, final String path) throws IOException, URISyntaxException { + public void downloadFile(final String url, final String path) throws IOException, InterruptedException, InvalidHttpResponseCodeException { if (url == null) throw new NullPointerException("URL cannot be null!"); if (url.isBlank()) @@ -161,13 +168,108 @@ public void downloadFile(final String url, final String path) throws IOException logger.info("Attempting to download file from {} and store it at {}", url, path); - final URL website = new URI(url).toURL(); - try (final InputStream inputStream = website.openStream()) { - try (final ReadableByteChannel rbc = Channels.newChannel(inputStream)) { - try (final FileOutputStream fos = new FileOutputStream(path)) { - fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); - } + final HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .build(); + + try (final HttpClient client = createHttpClient()) { + final HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); + + if (response.statusCode() != 200) + throw new InvalidHttpResponseCodeException(String.format("Invalid HTTP response code (%s)", response.statusCode())); + + try (final InputStream inputStream = response.body(); + final OutputStream outputStream = Files.newOutputStream(Path.of(path))) { + inputStream.transferTo(outputStream); + } + } + } + + /** + * Create a new {@link HttpClient} that uses the configured proxy, if any + * + * @return The {@link HttpClient} object + */ + private HttpClient createHttpClient() { + final HttpClient.Builder builder = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL); + + if (proxySelector != null) { + builder.proxy(proxySelector); + } + + if (proxySelector != null && proxyAuthEnabled) { + builder.authenticator(new ProxyAuthenticator(proxyUsername, proxyPassword)); + } + + return builder.build(); + } + + /** + * Configure the proxy authentication that is used for update requests + * + * @param enabled True if proxy authentication should be used, otherwise false + * @param username The proxy username + * @param password The proxy password + */ + public void setProxyAuthentication(final boolean enabled, final String username, final String password) { + clearProxyPassword(); + proxyAuthEnabled = false; + proxyUsername = null; + + if (!enabled) + return; + if (username == null) + throw new NullPointerException("Proxy username cannot be null!"); + + final String proxyUser = username.trim(); + if (proxyUser.isBlank()) + throw new IllegalArgumentException("Proxy username cannot be blank!"); + if (password == null) + throw new NullPointerException("Proxy password cannot be null!"); + if (password.isEmpty()) + throw new IllegalArgumentException("Proxy password cannot be empty!"); + + proxyAuthEnabled = true; + proxyUsername = proxyUser; + proxyPassword = password.toCharArray(); + } + + /** + * Clear the stored proxy password + */ + private void clearProxyPassword() { + if (proxyPassword != null) { + Arrays.fill(proxyPassword, '\0'); + proxyPassword = null; + } + } + + /** + * {@link Authenticator} that provides the configured proxy credentials + */ + private static final class ProxyAuthenticator extends Authenticator { + + private final String username; + private final char[] password; + + /** + * Initialize a new ProxyAuthenticator + * + * @param username The proxy username + * @param password The proxy password + */ + private ProxyAuthenticator(final String username, final char[] password) { + this.username = username; + this.password = password; + } + + @Override + protected PasswordAuthentication getPasswordAuthentication() { + if (getRequestorType() == RequestorType.PROXY) { + return new PasswordAuthentication(username, password); } + return null; } } @@ -194,6 +296,31 @@ public void setUpdateUrl(final String updateUrl) { this.updateUrl = updateUrl; } + /** + * Configure the proxy that is used for update requests + * + * @param enabled True if a custom proxy should be used, otherwise false + * @param host The proxy host + * @param port The proxy port + */ + public void setProxy(final boolean enabled, final String host, final int port) { + if (!enabled) { + proxySelector = null; + return; + } + if (host == null) + throw new NullPointerException("Proxy host cannot be null!"); + + final String proxyHost = host.trim(); + if (proxyHost.isBlank()) + throw new IllegalArgumentException("Proxy host cannot be blank!"); + if (port < 1 || port > 65535) + throw new IllegalArgumentException("Proxy port must be between 1 and 65535!"); + + logger.info("Using custom proxy {}:{} for update requests", proxyHost, port); + proxySelector = ProxySelector.of(InetSocketAddress.createUnresolved(proxyHost, port)); + } + /** * Set the current version * diff --git a/src/main/java/com/codedead/opal/utils/PasswordCipher.java b/src/main/java/com/codedead/opal/utils/PasswordCipher.java new file mode 100644 index 0000000..0850436 --- /dev/null +++ b/src/main/java/com/codedead/opal/utils/PasswordCipher.java @@ -0,0 +1,183 @@ +package com.codedead.opal.utils; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Base64; + +public final class PasswordCipher { + + private static final String PREFIX = "enc:v1:"; + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int KEY_LENGTH = 32; + private static final int IV_LENGTH = 12; + private static final int TAG_LENGTH = 128; + + private final Path keyFile; + private final SecureRandom secureRandom; + private final Logger logger; + + private byte[] key; + + /** + * Initialize a new PasswordCipher that stores its key in the default location + */ + public PasswordCipher() { + this(Path.of(SharedVariables.PROXY_PASSWORD_KEY_FILE_LOCATION)); + } + + /** + * Initialize a new PasswordCipher + * + * @param keyFile The location of the file that contains the encryption key + */ + public PasswordCipher(final Path keyFile) { + if (keyFile == null) + throw new NullPointerException("Key file cannot be null!"); + + this.keyFile = keyFile; + this.secureRandom = new SecureRandom(); + this.logger = LogManager.getLogger(PasswordCipher.class); + } + + /** + * Encrypt a value + * + * @param value The value that should be encrypted + * @return The encrypted value, or an empty {@link String} if the value was empty + * @throws IOException When the encryption key could not be loaded or stored + * @throws GeneralSecurityException When the value could not be encrypted + */ + public String encrypt(final String value) throws IOException, GeneralSecurityException { + if (value == null) + throw new NullPointerException("Value cannot be null!"); + if (value.isEmpty()) + return ""; + + final byte[] iv = new byte[IV_LENGTH]; + secureRandom.nextBytes(iv); + + final Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, getKey(true), new GCMParameterSpec(TAG_LENGTH, iv)); + final byte[] encryptedValue = cipher.doFinal(value.getBytes(StandardCharsets.UTF_8)); + + final byte[] payload = new byte[iv.length + encryptedValue.length]; + System.arraycopy(iv, 0, payload, 0, iv.length); + System.arraycopy(encryptedValue, 0, payload, iv.length, encryptedValue.length); + + return PREFIX + Base64.getEncoder().encodeToString(payload); + } + + /** + * Decrypt a value + * + * @param value The value that should be decrypted + * @return The decrypted value + * @throws IOException When the encryption key could not be loaded + * @throws GeneralSecurityException When the value could not be decrypted + */ + public String decrypt(final String value) throws IOException, GeneralSecurityException { + if (value == null) + throw new NullPointerException("Value cannot be null!"); + if (!value.startsWith(PREFIX)) + return value; + + final byte[] payload; + try { + payload = Base64.getDecoder().decode(value.substring(PREFIX.length())); + } catch (final IllegalArgumentException ex) { + throw new GeneralSecurityException("Encrypted value is invalid", ex); + } + + if (payload.length <= IV_LENGTH) + throw new GeneralSecurityException("Encrypted value is invalid"); + + final byte[] iv = new byte[IV_LENGTH]; + final byte[] encryptedValue = new byte[payload.length - IV_LENGTH]; + System.arraycopy(payload, 0, iv, 0, IV_LENGTH); + System.arraycopy(payload, IV_LENGTH, encryptedValue, 0, encryptedValue.length); + + final Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, getKey(false), new GCMParameterSpec(TAG_LENGTH, iv)); + + return new String(cipher.doFinal(encryptedValue), StandardCharsets.UTF_8); + } + + /** + * Retrieve the encryption key, creating it if requested + * + * @param createIfMissing True if the key should be created when it does not exist, otherwise false + * @return The {@link SecretKey} object + * @throws IOException When the key could not be loaded or created + */ + private SecretKey getKey(final boolean createIfMissing) throws IOException { + if (key == null) { + if (Files.exists(keyFile)) { + key = readKey(); + } else if (createIfMissing) { + key = createKey(); + } else { + throw new IOException(String.format("Proxy password key file does not exist (%s)", keyFile)); + } + } + + return new SecretKeySpec(key, "AES"); + } + + /** + * Read the encryption key from the key file + * + * @return The encryption key + * @throws IOException When the key file could not be read or is invalid + */ + private byte[] readKey() throws IOException { + final byte[] storedKey = Files.readAllBytes(keyFile); + if (storedKey.length != KEY_LENGTH) + throw new IOException(String.format("Invalid proxy password key file (%s)", keyFile)); + + return storedKey; + } + + /** + * Create a new encryption key and store it in the key file + * + * @return The encryption key + * @throws IOException When the key file could not be created + */ + private byte[] createKey() throws IOException { + final Path parent = keyFile.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + + final byte[] generatedKey = new byte[KEY_LENGTH]; + secureRandom.nextBytes(generatedKey); + + try { + Files.write(keyFile, generatedKey, StandardOpenOption.CREATE_NEW); + } catch (final FileAlreadyExistsException ex) { + return readKey(); + } + + try { + Files.setPosixFilePermissions(keyFile, PosixFilePermissions.fromString("rw-------")); + } catch (final IOException | UnsupportedOperationException ex) { + logger.warn("Unable to restrict the permissions of the proxy password key file", ex); + } + + return generatedKey; + } +} diff --git a/src/main/java/com/codedead/opal/utils/RunnableSiteOpener.java b/src/main/java/com/codedead/opal/utils/RunnableSiteOpener.java index b13d515..50178be 100644 --- a/src/main/java/com/codedead/opal/utils/RunnableSiteOpener.java +++ b/src/main/java/com/codedead/opal/utils/RunnableSiteOpener.java @@ -42,12 +42,13 @@ public void run() { } else { final String os = System.getProperty("os.name").toLowerCase(); - if (os.contains("win")) { - Runtime.getRuntime().exec(new String[]{"rundll32", "url.dll,FileProtocolHandler", url}); - } else if (os.contains("mac")) { - Runtime.getRuntime().exec(new String[]{"open", url}); - } else if (os.contains("nix") || os.contains("nux")) { - Runtime.getRuntime().exec(new String[]{"xdg-open", url}); + final String[] command = getCommand(os); + + //noinspection EmptyTryBlock + try (final Process process = new ProcessBuilder(command) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start()) { } } if (iRunnableHelper != null) { @@ -59,4 +60,24 @@ public void run() { } } } + + /** + * Get the command for opening a URL + * + * @param os The operating system + * @return The command for opening a URL + */ + private String [] getCommand(final String os) { + final String[] command; + if (os.contains("win")) { + command = new String[]{"rundll32", "url.dll,FileProtocolHandler", url}; + } else if (os.contains("mac")) { + command = new String[]{"open", url}; + } else if (os.contains("nix") || os.contains("nux")) { + command = new String[]{"xdg-open", url}; + } else { + throw new UnsupportedOperationException("Unsupported OS: " + os); + } + return command; + } } diff --git a/src/main/java/com/codedead/opal/utils/SharedVariables.java b/src/main/java/com/codedead/opal/utils/SharedVariables.java index 30fdf62..6b4f5ee 100644 --- a/src/main/java/com/codedead/opal/utils/SharedVariables.java +++ b/src/main/java/com/codedead/opal/utils/SharedVariables.java @@ -13,6 +13,7 @@ public final class SharedVariables { public static final String PROPERTIES_RESOURCE_LOCATION = "default.properties"; public static final String PROPERTIES_FILE_LOCATION = PROPERTIES_BASE_PATH + "/opal.properties"; + public static final String PROXY_PASSWORD_KEY_FILE_LOCATION = PROPERTIES_BASE_PATH + "/proxy.key"; public static final String HELP_DOCUMENTATION_RESOURCE_LOCATION = "/documents/help.pdf"; public static final String HELP_DOCUMENTATION_FILE_LOCATION = PROPERTIES_BASE_PATH + "/help.pdf"; diff --git a/src/main/resources/default.properties b/src/main/resources/default.properties index e0bae84..28be88b 100644 --- a/src/main/resources/default.properties +++ b/src/main/resources/default.properties @@ -1,6 +1,12 @@ locale=en-US autoUpdate=true updateApi=https://codedead.com/Software/Opal/version.json +updateProxyEnabled=false +updateProxyHost= +updateProxyPort=8080 +updateProxyAuthEnabled=false +updateProxyUsername= +updateProxyPassword= loglevel=ERROR timerDelay=3600000 timerDelayType=2 diff --git a/src/main/resources/translations/OpalApplication.properties b/src/main/resources/translations/OpalApplication.properties index 257ee3c..4e43d4e 100644 --- a/src/main/resources/translations/OpalApplication.properties +++ b/src/main/resources/translations/OpalApplication.properties @@ -36,7 +36,6 @@ ResetSettingsError=Unable to reset all settings! Save=Save SaveSettingsError=Unable to save settings! SaveSoundPreset=Save sound settings -SaveSoundPresetError=Unable to save the sound settings! Settings=Settings SettingsWindowError=Unable to open the Settings Window! SettingsWindowTitle=Opal - Settings @@ -106,3 +105,14 @@ AddCustomSound=Add custom sound OpenCustomSoundError=Unable to open custom sound! CustomSound=Custom sound Forest=Forest +UpdateProxy=Use a custom proxy for update checks +ProxyHost=Proxy host +ProxyPort=Proxy port +InvalidProxySettings=Please enter a valid proxy host and port (1-65535)! +ProxyAuthentication=Proxy authentication +ProxyUsername=Username +ProxyPassword=Password +InvalidProxyAuthentication=Please enter a proxy username and password! +Audio=Audio +Logging=Logging +HttpProxy=HTTP Proxy diff --git a/src/main/resources/translations/OpalApplication_de_DE.properties b/src/main/resources/translations/OpalApplication_de_DE.properties index 305df17..d46175a 100644 --- a/src/main/resources/translations/OpalApplication_de_DE.properties +++ b/src/main/resources/translations/OpalApplication_de_DE.properties @@ -10,7 +10,7 @@ Close=Schließen ConfirmReset=Sind Sie sicher, dass Sie alle Einstellungen zurücksetzen möchten? Donate=Spenden Exit=Beenden -File=Datei +File=_Datei FileExecutionError=Datei konnte nicht geöffnet werden! Fireplace=Kamin General=Allgemein @@ -21,7 +21,7 @@ Homepage=Webseite Language=Sprache License=Lizenz LicenseFileError=Lizenzdatei konnte nicht geöffnet werden! -LogLevel=Protokollierung +LogLevel=Protokollierungsstufe MainWindowTitle=Opal Nature=Natur NewUpdateAvailable=Version {v} ist verfügbar. Möchten Sie diese Version herunterladen? @@ -36,7 +36,6 @@ ResetSettingsError=Einstellungen konnten nicht zurückgesetzt werden! Save=Speichern SaveSettingsError=Einstellungen konnten nicht gespeichert werden! SaveSoundPreset=Toneinstellungen speichern -SaveSoundPresetError=Toneinstellungen konnten nicht gespeichert werden! Settings=Einstellungen SettingsWindowError=Einstellungen-Dialog konnte nicht geöffnet werden! SettingsWindowTitle=Opal - Einstellungen @@ -49,7 +48,7 @@ WebsiteError=Webseite konnte nicht geöffnet werden! Wind=Wind River=Fluss Clock=Uhr -Static=Statisch +Static=Statisches Rauschen Other=Sonstige Timer=Zeitsteuerung Enabled=Aktiviert @@ -72,7 +71,7 @@ TribalFestival=Stammesfest RugbyFootball=Rugby Fußball Sleepy=Schläfrig DrumTribalFestival=Stammesfest der Trommeln -Gong=Klingel +Gong=Gong MediaButtons=Medientasten DragDrop=Dateien ziehen und ablegen Theme=Thema @@ -106,3 +105,14 @@ AddCustomSound=Benutzerdefinierten Klang hinzufügen OpenCustomSoundError=Benutzerdefinierten Klang konnte nicht geöffnet werden! CustomSound=Benutzerdefinierter Klang Forest=Wald +UpdateProxy=Benutzerdefinierten Proxy für Update-Prüfungen verwenden +ProxyHost=Proxy-Host +ProxyPort=Proxy-Port +InvalidProxySettings=Bitte geben Sie einen gültigen Proxy-Host und Port (1-65535) ein! +ProxyAuthentication=Proxy-Authentifizierung +ProxyUsername=Benutzername +ProxyPassword=Passwort +InvalidProxyAuthentication=Bitte geben Sie einen Proxy-Benutzernamen und ein Passwort ein! +Audio=Audio +Logging=Protokollierung +HttpProxy=HTTP-Proxy diff --git a/src/main/resources/translations/OpalApplication_en_US.properties b/src/main/resources/translations/OpalApplication_en_US.properties index 257ee3c..4e43d4e 100644 --- a/src/main/resources/translations/OpalApplication_en_US.properties +++ b/src/main/resources/translations/OpalApplication_en_US.properties @@ -36,7 +36,6 @@ ResetSettingsError=Unable to reset all settings! Save=Save SaveSettingsError=Unable to save settings! SaveSoundPreset=Save sound settings -SaveSoundPresetError=Unable to save the sound settings! Settings=Settings SettingsWindowError=Unable to open the Settings Window! SettingsWindowTitle=Opal - Settings @@ -106,3 +105,14 @@ AddCustomSound=Add custom sound OpenCustomSoundError=Unable to open custom sound! CustomSound=Custom sound Forest=Forest +UpdateProxy=Use a custom proxy for update checks +ProxyHost=Proxy host +ProxyPort=Proxy port +InvalidProxySettings=Please enter a valid proxy host and port (1-65535)! +ProxyAuthentication=Proxy authentication +ProxyUsername=Username +ProxyPassword=Password +InvalidProxyAuthentication=Please enter a proxy username and password! +Audio=Audio +Logging=Logging +HttpProxy=HTTP Proxy diff --git a/src/main/resources/translations/OpalApplication_es_ES.properties b/src/main/resources/translations/OpalApplication_es_ES.properties index c3cd722..14e0bfd 100644 --- a/src/main/resources/translations/OpalApplication_es_ES.properties +++ b/src/main/resources/translations/OpalApplication_es_ES.properties @@ -6,7 +6,7 @@ AutoUpdate=Buscar actualizaciones automáticamente Birds=Aves Chatter=Charla CheckForUpdates=Buscar actualizaciones -Close=Cerca +Close=Cerrar ConfirmReset=¿Estás seguro de que quieres restablecer todos los ajustes? Donate=Donar Exit=Salida @@ -27,7 +27,7 @@ Nature=Naturaleza NewUpdateAvailable=La versión {v} ya está disponible. ¿Te gustaría descargar esta actualización? NoUpdateAvailable=¡No hay actualizaciones disponibles! Office=Oficina -OpenSoundPreset=Preajuste de sonido abierto +OpenSoundPreset=Abrir preajuste de sonido OpenSoundPresetError=¡No se puede abrir el preajuste de sonido! Phone=Teléfono Rain=Lluvia @@ -36,7 +36,6 @@ ResetSettingsError=¡No se pueden restablecer todas las configuraciones! Save=Guardar SaveSettingsError=¡No se puede guardar la configuración! SaveSoundPreset=Guardar configuración de sonido -SaveSoundPresetError=¡No se pueden guardar los ajustes de sonido! Settings=Ajustes SettingsWindowError=¡No se puede abrir la ventana de configuración! SettingsWindowTitle=Opal - Configuración @@ -49,7 +48,7 @@ WebsiteError=¡No se puede abrir el sitio web! Wind=Viento River=Río Clock=Reloj -Static=Estático +Static=Ruido estático Other=Otro Timer=Temporizador Enabled=Activado @@ -79,13 +78,13 @@ Theme=Tema Space=Espacio Restaurant=Restaurante Cancel=Cancelar -Display=Monitor +Display=Mostrar TrayIcon=Icono de bandeja TrayIconError=¡No se puede crear el icono de la bandeja! Ocean=Océano Train=Tren WhiteNoise=Ruido blanco -RadioFrequencyStatic=Estático de frecuencia de radio +RadioFrequencyStatic=Estática de radiofrecuencia PinkNoise=Ruido rosa BrownNoise=Ruido marrón TimerComputerShutdown=Apagar la computadora @@ -93,8 +92,8 @@ AudioBalance=Balance de audio Advanced=Avanzado Seagulls=Gaviotas BellTower=Campanario -SlowMetronome=Metronomo lento -FastMetronome=Metronomo rápido +SlowMetronome=Metrónomo lento +FastMetronome=Metrónomo rápido PlayPause=Reproducir / Pausa PlayPauseError=¡No se puede reproducir / pausar el sonido! Dolphins=Delfines @@ -106,3 +105,14 @@ AddCustomSound=Añadir sonido personalizado OpenCustomSoundError=¡No se puede abrir el sonido personalizado! CustomSound=Sonido personalizado Forest=Bosque +UpdateProxy=Usar un proxy personalizado para buscar actualizaciones +ProxyHost=Host del proxy +ProxyPort=Puerto del proxy +InvalidProxySettings=¡Introduzca un host y un puerto de proxy válidos (1-65535)! +ProxyAuthentication=Autenticación del proxy +ProxyUsername=Nombre de usuario +ProxyPassword=Contraseña +InvalidProxyAuthentication=¡Introduzca un nombre de usuario y una contraseña de proxy! +Audio=Audio +Logging=Registro +HttpProxy=Proxy HTTP diff --git a/src/main/resources/translations/OpalApplication_fr_FR.properties b/src/main/resources/translations/OpalApplication_fr_FR.properties index f6b21d2..f08067f 100644 --- a/src/main/resources/translations/OpalApplication_fr_FR.properties +++ b/src/main/resources/translations/OpalApplication_fr_FR.properties @@ -17,7 +17,7 @@ General=Général Help=Aide HelpFileError=Impossible d'ouvrir le fichier d'aide! HelpMenu=_Aide -Homepage=CodeDead +Homepage=Page d'accueil Language=Langue License=Licence LicenseFileError=Impossible d'ouvrir le fichier de licence! @@ -36,8 +36,7 @@ ResetSettingsError=Impossible de réinitialiser tous les paramètres! Save=Sauvegarder SaveSettingsError=Impossible d'enregistrer les paramètres! SaveSoundPreset=Enregistrer les paramètres sonores -SaveSoundPresetError=Impossible d'enregistrer les paramètres sonores! -Settings=Réglages +Settings=Paramètres SettingsWindowError=Impossible d'ouvrir la fenêtre des paramètres! SettingsWindowTitle=Opal - Paramètres Thunder=Tonnerre @@ -49,7 +48,7 @@ WebsiteError=Impossible d'ouvrir le site Web! Wind=Vent River=Rivière Clock=Horloge -Static=Bruit blanc +Static=Parasites Other=Autre Timer=Minuteur Enabled=Activé @@ -80,12 +79,12 @@ Space=L'espace Restaurant=Restaurant Cancel=Annuler Display=Affichage -TrayIcon=Icône de la barre +TrayIcon=Icône de la zone de notification TrayIconError=Impossible de créer l'icône de la barre ! Ocean=Océan Train=Train WhiteNoise=Bruit blanc -RadioFrequencyStatic=Bruit blanc de fréquence radio +RadioFrequencyStatic=Parasites radio PinkNoise=Bruit rose BrownNoise=Bruit brun TimerComputerShutdown=Éteindre l'ordinateur @@ -106,3 +105,14 @@ AddCustomSound=Ajouter un son personnalisé OpenCustomSoundError=Impossible d'ouvrir le son personnalisé! CustomSound=Son personnalisé Forest=Forêt +UpdateProxy=Utiliser un proxy personnalisé pour rechercher les mises à jour +ProxyHost=Hôte du proxy +ProxyPort=Port du proxy +InvalidProxySettings=Veuillez saisir un hôte et un port de proxy valides (1-65535) ! +ProxyAuthentication=Authentification du proxy +ProxyUsername=Nom d'utilisateur +ProxyPassword=Mot de passe +InvalidProxyAuthentication=Veuillez saisir un nom d'utilisateur et un mot de passe de proxy ! +Audio=Audio +Logging=Journalisation +HttpProxy=Proxy HTTP diff --git a/src/main/resources/translations/OpalApplication_hi_IN.properties b/src/main/resources/translations/OpalApplication_hi_IN.properties index 7f3547f..39ffb20 100644 --- a/src/main/resources/translations/OpalApplication_hi_IN.properties +++ b/src/main/resources/translations/OpalApplication_hi_IN.properties @@ -4,12 +4,12 @@ AboutWindowError=About विंडो खोलने में असमर् AboutWindowTitle=Opal - के बारे में AutoUpdate=अपडेट के लिए स्वचालित रूप से जाँच करें Birds=पक्षियों -Chatter=बकवास +Chatter=गपशप CheckForUpdates=अद्यतन के लिए जाँच -Close=बंद करना +Close=बंद करें ConfirmReset=क्या आप वाकई सभी सेटिंग्स रीसेट करना चाहते हैं? Donate=दान करें -Exit=बाहर निकलना +Exit=बाहर निकलें File=_फ़ाइल FileExecutionError=फ़ाइल खोलने में असमर्थ! Fireplace=चिमनी @@ -33,10 +33,9 @@ Phone=फ़ोन Rain=बारिश Reset=रीसेट ResetSettingsError=सभी सेटिंग्स रीसेट करने में असमर्थ! -Save=बचाना +Save=सहेजें SaveSettingsError=सेटिंग्स सहेजने में असमर्थ! SaveSoundPreset=ध्वनि सेटिंग सहेजें -SaveSoundPresetError=ध्वनि सेटिंग सहेजने में असमर्थ! Settings=समायोजन SettingsWindowError=सेटिंग्स विंडो खोलने में असमर्थ! SettingsWindowTitle=Opal - समायोजन @@ -51,7 +50,7 @@ River=नदी Clock=घड़ी Static=स्थिर Other=अन्य -Timer=घड़ी +Timer=टाइमर Enabled=सक्रिय Delay=देरी Seconds=सेकंड @@ -60,7 +59,7 @@ Hours=घंटे TimerDelayTooSmall=टाइमर विलंब 1 से कम नहीं हो सकता! Fantasy=कल्पना Fan=पंखा -TimerApplicationShutdown=बाहर निकलना Opal +TimerApplicationShutdown=Opal से बाहर निकलें Cave=गुफ़ा Frogs=मेंढक Zen=जेन @@ -75,10 +74,10 @@ DrumTribalFestival=ढोल आदिवासी त्यौहार Gong=घंटा MediaButtons=मीडिया बटन DragDrop=फ़ाइलें खींचें और छोड़ें -Theme=विषय +Theme=थीम Space=अंतरिक्ष Restaurant=रेस्टोरेंट -Cancel=रद्द करना +Cancel=रद्द करें Display=प्रदर्शन TrayIcon=ट्रे चिह्न TrayIconError=ट्रे आइकन बनाने में असमर्थ! @@ -106,3 +105,14 @@ AddCustomSound=कस्टम ध्वनि जोड़ें OpenCustomSoundError=कस्टम ध्वनि खोलने में असमर्थ! CustomSound=कस्टम ध्वनि Forest=जंगल +UpdateProxy=अपडेट जाँचने के लिए कस्टम प्रॉक्सी का उपयोग करें +ProxyHost=प्रॉक्सी होस्ट +ProxyPort=प्रॉक्सी पोर्ट +InvalidProxySettings=कृपया मान्य प्रॉक्सी होस्ट और पोर्ट (1-65535) दर्ज करें! +ProxyAuthentication=प्रॉक्सी प्रमाणीकरण +ProxyUsername=उपयोगकर्ता नाम +ProxyPassword=पासवर्ड +InvalidProxyAuthentication=कृपया प्रॉक्सी उपयोगकर्ता नाम और पासवर्ड दर्ज करें! +Audio=ऑडियो +Logging=लॉगिंग +HttpProxy=HTTP प्रॉक्सी diff --git a/src/main/resources/translations/OpalApplication_jp_JP.properties b/src/main/resources/translations/OpalApplication_ja_JP.properties similarity index 84% rename from src/main/resources/translations/OpalApplication_jp_JP.properties rename to src/main/resources/translations/OpalApplication_ja_JP.properties index 55d94e5..eaacb7c 100644 --- a/src/main/resources/translations/OpalApplication_jp_JP.properties +++ b/src/main/resources/translations/OpalApplication_ja_JP.properties @@ -1,6 +1,6 @@ About=このアプリについて AboutText=Opal は DeadLine によって作成されました\n\nオーディオ: ZapSplat.com\n画像: リミックス アイコン\nテーマ: AtlantaFX\nバージョン: 1.5.1\n\nCopyright © 2026 CodeDead -AboutWindowError=バージョン情報ウィンドウを開けません! +AboutWindowError=このアプリについてのウィンドウを開けません! AboutWindowTitle=Opal - このアプリについて AutoUpdate=アップデートを自動的に確認する Birds=鳥 @@ -36,7 +36,6 @@ ResetSettingsError=すべての設定をリセットできません! Save=保存 SaveSettingsError=設定を保存できません! SaveSoundPreset=サウンド設定を保存 -SaveSoundPresetError=サウンド設定を保存できません! Settings=設定 SettingsWindowError=設定ウィンドウを開けません! SettingsWindowTitle=Opal - 設定 @@ -49,7 +48,7 @@ WebsiteError=ウェブサイトを開けません! Wind=風 River=川 Clock=時計 -Static=静的 +Static=静的ノイズ Other=その他 Timer=タイマー Enabled=有効 @@ -79,7 +78,7 @@ Theme=テーマ Space=宇宙 Restaurant=レストラン Cancel=キャンセル -Display=画面 +Display=表示 TrayIcon=トレイアイコン TrayIconError=トレイ アイコンを作成できません! Ocean=海洋 @@ -106,3 +105,14 @@ AddCustomSound=カスタム サウンドを追加 OpenCustomSoundError=カスタム サウンドを開けません! CustomSound=カスタム サウンド Forest=森 +UpdateProxy=アップデートの確認にカスタムプロキシを使用する +ProxyHost=プロキシホスト +ProxyPort=プロキシポート +InvalidProxySettings=有効なプロキシホストとポート(1~65535)を入力してください! +ProxyAuthentication=プロキシ認証 +ProxyUsername=ユーザー名 +ProxyPassword=パスワード +InvalidProxyAuthentication=プロキシのユーザー名とパスワードを入力してください! +Audio=オーディオ +Logging=ログ +HttpProxy=HTTPプロキシ diff --git a/src/main/resources/translations/OpalApplication_nl_NL.properties b/src/main/resources/translations/OpalApplication_nl_NL.properties index 37ec88a..7def498 100644 --- a/src/main/resources/translations/OpalApplication_nl_NL.properties +++ b/src/main/resources/translations/OpalApplication_nl_NL.properties @@ -9,7 +9,7 @@ CheckForUpdates=Controleer op updates Close=Sluiten ConfirmReset=Weet u zeker dat u alle instellingen opnieuw wilt instellen? Donate=Doneer -Exit=Sluiten +Exit=Afsluiten File=_Bestand FileExecutionError=Kan het bestand niet openen! Fireplace=Open haard @@ -17,7 +17,7 @@ General=Algemeen Help=Help HelpFileError=Kan het helpbestand niet openen! HelpMenu=_Help -Homepage=CodeDead +Homepage=Startpagina Language=Taal License=Licentie LicenseFileError=Kan het licentiebestand niet openen! @@ -36,7 +36,6 @@ ResetSettingsError=Kan de instellingen niet resetten! Save=Opslaan SaveSettingsError=Kan instellingen niet opslaan! SaveSoundPreset=Sla geluidsinstellingen op -SaveSoundPresetError=Kan de geluidsinstellingen niet opslaan! Settings=Instellingen SettingsWindowError=Kan het instellingenvenster niet openen! SettingsWindowTitle=Opal - Instellingen @@ -80,14 +79,14 @@ Space=De ruimte Restaurant=Restaurant Cancel=Annuleren Display=Weergave -TrayIcon=Ladepictogram +TrayIcon=Systeemvakpictogram TrayIconError=Kan geen pictogram in het systeemvak maken! Ocean=Oceaan Train=Trein -WhiteNoise=Wit geluid +WhiteNoise=Witte ruis RadioFrequencyStatic=Radiofrequentie ruis -PinkNoise=Roze geluid -BrownNoise=Bruin geluid +PinkNoise=Roze ruis +BrownNoise=Bruine ruis TimerComputerShutdown=Computer afsluiten AudioBalance=Audio balans Advanced=Geavanceerd @@ -106,3 +105,14 @@ AddCustomSound=Aangepast geluid toevoegen OpenCustomSoundError=Kan aangepast geluid niet openen! CustomSound=Aangepast geluid Forest=Bos +UpdateProxy=Aangepaste proxy gebruiken om op updates te controleren +ProxyHost=Proxyhost +ProxyPort=Proxy-poort +InvalidProxySettings=Voer een geldige proxyhost en -poort in (1-65535)! +ProxyAuthentication=Proxy-authenticatie +ProxyUsername=Gebruikersnaam +ProxyPassword=Wachtwoord +InvalidProxyAuthentication=Voer een proxygebruikersnaam en wachtwoord in! +Audio=Audio +Logging=Logboekregistratie +HttpProxy=HTTP-proxy diff --git a/src/main/resources/translations/OpalApplication_ru_RU.properties b/src/main/resources/translations/OpalApplication_ru_RU.properties index e6b420c..e971695 100644 --- a/src/main/resources/translations/OpalApplication_ru_RU.properties +++ b/src/main/resources/translations/OpalApplication_ru_RU.properties @@ -1,12 +1,12 @@ -About=О +About=О программе AboutText=Opal был создан DeadLine\n\nАудио: ZapSplat.com\nИзображения: Remix Icon\nТема: AtlantaFX\nВерсия: 1.5.1\n\nАвторские права © 2026 CodeDead AboutWindowError=Не удается открыть окно «О программе»! -AboutWindowTitle=Opal - О компании +AboutWindowTitle=Opal - О программе AutoUpdate=Автоматически проверять наличие обновлений Birds=Птицы Chatter=болтовня CheckForUpdates=Проверить наличие обновлений -Close=Закрывать +Close=Закрыть ConfirmReset=Вы уверены, что хотите сбросить все настройки? Donate=Пожертвовать Exit=Выход @@ -31,12 +31,11 @@ OpenSoundPreset=Открыть пресет звука OpenSoundPresetError=Невозможно открыть пресет звука! Phone=Телефон Rain=Дождь -Reset=Перезагрузить +Reset=Сбросить ResetSettingsError=Невозможно сбросить все настройки! -Save=Сохранять +Save=Сохранить SaveSettingsError=Не удалось сохранить настройки! SaveSoundPreset=Сохранить настройки звука -SaveSoundPresetError=Не удалось сохранить настройки звука! Settings=Настройки SettingsWindowError=Не удается открыть окно настроек! SettingsWindowTitle=Opal — Настройки @@ -49,12 +48,12 @@ WebsiteError=Невозможно открыть сайт! Wind=Ветер River=Река Clock=Часы -Static=Статический +Static=Статический шум Other=Другой Timer=Таймер Enabled=Включено -Delay=Задерживать -Seconds=Второй(ые) +Delay=Задержка +Seconds=Секунды Minutes=Минута(ы) Hours=Часы TimerDelayTooSmall=Задержка таймера не может быть меньше 1! @@ -79,13 +78,13 @@ Theme=Тема Space=Космос Restaurant=Ресторан Cancel=Отмена -Display=Отображать +Display=Показать TrayIcon=Значок в трее TrayIconError=Невозможно создать иконку в трее! Ocean=Океан Train=Поезд WhiteNoise=Белый шум -RadioFrequencyStatic=Радиочастотный статический +RadioFrequencyStatic=Радиочастотные помехи PinkNoise=Розовый шум BrownNoise=Коричневый шум TimerComputerShutdown=Выключение компьютера @@ -106,3 +105,14 @@ AddCustomSound=Добавить пользовательский звук OpenCustomSoundError=Невозможно открыть пользовательский звук! CustomSound=Пользовательский звук Forest=Лес +UpdateProxy=Использовать собственный прокси для проверки обновлений +ProxyHost=Хост прокси +ProxyPort=Порт прокси +InvalidProxySettings=Укажите допустимый хост и порт прокси (1–65535)! +ProxyAuthentication=Аутентификация прокси +ProxyUsername=Имя пользователя +ProxyPassword=Пароль +InvalidProxyAuthentication=Укажите имя пользователя и пароль прокси! +Audio=Аудио +Logging=Журналирование +HttpProxy=HTTP-прокси diff --git a/src/main/resources/translations/OpalApplication_tr_TR.properties b/src/main/resources/translations/OpalApplication_tr_TR.properties index 40790b3..18cff52 100644 --- a/src/main/resources/translations/OpalApplication_tr_TR.properties +++ b/src/main/resources/translations/OpalApplication_tr_TR.properties @@ -6,9 +6,9 @@ AutoUpdate=Güncellemeleri otomatik olarak kontrol et Birds=Kuşlar Chatter=Gevezelik CheckForUpdates=Güncellemeleri kontrol et -Close=Kapalı +Close=Kapat ConfirmReset=Tüm ayarları sıfırlamak istediğinizden emin misiniz? -Donate=Bağış yapmak +Donate=Bağış Yap Exit=Çıkış File=_Dosya FileExecutionError=Dosya açılamıyor! @@ -33,10 +33,9 @@ Phone=Telefon Rain=Yağmur Reset=Sıfırla ResetSettingsError=Tüm ayarlar sıfırlanamıyor! -Save=Kaydetmek +Save=Kaydet SaveSettingsError=Ayarlar kaydedilemiyor! SaveSoundPreset=Ses ayarlarını kaydet -SaveSoundPresetError=Ses ayarları kaydedilemiyor! Settings=Ayarlar SettingsWindowError=Ayarlar Penceresi açılamıyor! SettingsWindowTitle=Opal - Ayarlar @@ -67,7 +66,7 @@ Zen=Zen Coffee=Kahve Zoo=Hayvanat bahçesi Audiences=İzleyiciler -NetworkingEvent=Ağ oluşturma olayı +NetworkingEvent=Networking etkinliği TribalFestival=Kabile festivali RugbyFootball=Ragbi futbolu Sleepy=Uykulu @@ -78,8 +77,8 @@ DragDrop=Dosyaları sürükleyip bırakın Theme=Tema Space=Uzay Restaurant=Restoran -Cancel=İptal etmek -Display=Görüntülemek +Cancel=İptal +Display=Göster TrayIcon=Tepsi ikonu TrayIconError=Tepsi simgesi oluşturulamıyor! Ocean=Okyanus @@ -96,7 +95,7 @@ BellTower=Çan kulesi SlowMetronome=Yavaş metronom FastMetronome=Hızlı metronom PlayPause=Oynat / Duraklat -PlayPauseError=Oynatma / Duraklatma düğmesi oluşturulamıyor! +PlayPauseError=Oynatma / duraklatma başarısız! Dolphins=Yunuslar RollerCoaster=Lunapark treni LargeCrowd=Kalabalık @@ -106,3 +105,14 @@ AddCustomSound=Özel ses ekle OpenCustomSoundError=Özel ses açılamıyor! CustomSound=Özel ses Forest=Orman +UpdateProxy=Güncellemeleri kontrol etmek için özel proxy kullan +ProxyHost=Proxy sunucusu +ProxyPort=Proxy bağlantı noktası +InvalidProxySettings=Lütfen geçerli bir proxy sunucusu ve bağlantı noktası (1-65535) girin! +ProxyAuthentication=Proxy kimlik doğrulaması +ProxyUsername=Kullanıcı adı +ProxyPassword=Parola +InvalidProxyAuthentication=Lütfen bir proxy kullanıcı adı ve parola girin! +Audio=Ses +Logging=Günlük kaydı +HttpProxy=HTTP proxy diff --git a/src/main/resources/translations/OpalApplication_uk_UA.properties b/src/main/resources/translations/OpalApplication_uk_UA.properties index bc53176..25e757d 100644 --- a/src/main/resources/translations/OpalApplication_uk_UA.properties +++ b/src/main/resources/translations/OpalApplication_uk_UA.properties @@ -36,7 +36,6 @@ ResetSettingsError=Неможливо скинути всі налаштуван Save=Збережіть SaveSettingsError=Не вдалося зберегти налаштування! SaveSoundPreset=Зберегти налаштування звуку -SaveSoundPresetError=Не вдалося зберегти налаштування звуку! Settings=Налаштування SettingsWindowError=Неможливо відкрити вікно налаштувань! SettingsWindowTitle=Opal - Налаштування @@ -49,7 +48,7 @@ WebsiteError=Неможливо відкрити сайт! Wind=Вітер River=Річка Clock=Годинник -Static=Статичний +Static=Статичний шум Other=Інше Timer=Таймер Enabled=Увімкнено @@ -60,7 +59,7 @@ Hours=Година(и) TimerDelayTooSmall=Затримка таймера не може бути менше 1! Fantasy=Фантазія Fan=Вентилятор -TimerApplicationShutdown=Вихід Opal +TimerApplicationShutdown=Вихід з Opal Cave=Печера Frogs=Жаби Zen=Дзен @@ -79,10 +78,10 @@ Theme=Тема Space=Простір Restaurant=Ресторан Cancel=Скасувати -Display=Дисплей +Display=Показати TrayIcon=Значок у треї TrayIconError=Не вдалося створити піктограму в треї! -Ocean=Океану +Ocean=Океан Train=Потяг WhiteNoise=Білий шум RadioFrequencyStatic=Радіочастотна статика @@ -90,7 +89,7 @@ PinkNoise=Рожевий шум BrownNoise=Коричневий шум TimerComputerShutdown=Вимкніть комп'ютер AudioBalance=Аудіобаланс -Advanced=Просунутий +Advanced=Розширений Seagulls=Чайки BellTower=Дзвіниця SlowMetronome=Метроном (повільний) @@ -106,3 +105,14 @@ AddCustomSound=Додати користувацький звук OpenCustomSoundError=Не вдалося відкрити користувацький звук! CustomSound=Користувацький звук Forest=Ліс +UpdateProxy=Використовувати власний проксі для перевірки оновлень +ProxyHost=Хост проксі +ProxyPort=Порт проксі +InvalidProxySettings=Вкажіть дійсний хост і порт проксі (1–65535)! +ProxyAuthentication=Аутентифікація проксі +ProxyUsername=Ім'я користувача +ProxyPassword=Пароль +InvalidProxyAuthentication=Вкажіть ім'я користувача та пароль проксі! +Audio=Аудіо +Logging=Журналювання +HttpProxy=HTTP-проксі diff --git a/src/main/resources/translations/OpalApplication_zh_CN.properties b/src/main/resources/translations/OpalApplication_zh_CN.properties index 6f36be1..e9dbff6 100644 --- a/src/main/resources/translations/OpalApplication_zh_CN.properties +++ b/src/main/resources/translations/OpalApplication_zh_CN.properties @@ -36,7 +36,6 @@ ResetSettingsError=无法重置所有设置! Save=保存 SaveSettingsError=无法保存设置! SaveSoundPreset=保存声音设置 -SaveSoundPresetError=无法保存声音设置! Settings=设置 SettingsWindowError=无法打开设置窗口! SettingsWindowTitle=Opal - 设置 @@ -67,13 +66,13 @@ Zen=禅 Coffee=咖啡 Zoo=动物园 Audiences=人群 -NetworkingEvent=交谈 +NetworkingEvent=社交活动 TribalFestival=部落庆典 RugbyFootball=橄榄球比赛 Sleepy=困倦 DrumTribalFestival=部落鼓乐 Gong=锣 -MediaButtons=播放按钮 +MediaButtons=媒体按钮 DragDrop=拖放文件 Theme=主题 Space=太空 @@ -85,9 +84,9 @@ TrayIconError=无法创建托盘图标! Ocean=海洋 Train=火车 WhiteNoise=白噪音 -RadioFrequencyStatic=无线电 -PinkNoise=粉噪音 -BrownNoise=棕噪音 +RadioFrequencyStatic=无线电静电噪声 +PinkNoise=粉红噪声 +BrownNoise=布朗噪声 TimerComputerShutdown=关闭电脑 AudioBalance=音频平衡 Advanced=高级 @@ -106,3 +105,14 @@ AddCustomSound=添加自定义声音 OpenCustomSoundError=无法打开自定义声音! CustomSound=自定义声音 Forest=森林 +UpdateProxy=使用自定义代理检查更新 +ProxyHost=代理主机 +ProxyPort=代理端口 +InvalidProxySettings=请输入有效的代理主机和端口(1-65535)! +ProxyAuthentication=代理身份验证 +ProxyUsername=用户名 +ProxyPassword=密码 +InvalidProxyAuthentication=请输入代理用户名和密码! +Audio=音频 +Logging=日志 +HttpProxy=HTTP 代理 diff --git a/src/main/resources/windows/SettingsWindow.fxml b/src/main/resources/windows/SettingsWindow.fxml index d58d92b..ff15149 100644 --- a/src/main/resources/windows/SettingsWindow.fxml +++ b/src/main/resources/windows/SettingsWindow.fxml @@ -9,6 +9,8 @@ + + @@ -16,6 +18,8 @@ + + - + - - + + @@ -183,55 +187,155 @@ - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/com/codedead/opal/controller/LanguageControllerTest.java b/src/test/java/com/codedead/opal/controller/LanguageControllerTest.java new file mode 100644 index 0000000..ca962a3 --- /dev/null +++ b/src/test/java/com/codedead/opal/controller/LanguageControllerTest.java @@ -0,0 +1,27 @@ +package com.codedead.opal.controller; + +import org.junit.jupiter.api.Test; + +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LanguageControllerTest { + + @Test + void mapsJapaneseLanguageTagsToTheSameIndex() { + assertEquals(5, LanguageController.getLanguageIndexFromLocale("ja-JP")); + assertEquals(5, LanguageController.getLanguageIndexFromLocale("jp-JP")); + } + + @Test + void returnsStandardJapaneseTagFromIndex() { + assertEquals("ja-JP", LanguageController.getLocaleFromLanguageIndex(5)); + } + + @Test + void migratesLegacyJapaneseTagToStandardLocale() { + assertEquals(Locale.forLanguageTag("ja-JP"), LanguageController.getLocaleFromLanguageTag("ja-JP")); + assertEquals(Locale.forLanguageTag("ja-JP"), LanguageController.getLocaleFromLanguageTag("jp-JP")); + } +} diff --git a/src/test/java/com/codedead/opal/controller/UpdateControllerTest.java b/src/test/java/com/codedead/opal/controller/UpdateControllerTest.java new file mode 100644 index 0000000..68912c0 --- /dev/null +++ b/src/test/java/com/codedead/opal/controller/UpdateControllerTest.java @@ -0,0 +1,78 @@ +package com.codedead.opal.controller; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class UpdateControllerTest { + + private static final String UPDATE_URL = "https://example.com/updates.json"; + private static final String CURRENT_VERSION = "1.0.0.0"; + + @Test + void rejectsNullProxyHostWhenEnabled() { + final UpdateController updateController = new UpdateController(UPDATE_URL, CURRENT_VERSION); + + assertThrows(NullPointerException.class, () -> updateController.setProxy(true, null, 8080)); + } + + @Test + void rejectsBlankProxyHostWhenEnabled() { + final UpdateController updateController = new UpdateController(UPDATE_URL, CURRENT_VERSION); + + assertThrows(IllegalArgumentException.class, () -> updateController.setProxy(true, " ", 8080)); + } + + @Test + void rejectsInvalidProxyPort() { + final UpdateController updateController = new UpdateController(UPDATE_URL, CURRENT_VERSION); + + assertThrows(IllegalArgumentException.class, () -> updateController.setProxy(true, "proxy.example.com", 0)); + assertThrows(IllegalArgumentException.class, () -> updateController.setProxy(true, "proxy.example.com", 65536)); + } + + @Test + void allowsDisabledProxyWithoutConfiguration() { + final UpdateController updateController = new UpdateController(UPDATE_URL, CURRENT_VERSION); + + assertDoesNotThrow(() -> updateController.setProxy(false, null, 0)); + } + + @Test + void acceptsValidProxyConfiguration() { + final UpdateController updateController = new UpdateController(UPDATE_URL, CURRENT_VERSION); + + assertDoesNotThrow(() -> updateController.setProxy(true, "proxy.example.com", 8080)); + } + + @Test + void rejectsNullOrBlankUsernameWhenAuthenticationIsEnabled() { + final UpdateController updateController = new UpdateController(UPDATE_URL, CURRENT_VERSION); + + assertThrows(NullPointerException.class, () -> updateController.setProxyAuthentication(true, null, "password")); + assertThrows(IllegalArgumentException.class, () -> updateController.setProxyAuthentication(true, " ", "password")); + } + + @Test + void rejectsNullOrEmptyPasswordWhenAuthenticationIsEnabled() { + final UpdateController updateController = new UpdateController(UPDATE_URL, CURRENT_VERSION); + + assertThrows(NullPointerException.class, () -> updateController.setProxyAuthentication(true, "username", null)); + assertThrows(IllegalArgumentException.class, () -> updateController.setProxyAuthentication(true, "username", "")); + } + + @Test + void allowsDisabledAuthenticationWithoutCredentials() { + final UpdateController updateController = new UpdateController(UPDATE_URL, CURRENT_VERSION); + + assertDoesNotThrow(() -> updateController.setProxyAuthentication(false, null, null)); + } + + @Test + void acceptsValidProxyAuthentication() { + final UpdateController updateController = new UpdateController(UPDATE_URL, CURRENT_VERSION); + + assertDoesNotThrow(() -> updateController.setProxyAuthentication(true, "username", "password")); + } +} diff --git a/src/test/java/com/codedead/opal/utils/PasswordCipherTest.java b/src/test/java/com/codedead/opal/utils/PasswordCipherTest.java new file mode 100644 index 0000000..cca5088 --- /dev/null +++ b/src/test/java/com/codedead/opal/utils/PasswordCipherTest.java @@ -0,0 +1,112 @@ +package com.codedead.opal.utils; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFileAttributeView; +import java.security.GeneralSecurityException; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +class PasswordCipherTest { + + @TempDir + private Path tempDirectory; + + @Test + void encryptsAndDecryptsPassword() throws IOException, GeneralSecurityException { + final PasswordCipher passwordCipher = new PasswordCipher(tempDirectory.resolve("proxy.key")); + + final String encrypted = passwordCipher.encrypt("secret"); + + assertTrue(encrypted.startsWith("enc:v1:")); + assertNotEquals("secret", encrypted); + assertEquals("secret", passwordCipher.decrypt(encrypted)); + } + + @Test + void usesRandomIvForEachEncryption() throws IOException, GeneralSecurityException { + final PasswordCipher passwordCipher = new PasswordCipher(tempDirectory.resolve("proxy.key")); + + assertNotEquals(passwordCipher.encrypt("secret"), passwordCipher.encrypt("secret")); + } + + @Test + void returnsLegacyPlaintextValues() throws IOException, GeneralSecurityException { + final PasswordCipher passwordCipher = new PasswordCipher(tempDirectory.resolve("proxy.key")); + + assertEquals("legacy", passwordCipher.decrypt("legacy")); + } + + @Test + void encryptsAndDecryptsEmptyValues() throws IOException, GeneralSecurityException { + final PasswordCipher passwordCipher = new PasswordCipher(tempDirectory.resolve("proxy.key")); + + assertEquals("", passwordCipher.encrypt("")); + assertEquals("", passwordCipher.decrypt("")); + } + + @Test + void rejectsTamperedCiphertext() throws IOException, GeneralSecurityException { + final PasswordCipher passwordCipher = new PasswordCipher(tempDirectory.resolve("proxy.key")); + final String encrypted = passwordCipher.encrypt("secret"); + + final byte[] payload = Base64.getDecoder().decode(encrypted.substring("enc:v1:".length())); + payload[payload.length - 1] ^= 1; + + final String tampered = "enc:v1:" + Base64.getEncoder().encodeToString(payload); + + assertThrows(GeneralSecurityException.class, () -> passwordCipher.decrypt(tampered)); + } + + @Test + void rejectsMalformedCiphertext() { + final PasswordCipher passwordCipher = new PasswordCipher(tempDirectory.resolve("proxy.key")); + + assertThrows(GeneralSecurityException.class, () -> passwordCipher.decrypt("enc:v1:not-base64!")); + } + + @Test + void reusesTheStoredKey() throws IOException, GeneralSecurityException { + final Path keyFile = tempDirectory.resolve("proxy.key"); + final PasswordCipher firstCipher = new PasswordCipher(keyFile); + final String encrypted = firstCipher.encrypt("secret"); + + final PasswordCipher secondCipher = new PasswordCipher(keyFile); + + assertTrue(Files.exists(keyFile)); + assertEquals("secret", secondCipher.decrypt(encrypted)); + } + + @Test + void failsToDecryptWhenTheKeyFileIsMissing() throws IOException, GeneralSecurityException { + final Path keyFile = tempDirectory.resolve("proxy.key"); + final String encrypted = new PasswordCipher(keyFile).encrypt("secret"); + Files.delete(keyFile); + + final PasswordCipher passwordCipher = new PasswordCipher(keyFile); + + assertThrows(IOException.class, () -> passwordCipher.decrypt(encrypted)); + assertTrue(Files.notExists(keyFile)); + } + + @Test + void storesTheKeyWithOwnerOnlyPermissions() throws IOException, GeneralSecurityException { + final Path keyFile = tempDirectory.resolve("proxy.key"); + new PasswordCipher(keyFile).encrypt("secret"); + + assumeTrue(Files.getFileStore(keyFile).supportsFileAttributeView(PosixFileAttributeView.class)); + + final String permissions = Files.getPosixFilePermissions(keyFile).toString(); + + assertEquals("[OWNER_READ, OWNER_WRITE]", permissions); + } +}