2 November, 2004: ... but one hundred thousand deaths is an easily-abused statistic

[ Home page | Web log ]

Most of my half-dozen readers will by now have heard about the research conducted by Les Roberts, Riyadh Lafta, Richard Garfield, Jamal Khudhairi and Gilbert Burnham, published in the current issue of The Lancet, estimating the number of civilians killed during the war against Iraq (so far). If you have not already done so, please read the paper; it is interesting, and will tell you much more about it than any number of `about the research' pieces in the newspapers or (god forbid) on people's web logs.

... Now you have done so, you will be familiar with the central conclusion of the piece, expressed by its authors as a quiet understatement:

In this case, the lack of precision does not hinder the clear identication of the major public-health problem in Iraq-- violence.

Very briefly, the way the study was conducted was to conduct surveys of clusters of households randomly assigned to bits of Iraq weighted by population. Each survey asked householders to record deaths of members of the household in a period prior to the invasion of Iraq and in a similar period after the invasion. From these data they estimated the death rate before and after the invasion, and from this formed an estimate of the total number of excess deaths from the invasion.

The authors acknowledge that, under current conditions, Iraq is not an easy country in which to conduct such a survey:

During September, 2004, many roads were not under the control of the Government of Iraq or coalition forces. Local police checkpoints were perceived by team members as target identification screens for rebel groups. To lessen risks to investigators,we sought to minimise travel distances and the number of Governorates to visit,while still sampling from all regions of the country.

-- and the survey certainly isn't as accurate as the ideal which could be expected in a country at peace. The headline figure -- of a conservative estimate of 98,000 deaths due to the war -- is the center of a 95% confidence interval which stretches from 8,000 to 194,000 deaths. (Note that this figure does not include information from Fallujah, an area which has suffered more violence than most and in which it was very difficult to do the survey. Including the figured from Fallujah increases the estimate by about 200,000 but broadens the confidence interval further.)

A number of commentators have argued that this wide uncertainty makes the study worthless. This is nonsense, but it is important to understand what it actually means.

Specifically, under the assumptions of the survey, the authors estimate that there is a 95% chance that the two limiting values they obtain enclose the true number of deaths. Loosely, you can turn this around and regard this as a 95% chance that the true value lies in the interval (and hence a 5% chance that the true number lies outside that interval). Further (if we assume a symmetric distribution, which is plausible), there is a 50% chance that the total number of deaths exceeded 98,000, and a 2.5% chance that it was less than 8,000.

To put this into English, it means `we're not sure exactly how many, but a hell of a lot of people almost certainly died'. Make up your own mind, but in my view the low estimates of the number of dead in Iraq should now be regarded with deep suspicion.

There have been several criticisms of this study, most of them rather silly. Tim Lambert and Daniel Davies on Crooked Timber discuss and rebut a variety of complaints. A few more criticisms should, perhaps, be added:

Another source of criticism of this research has been 10 Downing Street. Last Friday, the Official Spokesman complained that,

Firstly, the survey appeared to be based on an extrapolation technique rather than a detailed body count. Our worries centred on the fact that the technique in question appeared to treat Iraq as if every area was one and the same. In terms of the level of conflict, that was definitely not the case. Secondly, the survey appeared to assume that bombing had taken place throughout Iraq. Again, that was not true. It had been focussed primarily on areas such as Fallujah. Consequently, we did not believe that extrapolation was an appropriate technique to use.

This is -- I've searched in vain for a politer way to put it -- crap.

Almost all national-level statistics are based on extrapolation (or, more accurately, sampling). Are we supposed to believe that (say) the Census or (for another example) Government research showing public support for ID cards are worthless because they did not count each individual living person in the country?

The implication that no casualty figures could be accurate unless they are derived from a `detailed body count' is also absurd, especially given that Coalition forces have refused to conduct any such research; in any case, a `body count' would severely underestimate the total number killed -- partly because many bodies will not be recovered (for instance, those killed when bombed buildings collapse), and partly because it's now impossible accurately to count the bodies of those who have already died and been buried.

