Code

Merge branch 'maint'
authorJunio C Hamano <gitster@pobox.com>
Mon, 9 Feb 2009 06:07:53 +0000 (22:07 -0800)
committerJunio C Hamano <gitster@pobox.com>
Mon, 9 Feb 2009 06:07:53 +0000 (22:07 -0800)
* maint:
  gitweb: add $prevent_xss option to prevent XSS by repository content
  rev-list: fix showing distance when using --bisect-all

1  2 
gitweb/README
gitweb/gitweb.perl

diff --combined gitweb/README
index a9dc2e57d9bd79a505ca165c693ce0f3ccd2f333,19ae28ef9b5de046e003c02b9258dd576d8064f7..8433dd1d45780b3947cc4a0b31ee2059ea6e65bf
@@@ -162,12 -162,14 +162,12 @@@ not include variables usually directly 
     $GITWEB_LIST during installation.  If empty, $projectroot is used
     to scan for repositories.
   * $my_url, $my_uri
 -   URL and absolute URL of gitweb script; you might need to set those
 -   variables if you are using 'pathinfo' feature: see also below.
 +   Full URL and absolute URL of gitweb script;
 +   in earlier versions of gitweb you might have need to set those
 +   variables, now there should be no need to do it.
   * $home_link
     Target of the home link on top of all pages (the first part of view
 -   "breadcrumbs").  By default set to absolute URI of a page; you might
 -   need to set it up to [base] gitweb URI if you use 'pathinfo' feature
 -   (alternative format of the URLs, with project name embedded directly
 -   in the path part of URL).
 +   "breadcrumbs").  By default set to absolute URI of a page ($my_uri).
   * @stylesheets
     List of URIs of stylesheets (relative to base URI of a page). You
     might specify more than one stylesheet, for example use gitweb.css
     Rename detection options for git-diff and git-diff-tree. By default
     ('-M'); set it to ('-C') or ('-C', '-C') to also detect copies, or
     set it to () if you don't want to have renames detection.
+  * $prevent_xss
+    If true, some gitweb features are disabled to prevent content in
+    repositories from launching cross-site scripting (XSS) attacks.  Set this
+    to true if you don't trust the content of your repositories. The default
+    is false.
  
  
  Projects list file format
@@@ -258,7 -265,9 +263,9 @@@ You can use the following files in repo
     A .html file (HTML fragment) which is included on the gitweb project
     summary page inside <div> block element. You can use it for longer
     description of a project, to provide links (for example to project's
-    homepage), etc.
+    homepage), etc. This is recognized only if XSS prevention is off
+    ($prevent_xss is false); a way to include a readme safely when XSS
+    prevention is on may be worked out in the future.
   * description (or gitweb.description)
     Short (shortened by default to 25 characters in the projects list page)
     single line description of a project (of a repository). Plain text file;
