Add general proxy support (fixes #259)

Change-Id: I63a3eb1e14a35067522fa8ee9efc905059dea087
diff --git a/Changes b/Changes
index 7cc30b2..1cf6c54 100644
--- a/Changes
+++ b/Changes
@@ -19,6 +19,7 @@
         - Improve explanation of timed-out match counts (diewald)
         - Support response pipes (preliminary; diewald)
         - Support default alignment changes (diewald)
+        - Add general proxies support (diewald)
 
 0.59 2025-03-28
         - Docker only release (diewald)
diff --git a/kalamar.conf b/kalamar.conf
index f5bed8e..e3aa00b 100644
--- a/kalamar.conf
+++ b/kalamar.conf
@@ -71,6 +71,12 @@
     #   items_per_page => 20,
     #   context => '20-t,20-t',
     #   alignment => 'left' # or 'right' or 'center'
-    # }
+    # },
+
+    # proxies => [{
+    #   root_path => '/plugin/export',
+    #   mount => 'http://export-plugin:3333',
+    #   service => 'export-plugin-proxy'
+    # }]
   }
 }
diff --git a/lib/Kalamar.pm b/lib/Kalamar.pm
index 462e9ba..313055a 100644
--- a/lib/Kalamar.pm
+++ b/lib/Kalamar.pm
@@ -400,8 +400,32 @@
   $r->get('/contact')->mail_to_chiffre('documentation#contact');
 
   # API proxy route
-  $r->any('/api/v#apiv' => [apiv => ['1.0']])->name('proxy')->to('Proxy#pass');
-  $r->any('/api/v#apiv/*api_path' => [apiv => ['1.0']])->to('Proxy#pass');
+  $r->any('/api/v#apiv' => [apiv => ['1.0']])->name('proxy')->to('Proxy#api_pass');
+  $r->any('/api/v#apiv/*proxy_path' => [apiv => ['1.0']])->to('Proxy#api_pass');
+
+  # General proxy mounts
+  my $proxies = $conf->{'proxies'} // [];
+  foreach (@$proxies) {
+    next if $_ eq 'PROXY_STUB';
+
+    my $root_path = Mojo::Path->new($_->{root_path})
+      ->canonicalize->leading_slash(1)
+      ->trailing_slash(1);
+
+    my %stash_hash = (
+      service   => $_->{service},
+      root_path => $root_path,
+      mount     => $_->{mount},
+    );
+
+    $r->any($root_path)->name($_->{service})->to(
+      'Proxy#pass', %stash_hash
+    );
+
+    $r->any($root_path . '*proxy_path')->to(
+      'Proxy#pass', %stash_hash
+    );
+  };
 
   # Match route
   # Corpus route
diff --git a/lib/Kalamar/Controller/Proxy.pm b/lib/Kalamar/Controller/Proxy.pm
index 675a06a..18e6c30 100644
--- a/lib/Kalamar/Controller/Proxy.pm
+++ b/lib/Kalamar/Controller/Proxy.pm
@@ -1,12 +1,34 @@
 package Kalamar::Controller::Proxy;
 use Mojo::Base 'Mojolicious::Controller';
 
