chiark / gitweb /
The official `plain text' language is `txt'.
[odin-cgi] / lib / Odin.pm
1 ### -*-perl-*-
2
3 package Odin;
4
5 use DBI;
6 use Digest::SHA qw(sha256_hex);
7 use MIME::Base64;
8
9 ###--------------------------------------------------------------------------
10 ### Early utilities.
11
12 sub 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
21 our $DSN = "dbi:Pg(pg_enable_utf8=>1):host=db";
22 our $RETRY = 10;
23 our @BACKOFF = (0.1, 10, 1.5, 0.5, 2.0);
24
25 our $BASEURL = "http://odin.gg/";
26 our $STATIC = "http://odin.gg/";
27
28 our $SHORTURL_PATH = "u";
29 our $PASTEBIN_PATH = "p";
30
31 our $URLMAXLEN = 1024;
32 our @URLPAT = (
33   qr{^https?://}
34 );
35
36 our %COOKIE_DEFAULTS = (
37   -httponly => undef,
38   -max_age => 3600
39 );
40
41 require "config.pl";
42
43 our ($SCHEME, $DOMAIN, $BASEPATH) = $BASEURL =~ m!^([^:]+)://([^/]+)(/.*)$!;
44 merge_hash %COOKIE_DEFAULTS, -domain => $DOMAIN, -path => $BASEPATH;
45 merge_hash %COOKIE_DEFAULTS, -secure => undef if $SCHEME eq "https";
46
47 our $SHORTURL = "$BASEURL$SHORTURL_PATH";
48 our $PASTEBIN = "$BASEURL$PASTEBIN_PATH";
49
50 ###--------------------------------------------------------------------------
51 ### Miscellaneous utilities.
52
53 (our $PROG = $0) =~ s:^.*/::;
54
55 sub fail_cmdline ($$%) {
56   my ($msg, $label, %args) = @_;
57   print STDERR "$PROG: $msg\n";
58   exit 1;
59 }
60
61 our $FAILPROC = \&fail_cmdline;
62
63 sub fail ($;$%) {
64   my ($msg, $label, %args) = @_;
65   $FAILPROC->($msg, $label, %args);
66 }
67
68 sub 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
78 sub nice_name ($) {
79   my ($s) = @_;
80   $s =~ s/\W+//g;
81   return lc $s;
82 }
83
84 ###--------------------------------------------------------------------------
85 ### Database utilities.
86
87 sub 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
109 sub 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
130 sub sql_timestamp ($$) {
131   my ($db, $col) = @_;
132   return $db->{private_odin_unixstamp}->($col);
133 }
134
135 ###--------------------------------------------------------------------------
136 ### Sequence numbers and tagging.
137
138 sub 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
146 my $ALPHABET =
147   "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
148 my $NALPHA = length $ALPHABET;
149
150 sub 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
163 our %COOKIE;
164 sub 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
181 sub 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
191 sub path_info ($) {
192   my ($r) = @_;
193   return $ENV{PATH_INFO} // $r->path_info;
194 }
195
196 ###--------------------------------------------------------------------------
197 ### HTML utilities.
198
199 sub escapify ($$;$) {
200   my ($m, $s, $mode) = @_;
201   return $m->interp->apply_escapes($s, $mode // "h");
202 }
203
204 ###--------------------------------------------------------------------------
205 ### Access control.
206
207 our ($WHO, $WHOSURE);
208 our ($WHOMATCH, $WHOCMP, $WHOPAT);
209
210 sub 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
217 sub 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
226 sub 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
236 sub 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
246 sub valid_url_p ($) {
247   my ($url) = @_;
248   return
249     length $url < $URLMAXLEN &&
250     scalar grep { $url =~ /$_/ } @URLPAT;
251 }
252
253 sub 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
273 sub 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
284 sub 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
295 sub 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
310 our %PASTEBIN_DEFAULTS = (
311   title => "(untitled)",
312   lang => "txt",
313   content => ""
314 );
315 our @PASTEBIN_PROPS = keys %PASTEBIN_DEFAULTS;
316 our $PASTEBIN_PROPCOLS = join ", ", @PASTEBIN_PROPS;
317 our $PASTEBIN_PROPPLACES = join ", ", map "?", @PASTEBIN_PROPS;
318
319 sub 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
337 sub 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
349 sub 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
357 sub 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
368 sub 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
382 sub 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
394 sub 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
414 sub 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
426 sub 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
434 ###----- That's all, folks --------------------------------------------------
435
436 1;