osm: Escape name parameter. Closes: #731797
[ikiwiki.git] / IkiWiki / Plugin / osm.pm
1 #!/usr/bin/perl
2 # Copyright 2011 Blars Blarson
3 # Released under GPL version 2
4
5 package IkiWiki::Plugin::osm;
6 use utf8;
7 use strict;
8 use warnings;
9 use IkiWiki 3.0;
10
11 sub import {
12         add_underlay("osm");
13         hook(type => "getsetup", id => "osm", call => \&getsetup);
14         hook(type => "format", id => "osm", call => \&format);
15         hook(type => "preprocess", id => "osm", call => \&preprocess);
16         hook(type => "preprocess", id => "waypoint", call => \&process_waypoint);
17         hook(type => "savestate", id => "waypoint", call => \&savestate);
18         hook(type => "cgi", id => "osm", call => \&cgi);
19 }
20
21 sub getsetup () {
22         return
23                 plugin => {
24                         safe => 1,
25                         rebuild => 1,
26                         section => "special-purpose",
27                 },
28                 osm_default_zoom => {
29                         type => "integer",
30                         example => "15",
31                         description => "the default zoom when you click on the map link",
32                         safe => 1,
33                         rebuild => 1,
34                 },
35                 osm_default_icon => {
36                         type => "string",
37                         example => "ikiwiki/images/osm.png",
38                         description => "the icon shown on links and on the main map",
39                         safe => 0,
40                         rebuild => 1,
41                 },
42                 osm_alt => {
43                         type => "string",
44                         example => "",
45                         description => "the alt tag of links, defaults to empty",
46                         safe => 0,
47                         rebuild => 1,
48                 },
49                 osm_format => {
50                         type => "string",
51                         example => "KML",
52                         description => "the output format for waypoints, can be KML, GeoJSON or CSV (one or many, comma-separated)",
53                         safe => 1,
54                         rebuild => 1,
55                 },
56                 osm_tag_default_icon => {
57                         type => "string",
58                         example => "icon.png",
59                         description => "the icon attached to a tag, displayed on the map for tagged pages",
60                         safe => 0,
61                         rebuild => 1,
62                 },
63                 osm_openlayers_url => {
64                         type => "string",
65                         example => "http://www.openlayers.org/api/OpenLayers.js",
66                         description => "Url for the OpenLayers.js file",
67                         safe => 0,
68                         rebuild => 1,
69                 },
70                 osm_layers => {
71                         type => "string",
72                         example => { 'OSM', 'GoogleSatellite' },
73                         description => "Layers to use in the map. Can be either the 'OSM' string or a type option for Google maps (GoogleNormal, GoogleSatellite, GoogleHybrid or GooglePhysical). It can also be an arbitrary URL in a syntax acceptable for OpenLayers.Layer.OSM.url parameter.",
74                         safe => 0,
75                         rebuild => 1,
76                 },
77                 osm_google_apikey => {
78                         type => "string",
79                         example => "",
80                         description => "Google maps API key, Google layer not used if missing, see https://code.google.com/apis/console/ to get an API key",
81                         safe => 1,
82                         rebuild => 1,
83                 },
84 }
85
86 sub register_rendered_files {
87         my $map = shift;
88         my $page = shift;
89         my $dest = shift;
90
91         if ($page eq $dest) {
92                 my %formats = get_formats();
93                 if ($formats{'GeoJSON'}) {
94                         will_render($page, "$map/pois.json");
95                 }
96                 if ($formats{'CSV'}) {
97                         will_render($page, "$map/pois.txt");
98                 }
99                 if ($formats{'KML'}) {
100                         will_render($page, "$map/pois.kml");
101                 }
102         }
103 }
104
105 sub preprocess {
106         my %params=@_;
107         my $page = $params{page};
108         my $dest = $params{destpage};
109         my $loc = $params{loc}; # sanitized below
110         my $lat = $params{lat}; # sanitized below
111         my $lon = $params{lon}; # sanitized below
112         my $href = $params{href};
113
114         my ($width, $height, $float);
115         $height = scrub($params{'height'} || "300px", $page, $dest); # sanitized here
116         $width = scrub($params{'width'} || "500px", $page, $dest); # sanitized here
117         $float = (defined($params{'right'}) && 'right') || (defined($params{'left'}) && 'left'); # sanitized here
118         
119         my $zoom = scrub($params{'zoom'} // $config{'osm_default_zoom'} // 15, $page, $dest); # sanitized below
120         my $map;
121         $map = $params{'map'} || 'map';
122         
123         $map = scrub($map, $page, $dest); # sanitized here
124         my $name = scrub($params{'name'} || $map, $page, $dest);
125
126         if (defined($lon) || defined($lat) || defined($loc)) {
127                 ($lon, $lat) = scrub_lonlat($loc, $lon, $lat);
128         }
129
130         if ($zoom !~ /^\d\d?$/ || $zoom < 2 || $zoom > 18) {
131                 error("Bad zoom");
132         }
133
134         if (! defined $href || ! length $href) {
135                 $href=IkiWiki::cgiurl(
136                         do => "osm",
137                         map => $map,
138                 );
139         }
140
141         register_rendered_files($map, $page, $dest);
142
143         $pagestate{$page}{'osm'}{$map}{'displays'}{$name} = {
144                 height => $height,
145                 width => $width,
146                 float => $float,
147                 zoom => $zoom,
148                 fullscreen => 0,
149                 editable => defined($params{'editable'}),
150                 lat => $lat,
151                 lon => $lon,
152                 href => $href,
153                 google_apikey => $config{'osm_google_apikey'},
154         };
155         return "<div id=\"mapdiv-$name\"></div>";
156 }
157
158 sub process_waypoint {
159         my %params=@_;
160         my $loc = $params{'loc'}; # sanitized below
161         my $lat = $params{'lat'}; # sanitized below
162         my $lon = $params{'lon'}; # sanitized below
163         my $page = $params{'page'}; # not sanitized?
164         my $dest = $params{'destpage'}; # not sanitized?
165         my $hidden = defined($params{'hidden'}); # sanitized here
166         my ($p) = $page =~ /(?:^|\/)([^\/]+)\/?$/; # shorter page name
167         my $name = scrub($params{'name'} || $p, $page, $dest); # sanitized here
168         my $desc = scrub($params{'desc'} || '', $page, $dest); # sanitized here
169         my $zoom = scrub($params{'zoom'} // $config{'osm_default_zoom'} // 15, $page, $dest); # sanitized below
170         my $icon = $config{'osm_default_icon'} || "ikiwiki/images/osm.png"; # sanitized: we trust $config
171         my $map = scrub($params{'map'} || 'map', $page, $dest); # sanitized here
172         my $alt = $config{'osm_alt'} ? "alt=\"$config{'osm_alt'}\"" : ''; # sanitized: we trust $config
173         if ($zoom !~ /^\d\d?$/ || $zoom < 2 || $zoom > 18) {
174                 error("Bad zoom");
175         }
176
177         ($lon, $lat) = scrub_lonlat($loc, $lon, $lat);
178         if (!defined($lat) || !defined($lon)) {
179                 error("Must specify lat and lon");
180         }
181
182         my $tag = $params{'tag'};
183         foreach my $t (keys %{$typedlinks{$page}{'tag'}}) {
184                 if ($icon = get_tag_icon($t)) {
185                         $tag = $t;
186                         last;
187                 }
188                 $t =~ s!/$config{'tagbase'}/!!;
189                 if ($icon = get_tag_icon($t)) {
190                         $tag = $t;
191                         last;
192                 }
193         }
194         $icon = urlto($icon, $dest, 1);
195         $icon =~ s!/*$!!; # hack - urlto shouldn't be appending a slash in the first place
196         $tag = '' unless $tag;
197         register_rendered_files($map, $page, $dest);
198         $pagestate{$page}{'osm'}{$map}{'waypoints'}{$name} = {
199                 page => $page,
200                 desc => $desc,
201                 icon => $icon,
202                 tag => $tag,
203                 lat => $lat,
204                 lon => $lon,
205                 # How to link back to the page from the map, not to be
206                 # confused with the URL of the map itself sent to the
207                 # embeded map below. Note: used in generated KML etc file,
208                 # so must be absolute.
209                 href => urlto($page),
210         };
211
212         my $mapurl = IkiWiki::cgiurl(
213                 do => "osm",
214                 map => $map,
215                 lat => $lat,
216                 lon => $lon,
217                 zoom => $zoom,
218         );
219         my $output = '';
220         if (defined($params{'embed'})) {
221                 $output .= preprocess(%params,
222                         href => $mapurl,
223                 );
224         }
225         if (!$hidden) {
226                 $output .= "<a href=\"$mapurl\"><img class=\"img\" src=\"$icon\" $alt /></a>";
227         }
228         return $output;
229 }
230
231 # get the icon from the given tag
232 sub get_tag_icon($) {
233         my $tag = shift;
234         # look for an icon attached to the tag
235         my $attached = $tag . '/' . $config{'osm_tag_default_icon'};
236         if (srcfile($attached)) {
237                 return $attached;
238         }
239         else {
240                 return undef;
241         }
242 }
243
244 sub scrub_lonlat($$$) {
245         my ($loc, $lon, $lat) = @_;
246         if ($loc) {
247                 if ($loc =~ /^\s*(\-?\d+(?:\.\d*°?|(?:°?|\s)\s*\d+(?:\.\d*\'?|(?:\'|\s)\s*\d+(?:\.\d*)?\"?|\'?)°?)[NS]?)\s*\,?\;?\s*(\-?\d+(?:\.\d*°?|(?:°?|\s)\s*\d+(?:\.\d*\'?|(?:\'|\s)\s*\d+(?:\.\d*)?\"?|\'?)°?)[EW]?)\s*$/) {
248                         $lat = $1;
249                         $lon = $2;
250                 }
251                 else {
252                         error("Bad loc");
253                 }
254         }
255         if (defined($lat)) {
256                 if ($lat =~ /^(\-?)(\d+)(?:(\.\d*)°?|(?:°|\s)\s*(\d+)(?:(\.\d*)\'?|(?:\'|\s)\s*(\d+(?:\.\d*)?\"?)|\'?)|°?)\s*([NS])?\s*$/) {
257                         $lat = $2 + ($3//0) + ((($4//0) + (($5//0) + (($6//0)/60.)))/60.);
258                         if (($1 eq '-') || (($7//'') eq 'S')) {
259                                 $lat = - $lat;
260                         }
261                 }
262                 else {
263                         error("Bad lat");
264                 }
265         }
266         if (defined($lon)) {
267                 if ($lon =~ /^(\-?)(\d+)(?:(\.\d*)°?|(?:°|\s)\s*(\d+)(?:(\.\d*)\'?|(?:\'|\s)\s*(\d+(?:\.\d*)?\"?)|\'?)|°?)\s*([EW])?$/) {
268                         $lon = $2 + ($3//0) + ((($4//0) + (($5//0) + (($6//0)/60.)))/60.);
269                         if (($1 eq '-') || (($7//'') eq 'W')) {
270                                 $lon = - $lon;
271                         }
272                 }
273                 else {
274                         error("Bad lon");
275                 }
276         }
277         if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
278                 error("Location out of range");
279         }
280         return ($lon, $lat);
281 }
282
283 sub savestate {
284         my %waypoints = ();
285         my %linestrings = ();
286
287         foreach my $page (keys %pagestate) {
288                 if (exists $pagestate{$page}{'osm'}) {
289                         foreach my $map (keys %{$pagestate{$page}{'osm'}}) {
290                                 foreach my $name (keys %{$pagestate{$page}{'osm'}{$map}{'waypoints'}}) {
291                                         debug("found waypoint $name");
292                                         $waypoints{$map}{$name} = $pagestate{$page}{'osm'}{$map}{'waypoints'}{$name};
293                                 }
294                         }
295                 }
296         }
297
298         foreach my $page (keys %pagestate) {
299                 if (exists $pagestate{$page}{'osm'}) {
300                         foreach my $map (keys %{$pagestate{$page}{'osm'}}) {
301                                 # examine the links on this page
302                                 foreach my $name (keys %{$pagestate{$page}{'osm'}{$map}{'waypoints'}}) {
303                                         if (exists $links{$page}) {
304                                                 foreach my $otherpage (@{$links{$page}}) {
305                                                         if (exists $waypoints{$map}{$otherpage}) {
306                                                                 push(@{$linestrings{$map}}, [
307                                                                         [ $waypoints{$map}{$name}{'lon'}, $waypoints{$map}{$name}{'lat'} ],
308                                                                         [ $waypoints{$map}{$otherpage}{'lon'}, $waypoints{$map}{$otherpage}{'lat'} ]
309                                                                 ]);
310                                                         }
311                                                 }
312                                         }
313                                 }
314                         }
315                         # clear the state, it will be regenerated on the next parse
316                         # the idea here is to clear up removed waypoints...
317                         $pagestate{$page}{'osm'} = ();
318                 }
319         }
320
321         my %formats = get_formats();
322         if ($formats{'GeoJSON'}) {
323                 writejson(\%waypoints, \%linestrings);
324         }
325         if ($formats{'CSV'}) {
326                 writecsvs(\%waypoints, \%linestrings);
327         }
328         if ($formats{'KML'}) {
329                 writekml(\%waypoints, \%linestrings);
330         }
331 }
332
333 sub writejson($;$) {
334         my %waypoints = %{$_[0]};
335         my %linestrings = %{$_[1]};
336         eval q{use JSON};
337         error $@ if $@;
338         foreach my $map (keys %waypoints) {
339                 my %geojson = ( "type" => "FeatureCollection", "features" => []);
340                 foreach my $name (keys %{$waypoints{$map}}) {
341                         my %marker = ( "type" => "Feature",
342                                 "geometry" => { "type" => "Point", "coordinates" => [ $waypoints{$map}{$name}{'lon'}, $waypoints{$map}{$name}{'lat'} ] },
343                                 "properties" => $waypoints{$map}{$name} );
344                         push @{$geojson{'features'}}, \%marker;
345                 }
346                 foreach my $linestring (@{$linestrings{$map}}) {
347                         my %json  = ( "type" => "Feature",
348                                 "geometry" => { "type" => "LineString", "coordinates" => $linestring });
349                         push @{$geojson{'features'}}, \%json;
350                 }
351                 writefile("pois.json", $config{destdir} . "/$map", to_json(\%geojson));
352         }
353 }
354
355 sub writekml($;$) {
356         my %waypoints = %{$_[0]};
357         my %linestrings = %{$_[1]};
358         eval q{use XML::Writer};
359         error $@ if $@;
360         foreach my $map (keys %waypoints) {
361                 my $output;
362                 my $writer = XML::Writer->new( OUTPUT => \$output,
363                         DATA_MODE => 1, DATA_INDENT => ' ', ENCODING => 'UTF-8');
364                 $writer->xmlDecl();
365                 $writer->startTag("kml", "xmlns" => "http://www.opengis.net/kml/2.2");
366                 $writer->startTag("Document");
367
368                 # first pass: get the icons
369                 my %tags_map = (); # keep track of tags seen
370                 foreach my $name (keys %{$waypoints{$map}}) {
371                         my %options = %{$waypoints{$map}{$name}};
372                         if (!$tags_map{$options{tag}}) {
373                             debug("found new style " . $options{tag});
374                             $tags_map{$options{tag}} = ();
375                             $writer->startTag("Style", id => $options{tag});
376                             $writer->startTag("IconStyle");
377                             $writer->startTag("Icon");
378                             $writer->startTag("href");
379                             $writer->characters($options{icon});
380                             $writer->endTag();
381                             $writer->endTag();
382                             $writer->endTag();
383                             $writer->endTag();
384                         }
385                         $tags_map{$options{tag}}{$name} = \%options;
386                 }
387         
388                 foreach my $name (keys %{$waypoints{$map}}) {
389                         my %options = %{$waypoints{$map}{$name}};
390                         $writer->startTag("Placemark");
391                         $writer->startTag("name");
392                         $writer->characters($name);
393                         $writer->endTag();
394                         $writer->startTag("styleUrl");
395                         $writer->characters('#' . $options{tag});
396                         $writer->endTag();
397                         #$writer->emptyTag('atom:link', href => $options{href});
398                         # to make it easier for us as the atom:link parameter is
399                         # hard to access from javascript
400                         $writer->startTag('href');
401                         $writer->characters($options{href});
402                         $writer->endTag();
403                         $writer->startTag("description");
404                         $writer->characters($options{desc});
405                         $writer->endTag();
406                         $writer->startTag("Point");
407                         $writer->startTag("coordinates");
408                         $writer->characters($options{lon} . "," . $options{lat});
409                         $writer->endTag();
410                         $writer->endTag();
411                         $writer->endTag();
412                 }
413                 
414                 my $i = 0;
415                 foreach my $linestring (@{$linestrings{$map}}) {
416                         $writer->startTag("Placemark");
417                         $writer->startTag("name");
418                         $writer->characters("linestring " . $i++);
419                         $writer->endTag();
420                         $writer->startTag("LineString");
421                         $writer->startTag("coordinates");
422                         my $str = '';
423                         foreach my $coord (@{$linestring}) {
424                                 $str .= join(',', @{$coord}) . " \n";
425                         }
426                         $writer->characters($str);
427                         $writer->endTag();
428                         $writer->endTag();
429                         $writer->endTag();
430                 }
431                 $writer->endTag();
432                 $writer->endTag();
433                 $writer->end();
434
435                 writefile("pois.kml", $config{destdir} . "/$map", $output);
436         }
437 }
438
439 sub writecsvs($;$) {
440         my %waypoints = %{$_[0]};
441         foreach my $map (keys %waypoints) {
442                 my $poisf = "lat\tlon\ttitle\tdescription\ticon\ticonSize\ticonOffset\n";
443                 foreach my $name (keys %{$waypoints{$map}}) {
444                         my %options = %{$waypoints{$map}{$name}};
445                         my $line = 
446                                 $options{'lat'} . "\t" .
447                                 $options{'lon'} . "\t" .
448                                 $name . "\t" .
449                                 $options{'desc'} . '<br /><a href="' . $options{'page'} . '">' . $name . "</a>\t" .
450                                 $options{'icon'} . "\n";
451                         $poisf .= $line;
452                 }
453                 writefile("pois.txt", $config{destdir} . "/$map", $poisf);
454         }
455 }
456
457 # pipe some data through the HTML scrubber
458 #
459 # code taken from the meta.pm plugin
460 sub scrub($$$) {
461         if (IkiWiki::Plugin::htmlscrubber->can("sanitize")) {
462                 return IkiWiki::Plugin::htmlscrubber::sanitize(
463                         content => shift, page => shift, destpage => shift);
464         }
465         else {
466                 return shift;
467         }
468 }
469
470 # taken from toggle.pm
471 sub format (@) {
472         my %params=@_;
473
474         if ($params{content}=~m!<div[^>]*id="mapdiv-[^"]*"[^>]*>!g) {
475                 if (! ($params{content}=~s!</body>!include_javascript($params{page})."</body>"!em)) {
476                         # no <body> tag, probably in preview mode
477                         $params{content}=$params{content} . include_javascript($params{page});
478                 }
479         }
480         return $params{content};
481 }
482
483 sub preferred_format() {
484         if (!defined($config{'osm_format'}) || !$config{'osm_format'}) {
485                 $config{'osm_format'} = 'KML';
486         }
487         my @spl = split(/, */, $config{'osm_format'});
488         return shift @spl;
489 }
490
491 sub get_formats() {
492         if (!defined($config{'osm_format'}) || !$config{'osm_format'}) {
493                 $config{'osm_format'} = 'KML';
494         }
495         map { $_ => 1 } split(/, */, $config{'osm_format'});
496 }
497
498 sub include_javascript ($) {
499         my $page=shift;
500         my $loader;
501
502         if (exists $pagestate{$page}{'osm'}) {
503                 foreach my $map (keys %{$pagestate{$page}{'osm'}}) {
504                         foreach my $name (keys %{$pagestate{$page}{'osm'}{$map}{'displays'}}) {
505                                 $loader .= map_setup_code($map, $name, %{$pagestate{$page}{'osm'}{$map}{'displays'}{$name}});
506                         }
507                 }
508         }
509         if ($loader) {
510                 return embed_map_code($page) . "<script type=\"text/javascript\">$loader</script>";
511         }
512         else {
513                 return '';
514         }
515 }
516
517 sub cgi($) {
518         my $cgi=shift;
519
520         return unless defined $cgi->param('do') &&
521                 $cgi->param("do") eq "osm";
522         
523         IkiWiki::loadindex();
524
525         IkiWiki::decode_cgi_utf8($cgi);
526
527         my $map = $cgi->param('map');
528         if (!defined $map || $map !~ /^[a-z]*$/) {
529                 error("invalid map parameter");
530         }
531
532         print "Content-Type: text/html\r\n";
533         print ("\r\n");
534         print "<html><body>";
535         print "<div id=\"mapdiv-$map\"></div>";
536         print embed_map_code();
537         print "<script type=\"text/javascript\">";
538         print map_setup_code($map, $map,
539                 lat => "urlParams['lat']",
540                 lon => "urlParams['lon']",
541                 zoom => "urlParams['zoom']",
542                 fullscreen => 1,
543                 editable => 1,
544                 google_apikey => $config{'osm_google_apikey'},
545         );
546         print "</script>";
547         print "</body></html>";
548
549         exit 0;
550 }
551
552 sub embed_map_code(;$) {
553         my $page=shift;
554         my $olurl = $config{osm_openlayers_url} || "http://www.openlayers.org/api/OpenLayers.js";
555         my $code = '<script src="'.$olurl.'" type="text/javascript" charset="utf-8"></script>'."\n".
556                 '<script src="'.urlto("ikiwiki/osm.js", $page).
557                 '" type="text/javascript" charset="utf-8"></script>'."\n";
558         if ($config{'osm_google_apikey'}) {
559             $code .= '<script src="http://maps.google.com/maps?file=api&amp;v=2&amp;key='.$config{'osm_google_apikey'}.'&sensor=false" type="text/javascript" charset="utf-8"></script>';
560         }
561         return $code;
562 }
563
564 sub map_setup_code($;@) {
565         my $map=shift;
566         my $name=shift;
567         my %options=@_;
568
569         my $mapurl = $config{osm_map_url};
570
571         eval q{use JSON};
572         error $@ if $@;
573                                 
574         $options{'format'} = preferred_format();
575
576         my %formats = get_formats();
577         if ($formats{'GeoJSON'}) {
578                 $options{'jsonurl'} = urlto($map."/pois.json");
579         }
580         if ($formats{'CSV'}) {
581                 $options{'csvurl'} = urlto($map."/pois.txt");
582         }
583         if ($formats{'KML'}) {
584                 $options{'kmlurl'} = urlto($map."/pois.kml");
585         }
586
587         if ($mapurl) {
588                 $options{'mapurl'} = $mapurl;
589         }
590         $options{'layers'} = $config{osm_layers};
591
592         $name=~s/'//g; # $name comes from user input
593         return "mapsetup('mapdiv-$name', " . to_json(\%options) . ");";
594 }
595
596 1;