-# Pass proxy command to API
-sub pass {
+sub api_pass {
   my $c = shift;
 
   my $apiv = $c->stash('apiv');
-  my $path = $c->stash('api_path') // '';
+
+  # Get API request for proxying
+  # External URL!
+  my $base_url = Mojo::URL->new($c->korap->api($apiv));
+  $c->stash('root_path', $base_url->to_string);
+
+  $c->stash(service => 'proxy');
+
+  return $c->pass($base_url);
+};
+
+# Pass proxy command to API
+sub pass {
+  my $c = shift;
+  my $base_url = shift // Mojo::URL->new($c->stash('mount'));
+
+  my $base_path = $base_url->path->trailing_slash(1);
+
+  my $proxy_path = Mojo::Path->new(
+    $c->stash('proxy_path') ? $c->stash('proxy_path') : ''
+  )->leading_slash(0);
+
+  $base_path = $base_path->merge($proxy_path);
+  $base_url = $base_url->path($base_path);
 
   # Get the original request
   my $req = $c->req;
@@ -25,8 +47,7 @@
   # Get parameters of the request
   my $params = $req->query_params->clone;
 
-  # Get API request for proxying
-  my $url = Mojo::URL->new($c->korap->api($apiv))->path($path)->query($params);
+  my $url = $base_url->query($params);
 
   # Resend headers
   my $tx = $c->kalamar_ua->build_tx(
@@ -39,15 +60,16 @@
     before_korap_request => ($c, $tx)
   );
 
-  my $h = $c->res->headers;
-  $h->access_control_allow_origin('*');
+  my $origin = $c->req->headers->origin;
 
   # Retrieve CORS header
   if ($c->req->method eq 'OPTIONS') {
 
+    my $h = $c->res->headers;
     # Remember this option for a day
     $h->header('Access-Control-Max-Age' => '86400');
     $h->header('Access-Control-Allow-Headers' => '*');
+    $h->header('Access-Control-Allow-Origin', $origin || '*');
     $h->header('Access-Control-Allow-Methods' => 'GET, OPTIONS');
     return $c->render(
       status => 204,
@@ -75,7 +97,7 @@
       my $h = $c->res->headers;
       $h->header('X-Proxy' => 'Kalamar');
       $h->header('X-Robots' => 'noindex');
-      $h->access_control_allow_origin('*');
+      $h->header('Access-Control-Allow-Origin', $origin || '*');
       $h->header('Access-Control-Allow-Methods' => 'GET, OPTIONS');
 
       # Response is a redirect
@@ -83,16 +105,16 @@
 
         # Rewrite redirect location to surface URL
         my $location_url = $h->location;
-        my $base_url = Mojo::URL->new($c->korap->api)->to_abs->to_string;
+        my $root_path = $c->stash('root_path');
 
         # Remove the api part
         # ".*?" is just required for non-absolute base_urls
-        $location_url =~ s/^.*?${base_url}//;
+        $location_url =~ s/^.*?${root_path}//;
 
         # Turn the rewritten location into a URL object
         $location_url = Mojo::URL->new($location_url);
 
-        my $proxy_url = $c->url_for('proxy');
+        my $proxy_url = $c->url_for($c->stash('service') || 'proxy');
         $proxy_url->path->trailing_slash(1);
 
         # Rebase to proxy path
diff --git a/t/proxy.t b/t/proxy.t
index 1db8ac9..5d5280a 100644
--- a/t/proxy.t
+++ b/t/proxy.t
@@ -158,6 +158,55 @@
   ->header_is('Access-Control-Max-Age', '86400')
   ;
 
+# General proxy mounts
+my $plugin_path = '/realplugin';
+
+$t = Test::Mojo->new('Kalamar' => {
+  Kalamar => {
+    proxies => [
+      'PROXY_STUB',
+      {
+        root_path => '/plugin/hello',
+        mount => $plugin_path,
+        service => 'export-plugin-proxy'
+      }
+    ],
+    proxy_inactivity_timeout => 99,
+    proxy_connect_timeout => 66,
+  }
+});
+
+my $fake_plugin = $t->app->plugin(
+  Mount => {
+    $plugin_path =>
+      $fixtures_path->child('plugin-ex.pl')
+  }
+);
+
+# Configure fake plugin
+my $fake_plugin_app = $fake_plugin->pattern->defaults->{app};
+$fake_plugin_app->log($t->app->log);
+
+# Globally set server
+$t->app->ua->server->app($t->app);
+
+$t->get_ok('/realplugin')
+  ->status_is(200)
+  ->content_is('Hello base world!');
+
+$t->get_ok('/realplugin/huhux')
+  ->status_is(200)
+  ->content_is('Hello world! huhux');
+
+$t->get_ok('/plugin/hello/huhux')
+  ->status_is(200)
+  ->content_is('Hello world! huhux');
+
+# require Mojolicious::Command::routes;
+# use Mojo::Util qw(encode tablify);
+# my $rows = [];
+# Mojolicious::Command::routes::_walk($_, 0, $rows, 0) for @{$t->app->routes->children};
+# warn encode('UTF-8', tablify($rows));
 
 done_testing;
 __END__
diff --git a/t/server/plugin-ex.pl b/t/server/plugin-ex.pl
new file mode 100644
index 0000000..d7ef4e3
--- /dev/null
+++ b/t/server/plugin-ex.pl
@@ -0,0 +1,16 @@
+#!/usr/bin/env perl
+use Mojolicious::Lite;
+use strict;
+use warnings;
+
+# Base page
+get '/' => sub {
+  shift->render(text => 'Hello base world!');
+};
+
+get '/*all' => sub {
+  my $c = shift;
+  $c->render(text => 'Hello world! ' . $c->stash('all'));
+};
+
+app->start;