=head1 SQL Schema CREATE TABLE $p ( id integer PRIMARY KEY AUTOINCREMENT NOT NULL, issue integer NOT NULL, date integer NOT NULL, name text DEFAULT '' NOT NULL, email text DEFAULT '' NOT NULL, admin integer DEFAULT 0 NOT NULL, closed integer DEFAULT 0 NOT NULL, type text DEFAULT '' NOT NULL, status text DEFAULT '' NOT NULL, summary text NOT NULL, message text DEFAULT '' NOT NULL ); =cut package TUWF::Bug; use TUWF ':xml', ':html', 'html_escape'; use POSIX 'strftime'; sub new { my $class = shift; return bless { table => 'issues', types => [qw|bug feature docs other|], default_type => 'other', statusses => [qw|new accepted duplicate confirmed fixed wontfix worksforme|], default_status => 'new', admins => ['code1', 'code2'], @_ }, $class; } sub dbListing { my $self = shift; my %o = ( results => 100, page => 1, closed => 2, @_ ); $o{reverse} = 1 if !$o{sort}; my %where = ( 'NOT EXISTS(SELECT 1 FROM !s im WHERE im.id > m.id AND im.issue = m.issue)' => $self->{table}, $o{id} ? ('issue = ?' => $o{id}) : (), $o{closed} != 2 ? ('closed = ?' => $o{closed}?1:0) : (), ); my $order = sprintf { date => 'id %s', }->{$o{sort}||'date'}, $o{reverse} ? 'DESC' : 'ASC'; my($r, $np) = $TUWF::OBJ->dbPage(\%o, q{ SELECT issue, summary, date, type, status, closed FROM !s m !W ORDER BY !s}, $self->{table}, \%where, $order ); return wantarray ? ($r, $np) : $r; } sub dbItem { my($self, $id) = @_; return $TUWF::OBJ->dbAll(q{ SELECT issue, summary, date, type, status, closed, name, admin, message FROM !s WHERE issue = ? ORDER BY id}, $self->{table}, $id ); } sub dbRecent { my $self = shift; return $TUWF::OBJ->dbAll(q{ SELECT issue, summary, date, name FROM !s ORDER BY id DESC LIMIT 10}, $self->{table} ); } sub dbEmails { my($self, $id) = @_; return [ map $_->{email}, @{$TUWF::OBJ->dbAll(q|SELECT DISTINCT email FROM !s WHERE issue = ? AND email <> ''|, $self->{table}, $id)} ]; } sub dbSave { my($self, $id, $closed, @a) = @_; # TODO: Issue ID allocation may currently cause two bug reports created at # the same time to get the same id. my $issue = $id ? '?' : '(SELECT COALESCE(MAX(issue)+1, 1) FROM !s)'; $TUWF::OBJ->dbExec( "INSERT INTO !s (issue, date, closed, summary, name, email, type, status, message, admin) VALUES ($issue, ?, ?, !l)", $self->{table}, $id || $self->{table}, time(), $closed?1:0, \@a); return $TUWF::OBJ->dbRow('SELECT MAX(issue) AS issue FROM !s', $self->{table})->{issue}; } # TODO: pagination / filtering sub htmlListing { my($s, $l, $lnk) = @_; if(!@$l) { p class => 'bug_nolisting', 'No bugs found! Yay?'; return; } table class => 'bug_listing'; thead; Tr; td class => 'bug_col_id', 'Id'; td class => 'bug_col_type', 'Type'; td class => 'bug_col_status', 'Status'; td class => 'bug_col_date', 'Updated'; td class => 'bug_col_summary','Summary'; end; end; for(@$l) { Tr $_->{closed} ? (class => 'bug_closed') : (); td class => 'bug_col_id', $_->{issue}; td class => 'bug_col_type', $_->{type}; td class => 'bug_col_status', $_->{status}; td class => 'bug_col_date', strftime '%Y-%m-%d', gmtime $_->{date}; td class => 'bug_col_summary'; a href => $lnk->($_->{issue}), $_->{summary}; end; end; } end; } sub _escape_url { my $str = shift; my $r = ''; my $last = 0; while($str =~ m{((?:https?|ftp)://[^ ><"\n\s]+[\d\w=/-])}g) { $r .= sprintf '%s%2$s', html_escape(substr $str, $last, (pos($str)-length($1))-$last), html_escape($1); $last = pos $str; } return $r.html_escape(substr $str, $last); } sub htmlItem { my($s, $d) = @_; my $last = $d->[$#$d]; dl class => 'bug_status'; dt 'Id'; dd $last->{issue}; dt 'Messages'; dd $#$d+1; dt 'Type'; dd $last->{type}; dt 'Status'; dd $last->{status}; end; div class => 'bug_item'; my $num = -1; for my $m (@$d) { div class => 'bug_message'; h1 !++$num ? 'Description' : "Reply $num"; dl; dt !$num ? 'Created' : 'Added'; dd; txt strftime '%Y-%m-%d %T GMT', gmtime $m->{date}; txt ' by '; txt $m->{name}||'Anonymous' if !$m->{admin}; b $m->{name}||'Admin' if $m->{admin}; end; for($num ? qw|summary type status| : ()) { if($m->{$_} ne $d->[$num-1]{$_}) { dt "\u$_"; dd sprintf '"%s" to "%s"', $d->[$num-1]{$_}, $m->{$_}; } } if($num && !$m->{closed} != !$d->[$num-1]{closed}) { dt "Closed"; dd sprintf '"%s" to "%s"', $d->[$num-1]{closed}?'yes':'no', $m->{closed}?'yes':'no'; } end; p; lit _escape_url $m->{message}; end; end; } end; } sub htmlForm { my($s, $l, $url) = @_; # TODO: anti-spam JS form class => 'bug_frm', action => $url, method => 'post'; fieldset; input type => 'hidden', name => 'bug_id', value => $l ? $l->{issue} : 0; legend $l ? 'Reply' : 'Report a new bug'; ul; li class => 'bug_frm_summary'; label for => 'bug_summary', 'Summary'; input type => 'text', name => 'bug_summary', id => 'bug_summary', size => 45, value => $l?$l->{summary}:''; end; li class => 'bug_frm_name'; label for => 'bug_name', 'Name'; input type => 'text', name => 'bug_name', id => 'bug_name', size => 20, value => $TUWF::OBJ->reqCookie('bug_name')||''; lit ' '; txt 'Name+email will be remembered with a cookie.'; end; li class => 'bug_frm_mail'; label for => 'bug_email', 'Email'; input type => 'text', name => 'bug_email', id => 'bug_email', size => 20, value => $TUWF::OBJ->reqCookie('bug_email')||''; lit ' '; txt 'Optional, only used for notifications.'; end; if($l) { li class => 'bug_frm_admin'; label for => 'bug_type', 'Admin'; Select name => 'bug_type'; option value => $_, $_ eq $l->{type} ? (selected => 'selected') : (), $_ for @{$s->{types}}; end; Select name => 'bug_status'; option value => $_, $_ eq $l->{status} ? (selected => 'selected') : (), $_ for @{$s->{statusses}}; end; Select name => 'bug_closed'; option value => 0, !$l->{closed} ? (selected => 'selected') : (), 'Open'; option value => 1, $l->{closed} ? (selected => 'selected') : (), 'Closed'; end; input type => 'password', name => 'bug_code', id => 'bug_code', size => 10, value => $TUWF::OBJ->reqCookie('bug_code')||'code'; end; } else { li class => 'bug_frm_type'; label for => 'bug_type', 'Type'; Select name => 'bug_type'; option value => $_, $_ eq $s->{default_type} ? (selected => 'selected') : (), $_ for @{$s->{types}}; end; end; } li class => 'bug_frm_message'; textarea name => 'bug_message';end; br; lit 'Please use a pastebin if you want to include chunks of code or program output.'; end; li class => 'bug_frm_submit'; input type => 'submit', value => 'Submit'; end; end 'ul'; end; end 'form'; } sub handleForm { my($s, $url) = @_; my $f = $TUWF::OBJ->formValidate( { post => 'bug_id', template => 'uint' }, { post => 'bug_summary', maxlength => 200, minlength => 2 }, { post => 'bug_name', required => 0, default => '', maxlength => 200 }, { post => 'bug_email', required => 0, template => 'email' }, { post => 'bug_code', required => 0, default => '' }, { post => 'bug_message', maxlength => 256*1024, minlength => 1 }, ); return($f, undef) if $f->{_err}; return({ _err => [['bug_summary']], %$f}, undef) if $f->{bug_summary} =~ qr{http://}; # SPAM return({ _err => [['bug_summary']], %$f}, undef) if $f->{bug_message} =~ qr{(?:{bug_code} = '' if $f->{bug_code} eq 'code'; my $admin = grep($_ eq $f->{bug_code}, @{$s->{admins}}) ? 1 : 0; my $l; # Reply if($f->{bug_id} > 0) { $l = $s->dbListing(id => $f->{bug_id})->[0]; push @{$f->{_err}}, ['bug_id', 'db_check', ''] and return($f, undef) if !$l; # Check admin things if($admin) { my $fa = $TUWF::OBJ->formValidate( { post => 'bug_type', enum => $s->{types} }, { post => 'bug_status', enum => $s->{statusses} }, { post => 'bug_closed', enum => [0,1] }, ); $f = { %$f, %$fa }; return($f, $l) if $f->{_err}; } else { push @{$f->{_err}}, [ 'bug_code', 'invalid', '' ] and return($f, undef) if $f->{bug_code}; $f->{bug_type} = $l->{type}; $f->{bug_status} = $l->{status}; $f->{bug_closed} = $l->{closed}; } # New bug } else { $f->{bug_status} = $s->{default_status}; $f->{bug_closed} = 0; my $fa = $TUWF::OBJ->formValidate({ post => 'bug_type', enum => $s->{types} }); $f = { %$f, %$fa }; return($f, $l) if $f->{_err}; } # No errors? Save! my $id = $s->dbSave(map($f->{"bug_$_"}, qw|id closed summary name email type status message|), $admin); my $u = $url->($id); # For replies, send out notification emails if($l) { my $mails = $s->dbEmails($id); my $base = $TUWF::OBJ->reqBaseURI(); for(grep $_ ne $f->{bug_email}, @$mails) { $TUWF::OBJ->mail( "Hello!\n\n". "A new reply has been posted to a bug you have previously shown\n". "an interest in. You can find the reply at the following URL:\n\n". " $base$u\n\n". "If you do not wish to receive any more notifications for this (and\n". "perhaps other) bugs, please reply to this email stating your intent.", Subject => "Reply to $f->{bug_summary}", To => $_, ); } } $l = $s->dbListing(id => $id)->[0] if !$l; $TUWF::OBJ->resRedirect($u, 'post'); $f->{$_} and $TUWF::OBJ->resCookie($_ => $f->{$_}, expires => time()+365*24*3600) for ('bug_name', 'bug_email', 'bug_code'); return($f, $l); } sub atomFeed { my($s, $lnk) = @_; my $r = $s->dbRecent(); my $t = $r->[0]{date}||1463296545; $TUWF::OBJ->resHeader('Last-Modified' => strftime '%a, %d %b %Y %H:%M:%S GMT', gmtime $t); $TUWF::OBJ->resHeader('Content-Type' => 'application/atom+xml'); xml; tag feed => xmlns => 'http://www.w3.org/2005/Atom', 'xml:lang' => 'en', 'xml:base' => 'https://dev.yorhel.nl/'; tag title => "\u$s->{table} Recent Comments"; tag updated => strftime('%Y-%m-%dT%H:%M:%SZ', gmtime $t); tag id => $lnk->('feed.atom'); tag link => rel => 'self', type => 'application/atom+xml', href => $lnk->('feed.atom'), undef; tag link => rel => 'alternate', type => 'text/html', href => $lnk->(''), undef; for(@$r) { my $d = strftime('%Y-%m-%dT%H:%M:%SZ', gmtime $_->{date}); tag 'entry'; tag id => $lnk->($_->{issue})."#$d"; tag title => $_->{summary}; tag updated => $d; tag published => $d; tag 'author'; tag name => $_->{name}; end; tag link => rel => 'alternate', type => 'text/html', href => $lnk->($_->{issue}), undef; end 'entry'; } end 'feed'; } 1;