# Copyright (C) all contributors # License: AGPL-3.0+ # # Standalone code repository viewer for users w/o cgit. # This isn't intended to replicate all of cgit, but merely to be a # "good enough" viewer with search support and some UI hints to encourage # cloning + command-line usage. package PublicInbox::WwwCoderepo; use v5.12; use parent qw(PublicInbox::WwwStream); use File::Temp 0.19 (); # newdir use POSIX qw(O_RDWR F_GETFL); use PublicInbox::ViewVCS; use PublicInbox::WwwStatic qw(r); use PublicInbox::GitHTTPBackend; use PublicInbox::WwwStream; use PublicInbox::Hval qw(ascii_html); use PublicInbox::ViewDiff qw(uri_escape_path); use PublicInbox::RepoSnapshot; use PublicInbox::RepoAtom; use PublicInbox::RepoTree; my @EACH_REF = (qw(git for-each-ref --sort=-creatordate), "--format=%(HEAD)%00".join('%00', map { "%($_)" } qw(objectname refname:short subject creatordate:short))); my $EACH_REF = "@EACH_REF[0..2] '$EACH_REF[3]'"; my $HEADS_CMD = <<''; # heads (aka `branches'): $ git for-each-ref --sort=-creatordate refs/heads \ --format='%(HEAD) %(refname:short) %(subject) (%(creatordate:short))' my $TAGS_CMD = <<''; # tags: $ git for-each-ref --sort=-creatordate refs/tags \ --format='%(refname:short) %(subject) (%(creatordate:short))' my $NO_HEADS = "# no heads (branches), yet...\n"; my $NO_TAGS = "# no tags, yet...\n"; # shared with PublicInbox::Cgit sub prepare_coderepos { my ($self) = @_; my $pi_cfg = $self->{pi_cfg}; # TODO: support gitweb and other repository viewers? $pi_cfg->parse_cgitrc(undef, 0); my $code_repos = $pi_cfg->{-code_repos}; for my $k (grep(/\Acoderepo\.(?:.+)\.dir\z/, keys %$pi_cfg)) { $k = substr($k, length('coderepo.'), -length('.dir')); $code_repos->{$k} //= $pi_cfg->fill_code_repo($k); } # associate inboxes and extindices with coderepos for search: for my $k (grep(/\Apublicinbox\.(?:.+)\.coderepo\z/, keys %$pi_cfg)) { $k = substr($k, length('publicinbox.'), -length('.coderepo')); my $ibx = $pi_cfg->lookup_name($k) // next; $pi_cfg->repo_objs($ibx); } for my $k (grep(/\Aextindex\.(?:.+)\.coderepo\z/, keys %$pi_cfg)) { $k = substr($k, length('extindex.'), -length('.coderepo')); my $eidx = $pi_cfg->lookup_ei($k) // next; $pi_cfg->repo_objs($eidx); } } sub new { my ($cls, $pi_cfg) = @_; my $self = bless { pi_cfg => $pi_cfg }, $cls; prepare_coderepos($self); $self->{snapshots} = do { my $s = $pi_cfg->{'coderepo.snapshots'} // ''; $s eq 'all' ? \%PublicInbox::RepoSnapshot::FMT_TYPES : +{ map { $_ => 1 } split(/\s+/, $s) }; }; $self->{$_} = 10 for qw(summary_branches summary_tags); $self->{$_} = 10 for qw(summary_log); # try reuse STDIN if it's already /dev/null open $self->{log_fh}, '+>', '/dev/null' or die "open: $!"; my @l = stat($self->{log_fh}) or die "stat: $!"; my @s = stat(STDIN) or die "stat(STDIN): $!"; if ("@l[0, 1]" eq "@s[0, 1]") { my $f = fcntl(STDIN, F_GETFL, 0) // die "F_GETFL: $!"; $self->{log_fh} = *STDIN{IO} if $f & O_RDWR; } $self; } sub _snapshot_link_prep { my ($ctx) = @_; my @s = sort keys %{$ctx->{wcr}->{snapshots}} or return (); my $n = $ctx->{git}->local_nick // die "BUG: $ctx->{git_dir} nick"; $n =~ s!\.git/*\z!!; ($n) = ($n =~ m!([^/]+)/*\z!); (ascii_html($n).'-', @s); } sub _refs_heads_link { my ($line, $upfx) = @_; my ($pfx, $oid, $ref, $s, $cd) = split(/\0/, $line); my $align = length($ref) < 12 ? ' ' x (12 - length($ref)) : ''; ("$pfx ", ascii_html($ref), "$align ", ascii_html($s), " ($cd)\n") } sub _refs_tags_link { my ($line, $upfx, $snap_pfx, @snap_fmt) = @_; my (undef, $oid, $ref, $s, $cd) = split(/\0/, $line); my $align = length($ref) < 12 ? ' ' x (12 - length($ref)) : ''; if (@snap_fmt) { my $v = $ref; $v =~ s/\A[vV]//; @snap_fmt = map { qq{ $_} } @snap_fmt; substr($snap_fmt[0], 0, 1) = "\t"; } ("", ascii_html($ref), "$align ", ascii_html($s), " ($cd)", @snap_fmt, "\n"); } sub summary_finish { my ($ctx) = @_; my $wcb = delete($ctx->{env}->{'qspawn.wcb'}) or return; # already done my @x = split(/\n\n/sm, delete($ctx->{-each_refs}), 3); PublicInbox::WwwStream::html_init($ctx); my $zfh = $ctx->zfh; # git log my @r = split(/\n/s, pop(@x)); my $last = scalar(@r) > $ctx->{wcr}->{summary_log} ? pop(@r) : undef; my $tip_html = ''; my $tip = $ctx->{qp}->{h}; $tip_html .= ' '.ascii_html($tip).' --' if defined $tip; print $zfh <\$ git log --pretty=format:'%h %s (%cs)%d'$tip_html EOM for (@r) { my $d; # decorations s/^ \(([^\)]+)\)// and $d = $1; substr($_, 0, 1, ''); my ($H, $h, $cs, $s) = split(/ /, $_, 4); print $zfh "$h ", ascii_html($s), " (", $cs, ")\n"; print $zfh "\t(", ascii_html($d), ")\n" if $d; } print $zfh '# no commits in `', ($tip//'HEAD'),"', yet\n\n" if !@r; print $zfh "...\n" if $last; # README my ($bref, $oid, $ref_path) = @{delete $ctx->{-readme}}; if ($bref) { my $l = PublicInbox::Linkify->new; $$bref =~ s/\s*\z//sm; my (undef, $path) = split(/:/, $ref_path, 2); # HEAD:README print $zfh "\n\$ " . qq(git cat-file blob ). ascii_html($ref_path), "\n", $l->to_html($$bref), '
';
	}

	# refs/heads
	print $zfh '', $HEADS_CMD , '';
	@r = split(/^/sm, shift(@x) // '');
	$last = scalar(@r) > $ctx->{wcr}->{summary_branches} ? pop(@r) : undef;
	chomp(@r);
	for (@r) { print $zfh _refs_heads_link($_, './') }
	print $zfh $NO_HEADS if !@r;
	print $zfh qq(...\n) if $last;
	print $zfh "\n", $TAGS_CMD, '';
	@r = split(/^/sm, shift(@x) // '');
	$last = scalar(@r) > $ctx->{wcr}->{summary_tags} ? pop(@r) : undef;
	my ($snap_pfx, @snap_fmt) = _snapshot_link_prep($ctx);
	chomp @r;
	for (@r) { print $zfh _refs_tags_link($_, './', $snap_pfx, @snap_fmt) }
	print $zfh $NO_TAGS if !@r;
	print $zfh qq(...\n) if $last;
	$wcb->($ctx->html_done('
')); } sub capture_refs ($$) { # psgi_qx callback to capture git-for-each-ref + git-log my ($bref, $ctx) = @_; my $qsp_err = delete $ctx->{-qsp_err}; utf8::decode($$bref); $ctx->{-each_refs} = $$bref; summary_finish($ctx) if $ctx->{-readme}; } sub set_readme { # git->cat_async callback my ($bref, $oid, $type, $size, $ctx) = @_; my $ref_path = shift @{$ctx->{-nr_readme_tries}}; # e.g. HEAD:README if ($type eq 'blob' && !$ctx->{-readme}) { $ctx->{-readme} = [ $bref, $oid, $ref_path ]; } elsif (scalar @{$ctx->{-nr_readme_tries}} == 0) { $ctx->{-readme} //= []; # nothing left to try } # or try another README... summary_finish($ctx) if $ctx->{-each_refs} && $ctx->{-readme}; } sub summary { my ($self, $ctx) = @_; $ctx->{wcr} = $self; my $tip = $ctx->{qp}->{h}; # same as cgit if (defined $tip && $tip eq '') { delete $ctx->{qp}->{h}; undef($tip); } my $nb = $self->{summary_branches} + 1; my $nt = $self->{summary_tags} + 1; my $nl = $self->{summary_log} + 1; my @cmd = (qw(/bin/sh -c), "$EACH_REF --count=$nb refs/heads; echo && " . "$EACH_REF --count=$nt refs/tags; echo && " . qq(git log -$nl --pretty=format:'%d %H %h %cs %s' "\$@" --)); push @cmd, 'git', $tip if defined($tip); my $qsp = PublicInbox::Qspawn->new(\@cmd, { GIT_DIR => $ctx->{git}->{git_dir} }, { quiet => 1, 2 => $self->{log_fh} }); $qsp->{qsp_err} = \($ctx->{-qsp_err} = ''); $tip //= 'HEAD'; my @try = ("$tip:README", "$tip:README.md"); # TODO: configurable $ctx->{-nr_readme_tries} = [ @try ]; PublicInbox::ViewVCS::do_cat_async($ctx, \&set_readme, @try); sub { # $_[0] => PublicInbox::HTTP::{Identity,Chunked} $ctx->{env}->{'qspawn.wcb'} = $_[0]; $qsp->psgi_qx($ctx->{env}, undef, \&capture_refs, $ctx); } } # called by GzipFilter->close after translate sub zflush { $_[0]->SUPER::zflush('', $_[0]->_html_end) } # called by GzipFilter->write or GetlineBody->getline sub translate { my $ctx = shift; my $rec = $_[0] // return zflush($ctx); # getline my @out; my $fbuf = delete($ctx->{fbuf}) // shift; $fbuf .= shift while @_; if ($ctx->{-heads}) { while ($fbuf =~ s/\A([^\n]+)\n//s) { utf8::decode(my $x = $1); push @out, _refs_heads_link($x, '../../'); } } else { my ($snap_pfx, @snap_fmt) = _snapshot_link_prep($ctx); while ($fbuf =~ s/\A([^\n]+)\n//s) { utf8::decode(my $x = $1); push @out, _refs_tags_link($x, '../../', $snap_pfx, @snap_fmt); } } $ctx->{fbuf} = $fbuf; $ctx->SUPER::translate(@out); } sub _refs_parse_hdr { # {parse_hdr} for Qspawn my ($r, $bref, $ctx) = @_; my ($code, $top); if ($r == 0) { $code = 404; $top = $ctx->{-heads} ? $NO_HEADS : $NO_TAGS; } else { $code = 200; $top = $ctx->{-heads} ? $HEADS_CMD : $TAGS_CMD; } PublicInbox::WwwStream::html_init($ctx); bless $ctx, __PACKAGE__; # re-bless for ->translate print { $ctx->{zfh} } '
', $top;
	[ $code, delete($ctx->{-res_hdr}), $ctx ]; # [2] is qspawn.filter
}

sub refs_foo { # /$REPO/refs/{heads,tags} endpoints
	my ($self, $ctx, $pfx) = @_;
	$ctx->{wcr} = $self;
	$ctx->{-upfx} = '../../';
	$ctx->{-heads} = 1 if $pfx eq 'refs/heads';
	my $qsp = PublicInbox::Qspawn->new([@EACH_REF, $pfx ],
					{ GIT_DIR => $ctx->{git}->{git_dir} });
	$qsp->psgi_return($ctx->{env}, undef, \&_refs_parse_hdr, $ctx);
}

sub srv { # endpoint called by PublicInbox::WWW
	my ($self, $ctx) = @_;
	my $path_info = $ctx->{env}->{PATH_INFO};
	my $git;
	# handle clone requests
	my $cr = $self->{pi_cfg}->{-code_repos};
	if ($path_info =~ m!\A/(.+?)/($PublicInbox::GitHTTPBackend::ANY)\z!x and
		($git = $cr->{$1})) {
			PublicInbox::GitHTTPBackend::serve($ctx->{env},$git,$2);
	} elsif ($path_info =~ m!\A/(.+?)/\z! and ($ctx->{git} = $cr->{$1})) {
		summary($self, $ctx)
	} elsif ($path_info =~ m!\A/(.+?)/([a-f0-9]+)/s/([^/]+)?\z! and
			($ctx->{git} = $cr->{$1})) {
		$ctx->{lh} = $self->{log_fh};
		PublicInbox::ViewVCS::show($ctx, $2, $3);
	} elsif ($path_info =~ m!\A/(.+?)/tree/(.*)\z! and
			($ctx->{git} = $cr->{$1})) {
		$ctx->{lh} = $self->{log_fh};
		PublicInbox::RepoTree::srv_tree($ctx, $2) // r(404);
	} elsif ($path_info =~ m!\A/(.+?)/snapshot/([^/]+)\z! and
			($ctx->{git} = $cr->{$1})) {
		$ctx->{wcr} = $self;
		PublicInbox::RepoSnapshot::srv($ctx, $2) // r(404);
	} elsif ($path_info =~ m!\A/(.+?)/atom/(.*)\z! and
			($ctx->{git} = $cr->{$1})) {
		$ctx->{lh} = $self->{log_fh};
		PublicInbox::RepoAtom::srv_atom($ctx, $2) // r(404);
	} elsif ($path_info =~ m!\A/(.+?)/tags\.atom\z! and
			($ctx->{git} = $cr->{$1})) {
		PublicInbox::RepoAtom::srv_tags_atom($ctx);
	} elsif ($path_info =~ m!\A/(.+?)/(refs/(?:heads|tags))/\z! and
			($ctx->{git} = $cr->{$1})) {
		refs_foo($self, $ctx, $2);
	} elsif ($path_info =~ m!\A/(.+?)\z! and ($git = $cr->{$1})) {
		my $qs = $ctx->{env}->{QUERY_STRING};
		my $url = $git->base_url($ctx->{env});
		$url .= "?$qs" if $qs ne '';
		[ 301, [ Location => $url, 'Content-Type' => 'text/plain' ],
			[ "Redirecting to $url\n" ] ];
	} else {
		r(404);
	}
}

1;