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 = '-'; /// /// Characters that must not start or end a folder name, because they are either invisible or /// break path semantics on the client side. /// 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; } /// /// Builds the canonical folder name for an organization. /// /// /// false if the name contains nothing that survives the normalization, in which case /// there is no folder name Houston could safely claim. /// 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(); } /// /// S3 prefix of the folder, including the trailing slash. /// 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 + '/'; } }