├── .gitignore ├── .ruby-gemset ├── .ruby-version ├── Procfile ├── Gemfile ├── public ├── images │ └── whoots_tiles.jpg └── index.html ├── config.ru ├── Gemfile.lock ├── config └── puma │ ├── development.rb │ └── production.rb ├── README.textile ├── whoots_app.rb └── whoots_app.php /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | whoots 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.3.6 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec puma -C config/puma/${RACK_ENV:-development}.rb -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rack' 4 | gem 'rack-protection' 5 | gem 'puma' -------------------------------------------------------------------------------- /public/images/whoots_tiles.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timwaters/whoots/HEAD/public/images/whoots_tiles.jpg -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | require 'rack/protection' 3 | 4 | require_relative './whoots_app' 5 | 6 | # Add security headers 7 | use Rack::Protection 8 | use Rack::Protection::XSSHeader 9 | use Rack::Protection::FrameOptions 10 | 11 | #use Rack::Reloader, 0 #<=- useful to uncomment for dev 12 | use Rack::Static, :urls => [""], :root => "public", :index => 'index.html' 13 | #use Rack::Directory, :index => 'index.html' 14 | 15 | run WhootsApp -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | base64 (0.2.0) 5 | logger (1.6.6) 6 | nio4r (2.7.4) 7 | puma (6.6.0) 8 | nio4r (~> 2.0) 9 | rack (3.1.10) 10 | rack-protection (4.1.1) 11 | base64 (>= 0.1.0) 12 | logger (>= 1.6.0) 13 | rack (>= 3.0.0, < 4) 14 | 15 | PLATFORMS 16 | ruby 17 | x86_64-linux 18 | 19 | DEPENDENCIES 20 | puma 21 | rack 22 | rack-protection 23 | 24 | BUNDLED WITH 25 | 2.5.22 26 | -------------------------------------------------------------------------------- /config/puma/development.rb: -------------------------------------------------------------------------------- 1 | # Development configuration 2 | environment 'development' 3 | directory ENV.fetch('APP_ROOT', Dir.pwd) 4 | 5 | # Development port 6 | port ENV.fetch('PORT', 9292) 7 | 8 | # Single worker for easier debugging 9 | workers 1 10 | 11 | # More threads for development to handle concurrent requests 12 | threads 1, 6 13 | 14 | # Enable code reloading 15 | worker_timeout 3600 if ENV.fetch("RACK_ENV", "development") == "development" 16 | 17 | # Development logging 18 | quiet false 19 | 20 | # Allow puma to be restarted by `rails restart` command 21 | plugin :tmp_restart -------------------------------------------------------------------------------- /config/puma/production.rb: -------------------------------------------------------------------------------- 1 | # Production configuration for small server (128MB RAM) 2 | environment 'production' 3 | directory ENV.fetch('APP_ROOT', Dir.pwd) 4 | 5 | # Production port 6 | port ENV.fetch('PORT', 9292) 7 | 8 | # Single worker for small server 9 | workers 1 10 | 11 | # Conservative thread count 12 | threads 2, 4 13 | 14 | # Quiet logging in production 15 | quiet true 16 | 17 | # Production timeouts 18 | worker_timeout 60 19 | 20 | # Memory optimization 21 | max_heap_size = ENV.fetch('MAX_HEAP_SIZE', '64MB') 22 | out_of_band_gc_count = ENV.fetch('OOB_GC_COUNT', 1) 23 | 24 | before_fork do 25 | GC.compact if defined?(GC) && GC.respond_to?(:compact) 26 | end 27 | 28 | on_worker_boot do 29 | GC.start 30 | end 31 | 32 | # Memory monitoring 33 | worker_check_interval 30 34 | worker_timeout 30 -------------------------------------------------------------------------------- /README.textile: -------------------------------------------------------------------------------- 1 | h1. WhooTS : Rack, Ruby + mini WMS -> TMS Proxy 2 | 3 | Whoots is the tiny public wms to tms proxy 4 | 5 | Its a simple WMS to Google/OSM Scheme TMS proxy. You can use WMS servers in applications which only use those pesky "Slippy Tiles" 6 | 7 | h2. Installation 8 | 9 | You need : 10 | * Ruby! 11 | * Rack (gem install rack) 12 | 13 | 14 | h2. Usage 15 | 16 | * Gives OSM/Google Tiles, not true TMS (maybe later) 17 | 18 | http://example.com/tms/z/x/y/{layer}/http://path.to.wms.server 19 | 20 | defaults to png. Optionally pass in ?format=image/jpeg for jpeg e.g. 21 | 22 | http://whoots.mapwarper.net/tms/z/x/y/{layer}/http://path.to.wms.server?format=image/jpeg 23 | 24 | 25 | 26 | h2. Examples 27 | * 1 http://example.com/tms/!/!/!/2013/http://warper.geothings.net/maps/wms/2013 28 | * 29 | * 2a. From: http://hypercube.telascience.org/cgi-bin/mapserv?map=/home/ortelius/haiti/haiti.map&request=getMap&service=wms&version=1.1.1&format=image/jpeg&srs=epsg:4326&exceptions=application/vnd.ogc.se_inimage&layers=HAITI& 30 | * 31 | * 2b To: http://example.com/tms/!/!/!/HAITI/http://hypercube.telascience.org/cgi-bin/mapserv?map=/home/ortelius/haiti/haiti.map 32 | * 33 | * 2c Outputs: http://hypercube.telascience.org/cgi-bin/mapserv?bbox=-8051417.93739076,2107827.49199202,-8051265.06333419,2107980.36604859&format=image/png&service=WMS&version=1.1.1&request=GetMap&srs=EPSG:900913&width=256&height=256&layers=HAITI&map=/home/ortelius/haiti/haiti.map&styles= 34 | 35 | h2. Notes 36 | * Gives OSM/Google Tiles only, so make sure the WMS server support EPSG:3857 37 | * Doesn't do any caching. 38 | * See public/index.html for more examples and help! 39 | 40 | 41 | h2. puma 42 | 43 | for development: 44 | bundle exec puma -C config/puma/development.rb 45 | 46 | for production: 47 | RACK_ENV=production bundle exec puma -C config/puma/production.rb 48 | 49 | 50 | -------------------------------------------------------------------------------- /whoots_app.rb: -------------------------------------------------------------------------------- 1 | #require 'rubygems' 2 | #require 'erb' 3 | 4 | class WhootsApp 5 | 6 | def self.call(env) 7 | req = Rack::Request.new(env) 8 | 9 | case req.path 10 | when /^\/hi\b/ 11 | Rack::Response.new(["Hello World!"]).finish 12 | when /^\/tms\/\d+\/\d+\/\d+\/\w+\/*/ 13 | #'/tms/:z/:x/:y/:layers/*' 14 | params = req.path.split("/") 15 | 16 | # Validate numeric parameters 17 | return [400, {'Content-Type' => 'text/plain'}, ['Invalid parameters']] unless ( 18 | params[2..4].all? { |p| p.match?(/\A\d+\z/) } 19 | ) 20 | 21 | layer = params[5].to_s.gsub(/[^a-zA-Z0-9\-_\.:\s%]/, '') 22 | 23 | z,x,y = params[2],params[3],params[4] 24 | 25 | scheme = params[6].strip.match?(/\Ahttps?:\z/) ? params[6].strip.chomp(':') : 'http' 26 | 27 | # Sanitize splat path to prevent directory traversal 28 | splat = "#{scheme}://" + params[6..params.length] 29 | .select { |p| p.match?(/\A[\w\-\.\/]+\z/) } 30 | .join("/") 31 | # splat = params[6..params.length].join("/") 32 | # Validate splat is not empty after sanitization 33 | return [400, {'Content-Type' => 'text/plain'}, ['Invalid path']] if splat.empty? 34 | 35 | query_params = Rack::Utils.parse_query(req.query_string) 36 | 37 | # Sanitize map parameter 38 | map = query_params["map"].to_s.gsub(/[^a-zA-Z0-9\-_\.\/]/, '') 39 | 40 | x = x.to_i 41 | y = y.to_i 42 | z = z.to_i 43 | #for Google/OSM tile scheme we need to alter the y: 44 | y = ((2**z)-y-1) 45 | #calculate the bbox 46 | bbox = get_tile_bbox(x,y,z) 47 | #build up the other params 48 | format = query_params["format"] == "image/jpeg" ? "image/jpeg" : "image/png" 49 | service = "WMS" 50 | version = "1.1.1" 51 | request = "GetMap" 52 | srs = "EPSG:3857" 53 | width = "256" 54 | height = "256" 55 | layers = layer || "" 56 | 57 | # Only include map parameter if it exists and is not empty 58 | map_param = "" 59 | unless query_params["map"].to_s.empty? 60 | map = query_params["map"].to_s.gsub(/[^a-zA-Z0-9\-_\.\/]/, '') 61 | map_param = "&map=" + map 62 | end 63 | 64 | base_url = splat 65 | url = base_url + "?"+ "bbox="+bbox+"&format="+format+"&service="+service+"&version="+version+"&request="+request+"&srs="+srs+"&width="+width+"&height="+height+"&layers="+layers+map_param+"&styles=" 66 | 67 | return [302, {'Location' => url}, []] 68 | else 69 | Rack::Response.new(["Not found. Whoots"], 404).finish 70 | end 71 | end 72 | 73 | 74 | 75 | def self.get_tile_bbox(x,y,z) 76 | min_x, min_y = get_merc_coords(x * 256, y * 256, z) 77 | max_x, max_y = get_merc_coords( (x + 1) * 256, (y + 1) * 256, z ) 78 | return "#{min_x},#{min_y},#{max_x},#{max_y}" 79 | end 80 | 81 | def self.get_merc_coords(x,y,z) 82 | resolution = (2 * Math::PI * 6378137 / 256) / (2 ** z) 83 | merc_x = (x * resolution -2 * Math::PI * 6378137 / 2.0) 84 | merc_y = (y * resolution - 2 * Math::PI * 6378137 / 2.0) 85 | return merc_x, merc_y 86 | end 87 | 88 | 89 | end 90 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | WhooTS - the tiny public wms to tms proxy 2 | 3 | 4 |

