Config rework (#2823)

# Description of Changes

Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: a <a>
This commit is contained in:
Anthony Stirling
2025-01-31 11:00:03 +00:00
committed by GitHub
parent 3dd8b53f85
commit 382f5603a8
7 changed files with 544 additions and 199 deletions

View File

@@ -9,135 +9,200 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Arrays;
import java.util.List;
import org.simpleyaml.configuration.comments.CommentType;
import org.simpleyaml.configuration.file.YamlFile;
import org.simpleyaml.configuration.implementation.SimpleYamlImplementation;
import org.simpleyaml.configuration.implementation.snakeyaml.lib.DumperOptions;
import java.util.*;
import lombok.extern.slf4j.Slf4j;
/**
* A naive, line-based approach to merging "settings.yml" with "settings.yml.template" while
* preserving exact whitespace, blank lines, and inline comments -- but we only rewrite the file if
* the merged content actually differs.
*/
@Slf4j
public class ConfigInitializer {
public void ensureConfigExists() throws IOException, URISyntaxException {
// Define the path to the external config directory
// 1) If settings file doesn't exist, create from template
Path destPath = Paths.get(InstallationPathConfig.getSettingsPath());
// Check if the file already exists
if (Files.notExists(destPath)) {
// Ensure the destination directory exists
Files.createDirectories(destPath.getParent());
// Copy the resource from classpath to the external directory
try (InputStream in =
getClass().getClassLoader().getResourceAsStream("settings.yml.template")) {
if (in != null) {
Files.copy(in, destPath);
} else {
if (in == null) {
throw new FileNotFoundException(
"Resource file not found: settings.yml.template");
}
Files.copy(in, destPath);
}
log.info("Created settings file from template");
} else {
// Define the path to the config settings file
// 2) Merge existing file with the template
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
// Load the template resource
URL settingsTemplateResource =
getClass().getClassLoader().getResource("settings.yml.template");
if (settingsTemplateResource == null) {
URL templateResource = getClass().getClassLoader().getResource("settings.yml.template");
if (templateResource == null) {
throw new IOException("Resource not found: settings.yml.template");
}
// Create a temporary file to copy the resource content
// Copy template to a temp location so we can read lines
Path tempTemplatePath = Files.createTempFile("settings.yml", ".template");
try (InputStream in = settingsTemplateResource.openStream()) {
try (InputStream in = templateResource.openStream()) {
Files.copy(in, tempTemplatePath, StandardCopyOption.REPLACE_EXISTING);
}
final YamlFile settingsTemplateFile = new YamlFile(tempTemplatePath.toFile());
DumperOptions yamlOptionsSettingsTemplateFile =
((SimpleYamlImplementation) settingsTemplateFile.getImplementation())
.getDumperOptions();
yamlOptionsSettingsTemplateFile.setSplitLines(false);
settingsTemplateFile.loadWithComments();
// 2a) Read lines from both files
List<String> templateLines = Files.readAllLines(tempTemplatePath);
List<String> mainLines = Files.readAllLines(settingsPath);
final YamlFile settingsFile = new YamlFile(settingsPath.toFile());
DumperOptions yamlOptionsSettingsFile =
((SimpleYamlImplementation) settingsFile.getImplementation())
.getDumperOptions();
yamlOptionsSettingsFile.setSplitLines(false);
settingsFile.loadWithComments();
// 2b) Merge lines
List<String> mergedLines = mergeYamlLinesWithTemplate(templateLines, mainLines);
// Load headers and comments
String header = settingsTemplateFile.getHeader();
// Create a new file for temporary settings
final YamlFile tempSettingFile = new YamlFile(settingsPath.toFile());
DumperOptions yamlOptionsTempSettingFile =
((SimpleYamlImplementation) tempSettingFile.getImplementation())
.getDumperOptions();
yamlOptionsTempSettingFile.setSplitLines(false);
tempSettingFile.createNewFile(true);
tempSettingFile.setHeader(header);
// Get all keys from the template
List<String> keys =
Arrays.asList(settingsTemplateFile.getKeys(true).toArray(new String[0]));
for (String key : keys) {
if (!key.contains(".")) {
// Add blank lines and comments to specific sections
tempSettingFile
.path(key)
.comment(settingsTemplateFile.getComment(key))
.blankLine();
continue;
}
// Copy settings from the template to the settings.yml file
changeConfigItemFromCommentToKeyValue(
settingsTemplateFile, settingsFile, tempSettingFile, key);
// 2c) Only write if there's an actual difference
if (!mergedLines.equals(mainLines)) {
Files.write(settingsPath, mergedLines);
log.info("Settings file updated based on template changes.");
} else {
log.info("No changes detected; settings file left as-is.");
}
// Save the settings.yml file
tempSettingFile.save();
Files.deleteIfExists(tempTemplatePath);
}
// Create custom settings file if it doesn't exist
// 3) Ensure custom settings file exists
Path customSettingsPath = Paths.get(InstallationPathConfig.getCustomSettingsPath());
if (!Files.exists(customSettingsPath)) {
Files.createFile(customSettingsPath);
}
}
private void changeConfigItemFromCommentToKeyValue(
final YamlFile settingsTemplateFile,
final YamlFile settingsFile,
final YamlFile tempSettingFile,
String path) {
if (settingsFile.get(path) == null && settingsTemplateFile.get(path) != null) {
// If the key is only in the template, add it to the temporary settings with comments
tempSettingFile
.path(path)
.set(settingsTemplateFile.get(path))
.comment(settingsTemplateFile.getComment(path, CommentType.BLOCK))
.commentSide(settingsTemplateFile.getComment(path, CommentType.SIDE));
} else if (settingsFile.get(path) != null && settingsTemplateFile.get(path) != null) {
// If the key is in both, update the temporary settings with the main settings' value
// and comments
tempSettingFile
.path(path)
.set(settingsFile.get(path))
.comment(settingsTemplateFile.getComment(path, CommentType.BLOCK))
.commentSide(settingsTemplateFile.getComment(path, CommentType.SIDE));
} else {
// Log if the key is not found in both YAML files
log.info("Key not found in both YAML files: " + path);
/**
* Merge logic that: - Reads the template lines block-by-block (where a "block" = a key and all
* the lines that belong to it), - If the main file has that key, we keep the main file's block
* (preserving whitespace + inline comments). - Otherwise, we insert the template's block. - We
* also remove keys from main that no longer exist in the template.
*
* @param templateLines lines from settings.yml.template
* @param mainLines lines from the existing settings.yml
* @return merged lines
*/
private List<String> mergeYamlLinesWithTemplate(
List<String> templateLines, List<String> mainLines) {
// 1) Parse template lines into an ordered map: path -> Block
LinkedHashMap<String, Block> templateBlocks = parseYamlBlocks(templateLines);
// 2) Parse main lines into a map: path -> Block
LinkedHashMap<String, Block> mainBlocks = parseYamlBlocks(mainLines);
// 3) Build the final list by iterating template blocks in order
List<String> merged = new ArrayList<>();
for (Map.Entry<String, Block> entry : templateBlocks.entrySet()) {
String path = entry.getKey();
Block templateBlock = entry.getValue();
if (mainBlocks.containsKey(path)) {
// If main has the same block, prefer main's lines
merged.addAll(mainBlocks.get(path).lines);
} else {
// Otherwise, add the template block
merged.addAll(templateBlock.lines);
}
}
return merged;
}
/**
* Parse a list of lines into a map of "path -> Block" where "Block" is all lines that belong to
* that key (including subsequent indented lines). Very naive approach that may not work with
* advanced YAML.
*/
private LinkedHashMap<String, Block> parseYamlBlocks(List<String> lines) {
LinkedHashMap<String, Block> blocks = new LinkedHashMap<>();
Block currentBlock = null;
String currentPath = null;
for (String line : lines) {
if (isLikelyKeyLine(line)) {
// Found a new "key: ..." line
if (currentBlock != null && currentPath != null) {
blocks.put(currentPath, currentBlock);
}
currentBlock = new Block();
currentBlock.lines.add(line);
currentPath = computePathForLine(line);
} else {
// Continuation of current block (comments, blank lines, sub-lines)
if (currentBlock == null) {
// If file starts with comments/blank lines, treat as "header block" with path
// ""
currentBlock = new Block();
currentPath = "";
}
currentBlock.lines.add(line);
}
}
if (currentBlock != null && currentPath != null) {
blocks.put(currentPath, currentBlock);
}
return blocks;
}
/**
* Checks if the line is likely "key:" or "key: value", ignoring comments/blank. Skips lines
* starting with "-" or "#".
*/
private boolean isLikelyKeyLine(String line) {
String trimmed = line.trim();
if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("-")) {
return false;
}
int colonIdx = trimmed.indexOf(':');
return (colonIdx > 0); // someKey:
}
// For a line like "security: ", returns "security" or "security.enableLogin"
// by looking at indentation. Very naive.
private static final Deque<String> pathStack = new ArrayDeque<>();
private static int currentIndentLevel = 0;
private String computePathForLine(String line) {
// count leading spaces
int leadingSpaces = 0;
for (char c : line.toCharArray()) {
if (c == ' ') leadingSpaces++;
else break;
}
// assume 2 spaces = 1 indent
int indentLevel = leadingSpaces / 2;
String trimmed = line.trim();
int colonIdx = trimmed.indexOf(':');
String keyName = trimmed.substring(0, colonIdx).trim();
// pop stack until we match the new indent level
while (currentIndentLevel >= indentLevel && !pathStack.isEmpty()) {
pathStack.pop();
currentIndentLevel--;
}
// push the new key
pathStack.push(keyName);
currentIndentLevel = indentLevel;
// build path by reversing the stack
String[] arr = pathStack.toArray(new String[0]);
List<String> reversed = Arrays.asList(arr);
Collections.reverse(reversed);
return String.join(".", reversed);
}
/**
* Simple holder for the lines that comprise a "block" (i.e. a key and its subsequent lines).
*/
private static class Block {
List<String> lines = new ArrayList<>();
}
}

View File

@@ -9,15 +9,17 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Deque;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.simpleyaml.configuration.file.YamlFile;
import org.simpleyaml.configuration.file.YamlFileWrapper;
import org.simpleyaml.configuration.implementation.SimpleYamlImplementation;
import org.simpleyaml.configuration.implementation.snakeyaml.lib.DumperOptions;
import org.springframework.web.multipart.MultipartFile;
import com.fathzer.soft.javaluator.DoubleEvaluator;
@@ -346,41 +348,208 @@ public class GeneralUtils {
public static void saveKeyToConfig(String id, String key, boolean autoGenerated)
throws IOException {
Path path =
Paths.get(
InstallationPathConfig
.getSettingsPath()); // Target the configs/settings.yml
final YamlFile settingsYml = new YamlFile(path.toFile());
DumperOptions yamlOptionssettingsYml =
((SimpleYamlImplementation) settingsYml.getImplementation()).getDumperOptions();
yamlOptionssettingsYml.setSplitLines(false);
settingsYml.loadWithComments();
YamlFileWrapper writer = settingsYml.path(id).set(key);
if (autoGenerated) {
writer.comment("# Automatically Generated Settings (Do Not Edit Directly)");
}
settingsYml.save();
doSaveKeyToConfig(id, (key == null ? "" : key), autoGenerated);
}
public static void saveKeyToConfig(String id, boolean key, boolean autoGenerated)
throws IOException {
Path path = Paths.get(InstallationPathConfig.getSettingsPath());
doSaveKeyToConfig(id, String.valueOf(key), autoGenerated);
}
final YamlFile settingsYml = new YamlFile(path.toFile());
DumperOptions yamlOptionssettingsYml =
((SimpleYamlImplementation) settingsYml.getImplementation()).getDumperOptions();
yamlOptionssettingsYml.setSplitLines(false);
/*------------------------------------------------------------------------*
* Internal Implementation Details *
*------------------------------------------------------------------------*/
settingsYml.loadWithComments();
YamlFileWrapper writer = settingsYml.path(id).set(key);
if (autoGenerated) {
writer.comment("# Automatically Generated Settings (Do Not Edit Directly)");
/**
* Actually performs the line-based update for the given path (e.g. "security.csrfDisabled") to
* a new string value (e.g. "true"), possibly marking it as auto-generated.
*/
private static void doSaveKeyToConfig(String fullPath, String newValue, boolean autoGenerated)
throws IOException {
// 1) Load the file (settings.yml)
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
if (!Files.exists(settingsPath)) {
log.warn("Settings file not found at {}, creating a new empty file...", settingsPath);
Files.createDirectories(settingsPath.getParent());
Files.createFile(settingsPath);
}
settingsYml.save();
List<String> lines = Files.readAllLines(settingsPath);
// 2) Build a map of "nestedKeyPath -> lineIndex" by parsing indentation
// Also track each line's indentation so we can preserve it when rewriting.
Map<String, LineInfo> pathToLine = parseNestedYamlKeys(lines);
// 3) If the path is found, rewrite its line. Else, append at the bottom (no indentation).
boolean changed = false;
if (pathToLine.containsKey(fullPath)) {
// Rewrite existing line
LineInfo info = pathToLine.get(fullPath);
String oldLine = lines.get(info.lineIndex);
String newLine =
rewriteLine(oldLine, info.indentSpaces, fullPath, newValue, autoGenerated);
if (!newLine.equals(oldLine)) {
lines.set(info.lineIndex, newLine);
changed = true;
}
} else {
// Append a new line at the bottom, with zero indentation
String appended = fullPath + ": " + newValue;
if (autoGenerated) {
appended += " # Automatically Generated Settings (Do Not Edit Directly)";
}
lines.add(appended);
changed = true;
}
// 4) If changed, write back to file
if (changed) {
Files.write(settingsPath, lines);
log.info(
"Updated '{}' to '{}' (autoGenerated={}) in {}",
fullPath,
newValue,
autoGenerated,
settingsPath);
} else {
log.info("No changes for '{}' (already set to '{}').", fullPath, newValue);
}
}
/** A small record-like class that holds: - lineIndex - indentSpaces */
private static class LineInfo {
int lineIndex;
int indentSpaces;
public LineInfo(int lineIndex, int indentSpaces) {
this.lineIndex = lineIndex;
this.indentSpaces = indentSpaces;
}
}
/**
* Parse the YAML lines to build a map: "full.nested.key" -> (lineIndex, indentSpaces). We do a
* naive indentation-based path stacking: - 2 spaces = 1 indent level - lines that start with
* fewer or equal indentation pop the stack - lines that look like "key:" or "key: value" cause
* a push
*/
private static Map<String, LineInfo> parseNestedYamlKeys(List<String> lines) {
Map<String, LineInfo> result = new HashMap<>();
// We'll maintain a stack of (keyName, indentLevel).
// Each line that looks like "myKey:" or "myKey: value" is a new "child" of the top of the
// stack if indent is deeper.
Deque<String> pathStack = new ArrayDeque<>();
Deque<Integer> indentStack = new ArrayDeque<>();
indentStack.push(-1); // sentinel
for (int i = 0; i < lines.size(); i++) {
String line = lines.get(i);
String trimmed = line.trim();
// skip blank lines, comment lines, or list items
if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("-")) {
continue;
}
// check if there's a colon
int colonIdx = trimmed.indexOf(':');
if (colonIdx <= 0) { // must have at least one char before ':'
continue;
}
// parse out key
String keyPart = trimmed.substring(0, colonIdx).trim();
if (keyPart.isEmpty()) {
continue;
}
// count leading spaces for indentation
int leadingSpaces = countLeadingSpaces(line);
int indentLevel = leadingSpaces / 2; // assume 2 spaces per level
// pop from stack until we get to a shallower indentation
while (indentStack.peek() != null && indentStack.peek() >= indentLevel) {
indentStack.pop();
pathStack.pop();
}
// push the new key
pathStack.push(keyPart);
indentStack.push(indentLevel);
// build the full path
String[] arr = pathStack.toArray(new String[0]);
List<String> reversed = Arrays.asList(arr);
Collections.reverse(reversed);
String fullPath = String.join(".", reversed);
// store line info
result.put(fullPath, new LineInfo(i, leadingSpaces));
}
return result;
}
/**
* Rewrite a single line to set a new value, preserving indentation and (optionally) the
* existing or auto-generated inline comment.
*
* <p>For example, oldLine might be: " csrfDisabled: false # set to 'true' to disable CSRF
* protection" newValue = "true" autoGenerated = false
*
* <p>We'll produce something like: " csrfDisabled: true # set to 'true' to disable CSRF
* protection"
*/
private static String rewriteLine(
String oldLine, int indentSpaces, String path, String newValue, boolean autoGenerated) {
// We'll keep the exact leading indentation (indentSpaces).
// Then "key: newValue". We'll try to preserve any existing inline comment unless
// autoGenerated is true.
// 1) Extract leading spaces from the old line (just in case they differ from indentSpaces).
int actualLeadingSpaces = countLeadingSpaces(oldLine);
String leading = oldLine.substring(0, actualLeadingSpaces);
// 2) Remove leading spaces from the rest
String trimmed = oldLine.substring(actualLeadingSpaces);
// 3) Check for existing comment
int hashIndex = trimmed.indexOf('#');
String lineWithoutComment =
(hashIndex >= 0) ? trimmed.substring(0, hashIndex).trim() : trimmed.trim();
String oldComment = (hashIndex >= 0) ? trimmed.substring(hashIndex).trim() : "";
// 4) Rebuild "key: newValue"
// The "key" here is everything before ':' in lineWithoutComment
int colonIdx = lineWithoutComment.indexOf(':');
String existingKey =
(colonIdx >= 0)
? lineWithoutComment.substring(0, colonIdx).trim()
: path; // fallback if line is malformed
StringBuilder sb = new StringBuilder();
sb.append(leading); // restore original leading spaces
// "key: newValue"
sb.append(existingKey).append(": ").append(newValue);
// 5) If autoGenerated, add/replace comment
if (autoGenerated) {
sb.append(" # Automatically Generated Settings (Do Not Edit Directly)");
} else {
// preserve the old comment if it exists
if (!oldComment.isEmpty()) {
sb.append(" ").append(oldComment);
}
}
return sb.toString();
}
private static int countLeadingSpaces(String line) {
int count = 0;
for (char c : line.toCharArray()) {
if (c == ' ') count++;
else break;
}
return count;
}
public static String generateMachineFingerprint() {