301 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			301 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
using System.Net;
 | 
						|
using System.Text;
 | 
						|
using System.Text.Json;
 | 
						|
using DysonNetwork.Drive.Billing;
 | 
						|
using DysonNetwork.Shared.Auth;
 | 
						|
using DysonNetwork.Shared.Proto;
 | 
						|
using Microsoft.AspNetCore.Mvc;
 | 
						|
using Microsoft.Extensions.Options;
 | 
						|
using NodaTime;
 | 
						|
using tusdotnet.Interfaces;
 | 
						|
using tusdotnet.Models;
 | 
						|
using tusdotnet.Models.Configuration;
 | 
						|
 | 
						|
namespace DysonNetwork.Drive.Storage;
 | 
						|
 | 
						|
public abstract class TusService
 | 
						|
{
 | 
						|
    public static DefaultTusConfiguration BuildConfiguration(
 | 
						|
        ITusStore store,
 | 
						|
        IConfiguration configuration
 | 
						|
    ) => new()
 | 
						|
    {
 | 
						|
        Store = store,
 | 
						|
        Events = new Events
 | 
						|
        {
 | 
						|
            OnAuthorizeAsync = async eventContext =>
 | 
						|
            {
 | 
						|
                if (eventContext.Intent == IntentType.DeleteFile)
 | 
						|
                {
 | 
						|
                    eventContext.FailRequest(
 | 
						|
                        HttpStatusCode.BadRequest,
 | 
						|
                        "Deleting files from this endpoint was disabled, please refer to the Dyson Network File API."
 | 
						|
                    );
 | 
						|
                    return;
 | 
						|
                }
 | 
						|
 | 
						|
                var httpContext = eventContext.HttpContext;
 | 
						|
                if (httpContext.Items["CurrentUser"] is not Account currentUser)
 | 
						|
                {
 | 
						|
                    eventContext.FailRequest(HttpStatusCode.Unauthorized);
 | 
						|
                    return;
 | 
						|
                }
 | 
						|
 | 
						|
                if (eventContext.Intent != IntentType.CreateFile) return;
 | 
						|
 | 
						|
                using var scope = httpContext.RequestServices.CreateScope();
 | 
						|
 | 
						|
                if (!currentUser.IsSuperuser)
 | 
						|
                {
 | 
						|
                    var pm = scope.ServiceProvider.GetRequiredService<PermissionService.PermissionServiceClient>();
 | 
						|
                    var allowed = await pm.HasPermissionAsync(new HasPermissionRequest
 | 
						|
                        { Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" });
 | 
						|
                    if (!allowed.HasPermission)
 | 
						|
                        eventContext.FailRequest(HttpStatusCode.Forbidden);
 | 
						|
                }
 | 
						|
 | 
						|
                var filePool = httpContext.Request.Headers["X-FilePool"].FirstOrDefault();
 | 
						|
                if (string.IsNullOrEmpty(filePool)) filePool = configuration["Storage:PreferredRemote"];
 | 
						|
                if (!Guid.TryParse(filePool, out _))
 | 
						|
                {
 | 
						|
                    eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file pool id");
 | 
						|
                    return;
 | 
						|
                }
 | 
						|
 | 
						|
                var fs = scope.ServiceProvider.GetRequiredService<FileService>();
 | 
						|
                var pool = await fs.GetPoolAsync(Guid.Parse(filePool!));
 | 
						|
                if (pool is null)
 | 
						|
                {
 | 
						|
                    eventContext.FailRequest(HttpStatusCode.BadRequest, "Pool not found");
 | 
						|
                    return;
 | 
						|
                }
 | 
						|
 | 
						|
                if (pool.PolicyConfig.RequirePrivilege > 0)
 | 
						|
                {
 | 
						|
                    if (currentUser.PerkSubscription is null)
 | 
						|
                    {
 | 
						|
                        eventContext.FailRequest(
 | 
						|
                            HttpStatusCode.Forbidden,
 | 
						|
                            $"You need to have join the Stellar Program to use this pool"
 | 
						|
                        );
 | 
						|
                        return;
 | 
						|
                    }
 | 
						|
 | 
						|
                    var privilege =
 | 
						|
                        PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier);
 | 
						|
                    if (privilege < pool.PolicyConfig.RequirePrivilege)
 | 
						|
                    {
 | 
						|
                        eventContext.FailRequest(
 | 
						|
                            HttpStatusCode.Forbidden,
 | 
						|
                            $"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool, you are tier {privilege}"
 | 
						|
                        );
 | 
						|
                    }
 | 
						|
                }
 | 
						|
                
 | 
						|
                var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault();
 | 
						|
                if (!string.IsNullOrEmpty(bundleId) && !Guid.TryParse(bundleId, out _))
 | 
						|
                {
 | 
						|
                    eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file bundle id");
 | 
						|
                }
 | 
						|
            },
 | 
						|
            OnFileCompleteAsync = async eventContext =>
 | 
						|
            {
 | 
						|
                using var scope = eventContext.HttpContext.RequestServices.CreateScope();
 | 
						|
                var services = scope.ServiceProvider;
 | 
						|
 | 
						|
                var httpContext = eventContext.HttpContext;
 | 
						|
                if (httpContext.Items["CurrentUser"] is not Account user) return;
 | 
						|
 | 
						|
                var file = await eventContext.GetFileAsync();
 | 
						|
                var metadata = await file.GetMetadataAsync(eventContext.CancellationToken);
 | 
						|
                var fileName = metadata.TryGetValue("filename", out var fn)
 | 
						|
                    ? fn.GetString(Encoding.UTF8)
 | 
						|
                    : "uploaded_file";
 | 
						|
                var contentType = metadata.TryGetValue("content-type", out var ct) ? ct.GetString(Encoding.UTF8) : null;
 | 
						|
 | 
						|
                var filePath = Path.Combine(configuration.GetValue<string>("Tus:StorePath")!, file.Id);
 | 
						|
 | 
						|
                var filePool = httpContext.Request.Headers["X-FilePool"].FirstOrDefault();
 | 
						|
                var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault();
 | 
						|
                var encryptPassword = httpContext.Request.Headers["X-FilePass"].FirstOrDefault();
 | 
						|
 | 
						|
                if (string.IsNullOrEmpty(filePool))
 | 
						|
                    filePool = configuration["Storage:PreferredRemote"];
 | 
						|
 | 
						|
                Instant? expiredAt = null;
 | 
						|
                var expiredString = httpContext.Request.Headers["X-FileExpire"].FirstOrDefault();
 | 
						|
                if (!string.IsNullOrEmpty(expiredString) && int.TryParse(expiredString, out var expired))
 | 
						|
                    expiredAt = Instant.FromUnixTimeSeconds(expired);
 | 
						|
                
 | 
						|
                try
 | 
						|
                {
 | 
						|
                    var fileService = services.GetRequiredService<FileService>();
 | 
						|
                    var info = await fileService.ProcessNewFileAsync(
 | 
						|
                        user,
 | 
						|
                        file.Id,
 | 
						|
                        filePool!,
 | 
						|
                        bundleId,
 | 
						|
                        filePath,
 | 
						|
                        fileName,
 | 
						|
                        contentType,
 | 
						|
                        encryptPassword,
 | 
						|
                        expiredAt
 | 
						|
                    );
 | 
						|
 | 
						|
                    using var finalScope = eventContext.HttpContext.RequestServices.CreateScope();
 | 
						|
                    var jsonOptions = finalScope.ServiceProvider.GetRequiredService<IOptions<JsonOptions>>().Value
 | 
						|
                        .JsonSerializerOptions;
 | 
						|
                    var infoJson = JsonSerializer.Serialize(info, jsonOptions);
 | 
						|
                    eventContext.HttpContext.Response.Headers.Append("X-FileInfo", infoJson);
 | 
						|
                }
 | 
						|
                catch (Exception ex)
 | 
						|
                {
 | 
						|
                    var logger = services.GetRequiredService<ILogger<TusService>>();
 | 
						|
                    eventContext.HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
 | 
						|
                    await eventContext.HttpContext.Response.WriteAsync(ex.Message);
 | 
						|
                    logger.LogError(ex, "Error handling file upload...");
 | 
						|
                }
 | 
						|
            },
 | 
						|
            OnBeforeCreateAsync = async eventContext =>
 | 
						|
            {
 | 
						|
                var httpContext = eventContext.HttpContext;
 | 
						|
                if (httpContext.Items["CurrentUser"] is not Account currentUser)
 | 
						|
                {
 | 
						|
                    eventContext.FailRequest(HttpStatusCode.Unauthorized);
 | 
						|
                    return;
 | 
						|
                }
 | 
						|
                var accountId = Guid.Parse(currentUser.Id);
 | 
						|
 | 
						|
                var poolId = eventContext.HttpContext.Request.Headers["X-FilePool"].FirstOrDefault();
 | 
						|
                if (string.IsNullOrEmpty(poolId)) poolId = configuration["Storage:PreferredRemote"];
 | 
						|
                if (!Guid.TryParse(poolId, out _))
 | 
						|
                {
 | 
						|
                    eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file pool id");
 | 
						|
                    return;
 | 
						|
                }
 | 
						|
 | 
						|
                var bundleId = eventContext.HttpContext.Request.Headers["X-FileBundle"].FirstOrDefault();
 | 
						|
                if (!string.IsNullOrEmpty(bundleId) && !Guid.TryParse(bundleId, out _))
 | 
						|
                {
 | 
						|
                    eventContext.FailRequest(HttpStatusCode.BadRequest, "Invalid file bundle id");
 | 
						|
                    return;
 | 
						|
                }
 | 
						|
 | 
						|
                var metadata = eventContext.Metadata;
 | 
						|
                var contentType = metadata.TryGetValue("content-type", out var ct) ? ct.GetString(Encoding.UTF8) : null;
 | 
						|
 | 
						|
                var scope = eventContext.HttpContext.RequestServices.CreateScope();
 | 
						|
 | 
						|
                var rejected = false;
 | 
						|
 | 
						|
                var fs = scope.ServiceProvider.GetRequiredService<FileService>();
 | 
						|
                var pool = await fs.GetPoolAsync(Guid.Parse(poolId!));
 | 
						|
                if (pool is null)
 | 
						|
                {
 | 
						|
                    eventContext.FailRequest(HttpStatusCode.BadRequest, "Pool not found");
 | 
						|
                    rejected = true;
 | 
						|
                }
 | 
						|
 | 
						|
                var logger = scope.ServiceProvider.GetRequiredService<ILogger<TusService>>();
 | 
						|
 | 
						|
                // Do the policy check
 | 
						|
                var policy = pool!.PolicyConfig;
 | 
						|
                if (!rejected && !pool.PolicyConfig.AllowEncryption)
 | 
						|
                {
 | 
						|
                    var encryptPassword = eventContext.HttpContext.Request.Headers["X-FilePass"].FirstOrDefault();
 | 
						|
                    if (!string.IsNullOrEmpty(encryptPassword))
 | 
						|
                    {
 | 
						|
                        eventContext.FailRequest(
 | 
						|
                            HttpStatusCode.Forbidden,
 | 
						|
                            "File encryption is not allowed in this pool"
 | 
						|
                        );
 | 
						|
                        rejected = true;
 | 
						|
                    }
 | 
						|
                }
 | 
						|
 | 
						|
                if (!rejected && policy.AcceptTypes is not null)
 | 
						|
                {
 | 
						|
                    if (string.IsNullOrEmpty(contentType))
 | 
						|
                    {
 | 
						|
                        eventContext.FailRequest(
 | 
						|
                            HttpStatusCode.BadRequest,
 | 
						|
                            "Content type is required by the pool's policy"
 | 
						|
                        );
 | 
						|
                        rejected = true;
 | 
						|
                    }
 | 
						|
                    else
 | 
						|
                    {
 | 
						|
                        var foundMatch = false;
 | 
						|
                        foreach (var acceptType in policy.AcceptTypes)
 | 
						|
                        {
 | 
						|
                            if (acceptType.EndsWith("/*", StringComparison.OrdinalIgnoreCase))
 | 
						|
                            {
 | 
						|
                                var type = acceptType[..^2];
 | 
						|
                                if (!contentType.StartsWith($"{type}/", StringComparison.OrdinalIgnoreCase)) continue;
 | 
						|
                                foundMatch = true;
 | 
						|
                                break;
 | 
						|
                            }
 | 
						|
                            else if (acceptType.Equals(contentType, StringComparison.OrdinalIgnoreCase))
 | 
						|
                            {
 | 
						|
                                foundMatch = true;
 | 
						|
                                break;
 | 
						|
                            }
 | 
						|
                        }
 | 
						|
 | 
						|
                        if (!foundMatch)
 | 
						|
                        {
 | 
						|
                            eventContext.FailRequest(
 | 
						|
                                HttpStatusCode.Forbidden,
 | 
						|
                                $"Content type {contentType} is not allowed by the pool's policy"
 | 
						|
                            );
 | 
						|
                            rejected = true;
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
                }
 | 
						|
 | 
						|
                if (!rejected && policy.MaxFileSize is not null)
 | 
						|
                {
 | 
						|
                    if (eventContext.UploadLength > policy.MaxFileSize)
 | 
						|
                    {
 | 
						|
                        eventContext.FailRequest(
 | 
						|
                            HttpStatusCode.Forbidden,
 | 
						|
                            $"File size {eventContext.UploadLength} is larger than the pool's maximum file size {policy.MaxFileSize}"
 | 
						|
                        );
 | 
						|
                        rejected = true;
 | 
						|
                    }
 | 
						|
                }
 | 
						|
 | 
						|
                if (!rejected)
 | 
						|
                {
 | 
						|
                    var quotaService = scope.ServiceProvider.GetRequiredService<QuotaService>();
 | 
						|
                    var (ok, billableUnit, quota) = await quotaService.IsFileAcceptable(
 | 
						|
                        accountId,
 | 
						|
                        pool.BillingConfig.CostMultiplier ?? 1.0,
 | 
						|
                        eventContext.UploadLength
 | 
						|
                    );
 | 
						|
                    if (!ok)
 | 
						|
                    {
 | 
						|
                        eventContext.FailRequest(
 | 
						|
                            HttpStatusCode.Forbidden,
 | 
						|
                            $"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB"
 | 
						|
                        );
 | 
						|
                        rejected = true;
 | 
						|
                    }
 | 
						|
                }
 | 
						|
 | 
						|
                if (rejected)
 | 
						|
                    logger.LogInformation("File rejected #{FileId}", eventContext.FileId);
 | 
						|
            },
 | 
						|
            OnCreateCompleteAsync = eventContext =>
 | 
						|
            {
 | 
						|
                var directUpload = eventContext.HttpContext.Request.Headers["X-DirectUpload"].FirstOrDefault();
 | 
						|
                if (!string.IsNullOrEmpty(directUpload)) return Task.CompletedTask;
 | 
						|
 | 
						|
                var gatewayUrl = configuration["GatewayUrl"];
 | 
						|
                if (gatewayUrl is not null)
 | 
						|
                    eventContext.SetUploadUrl(new Uri(gatewayUrl + "/drive/tus/" + eventContext.FileId));
 | 
						|
                return Task.CompletedTask;
 | 
						|
            },
 | 
						|
        }
 | 
						|
    };
 | 
						|
} |