diff --git a/MetaforceInstaller.Cloud/ICloudService.cs b/MetaforceInstaller.Cloud/ICloudService.cs new file mode 100644 index 0000000..a179bba --- /dev/null +++ b/MetaforceInstaller.Cloud/ICloudService.cs @@ -0,0 +1,8 @@ +using MetaforceInstaller.Cloud.Models; + +namespace MetaforceInstaller.Cloud; + +public interface ICloudService +{ + Task> GetObjects(string webLink); +} \ No newline at end of file diff --git a/MetaforceInstaller.Cloud/Implementations/MailRu/DTO/CloudObjectResponse.cs b/MetaforceInstaller.Cloud/Implementations/MailRu/DTO/CloudObjectResponse.cs new file mode 100644 index 0000000..7f2bdb3 --- /dev/null +++ b/MetaforceInstaller.Cloud/Implementations/MailRu/DTO/CloudObjectResponse.cs @@ -0,0 +1,181 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MetaforceInstaller.Cloud.Implementations.MailRu.DTO; + +public sealed class CloudObjectsResponse +{ + [JsonPropertyName("count")] + public CountInfo? Count { get; init; } + + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("weblink")] + public string? WebLink { get; init; } + + [JsonPropertyName("size")] + public long? Size { get; init; } + + [JsonPropertyName("rev")] + public long? Rev { get; init; } + + [JsonPropertyName("kind")] + public string? Kind { get; init; } + + [JsonPropertyName("type")] + public string? Type { get; init; } + + [JsonPropertyName("public")] + public PublicInfo? Public { get; init; } + + [JsonPropertyName("list")] + public List? List { get; init; } + + [JsonPropertyName("owner")] + public OwnerInfo? Owner { get; init; } +} + +public sealed class CountInfo +{ + [JsonPropertyName("folders")] + public int? Folders { get; init; } + + [JsonPropertyName("files")] + public int? Files { get; init; } +} + +public sealed class PublicInfo +{ + [JsonPropertyName("type")] + public string? Type { get; init; } + + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("id")] + public string? Id { get; init; } + + [JsonPropertyName("ctime")] + public long? CTime { get; init; } + + [JsonPropertyName("views")] + public long? Views { get; init; } + + [JsonPropertyName("downloads")] + public long? Downloads { get; init; } +} + +public sealed class OwnerInfo +{ + [JsonPropertyName("email")] + public string? Email { get; init; } + + [JsonPropertyName("user_flags")] + public UserFlags? UserFlags { get; init; } +} + +public sealed class UserFlags +{ + [JsonPropertyName("PAID_ACCOUNT")] + public bool? PaidAccount { get; init; } +} + +/// +/// Базовый тип элемента из "list". Конкретный тип выбирается по полю "type". +/// +[JsonConverter(typeof(CloudObjectJsonConverter))] +public abstract class CloudObjectBase +{ + [JsonPropertyName("name")] + public string? Name { get; init; } + + [JsonPropertyName("weblink")] + public string? WebLink { get; init; } + + [JsonPropertyName("size")] + public long? Size { get; init; } + + [JsonPropertyName("kind")] + public string? Kind { get; init; } + + [JsonPropertyName("type")] + public string? Type { get; init; } +} + +public sealed class CloudFolderObject : CloudObjectBase +{ + [JsonPropertyName("count")] + public CountInfo? Count { get; init; } + + [JsonPropertyName("rev")] + public long? Rev { get; init; } +} + +public sealed class CloudFileObject : CloudObjectBase +{ + [JsonPropertyName("mtime")] + public long? MTime { get; init; } + + [JsonPropertyName("hash")] + public string? Hash { get; init; } +} + +public sealed class ZipWebLinkRequest +{ + [JsonPropertyName("x-email")] + public string XEmail { get; init; } + + [JsonPropertyName("weblink_list")] + public IReadOnlyList WeblinkList { get; init; } + + [JsonPropertyName("name")] + public string Name { get; init; } +} + +public sealed class ZipWebLinkResponse +{ + [JsonPropertyName("key")] + public string Key { get; init; } +} + +/// +/// Конвертер, который смотрит на поле "type" и десериализует в CloudFolderObject или CloudFileObject. +/// +public sealed class CloudObjectJsonConverter : JsonConverter +{ + public override CloudObjectBase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var doc = JsonDocument.ParseValue(ref reader); + + var root = doc.RootElement; + if (!root.TryGetProperty("type", out var typeProp)) + throw new JsonException("Missing required property 'type' for cloud object."); + + var type = typeProp.GetString(); + + return type switch + { + "folder" => root.Deserialize(options) + ?? throw new JsonException("Failed to deserialize folder object."), + "file" => root.Deserialize(options) + ?? throw new JsonException("Failed to deserialize file object."), + _ => throw new JsonException($"Unknown cloud object type '{type}'.") + }; + } + + public override void Write(Utf8JsonWriter writer, CloudObjectBase value, JsonSerializerOptions options) + { + switch (value) + { + case CloudFolderObject folder: + JsonSerializer.Serialize(writer, folder, options); + break; + case CloudFileObject file: + JsonSerializer.Serialize(writer, file, options); + break; + default: + throw new JsonException($"Unknown runtime type '{value.GetType().Name}'."); + } + } +} \ No newline at end of file diff --git a/MetaforceInstaller.Cloud/Implementations/MailRu/MailRuCloudService.cs b/MetaforceInstaller.Cloud/Implementations/MailRu/MailRuCloudService.cs new file mode 100644 index 0000000..d7ae0ca --- /dev/null +++ b/MetaforceInstaller.Cloud/Implementations/MailRu/MailRuCloudService.cs @@ -0,0 +1,116 @@ +using System.Text; +using System.Text.Json; +using System.Web; +using MetaforceInstaller.Cloud.Implementations.MailRu.DTO; +using MetaforceInstaller.Cloud.Models; + +namespace MetaforceInstaller.Cloud.Implementations.MailRu; + +public class MailRuCloudService : ICloudService +{ + private const string BaseUrl = "https://cloud.mail.ru"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly HttpClient _httpClient; + + public MailRuCloudService(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task> GetObjects(string webLink) + { + var dto = await GetFromPublicApi( + path: "/api/v4/public/list", + query: new Dictionary + { + ["weblink"] = webLink, + ["sort"] = "name", + ["order"] = "asc", + ["offset"] = "0", + ["limit"] = "500", + ["version"] = "4" + }).ConfigureAwait(false); + + if (dto?.List is null || dto.List.Count == 0) + return []; + + var result = new List(dto.List.Count); + foreach (var entry in dto.List) + result.Add(Map(entry)); + + return result; + } + + public async Task GetDownloadLink(IEnumerable webLinks, string outputFileName = "archive") + { + var result = await PostToPublicApi( + path: "/api/v3/zip/weblink", + query: new Dictionary(), + new ZipWebLinkRequest + { + XEmail = "anonym", + WeblinkList = webLinks.ToList(), + Name = outputFileName + } + ); + + return result?.Key; + } + + private async Task GetFromPublicApi(string path, IReadOnlyDictionary query) + { + var uri = BuildUri(path, query); + + using var response = await _httpClient.GetAsync(uri).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return JsonSerializer.Deserialize(json, JsonOptions); + } + + private async Task PostToPublicApi(string path, IReadOnlyDictionary query, + object body) + { + var uri = BuildUri(path, query); + + var jsonBody = JsonSerializer.Serialize(body, JsonOptions); + using var content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); + + using var response = await _httpClient.PostAsync(uri, content).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return JsonSerializer.Deserialize(json, JsonOptions); + } + + private static Uri BuildUri(string path, IReadOnlyDictionary query) + { + var builder = new UriBuilder(BaseUrl) + { + Port = -1, + Path = path + }; + + var qs = HttpUtility.ParseQueryString(builder.Query); + foreach (var (key, value) in query) + qs[key] = value; + + builder.Query = qs.ToString(); + return builder.Uri; + } + + private static CloudObject Map(CloudObjectBase entry) + { + return new CloudObject + { + Name = entry.Name, + WebLink = entry.WebLink, + Type = entry.Type == "folder" ? CloudObjectType.Folder : CloudObjectType.File + }; + } +} \ No newline at end of file diff --git a/MetaforceInstaller.Cloud/MetaforceInstaller.Cloud.csproj b/MetaforceInstaller.Cloud/MetaforceInstaller.Cloud.csproj new file mode 100644 index 0000000..17b910f --- /dev/null +++ b/MetaforceInstaller.Cloud/MetaforceInstaller.Cloud.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/MetaforceInstaller.Cloud/Models/CloudObject.cs b/MetaforceInstaller.Cloud/Models/CloudObject.cs new file mode 100644 index 0000000..643a9d5 --- /dev/null +++ b/MetaforceInstaller.Cloud/Models/CloudObject.cs @@ -0,0 +1,14 @@ +namespace MetaforceInstaller.Cloud.Models; + +public class CloudObject +{ + public string Name { get; set; } + public string WebLink { get; set; } + public CloudObjectType Type { get; set; } +} + +public enum CloudObjectType +{ + Folder, + File +} \ No newline at end of file