do_for_each_reflog(): return early on error
[gitweb.git] / gitweb / gitweb.perl
index b67972ec5d9e2ddd78ffccada03361ed0bc63a14..4171de86e370518e9ec5bf10fcb919ec42019ba2 100755 (executable)
@@ -760,6 +760,7 @@ sub check_loadavg {
        search_use_regexp => "sr",
        ctag => "by_tag",
        diff_style => "ds",
+       project_filter => "pf",
        # this must be last entry (for manipulation from JavaScript)
        javascript => "js"
 );
@@ -976,7 +977,7 @@ sub evaluate_path_info {
 
 our ($action, $project, $file_name, $file_parent, $hash, $hash_parent, $hash_base,
      $hash_parent_base, @extra_options, $page, $searchtype, $search_use_regexp,
-     $searchtext, $search_regexp);
+     $searchtext, $search_regexp, $project_filter);
 sub evaluate_and_validate_params {
        our $action = $input_params{'action'};
        if (defined $action) {
@@ -994,6 +995,13 @@ sub evaluate_and_validate_params {
                }
        }
 
+       our $project_filter = $input_params{'project_filter'};
+       if (defined $project_filter) {
+               if (!validate_pathname($project_filter)) {
+                       die_error(404, "Invalid project_filter parameter");
+               }
+       }
+
        our $file_name = $input_params{'file_name'};
        if (defined $file_name) {
                if (!validate_pathname($file_name)) {
@@ -1716,6 +1724,88 @@ sub chop_and_escape_str {
        }
 }
 
+# Highlight selected fragments of string, using given CSS class,
+# and escape HTML.  It is assumed that fragments do not overlap.
+# Regions are passed as list of pairs (array references).
+#
+# Example: esc_html_hl_regions("foobar", "mark", [ 0, 3 ]) returns
+# '<span class="mark">foo</span>bar'
+sub esc_html_hl_regions {
+       my ($str, $css_class, @sel) = @_;
+       return esc_html($str) unless @sel;
+
+       my $out = '';
+       my $pos = 0;
+
+       for my $s (@sel) {
+               $out .= esc_html(substr($str, $pos, $s->[0] - $pos))
+                       if ($s->[0] - $pos > 0);
+               $out .= $cgi->span({-class => $css_class},
+                                  esc_html(substr($str, $s->[0], $s->[1] - $s->[0])));
+
+               $pos = $s->[1];
+       }
+       $out .= esc_html(substr($str, $pos))
+               if ($pos < length($str));
+
+       return $out;
+}
+
+# return positions of beginning and end of each match
+sub matchpos_list {
+       my ($str, $regexp) = @_;
+       return unless (defined $str && defined $regexp);
+
+       my @matches;
+       while ($str =~ /$regexp/g) {
+               push @matches, [$-[0], $+[0]];
+       }
+       return @matches;
+}
+
+# highlight match (if any), and escape HTML
+sub esc_html_match_hl {
+       my ($str, $regexp) = @_;
+       return esc_html($str) unless defined $regexp;
+
+       my @matches = matchpos_list($str, $regexp);
+       return esc_html($str) unless @matches;
+
+       return esc_html_hl_regions($str, 'match', @matches);
+}
+
+
+# highlight match (if any) of shortened string, and escape HTML
+sub esc_html_match_hl_chopped {
+       my ($str, $chopped, $regexp) = @_;
+       return esc_html_match_hl($str, $regexp) unless defined $chopped;
+
+       my @matches = matchpos_list($str, $regexp);
+       return esc_html($chopped) unless @matches;
+
+       # filter matches so that we mark chopped string
+       my $tail = "... "; # see chop_str
+       unless ($chopped =~ s/\Q$tail\E$//) {
+               $tail = '';
+       }
+       my $chop_len = length($chopped);
+       my $tail_len = length($tail);
+       my @filtered;
+
+       for my $m (@matches) {
+               if ($m->[0] > $chop_len) {
+                       push @filtered, [ $chop_len, $chop_len + $tail_len ] if ($tail_len > 0);
+                       last;
+               } elsif ($m->[1] > $chop_len) {
+                       push @filtered, [ $m->[0], $chop_len + $tail_len ];
+                       last;
+               }
+               push @filtered, $m;
+       }
+
+       return esc_html_hl_regions($chopped . $tail, 'match', @filtered);
+}
+
 ## ----------------------------------------------------------------------
 ## functions returning short strings
 
@@ -2838,10 +2928,9 @@ sub git_get_project_url_list {
 
 sub git_get_projects_list {
        my $filter = shift || '';
+       my $paranoid = shift;
        my @list;
 
-       $filter =~ s/\.git$//;
-
        if (-d $projects_list) {
                # search in directory
                my $dir = $projects_list;
@@ -2850,7 +2939,7 @@ sub git_get_projects_list {
                my $pfxlen = length("$dir");
                my $pfxdepth = ($dir =~ tr!/!!);
                # when filtering, search only given subdirectory
-               if ($filter) {
+               if ($filter && !$paranoid) {
                        $dir .= "/$filter";
                        $dir =~ s!/+$!!;
                }
@@ -2875,6 +2964,10 @@ sub git_get_projects_list {
                                }
 
                                my $path = substr($File::Find::name, $pfxlen + 1);
+                               # paranoidly only filter here
+                               if ($paranoid && $filter && $path !~ m!^\Q$filter\E/!) {
+                                       next;
+                               }
                                # we check related file in $projectroot
                                if (check_export_ok("$projectroot/$path")) {
                                        push @list, { path => $path };
@@ -2985,6 +3078,10 @@ sub search_projects_list {
        return @$projlist
                unless ($tagfilter || $search_re);
 
+       # searching projects require filling to be run before it;
+       fill_project_list_info($projlist,
+                              $tagfilter  ? 'ctags' : (),
+                              $search_re ? ('path', 'descr') : ());
        my @projects;
  PROJECT:
        foreach my $pr (@$projlist) {
@@ -3740,7 +3837,12 @@ sub run_highlighter {
 sub get_page_title {
        my $title = to_utf8($site_name);
 
-       return $title unless (defined $project);
+       unless (defined $project) {
+               if (defined $project_filter) {
+                       $title .= " - projects in '" . esc_path($project_filter) . "'";
+               }
+               return $title;
+       }
        $title .= " - " . to_utf8($project);
 
        return $title unless (defined $action);
@@ -3784,6 +3886,7 @@ sub print_feed_meta {
                                '-type' => "application/$type+xml"
                        );
 
+                       $href_params{'extra_options'} = undef;
                        $href_params{'action'} = $type;
                        $link_attr{'-href'} = href(%href_params);
                        print "<link ".
@@ -3834,12 +3937,27 @@ sub print_header_links {
        }
 }
 
+sub print_nav_breadcrumbs_path {
+       my $dirprefix = undef;
+       while (my $part = shift) {
+               $dirprefix .= "/" if defined $dirprefix;
+               $dirprefix .= $part;
+               print $cgi->a({-href => href(project => undef,
+                                            project_filter => $dirprefix,
+                                            action => "project_list")},
+                             esc_html($part)) . " / ";
+       }
+}
+
 sub print_nav_breadcrumbs {
        my %opts = @_;
 
        print $cgi->a({-href => esc_url($home_link)}, $home_link_str) . " / ";
        if (defined $project) {
-               print $cgi->a({-href => href(action=>"summary")}, esc_html($project));
+               my @dirname = split '/', $project;
+               my $projectbasename = pop @dirname;
+               print_nav_breadcrumbs_path(@dirname);
+               print $cgi->a({-href => href(action=>"summary")}, esc_html($projectbasename));
                if (defined $action) {
                        my $action_print = $action ;
                        if (defined $opts{-action_extra}) {
@@ -3852,6 +3970,8 @@ sub print_nav_breadcrumbs {
                        print " / $opts{-action_extra}";
                }
                print "\n";
+       } elsif (defined $project_filter) {
+               print_nav_breadcrumbs_path(split '/', $project_filter);
        }
 }
 
@@ -3974,9 +4094,11 @@ sub git_footer_html {
                }
 
        } else {
-               print $cgi->a({-href => href(project=>undef, action=>"opml"),
+               print $cgi->a({-href => href(project=>undef, action=>"opml",
+                                            project_filter => $project_filter),
                              -class => $feed_class}, "OPML") . " ";
-               print $cgi->a({-href => href(project=>undef, action=>"project_index"),
+               print $cgi->a({-href => href(project=>undef, action=>"project_index",
+                                            project_filter => $project_filter),
                              -class => $feed_class}, "TXT") . "\n";
        }
        print "</div>\n"; # class="page_footer"
@@ -5134,35 +5256,98 @@ sub git_patchset_body {
 
 # . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
 
-# fills project list info (age, description, owner, category, forks)
+sub git_project_search_form {
+       my ($searchtext, $search_use_regexp) = @_;
+
+       my $limit = '';
+       if ($project_filter) {
+               $limit = " in '$project_filter/'";
+       }
+
+       print "<div class=\"projsearch\">\n";
+       print $cgi->startform(-method => 'get', -action => $my_uri) .
+             $cgi->hidden(-name => 'a', -value => 'project_list')  . "\n";
+       print $cgi->hidden(-name => 'pf', -value => $project_filter). "\n"
+               if (defined $project_filter);
+       print $cgi->textfield(-name => 's', -value => $searchtext,
+                             -title => "Search project by name and description$limit",
+                             -size => 60) . "\n" .
+             "<span title=\"Extended regular expression\">" .
+             $cgi->checkbox(-name => 'sr', -value => 1, -label => 're',
+                            -checked => $search_use_regexp) .
+             "</span>\n" .
+             $cgi->submit(-name => 'btnS', -value => 'Search') .
+             $cgi->end_form() . "\n" .
+             $cgi->a({-href => href(project => undef, searchtext => undef,
+                                    project_filter => $project_filter)},
+                     esc_html("List all projects$limit")) . "<br />\n";
+       print "</div>\n";
+}
+
+# entry for given @keys needs filling if at least one of keys in list
+# is not present in %$project_info
+sub project_info_needs_filling {
+       my ($project_info, @keys) = @_;
+
+       # return List::MoreUtils::any { !exists $project_info->{$_} } @keys;
+       foreach my $key (@keys) {
+               if (!exists $project_info->{$key}) {
+                       return 1;
+               }
+       }
+       return;
+}
+
+# fills project list info (age, description, owner, category, forks, etc.)
 # for each project in the list, removing invalid projects from
-# returned list
+# returned list, or fill only specified info.
+#
+# Invalid projects are removed from the returned list if and only if you
+# ask 'age' or 'age_string' to be filled, because they are the only fields
+# that run unconditionally git command that requires repository, and
+# therefore do always check if project repository is invalid.
+#
+# USAGE:
+# * fill_project_list_info(\@project_list, 'descr_long', 'ctags')
+#   ensures that 'descr_long' and 'ctags' fields are filled
+# * @project_list = fill_project_list_info(\@project_list)
+#   ensures that all fields are filled (and invalid projects removed)
+#
 # NOTE: modifies $projlist, but does not remove entries from it
 sub fill_project_list_info {
-       my $projlist = shift;
+       my ($projlist, @wanted_keys) = @_;
        my @projects;
+       my $filter_set = sub { return @_; };
+       if (@wanted_keys) {
+               my %wanted_keys = map { $_ => 1 } @wanted_keys;
+               $filter_set = sub { return grep { $wanted_keys{$_} } @_; };
+       }
 
        my $show_ctags = gitweb_check_feature('ctags');
  PROJECT:
        foreach my $pr (@$projlist) {
-               my (@activity) = git_get_last_activity($pr->{'path'});
-               unless (@activity) {
-                       next PROJECT;
+               if (project_info_needs_filling($pr, $filter_set->('age', 'age_string'))) {
+                       my (@activity) = git_get_last_activity($pr->{'path'});
+                       unless (@activity) {
+                               next PROJECT;
+                       }
+                       ($pr->{'age'}, $pr->{'age_string'}) = @activity;
                }
-               ($pr->{'age'}, $pr->{'age_string'}) = @activity;
-               if (!defined $pr->{'descr'}) {
+               if (project_info_needs_filling($pr, $filter_set->('descr', 'descr_long'))) {
                        my $descr = git_get_project_description($pr->{'path'}) || "";
                        $descr = to_utf8($descr);
                        $pr->{'descr_long'} = $descr;
                        $pr->{'descr'} = chop_str($descr, $projects_list_description_width, 5);
                }
-               if (!defined $pr->{'owner'}) {
+               if (project_info_needs_filling($pr, $filter_set->('owner'))) {
                        $pr->{'owner'} = git_get_project_owner("$pr->{'path'}") || "";
                }
-               if ($show_ctags) {
+               if ($show_ctags &&
+                   project_info_needs_filling($pr, $filter_set->('ctags'))) {
                        $pr->{'ctags'} = git_get_project_ctags($pr->{'path'});
                }
-               if ($projects_list_group_categories && !defined $pr->{'category'}) {
+               if ($projects_list_group_categories &&
+                   project_info_needs_filling($pr, $filter_set->('category'))) {
                        my $cat = git_get_project_category($pr->{'path'}) ||
                                                           $project_list_default_category;
                        $pr->{'category'} = to_utf8($cat);
@@ -5266,10 +5451,17 @@ sub git_project_list_rows {
                        print "</td>\n";
                }
                print "<td>" . $cgi->a({-href => href(project=>$pr->{'path'}, action=>"summary"),
-                                       -class => "list"}, esc_html($pr->{'path'})) . "</td>\n" .
+                                       -class => "list"},
+                                      esc_html_match_hl($pr->{'path'}, $search_regexp)) .
+                     "</td>\n" .
                      "<td>" . $cgi->a({-href => href(project=>$pr->{'path'}, action=>"summary"),
-                                       -class => "list", -title => $pr->{'descr_long'}},
-                                       esc_html($pr->{'descr'})) . "</td>\n" .
+                                       -class => "list",
+                                       -title => $pr->{'descr_long'}},
+                                       $search_regexp
+                                       ? esc_html_match_hl_chopped($pr->{'descr_long'},
+                                                                   $pr->{'descr'}, $search_regexp)
+                                       : esc_html($pr->{'descr'})) .
+                     "</td>\n" .
                      "<td><i>" . chop_and_escape_str($pr->{'owner'}, 15) . "</i></td>\n";
                print "<td class=\"". age_class($pr->{'age'}) . "\">" .
                      (defined $pr->{'age_string'} ? $pr->{'age_string'} : "No commits") . "</td>\n" .
@@ -5298,12 +5490,13 @@ sub git_project_list_body {
        # filtering out forks before filling info allows to do less work
        @projects = filter_forks_from_projects_list(\@projects)
                if ($check_forks);
-       @projects = fill_project_list_info(\@projects);
-       # searching projects require filling to be run before it
+       # search_projects_list pre-fills required info
        @projects = search_projects_list(\@projects,
                                         'search_regexp' => $search_regexp,
                                         'tagfilter'  => $tagfilter)
                if ($tagfilter || $search_regexp);
+       # fill the rest
+       @projects = fill_project_list_info(\@projects);
 
        $order ||= $default_projects_order;
        $from = 0 unless defined $from;
@@ -5991,7 +6184,7 @@ sub git_project_list {
                die_error(400, "Unknown order parameter");
        }
 
-       my @list = git_get_projects_list();
+       my @list = git_get_projects_list($project_filter, $strict_export);
        if (!@list) {
                die_error(404, "No projects found");
        }
@@ -6002,11 +6195,8 @@ sub git_project_list {
                insert_file($home_text);
                print "</div>\n";
        }
-       print $cgi->startform(-method => "get") .
-             "<p class=\"projsearch\">Search:\n" .
-             $cgi->textfield(-name => "s", -value => $searchtext, -override => 1) . "\n" .
-             "</p>" .
-             $cgi->end_form() . "\n";
+
+       git_project_search_form($searchtext, $search_use_regexp);
        git_project_list_body(\@list, $order);
        git_footer_html();
 }
@@ -6017,7 +6207,9 @@ sub git_forks {
                die_error(400, "Unknown order parameter");
        }
 
-       my @list = git_get_projects_list($project);
+       my $filter = $project;
+       $filter =~ s/\.git$//;
+       my @list = git_get_projects_list($filter);
        if (!@list) {
                die_error(404, "No forks found");
        }
@@ -6030,7 +6222,7 @@ sub git_forks {
 }
 
 sub git_project_index {
-       my @projects = git_get_projects_list();
+       my @projects = git_get_projects_list($project_filter, $strict_export);
        if (!@projects) {
                die_error(404, "No projects found");
        }
@@ -6076,7 +6268,9 @@ sub git_summary {
 
        if ($check_forks) {
                # find forks of a project
-               @forklist = git_get_projects_list($project);
+               my $filter = $project;
+               $filter =~ s/\.git$//;
+               @forklist = git_get_projects_list($filter);
                # filter out forks of forks
                @forklist = filter_forks_from_projects_list(\@forklist)
                        if (@forklist);
@@ -6810,6 +7004,28 @@ sub snapshot_name {
        return wantarray ? ($name, $name) : $name;
 }
 
+sub exit_if_unmodified_since {
+       my ($latest_epoch) = @_;
+       our $cgi;
+
+       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) {
+                       my %latest_date = parse_date($latest_epoch);
+                       print $cgi->header(
+                               -last_modified => $latest_date{'rfc2822'},
+                               -status => '304 Not Modified');
+                       goto DONE_GITWEB;
+               }
+       }
+}
+
 sub git_snapshot {
        my $format = $input_params{'snapshot_format'};
        if (!@snapshot_fmts) {
@@ -6836,6 +7052,10 @@ sub git_snapshot {
 
        my ($name, $prefix) = snapshot_name($project, $hash);
        my $filename = "$name$known_snapshot_formats{$format}{'suffix'}";
+
+       my %co = parse_commit($hash);
+       exit_if_unmodified_since($co{'committer_epoch'}) if %co;
+
        my $cmd = quote_command(
                git_cmd(), 'archive',
                "--format=$known_snapshot_formats{$format}{'format'}",
@@ -6845,9 +7065,15 @@ sub git_snapshot {
        }
 
        $filename =~ s/(["\\])/\\$1/g;
+       my %latest_date;
+       if (%co) {
+               %latest_date = parse_date($co{'committer_epoch'}, $co{'committer_tz'});
+       }
+
        print $cgi->header(
                -type => $known_snapshot_formats{$format}{'type'},
                -content_disposition => 'inline; filename="' . $filename . '"',
+               %co ? (-last_modified => $latest_date{'rfc2822'}) : (),
                -status => '200 OK');
 
        open my $fd, "-|", $cmd
@@ -7627,33 +7853,14 @@ sub git_feed {
        if (defined($commitlist[0])) {
                %latest_commit = %{$commitlist[0]};
                my $latest_epoch = $latest_commit{'committer_epoch'};
-               %latest_date   = parse_date($latest_epoch, $latest_commit{'comitter_tz'});
-               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',
-                       -last_modified => $latest_date{'rfc2822'});
-       } else {
-               print $cgi->header(
-                       -type => $content_type,
-                       -charset => 'utf-8');
+               exit_if_unmodified_since($latest_epoch);
+               %latest_date = parse_date($latest_epoch, $latest_commit{'comitter_tz'});
        }
+       print $cgi->header(
+               -type => $content_type,
+               -charset => 'utf-8',
+               %latest_date ? (-last_modified => $latest_date{'rfc2822'}) : (),
+               -status => '200 OK');
 
        # Optimization: skip generating the body if client asks only
        # for Last-Modified date.
@@ -7867,7 +8074,7 @@ sub git_atom {
 }
 
 sub git_opml {
-       my @list = git_get_projects_list();
+       my @list = git_get_projects_list($project_filter, $strict_export);
        if (!@list) {
                die_error(404, "No projects found");
        }
@@ -7878,11 +8085,17 @@ sub git_opml {
                -content_disposition => 'inline; filename="opml.xml"');
 
        my $title = esc_html($site_name);
+       my $filter = " within subdirectory ";
+       if (defined $project_filter) {
+               $filter .= esc_html($project_filter);
+       } else {
+               $filter = "";
+       }
        print <<XML;
 <?xml version="1.0" encoding="utf-8"?>
 <opml version="1.0">
 <head>
-  <title>$title OPML Export</title>
+  <title>$title OPML Export$filter</title>
 </head>
 <body>
 <outline text="git RSS feeds">