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()
{ }
}