Further, the survey did not treat `Iraq as if every area was one and the same', as even a cursory inspection of the paper will tell the reader. Similarly, the survey did not `assume that bombing had taken place throughout Iraq'; instead, samples were taken at numerous locations in order to account for the geographical distribution of damage (many of the sampled areas were unbombed, as you would expect). Specifically, as I have remarked, the headline number excludes Fallujah, because of the high concentration of bombing and difficulties of conducting the survey there.

And yesterday, the Official Spokesman was at it again, repeating the same false statements and adding some new stuff to confuse the lobby journalists:

Asked to explain further the Government's previous concerns and doubts about the methodology applied in the ``Lancet'' article about the number of Iraqi deaths, the PMOS replied because it relied on the extrapolation technique assumed, that Iraq was uniform in terms of intensity of conflict. It wasn't. The article also assumed that bombing was general throughout Iraq, which was not the case. The Iraqi Department of Health had issued figures that showed over a 6 month period there were about 3,000 deaths, which was a long way short of the figures quoted in the ``Lancet''. The Iraqi DOH measured those figures by the number of people who came into hospitals throughout Iraq, and it was very difficult to rely on any such figures quoted in the ``Lancet'' with any certainty.

So: the Iraqi Department of Health counted the number of people who came into hospitals and then died. And they got a different figure from a survey which attempted to estimate the total number of people who died, whether in or out of hospital. Quelle fucking surprise. It's -- at the risk of pointing out the obvious -- a war zone! People who get hit by a LASER-guided bomb don't have time to go to hospital before they die! Of course the fucking numbers differ.

What's going on here, then? I am not sure, and indeed am having trouble untangling my cynicism about others' competence from my cynicism about others' honesty. But my best guess: part of this is misdirection -- the idea, I think, being to try to convince journalists who may be too lazy to check the story properly that It's Not Quite As Bad As They Think; as for the rest, well, the charitable explanation is that the Downing Street press office doesn't know any statistics and doesn't know anyone who does. The more cynical explanation is that this is simple and shameful dishonesty, a feeble attempt to discredit a study which suggests that the war against Iraq has killed the very people it was supposed to save by the tens of thousands.

This is one of the most disappointing pieces of government information I've seen since -- not coincidentally -- that crap they put out about `weapons of mass destruction'. A sad business, all in all.

Moving on, many supporters of the war have been embarrassed by this study, apparently because they do not understand what a war is. For the avoidance of doubt, the fact that one hundred thousand people may have been killed as a result of the late unpleasantness does not necessarily mean that the war was a mistake or `wrong', any more than the fact that a third to one half that number were killed in a single day in Dresden in February 1945 made the war against the Nazis a mistake or `wrong'. (Of course, the civilians killed in Dresden were killed deliberately, whereas -- charitably -- most of those killed in Iraq have been killed recklessly.)

It may tell us more about the conduct of the war (is it really a good idea to supress an insurgency by dropping five hundred pound bombs on urban areas, even if they are LASER-guided?); but in my opinion it is more telling that many apologists for the present war -- usually the first people to compare Saddam to Hitler and Iraq in 2003-4 to Germany in 1939-45 -- have confused these two issues. In wars, people die, often by mistake and often in large numbers. The case the war apologists have now to make is not about the exact numbers of deaths -- later, we will better know how many died, but for the moment 98,000 is the best estimate available -- but whether the deaths were necessary.

And, frankly, it doesn't look very good so far, does it?

