diff --git a/LinkRouter/App/Configuration/Config.cs b/LinkRouter/App/Configuration/Config.cs index 4164f50..5166264 100644 --- a/LinkRouter/App/Configuration/Config.cs +++ b/LinkRouter/App/Configuration/Config.cs @@ -1,4 +1,7 @@ -using LinkRouter.App.Models; +using System.Text; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using LinkRouter.App.Models; namespace LinkRouter.App.Configuration; @@ -8,7 +11,8 @@ public class Config public NotFoundBehaviorConfig NotFoundBehavior { get; set; } = new(); - public RedirectRoute[] Routes { get; set; } = [ + public RedirectRoute[] Routes { get; set; } = + [ new RedirectRoute() { Route = "/instagram", @@ -20,10 +24,85 @@ public class Config RedirectUrl = "https://example.com" }, ]; - + public class NotFoundBehaviorConfig { public bool RedirectOn404 { get; set; } = false; public string RedirectUrl { get; set; } = "https://example.com/404"; } + + [JsonIgnore] public CompiledRoute[]? CompiledRoutes { get; set; } + + public void CompileRoutes() + { + var compiledRoutes = new List(); + + foreach (var route in Routes) + { + if (!route.Route.StartsWith("/")) + route.Route = "/" + route.Route; + + if (!route.Route.EndsWith("/")) + route.Route += "/"; + + var compiled = new CompiledRoute + { + Route = route.Route, + RedirectUrl = route.RedirectUrl + }; + + var replacements = new List<(int Index, int Length, string NewText)>(); + + var escaped = Regex.Escape(route.Route); + + var pattern = new Regex(@"\\\{(\d|\w+)\}", RegexOptions.CultureInvariant); + + var matches = pattern.Matches(escaped); + + foreach (var match in matches.Select(x => x)) + { + // Check if the placeholder is immediately followed by another placeholder + if (escaped.Length >= match.Index + match.Length + 2 + && escaped.Substring(match.Index + match.Length, 2) == "\\{") + throw new InvalidOperationException( + $"Placeholder {match.Groups[1].Value} cannot be immediately followed by another placeholder. " + + $"Please add any separator."); + + replacements.Add((match.Index, match.Length, "(.+)")); + } + + var compiledRouteBuilder = new StringBuilder(escaped); + + foreach (var replacement in replacements.OrderByDescending(r => r.Index)) + { + compiledRouteBuilder.Remove(replacement.Index, replacement.Length); + compiledRouteBuilder.Insert(replacement.Index, replacement.NewText); + } + + compiled.CompiledPattern = new Regex(compiledRouteBuilder.ToString(), + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + var duplicate = matches + .Select((m, i) => m.Groups[1].Value) + .GroupBy(x => x) + .FirstOrDefault(x => x.Count() > 1); + + if (duplicate != null) + throw new InvalidOperationException("Cannot use a placeholder twice in the route: " + duplicate.Key); + + compiled.Placeholders = matches + .Select((m, i) => m.Groups[1].Value) + .Distinct() + .Select((name, i) => (name, i)) + .ToDictionary(x => x.name, x => x.i + 1); + + compiledRoutes.Add(compiled); + } + + CompiledRoutes = compiledRoutes + .ToArray(); + } + + [JsonIgnore] public static Regex ErrorCodePattern = new(@"\s*\-\>\s*(\d+)\s*$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + } \ No newline at end of file diff --git a/LinkRouter/App/Http/Controllers/RedirectController.cs b/LinkRouter/App/Http/Controllers/RedirectController.cs index 63dbe6b..d4a1750 100644 --- a/LinkRouter/App/Http/Controllers/RedirectController.cs +++ b/LinkRouter/App/Http/Controllers/RedirectController.cs @@ -10,7 +10,6 @@ public class RedirectController : Controller { private readonly Config Config; - private readonly Counter RouteCounter = Metrics.CreateCounter( "linkrouter_requests", @@ -37,38 +36,71 @@ public RedirectController(Config config) } [HttpGet("/{*path}")] - public IActionResult RedirectToExternalUrl(string path) + public async Task RedirectToExternalUrl(string path) { - var redirectRoute = Config.Routes.FirstOrDefault(x => x.Route == path || x.Route == path + "/" || x.Route == "/" + path); - - if (redirectRoute != null) + if (!path.EndsWith("/")) + path += "/"; + + path = "/" + path; + + Console.WriteLine(path); + + var redirectRoute = Config.CompiledRoutes?.FirstOrDefault(x => x.CompiledPattern.IsMatch(path)); + + if (redirectRoute == null) { - RouteCounter - .WithLabels(redirectRoute.Route) + NotFoundCounter + .WithLabels(path) .Inc(); - return Redirect(redirectRoute.RedirectUrl); - } + if (Config.NotFoundBehavior.RedirectOn404) + if (Config.ErrorCodePattern.IsMatch(Config.NotFoundBehavior.RedirectUrl)) + { + var errorCodeMatch = Config.ErrorCodePattern.Match(Config.NotFoundBehavior.RedirectUrl); + var errorCode = int.Parse(errorCodeMatch.Groups[1].Value); + return StatusCode(errorCode); + } else + return Redirect(Config.NotFoundBehavior.RedirectUrl); - NotFoundCounter - .WithLabels("/" + path) - .Inc(); + return NotFound(); + } - if (Config.NotFoundBehavior.RedirectOn404) - return Redirect(Config.NotFoundBehavior.RedirectUrl); + var match = redirectRoute.CompiledPattern.Match(path); - return NotFound(); + string redirectUrl = redirectRoute.RedirectUrl; + + if (Config.ErrorCodePattern.IsMatch(redirectUrl)) + { + var errorCodeMatch = Config.ErrorCodePattern.Match(redirectUrl); + var errorCode = int.Parse(errorCodeMatch.Groups[1].Value); + return StatusCode(errorCode); + } + + foreach (var placeholder in redirectRoute.Placeholders) + { + var value = match.Groups[placeholder.Value].Value; + redirectUrl = redirectUrl.Replace("{" + placeholder.Key + "}", value); + } + + return Redirect(redirectUrl); } - + [HttpGet("/")] public IActionResult GetRootRoute() { RouteCounter .WithLabels("/") .Inc(); - + string url = Config.RootRoute; + if (Config.ErrorCodePattern.IsMatch(url)) + { + var errorCodeMatch = Config.ErrorCodePattern.Match(url); + var errorCode = int.Parse(errorCodeMatch.Groups[1].Value); + return StatusCode(errorCode); + } + return Redirect(url); } } \ No newline at end of file diff --git a/LinkRouter/App/Models/CompiledRoute.cs b/LinkRouter/App/Models/CompiledRoute.cs new file mode 100644 index 0000000..1d5d81a --- /dev/null +++ b/LinkRouter/App/Models/CompiledRoute.cs @@ -0,0 +1,10 @@ +using System.Text.RegularExpressions; + +namespace LinkRouter.App.Models; + +public class CompiledRoute : RedirectRoute +{ + public Regex CompiledPattern { get; set; } + + public Dictionary Placeholders { get; set; } = new(); +} \ No newline at end of file diff --git a/LinkRouter/App/Models/RedirectRoute.cs b/LinkRouter/App/Models/RedirectRoute.cs index daefe87..5cf2d7a 100644 --- a/LinkRouter/App/Models/RedirectRoute.cs +++ b/LinkRouter/App/Models/RedirectRoute.cs @@ -3,6 +3,6 @@ public class RedirectRoute { public string Route { get; set; } - + public string RedirectUrl { get; set; } } \ No newline at end of file diff --git a/LinkRouter/App/Services/ConfigWatcher.cs b/LinkRouter/App/Services/ConfigWatcher.cs index 18484c8..a78570d 100644 --- a/LinkRouter/App/Services/ConfigWatcher.cs +++ b/LinkRouter/App/Services/ConfigWatcher.cs @@ -22,32 +22,44 @@ protected override Task ExecuteAsync(CancellationToken stoppingToken) { Logger.LogWarning("Watched file does not exist: {FilePath}", ConfigPath); } - + Watcher = new FileSystemWatcher(Path.GetDirectoryName(ConfigPath) ?? throw new InvalidOperationException()) { Filter = Path.GetFileName(ConfigPath), NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.CreationTime }; - + + OnChanged(Watcher, new FileSystemEventArgs(WatcherChangeTypes.Created, string.Empty, string.Empty)); + Watcher.Changed += OnChanged; - + Watcher.EnableRaisingEvents = true; - + return Task.CompletedTask; } - + private void OnChanged(object sender, FileSystemEventArgs e) { try { var content = File.ReadAllText(ConfigPath); - + var config = JsonSerializer.Deserialize(content); - + Config.Routes = config?.Routes ?? []; Config.RootRoute = config?.RootRoute ?? "https://example.com"; - + Logger.LogInformation("Config file changed."); + + try + { + Config.CompileRoutes(); + } + catch (InvalidOperationException ex) + { + Logger.LogError("Failed to compile routes: " + ex.Message); + Environment.Exit(1); + } } catch (IOException ex) { diff --git a/LinkRouter/Program.cs b/LinkRouter/Program.cs index 3ba680d..4c510be 100644 --- a/LinkRouter/Program.cs +++ b/LinkRouter/Program.cs @@ -12,41 +12,39 @@ public abstract class Program public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); - + Directory.CreateDirectory(PathBuilder.Dir("data")); - + builder.Services.AddControllers(); - + var loggerProviders = LoggerBuildHelper.BuildFromConfiguration(configuration => { configuration.Console.Enable = true; configuration.Console.EnableAnsiMode = true; }); - + builder.Logging.ClearProviders(); builder.Logging.AddProviders(loggerProviders); builder.Services.AddHostedService(); - + var configPath = Path.Combine("data", "config.json"); - + if (!File.Exists(configPath)) File.WriteAllText( - configPath, - JsonSerializer.Serialize(new Config(), new JsonSerializerOptions {WriteIndented = true} + configPath, + JsonSerializer.Serialize(new Config(), new JsonSerializerOptions { WriteIndented = true } )); - + Config config = JsonSerializer.Deserialize(File.ReadAllText(configPath)) ?? new Config(); - File.WriteAllText(configPath, JsonSerializer.Serialize(config, new JsonSerializerOptions {WriteIndented = true})); - + File.WriteAllText(configPath, + JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true })); + builder.Services.AddSingleton(config); - - builder.Services.AddMetricServer(options => - { - options.Port = 5000; - }); - + + builder.Services.AddMetricServer(options => { options.Port = 5000; }); + var app = builder.Build(); app.UseMetricServer(); @@ -54,4 +52,4 @@ public static void Main(string[] args) app.Run(); } -} +} \ No newline at end of file diff --git a/README.md b/README.md index f362f18..565b33b 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,13 @@ [⬇️How to install⬇️](#installation) ## Features - - **Path-based Redirection:** Reads a config file that maps paths to redirect URLs. When a request hits a registered path, the router issues an HTTP redirect to the corresponding target. +- **Path-based Redirection:** Reads a config file that maps paths to redirect URLs. When a request hits a registered path, the router issues an HTTP redirect to the corresponding target. - **Hot Reloading:** The config is cached at startup and automatically reloaded when the file changes — no restart required. [Example config](#example-config) - **Low Resource Usage:** Uses less than 50MB of RAM, making it ideal for constrained environments. - **Metrics Endpoint:** Exposes Prometheus-compatible metrics at `:5000/metrics` for easy observability and monitoring. [How to use](#metrics) - - **Docker-Deployable:** Comes with a minimal Dockerfile for easy containerized deployment. +- **Docker-Deployable:** Comes with a minimal Dockerfile for easy containerized deployment. +- **Placeholders:** Supports placeholders in redirect URLs, allowing dynamic URL generation based on the requested path. For example, a route defined as `/user/{username}` can redirect to `https://example.com/profile/{username}`, where `{username}` is replaced with the actual value from the request. +- **Status Code:** You are able to configure if the redirect should redirect to an url or just return a custom status code of your choice. Example `"RedirectUrl": "-> 418"` will return the status code 418 (I'm a teapot :) ) ## Configuration Routes are managed via a configuration file, `/data/config.json`. You can define paths and their corresponding URLs in this file. The application automatically normalizes routes to handle both trailing and non-trailing slashes. @@ -24,11 +26,15 @@ Routes are managed via a configuration file, `/data/config.json`. You can define "Routes": [ { "Route": "/instagram", // has to start with a slash - "RedirectUrl": "https://instagram.com/{yourname}" + "RedirectUrl": "https://instagram.com/maxmustermann" }, { - "Route": "/example", // has to start with a slash - "RedirectUrl": "https://example.com" + "Route": "/article/{id}", // {id} is a placeholder + "RedirectUrl": "https://example.com/article/{id}", // {id} will be replaced with the actual value from the request + }, + { + "Route": "/teapot", + "RedirectUrl": "-> 418" // will return a 418 status code (I'm a teapot :) ) } ] }