Compare commits
4 Commits
637cc0cfa4
...
c08503d2f3
| Author | SHA1 | Date | |
|---|---|---|---|
|
c08503d2f3
|
|||
|
c8fec66e07
|
|||
|
61b49377a7
|
|||
|
0123c74ab8
|
@@ -6,7 +6,6 @@ namespace DysonNetwork.Drive.Storage;
|
|||||||
|
|
||||||
public class CloudFileUnusedRecyclingJob(
|
public class CloudFileUnusedRecyclingJob(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
FileReferenceService fileRefService,
|
|
||||||
ILogger<CloudFileUnusedRecyclingJob> logger,
|
ILogger<CloudFileUnusedRecyclingJob> logger,
|
||||||
IConfiguration configuration
|
IConfiguration configuration
|
||||||
)
|
)
|
||||||
@@ -80,15 +79,15 @@ public class CloudFileUnusedRecyclingJob(
|
|||||||
processedCount += fileBatch.Count;
|
processedCount += fileBatch.Count;
|
||||||
lastProcessedId = fileBatch.Last();
|
lastProcessedId = fileBatch.Last();
|
||||||
|
|
||||||
// Get all relevant file references for this batch
|
// Optimized query: Find files that have no references OR all references are expired
|
||||||
var fileReferences = await fileRefService.GetReferencesAsync(fileBatch);
|
// This replaces the memory-intensive approach of loading all references
|
||||||
|
var filesToMark = await db.Files
|
||||||
// Filter to find files that have no references or all expired references
|
.Where(f => fileBatch.Contains(f.Id))
|
||||||
var filesToMark = fileBatch.Where(fileId =>
|
.Where(f => !db.FileReferences.Any(r => r.FileId == f.Id) || // No references at all
|
||||||
!fileReferences.TryGetValue(fileId, out var references) ||
|
!db.FileReferences.Any(r => r.FileId == f.Id && // OR has references but all are expired
|
||||||
references.Count == 0 ||
|
(r.ExpiredAt == null || r.ExpiredAt > now)))
|
||||||
references.All(r => r.ExpiredAt.HasValue && r.ExpiredAt.Value <= now)
|
.Select(f => f.Id)
|
||||||
).ToList();
|
.ToListAsync();
|
||||||
|
|
||||||
if (filesToMark.Count > 0)
|
if (filesToMark.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -120,4 +119,4 @@ public class CloudFileUnusedRecyclingJob(
|
|||||||
|
|
||||||
logger.LogInformation("Completed marking {MarkedCount} files for recycling", markedCount);
|
logger.LogInformation("Completed marking {MarkedCount} files for recycling", markedCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,137 +29,200 @@ public class FileController(
|
|||||||
[FromQuery] string? passcode = null
|
[FromQuery] string? passcode = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// Support the file extension for client side data recognize
|
var (fileId, fileExtension) = ParseFileId(id);
|
||||||
string? fileExtension = null;
|
var file = await fs.GetFileAsync(fileId);
|
||||||
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 is null) return NotFound("File not found.");
|
||||||
|
|
||||||
|
var accessResult = await ValidateFileAccess(file, passcode);
|
||||||
|
if (accessResult is not null) return accessResult;
|
||||||
|
|
||||||
|
// Handle direct storage URL redirect
|
||||||
|
if (!string.IsNullOrWhiteSpace(file.StorageUrl))
|
||||||
|
return Redirect(file.StorageUrl);
|
||||||
|
|
||||||
|
// Handle files not yet uploaded to remote storage
|
||||||
|
if (file.UploadedAt is null)
|
||||||
|
return await ServeLocalFile(file);
|
||||||
|
|
||||||
|
// Handle uploaded files
|
||||||
|
return await ServeRemoteFile(file, fileExtension, download, original, thumbnail, overrideMimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (string fileId, string? extension) ParseFileId(string id)
|
||||||
|
{
|
||||||
|
if (!id.Contains('.')) return (id, null);
|
||||||
|
|
||||||
|
var parts = id.Split('.');
|
||||||
|
return (parts.First(), parts.Last());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ActionResult?> ValidateFileAccess(SnCloudFile file, string? passcode)
|
||||||
|
{
|
||||||
if (file.Bundle is not null && !file.Bundle.VerifyPasscode(passcode))
|
if (file.Bundle is not null && !file.Bundle.VerifyPasscode(passcode))
|
||||||
return StatusCode(StatusCodes.Status403Forbidden, "The passcode is incorrect.");
|
return StatusCode(StatusCodes.Status403Forbidden, "The passcode is incorrect.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(file.StorageUrl)) return Redirect(file.StorageUrl);
|
private async Task<ActionResult> ServeLocalFile(SnCloudFile file)
|
||||||
|
{
|
||||||
if (file.UploadedAt is null)
|
// Try temp storage first
|
||||||
|
var tempFilePath = Path.Combine(Path.GetTempPath(), file.Id);
|
||||||
|
if (System.IO.File.Exists(tempFilePath))
|
||||||
{
|
{
|
||||||
// File is not yet uploaded to remote storage. Try to serve from local temp storage.
|
if (file.IsEncrypted)
|
||||||
var tempFilePath = Path.Combine(Path.GetTempPath(), file.Id);
|
return StatusCode(StatusCodes.Status403Forbidden, "Encrypted files cannot be accessed before they are processed and stored.");
|
||||||
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.");
|
return PhysicalFile(tempFilePath, file.MimeType ?? "application/octet-stream", file.Name, enableRangeProcessing: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback for tus uploads
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ActionResult> ServeRemoteFile(
|
||||||
|
SnCloudFile file,
|
||||||
|
string? fileExtension,
|
||||||
|
bool download,
|
||||||
|
bool original,
|
||||||
|
bool thumbnail,
|
||||||
|
string? overrideMimeType
|
||||||
|
)
|
||||||
|
{
|
||||||
if (!file.PoolId.HasValue)
|
if (!file.PoolId.HasValue)
|
||||||
return StatusCode(StatusCodes.Status500InternalServerError, "File is in an inconsistent state: uploaded but no pool ID.");
|
return StatusCode(StatusCodes.Status500InternalServerError, "File is in an inconsistent state: uploaded but no pool ID.");
|
||||||
|
|
||||||
var pool = await fs.GetPoolAsync(file.PoolId.Value);
|
var pool = await fs.GetPoolAsync(file.PoolId.Value);
|
||||||
if (pool is null)
|
if (pool is null)
|
||||||
return StatusCode(StatusCodes.Status410Gone, "The pool of the file no longer exists or not accessible.");
|
return StatusCode(StatusCodes.Status410Gone, "The pool of the file no longer exists or not accessible.");
|
||||||
|
|
||||||
|
if (!pool.PolicyConfig.AllowAnonymous && HttpContext.Items["CurrentUser"] is not Account)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
var dest = pool.StorageConfig;
|
var dest = pool.StorageConfig;
|
||||||
|
var fileName = BuildRemoteFileName(file, original, thumbnail);
|
||||||
|
|
||||||
if (!pool.PolicyConfig.AllowAnonymous)
|
// Try proxy redirects first
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
var proxyResult = TryProxyRedirect(file, dest, fileName);
|
||||||
return Unauthorized();
|
if (proxyResult is not null) return proxyResult;
|
||||||
// TODO: Provide ability to add access log
|
|
||||||
|
|
||||||
|
// Handle signed URLs
|
||||||
|
if (dest.EnableSigned)
|
||||||
|
return await CreateSignedUrl(file, dest, fileName, fileExtension, download, overrideMimeType);
|
||||||
|
|
||||||
|
// Fallback to direct S3 endpoint
|
||||||
|
var protocol = dest.EnableSsl ? "https" : "http";
|
||||||
|
return Redirect($"{protocol}://{dest.Endpoint}/{dest.Bucket}/{fileName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildRemoteFileName(SnCloudFile file, bool original, bool thumbnail)
|
||||||
|
{
|
||||||
var fileName = string.IsNullOrWhiteSpace(file.StorageId) ? file.Id : file.StorageId;
|
var fileName = string.IsNullOrWhiteSpace(file.StorageId) ? file.Id : file.StorageId;
|
||||||
|
|
||||||
switch (thumbnail)
|
if (thumbnail)
|
||||||
{
|
{
|
||||||
case true when file.HasThumbnail:
|
if (!file.HasThumbnail) throw new InvalidOperationException("Thumbnail not available");
|
||||||
fileName += ".thumbnail";
|
fileName += ".thumbnail";
|
||||||
break;
|
}
|
||||||
case true when !file.HasThumbnail:
|
else if (!original && file.HasCompression)
|
||||||
return NotFound();
|
{
|
||||||
|
fileName += ".compressed";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!original && file.HasCompression)
|
return fileName;
|
||||||
fileName += ".compressed";
|
}
|
||||||
|
|
||||||
|
private ActionResult? TryProxyRedirect(SnCloudFile file, RemoteStorageConfig dest, string fileName)
|
||||||
|
{
|
||||||
if (dest.ImageProxy is not null && (file.MimeType?.StartsWith("image/") ?? false))
|
if (dest.ImageProxy is not null && (file.MimeType?.StartsWith("image/") ?? false))
|
||||||
{
|
{
|
||||||
var proxyUrl = dest.ImageProxy;
|
return Redirect(BuildProxyUrl(dest.ImageProxy, fileName));
|
||||||
var baseUri = new Uri(proxyUrl.EndsWith('/') ? proxyUrl : $"{proxyUrl}/");
|
|
||||||
var fullUri = new Uri(baseUri, fileName);
|
|
||||||
return Redirect(fullUri.ToString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dest.AccessProxy is not null)
|
if (dest.AccessProxy is not null)
|
||||||
{
|
{
|
||||||
var proxyUrl = dest.AccessProxy;
|
return Redirect(BuildProxyUrl(dest.AccessProxy, fileName));
|
||||||
var baseUri = new Uri(proxyUrl.EndsWith('/') ? proxyUrl : $"{proxyUrl}/");
|
|
||||||
var fullUri = new Uri(baseUri, fileName);
|
|
||||||
return Redirect(fullUri.ToString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dest.EnableSigned)
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildProxyUrl(string proxyUrl, string fileName)
|
||||||
|
{
|
||||||
|
var baseUri = new Uri(proxyUrl.EndsWith('/') ? proxyUrl : $"{proxyUrl}/");
|
||||||
|
var fullUri = new Uri(baseUri, fileName);
|
||||||
|
return fullUri.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ActionResult> CreateSignedUrl(
|
||||||
|
SnCloudFile file,
|
||||||
|
RemoteStorageConfig dest,
|
||||||
|
string fileName,
|
||||||
|
string? fileExtension,
|
||||||
|
bool download,
|
||||||
|
string? overrideMimeType
|
||||||
|
)
|
||||||
|
{
|
||||||
|
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 = BuildSignedUrlHeaders(file, fileExtension, overrideMimeType, download);
|
||||||
|
|
||||||
|
var openUrl = await client.PresignedGetObjectAsync(
|
||||||
|
new PresignedGetObjectArgs()
|
||||||
|
.WithBucket(dest.Bucket)
|
||||||
|
.WithObject(fileName)
|
||||||
|
.WithExpiry(3600)
|
||||||
|
.WithHeaders(headers)
|
||||||
|
);
|
||||||
|
|
||||||
|
return Redirect(openUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, string> BuildSignedUrlHeaders(
|
||||||
|
SnCloudFile file,
|
||||||
|
string? fileExtension,
|
||||||
|
string? overrideMimeType,
|
||||||
|
bool download
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var headers = new Dictionary<string, string>();
|
||||||
|
|
||||||
|
string? contentType = null;
|
||||||
|
if (fileExtension is not null && MimeTypes.TryGetMimeType(fileExtension, out var mimeType))
|
||||||
{
|
{
|
||||||
var client = fs.CreateMinioClient(dest);
|
contentType = mimeType;
|
||||||
if (client is null)
|
}
|
||||||
return BadRequest(
|
else if (overrideMimeType is not null)
|
||||||
"Failed to configure client for remote destination, file got an invalid storage remote."
|
{
|
||||||
);
|
contentType = overrideMimeType;
|
||||||
|
}
|
||||||
var headers = new Dictionary<string, string>();
|
else if (file.MimeType is not null && !file.MimeType.EndsWith("unknown"))
|
||||||
if (fileExtension is not null)
|
{
|
||||||
{
|
contentType = file.MimeType;
|
||||||
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)
|
if (contentType is not null)
|
||||||
var protocol = dest.EnableSsl ? "https" : "http";
|
{
|
||||||
// Use the path bucket lookup mode
|
headers.Add("Response-Content-Type", contentType);
|
||||||
return Redirect($"{protocol}://{dest.Endpoint}/{dest.Bucket}/{fileName}");
|
}
|
||||||
|
|
||||||
|
if (download)
|
||||||
|
{
|
||||||
|
headers.Add("Response-Content-Disposition", $"attachment; filename=\"{file.Name}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}/info")]
|
[HttpGet("{id}/info")]
|
||||||
@@ -175,14 +238,7 @@ public class FileController(
|
|||||||
[HttpPatch("{id}/name")]
|
[HttpPatch("{id}/name")]
|
||||||
public async Task<ActionResult<SnCloudFile>> UpdateFileName(string id, [FromBody] string name)
|
public async Task<ActionResult<SnCloudFile>> UpdateFileName(string id, [FromBody] string name)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
return await UpdateFileProperty(id, file => file.Name = name);
|
||||||
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 class MarkFileRequest
|
||||||
@@ -194,27 +250,28 @@ public class FileController(
|
|||||||
[HttpPut("{id}/marks")]
|
[HttpPut("{id}/marks")]
|
||||||
public async Task<ActionResult<SnCloudFile>> MarkFile(string id, [FromBody] MarkFileRequest request)
|
public async Task<ActionResult<SnCloudFile>> MarkFile(string id, [FromBody] MarkFileRequest request)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
return await UpdateFileProperty(id, file => file.SensitiveMarks = request.SensitiveMarks);
|
||||||
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]
|
[Authorize]
|
||||||
[HttpPut("{id}/meta")]
|
[HttpPut("{id}/meta")]
|
||||||
public async Task<ActionResult<SnCloudFile>> UpdateFileMeta(string id, [FromBody] Dictionary<string, object?> meta)
|
public async Task<ActionResult<SnCloudFile>> UpdateFileMeta(string id, [FromBody] Dictionary<string, object?> meta)
|
||||||
|
{
|
||||||
|
return await UpdateFileProperty(id, file => file.UserMeta = meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ActionResult<SnCloudFile>> UpdateFileProperty(string fileId, Action<SnCloudFile> updateAction)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == id && f.AccountId == accountId);
|
|
||||||
|
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == fileId && f.AccountId == accountId);
|
||||||
if (file is null) return NotFound();
|
if (file is null) return NotFound();
|
||||||
file.UserMeta = meta;
|
|
||||||
|
updateAction(file);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await fs._PurgeCacheAsync(file.Id);
|
await fs._PurgeCacheAsync(file.Id);
|
||||||
|
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,53 +10,59 @@ namespace DysonNetwork.Drive.Storage;
|
|||||||
public class FileExpirationJob(AppDatabase db, FileService fileService, ILogger<FileExpirationJob> logger) : IJob
|
public class FileExpirationJob(AppDatabase db, FileService fileService, ILogger<FileExpirationJob> logger) : IJob
|
||||||
{
|
{
|
||||||
public async Task Execute(IJobExecutionContext context)
|
public async Task Execute(IJobExecutionContext context)
|
||||||
{
|
{
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
logger.LogInformation("Running file reference expiration job at {now}", now);
|
logger.LogInformation("Running file reference expiration job at {now}", now);
|
||||||
|
|
||||||
// Find all expired references
|
// Delete expired references in bulk and get affected file IDs
|
||||||
var expiredReferences = await db.FileReferences
|
var affectedFileIds = await db.FileReferences
|
||||||
.Where(r => r.ExpiredAt < now && r.ExpiredAt != null)
|
.Where(r => r.ExpiredAt < now && r.ExpiredAt != null)
|
||||||
|
.Select(r => r.FileId)
|
||||||
|
.Distinct()
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
if (!expiredReferences.Any())
|
if (!affectedFileIds.Any())
|
||||||
{
|
{
|
||||||
logger.LogInformation("No expired file references found");
|
logger.LogInformation("No expired file references found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation("Found {count} expired file references", expiredReferences.Count);
|
logger.LogInformation("Found expired references for {count} files", affectedFileIds.Count);
|
||||||
|
|
||||||
// Get unique file IDs
|
// Delete expired references in bulk
|
||||||
var fileIds = expiredReferences.Select(r => r.FileId).Distinct().ToList();
|
var deletedReferencesCount = await db.FileReferences
|
||||||
var filesAndReferenceCount = new Dictionary<string, int>();
|
.Where(r => r.ExpiredAt < now && r.ExpiredAt != null)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
// Delete expired references
|
logger.LogInformation("Deleted {count} expired file references", deletedReferencesCount);
|
||||||
db.FileReferences.RemoveRange(expiredReferences);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
// Check remaining references for each file
|
// Find files that now have no remaining references (bulk operation)
|
||||||
foreach (var fileId in fileIds)
|
var filesToDelete = await db.Files
|
||||||
{
|
.Where(f => affectedFileIds.Contains(f.Id))
|
||||||
var remainingReferences = await db.FileReferences
|
.Where(f => !db.FileReferences.Any(r => r.FileId == f.Id))
|
||||||
.Where(r => r.FileId == fileId)
|
.Select(f => f.Id)
|
||||||
.CountAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
filesAndReferenceCount[fileId] = remainingReferences;
|
if (filesToDelete.Any())
|
||||||
|
{
|
||||||
|
logger.LogInformation("Deleting {count} files that have no remaining references", filesToDelete.Count);
|
||||||
|
|
||||||
// If no references remain, delete the file
|
// Get files for deletion
|
||||||
if (remainingReferences == 0)
|
var files = await db.Files
|
||||||
{
|
.Where(f => filesToDelete.Contains(f.Id))
|
||||||
var file = await db.Files.FirstOrDefaultAsync(f => f.Id == fileId);
|
.ToListAsync();
|
||||||
if (file == null) continue;
|
|
||||||
logger.LogInformation("Deleting file {fileId} as all references have expired", fileId);
|
// Delete files and their data in parallel
|
||||||
await fileService.DeleteFileAsync(file);
|
var deleteTasks = files.Select(f => fileService.DeleteFileAsync(f));
|
||||||
}
|
await Task.WhenAll(deleteTasks);
|
||||||
else
|
}
|
||||||
{
|
|
||||||
// Just purge the cache
|
// Purge cache for files that still have references
|
||||||
await fileService._PurgeCacheAsync(fileId);
|
var filesWithRemainingRefs = affectedFileIds.Except(filesToDelete).ToList();
|
||||||
}
|
if (filesWithRemainingRefs.Any())
|
||||||
|
{
|
||||||
|
var cachePurgeTasks = filesWithRemainingRefs.Select(fileService._PurgeCacheAsync);
|
||||||
|
await Task.WhenAll(cachePurgeTasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation("Completed file reference expiration job");
|
logger.LogInformation("Completed file reference expiration job");
|
||||||
|
|||||||
@@ -90,13 +90,45 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
|||||||
return references;
|
return references;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Dictionary<string, List<CloudFileReference>>> GetReferencesAsync(IEnumerable<string> fileId)
|
public async Task<Dictionary<string, List<CloudFileReference>>> GetReferencesAsync(IEnumerable<string> fileIds)
|
||||||
{
|
{
|
||||||
var references = await db.FileReferences
|
var fileIdList = fileIds.ToList();
|
||||||
.Where(r => fileId.Contains(r.FileId))
|
var result = new Dictionary<string, List<CloudFileReference>>();
|
||||||
.GroupBy(r => r.FileId)
|
|
||||||
.ToDictionaryAsync(r => r.Key, r => r.ToList());
|
// Check cache for each file ID
|
||||||
return references;
|
var uncachedFileIds = new List<string>();
|
||||||
|
foreach (var fileId in fileIdList)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}list:{fileId}";
|
||||||
|
var cachedReferences = await cache.GetAsync<List<CloudFileReference>>(cacheKey);
|
||||||
|
if (cachedReferences is not null)
|
||||||
|
{
|
||||||
|
result[fileId] = cachedReferences;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
uncachedFileIds.Add(fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch uncached references from database
|
||||||
|
if (uncachedFileIds.Any())
|
||||||
|
{
|
||||||
|
var dbReferences = await db.FileReferences
|
||||||
|
.Where(r => uncachedFileIds.Contains(r.FileId))
|
||||||
|
.GroupBy(r => r.FileId)
|
||||||
|
.ToDictionaryAsync(r => r.Key, r => r.ToList());
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
foreach (var kvp in dbReferences)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}list:{kvp.Key}";
|
||||||
|
await cache.SetAsync(cacheKey, kvp.Value, CacheDuration);
|
||||||
|
result[kvp.Key] = kvp.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -150,9 +182,19 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
|||||||
/// <returns>A list of file references with the specified usage</returns>
|
/// <returns>A list of file references with the specified usage</returns>
|
||||||
public async Task<List<CloudFileReference>> GetUsageReferencesAsync(string usage)
|
public async Task<List<CloudFileReference>> GetUsageReferencesAsync(string usage)
|
||||||
{
|
{
|
||||||
return await db.FileReferences
|
var cacheKey = $"{CacheKeyPrefix}usage:{usage}";
|
||||||
|
|
||||||
|
var cachedReferences = await cache.GetAsync<List<CloudFileReference>>(cacheKey);
|
||||||
|
if (cachedReferences is not null)
|
||||||
|
return cachedReferences;
|
||||||
|
|
||||||
|
var references = await db.FileReferences
|
||||||
.Where(r => r.Usage == usage)
|
.Where(r => r.Usage == usage)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
await cache.SetAsync(cacheKey, references, CacheDuration);
|
||||||
|
|
||||||
|
return references;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -209,8 +251,9 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
|||||||
|
|
||||||
public async Task<int> DeleteResourceReferencesBatchAsync(IEnumerable<string> resourceIds, string? usage = null)
|
public async Task<int> DeleteResourceReferencesBatchAsync(IEnumerable<string> resourceIds, string? usage = null)
|
||||||
{
|
{
|
||||||
|
var resourceIdList = resourceIds.ToList();
|
||||||
var references = await db.FileReferences
|
var references = await db.FileReferences
|
||||||
.Where(r => resourceIds.Contains(r.ResourceId))
|
.Where(r => resourceIdList.Contains(r.ResourceId))
|
||||||
.If(usage != null, q => q.Where(q => q.Usage == usage))
|
.If(usage != null, q => q.Where(q => q.Usage == usage))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
@@ -222,8 +265,9 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
|||||||
db.FileReferences.RemoveRange(references);
|
db.FileReferences.RemoveRange(references);
|
||||||
var deletedCount = await db.SaveChangesAsync();
|
var deletedCount = await db.SaveChangesAsync();
|
||||||
|
|
||||||
// Purge caches
|
// Purge caches for files and resources
|
||||||
var tasks = fileIds.Select(fileService._PurgeCacheAsync).ToList();
|
var tasks = fileIds.Select(fileService._PurgeCacheAsync).ToList();
|
||||||
|
tasks.AddRange(resourceIdList.Select(PurgeCacheForResourceAsync));
|
||||||
await Task.WhenAll(tasks);
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
return deletedCount;
|
return deletedCount;
|
||||||
@@ -473,4 +517,4 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
|||||||
|
|
||||||
return await SetReferenceExpirationAsync(referenceId, expiredAt);
|
return await SetReferenceExpirationAsync(referenceId, expiredAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,30 +99,75 @@ public class FileService(
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var accountId = Guid.Parse(account.Id);
|
var accountId = Guid.Parse(account.Id);
|
||||||
|
var pool = await ValidateAndGetPoolAsync(filePool);
|
||||||
|
var bundle = await ValidateAndGetBundleAsync(fileBundleId, accountId);
|
||||||
|
var finalExpiredAt = CalculateFinalExpiration(expiredAt, pool, bundle);
|
||||||
|
|
||||||
|
var (managedTempPath, fileSize, finalContentType) = await PrepareFileAsync(fileId, filePath, fileName, contentType);
|
||||||
|
|
||||||
|
var file = CreateFileObject(fileId, fileName, finalContentType, fileSize, finalExpiredAt, bundle, accountId);
|
||||||
|
|
||||||
|
if (!pool.PolicyConfig.NoMetadata)
|
||||||
|
{
|
||||||
|
await ExtractMetadataAsync(file, managedTempPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var (processingPath, isTempFile) = await ProcessEncryptionAsync(fileId, managedTempPath, encryptPassword, pool, file);
|
||||||
|
|
||||||
|
file.Hash = await HashFileAsync(processingPath);
|
||||||
|
|
||||||
|
await SaveFileToDatabaseAsync(file);
|
||||||
|
|
||||||
|
await PublishFileUploadedEventAsync(file, pool, processingPath, isTempFile);
|
||||||
|
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<FilePool> ValidateAndGetPoolAsync(string filePool)
|
||||||
|
{
|
||||||
var pool = await GetPoolAsync(Guid.Parse(filePool));
|
var pool = await GetPoolAsync(Guid.Parse(filePool));
|
||||||
if (pool is null) throw new InvalidOperationException("Pool not found");
|
if (pool is null) throw new InvalidOperationException("Pool not found");
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SnFileBundle?> ValidateAndGetBundleAsync(string? fileBundleId, Guid accountId)
|
||||||
|
{
|
||||||
|
if (fileBundleId is null) return null;
|
||||||
|
|
||||||
|
var bundle = await GetBundleAsync(Guid.Parse(fileBundleId), accountId);
|
||||||
|
if (bundle is null) throw new InvalidOperationException("Bundle not found");
|
||||||
|
|
||||||
|
return bundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Instant? CalculateFinalExpiration(Instant? expiredAt, FilePool pool, SnFileBundle? bundle)
|
||||||
|
{
|
||||||
|
var finalExpiredAt = expiredAt;
|
||||||
|
|
||||||
|
// Apply pool expiration policy
|
||||||
if (pool.StorageConfig.Expiration is not null && expiredAt.HasValue)
|
if (pool.StorageConfig.Expiration is not null && expiredAt.HasValue)
|
||||||
{
|
{
|
||||||
var expectedExpiration = SystemClock.Instance.GetCurrentInstant() - expiredAt.Value;
|
var expectedExpiration = SystemClock.Instance.GetCurrentInstant() - expiredAt.Value;
|
||||||
var effectiveExpiration = pool.StorageConfig.Expiration < expectedExpiration
|
var effectiveExpiration = pool.StorageConfig.Expiration < expectedExpiration
|
||||||
? pool.StorageConfig.Expiration
|
? pool.StorageConfig.Expiration
|
||||||
: expectedExpiration;
|
: expectedExpiration;
|
||||||
expiredAt = SystemClock.Instance.GetCurrentInstant() + effectiveExpiration;
|
finalExpiredAt = SystemClock.Instance.GetCurrentInstant() + effectiveExpiration;
|
||||||
}
|
|
||||||
|
|
||||||
var bundle = fileBundleId is not null
|
|
||||||
? await GetBundleAsync(Guid.Parse(fileBundleId), accountId)
|
|
||||||
: null;
|
|
||||||
if (fileBundleId is not null && bundle is null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Bundle not found");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bundle expiration takes precedence
|
||||||
if (bundle?.ExpiredAt != null)
|
if (bundle?.ExpiredAt != null)
|
||||||
expiredAt = bundle.ExpiredAt.Value;
|
finalExpiredAt = bundle.ExpiredAt.Value;
|
||||||
|
|
||||||
|
return finalExpiredAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string tempPath, long fileSize, string contentType)> PrepareFileAsync(
|
||||||
|
string fileId,
|
||||||
|
string filePath,
|
||||||
|
string fileName,
|
||||||
|
string? contentType
|
||||||
|
)
|
||||||
|
{
|
||||||
var managedTempPath = Path.Combine(Path.GetTempPath(), fileId);
|
var managedTempPath = Path.Combine(Path.GetTempPath(), fileId);
|
||||||
File.Copy(filePath, managedTempPath, true);
|
File.Copy(filePath, managedTempPath, true);
|
||||||
|
|
||||||
@@ -131,49 +176,66 @@ public class FileService(
|
|||||||
var finalContentType = contentType ??
|
var finalContentType = contentType ??
|
||||||
(!fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName));
|
(!fileName.Contains('.') ? "application/octet-stream" : MimeTypes.GetMimeType(fileName));
|
||||||
|
|
||||||
var file = new SnCloudFile
|
return (managedTempPath, fileSize, finalContentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SnCloudFile CreateFileObject(
|
||||||
|
string fileId,
|
||||||
|
string fileName,
|
||||||
|
string contentType,
|
||||||
|
long fileSize,
|
||||||
|
Instant? expiredAt,
|
||||||
|
SnFileBundle? bundle,
|
||||||
|
Guid accountId
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return new SnCloudFile
|
||||||
{
|
{
|
||||||
Id = fileId,
|
Id = fileId,
|
||||||
Name = fileName,
|
Name = fileName,
|
||||||
MimeType = finalContentType,
|
MimeType = contentType,
|
||||||
Size = fileSize,
|
Size = fileSize,
|
||||||
ExpiredAt = expiredAt,
|
ExpiredAt = expiredAt,
|
||||||
BundleId = bundle?.Id,
|
BundleId = bundle?.Id,
|
||||||
AccountId = Guid.Parse(account.Id),
|
AccountId = accountId,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!pool.PolicyConfig.NoMetadata)
|
private async Task<(string processingPath, bool isTempFile)> ProcessEncryptionAsync(
|
||||||
{
|
string fileId,
|
||||||
await ExtractMetadataAsync(file, managedTempPath);
|
string managedTempPath,
|
||||||
}
|
string? encryptPassword,
|
||||||
|
FilePool pool,
|
||||||
|
SnCloudFile file
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(encryptPassword))
|
||||||
|
return (managedTempPath, true);
|
||||||
|
|
||||||
string processingPath = managedTempPath;
|
if (!pool.PolicyConfig.AllowEncryption)
|
||||||
bool isTempFile = true;
|
throw new InvalidOperationException("Encryption is not allowed in this pool");
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(encryptPassword))
|
var encryptedPath = Path.Combine(Path.GetTempPath(), $"{fileId}.encrypted");
|
||||||
{
|
FileEncryptor.EncryptFile(managedTempPath, encryptedPath, encryptPassword);
|
||||||
if (!pool.PolicyConfig.AllowEncryption)
|
|
||||||
throw new InvalidOperationException("Encryption is not allowed in this pool");
|
|
||||||
|
|
||||||
var encryptedPath = Path.Combine(Path.GetTempPath(), $"{fileId}.encrypted");
|
File.Delete(managedTempPath);
|
||||||
FileEncryptor.EncryptFile(managedTempPath, encryptedPath, encryptPassword);
|
|
||||||
|
|
||||||
File.Delete(managedTempPath);
|
file.IsEncrypted = true;
|
||||||
|
file.MimeType = "application/octet-stream";
|
||||||
|
file.Size = new FileInfo(encryptedPath).Length;
|
||||||
|
|
||||||
processingPath = encryptedPath;
|
return (encryptedPath, true);
|
||||||
|
}
|
||||||
file.IsEncrypted = true;
|
|
||||||
file.MimeType = "application/octet-stream";
|
|
||||||
file.Size = new FileInfo(processingPath).Length;
|
|
||||||
}
|
|
||||||
|
|
||||||
file.Hash = await HashFileAsync(processingPath);
|
|
||||||
|
|
||||||
|
private async Task SaveFileToDatabaseAsync(SnCloudFile file)
|
||||||
|
{
|
||||||
db.Files.Add(file);
|
db.Files.Add(file);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
file.StorageId ??= file.Id;
|
file.StorageId ??= file.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PublishFileUploadedEventAsync(SnCloudFile file, FilePool pool, string processingPath, bool isTempFile)
|
||||||
|
{
|
||||||
var js = nats.CreateJetStreamContext();
|
var js = nats.CreateJetStreamContext();
|
||||||
await js.PublishAsync(
|
await js.PublishAsync(
|
||||||
FileUploadedEvent.Type,
|
FileUploadedEvent.Type,
|
||||||
@@ -186,8 +248,6 @@ public class FileService(
|
|||||||
isTempFile)
|
isTempFile)
|
||||||
).ToByteArray()
|
).ToByteArray()
|
||||||
);
|
);
|
||||||
|
|
||||||
return file;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ExtractMetadataAsync(SnCloudFile file, string filePath)
|
private async Task ExtractMetadataAsync(SnCloudFile file, string filePath)
|
||||||
@@ -724,4 +784,4 @@ file class UpdatableCloudFile(SnCloudFile file)
|
|||||||
.SetProperty(f => f.UserMeta, userMeta)
|
.SetProperty(f => f.UserMeta, userMeta)
|
||||||
.SetProperty(f => f.IsMarkedRecycle, IsMarkedRecycle);
|
.SetProperty(f => f.IsMarkedRecycle, IsMarkedRecycle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using DysonNetwork.Drive.Billing;
|
|||||||
using DysonNetwork.Drive.Storage.Model;
|
using DysonNetwork.Drive.Storage.Model;
|
||||||
using DysonNetwork.Shared.Auth;
|
using DysonNetwork.Shared.Auth;
|
||||||
using DysonNetwork.Shared.Http;
|
using DysonNetwork.Shared.Http;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -32,46 +33,82 @@ public class FileUploadController(
|
|||||||
[HttpPost("create")]
|
[HttpPost("create")]
|
||||||
public async Task<IActionResult> CreateUploadTask([FromBody] CreateUploadTaskRequest request)
|
public async Task<IActionResult> CreateUploadTask([FromBody] CreateUploadTaskRequest request)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||||
{
|
if (currentUser is null)
|
||||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentUser.IsSuperuser)
|
var permissionCheck = await ValidateUserPermissions(currentUser);
|
||||||
{
|
if (permissionCheck is not null) return permissionCheck;
|
||||||
var allowed = await permission.HasPermissionAsync(new HasPermissionRequest
|
|
||||||
{ Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" });
|
|
||||||
if (!allowed.HasPermission)
|
|
||||||
{
|
|
||||||
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
request.PoolId ??= Guid.Parse(configuration["Storage:PreferredRemote"]!);
|
request.PoolId ??= Guid.Parse(configuration["Storage:PreferredRemote"]!);
|
||||||
|
|
||||||
var pool = await fileService.GetPoolAsync(request.PoolId.Value);
|
var pool = await fileService.GetPoolAsync(request.PoolId.Value);
|
||||||
if (pool is null)
|
if (pool is null)
|
||||||
{
|
|
||||||
return new ObjectResult(ApiError.NotFound("Pool")) { StatusCode = 404 };
|
return new ObjectResult(ApiError.NotFound("Pool")) { StatusCode = 404 };
|
||||||
}
|
|
||||||
|
|
||||||
if (pool.PolicyConfig.RequirePrivilege is > 0)
|
var poolValidation = await ValidatePoolAccess(currentUser, pool, request);
|
||||||
|
if (poolValidation is not null) return poolValidation;
|
||||||
|
|
||||||
|
var policyValidation = ValidatePoolPolicy(pool.PolicyConfig, request);
|
||||||
|
if (policyValidation is not null) return policyValidation;
|
||||||
|
|
||||||
|
var quotaValidation = await ValidateQuota(currentUser, pool, request.FileSize);
|
||||||
|
if (quotaValidation is not null) return quotaValidation;
|
||||||
|
|
||||||
|
EnsureTempDirectoryExists();
|
||||||
|
|
||||||
|
// Check if a file with the same hash already exists
|
||||||
|
var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Hash == request.Hash);
|
||||||
|
if (existingFile != null)
|
||||||
{
|
{
|
||||||
var privilege =
|
return Ok(new CreateUploadTaskResponse
|
||||||
currentUser.PerkSubscription is null ? 0 :
|
|
||||||
PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier);
|
|
||||||
if (privilege < pool.PolicyConfig.RequirePrivilege)
|
|
||||||
{
|
{
|
||||||
return new ObjectResult(ApiError.Unauthorized(
|
FileExists = true,
|
||||||
$"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use pool {pool.Name}, you are tier {privilege}",
|
File = existingFile
|
||||||
forbidden: true))
|
});
|
||||||
{
|
|
||||||
StatusCode = 403
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var policy = pool.PolicyConfig;
|
var (taskId, task) = await CreateUploadTaskInternal(request);
|
||||||
|
return Ok(new CreateUploadTaskResponse
|
||||||
|
{
|
||||||
|
FileExists = false,
|
||||||
|
TaskId = taskId,
|
||||||
|
ChunkSize = task.ChunkSize,
|
||||||
|
ChunksCount = task.ChunksCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult?> ValidateUserPermissions(Account currentUser)
|
||||||
|
{
|
||||||
|
if (currentUser.IsSuperuser) return null;
|
||||||
|
|
||||||
|
var allowed = await permission.HasPermissionAsync(new HasPermissionRequest
|
||||||
|
{ Actor = $"user:{currentUser.Id}", Area = "global", Key = "files.create" });
|
||||||
|
|
||||||
|
return allowed.HasPermission ? null :
|
||||||
|
new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult?> ValidatePoolAccess(Account currentUser, FilePool pool, CreateUploadTaskRequest request)
|
||||||
|
{
|
||||||
|
if (pool.PolicyConfig.RequirePrivilege <= 0) return null;
|
||||||
|
|
||||||
|
var privilege = currentUser.PerkSubscription is null ? 0 :
|
||||||
|
PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier);
|
||||||
|
|
||||||
|
if (privilege < pool.PolicyConfig.RequirePrivilege)
|
||||||
|
{
|
||||||
|
return new ObjectResult(ApiError.Unauthorized(
|
||||||
|
$"You need Stellar Program tier {pool.PolicyConfig.RequirePrivilege} to use pool {pool.Name}, you are tier {privilege}",
|
||||||
|
forbidden: true))
|
||||||
|
{ StatusCode = 403 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IActionResult? ValidatePoolPolicy(PolicyConfig policy, CreateUploadTaskRequest request)
|
||||||
|
{
|
||||||
if (!policy.AllowEncryption && !string.IsNullOrEmpty(request.EncryptPassword))
|
if (!policy.AllowEncryption && !string.IsNullOrEmpty(request.EncryptPassword))
|
||||||
{
|
{
|
||||||
return new ObjectResult(ApiError.Unauthorized("File encryption is not allowed in this pool", true))
|
return new ObjectResult(ApiError.Unauthorized("File encryption is not allowed in this pool", true))
|
||||||
@@ -103,8 +140,7 @@ public class FileUploadController(
|
|||||||
if (!foundMatch)
|
if (!foundMatch)
|
||||||
{
|
{
|
||||||
return new ObjectResult(
|
return new ObjectResult(
|
||||||
ApiError.Unauthorized($"Content type {request.ContentType} is not allowed by the pool's policy",
|
ApiError.Unauthorized($"Content type {request.ContentType} is not allowed by the pool's policy", true))
|
||||||
true))
|
|
||||||
{ StatusCode = 403 };
|
{ StatusCode = 403 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,42 +148,41 @@ public class FileUploadController(
|
|||||||
if (policy.MaxFileSize is not null && request.FileSize > policy.MaxFileSize)
|
if (policy.MaxFileSize is not null && request.FileSize > policy.MaxFileSize)
|
||||||
{
|
{
|
||||||
return new ObjectResult(ApiError.Unauthorized(
|
return new ObjectResult(ApiError.Unauthorized(
|
||||||
$"File size {request.FileSize} is larger than the pool's maximum file size {policy.MaxFileSize}",
|
$"File size {request.FileSize} is larger than the pool's maximum file size {policy.MaxFileSize}", true))
|
||||||
true))
|
|
||||||
{
|
|
||||||
StatusCode = 403
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
var (ok, billableUnit, quota) = await quotaService.IsFileAcceptable(
|
|
||||||
Guid.Parse(currentUser.Id),
|
|
||||||
pool.BillingConfig.CostMultiplier ?? 1.0,
|
|
||||||
request.FileSize
|
|
||||||
);
|
|
||||||
if (!ok)
|
|
||||||
{
|
|
||||||
return new ObjectResult(
|
|
||||||
ApiError.Unauthorized($"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB",
|
|
||||||
true))
|
|
||||||
{ StatusCode = 403 };
|
{ StatusCode = 403 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult?> ValidateQuota(Account currentUser, FilePool pool, long fileSize)
|
||||||
|
{
|
||||||
|
var (ok, billableUnit, quota) = await quotaService.IsFileAcceptable(
|
||||||
|
Guid.Parse(currentUser.Id),
|
||||||
|
pool.BillingConfig.CostMultiplier ?? 1.0,
|
||||||
|
fileSize
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
return new ObjectResult(
|
||||||
|
ApiError.Unauthorized($"File size {billableUnit} MiB is exceeded the user's quota {quota} MiB", true))
|
||||||
|
{ StatusCode = 403 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureTempDirectoryExists()
|
||||||
|
{
|
||||||
if (!Directory.Exists(_tempPath))
|
if (!Directory.Exists(_tempPath))
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(_tempPath);
|
Directory.CreateDirectory(_tempPath);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if a file with the same hash already exists
|
private async Task<(string taskId, UploadTask task)> CreateUploadTaskInternal(CreateUploadTaskRequest request)
|
||||||
var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Hash == request.Hash);
|
{
|
||||||
if (existingFile != null)
|
|
||||||
{
|
|
||||||
return Ok(new CreateUploadTaskResponse
|
|
||||||
{
|
|
||||||
FileExists = true,
|
|
||||||
File = existingFile
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var taskId = await Nanoid.GenerateAsync();
|
var taskId = await Nanoid.GenerateAsync();
|
||||||
var taskPath = Path.Combine(_tempPath, taskId);
|
var taskPath = Path.Combine(_tempPath, taskId);
|
||||||
Directory.CreateDirectory(taskPath);
|
Directory.CreateDirectory(taskPath);
|
||||||
@@ -171,14 +206,7 @@ public class FileUploadController(
|
|||||||
};
|
};
|
||||||
|
|
||||||
await System.IO.File.WriteAllTextAsync(Path.Combine(taskPath, "task.json"), JsonSerializer.Serialize(task));
|
await System.IO.File.WriteAllTextAsync(Path.Combine(taskPath, "task.json"), JsonSerializer.Serialize(task));
|
||||||
|
return (taskId, task);
|
||||||
return Ok(new CreateUploadTaskResponse
|
|
||||||
{
|
|
||||||
FileExists = false,
|
|
||||||
TaskId = taskId,
|
|
||||||
ChunkSize = chunkSize,
|
|
||||||
ChunksCount = chunksCount
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UploadChunkRequest
|
public class UploadChunkRequest
|
||||||
@@ -211,68 +239,91 @@ public class FileUploadController(
|
|||||||
{
|
{
|
||||||
var taskPath = Path.Combine(_tempPath, taskId);
|
var taskPath = Path.Combine(_tempPath, taskId);
|
||||||
if (!Directory.Exists(taskPath))
|
if (!Directory.Exists(taskPath))
|
||||||
{
|
|
||||||
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||||
}
|
|
||||||
|
|
||||||
var taskJsonPath = Path.Combine(taskPath, "task.json");
|
var taskJsonPath = Path.Combine(taskPath, "task.json");
|
||||||
if (!System.IO.File.Exists(taskJsonPath))
|
if (!System.IO.File.Exists(taskJsonPath))
|
||||||
{
|
|
||||||
return new ObjectResult(ApiError.NotFound("Upload task metadata")) { StatusCode = 404 };
|
return new ObjectResult(ApiError.NotFound("Upload task metadata")) { StatusCode = 404 };
|
||||||
}
|
|
||||||
|
|
||||||
var task = JsonSerializer.Deserialize<UploadTask>(await System.IO.File.ReadAllTextAsync(taskJsonPath));
|
var task = JsonSerializer.Deserialize<UploadTask>(await System.IO.File.ReadAllTextAsync(taskJsonPath));
|
||||||
if (task == null)
|
if (task == null)
|
||||||
{
|
|
||||||
return new ObjectResult(new ApiError { Code = "BAD_REQUEST", Message = "Invalid task metadata.", Status = 400 })
|
return new ObjectResult(new ApiError { Code = "BAD_REQUEST", Message = "Invalid task metadata.", Status = 400 })
|
||||||
{ StatusCode = 400 };
|
{ StatusCode = 400 };
|
||||||
}
|
|
||||||
|
var currentUser = HttpContext.Items["CurrentUser"] as Account;
|
||||||
|
if (currentUser is null)
|
||||||
|
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||||
|
|
||||||
var mergedFilePath = Path.Combine(_tempPath, taskId + ".tmp");
|
var mergedFilePath = Path.Combine(_tempPath, taskId + ".tmp");
|
||||||
await using (var mergedStream = new FileStream(mergedFilePath, FileMode.Create))
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
for (var i = 0; i < task.ChunksCount; i++)
|
await MergeChunks(taskPath, mergedFilePath, task.ChunksCount);
|
||||||
|
|
||||||
|
var fileId = await Nanoid.GenerateAsync();
|
||||||
|
var cloudFile = await fileService.ProcessNewFileAsync(
|
||||||
|
currentUser,
|
||||||
|
fileId,
|
||||||
|
task.PoolId.ToString(),
|
||||||
|
task.BundleId?.ToString(),
|
||||||
|
mergedFilePath,
|
||||||
|
task.FileName,
|
||||||
|
task.ContentType,
|
||||||
|
task.EncryptPassword,
|
||||||
|
task.ExpiredAt
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(cloudFile);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Log the error and clean up
|
||||||
|
// (Assuming you have a logger - you might want to inject ILogger)
|
||||||
|
await CleanupTempFiles(taskPath, mergedFilePath);
|
||||||
|
return new ObjectResult(new ApiError
|
||||||
{
|
{
|
||||||
var chunkPath = Path.Combine(taskPath, $"{i}.chunk");
|
Code = "UPLOAD_FAILED",
|
||||||
if (!System.IO.File.Exists(chunkPath))
|
Message = "Failed to complete file upload.",
|
||||||
{
|
Status = 500
|
||||||
// Clean up partially uploaded file
|
}) { StatusCode = 500 };
|
||||||
mergedStream.Close();
|
|
||||||
System.IO.File.Delete(mergedFilePath);
|
|
||||||
Directory.Delete(taskPath, true);
|
|
||||||
return new ObjectResult(new ApiError
|
|
||||||
{ Code = "CHUNK_MISSING", Message = $"Chunk {i} is missing.", Status = 400 })
|
|
||||||
{ StatusCode = 400 };
|
|
||||||
}
|
|
||||||
|
|
||||||
await using var chunkStream = new FileStream(chunkPath, FileMode.Open);
|
|
||||||
await chunkStream.CopyToAsync(mergedStream);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
|
||||||
{
|
{
|
||||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
// Always clean up temp files
|
||||||
|
await CleanupTempFiles(taskPath, mergedFilePath);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var fileId = await Nanoid.GenerateAsync();
|
private async Task MergeChunks(string taskPath, string mergedFilePath, int chunksCount)
|
||||||
|
{
|
||||||
|
await using var mergedStream = new FileStream(mergedFilePath, FileMode.Create);
|
||||||
|
|
||||||
var cloudFile = await fileService.ProcessNewFileAsync(
|
for (var i = 0; i < chunksCount; i++)
|
||||||
currentUser,
|
{
|
||||||
fileId,
|
var chunkPath = Path.Combine(taskPath, $"{i}.chunk");
|
||||||
task.PoolId.ToString(),
|
if (!System.IO.File.Exists(chunkPath))
|
||||||
task.BundleId?.ToString(),
|
{
|
||||||
mergedFilePath,
|
throw new InvalidOperationException($"Chunk {i} is missing.");
|
||||||
task.FileName,
|
}
|
||||||
task.ContentType,
|
|
||||||
task.EncryptPassword,
|
|
||||||
task.ExpiredAt
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clean up
|
await using var chunkStream = new FileStream(chunkPath, FileMode.Open);
|
||||||
Directory.Delete(taskPath, true);
|
await chunkStream.CopyToAsync(mergedStream);
|
||||||
System.IO.File.Delete(mergedFilePath);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(cloudFile);
|
private async Task CleanupTempFiles(string taskPath, string mergedFilePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(taskPath))
|
||||||
|
Directory.Delete(taskPath, true);
|
||||||
|
|
||||||
|
if (System.IO.File.Exists(mergedFilePath))
|
||||||
|
System.IO.File.Delete(mergedFilePath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore cleanup errors to avoid masking the original exception
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
namespace DysonNetwork.Pass.Permission;
|
namespace DysonNetwork.Pass.Permission;
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using DysonNetwork.Shared.Models;
|
using DysonNetwork.Shared.Models;
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Method)]
|
[AttributeUsage(AttributeTargets.Method)]
|
||||||
@@ -10,8 +11,11 @@ public class RequiredPermissionAttribute(string area, string key) : Attribute
|
|||||||
public string Key { get; } = key;
|
public string Key { get; } = key;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PermissionMiddleware(RequestDelegate next)
|
public class PermissionMiddleware(RequestDelegate next, ILogger<PermissionMiddleware> logger)
|
||||||
{
|
{
|
||||||
|
private const string ForbiddenMessage = "Insufficient permissions";
|
||||||
|
private const string UnauthorizedMessage = "Authentication required";
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext httpContext, PermissionService pm)
|
public async Task InvokeAsync(HttpContext httpContext, PermissionService pm)
|
||||||
{
|
{
|
||||||
var endpoint = httpContext.GetEndpoint();
|
var endpoint = httpContext.GetEndpoint();
|
||||||
@@ -22,31 +26,59 @@ public class PermissionMiddleware(RequestDelegate next)
|
|||||||
|
|
||||||
if (attr != null)
|
if (attr != null)
|
||||||
{
|
{
|
||||||
|
// Validate permission attributes
|
||||||
|
if (string.IsNullOrWhiteSpace(attr.Area) || string.IsNullOrWhiteSpace(attr.Key))
|
||||||
|
{
|
||||||
|
logger.LogWarning("Invalid permission attribute: Area='{Area}', Key='{Key}'", attr.Area, attr.Key);
|
||||||
|
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||||
|
await httpContext.Response.WriteAsync("Server configuration error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (httpContext.Items["CurrentUser"] is not SnAccount currentUser)
|
if (httpContext.Items["CurrentUser"] is not SnAccount currentUser)
|
||||||
{
|
{
|
||||||
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
|
logger.LogWarning("Permission check failed: No authenticated user for {Area}/{Key}", attr.Area, attr.Key);
|
||||||
await httpContext.Response.WriteAsync("Unauthorized");
|
httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
await httpContext.Response.WriteAsync(UnauthorizedMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentUser.IsSuperuser)
|
if (currentUser.IsSuperuser)
|
||||||
{
|
{
|
||||||
// Bypass the permission check for performance
|
// Bypass the permission check for performance
|
||||||
|
logger.LogDebug("Superuser {UserId} bypassing permission check for {Area}/{Key}",
|
||||||
|
currentUser.Id, attr.Area, attr.Key);
|
||||||
await next(httpContext);
|
await next(httpContext);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var actor = $"user:{currentUser.Id}";
|
var actor = $"user:{currentUser.Id}";
|
||||||
var permNode = await pm.GetPermissionAsync<bool>(actor, attr.Area, attr.Key);
|
try
|
||||||
|
|
||||||
if (!permNode)
|
|
||||||
{
|
{
|
||||||
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
|
var permNode = await pm.GetPermissionAsync<bool>(actor, attr.Area, attr.Key);
|
||||||
await httpContext.Response.WriteAsync($"Permission {attr.Area}/{attr.Key} = {true} was required.");
|
|
||||||
|
if (!permNode)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Permission denied for user {UserId}: {Area}/{Key}",
|
||||||
|
currentUser.Id, attr.Area, attr.Key);
|
||||||
|
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
await httpContext.Response.WriteAsync(ForbiddenMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogDebug("Permission granted for user {UserId}: {Area}/{Key}",
|
||||||
|
currentUser.Id, attr.Area, attr.Key);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Error checking permission for user {UserId}: {Area}/{Key}",
|
||||||
|
currentUser.Id, attr.Area, attr.Key);
|
||||||
|
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||||
|
await httpContext.Response.WriteAsync("Permission check failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await next(httpContext);
|
await next(httpContext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
@@ -6,24 +8,33 @@ using DysonNetwork.Shared.Models;
|
|||||||
|
|
||||||
namespace DysonNetwork.Pass.Permission;
|
namespace DysonNetwork.Pass.Permission;
|
||||||
|
|
||||||
|
public class PermissionServiceOptions
|
||||||
|
{
|
||||||
|
public TimeSpan CacheExpiration { get; set; } = TimeSpan.FromMinutes(1);
|
||||||
|
public bool EnableWildcardMatching { get; set; } = true;
|
||||||
|
public int MaxWildcardMatches { get; set; } = 100;
|
||||||
|
}
|
||||||
|
|
||||||
public class PermissionService(
|
public class PermissionService(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
ICacheService cache
|
ICacheService cache,
|
||||||
|
ILogger<PermissionService> logger,
|
||||||
|
IOptions<PermissionServiceOptions> options
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan CacheExpiration = TimeSpan.FromMinutes(1);
|
private readonly PermissionServiceOptions _options = options.Value;
|
||||||
|
|
||||||
private const string PermCacheKeyPrefix = "perm:";
|
private const string PermissionCacheKeyPrefix = "perm:";
|
||||||
private const string PermGroupCacheKeyPrefix = "perm-cg:";
|
private const string PermissionGroupCacheKeyPrefix = "perm-cg:";
|
||||||
private const string PermissionGroupPrefix = "perm-g:";
|
private const string PermissionGroupPrefix = "perm-g:";
|
||||||
|
|
||||||
private static string _GetPermissionCacheKey(string actor, string area, string key) =>
|
private static string GetPermissionCacheKey(string actor, string area, string key) =>
|
||||||
PermCacheKeyPrefix + actor + ":" + area + ":" + key;
|
PermissionCacheKeyPrefix + actor + ":" + area + ":" + key;
|
||||||
|
|
||||||
private static string _GetGroupsCacheKey(string actor) =>
|
private static string GetGroupsCacheKey(string actor) =>
|
||||||
PermGroupCacheKeyPrefix + actor;
|
PermissionGroupCacheKeyPrefix + actor;
|
||||||
|
|
||||||
private static string _GetPermissionGroupKey(string actor) =>
|
private static string GetPermissionGroupKey(string actor) =>
|
||||||
PermissionGroupPrefix + actor;
|
PermissionGroupPrefix + actor;
|
||||||
|
|
||||||
public async Task<bool> HasPermissionAsync(string actor, string area, string key)
|
public async Task<bool> HasPermissionAsync(string actor, string area, string key)
|
||||||
@@ -34,14 +45,49 @@ public class PermissionService(
|
|||||||
|
|
||||||
public async Task<T?> GetPermissionAsync<T>(string actor, string area, string key)
|
public async Task<T?> GetPermissionAsync<T>(string actor, string area, string key)
|
||||||
{
|
{
|
||||||
var cacheKey = _GetPermissionCacheKey(actor, area, key);
|
// Input validation
|
||||||
|
if (string.IsNullOrWhiteSpace(actor))
|
||||||
|
throw new ArgumentException("Actor cannot be null or empty", nameof(actor));
|
||||||
|
if (string.IsNullOrWhiteSpace(area))
|
||||||
|
throw new ArgumentException("Area cannot be null or empty", nameof(area));
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
throw new ArgumentException("Key cannot be null or empty", nameof(key));
|
||||||
|
|
||||||
var (hit, cachedValue) = await cache.GetAsyncWithStatus<T>(cacheKey);
|
var cacheKey = GetPermissionCacheKey(actor, area, key);
|
||||||
if (hit)
|
|
||||||
return cachedValue;
|
try
|
||||||
|
{
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var (hit, cachedValue) = await cache.GetAsyncWithStatus<T>(cacheKey);
|
||||||
var groupsKey = _GetGroupsCacheKey(actor);
|
if (hit)
|
||||||
|
{
|
||||||
|
logger.LogDebug("Permission cache hit for {Actor}:{Area}:{Key}", actor, area, key);
|
||||||
|
return cachedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var groupsId = await GetOrCacheUserGroupsAsync(actor, now);
|
||||||
|
|
||||||
|
var permission = await FindPermissionNodeAsync(actor, area, key, groupsId, now);
|
||||||
|
var result = permission != null ? DeserializePermissionValue<T>(permission.Value) : default;
|
||||||
|
|
||||||
|
await cache.SetWithGroupsAsync(cacheKey, result,
|
||||||
|
[GetPermissionGroupKey(actor)],
|
||||||
|
_options.CacheExpiration);
|
||||||
|
|
||||||
|
logger.LogDebug("Permission resolved for {Actor}:{Area}:{Key} = {Result}",
|
||||||
|
actor, area, key, result != null);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Error retrieving permission for {Actor}:{Area}:{Key}", actor, area, key);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<Guid>> GetOrCacheUserGroupsAsync(string actor, Instant now)
|
||||||
|
{
|
||||||
|
var groupsKey = GetGroupsCacheKey(actor);
|
||||||
|
|
||||||
var groupsId = await cache.GetAsync<List<Guid>>(groupsKey);
|
var groupsId = await cache.GetAsync<List<Guid>>(groupsKey);
|
||||||
if (groupsId == null)
|
if (groupsId == null)
|
||||||
@@ -54,11 +100,20 @@ public class PermissionService(
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
await cache.SetWithGroupsAsync(groupsKey, groupsId,
|
await cache.SetWithGroupsAsync(groupsKey, groupsId,
|
||||||
[_GetPermissionGroupKey(actor)],
|
[GetPermissionGroupKey(actor)],
|
||||||
CacheExpiration);
|
_options.CacheExpiration);
|
||||||
|
|
||||||
|
logger.LogDebug("Cached {Count} groups for actor {Actor}", groupsId.Count, actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
var permission = await db.PermissionNodes
|
return groupsId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SnPermissionNode?> FindPermissionNodeAsync(string actor, string area, string key,
|
||||||
|
List<Guid> groupsId, Instant now)
|
||||||
|
{
|
||||||
|
// First try exact match (highest priority)
|
||||||
|
var exactMatch = await db.PermissionNodes
|
||||||
.Where(n => (n.GroupId == null && n.Actor == actor) ||
|
.Where(n => (n.GroupId == null && n.Actor == actor) ||
|
||||||
(n.GroupId != null && groupsId.Contains(n.GroupId.Value)))
|
(n.GroupId != null && groupsId.Contains(n.GroupId.Value)))
|
||||||
.Where(n => n.Key == key && n.Area == area)
|
.Where(n => n.Key == key && n.Area == area)
|
||||||
@@ -66,13 +121,85 @@ public class PermissionService(
|
|||||||
.Where(n => n.AffectedAt == null || n.AffectedAt <= now)
|
.Where(n => n.AffectedAt == null || n.AffectedAt <= now)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
var result = permission is not null ? _DeserializePermissionValue<T>(permission.Value) : default;
|
if (exactMatch != null)
|
||||||
|
{
|
||||||
|
return exactMatch;
|
||||||
|
}
|
||||||
|
|
||||||
await cache.SetWithGroupsAsync(cacheKey, result,
|
// If no exact match and wildcards are enabled, try wildcard matches
|
||||||
[_GetPermissionGroupKey(actor)],
|
if (!_options.EnableWildcardMatching)
|
||||||
CacheExpiration);
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
var wildcardMatches = await db.PermissionNodes
|
||||||
|
.Where(n => (n.GroupId == null && n.Actor == actor) ||
|
||||||
|
(n.GroupId != null && groupsId.Contains(n.GroupId.Value)))
|
||||||
|
.Where(n => (n.Key.Contains("*") || n.Area.Contains("*")))
|
||||||
|
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
|
||||||
|
.Where(n => n.AffectedAt == null || n.AffectedAt <= now)
|
||||||
|
.Take(_options.MaxWildcardMatches)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Find the best wildcard match
|
||||||
|
SnPermissionNode? bestMatch = null;
|
||||||
|
var bestMatchScore = -1;
|
||||||
|
|
||||||
|
foreach (var node in wildcardMatches)
|
||||||
|
{
|
||||||
|
var score = CalculateWildcardMatchScore(node.Area, node.Key, area, key);
|
||||||
|
if (score > bestMatchScore)
|
||||||
|
{
|
||||||
|
bestMatch = node;
|
||||||
|
bestMatchScore = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestMatch != null)
|
||||||
|
{
|
||||||
|
logger.LogDebug("Found wildcard permission match: {NodeArea}:{NodeKey} for {Area}:{Key}",
|
||||||
|
bestMatch.Area, bestMatch.Key, area, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CalculateWildcardMatchScore(string nodeArea, string nodeKey, string targetArea, string targetKey)
|
||||||
|
{
|
||||||
|
// Calculate how well the wildcard pattern matches
|
||||||
|
// Higher score = better match
|
||||||
|
var areaScore = CalculatePatternMatchScore(nodeArea, targetArea);
|
||||||
|
var keyScore = CalculatePatternMatchScore(nodeKey, targetKey);
|
||||||
|
|
||||||
|
// Perfect match gets highest score
|
||||||
|
if (areaScore == int.MaxValue && keyScore == int.MaxValue)
|
||||||
|
return int.MaxValue;
|
||||||
|
|
||||||
|
// Prefer area matches over key matches, more specific patterns over general ones
|
||||||
|
return (areaScore * 1000) + keyScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CalculatePatternMatchScore(string pattern, string target)
|
||||||
|
{
|
||||||
|
if (pattern == target)
|
||||||
|
return int.MaxValue; // Exact match
|
||||||
|
|
||||||
|
if (!pattern.Contains("*"))
|
||||||
|
return -1; // No wildcard, not a match
|
||||||
|
|
||||||
|
// Simple wildcard matching: * matches any sequence of characters
|
||||||
|
var regexPattern = "^" + System.Text.RegularExpressions.Regex.Escape(pattern).Replace("\\*", ".*") + "$";
|
||||||
|
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
if (regex.IsMatch(target))
|
||||||
|
{
|
||||||
|
// Score based on specificity (shorter patterns are less specific)
|
||||||
|
var wildcardCount = pattern.Count(c => c == '*');
|
||||||
|
var length = pattern.Length;
|
||||||
|
return Math.Max(1, 1000 - (wildcardCount * 100) - length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1; // No match
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SnPermissionNode> AddPermissionNode<T>(
|
public async Task<SnPermissionNode> AddPermissionNode<T>(
|
||||||
@@ -91,7 +218,7 @@ public class PermissionService(
|
|||||||
Actor = actor,
|
Actor = actor,
|
||||||
Key = key,
|
Key = key,
|
||||||
Area = area,
|
Area = area,
|
||||||
Value = _SerializePermissionValue(value),
|
Value = SerializePermissionValue(value),
|
||||||
ExpiredAt = expiredAt,
|
ExpiredAt = expiredAt,
|
||||||
AffectedAt = affectedAt
|
AffectedAt = affectedAt
|
||||||
};
|
};
|
||||||
@@ -122,7 +249,7 @@ public class PermissionService(
|
|||||||
Actor = actor,
|
Actor = actor,
|
||||||
Key = key,
|
Key = key,
|
||||||
Area = area,
|
Area = area,
|
||||||
Value = _SerializePermissionValue(value),
|
Value = SerializePermissionValue(value),
|
||||||
ExpiredAt = expiredAt,
|
ExpiredAt = expiredAt,
|
||||||
AffectedAt = affectedAt,
|
AffectedAt = affectedAt,
|
||||||
Group = group,
|
Group = group,
|
||||||
@@ -134,8 +261,8 @@ public class PermissionService(
|
|||||||
|
|
||||||
// Invalidate related caches
|
// Invalidate related caches
|
||||||
await InvalidatePermissionCacheAsync(actor, area, key);
|
await InvalidatePermissionCacheAsync(actor, area, key);
|
||||||
await cache.RemoveAsync(_GetGroupsCacheKey(actor));
|
await cache.RemoveAsync(GetGroupsCacheKey(actor));
|
||||||
await cache.RemoveGroupAsync(_GetPermissionGroupKey(actor));
|
await cache.RemoveGroupAsync(GetPermissionGroupKey(actor));
|
||||||
|
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
@@ -164,22 +291,22 @@ public class PermissionService(
|
|||||||
|
|
||||||
// Invalidate caches
|
// Invalidate caches
|
||||||
await InvalidatePermissionCacheAsync(actor, area, key);
|
await InvalidatePermissionCacheAsync(actor, area, key);
|
||||||
await cache.RemoveAsync(_GetGroupsCacheKey(actor));
|
await cache.RemoveAsync(GetGroupsCacheKey(actor));
|
||||||
await cache.RemoveGroupAsync(_GetPermissionGroupKey(actor));
|
await cache.RemoveGroupAsync(GetPermissionGroupKey(actor));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task InvalidatePermissionCacheAsync(string actor, string area, string key)
|
private async Task InvalidatePermissionCacheAsync(string actor, string area, string key)
|
||||||
{
|
{
|
||||||
var cacheKey = _GetPermissionCacheKey(actor, area, key);
|
var cacheKey = GetPermissionCacheKey(actor, area, key);
|
||||||
await cache.RemoveAsync(cacheKey);
|
await cache.RemoveAsync(cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static T? _DeserializePermissionValue<T>(JsonDocument json)
|
private static T? DeserializePermissionValue<T>(JsonDocument json)
|
||||||
{
|
{
|
||||||
return JsonSerializer.Deserialize<T>(json.RootElement.GetRawText());
|
return JsonSerializer.Deserialize<T>(json.RootElement.GetRawText());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JsonDocument _SerializePermissionValue<T>(T obj)
|
private static JsonDocument SerializePermissionValue<T>(T obj)
|
||||||
{
|
{
|
||||||
var str = JsonSerializer.Serialize(obj);
|
var str = JsonSerializer.Serialize(obj);
|
||||||
return JsonDocument.Parse(str);
|
return JsonDocument.Parse(str);
|
||||||
@@ -192,7 +319,109 @@ public class PermissionService(
|
|||||||
Actor = actor,
|
Actor = actor,
|
||||||
Area = area,
|
Area = area,
|
||||||
Key = key,
|
Key = key,
|
||||||
Value = _SerializePermissionValue(value),
|
Value = SerializePermissionValue(value),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists all effective permissions for an actor (including group permissions)
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<SnPermissionNode>> ListEffectivePermissionsAsync(string actor)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(actor))
|
||||||
|
throw new ArgumentException("Actor cannot be null or empty", nameof(actor));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var groupsId = await GetOrCacheUserGroupsAsync(actor, now);
|
||||||
|
|
||||||
|
var permissions = await db.PermissionNodes
|
||||||
|
.Where(n => (n.GroupId == null && n.Actor == actor) ||
|
||||||
|
(n.GroupId != null && groupsId.Contains(n.GroupId.Value)))
|
||||||
|
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
|
||||||
|
.Where(n => n.AffectedAt == null || n.AffectedAt <= now)
|
||||||
|
.OrderBy(n => n.Area)
|
||||||
|
.ThenBy(n => n.Key)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
logger.LogDebug("Listed {Count} effective permissions for actor {Actor}", permissions.Count, actor);
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Error listing permissions for actor {Actor}", actor);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists all direct permissions for an actor (excluding group permissions)
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<SnPermissionNode>> ListDirectPermissionsAsync(string actor)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(actor))
|
||||||
|
throw new ArgumentException("Actor cannot be null or empty", nameof(actor));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var permissions = await db.PermissionNodes
|
||||||
|
.Where(n => n.GroupId == null && n.Actor == actor)
|
||||||
|
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
|
||||||
|
.Where(n => n.AffectedAt == null || n.AffectedAt <= now)
|
||||||
|
.OrderBy(n => n.Area)
|
||||||
|
.ThenBy(n => n.Key)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
logger.LogDebug("Listed {Count} direct permissions for actor {Actor}", permissions.Count, actor);
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Error listing direct permissions for actor {Actor}", actor);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a permission pattern for wildcard usage
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsValidPermissionPattern(string pattern)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(pattern))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Basic validation: no consecutive wildcards, no leading/trailing wildcards in some cases
|
||||||
|
if (pattern.Contains("**") || pattern.StartsWith("*") || pattern.EndsWith("*"))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Check for valid characters (alphanumeric, underscore, dash, dot, star)
|
||||||
|
return pattern.All(c => char.IsLetterOrDigit(c) || c == '_' || c == '-' || c == '.' || c == '*' || c == ':');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears all cached permissions for an actor
|
||||||
|
/// </summary>
|
||||||
|
public async Task ClearActorCacheAsync(string actor)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(actor))
|
||||||
|
throw new ArgumentException("Actor cannot be null or empty", nameof(actor));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var groupsKey = GetGroupsCacheKey(actor);
|
||||||
|
var permissionGroupKey = GetPermissionGroupKey(actor);
|
||||||
|
|
||||||
|
await cache.RemoveAsync(groupsKey);
|
||||||
|
await cache.RemoveGroupAsync(permissionGroupKey);
|
||||||
|
|
||||||
|
logger.LogInformation("Cleared cache for actor {Actor}", actor);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Error clearing cache for actor {Actor}", actor);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using NodaTime.Serialization.Protobuf;
|
using NodaTime.Serialization.Protobuf;
|
||||||
@@ -9,69 +10,174 @@ namespace DysonNetwork.Pass.Permission;
|
|||||||
|
|
||||||
public class PermissionServiceGrpc(
|
public class PermissionServiceGrpc(
|
||||||
PermissionService permissionService,
|
PermissionService permissionService,
|
||||||
AppDatabase db
|
AppDatabase db,
|
||||||
|
ILogger<PermissionServiceGrpc> logger
|
||||||
) : DysonNetwork.Shared.Proto.PermissionService.PermissionServiceBase
|
) : DysonNetwork.Shared.Proto.PermissionService.PermissionServiceBase
|
||||||
{
|
{
|
||||||
public override async Task<HasPermissionResponse> HasPermission(HasPermissionRequest request, ServerCallContext context)
|
public override async Task<HasPermissionResponse> HasPermission(HasPermissionRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
var hasPermission = await permissionService.HasPermissionAsync(request.Actor, request.Area, request.Key);
|
try
|
||||||
return new HasPermissionResponse { HasPermission = hasPermission };
|
{
|
||||||
|
var hasPermission = await permissionService.HasPermissionAsync(request.Actor, request.Area, request.Key);
|
||||||
|
return new HasPermissionResponse { HasPermission = hasPermission };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Error checking permission for actor {Actor}, area {Area}, key {Key}",
|
||||||
|
request.Actor, request.Area, request.Key);
|
||||||
|
throw new RpcException(new Status(StatusCode.Internal, "Permission check failed"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<GetPermissionResponse> GetPermission(GetPermissionRequest request, ServerCallContext context)
|
public override async Task<GetPermissionResponse> GetPermission(GetPermissionRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
var permissionValue = await permissionService.GetPermissionAsync<JsonDocument>(request.Actor, request.Area, request.Key);
|
try
|
||||||
return new GetPermissionResponse { Value = permissionValue != null ? Value.Parser.ParseJson(permissionValue.RootElement.GetRawText()) : null };
|
{
|
||||||
|
var permissionValue = await permissionService.GetPermissionAsync<JsonDocument>(request.Actor, request.Area, request.Key);
|
||||||
|
return new GetPermissionResponse
|
||||||
|
{
|
||||||
|
Value = permissionValue != null ? Value.Parser.ParseJson(permissionValue.RootElement.GetRawText()) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Error getting permission for actor {Actor}, area {Area}, key {Key}",
|
||||||
|
request.Actor, request.Area, request.Key);
|
||||||
|
throw new RpcException(new Status(StatusCode.Internal, "Failed to retrieve permission"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<AddPermissionNodeResponse> AddPermissionNode(AddPermissionNodeRequest request, ServerCallContext context)
|
public override async Task<AddPermissionNodeResponse> AddPermissionNode(AddPermissionNodeRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
var node = await permissionService.AddPermissionNode(
|
try
|
||||||
request.Actor,
|
{
|
||||||
request.Area,
|
JsonDocument jsonValue;
|
||||||
request.Key,
|
try
|
||||||
JsonDocument.Parse(request.Value.ToString()), // Convert Value to JsonDocument
|
{
|
||||||
request.ExpiredAt?.ToInstant(),
|
jsonValue = JsonDocument.Parse(request.Value.ToString());
|
||||||
request.AffectedAt?.ToInstant()
|
}
|
||||||
);
|
catch (JsonException ex)
|
||||||
return new AddPermissionNodeResponse { Node = node.ToProtoValue() };
|
{
|
||||||
|
logger.LogWarning(ex, "Invalid JSON in permission value for actor {Actor}, area {Area}, key {Key}",
|
||||||
|
request.Actor, request.Area, request.Key);
|
||||||
|
throw new RpcException(new Status(StatusCode.InvalidArgument, "Invalid permission value format"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var node = await permissionService.AddPermissionNode(
|
||||||
|
request.Actor,
|
||||||
|
request.Area,
|
||||||
|
request.Key,
|
||||||
|
jsonValue,
|
||||||
|
request.ExpiredAt?.ToInstant(),
|
||||||
|
request.AffectedAt?.ToInstant()
|
||||||
|
);
|
||||||
|
return new AddPermissionNodeResponse { Node = node.ToProtoValue() };
|
||||||
|
}
|
||||||
|
catch (RpcException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Error adding permission node for actor {Actor}, area {Area}, key {Key}",
|
||||||
|
request.Actor, request.Area, request.Key);
|
||||||
|
throw new RpcException(new Status(StatusCode.Internal, "Failed to add permission node"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<AddPermissionNodeToGroupResponse> AddPermissionNodeToGroup(AddPermissionNodeToGroupRequest request, ServerCallContext context)
|
public override async Task<AddPermissionNodeToGroupResponse> AddPermissionNodeToGroup(AddPermissionNodeToGroupRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
var group = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Id == Guid.Parse(request.Group.Id));
|
try
|
||||||
if (group == null)
|
|
||||||
{
|
{
|
||||||
throw new RpcException(new Status(StatusCode.NotFound, "Permission group not found."));
|
var group = await FindPermissionGroupAsync(request.Group.Id);
|
||||||
}
|
if (group == null)
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(StatusCode.NotFound, "Permission group not found"));
|
||||||
|
}
|
||||||
|
|
||||||
var node = await permissionService.AddPermissionNodeToGroup(
|
JsonDocument jsonValue;
|
||||||
group,
|
try
|
||||||
request.Actor,
|
{
|
||||||
request.Area,
|
jsonValue = JsonDocument.Parse(request.Value.ToString());
|
||||||
request.Key,
|
}
|
||||||
JsonDocument.Parse(request.Value.ToString()), // Convert Value to JsonDocument
|
catch (JsonException ex)
|
||||||
request.ExpiredAt?.ToInstant(),
|
{
|
||||||
request.AffectedAt?.ToInstant()
|
logger.LogWarning(ex, "Invalid JSON in permission value for group {GroupId}, actor {Actor}, area {Area}, key {Key}",
|
||||||
);
|
request.Group.Id, request.Actor, request.Area, request.Key);
|
||||||
return new AddPermissionNodeToGroupResponse { Node = node.ToProtoValue() };
|
throw new RpcException(new Status(StatusCode.InvalidArgument, "Invalid permission value format"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var node = await permissionService.AddPermissionNodeToGroup(
|
||||||
|
group,
|
||||||
|
request.Actor,
|
||||||
|
request.Area,
|
||||||
|
request.Key,
|
||||||
|
jsonValue,
|
||||||
|
request.ExpiredAt?.ToInstant(),
|
||||||
|
request.AffectedAt?.ToInstant()
|
||||||
|
);
|
||||||
|
return new AddPermissionNodeToGroupResponse { Node = node.ToProtoValue() };
|
||||||
|
}
|
||||||
|
catch (RpcException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Error adding permission node to group {GroupId} for actor {Actor}, area {Area}, key {Key}",
|
||||||
|
request.Group.Id, request.Actor, request.Area, request.Key);
|
||||||
|
throw new RpcException(new Status(StatusCode.Internal, "Failed to add permission node to group"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<RemovePermissionNodeResponse> RemovePermissionNode(RemovePermissionNodeRequest request, ServerCallContext context)
|
public override async Task<RemovePermissionNodeResponse> RemovePermissionNode(RemovePermissionNodeRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
await permissionService.RemovePermissionNode(request.Actor, request.Area, request.Key);
|
try
|
||||||
return new RemovePermissionNodeResponse { Success = true };
|
{
|
||||||
|
await permissionService.RemovePermissionNode(request.Actor, request.Area, request.Key);
|
||||||
|
return new RemovePermissionNodeResponse { Success = true };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Error removing permission node for actor {Actor}, area {Area}, key {Key}",
|
||||||
|
request.Actor, request.Area, request.Key);
|
||||||
|
throw new RpcException(new Status(StatusCode.Internal, "Failed to remove permission node"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<RemovePermissionNodeFromGroupResponse> RemovePermissionNodeFromGroup(RemovePermissionNodeFromGroupRequest request, ServerCallContext context)
|
public override async Task<RemovePermissionNodeFromGroupResponse> RemovePermissionNodeFromGroup(RemovePermissionNodeFromGroupRequest request, ServerCallContext context)
|
||||||
{
|
{
|
||||||
var group = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Id == Guid.Parse(request.Group.Id));
|
try
|
||||||
if (group == null)
|
|
||||||
{
|
{
|
||||||
throw new RpcException(new Status(StatusCode.NotFound, "Permission group not found."));
|
var group = await FindPermissionGroupAsync(request.Group.Id);
|
||||||
|
if (group == null)
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(StatusCode.NotFound, "Permission group not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
await permissionService.RemovePermissionNodeFromGroup<JsonDocument>(group, request.Actor, request.Area, request.Key);
|
||||||
|
return new RemovePermissionNodeFromGroupResponse { Success = true };
|
||||||
|
}
|
||||||
|
catch (RpcException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Error removing permission node from group {GroupId} for actor {Actor}, area {Area}, key {Key}",
|
||||||
|
request.Group.Id, request.Actor, request.Area, request.Key);
|
||||||
|
throw new RpcException(new Status(StatusCode.Internal, "Failed to remove permission node from group"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SnPermissionGroup?> FindPermissionGroupAsync(string groupId)
|
||||||
|
{
|
||||||
|
if (!Guid.TryParse(groupId, out var guid))
|
||||||
|
{
|
||||||
|
logger.LogWarning("Invalid GUID format for group ID: {GroupId}", groupId);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await permissionService.RemovePermissionNodeFromGroup<JsonDocument>(group, request.Actor, request.Area, request.Key);
|
return await db.PermissionGroups.FirstOrDefaultAsync(g => g.Id == guid);
|
||||||
return new RemovePermissionNodeFromGroupResponse { Success = true };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
345
DysonNetwork.Pass/PermissionController.cs
Normal file
345
DysonNetwork.Pass/PermissionController.cs
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using DysonNetwork.Pass.Permission;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using NodaTime;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("/api/permissions")]
|
||||||
|
[Authorize]
|
||||||
|
public class PermissionController(
|
||||||
|
PermissionService permissionService,
|
||||||
|
AppDatabase db
|
||||||
|
) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Check if an actor has a specific permission
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("check/{actor}/{area}/{key}")]
|
||||||
|
[RequiredPermission("maintenance", "permissions.check")]
|
||||||
|
[ProducesResponseType<bool>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<IActionResult> CheckPermission(string actor, string area, string key)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var hasPermission = await permissionService.HasPermissionAsync(actor, area, key);
|
||||||
|
return Ok(hasPermission);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "Failed to check permission", details = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all effective permissions for an actor (including group permissions)
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("actors/{actor}/permissions/effective")]
|
||||||
|
[RequiredPermission("maintenance", "permissions.check")]
|
||||||
|
[ProducesResponseType<List<SnPermissionNode>>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<IActionResult> GetEffectivePermissions(string actor)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var permissions = await permissionService.ListEffectivePermissionsAsync(actor);
|
||||||
|
return Ok(permissions);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "Failed to list permissions", details = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all direct permissions for an actor (excluding group permissions)
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("actors/{actor}/permissions/direct")]
|
||||||
|
[RequiredPermission("maintenance", "permissions.check")]
|
||||||
|
[ProducesResponseType<List<SnPermissionNode>>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<IActionResult> GetDirectPermissions(string actor)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var permissions = await permissionService.ListDirectPermissionsAsync(actor);
|
||||||
|
return Ok(permissions);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "Failed to list permissions", details = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Give a permission to an actor
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("actors/{actor}/permissions/{area}/{key}")]
|
||||||
|
[RequiredPermission("maintenance", "permissions.manage")]
|
||||||
|
[ProducesResponseType<SnPermissionNode>(StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<IActionResult> GivePermission(
|
||||||
|
string actor,
|
||||||
|
string area,
|
||||||
|
string key,
|
||||||
|
[FromBody] PermissionRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var permission = await permissionService.AddPermissionNode(
|
||||||
|
actor,
|
||||||
|
area,
|
||||||
|
key,
|
||||||
|
JsonDocument.Parse(JsonSerializer.Serialize(request.Value)),
|
||||||
|
request.ExpiredAt,
|
||||||
|
request.AffectedAt
|
||||||
|
);
|
||||||
|
return Created($"/api/permissions/actors/{actor}/permissions/{area}/{key}", permission);
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "Failed to add permission", details = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove a permission from an actor
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("actors/{actor}/permissions/{area}/{key}")]
|
||||||
|
[RequiredPermission("maintenance", "permissions.manage")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<IActionResult> RemovePermission(string actor, string area, string key)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await permissionService.RemovePermissionNode(actor, area, key);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "Failed to remove permission", details = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all groups for an actor
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("actors/{actor}/groups")]
|
||||||
|
[RequiredPermission("maintenance", "permissions.groups.check")]
|
||||||
|
[ProducesResponseType<List<SnPermissionGroupMember>>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<IActionResult> GetActorGroups(string actor)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var groups = await db.PermissionGroupMembers
|
||||||
|
.Where(m => m.Actor == actor)
|
||||||
|
.Where(m => m.ExpiredAt == null || m.ExpiredAt > now)
|
||||||
|
.Where(m => m.AffectedAt == null || m.AffectedAt <= now)
|
||||||
|
.Include(m => m.Group)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(groups);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "Failed to list actor groups", details = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add an actor to a permission group
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("actors/{actor}/groups/{groupId}")]
|
||||||
|
[RequiredPermission("maintenance", "permissions.groups.manage")]
|
||||||
|
[ProducesResponseType<SnPermissionGroupMember>(StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<IActionResult> AddActorToGroup(
|
||||||
|
string actor,
|
||||||
|
Guid groupId,
|
||||||
|
[FromBody] GroupMembershipRequest? request = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var group = await db.PermissionGroups.FindAsync(groupId);
|
||||||
|
if (group == null)
|
||||||
|
{
|
||||||
|
return NotFound(new { error = "Permission group not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if actor is already in the group
|
||||||
|
var existing = await db.PermissionGroupMembers
|
||||||
|
.FirstOrDefaultAsync(m => m.Actor == actor && m.GroupId == groupId);
|
||||||
|
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Actor is already in this group" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = new SnPermissionGroupMember
|
||||||
|
{
|
||||||
|
Actor = actor,
|
||||||
|
GroupId = groupId,
|
||||||
|
Group = group,
|
||||||
|
ExpiredAt = request?.ExpiredAt,
|
||||||
|
AffectedAt = request?.AffectedAt
|
||||||
|
};
|
||||||
|
|
||||||
|
db.PermissionGroupMembers.Add(member);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Clear actor cache
|
||||||
|
await permissionService.ClearActorCacheAsync(actor);
|
||||||
|
|
||||||
|
return Created($"/api/permissions/actors/{actor}/groups/{groupId}", member);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "Failed to add actor to group", details = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove an actor from a permission group
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("actors/{actor}/groups/{groupId}")]
|
||||||
|
[RequiredPermission("maintenance", "permissions.groups.manage")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<IActionResult> RemoveActorFromGroup(string actor, Guid groupId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var member = await db.PermissionGroupMembers
|
||||||
|
.FirstOrDefaultAsync(m => m.Actor == actor && m.GroupId == groupId);
|
||||||
|
|
||||||
|
if (member == null)
|
||||||
|
{
|
||||||
|
return NotFound(new { error = "Actor is not in this group" });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.PermissionGroupMembers.Remove(member);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Clear actor cache
|
||||||
|
await permissionService.ClearActorCacheAsync(actor);
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "Failed to remove actor from group", details = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clear permission cache for an actor
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("actors/{actor}/cache/clear")]
|
||||||
|
[RequiredPermission("maintenance", "permissions.cache.manage")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<IActionResult> ClearActorCache(string actor)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await permissionService.ClearActorCacheAsync(actor);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return StatusCode(500, new { error = "Failed to clear cache", details = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validate a permission pattern
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("validate-pattern")]
|
||||||
|
[RequiredPermission("maintenance", "permissions.check")]
|
||||||
|
[ProducesResponseType<PatternValidationResponse>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public IActionResult ValidatePattern([FromBody] PatternValidationRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var isValid = PermissionService.IsValidPermissionPattern(request.Pattern);
|
||||||
|
return Ok(new PatternValidationResponse
|
||||||
|
{
|
||||||
|
Pattern = request.Pattern,
|
||||||
|
IsValid = isValid,
|
||||||
|
Message = isValid ? "Pattern is valid" : "Pattern contains invalid characters or consecutive wildcards"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PermissionRequest
|
||||||
|
{
|
||||||
|
public object? Value { get; set; }
|
||||||
|
public NodaTime.Instant? ExpiredAt { get; set; }
|
||||||
|
public NodaTime.Instant? AffectedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GroupMembershipRequest
|
||||||
|
{
|
||||||
|
public NodaTime.Instant? ExpiredAt { get; set; }
|
||||||
|
public NodaTime.Instant? AffectedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PatternValidationRequest
|
||||||
|
{
|
||||||
|
public string Pattern { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PatternValidationResponse
|
||||||
|
{
|
||||||
|
public string Pattern { get; set; } = string.Empty;
|
||||||
|
public bool IsValid { get; set; }
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"App": "Host=localhost;Port=5432;Database=dyson_network;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
"App": "Host=localhost;Port=5432;Database=dyson_sphere;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||||
},
|
},
|
||||||
"GeoIp": {
|
"GeoIp": {
|
||||||
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"App": "Host=host.docker.internal;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
"App": "Host=host.docker.internal;Port=5432;Database=dyson_sphere_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||||
},
|
},
|
||||||
"KnownProxies": ["127.0.0.1", "::1"]
|
"KnownProxies": ["127.0.0.1", "::1"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"App": "Host=host.docker.internal;Port=5432;Database=dyson_network;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
"App": "Host=host.docker.internal;Port=5432;Database=dyson_sphere;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||||
},
|
},
|
||||||
"GeoIp": {
|
"GeoIp": {
|
||||||
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"App": "Host=host.docker.internal;Port=5432;Database=dyson_network;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
"App": "Host=host.docker.internal;Port=5432;Database=dyson_sphere;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||||
},
|
},
|
||||||
"GeoIp": {
|
"GeoIp": {
|
||||||
"DatabasePath": "/app/keys/GeoLite2-City.mmdb"
|
"DatabasePath": "/app/keys/GeoLite2-City.mmdb"
|
||||||
|
|||||||
Reference in New Issue
Block a user