Lastly, as a brief comment on the conduct of the war, I will draw your attention to this film taken from an F16 flying over Fallujah in April. (Don't pay too much attention to the commentary on that page, but the video is linked from there; sadly it's in the proprietary -- and rubbish -- Microsoft `Media Player' format, but `xine' will play it.)

Channel 4 News covered this footage, which shows a pilot guiding a bomb towards a building, presumably under instructions from a forward air controller on the ground. While the bomb is falling, a crowd of people appears in the street adjacent to the building and the pilot asks whether he should direct the bomb onto them. The person on the ground tells him to, and he does.

There is some controversy about whether the people bombed -- from a rough count there are thirty to forty people on the video -- were combatants or not. You can read about that in the sites I've linked to. That's not what I want to draw your attention to, though. Here are some frame grabs from the video (lousy quality, sorry). First, a general view from before the crowd emerges, to give a sense of scale:

Street scene

Note cross-hair, showing the original target of the bomb, designated by a LASER shining from the 'plane onto the ground. The pointing of the LASER can be changed as the bomb is falling, allowing the target of the bomb to be altered. The street in the center of the picture is quite wide -- about 40 meters, judging from the size of the people in the second picture, and it is surrounded by two- and three-storey buildings.

Crowd, about to be bombed

Here is the crowd of people. By this stage the pilot of the 'plane is steering the bomb towards them. For those who can't play the video, the dialogue between the pilot and ground controller ran,

Pilot: I got numerous individuals on the road. Do you want me to take those out?

Controller: Take 'em out.

(As an aside, remember that the controller on the ground may have a much better view of proceedings than the pilot -- these people may have been running towards his position shooting at him, for instance, and we would not be able to tell that from the video.)

Note the counter at the lower right of the picture, reading `000:11' -- that is the time before the bomb hits the ground.


And here is the cloud of debris about a second after the explosion.

Bearing in mind the width of the street and the nature of the surrounding area, consider this:

Most everything will be severely damaged, injured, destroyed, or killed within 20 meters of a 500-pound bomb blast....

Safe distances for unprotected troops are approximately 1,000 meters for 2000-pound bombs and 500 meters for 500-pound ones. Even protected troops are not entirely safe within 240 meters of a 2,000-pound bomb or 220 meters of a 500-pound bomb.

During the war proper -- before the `Mission Accomplished' stunt -- the Americans dropped about 18,000 guided bombs. (This ignores a further 10,000 or so `dumb' bombs.) As a brief estimation exercise, if all of those were dropped in urban areas with a population density of 5,000/km² (probably an underestimate), then for a uniform distribution of targets we'd expect around 100,000 casualties from bombs with a 20-meter lethal radius. That's certainly an overestimate (even the US Air Force no longer bombs at random) but the order of magnitude is... suggestive.

#!/usr/bin/perl -w # # wcomments: # Simple web comments script. Note that this is intended to be used as a SSI. # # Copyright (c) 2004 Chris Lightfoot. All rights reserved. # Email: chris@ex-parrot.com; WWW: http://www.ex-parrot.com/~chris/ # my $rcsid = ''; $rcsid .= '$Id: wcomments,v 1.26 2006/08/05 14:57:04 chris Exp $'; use strict; use Carp; use CGI::Fast; use CGI qw(-no_xhtml -nosticky); use HTML::Entities; use HTML::Sanitizer; use IO::File; use DBI; use DBD::SQLite; use Error qw(:try); use Digest::MD5; use POSIX; use Mail::RFC822::Address; use Mail::Sendmail; use Regexp::Common qw(URI); #CGI::autoEscape(0); $ENV{HOME} = '/home/chris'; #(getpwuid($<))[7]; my $CT = 'text/html; charset=iso-8859-1'; my $dbname = "$ENV{HOME}/private/web/wcomments.sqlite"; my $policy_url = "/~chris/wwwitter/comments-policy.html"; my $article_root = '/home/chris/public_html/wwwitter'; my $article_url = '/~chris/wwwitter'; my $script_url = '/~chris/scripts/display-comments'; # need this because of mod_include flakiness my $secret = '6063cd79186a5817a04391343355b494'; # hey, do I *look* like some kind of cypherweenie? # select_single_row DBH STATEMENT [BINDVALS] # Return in list context the columns returned by performing STATEMENT on DBH # with BINDVALS. sub select_single_row ($$;@) { my ($dbh, $stmt, @binds) = @_; my $x = $dbh->selectall_arrayref($stmt, {}, @binds); throw Error::Simple("statement `$stmt' returned " . scalar(@$x) . " rows in select_single_row, should be 1") unless (@$x == 1); return @{$x->[0]}; } # select_single_value DBH STATEMENT [BINDVALS] # As select_single_row, but return in scalar context the single value returned. sub select_single_value ($$;@) { my ($dbh, $stmt, @binds) = @_; my @x = select_single_row($dbh, $stmt, @binds); throw Error::Simple("statement `$stmt' returned " . scalar(@x) . " columns in select_single_value, should be 1") unless (@x == 1); return $x[0]; } # new_url QUERY [PARAM VALUE]... # Return the URL of the QUERY, with the given changes to PARAMs. sub new_url ($%) { my ($q, %p) = @_; my $q2 = new CGI($q); foreach (keys %p) { if (!defined($p{$_})) { $q2->delete($_); } else { $q2->param($_, $p{$_}); } } # return $q2->url(-absolute => 1, -query => 1); return "$script_url?" . $q2->query_string(); } # create_schema DBH # Set up the database according to the proper schema. sub create_schema ($) { my $dbh = shift; if (select_single_value($dbh, q#select count(*) from sqlite_master where type = 'table' and name = 'comments'#) == 0) { $dbh->do(q# create table comments ( id text not null primary key, -- comment ID, 8 hex digits article text not null, -- article on which comment is rooted refs text not null default '', -- reference list of comment author text not null, -- metadata, content of comment email text not null, link string, date integer not null, ipaddr integer not null, content text not null, -- as HTML visible integer not null default 0 -- should comment be shown? )#); } if (select_single_value($dbh, q#select count(*) from sqlite_master where type = 'index' and name = 'comments_refs_index' and tbl_name = 'comments'#) == 0) { $dbh->do(q# create index comments_refs_index on comments(refs) #); } if (select_single_value($dbh, q#select count(*) from sqlite_master where type = 'table' and name = 'authors'#) == 0) { $dbh->do(q# create table authors ( digest text not null primary key, -- random token status integer not null default 0, -- nonzero means to hold the comment author text not null, email text not null, link text )#); } $dbh->commit(); } # html_head TITLE # Return the top of an HTML page, with the given TITLE. sub html_head ($) { my ($title) = @_; encode_entities($title); return <