@@@ -320,82 -329,6 +327,82 @@@ something like the following in your gi
    $home_link = "/";
  
  
 +PATH_INFO usage
 +-----------------------
 +If you enable PATH_INFO usage in gitweb by putting
 +
 +   $feature{'pathinfo'}{'default'} = [1];
 +
 +in your gitweb.conf, it is possible to set up your server so that it
 +consumes and produces URLs in the form
 +
 +http://git.example.com/project.git/shortlog/sometag
 +
 +by using a configuration such as the following, that assumes that
 +/var/www/gitweb is the DocumentRoot of your webserver, and that it
 +contains the gitweb.cgi script and complementary static files
 +(stylesheet, favicon):
 +
 +<VirtualHost *:80>
 +      ServerAlias git.example.com
 +
 +      DocumentRoot /var/www/gitweb
 +
 +      <Directory /var/www/gitweb>
 +              Options ExecCGI
 +              AddHandler cgi-script cgi
 +
 +              DirectoryIndex gitweb.cgi
 +
 +              RewriteEngine On
 +              RewriteCond %{REQUEST_FILENAME} !-f
 +              RewriteCond %{REQUEST_FILENAME} !-d
 +              RewriteRule ^.* /gitweb.cgi/$0 [L,PT]
 +      </Directory>
 +</VirtualHost>
 +
 +The rewrite rule guarantees that existing static files will be properly
 +served, whereas any other URL will be passed to gitweb as PATH_INFO
 +parameter.
 +
 +Notice that in this case you don't need special settings for
 +@stylesheets, $my_uri and $home_link, but you lose "dumb client" access
 +to your project .git dirs. A possible workaround for the latter is the
 +following: in your project root dir (e.g. /pub/git) have the projects
 +named without a .git extension (e.g. /pub/git/project instead of
 +/pub/git/project.git) and configure Apache as follows:
 +
 +<VirtualHost *:80>
 +      ServerAlias git.example.com
 +
 +      DocumentRoot /var/www/gitweb
 +
 +      AliasMatch ^(/.*?)(\.git)(/.*)? /pub/git$1$3
 +      <Directory /var/www/gitweb>
 +              Options ExecCGI
 +              AddHandler cgi-script cgi
 +
 +              DirectoryIndex gitweb.cgi
 +
 +              RewriteEngine On
 +              RewriteCond %{REQUEST_FILENAME} !-f
 +              RewriteCond %{REQUEST_FILENAME} !-d
 +              RewriteRule ^.* /gitweb.cgi/$0 [L,PT]
 +      </Directory>
 +</VirtualHost>
 +
 +The additional AliasMatch makes it so that
 +
 +http://git.example.com/project.git
 +
 +will give raw access to the project's git dir (so that the project can
 +be cloned), while
 +
 +http://git.example.com/project
 +
 +will provide human-friendly gitweb access.
 +
 +
  Originally written by:
    Kay Sievers <kay.sievers@vrfy.org>
  
diff --combined gitweb/gitweb.perl
index f27dbb6bf4acfe6f5381d7c86760b1170c8a10ff,bdaa4e9463460a149a5c7f13881e5373257bc4e5..54108742857b1b71060b5d7bd95d4d2fc619d717
@@@ -132,6 -132,10 +132,10 @@@ our $fallback_encoding = 'latin1'
  # - one might want to include '-B' option, e.g. '-B', '-M'
  our @diff_opts = ('-M'); # taken from git_commit
  
