To check out this repository please hg clone the following URL, or open the URL using EasyMercurial or your preferred Mercurial client.

Statistics Download as Zip
| Branch: | Tag: | Revision:

root / extra / soundsoftware / SoundSoftware.pm @ 733:c7a731db96e5

History | View | Annotate | Download (15.4 KB)

1 7:3c16ed8faa07 Chris
package Apache::Authn::SoundSoftware;
2
3
=head1 Apache::Authn::SoundSoftware
4
5
SoundSoftware - a mod_perl module for Apache authentication against a
6
Redmine database and optional LDAP implementing the access control
7
rules required for the SoundSoftware.ac.uk repository site.
8
9
=head1 SYNOPSIS
10
11
This module is closely based on the Redmine.pm authentication module
12
provided with Redmine.  It is intended to be used for authentication
13
in front of a repository service such as hgwebdir.
14
15
Requirements:
16
17
1. Clone/pull from repo for public project: Any user, no
18
authentication required
19
20
2. Clone/pull from repo for private project: Project members only
21
22
3. Push to repo for public project: "Permitted" users only (this
23 8:0c83d98252d9 Chris
probably means project members who are also identified in the hgrc web
24
section for the repository and so will be approved by hgwebdir?)
25 7:3c16ed8faa07 Chris
26 8:0c83d98252d9 Chris
4. Push to repo for private project: "Permitted" users only (as above)
27 7:3c16ed8faa07 Chris
28 300:034e9b00b341 chris
5. Push to any repo that is tracking an external repo: Refused always
29
30 7:3c16ed8faa07 Chris
=head1 INSTALLATION
31
32
Debian/ubuntu:
33
34
  apt-get install libapache-dbi-perl libapache2-mod-perl2 \
35
    libdbd-mysql-perl libauthen-simple-ldap-perl libio-socket-ssl-perl
36
37
Note that LDAP support is hardcoded "on" in this script (it is
38
optional in the original Redmine.pm).
39
40
=head1 CONFIGURATION
41
42
   ## This module has to be in your perl path
43
   ## eg:  /usr/local/lib/site_perl/Apache/Authn/SoundSoftware.pm
44
   PerlLoadModule Apache::Authn::SoundSoftware
45
46
   # Example when using hgwebdir
47
   ScriptAlias / "/var/hg/hgwebdir.cgi/"
48
49
   <Location />
50
       AuthName "Mercurial"
51
       AuthType Basic
52
       Require valid-user
53
       PerlAccessHandler Apache::Authn::SoundSoftware::access_handler
54
       PerlAuthenHandler Apache::Authn::SoundSoftware::authen_handler
55
       SoundSoftwareDSN "DBI:mysql:database=redmine;host=localhost"
56
       SoundSoftwareDbUser "redmine"
57
       SoundSoftwareDbPass "password"
58
       Options +ExecCGI
59
       AddHandler cgi-script .cgi
60
       ## Optional where clause (fulltext search would be slow and
61
       ## database dependant).
62
       # SoundSoftwareDbWhereClause "and members.role_id IN (1,2)"
63 8:0c83d98252d9 Chris
       ## Optional prefix for local repository URLs
64
       # SoundSoftwareRepoPrefix "/var/hg/"
65 7:3c16ed8faa07 Chris
  </Location>
