Added PowerPoint templates to the Slide Assistant (#1055)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Sabrina-devopsandThorsten Sommer authored and GitHub committed 2026-10-25 18:52:39 +01:00
1 parent 8c5cfa78b6
commit 6eb23ebbcb
136 files changed
+16535 -522

No files matched your search

@@ -235,7 +235,13 @@ public static class ConfigurationSelectDataFactory
foreach (var expertise in Enum.GetValues<AudienceExpertise>())
yield return new(expertise.Name(), expertise);
}
public static IEnumerable<ConfigurationSelectData<PresentationOccasion>> GetSlideBuilderOccasionData()
{
foreach (var occasion in Enum.GetValues<PresentationOccasion>())
yield return new(occasion.Name(), occasion);
}
public static IEnumerable<ConfigurationSelectData<string>> GetProfilesData(IEnumerable<Profile> profiles)
{
foreach (var profile in profiles.GetAllProfiles())
@@ -204,7 +204,7 @@ public sealed class Data
public DataEMail EMail { get; init; } = new();
public DataSlideBuilder SlideBuilder { get; init; } = new();
public DataSlideBuilder SlideBuilder { get; init; } = new(x => x.SlideBuilder);
/// <summary>
/// Gets the managed Assistant Builder defaults.
@@ -1,14 +1,101 @@
using AIStudio.Assistants.SlideBuilder;
using System.Linq.Expressions;
using AIStudio.Assistants.SlideBuilder;
using AIStudio.Provider;
using AIStudio.Tools.PowerPoint;
namespace AIStudio.Settings.DataModel;
public class DataSlideBuilder
/// <summary>
/// Stores the settings of the Slide Assistant.
/// </summary>
/// <param name="configSelection">The managed-configuration selector.</param>
public class DataSlideBuilder(Expression<Func<Data, DataSlideBuilder>>? configSelection = null)
{
/// <summary>
/// Initializes an unmanaged Slide Assistant settings instance.
/// </summary>
public DataSlideBuilder() : this(null)
{
}
/// <summary>
/// The PowerPoint presentation or template (.pptx, .potx) whose layouts the slides use.
/// </summary>
/// <remarks>
/// Empty for no template, which makes the assistant plan an outline only. Like the other
/// preselections, the template applies only while preselection is enabled: an organization
/// which wants its corporate template used for sure locks PreselectOptions as well.
/// </remarks>
public string TemplatePath { get; set; } = ManagedConfiguration.Register(configSelection, value => value.TemplatePath, string.Empty);
/// <summary>
/// Preselect the footer text of the slides?
/// </summary>
public string PreselectedFooterText { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedFooterText, string.Empty);
/// <summary>
/// The most bullet points per slide the user can ask for.
/// </summary>
public const int MAX_BULLET_POINTS_PER_SLIDE = 7;
/// <summary>
/// Preselect the maximum number of bullet points per slide, all text slots of a slide together?
/// </summary>
/// <remarks>
/// 0 sets no maximum, as in the assistant, where the LLM decides by the room of each layout. The
/// number should fit the room for text in the layouts of the template, which is why organizations
/// may dictate it.
/// </remarks>
public int PreselectedMaxBulletPointsPerSlide { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedMaxBulletPointsPerSlide, 0);
/// <summary>
/// Whether a number of bullet points per slide can be preselected: 0 for no maximum, or up to MAX_BULLET_POINTS_PER_SLIDE.
/// </summary>
public static bool IsValidMaxBulletPointsPerSlide(int value) => value is >= 0 and <= MAX_BULLET_POINTS_PER_SLIDE;
/// <summary>
/// Preselect keeping the picture placeholders which a new slide has no picture for?
/// </summary>
/// <remarks>
/// Off by default: the slide leaves them out, as every other placeholder without content. When
/// on, PowerPoint shows them while editing, for pictures of the user's own.
/// </remarks>
public bool PreselectedKeepEmptyPicturePlaceholders { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedKeepEmptyPicturePlaceholders, false);
/// <summary>
/// Preselect the folder of pictures and icons which the LLM may choose from by their names?
/// </summary>
/// <remarks>
/// Empty for no picture library. Like the template, the folder applies only while preselection
/// is enabled, and a configuration plugin may ship it and name it relative to its directory.
/// </remarks>
public string PreselectedPictureLibraryPath { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedPictureLibraryPath, string.Empty);
/// <summary>
/// Preselect the layouts for kinds of slides, i.e., the name of a layout of the template by kind of slide?
/// </summary>
/// <remarks>
/// A kind of slide which is missing has no preference, and the LLM decides. Names instead of IDs
/// keep the choices valid across sessions, computers, and versions of the template; a name which
/// the current template lacks counts as no preference.
/// </remarks>
public Dictionary<LayoutPurpose, string> PreselectedLayouts { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedLayouts, new Dictionary<LayoutPurpose, string>());
/// <summary>
/// Preselect what the presentation does with the slides of the preselected template, by the number of each slide?
/// </summary>
/// <remarks>
/// The slides count from 1, in the order of the file, hidden ones included. A slide which is
/// missing is left out, as by default. The choices belong to the preselected template: for any
/// other template, the assistant ignores them, since its slides are other ones.
/// </remarks>
public Dictionary<int, TemplateSlideUse> PreselectedTemplateSlideUses { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedTemplateSlideUses, new Dictionary<int, TemplateSlideUse>());
/// <summary>
/// Preselect any Slide Builder options?
/// </summary>
public bool PreselectOptions { get; set; } = true;
public bool PreselectOptions { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectOptions, true);
/// <summary>
/// Preselect a profile?
@@ -49,7 +136,17 @@ public class DataSlideBuilder
/// Preselect the audience expertise?
/// </summary>
public AudienceExpertise PreselectedAudienceExpertise { get; set; }
/// <summary>
/// Preselect the occasion of the presentation?
/// </summary>
public PresentationOccasion PreselectedOccasion { get; set; }
/// <summary>
/// Preselect any other occasion?
/// </summary>
public string PreselectedOtherOccasion { get; set; } = string.Empty;
/// <summary>
/// Preselect any important aspects that the Slide Builder should take into account?
/// </summary>
@@ -153,6 +153,7 @@ public static partial class ManagedConfiguration
/// <param name="configSelection">The expression to select the configuration class.</param>
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
/// <param name="dryRun">When true, the method will not apply any changes, but only check if the configuration can be read.</param>
/// <param name="convert">Turns the configured text into the value to store, e.g., a path relative to the plugin into a full one; null rejects the text. Without it, the text is stored as it is.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <returns>True when the configuration was successfully processed, otherwise false.</returns>
public static bool TryProcessConfiguration<TClass>(
@@ -160,9 +161,10 @@ public static partial class ManagedConfiguration
Expression<Func<TClass, string>> propertyExpression,
Guid configPluginId,
LuaTable settings,
bool dryRun)
bool dryRun,
Func<string, string?>? convert = null)
{
return TryProcessConfiguration(configSelection, propertyExpression, string.Empty, configPluginId, settings, dryRun);
return TryProcessConfiguration(configSelection, propertyExpression, string.Empty, configPluginId, settings, dryRun, convert);
}
/// <summary>
@@ -179,6 +181,7 @@ public static partial class ManagedConfiguration
/// <param name="configSelection">The expression to select the configuration class.</param>
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
/// <param name="dryRun">When true, the method will not apply any changes, but only check if the configuration can be read.</param>
/// <param name="convert">For plain strings: turns the configured text into the value to store; null rejects the text. Without it, the text is stored as it is.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TDataType">The data type of the configured value.</typeparam>
/// <returns>True when the configuration was successfully processed, otherwise false.</returns>
@@ -188,7 +191,8 @@ public static partial class ManagedConfiguration
TDataType configuredType,
Guid configPluginId,
LuaTable settings,
bool dryRun)
bool dryRun,
Func<string, string?>? convert = null)
{
//
// Handle configured string values
@@ -217,8 +221,9 @@ public static partial class ManagedConfiguration
// Case: the read string is just a string:
case string:
configuredValue = configuredText;
successful = true;
var convertedText = convert is null ? configuredText : convert(configuredText);
successful = convertedText is not null;
configuredValue = convertedText ?? configuredText;
break;
}
}
@@ -901,6 +906,156 @@ public static partial class ManagedConfiguration
return HandleParsedScalarValue(configPluginId, dryRun, successful, configMeta, configuredValue, managedMode, settingName);
}
/// <summary>
/// Attempts to process the configuration settings from a Lua table for dictionaries which map enum keys to texts.
/// </summary>
/// <remarks>
/// When the configuration is successfully processed, it updates the configuration metadata with the configured value.
/// Furthermore, it applies the configured managed state to the provided configuration plugin ID.
/// The setting's value is set to the configured value when locked or when the editable default should apply.
/// A key which names no member of the enum is passed over, as with the enum dictionaries.
/// </remarks>
/// <param name="configPluginId">The ID of the related configuration plugin.</param>
/// <param name="settings">The Lua table containing the settings to process.</param>
/// <param name="configSelection">The expression to select the configuration class.</param>
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
/// <param name="dryRun">When true, the method will not apply any changes but only check if the configuration can be read.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TKey">The enum type of the dictionary keys.</typeparam>
/// <returns>True when the configuration was successfully processed, otherwise false.</returns>
public static bool TryProcessConfiguration<TClass, TKey>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, Dictionary<TKey, string>>> propertyExpression,
Guid configPluginId,
LuaTable settings,
bool dryRun)
where TKey : struct, Enum
{
//
// Handle configured dictionaries of enum keys and texts (Lua keys and values are strings)
//
// Check if that configuration was registered:
if(!TryGet(configSelection, propertyExpression, out var configMeta))
return false;
var successful = false;
var configuredValue = new Dictionary<TKey, string>(configMeta.Default);
// Step 1 -- try to read the Lua value (we expect a table) out of the Lua table:
if (settings.TryGetValue(SettingsManager.ToSettingName(propertyExpression), out var configuredLuaList) &&
configuredLuaList.Type is LuaValueType.Table &&
configuredLuaList.TryRead<LuaTable>(out var valueTable))
{
configuredValue.Clear();
// In order to iterate over all key-value pairs in the Lua table, we have to use TryGetNext.
// Thus, we initialize the previous key variable to Nil and keep calling TryGetNext until
// there are no more pairs:
var previousKey = LuaValue.Nil;
while(valueTable.TryGetNext(previousKey, out var pair))
{
// Update the previous key for the next iteration:
previousKey = pair.Key;
// Try to read both the key and the value as strings:
var hadKey = pair.Key.TryRead<string>(out var keyText);
var hadValue = pair.Value.TryRead<string>(out var valueText);
// If both were read successfully and the key names a member of the enum, add them to the dictionary:
if (hadKey && hadValue && EnumNames.TryParse<TKey>(keyText, out var key))
configuredValue[key] = valueText;
}
successful = true;
}
if(dryRun)
return successful;
var settingName = SettingName(propertyExpression);
var managedMode = ReadManagedConfigurationMode(propertyExpression, settings);
return HandleParsedScalarValue(configPluginId, dryRun, successful, configMeta, configuredValue, managedMode, settingName);
}
/// <summary>
/// Attempts to process the configuration settings from a Lua table for dictionaries which map numbers to enum values.
/// </summary>
/// <remarks>
/// When the configuration is successfully processed, it updates the configuration metadata with the configured value.
/// Furthermore, it applies the configured managed state to the provided configuration plugin ID.
/// The setting's value is set to the configured value when locked or when the editable default should apply.
/// The numbers count from 1, like the slides of a presentation. An entry whose key is no such number or
/// whose value names no member of the enum is passed over with a warning, since the plugin's author
/// would otherwise never learn why the entry has no effect.
/// </remarks>
/// <param name="configPluginId">The ID of the related configuration plugin.</param>
/// <param name="settings">The Lua table containing the settings to process.</param>
/// <param name="configSelection">The expression to select the configuration class.</param>
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
/// <param name="dryRun">When true, the method will not apply any changes but only check if the configuration can be read.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The enum type of the dictionary values.</typeparam>
/// <returns>True when the configuration was successfully processed, otherwise false.</returns>
public static bool TryProcessConfiguration<TClass, TValue>(
Expression<Func<Data, TClass>> configSelection,
Expression<Func<TClass, Dictionary<int, TValue>>> propertyExpression,
Guid configPluginId,
LuaTable settings,
bool dryRun)
where TValue : struct, Enum
{
//
// Handle configured dictionaries of numbers and enum values (Lua keys are numbers, values are strings)
//
// Check if that configuration was registered:
if(!TryGet(configSelection, propertyExpression, out var configMeta))
return false;
var successful = false;
var configuredValue = new Dictionary<int, TValue>(configMeta.Default);
// Step 1 -- try to read the Lua value (we expect a table) out of the Lua table:
if (settings.TryGetValue(SettingsManager.ToSettingName(propertyExpression), out var configuredLuaList) &&
configuredLuaList.Type is LuaValueType.Table &&
configuredLuaList.TryRead<LuaTable>(out var valueTable))
{
configuredValue.Clear();
// In order to iterate over all key-value pairs in the Lua table, we have to use TryGetNext.
// Thus, we initialize the previous key variable to Nil and keep calling TryGetNext until
// there are no more pairs:
var previousKey = LuaValue.Nil;
while(valueTable.TryGetNext(previousKey, out var pair))
{
// Update the previous key for the next iteration:
previousKey = pair.Key;
// Try to read the key as a number and the value as a string:
var hadKey = pair.Key.TryRead<int>(out var key) && pair.Key.Type is LuaValueType.Number && key > 0;
var hadValue = pair.Value.TryRead<string>(out var valueText);
// If both were read successfully and the value names a member of the enum, add them to the dictionary:
if (hadKey && hadValue && EnumNames.TryParse<TValue>(valueText, out var value))
configuredValue[key] = value;
// A dry run comes before the actual one, so only the actual one reports the entry:
else if (!dryRun)
Log.LogWarning($"The configuration plugin '{configPluginId}' has the entry '{pair.Key}' = '{pair.Value}' in the setting '{configMeta.SettingName}', which is no valid entry. The keys are numbers from 1, and the values are names of options. Ignoring it.");
}
successful = true;
}
if(dryRun)
return successful;
var settingName = SettingName(propertyExpression);
var managedMode = ReadManagedConfigurationMode(propertyExpression, settings);
return HandleParsedScalarValue(configPluginId, dryRun, successful, configMeta, configuredValue, managedMode, settingName);
}
/// <summary>
/// Handles the parsed configuration value based on whether the parsing was successful and whether it's a dry run.
/// </summary>
@@ -288,4 +288,76 @@ public static partial class ManagedConfiguration
return defaultValues;
}
/// <summary>
/// Registers a configuration setting with a default dictionary which maps enum keys to texts.
/// </summary>
/// <remarks>
/// When the method is invoked with a null configSelection, the configuration path
/// is ignored, and the specified default values are returned without registration.
/// </remarks>
/// <param name="configSelection">The expression that selects the configuration class from the root Data model.</param>
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
/// <param name="defaultValues">The default dictionary of values to use when the setting is not configured.</param>
/// <typeparam name="TClass">The type of the configuration class from which the property is selected.</typeparam>
/// <typeparam name="TKey">The enum type of the dictionary keys.</typeparam>
/// <returns>A dictionary containing the default values.</returns>
public static Dictionary<TKey, string> Register<TClass, TKey>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, Dictionary<TKey, string>>> propertyExpression, Dictionary<TKey, string> defaultValues)
where TKey : struct, Enum
{
// When called from the JSON deserializer by using the standard constructor,
// we ignore the register call and return the default value:
if (configSelection is null)
return new();
var configPath = Path(configSelection, propertyExpression);
// If the metadata already exists for this configuration path, we return the default value:
if (METADATA.ContainsKey(configPath))
return defaultValues;
// Not registered yet, so we register it now:
METADATA[configPath] = new ConfigMeta<TClass, Dictionary<TKey, string>>(configSelection, propertyExpression)
{
Default = defaultValues,
};
return defaultValues;
}
/// <summary>
/// Registers a configuration setting with a default dictionary which maps numbers to enum values, e.g., slide numbers to a use.
/// </summary>
/// <remarks>
/// When the method is invoked with a null configSelection, the configuration path
/// is ignored, and the specified default values are returned without registration.
/// </remarks>
/// <param name="configSelection">The expression that selects the configuration class from the root Data model.</param>
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
/// <param name="defaultValues">The default dictionary of values to use when the setting is not configured.</param>
/// <typeparam name="TClass">The type of the configuration class from which the property is selected.</typeparam>
/// <typeparam name="TValue">The enum type of the dictionary values.</typeparam>
/// <returns>A dictionary containing the default values.</returns>
public static Dictionary<int, TValue> Register<TClass, TValue>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, Dictionary<int, TValue>>> propertyExpression, Dictionary<int, TValue> defaultValues)
where TValue : struct, Enum
{
// When called from the JSON deserializer by using the standard constructor,
// we ignore the register call and return the default value:
if (configSelection is null)
return new();
var configPath = Path(configSelection, propertyExpression);
// If the metadata already exists for this configuration path, we return the default value:
if (METADATA.ContainsKey(configPath))
return defaultValues;
// Not registered yet, so we register it now:
METADATA[configPath] = new ConfigMeta<TClass, Dictionary<int, TValue>>(configSelection, propertyExpression)
{
Default = defaultValues,
};
return defaultValues;
}
}
@@ -266,6 +266,74 @@ public static partial class ManagedConfiguration
return false;
}
/// <summary>
/// Attempts to retrieve the configuration metadata for a setting which maps enum keys to texts.
/// </summary>
/// <remarks>
/// When no configuration metadata is found, it returns a NoConfig instance with the default
/// value set to an empty dictionary. This allows the caller to handle the absence of configuration
/// gracefully. In such cases, the return value of the method will be false.
/// </remarks>
/// <param name="configSelection">The expression to select the configuration class.</param>
/// <param name="propertyExpression">The expression to select the property within the
/// configuration class.</param>
/// <param name="configMeta">The output parameter that will hold the configuration metadata
/// if found.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TKey">The enum type of the dictionary keys.</typeparam>
/// <returns>True if the configuration metadata was found, otherwise false.</returns>
public static bool TryGet<TClass, TKey>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, Dictionary<TKey, string>>> propertyExpression, out ConfigMeta<TClass, Dictionary<TKey, string>> configMeta)
where TKey : struct, Enum
{
var configPath = Path(configSelection, propertyExpression);
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, Dictionary<TKey, string>> meta)
{
meta.RestoreLockedConfiguration();
configMeta = meta;
return true;
}
configMeta = new NoConfig<TClass, Dictionary<TKey, string>>(configSelection, propertyExpression)
{
Default = new Dictionary<TKey, string>(),
};
return false;
}
/// <summary>
/// Attempts to retrieve the configuration metadata for a setting which maps numbers to enum values.
/// </summary>
/// <remarks>
/// When no configuration metadata is found, it returns a NoConfig instance with the default
/// value set to an empty dictionary. This allows the caller to handle the absence of configuration
/// gracefully. In such cases, the return value of the method will be false.
/// </remarks>
/// <param name="configSelection">The expression to select the configuration class.</param>
/// <param name="propertyExpression">The expression to select the property within the
/// configuration class.</param>
/// <param name="configMeta">The output parameter that will hold the configuration metadata
/// if found.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The enum type of the dictionary values.</typeparam>
/// <returns>True if the configuration metadata was found, otherwise false.</returns>
public static bool TryGet<TClass, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, Dictionary<int, TValue>>> propertyExpression, out ConfigMeta<TClass, Dictionary<int, TValue>> configMeta)
where TValue : struct, Enum
{
var configPath = Path(configSelection, propertyExpression);
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, Dictionary<int, TValue>> meta)
{
meta.RestoreLockedConfiguration();
configMeta = meta;
return true;
}
configMeta = new NoConfig<TClass, Dictionary<int, TValue>>(configSelection, propertyExpression)
{
Default = new Dictionary<int, TValue>(),
};
return false;
}
/// <summary>
/// Checks whether a configuration plugin may manage a setting, or whether that setting belongs
/// to the IT department of an organization.
@@ -0,0 +1,172 @@
namespace AIStudio.Settings;
/// <summary>
/// Resolves a file or folder a configuration plugin names in a setting, such as the PowerPoint
/// template or the picture library of the Slide Assistant, or the icon of a provider.
/// </summary>
/// <remarks>
/// A plugin may ship the file or folder itself, next to its plugin.lua, and name it by a path
/// relative to its directory. Such a path has to stay inside that directory, so a plugin cannot reach
/// an arbitrary file elsewhere on the system through it. An absolute path, e.g., on a network share,
/// stays as the organization wrote it, where the setting allows one.
/// </remarks>
internal static class PluginFilePath
{
/// <summary>
/// Resolves a path against the directory of the plugin which states it.
/// </summary>
/// <param name="path">The path as the plugin states it: empty, absolute, or relative to the plugin directory.</param>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <param name="resolvedPath">The full path; empty for an empty path, which means no file.</param>
/// <param name="issue">Why the path cannot be used; empty on success.</param>
/// <returns>Whether the path can be used.</returns>
public static bool TryResolve(string path, string pluginPath, out string resolvedPath, out string issue)
{
resolvedPath = string.Empty;
issue = string.Empty;
if (string.IsNullOrWhiteSpace(path))
return true;
if (Path.IsPathFullyQualified(path))
{
resolvedPath = path;
return true;
}
return TryResolveInside(path, pluginPath, out resolvedPath, out issue);
}
/// <summary>
/// Resolves the path of a folder against the directory of the plugin which states it.
/// </summary>
/// <param name="path">The path as the plugin states it: empty, absolute, or relative to the plugin directory.</param>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <param name="resolvedPath">The full path; empty for an empty path, which means no folder.</param>
/// <param name="issue">Why the path cannot be used; empty on success.</param>
/// <returns>Whether the path can be used.</returns>
public static bool TryResolveDirectory(string path, string pluginPath, out string resolvedPath, out string issue)
{
resolvedPath = string.Empty;
issue = string.Empty;
if (string.IsNullOrWhiteSpace(path))
return true;
if (Path.IsPathFullyQualified(path))
{
resolvedPath = path;
return true;
}
return TryResolveInside(path, pluginPath, true, out resolvedPath, out issue);
}
/// <summary>
/// Resolves a path relative to the directory of a plugin, which has to name an existing file
/// inside that directory.
/// </summary>
/// <param name="relativePath">The path relative to the plugin directory; both kinds of slashes work.</param>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <param name="fullPath">The full path of the file; empty when the path cannot be used.</param>
/// <param name="issue">Why the path cannot be used; empty on success.</param>
/// <returns>Whether the path names a file inside the plugin directory.</returns>
public static bool TryResolveInside(string relativePath, string pluginPath, out string fullPath, out string issue) => TryResolveInside(relativePath, pluginPath, false, out fullPath, out issue);
/// <summary>
/// Resolves a path relative to the directory of a plugin, which has to name an existing file or
/// folder inside that directory.
/// </summary>
/// <remarks>
/// Every file and folder a plugin ships itself is checked here, so that all of them follow the
/// same rules: no '..' segments, and no link along the path which leads out of the plugin directory.
/// </remarks>
/// <param name="relativePath">The path relative to the plugin directory; both kinds of slashes work.</param>
/// <param name="pluginPath">The directory of the plugin.</param>
/// <param name="isDirectory">Whether the path has to name a folder rather than a file.</param>
/// <param name="fullPath">The full path; empty when the path cannot be used.</param>
/// <param name="issue">Why the path cannot be used; empty on success.</param>
/// <returns>Whether the path names a file or folder inside the plugin directory.</returns>
private static bool TryResolveInside(string relativePath, string pluginPath, bool isDirectory, out string fullPath, out string issue)
{
fullPath = string.Empty;
issue = string.Empty;
if (string.IsNullOrWhiteSpace(pluginPath))
{
issue = "The relative path cannot be resolved because the plugin directory is unknown.";
return false;
}
var normalizedPath = relativePath
.Replace('/', Path.DirectorySeparatorChar)
.Replace('\\', Path.DirectorySeparatorChar);
if (normalizedPath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries).Any(segment => segment == ".."))
{
issue = "The relative path must not contain '..' path segments.";
return false;
}
string pluginRoot;
string candidatePath;
try
{
pluginRoot = Path.GetFullPath(pluginPath);
candidatePath = Path.GetFullPath(Path.Combine(pluginRoot, normalizedPath));
}
catch (Exception e)
{
issue = $"The relative path is invalid: {e.Message}";
return false;
}
if (!PathTools.IsInsideDirectory(pluginRoot, candidatePath))
{
issue = "The relative path points outside of the plugin directory.";
return false;
}
if (isDirectory ? !Directory.Exists(candidatePath) : !File.Exists(candidatePath))
{
issue = isDirectory ? "The folder does not exist in the plugin directory." : "The file does not exist in the plugin directory.";
return false;
}
try
{
if (!LinksStayInsideDirectory(pluginRoot, candidatePath))
{
issue = "The relative path contains a link that points outside of the plugin directory.";
return false;
}
}
catch (Exception e)
{
issue = $"The links of the relative path cannot be checked: {e.Message}";
return false;
}
fullPath = candidatePath;
return true;
}
/// <summary>
/// Whether no link along the path leads out of the root directory.
/// </summary>
private static bool LinksStayInsideDirectory(string rootDirectory, string path)
{
var relativePath = Path.GetRelativePath(rootDirectory, path);
var currentPath = Path.GetFullPath(rootDirectory);
foreach (var segment in relativePath.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries))
{
currentPath = Path.Combine(currentPath, segment);
FileSystemInfo pathInfo = Directory.Exists(currentPath) ? new DirectoryInfo(currentPath) : new FileInfo(currentPath);
var finalTarget = pathInfo.ResolveLinkTarget(true);
if (finalTarget is not null && !PathTools.IsInsideDirectory(rootDirectory, finalTarget.FullName))
return false;
}
return true;
}
}
@@ -27,52 +27,15 @@ internal static class PluginIconFile
return false;
}
if (string.IsNullOrWhiteSpace(pluginPath))
{
issue = "The icon path cannot be resolved because the configuration plugin directory is unknown.";
return false;
}
var relativePath = iconPath
.Replace('/', Path.DirectorySeparatorChar)
.Replace('\\', Path.DirectorySeparatorChar);
if (relativePath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries).Any(segment => segment == ".."))
{
issue = "The icon path must not contain '..' path segments.";
return false;
}
string pluginRoot;
string resolvedPath;
try
{
pluginRoot = Path.GetFullPath(pluginPath);
resolvedPath = Path.GetFullPath(Path.Combine(pluginRoot, relativePath));
}
catch (Exception e)
{
issue = $"The icon path is invalid: {e.Message}";
return false;
}
if (!PathTools.IsInsideDirectory(pluginRoot, resolvedPath))
{
issue = "The icon path points outside of the configuration plugin directory.";
return false;
}
if (!string.Equals(Path.GetExtension(resolvedPath), ".svg", StringComparison.OrdinalIgnoreCase))
if (!string.Equals(Path.GetExtension(iconPath), ".svg", StringComparison.OrdinalIgnoreCase))
{
issue = "The icon file must use the .svg extension.";
return false;
}
if (!File.Exists(resolvedPath))
{
issue = "The icon file does not exist.";
// The same rules as for every other file a plugin ships:
if (!PluginFilePath.TryResolveInside(iconPath, pluginPath, out var resolvedPath, out issue))
return false;
}
try
{
@@ -84,12 +47,6 @@ internal static class PluginIconFile
return false;
}
if (!LinksStayInsideDirectory(pluginRoot, resolvedPath))
{
issue = "The icon path contains a link that points outside of the configuration plugin directory.";
return false;
}
return SvgIcon.TryCreateDataUrl(File.ReadAllBytes(resolvedPath), out dataUrl, out issue);
}
catch (Exception e)
@@ -98,20 +55,4 @@ internal static class PluginIconFile
return false;
}
}
private static bool LinksStayInsideDirectory(string rootDirectory, string filePath)
{
var relativePath = Path.GetRelativePath(rootDirectory, filePath);
var currentPath = Path.GetFullPath(rootDirectory);
foreach (var segment in relativePath.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries))
{
currentPath = Path.Combine(currentPath, segment);
FileSystemInfo pathInfo = Directory.Exists(currentPath) ? new DirectoryInfo(currentPath) : new FileInfo(currentPath);
var finalTarget = pathInfo.ResolveLinkTarget(true);
if (finalTarget is not null && !PathTools.IsInsideDirectory(rootDirectory, finalTarget.FullName))
return false;
}
return true;
}
}