aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--Digitigrade/HttpResponseStatus/PolicyRejected.php22
-rw-r--r--Digitigrade/Model/FetchableModel.php3
-rw-r--r--Digitigrade/Model/Note.php2
-rw-r--r--Digitigrade/Model/PushableModel.php3
-rw-r--r--Digitigrade/Policy.php37
-rw-r--r--Digitigrade/Policy/BasicCensor.php47
-rw-r--r--Digitigrade/Policy/BlockInstance.php37
-rw-r--r--Digitigrade/PolicyManager.php46
-rw-r--r--README.md11
-rw-r--r--blocked-instances.txt2
-rw-r--r--index.php3
-rw-r--r--policies.conf.php8
-rw-r--r--routes/actor_profile_fragment.php5
-rw-r--r--routes/note_fragment.php14
14 files changed, 237 insertions, 3 deletions
diff --git a/Digitigrade/HttpResponseStatus/PolicyRejected.php b/Digitigrade/HttpResponseStatus/PolicyRejected.php
new file mode 100644
index 0000000..59a4811
--- /dev/null
+++ b/Digitigrade/HttpResponseStatus/PolicyRejected.php
@@ -0,0 +1,22 @@
+<?php
+namespace Digitigrade\HttpResponseStatus;
+
+use Digitigrade\HttpResponseStatus;
+use Digitigrade\Model\FetchableModel;
+
+class PolicyRejected extends \Exception implements HttpResponseStatus {
+ public function __construct() {
+ $this->message = "Object was rejected by one or more of the instance's policies";
+ }
+
+ public function httpCode(): int {
+ return 403;
+ }
+
+ public function httpReason(): string {
+ return "Forbidden";
+ }
+
+ public function httpHeaders() {
+ }
+} \ No newline at end of file
diff --git a/Digitigrade/Model/FetchableModel.php b/Digitigrade/Model/FetchableModel.php
index 6d21f40..3efa763 100644
--- a/Digitigrade/Model/FetchableModel.php
+++ b/Digitigrade/Model/FetchableModel.php
@@ -2,6 +2,7 @@
namespace Digitigrade\Model;
use Digitigrade\Model;
+use Digitigrade\PolicyManager;
use Digitigrade\RemoteFetchable;
abstract class FetchableModel extends Model implements RemoteFetchable {
@@ -75,7 +76,7 @@ abstract class FetchableModel extends Model implements RemoteFetchable {
$obj = static::createFromJson($data, $autoSave, $uri);
$obj->finaliseAfterFetch($uri);
- if (!$obj->validate()) {
+ if (!$obj->validate() || !PolicyManager::getInstance()->check($obj)) {
return null; // and don't save
}
if ($autoSave) {
diff --git a/Digitigrade/Model/Note.php b/Digitigrade/Model/Note.php
index f3c0901..880e3e8 100644
--- a/Digitigrade/Model/Note.php
+++ b/Digitigrade/Model/Note.php
@@ -20,7 +20,7 @@ class Note extends PushableModel implements TimelineIncludeable, Notifyable {
public Actor $author;
public ?string $summary;
public string $plainContent;
- // associative array mapping strings to strings - mimetypes to content
+ /** @var array associative array mapping strings to strings - mimetypes to content */
public array $formattedContent = [];
public string $language;
/**
diff --git a/Digitigrade/Model/PushableModel.php b/Digitigrade/Model/PushableModel.php
index 271b313..e773779 100644
--- a/Digitigrade/Model/PushableModel.php
+++ b/Digitigrade/Model/PushableModel.php
@@ -2,6 +2,7 @@
namespace Digitigrade\Model;
use Digitigrade\Job\PushObject;
+use Digitigrade\PolicyManager;
abstract class PushableModel extends FetchableModel implements \JsonSerializable {
public ?int $id;
@@ -20,7 +21,7 @@ abstract class PushableModel extends FetchableModel implements \JsonSerializable
}
$obj = static::createFromJson($data, $autoSave);
- if (!$obj->validate()) {
+ if (!$obj->validate() || !PolicyManager::getInstance()->check($obj)) {
return null; // and don't save
}
if ($autoSave) {
diff --git a/Digitigrade/Policy.php b/Digitigrade/Policy.php
new file mode 100644
index 0000000..56fbdb0
--- /dev/null
+++ b/Digitigrade/Policy.php
@@ -0,0 +1,37 @@
+<?php
+namespace Digitigrade;
+
+use Digitigrade\Model\Actor;
+use Digitigrade\Model\Interaction;
+use Digitigrade\Model\Note;
+
+abstract class Policy {
+ /**
+ * Checks if an Actor is allowed and possibly alters it.
+ * @param Actor $actor the actor to check (may be modified in-place)
+ * @return bool true if allowed, false if the actor should be dropped
+ */
+ public function checkActor(Actor $actor): bool {
+ return true;
+ }
+
+ /**
+ * Checks if an Interaction is allowed and possibly alters it.
+ * @param Interaction $interaction the interaction to check (may be modified in-place)
+ * @return bool true if allowed, false if the interaction should be dropped
+ */
+ public function checkInteraction(Interaction $interaction): bool {
+ return true;
+ }
+
+ /**
+ * Checks if a Note is allowed and possibly alters it.
+ * @param Note $note the note to check (may be modified in-place)
+ * @return bool true if allowed, false if the note should be dropped
+ */
+ public function checkNote(Note $note): bool {
+ return true;
+ }
+
+ // TODO: public function checkExtension()
+} \ No newline at end of file
diff --git a/Digitigrade/Policy/BasicCensor.php b/Digitigrade/Policy/BasicCensor.php
new file mode 100644
index 0000000..5866488
--- /dev/null
+++ b/Digitigrade/Policy/BasicCensor.php
@@ -0,0 +1,47 @@
+<?php
+namespace Digitigrade\Policy;
+
+use Digitigrade\Model\Actor;
+use Digitigrade\Model\Note;
+use Digitigrade\Policy;
+
+/**
+ * Simple word censorship policy that replaces all instances of some text with another string.
+ *
+ * Suffers from the Scunthorpe problem.
+ *
+ * Can also be (ab)used to replace some word with another word for humorous effect :3
+ */
+class BasicCensor extends Policy {
+ /** @var string[] */
+ private array $words;
+ private string $replacement;
+
+ public function __construct(array $words, string $replacement) {
+ $this->words = $words;
+ $this->replacement = $replacement;
+ }
+
+ public function checkNote(Note $note): bool {
+ foreach ($this->words as $w) {
+ if (isset($note->summary))
+ $note->summary = str_replace($w, $this->replacement, $note->summary);
+ $note->plainContent = str_replace($w, $this->replacement, $note->plainContent);
+ foreach ($note->formattedContent as $type => $content) {
+ $note->formattedContent[$type] = str_replace($w, $this->replacement, $content);
+ }
+ }
+ return true;
+ }
+
+ public function checkActor(Actor $actor): bool {
+ foreach ($this->words as $w) {
+ $actor->displayName = str_replace($w, $this->replacement, $actor->displayName);
+ if (isset($actor->pronouns))
+ $actor->pronouns = str_replace($w, $this->replacement, $actor->pronouns);
+ if (isset($actor->bio))
+ $actor->bio = str_replace($w, $this->replacement, $actor->bio);
+ }
+ return true;
+ }
+} \ No newline at end of file
diff --git a/Digitigrade/Policy/BlockInstance.php b/Digitigrade/Policy/BlockInstance.php
new file mode 100644
index 0000000..5f902f2
--- /dev/null
+++ b/Digitigrade/Policy/BlockInstance.php
@@ -0,0 +1,37 @@
+<?php
+namespace Digitigrade\Policy;
+
+use Digitigrade\Model\Actor;
+use Digitigrade\Model\Interaction;
+use Digitigrade\Model\Note;
+use Digitigrade\Model\PushableModel;
+use Digitigrade\Policy;
+
+class BlockInstance extends Policy {
+ /** @var string[] */
+ private array $blockedHosts;
+
+ public function __construct(string ...$hosts) {
+ $this->blockedHosts = $hosts;
+ }
+
+ private function isBlocked(PushableModel $obj): bool {
+ $objHost = hostname_from_uri($obj->uri);
+ foreach ($this->blockedHosts as $host) {
+ if (str_ends_with($objHost, $host)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public function checkActor(Actor $actor): bool {
+ return !$this->isBlocked($actor);
+ }
+ public function checkInteraction(Interaction $interaction): bool {
+ return !$this->isBlocked($interaction);
+ }
+ public function checkNote(Note $note): bool {
+ return !$this->isBlocked($note);
+ }
+} \ No newline at end of file
diff --git a/Digitigrade/PolicyManager.php b/Digitigrade/PolicyManager.php
new file mode 100644
index 0000000..24c2c7f
--- /dev/null
+++ b/Digitigrade/PolicyManager.php
@@ -0,0 +1,46 @@
+<?php
+namespace Digitigrade;
+
+use Digitigrade\Model\Actor;
+use Digitigrade\Model\FetchableModel;
+use Digitigrade\Model\Interaction;
+use Digitigrade\Model\Note;
+
+class PolicyManager extends Singleton {
+ /** @var Policy[] */
+ private array $policies = [];
+
+ public function register(Policy $policy) {
+ $this->policies[] = $policy;
+ }
+
+ public function registerAll(Policy ...$policies) {
+ foreach ($policies as $p) {
+ $this->register($p);
+ }
+ }
+
+ /**
+ * Checks if an object is allowed by all policies and possibly alters it.
+ * @param FetchableModel $object the object to check (may be modified in-place)
+ * @return bool true if allowed, false if it should be dropped
+ */
+ public function check(FetchableModel $object): bool {
+ return match ($object::class) {
+ Actor::class => self::arrayAll($this->policies, fn(Policy $p) => $p->checkActor($object)),
+ Interaction::class => self::arrayAll($this->policies, fn(Policy $p) => $p->checkInteraction($object)),
+ Note::class => self::arrayAll($this->policies, fn(Policy $p) => $p->checkNote($object)),
+ default => true
+ };
+ }
+
+ private static function arrayAll(array $arr, callable $test): bool {
+ // i would use array_all but that's php 8.4+ only which i don't have lol
+ foreach ($arr as $item) {
+ if (!$test($item)) {
+ return false;
+ }
+ }
+ return true;
+ }
+} \ No newline at end of file
diff --git a/README.md b/README.md
index 01b0bf8..9fe8e2e 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,17 @@ whenever you want to update to the latest version (from git) you should:
if it looks messed up in your web browser after updating, you should be able
to fix it by force reloading the page once
+## policies
+
+digitigrade has a "policy" mechanism that allows you to alter or outright
+reject incoming notes/actors/interactions as well as anything created by local
+users, according to your needs. there are some built-in policies (such as
+`Digitigrade\Policy\BlockInstance` which rejects anything from certain
+instances) and you can configure them in `policies.conf.php`.
+
+the default configuration allows you to specify a list of blocked instances in
+the file `blocked-instances.txt`, one domain per line
+
## sample nginx configuration
```nginx
diff --git a/blocked-instances.txt b/blocked-instances.txt
new file mode 100644
index 0000000..7f81b55
--- /dev/null
+++ b/blocked-instances.txt
@@ -0,0 +1,2 @@
+evil.social.example
+diabolical.social.example \ No newline at end of file
diff --git a/index.php b/index.php
index 4a2c2be..e7ad33d 100644
--- a/index.php
+++ b/index.php
@@ -11,6 +11,9 @@ function require_all_glob(string $pattern) {
require_all_glob('routes/*.php');
require_all_glob('misc/*.php');
+require_once __DIR__ . '/policies.conf.php';
+
+
$lang = get_ui_language();
header('Content-Language: ' . str_replace('_', '-', $lang));
Digitigrade\Text::setDefaultLanguage($lang);
diff --git a/policies.conf.php b/policies.conf.php
new file mode 100644
index 0000000..389551a
--- /dev/null
+++ b/policies.conf.php
@@ -0,0 +1,8 @@
+<?php
+
+// Policy config file
+
+Digitigrade\PolicyManager::getInstance()->registerAll(
+ new Digitigrade\Policy\BlockInstance(...explode("\n", file_get_contents(__DIR__ . '/blocked-instances.txt'))),
+ //new Digitigrade\Policy\BasicCensor(['fuck', 'shit'], '••••'),
+); \ No newline at end of file
diff --git a/routes/actor_profile_fragment.php b/routes/actor_profile_fragment.php
index f2d0004..e5fe33f 100644
--- a/routes/actor_profile_fragment.php
+++ b/routes/actor_profile_fragment.php
@@ -1,8 +1,10 @@
<?php
+use Digitigrade\HttpResponseStatus\PolicyRejected;
use Digitigrade\Model\Actor;
use Digitigrade\Model\StorageObject;
use Digitigrade\Model\UserAccount;
+use Digitigrade\PolicyManager;
use Digitigrade\Router;
use Digitigrade\StorageProvider\FilesystemStorage;
@@ -78,6 +80,9 @@ Router::getInstance()->mount('/fragment/profile', function (array $args) {
$actor->avatar = $obj->getUrl();
}
+ if (!PolicyManager::getInstance()->check($actor)) {
+ throw new PolicyRejected();
+ }
$actor->save();
$actor->publish();
diff --git a/routes/note_fragment.php b/routes/note_fragment.php
index e9a7396..ac7927c 100644
--- a/routes/note_fragment.php
+++ b/routes/note_fragment.php
@@ -3,6 +3,7 @@
use Digitigrade\FormattingProvider\CommonMark;
use Digitigrade\HttpResponseStatus\BadRequest;
use Digitigrade\HttpResponseStatus\Forbidden;
+use Digitigrade\HttpResponseStatus\PolicyRejected;
use Digitigrade\Model\Actor;
use Digitigrade\Model\Interaction;
use Digitigrade\Model\InteractionKind;
@@ -11,6 +12,7 @@ use Digitigrade\Model\NoteAttachment;
use Digitigrade\Model\NotePrivacyScope;
use Digitigrade\Model\StorageObject;
use Digitigrade\Model\UserAccount;
+use Digitigrade\PolicyManager;
use Digitigrade\Router;
use Digitigrade\StorageProvider;
use Digitigrade\StorageProvider\FilesystemStorage;
@@ -47,6 +49,10 @@ Router::getInstance()->mount('/fragment/note/:id/:action', function (array $args
if ($interaction == null) {
// we are adding a new interaction
$interaction = Interaction::create($user->actor, InteractionKind::from($action), $note);
+ if (!PolicyManager::getInstance()->check($interaction)) {
+ $interaction->remove();
+ throw new PolicyRejected();
+ }
$interaction->publish();
$interaction->processTimelineAdditions();
$interaction->processNotifications();
@@ -90,6 +96,10 @@ Router::getInstance()->mount('/fragment/note/:id/reply', function (array $args)
$reply->mentions[] = Actor::find($uid);
}
$reply->generateLinkPreviews();
+ if (!PolicyManager::getInstance()->check($reply)) {
+ $reply->remove();
+ throw new PolicyRejected();
+ }
$reply->save();
$reply->publish();
$reply->processTimelineAdditions();
@@ -128,6 +138,10 @@ Router::getInstance()->mount('/fragment/note', function (array $args) {
$note->attachments[] = NoteAttachment::fromStorageObject($obj, $index++, $altTexts[$i] ?: null);
}
$note->generateLinkPreviews();
+ if (!PolicyManager::getInstance()->check($note)) {
+ $note->remove();
+ throw new PolicyRejected();
+ }
$note->save();
$note->publish();
$note->processTimelineAdditions();