using System.IO.Compression; 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); } } [HttpPost("deploy")] [Authorize] [Consumes("multipart/form-data")] public async Task Deploy( Guid siteId, [FromForm(Name = "file")] IFormFile? zipFile, [FromQuery] bool smart = true ) { var check = await CheckAccess(siteId); if (check != null) return check; if (zipFile == null || zipFile.Length == 0) return BadRequest("No file provided."); if (Path.GetExtension(zipFile.FileName).ToLowerInvariant() != ".zip") return BadRequest("Only .zip files are allowed for deployment."); // Define size limits const long maxZipFileSize = 52428800; // 50MB for the zip file itself const long maxTotalSiteSizeAfterExtract = 104857600; // 100MB total size after extraction if (zipFile.Length > maxZipFileSize) return BadRequest($"Zip file size exceeds {maxZipFileSize / (1024 * 1024)}MB limit."); try { // For now, we'll only check the zip file size. // A more robust solution might involve extracting to a temp location // and checking the uncompressed size before moving, but that's more complex. // Get current site size before deployment long currentTotal = await fileManager.GetTotalSiteSize(siteId); // This is a rough check. The actual uncompressed size might be much larger. // Consider adding a more sophisticated check if this is a concern. if (currentTotal + zipFile.Length * 3 > maxTotalSiteSizeAfterExtract) // Heuristic: assume 3x expansion return BadRequest( $"Deployment would exceed total site size limit of {maxTotalSiteSizeAfterExtract / (1024 * 1024)}MB."); var siteDir = fileManager.GetSiteDirectory(siteId); Directory.CreateDirectory(siteDir); // Ensure site directory exists if (smart) { // Smart mode: Extract to temp directory and flatten if single folder wrapper var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); Directory.CreateDirectory(tempDir); try { await using (var archive = new ZipArchive(zipFile.OpenReadStream(), ZipArchiveMode.Read)) { await archive.ExtractToDirectoryAsync(tempDir, true); } // Check if temp directory has exactly one subdirectory and no files at root var rootEntries = Directory.GetFileSystemEntries(tempDir); if (rootEntries.Length == 1 && Directory.Exists(rootEntries[0])) { var innerDir = rootEntries[0]; // Flatten: move contents of innerDir to siteDir foreach (var file in Directory.GetFiles(innerDir, "*", SearchOption.AllDirectories)) { string relPath = Path.GetRelativePath(innerDir, file); string destFile = Path.Combine(siteDir, relPath); string? destDir = Path.GetDirectoryName(destFile); if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir)) Directory.CreateDirectory(destDir); System.IO.File.Move(file, destFile, true); } // Also create empty directories foreach (var dir in Directory.GetDirectories(innerDir, "*", SearchOption.AllDirectories)) { string relPath = Path.GetRelativePath(innerDir, dir); string destDirPath = Path.Combine(siteDir, relPath); Directory.CreateDirectory(destDirPath); } } else { // No smart flattening needed, extract directly to siteDir using (var archive = new ZipArchive(zipFile.OpenReadStream(), ZipArchiveMode.Read)) { archive.ExtractToDirectory(siteDir, true); } } } finally { Directory.Delete(tempDir, true); } } else { await fileManager.DeployZip(siteId, zipFile); } return Ok("Deployment successful."); } catch (Exception ex) { return BadRequest($"Deployment failed: {ex.Message}"); } } [HttpDelete("purge")] [Authorize] public async Task Purge(Guid siteId) { var check = await CheckAccess(siteId); if (check != null) return check; try { await fileManager.PurgeSite(siteId); return Ok("Site content purged successfully."); } catch (Exception ex) { return BadRequest($"Purge failed: {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; string fullPath; try { fullPath = fileManager.GetValidatedFullPath(siteId, relativePath); } catch (ArgumentException) { return BadRequest("Invalid path"); } 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 if (request.NewContent.Length > maxFileSize) return BadRequest("New content exceeds 1MB limit"); long oldSize = 0; try { var fullPath = fileManager.GetValidatedFullPath(siteId, relativePath); if (System.IO.File.Exists(fullPath)) oldSize = new FileInfo(fullPath).Length; var currentTotal = await fileManager.GetTotalSiteSize(siteId); if (currentTotal - oldSize + request.NewContent.Length > maxTotalSize) return BadRequest("Site total size would exceed 25MB limit"); await fileManager.UpdateFile(siteId, relativePath, request.NewContent); return Ok(); } catch (ArgumentException) { return BadRequest("Invalid path"); } 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!; }