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
85 changes: 82 additions & 3 deletions LinkRouter/App/Configuration/Config.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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",
Expand All @@ -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<CompiledRoute>();

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);

}
66 changes: 49 additions & 17 deletions LinkRouter/App/Http/Controllers/RedirectController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ public class RedirectController : Controller
{

private readonly Config Config;


private readonly Counter RouteCounter = Metrics.CreateCounter(
"linkrouter_requests",
Expand All @@ -37,38 +36,71 @@ public RedirectController(Config config)
}

[HttpGet("/{*path}")]
public IActionResult RedirectToExternalUrl(string path)
public async Task<ActionResult> 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);
}
}
10 changes: 10 additions & 0 deletions LinkRouter/App/Models/CompiledRoute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Text.RegularExpressions;

namespace LinkRouter.App.Models;

public class CompiledRoute : RedirectRoute
{
public Regex CompiledPattern { get; set; }

public Dictionary<string, int> Placeholders { get; set; } = new();
}
2 changes: 1 addition & 1 deletion LinkRouter/App/Models/RedirectRoute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
public class RedirectRoute
{
public string Route { get; set; }

public string RedirectUrl { get; set; }
}
28 changes: 20 additions & 8 deletions LinkRouter/App/Services/ConfigWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Config>(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)
{
Expand Down
34 changes: 16 additions & 18 deletions LinkRouter/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,46 +12,44 @@ 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<ConfigWatcher>();

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<Config>(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();
app.MapControllers();

app.Run();
}
}
}
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 :) )
}
]
}
Expand Down