fix: COS 签名 URL 过期时按 object key 重新签发访问地址 #12
@ -39,6 +39,37 @@ public static class FileStoragePath
|
||||
&& (value.StartsWith("http://", 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>
|
||||
/// 读取对象时尝试的 key:含/不含 <c>admin/</c> 前缀(自定义域路径常带 PathBase)。
|
||||
/// </summary>
|
||||
|
||||
@ -8,7 +8,7 @@ namespace Oms.Application.Storage;
|
||||
public static class OmsObjectStorageExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 将 key / 相对路径规范为库内 object key;绝对外链原样返回;空返回 null。
|
||||
/// 将 key / 相对路径规范为库内 object key;本系统 COS 签名 URL 剥签后存 key;其它绝对外链原样返回;空返回 null。
|
||||
/// </summary>
|
||||
public static string? ToStorageKey(string? keyOrUrl)
|
||||
{
|
||||
@ -23,6 +23,12 @@ public static class OmsObjectStorageExtensions
|
||||
return "0";
|
||||
}
|
||||
|
||||
// 私有桶签名 URL 会过期,禁止写入库;只保留 object key。
|
||||
if (FileStoragePath.IsManagedObjectStorageUrl(raw))
|
||||
{
|
||||
return FileStoragePath.NormalizeKey(raw);
|
||||
}
|
||||
|
||||
if (FileStoragePath.IsAbsoluteUrl(raw))
|
||||
{
|
||||
return raw;
|
||||
@ -32,7 +38,7 @@ public static class OmsObjectStorageExtensions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 临时访问地址。空值返回空串;外链原样返回;逻辑 key 走 <see cref="IObjectStorage.GetUrlAsync"/>。
|
||||
/// 临时访问地址。空值返回空串;真正外链原样返回;逻辑 key / 本系统 COS URL 走 <see cref="IObjectStorage.GetUrlAsync"/> 重新签名。
|
||||
/// Local 存储目录不可写/不可读时降级返回 key,避免拖垮下单等主流程。
|
||||
/// </summary>
|
||||
public static async Task<string> GetPublicUrlAsync(
|
||||
@ -46,7 +52,12 @@ public static class OmsObjectStorageExtensions
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
using Fake.ObjectStorage;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using Oms.Application.Storage;
|
||||
|
||||
namespace Oms.Tests;
|
||||
@ -16,4 +18,69 @@ public class FileStoragePathTests
|
||||
keys.Should().Contain("admin/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