Skip to content
Open
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
46 changes: 46 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Build and Push Docker Image

on:
push:
branches:
- main

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/calcpadce-server

jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha
type=raw,value=latest

- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: Calcpad.Server/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
17 changes: 17 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*.user
*.userosscache
*.sln.docstates
Calcpad.Web

# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
Expand Down Expand Up @@ -93,6 +94,9 @@ StyleCopReport.xml
*.svclog
*.scc

# Files built by Visual Studio Code
*.csproj.lscache

# Chutzpah Test files
_Chutzpah*

Expand Down Expand Up @@ -275,6 +279,16 @@ FakesAssemblies/
.ntvs_analysis.dat
node_modules/

# MathJax bundle (downloaded during CI build)
docs/javascripts/mathjax/

# MkDocs build output
site/

# npm artifacts from local MathJax setup
package.json
package-lock.json

# Visual Studio 6 build log
*.plg

Expand Down Expand Up @@ -348,3 +362,6 @@ MigrationBackup/

# Ionide (cross platform F# VS Code tools) working folder
.ionide/

# Installer output
Calcpad.Wpf/Installer/Output/
51 changes: 48 additions & 3 deletions Calcpad.Server/Core/Controllers/CalcpadController.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Calcpad.Server.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;

namespace Calcpad.Server.Controllers
{
Expand All @@ -17,6 +18,7 @@ public CalcpadController(CalcpadService calcpadService, PdfGeneratorService pdfG
}

[HttpPost("convert")]
[EnableRateLimiting("convert")]
public async Task<IActionResult> ConvertToHtml([FromBody] CalcpadRequest request)
{
try
Expand All @@ -26,6 +28,9 @@ public async Task<IActionResult> ConvertToHtml([FromBody] CalcpadRequest request
return BadRequest("Content is required");
}

var validationError = ValidateSettings(request);
if (validationError != null) return BadRequest(validationError);

// Get HTML result first
var htmlResult = await _calcpadService.ConvertAsync(request.Content, request.Settings, request.ForceUnwrappedCode, request.Theme);

Expand All @@ -40,7 +45,9 @@ public async Task<IActionResult> ConvertToHtml([FromBody] CalcpadRequest request
}
catch (Exception ex)
{
return StatusCode(500, $"Error processing Calcpad content: {ex.Message}");
return StatusCode(500, CalcpadApiService.IsPublicMode
? "An internal error occurred while processing the request."
: $"Error processing Calcpad content: {ex.Message}");
}
}

Expand All @@ -54,7 +61,9 @@ private async Task<IActionResult> GeneratePdfResponse(string htmlContent, PdfSet
}
catch (Exception ex)
{
return StatusCode(500, $"PDF generation failed: {ex.Message}");
return StatusCode(500, CalcpadApiService.IsPublicMode
? "An internal error occurred during PDF generation."
: $"PDF generation failed: {ex.Message}");
}
}

Expand Down Expand Up @@ -85,6 +94,7 @@ private async Task<IActionResult> GeneratePdfResponse(string htmlContent, PdfSet
}

[HttpPost("convert-unwrapped")]
[EnableRateLimiting("convert")]
public async Task<IActionResult> ConvertToUnwrappedHtml([FromBody] CalcpadRequest request)
{
try
Expand All @@ -94,21 +104,56 @@ public async Task<IActionResult> ConvertToUnwrappedHtml([FromBody] CalcpadReques
return BadRequest("Content is required");
}

var validationError = ValidateSettings(request);
if (validationError != null) return BadRequest(validationError);

var result = await _calcpadService.ConvertAsync(request.Content, request.Settings, forceUnwrappedCode: true, request.Theme);
return Content(result, "text/html");
}
catch (Exception ex)
{
return StatusCode(500, $"Error processing Calcpad content: {ex.Message}");
return StatusCode(500, CalcpadApiService.IsPublicMode
? "An internal error occurred while processing the request."
: $"Error processing Calcpad content: {ex.Message}");
}
}

[HttpGet("sample")]
[EnableRateLimiting("general")]
public IActionResult GetSample()
{
var sampleContent = _calcpadService.GetSampleContent();
return Ok(new CalcpadRequest { Content = sampleContent });
}

