package ctbrec; import com.squareup.moshi.JsonAdapter; import com.squareup.moshi.Moshi; import ctbrec.io.*; import ctbrec.recorder.postprocessing.PostProcessor; import ctbrec.sites.Site; import ctbrec.sites.chaturbate.ChaturbateModel; import org.apache.commons.io.FileUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.time.Instant; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.format.FormatStyle; import java.util.*; import java.util.stream.Collectors; import static java.nio.charset.StandardCharsets.UTF_8; import static java.nio.file.StandardOpenOption.*; // TODO don't use singleton pattern public class Config { private static final Logger LOG = LoggerFactory.getLogger(Config.class); private static final String SYSPROP_CONFIG_DIR = "ctbrec.config.dir"; private static final String V_4_7_5 = "4.7.5"; private static Config instance; private Settings settings; private final String filename; private final List sites; private final File configDir; /** * to temporarily disable saving of the config * this is useful for the SettingsTab, because setting the initial values of some components causes an immediate save */ private boolean savingDisabled = false; public static final String RECORDING_DATE_FORMAT = "yyyy-MM-dd_HH-mm-ss_SSS"; public Config(List sites) throws IOException { this.sites = sites; copyConfigIfNewVersion(); if (System.getProperty(SYSPROP_CONFIG_DIR) != null) { configDir = new File(System.getProperty(SYSPROP_CONFIG_DIR), Version.getVersion().toString()); } else { configDir = new File(OS.getConfigDir(), Version.getVersion().toString()); } backupConfig(configDir); if (System.getProperty("ctbrec.config") != null) { filename = System.getProperty("ctbrec.config"); } else { filename = "settings.json"; } } private void backupConfig(File currentConfigDir) throws IOException { DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(RECORDING_DATE_FORMAT); File src = currentConfigDir; if (src.exists()) { File target = new File(src.getParentFile(), src.getName() + "_backup_" + dateTimeFormatter.format(LocalDateTime.now())); LOG.info("Creating a backup of {} the config in {}", src, target); FileUtils.copyDirectory(src, target, pathname -> !(pathname.toString().contains("minimal-browser") && pathname.toString().contains("Cache")), true); deleteOldBackups(currentConfigDir); } } private void deleteOldBackups(File currentConfigDir) { File parent = currentConfigDir.getParentFile(); File[] backupDirectories = parent.listFiles(file -> file.isDirectory() && file.getName().matches(".*?_backup_\\d{4}-\\d{2}-\\d{2}_\\d{2}-\\d{2}-\\d{2}_\\d{3}")); Arrays.sort(backupDirectories, Comparator.comparing(File::getName)); for (int i = 0; i < backupDirectories.length - 5; i++) { File dirToDelete = backupDirectories[i]; try { LOG.info("Delete old config backup {}", dirToDelete); IoUtils.deleteDirectory(dirToDelete); } catch (IOException e) { LOG.error("Couldn't delete old config backup {}. You might have to delete it manually.", dirToDelete, e); } } } private void copyConfigIfNewVersion() throws IOException { File configDirectory; if (System.getProperty(SYSPROP_CONFIG_DIR) != null) { configDirectory = new File(System.getProperty(SYSPROP_CONFIG_DIR)); } else { configDirectory = OS.getConfigDir(); } Version currentVersion = Version.getVersion(); Version previousVersion = getPreviousVersion(configDirectory); File src; File target = new File(configDirectory, currentVersion.toString()); if (target.exists()) { return; } if (previousVersion.compareTo(Version.of(V_4_7_5)) <= 0) { src = configDirectory; } else { src = new File(configDirectory, previousVersion.toString()); } if (!src.exists()) { // new installation return; } if (!Objects.equals(previousVersion, currentVersion)) { LOG.debug("Version update {} -> {}", previousVersion, currentVersion); LOG.debug("Copying config from {} to {}", src, target); FileUtils.copyDirectory(src, target, pathname -> !(pathname.toString().contains("minimal-browser") && pathname.toString().contains("Cache")), true); } } private Version getPreviousVersion(File configDirectory) { File[] versionDirectories = configDirectory.listFiles((dir, name) -> name.matches("\\d+\\.\\d+\\.\\d+")); if (versionDirectories != null) { Optional previousVersion = Arrays.stream(versionDirectories) .map(File::getName) .map(Version::of) .max(Comparator.naturalOrder()); return previousVersion.orElse(Version.of(V_4_7_5)); } else { return Version.of(V_4_7_5); } } private void load() throws IOException { Moshi moshi = new Moshi.Builder() .add(Model.class, new ModelJsonAdapter(sites)) .add(PostProcessor.class, new PostProcessorJsonAdapter()) .add(File.class, new FileJsonAdapter()) .add(UUID.class, new UuidJSonAdapter()) .add(LocalTime.class, new LocalTimeJsonAdapter()) .build(); JsonAdapter adapter = moshi.adapter(Settings.class).lenient(); File configFile = new File(configDir, filename); LOG.info("Loading config from {}", configFile.getAbsolutePath()); if (configFile.exists()) { try { byte[] fileContent = Files.readAllBytes(configFile.toPath()); if (fileContent[0] == -17 && fileContent[1] == -69 && fileContent[2] == -65) { // found BOM (byte order mark) LOG.debug("Removing BOM from config file"); fileContent[0] = ' '; fileContent[1] = ' '; fileContent[2] = ' '; } String json = new String(fileContent, UTF_8).trim(); settings = Objects.requireNonNull(adapter.fromJson(json)); settings.httpTimeout = Math.max(settings.httpTimeout, 10_000); if (settings.recordingsDir.endsWith("/")) { settings.recordingsDir = settings.recordingsDir.substring(0, settings.recordingsDir.length() - 1); } } catch (Exception e) { settings = OS.getDefaultSettings(); for (Site site : sites) { site.setEnabled(!settings.disabledSites.contains(site.getName())); } throw e; } } else { LOG.error("Config file does not exist. Falling back to default values."); settings = OS.getDefaultSettings(); } for (Site site : sites) { site.setEnabled(!settings.disabledSites.contains(site.getName())); } migrateOldSettings(); } private void migrateOldSettings() { // 5.0.0 convertChaturbateModelNamesToLowerCase(); } private void convertChaturbateModelNamesToLowerCase() { final String CTB = "chaturbate.com"; // convert model notes Map convertedModelNotes = new HashMap<>(); getSettings().modelNotes.forEach((key, value) -> { if (key.contains(CTB)) { convertedModelNotes.put(key.toLowerCase(), value); } else { convertedModelNotes.put(key, value); } }); getSettings().modelNotes.clear(); getSettings().modelNotes.putAll(convertedModelNotes); // convert model portraits Map convertedModelPortraits = new HashMap<>(); getSettings().modelPortraits.forEach((key, value) -> { if (key.contains(CTB)) { convertedModelPortraits.put(key.toLowerCase(), value); } else { convertedModelPortraits.put(key, value); } }); getSettings().modelPortraits.clear(); getSettings().modelPortraits.putAll(convertedModelPortraits); // convert model groups getSettings().modelGroups.forEach(mg -> mg.setModelUrls(mg.getModelUrls().stream() .filter(Objects::nonNull) .map(url -> url.contains(CTB) ? url.toLowerCase() : url) .collect(Collectors.toList()))); // NOSONAR - has to be mutable // convert ignored models getSettings().ignoredModels = getSettings().ignoredModels.stream() .filter(Objects::nonNull) .map(url -> url.contains(CTB) ? url.toLowerCase() : url) .collect(Collectors.toList()); // NOSONAR - has to be mutable // change the model objects getSettings().models.stream() .filter(ChaturbateModel.class::isInstance) .filter(m -> m.getUrl() != null) .forEach(m -> { m.setDisplayName(m.getName()); m.setName(m.getName().toLowerCase()); m.setUrl(m.getUrl().toLowerCase()); }); getSettings().recordLater.stream() .filter(ChaturbateModel.class::isInstance) .filter(m -> m.getUrl() != null) .forEach(m -> { m.setDisplayName(m.getName()); m.setName(m.getName().toLowerCase()); m.setUrl(m.getUrl().toLowerCase()); }); } public static synchronized void init(List sites) throws IOException { if (instance == null) { instance = new Config(sites); instance.load(); } } public static synchronized Config getInstance() { if (instance == null) { throw new IllegalStateException("Config not initialized, call init() first"); } return instance; } public Settings getSettings() { return settings; } public synchronized void save() throws IOException { if (savingDisabled) { return; } Moshi moshi = new Moshi.Builder() .add(Model.class, new ModelJsonAdapter()) .add(PostProcessor.class, new PostProcessorJsonAdapter()) .add(File.class, new FileJsonAdapter()) .add(UUID.class, new UuidJSonAdapter()) .add(LocalTime.class, new LocalTimeJsonAdapter()) .build(); JsonAdapter adapter = moshi.adapter(Settings.class).indent(" "); String json = adapter.toJson(settings); File configFile = new File(configDir, filename); LOG.debug("Saving config to {}", configFile.getAbsolutePath()); Files.createDirectories(configDir.toPath()); Files.writeString(configFile.toPath(), json, CREATE, WRITE, TRUNCATE_EXISTING); } public static boolean isServerMode() { return Objects.equals(System.getProperty("ctbrec.server.mode"), "1"); } public static boolean isDevMode() { return Objects.equals(System.getenv("CTBREC_DEV"), "1"); } public File getConfigDir() { return configDir; } public File getFileForRecording(Model model, String suffix, Instant startTime) { DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(RECORDING_DATE_FORMAT); LocalDateTime startDateTime = LocalDateTime.ofInstant(startTime, ZoneId.systemDefault()); String formattedDate = dateTimeFormatter.format(startDateTime); File dirForRecording = getDirForRecording(model, formattedDate); File targetFile = new File(dirForRecording, model.getSanitizedNamed() + '_' + formattedDate + '.' + suffix); return targetFile; } private File getDirForRecording(Model model, String formattedDate) { switch (getSettings().recordingsDirStructure) { case ONE_PER_MODEL -> { return new File(getSettings().recordingsDir, model.getSanitizedNamed()); } case ONE_PER_RECORDING -> { File modelDir = new File(getSettings().recordingsDir, model.getSanitizedNamed()); return new File(modelDir, formattedDate); } default -> { return new File(getSettings().recordingsDir); } } } public String getServerUrl() { String scheme = getSettings().transportLayerSecurity ? "https" : "http"; int port = getSettings().httpPort; String baseUrl = scheme + "://" + getSettings().httpServer + ":" + port + getContextPath(); return baseUrl; } public String getContextPath() { String context = Optional.ofNullable(getSettings().servletContext).orElse(""); if (!context.startsWith("/") && !context.isEmpty()) { context = '/' + context; } if (context.endsWith("/")) { context = context.substring(0, context.length() - 1); } return context; } public DateTimeFormatter getDateTimeFormatter() { var dtf = getSettings().dateTimeFormat; if (StringUtil.isNotBlank(dtf)) { return DateTimeFormatter.ofPattern(dtf); } else { return DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT); } } // public String getModelNotes(Model m) { // return Config.getInstance().getSettings().modelNotes.getOrDefault(m.getUrl(), ""); // } public void disableSaving() { savingDisabled = true; } public void enableSaving() { savingDisabled = false; } public boolean isIgnored(Model model) { List ignored = Config.getInstance().getSettings().ignoredModels; return ignored.contains(model.getUrl()); } }