66
67
See the original Redmine.pm for further configuration notes.
68
69
=cut
70
71
use strict;
72
use warnings FATAL => 'all', NONFATAL => 'redefine';
73
74
use DBI;
75
use Digest::SHA1;
76
use Authen::Simple::LDAP;
77
use Apache2::Module;
78
use Apache2::Access;
79
use Apache2::ServerRec qw();
80
use Apache2::RequestRec qw();
81
use Apache2::RequestUtil qw();
82
use Apache2::Const qw(:common :override :cmd_how);
83
use APR::Pool ();
84
use APR::Table ();
85
86
my @directives = (
87
  {
88
    name => 'SoundSoftwareDSN',
89
    req_override => OR_AUTHCFG,
90
    args_how => TAKE1,
91
    errmsg => 'Dsn in format used by Perl DBI. eg: "DBI:Pg:dbname=databasename;host=my.db.server"',
92
  },
93
  {
94
    name => 'SoundSoftwareDbUser',
95
    req_override => OR_AUTHCFG,
96
    args_how => TAKE1,
97
  },
98
  {
99
    name => 'SoundSoftwareDbPass',
100
    req_override => OR_AUTHCFG,
101
    args_how => TAKE1,
102
  },
103
  {
104
    name => 'SoundSoftwareDbWhereClause',
105
    req_override => OR_AUTHCFG,
106
    args_how => TAKE1,
107
  },
108
  {
109 8:0c83d98252d9 Chris
    name => 'SoundSoftwareRepoPrefix',
110 7:3c16ed8faa07 Chris
    req_override => OR_AUTHCFG,
111
    args_how => TAKE1,
112
  },
113 732:897bc2b63bfe Chris
  {
114
    name => 'SoundSoftwareSslRequired',
115
    req_override => OR_AUTHCFG,
116
    args_how => TAKE1,
117
  },
118 7:3c16ed8faa07 Chris
);
119
120
sub SoundSoftwareDSN {
121 8:0c83d98252d9 Chris
    my ($self, $parms, $arg) = @_;
122
    $self->{SoundSoftwareDSN} = $arg;
123
    my $query = "SELECT
124 301:6d3f8aeb51b7 chris
                 hashed_password, salt, auth_source_id, permissions
125 7:3c16ed8faa07 Chris
              FROM members, projects, users, roles, member_roles
126
              WHERE
127
                projects.id=members.project_id
128
                AND member_roles.member_id=members.id
129
                AND users.id=members.user_id
130
                AND roles.id=member_roles.role_id
131
                AND users.status=1
132
                AND login=?
133
                AND identifier=? ";
134 8:0c83d98252d9 Chris
    $self->{SoundSoftwareQuery} = trim($query);
135 7:3c16ed8faa07 Chris
}
136
137
sub SoundSoftwareDbUser { set_val('SoundSoftwareDbUser', @_); }
138
sub SoundSoftwareDbPass { set_val('SoundSoftwareDbPass', @_); }
139
sub SoundSoftwareDbWhereClause {
140 8:0c83d98252d9 Chris
    my ($self, $parms, $arg) = @_;
141
    $self->{SoundSoftwareQuery} = trim($self->{SoundSoftwareQuery}.($arg ? $arg : "")." ");
142 7:3c16ed8faa07 Chris
}
143
144 8:0c83d98252d9 Chris
sub SoundSoftwareRepoPrefix {
145
    my ($self, $parms, $arg) = @_;
146
    if ($arg) {
147
	$self->{SoundSoftwareRepoPrefix} = $arg;
148
    }
149 7:3c16ed8faa07 Chris
}
150
151 732:897bc2b63bfe Chris
sub SoundSoftwareSslRequired { set_val('SoundSoftwareSslRequired', @_); }
152
153 7:3c16ed8faa07 Chris
sub trim {
154 8:0c83d98252d9 Chris
    my $string = shift;
155
    $string =~ s/\s{2,}/ /g;
156
    return $string;
157 7:3c16ed8faa07 Chris
}
158
159
sub set_val {
160 8:0c83d98252d9 Chris
    my ($key, $self, $parms, $arg) = @_;
161
    $self->{$key} = $arg;
162 7:3c16ed8faa07 Chris
}
163
164
Apache2::Module::add(__PACKAGE__, \@directives);
165
166
167
my %read_only_methods = map { $_ => 1 } qw/GET PROPFIND REPORT OPTIONS/;
168
169
sub access_handler {
170 8:0c83d98252d9 Chris
    my $r = shift;
171 7:3c16ed8faa07 Chris
172 517:bd1d512f9e1b Chris
    print STDERR "SoundSoftware.pm:$$: In access handler at " . scalar localtime() . "\n";
173 7:3c16ed8faa07 Chris
174 8:0c83d98252d9 Chris
    unless ($r->some_auth_required) {
175
	$r->log_reason("No authentication has been configured");
176
	return FORBIDDEN;
177
    }
178 7:3c16ed8faa07 Chris
179 8:0c83d98252d9 Chris
    my $method = $r->method;
180 7:3c16ed8faa07 Chris
181 517:bd1d512f9e1b Chris
    print STDERR "SoundSoftware.pm:$$: Method: $method, uri " . $r->uri . ", location " . $r->location . "\n";
182
    print STDERR "SoundSoftware.pm:$$: Accept: " . $r->headers_in->{Accept} . "\n";
183 7:3c16ed8faa07 Chris
184 8:0c83d98252d9 Chris
    my $dbh = connect_database($r);
185 152:a389c77da9fd Chris
    unless ($dbh) {
186 517:bd1d512f9e1b Chris
	print STDERR "SoundSoftware.pm:$$: Database connection failed!: " . $DBI::errstr . "\n";
187 152:a389c77da9fd Chris
	return FORBIDDEN;
188
    }
189
190 300:034e9b00b341 chris
    print STDERR "Connected to db, dbh is " . $dbh . "\n";
191 7:3c16ed8faa07 Chris
192 8:0c83d98252d9 Chris
    my $project_id = get_project_identifier($dbh, $r);
193 300:034e9b00b341 chris
194 732:897bc2b63bfe Chris
    # We want to delegate most of the work to the authentication
195
    # handler (to ensure that user is asked to login even for
196
    # nonexistent projects -- so they can't tell whether a private
197
    # project exists or not without authenticating). So
198
    #
199
    # * if the project is public
200
    #   - if the method is read-only
201
    #     + set handler to OK, no auth needed
202
    #   - if the method is not read-only
203
    #     + if the repo is read-only, return forbidden
204
    #     + else require auth
205
    # * if the project is not public or does not exist
206
    #     + require auth
207
    #
208
    # If we are requiring auth and are not currently https, and
209
    # https is required, then we must return a redirect to https
210
    # instead of an OK.
211 300:034e9b00b341 chris
212 8:0c83d98252d9 Chris
    my $status = get_project_status($dbh, $project_id, $r);
213 732:897bc2b63bfe Chris
    my $readonly = project_repo_is_readonly($dbh, $project_id, $r);
214 7:3c16ed8faa07 Chris
215 8:0c83d98252d9 Chris
    $dbh->disconnect();
216
    undef $dbh;
217 7:3c16ed8faa07 Chris
218 732:897bc2b63bfe Chris
    if ($status == 1) { # public
219
220
	print STDERR "SoundSoftware.pm:$$: Project is public\n";
221
222
	if (!defined $read_only_methods{$method}) {
223
224
	    print STDERR "SoundSoftware.pm:$$: Method is not read-only\n";
225
226
	    if ($readonly) {
227
		print STDERR "SoundSoftware.pm:$$: Project repo is read-only, refusing access\n";
228
		return FORBIDDEN;
229
	    } else {
230
		print STDERR "SoundSoftware.pm:$$: Project repo is read-write, auth required\n";
231
		# fall through, this is the normal case
232
	    }
233
234
	} else {
235
	    # Public project, read-only method -- this is the only
236
	    # case we can decide for certain to accept in this function
237
	    print STDERR "SoundSoftware.pm:$$: Method is read-only, no restriction here\n";
238
	    $r->set_handlers(PerlAuthenHandler => [\&OK]);
239
	    return OK;
240
	}
241
242
    } else { # status != 1, i.e. nonexistent or private -- equivalent here
243
244
	print STDERR "SoundSoftware.pm:$$: Project is private or nonexistent, auth required\n";
245
	# fall through
246 8:0c83d98252d9 Chris
    }
247 7:3c16ed8faa07 Chris
248 733:c7a731db96e5 Chris
    my $cfg = Apache2::Module::get_config
249
        (__PACKAGE__, $r->server, $r->per_dir_config);
250 732:897bc2b63bfe Chris
    if ($cfg->{SoundSoftwareSslRequired} eq "on") {
251
	if ($r->dir_config('HTTPS') eq "on") {
252
	    return OK;
253
	} else {
254
	    my $redir_to = "https://" . $r->hostname() . $r->unparsed_uri();
255
	    print STDERR "SoundSoftware.pm:$$: Need to switch to HTTPS, redirecting to $redir_to\n";
256 733:c7a731db96e5 Chris
	    $r->headers_out->add('Location' => $redir_to);
257 732:897bc2b63bfe Chris
	    return REDIRECT;
258
	}
259 733:c7a731db96e5 Chris
    } elsif ($cfg->{SoundSoftwareSslRequired} eq "off") {
260 732:897bc2b63bfe Chris
	return OK;
261
    } else {
262
	print STDERR "WARNING: SoundSoftware.pm:$$: SoundSoftwareSslRequired should be either 'on' or 'off'\n";
263
	return OK;
264
    }
265 7:3c16ed8faa07 Chris
}
266
267
sub authen_handler {
268 8:0c83d98252d9 Chris
    my $r = shift;
269
270 517:bd1d512f9e1b Chris
    print STDERR "SoundSoftware.pm:$$: In authentication handler at " . scalar localtime() . "\n";
271 7:3c16ed8faa07 Chris
272 8:0c83d98252d9 Chris
    my $dbh = connect_database($r);
273 152:a389c77da9fd Chris
    unless ($dbh) {
274 517:bd1d512f9e1b Chris
        print STDERR "SoundSoftware.pm:$$: Database connection failed!: " . $DBI::errstr . "\n";
275 152:a389c77da9fd Chris
        return AUTH_REQUIRED;
276
    }
277 8:0c83d98252d9 Chris
278
    my $project_id = get_project_identifier($dbh, $r);
279
    my $realm = get_realm($dbh, $project_id, $r);
280
    $r->auth_name($realm);
281
282
    my ($res, $redmine_pass) =  $r->get_basic_auth_pw();
283
    unless ($res == OK) {
284
	$dbh->disconnect();
285
	undef $dbh;
286
	return $res;
287
    }
288
289 517:bd1d512f9e1b Chris
    print STDERR "SoundSoftware.pm:$$: User is " . $r->user . ", got password\n";
290 8:0c83d98252d9 Chris
291 732:897bc2b63bfe Chris
    my $status = get_project_status($dbh, $project_id, $r);
292
    if ($status == 0) {
293
	# nonexistent, behave like private project you aren't a member of
294
	print STDERR "SoundSoftware.pm:$$: Project doesn't exist, not permitted\n";
295
	$dbh->disconnect();
296
	undef $dbh;
297
	$r->note_auth_failure();
298
	return AUTH_REQUIRED;
299
    }
300
301 8:0c83d98252d9 Chris
    my $permitted = is_permitted($dbh, $project_id, $r->user, $redmine_pass, $r);
302
303
    $dbh->disconnect();
304
    undef $dbh;
305
306
    if ($permitted) {
307
	return OK;
308
    } else {
309 517:bd1d512f9e1b Chris
	print STDERR "SoundSoftware.pm:$$: Not permitted\n";
310 8:0c83d98252d9 Chris
	$r->note_auth_failure();
311
	return AUTH_REQUIRED;
312
    }
313 7:3c16ed8faa07 Chris
}
314
315
sub get_project_status {
316 8:0c83d98252d9 Chris
    my $dbh = shift;
317 7:3c16ed8faa07 Chris
    my $project_id = shift;
318
    my $r = shift;
319 8:0c83d98252d9 Chris
320
    if (!defined $project_id or $project_id eq '') {
321
	return 0; # nonexistent
322
    }
323 7:3c16ed8faa07 Chris
324
    my $sth = $dbh->prepare(
325
        "SELECT is_public FROM projects WHERE projects.identifier = ?;"
326
    );
327
328
    $sth->execute($project_id);
329 8:0c83d98252d9 Chris
    my $ret = 0; # nonexistent
330 7:3c16ed8faa07 Chris
    if (my @row = $sth->fetchrow_array) {
331
    	if ($row[0] eq "1" || $row[0] eq "t") {
332
	    $ret = 1; # public
333
    	} else {
334 8:0c83d98252d9 Chris
	    $ret = 2; # private
335 7:3c16ed8faa07 Chris
	}
336
    }
337
    $sth->finish();
338
    undef $sth;
339
340
    $ret;
341
}
342
343 300:034e9b00b341 chris
sub project_repo_is_readonly {
344
    my $dbh = shift;
345
    my $project_id = shift;
346
    my $r = shift;
347
348
    if (!defined $project_id or $project_id eq '') {
349
        return 0; # nonexistent
350
    }
351
352
    my $sth = $dbh->prepare(
353
        "SELECT repositories.is_external FROM repositories, projects WHERE projects.identifier = ? AND repositories.project_id = projects.id;"
354
    );
355
356
    $sth->execute($project_id);
357
    my $ret = 0; # nonexistent
358
    if (my @row = $sth->fetchrow_array) {
359 301:6d3f8aeb51b7 chris
        if (defined($row[0]) && ($row[0] eq "1" || $row[0] eq "t")) {
360 300:034e9b00b341 chris
            $ret = 1; # read-only (i.e. external)
361
        } else {
362
            $ret = 0; # read-write
363
        }
364
    }
365
    $sth->finish();
366
    undef $sth;
367
368
    $ret;
369
}
370
371 8:0c83d98252d9 Chris
sub is_permitted {
372
    my $dbh = shift;
373
    my $project_id = shift;
374
    my $redmine_user = shift;
375
    my $redmine_pass = shift;
376
    my $r = shift;
377 7:3c16ed8faa07 Chris
378 8:0c83d98252d9 Chris
    my $pass_digest = Digest::SHA1::sha1_hex($redmine_pass);
379 7:3c16ed8faa07 Chris
380 8:0c83d98252d9 Chris
    my $cfg = Apache2::Module::get_config
381
	(__PACKAGE__, $r->server, $r->per_dir_config);
382 7:3c16ed8faa07 Chris
383 8:0c83d98252d9 Chris
    my $query = $cfg->{SoundSoftwareQuery};
384
    my $sth = $dbh->prepare($query);
385
    $sth->execute($redmine_user, $project_id);
386 7:3c16ed8faa07 Chris
387 8:0c83d98252d9 Chris
    my $ret;
388 301:6d3f8aeb51b7 chris
    while (my ($hashed_password, $salt, $auth_source_id, $permissions) = $sth->fetchrow_array) {
389 7:3c16ed8faa07 Chris
390 8:0c83d98252d9 Chris
	# Test permissions for this user before we verify credentials
391
	# -- if the user is not permitted this action anyway, there's
392
	# not much point in e.g. contacting the LDAP
393 7:3c16ed8faa07 Chris
394 8:0c83d98252d9 Chris
	my $method = $r->method;
395 7:3c16ed8faa07 Chris
396 8:0c83d98252d9 Chris
	if ((defined $read_only_methods{$method} && $permissions =~ /:browse_repository/)
397
	    || $permissions =~ /:commit_access/) {
398
399
	    # User would be permitted this action, if their
400
	    # credentials checked out -- test those now
401
402
	    print STDERR "SoundSoftware.pm: User $redmine_user has required role, checking credentials\n";
403
404
	    unless ($auth_source_id) {
405 301:6d3f8aeb51b7 chris
                my $salted_password = Digest::SHA1::sha1_hex($salt.$pass_digest);
406
		if ($hashed_password eq $salted_password) {
407 8:0c83d98252d9 Chris
		    print STDERR "SoundSoftware.pm: User $redmine_user authenticated via password\n";
408
		    $ret = 1;
409
		    last;
410
		}
411
	    } else {
412
		my $sthldap = $dbh->prepare(
413
		    "SELECT host,port,tls,account,account_password,base_dn,attr_login FROM auth_sources WHERE id = ?;"
414
		    );
415
		$sthldap->execute($auth_source_id);
416
		while (my @rowldap = $sthldap->fetchrow_array) {
417
		    my $ldap = Authen::Simple::LDAP->new(
418
			host    => ($rowldap[2] eq "1" || $rowldap[2] eq "t") ? "ldaps://$rowldap[0]" : $rowldap[0],
419
			port    => $rowldap[1],
420
			basedn  => $rowldap[5],
421
			binddn  => $rowldap[3] ? $rowldap[3] : "",
422
			bindpw  => $rowldap[4] ? $rowldap[4] : "",
423
			filter  => "(".$rowldap[6]."=%s)"
424
			);
425
		    if ($ldap->authenticate($redmine_user, $redmine_pass)) {
426 517:bd1d512f9e1b Chris
			print STDERR "SoundSoftware.pm:$$: User $redmine_user authenticated via LDAP\n";
427 8:0c83d98252d9 Chris
			$ret = 1;
428
		    }
429
		}
430
		$sthldap->finish();
431
		undef $sthldap;
432
	    }
433
	} else {
434 517:bd1d512f9e1b Chris
	    print STDERR "SoundSoftware.pm:$$: User $redmine_user lacks required role for this project\n";
435 8:0c83d98252d9 Chris
	}
436 7:3c16ed8faa07 Chris
    }
437
438 8:0c83d98252d9 Chris
    $sth->finish();
439
    undef $sth;
440
441
    $ret;
442 7:3c16ed8faa07 Chris
}
443
444
sub get_project_identifier {
445 8:0c83d98252d9 Chris
    my $dbh = shift;
446 7:3c16ed8faa07 Chris
    my $r = shift;
447
448
    my $location = $r->location;
449
    my ($repo) = $r->uri =~ m{$location/*([^/]+)};
450 10:2c10dc5f122d Chris
451
    return $repo if (!$repo);
452
453 7:3c16ed8faa07 Chris
    $repo =~ s/[^a-zA-Z0-9\._-]//g;
454
455 8:0c83d98252d9 Chris
    # The original Redmine.pm returns the string just calculated as
456
    # the project identifier.  That won't do for us -- we may have
457
    # (and in fact already do have, in our test instance) projects
458
    # whose repository names differ from the project identifiers.
459
460
    # This is a rather fundamental change because it means that almost
461
    # every request needs more than one database query -- which
462
    # prompts us to start passing around $dbh instead of connecting
463
    # locally within each function as is done in Redmine.pm.
464
465 7:3c16ed8faa07 Chris
    my $sth = $dbh->prepare(
466
        "SELECT projects.identifier FROM projects, repositories WHERE repositories.project_id = projects.id AND repositories.url LIKE ?;"
467
    );
468
469 8:0c83d98252d9 Chris
    my $cfg = Apache2::Module::get_config
470
	(__PACKAGE__, $r->server, $r->per_dir_config);
471
472
    my $prefix = $cfg->{SoundSoftwareRepoPrefix};
473
    if (!defined $prefix) { $prefix = '%/'; }
474
475 7:3c16ed8faa07 Chris
    my $identifier = '';
476
477 8:0c83d98252d9 Chris
    $sth->execute($prefix . $repo);
478 7:3c16ed8faa07 Chris
    my $ret = 0;
479
    if (my @row = $sth->fetchrow_array) {
480
	$identifier = $row[0];
481
    }
482
    $sth->finish();
483
    undef $sth;
484
485 517:bd1d512f9e1b Chris
    print STDERR "SoundSoftware.pm:$$: Repository '$repo' belongs to project '$identifier'\n";
486 7:3c16ed8faa07 Chris
487
    $identifier;
488
}
489
490 8:0c83d98252d9 Chris
sub get_realm {
491
    my $dbh = shift;
492
    my $project_id = shift;
493
    my $r = shift;
494
495
    my $sth = $dbh->prepare(
496
        "SELECT projects.name FROM projects WHERE projects.identifier = ?;"
497
    );
498
499
    my $name = $project_id;
500
501
    $sth->execute($project_id);
502
    my $ret = 0;
503
    if (my @row = $sth->fetchrow_array) {
504
	$name = $row[0];
505
    }
506
    $sth->finish();
507
    undef $sth;
508
509
    # be timid about characters not permitted in auth realm and revert
510
    # to project identifier if any are found
511
    if ($name =~ m/[^\w\d\s\._-]/) {
512
	$name = $project_id;
513 733:c7a731db96e5 Chris
    } elsif ($name =~ m/^\s*$/) {
514
	# empty or whitespace
515
	$name = $project_id;
516
    }
517
518
    if ($name =~ m/^\s*$/) {
519
        # nothing even in $project_id -- probably a nonexistent project.
520
        # use repo name instead (don't want to admit to user that project
521
        # doesn't exist)
522
        my $location = $r->location;
523
        my ($repo) = $r->uri =~ m{$location/*([^/]+)};
524
        $name = $repo;
525 8:0c83d98252d9 Chris
    }
526
527
    my $realm = '"Mercurial repository for ' . "'$name'" . '"';
528
529
    $realm;
530
}
531
532 7:3c16ed8faa07 Chris
sub connect_database {
533
    my $r = shift;
534
535 8:0c83d98252d9 Chris
    my $cfg = Apache2::Module::get_config
536
	(__PACKAGE__, $r->server, $r->per_dir_config);
537
538
    return DBI->connect($cfg->{SoundSoftwareDSN},
539 152:a389c77da9fd Chris
	                $cfg->{SoundSoftwareDbUser},
540
		        $cfg->{SoundSoftwareDbPass});
541 7:3c16ed8faa07 Chris
}
542
543
1;