diff --git a/.gitignore b/.gitignore index ccc5771..dd3c65a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ bin .idea/ Logs/ Infostacker.csproj.user +*.user diff --git a/Controllers/SharingController.cs b/Controllers/SharingController.cs index 0c4feb6..0187226 100644 --- a/Controllers/SharingController.cs +++ b/Controllers/SharingController.cs @@ -18,7 +18,6 @@ public SharingController(ISharingService sharingService, ILogger UploadMarkdownWithFiles([FromForm] string markdown, [FromForm] List files) { Guid identifier = await _sharingService.UploadMarkdownWithFiles(markdown, files); @@ -30,13 +29,13 @@ public async Task UploadMarkdownWithFiles([FromForm] string markd return Ok(new { Message = "Markdown and files uploaded successfully.", id = identifier }); } - [HttpGet("{identifier}")] - public async Task GetMarkdownContent(string identifier) + [HttpGet("{identifier:guid}")] + public async Task GetMarkdownContent(Guid identifier) { string? result = await _sharingService.GetMarkdownContent(identifier); if (result is null) { - return LogAndReturnNotFound("No markdown or files with given identifier found.", identifier); + return LogAndReturnNotFound("No markdown or files with given identifier found.", identifier.ToString()); } return new ContentResult { @@ -46,7 +45,7 @@ public async Task GetMarkdownContent(string identifier) }; } - [HttpPut("{identifier}")] + [HttpPut("{identifier:guid}")] public async Task UpdateMarkdownWithFiles([FromForm] string markdown, [FromForm] List files, Guid identifier) { if (!await _sharingService.UpdateMarkdownWithFiles(markdown, files, identifier)) @@ -57,58 +56,58 @@ public async Task UpdateMarkdownWithFiles([FromForm] string markd return Ok(new { Message = "Markdown and files updated successfully.", id = identifier }); } - [HttpDelete("{identifier}")] - public async Task DeleteMarkdownWithFiles(string identifier) + [HttpDelete("{identifier:guid}")] + public async Task DeleteMarkdownWithFiles(Guid identifier) { if (!await _sharingService.DeleteMarkdownWithFiles(identifier)) { - return LogAndReturnNotFound("No markdown or files with given identifier found.", identifier); + return LogAndReturnNotFound("No markdown or files with given identifier found.", identifier.ToString()); } return Ok(new { Message = "Markdown and files successfully deleted.", id = identifier }); } - [HttpGet("pdf/{identifier}/{fileName}")] - public async Task GetPdf(string identifier, string fileName) + [HttpGet("pdf/{identifier:guid}/{fileName}")] + public async Task GetPdf(Guid identifier, string fileName) { - FileStream stream = await _sharingService.GetPdf(identifier, fileName); + FileStream? stream = await _sharingService.GetPdf(identifier, fileName); if (stream is null) { - return LogAndReturnNotFound("PDF does not exist.", identifier, fileName); + return LogAndReturnNotFound("PDF does not exist.", identifier.ToString(), fileName); } return File(stream, "application/pdf"); } - [HttpGet("doc/{identifier}/{fileName}")] - public async Task GetDoc(string identifier, string fileName) + [HttpGet("doc/{identifier:guid}/{fileName}")] + public async Task GetDoc(Guid identifier, string fileName) { - FileStream stream = await _sharingService.GetDoc(identifier, fileName); + FileStream? stream = await _sharingService.GetDoc(identifier, fileName); if (stream is null) { - return LogAndReturnNotFound("Doc does not exist.", identifier, fileName); + return LogAndReturnNotFound("Doc does not exist.", identifier.ToString(), fileName); } return File(stream, "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); } - [HttpGet("image/{identifier}/{fileName}")] - public async Task GetImage(string identifier, string fileName) + [HttpGet("image/{identifier:guid}/{fileName}")] + public async Task GetImage(Guid identifier, string fileName) { - FileStream stream = await _sharingService.GetImage(identifier, fileName); + FileStream? stream = await _sharingService.GetImage(identifier, fileName); if (stream is null) { - return LogAndReturnNotFound("Image does not exist.", identifier, fileName); + return LogAndReturnNotFound("Image does not exist.", identifier.ToString(), fileName); } return File(stream, "image/png"); } - [HttpGet("video/{identifier}/{fileName}")] - public async Task GetVideo(string identifier, string fileName) + [HttpGet("video/{identifier:guid}/{fileName}")] + public async Task GetVideo(Guid identifier, string fileName) { - FileStream stream = await _sharingService.GetVideo(identifier, fileName); + FileStream? stream = await _sharingService.GetVideo(identifier, fileName); if (stream is null) { - return LogAndReturnNotFound("Video does not exist.", identifier, fileName); + return LogAndReturnNotFound("Video does not exist.", identifier.ToString(), fileName); } return File(stream, "video/mp4"); } diff --git a/Infostacker.csproj b/Infostacker.csproj index a0f0c31..6625d1e 100644 --- a/Infostacker.csproj +++ b/Infostacker.csproj @@ -1,19 +1,19 @@  - net8.0 + net10.0 enable enable - - - - - + + + + + - + diff --git a/Program.cs b/Program.cs index ca13cd0..d49d868 100644 --- a/Program.cs +++ b/Program.cs @@ -1,66 +1,131 @@ +using System.Threading.RateLimiting; using Infostacker.Services; using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.DependencyInjection.Extensions; using Serilog; -using Serilog.Core; using SharingService = Infostacker.Services.SharingService; -WebApplicationBuilder? builder = WebApplication.CreateBuilder(args); -string? MyAllowSpecificOrigins = "_myAllowSpecificOrigins"; -string seqServer = string.IsNullOrWhiteSpace(builder.Configuration.GetValue("SeqServer")) ? "https://localhost:5341" : builder.Configuration.GetValue("SeqServer"); +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +const string AllowSpecificOrigins = "_myAllowSpecificOrigins"; -Logger? logger = new LoggerConfiguration() +builder.Configuration.AddJsonFile("version.json", optional: true); + +string seqServer = builder.Configuration.GetValue("SeqServer")?.Trim() ?? string.Empty; +if (string.IsNullOrWhiteSpace(seqServer)) +{ + seqServer = "https://localhost:5341"; +} + +Serilog.ILogger logger = new LoggerConfiguration() .Enrich.WithProperty("Application", "InfostackerService") .WriteTo.Console() - .WriteTo.File("Logs/logs.txt", - rollingInterval: RollingInterval.Day) - .WriteTo.Seq(seqServer ?? "https://localhost:5341") + .WriteTo.File("Logs/logs.txt", rollingInterval: RollingInterval.Day) + .WriteTo.Seq(seqServer) .MinimumLevel.Information() .CreateLogger(); -// Add services to the container. -builder.Configuration.AddJsonFile("version.json"); +Log.Logger = logger; + +builder.Logging.ClearProviders(); +builder.Logging.AddSerilog(logger); + builder.Services.AddMemoryCache(); builder.Services.AddControllers(); -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -builder.Services.AddCors(options => +builder.Services.TryAddSingleton(); +builder.Services.AddTransient(); + +long maxRequestBodySize = builder.Configuration.GetValue("MaxRequestBodySizeInBytes") is > 0 + ? builder.Configuration.GetValue("MaxRequestBodySizeInBytes") + : 104857600L; + +builder.Services.Configure(options => { - options.AddPolicy(name: MyAllowSpecificOrigins, - policy => - { - policy.WithOrigins("app://obsidian.md") - .AllowAnyHeader() - .AllowAnyMethod(); - }); + options.MultipartBodyLengthLimit = maxRequestBodySize; }); -builder.Services.Configure(options => +builder.WebHost.ConfigureKestrel(options => { - options.MultipartBodyLengthLimit = 104857600; // 100MB + options.Limits.MaxRequestBodySize = maxRequestBodySize; }); -builder.Services.AddTransient(); -builder.Services.TryAddSingleton(); -builder.Logging.ClearProviders(); -builder.Logging.AddSerilog(logger); +builder.Services.Configure(options => +{ + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; +}); + +int readRequestsPerMinute = builder.Configuration.GetValue("RateLimiting:ReadRequestsPerMinute") is > 0 + ? builder.Configuration.GetValue("RateLimiting:ReadRequestsPerMinute") + : 120; +int writeRequestsPerMinute = builder.Configuration.GetValue("RateLimiting:WriteRequestsPerMinute") is > 0 + ? builder.Configuration.GetValue("RateLimiting:WriteRequestsPerMinute") + : 30; + +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.OnRejected = async (context, cancellationToken) => + { + Log.Warning( + "Rate limit exceeded for {IpAddress} on {RequestPath}.", + context.HttpContext.Connection.RemoteIpAddress?.ToString(), + context.HttpContext.Request.Path.Value); + + context.HttpContext.Response.ContentType = "application/json"; + await context.HttpContext.Response.WriteAsync("{\"message\":\"Rate limit exceeded. Try again later.\"}", cancellationToken) + .ConfigureAwait(false); + }; + + options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => + { + string ipAddress = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"; + bool isWriteMethod = HttpMethods.IsPost(httpContext.Request.Method) + || HttpMethods.IsPut(httpContext.Request.Method) + || HttpMethods.IsDelete(httpContext.Request.Method); + + int permitLimit = isWriteMethod ? writeRequestsPerMinute : readRequestsPerMinute; + string partitionKey = $"{ipAddress}:{(isWriteMethod ? "write" : "read")}"; -WebApplication? app = builder.Build(); + return RateLimitPartition.GetFixedWindowLimiter( + partitionKey, + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = permitLimit, + Window = TimeSpan.FromMinutes(1), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 0, + AutoReplenishment = true + }); + }); +}); + +builder.Services.AddCors(options => +{ + options.AddPolicy(AllowSpecificOrigins, policy => + { + policy.WithOrigins("app://obsidian.md") + .AllowAnyHeader() + .AllowAnyMethod(); + }); +}); + +WebApplication app = builder.Build(); -// Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } -//app.UseHttpsRedirection(); - +app.UseForwardedHeaders(); +app.UseSerilogRequestLogging(); +app.UseHttpsRedirection(); +app.UseCors(AllowSpecificOrigins); +app.UseRateLimiter(); app.UseAuthorization(); - -app.UseCors(MyAllowSpecificOrigins); - app.MapControllers(); app.Run(); diff --git a/Properties/PublishProfiles/FolderProfile.pubxml b/Properties/PublishProfiles/FolderProfile.pubxml index 443335f..c7c6e39 100644 --- a/Properties/PublishProfiles/FolderProfile.pubxml +++ b/Properties/PublishProfiles/FolderProfile.pubxml @@ -10,11 +10,11 @@ https://go.microsoft.com/fwlink/?LinkID=208121. Release Any CPU FileSystem - bin\Release\net8.0\publish\ + bin\Release\net10.0\publish\ FileSystem <_TargetId>Folder - net8.0 + net10.0 07d340ec-bbee-4030-a832-d5fc81d4b680 false diff --git a/Properties/PublishProfiles/FolderProfile.pubxml.user b/Properties/PublishProfiles/FolderProfile.pubxml.user deleted file mode 100644 index 72956a8..0000000 --- a/Properties/PublishProfiles/FolderProfile.pubxml.user +++ /dev/null @@ -1,11 +0,0 @@ - - - - - <_PublishTargetUrl>C:\Projects\SharingAPI\bin\Release\net8.0\publish\ - True|2024-08-18T21:57:46.5485910Z||; - - - \ No newline at end of file diff --git a/README.md b/README.md index f75d5b0..441d327 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,18 @@ 1. Clone the repository. 2. Open the repository with your preferred IDE (For guide purposes, I will use Visual Studio). 3. Go to `appsettings.json` and fill in the path where notes should be stored on the server (`NotesFolder`) and paste your Adobe token into `AdobeAPIToken`. -4. Publish the solution. (For detailed instructions, see "Publishing with Visual Studio" below) -5. Open up IIS. -6. Expand connections list, right click on "Sites" and press "Add website". -7. Choose a name for the website, ex. "SharingAPI". Then, for physical path, pick the location where you published the project to. In the Binding section, choose whatever you want to host it under. Then, press OK. +4. Review security limits in `appsettings.json` and tune them for your deployment: + - `MaxFileSizeInBytes` + - `MaxFilesPerUpload` + - `MaxTotalUploadBytes` + - `MaxMarkdownLength` + - `MaxRequestBodySizeInBytes` + - `RateLimiting.ReadRequestsPerMinute` + - `RateLimiting.WriteRequestsPerMinute` +5. Publish the solution. (For detailed instructions, see "Publishing with Visual Studio" below) +6. Open up IIS. +7. Expand connections list, right click on "Sites" and press "Add website". +8. Choose a name for the website, ex. "SharingAPI". Then, for physical path, pick the location where you published the project to. In the Binding section, choose whatever you want to host it under. Then, press OK. ## Publishing with Visual Studio @@ -75,7 +83,7 @@ To enable CORS (Cross-Origin Resource Sharing) on an IIS server, you can add COR - '*' `` specifies the allowed request headers. - '*' `` specifies the headers that the server exposes to the client. - '*' - optional tags + '*' - optional tags 4. **Save Changes**: After adding the CORS configuration to the `web.config` file, save the changes. @@ -88,4 +96,4 @@ Once these steps are completed, the IIS server will include the specified CORS h In the releases, there is an already published current version of the API with sample notes folder set up. Unpack it into your site's location and modify `appsettings.json` and `web.config` according to your needs. -API server should now be correctly set up. \ No newline at end of file +API server should now be correctly set up. diff --git a/Services/ISharingService.cs b/Services/ISharingService.cs index c744fb5..5acdcb9 100644 --- a/Services/ISharingService.cs +++ b/Services/ISharingService.cs @@ -3,12 +3,12 @@ namespace Infostacker.Services; public interface ISharingService { Task UploadMarkdownWithFiles(string markdown, List files); - Task GetMarkdownContent(string identifier); + Task GetMarkdownContent(Guid identifier); Task UpdateMarkdownWithFiles(string markdown, List files, Guid identifier); - Task DeleteMarkdownWithFiles(string identifier); - Task GetPdf(string identifier, string fileName); - Task GetDoc(string identifier, string fileName); - Task GetImage(string identifier, string fileName); - Task GetVideo(string identifier, string fileName); + Task DeleteMarkdownWithFiles(Guid identifier); + Task GetPdf(Guid identifier, string fileName); + Task GetDoc(Guid identifier, string fileName); + Task GetImage(Guid identifier, string fileName); + Task GetVideo(Guid identifier, string fileName); Task GetVersion(); -} \ No newline at end of file +} diff --git a/Services/SharingService.cs b/Services/SharingService.cs index 5d19cb8..be34552 100644 --- a/Services/SharingService.cs +++ b/Services/SharingService.cs @@ -6,363 +6,498 @@ namespace Infostacker.Services; public partial class SharingService : ISharingService { + private static readonly MarkdownPipeline SafeMarkdownPipeline = new MarkdownPipelineBuilder() + .DisableHtml() + .Build(); + private static readonly HashSet AcceptedImageFormats = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png" }; + private static readonly HashSet AcceptedVideoFormats = new(StringComparer.OrdinalIgnoreCase) { ".mp4" }; + private static readonly HashSet AcceptedDocFormats = new(StringComparer.OrdinalIgnoreCase) { ".doc", ".docx" }; + private static readonly HashSet AcceptedPdfFormats = new(StringComparer.OrdinalIgnoreCase) { ".pdf" }; + private static readonly HashSet AcceptedUploadFormats = new(StringComparer.OrdinalIgnoreCase) + { + ".pdf", + ".doc", + ".docx", + ".jpg", + ".jpeg", + ".png", + ".mp4" + }; + private readonly ILogger _logger; private readonly IConfiguration _configuration; private readonly LinkGenerator _linkGenerator; private readonly IHttpContextAccessor _httpContextAccessor; - public required string? NotesFolderPath; - public required string? TemplatePath; - public required string? TemplateScriptPath; - public required string? BuildVersion; - public required int? MaxFileSize; - - public SharingService(ILogger logger, IConfiguration configuration, LinkGenerator linkGenerator, IHttpContextAccessor httpContextAccessor) + private readonly string _notesFolderPath; + private readonly string _templatePath; + private readonly string _templateScriptPath; + private readonly string _buildVersion; + private readonly int _maxFileSize; + private readonly int _maxFilesPerUpload; + private readonly long _maxTotalUploadBytes; + private readonly int _maxMarkdownLength; + + public SharingService( + ILogger logger, + IConfiguration configuration, + LinkGenerator linkGenerator, + IHttpContextAccessor httpContextAccessor) { _logger = logger; _configuration = configuration; _linkGenerator = linkGenerator; _httpContextAccessor = httpContextAccessor; - NotesFolderPath = _configuration.GetSection("NotesFolder").Value ?? string.Empty; - TemplatePath = _configuration.GetSection("TemplatePath").Value ?? string.Empty; - TemplateScriptPath = _configuration.GetSection("TemplateScriptPath").Value ?? string.Empty; - BuildVersion = _configuration.GetSection("version").Value ?? string.Empty; - MaxFileSize = int.Parse(_configuration.GetSection("MaxFileSizeInBytes").Value); - - + + _notesFolderPath = GetRequiredPathSetting("NotesFolder"); + _templatePath = GetRequiredPathSetting("TemplatePath"); + _templateScriptPath = GetRequiredPathSetting("TemplateScriptPath"); + _buildVersion = _configuration.GetValue("version") ?? string.Empty; + _maxFileSize = _configuration.GetValue("MaxFileSizeInBytes") is > 0 ? _configuration.GetValue("MaxFileSizeInBytes") : 104857600; + _maxFilesPerUpload = _configuration.GetValue("MaxFilesPerUpload") is > 0 ? _configuration.GetValue("MaxFilesPerUpload") : 20; + _maxTotalUploadBytes = _configuration.GetValue("MaxTotalUploadBytes") is > 0 ? _configuration.GetValue("MaxTotalUploadBytes") : 104857600L; + _maxMarkdownLength = _configuration.GetValue("MaxMarkdownLength") is > 0 ? _configuration.GetValue("MaxMarkdownLength") : 1_000_000; + + Directory.CreateDirectory(_notesFolderPath); } public async Task UploadMarkdownWithFiles(string markdown, List files) { - Guid identifier = Guid.NewGuid(); - - if (files.Any(file => file.Length > MaxFileSize)) + if (!IsUploadRequestValid(markdown, files, out string validationMessage)) { - _logger.LogInformation("File exceeded max size, discarding note."); + _logger.LogWarning("Upload request rejected. Reason: {Reason}", validationMessage); return Guid.Empty; } - // Create a directory with the GUID as its name - string directoryPath = Path.Combine(NotesFolderPath, identifier.ToString()); + Guid identifier = Guid.NewGuid(); + string directoryPath = GetNoteDirectoryPath(identifier); + try { Directory.CreateDirectory(directoryPath); + await SaveFiles(markdown, files, directoryPath).ConfigureAwait(false); + return identifier; } - catch (Exception e) + catch (Exception exception) { - _logger.LogError($"Error creating directory: {e}"); + _logger.LogError(exception, "Error while saving uploaded markdown and files for {Identifier}.", identifier); + + TryDeleteDirectory(directoryPath); return Guid.Empty; } - - await SaveFiles(markdown, files, directoryPath); - return identifier; } - public Task GetMarkdownContent(string identifier) + public async Task GetMarkdownContent(Guid identifier) { - // Construct the path to the markdown file using the provided GUID - string markdownFilePath = Path.Combine(NotesFolderPath, identifier, "content.md"); + string noteDirectoryPath = GetNoteDirectoryPath(identifier); + string markdownFilePath = Path.Combine(noteDirectoryPath, "content.md"); - // Check if the file exists if (!File.Exists(markdownFilePath)) { - return Task.FromResult(null); + return null; } - _logger.LogInformation("Markdown file found in \"{path}\"", markdownFilePath); - // Read the content of the markdown file - string markdownContent = File.ReadAllText(markdownFilePath); + if (!File.Exists(_templatePath) || !File.Exists(_templateScriptPath)) + { + _logger.LogError("Template files are missing. TemplatePath: {TemplatePath}, ScriptTemplatePath: {ScriptTemplatePath}", _templatePath, _templateScriptPath); + return null; + } - // Convert markdown string to HTML - string htmlContent = Markdown.ToHtml(markdownContent); + string markdownContent = await File.ReadAllTextAsync(markdownFilePath).ConfigureAwait(false); + string htmlTemplate = await File.ReadAllTextAsync(_templatePath).ConfigureAwait(false); + string scriptTemplate = await File.ReadAllTextAsync(_templateScriptPath).ConfigureAwait(false); - // Reading templates - string? templatePath = TemplatePath; - string? scriptTemplatePath = TemplateScriptPath; - string htmlTemplate = File.ReadAllText(templatePath); - string scriptTemplate = File.ReadAllText(scriptTemplatePath); Regex regex = FileAttachmentRegex(); + htmlTemplate = htmlTemplate.Replace("{title}", ExtractSafeTitle(markdownContent, regex)); + htmlTemplate = htmlTemplate.Replace("{markdown}", Markdown.ToHtml(markdownContent, SafeMarkdownPipeline)); - // Setting note page title - try - { - string[] markdownLines = markdownContent.Split(["\r\n", "\r", "\n"], StringSplitOptions.None); - string noteTitle = markdownLines.Length > 0 ? markdownLines[0].TrimEnd() : string.Empty; + IEnumerable noteFiles = Directory.EnumerateFiles(noteDirectoryPath); + IEnumerable pdfFiles = noteFiles.Where(file => AcceptedPdfFormats.Contains(Path.GetExtension(file))); + IEnumerable docFiles = noteFiles.Where(file => AcceptedDocFormats.Contains(Path.GetExtension(file))); + IEnumerable imageFiles = noteFiles.Where(file => AcceptedImageFormats.Contains(Path.GetExtension(file))); + IEnumerable videoFiles = noteFiles.Where(file => AcceptedVideoFormats.Contains(Path.GetExtension(file))); - if (regex.Matches(noteTitle).Any()) - { - markdownLines = new[] { "Untitled", "\n" }.Concat(markdownLines).ToArray(); - noteTitle = "Untitled"; - } - - _logger.LogInformation("Title extracted from markdown as \"{title}\"", noteTitle); - htmlTemplate = htmlTemplate.Replace("{title}", noteTitle); - - string[] htmlLines = htmlTemplate.Split([Environment.NewLine], StringSplitOptions.None); - _logger.LogInformation("Title line after replacement action: {titleLine}", htmlLines[3]); - } - catch (Exception e) - { - _logger.LogError("Error while replacing title: \"{error}\"", e); - } - - // Inserting markdown content into html - _logger.LogInformation("Replacing markdown placeholder tag with html"); - htmlTemplate = htmlTemplate.Replace("{markdown}", htmlContent); - - // Getting PDFs - IEnumerable pdfFiles = Directory.GetFiles(Path.Combine(NotesFolderPath, identifier)) - .Where(file => Path.GetExtension(file).Equals(".pdf", StringComparison.InvariantCultureIgnoreCase)); - - IEnumerable pdfUrls = pdfFiles.Select(file => - { - string fileName = Path.GetFileName(file); - string? url = _linkGenerator.GetPathByAction("GetPdf", "sharing", new { identifier, fileName }); - string scheme = _httpContextAccessor.HttpContext.Request.Scheme; - string host = _httpContextAccessor.HttpContext.Request.Host.ToString(); - return Uri.UnescapeDataString($"{scheme}://{host}{url}"); - }); - - // Adding PDFs to template - foreach (string pdfUrl in pdfUrls) - { - scriptTemplate = scriptTemplate.Replace("{pdfUrl}", $"\"{pdfUrl}\""); - scriptTemplate = scriptTemplate.Replace("{pdfDivId}", $"{Guid.NewGuid()}"); - scriptTemplate = scriptTemplate.Replace("{pdfName}", $"\"{Path.GetFileName(pdfUrl)}\""); - scriptTemplate = scriptTemplate.Replace("{token}", $"\"{_configuration.GetSection("AdobeAPIToken").Value}\""); - MatchCollection matches = regex.Matches(htmlTemplate); - foreach (Match match in matches) + foreach (string pdfFilePath in pdfFiles) + { + string fileName = Path.GetFileName(pdfFilePath); + string pdfUrl = BuildFileUrl("GetPdf", identifier, fileName); + if (string.IsNullOrWhiteSpace(pdfUrl)) { - // check if the match value contains the file name without the first 9 random characters - string sanitizedMatch = Regex.Replace(WebUtility.HtmlDecode(match.Value), @"[^a-zA-Z0-9().\- ]", "_"); - if (!sanitizedMatch.Contains(Path.GetFileName(pdfUrl)[9..])) continue; - htmlTemplate = ReplaceFirstOccurrence(htmlTemplate, match.Value, scriptTemplate); - break; + continue; } - - scriptTemplate = File.ReadAllText(scriptTemplatePath); - } - // Getting docs - IEnumerable docFiles = Directory.GetFiles(Path.Combine(NotesFolderPath, identifier)) - .Where(file => Path.GetExtension(file).Equals(".doc", StringComparison.InvariantCultureIgnoreCase) || - Path.GetExtension(file).Equals(".docx", StringComparison.InvariantCultureIgnoreCase)); + string workingScriptTemplate = scriptTemplate + .Replace("{pdfUrl}", $"\"{pdfUrl}\"") + .Replace("{pdfDivId}", $"{Guid.NewGuid():N}") + .Replace("{pdfName}", $"\"{fileName}\"") + .Replace("{token}", $"\"{_configuration.GetValue("AdobeAPIToken") ?? string.Empty}\""); - IEnumerable docUrls = docFiles.Select(file => - { - string fileName = Path.GetFileName(file); - string? url = _linkGenerator.GetPathByAction("GetDoc", "sharing", new { identifier, fileName }); - string scheme = _httpContextAccessor.HttpContext.Request.Scheme; - string host = _httpContextAccessor.HttpContext.Request.Host.ToString(); - return Uri.UnescapeDataString($"{scheme}://{host}{url}"); - }); + htmlTemplate = ReplaceAttachmentReference(htmlTemplate, regex, fileName, workingScriptTemplate); + } - // Adding docs to template - foreach (string docUrl in docUrls) + foreach (string docFilePath in docFiles) { - MatchCollection matches = regex.Matches(htmlTemplate); - foreach (Match match in matches) + string fileName = Path.GetFileName(docFilePath); + string docUrl = BuildFileUrl("GetDoc", identifier, fileName); + if (string.IsNullOrWhiteSpace(docUrl)) { - // check if the match value contains the file name without the first 9 random characters - string sanitizedMatch = Regex.Replace(WebUtility.HtmlDecode(match.Value), @"[^a-zA-Z0-9().\- ]", "_"); - if (!sanitizedMatch.Contains(Path.GetFileName(docUrl)[9..])) continue; - htmlTemplate = ReplaceFirstOccurrence(htmlTemplate, match.Value, $"
\n"); - break; + continue; } - } - List acceptedImageFormats = new List - { - ".jpg", - ".jpeg", - ".png" - }; - // Getting images - IEnumerable imageFiles = Directory.GetFiles(Path.Combine(NotesFolderPath, identifier)) - .Where(file => acceptedImageFormats.Contains(Path.GetExtension(file).ToLowerInvariant())); - IEnumerable imagePaths = imageFiles.Select(file => - { - string fileName = Path.GetFileName(file); - string? url = _linkGenerator.GetPathByAction("GetImage", "sharing", new { identifier, fileName }); - string scheme = _httpContextAccessor.HttpContext.Request.Scheme; - string host = _httpContextAccessor.HttpContext.Request.Host.ToString(); - return $"{scheme}://{host}{url}"; - }); + string replacement = $"
\n"; + htmlTemplate = ReplaceAttachmentReference(htmlTemplate, regex, fileName, replacement); + } - // Adding images to template - foreach (string imagePath in imagePaths) + foreach (string imageFilePath in imageFiles) { - MatchCollection matches = regex.Matches(htmlTemplate); - foreach (Match match in matches) + string fileName = Path.GetFileName(imageFilePath); + string imageUrl = BuildFileUrl("GetImage", identifier, fileName); + if (string.IsNullOrWhiteSpace(imageUrl)) { - // check if the match value contains the file name without the first 9 random characters - string sanitizedMatch = Regex.Replace(WebUtility.HtmlDecode(match.Value), @"[^a-zA-Z0-9().\- ]", "_"); - if (!sanitizedMatch.Contains(Path.GetFileName(Uri.UnescapeDataString(imagePath))[9..])) continue; - htmlTemplate = ReplaceFirstOccurrence(htmlTemplate, match.Value, $"
\n"); - break; + continue; } - } - List acceptedVideoFormats = new List - { - ".mp4" - }; - // Getting videos - IEnumerable videoFiles = Directory.GetFiles(Path.Combine(NotesFolderPath, identifier)) - .Where(file => acceptedVideoFormats.Contains(Path.GetExtension(file).ToLowerInvariant())); - - IEnumerable videoPaths = videoFiles.Select(file => - { - string fileName = Path.GetFileName(file); - string? url = _linkGenerator.GetPathByAction("GetVideo", "sharing", new { identifier, fileName }); - string scheme = _httpContextAccessor.HttpContext.Request.Scheme; - string host = _httpContextAccessor.HttpContext.Request.Host.ToString(); - return $"{scheme}://{host}{url}"; - }); + string replacement = $"
\n"; + htmlTemplate = ReplaceAttachmentReference(htmlTemplate, regex, fileName, replacement); + } - // Adding videos to template - foreach (string videoPath in videoPaths) + foreach (string videoFilePath in videoFiles) { - MatchCollection matches = regex.Matches(htmlTemplate); - foreach (Match match in matches) + string fileName = Path.GetFileName(videoFilePath); + string videoUrl = BuildFileUrl("GetVideo", identifier, fileName); + if (string.IsNullOrWhiteSpace(videoUrl)) { - // check if the match value contains the file name without the first 9 random characters - string sanitizedMatch = Regex.Replace(WebUtility.HtmlDecode(match.Value), @"[^a-zA-Z0-9().\- ]", "_"); - if (!sanitizedMatch.Contains(Path.GetFileName(Uri.UnescapeDataString(videoPath))[9..])) continue; - htmlTemplate = ReplaceFirstOccurrence(htmlTemplate, match.Value, $"\n"); - break; + continue; } + + string replacement = $"\n"; + htmlTemplate = ReplaceAttachmentReference(htmlTemplate, regex, fileName, replacement); } - return Task.FromResult(htmlTemplate); + + return htmlTemplate; } public async Task UpdateMarkdownWithFiles(string markdown, List files, Guid identifier) { - if (files.Any(file => file.Length > MaxFileSize)) + if (!IsUploadRequestValid(markdown, files, out string validationMessage)) { - _logger.LogInformation("File exceeded max size, discarding note."); + _logger.LogWarning("Update request rejected for {Identifier}. Reason: {Reason}", identifier, validationMessage); return false; } - - // Create a directory with the GUID as its name - string directoryPath = Path.Combine(NotesFolderPath, identifier.ToString()); + + string directoryPath = GetNoteDirectoryPath(identifier); if (!Directory.Exists(directoryPath)) { return false; } - Directory.Delete(directoryPath, true); - Directory.CreateDirectory(directoryPath); - await SaveFiles(markdown, files, directoryPath); - return true; + try + { + Directory.Delete(directoryPath, recursive: true); + Directory.CreateDirectory(directoryPath); + await SaveFiles(markdown, files, directoryPath).ConfigureAwait(false); + return true; + } + catch (Exception exception) + { + _logger.LogError(exception, "Error while updating markdown/files for {Identifier}.", identifier); + return false; + } } - public Task DeleteMarkdownWithFiles(string identifier) + public Task DeleteMarkdownWithFiles(Guid identifier) { - string directoryPath = Path.Combine(NotesFolderPath, identifier); - + string directoryPath = GetNoteDirectoryPath(identifier); if (!Directory.Exists(directoryPath)) { return Task.FromResult(false); } - Directory.Move(directoryPath, directoryPath + "-deleted"); - return Task.FromResult(true); + string deletedDirectoryPath = $"{directoryPath}-deleted-{DateTime.UtcNow:yyyyMMddHHmmssfff}"; + + try + { + Directory.Move(directoryPath, deletedDirectoryPath); + return Task.FromResult(true); + } + catch (Exception exception) + { + _logger.LogError(exception, "Error while deleting note directory for {Identifier}.", identifier); + return Task.FromResult(false); + } } - public Task GetPdf(string identifier, string fileName) + public Task GetPdf(Guid identifier, string fileName) { - string filePath = Path.Combine(NotesFolderPath, identifier, fileName); - - return !File.Exists(filePath) ? Task.FromResult(null) : Task.FromResult(new FileStream(filePath, FileMode.Open, FileAccess.Read)); + return GetFileStream(identifier, fileName, AcceptedPdfFormats); } - public Task GetDoc(string identifier, string fileName) + public Task GetDoc(Guid identifier, string fileName) { - string filePath = Path.Combine(NotesFolderPath, identifier, fileName); - - return !File.Exists(filePath) ? Task.FromResult(null) : Task.FromResult(new FileStream(filePath, FileMode.Open, FileAccess.Read)); + return GetFileStream(identifier, fileName, AcceptedDocFormats); } - public Task GetImage(string identifier, string fileName) + public Task GetImage(Guid identifier, string fileName) { - string filePath = Path.Combine(NotesFolderPath, identifier, fileName); - - return !File.Exists(filePath) ? Task.FromResult(null) : Task.FromResult(new FileStream(filePath, FileMode.Open, FileAccess.Read)); + return GetFileStream(identifier, fileName, AcceptedImageFormats); } - - public Task GetVideo(string identifier, string fileName) - { - string filePath = Path.Combine(NotesFolderPath, identifier, fileName); - return !File.Exists(filePath) ? Task.FromResult(null) : Task.FromResult(new FileStream(filePath, FileMode.Open, FileAccess.Read)); + public Task GetVideo(Guid identifier, string fileName) + { + return GetFileStream(identifier, fileName, AcceptedVideoFormats); } public Task GetVersion() { var versionInfo = new { - Version = BuildVersion, - CompilationDate = File.GetLastAccessTime(GetType().Assembly.Location) + Version = _buildVersion, + CompilationDate = File.GetLastWriteTime(GetType().Assembly.Location) }; + return Task.FromResult((object)versionInfo); } - private async Task SaveFiles(string markdown, List files, string directoryPath) + private string GetRequiredPathSetting(string settingName) { - // Save the markdown content to a file within this directory - string markdownFilePath = Path.Combine(directoryPath, "content.md"); - await File.WriteAllTextAsync(markdownFilePath, markdown); + string? configuredPath = _configuration.GetValue(settingName); + if (string.IsNullOrWhiteSpace(configuredPath)) + { + throw new InvalidOperationException($"Configuration value '{settingName}' is required."); + } + + return Path.GetFullPath(configuredPath); + } + + private bool IsUploadRequestValid(string markdown, List files, out string validationMessage) + { + if (markdown is null) + { + validationMessage = "Markdown payload is required."; + return false; + } - _logger.LogInformation($"Markdown saved to: {markdownFilePath}"); + if (markdown.Length > _maxMarkdownLength) + { + validationMessage = $"Markdown exceeds max length of {_maxMarkdownLength} characters."; + return false; + } + + if (files.Count > _maxFilesPerUpload) + { + validationMessage = $"Request exceeds max file count of {_maxFilesPerUpload}."; + return false; + } long totalBytes = 0; foreach (IFormFile file in files) { - // Log the file name - _logger.LogInformation($"Received file: {file.FileName}"); + string fileName = Path.GetFileName(file.FileName ?? string.Empty); + string extension = Path.GetExtension(fileName); + + if (string.IsNullOrWhiteSpace(fileName)) + { + validationMessage = "File name cannot be empty."; + return false; + } - string filename = Regex.Replace(file.FileName, @"[^a-zA-Z0-9().\- ]", "_"); - //string filename = file.FileName; + if (!AcceptedUploadFormats.Contains(extension)) + { + validationMessage = $"File extension '{extension}' is not allowed."; + return false; + } - // Save each file in the directory - Random random = new(); - string uniqueFileName = $"{random.Next():x}-{filename}"; - string filePath = Path.Combine(directoryPath, uniqueFileName); + if (file.Length > _maxFileSize) + { + validationMessage = $"File '{fileName}' exceeds max size of {_maxFileSize} bytes."; + return false; + } - await using (FileStream stream = new(filePath, FileMode.Create)) + totalBytes += file.Length; + if (totalBytes > _maxTotalUploadBytes) { - await file.CopyToAsync(stream).ConfigureAwait(false); - + validationMessage = $"Total upload payload exceeds {_maxTotalUploadBytes} bytes."; + return false; } - string fileContent = await File.ReadAllTextAsync(filePath); - if (IsBase64String(fileContent)) + } + + validationMessage = string.Empty; + return true; + } + + private async Task SaveFiles(string markdown, List files, string directoryPath) + { + string markdownFilePath = Path.Combine(directoryPath, "content.md"); + await File.WriteAllTextAsync(markdownFilePath, markdown).ConfigureAwait(false); + _logger.LogInformation("Markdown saved to {MarkdownFilePath}", markdownFilePath); + + long totalBytes = 0; + foreach (IFormFile file in files) + { + string originalFileName = Path.GetFileName(file.FileName ?? string.Empty); + string sanitizedFileName = Regex.Replace(originalFileName, @"[^a-zA-Z0-9().\- ]", "_"); + + if (string.IsNullOrWhiteSpace(sanitizedFileName)) { - byte[] binary = Convert.FromBase64String(fileContent); - await File.WriteAllBytesAsync(filePath, binary); + sanitizedFileName = "attachment.bin"; } + string uniqueFileName = $"{Guid.NewGuid():N}-{sanitizedFileName}"; + string filePath = Path.Combine(directoryPath, uniqueFileName); + + await using FileStream stream = new(filePath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + await file.CopyToAsync(stream).ConfigureAwait(false); + totalBytes += file.Length; + _logger.LogInformation("Saved uploaded file {OriginalFileName} as {StoredFileName}", originalFileName, uniqueFileName); + } + + _logger.LogInformation("Total bytes received: {TotalBytes}", totalBytes); + } + + private Task GetFileStream(Guid identifier, string fileName, ISet allowedExtensions) + { + if (!TryGetSafeFilePath(identifier, fileName, allowedExtensions, out string? filePath)) + { + return Task.FromResult(null); + } + + if (!File.Exists(filePath)) + { + return Task.FromResult(null); + } + + FileStream stream = new(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + return Task.FromResult(stream); + } + + private bool TryGetSafeFilePath(Guid identifier, string fileName, ISet allowedExtensions, out string? filePath) + { + filePath = null; + + if (string.IsNullOrWhiteSpace(fileName)) + { + return false; + } + + string normalizedFileName = Path.GetFileName(fileName.Trim()); + if (!string.Equals(normalizedFileName, fileName, StringComparison.Ordinal)) + { + return false; + } + + if (normalizedFileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + return false; + } + + string extension = Path.GetExtension(normalizedFileName); + if (!allowedExtensions.Contains(extension)) + { + return false; + } + + string noteDirectoryPath = GetNoteDirectoryPath(identifier); + string rootPath = EnsureTrailingSeparator(Path.GetFullPath(noteDirectoryPath)); + string candidatePath = Path.GetFullPath(Path.Combine(noteDirectoryPath, normalizedFileName)); + + if (!candidatePath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + filePath = candidatePath; + return true; + } + + private string BuildFileUrl(string actionName, Guid identifier, string fileName) + { + string? relativeUrl = _linkGenerator.GetPathByAction(actionName, "sharing", new { identifier, fileName }); + if (string.IsNullOrWhiteSpace(relativeUrl)) + { + return string.Empty; + } + + HttpContext? httpContext = _httpContextAccessor.HttpContext; + if (httpContext is null || string.IsNullOrWhiteSpace(httpContext.Request.Host.Value)) + { + return Uri.UnescapeDataString(relativeUrl); + } + + return Uri.UnescapeDataString($"{httpContext.Request.Scheme}://{httpContext.Request.Host}{relativeUrl}"); + } + + private static string ExtractSafeTitle(string markdownContent, Regex attachmentRegex) + { + string[] markdownLines = markdownContent.Split(["\r\n", "\r", "\n"], StringSplitOptions.None); + string noteTitle = markdownLines.Length > 0 ? markdownLines[0].Trim() : string.Empty; + + if (string.IsNullOrWhiteSpace(noteTitle) || attachmentRegex.IsMatch(noteTitle)) + { + noteTitle = "Untitled"; + } + + return WebUtility.HtmlEncode(noteTitle); + } + + private static string ReplaceAttachmentReference(string htmlTemplate, Regex regex, string storedFileName, string replacement) + { + string attachmentName = StripStoredPrefix(storedFileName); + MatchCollection matches = regex.Matches(htmlTemplate); + foreach (Match match in matches) + { + string sanitizedMatch = Regex.Replace(WebUtility.HtmlDecode(match.Value), @"[^a-zA-Z0-9().\- ]", "_"); + if (!sanitizedMatch.Contains(attachmentName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + return ReplaceFirstOccurrence(htmlTemplate, match.Value, replacement); } - _logger.LogInformation($"Total bytes received: {totalBytes}"); + return htmlTemplate; } - - private static bool IsBase64String(string base64) + + private static string StripStoredPrefix(string fileName) { - Span buffer = new(new byte[base64.Length]); - return Convert.TryFromBase64String(base64, buffer , out int _); + int separatorIndex = fileName.IndexOf('-'); + return separatorIndex >= 0 && separatorIndex + 1 < fileName.Length + ? fileName[(separatorIndex + 1)..] + : fileName; } - + private static string ReplaceFirstOccurrence(string source, string oldValue, string newValue) { - int pos = source.IndexOf(oldValue); - if (pos < 0) + int position = source.IndexOf(oldValue, StringComparison.Ordinal); + return position < 0 + ? source + : source[..position] + newValue + source[(position + oldValue.Length)..]; + } + + private string GetNoteDirectoryPath(Guid identifier) + { + return Path.Combine(_notesFolderPath, identifier.ToString("D")); + } + + private static string EnsureTrailingSeparator(string path) + { + return path.EndsWith(Path.DirectorySeparatorChar) ? path : path + Path.DirectorySeparatorChar; + } + + private void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch (Exception exception) { - return source; + _logger.LogWarning(exception, "Cleanup failed for path {Path}", path); } - return source[..pos] + newValue + source[(pos + oldValue.Length)..]; } [GeneratedRegex(@"!\[\[.*?\]\]")] private static partial Regex FileAttachmentRegex(); -} \ No newline at end of file +} diff --git a/appsettings.json b/appsettings.json index 6ccc043..baae514 100644 --- a/appsettings.json +++ b/appsettings.json @@ -10,6 +10,14 @@ "TemplatePath": "template.html", "TemplateScriptPath": "templateScript.html", "MaxFileSizeInBytes": 104857600, + "MaxFilesPerUpload": 20, + "MaxTotalUploadBytes": 104857600, + "MaxMarkdownLength": 1000000, + "MaxRequestBodySizeInBytes": 104857600, + "RateLimiting": { + "ReadRequestsPerMinute": 120, + "WriteRequestsPerMinute": 30 + }, "AllowedHosts": "*", "SeqServer": "" } diff --git a/install.iss b/install.iss index 37d7f87..87ca0e7 100644 --- a/install.iss +++ b/install.iss @@ -5,7 +5,7 @@ AppId={{195D9982-0962-4B7A-B435-AE01735013B2} AppName=InfostackerService -AppVersion=1.4.1 +AppVersion=1.5 AppVerName=InfostackerService {#Date} AppPublisher=Taskscape Ltd CreateAppDir=yes