EOF } # html_tail # Return the end of an HTML page. sub html_tail () { return <

Comments copyright (c) contributors and available under a Creative Commons License. See also the comments policy.

EOF } sub page_head ($) { my ($title) = @_; encode_entities($title); return < $title


EOF } sub page_tail () { return <

Comments copyright (c) contributors and available under a Creative Commons License. See also the comments policy.

EOF } # error_page QUERY SHORT LONG # Return the HTML for an error page on QUERY, with SHORT and LONG error text. sub error_page ($$$) { my ($q, $short, $long) = @_; return html_head("Error: $short") . $q->p(encode_entities($long)) . html_tail(); } # comment_url DBH ID # Return the URL of a comment. sub comment_url ($$) { my ($dbh, $id) = @_; my $article = select_single_value($dbh, 'select article from comments where id = ?', $id); return "$article_url/$article#wcomment_$id"; } # article_url ARTICLE # Return the URL of an article. sub article_url ($) { my ($art) = @_; return "$article_url/$art"; } # read_data_from_item FILENAME # Read title, date and text from FILENAME. sub read_data_from_item ($) { my $fn = shift; my $F = new IO::File($fn, O_RDONLY) or die "$fn: open: $!"; my $line; my ($title, $date, $text); $text = ""; my $f = 0; while ($line = $F->getline()) { if ($line =~ m##) { # XXX we assume that the subject is already HTML-entity encoded ($title, $date) = ($1, $2); } $f = 0 if ($f && $line =~ m##); $text .= $line if ($f); if ($line =~ m#
#) { $f = 1; $text = ""; } } $F->close(); die "$fn lacks title and date" unless ($title and $date); die "$fn lacks text" unless (defined $text); return ($title, $date, $text); } # article_title ARTICLE # Return the title of an ARTICLE. my %article_title_cache; sub article_title ($) { my ($art) = @_; if (!exists($article_title_cache{$art})) { ($article_title_cache{$art}) = read_data_from_item("$article_root/$art"); } return $article_title_cache{$art}; } # format_one_comment DBH ID AUTHOR EMAIL LINK DATE CONTENT # Return HTML to display a single comment. #sub format_one_comment ($$$$$$$) { sub format_one_comment ($$@) { my ($dbh, $id, $author, $email, $link, $date, $content) = @_; my $html = ''; # Format the date as a string. my $ds; if (abs($date - time) < 24 * 3600) { $ds = strftime('%H:%M', localtime($date)); } elsif (abs($date - time) < 7 * 24 * 3600) { $ds = strftime('%A, %H:%M', localtime($date)); } elsif (abs($date - time) < 365 * 24 * 3600) { $ds = strftime('%A, %e %B %H:%M', localtime($date)); } else { $ds = strftime('%A, %e %B %Y %H:%M', localtime($date)); } $link = "mailto:$email" if (!defined($link)); $html .= sprintf('

