using System.Globalization;
using System.Net;
using Sunfish.Shares;
namespace Sunfish.Config;
/// <summary>Seccion [Server].</summary>
public sealed class ServerSettings
{
public string Name { get; set; } = "Sunfish";
public bool Http { get; set; } = true;
public int HttpPort { get; set; } = 8080;
public bool Ftp { get; set; } = true;
public int FtpPort { get; set; } = 2121;
/// <summary>Rango de puertos para el modo pasivo, "50000-50050" (vacio: cualquiera libre).</summary>
public string FtpPassivePorts { get; set; } = "50000-50050";
/// <summary>IP que se anuncia en PASV si el servidor esta detras de NAT (null: la local).</summary>
public IPAddress? FtpPublicAddress { get; set; }
/// <summary>Juego de caracteres de los nombres para clientes que no piden UTF-8.</summary>
public string FtpCharset { get; set; } = "iso-8859-1";
public List<IPAddress> Bind { get; set; } = new() { IPAddress.Any, IPAddress.IPv6Any };
public bool RootMenu { get; set; } = true;
public bool SelfDownload { get; set; } = true;
public bool Announce { get; set; } = true;
}
/// <summary>Seccion [Share nombre]: una carpeta publicada en una location.</summary>
public sealed class ShareConfig
{
public string Name { get; set; } = "";
public string Location { get; set; } = "/";
public string Path { get; set; } = "";
public string Index { get; set; } = "index.html";
public bool Enabled { get; set; } = true;
public bool Listing { get; set; } = true;
public bool Upload { get; set; }
public bool Overwrite { get; set; }
public bool Rename { get; set; }
public bool Delete { get; set; }
public bool Zip { get; set; }
public bool Ftp { get; set; } = true;
public bool WebDav { get; set; } = true;
public bool Mac { get; set; } = true;
public bool ShortNames { get; set; } = true;
public List<string> Users { get; set; } = new();
public ShareConfig Clone()
{
var copy = (ShareConfig)MemberwiseClone();
copy.Users = new List<string>(Users);
return copy;
}
}
/// <summary>Seccion [User nombre].</summary>
public sealed class UserConfig
{
public string Name { get; set; } = "";
public string PasswordHash { get; set; } = "";
}
/// <summary>Seccion [Admin].</summary>
public sealed class AdminSettings
{
public bool Enabled { get; set; }
public string PasswordHash { get; set; } = "";
public List<string> AllowFrom { get; set; } = new() { "127.0.0.1", "::1" };
}
/// <summary>
/// Configuracion completa (sunfish.conf). Se lee de un INI y se vuelve a escribir sobre el
/// mismo documento, asi se conservan comentarios y orden.
/// </summary>
public sealed class SunfishConfig
{
private readonly IniDocument _doc;
public SunfishConfig() : this(new IniDocument()) { }
private SunfishConfig(IniDocument doc) => _doc = doc;
public ServerSettings Server { get; } = new();
public AdminSettings Admin { get; } = new();
public List<ShareConfig> Shares { get; } = new();
public List<UserConfig> Users { get; } = new();
/// <summary>Fichero del que se leyo (null si se creo en memoria).</summary>
public string? FilePath { get; set; }
/// <summary>Errores encontrados al leer; la configuracion se usa igualmente.</summary>
public List<string> Errors { get; } = new();
/// <summary>
/// Copia independiente (mismo texto, comentarios incluidos) para editarla sin tocar la que
/// esta sirviendo; se aplica despues con SunfishHost.ApplyAsync.
/// </summary>
public SunfishConfig Clone()
{
var copy = Parse(ToText());
copy.FilePath = FilePath;
return copy;
}
public static SunfishConfig Load(string path)
{
var config = Parse(File.ReadAllText(path));
config.FilePath = path;
return config;
}
public static SunfishConfig Parse(string text)
{
var doc = IniDocument.Parse(text);
var config = new SunfishConfig(doc);
foreach (var (line, _) in doc.Errors)
config.Errors.Add(Strings.ConfigLine(line, Strings.ConfigBadLine));
foreach (var section in doc.Sections)
{
var reader = new SectionReader(section, config.Errors);
switch (section.Kind.ToLowerInvariant())
{
case "server":
config.ReadServer(reader);
break;
case "admin":
config.ReadAdmin(reader);
break;
case "share":
config.Shares.Add(ReadShare(reader, section.Title));
break;
case "user":
config.Users.Add(new UserConfig { Name = section.Title, PasswordHash = reader.String("PasswordHash", "") });
break;
default:
config.Errors.Add(Strings.ConfigLine(section.LineNumber, Strings.ConfigUnknownSection(section.Name)));
break;
}
}
config.Validate();
return config;
}
private void ReadServer(SectionReader r)
{
Server.Name = r.String("Name", Server.Name);
Server.Http = r.Bool("Http", Server.Http);
Server.HttpPort = r.Port("HttpPort", Server.HttpPort);
Server.Ftp = r.Bool("Ftp", Server.Ftp);
Server.FtpPort = r.Port("FtpPort", Server.FtpPort);
Server.FtpPassivePorts = r.String("FtpPassivePorts", Server.FtpPassivePorts);
Server.FtpCharset = r.String("FtpCharset", Server.FtpCharset);
if (r.String("FtpPublicAddress", "") is { Length: > 0 } publicAddress)
{
if (IPAddress.TryParse(publicAddress, out var parsed))
Server.FtpPublicAddress = parsed;
else
r.Error(Strings.ConfigBadAddress(publicAddress));
}
Server.RootMenu = r.Bool("RootMenu", Server.RootMenu);
Server.SelfDownload = r.Bool("SelfDownload", Server.SelfDownload);
Server.Announce = r.Bool("Announce", Server.Announce);
var bind = r.List("Bind");
if (bind is not null)
{
var addresses = new List<IPAddress>();
foreach (var item in bind)
{
if (IPAddress.TryParse(item, out var address))
addresses.Add(address);
else
r.Error(Strings.ConfigBadAddress(item));
}
if (addresses.Count > 0)
Server.Bind = addresses;
}
}
private void ReadAdmin(SectionReader r)
{
Admin.Enabled = r.Bool("Enabled", Admin.Enabled);
Admin.PasswordHash = r.String("PasswordHash", "");
Admin.AllowFrom = r.List("AllowFrom") ?? Admin.AllowFrom;
}
private static ShareConfig ReadShare(SectionReader r, string name)
{
var s = new ShareConfig { Name = name };
s.Location = r.String("Location", s.Location);
s.Path = r.String("Path", s.Path);
s.Index = r.String("Index", s.Index);
s.Enabled = r.Bool("Enabled", s.Enabled);
s.Listing = r.Bool("Listing", s.Listing);
s.Upload = r.Bool("Upload", s.Upload);
s.Overwrite = r.Bool("Overwrite", s.Overwrite);
s.Rename = r.Bool("Rename", s.Rename);
s.Delete = r.Bool("Delete", s.Delete);
s.Zip = r.Bool("Zip", s.Zip);
s.Ftp = r.Bool("Ftp", s.Ftp);
s.WebDav = r.Bool("WebDav", s.WebDav);
s.Mac = r.Bool("Mac", s.Mac);
s.ShortNames = r.Bool("ShortNames", s.ShortNames);
s.Users = r.List("Users") ?? s.Users;
return s;
}
/// <summary>Comprueba lo que no depende de una sola clave: locations validas y unicas, rutas.</summary>
private void Validate()
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var share in Shares)
{
if (!LocationPath.TryNormalize(share.Location, out var normalized))
{
Errors.Add(Strings.ConfigBadLocation(share.Name, share.Location));
share.Enabled = false;
continue;
}
share.Location = normalized;
if (string.IsNullOrWhiteSpace(share.Path))
{
Errors.Add(Strings.ConfigMissingPath(share.Name));
share.Enabled = false;
}
if (share.Enabled && !seen.Add(normalized))
{
Errors.Add(Strings.ConfigDuplicateLocation(share.Name, share.Location));
share.Enabled = false;
}
}
}
/// <summary>Texto INI con los valores actuales, sobre el documento original.</summary>
public string ToText()
{
WriteServer(_doc.GetOrAdd("Server"));
var admin = _doc.Find("Admin");
if (admin is not null || Admin.Enabled || Admin.PasswordHash.Length > 0)
WriteAdmin(admin ?? _doc.GetOrAdd("Admin"));
SyncSections("User", Users.Select(u => u.Name), (section, name) =>
{
var user = Users.First(u => u.Name == name);
section.Set("PasswordHash", user.PasswordHash);
});
SyncSections("Share", Shares.Select(s => s.Name), (section, name) =>
WriteShare(section, Shares.First(s => s.Name == name)));
return _doc.ToText(OperatingSystem.IsWindows() ? "\r\n" : "\n");
}
/// <summary>Guarda de forma atomica: fichero temporal y renombrado.</summary>
public void Save(string? path = null)
{
path ??= FilePath ?? throw new InvalidOperationException("No config path");
string full = System.IO.Path.GetFullPath(path);
string? dir = System.IO.Path.GetDirectoryName(full);
if (dir is not null)
Directory.CreateDirectory(dir);
string temp = full + ".tmp";
File.WriteAllText(temp, ToText());
File.Move(temp, full, overwrite: true);
FilePath = full;
}
private void SyncSections(string kind, IEnumerable<string> names, Action<IniSection, string> write)
{
var wanted = names.ToList();
foreach (var section in _doc.Sections.Where(s => s.Kind.Equals(kind, StringComparison.OrdinalIgnoreCase)).ToList())
{
if (!wanted.Contains(section.Title, StringComparer.OrdinalIgnoreCase))
_doc.Remove(section);
}
foreach (var name in wanted)
write(_doc.GetOrAdd($"{kind} {name}"), name);
}
private void WriteServer(IniSection s)
{
var d = new ServerSettings();
SetIf(s, "Name", Server.Name, d.Name);
SetIf(s, "Http", Server.Http, d.Http);
SetIf(s, "HttpPort", Server.HttpPort, d.HttpPort);
SetIf(s, "Ftp", Server.Ftp, d.Ftp);
SetIf(s, "FtpPort", Server.FtpPort, d.FtpPort);
SetIf(s, "FtpPassivePorts", Server.FtpPassivePorts, d.FtpPassivePorts);
SetIf(s, "FtpPublicAddress", Server.FtpPublicAddress?.ToString() ?? "", "");
SetIf(s, "FtpCharset", Server.FtpCharset, d.FtpCharset);
SetIf(s, "Bind", string.Join(", ", Server.Bind), string.Join(", ", d.Bind));
SetIf(s, "RootMenu", Server.RootMenu, d.RootMenu);
SetIf(s, "SelfDownload", Server.SelfDownload, d.SelfDownload);
SetIf(s, "Announce", Server.Announce, d.Announce);
}
private void WriteAdmin(IniSection s)
{
s.Set("Enabled", Format(Admin.Enabled));
s.Set("PasswordHash", Admin.PasswordHash);
s.Set("AllowFrom", string.Join(", ", Admin.AllowFrom));
}
private static void WriteShare(IniSection s, ShareConfig share)
{
var d = new ShareConfig();
s.Set("Location", share.Location);
s.Set("Path", share.Path);
SetIf(s, "Index", share.Index, d.Index);
SetIf(s, "Enabled", share.Enabled, d.Enabled);
SetIf(s, "Listing", share.Listing, d.Listing);
SetIf(s, "Upload", share.Upload, d.Upload);
SetIf(s, "Overwrite", share.Overwrite, d.Overwrite);
SetIf(s, "Rename", share.Rename, d.Rename);
SetIf(s, "Delete", share.Delete, d.Delete);
SetIf(s, "Zip", share.Zip, d.Zip);
SetIf(s, "Ftp", share.Ftp, d.Ftp);
SetIf(s, "WebDav", share.WebDav, d.WebDav);
SetIf(s, "Mac", share.Mac, d.Mac);
SetIf(s, "ShortNames", share.ShortNames, d.ShortNames);
SetIf(s, "Users", string.Join(", ", share.Users), "");
}
/// <summary>Escribe la clave si ya estaba en el fichero o si el valor no es el de por defecto.</summary>
private static void SetIf(IniSection s, string key, string value, string defaultValue)
{
if (s.Has(key) || value != defaultValue)
s.Set(key, value);
}
private static void SetIf(IniSection s, string key, bool value, bool defaultValue)
{
// "yes", "on" o "1" se respetan si siguen significando lo mismo.
if (s.Get(key) is { } current && TryParseBool(current, out bool same) && same == value)
return;
SetIf(s, key, Format(value), Format(defaultValue));
}
private static bool TryParseBool(string text, out bool value)
{
switch (text.Trim().ToLowerInvariant())
{
case "true": case "yes": case "on": case "1": value = true; return true;
case "false": case "no": case "off": case "0": value = false; return true;
default: value = false; return false;
}
}
private static void SetIf(IniSection s, string key, int value, int defaultValue) =>
SetIf(s, key, value.ToString(CultureInfo.InvariantCulture), defaultValue.ToString(CultureInfo.InvariantCulture));
private static string Format(bool value) => value ? "true" : "false";
/// <summary>Lectura tipada de una seccion, acumulando errores con su numero de linea.</summary>
private sealed class SectionReader(IniSection section, List<string> errors)
{
private int _line = section.LineNumber;
public void Error(string message) => errors.Add(Strings.ConfigLine(_line, message));
private string? Raw(string key)
{
var entry = section.FindEntry(key);
if (entry is null)
return null;
_line = entry.Number;
return entry.Value;
}
public string String(string key, string defaultValue) => Raw(key) ?? defaultValue;
public bool Bool(string key, bool defaultValue)
{
string? value = Raw(key);
if (value is null)
return defaultValue;
if (TryParseBool(value, out bool result))
return result;
Error(Strings.ConfigBadBool(key, value));
return defaultValue;
}
public int Port(string key, int defaultValue)
{
string? value = Raw(key);
if (value is null)
return defaultValue;
if (int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out int port) && port is >= 0 and <= 65535)
return port;
Error(Strings.ConfigBadNumber(key, value));
return defaultValue;
}
public List<string>? List(string key)
{
string? value = Raw(key);
if (value is null)
return null;
return value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
}
}
}