diff --git a/DysonNetwork.Zone/Publication/PublicationSiteManager.cs b/DysonNetwork.Zone/Publication/PublicationSiteManager.cs new file mode 100644 index 0000000..2d52130 --- /dev/null +++ b/DysonNetwork.Zone/Publication/PublicationSiteManager.cs @@ -0,0 +1,133 @@ +using DysonNetwork.Shared.Models; + +namespace DysonNetwork.Zone.Publication; + +public class FileEntry +{ + public bool IsDirectory { get; set; } + public string RelativePath { get; set; } = null!; + public long Size { get; set; } + public DateTimeOffset Modified { get; set; } +} + +public class PublicationSiteManager( + IConfiguration configuration, + IWebHostEnvironment hostEnvironment, + PublicationSiteService publicationSiteService +) +{ + private readonly string _basePath = Path.Combine( + hostEnvironment.WebRootPath, + configuration["Sites:BasePath"]!.TrimStart('/') + ); + + private string GetFullPath(Guid siteId, string relativePath) + { + var fullPath = Path.Combine(_basePath, siteId.ToString(), relativePath); + var siteDirPath = Path.Combine(_basePath, siteId.ToString()); + return !Path.GetRelativePath(siteDirPath, fullPath).StartsWith('.') + ? throw new ArgumentException("Invalid path") + : fullPath; + } + + private async Task EnsureSiteDirectory(Guid siteId) + { + var site = await publicationSiteService.GetSiteById(siteId); + if (site is not { Mode: PublicationSiteMode.SelfManaged }) + throw new InvalidOperationException("Site not found or not self-managed"); + var dir = Path.Combine(_basePath, siteId.ToString()); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + } + + public async Task> ListFiles(Guid siteId, string relativePath = "") + { + await EnsureSiteDirectory(siteId); + var dir = Path.Combine(_basePath, siteId.ToString(), relativePath); + if (!Directory.Exists(dir)) + throw new DirectoryNotFoundException("Directory not found"); + + var entries = (from file in Directory.GetFiles(dir) + let fileInfo = new FileInfo(file) + select new FileEntry + { + IsDirectory = false, + RelativePath = Path.GetRelativePath(Path.Combine(_basePath, siteId.ToString()), file), + Size = fileInfo.Length, Modified = fileInfo.LastWriteTimeUtc + }).ToList(); + entries.AddRange(from subDir in Directory.GetDirectories(dir) + let dirInfo = new DirectoryInfo(subDir) + select new FileEntry + { + IsDirectory = true, + RelativePath = Path.GetRelativePath(Path.Combine(_basePath, siteId.ToString()), subDir), + Size = 0, // Directories don't have size + Modified = dirInfo.LastWriteTimeUtc + }); + + return entries; + } + + public async Task UploadFile(Guid siteId, string relativePath, IFormFile file) + { + await EnsureSiteDirectory(siteId); + var fullPath = GetFullPath(siteId, relativePath); + + var dir = Path.GetDirectoryName(fullPath); + if (dir != null && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + await using var stream = new FileStream(fullPath, FileMode.Create); + await file.CopyToAsync(stream); + } + + public async Task ReadFileContent(Guid siteId, string relativePath) + { + await EnsureSiteDirectory(siteId); + var fullPath = GetFullPath(siteId, relativePath); + if (!File.Exists(fullPath)) + throw new FileNotFoundException(); + return await File.ReadAllTextAsync(fullPath); + } + + public async Task GetTotalSiteSize(Guid siteId) + { + await EnsureSiteDirectory(siteId); + var dir = new DirectoryInfo(Path.Combine(_basePath, siteId.ToString())); + return GetDirectorySize(dir); + } + + private long GetDirectorySize(DirectoryInfo dir) + { + var files = dir.GetFiles(); + var size = files.Sum(file => file.Length); + + var subDirs = dir.GetDirectories(); + size += subDirs.Sum(GetDirectorySize); + + return size; + } + + public string GetFullPathForDownload(Guid siteId, string relativePath) + { + // Internal method to get path without throwing if not exists + return Path.Combine(_basePath, siteId.ToString(), relativePath); + } + + public async Task UpdateFile(Guid siteId, string relativePath, string newContent) + { + await EnsureSiteDirectory(siteId); + var fullPath = GetFullPath(siteId, relativePath); + await File.WriteAllTextAsync(fullPath, newContent); + } + + public async Task DeleteFile(Guid siteId, string relativePath) + { + await EnsureSiteDirectory(siteId); + var fullPath = GetFullPath(siteId, relativePath); + if (File.Exists(fullPath)) + File.Delete(fullPath); + else if (Directory.Exists(fullPath)) + Directory.Delete(fullPath, true); + } +} \ No newline at end of file diff --git a/DysonNetwork.Zone/Publication/SiteManagerController.cs b/DysonNetwork.Zone/Publication/SiteManagerController.cs new file mode 100644 index 0000000..f2a9f7c --- /dev/null +++ b/DysonNetwork.Zone/Publication/SiteManagerController.cs @@ -0,0 +1,185 @@ +using DysonNetwork.Shared.Models; +using DysonNetwork.Shared.Proto; +using DysonNetwork.Shared.Registry; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using PublisherMemberRole = DysonNetwork.Shared.Models.PublisherMemberRole; + +namespace DysonNetwork.Zone.Publication; + +[ApiController] +[Route("api/sites/{siteId:guid}/files")] +public class SiteManagerController( + PublicationSiteService publicationSiteService, + PublicationSiteManager fileManager, + RemotePublisherService remotePublisherService +) : ControllerBase +{ + private async Task CheckAccess(Guid siteId) + { + var site = await publicationSiteService.GetSiteById(siteId); + if (site is not { Mode: PublicationSiteMode.SelfManaged }) + return NotFound("Site not found or not self-managed"); + + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var accountId = Guid.Parse(currentUser.Id); + var isMember = await remotePublisherService.IsMemberWithRole(site.PublisherId, accountId, PublisherMemberRole.Editor); + return !isMember ? Forbid() : null; + } + + [HttpGet] + [Authorize] + public async Task>> ListFiles(Guid siteId, [FromQuery] string path = "") + { + var check = await CheckAccess(siteId); + if (check != null) return check; + + try + { + var entries = await fileManager.ListFiles(siteId, path); + return Ok(entries); + } + catch (DirectoryNotFoundException) + { + return NotFound("Directory not found"); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + [HttpPost("upload")] + [Authorize] + [Consumes("multipart/form-data")] + public async Task UploadFile(Guid siteId, [FromForm] string filePath, IFormFile? file) + { + var check = await CheckAccess(siteId); + if (check != null) return check; + + if (file == null || file.Length == 0) + return BadRequest("No file provided"); + + const long maxFileSize = 1048576; // 1MB + const long maxTotalSize = 26214400; // 25MB + + if (file.Length > maxFileSize) + return BadRequest("File size exceeds 1MB limit"); + + var currentTotal = await fileManager.GetTotalSiteSize(siteId); + if (currentTotal + file.Length > maxTotalSize) + return BadRequest("Site total size would exceed 25MB limit"); + + try + { + await fileManager.UploadFile(siteId, filePath, file); + return Ok(); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("content/{**relativePath}")] + [Authorize] + public async Task> GetFileContent(Guid siteId, string relativePath) + { + var check = await CheckAccess(siteId); + if (check != null) return check; + + try + { + var content = await fileManager.ReadFileContent(siteId, relativePath); + return Ok(content); + } + catch (FileNotFoundException) + { + return NotFound(); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("download/{**relativePath}")] + [Authorize] + public async Task DownloadFile(Guid siteId, string relativePath) + { + var check = await CheckAccess(siteId); + if (check != null) return check; + + var fullPath = fileManager.GetFullPathForDownload(siteId, relativePath); + if (!System.IO.File.Exists(fullPath)) + return NotFound(); + + // Determine MIME type + var mimeType = "application/octet-stream"; // default + var ext = Path.GetExtension(relativePath).ToLowerInvariant(); + if (ext == ".txt") mimeType = "text/plain"; + else if (ext == ".html" || ext == ".htm") mimeType = "text/html"; + else if (ext == ".css") mimeType = "text/css"; + else if (ext == ".js") mimeType = "application/javascript"; + else if (ext == ".json") mimeType = "application/json"; + + return PhysicalFile(fullPath, mimeType, Path.GetFileName(relativePath)); + } + + [HttpPut("edit/{**relativePath}")] + [Authorize] + public async Task UpdateFile(Guid siteId, string relativePath, [FromBody] UpdateFileRequest request) + { + var check = await CheckAccess(siteId); + if (check != null) return check; + + const long maxFileSize = 1048576; // 1MB + const long maxTotalSize = 26214400; // 25MB + + var fullPath = fileManager.GetFullPathForDownload(siteId, relativePath); + long oldSize = 0; + if (System.IO.File.Exists(fullPath)) + oldSize = new FileInfo(fullPath).Length; + + if (request.NewContent.Length > maxFileSize) + return BadRequest("New content exceeds 1MB limit"); + + var currentTotal = await fileManager.GetTotalSiteSize(siteId); + if (currentTotal - oldSize + request.NewContent.Length > maxTotalSize) + return BadRequest("Site total size would exceed 25MB limit"); + + try + { + await fileManager.UpdateFile(siteId, relativePath, request.NewContent); + return Ok(); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + [HttpDelete("delete/{**relativePath}")] + [Authorize] + public async Task DeleteFile(Guid siteId, string relativePath) + { + var check = await CheckAccess(siteId); + if (check != null) return check; + + try + { + await fileManager.DeleteFile(siteId, relativePath); + return Ok(); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } +} + +public class UpdateFileRequest +{ + public string NewContent { get; set; } = null!; +} diff --git a/DysonNetwork.Zone/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Zone/Startup/ServiceCollectionExtensions.cs index 69e02e2..b9f4ec2 100644 --- a/DysonNetwork.Zone/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Zone/Startup/ServiceCollectionExtensions.cs @@ -76,7 +76,8 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); - + services.AddScoped(); + return services; } }