fix: COS 签名 URL 过期时按 object key 重新签发访问地址
All checks were successful
Build and Deploy / deploy (push) Successful in 1m7s
All checks were successful
Build and Deploy / deploy (push) Successful in 1m7s
历史库内若存带 q-sign 的完整 COS 链接,打印/查看会 AccessDenied;写入只保留 key,读出时重新签名。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6021562fea
commit
e48f8ce926
@ -39,6 +39,37 @@ public static class FileStoragePath
|
|||||||
&& (value.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
&& (value.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||||
|| value.StartsWith("https://", StringComparison.OrdinalIgnoreCase));
|
|| value.StartsWith("https://", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否为本系统对象存储域名(自定义域 cos.* / 腾讯云默认域)。
|
||||||
|
/// 这类地址带临时签名,库内应存 object key,读出时重新签名;不可当外链原样返回。
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsManagedObjectStorageUrl(string? value)
|
||||||
|
{
|
||||||
|
if (!IsAbsoluteUrl(value))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var host = new Uri(value!).Host;
|
||||||
|
if (string.IsNullOrWhiteSpace(host))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
host = host.ToLowerInvariant();
|
||||||
|
return host.StartsWith("cos.", StringComparison.Ordinal)
|
||||||
|
|| host.Contains("myqcloud.com", StringComparison.Ordinal)
|
||||||
|
|| host.Contains("tencentcos.cn", StringComparison.Ordinal)
|
||||||
|
|| host.Contains("tencentcos.com", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
catch (UriFormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 读取对象时尝试的 key:含/不含 <c>admin/</c> 前缀(自定义域路径常带 PathBase)。
|
/// 读取对象时尝试的 key:含/不含 <c>admin/</c> 前缀(自定义域路径常带 PathBase)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ namespace Oms.Application.Storage;
|
|||||||
public static class OmsObjectStorageExtensions
|
public static class OmsObjectStorageExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 将 key / 相对路径规范为库内 object key;绝对外链原样返回;空返回 null。
|
/// 将 key / 相对路径规范为库内 object key;本系统 COS 签名 URL 剥签后存 key;其它绝对外链原样返回;空返回 null。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string? ToStorageKey(string? keyOrUrl)
|
public static string? ToStorageKey(string? keyOrUrl)
|
||||||
{
|
{
|
||||||
@ -23,6 +23,12 @@ public static class OmsObjectStorageExtensions
|
|||||||
return "0";
|
return "0";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 私有桶签名 URL 会过期,禁止写入库;只保留 object key。
|
||||||
|
if (FileStoragePath.IsManagedObjectStorageUrl(raw))
|
||||||
|
{
|
||||||
|
return FileStoragePath.NormalizeKey(raw);
|
||||||
|
}
|
||||||
|
|
||||||
if (FileStoragePath.IsAbsoluteUrl(raw))
|
if (FileStoragePath.IsAbsoluteUrl(raw))
|
||||||
{
|
{
|
||||||
return raw;
|
return raw;
|
||||||
@ -32,7 +38,7 @@ public static class OmsObjectStorageExtensions
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 临时访问地址。空值返回空串;外链原样返回;逻辑 key 走 <see cref="IObjectStorage.GetUrlAsync"/>。
|
/// 临时访问地址。空值返回空串;真正外链原样返回;逻辑 key / 本系统 COS URL 走 <see cref="IObjectStorage.GetUrlAsync"/> 重新签名。
|
||||||
/// Local 存储目录不可写/不可读时降级返回 key,避免拖垮下单等主流程。
|
/// Local 存储目录不可写/不可读时降级返回 key,避免拖垮下单等主流程。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static async Task<string> GetPublicUrlAsync(
|
public static async Task<string> GetPublicUrlAsync(
|
||||||
@ -46,7 +52,12 @@ public static class OmsObjectStorageExtensions
|
|||||||
}
|
}
|
||||||
|
|
||||||
var raw = keyOrUrl.Trim();
|
var raw = keyOrUrl.Trim();
|
||||||
if (FileStoragePath.IsAbsoluteUrl(raw))
|
// 历史数据可能把带 q-sign 的完整 URL 存进库;直接返回会 AccessDenied/Request has expired。
|
||||||
|
if (FileStoragePath.IsManagedObjectStorageUrl(raw))
|
||||||
|
{
|
||||||
|
raw = FileStoragePath.NormalizeKey(raw);
|
||||||
|
}
|
||||||
|
else if (FileStoragePath.IsAbsoluteUrl(raw))
|
||||||
{
|
{
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
|
using Fake.ObjectStorage;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
using Moq;
|
||||||
using Oms.Application.Storage;
|
using Oms.Application.Storage;
|
||||||
|
|
||||||
namespace Oms.Tests;
|
namespace Oms.Tests;
|
||||||
@ -16,4 +18,69 @@ public class FileStoragePathTests
|
|||||||
keys.Should().Contain("admin/uploads/prints/orders/IDE2610061059117673-20261006105912.pdf");
|
keys.Should().Contain("admin/uploads/prints/orders/IDE2610061059117673-20261006105912.pdf");
|
||||||
keys.Should().Contain("uploads/prints/orders/IDE2610061059117673-20261006105912.pdf");
|
keys.Should().Contain("uploads/prints/orders/IDE2610061059117673-20261006105912.pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://cos.ideship.com/uploads/a.pdf?q-sign-algorithm=sha1", true)]
|
||||||
|
[InlineData("http://cos.test.ideship.com/admin/uploads/a.pdf", true)]
|
||||||
|
[InlineData("https://bucket-123.cos.ap-guangzhou.myqcloud.com/uploads/a.pdf", true)]
|
||||||
|
[InlineData("https://third-party.example.com/label.pdf", false)]
|
||||||
|
[InlineData("uploads/prints/orders/a.pdf", false)]
|
||||||
|
public void IsManagedObjectStorageUrl_ShouldDetectCosHosts(string value, bool expected)
|
||||||
|
{
|
||||||
|
FileStoragePath.IsManagedObjectStorageUrl(value).Should().Be(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToStorageKey_ShouldStripExpiredCosSignedUrlToObjectKey()
|
||||||
|
{
|
||||||
|
var expired =
|
||||||
|
"https://cos.ideship.com/uploads/prints/orders/IDE2610081704036507-20261008170404.pdf?q-sign-algorithm=sha1&q-sign-time=1;2";
|
||||||
|
|
||||||
|
OmsObjectStorageExtensions.ToStorageKey(expired)
|
||||||
|
.Should()
|
||||||
|
.Be("uploads/prints/orders/IDE2610081704036507-20261008170404.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToStorageKey_ShouldKeepExternalAbsoluteUrl()
|
||||||
|
{
|
||||||
|
const string external = "https://third-party.example.com/label.pdf?token=1";
|
||||||
|
OmsObjectStorageExtensions.ToStorageKey(external).Should().Be(external);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPublicUrl_ShouldResignManagedCosAbsoluteUrl()
|
||||||
|
{
|
||||||
|
var expired =
|
||||||
|
"https://cos.ideship.com/uploads/prints/orders/IDE2610081704036507-20261008170404.pdf?q-sign-algorithm=sha1&q-sign-time=1;2";
|
||||||
|
const string fresh =
|
||||||
|
"https://cos.ideship.com/uploads/prints/orders/IDE2610081704036507-20261008170404.pdf?q-sign-algorithm=sha1&fresh=1";
|
||||||
|
|
||||||
|
var storage = new Mock<IObjectStorage>(MockBehavior.Strict);
|
||||||
|
storage
|
||||||
|
.Setup(x => x.GetUrlAsync(
|
||||||
|
"uploads/prints/orders/IDE2610081704036507-20261008170404.pdf",
|
||||||
|
It.IsAny<TimeSpan?>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(fresh);
|
||||||
|
|
||||||
|
var url = await storage.Object.GetPublicUrlAsync(expired);
|
||||||
|
|
||||||
|
url.Should().Be(fresh);
|
||||||
|
storage.VerifyAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPublicUrl_ShouldPassThroughExternalAbsoluteUrl()
|
||||||
|
{
|
||||||
|
const string external = "https://third-party.example.com/label.pdf";
|
||||||
|
var storage = new Mock<IObjectStorage>(MockBehavior.Strict);
|
||||||
|
|
||||||
|
var url = await storage.Object.GetPublicUrlAsync(external);
|
||||||
|
|
||||||
|
url.Should().Be(external);
|
||||||
|
storage.Verify(
|
||||||
|
x => x.GetUrlAsync(It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user