Fix CGI error detection
[girocco.git] / cgi / Git / RepoCGI.pm
blobb4f585ec1bd6978d88a868ffccbef2abd5ecdf20
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 = $self->cgi;
63 my $cginame = $cgi->url(-absolute => 1);
64 $cginame =~ s#^/m/##;
65 if ($cginame =~ /^[a-zA-Z0-9_.\/-]+\.cgi$/) {
66 print <<EOT;
67 <div align="right">
68 <a href="http://repo.or.cz/gitweb.cgi/repo.git?a=blob;f=cgi/$cginame">(view source)</a>
69 </div>
70 EOT
72 print <<EOT;
73 </body>
74 </html>
75 EOT
78 sub cgi {
79 my $self = shift;
80 $self->{cgi};
83 sub err {
84 my $self = shift;
85 print "<p style=\"text-color: red\">@_</p>\n";
86 $self->{err}++;
89 sub err_check {
90 my $self = shift;
91 my $err = $self->{err};
92 $err and print "<p>Operation aborted due to $err errors.</p>\n";
93 $err;
96 sub wparam {
97 my $self = shift;
98 my ($param) = @_;
99 my $val = $self->{cgi}->param($param);
100 $val =~ s/^\s*(.*?)\s*$/$1/;
101 $val;
105 ### Random utility functions
107 sub scrypt {
108 my ($pwd) = @_;
109 crypt($pwd, join ('', ('.', '/', 2..9, 'A'..'Z', 'a'..'z')[rand 64, rand 64]));
112 sub html_esc {
113 my ($str) = @_;
114 $str =~ s/&/&amp;/g;
115 $str =~ s/</&lt;/g; $str =~ s/>/&gt;/g;
116 $str =~ s/"/&quot;/g;
117 $str;
120 sub jailed_file {
121 my ($filename) = @_;
122 "/home/repo/j/$filename";
125 sub lock_file {
126 my ($path) = @_;
128 $path .= '.lock';
130 use Errno qw(EEXIST);
131 use Fcntl qw(O_WRONLY O_CREAT O_EXCL);
132 use IO::Handle;
133 my $handle = new IO::Handle;
135 unless (sysopen($handle, $path, O_WRONLY|O_CREAT|O_EXCL)) {
136 my $cnt = 0;
137 while (not sysopen($handle, $path, O_WRONLY|O_CREAT|O_EXCL)) {
138 ($! == EEXIST) or die "$path open failed: $!";
139 ($cnt++ < 16) or die "$path open failed: cannot open lockfile";
140 sleep(1);
143 # XXX: filedb-specific
144 chmod 0664, $path or die "$path g+w failed: $!";
146 $handle;
149 sub unlock_file {
150 my ($path) = @_;
152 rename "$path.lock", $path or die "$path unlock failed: $!";
155 sub filedb_atomic_append {
156 my ($file, $line) = @_;
157 my $id = 65536;
159 open my $src, $file or die "$file open for reading failed: $!";
160 my $dst = lock_file($file);
162 while (<$src>) {
163 my $aid = (split /:/)[2];
164 $id = $aid + 1 if ($aid >= $id);
166 print $dst $_ or die "$file(l) write failed: $!";
169 $line =~ s/\\i/$id/g;
170 print $dst "$line\n" or die "$file(l) write failed: $!";
172 close $dst or die "$file(l) close failed: $!";
173 close $src;
175 unlock_file($file);
177 $id;
180 sub filedb_atomic_edit {
181 my ($file, $fn) = @_;
183 open my $src, $file or die "$file open for reading failed: $!";
184 my $dst = lock_file($file);
186 while (<$src>) {
187 print $dst $fn->($_) or die "$file(l) write failed: $!";
190 close $dst or die "$file(l) close failed: $!";
191 close $src;
193 unlock_file($file);
196 # BOTH user AND project name!
197 sub valid_name {
198 $_ = $_[0];
199 /^[a-zA-Z0-9_+-]+$/;
201 sub valid_email {
202 $_ = $_[0];
203 /^[a-zA-Z0-9+._-]+@[a-zA-Z0-9-.]+$/;
205 sub valid_web_url {
206 $_ = $_[0];
207 /^http:\/\/[a-zA-Z0-9-.]+\/[_\%a-zA-Z0-9.\/~-]+(#[a-zA-Z0-9._-]+)?$/;
209 sub valid_repo_url {
210 $_ = $_[0];
211 /^http:\/\/[a-zA-Z0-9-.]+\/[_\%a-zA-Z0-9.\/~-]+$/ or
212 /^git:\/\/[a-zA-Z0-9-.]+\/[_\%a-zA-Z0-9.\/~-]+$/;
216 ### Project object
218 package Git::RepoCGI::Project;
220 BEGIN { use Git::RepoCGI; }
222 our %propmap = (
223 url => 'base_url',
224 email => 'owner',
225 desc => 'description',
226 hp => 'homepage',
229 sub _property_path {
230 my $self = shift;
231 my ($name) = @_;
232 $self->{path}.'/'.$propmap{$name};
235 sub _property_fget {
236 my $self = shift;
237 my ($name) = @_;
238 my $value;
239 $propmap{$name} or die "unknown property: $name";
240 open P, $self->_property_path($name) or die "$name get failed: $!";
241 chomp($value = <P>);
242 close P;
243 $value;
246 sub _property_fput {
247 my $self = shift;
248 my ($name, $value) = @_;
249 $propmap{$name} or die "unknown property: $name";
250 open P, '>'.$self->_property_path($name) or die "$name put failed: $!";
251 $value ne '' and print P "$value\n";
252 close P;
253 chmod 0664, $self->_property_path($name) or die "$name chmod failed: $!";
256 sub _properties_load {
257 my $self = shift;
258 foreach my $prop (keys %propmap) {
259 $self->{$prop} = $self->_property_fget($prop);
263 sub _properties_save {
264 my $self = shift;
265 foreach my $prop (keys %propmap) {
266 $self->_property_fput($prop, $self->{$prop});
270 sub _nofetch_path {
271 my $self = shift;
272 $self->_property_path('.nofetch');
275 sub _nofetch {
276 my $self = shift;
277 my ($nofetch) = @_;
278 my $np = $self->_nofetch_path;
279 if ($nofetch) {
280 open X, '>'.$np or die "nofetch failed: $!";
281 close X;
282 } else {
283 unlink $np or die "yesfetch failed: $!";
287 sub _group_add {
288 my $self = shift;
289 my ($xtra) = @_;
290 $xtra .= join(',', @{$self->{users}});
291 filedb_atomic_append(jailed_file('/etc/group'),
292 join(':', $self->{name}, $self->{crypt}, '\i', $xtra));
295 sub _group_update {
296 my $self = shift;
297 my $xtra = join(',', @{$self->{users}});
298 filedb_atomic_edit(jailed_file('/etc/group'),
299 sub {
300 $_ = $_[0];
301 chomp;
302 if ($self->{name} eq (split /:/)[0]) {
303 # preserve readonly flag
304 s/::([^:]*)$/:$1/ and $xtra = ":$xtra";
305 return join(':', $self->{name}, $self->{crypt}, $self->{gid}, $xtra)."\n";
306 } else {
307 return "$_\n";
313 # private constructor, do not use
314 sub _new {
315 my $class = shift;
316 my ($name, $path) = @_;
317 valid_name($name) or die "refusing to create project with invalid name ($name)!";
318 my $proj = { name => $name, path => $path };
320 bless $proj, $class;
323 # public constructor #0
324 # creates a virtual project not connected to disk image
325 # you can conjure() it later to disk
326 sub ghost {
327 my $class = shift;
328 my ($name, $mirror) = @_;
329 my $self = $class->_new($name, $mirror ? "/home/repo/repodata/to-clone/$name" : "/srv/git/$name.git");
330 $self->{users} = [];
331 $self->{mirror} = $mirror;
332 $self;
335 # public constructor #1
336 sub load {
337 my $class = shift;
338 my ($name) = @_;
340 open F, jailed_file("/etc/group") or die "project load failed: $!";
341 while (<F>) {
342 chomp;
343 @_ = split /:+/;
344 next unless (shift eq $name);
346 my $self = $class->_new($name, "/srv/git/$name.git");
347 (-d $self->{path}) or die "invalid path (".$self->{path}.") for project ".$self->{name};
349 my $ulist;
350 ($self->{crypt}, $self->{gid}, $ulist) = @_;
351 $self->{users} = [split /,/, $ulist];
352 $self->{mirror} = ! -e $self->_nofetch_path;
354 $self->_properties_load;
355 return $self;
357 close F;
358 undef;
361 # $proj may not be in sane state if this returns false!
362 sub cgi_fill {
363 my $self = shift;
364 my ($repo) = @_;
365 my $cgi = $repo->cgi;
367 my $pwd = $cgi->param('pwd');
368 if ($pwd ne '' or not $self->{crypt}) {
369 $self->{crypt} = scrypt($pwd);
372 $self->{email} = $repo->wparam('email');
373 valid_email($self->{email})
374 or $repo->err("Your email sure looks weird...?");
376 $self->{url} = $repo->wparam('url');
377 if ($self->{url}) {
378 valid_repo_url($self->{url})
379 or $repo->err("Invalid URL. Note that only HTTP and Git protocol is supported. If the URL contains funny characters, contact me.");
382 $self->{desc} = $repo->wparam('desc');
383 length($self->{desc}) <= 1024
384 or $repo->err("<b>Short</b> description length > 1kb!");
386 $self->{hp} = $repo->wparam('hp');
387 if ($self->{hp}) {
388 valid_web_url($self->{hp})
389 or $repo->err("Invalid homepage URL. Note that only HTTP protocol is supported. If the URL contains funny characters, contact me.");
392 # FIXME: Permit only existing users
393 $self->{users} = grep { valid_name($_) } $cgi->param('user');
395 not $repo->err_check;
398 sub form_defaults {
399 my $self = shift;
401 name => $self->{name},
402 email => $self->{email},
403 url => $self->{url},
404 desc => html_esc($self->{desc}),
405 hp => $self->{hp},
406 users => $self->{users},
410 sub premirror {
411 my $self = shift;
413 mkdir $self->{path} or die "mkdir failed: $!";
414 $self->_properties_save;
415 $self->_group_add;
418 sub conjure {
419 my $self = shift;
421 system('cg-admin-setuprepo', '-g', 'repo', $self->{path}) == 0
422 or die "cg-admin-setuprepo failed: $?";
423 $self->_nofetch(1);
424 $self->_properties_save;
425 chmod 0775, $self->{path} or die "chmod failed: $!";
426 $self->_group_add(':');
429 sub update {
430 my $self = shift;
432 $self->_properties_save;
433 $self->_group_update;
436 # static method
437 sub does_exist {
438 my ($name) = @_;
439 valid_name($name) or die "tried to query for project with invalid name $name!";
440 (available($name)
441 or -d "/home/repo/repodata/cloning/$name"
442 or -d "/home/repo/repodata/to-clone/$name");
444 sub available {
445 my ($name) = @_;
446 valid_name($name) or die "tried to query for project with invalid name $name!";
447 (-d "/srv/git/$name.git");
451 ### User object
453 package Git::RepoCGI::User;
455 BEGIN { use Git::RepoCGI; }
457 sub _passwd_add {
458 my $self = shift;
459 filedb_atomic_append(jailed_file('/etc/passwd'),
460 join(':', $self->{name}, 'x', '\i', $self->{email}, '/', '/bin/git-shell'));
463 sub _sshkey_path {
464 my $self = shift;
465 '/etc/sshkeys/'.$self->{name};
468 sub _sshkey_save {
469 my $self = shift;
470 open F, ">".jailed_file($self->_sshkey_path) or die "sshkey failed: $!";
471 print F $self->{keys}."\n";
472 close F;
473 chmod 0664, jailed_file($self->_sshkey_path);
476 # private constructor, do not use
477 sub _new {
478 my $class = shift;
479 my ($name) = @_;
480 valid_name($name) or die "refusing to create user with invalid name ($name)!";
481 my $proj = { name => $name };
483 bless $proj, $class;
486 # public constructor #0
487 # creates a virtual user not connected to disk record
488 # you can conjure() it later to disk
489 sub ghost {
490 my $class = shift;
491 my ($name) = @_;
492 my $self = $class->_new($name);
493 $self;
496 # $user may not be in sane state if this returns false!
497 sub cgi_fill {
498 my $self = shift;
499 my ($repo) = @_;
500 my $cgi = $repo->cgi;
502 $self->{name} = $repo->wparam('name');
503 valid_name($self->{name})
504 or $repo->err("Name contains invalid characters.");
506 $self->{email} = $repo->wparam('email');
507 valid_email($self->{email})
508 or $repo->err("Your email sure looks weird...?");
510 $self->{keys} = $cgi->param('keys');
511 length($self->{keys}) <= 4096
512 or $repo->err("The list of keys is more than 4kb. Do you really need that much?");
514 not $repo->err_check;
517 sub conjure {
518 my $self = shift;
520 $self->_passwd_add;
521 $self->_sshkey_save;
524 # static method
525 sub does_exist {
526 my ($name) = @_;
527 valid_name($name) or die "tried to query for user with invalid name $name!";
528 (-e jailed_file("/etc/sshkeys/$name"));
530 sub available {
531 does_exist(@_);