398 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			398 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
using DysonNetwork.Drive.Billing;
 | 
						|
using DysonNetwork.Shared.Auth;
 | 
						|
using DysonNetwork.Shared.Models;
 | 
						|
using DysonNetwork.Shared.Proto;
 | 
						|
using Microsoft.AspNetCore.Authorization;
 | 
						|
using Microsoft.AspNetCore.Mvc;
 | 
						|
using Microsoft.EntityFrameworkCore;
 | 
						|
using Minio.DataModel.Args;
 | 
						|
 | 
						|
namespace DysonNetwork.Drive.Storage;
 | 
						|
 | 
						|
[ApiController]
 | 
						|
[Route("/api/files")]
 | 
						|
public class FileController(
 | 
						|
    AppDatabase db,
 | 
						|
    FileService fs,
 | 
						|
    QuotaService qs,
 | 
						|
    IConfiguration configuration,
 | 
						|
    IWebHostEnvironment env
 | 
						|
) : ControllerBase
 | 
						|
{
 | 
						|
    [HttpGet("{id}")]
 | 
						|
    public async Task<ActionResult> OpenFile(
 | 
						|
        string id,
 | 
						|
        [FromQuery] bool download = false,
 | 
						|
        [FromQuery] bool original = false,
 | 
						|
        [FromQuery] bool thumbnail = false,
 | 
						|
        [FromQuery] string? overrideMimeType = null,
 | 
						|
        [FromQuery] string? passcode = null
 | 
						|
    )
 | 
						|
    {
 | 
						|
        // Support the file extension for client side data recognize
 | 
						|
        string? fileExtension = null;
 | 
						|
        if (id.Contains('.'))
 | 
						|
        {
 | 
						|
            var splitId = id.Split('.');
 | 
						|
            id = splitId.First();
 | 
						|
            fileExtension = splitId.Last();
 | 
						|
        }
 | 
						|
 | 
						|
        var file = await fs.GetFileAsync(id);
 | 
						|
        if (file is null) return NotFound("File not found.");
 | 
						|
 | 
						|
        if (file.Bundle is not null && !file.Bundle.VerifyPasscode(passcode))
 | 
						|
            return StatusCode(StatusCodes.Status403Forbidden, "The passcode is incorrect.");
 | 
						|
 | 
						|
        if (!string.IsNullOrWhiteSpace(file.StorageUrl)) return Redirect(file.StorageUrl);
 | 
						|
 | 
						|
        if (file.UploadedAt is null)
 | 
						|
        {
 | 
						|
            // File is not yet uploaded to remote storage. Try to serve from local temp storage.
 | 
						|
            var tempFilePath = Path.Combine(Path.GetTempPath(), file.Id);
 | 
						|
            if (System.IO.File.Exists(tempFilePath))
 | 
						|
            {
 | 
						|
                if (file.IsEncrypted)
 | 
						|
                {
 | 
						|
                    return StatusCode(StatusCodes.Status403Forbidden, "Encrypted files cannot be accessed before they are processed and stored.");
 | 
						|
                }
 | 
						|
                return PhysicalFile(tempFilePath, file.MimeType ?? "application/octet-stream", file.Name, enableRangeProcessing: true);
 | 
						|
            }
 | 
						|
        
 | 
						|
            // Fallback for tus uploads that are not processed yet.
 | 
						|
            var tusStorePath = configuration.GetValue<string>("Tus:StorePath");
 | 
						|
            if (!string.IsNullOrEmpty(tusStorePath))
 | 
						|
            {
 | 
						|
                var tusFilePath = Path.Combine(env.ContentRootPath, tusStorePath, file.Id);
 | 
						|
                if (System.IO.File.Exists(tusFilePath))
 | 
						|
                {
 | 
						|
                    return PhysicalFile(tusFilePath, file.MimeType ?? "application/octet-stream", file.Name, enableRangeProcessing: true);
 | 
						|
                }
 | 
						|
            }
 | 
						|
 | 
						|
            return StatusCode(StatusCodes.Status400BadRequest, "File is being processed. Please try again later.");
 | 
						|
        }
 | 
						|
 | 
						|
        if (!file.PoolId.HasValue)
 | 
						|
            return StatusCode(StatusCodes.Status500InternalServerError, "File is in an inconsistent state: uploaded but no pool ID.");
 | 
						|
 | 
						|
        var pool = await fs.GetPoolAsync(file.PoolId.Value);
 | 
						|
        if (pool is null)
 | 
						|
            return StatusCode(StatusCodes.Status410Gone, "The pool of the file no longer exists or not accessible.");
 | 
						|
        var dest = pool.StorageConfig;
 | 
						|
 | 
						|
        if (!pool.PolicyConfig.AllowAnonymous)
 | 
						|
            if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | 
						|
                return Unauthorized();
 | 
						|
        // TODO: Provide ability to add access log
 | 
						|
 | 
						|
        var fileName = string.IsNullOrWhiteSpace(file.StorageId) ? file.Id : file.StorageId;
 | 
						|
 | 
						|
        switch (thumbnail)
 | 
						|
        {
 | 
						|
            case true when file.HasThumbnail:
 | 
						|
                fileName += ".thumbnail";
 | 
						|
                break;
 | 
						|
            case true when !file.HasThumbnail:
 | 
						|
                return NotFound();
 | 
						|
        }
 | 
						|
 | 
						|
        if (!original && file.HasCompression)
 | 
						|
            fileName += ".compressed";
 | 
						|
 | 
						|
        if (dest.ImageProxy is not null && (file.MimeType?.StartsWith("image/") ?? false))
 | 
						|
        {
 | 
						|
            var proxyUrl = dest.ImageProxy;
 | 
						|
            var baseUri = new Uri(proxyUrl.EndsWith('/') ? proxyUrl : $"{proxyUrl}/");
 | 
						|
            var fullUri = new Uri(baseUri, fileName);
 | 
						|
            return Redirect(fullUri.ToString());
 | 
						|
        }
 | 
						|
 | 
						|
        if (dest.AccessProxy is not null)
 | 
						|
        {
 | 
						|
            var proxyUrl = dest.AccessProxy;
 | 
						|
            var baseUri = new Uri(proxyUrl.EndsWith('/') ? proxyUrl : $"{proxyUrl}/");
 | 
						|
            var fullUri = new Uri(baseUri, fileName);
 | 
						|
            return Redirect(fullUri.ToString());
 | 
						|
        }
 | 
						|
 | 
						|
        if (dest.EnableSigned)
 | 
						|
        {
 | 
						|
            var client = fs.CreateMinioClient(dest);
 | 
						|
            if (client is null)
 | 
						|
                return BadRequest(
 | 
						|
                    "Failed to configure client for remote destination, file got an invalid storage remote."
 | 
						|
                );
 | 
						|
 | 
						|
            var headers = new Dictionary<string, string>();
 | 
						|
            if (fileExtension is not null)
 | 
						|
            {
 | 
						|
                if (MimeTypes.TryGetMimeType(fileExtension, out var mimeType))
 | 
						|
                    headers.Add("Response-Content-Type", mimeType);
 | 
						|
            }
 | 
						|
            else if (overrideMimeType is not null)
 | 
						|
            {
 | 
						|
                headers.Add("Response-Content-Type", overrideMimeType);
 | 
						|
            }
 | 
						|
            else if (file.MimeType is not null && !file.MimeType!.EndsWith("unknown"))
 | 
						|
            {
 | 
						|
                headers.Add("Response-Content-Type", file.MimeType);
 | 
						|
            }
 | 
						|
 | 
						|
            if (download)
 | 
						|
            {
 | 
						|
                headers.Add("Response-Content-Disposition", $"attachment; filename=\"{file.Name}\"");
 | 
						|
            }
 | 
						|
 | 
						|
            var bucket = dest.Bucket;
 | 
						|
            var openUrl = await client.PresignedGetObjectAsync(
 | 
						|
                new PresignedGetObjectArgs()
 | 
						|
                    .WithBucket(bucket)
 | 
						|
                    .WithObject(fileName)
 | 
						|
                    .WithExpiry(3600)
 | 
						|
                    .WithHeaders(headers)
 | 
						|
            );
 | 
						|
 | 
						|
            return Redirect(openUrl);
 | 
						|
        }
 | 
						|
 | 
						|
        // Fallback redirect to the S3 endpoint (public read)
 | 
						|
        var protocol = dest.EnableSsl ? "https" : "http";
 | 
						|
        // Use the path bucket lookup mode
 | 
						|
        return Redirect($"{protocol}://{dest.Endpoint}/{dest.Bucket}/{fileName}");
 | 
						|
    }
 | 
						|
 | 
						|
    [HttpGet("{id}/info")]
 | 
						|
    public async Task<ActionResult<SnCloudFile>> GetFileInfo(string id)
 | 
						|
    {
 | 
						|
        var file = await fs.GetFileAsync(id);
 | 
						|
        if (file is null) return NotFound("File not found.");
 | 
						|
 | 
						|
        return file;
 | 
						|
    }
 | 
						|
 | 
						|
    [Authorize]
 | 
						|
    [HttpPatch("{id}/name")]
 | 
						|
    public async Task<ActionResult<SnCloudFile>> UpdateFileName(string id, [FromBody] string name)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
						|
        var accountId = Guid.Parse(currentUser.Id);
 | 
						|
        var file = await db.Files.FirstOrDefaultAsync(f => f.Id == id && f.AccountId == accountId);
 | 
						|
        if (file is null) return NotFound();
 | 
						|
        file.Name = name;
 | 
						|
        await db.SaveChangesAsync();
 | 
						|
        await fs._PurgeCacheAsync(file.Id);
 | 
						|
        return file;
 | 
						|
    }
 | 
						|
 | 
						|
    public class MarkFileRequest
 | 
						|
    {
 | 
						|
        public List<Shared.Models.ContentSensitiveMark>? SensitiveMarks { get; set; }
 | 
						|
    }
 | 
						|
 | 
						|
    [Authorize]
 | 
						|
    [HttpPut("{id}/marks")]
 | 
						|
    public async Task<ActionResult<SnCloudFile>> MarkFile(string id, [FromBody] MarkFileRequest request)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
						|
        var accountId = Guid.Parse(currentUser.Id);
 | 
						|
        var file = await db.Files.FirstOrDefaultAsync(f => f.Id == id && f.AccountId == accountId);
 | 
						|
        if (file is null) return NotFound();
 | 
						|
        file.SensitiveMarks = request.SensitiveMarks;
 | 
						|
        await db.SaveChangesAsync();
 | 
						|
        await fs._PurgeCacheAsync(file.Id);
 | 
						|
        return file;
 | 
						|
    }
 | 
						|
 | 
						|
    [Authorize]
 | 
						|
    [HttpPut("{id}/meta")]
 | 
						|
    public async Task<ActionResult<SnCloudFile>> UpdateFileMeta(string id, [FromBody] Dictionary<string, object?> meta)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
						|
        var accountId = Guid.Parse(currentUser.Id);
 | 
						|
        var file = await db.Files.FirstOrDefaultAsync(f => f.Id == id && f.AccountId == accountId);
 | 
						|
        if (file is null) return NotFound();
 | 
						|
        file.UserMeta = meta;
 | 
						|
        await db.SaveChangesAsync();
 | 
						|
        await fs._PurgeCacheAsync(file.Id);
 | 
						|
        return file;
 | 
						|
    }
 | 
						|
 | 
						|
    [Authorize]
 | 
						|
    [HttpGet("me")]
 | 
						|
    public async Task<ActionResult<List<SnCloudFile>>> GetMyFiles(
 | 
						|
        [FromQuery] Guid? pool,
 | 
						|
        [FromQuery] bool recycled = false,
 | 
						|
        [FromQuery] int offset = 0,
 | 
						|
        [FromQuery] int take = 20
 | 
						|
    )
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
						|
        var accountId = Guid.Parse(currentUser.Id);
 | 
						|
 | 
						|
        var query = db.Files
 | 
						|
            .Where(e => e.IsMarkedRecycle == recycled)
 | 
						|
            .Where(e => e.AccountId == accountId)
 | 
						|
            .Include(e => e.Pool)
 | 
						|
            .OrderByDescending(e => e.CreatedAt)
 | 
						|
            .AsQueryable();
 | 
						|
 | 
						|
        if (pool.HasValue) query = query.Where(e => e.PoolId == pool);
 | 
						|
 | 
						|
        var total = await query.CountAsync();
 | 
						|
        Response.Headers.Append("X-Total", total.ToString());
 | 
						|
 | 
						|
        var files = await query
 | 
						|
            .Skip(offset)
 | 
						|
            .Take(take)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        return Ok(files);
 | 
						|
    }
 | 
						|
 | 
						|
    [Authorize]
 | 
						|
    [HttpDelete("{id}")]
 | 
						|
    public async Task<ActionResult> DeleteFile(string id)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
						|
        var userId = Guid.Parse(currentUser.Id);
 | 
						|
 | 
						|
        var file = await db.Files
 | 
						|
            .Where(e => e.Id == id)
 | 
						|
            .Where(e => e.AccountId == userId)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
        if (file is null) return NotFound();
 | 
						|
 | 
						|
        await fs.DeleteFileDataAsync(file, force: true);
 | 
						|
        await fs.DeleteFileAsync(file);
 | 
						|
 | 
						|
        return NoContent();
 | 
						|
    }
 | 
						|
 | 
						|
    [Authorize]
 | 
						|
    [HttpDelete("me/recycle")]
 | 
						|
    public async Task<ActionResult> DeleteMyRecycledFiles()
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
						|
        var accountId = Guid.Parse(currentUser.Id);
 | 
						|
 | 
						|
        var count = await fs.DeleteAccountRecycledFilesAsync(accountId);
 | 
						|
        return Ok(new { Count = count });
 | 
						|
    }
 | 
						|
 | 
						|
    [Authorize]
 | 
						|
    [HttpDelete("recycle")]
 | 
						|
    [RequiredPermission("maintenance", "files.delete.recycle")]
 | 
						|
    public async Task<ActionResult> DeleteAllRecycledFiles()
 | 
						|
    {
 | 
						|
        var count = await fs.DeleteAllRecycledFilesAsync();
 | 
						|
        return Ok(new { Count = count });
 | 
						|
    }
 | 
						|
 | 
						|
    public class CreateFastFileRequest
 | 
						|
    {
 | 
						|
        public string Name { get; set; } = null!;
 | 
						|
        public long Size { get; set; }
 | 
						|
        public string Hash { get; set; } = null!;
 | 
						|
        public string? MimeType { get; set; }
 | 
						|
        public string? Description { get; set; }
 | 
						|
        public Dictionary<string, object?>? UserMeta { get; set; }
 | 
						|
        public Dictionary<string, object?>? FileMeta { get; set; }
 | 
						|
        public List<Shared.Models.ContentSensitiveMark>? SensitiveMarks { get; set; }
 | 
						|
        public Guid PoolId { get; set; }
 | 
						|
    }
 | 
						|
 | 
						|
    [Authorize]
 | 
						|
    [HttpPost("fast")]
 | 
						|
    [RequiredPermission("global", "files.create")]
 | 
						|
    public async Task<ActionResult<SnCloudFile>> CreateFastFile([FromBody] CreateFastFileRequest request)
 | 
						|
    {
 | 
						|
        if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | 
						|
        var accountId = Guid.Parse(currentUser.Id);
 | 
						|
 | 
						|
        var pool = await db.Pools.FirstOrDefaultAsync(p => p.Id == request.PoolId);
 | 
						|
        if (pool is null) return BadRequest();
 | 
						|
        if (!currentUser.IsSuperuser && pool.AccountId != accountId)
 | 
						|
            return StatusCode(403, "You don't have permission to create files in this pool.");
 | 
						|
 | 
						|
        if (!pool.PolicyConfig.EnableFastUpload)
 | 
						|
            return StatusCode(
 | 
						|
                403,
 | 
						|
                "This pool does not allow fast upload"
 | 
						|
            );
 | 
						|
 | 
						|
        if (pool.PolicyConfig.RequirePrivilege > 0)
 | 
						|
        {
 | 
						|
            if (currentUser.PerkSubscription is null)
 | 
						|
            {
 | 
						|
                return StatusCode(
 | 
						|
                    403,
 | 
						|
                    $"You need to have join the Stellar Program to use this pool"
 | 
						|
                );
 | 
						|
            }
 | 
						|
 | 
						|
            var privilege =
 | 
						|
                PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier);
 | 
						|
            if (privilege < pool.PolicyConfig.RequirePrivilege)
 | 
						|
            {
 | 
						|
                return StatusCode(
 | 
						|
                    403,
 | 
						|
                    $"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use this pool, you are tier {privilege}"
 | 
						|
                );
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        if (request.Size > pool.PolicyConfig.MaxFileSize)
 | 
						|
        {
 | 
						|
            return StatusCode(
 | 
						|
                403,
 | 
						|
                $"File size {request.Size} is larger than the pool's maximum file size {pool.PolicyConfig.MaxFileSize}"
 | 
						|
            );
 | 
						|
        }
 | 
						|
 | 
						|
        var (ok, billableUnit, quota) = await qs.IsFileAcceptable(
 | 
						|
            accountId,
 | 
						|
            pool.BillingConfig.CostMultiplier ?? 1.0,
 | 
						|
            request.Size
 | 
						|
        );
 | 
						|
        if (!ok)
 | 
						|
        {
 | 
						|
            return StatusCode(
 | 
						|
                403,
 | 
						|
                $"File size {billableUnit} is larger than the user's quota {quota}"
 | 
						|
            );
 | 
						|
        }
 | 
						|
 | 
						|
        await using var transaction = await db.Database.BeginTransactionAsync();
 | 
						|
        try
 | 
						|
        {
 | 
						|
            var file = new SnCloudFile
 | 
						|
            {
 | 
						|
                Name = request.Name,
 | 
						|
                Size = request.Size,
 | 
						|
                Hash = request.Hash,
 | 
						|
                MimeType = request.MimeType,
 | 
						|
                Description = request.Description,
 | 
						|
                AccountId = accountId,
 | 
						|
                UserMeta = request.UserMeta,
 | 
						|
                FileMeta = request.FileMeta,
 | 
						|
                SensitiveMarks = request.SensitiveMarks,
 | 
						|
                PoolId = request.PoolId
 | 
						|
            };
 | 
						|
            db.Files.Add(file);
 | 
						|
            await db.SaveChangesAsync();
 | 
						|
            await fs._PurgeCacheAsync(file.Id);
 | 
						|
            await transaction.CommitAsync();
 | 
						|
 | 
						|
            file.FastUploadLink = await fs.CreateFastUploadLinkAsync(file);
 | 
						|
 | 
						|
            return file;
 | 
						|
        }
 | 
						|
        catch (Exception)
 | 
						|
        {
 | 
						|
            await transaction.RollbackAsync();
 | 
						|
            throw;
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 |