Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ bin
.idea/
Logs/
Infostacker.csproj.user
*.user
47 changes: 23 additions & 24 deletions Controllers/SharingController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ public SharingController(ISharingService sharingService, ILogger<SharingControll
}

[HttpPost("uploadmarkdownwithfiles")]
[RateLimit(100, 86400)]
public async Task<IActionResult> UploadMarkdownWithFiles([FromForm] string markdown, [FromForm] List<IFormFile> files)
{
Guid identifier = await _sharingService.UploadMarkdownWithFiles(markdown, files);
Expand All @@ -30,13 +29,13 @@ public async Task<IActionResult> UploadMarkdownWithFiles([FromForm] string markd
return Ok(new { Message = "Markdown and files uploaded successfully.", id = identifier });
}

[HttpGet("{identifier}")]
public async Task<IActionResult> GetMarkdownContent(string identifier)
[HttpGet("{identifier:guid}")]
public async Task<IActionResult> 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 {
Expand All @@ -46,7 +45,7 @@ public async Task<IActionResult> GetMarkdownContent(string identifier)
};
}

[HttpPut("{identifier}")]
[HttpPut("{identifier:guid}")]
public async Task<IActionResult> UpdateMarkdownWithFiles([FromForm] string markdown, [FromForm] List<IFormFile> files, Guid identifier)
{
if (!await _sharingService.UpdateMarkdownWithFiles(markdown, files, identifier))
Expand All @@ -57,58 +56,58 @@ public async Task<IActionResult> UpdateMarkdownWithFiles([FromForm] string markd
return Ok(new { Message = "Markdown and files updated successfully.", id = identifier });
}

[HttpDelete("{identifier}")]
public async Task<IActionResult> DeleteMarkdownWithFiles(string identifier)
[HttpDelete("{identifier:guid}")]
public async Task<IActionResult> 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<IActionResult> GetPdf(string identifier, string fileName)
[HttpGet("pdf/{identifier:guid}/{fileName}")]
public async Task<IActionResult> 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<IActionResult> GetDoc(string identifier, string fileName)
[HttpGet("doc/{identifier:guid}/{fileName}")]
public async Task<IActionResult> 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<IActionResult> GetImage(string identifier, string fileName)
[HttpGet("image/{identifier:guid}/{fileName}")]
public async Task<IActionResult> 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<IActionResult> GetVideo(string identifier, string fileName)
[HttpGet("video/{identifier:guid}/{fileName}")]
public async Task<IActionResult> 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");
}
Expand Down
14 changes: 7 additions & 7 deletions Infostacker.csproj
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Markdig" Version="0.40.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Markdig" Version="1.0.0" />
<PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.4" />
</ItemGroup>

<ItemGroup>
Expand Down
129 changes: 97 additions & 32 deletions Program.cs
Original file line number Diff line number Diff line change
@@ -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<string>("SeqServer")) ? "https://localhost:5341" : builder.Configuration.GetValue<string>("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<string>("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<IHttpContextAccessor, HttpContextAccessor>();
builder.Services.AddTransient<ISharingService, SharingService>();

long maxRequestBodySize = builder.Configuration.GetValue<long?>("MaxRequestBodySizeInBytes") is > 0
? builder.Configuration.GetValue<long>("MaxRequestBodySizeInBytes")
: 104857600L;

builder.Services.Configure<FormOptions>(options =>
{
options.AddPolicy(name: MyAllowSpecificOrigins,
policy =>
{
policy.WithOrigins("app://obsidian.md")
.AllowAnyHeader()
.AllowAnyMethod();
});
options.MultipartBodyLengthLimit = maxRequestBodySize;
});

builder.Services.Configure<FormOptions>(options =>
builder.WebHost.ConfigureKestrel(options =>
{
options.MultipartBodyLengthLimit = 104857600; // 100MB
options.Limits.MaxRequestBodySize = maxRequestBodySize;
});

builder.Services.AddTransient<ISharingService, SharingService>();
builder.Services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
builder.Logging.ClearProviders();
builder.Logging.AddSerilog(logger);
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
});

int readRequestsPerMinute = builder.Configuration.GetValue<int?>("RateLimiting:ReadRequestsPerMinute") is > 0
? builder.Configuration.GetValue<int>("RateLimiting:ReadRequestsPerMinute")
: 120;
int writeRequestsPerMinute = builder.Configuration.GetValue<int?>("RateLimiting:WriteRequestsPerMinute") is > 0
? builder.Configuration.GetValue<int>("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>(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();
4 changes: 2 additions & 2 deletions Properties/PublishProfiles/FolderProfile.pubxml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<PublishProvider>FileSystem</PublishProvider>
<PublishUrl>bin\Release\net8.0\publish\</PublishUrl>
<PublishUrl>bin\Release\net10.0\publish\</PublishUrl>
<WebPublishMethod>FileSystem</WebPublishMethod>
<_TargetId>Folder</_TargetId>
<SiteUrlToLaunchAfterPublish />
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ProjectGuid>07d340ec-bbee-4030-a832-d5fc81d4b680</ProjectGuid>
<SelfContained>false</SelfContained>
</PropertyGroup>
Expand Down
11 changes: 0 additions & 11 deletions Properties/PublishProfiles/FolderProfile.pubxml.user

This file was deleted.

20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -75,7 +83,7 @@ To enable CORS (Cross-Origin Resource Sharing) on an IIS server, you can add COR
- '*' `<allowHeaders>` specifies the allowed request headers.
- '*' `<exposeHeaders>` specifies the headers that the server exposes to the client.

<sub><sup>'*' - optional tags</sup></sub>
<sub>'*' - optional tags</sub>

4. **Save Changes**: After adding the CORS configuration to the `web.config` file, save the changes.

Expand All @@ -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.
API server should now be correctly set up.
14 changes: 7 additions & 7 deletions Services/ISharingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ namespace Infostacker.Services;
public interface ISharingService
{
Task<Guid> UploadMarkdownWithFiles(string markdown, List<IFormFile> files);
Task<string> GetMarkdownContent(string identifier);
Task<string?> GetMarkdownContent(Guid identifier);
Task<bool> UpdateMarkdownWithFiles(string markdown, List<IFormFile> files, Guid identifier);
Task<bool> DeleteMarkdownWithFiles(string identifier);
Task<FileStream> GetPdf(string identifier, string fileName);
Task<FileStream> GetDoc(string identifier, string fileName);
Task<FileStream> GetImage(string identifier, string fileName);
Task<FileStream> GetVideo(string identifier, string fileName);
Task<bool> DeleteMarkdownWithFiles(Guid identifier);
Task<FileStream?> GetPdf(Guid identifier, string fileName);
Task<FileStream?> GetDoc(Guid identifier, string fileName);
Task<FileStream?> GetImage(Guid identifier, string fileName);
Task<FileStream?> GetVideo(Guid identifier, string fileName);
Task<object> GetVersion();
}
}
Loading