Posted by %s, %s (link):

', $id, encode_entities($link), encode_entities($author), $ds, comment_url($dbh, $id)); $html .= "
" . sanitise($content, $email eq 'chris@ex-parrot.com') . "
"; return $html; } # do_format_comments DBH QUERY LIST FIRST LAST # Format comments LIST[FIRST..LAST]. sub do_format_comments ($$$$$); sub do_format_comments ($$$$$) { my ($dbh, $q, $cc, $first, $last) = @_; my $html = ''; my $article = $q->param('article'); for (my $i = $first; $i <= $last; ++$i) { my ($id, $refs, $author, $email, $link, $date, $content) = @{$cc->[$i]}; $html .= "
  • " . format_one_comment($dbh, $id, $author, $email, $link, $date, $content); $html .= sprintf('Reply to this.', encode_entities(new_url($q, mode => 'post', article => $article, replyid => $id))); # Consider whether the following comments are replies to this comment. my $R = "$refs,$id"; my $j; for ($j = $i + 1; $j <= $last && $cc->[$j]->[1] =~ /^$R(,|$)/; ++$j) {} --$j; $html .= "
      " . do_format_comments($dbh, $q, $cc, $i + 1, $j) . "
    " if ($j > $i); $i = $j; $html .= "
  • "; } return $html; } # format_comments QUERY DBH ARTICLE # Return HTML displaying comments on ARTICLE. sub format_comments ($$$) { my ($q, $dbh, $article) = @_; my $html = '
      '; my $cc = $dbh->selectall_arrayref(q(select id, refs, author, email, link, date, content from comments where article = ? and visible <> 0 order by refs || ',' || id, date), {}, $article); $html .= do_format_comments($dbh, $q, $cc, 0, @$cc - 1) . '
    ' . sprintf('

    Post a new comment.

    ', encode_entities(new_url($q, mode => 'post', article => $article, replyid => undef))); return $html; } # sanitise UNTRUSTED [PRIV] # Take UNTRUSTED HTML input, and return (more-or-less) proper HTML. If PRIV is # true, allow some additional privileges (in respect of images). sub sanitise ($;$) { my ($text, $priv) = @_; $priv ||= 0; # Do the blank lines thing. $text =~ s#\r\n#\n#sg; $text =~ s#\n\n+#

    #sg; $text = "

    $text"; our $s; $s ||= new HTML::Sanitizer( a => { href => 1, '*' => 0 }, td => { width => 1, align => 1, '*' => 0 }, th => { width => 1, align => 1, '*' => 0 }, '*' => 0 ); $s->permit( qw( strong em cite table tr td th ul ol li br p blockquote strike sup sub ) ); $s->permit(img => { src => 1, alt => 1, title => 1, '*' => 0}) if ($priv); $text = $s->filter_xml_fragment($text); # Drop any empty tags. $text =~ s#<([^/][^>]*)>(\s*)#$2#sg; # Turn XHTML to HTML. Uses _xml_ above so that we always get close tags. # God what a nightmare this is. $text =~ s# />#>#g; # This *really* shouldn't be necessary. decode_entities($text); return $text; } # $allowed_html_blurb # Description of what can be done with HTML. my $allowed_html_blurb = <Allowed HTML

    Blank lines in your comment will be converted into paragraph breaks. You can also use any of the following HTML:

    • <strong>strong</strong>
    • <em>emphasised<em>
    • <cite>citation</cite>
    • <strike>struck out</strike>
    • <a href="http://www.google.com/">link</a>
    • Line <br>
    • <sup>super</sup>script
    • <sub>sub</sub>script



    I returned and saw under the sun, that the race is not to the swift, nor the battle to the strong, neither yet bread to the wise, nor yet riches to men of understanding, nor yet favour to men of skill; but time and chance happeneth to them all.




    • <li>item</li>
    • <li>item</li>



    1. <li>item</li>
    2. <li>item</li>




    <tr> <th> Heading 1 </th> <th> Heading 2 </th> </tr>
    <tr> <td> Data 1 </td> <td> Data 2 </td> </tr>


    EOF # timestamp TIME # Return a textual timestamp. sub timestamp ($) { my ($t) = @_; return strftime('%H:%M, %A, %e %B %Y', localtime($t)); } # unws STRING # Strip whitespace from start and end of STRING. sub unws ($) { $_[0] =~ s/^\s+//; $_[0] =~ s/\s+$//; } # magic_number [N] # Generate or, if N is supplied, check a magic number. sub magic_number (;$) { if (@_) { my $n = shift; return 0 if (!defined($n) || $n !~ /^[0-9a-f]{6}$/i); return substr($n, 3) eq substr(Digest::MD5::md5_hex("$secret\0" . substr($n, 0, 3)), 0, 3); } else { my $n = sprintf('%03x', int(rand(0x1000))); return $n . substr(Digest::MD5::md5_hex("$secret\0$n"), 0, 3); } } # post_comment_form QUERY DBH ARTICLE AUTHORDIGEST # Return HTML displaying a form for posting a new comment. Modifies # AUTHORDIGEST to give a new cookie. sub post_comment_form ($$$$) { my ($q, $dbh, $article, $authordigest) = @_; my $html = ''; # If the author hasn't specified the name, see if their cookie is in the # database. my ($c_author, $c_email, $c_link) = $dbh->selectrow_array('select author, email, link from authors where digest = ?', {}, $authordigest) if (defined($authordigest)); my $author = $q->param('author'); $author = $c_author || '' unless (defined($author)); my $email = $q->param('email'); $email = $c_email || '' unless (defined($email)); my $link = $q->param('link'); $link = $c_link || '' unless (defined($link));; my $replyid = $q->param('replyid') || ''; throw Error::Simple("bad reply id $replyid") if ($replyid ne '' and select_single_value($dbh, 'select count(*) from comments where id = ? and visible <> 0', $replyid) != 1); my $magic_number = $q->param('magic_number'); unws($author); unws($email); unws($link); $author =~ s#\s+# #g; $q->param('author', $author); $q->param('email', $email); $q->param('link', $link); $q->param('article', $article); $q->param('replyid', $replyid); my $linkerror = ''; $linkerror = '
    If you give a web page link, it should be a full URL starting http://.' if ($link ne '' and $link !~ $RE{URI}{HTTP}{-scheme => qr/https?/}); my $emailerror = ''; $emailerror = '
    You must give a valid email address.' if (!Mail::RFC822::Address::valid($email)); my $text = $q->param('text'); $text ||= ''; $q->param('text', $text); # $html .= $q->p('Please read the', $q->a({ href => $policy_url }, 'comments policy'), 'before posting'); $html .= $q->p($q->strong('Commenters are reminded that they must read and agree to the ', $q->a({ href => $policy_url }, 'comments policy'), 'before posting. In particular, to have your comment posted, you must: write something interesting; give your full real name and email address; and avoid errors of grammar and orthography.')); if ($replyid ne '') { $html .= $q->p($q->em('This is the comment to which you are replying:')) . $q->blockquote(format_one_comment($dbh, $replyid, $dbh->selectrow_array('select author, email, link, date, content from comments where id = ?', {}, $replyid))); } if (!defined($q->param('counter'))) { $q->param('counter', 0); } else { $q->param('counter', int($q->param('counter')) + 1); $html .= $q->h3("Previewing your comment") . $q->p('Not yet posted by', $q->strong( $q->a({ href => ($link ? $link : "mailto:$email") }, $author) ) . ':') . $q->div(sanitise($text, ($email eq 'chris@ex-parrot.com' ? 1 : 0))); } $q->param('counter', 0) unless ($emailerror eq '' and $linkerror eq '' and $text =~ /[^\s]/); if ($emailerror eq '' and $linkerror eq '') { my $newdigest = Digest::MD5::md5_hex("$secret\0$author\0$email\0$link"); $dbh->do('delete from authors where digest = ?', {}, $newdigest); $dbh->do('insert into authors (digest, author, email, link) values (?, ?, ?, ?)', {}, $newdigest, $author, $email, $link); $dbh->commit(); $authordigest = $_[3] = $newdigest; } unless ($q->param('Post') and $q->param('counter') > 0 and magic_number($magic_number)) { # Don't have enough information to post the comment yet; return a form # for the user to fill out. $magic_number = magic_number() if (!magic_number($magic_number)); $html .= $q->start_form(-method => 'post', -action => $q->url(-absolute => 1)) . $q->hidden(-name => 'mode') . $q->hidden(-name => 'counter') . $q->hidden(-name => 'article') . $q->hidden(-name => 'replyid', -default => '') . $q->start_table({ style => 'width: 100%' }) . $q->Tr($q->th('Name'), $q->td($q->textfield({ name => 'author', size => 25 }))) . $q->Tr($q->th('Email'), $q->td($q->textfield({ name => 'email', size => 25 }), $emailerror)) . $q->Tr($q->th('Web page'), $q->td($q->textfield({ name => 'link', size => 25}), $linkerror)) . $q->Tr($q->th({ colspan => 2 }, 'Comment')) . $q->Tr($q->td({ colspan => 2 }, $q->textarea({ name => 'text', style => 'width: 100%', columns => 40, rows => 25 }))) . ($q->param('counter') > 0 ? $q->Tr($q->th("Magic number"), $q->td($q->textfield({ name => 'magic_number', size => 25}), $q->br(), "(The magic number is $magic_number)")) : '') . $q->end_table() . $q->submit({ -name => 'Preview', -value => 'Preview' }) . " " . ($q->param('counter') > 0 ? $q->submit({ -name => 'Post', -value => 'Post' }) : '') . $q->end_form() . $allowed_html_blurb; } else { # Have information to post a comment; do so. my $doemail = 1; my $status = select_single_value($dbh, 'select status from authors where digest = ?', $authordigest); $status ||= 0; $html = $q->p( 'We have now disabled comments to Chris\'s weblog, since for some time the only new contributions have been comment spam. Please contact us if you would like something to be added.'); if (0) { $status = 1 if ($article =~ m#^20040909#); if ($article eq '20040705-where_theres_muck.html' || $text =~ /TARRIFIC/) { $status = 1; $doemail = 0; } elsif ($author eq 'engine marketing search') { $status = 1; $doemail = 0; } elsif ($author =~ /De[rs]o?lo?p\d*|Deofl|Sweedrjj|Nerol|Cseloplj\d*/ || $text =~ /(rolex|vicodin|levitra|xanax)/i || $text =~ /(airlinetickets|really cheap airline)/i || $text =~ /(tramado[ln]|air-conditioning-review)/i) { $status = 1; $doemail = 1; } my $refs = ''; if ($replyid ne '') { $refs = select_single_value($dbh, 'select refs from comments where id = ?', $replyid) || ''; $refs .= ",$replyid"; } $link = undef if ($link eq ''); my $id = sprintf('%08x', select_single_value($dbh, 'select count(*) from comments') + 100); $dbh->do('insert into comments (id, article, refs, author, email, link, date, ipaddr, content, visible) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', {}, $id, $article, $refs, $author, $email, $link, time(), $q->remote_host(), $text, ($status == 0 ? 1 : 0)); $dbh->commit(); $html = $q->p('Thank you for your comment. You can view it', $q->a({ href => comment_url($dbh, $id) }, 'here') . '.'); my $extra = ''; if ($status != 0) { $extra = 'Comment has been HELD.'; } my $showlink = "http://ex-parrot.com/~chris/scripts/display-comments?mode=show;id=$id;s=" . Digest::MD5::md5_hex("$secret\0show\0$id"); my $hidelink = "http://ex-parrot.com/~chris/scripts/display-comments?mode=hide;id=$id;s=" . Digest::MD5::md5_hex("$secret\0hide\0$id"); # Notify me about the comment. my $commlink = comment_url($dbh, $id); $link ||= ''; if ($doemail) { sendmail( From => 'wcomments@ex-parrot.com', To => 'cl-wcomments@ex-parrot.com', Subject => 'New comment posted by ' . $author, 'Message-ID' => sprintf('', time(), int(rand(0xffffffff)), int(rand(0xffffffff))), Message => < $link http://ex-parrot.com$commlink Show: $showlink Hide: $hidelink $text $extra EOF } } } return $html; } my $dbh = DBI->connect("dbi:SQLite:$dbname", undef, undef, { AutoCommit => 0, RaiseError => 1 }); create_schema($dbh); while (my $q = new CGI::Fast()) { # $q->autoEscape(0); try { my $authordigest = $q->cookie('wcomments_author'); if (defined($authordigest) and (length($authordigest) != 32 or $authordigest =~ /[^0-9a-f]/i)) { $authordigest = undef; } my $mode = $q->param('mode'); throw Error::Simple("no mode specified") if (!defined($mode)); my $article; if ($mode !~ /^(recent|show|hide)$/) { # To which article are we looking at comments? Sanity-check it. $article = $q->param('article'); throw Error::Simple("no article specified") if (!defined($article)); $article =~ s#.*/([^/]+\.html$)#$1#; throw Error::Simple("bad article \"$article\"") if (!-e "$article_root/$article" || $article !~ /^(\d{8})-/ );#|| $1 < '20040201'); } # Five modes: show a link with the number of comments to be displayed; # show the comments; show complete HTML pages with facility to post # a comment; report recently-made comments; or show or hide a comment. if ($mode eq 'commentslink') { # Link to article, showing number of comments. print $q->header(-type => $CT); my $ncomms = select_single_value($dbh, 'select count(*) from comments where article = ? and visible <> 0', $article); print $q->p($q->a({ href => "$article#wcomments" }, "Comments"), ($ncomms > 0 ? sprintf("(%d so far)", $ncomms) : "")); } elsif ($mode eq 'comments') { # Show comments. print $q->header(-type => $CT), html_head('Comments'), '', format_comments($q, $dbh, $article), html_tail(); } elsif ($mode eq 'post') { # Post a comment or reply. if (select_single_value($dbh, 'select count(*) from ipbans where ipaddr = ?', $q->remote_host()) > 0) { print $q->header(-type => $CT, -status => 500), page_head("Error"), $q->p('Unfortunately, an error has occured. Please try again later.'), page_tail(); } else { my $f = post_comment_form($q, $dbh, $article, $authordigest); print $q->header(-type => $CT, -cookie => $q->cookie( -name => 'wcomments_author', -value => $authordigest, -expires => '+365d')), page_head("Post comment"), $q->p( 'We have now disabled comments to Chris\'s weblog, since for some time the only new contributions have been comment spam. Please contact us if you would like something to be added.'), page_tail(); } } elsif ($mode eq 'recent') { # XXX should only show one element for each article-author tuple. my $x = $dbh->selectall_arrayref( 'select id, author, article from comments where visible = 1 order by date desc limit 6'); print $q->header(-type => $CT), "
      "; foreach (@$x) { my ($id, $author, $article) = @$_; print $q->li($q->a({ href => comment_url($dbh, $id) }, encode_entities($author)), "on", $q->a({ href => article_url($article) }, article_title($article))); } print "
    "; } elsif ($mode =~ /^(show|hide)$/) { my $id = $q->param('id'); my $s = $q->param('s'); throw Error::Simple("Missing parameter") if (!defined($id) || !defined($s)); throw Error::Simple("Permission denied") unless ($s eq Digest::MD5::md5_hex("$secret\0$mode\0$id")); $dbh->do('update comments set visible = ? where id = ?', {}, ($mode eq 'show' ? 1 : 0), $id); $dbh->commit(); my $verb = ($mode eq 'show' ? 'made visible' : 'hidden'); print $q->header(-type => $CT), $q->start_html("Comment $id $verb"), $q->p("Comment $id $verb"), $q->end_html(); } else { throw Error::Simple("bad mode \"$mode\""); } } catch Error::Simple with { my $E = shift; print $q->header(-type => $CT), error_page($q, "Comments error", $E->text()); $dbh->rollback(); }; $dbh->rollback(); # DBD::SQLite bug } $dbh->disconnect();

    Copyright (c) 2004 Chris Lightfoot; available under a Creative Commons License.