Rewrite extension, call API manually, take advantage of features not supported by the current library (#24)

This PR updates the entire extension. It will not use a library that has not been updated for years, so it should work with newer versions of PHP.
Additionally, it sends more information to the Akismet API to make spam detection more accurate.
The Akismet class can be used by other extensions.

Todo:
- [x] Convert frontend to TypeScript
- [x] Call Akismet API manually
- [x] Option to remove blatant spam
- [x] Permission to bypass Akismet
- [x] Sending additional parameters like `is_test`

Nice to have, but can be left for another PR:
- [ ] Suspend obvious spamers
- [ ] Send  `blog_lang` parameter
- [ ] Checking post edits

Sponsored by [forum.android.com.pl](https://forum.android.com.pl/)
This commit is contained in:
Rafał Całka 2022-01-05 00:31:47 +01:00 committed by GitHub
parent a8937161db
commit 178f91aff9
17 changed files with 1773 additions and 1312 deletions

View File

@ -1,6 +1,8 @@
.idea
/vendor
composer.phar
.DS_Store
Thumbs.db
node_modules
js/dist/*
composer.lock

View File

@ -21,7 +21,7 @@
"require": {
"flarum/core": "^1.1",
"flarum/approval": "^1.1",
"tijsverkoyen/akismet": "^1.1"
"guzzlehttp/guzzle": "^7.4"
},
"autoload": {
"psr-4": {

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,19 @@
{
"private": true,
"name": "@flarum/akismet",
"prettier": "@flarum/prettier-config",
"dependencies": {
"flarum-webpack-config": "^1.0.0",
"webpack": "^4.46.0",
"webpack-cli": "^4.9.0"
"webpack-cli": "^4.9.1"
},
"scripts": {
"dev": "webpack --mode development --watch",
"build": "webpack --mode production"
},
"devDependencies": {
"@flarum/prettier-config": "^1.0.0",
"flarum-tsconfig": "^1.0.2",
"prettier": "^2.5.1"
}
}

View File

@ -1,11 +0,0 @@
import app from 'flarum/app';
app.initializers.add('flarum-akismet', () => {
app.extensionData
.for('flarum-akismet')
.registerSetting({
setting: 'flarum-akismet.api_key',
type: 'text',
label: app.translator.trans('flarum-akismet.admin.akismet_settings.api_key_label')
});
});

View File

@ -0,0 +1,26 @@
import app from 'flarum/admin/app';
app.initializers.add('flarum-akismet', () => {
app.extensionData
.for('flarum-akismet')
.registerSetting({
setting: 'flarum-akismet.api_key',
type: 'text',
label: app.translator.trans('flarum-akismet.admin.akismet_settings.api_key_label'),
})
.registerSetting({
//https://blog.akismet.com/2014/04/23/theres-a-ninja-in-your-akismet/
setting: 'flarum-akismet.delete_blatant_spam',
type: 'boolean',
label: app.translator.trans('flarum-akismet.admin.akismet_settings.delete_blatant_spam_label'),
help: app.translator.trans('flarum-akismet.admin.akismet_settings.delete_blatant_spam_help'),
})
.registerPermission(
{
icon: 'fas fa-vote-yea',
label: app.translator.trans('flarum-akismet.admin.permissions.bypass_akismet'),
permission: 'bypassAkismet',
},
'start'
);
});

View File

@ -1,25 +0,0 @@
import { extend, override } from 'flarum/extend';
import app from 'flarum/app';
import PostControls from 'flarum/utils/PostControls';
import CommentPost from 'flarum/components/CommentPost';
app.initializers.add('flarum-akismet', () => {
extend(PostControls, 'destructiveControls', function(items, post) {
if (items.has('approve')) {
const flags = post.flags();
if (flags && flags.some(flag => flag.type() === 'akismet')) {
items.get('approve').children = app.translator.trans('flarum-akismet.forum.post.not_spam_button');
}
}
});
override(CommentPost.prototype, 'flagReason', function(original, flag) {
if (flag.type() === 'akismet') {
return app.translator.trans('flarum-akismet.forum.post.akismet_flagged_text');
}
return original(flag);
});
}, -20); // run after the approval extension

View File

@ -0,0 +1,27 @@
import { extend, override } from 'flarum/common/extend';
import app from 'flarum/forum/app';
import PostControls from 'flarum/forum/utils/PostControls';
import CommentPost from 'flarum/forum/components/CommentPost';
import ItemList from 'flarum/common/utils/ItemList';
import Post from 'flarum/common/models/Post';
app.initializers.add('flarum-akismet', () => {
extend(PostControls, 'destructiveControls', function (items: ItemList, post: Post) {
if (items.has('approve')) {
const flags = post.flags();
if (flags && flags.some((flag) => flag.type() === 'akismet')) {
items.get('approve').children = app.translator.trans('flarum-akismet.forum.post.not_spam_button');
}
}
});
override(CommentPost.prototype, 'flagReason', function (original, flag) {
if (flag.type() === 'akismet') {
return app.translator.trans('flarum-akismet.forum.post.akismet_flagged_text');
}
return original(flag);
});
});

View File

@ -0,0 +1,11 @@
{
"extends": "flarum-tsconfig",
"include": ["src/**/*", "../vendor/flarum/core/js/dist-typings/@types/**/*"],
"compilerOptions": {
"declarationDir": "./dist-typings",
"baseUrl": ".",
"paths": {
"flarum/*": ["../vendor/flarum/core/js/dist-typings/*"]
}
}
}

View File

@ -7,10 +7,14 @@ flarum-akismet:
# Translations in this namespace are used by the admin interface.
admin:
# These translations are used in the Akismet Settings modal dialog.
# These translations are used in the Akismet Settings.
akismet_settings:
api_key_label: API Key
title: Akismet Settings
delete_blatant_spam_label: Automatically delete blatant spam
delete_blatant_spam_help: If Akismet has determined that the comment is blatant spam, instead of flagging, automatically delete post
permissions:
bypass_akismet: Bypass Akismet
# Translations in this namespace are used by the forum user interface.
forum:

View File

@ -0,0 +1,229 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Akismet;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Psr\Http\Message\ResponseInterface;
class Akismet
{
private $apiKey;
private $apiUrl;
private $flarumVersion;
private $extensionVersion;
private $params = [];
public function __construct(string $apiKey, string $homeUrl, string $flarumVersion, string $extensionVersion, bool $inDebugMode = false)
{
$this->apiKey = $apiKey;
$this->apiUrl = "https://$apiKey.rest.akismet.com/1.1";
$this->params['blog'] = $homeUrl;
$this->flarumVersion = $flarumVersion;
$this->extensionVersion = $extensionVersion;
if ($inDebugMode) {
$this->params['is_test'] = true;
}
}
public function isConfigured(): bool
{
return !empty($this->apiKey);
}
/**
* @param string $type e.g. comment-check, submit-spam or submit-ham;
* @throws GuzzleException
*/
protected function sendRequest(string $type): ResponseInterface
{
$client = new Client();
return $client->request('POST', "$this->apiUrl/$type", [
'headers' => [
'User-Agent' => "Flarum/$this->flarumVersion | Akismet/$this->extensionVersion",
],
'form_params' => $this->params,
]);
}
/**
* @throws GuzzleException
*/
public function checkSpam(): array
{
$response = $this->sendRequest('comment-check');
return [
'isSpam' => $response->getBody()->getContents() === 'true',
'proTip' => $response->getHeaderLine('X-akismet-pro-tip'),
];
}
/**
* @throws GuzzleException
*/
public function submitSpam()
{
$this->sendRequest('submit-spam');
}
/**
* @throws GuzzleException
*/
public function submitHam()
{
$this->sendRequest('submit-ham');
}
/**
* Allows you to set additional parameter
* This lets you use Akismet features not supported directly in this util.
*/
public function withParam(string $key, $value): Akismet
{
$new = clone $this;
$new->params[$key] = $value;
return $new;
}
/**
* The front page or home URL of the instance making the request. For a blog or wiki this would be the front page. Note: Must be a full URI, including http://.
*/
public function withBlog(string $url): Akismet
{
return $this->withParam('blog', $url);
}
/**
* IP address of the comment submitter.
*/
public function withIp(string $ip): Akismet
{
return $this->withParam('user_ip', $ip);
}
/**
* User agent string of the web browser submitting the comment - typically the HTTP_USER_AGENT cgi variable. Not to be confused with the user agent of your Akismet library.
*/
public function withUserAgent(string $userAgent): Akismet
{
return $this->withParam('user_agent', $userAgent);
}
/**
* The content of the HTTP_REFERER header should be sent here.
*/
public function withReferrer(string $referrer): Akismet
{
return $this->withParam('referrer', $referrer);
}
/**
* The full permanent URL of the entry the comment was submitted to.
*/
public function withPermalink(string $permalink): Akismet
{
return $this->withParam('permalink', $permalink);
}
/**
* A string that describes the type of content being sent
* Examples:
* comment: A blog comment.
* forum-post: A top-level forum post.
* reply: A reply to a top-level forum post.
* blog-post: A blog post.
* contact-form: A contact form or feedback form submission.
* signup: A new user account.
* message: A message sent between just a few users.
* You may send a value not listed above if none of them accurately describe your content. This is further explained here: https://blog.akismet.com/2012/06/19/pro-tip-tell-us-your-comment_type/
*/
public function withType(string $type): Akismet
{
return $this->withParam('comment_type', $type);
}
/**
* Name submitted with the comment.
*/
public function withAuthorName(string $name): Akismet
{
return $this->withParam('comment_author', $name);
}
/**
* Email address submitted with the comment.
*/
public function withAuthorEmail(string $email): Akismet
{
return $this->withParam('comment_author_email', $email);
}
/*
* URL submitted with comment. Only send a URL that was manually entered by the user, not an automatically generated URL like the user’s profile URL on your site.
*/
public function withAuthorUrl(string $url): Akismet
{
return $this->withParam('comment_author_url', $url);
}
/**
* The content that was submitted.
*/
public function withContent(string $content): Akismet
{
return $this->withParam('comment_content', $content);
}
/**
* The UTC timestamp of the creation of the comment, in ISO 8601 format. May be omitted for comment-check requests if the comment is sent to the API at the time it is created.
*/
public function withDateGmt(string $date): Akismet
{
return $this->withParam('comment_date_gmt', $date);
}
/**
* The UTC timestamp of the publication time for the post, page or thread on which the comment was posted.
*/
public function withPostModifiedDateGtm(string $date): Akismet
{
return $this->withParam('comment_post_modified_gmt', $date);
}
/**
* Indicates the language(s) in use on the blog or site, in ISO 639-1 format, comma-separated. A site with articles in English and French might use “en, fr_ca”.
*/
public function withLanguage(string $language): Akismet
{
return $this->withParam('blog_lang', $language);
}
/**
* This is an optional parameter. You can use it when submitting test queries to Akismet.
*/
public function withTest(): Akismet
{
return $this->withParam('is_test', true);
}
/**
* If you are sending content to Akismet to be rechecked, such as a post that has been edited or old pending comments that you’d like to recheck, include the parameter recheck_reason with a string describing why the content is being rechecked. For example, edit.
*/
public function withRecheckReason(string $reason): Akismet
{
return $this->withParam('recheck_reason', $reason);
}
}

View File

@ -9,8 +9,8 @@
namespace Flarum\Akismet\Listener;
use Flarum\Akismet\Akismet;
use Flarum\Approval\Event\PostWasApproved;
use TijsVerkoyen\Akismet\Akismet;
class SubmitHam
{
@ -26,16 +26,20 @@ class SubmitHam
public function handle(PostWasApproved $event)
{
if (!$this->akismet->isConfigured()) {
return;
}
$post = $event->post;
if ($post->is_spam) {
$this->akismet->submitHam(
$post->ip_address,
null,
$post->content,
$post->user->username,
$post->user->email
);
$this->akismet
->withContent($post->content)
->withIp($post->ip_address)
->withAuthorName($post->user->username)
->withAuthorEmail($post->user->email)
->withType($post->number === 1 ? 'forum-post' : 'reply')
->submitHam();
}
}
}

View File

@ -9,8 +9,8 @@
namespace Flarum\Akismet\Listener;
use Flarum\Akismet\Akismet;
use Flarum\Post\Event\Hidden;
use TijsVerkoyen\Akismet\Akismet;
class SubmitSpam
{
@ -26,16 +26,20 @@ class SubmitSpam
public function handle(Hidden $event)
{
if (!$this->akismet->isConfigured()) {
return;
}
$post = $event->post;
if ($post->is_spam) {
$this->akismet->submitSpam(
$post->ip_address,
null,
$post->content,
$post->user->username,
$post->user->email
);
$this->akismet
->withContent($post->content)
->withIp($post->ip_address)
->withAuthorName($post->user->username)
->withAuthorEmail($post->user->email)
->withType($post->number === 1 ? 'forum-post' : 'reply')
->submitSpam();
}
}
}

View File

@ -9,9 +9,12 @@
namespace Flarum\Akismet\Listener;
use Carbon\Carbon;
use Flarum\Akismet\Akismet;
use Flarum\Flags\Flag;
use Flarum\Post\CommentPost;
use Flarum\Post\Event\Saving;
use TijsVerkoyen\Akismet\Akismet;
use Flarum\Settings\SettingsRepositoryInterface;
class ValidatePost
{
@ -19,46 +22,69 @@ class ValidatePost
* @var Akismet
*/
protected $akismet;
/**
* @var SettingsRepositoryInterface
*/
private $settings;
public function __construct(Akismet $akismet)
public function __construct(Akismet $akismet, SettingsRepositoryInterface $settings)
{
$this->akismet = $akismet;
$this->settings = $settings;
}
public function handle(Saving $event)
{
$post = $event->post;
if ($post->exists || $post->user->groups()->count()) {
if (!$this->akismet->isConfigured()) {
return;
}
$isSpam = $this->akismet->isSpam(
$post->content,
$post->user->username,
$post->user->email,
null,
'comment'
);
$post = $event->post;
if ($isSpam) {
$post->is_approved = false;
//TODO Sometimes someone posts spam when editing a post. In this case 'recheck_reason=edit' can be used when sending a request to Akismet
if ($post->exists || !($post instanceof CommentPost) || $post->user->hasPermission('bypassAkismet')) {
return;
}
$result = $this->akismet
->withContent($post->content)
->withAuthorName($post->user->username)
->withAuthorEmail($post->user->email)
->withType($post->number == 1 ? 'forum-post' : 'reply')
->withIp($post->ip_address)
->withUserAgent($_SERVER['HTTP_USER_AGENT'])
->checkSpam();
if ($result['isSpam']) {
$post->is_spam = true;
$post->afterSave(function ($post) {
if ($post->number == 1) {
$post->discussion->is_approved = false;
$post->discussion->save();
}
if ($result['proTip'] === 'discard' && $this->settings->get('flarum-akismet.delete_blatant_spam')) {
$post->hide();
$flag = new Flag;
$post->afterSave(function ($post) {
if ($post->number == 1) {
$post->discussion->hide();
}
});
} else {
$post->is_approved = false;
$flag->post_id = $post->id;
$flag->type = 'akismet';
$flag->created_at = time();
$post->afterSave(function ($post) {
if ($post->number == 1) {
$post->discussion->is_approved = false;
$post->discussion->save();
}
$flag->save();
});
$flag = new Flag;
$flag->post_id = $post->id;
$flag->type = 'akismet';
$flag->created_at = Carbon::now();
$flag->save();
});
}
}
}
}

View File

@ -9,24 +9,37 @@
namespace Flarum\Akismet\Provider;
use Flarum\Extension\ExtensionManager;
use Flarum\Foundation\AbstractServiceProvider;
use Flarum\Foundation\Application;
use Flarum\Foundation\Config;
use Flarum\Http\UrlGenerator;
use Flarum\Settings\SettingsRepositoryInterface;
use TijsVerkoyen\Akismet\Akismet;
use Flarum\Akismet\Akismet;
use Illuminate\Container\Container;
class AkismetProvider extends AbstractServiceProvider
{
public function register()
{
$this->app->bind(Akismet::class, function () {
$this->container->bind(Akismet::class, function (Container $container) {
/** @var SettingsRepositoryInterface $settings */
$settings = $this->app->make(SettingsRepositoryInterface::class);
$settings = $container->make(SettingsRepositoryInterface::class);
/** @var UrlGenerator $url */
$url = $this->app->make(UrlGenerator::class);
$url = $container->make(UrlGenerator::class);
/** @var Config $config */
$config = $container->make(Config::class);
/** @var ExtensionManager $extensions */
$extensions = $this->container->make(ExtensionManager::class);
/** @var Application $app */
$app = $container->make(Application::class);
return new Akismet(
$settings->get('flarum-akismet.api_key'),
$url->to('forum')->base()
$url->to('forum')->base(),
$app::VERSION,
$extensions->getExtension('flarum-akismet')->getVersion(),
$config->inDebugMode()
);
});
}