Provide (view source) link at the footer of each page
[girocco/susan.git] / cgi / Git / RepoCGI.pm
blob2319ff5c42f1f0ac89d34a6b7b0b924fbe04a948
1 package Git::RepoCGI;
3 use strict;
4 use warnings;
7 ### Administrativa
9 BEGIN {
10 our $VERSION = '0.1';
11 our @ISA = qw(Exporter);
12 our @EXPORT = qw(scrypt html_esc jailed_file
13 filedb_atomic_append filedb_atomic_edit
14 valid_name valid_email valid_repo_url valid_web_url);
16 use CGI qw(:standard :escapeHTML -nosticky);
17 use CGI::Util qw(unescape);
18 use CGI::Carp qw(fatalsToBrowser);
22 ### RepoCGI object
24 sub new {
25 my $class = shift;
26 my ($heading) = @_;
27 my $repo = {};
29 $repo->{cgi} = CGI->new;
31 print $repo->{cgi}->header(-type=>'text/html', -charset => 'utf-8');
33 print <<EOT;
34 <?xml version="1.0" encoding="utf-8"?>
35 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
36 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-US" lang="en-US">
38 <head>
39 <title>repo.or.cz :: $heading</title>
40 <link rel="stylesheet" type="text/css" href="/gitweb.css"/>
41 <link rel="shortcut icon" href="/git-favicon.png" type="image/png"/>
42 </head>
44 <body>
46 <div class="page_header">
47 <a href="http://git.or.cz/" title="Git homepage"><img src="/git-logo.png" width="72" height="27" alt="git" style="float:right; border-width:0px;"/></a>
48 <a href="/">repo.or.cz</a>
49 <div class="search">
50 Administration Interface
51 </div>
52 </div>
54 <h1>$heading</h1>
55 EOT
57 bless $repo, $class;
60 sub DESTROY {
61 my $self = shift;
62 my $cgi = $repo->cgi;
63 my $cginame = $cgi->url(-relative => 1);
64 if ($cginame =~ /^[a-zA-Z_.-]+\.cgi$/) {
65 print <<EOT;
66 <div align="right">
67 <a href="http://repo.or.cz/gitweb.cgi/repo.git?a=blob;f=cgi/$cginame">(view source)</a>
68 </div>
69 EOT
71 print <<EOT;
72 </body>
73 </html>
74 EOT
77 sub cgi {
78 my $self = shift;
79 $self->{cgi};
82 sub err {
83 my $self = shift;
84 print "<p style=\"text-color: red\">@_</p>\n";
85 $self->{err}++;
88 sub err_check {
89 my $self = shift;
90 my $err = $self->{err};
91 $err and print "<p>Operation aborted due to $err errors.</p>\n";
92 $err;
95 sub wparam {
96 my $self = shift;
97 my ($param) = @_;
98 my $val = $self->{cgi}->param($param);
99 $val =~ s/^\s*(.*?)\s*$/$1/;
100 $val;
104 ### Random utility functions
106 sub scrypt {
107 my ($pwd) = @_;
108 crypt($pwd, join ('', ('.', '/', 2..9, 'A'..'Z', 'a'..'z')[rand 64, rand 64]));
111 sub html_esc {
112 my ($str) = @_;
113 $str =~ s/&/&amp;/g;
114 $str =~ s/</&lt;/g; $str =~ s/>/&gt;/g;
115 $str =~ s/"/&quot;/g;
118 sub jailed_file {
119 my ($filename) = @_;
120 "/home/repo/j/$filename";
123 sub lock_file {
124 my ($path) = @_;
126 $path .= '.lock';
128 use Errno qw(EEXIST);
129 use IO::Handle;
130 my $handle = new IO::Handle;
132 unless (sysopen($handle, $path, O_WRITE|O_CREAT|O_EXCL)) {
133 my $cnt = 0;
134 while (not sysopen($handle, $path, O_WRITE|O_CREAT|O_EXCL)) {
135 ($! == EEXIST) or die "$path open failed: $!";
136 ($cnt++ < 16) or die "$path open failed: cannot open lockfile";
137 sleep(1);
140 # XXX: filedb-specific
141 chmod 0664, $path or die "$path g+w failed: $!";
143 $handle;
146 sub unlock_file {
147 my ($path) = @_;
149 rename "$path.lock", $path or die "$path unlock failed: $!";
152 sub filedb_atomic_append {
153 my ($file, $line) = @_;
154 my $id = 65536;
156 open my $src, $file or die "$file open for reading failed: $!";
157 my $dst = lock_file($file);
159 while (<$src>) {
160 my $aid = (split /:/)[2];
161 $id = $aid + 1 if ($aid >= $id);
163 print $dst $_ or die "$file(l) write failed: $!";
166 $line =~ s/\\i/$id/g;
167 print $dst "$line\n" or die "$file(l) write failed: $!";
169 close $dst or die "$file(l) close failed: $!";
170 close $src;
172 unlock_file($file);
174 $id;
177 sub filedb_atomic_edit {
178 my ($file, $fn) = @_;
180 open my $src, $file or die "$file open for reading failed: $!";
181 my $dst = lock_file($file);
183 while (<$src>) {
184 print $dst $fn($_) or die "$file(l) write failed: $!";
187 close $dst or die "$file(l) close failed: $!";
188 close $src;
190 unlock_file($file);
193 # BOTH user AND project name!
194 sub valid_name {
195 $_ = $_[0];
196 /^[a-zA-Z0-9_+-]+$/;
198 sub valid_email {
199 $_ = $_[0];
200 /^[a-zA-Z0-9+._-]+@[a-zA-Z0-9-.]+$/;
202 sub valid_web_url {
203 $_ = $_[0];
204 /^http:\/\/[a-zA-Z0-9-.]+\/[_\%a-zA-Z0-9.\/~-]+(#[a-zA-Z0-9._-]+)?$/;
206 sub valid_repo_url {
207 $_ = $_[0];
208 /^http:\/\/[a-zA-Z0-9-.]+\/[_\%a-zA-Z0-9.\/~-]+$/ or
209 /^git:\/\/[a-zA-Z0-9-.]+\/[_\%a-zA-Z0-9.\/~-]+$/;
213 ### Project object
215 package Git::RepoCGI::Project;
217 our %propmap = {
218 url => 'base_url',
219 email => 'owner',
220 desc => 'description',
221 hp => 'homepage',
224 sub _property_path {
225 my $self = shift;
226 my ($name) = @_;
227 $self->{path}.'/'.$propmap{$name};
230 sub _property_fget {
231 my $self = shift;
232 my ($name) = @_;
233 my $value;
234 $propmap{$name} or die "unknown property: $name";
235 open P, $self->_property_path($name) or die "$name get failed: $!";
236 chomp($value = <P>);
237 close P;
238 $value;
241 sub _property_fput {
242 my $self = shift;
243 my ($name, $value) = @_;
244 $propmap{$name} or die "unknown property: $name";
245 open P, '>'.$self->_property_path($name) or die "$name put failed: $!";
246 $value ne '' and print P "$value\n";
247 close P;
248 chmod 0664, $self->_property_path($name) or die "$name chmod failed: $!";
251 sub _properties_load {
252 my $self = shift;
253 foreach my $prop (keys %propmap) {
254 $self->{$prop} = $self->_property_fget($prop);
258 sub _properties_save {
259 my $self = shift;
260 foreach my $prop (keys %propmap) {
261 $self->_property_fput($prop, $self->{$prop});
265 sub _nofetch {
266 my $self = shift;
267 my ($nofetch) = @_;
268 my $np = $self->_property_path('.nofetch');
269 if ($nofetch) {
270 open X, '>'.$np or die "nofetch failed: $!";
271 close X;
272 } else {
273 unlink $np or die "yesfetch failed: $!";
277 sub _group_add {
278 my $self = shift;
279 my ($xtra) = @_;
280 $xtra .= join(',', @users);
281 filedb_atomic_append(jailed_file('/etc/group'),
282 join(':', $self->{name}, $self->{crypt}, '\i', $xtra));
285 sub _group_update {
286 my $self = shift;
287 my $xtra = join(',', @users);
288 filedb_atomic_edit(jailed_file('/etc/group'),
289 sub {
290 $_ = $_[0];
291 chomp;
292 if ($self->{name} eq (split /:/)[0]) {
293 # preserve readonly flag
294 s/::([^:]*)$/:$1/ and $xtra = ":$xtra";
295 return join(':', $self->{name}, $self->{crypt}, $self->{gid}, $xtra)."\n";
296 } else {
297 return "$_\n";
303 # private constructor, do not use
304 sub _new {
305 my $class = shift;
306 my ($name, $path) = @_;
307 valid_name($name) or die "refusing to create project with invalid name ($name)!";
308 my $proj = { name => $name, path => $path };
310 bless $proj, $class;
313 # public constructor #0
314 # creates a virtual project not connected to disk image
315 # you can conjure() it later to disk
316 sub ghost {
317 my $class = shift;
318 my ($name, $mirror) = @_;
319 my $self = $class->_new($name, $mirror ? "/home/repo/repodata/to-clone/$name" : "/srv/git/$name.git");
320 $self->{users} = [];
321 $self->{mirror} = $mirror;
322 $self;
325 # public constructor #1
326 sub load {
327 my $class = shift;
328 my ($name) = @_;
330 open F, jailed_file("/etc/group") or die "project load failed: $!";
331 while (<F>) {
332 chomp;
333 @_ = split /:+/;
334 next unless (shift eq $name);
336 my $self = $class->_new($name, "/srv/git/$name.git");
337 (-d $self->{path}) or die "invalid path (".$self->{path}.") for project ".$self->{name};
339 my $ulist;
340 ($self->{crypt}, $self->{gid}, $ulist) = @_;
341 $self->{users} = [split /,/, $ulist];
343 $self->_properties_load;
344 return $self;
346 close F;
347 undef;
350 # $proj may not be in sane state if this returns false!
351 sub cgi_fill {
352 my $self = shift;
353 my ($repo) = @_;
354 my $cgi = $repo->cgi;
356 my $pwd = $cgi->param('pwd');
357 if ($pwd ne '' or not $self->{crypt}) {
358 $self->{crypt} = scrypt($pwd);
361 $self->{email} = $repo->wparam('email');
362 valid_email($self->{email})
363 or $repo->err "Your email sure looks weird...?";
365 $self->{url} = $repo->wparam('url');
366 if ($self->{url}) {
367 valid_repo_url($self->{url})
368 or $repo->err "Invalid URL. Note that only HTTP and Git protocol is supported. If the URL contains funny characters, contact me.";
371 $self->{desc} = $repo->wparam('desc');
372 length($self->{desc}) <= 1024
373 or $repo->err "<b>Short</b> description length > 1kb!";
375 $self->{hp} = $repo->wparam('hp');
376 if ($self->{hp}) {
377 valid_web_url($self->{hp})
378 or $repo->err "Invalid homepage URL. Note that only HTTP protocol is supported. If the URL contains funny characters, contact me.";
381 # FIXME: Permit only existing users
382 $self->{users} = grep { valid_name($_) } $cgi->param('user');
384 $repo->err_check;
387 sub form_defaults {
388 my $self = shift;
390 name => $self->{name},
391 email => $self->{email},
392 url => $self->{url},
393 desc => html_esc($self->{desc}),
394 hp => $self->{hp},
395 users => $self->{users},
399 sub premirror {
400 my $self = shift;
402 mkdir $self->{path} or die "mkdir failed: $!";
403 $self->_properties_save;
404 $self->_group_add;
407 sub conjure {
408 my $self = shift;
410 system('cg-admin-setuprepo', '-g', 'repo', $self->{path}) == 0
411 or die "cg-admin-setuprepo failed: $?";
412 $self->_nofetch(1);
413 $self->_properties_save;
414 chmod 0775, $self->{path} or die "chmod failed: $!";
415 $self->_group_add(':');
418 sub update {
419 my $self = shift;
421 $self->_properties_save;
422 $self->_group_update;
425 # static method
426 sub does_exist {
427 my ($name) = @_;
428 valid_name($name) or die "tried to query for project with invalid name $name!";
429 (available($name)
430 or -d "/home/repo/repodata/cloning/$name"
431 or -d "/home/repo/repodata/to-clone/$name");
433 sub available {
434 my ($name) = @_;
435 valid_name($name) or die "tried to query for project with invalid name $name!";
436 (-d "/srv/git/$name.git");
440 ### User object
442 package Git::RepoCGI::User;
444 sub _passwd_add {
445 my $self = shift;
446 filedb_atomic_append(jailed_file('/etc/passwd'),
447 join(':', $self->{name}, 'x', '\i', $self->{email}, '/', '/bin/git-shell'));
450 sub _sshkey_path {
451 my $self = shift;
452 '/etc/sshkeys/'.$self->{name};
455 sub _sshkey_save {
456 my $self = shift;
457 open F, ">".jailed_file($self->_sshkey_path) or die "sshkey failed: $!";
458 print F $self->{keys}."\n";
459 close F;
460 chmod 0664, jailed_file($self->_sshkey_path);
463 # private constructor, do not use
464 sub _new {
465 my $class = shift;
466 my ($name) = @_;
467 valid_name($name) or die "refusing to create user with invalid name ($name)!";
468 my $proj = { name => $name };
470 bless $proj, $class;
473 # public constructor #0
474 # creates a virtual user not connected to disk record
475 # you can conjure() it later to disk
476 sub ghost {
477 my $class = shift;
478 my ($name) = @_;
479 my $self = $class->_new($name);
480 $self;
483 # $user may not be in sane state if this returns false!
484 sub cgi_fill {
485 my $self = shift;
486 my ($repo) = @_;
487 my $cgi = $repo->cgi;
489 $self->{name} = $repo->wparam('name');
490 valid_name($self->{name})
491 or $repo->err "Name contains invalid characters.";
493 $self->{email} = $repo->wparam('email');
494 valid_email($self->{email})
495 or $repo->err "Your email sure looks weird...?";
497 $self->{keys} = $cgi->param('keys');
498 length($self->{keys}) <= 4096
499 or $repo->err "The list of keys is more than 4kb. Do you really need that much?";
501 $repo->err_check;
504 sub conjure {
505 my $self = shift;
507 $self->_passwd_add;
508 $self->_sshkey_save;
511 # static method
512 sub does_exist {
513 my ($name) = @_;
514 valid_name($name) or die "tried to query for user with invalid name $name!";
515 (-e jailed_file("/etc/sshkeys/$name"));
517 sub available {
518 does_exist(@_);