+ # Disables features that would allow repository owners to inject script into
+ # the gitweb domain.
+ our $prevent_xss = 0;
  # information about snapshot formats that gitweb is capable of serving
  our %known_snapshot_formats = (
        # name => {
@@@ -203,7 -207,7 +207,7 @@@ our %feature = 
        # $feature{'blame'}{'override'} = 1;
        # and in project config gitweb.blame = 0|1;
        'blame' => {
 -              'sub' => \&feature_blame,
 +              'sub' => sub { feature_bool('blame', @_) },
                'override' => 0,
                'default' => [0]},
  
        # $feature{'grep'}{'override'} = 1;
        # and in project config gitweb.grep = 0|1;
        'grep' => {
 -              'sub' => \&feature_grep,
 +              'sub' => sub { feature_bool('grep', @_) },
                'override' => 0,
                'default' => [1]},
  
        # $feature{'pickaxe'}{'override'} = 1;
        # and in project config gitweb.pickaxe = 0|1;
        'pickaxe' => {
 -              'sub' => \&feature_pickaxe,
 +              'sub' => sub { feature_bool('pickaxe', @_) },
                'override' => 0,
                'default' => [1]},
  
        'ctags' => {
                'override' => 0,
                'default' => [0]},
 +
 +      # The maximum number of patches in a patchset generated in patch
 +      # view. Set this to 0 or undef to disable patch view, or to a
 +      # negative number to remove any limit.
 +
 +      # To disable system wide have in $GITWEB_CONFIG
 +      # $feature{'patches'}{'default'} = [0];
 +      # To have project specific config enable override in $GITWEB_CONFIG
 +      # $feature{'patches'}{'override'} = 1;
 +      # and in project config gitweb.patches = 0|n;
 +      # where n is the maximum number of patches allowed in a patchset.
 +      'patches' => {
 +              'sub' => \&feature_patches,
 +              'override' => 0,
 +              'default' => [16]},
  );
  
  sub gitweb_get_feature {
@@@ -378,17 -367,16 +382,17 @@@ sub gitweb_check_feature 
  }
  
  
 -sub feature_blame {
 -      my ($val) = git_get_project_config('blame', '--bool');
 +sub feature_bool {
 +      my $key = shift;
 +      my ($val) = git_get_project_config($key, '--bool');
  
        if ($val eq 'true') {
 -              return 1;
 +              return (1);
        } elsif ($val eq 'false') {
 -              return 0;
 +              return (0);
        }
  
 -      return $_[0];
 +      return ($_[0]);
  }
  
  sub feature_snapshot {
        return @fmts;
  }
  
 -sub feature_grep {
 -      my ($val) = git_get_project_config('grep', '--bool');
 +sub feature_patches {
 +      my @val = (git_get_project_config('patches', '--int'));
  
 -      if ($val eq 'true') {
 -              return (1);
 -      } elsif ($val eq 'false') {
 -              return (0);
 -      }
 -
 -      return ($_[0]);
 -}
 -
 -sub feature_pickaxe {
 -      my ($val) = git_get_project_config('pickaxe', '--bool');
 -
 -      if ($val eq 'true') {
 -              return (1);
 -      } elsif ($val eq 'false') {
 -              return (0);
 +      if (@val) {
 +              return @val;
        }
  
        return ($_[0]);
@@@ -506,8 -508,6 +510,8 @@@ our %actions = 
        "heads" => \&git_heads,
        "history" => \&git_history,
        "log" => \&git_log,
 +      "patch" => \&git_patch,
 +      "patches" => \&git_patches,
        "rss" => \&git_rss,
        "atom" => \&git_atom,
        "search" => \&git_search,
@@@ -834,7 -834,7 +838,7 @@@ sub href (%) 
        }
  
        my $use_pathinfo = gitweb_check_feature('pathinfo');
 -      if ($use_pathinfo) {
 +      if ($use_pathinfo and defined $params{'project'}) {
                # try to put as many parameters as possible in PATH_INFO:
                #   - project name
                #   - action
                $href =~ s,/$,,;
  
                # Then add the project name, if present
 -              $href .= "/".esc_url($params{'project'}) if defined $params{'project'};
 +              $href .= "/".esc_url($params{'project'});
                delete $params{'project'};
  
                # since we destructively absorb parameters, we keep this
@@@ -2901,14 -2901,9 +2905,14 @@@ sub git_header_html 
  <meta name="robots" content="index, nofollow"/>
  <title>$title</title>
  EOF
 -# print out each stylesheet that exist
 +      # the stylesheet, favicon etc urls won't work correctly with path_info
 +      # unless we set the appropriate base URL
 +      if ($ENV{'PATH_INFO'}) {
 +              print '<base href="'.esc_url($my_url).'" />\n';
 +      }
 +      # print out each stylesheet that exist, providing backwards capability
 +      # for those people who defined $stylesheet in a config file
        if (defined $stylesheet) {
 -#provides backwards capability for those people who define style sheet in a config file
                print '<link rel="stylesheet" type="text/css" href="'.$stylesheet.'"/>'."\n";
        } else {
                foreach my $stylesheet (@stylesheets) {
@@@ -4503,7 -4498,9 +4507,9 @@@ sub git_summary 
  
        print "</table>\n";
  
-       if (-s "$projectroot/$project/README.html") {
+       # If XSS prevention is on, we don't include README.html.
+       # TODO: Allow a readme in some safe format.
+       if (!$prevent_xss && -s "$projectroot/$project/README.html") {
                print "<div class=\"title\">readme</div>\n" .
                      "<div class=\"readme\">\n";
                insert_file("$projectroot/$project/README.html");
@@@ -4585,33 -4582,28 +4591,33 @@@ sub git_tag 
  }
  
  sub git_blame {
 -      my $fd;
 -      my $ftype;
 -
 +      # permissions
        gitweb_check_feature('blame')
 -          or die_error(403, "Blame view not allowed");
 +              or die_error(403, "Blame view not allowed");
  
 +      # error checking
        die_error(400, "No file name given") unless $file_name;
        $hash_base ||= git_get_head_hash($project);
 -      die_error(404, "Couldn't find base commit") unless ($hash_base);
 +      die_error(404, "Couldn't find base commit") unless $hash_base;
        my %co = parse_commit($hash_base)
                or die_error(404, "Commit not found");
 +      my $ftype = "blob";
        if (!defined $hash) {
                $hash = git_get_hash_by_path($hash_base, $file_name, "blob")
                        or die_error(404, "Error looking up file");
 +      } else {
 +              $ftype = git_get_type($hash);
 +              if ($ftype !~ "blob") {
 +                      die_error(400, "Object is not a blob");
 +              }
        }
 -      $ftype = git_get_type($hash);
 -      if ($ftype !~ "blob") {
 -              die_error(400, "Object is not a blob");
 -      }
 -      open ($fd, "-|", git_cmd(), "blame", '-p', '--',
 -            $file_name, $hash_base)
 +
 +      # run git-blame --porcelain
 +      open my $fd, "-|", git_cmd(), "blame", '-p',
 +              $hash_base, '--', $file_name
                or die_error(500, "Open git-blame failed");
 +
 +      # page header
        git_header_html();
        my $formats_nav =
                $cgi->a({-href => href(action=>"blob", -replay=>1)},
        git_print_page_nav('','', $hash_base,$co{'tree'},$hash_base, $formats_nav);
        git_print_header_div('commit', esc_html($co{'title'}), $hash_base);
        git_print_page_path($file_name, $ftype, $hash_base);
 -      my @rev_color = (qw(light2 dark2));
 +
 +      # page body
 +      my @rev_color = qw(light2 dark2);
        my $num_colors = scalar(@rev_color);
        my $current_color = 0;
 -      my $last_rev;
 +      my %metainfo = ();
 +
        print <<HTML;
  <div class="page_body">
  <table class="blame">
  <tr><th>Commit</th><th>Line</th><th>Data</th></tr>
  HTML
 -      my %metainfo = ();
 -      while (1) {
 -              $_ = <$fd>;
 -              last unless defined $_;
 + LINE:
 +      while (my $line = <$fd>) {
 +              chomp $line;
 +              # the header: <SHA-1> <src lineno> <dst lineno> [<lines in group>]
 +              # no <lines in group> for subsequent lines in group of lines
                my ($full_rev, $orig_lineno, $lineno, $group_size) =
 -                  /^([0-9a-f]{40}) (\d+) (\d+)(?: (\d+))?$/;
 +                 ($line =~ /^([0-9a-f]{40}) (\d+) (\d+)(?: (\d+))?$/);
                if (!exists $metainfo{$full_rev}) {
                        $metainfo{$full_rev} = {};
                }
                my $meta = $metainfo{$full_rev};
 -              while (<$fd>) {
 -                      last if (s/^\t//);
 -                      if (/^(\S+) (.*)$/) {
 +              my $data;
 +              while ($data = <$fd>) {
 +                      chomp $data;
 +                      last if ($data =~ s/^\t//); # contents of line
 +                      if ($data =~ /^(\S+) (.*)$/) {
                                $meta->{$1} = $2;
                        }
                }
 -              my $data = $_;
 -              chomp $data;
 -              my $rev = substr($full_rev, 0, 8);
 +              my $short_rev = substr($full_rev, 0, 8);
                my $author = $meta->{'author'};
 -              my %date = parse_date($meta->{'author-time'},
 -                                    $meta->{'author-tz'});
 +              my %date =
 +                      parse_date($meta->{'author-time'}, $meta->{'author-tz'});
                my $date = $date{'iso-tz'};
                if ($group_size) {
 -                      $current_color = ++$current_color % $num_colors;
 +                      $current_color = ($current_color + 1) % $num_colors;
                }
 -              print "<tr class=\"$rev_color[$current_color]\">\n";
 +              print "<tr id=\"l$lineno\" class=\"$rev_color[$current_color]\">\n";
                if ($group_size) {
                        print "<td class=\"sha1\"";
                        print " title=\"". esc_html($author) . ", $date\"";
                        print $cgi->a({-href => href(action=>"commit",
                                                     hash=>$full_rev,
                                                     file_name=>$file_name)},
 -                                    esc_html($rev));
 +                                    esc_html($short_rev));
                        print "</td>\n";
                }
 -              open (my $dd, "-|", git_cmd(), "rev-parse", "$full_rev^")
 -                      or die_error(500, "Open git-rev-parse failed");
 -              my $parent_commit = <$dd>;
 -              close $dd;
 -              chomp($parent_commit);
 +              my $parent_commit;
 +              if (!exists $meta->{'parent'}) {
 +                      open (my $dd, "-|", git_cmd(), "rev-parse", "$full_rev^")
 +                              or die_error(500, "Open git-rev-parse failed");
 +                      $parent_commit = <$dd>;
 +                      close $dd;
 +                      chomp($parent_commit);
 +                      $meta->{'parent'} = $parent_commit;
 +              } else {
 +                      $parent_commit = $meta->{'parent'};
 +              }
                my $blamed = href(action => 'blame',
                                  file_name => $meta->{'filename'},
                                  hash_base => $parent_commit);
                print "<td class=\"linenr\">";
                print $cgi->a({ -href => "$blamed#l$orig_lineno",
 -                              -id => "l$lineno",
                                -class => "linenr" },
                              esc_html($lineno));
                print "</td>";
        print "</div>";
        close $fd
                or print "Reading blob failed\n";
 +
 +      # page footer
        git_footer_html();
  }
  
@@@ -4764,10 -4745,21 +4770,21 @@@ sub git_blob_plain 
                $save_as .= '.txt';
        }
  
+       # With XSS prevention on, blobs of all types except a few known safe
+       # ones are served with "Content-Disposition: attachment" to make sure
+       # they don't run in our security domain.  For certain image types,
+       # blob view writes an <img> tag referring to blob_plain view, and we
+       # want to be sure not to break that by serving the image as an
+       # attachment (though Firefox 3 doesn't seem to care).
+       my $sandbox = $prevent_xss &&
+               $type !~ m!^(?:text/plain|image/(?:gif|png|jpeg))$!;
        print $cgi->header(
                -type => $type,
                -expires => $expires,
-               -content_disposition => 'inline; filename="' . $save_as . '"');
+               -content_disposition =>
+                       ($sandbox ? 'attachment' : 'inline')
+                       . '; filename="' . $save_as . '"');
        undef $/;
        binmode STDOUT, ':raw';
        print <$fd>;
@@@ -5023,15 -5015,6 +5040,15 @@@ sub git_log 
  
        my $paging_nav = format_paging_nav('log', $hash, $head, $page, $#commitlist >= 100);
  
 +      my ($patch_max) = gitweb_get_feature('patches');
 +      if ($patch_max) {
 +              if ($patch_max < 0 || @commitlist <= $patch_max) {
 +                      $paging_nav .= " &sdot; " .
 +                              $cgi->a({-href => href(action=>"patches", -replay=>1)},
 +                                      "patches");
 +              }
 +      }
 +
        git_header_html();
        git_print_page_nav('log','', $hash,undef,undef, $paging_nav);
  
@@@ -5111,11 -5094,6 +5128,11 @@@ sub git_commit 
                        } @$parents ) .
                        ')';
        }
 +      if (gitweb_check_feature('patches')) {
 +              $formats_nav .= " | " .
 +                      $cgi->a({-href => href(action=>"patch", -replay=>1)},
 +                              "patch");
 +      }
  
        if (!defined $parent) {
                $parent = "--root";
@@@ -5392,14 -5370,7 +5409,14 @@@ sub git_blobdiff_plain 
  }
  
  sub git_commitdiff {
 -      my $format = shift || 'html';
 +      my %params = @_;
 +      my $format = $params{-format} || 'html';
 +
 +      my ($patch_max) = gitweb_get_feature('patches');
 +      if ($format eq 'patch') {
 +              die_error(403, "Patch view not allowed") unless $patch_max;
 +      }
 +
        $hash ||= $hash_base || "HEAD";
        my %co = parse_commit($hash)
            or die_error(404, "Unknown commit object");
                $formats_nav =
                        $cgi->a({-href => href(action=>"commitdiff_plain", -replay=>1)},
                                "raw");
 +              if ($patch_max) {
 +                      $formats_nav .= " | " .
 +                              $cgi->a({-href => href(action=>"patch", -replay=>1)},
 +                                      "patch");
 +              }
  
                if (defined $hash_parent &&
                    $hash_parent ne '-c' && $hash_parent ne '--cc') {
                open $fd, "-|", git_cmd(), "diff-tree", '-r', @diff_opts,
                        '-p', $hash_parent_param, $hash, "--"
                        or die_error(500, "Open git-diff-tree failed");
 -
 +      } elsif ($format eq 'patch') {
 +              # For commit ranges, we limit the output to the number of
 +              # patches specified in the 'patches' feature.
 +              # For single commits, we limit the output to a single patch,
 +              # diverging from the git-format-patch default.
 +              my @commit_spec = ();
 +              if ($hash_parent) {
 +                      if ($patch_max > 0) {
 +                              push @commit_spec, "-$patch_max";
 +                      }
 +                      push @commit_spec, '-n', "$hash_parent..$hash";
 +              } else {
 +                      if ($params{-single}) {
 +                              push @commit_spec, '-1';
 +                      } else {
 +                              if ($patch_max > 0) {
 +                                      push @commit_spec, "-$patch_max";
 +                              }
 +                              push @commit_spec, "-n";
 +                      }
 +                      push @commit_spec, '--root', $hash;
 +              }
 +              open $fd, "-|", git_cmd(), "format-patch", '--encoding=utf8',
 +                      '--stdout', @commit_spec
 +                      or die_error(500, "Open git-format-patch failed");
        } else {
                die_error(400, "Unknown commitdiff format");
        }
                        print to_utf8($line) . "\n";
                }
                print "---\n\n";
 +      } elsif ($format eq 'patch') {
 +              my $filename = basename($project) . "-$hash.patch";
 +
 +              print $cgi->header(
 +                      -type => 'text/plain',
 +                      -charset => 'utf-8',
 +                      -expires => $expires,
 +                      -content_disposition => 'inline; filename="' . "$filename" . '"');
        }
  
        # write patch
                print <$fd>;
                close $fd
                        or print "Reading git-diff-tree failed\n";
 +      } elsif ($format eq 'patch') {
 +              local $/ = undef;
 +              print <$fd>;
 +              close $fd
 +                      or print "Reading git-format-patch failed\n";
        }
  }
  
  sub git_commitdiff_plain {
 -      git_commitdiff('plain');
 +      git_commitdiff(-format => 'plain');
 +}
 +
 +# format-patch-style patches
 +sub git_patch {
 +      git_commitdiff(-format => 'patch', -single=> 1);
 +}
 +
 +sub git_patches {
 +      git_commitdiff(-format => 'patch');
  }
  
  sub git_history {
@@@ -5975,14 -5895,6 +5992,14 @@@ sub git_shortlog 
                        $cgi->a({-href => href(-replay=>1, page=>$page+1),
                                 -accesskey => "n", -title => "Alt-n"}, "next");
        }
 +      my $patch_max = gitweb_check_feature('patches');
 +      if ($patch_max) {
 +              if ($patch_max < 0 || @commitlist <= $patch_max) {
 +                      $paging_nav .= " &sdot; " .
 +                              $cgi->a({-href => href(action=>"patches", -replay=>1)},
 +                                      "patches");
 +              }
 +      }
  
        git_header_html();
        git_print_page_nav('shortlog','', $hash,$hash,$hash, $paging_nav);
@@@ -6020,25 -5932,7 +6037,25 @@@ sub git_feed 
        }
        if (defined($commitlist[0])) {
                %latest_commit = %{$commitlist[0]};
 -              %latest_date   = parse_date($latest_commit{'author_epoch'});
 +              my $latest_epoch = $latest_commit{'committer_epoch'};
 +              %latest_date   = parse_date($latest_epoch);
 +              my $if_modified = $cgi->http('IF_MODIFIED_SINCE');
 +              if (defined $if_modified) {
 +                      my $since;
 +                      if (eval { require HTTP::Date; 1; }) {
 +                              $since = HTTP::Date::str2time($if_modified);
 +                      } elsif (eval { require Time::ParseDate; 1; }) {
 +                              $since = Time::ParseDate::parsedate($if_modified, GMT => 1);
 +                      }
 +                      if (defined $since && $latest_epoch <= $since) {
 +                              print $cgi->header(
 +                                      -type => $content_type,
 +                                      -charset => 'utf-8',
 +                                      -last_modified => $latest_date{'rfc2822'},
 +                                      -status => '304 Not Modified');
 +                              return;
 +                      }
 +              }
                print $cgi->header(
                        -type => $content_type,
                        -charset => 'utf-8',
                print "<title>$title</title>\n" .
                      "<link>$alt_url</link>\n" .
                      "<description>$descr</description>\n" .
 -                    "<language>en</language>\n";
 +                    "<language>en</language>\n" .
 +                    # project owner is responsible for 'editorial' content
 +                    "<managingEditor>$owner</managingEditor>\n";
 +              if (defined $logo || defined $favicon) {
 +                      # prefer the logo to the favicon, since RSS
 +                      # doesn't allow both
 +                      my $img = esc_url($logo || $favicon);
 +                      print "<image>\n" .
 +                            "<url>$img</url>\n" .
 +                            "<title>$title</title>\n" .
 +                            "<link>$alt_url</link>\n" .
 +                            "</image>\n";
 +              }
 +              if (%latest_date) {
 +                      print "<pubDate>$latest_date{'rfc2822'}</pubDate>\n";
 +                      print "<lastBuildDate>$latest_date{'rfc2822'}</lastBuildDate>\n";
 +              }
 +              print "<generator>gitweb v.$version/$git_version</generator>\n";
        } elsif ($format eq 'atom') {
                print <<XML;
  <feed xmlns="http://www.w3.org/2005/Atom">
@@@ -6141,7 -6018,6 +6158,7 @@@ XM
                } else {
                        print "<updated>$latest_date{'iso-8601'}</updated>\n";
                }
 +              print "<generator version='$version/$git_version'>gitweb</generator>\n";
        }
  
        # contents
@@@ -6263,11 -6139,7 +6280,11 @@@ sub git_atom 
  sub git_opml {
        my @list = git_get_projects_list();
  
 -      print $cgi->header(-type => 'text/xml', -charset => 'utf-8');
 +      print $cgi->header(
 +              -type => 'text/xml',
 +              -charset => 'utf-8',
 +              -content_disposition => 'inline; filename="opml.xml"');
 +
        print <<XML;
  <?xml version="1.0" encoding="utf-8"?>
  <opml version="1.0">
@@@ -6291,8 -6163,8 +6308,8 @@@ XM
                }
  
                my $path = esc_html(chop_str($proj{'path'}, 25, 5));
 -              my $rss  = "$my_url?p=$proj{'path'};a=rss";
 -              my $html = "$my_url?p=$proj{'path'};a=summary";
 +              my $rss  = href('project' => $proj{'path'}, 'action' => 'rss', -full => 1);
 +              my $html = href('project' => $proj{'path'}, 'action' => 'summary', -full => 1);
                print "<outline type=\"rss\" text=\"$path\" title=\"$path\" xmlUrl=\"$rss\" htmlUrl=\"$html\"/>\n";
        }
        print <<XML;