diff options
-rw-r--r-- | elm/Report.elm | 75 | ||||
-rw-r--r-- | lib/VNWeb/Auth.pm | 1 | ||||
-rw-r--r-- | lib/VNWeb/Discussions/Edit.pm | 6 | ||||
-rw-r--r-- | lib/VNWeb/Discussions/Thread.pm | 8 | ||||
-rw-r--r-- | lib/VNWeb/HTML.pm | 3 | ||||
-rw-r--r-- | lib/VNWeb/Misc/Reports.pm | 202 | ||||
-rw-r--r-- | sql/perms.sql | 1 | ||||
-rw-r--r-- | sql/schema.sql | 17 | ||||
-rw-r--r-- | sql/tableattrs.sql | 1 | ||||
-rw-r--r-- | util/updates/2020-07-23-reports.sql | 19 |
10 files changed, 328 insertions, 5 deletions
diff --git a/elm/Report.elm b/elm/Report.elm new file mode 100644 index 00000000..5bb75aaf --- /dev/null +++ b/elm/Report.elm @@ -0,0 +1,75 @@ +module Report exposing (main) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Browser +import Browser.Navigation exposing (load) +import Lib.Html exposing (..) +import Lib.Api as Api +import Gen.Api as GApi +import Gen.Report as GR + + +main : Program GR.Send Model Msg +main = Browser.element + { init = \e -> ((Api.Normal, e), Cmd.none) + , view = view + , update = update + , subscriptions = always Sub.none + } + +type alias Model = (Api.State,GR.Send) + +type Msg + = Reason String + | Message String + | Submit + | Submitted GApi.Response + + +-- These can be different depending on the rtype. +reasons = + [ "Spam" + , "Links to piracy or illegal content" + , "Off-topic / wrong board" + , "Unmarked spoilers" + , "Unwelcome behavior" + , "Other" + ] + + +update : Msg -> Model -> (Model, Cmd Msg) +update msg (state,model) = + case msg of + Reason s -> ((state, { model | reason = s }), Cmd.none) + Message s -> ((state, { model | message = s }), Cmd.none) + Submit -> ((Api.Loading, model), GR.send model Submitted) + Submitted r -> ((Api.Error r, model), Cmd.none) + + +view : Model -> Html Msg +view (state,model) = + form_ Submit (state == Api.Loading) + [ div [ class "mainbox" ] + [ h1 [] [ text "Submit report" ] + , if state == Api.Error GApi.Success + then p [] [ text "Your report has been submitted, a moderator will look at it as soon as possible." ] + else table [ class "formtable" ] <| + [ formField "Subject" [ a [ href model.path ] [ text model.title ] ] + , formField "" + [ text "Your report will be forwarded to a moderator." + , br [] [] + , text "Keep in mind that not every report will be acted upon, we may decide that the problem you reported is still within acceptable limits." + , br [] [] + , if model.loggedin + then text "We generally do not provide feedback on reports, but a moderator may decide to contact you for clarification." + else text "We generally do not provide feedback on reports, but you may leave your email address in the message if you wish to be available for clarification." + ] + , formField "reason::Reason" [ inputSelect "reason" model.reason Reason [style "width" "300px"] (("","-- Select --") :: List.map (\s->(s,s)) reasons) ] + ] ++ if model.reason == "" then [] else + [ formField "message::Message" [ inputTextArea "message" model.message Message [] ] + , formField "" [ submitButton "Submit" state True ] + ] + ] + ] diff --git a/lib/VNWeb/Auth.pm b/lib/VNWeb/Auth.pm index 6e4d68c0..82f83f63 100644 --- a/lib/VNWeb/Auth.pm +++ b/lib/VNWeb/Auth.pm @@ -62,6 +62,7 @@ use overload bool => sub { defined shift->{user}{user_id} }; sub uid { shift->{user}{user_id} } sub user { shift->{user} } sub token { shift->{token} } +sub isMod { auth->permUsermod || auth->permDbmod || auth->permImgmod || auth->permBoardmod || auth->permTagmod } diff --git a/lib/VNWeb/Discussions/Edit.pm b/lib/VNWeb/Discussions/Edit.pm index f8108c8a..7dc2a0c5 100644 --- a/lib/VNWeb/Discussions/Edit.pm +++ b/lib/VNWeb/Discussions/Edit.pm @@ -97,7 +97,7 @@ elm_api DiscussionsEdit => $FORM_OUT, $FORM_IN, sub { hidden => $data->{hidden}, locked => $data->{locked}, ) : (), - auth->permBoardmod || auth->permDbmod || auth->permUsermod ? ( + auth->isMod ? ( private => $data->{private} ) : (), }; @@ -164,14 +164,14 @@ TUWF::get qr{(?:/t/(?<board>$BOARD_RE)/new|/$RE{postid}/edit)}, sub { } $t->{can_mod} = auth->permBoardmod; - $t->{can_private} = auth->permBoardmod || auth->permDbmod || auth->permUsermod; + $t->{can_private} = auth->isMod; $t->{hidden} = $tid && $num == 1 ? $t->{thread_hidden}//0 : $t->{hidden}//0; $t->{msg} //= ''; $t->{title} //= tuwf->reqGet('title'); $t->{tid} //= undef; $t->{num} //= undef; - $t->{private} //= 0; + $t->{private} //= auth->isMod && tuwf->reqGet('priv') ? 1 : 0; $t->{locked} //= 0; $t->{delete} = 0; diff --git a/lib/VNWeb/Discussions/Thread.pm b/lib/VNWeb/Discussions/Thread.pm index ed357b5d..8c21529c 100644 --- a/lib/VNWeb/Discussions/Thread.pm +++ b/lib/VNWeb/Discussions/Thread.pm @@ -114,9 +114,13 @@ sub posts_ { td_ class => 'tc2', sub { i_ class => 'edit', sub { txt_ '< '; - a_ href => "/t$t->{id}.$_->{num}/edit", 'edit'; + if(can_edit t => $_) { + a_ href => "/t$t->{id}.$_->{num}/edit", 'edit'; + txt_ ' - '; + } + a_ href => "/report/t/t$t->{id}.$_->{num}", 'report'; txt_ ' >'; - } if can_edit t => $_; + }; if($_->{hidden}) { i_ class => 'deleted', 'Post deleted.'; } else { diff --git a/lib/VNWeb/HTML.pm b/lib/VNWeb/HTML.pm index 89a5fa6f..46905887 100644 --- a/lib/VNWeb/HTML.pm +++ b/lib/VNWeb/HTML.pm @@ -246,6 +246,9 @@ sub _menu_ { a_ href => '/p/add', 'Add Producer'; br_; a_ href => '/s/new', 'Add Staff'; br_; } + if(auth->isMod) { + a_ href => '/report/list?status=new', sprintf 'Reports (%d)', tuwf->dbVali('SELECT count(*) FROM reports WHERE status = \'new\''); br_; + } br_; form_ action => "$uid/logout", method => 'post', sub { input_ type => 'hidden', class => 'hidden', name => 'csrf', value => auth->csrftoken; diff --git a/lib/VNWeb/Misc/Reports.pm b/lib/VNWeb/Misc/Reports.pm new file mode 100644 index 00000000..85691d4e --- /dev/null +++ b/lib/VNWeb/Misc/Reports.pm @@ -0,0 +1,202 @@ +package VNWeb::Misc::Reports; + +use VNWeb::Prelude; + +my $reportsperday = 5; + +my @STATUS = qw/new busy done dismissed/; + +# Requires objects with {rtype,object} fields, adds {title,path} fields. +sub enrich_object { + for(@_) { + delete $_->@{'title','path'}; + if($_->{rtype} eq 't' && $_->{object} =~ /^$RE{postid}$/) { + my $title = tuwf->dbVali( + "SELECT 'Post #'||tp.num||' on '||t.title + FROM threads t JOIN threads_posts tp ON tp.tid = t.id + WHERE NOT t.hidden AND NOT t.private AND t.id =", \"$+{id}", 'AND tp.num =', \"$+{num}" + ); + $_->@{'title','path'} = ($title, "/$_->{object}") if $title; + } + } +} + + +sub is_throttled { + tuwf->dbVali('SELECT COUNT(*) FROM reports WHERE date > NOW()-\'1 day\'::interval AND', auth ? ('uid =', \auth->uid) : ('ip =', \tuwf->reqIP)) >= $reportsperday +} + + +my $FORM = form_compile any => { + rtype => {}, + object => {}, + title => {}, + path => {}, + reason => { maxlength => 50 }, + message => { required => 0, default => '', maxlength => 50000 }, + loggedin => { anybool => 1 }, +}; + +elm_api Report => undef, $FORM, sub { + my($data) = @_; + enrich_object $data; + return elm_Invalid if !$data->{title}; + return elm_Unauth if is_throttled; + + tuwf->dbExeci('INSERT INTO reports', { + uid => auth->uid, + ip => auth ? undef : tuwf->reqIP, + rtype => $data->{rtype}, + object => $data->{object}, + reason => $data->{reason}, + message => $data->{message}, + }); + elm_Success +}; + + +TUWF::get qr{/report/(?<rtype>t)/(?<object>.+)}, sub { + my $obj = { rtype => tuwf->capture('rtype'), object => tuwf->capture('object') }; + enrich_object $obj; + return tuwf->resNotFound if !$obj->{title}; + + framework_ title => 'Submit report', sub { + if(is_throttled) { + div_ class => 'mainbox', sub { + h1_ 'Submit report'; + p_ "Sorry, you can only submit $reportsperday reports per day. If you wish to report more, you can do so by sending an email to ".config->{admin_email} + } + } else { + elm_ Report => $FORM, { elm_empty($FORM)->%*, %$obj, loggedin => !!auth }; + } + }; +}; + + +sub report_ { + my($r, $url) = @_; + td_ style => 'padding: 3px 5px 5px 20px', sub { + a_ href => "?id=$r->{id}", "#$r->{id}"; + b_ class => 'grayedout', ' '.fmtdate $r->{date}, 'full'; + txt_ ' by '; + if($r->{uid}) { + a_ href => "/u$r->{uid}", $r->{username}; + txt_ ' ('; + a_ href => "/t/u$r->{uid}/new?title=Regarding your report&priv=1", 'pm'; + txt_ ')'; + } else { + txt_ $r->{ip}||'[anonymous]'; + } + br_; + a_ href => $r->{path}, $r->{title}; + br_; + txt_ $r->{reason}; + div_ class => 'quote', sub { lit_ TUWF::XML::html_escape $r->{message} } if $r->{message}; + }; + td_ style => 'width: 300px', sub { + form_ method => 'post', action => '/report/edit', sub { + input_ type => 'hidden', name => 'id', value => $r->{id}; + input_ type => 'hidden', name => 'url', value => $url; + textarea_ name => 'comment', rows => 2, cols => 25, style => 'width: 290px', placeholder => 'Mod comment... (optional)', ''; + br_; + select_ style => 'width: 100px', name => 'status', sub { + option_ value => $_, $_ eq $r->{status} ? (selected => 'selected') : (), ucfirst $_ for @STATUS; + }; + input_ type => 'submit', class => 'submit', value => 'Update'; + }; + }; + td_ sub { + lit_ TUWF::XML::html_escape $r->{log}; + }; +} + + +TUWF::get qr{/report/list}, sub { + return tuwf->resDenied if !auth->isMod; + + my $opt = tuwf->validate(get => + p => { upage => 1 }, + status => { enum => \@STATUS, required => 0 }, + id => { id => 1, required => 0 }, + )->data; + + my $where = sql_and + $opt->{id} ? sql 'r.id =', \$opt->{id} : (), + $opt->{status} ? sql 'r.status =', \$opt->{status} : (); + + my $cnt = tuwf->dbVali('SELECT count(*) FROM reports r WHERE', $where); + my $lst = tuwf->dbPagei({results => 50, page => $opt->{p}}, + 'SELECT r.id,', sql_totime('r.date'), 'as date, r.uid, u.username, r.ip, r.reason, r.rtype, r.object, r.status, r.message, r.log + FROM reports r + LEFT JOIN users u ON u.id = r.uid + WHERE', $where, ' + ORDER BY r.id DESC' + ); + enrich_object @$lst; + + my sub url { '?'.query_encode %$opt, @_ } + + framework_ title => 'Reports', sub { + div_ class => 'mainbox', sub { + h1_ 'Reports'; + p_ 'Welcome to the super advanced reports handling interface. Reports can have the following statuses:'; + ul_ sub { + li_ 'New: Default status for newly submitted reports'; + li_ 'Busy: You can use this state to indicate that you\'re working on it.'; + li_ 'Done: Report handled.'; + li_ 'Dismissed: Report ignored.'; + }; + p_ q{ + There's no flowchart you have to follow, if you can quickly handle a report you can go directly from 'New' to 'Done' or 'Dismissed'. + If you want to bring an older report to other's attention you can go back from any existing state to 'New'. + }; + p_ q{ + Feel free to skip over reports that you can't or don't want to handle, someone else will eventually pick it up. + }; + p_ q{ + Changing the status and/or adding a comment will add an entry to the log, so other mods can see what is going on. Everything on this page is only visible to moderators. + }; + br_; + br_; + p_ class => 'browseopts', sub { + a_ href => url(p => undef, status => undef), !$opt->{status} ? (class => 'optselected') : (), 'All'; + a_ href => url(p => undef, status => $_), $opt->{status} && $opt->{status} eq $_ ? (class => 'optselected') : (), ucfirst $_ for @STATUS; + }; + }; + + paginate_ \&url, $opt->{p}, [$cnt, 50], 't'; + div_ class => 'mainbox thread', sub { + table_ class => 'stripe', sub { + my $url = '/report/list'.url; + tr_ sub { report_ $_, $url } for @$lst; + tr_ sub { td_ style => 'text-align: center', 'Nothing to report! (heh)' } if !@$lst; + }; + }; + paginate_ \&url, $opt->{p}, [$cnt, 50], 'b'; + }; +}; + + +TUWF::post qr{/report/edit}, sub { + return tuwf->resDenied if !auth->isMod; + my $frm = tuwf->validate(post => + id => { id => 1 }, + url => { regex => qr{^/report/list} }, + status => { enum => \@STATUS }, + comment => { required => 0, default => '' }, + )->data; + my $r = tuwf->dbRowi('SELECT id, status FROM reports WHERE id =', \$frm->{id}); + return tuwf->resNotFound if !$r->{id}; + + my $log = join '; ', + $r->{status} ne $frm->{status} ? "$r->{status} -> $frm->{status}" : (), + $frm->{comment} ? $frm->{comment} : (); + + if($log) { + $log = sprintf "%s <%s> %s\n", fmtdate(time, 'full'), auth->user->{user_name}, $log; + tuwf->dbExeci('UPDATE reports SET lastmod = NOW(), status =', \$frm->{status}, ', log = log ||', \$log, 'WHERE id =', \$r->{id}); + } + tuwf->resRedirect($frm->{url}, 'post'); +}; + +1; diff --git a/sql/perms.sql b/sql/perms.sql index 547ad60b..452141fc 100644 --- a/sql/perms.sql +++ b/sql/perms.sql @@ -37,6 +37,7 @@ GRANT SELECT, INSERT, DELETE ON releases_producers TO vndb_site; GRANT SELECT, INSERT ON releases_producers_hist TO vndb_site; GRANT SELECT, INSERT, DELETE ON releases_vn TO vndb_site; GRANT SELECT, INSERT ON releases_vn_hist TO vndb_site; +GRANT SELECT, INSERT, UPDATE ON reports TO vndb_site; GRANT SELECT, INSERT, UPDATE, DELETE ON rlists TO vndb_site; -- No access to the 'sessions' table, managed by the user_* functions. GRANT SELECT ON shop_denpa TO vndb_site; diff --git a/sql/schema.sql b/sql/schema.sql index 0305d226..c6027f61 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -63,6 +63,8 @@ CREATE TYPE platform AS ENUM ('win', 'dos', 'lin', 'mac', 'ios', 'and', CREATE TYPE producer_type AS ENUM ('co', 'in', 'ng'); CREATE TYPE producer_relation AS ENUM ('old', 'new', 'sub', 'par', 'imp', 'ipa', 'spa', 'ori'); CREATE TYPE release_type AS ENUM ('complete', 'partial', 'trial'); +CREATE TYPE report_status AS ENUM ('new', 'busy', 'done', 'dismissed'); +CREATE TYPE report_type AS ENUM ('t'); CREATE TYPE tag_category AS ENUM('cont', 'ero', 'tech'); CREATE TYPE vn_relation AS ENUM ('seq', 'preq', 'set', 'alt', 'char', 'side', 'par', 'ser', 'fan', 'orig'); CREATE TYPE session_type AS ENUM ('web', 'pass', 'mail'); @@ -480,6 +482,21 @@ CREATE TABLE releases_vn_hist ( PRIMARY KEY(chid, vid) ); +-- reports +CREATE TABLE reports ( + id SERIAL PRIMARY KEY, + date timestamptz NOT NULL DEFAULT NOW(), + lastmod timestamptz, + uid integer, -- user who created the report, if logged in + ip inet, -- IP address of the visitor, if not logged in + reason text NOT NULL, + rtype report_type NOT NULL, + status report_status NOT NULL DEFAULT 'new', + object text NOT NULL, -- The id of the thing being reported + message text NOT NULL, + log text NOT NULL DEFAULT '' +); + -- rlists CREATE TABLE rlists ( uid integer NOT NULL DEFAULT 0, -- [pub] diff --git a/sql/tableattrs.sql b/sql/tableattrs.sql index 60fc1bb9..919d05d7 100644 --- a/sql/tableattrs.sql +++ b/sql/tableattrs.sql @@ -117,6 +117,7 @@ CREATE INDEX image_votes_id ON image_votes (id); CREATE INDEX notifications_uid ON notifications (uid); CREATE INDEX releases_producers_pid ON releases_producers (pid); CREATE INDEX releases_vn_vid ON releases_vn (vid); +CREATE INDEX reports_status ON reports (status,id); CREATE INDEX staff_alias_id ON staff_alias (id); CREATE UNIQUE INDEX tags_vn_pkey ON tags_vn (tag,vid,uid); CREATE INDEX tags_vn_date ON tags_vn (date); diff --git a/util/updates/2020-07-23-reports.sql b/util/updates/2020-07-23-reports.sql new file mode 100644 index 00000000..1738fd72 --- /dev/null +++ b/util/updates/2020-07-23-reports.sql @@ -0,0 +1,19 @@ +CREATE TYPE report_status AS ENUM ('new', 'busy', 'done', 'dismissed'); +CREATE TYPE report_type AS ENUM ('t'); + +CREATE TABLE reports ( + id SERIAL PRIMARY KEY, + date timestamptz NOT NULL DEFAULT NOW(), + lastmod timestamptz, + uid integer, -- user who created the report, if logged in + ip inet, -- IP address of the visitor, if not logged in + reason text NOT NULL, + rtype report_type NOT NULL, + status report_status NOT NULL DEFAULT 'new', + object text NOT NULL, -- The id of the thing being reported + message text NOT NULL, + log text NOT NULL DEFAULT '' +); +CREATE INDEX reports_status ON reports (status,id); + +\i sql/perms.sql |