Pin nixpkgs to the revision used by itc.componentware: the channel version ships a latexminted that crashes on Python 3.14, which made every listing fail and sent latexmk into an endless error prompt. Also force nonstopmode/halt-on-error so a broken listing can never loop again.
113 lines
3.5 KiB
C#
113 lines
3.5 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
using System.Text;
|
|
|
|
namespace Houston.Model.Documents;
|
|
|
|
public readonly record struct OrgSlug
|
|
{
|
|
public const int MaxLength = 128;
|
|
|
|
private const string CharactersToReplace = @"/\{}^%`][""><~#|";
|
|
|
|
private const char ReplacementCharacter = '-';
|
|
|
|
/// <summary>
|
|
/// Characters that must not start or end a folder name, because they are either invisible or
|
|
/// break path semantics on the client side.
|
|
/// </summary>
|
|
private static readonly char[] UntrimmableEdgeCharacters = [' ', '.', ReplacementCharacter];
|
|
|
|
public string Value { get; }
|
|
|
|
private OrgSlug(string value) => Value = value;
|
|
|
|
public static bool TryFromS3Key(string key, [NotNullWhen(true)] out OrgSlug? result)
|
|
{
|
|
result = null;
|
|
|
|
if (!key.EndsWith('/')) return false;
|
|
|
|
var parts = key.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
return parts is [var name] && TryFromFolderName(name, out result);
|
|
}
|
|
|
|
public static bool TryFromFolderName(string name, [NotNullWhen(true)] out OrgSlug? result)
|
|
{
|
|
result = null;
|
|
|
|
if (String.IsNullOrWhiteSpace(name)) return false;
|
|
if (name.Contains('/')) return false;
|
|
if (name == "..") return false;
|
|
|
|
result = new OrgSlug(name);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the canonical folder name for an organization.
|
|
/// </summary>
|
|
/// <returns>
|
|
/// <c>false</c> if the name contains nothing that survives the normalization, in which case
|
|
/// there is no folder name Houston could safely claim.
|
|
/// </returns>
|
|
public static bool TryFrom(OrgName name, [NotNullWhen(true)] out OrgSlug? result)
|
|
{
|
|
result = null;
|
|
|
|
var slug = Normalize(name.Value, MaxLength);
|
|
if (slug.Length == 0)
|
|
return false;
|
|
|
|
result = new OrgSlug(slug);
|
|
return true;
|
|
}
|
|
|
|
public static bool TryFrom(OrgName name, OrgId id, [NotNullWhen(true)] out OrgSlug? result)
|
|
{
|
|
result = null;
|
|
|
|
var normalizedId = Normalize(id.Value, MaxLength);
|
|
if (normalizedId.Length == 0)
|
|
return false;
|
|
|
|
// The id is what makes the folder name unique, so the name is what gets truncated.
|
|
var suffix = $" ({normalizedId})";
|
|
if (suffix.Length >= MaxLength)
|
|
return false;
|
|
|
|
var slug = Normalize(name.Value, MaxLength - suffix.Length);
|
|
|
|
result = new OrgSlug(slug.Length == 0 ? normalizedId : slug + suffix);
|
|
return true;
|
|
}
|
|
|
|
private static string Normalize(string value, int maxLength)
|
|
{
|
|
var builder = new StringBuilder(Math.Min(value.Length, maxLength));
|
|
|
|
foreach (var character in value.Trim().TakeWhile(c => builder.Length != maxLength))
|
|
{
|
|
builder.Append(Char.IsControl(character) || CharactersToReplace.Contains(character)
|
|
? ReplacementCharacter
|
|
: character);
|
|
}
|
|
|
|
// Truncating can uncover a trailing space or dot, so trimming happens afterwards.
|
|
return builder.ToString().Trim().Trim(UntrimmableEdgeCharacters).Trim();
|
|
}
|
|
|
|
/// <summary>
|
|
/// S3 prefix of the folder, including the trailing slash.
|
|
/// </summary>
|
|
public override string ToString()
|
|
{
|
|
// A default(OrgSlug) would resolve to the bucket root and expose every customer's
|
|
// documents, so it must never be used as a prefix.
|
|
if (String.IsNullOrEmpty(Value))
|
|
throw new InvalidOperationException("An uninitialized OrgSlug cannot be used as an S3 prefix.");
|
|
|
|
return Value + '/';
|
|
}
|
|
}
|