5 | WhooTS - the tiny public wms to tms proxy

6 |

What is it?

7 |

Its a simple WMS to Google/OSM Scheme TMS proxy. You can use WMS servers in applications which only use those pesky "Slippy Tiles" 8 |

9 | 10 |

Usage:

11 |

http://whoots.mapwarper.net/tms/z/x/y/{layer}/http://path.to.wms.server

12 | 13 | defaults to png. Optionally pass in ?format=image/jpeg for jpeg e.g. 14 | 15 | http://whoots.mapwarper.net/tms/z/x/y/{layer}/http://path.to.wms.server?format=image/jpeg 16 | 17 | e.g
18 | http://whoots.mapwarper.net/tms/!/!/!/2013/http://warper.geothings.net/maps/wms/2013
19 | http://whoots.mapwarper.net/tms/z/x/y/870/http://maps.nypl.org/warper/layers/wms/870
20 |
21 | Using this WMS server:
22 | http://hypercube.telascience.org/cgi-bin/mapserv?map=/home/ortelius/haiti/haiti.map&request=getMap&service=wms&version=1.1.1&format=image/jpeg&srs=epsg:4326&exceptions=application/vnd.ogc.se_inimage&layers=HAITI&

23 | http://whoots.mapwarper.net/tms/!/!/!/HAITI/http://hypercube.telascience.org/cgi-bin/mapserv?map=/home/ortelius/haiti/haiti.map
24 |
25 | http://whoots.mapwarper.net/tms/19/154563/197076/870/http://maps.nypl.org/warper/layers/wms/870 26 | 27 |