private static string? ValidateSettings(CalcpadRequest request)
{
if (request.Settings?.Math != null)
{
var m = request.Settings.Math;
if (m.Decimals is < 0 or > 15) return "Decimals must be between 0 and 15.";
if (m.Degrees is not null and not (0 or 1)) return "Degrees must be 0 or 1.";
}
if (request.Settings?.Plot != null)
{
var p = request.Settings.Plot;
if (p.ScreenScaleFactor is < 0.1 or > 10) return "ScreenScaleFactor must be between 0.1 and 10.";
if (p.ImagePath?.Length > 200) return "ImagePath is too long.";
if (p.ImageUri?.Length > 500) return "ImageUri is too long.";
}
if (request.PdfSettings != null)
{
var pdf = request.PdfSettings;
if (pdf.Scale is < 0.1 or > 3.0) return "PDF scale must be between 0.1 and 3.0.";
if (pdf.DocumentTitle?.Length > 200) return "DocumentTitle is too long.";
if (pdf.DocumentSubtitle?.Length > 200) return "DocumentSubtitle is too long.";
if (pdf.Author?.Length > 100) return "Author is too long.";
if (pdf.Company?.Length > 100) return "Company is too long.";
if (pdf.Project?.Length > 100) return "Project is too long.";
}
if (request.Theme is not ("light" or "dark")) return "Theme must be 'light' or 'dark'.";
return null;
}
}

public class CalcpadRequest
Expand Down
83 changes: 77 additions & 6 deletions Calcpad.Server/Core/Services/CalcpadApiService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using Calcpad.Server.Services;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.DependencyInjection;
using System.Threading.RateLimiting;

namespace Calcpad.Server.Services
{
Expand All @@ -9,13 +11,28 @@ namespace Calcpad.Server.Services
/// </summary>
public static class CalcpadApiService
{
/// <summary>
/// When true, strict security restrictions are applied (public Docker server).
/// When false (default), the server runs in permissive local mode (Windows tray app).
/// Controlled by the CALCPAD_PUBLIC_MODE environment variable.
/// </summary>
public static bool IsPublicMode { get; } =
string.Equals(Environment.GetEnvironmentVariable("CALCPAD_PUBLIC_MODE"), "true", StringComparison.OrdinalIgnoreCase)
|| Environment.GetEnvironmentVariable("CALCPAD_PUBLIC_MODE") == "1";

/// <summary>
/// Configure the web application builder with all necessary services
/// </summary>
public static WebApplicationBuilder ConfigureBuilder(string[] args)
{
var builder = WebApplication.CreateBuilder(args);

// Limit request body size: 512 KB in public mode, 50 MB locally
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = IsPublicMode ? 512_000 : 50_000_000;
});

// Add services to the container
builder.Services.AddControllers()
.AddApplicationPart(typeof(CalcpadApiService).Assembly); // Discover controllers from Core assembly
Expand All @@ -24,14 +41,53 @@ public static WebApplicationBuilder ConfigureBuilder(string[] args)
builder.Services.AddScoped<CalcpadService>();
builder.Services.AddScoped<PdfGeneratorService>();

// Add CORS policy
// Add CORS policy — restricted in public mode, open locally
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll", policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
if (IsPublicMode)
{
var corsOrigins = Environment.GetEnvironmentVariable("CALCPAD_CORS_ORIGINS")
?? "https://calcpad-ce.org";
policy.WithOrigins(corsOrigins.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.AllowAnyMethod()
.AllowAnyHeader();
}
else
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
}
});
});

// Rate limiting — strict in public mode, effectively unlimited locally
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
var convertLimit = IsPublicMode ? 20 : int.MaxValue;
var pdfLimit = IsPublicMode ? 5 : int.MaxValue;
var generalLimit = IsPublicMode ? 100 : int.MaxValue;

options.AddFixedWindowLimiter("convert", opt =>
{
opt.PermitLimit = convertLimit;
opt.Window = TimeSpan.FromMinutes(1);
opt.QueueLimit = 0;
});
options.AddFixedWindowLimiter("pdf", opt =>
{
opt.PermitLimit = pdfLimit;
opt.Window = TimeSpan.FromMinutes(1);
opt.QueueLimit = 0;
});
options.AddFixedWindowLimiter("general", opt =>
{
opt.PermitLimit = generalLimit;
opt.Window = TimeSpan.FromMinutes(1);
opt.QueueLimit = 0;
});
});

Expand All @@ -43,17 +99,32 @@ public static WebApplicationBuilder ConfigureBuilder(string[] args)
/// </summary>
public static WebApplication ConfigureApp(WebApplication app)
{
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
// Expose Swagger only in local mode (not on the public server)
if (!IsPublicMode)
{
app.UseSwagger();
app.UseSwaggerUI();
}

app.UseHttpsRedirection();

// Security headers
app.Use(async (context, next) =>
{
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["X-Frame-Options"] = "DENY";
context.Response.Headers["Cache-Control"] = "no-store";
context.Response.Headers["Referrer-Policy"] = "no-referrer";
await next();
});

app.UseCors("AllowAll");
app.UseRateLimiter();
app.MapControllers();

var mode = IsPublicMode ? "PUBLIC" : "LOCAL";
Console.WriteLine($"Calcpad server starting in {mode} mode");

return app;
}

Expand Down
Loading