forked from gongxuegit/tiku-backend.net
feat: add asset access signing endpoints
This commit is contained in:
92
Tiku.Api/Contracts/AssetAccessDtos.cs
Normal file
92
Tiku.Api/Contracts/AssetAccessDtos.cs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Tiku.Application.Assets;
|
||||||
|
using Tiku.Application.Storage;
|
||||||
|
using Tiku.Domain.Content;
|
||||||
|
|
||||||
|
namespace Tiku.Api.Contracts;
|
||||||
|
|
||||||
|
public sealed class AssetAccessQueryDto
|
||||||
|
{
|
||||||
|
[StringLength(100)]
|
||||||
|
public string? TenantCode { get; set; }
|
||||||
|
|
||||||
|
[Range(1, 7200)]
|
||||||
|
public int? ExpiresInSeconds { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AssetAccessResponseDto(
|
||||||
|
ContentAssetAccessSummaryDto Item,
|
||||||
|
AssetAccessPrincipalDto Access,
|
||||||
|
SignedStorageUrlDto Url,
|
||||||
|
JsonElement Watermark)
|
||||||
|
{
|
||||||
|
public static AssetAccessResponseDto FromApplication(AssetAccessResultModel result)
|
||||||
|
{
|
||||||
|
return new AssetAccessResponseDto(
|
||||||
|
ContentAssetAccessSummaryDto.FromApplication(result.Item),
|
||||||
|
new AssetAccessPrincipalDto(
|
||||||
|
result.Access.UserId,
|
||||||
|
result.Access.IsMember,
|
||||||
|
result.Access.HasSvip),
|
||||||
|
SignedStorageUrlDto.FromApplication(result.Url),
|
||||||
|
result.Watermark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ContentAssetAccessSummaryDto(
|
||||||
|
Guid Id,
|
||||||
|
ContentAssetType AssetType,
|
||||||
|
string? Title,
|
||||||
|
string? FileName,
|
||||||
|
string? PreviewUrl,
|
||||||
|
AssetUploadStatus UploadStatus,
|
||||||
|
AssetSecurityScanStatus SecurityScanStatus,
|
||||||
|
AssetPreviewStatus PreviewStatus,
|
||||||
|
ContentVisibility Visibility)
|
||||||
|
{
|
||||||
|
public static ContentAssetAccessSummaryDto FromApplication(ContentAssetAccessSummary item)
|
||||||
|
{
|
||||||
|
return new ContentAssetAccessSummaryDto(
|
||||||
|
item.Id,
|
||||||
|
item.AssetType,
|
||||||
|
item.Title,
|
||||||
|
item.FileName,
|
||||||
|
item.PreviewUrl,
|
||||||
|
item.UploadStatus,
|
||||||
|
item.SecurityScanStatus,
|
||||||
|
item.PreviewStatus,
|
||||||
|
item.Visibility);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AssetAccessPrincipalDto(
|
||||||
|
Guid? UserId,
|
||||||
|
bool IsMember,
|
||||||
|
bool HasSvip);
|
||||||
|
|
||||||
|
public sealed record SignedStorageUrlDto(
|
||||||
|
string Provider,
|
||||||
|
string? Bucket,
|
||||||
|
string? ObjectKey,
|
||||||
|
string Method,
|
||||||
|
string Url,
|
||||||
|
IReadOnlyDictionary<string, string> Headers,
|
||||||
|
DateTimeOffset ExpiresAt,
|
||||||
|
int ExpiresInSeconds,
|
||||||
|
string SignatureMode)
|
||||||
|
{
|
||||||
|
public static SignedStorageUrlDto FromApplication(ObjectStorageSignedUrl signedUrl)
|
||||||
|
{
|
||||||
|
return new SignedStorageUrlDto(
|
||||||
|
signedUrl.Provider,
|
||||||
|
signedUrl.Bucket,
|
||||||
|
signedUrl.ObjectKey,
|
||||||
|
signedUrl.Method,
|
||||||
|
signedUrl.Url.AbsoluteUri,
|
||||||
|
signedUrl.Headers,
|
||||||
|
signedUrl.ExpiresAt,
|
||||||
|
(int)signedUrl.ExpiresIn.TotalSeconds,
|
||||||
|
signedUrl.SignatureMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
Tiku.Api/Controllers/AssetsController.cs
Normal file
97
Tiku.Api/Controllers/AssetsController.cs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Tiku.Api.Contracts;
|
||||||
|
using Tiku.Application.Assets;
|
||||||
|
using Tiku.Application.Security;
|
||||||
|
using Tiku.Domain.Tenancy;
|
||||||
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tiku.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[Produces("application/json")]
|
||||||
|
[Route("api/assets")]
|
||||||
|
public sealed class AssetsController(
|
||||||
|
IAssetAccessService assetAccessService,
|
||||||
|
ICurrentTenant currentTenant,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
TikuDbContext dbContext) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("{assetId:guid}/download")]
|
||||||
|
[EndpointSummary("获取资源下载地址")]
|
||||||
|
[ProducesResponseType<AssetAccessResponseDto>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)]
|
||||||
|
public async Task<ActionResult<AssetAccessResponseDto>> Download(
|
||||||
|
Guid assetId,
|
||||||
|
[FromQuery] AssetAccessQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await assetAccessService.DownloadAsync(
|
||||||
|
ToRequest(assetId, query, await ResolveTenantIdAsync(query.TenantCode, cancellationToken)),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return Ok(AssetAccessResponseDto.FromApplication(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{assetId:guid}/preview")]
|
||||||
|
[EndpointSummary("获取资源预览地址")]
|
||||||
|
[ProducesResponseType<AssetAccessResponseDto>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)]
|
||||||
|
public async Task<ActionResult<AssetAccessResponseDto>> Preview(
|
||||||
|
Guid assetId,
|
||||||
|
[FromQuery] AssetAccessQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await assetAccessService.PreviewAsync(
|
||||||
|
ToRequest(assetId, query, await ResolveTenantIdAsync(query.TenantCode, cancellationToken)),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return Ok(AssetAccessResponseDto.FromApplication(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
private AssetAccessRequest ToRequest(Guid assetId, AssetAccessQueryDto query, Guid tenantId)
|
||||||
|
{
|
||||||
|
return new AssetAccessRequest(
|
||||||
|
tenantId,
|
||||||
|
assetId,
|
||||||
|
currentUser.UserId,
|
||||||
|
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
Request.Headers.UserAgent.ToString(),
|
||||||
|
query.ExpiresInSeconds.HasValue
|
||||||
|
? TimeSpan.FromSeconds(query.ExpiresInSeconds.Value)
|
||||||
|
: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Guid> ResolveTenantIdAsync(string? tenantCode, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (currentTenant.TenantId.HasValue)
|
||||||
|
{
|
||||||
|
return currentTenant.TenantId.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedTenantCode = tenantCode ?? Request.Headers["x-tenant-code"].FirstOrDefault();
|
||||||
|
if (string.IsNullOrWhiteSpace(resolvedTenantCode))
|
||||||
|
{
|
||||||
|
throw new TenantNotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var tenantId = await dbContext.Tenants
|
||||||
|
.Where(tenant =>
|
||||||
|
tenant.Slug == resolvedTenantCode.Trim() &&
|
||||||
|
tenant.Status == TenantStatus.Active)
|
||||||
|
.Select(tenant => (Guid?)tenant.Id)
|
||||||
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
return tenantId ?? throw new TenantNotFoundException();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Tiku.Api.Controllers;
|
using Tiku.Api.Controllers;
|
||||||
|
using Tiku.Application.Assets;
|
||||||
using Tiku.Application.Auth;
|
using Tiku.Application.Auth;
|
||||||
|
using Tiku.Application.Storage;
|
||||||
using Tiku.Infrastructure.Content;
|
using Tiku.Infrastructure.Content;
|
||||||
using Tiku.Infrastructure.QuestionBanks;
|
using Tiku.Infrastructure.QuestionBanks;
|
||||||
|
|
||||||
@@ -75,6 +77,28 @@ public sealed class ExceptionHandlingMiddleware(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exception is AssetAccessException assetAccessException)
|
||||||
|
{
|
||||||
|
await WriteProblemAsync(
|
||||||
|
context,
|
||||||
|
assetAccessException.Message,
|
||||||
|
AssetAccessStatusCode(assetAccessException.Code),
|
||||||
|
assetAccessException.Code.ToLowerInvariant());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exception is ObjectStorageException storageException)
|
||||||
|
{
|
||||||
|
await WriteProblemAsync(
|
||||||
|
context,
|
||||||
|
storageException.Message,
|
||||||
|
storageException is ObjectStorageNotConfiguredException
|
||||||
|
? StatusCodes.Status503ServiceUnavailable
|
||||||
|
: StatusCodes.Status400BadRequest,
|
||||||
|
storageException.Code.ToLowerInvariant());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
logger.LogError(exception, "Unhandled API exception");
|
logger.LogError(exception, "Unhandled API exception");
|
||||||
|
|
||||||
var problem = new ProblemDetails
|
var problem = new ProblemDetails
|
||||||
@@ -132,4 +156,17 @@ public sealed class ExceptionHandlingMiddleware(
|
|||||||
context.Response.StatusCode = status;
|
context.Response.StatusCode = status;
|
||||||
await context.Response.WriteAsJsonAsync(problem);
|
await context.Response.WriteAsJsonAsync(problem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int AssetAccessStatusCode(string code)
|
||||||
|
{
|
||||||
|
return code switch
|
||||||
|
{
|
||||||
|
"ASSET_NOT_FOUND" => StatusCodes.Status404NotFound,
|
||||||
|
"AUTH_REQUIRED" => StatusCodes.Status401Unauthorized,
|
||||||
|
"ASSET_HIDDEN" or "ASSET_MEMBERSHIP_REQUIRED" or "ASSET_SVIP_REQUIRED" => StatusCodes.Status403Forbidden,
|
||||||
|
"ASSET_UPLOAD_NOT_VERIFIED" or "ASSET_SECURITY_SCAN_NOT_PASSED" => StatusCodes.Status409Conflict,
|
||||||
|
"ASSET_PREVIEW_NOT_SUPPORTED" => StatusCodes.Status400BadRequest,
|
||||||
|
_ => StatusCodes.Status400BadRequest
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
Tiku.Application/Assets/AssetAccessModels.cs
Normal file
40
Tiku.Application/Assets/AssetAccessModels.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Tiku.Application.Storage;
|
||||||
|
using Tiku.Domain.Content;
|
||||||
|
|
||||||
|
namespace Tiku.Application.Assets;
|
||||||
|
|
||||||
|
public sealed record AssetAccessRequest(
|
||||||
|
Guid TenantId,
|
||||||
|
Guid AssetId,
|
||||||
|
Guid? UserId,
|
||||||
|
string? IpAddress,
|
||||||
|
string? UserAgent,
|
||||||
|
TimeSpan? RequestedExpiresIn = null);
|
||||||
|
|
||||||
|
public sealed record AssetAccessResultModel(
|
||||||
|
ContentAssetAccessSummary Item,
|
||||||
|
AssetAccessPrincipal Access,
|
||||||
|
ObjectStorageSignedUrl Url,
|
||||||
|
JsonElement Watermark);
|
||||||
|
|
||||||
|
public sealed record ContentAssetAccessSummary(
|
||||||
|
Guid Id,
|
||||||
|
ContentAssetType AssetType,
|
||||||
|
string? Title,
|
||||||
|
string? FileName,
|
||||||
|
string? PreviewUrl,
|
||||||
|
AssetUploadStatus UploadStatus,
|
||||||
|
AssetSecurityScanStatus SecurityScanStatus,
|
||||||
|
AssetPreviewStatus PreviewStatus,
|
||||||
|
ContentVisibility Visibility);
|
||||||
|
|
||||||
|
public sealed record AssetAccessPrincipal(
|
||||||
|
Guid? UserId,
|
||||||
|
bool IsMember,
|
||||||
|
bool HasSvip);
|
||||||
|
|
||||||
|
public sealed class AssetAccessException(string message, string code) : InvalidOperationException(message)
|
||||||
|
{
|
||||||
|
public string Code { get; } = code;
|
||||||
|
}
|
||||||
12
Tiku.Application/Assets/IAssetAccessService.cs
Normal file
12
Tiku.Application/Assets/IAssetAccessService.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Tiku.Application.Assets;
|
||||||
|
|
||||||
|
public interface IAssetAccessService
|
||||||
|
{
|
||||||
|
Task<AssetAccessResultModel> DownloadAsync(
|
||||||
|
AssetAccessRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<AssetAccessResultModel> PreviewAsync(
|
||||||
|
AssetAccessRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
329
Tiku.Infrastructure/Assets/AssetAccessService.cs
Normal file
329
Tiku.Infrastructure/Assets/AssetAccessService.cs
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Tiku.Application.Assets;
|
||||||
|
using Tiku.Application.Storage;
|
||||||
|
using Tiku.Domain.Commerce;
|
||||||
|
using Tiku.Domain.Content;
|
||||||
|
using Tiku.Domain.Tenancy;
|
||||||
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tiku.Infrastructure.Assets;
|
||||||
|
|
||||||
|
public sealed class AssetAccessService(
|
||||||
|
TikuDbContext dbContext,
|
||||||
|
IObjectStorageService objectStorageService) : IAssetAccessService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan DefaultDownloadTtl = TimeSpan.FromMinutes(15);
|
||||||
|
private static readonly TimeSpan DefaultPreviewTtl = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
|
public Task<AssetAccessResultModel> DownloadAsync(
|
||||||
|
AssetAccessRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return AccessAsync(request, AssetAccessType.Download, AssetAccessDisposition.Attachment, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<AssetAccessResultModel> PreviewAsync(
|
||||||
|
AssetAccessRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return AccessAsync(request, AssetAccessType.Preview, AssetAccessDisposition.Inline, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AssetAccessResultModel> AccessAsync(
|
||||||
|
AssetAccessRequest request,
|
||||||
|
AssetAccessType accessType,
|
||||||
|
AssetAccessDisposition disposition,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var asset = await dbContext.ContentAssets
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
item =>
|
||||||
|
item.TenantId == request.TenantId &&
|
||||||
|
item.Id == request.AssetId &&
|
||||||
|
item.Status == ContentStatus.Active,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (asset is null)
|
||||||
|
{
|
||||||
|
throw new AssetAccessException("Asset was not found.", "ASSET_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var access = await ResolveAccessAsync(request, asset, cancellationToken);
|
||||||
|
AssertPublishedAsset(asset);
|
||||||
|
if (accessType == AssetAccessType.Preview)
|
||||||
|
{
|
||||||
|
AssertPreviewable(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
var ttl = ResolveTtl(request, accessType, asset.Visibility);
|
||||||
|
var signedUrl = await objectStorageService.SignDownloadAsync(
|
||||||
|
new ObjectStorageDownloadSignRequest(
|
||||||
|
request.TenantId,
|
||||||
|
ToStorageProvider(asset.StorageProvider),
|
||||||
|
asset.Bucket,
|
||||||
|
accessType == AssetAccessType.Preview
|
||||||
|
? asset.PreviewObjectKey ?? asset.ObjectKey
|
||||||
|
: asset.ObjectKey,
|
||||||
|
ttl,
|
||||||
|
accessType == AssetAccessType.Preview
|
||||||
|
? asset.PreviewUrl ?? asset.CdnUrl
|
||||||
|
: asset.CdnUrl,
|
||||||
|
asset.FileName ?? asset.Title,
|
||||||
|
disposition == AssetAccessDisposition.Inline ? "inline" : "attachment"),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (accessType == AssetAccessType.Download)
|
||||||
|
{
|
||||||
|
asset.DownloadCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
dbContext.ContentAssetAccessEvents.Add(CreateAccessEvent(
|
||||||
|
request,
|
||||||
|
asset,
|
||||||
|
accessType,
|
||||||
|
disposition,
|
||||||
|
AssetAccessResult.Granted,
|
||||||
|
signedUrl,
|
||||||
|
null,
|
||||||
|
access));
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new AssetAccessResultModel(
|
||||||
|
ToSummary(asset),
|
||||||
|
access,
|
||||||
|
signedUrl,
|
||||||
|
CreateWatermark(request, asset, accessType, signedUrl));
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (ShouldAuditDenied(exception))
|
||||||
|
{
|
||||||
|
dbContext.ContentAssetAccessEvents.Add(CreateAccessEvent(
|
||||||
|
request,
|
||||||
|
asset,
|
||||||
|
accessType,
|
||||||
|
disposition,
|
||||||
|
AssetAccessResult.Denied,
|
||||||
|
null,
|
||||||
|
DenyCode(exception),
|
||||||
|
null));
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AssetAccessPrincipal> ResolveAccessAsync(
|
||||||
|
AssetAccessRequest request,
|
||||||
|
ContentAsset asset,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (asset.Visibility == ContentVisibility.Public || asset.IsPublic)
|
||||||
|
{
|
||||||
|
return new AssetAccessPrincipal(request.UserId, IsMember: false, HasSvip: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.Visibility == ContentVisibility.Hidden)
|
||||||
|
{
|
||||||
|
throw new AssetAccessException("Asset is hidden.", "ASSET_HIDDEN");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.UserId.HasValue)
|
||||||
|
{
|
||||||
|
throw new AssetAccessException("Authentication is required.", "AUTH_REQUIRED");
|
||||||
|
}
|
||||||
|
|
||||||
|
var isMember = await dbContext.TenantMemberships
|
||||||
|
.AnyAsync(
|
||||||
|
membership =>
|
||||||
|
membership.TenantId == request.TenantId &&
|
||||||
|
membership.UserId == request.UserId.Value &&
|
||||||
|
membership.Status == MembershipStatus.Active,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (!isMember)
|
||||||
|
{
|
||||||
|
throw new AssetAccessException("Tenant membership is required for this asset.", "ASSET_MEMBERSHIP_REQUIRED");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.Visibility == ContentVisibility.Members)
|
||||||
|
{
|
||||||
|
return new AssetAccessPrincipal(request.UserId, IsMember: true, HasSvip: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasSvip = await HasSvipAccessAsync(request, asset, cancellationToken);
|
||||||
|
if (!hasSvip)
|
||||||
|
{
|
||||||
|
throw new AssetAccessException("SVIP entitlement is required for this asset.", "ASSET_SVIP_REQUIRED");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AssetAccessPrincipal(request.UserId, IsMember: true, HasSvip: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<bool> HasSvipAccessAsync(
|
||||||
|
AssetAccessRequest request,
|
||||||
|
ContentAsset asset,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
return dbContext.Entitlements.AnyAsync(
|
||||||
|
entitlement =>
|
||||||
|
entitlement.TenantId == request.TenantId &&
|
||||||
|
entitlement.UserId == request.UserId!.Value &&
|
||||||
|
entitlement.EntitlementType == "svip" &&
|
||||||
|
entitlement.Status == EntitlementStatus.Active &&
|
||||||
|
entitlement.StartsAt <= now &&
|
||||||
|
(entitlement.ExpiresAt == null || entitlement.ExpiresAt > now) &&
|
||||||
|
(entitlement.ScopeType == EntitlementScopeType.Tenant ||
|
||||||
|
(asset.RegionId != null &&
|
||||||
|
entitlement.ScopeType == EntitlementScopeType.Region &&
|
||||||
|
entitlement.ScopeId == asset.RegionId) ||
|
||||||
|
(asset.SubjectId != null &&
|
||||||
|
entitlement.ScopeType == EntitlementScopeType.Subject &&
|
||||||
|
entitlement.ScopeId == asset.SubjectId)),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertPublishedAsset(ContentAsset asset)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(asset.ObjectKey) && asset.UploadStatus != AssetUploadStatus.Verified)
|
||||||
|
{
|
||||||
|
throw new AssetAccessException("Asset upload has not been verified.", "ASSET_UPLOAD_NOT_VERIFIED");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.SecurityScanStatus is AssetSecurityScanStatus.Failed or AssetSecurityScanStatus.Scanning)
|
||||||
|
{
|
||||||
|
throw new AssetAccessException("Asset security scan has not passed.", "ASSET_SECURITY_SCAN_NOT_PASSED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertPreviewable(ContentAsset asset)
|
||||||
|
{
|
||||||
|
var mimeType = asset.MimeType?.Split(';', StringSplitOptions.TrimEntries)[0].ToLowerInvariant() ?? string.Empty;
|
||||||
|
var previewable = asset.AssetType is ContentAssetType.Pdf or ContentAssetType.Image ||
|
||||||
|
mimeType == "application/pdf" ||
|
||||||
|
mimeType.StartsWith("image/", StringComparison.Ordinal);
|
||||||
|
if (!previewable)
|
||||||
|
{
|
||||||
|
throw new AssetAccessException("Asset type does not support inline preview.", "ASSET_PREVIEW_NOT_SUPPORTED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeSpan ResolveTtl(
|
||||||
|
AssetAccessRequest request,
|
||||||
|
AssetAccessType accessType,
|
||||||
|
ContentVisibility visibility)
|
||||||
|
{
|
||||||
|
var fallback = accessType == AssetAccessType.Preview ? DefaultPreviewTtl : DefaultDownloadTtl;
|
||||||
|
var requested = request.RequestedExpiresIn ?? fallback;
|
||||||
|
var max = visibility == ContentVisibility.Public ? TimeSpan.FromHours(2) : TimeSpan.FromMinutes(15);
|
||||||
|
if (requested <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return requested <= max ? requested : max;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ContentAssetAccessEvent CreateAccessEvent(
|
||||||
|
AssetAccessRequest request,
|
||||||
|
ContentAsset asset,
|
||||||
|
AssetAccessType accessType,
|
||||||
|
AssetAccessDisposition disposition,
|
||||||
|
AssetAccessResult result,
|
||||||
|
ObjectStorageSignedUrl? signedUrl,
|
||||||
|
string? denyCode,
|
||||||
|
AssetAccessPrincipal? access)
|
||||||
|
{
|
||||||
|
return new ContentAssetAccessEvent
|
||||||
|
{
|
||||||
|
TenantId = request.TenantId,
|
||||||
|
AssetId = asset.Id,
|
||||||
|
UserId = request.UserId,
|
||||||
|
ActorRole = ResolveActorRole(access),
|
||||||
|
AccessType = accessType,
|
||||||
|
Visibility = asset.Visibility.ToString(),
|
||||||
|
AssetType = asset.AssetType.ToString(),
|
||||||
|
StorageProvider = asset.StorageProvider.ToString(),
|
||||||
|
Disposition = disposition,
|
||||||
|
ExpiresInSeconds = signedUrl is null ? null : (int)signedUrl.ExpiresIn.TotalSeconds,
|
||||||
|
SignatureMode = signedUrl?.SignatureMode,
|
||||||
|
Result = result,
|
||||||
|
DenyCode = denyCode,
|
||||||
|
IpAddress = request.IpAddress,
|
||||||
|
UserAgent = request.UserAgent,
|
||||||
|
Metadata = JsonSerializer.SerializeToElement(new
|
||||||
|
{
|
||||||
|
asset.Title,
|
||||||
|
asset.FileName,
|
||||||
|
signedUrl?.Method
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AssetAccessActorRole ResolveActorRole(AssetAccessPrincipal? access)
|
||||||
|
{
|
||||||
|
return access?.UserId.HasValue == true ? AssetAccessActorRole.Student : AssetAccessActorRole.Anonymous;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ContentAssetAccessSummary ToSummary(ContentAsset asset)
|
||||||
|
{
|
||||||
|
return new ContentAssetAccessSummary(
|
||||||
|
asset.Id,
|
||||||
|
asset.AssetType,
|
||||||
|
asset.Title,
|
||||||
|
asset.FileName,
|
||||||
|
asset.PreviewUrl,
|
||||||
|
asset.UploadStatus,
|
||||||
|
asset.SecurityScanStatus,
|
||||||
|
asset.PreviewStatus,
|
||||||
|
asset.Visibility);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement CreateWatermark(
|
||||||
|
AssetAccessRequest request,
|
||||||
|
ContentAsset asset,
|
||||||
|
AssetAccessType accessType,
|
||||||
|
ObjectStorageSignedUrl signedUrl)
|
||||||
|
{
|
||||||
|
return JsonSerializer.SerializeToElement(new
|
||||||
|
{
|
||||||
|
request.TenantId,
|
||||||
|
assetId = asset.Id,
|
||||||
|
request.UserId,
|
||||||
|
accessType = accessType.ToString(),
|
||||||
|
signedUrl.ExpiresAt,
|
||||||
|
asset.Title,
|
||||||
|
asset.FileName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldAuditDenied(Exception exception)
|
||||||
|
{
|
||||||
|
return exception is AssetAccessException or ObjectStorageException or ObjectStorageNotConfiguredException;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DenyCode(Exception exception)
|
||||||
|
{
|
||||||
|
return exception switch
|
||||||
|
{
|
||||||
|
AssetAccessException accessException => accessException.Code,
|
||||||
|
ObjectStorageException storageException => storageException.Code,
|
||||||
|
_ => "ASSET_ACCESS_DENIED"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToStorageProvider(AssetStorageProvider provider)
|
||||||
|
{
|
||||||
|
return provider switch
|
||||||
|
{
|
||||||
|
AssetStorageProvider.ExternalUrl => ObjectStorageProviders.ExternalUrl,
|
||||||
|
AssetStorageProvider.SupabaseStorage => ObjectStorageProviders.SupabaseStorage,
|
||||||
|
AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss,
|
||||||
|
AssetStorageProvider.TencentCos => ObjectStorageProviders.TencentCos,
|
||||||
|
AssetStorageProvider.QiniuKodo => ObjectStorageProviders.QiniuKodo,
|
||||||
|
AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev,
|
||||||
|
_ => ObjectStorageProviders.ExternalUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
|
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
|
||||||
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
|
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
|
||||||
services.AddScoped<IAssetQueryService, AssetQueryService>();
|
services.AddScoped<IAssetQueryService, AssetQueryService>();
|
||||||
|
services.AddScoped<IAssetAccessService, AssetAccessService>();
|
||||||
services.AddOptions<AliyunOssOptions>()
|
services.AddOptions<AliyunOssOptions>()
|
||||||
.Validate(
|
.Validate(
|
||||||
AliyunOssOptions.BeValid,
|
AliyunOssOptions.BeValid,
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Tiku.Application.Auth;
|
using Tiku.Application.Auth;
|
||||||
|
using Tiku.Application.Storage;
|
||||||
using Tiku.Domain.Identity;
|
using Tiku.Domain.Identity;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
namespace Tiku.IntegrationTests.Api;
|
namespace Tiku.IntegrationTests.Api;
|
||||||
|
|
||||||
public sealed class ApiTestFactory(IWechatOAuthClient? wechatOAuthClient = null) : WebApplicationFactory<Program>
|
public sealed class ApiTestFactory(
|
||||||
|
IWechatOAuthClient? wechatOAuthClient = null,
|
||||||
|
IObjectStorageService? objectStorageService = null) : WebApplicationFactory<Program>
|
||||||
{
|
{
|
||||||
private readonly string databaseName = Guid.NewGuid().ToString();
|
private readonly string databaseName = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
@@ -34,6 +37,11 @@ public sealed class ApiTestFactory(IWechatOAuthClient? wechatOAuthClient = null)
|
|||||||
{
|
{
|
||||||
services.AddSingleton(wechatOAuthClient);
|
services.AddSingleton(wechatOAuthClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (objectStorageService is not null)
|
||||||
|
{
|
||||||
|
services.AddSingleton(objectStorageService);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
340
Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs
Normal file
340
Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Tiku.Api.Contracts;
|
||||||
|
using Tiku.Application.Storage;
|
||||||
|
using Tiku.Domain.Common;
|
||||||
|
using Tiku.Domain.Commerce;
|
||||||
|
using Tiku.Domain.Content;
|
||||||
|
using Tiku.Domain.Identity;
|
||||||
|
using Tiku.Domain.Tenancy;
|
||||||
|
using Tiku.Infrastructure.Auth;
|
||||||
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tiku.IntegrationTests.Api;
|
||||||
|
|
||||||
|
public sealed class AssetAccessEndpointTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Public_asset_can_be_downloaded_anonymously_and_is_audited()
|
||||||
|
{
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
var assetId = Guid.NewGuid();
|
||||||
|
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
|
||||||
|
await factory.SeedAsync(
|
||||||
|
Tenant(tenantId, "master"),
|
||||||
|
PublicAsset(tenantId, assetId));
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
using var response = await client.GetAsync($"/api/assets/{assetId}/download?tenantCode=master");
|
||||||
|
var body = await ReadJsonAsync(response);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal("https://storage.example.test/download.pdf", body.RootElement.GetProperty("url").GetProperty("url").GetString());
|
||||||
|
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
Assert.Equal(1, dbContext.ContentAssets.Single(asset => asset.Id == assetId).DownloadCount);
|
||||||
|
Assert.Contains(dbContext.ContentAssetAccessEvents, item =>
|
||||||
|
item.AssetId == assetId &&
|
||||||
|
item.Result == AssetAccessResult.Granted &&
|
||||||
|
item.AccessType == AssetAccessType.Download);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Members_asset_requires_authentication()
|
||||||
|
{
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
var assetId = Guid.NewGuid();
|
||||||
|
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
|
||||||
|
await factory.SeedAsync(
|
||||||
|
Tenant(tenantId, "master"),
|
||||||
|
new ContentAsset
|
||||||
|
{
|
||||||
|
Id = assetId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
Title = "会员资料",
|
||||||
|
Visibility = ContentVisibility.Members,
|
||||||
|
StorageProvider = AssetStorageProvider.LocalDev,
|
||||||
|
Bucket = "tenant-assets",
|
||||||
|
ObjectKey = $"{tenantId:N}/members.pdf",
|
||||||
|
MimeType = "application/pdf",
|
||||||
|
AssetType = ContentAssetType.Pdf,
|
||||||
|
UploadStatus = AssetUploadStatus.Verified,
|
||||||
|
SecurityScanStatus = AssetSecurityScanStatus.Passed
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
using var response = await client.GetAsync($"/api/assets/{assetId}/download?tenantCode=master");
|
||||||
|
var body = await ReadJsonAsync(response);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
Assert.Equal("auth_required", body.RootElement.GetProperty("code").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Members_asset_can_be_downloaded_by_active_member()
|
||||||
|
{
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
var assetId = Guid.NewGuid();
|
||||||
|
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
|
||||||
|
var seed = await SeedLoginUserAsync(factory, tenantId);
|
||||||
|
await factory.SeedAsync(
|
||||||
|
new ContentAsset
|
||||||
|
{
|
||||||
|
Id = assetId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
Title = "会员资料",
|
||||||
|
Visibility = ContentVisibility.Members,
|
||||||
|
StorageProvider = AssetStorageProvider.LocalDev,
|
||||||
|
Bucket = "tenant-assets",
|
||||||
|
ObjectKey = $"{tenantId:N}/members.pdf",
|
||||||
|
MimeType = "application/pdf",
|
||||||
|
AssetType = ContentAssetType.Pdf,
|
||||||
|
UploadStatus = AssetUploadStatus.Verified,
|
||||||
|
SecurityScanStatus = AssetSecurityScanStatus.Passed
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
await LoginAsync(client, seed);
|
||||||
|
|
||||||
|
using var response = await client.GetAsync($"/api/assets/{assetId}/download");
|
||||||
|
var body = await ReadJsonAsync(response);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal(seed.UserId, body.RootElement.GetProperty("access").GetProperty("userId").GetGuid());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Svip_asset_requires_active_entitlement()
|
||||||
|
{
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
var assetId = Guid.NewGuid();
|
||||||
|
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
|
||||||
|
var seed = await SeedLoginUserAsync(factory, tenantId);
|
||||||
|
await factory.SeedAsync(
|
||||||
|
new ContentAsset
|
||||||
|
{
|
||||||
|
Id = assetId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
Title = "SVIP 资料",
|
||||||
|
Visibility = ContentVisibility.Svip,
|
||||||
|
StorageProvider = AssetStorageProvider.LocalDev,
|
||||||
|
Bucket = "tenant-assets",
|
||||||
|
ObjectKey = $"{tenantId:N}/svip.pdf",
|
||||||
|
MimeType = "application/pdf",
|
||||||
|
AssetType = ContentAssetType.Pdf,
|
||||||
|
UploadStatus = AssetUploadStatus.Verified,
|
||||||
|
SecurityScanStatus = AssetSecurityScanStatus.Passed
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
await LoginAsync(client, seed);
|
||||||
|
|
||||||
|
using var deniedResponse = await client.GetAsync($"/api/assets/{assetId}/download");
|
||||||
|
await factory.SeedAsync(new Entitlement
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
UserId = seed.UserId,
|
||||||
|
EntitlementType = "svip",
|
||||||
|
ScopeType = EntitlementScopeType.Tenant,
|
||||||
|
Status = EntitlementStatus.Active,
|
||||||
|
StartsAt = DateTimeOffset.UtcNow.AddMinutes(-1)
|
||||||
|
});
|
||||||
|
using var grantedResponse = await client.GetAsync($"/api/assets/{assetId}/download");
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, deniedResponse.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, grantedResponse.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Preview_rejects_non_previewable_assets()
|
||||||
|
{
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
var assetId = Guid.NewGuid();
|
||||||
|
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
|
||||||
|
await factory.SeedAsync(
|
||||||
|
Tenant(tenantId, "master"),
|
||||||
|
new ContentAsset
|
||||||
|
{
|
||||||
|
Id = assetId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
Title = "压缩包",
|
||||||
|
Visibility = ContentVisibility.Public,
|
||||||
|
StorageProvider = AssetStorageProvider.LocalDev,
|
||||||
|
Bucket = "tenant-assets",
|
||||||
|
ObjectKey = $"{tenantId:N}/archive.zip",
|
||||||
|
MimeType = "application/zip",
|
||||||
|
AssetType = ContentAssetType.Package,
|
||||||
|
UploadStatus = AssetUploadStatus.Verified,
|
||||||
|
SecurityScanStatus = AssetSecurityScanStatus.Passed
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
using var response = await client.GetAsync($"/api/assets/{assetId}/preview?tenantCode=master");
|
||||||
|
var body = await ReadJsonAsync(response);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
Assert.Equal("asset_preview_not_supported", body.RootElement.GetProperty("code").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ContentAsset PublicAsset(Guid tenantId, Guid assetId)
|
||||||
|
{
|
||||||
|
return new ContentAsset
|
||||||
|
{
|
||||||
|
Id = assetId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
Title = "公开资料",
|
||||||
|
FileName = "download.pdf",
|
||||||
|
Visibility = ContentVisibility.Public,
|
||||||
|
StorageProvider = AssetStorageProvider.LocalDev,
|
||||||
|
Bucket = "tenant-assets",
|
||||||
|
ObjectKey = $"{tenantId:N}/download.pdf",
|
||||||
|
MimeType = "application/pdf",
|
||||||
|
AssetType = ContentAssetType.Pdf,
|
||||||
|
UploadStatus = AssetUploadStatus.Verified,
|
||||||
|
SecurityScanStatus = AssetSecurityScanStatus.Passed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Tenant Tenant(Guid id, string slug)
|
||||||
|
{
|
||||||
|
return new Tenant
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Slug = slug,
|
||||||
|
Name = slug,
|
||||||
|
Status = TenantStatus.Active,
|
||||||
|
Metadata = JsonDefaults.Object()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLoginUserAsync(
|
||||||
|
ApiTestFactory factory,
|
||||||
|
Guid tenantId)
|
||||||
|
{
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var phone = "13800000000";
|
||||||
|
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
||||||
|
|
||||||
|
await factory.SeedAsync(
|
||||||
|
Tenant(tenantId, tenantId.ToString("N")),
|
||||||
|
new User
|
||||||
|
{
|
||||||
|
Id = userId,
|
||||||
|
Phone = phone,
|
||||||
|
Name = "Test User"
|
||||||
|
},
|
||||||
|
new TenantMembership
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
UserId = userId,
|
||||||
|
Role = TenantRole.Student,
|
||||||
|
Status = MembershipStatus.Active
|
||||||
|
},
|
||||||
|
new UserIdentity
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
Provider = "password",
|
||||||
|
ProviderSubject = phone,
|
||||||
|
Phone = phone,
|
||||||
|
SecretPayload = CreateSecretPayload(passwordHash)
|
||||||
|
});
|
||||||
|
|
||||||
|
return (tenantId, userId, phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task LoginAsync(
|
||||||
|
HttpClient client,
|
||||||
|
(Guid TenantId, Guid UserId, string Phone) seed)
|
||||||
|
{
|
||||||
|
var loginResponse = await client.PostAsJsonAsync(
|
||||||
|
"/api/auth/login/password",
|
||||||
|
new PasswordLoginDto
|
||||||
|
{
|
||||||
|
TenantId = seed.TenantId,
|
||||||
|
Phone = seed.Phone,
|
||||||
|
Password = "passw0rd!"
|
||||||
|
});
|
||||||
|
var loginJson = await ReadJsonAsync(loginResponse);
|
||||||
|
var accessToken = loginJson.RootElement
|
||||||
|
.GetProperty("tokens")
|
||||||
|
.GetProperty("accessToken")
|
||||||
|
.GetString();
|
||||||
|
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
||||||
|
{
|
||||||
|
var stream = await response.Content.ReadAsStreamAsync();
|
||||||
|
return await JsonDocument.ParseAsync(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement CreateSecretPayload(string passwordHash)
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(
|
||||||
|
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||||
|
return document.RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeObjectStorageService : IObjectStorageService
|
||||||
|
{
|
||||||
|
public string ConfiguredDefaultProvider() => ObjectStorageProviders.LocalDev;
|
||||||
|
public string ConfiguredDefaultBucket() => "tenant-assets";
|
||||||
|
public string NormalizeProvider(string? value, string? fallback = null) => value ?? fallback ?? ObjectStorageProviders.LocalDev;
|
||||||
|
public string ValidateObjectKey(Guid tenantId, string objectKey) => objectKey;
|
||||||
|
public string ValidateMimeType(string mimeType) => mimeType;
|
||||||
|
public long? ValidateFileSize(long? fileSizeBytes) => fileSizeBytes;
|
||||||
|
public void AssertUploadProvider(string provider) { }
|
||||||
|
public void AssertWritableLocation(StorageAssetLocation location) { }
|
||||||
|
|
||||||
|
public Task<ObjectStorageSignedUrl> SignUploadAsync(
|
||||||
|
ObjectStorageUploadSignRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult(Signed("PUT", request.Bucket, request.ObjectKey, request.ExpiresIn));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ObjectStorageSignedUrl> SignDownloadAsync(
|
||||||
|
ObjectStorageDownloadSignRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult(Signed("GET", request.Bucket, request.ObjectKey, request.ExpiresIn));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ObjectStorageMetadata> HeadObjectAsync(
|
||||||
|
ObjectStorageHeadRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new ObjectStorageMetadata(
|
||||||
|
request.Provider,
|
||||||
|
request.Bucket,
|
||||||
|
request.ObjectKey,
|
||||||
|
Exists: true,
|
||||||
|
request.DeclaredFileSizeBytes,
|
||||||
|
request.DeclaredMimeType,
|
||||||
|
request.DeclaredChecksumSha256,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
new Dictionary<string, string>(),
|
||||||
|
"fake"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ObjectStorageSignedUrl Signed(
|
||||||
|
string method,
|
||||||
|
string? bucket,
|
||||||
|
string? objectKey,
|
||||||
|
TimeSpan expiresIn)
|
||||||
|
{
|
||||||
|
return new ObjectStorageSignedUrl(
|
||||||
|
ObjectStorageProviders.LocalDev,
|
||||||
|
bucket,
|
||||||
|
objectKey,
|
||||||
|
method,
|
||||||
|
new Uri("https://storage.example.test/download.pdf"),
|
||||||
|
new Dictionary<string, string>(),
|
||||||
|
DateTimeOffset.UtcNow.Add(expiresIn),
|
||||||
|
expiresIn,
|
||||||
|
"fake-signed-url");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user