From 214ceda8903bafc28981b58a0530c2a15d7812cc Mon Sep 17 00:00:00 2001 From: crupest Date: Thu, 25 Apr 2019 19:03:46 +0800 Subject: Rename the cos service file. Fix a bug in test. --- Timeline/Services/QCloudCosService.cs | 257 ++++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 Timeline/Services/QCloudCosService.cs (limited to 'Timeline/Services/QCloudCosService.cs') diff --git a/Timeline/Services/QCloudCosService.cs b/Timeline/Services/QCloudCosService.cs new file mode 100644 index 00000000..f4358714 --- /dev/null +++ b/Timeline/Services/QCloudCosService.cs @@ -0,0 +1,257 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Timeline.Configs; + +namespace Timeline.Services +{ + public interface IQCloudCosService + { + /// + /// Test if an object in the bucket exists. + /// + /// The bucket name. + /// The object key. + /// True if exists. False if not. + Task IsObjectExists(string bucket, string key); + + /// + /// Generate a presignated url to access the object. + /// + /// The bucket name. + /// The object key. + /// The presignated url. + string GenerateObjectGetUrl(string bucket, string key); + } + + public class QCloudCosService : IQCloudCosService + { + private readonly IOptionsMonitor _config; + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + + public QCloudCosService(IOptionsMonitor config, ILogger logger, IHttpClientFactory httpClientFactory) + { + _config = config; + _logger = logger; + _httpClientFactory = httpClientFactory; + } + + private const string BucketNamePattern = @"^(([a-z0-9][a-z0-9-]*[a-z0-9])|[a-z0-9])$"; + + public static bool ValidateBucketName(string bucketName) + { + return Regex.IsMatch(bucketName, BucketNamePattern); + } + + public class QCloudCredentials + { + public string SecretId { get; set; } + public string SecretKey { get; set; } + } + + public class RequestInfo + { + public string Method { get; set; } + public string Uri { get; set; } + public IEnumerable> Parameters { get; set; } + public IEnumerable> Headers { get; set; } + } + + public class TimeDuration + { + public TimeDuration() + { + + } + + public TimeDuration(DateTimeOffset start, DateTimeOffset end) + { + Start = start; + End = end; + } + + public DateTimeOffset Start { get; set; } + public DateTimeOffset End { get; set; } + } + + public static string GenerateSign(QCloudCredentials credentials, RequestInfo request, TimeDuration signValidTime) + { + Debug.Assert(credentials != null); + Debug.Assert(credentials.SecretId != null); + Debug.Assert(credentials.SecretKey != null); + Debug.Assert(request != null); + Debug.Assert(request.Method != null); + Debug.Assert(request.Uri != null); + Debug.Assert(signValidTime != null); + Debug.Assert(signValidTime.Start < signValidTime.End, "Start must be before End in sign valid time."); + + List<(string key, string value)> Transform(IEnumerable> raw) + { + if (raw == null) + return new List<(string key, string value)>(); + + var sorted = raw.Select(p => (key: p.Key.ToLower(), value: WebUtility.UrlEncode(p.Value))).ToList(); + sorted.Sort((left, right) => string.CompareOrdinal(left.key, right.key)); + return sorted; + } + + var transformedParameters = Transform(request.Parameters); + var transformedHeaders = Transform(request.Headers); + + List<(string, string)> result = new List<(string, string)>(); + + const string signAlgorithm = "sha1"; + result.Add(("q-sign-algorithm", signAlgorithm)); + + result.Add(("q-ak", credentials.SecretId)); + + var signTime = $"{signValidTime.Start.ToUnixTimeSeconds().ToString()};{signValidTime.End.ToUnixTimeSeconds().ToString()}"; + var keyTime = signTime; + result.Add(("q-sign-time", signTime)); + result.Add(("q-key-time", keyTime)); + + result.Add(("q-header-list", string.Join(';', transformedHeaders.Select(h => h.key)))); + result.Add(("q-url-param-list", string.Join(';', transformedParameters.Select(p => p.key)))); + + HMACSHA1 hmac = new HMACSHA1(); + + string ByteArrayToString(byte[] bytes) + { + return BitConverter.ToString(bytes).Replace("-", "").ToLower(); + } + + hmac.Key = Encoding.UTF8.GetBytes(credentials.SecretKey); + var signKey = ByteArrayToString(hmac.ComputeHash(Encoding.UTF8.GetBytes(keyTime))); + + string Join(IEnumerable<(string key, string value)> raw) + { + return string.Join('&', raw.Select(p => string.Concat(p.key, "=", p.value))); + } + + var httpString = new StringBuilder() + .Append(request.Method.ToLower()).Append('\n') + .Append(request.Uri).Append('\n') + .Append(Join(transformedParameters)).Append('\n') + .Append(Join(transformedHeaders)).Append('\n') + .ToString(); + + string Sha1(string data) + { + var sha1 = SHA1.Create().ComputeHash(Encoding.UTF8.GetBytes(data)); + return ByteArrayToString(sha1); + } + + var stringToSign = new StringBuilder() + .Append(signAlgorithm).Append('\n') + .Append(signTime).Append('\n') + .Append(Sha1(httpString)).Append('\n') + .ToString(); + + hmac.Key = Encoding.UTF8.GetBytes(signKey); + var signature = ByteArrayToString(hmac.ComputeHash( + Encoding.UTF8.GetBytes(stringToSign))); + + result.Add(("q-signature", signature)); + + return Join(result); + } + + private QCloudCredentials GetCredentials() + { + var config = _config.CurrentValue; + return new QCloudCredentials + { + SecretId = config.SecretId, + SecretKey = config.SecretKey + }; + } + + private string GetHost(string bucket) + { + var config = _config.CurrentValue; + return $"{bucket}-{config.AppId}.cos.{config.Region}.myqcloud.com"; + } + + public async Task IsObjectExists(string bucket, string key) + { + if (bucket == null) + throw new ArgumentNullException(nameof(bucket)); + if (key == null) + throw new ArgumentNullException(nameof(key)); + if (!ValidateBucketName(bucket)) + throw new ArgumentException($"Bucket name is not valid. Param is {bucket} .", nameof(bucket)); + + var client = _httpClientFactory.CreateClient(); + + var host = GetHost(bucket); + var encodedKey = WebUtility.UrlEncode(key); + + var request = new HttpRequestMessage(); + request.Method = HttpMethod.Head; + request.RequestUri = new Uri($"https://{host}/{encodedKey}"); + request.Headers.Host = host; + request.Headers.Date = DateTimeOffset.Now; + request.Headers.TryAddWithoutValidation("Authorization", GenerateSign(GetCredentials(), new RequestInfo + { + Method = "head", + Uri = "/" + encodedKey, + Headers = new Dictionary + { + ["Host"] = host + } + }, new TimeDuration(DateTimeOffset.Now, DateTimeOffset.Now.AddMinutes(2)))); + + try + { + var response = await client.SendAsync(request); + + if (response.IsSuccessStatusCode) + return true; + if (response.StatusCode == HttpStatusCode.NotFound) + return false; + + throw new Exception($"Unknown response code. {response.ToString()}"); + } + catch (Exception e) + { + _logger.LogError(e, "An error occured when test a cos object existence."); + return false; + } + } + + public string GenerateObjectGetUrl(string bucket, string key) + { + if (bucket == null) + throw new ArgumentNullException(nameof(bucket)); + if (key == null) + throw new ArgumentNullException(nameof(key)); + if (!ValidateBucketName(bucket)) + throw new ArgumentException($"Bucket name is not valid. Param is {bucket} .", nameof(bucket)); + + var host = GetHost(bucket); + var encodedKey = WebUtility.UrlEncode(key); + + var signature = GenerateSign(GetCredentials(), new RequestInfo + { + Method = "get", + Uri = "/" + encodedKey, + Headers = new Dictionary + { + ["Host"] = host + } + }, new TimeDuration(DateTimeOffset.Now, DateTimeOffset.Now.AddMinutes(6))); + + return $"https://{host}/{encodedKey}?{signature}"; + } + } +} -- cgit v1.2.3 From 484f59f9c954fdced635c24c5ab49840c3022d93 Mon Sep 17 00:00:00 2001 From: crupest Date: Tue, 30 Apr 2019 19:49:27 +0800 Subject: Add avatar upload function. --- Timeline/Controllers/UserController.cs | 34 ++++++++++++++-- Timeline/Entities/Http/User.cs | 11 ++++++ Timeline/Services/QCloudCosService.cs | 71 ++++++++++++++++++++++++++++++++++ Timeline/Services/UserService.cs | 42 ++++++++++++++++++++ 4 files changed, 154 insertions(+), 4 deletions(-) (limited to 'Timeline/Services/QCloudCosService.cs') diff --git a/Timeline/Controllers/UserController.cs b/Timeline/Controllers/UserController.cs index eaa205de..a18e36e9 100644 --- a/Timeline/Controllers/UserController.cs +++ b/Timeline/Controllers/UserController.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using System; +using System.IO; using System.Threading.Tasks; using Timeline.Entities; using Timeline.Entities.Http; @@ -82,14 +84,38 @@ namespace Timeline.Controllers [HttpGet("user/{username}/avatar"), Authorize] public async Task GetAvatar([FromRoute] string username) { - var existence = (await _userService.GetUser(username)) != null; - if (!existence) - return NotFound(); - var url = await _userService.GetAvatarUrl(username); + if (url == null) + return NotFound(); return Redirect(url); } + [HttpPut("user/{username}/avatar"), Authorize] + [Consumes("image/png", "image/gif", "image/jpeg", "image/svg+xml")] + public async Task PutAvatar([FromRoute] string username, [FromHeader(Name="Content-Type")] string contentType) + { + bool isAdmin = User.IsInRole("admin"); + if (!isAdmin) + { + if (username != User.Identity.Name) + return StatusCode(StatusCodes.Status403Forbidden, PutAvatarResponse.Forbidden); + } + + var stream = new MemoryStream(); + await Request.Body.CopyToAsync(stream); + var result = await _userService.PutAvatar(username, stream.ToArray(), contentType); + switch (result) + { + case PutAvatarResult.Success: + return Ok(PutAvatarResponse.Success); + case PutAvatarResult.UserNotExists: + return BadRequest(PutAvatarResponse.NotExists); + default: + throw new Exception("Unknown put avatar result."); + } + } + + [HttpPost("userop/changepassword"), Authorize] public async Task ChangePassword([FromBody] ChangePasswordRequest request) { diff --git a/Timeline/Entities/Http/User.cs b/Timeline/Entities/Http/User.cs index d42ca088..31cafaa3 100644 --- a/Timeline/Entities/Http/User.cs +++ b/Timeline/Entities/Http/User.cs @@ -40,4 +40,15 @@ public static ReturnCodeMessageResponse BadOldPassword { get; } = new ReturnCodeMessageResponse(BadOldPasswordCode, "Old password is wrong."); public static ReturnCodeMessageResponse NotExists { get; } = new ReturnCodeMessageResponse(NotExistsCode, "Username does not exists, please update token."); } + + public static class PutAvatarResponse + { + public const int SuccessCode = 0; + public const int ForbiddenCode = 1; + public const int NotExistsCode = 2; + + public static ReturnCodeMessageResponse Success {get;} = new ReturnCodeMessageResponse(SuccessCode, "Success to upload avatar."); + public static ReturnCodeMessageResponse Forbidden {get;} = new ReturnCodeMessageResponse(ForbiddenCode, "You are not allowed to upload the user's avatar."); + public static ReturnCodeMessageResponse NotExists {get;} = new ReturnCodeMessageResponse(NotExistsCode, "The username does not exists. If you are a user, try update your token."); + } } diff --git a/Timeline/Services/QCloudCosService.cs b/Timeline/Services/QCloudCosService.cs index f4358714..078dd37b 100644 --- a/Timeline/Services/QCloudCosService.cs +++ b/Timeline/Services/QCloudCosService.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; @@ -24,6 +25,14 @@ namespace Timeline.Services /// True if exists. False if not. Task IsObjectExists(string bucket, string key); + /// + /// Upload an object use put method. + /// + /// The bucket name. + /// The object key. + /// The data to upload. + Task PutObject(string bucket, string key, byte[] data, string contentType); + /// /// Generate a presignated url to access the object. /// @@ -229,6 +238,68 @@ namespace Timeline.Services } } + public async Task PutObject(string bucket, string key, byte[] data, string contentType) + { + if (bucket == null) + throw new ArgumentNullException(nameof(bucket)); + if (key == null) + throw new ArgumentNullException(nameof(key)); + if (!ValidateBucketName(bucket)) + throw new ArgumentException($"Bucket name is not valid. Param is {bucket} .", nameof(bucket)); + if (data == null) + throw new ArgumentNullException(nameof(data)); + + var host = GetHost(bucket); + var encodedKey = WebUtility.UrlEncode(key); + var md5 = Convert.ToBase64String(MD5.Create().ComputeHash(data)); + + const string kContentMD5HeaderName = "Content-MD5"; + const string kContentTypeHeaderName = "Content-Type"; + + var httpRequest = new HttpRequestMessage() + { + Method = HttpMethod.Put, + RequestUri = new Uri($"https://{host}/{encodedKey}") + }; + httpRequest.Headers.Host = host; + httpRequest.Headers.Date = DateTimeOffset.Now; + var httpContent = new ByteArrayContent(data); + httpContent.Headers.Add(kContentMD5HeaderName, md5); + httpRequest.Content = httpContent; + + var signedHeaders = new Dictionary + { + ["Host"] = host, + [kContentMD5HeaderName] = md5 + }; + + if (contentType != null) + { + httpContent.Headers.Add(kContentTypeHeaderName, contentType); + signedHeaders.Add(kContentTypeHeaderName, contentType); + } + + httpRequest.Headers.TryAddWithoutValidation("Authorization", GenerateSign(GetCredentials(), new RequestInfo + { + Method = "put", + Uri = "/" + encodedKey, + Headers = signedHeaders + }, new TimeDuration(DateTimeOffset.Now, DateTimeOffset.Now.AddMinutes(10)))); + + var client = _httpClientFactory.CreateClient(); + + try + { + var response = await client.SendAsync(httpRequest); + if (!response.IsSuccessStatusCode) + throw new Exception($"Not success status code. {response.ToString()}"); + } + catch (Exception e) + { + _logger.LogError(e, "An error occured when test a cos object existence."); + } + } + public string GenerateObjectGetUrl(string bucket, string key) { if (bucket == null) diff --git a/Timeline/Services/UserService.cs b/Timeline/Services/UserService.cs index 4a47ca0f..9ebf2668 100644 --- a/Timeline/Services/UserService.cs +++ b/Timeline/Services/UserService.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using System; using System.Linq; using System.Threading.Tasks; using Timeline.Entities; @@ -65,6 +66,18 @@ namespace Timeline.Services BadOldPassword } + public enum PutAvatarResult + { + /// + /// Success to upload avatar. + /// + Success, + /// + /// The user does not exists. + /// + UserNotExists + } + public interface IUserService { /// @@ -141,7 +154,14 @@ namespace Timeline.Services /// if old password is wrong. Task ChangePassword(string username, string oldPassword, string newPassword); + /// + /// Get the true avatar url of a user. + /// + /// The name of user. + /// The url if user exists. Null if user does not exist. Task GetAvatarUrl(string username); + + Task PutAvatar(string username, byte[] data, string mimeType); } public class UserService : IUserService @@ -301,11 +321,33 @@ namespace Timeline.Services public async Task GetAvatarUrl(string username) { + if (username == null) + throw new ArgumentNullException(nameof(username)); + + if ((await GetUser(username)) == null) + return null; + var exists = await _cosService.IsObjectExists("avatar", username); if (exists) return _cosService.GenerateObjectGetUrl("avatar", username); else return _cosService.GenerateObjectGetUrl("avatar", "__default"); } + + public async Task PutAvatar(string username, byte[] data, string mimeType) + { + if (username == null) + throw new ArgumentNullException(nameof(username)); + if (data == null) + throw new ArgumentNullException(nameof(data)); + if (mimeType == null) + throw new ArgumentNullException(nameof(mimeType)); + + if ((await GetUser(username)) == null) + return PutAvatarResult.UserNotExists; + + await _cosService.PutObject("avatar", username, data, mimeType); + return PutAvatarResult.Success; + } } } -- cgit v1.2.3 From a04bcb5971872e7dbc079de9337875e73f7642dc Mon Sep 17 00:00:00 2001 From: crupest Date: Tue, 30 Apr 2019 20:00:42 +0800 Subject: Throw exception in cos service. --- Timeline/Services/QCloudCosService.cs | 3 ++- Timeline/Services/UserService.cs | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) (limited to 'Timeline/Services/QCloudCosService.cs') diff --git a/Timeline/Services/QCloudCosService.cs b/Timeline/Services/QCloudCosService.cs index 078dd37b..b37631e5 100644 --- a/Timeline/Services/QCloudCosService.cs +++ b/Timeline/Services/QCloudCosService.cs @@ -234,7 +234,7 @@ namespace Timeline.Services catch (Exception e) { _logger.LogError(e, "An error occured when test a cos object existence."); - return false; + throw; } } @@ -297,6 +297,7 @@ namespace Timeline.Services catch (Exception e) { _logger.LogError(e, "An error occured when test a cos object existence."); + throw; } } diff --git a/Timeline/Services/UserService.cs b/Timeline/Services/UserService.cs index 9ebf2668..8ab3bc54 100644 --- a/Timeline/Services/UserService.cs +++ b/Timeline/Services/UserService.cs @@ -161,6 +161,14 @@ namespace Timeline.Services /// The url if user exists. Null if user does not exist. Task GetAvatarUrl(string username); + /// + /// Put a avatar of a user. + /// + /// The name of user. + /// The data of avatar image. + /// The mime type of the image. + /// Return if success. + /// Return if user does not exist. Task PutAvatar(string username, byte[] data, string mimeType); } -- cgit v1.2.3