Openstreetmap Potlatch editing example

28 | 29 |

Map Warper http://mapwarper.net

30 | WMS Link: http://mapwarper.net/maps/wms/2013
31 |
32 | http://www.openstreetmap.org/edit?lat=18.601316&lon=-72.32806&zoom=18&tileurl=http://whoots.mapwarper.net/tms/!/!/!/2013/http://mapwarper.net/maps/wms/2013 33 | 34 |

NYPL Map Rectifier http://maps.nypl.org

35 | 36 | http://www.openstreetmap.org/edit?lat=40.73658&lon=-73.87108&zoom=17&tileurl=http://whoots.mapwarper.net/tms/!/!/!/870/http://maps.nypl.org/warper/layers/wms/870 37 | 38 |

Telascience Haiti http://hypercube.telascience.org/haiti/

39 | http://www.openstreetmap.org/edit?lat=18.601316&lon=-72.32806&zoom=18&tileurl=http://whoots.mapwarper.net/tms/!/!/!/HAITI/http://hypercube.telascience.org/cgi-bin/mapserv?map=/home/ortelius/haiti/haiti.map

40 | 41 |

Example Outputs

42 | http://hypercube.telascience.org/cgi-bin/mapserv?bbox=-8051417.93739076,2107827.49199202,-8051265.06333419,2107980.36604859&format=image/png&service=WMS&version=1.1.1&request=GetMap&srs=EPSG:900913&width=256&height=256&layers=HAITI&map=/home/ortelius/haiti/haiti.map&styles= 43 |

