summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--elm/Report.elm75
-rw-r--r--lib/VNWeb/Auth.pm1
-rw-r--r--lib/VNWeb/Discussions/Edit.pm6
-rw-r--r--lib/VNWeb/Discussions/Thread.pm8
-rw-r--r--lib/VNWeb/HTML.pm3
-rw-r--r--lib/VNWeb/Misc/Reports.pm202
-rw-r--r--sql/perms.sql1
-rw-r--r--sql/schema.sql17
-rw-r--r--sql/tableattrs.sql1
-rw-r--r--util/updates/2020-07-23-reports.sql19
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