chiark / gitweb /
lib/Odin.pm, bin/pastebin.userv: Use our own simple option parser.
[odin-cgi] / lib / Odin.pm
CommitLineData
be24e9af
MW
1### -*-perl-*-
2
3package Odin;
4
5use DBI;
6use Digest::SHA qw(sha256_hex);
7use MIME::Base64;
8
9###--------------------------------------------------------------------------
10### Early utilities.
11
12sub merge_hash (\%%) {
13 my ($hashref, %defaults) = @_;
14 for my $k (keys %defaults)
15 { $hashref->{$k} = $defaults{$k} unless exists $hashref->{$k}; }
16}
17
18###--------------------------------------------------------------------------
19### Configuration.
20
21our $DSN = "dbi:Pg(pg_enable_utf8=>1):host=db";
22our $RETRY = 10;
23our @BACKOFF = (0.1, 10, 1.5, 0.5, 2.0);
24
25our $BASEURL = "http://odin.gg/";
26our $STATIC = "http://odin.gg/";
27
28our $SHORTURL_PATH = "u";
29our $PASTEBIN_PATH = "p";
30
31our $URLMAXLEN = 1024;
32our @URLPAT = (
33 qr{^https?://}
34);
35
36our %COOKIE_DEFAULTS = (
37 -httponly => undef,
38 -max_age => 3600
39);
40
41require "config.pl";
42
43our ($SCHEME, $DOMAIN, $BASEPATH) = $BASEURL =~ m!^([^:]+)://([^/]+)(/.*)$!;
44merge_hash %COOKIE_DEFAULTS, -domain => $DOMAIN, -path => $BASEPATH;
45merge_hash %COOKIE_DEFAULTS, -secure => undef if $SCHEME eq "https";
46
47our $SHORTURL = "$BASEURL$SHORTURL_PATH";
48our $PASTEBIN = "$BASEURL$PASTEBIN_PATH";
49
50###--------------------------------------------------------------------------
51### Miscellaneous utilities.
52
53(our $PROG = $0) =~ s:^.*/::;
54
55sub fail_cmdline ($$%) {
56 my ($msg, $label, %args) = @_;
57 print STDERR "$PROG: $msg\n";
58 exit 1;
59}
60
61our $FAILPROC = \&fail_cmdline;
62
63sub fail ($;$%) {
64 my ($msg, $label, %args) = @_;
65 $FAILPROC->($msg, $label, %args);
66}
67
68sub set_mason_failproc ($) {
69 my ($m) = @_;
70 $FAILPROC = sub {
71 my ($msg, $label, %args) = @_;
72 $m->clear_buffer;
73 $m->comp($label, %args);
74 $m->abort;
75 };
76}
77
78sub nice_name ($) {
79 my ($s) = @_;
80 $s =~ s/\W+//g;
81 return lc $s;
82}
83
84###--------------------------------------------------------------------------
85### Database utilities.
86
87sub open_db (@) {
88 my @attr = @_;
89 my $db = DBI->connect_cached($DSN, undef, undef, {
90 PrintError => 0,
91 RaiseError => 1,
92 @attr
93 });
94
95 my $drv = $db->{Driver}{Name};
96 if ($drv eq "Pg") {
97 $db->{private_odin_retry_p} = sub { $db->state =~ /^40[0P]01$/ };
98 $db->{private_odin_unixstamp} = sub { "extract(epoch from $_[0])" };
99 } elsif ($drv eq "SQLite") {
100 $db->{private_odin_retry_p} = sub { $db->err == 5 };
101 $db->{private_odin_unixstamp} = sub { "strftime('%s', $_[0])" };
102 } else {
103 fail "unsupported database driver `$drv' (patches welcome)", undef;
104 }
105
106 return $db;
107}
108
109sub xact (&$) {
110 my ($body, $db) = @_;
111 my @rv;
112 my $exc;
113
114 my ($sleep, $maxsleep, $mult, $minvar, $maxvar) = @BACKOFF;
115 for (my $i = 0; $i < $RETRY; $i++) {
116 $db->begin_work;
117 eval { @rv = $body->(); $db->commit; };
118 $exc = $@;
119 return @rv unless $exc;
120 my $retryp = $db->{private_odin_retry_p}();
121 eval { $db->rollback; };
122 die $exc unless $retryp;
123 my $t = $sleep * ($minvar + rand($maxvar - $minvar));
124 $sleep *= $mult; $sleep = $max if $sleep > $max;
125 select undef, undef, undef, $t;
126 }
127 die $exc;
128}
129
130sub sql_timestamp ($$) {
131 my ($db, $col) = @_;
132 return $db->{private_odin_unixstamp}->($col);
133}
134
135###--------------------------------------------------------------------------
136### Sequence numbers and tagging.
137
138sub next_seq ($$) {
139 my ($db, $table) = @_;
140 my ($seq) = $db->selectrow_array("SELECT seq FROM $table");
141 die "no sequence number in $table" unless defined $seq;
142 $db->do("UPDATE $table SET seq = ?", undef, $seq + 1);
143 return $seq;
144}
145
146my $ALPHABET =
147 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
148my $NALPHA = length $ALPHABET;
149
150sub encode_tag ($) {
151 my ($seq) = @_;
152 my $tag = "";
153 while ($seq) {
154 $tag .= substr($ALPHABET, $seq % $NALPHA, 1);
155 $seq = int $seq/$NALPHA;
156 }
157 return $tag;
158}
159
160###--------------------------------------------------------------------------
161### HTTP utilities.
162
163our %COOKIE;
164sub fetch_cookies ($) {
165 my ($r) = @_;
166
167 %COOKIE = ();
168 my $cookies = $r->header_in("Cookie");
169 if (defined $cookies) {
170 for my $kv (split /;/, $cookies) {
171 my ($k, $v) = split /=/, $kv, 2;
172 $k =~ s/^\s*(|\S|\S.*\S)\s*$/$1/;
173 $v =~ s/^\s*(|\S|\S.*\S)\s*$/$1/;
174 $v =~ s/\+/ /g;
175 $v =~ s/\%([0-9a-f][0-9a-f])/chr hex $1/eg;
176 $COOKIE{$k} = $v;
177 }
178 }
179}
180
181sub bake_cookie ($$%) {
182 my ($r, $cookie, %attr) = @_;
183 merge_hash %attr, %COOKIE_DEFAULTS;
184 my @attr = map {
185 my $v = $attr{$_}; tr/_-/-/d;
186 defined $v ? "$_=$v" : $_
187 } keys %attr;
188 $r->headers_out->add("Set-Cookie", join "; ", $cookie, @attr);
189}
190
191sub path_info ($) {
192 my ($r) = @_;
193 return $ENV{PATH_INFO} // $r->path_info;
194}
195
196###--------------------------------------------------------------------------
197### HTML utilities.
198
199sub escapify ($$;$) {
200 my ($m, $s, $mode) = @_;
201 return $m->interp->apply_escapes($s, $mode // "h");
202}
203
204###--------------------------------------------------------------------------
205### Access control.
206
207our ($WHO, $WHOSURE);
208our ($WHOMATCH, $WHOCMP, $WHOPAT);
209
210sub cgi_who ($) {
211 my ($r) = @_;
212 my $raddr = $ENV{REMOTE_ADDR} // $r->connection->remote_ip;
213 $WHO = ":NET-$raddr"; $WHOSURE = 0;
214 $WHOMATCH = "LIKE"; $WHOCMP = ":NET-\%"; $WHOPAT = qr/^:NET-/;
215}
216
217sub cmdline_who () {
218 $WHO = $ENV{USERV_USER}
219 // ($< == $> && $ENV{USER})
220 // @{[getpwuid $<]}[0]
221 // die "nameless user";
222 $WHOMATCH = "="; $WHOCMP = $WHO; $WHOPAT = qr/^\Q$WHO\E$/;
223 $WHOSURE = 1;
224}
225
226sub new_editkey () {
227 open my $fh, "/dev/urandom" or die "open urandom: $!";
228 sysread $fh, my $rand, 16;
229 (my $edit = encode_base64 $rand) =~ tr:+/=\n:.-:d;
230 return $edit, sha256_hex $edit;
231}
232
233###--------------------------------------------------------------------------
234### URL shortening.
235
236sub get_shorturl ($) {
237 my ($tag) = @_;
238
239 my $db = open_db;
240 my ($url) = $db->selectrow_array
241 ("SELECT url FROM odin_shorturl WHERE tag = ?", undef, $tag);
242 fail "tag `$tag' not found", ".notfound", tag => $tag unless defined $url;
243 return $url;
244}
245
246sub valid_url_p ($) {
247 my ($url) = @_;
248 return
249 length $url < $URLMAXLEN &&
250 scalar grep { $url =~ /$_/ } @URLPAT;
251}
252
253sub new_shorturl ($) {
254 my ($url) = @_;
255
256 valid_url_p $url or fail "invalid url", ".badurl", u => $url;
257
258 my $db = open_db;
259 my $tag;
260 xact {
261 ($tag) = $db->selectrow_array
262 ("SELECT tag FROM odin_shorturl WHERE owner $WHOMATCH ? AND url = ?",
263 undef, $WHOCMP, $url);
264 unless (defined $tag) {
265 $tag = encode_tag(next_seq($db, "odin_shorturl_seq"));
266 $db->do("INSERT INTO odin_shorturl (tag, owner, url) VALUES (?, ?, ?)",
267 undef, $tag, $WHO, $url);
268 }
269 } $db;
270 return $tag;
271}
272
273sub check_shorturl_owner ($$) {
274 my ($db, $tag) = @_;
275
276 my ($owner) = $db->selectrow_array
277 ("SELECT owner FROM odin_shorturl WHERE tag = ?", undef, $tag);
278 fail "tag `$tag' not found", ".notfound", tag => $tag
279 unless defined $owner;
280 fail "not owner of `$tag'", ".notowner", tag => $tag
281 unless $owner =~ /$WHOPAT/;
282}
283
284sub update_shorturl ($$) {
285 my ($tag, $url) = @_;
286
287 my $db = open_db;
288 xact {
289 check_shorturl_owner $db, $tag;
290 $db->do("UPDATE odin_shorturl SET url = ? WHERE tag = ?",
291 undef, $url, $tag);
292 } $db;
293}
294
295sub delete_shorturl (@) {
296 my (@tags) = @_;
297
298 my $db = open_db;
299 xact {
300 for my $tag (@tags) {
301 check_shorturl_owner $db, $tag;
302 $db->do("DELETE FROM odin_shorturl WHERE tag = ?", undef, $tag);
303 }
304 } $db;
305}
306
307###--------------------------------------------------------------------------
308### Paste bin.
309
310our %PASTEBIN_DEFAULTS = (
311 title => "(untitled)",
97a33b9c 312 lang => "txt",
be24e9af
MW
313 content => ""
314);
315our @PASTEBIN_PROPS = keys %PASTEBIN_DEFAULTS;
316our $PASTEBIN_PROPCOLS = join ", ", @PASTEBIN_PROPS;
317our $PASTEBIN_PROPPLACES = join ", ", map "?", @PASTEBIN_PROPS;
318
319sub new_pastebin (\%) {
320 my ($new) = @_;
321
322 my $db = open_db;
323 my ($editkey, $hash) = new_editkey;
324 my $tag;
325
326 merge_hash %$new, %PASTEBIN_DEFAULTS;
327 xact {
328 $tag = encode_tag next_seq $db, "odin_pastebin_seq";
329 $db->do("INSERT INTO odin_pastebin
330 (tag, edithash, owner, $PASTEBIN_PROPCOLS)
331 VALUES (?, ?, ?, $PASTEBIN_PROPPLACES)", undef,
332 $tag, $hash, $WHO, @{$new}{@PASTEBIN_PROPS});
333 } $db;
334 return $tag, $editkey;
335}
336
337sub get_pastebin ($$\%) {
338 my ($db, $tag, $props) = @_;
339
340 (my $owner, my $hash, @{$props}{@PASTEBIN_PROPS}) =
341 $db->selectrow_array("SELECT owner, edithash, $PASTEBIN_PROPCOLS
342 FROM odin_pastebin WHERE tag = ?",
343 undef, $tag);
344 fail "tag `$tag' not found", ".notfound", tag => $tag
345 unless defined $owner;
346 return $owner, $hash;
347}
348
349sub get_pastebin_check_owner ($$\%) {
350 my ($db, $tag, $props) = @_;
351
352 my ($owner, $hash) = get_pastebin $db, $tag, %$props;
353 fail "not owner of `$tag'", ".notowner", tag => $tag
354 unless $WHOSURE && $WHO eq $owner;
355}
356
357sub get_pastebin_check_editkey_or_owner ($$$\%) {
358 my ($db, $tag, $editkey, $props) = @_;
359
360 if (!defined $editkey) { get_pastebin_check_owner $db, $tag, %$props; }
361 else {
362 my ($owner, $hash) = get_pastebin $db, $tag, %$props;
363 fail "incorrect edit key for `$tag'", ".badhash", tag => $tag
364 unless $hash eq sha256_hex $editkey;
365 }
366}
367
368sub rekey_pastebin ($) {
369 my ($tag) = @_;
370
371 my $db = open_db;
372 my $editkey;
373 xact {
374 get_pastebin_check_owner $db, $tag, my %hunoz;
375 ($editkey, my $hash) = new_editkey;
376 $db->do("UPDATE odin_pastebin SET edithash = ? WHERE tag = ?",
377 undef, $hash, $tag);
378 } $db;
379 return $editkey;
380}
381
382sub claim_pastebin ($$) {
383 my ($tag, $editkey) = @_;
384
385 my $db = open_db;
386 $WHOSURE or fail "you can't claim pastes", ".notsure";
387 xact {
388 get_pastebin_check_editkey_or_owner $db, $tag, $editkey, my %hunoz;
389 $db->do("UPDATE odin_pastebin SET owner = ? WHERE tag = ?",
390 undef, $WHO, $tag);
391 } $db;
392}
393
394sub update_pastebin ($$\%) {
395 my ($tag, $editkey, $new) = @_;
396
397 my $db = open_db;
398 my $editp = 0;
399 xact {
400 get_pastebin_check_editkey_or_owner $db, $tag, $editkey, my %old;
401 for my $p (@PASTEBIN_PROPS) {
402 if (!defined $new->{$p}) { $new->{$p} = $old{$p}; }
403 else {
404 $db->do("UPDATE odin_pastebin SET $p = ? WHERE tag = ?",
405 undef, $new->{$p}, $tag)
406 unless $new->{$p} eq $old{$p};
407 $editp = 1;
408 }
409 }
410 } $db;
411 return $editp;
412}
413
414sub delete_pastebin (@) {
415 my @a = @_;
416 my $db = open_db;
417 xact {
418 while (@a) {
419 (my $tag, my $editkey, @a) = @a;
420 get_pastebin_check_editkey_or_owner $db, $tag, $editkey, my %hunoz;
421 $db->do("DELETE FROM odin_pastebin WHERE tag = ?", undef, $tag);
422 }
423 } $db;
424}
425
426sub tidy_pastebin_content ($) {
427 my ($content) = @_;
428 return undef unless defined $content;
429 $content =~ tr/\r//d;
430 $content =~ s/([^\n])\z/$1\n/;
431 return $content;
432}
433
f0bcb39a
MW
434###--------------------------------------------------------------------------
435### Simple option parser.
436
437package Odin::OptParse;
438
439sub new {
440 my ($cls, @args) = @_;
441 return bless {
442 cur => "",
443 args => \@args,
444 opt => undef,
445 ok => 1
446 }, $cls;
447}
448
449sub get {
450 my ($me) = @_;
451 if (!length $me->{cur}) {
452 my $args = $me->{args};
453 if (!@$args) { return undef; }
454 elsif ($args->[0] =~ /^[^-]|^-$/) { return undef; }
455 elsif ($args->[0] eq "--") { shift @$args; return undef; }
456 $me->{cur} = substr shift @$args, 1;
457 }
458 my $o = $me->{opt} = substr $me->{cur}, 0, 1;
459 $me->{cur} = substr $me->{cur}, 1;
460 return $o;
461}
462
463sub arg {
464 my ($me) = @_;
465 my $a;
466 if (length $me->{cur}) { $a = $me->{cur}; $me->{cur} = ""; }
467 elsif (@{$me->{args}}) { $a = shift @{$me->{args}}; }
468 else { $a = undef; $me->err("option `-$me->{opt}' requires an argument"); }
469 return $a;
470}
471
472sub rest { return @{$_[0]->{args}}; }
473sub ok { return $_[0]->{ok}; }
474sub bad { $_[0]->{ok} = 0; }
475sub err { $_[0]->bad; print STDERR "$PROG: $_[1]\n"; }
476sub unk { $_[0]->err("unknown option `-$_[0]->{opt}'"); }
477
be24e9af
MW
478###----- That's all, folks --------------------------------------------------
479
4801;