44 | http://maps.nypl.org/warper/layers/wms/870?bbox=-8223095.50291926,4973298.80834671,-8222789.75480612,4973604.55645985&format=image/png&service=WMS&version=1.1.1&request=GetMap&srs=EPSG:900913&width=256&height=256&layers=870&map=&styles= 45 |
46 | 47 |

Important Notes

48 | * Use only a WMS that supports SRS EPSG:3857
49 | * Tiles are Google / OSM Scheme 50 | 51 |
52 |

About: Made with Rack and Ruby by Tim Waters tim@geothings.net Blog
53 | Code available at: github

54 | -------------------------------------------------------------------------------- /whoots_app.php: -------------------------------------------------------------------------------- 1 | 200, 13 | 'headers' => ['Content-Type' => 'text/html'], 14 | 'body' => file_get_contents('public/index.html') 15 | ]; 16 | } 17 | 18 | // Serve the specific image file 19 | if ($path === '/images/whoots_tiles.jpg') { 20 | $filePath = 'public/images/whoots_tiles.jpg'; 21 | if (file_exists($filePath) && is_file($filePath)) { 22 | $mimeType = mime_content_type($filePath); 23 | return [ 24 | 'status' => 200, 25 | 'headers' => ['Content-Type' => $mimeType], 26 | 'body' => file_get_contents($filePath) 27 | ]; 28 | } 29 | } 30 | 31 | if (preg_match('/^\/hi\b/', $path)) { 32 | return [ 33 | 'status' => 200, 34 | 'headers' => ['Content-Type' => 'text/plain'], 35 | 'body' => 'Hello World!' 36 | ]; 37 | } 38 | 39 | if (preg_match('#^/tms/(\d+)/(\d+)/(\d+)/([^/]+)/#', $path, $matches)) { 40 | $params = explode('/', trim($path, '/')); 41 | 42 | // Validate numeric parameters 43 | if (!preg_match('/^\d+$/', $params[1]) || 44 | !preg_match('/^\d+$/', $params[2]) || 45 | !preg_match('/^\d+$/', $params[3])) { 46 | return [ 47 | 'status' => 400, 48 | 'headers' => ['Content-Type' => 'text/plain'], 49 | 'body' => 'Invalid parameters' 50 | ]; 51 | } 52 | 53 | $layer = preg_replace('/[^a-zA-Z0-9\-_\.:\s%]/', '', $params[4]); 54 | 55 | $z = (int)$params[1]; 56 | $x = (int)$params[2]; 57 | $y = (int)$params[3]; 58 | 59 | // Get the scheme from the path 60 | $scheme = 'http'; 61 | if (isset($params[6]) && preg_match('/^https?:$/', $params[6])) { 62 | $scheme = rtrim($params[6], ':'); 63 | } 64 | 65 | // Sanitize and build splat path 66 | $splatParams = array_slice($params, 6); 67 | $sanitizedSplat = array_filter($splatParams, fn($p) => preg_match('/^[\w\-\.\/]+$/', $p)); 68 | $splat = $scheme . "://" . implode("/", $sanitizedSplat); 69 | 70 | if (empty($splat)) { 71 | return [ 72 | 'status' => 400, 73 | 'headers' => ['Content-Type' => 'text/plain'], 74 | 'body' => 'Invalid path' 75 | ]; 76 | } 77 | 78 | // Parse query parameters 79 | $queryString = $serverVars['QUERY_STRING'] ?? ''; 80 | parse_str($queryString, $queryParams); 81 | 82 | // For Google/OSM tile scheme we need to alter the y 83 | $y = ((2 ** $z) - $y - 1); 84 | 85 | // Calculate bbox 86 | $bbox = self::getTileBbox($x, $y, $z); 87 | 88 | // Build WMS parameters 89 | $format = ($queryParams['format'] ?? '') === 'image/jpeg' ? 'image/jpeg' : 'image/png'; 90 | $wmsParams = [ 91 | 'bbox' => $bbox, 92 | 'format' => $format, 93 | 'service' => 'WMS', 94 | 'version' => '1.1.1', 95 | 'request' => 'GetMap', 96 | 'srs' => 'EPSG:3857', 97 | 'width' => '256', 98 | 'height' => '256', 99 | 'layers' => $layer, 100 | 'styles' => '' 101 | ]; 102 | 103 | // Add map parameter if it exists 104 | if (!empty($queryParams['map'])) { 105 | $map = preg_replace('/[^a-zA-Z0-9\-_\.\/]/', '', $queryParams['map']); 106 | $wmsParams['map'] = $map; 107 | } 108 | 109 | $url = $splat . '?' . http_build_query($wmsParams); 110 | 111 | return [ 112 | 'status' => 302, 113 | 'headers' => ['Location' => $url], 114 | 'body' => '' 115 | ]; 116 | } 117 | 118 | return [ 119 | 'status' => 404, 120 | 'headers' => ['Content-Type' => 'text/html'], 121 | 'body' => 'Not found. Whoots' 122 | ]; 123 | } 124 | 125 | private static function getTileBbox(int $x, int $y, int $z): string { 126 | [$minX, $minY] = self::getMercCoords($x * 256, $y * 256, $z); 127 | [$maxX, $maxY] = self::getMercCoords(($x + 1) * 256, ($y + 1) * 256, $z); 128 | return "{$minX},{$minY},{$maxX},{$maxY}"; 129 | } 130 | 131 | private static function getMercCoords(int $x, int $y, int $z): array { 132 | $resolution = (2 * M_PI * 6378137 / 256) / (2 ** $z); 133 | $mercX = ($x * $resolution - 2 * M_PI * 6378137 / 2.0); 134 | $mercY = ($y * $resolution - 2 * M_PI * 6378137 / 2.0); 135 | return [$mercX, $mercY]; 136 | } 137 | } 138 | 139 | // Example usage in a front controller: 140 | if (php_sapi_name() !== 'cli') { 141 | $result = WhootsApp::call($_SERVER); 142 | http_response_code($result['status']); 143 | foreach ($result['headers'] as $name => $value) { 144 | header("$name: $value"); 145 | } 146 | echo $result['body']; 147 | } 148 | ?> --------------------------------------------------------------------------------