Offload user implementation to Git::RepoCGI::User
[girocco.git] / cgi / Git / RepoCGI.pm
blob8c12534602af068e8f273a51cad1155c087abeb4
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 print <<EOT;
62 </body>
63 </html>
64 EOT
67 sub cgi {
68 my $self = shift;
69 $self->{cgi};
72 sub err {
73 my $self = shift;
74 print "<p style=\"text-color: red\">@_</p>\n";
75 $self->{err}++;
78 sub err_check {
79 my $self = shift;
80 my $err = $self->{err};
81 $err and print "<p>Operation aborted due to $err errors.</p>\n";
82 $err;
85 sub wparam {
86 my $self = shift;
87 my ($param) = @_;
88 my $val = $self->{cgi}->param($param);
89 $val =~ s/^\s*(.*?)\s*$/$1/;
90 $val;
94 ### Random utility functions
96 sub scrypt {
97 my ($pwd) = @_;
98 crypt($pwd, join ('', ('.', '/', 2..9, 'A'..'Z', 'a'..'z')[rand 64, rand 64]));
101 sub html_esc {
102 my ($str) = @_;
103 $str =~ s/&/&amp;/g;
104 $str =~ s/</&lt;/g; $str =~ s/>/&gt;/g;
105 $str =~ s/"/&quot;/g;
108 sub jailed_file {
109 my ($filename) = @_;
110 "/home/repo/j/$filename";
113 sub lock_file {
114 my ($path) = @_;
116 $path .= '.lock';
118 use Errno qw(EEXIST);
119 use IO::Handle;
120 my $handle = new IO::Handle;
122 unless (sysopen($handle, $path, O_WRITE|O_CREAT|O_EXCL)) {
123 my $cnt = 0;
124 while (not sysopen($handle, $path, O_WRITE|O_CREAT|O_EXCL)) {
125 ($! == EEXIST) or die "$path open failed: $!";
126 ($cnt++ < 16) or die "$path open failed: cannot open lockfile";
127 sleep(1);
130 # XXX: filedb-specific
131 chmod 0664, $path or die "$path g+w failed: $!";
133 $handle;
136 sub unlock_file {
137 my ($path) = @_;
139 rename "$path.lock", $path or die "$path unlock failed: $!";
142 sub filedb_atomic_append {
143 my ($file, $line) = @_;
144 my $id = 65536;
146 open my $src, $file or die "$file open for reading failed: $!";
147 my $dst = lock_file($file);
149 while (<$src>) {
150 my $aid = (split /:/)[2];
151 $id = $aid + 1 if ($aid >= $id);
153 print $dst $_ or die "$file(l) write failed: $!";
156 $line =~ s/\\i/$id/g;
157 print $dst "$line\n" or die "$file(l) write failed: $!";
159 close $dst or die "$file(l) close failed: $!";
160 close $src;
162 unlock_file($file);
164 $id;
167 sub filedb_atomic_edit {
168 my ($file, $fn) = @_;
170 open my $src, $file or die "$file open for reading failed: $!";
171 my $dst = lock_file($file);
173 while (<$src>) {
174 print $dst $fn($_) or die "$file(l) write failed: $!";
177 close $dst or die "$file(l) close failed: $!";
178 close $src;
180 unlock_file($file);
183 # BOTH user AND project name!
184 sub valid_name {
185 $_ = $_[0];
186 /^[a-zA-Z0-9_+-]+$/;
188 sub valid_email {
189 $_ = $_[0];
190 /^[a-zA-Z0-9+._-]+@[a-zA-Z0-9-.]+$/;
192 sub valid_web_url {
193 $_ = $_[0];
194 /^http:\/\/[a-zA-Z0-9-.]+\/[_\%a-zA-Z0-9.\/~-]+(#[a-zA-Z0-9._-]+)?$/;
196 sub valid_repo_url {
197 $_ = $_[0];
198 /^http:\/\/[a-zA-Z0-9-.]+\/[_\%a-zA-Z0-9.\/~-]+$/ or
199 /^git:\/\/[a-zA-Z0-9-.]+\/[_\%a-zA-Z0-9.\/~-]+$/;
203 ### Project object
205 package Git::RepoCGI::Project;
207 our %propmap = {
208 url => 'base_url',
209 email => 'owner',
210 desc => 'description',
211 hp => 'homepage',
214 sub _property_path {
215 my $self = shift;
216 my ($name) = @_;
217 $self->{path}.'/'.$propmap{$name};
220 sub _property_fget {
221 my $self = shift;
222 my ($name) = @_;
223 my $value;
224 $propmap{$name} or die "unknown property: $name";
225 open P, $self->_property_path($name) or die "$name get failed: $!";
226 chomp($value = <P>);
227 close P;
228 $value;
231 sub _property_fput {
232 my $self = shift;
233 my ($name, $value) = @_;
234 $propmap{$name} or die "unknown property: $name";
235 open P, '>'.$self->_property_path($name) or die "$name put failed: $!";
236 $value ne '' and print P "$value\n";
237 close P;
238 chmod 0664, $self->_property_path($name) or die "$name chmod failed: $!";
241 sub _properties_load {
242 my $self = shift;
243 foreach my $prop (keys %propmap) {
244 $self->{$prop} = $self->_property_fget($prop);
248 sub _properties_save {
249 my $self = shift;
250 foreach my $prop (keys %propmap) {
251 $self->_property_fput($prop, $self->{$prop});
255 sub _nofetch {
256 my $self = shift;
257 my ($nofetch) = @_;
258 my $np = $self->_property_path('.nofetch');
259 if ($nofetch) {
260 open X, '>'.$np or die "nofetch failed: $!";
261 close X;
262 } else {
263 unlink $np or die "yesfetch failed: $!";
267 sub _group_add {
268 my $self = shift;
269 my ($xtra) = @_;
270 $xtra .= join(',', @users);
271 filedb_atomic_append(jailed_file('/etc/group'),
272 join(':', $self->{name}, $self->{crypt}, '\i', $xtra));
275 sub _group_update {
276 my $self = shift;
277 my $xtra = join(',', @users);
278 filedb_atomic_edit(jailed_file('/etc/group'),
279 sub {
280 $_ = $_[0];
281 chomp;
282 if ($self->{name} eq (split /:/)[0]) {
283 # preserve readonly flag
284 s/::([^:]*)$/:$1/ and $xtra = ":$xtra";
285 return join(':', $self->{name}, $self->{crypt}, $self->{gid}, $xtra)."\n";
286 } else {
287 return "$_\n";
293 # private constructor, do not use
294 sub _new {
295 my $class = shift;
296 my ($name, $path) = @_;
297 valid_name($name) or die "refusing to create project with invalid name ($name)!";
298 my $proj = { name => $name, path => $path };
300 bless $proj, $class;
303 # public constructor #0
304 # creates a virtual project not connected to disk image
305 # you can conjure() it later to disk
306 sub ghost {
307 my $class = shift;
308 my ($name, $mirror) = @_;
309 my $self = $class->_new($name, $mirror ? "/home/repo/repodata/to-clone/$name" : "/srv/git/$name.git");
310 $self->{users} = [];
311 $self->{mirror} = $mirror;
312 $self;
315 # public constructor #1
316 sub load {
317 my $class = shift;
318 my ($name) = @_;
320 open F, jailed_file("/etc/group") or die "project load failed: $!";
321 while (<F>) {
322 chomp;
323 @_ = split /:+/;
324 next unless (shift eq $name);
326 my $self = $class->_new($name, "/srv/git/$name.git");
327 (-d $self->{path}) or die "invalid path (".$self->{path}.") for project ".$self->{name};
329 my $ulist;
330 ($self->{crypt}, $self->{gid}, $ulist) = @_;
331 $self->{users} = [split /,/, $ulist];
333 $self->_properties_load;
334 return $self;
336 close F;
337 undef;
340 # $proj may not be in sane state if this returns false!
341 sub cgi_fill {
342 my $self = shift;
343 my ($repo) = @_;
344 my $cgi = $repo->cgi;
346 my $pwd = $cgi->param('pwd');
347 if ($pwd ne '' or not $self->{crypt}) {
348 $self->{crypt} = scrypt($pwd);
351 $self->{email} = $repo->wparam('email');
352 valid_email($self->{email})
353 or $repo->err "Your email sure looks weird...?";
355 $self->{url} = $repo->wparam('url');
356 if ($self->{url}) {
357 valid_repo_url($self->{url})
358 or $repo->err "Invalid URL. Note that only HTTP and Git protocol is supported. If the URL contains funny characters, contact me.";
361 $self->{desc} = $repo->wparam('desc');
362 length($self->{desc}) <= 1024
363 or $repo->err "<b>Short</b> description length > 1kb!";
365 $self->{hp} = $repo->wparam('hp');
366 if ($self->{hp}) {
367 valid_web_url($self->{hp})
368 or $repo->err "Invalid homepage URL. Note that only HTTP protocol is supported. If the URL contains funny characters, contact me.";
371 # FIXME: Permit only existing users
372 $self->{users} = grep { valid_name($_) } $cgi->param('user');
374 $repo->err_check;
377 sub form_defaults {
378 my $self = shift;
380 name => $self->{name},
381 email => $self->{email},
382 url => $self->{url},
383 desc => html_esc($self->{desc}),
384 hp => $self->{hp},
385 users => $self->{users},
389 sub premirror {
390 my $self = shift;
392 mkdir $self->{path} or die "mkdir failed: $!";
393 $self->_properties_save;
394 $self->_group_add;
397 sub conjure {
398 my $self = shift;
400 system('cg-admin-setuprepo', '-g', 'repo', $self->{path}) == 0
401 or die "cg-admin-setuprepo failed: $?";
402 $self->_nofetch(1);
403 $self->_properties_save;
404 chmod 0775, $self->{path} or die "chmod failed: $!";
405 $self->_group_add(':');
408 sub update {
409 my $self = shift;
411 $self->_properties_save;
412 $self->_group_update;
416 ### User object
418 package Git::RepoCGI::User;
420 sub _passwd_add {
421 my $self = shift;
422 filedb_atomic_append(jailed_file('/etc/passwd'),
423 join(':', $self->{name}, 'x', '\i', $self->{email}, '/', '/bin/git-shell'));
426 sub _sshkey_path {
427 my $self = shift;
428 '/etc/sshkeys/'.$self->{name};
431 sub _sshkey_save {
432 my $self = shift;
433 open F, ">".jailed_file($self->_sshkey_path) or die "sshkey failed: $!";
434 print F $self->{keys}."\n";
435 close F;
436 chmod 0664, jailed_file($self->_sshkey_path);
439 # private constructor, do not use
440 sub _new {
441 my $class = shift;
442 my ($name) = @_;
443 valid_name($name) or die "refusing to create user with invalid name ($name)!";
444 my $proj = { name => $name };
446 bless $proj, $class;
449 # public constructor #0
450 # creates a virtual user not connected to disk record
451 # you can conjure() it later to disk
452 sub ghost {
453 my $class = shift;
454 my ($name) = @_;
455 my $self = $class->_new($name);
456 $self;
459 # $user may not be in sane state if this returns false!
460 sub cgi_fill {
461 my $self = shift;
462 my ($repo) = @_;
463 my $cgi = $repo->cgi;
465 $self->{name} = $repo->wparam('name');
466 valid_name($self->{name})
467 or $repo->err "Name contains invalid characters.";
469 $self->{email} = $repo->wparam('email');
470 valid_email($self->{email})
471 or $repo->err "Your email sure looks weird...?";
473 $self->{keys} = $cgi->param('keys');
474 length($self->{keys}) <= 4096
475 or $repo->err "The list of keys is more than 4kb. Do you really need that much?";
477 $repo->err_check;
480 sub conjure {
481 my $self = shift;
483 $self->_passwd_add;
484 $self->_sshkey_save;