diff --git a/DisCatSharp.Tests/SafetyTests/DisCatSharp.SafetyTests.csproj b/DisCatSharp.Tests/SafetyTests/DisCatSharp.SafetyTests.csproj index bde63f6e9..8be25edd3 100644 --- a/DisCatSharp.Tests/SafetyTests/DisCatSharp.SafetyTests.csproj +++ b/DisCatSharp.Tests/SafetyTests/DisCatSharp.SafetyTests.csproj @@ -1,23 +1,23 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/DisCatSharp/Entities/DiscordSignedLink.cs b/DisCatSharp/Entities/DiscordSignedLink.cs new file mode 100644 index 000000000..622c3ca23 --- /dev/null +++ b/DisCatSharp/Entities/DiscordSignedLink.cs @@ -0,0 +1,85 @@ +using System; +using System.Globalization; +using System.Web; + +namespace DisCatSharp.Entities; + +/// +/// Represents a used for attachments and other things to improve security +/// and prevent bad actors from abusing Discord's CDN. +/// +public class DiscordSignedLink : Uri +{ + /// + /// When the signed link expires. + /// + public DateTimeOffset? ExpiresAt { get; init; } + + /// + /// When the signed link was generated. + /// + public DateTimeOffset? IssuedAt { get; init; } + + /// + /// The signature of the signed link. + /// + public string? Signature { get; init; } + + /// + /// Initializes a new instance of the class with the specified URI for signed discord links. + /// + /// An . + public DiscordSignedLink(Uri uri) + : base(uri.AbsoluteUri) + { + ArgumentNullException.ThrowIfNull(uri); + + if (string.IsNullOrWhiteSpace(this.Query)) + return; + + var queries = HttpUtility.ParseQueryString(this.Query); + + if (!queries.HasKeys()) + return; + + if (queries.Get("ex") is { } expiresString && long.TryParse(expiresString, NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out var expiresTimeStamp)) + this.ExpiresAt = DateTimeOffset.FromUnixTimeSeconds(expiresTimeStamp); + + if (queries.Get("is") is { } issuedString && + long.TryParse(issuedString, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var issuedTimeStamp)) + this.IssuedAt = DateTimeOffset.FromUnixTimeSeconds(issuedTimeStamp); + + this.Signature = queries.Get("hm"); + } + + /// + /// Initializes a new instance of the class with the specified URI for signed discord links. + /// + /// A string that identifies the resource to be represented by the instance. + public DiscordSignedLink(string uriString) + : base(uriString) + { + ArgumentNullException.ThrowIfNull(uriString); + + if (string.IsNullOrWhiteSpace(this.Query)) + return; + + var queries = HttpUtility.ParseQueryString(this.Query); + + if (!queries.HasKeys()) + return; + + if (queries.Get("ex") is { } expiresString && long.TryParse(expiresString, NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out var expiresTimeStamp)) + this.ExpiresAt = DateTimeOffset.FromUnixTimeSeconds(expiresTimeStamp); + + if (queries.Get("is") is { } issuedString && + long.TryParse(issuedString, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var issuedTimeStamp)) + this.IssuedAt = DateTimeOffset.FromUnixTimeSeconds(issuedTimeStamp); + + this.Signature = queries.Get("hm"); + } +} diff --git a/DisCatSharp/Entities/DiscordUri.cs b/DisCatSharp/Entities/DiscordUri.cs index aab5e77ea..f9efeed3c 100644 --- a/DisCatSharp/Entities/DiscordUri.cs +++ b/DisCatSharp/Entities/DiscordUri.cs @@ -1,142 +1,145 @@ using System; using System.Runtime.CompilerServices; using Newtonsoft.Json; -namespace DisCatSharp.Net; +namespace DisCatSharp.Entities; /// /// An URI in a Discord embed doesn't necessarily conform to the RFC 3986. If it uses the attachment:// /// protocol, it mustn't contain a trailing slash to be interpreted correctly as an embed attachment reference by /// Discord. /// [JsonConverter(typeof(DiscordUriJsonConverter))] -public class DiscordUri +public sealed class DiscordUri : DiscordSignedLink { private readonly object _value; /// /// The type of this URI. /// public DiscordUriType Type { get; } /// /// Initializes a new instance of the class. /// /// The value. internal DiscordUri(Uri value) + : base(value) { this._value = value ?? throw new ArgumentNullException(nameof(value)); this.Type = DiscordUriType.Standard; } /// /// Initializes a new instance of the class. /// /// The value. internal DiscordUri(string value) + : base(value) { ArgumentNullException.ThrowIfNull(value); if (IsStandard(value)) { - this._value = new Uri(value); + this._value = new DiscordSignedLink(value); this.Type = DiscordUriType.Standard; } else { this._value = value; this.Type = DiscordUriType.NonStandard; } } /// /// Whether the uri is a standard uri /// /// Uri string [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool IsStandard(string value) => !value.StartsWith("attachment://", StringComparison.Ordinal); /// /// Returns a string representation of this DiscordUri. /// /// This DiscordUri, as a string. public override string ToString() => this._value.ToString(); /// /// Converts this DiscordUri into a canonical representation of a if it can be represented as /// such, throwing an exception otherwise. /// /// A canonical representation of this DiscordUri. /// If is not , as /// that would mean creating an invalid Uri, which would result in loss of data. public Uri ToUri() => this.Type == DiscordUriType.Standard ? this._value as Uri : throw new UriFormatException( - $@"DiscordUri ""{this._value}"" would be invalid as a regular URI, please the {nameof(this.Type)} property first."); + $@"DiscordUri ""{this._value}"" would be invalid as a regular URI, please set the correct {nameof(this.Type)} property first."); /// /// Represents a uri json converter. /// internal sealed class DiscordUriJsonConverter : JsonConverter { /// /// Writes the json. /// /// The writer. /// The value. /// The serializer. - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) => writer.WriteValue((value as DiscordUri)._value); + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + => writer.WriteValue((value as DiscordUri)._value); /// /// Reads the json. /// /// The reader. /// The object type. /// The existing value. /// The serializer. public override object ReadJson( JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer ) { var val = reader.Value; return val == null ? null : val is not string s ? throw new JsonReaderException("DiscordUri value invalid format! This is a bug in DisCatSharp. " + $"Include the type in your bug report: [[{reader.TokenType}]]") : IsStandard(s) ? new(new Uri(s)) : new DiscordUri(s); } /// /// Whether it can be converted. /// /// The object type. /// A bool. public override bool CanConvert(Type objectType) => objectType == typeof(DiscordUri); } } /// /// Represents a uri type. /// public enum DiscordUriType : byte { /// /// Represents a URI that conforms to RFC 3986, meaning it's stored internally as a and will /// contain a trailing slash after the domain name. /// Standard, /// /// Represents a URI that does not conform to RFC 3986, meaning it's stored internally as a plain string and /// should be treated as one. /// NonStandard } diff --git a/DisCatSharp/Entities/Embed/DiscordEmbedVideo.cs b/DisCatSharp/Entities/Embed/DiscordEmbedVideo.cs index b30209971..f1be45662 100644 --- a/DisCatSharp/Entities/Embed/DiscordEmbedVideo.cs +++ b/DisCatSharp/Entities/Embed/DiscordEmbedVideo.cs @@ -1,35 +1,35 @@ using System; using Newtonsoft.Json; namespace DisCatSharp.Entities; /// /// Represents a video inside an embed. /// public sealed class DiscordEmbedVideo : ObservableApiObject { /// /// Gets the source url of the video. /// [JsonProperty("url", NullValueHandling = NullValueHandling.Ignore)] - public Uri Url { get; internal set; } + public DiscordSignedLink Url { get; internal set; } /// /// Gets the height of the video. /// [JsonProperty("height", NullValueHandling = NullValueHandling.Ignore)] public int Height { get; internal set; } /// /// Gets the width of the video. /// [JsonProperty("width", NullValueHandling = NullValueHandling.Ignore)] public int Width { get; internal set; } /// /// Initializes a new instance of the class. /// internal DiscordEmbedVideo() { } } diff --git a/DisCatSharp/Entities/Message/DiscordAttachment.cs b/DisCatSharp/Entities/Message/DiscordAttachment.cs index c2695f1a7..51bf71e27 100644 --- a/DisCatSharp/Entities/Message/DiscordAttachment.cs +++ b/DisCatSharp/Entities/Message/DiscordAttachment.cs @@ -1,99 +1,99 @@ using DisCatSharp.Enums; using Newtonsoft.Json; namespace DisCatSharp.Entities; /// /// Represents an attachment for a message. /// public class DiscordAttachment : NullableSnowflakeObject { /// /// Gets the name of the file. /// [JsonProperty("filename", NullValueHandling = NullValueHandling.Ignore)] public string Filename { get; internal set; } /// /// Gets the description of the file. /// [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] public string? Description { get; set; } /// /// Gets the media, or MIME, type of the file. /// [JsonProperty("content_type", NullValueHandling = NullValueHandling.Ignore)] public string MediaType { get; internal set; } /// /// Gets the file size in bytes. /// [JsonProperty("size", NullValueHandling = NullValueHandling.Ignore)] public int? FileSize { get; internal set; } /// /// Gets the URL of the file. /// [JsonProperty("url", NullValueHandling = NullValueHandling.Ignore)] - public string Url { get; internal set; } + public DiscordSignedLink Url { get; internal set; } /// /// Gets the proxied URL of the file. /// [JsonProperty("proxy_url", NullValueHandling = NullValueHandling.Ignore)] - public string ProxyUrl { get; internal set; } + public DiscordSignedLink ProxyUrl { get; internal set; } /// /// Gets the height. Applicable only if the attachment is an image. /// [JsonProperty("height", NullValueHandling = NullValueHandling.Ignore)] public int? Height { get; internal set; } /// /// Gets the width. Applicable only if the attachment is an image. /// [JsonProperty("width", NullValueHandling = NullValueHandling.Ignore)] public int? Width { get; internal set; } /// /// Gets the uploaded filename if the attachment was uploaded via GCP. /// [JsonProperty("uploaded_filename", NullValueHandling = NullValueHandling.Ignore)] internal string? UploadedFilename { get; set; } /// /// Gets whether this attachment is ephemeral. /// Ephemeral attachments will automatically be removed after a set period of time. /// Ephemeral attachments on messages are guaranteed to be available as long as the message itself exists. /// [JsonProperty("ephemeral", NullValueHandling = NullValueHandling.Ignore)] public bool? Ephemeral { get; internal set; } /// /// The duration in seconds of the audio file (currently only for voice messages). /// Only presented when the message flags include and for the attached voice message. /// [JsonProperty("duration_secs", NullValueHandling = NullValueHandling.Ignore)] public float? DurationSecs { get; internal set; } /// /// The base64 encoded byte-array representing a sampled waveform (currently only for voice messages). /// Only presented when the message flags include and for the attached voice message. /// [JsonProperty("waveform", NullValueHandling = NullValueHandling.Ignore)] public string WaveForm { get; internal set; } /// /// Gets the attachment flags. /// [JsonProperty("flags", NullValueHandling = NullValueHandling.Ignore)] public AttachmentFlags Flags { get; internal set; } = AttachmentFlags.None; /// /// Initializes a new instance of the class. /// internal DiscordAttachment() { } }