[PLUGIN][ActivityPub] Introduce ActivitypubObject. Beware, inside the plugin, an Object can never be an Activity.

Many bug fixes and other major changes (interface changed, see EVENTS.md)
This commit is contained in:
Diogo Peralta Cordeiro 2021-12-08 22:24:52 +00:00
parent b1227d36f1
commit 480a42cca5
Signed by: diogo
GPG Key ID: 18D2D35001FBFAB0
10 changed files with 400 additions and 121 deletions

View File

@ -217,7 +217,6 @@ class Posting extends Component
'verb' => 'create',
'object_type' => 'note',
'object_id' => $note->getId(),
'is_local' => true,
'source' => 'web',
]);
DB::persist($act);

View File

@ -31,6 +31,7 @@ declare(strict_types=1);
namespace Plugin\ActivityPub;
use App\Core\DB\DB;
use App\Core\Event;
use App\Core\HTTPClient;
use App\Core\Log;
@ -40,6 +41,7 @@ use App\Core\Router\Router;
use App\Entity\Activity;
use App\Entity\Actor;
use App\Entity\LocalUser;
use App\Entity\Note;
use App\Util\Common;
use App\Util\Exception\NoSuchActorException;
use App\Util\Nickname;
@ -47,7 +49,9 @@ use Component\FreeNetwork\Entity\FreeNetworkActorProtocol;
use Component\FreeNetwork\Util\Discovery;
use Exception;
use Plugin\ActivityPub\Controller\Inbox;
use Plugin\ActivityPub\Entity\ActivitypubActivity;
use Plugin\ActivityPub\Entity\ActivitypubActor;
use Plugin\ActivityPub\Entity\ActivitypubObject;
use Plugin\ActivityPub\Util\HTTPSignature;
use Plugin\ActivityPub\Util\Model;
use Plugin\ActivityPub\Util\Response\ActorResponse;
@ -380,6 +384,62 @@ class ActivityPub extends Plugin
}
}
/**
* Get a Note from ActivityPub URI, if it doesn't exist, attempt to fetch it
* This should only be necessary internally.
*
* @param string $resource
* @param bool $try_online
* @return null|Note|mixed got from URI
* @throws ClientExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ServerExceptionInterface
* @throws TransportExceptionInterface
*/
public static function getObjectByUri(string $resource, bool $try_online = true)
{
// Try known objects
$known_object = ActivitypubObject::getWithPK(['object_uri' => $resource]);
if ($known_object instanceof ActivitypubObject) {
return $known_object->getObject();
}
// Try known activities
$known_activity = ActivitypubActivity::getWithPK(['activity_uri' => $resource]);
if ($known_activity instanceof ActivitypubActivity) {
return $known_activity->getActivity();
}
// Try local Notes (pretty incomplete effort, I know)
if (Common::isValidHttpUrl($resource)) {
// This means $resource is a valid url
$resource_parts = parse_url($resource);
// TODO: Use URLMatcher
if ($resource_parts['host'] === $_ENV['SOCIAL_DOMAIN']) { // XXX: Common::config('site', 'server')) {
$local_note = DB::find('note', ['url' => $resource]);
if ($local_note instanceof Note) {
return $local_note;
}
}
}
// Try remote
if (!$try_online) {
return null;
}
$response = HTTPClient::get($resource, ['headers' => ActivityPub::HTTP_CLIENT_HEADERS]);
// If it was deleted
if ($response->getStatusCode() == 410) {
//$obj = Type::create('Tombstone', ['id' => $resource]);
return null;
} elseif (!HTTPClient::statusCodeIsOkay($response)) { // If it is unavailable
throw new Exception('Non Ok Status Code for given Object id.');
} else {
return Model::jsonToType($response->getContent());
}
}
/**
* Get an Actor from ActivityPub URI, if it doesn't exist, attempt to fetch it
* This should only be necessary internally.

View File

@ -84,11 +84,17 @@ class Inbox extends Controller
}
try {
$resource_parts = parse_url($type->get('actor'));
if ($resource_parts['host'] !== $_ENV['SOCIAL_DOMAIN']) { // XXX: Common::config('site', 'server')) {
$ap_actor = ActivitypubActor::fromUri($type->get('actor'));
$actor = Actor::getById($ap_actor->getActorId());
DB::flush();
} else {
throw new Exception('Only remote actors can use this endpoint.');
}
unset($resource_parts);
} catch (Exception $e) {
return $error('Invalid actor.');
return $error('Invalid actor: ' . $e->getMessage());
}
$activitypub_rsa = ActivitypubRsa::getByActor($actor);

View File

@ -0,0 +1,69 @@
**ActivityPubValidateActivityStreamsTwoData**: To extend an Activity properties that we are managing from JSON
* `@param string $type_name` When we handle a Type, we will send you the type identifier of the one being handleded
* `@param array &$validators` attribute => Validator the array key should have the attribute name that you want to hand, the value should be a validator class
Example:
```php
public function onActivityPubValidateActivityStreamsTwoData(string $type_name, array &$validators): bool {
if ($type_name === '{Type}') {
$validators['attribute'] = myValidator::class;
}
return Event::next;
}
```
The Validator should be of the form:
```php
class myValidator extends \Plugin\ActivityPub\Util\ModelValidator
{
/**
* Validate Attribute's value
*
* @param mixed $value from JSON's attribute
* @param mixed $container A {Type}
* @return bool
* @throws Exception
*/
public function validate($value, $container): bool
{
// Validate that container is a {Type}
\ActivityPhp\Type\Util::subclassOf($container, \ActivityPhp\Type\Extended\Object\{Type}::class, true);
return {Validation Result};
```
**ActivityPubAddActivityStreamsTwoData**: To add attributes to an entity that we are managing to JSON (commonly federating out via ActivityPub)
* `@param string $type_name` When we handle a Type, we will send you the type identifier of the one being handleded
* `@param \ActivityPhp\Type\AbstractObject &$type_activity` The Activity in the intermediate format between Model and JSON
**ActivityPubActivityStreamsTwoResponse**: To add a route to ActivityPub (the route must already exist in your plugin) (commonly being requested to ActivityPub)
* `@param string $route` Route identifier
* `@param array $vars` From your controller
* `@param \Plugin\ActivityPub\Util\TypeResponse &$response` The JSON (protip: ModelResponse's handler will convert entities into TypeResponse)
Example:
```php
public function onActivityPubActivityStreamsTwoResponse(string $route, arrray $vars, ?TypeResponse &$response = null): bool {
if ($route === '{Object route}') {
$response = \Plugin\ActivityPub\Util\ModelResponse::handle($vars[{Object}]);
return Event::stop;
}
return Event::next;
}
```
**NewActivityPubActivity**: To convert an Activity Streams 2.0 formatted activity into Entities (commonly when we receive a JSON in our inbox)
* `@param Actor $actor` Actor who authored the activity
* `@param \ActivityPhp\Type\AbstractObject $type_activity` Activity
* `@param \ActivityPhp\Type\AbstractObject $type_object` Object
* `@param ?\Plugin\ActivityPub\Entity\ActivitypubActivity &$ap_act` ActivitypubActivity
**NewActivityPubActivityWithObject**: To convert an Activity Streams 2.0 formatted activity with a known object into Entities (commonly when we receive a JSON in our inbox)
* `@param Actor $actor` Actor who authored the activity
* `@param \ActivityPhp\Type\AbstractObject $type_activity` Activity
* `@param Entity $type_object` Object
* `@param ?\Plugin\ActivityPub\Entity\ActivitypubActivity &$ap_act` ActivitypubActivity

View File

@ -48,8 +48,6 @@ class ActivitypubActivity extends Entity
// @codeCoverageIgnoreStart
private int $activity_id;
private string $activity_uri;
private string $object_uri;
private bool $is_local;
private DateTimeInterface $created;
private DateTimeInterface $modified;
@ -75,28 +73,6 @@ class ActivitypubActivity extends Entity
return $this;
}
public function getObjectUri(): string
{
return $this->object_uri;
}
public function setObjectUri(string $object_uri): self
{
$this->object_uri = $object_uri;
return $this;
}
public function setIsLocal(bool $is_local): self
{
$this->is_local = $is_local;
return $this;
}
public function getIsLocal(): bool
{
return $this->is_local;
}
public function setCreated(DateTimeInterface $created): self
{
$this->created = $created;
@ -134,15 +110,12 @@ class ActivitypubActivity extends Entity
'fields' => [
'activity_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Activity.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'activity_id to give attention'],
'activity_uri' => ['type' => 'text', 'not null' => true, 'description' => 'Activity\'s URI'],
'object_uri' => ['type' => 'text', 'not null' => true, 'description' => 'Object\'s URI'],
'is_local' => ['type' => 'bool', 'not null' => true, 'description' => 'whether this was a locally generated or an imported activity'],
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
],
'primary key' => ['activity_uri'],
'indexes' => [
'activity_activity_uri_idx' => ['activity_uri'],
'activity_object_uri_idx' => ['object_uri'],
],
];
}

View File

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
// {{{ License
// This file is part of GNU social - https://www.gnu.org/software/social
//
// GNU social is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// GNU social is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with GNU social. If not, see <http://www.gnu.org/licenses/>.
// }}}
/**
* ActivityPub implementation for GNU social
*
* @package GNUsocial
* @category ActivityPub
* @author Diogo Peralta Cordeiro <@diogo.site>
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
namespace Plugin\ActivityPub\Entity;
use App\Core\DB\DB;
use App\Core\Entity;
use DateTimeInterface;
/**
* Table Definition for activitypub_object
*
* @copyright 2021 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
class ActivitypubObject extends Entity
{
// {{{ Autocode
// @codeCoverageIgnoreStart
private string $object_uri;
private int $object_id;
private string $object_type;
private DateTimeInterface $created;
private DateTimeInterface $modified;
public function getObjectUri(): string
{
return $this->object_uri;
}
public function setObjectUri(string $object_uri): self
{
$this->object_uri = $object_uri;
return $this;
}
public function getObjectId(): int
{
return $this->object_id;
}
public function setObjectId(int $object_id): self
{
$this->object_id = $object_id;
return $this;
}
public function getObjectType(): string
{
return $this->object_type;
}
public function setObjectType(string $object_type): self
{
$this->object_type = $object_type;
return $this;
}
public function setCreated(DateTimeInterface $created): self
{
$this->created = $created;
return $this;
}
public function getCreated(): DateTimeInterface
{
return $this->created;
}
public function setModified(DateTimeInterface $modified): self
{
$this->modified = $modified;
return $this;
}
public function getModified(): DateTimeInterface
{
return $this->modified;
}
// @codeCoverageIgnoreEnd
// }}} Autocode
public function getObject()
{
return DB::findOneBy($this->getObjectType(), ['id' => $this->getObjectId()]);
}
public static function schemaDef(): array
{
return [
'name' => 'activitypub_object',
'fields' => [
'object_uri' => ['type' => 'text', 'not null' => true, 'description' => 'Object\'s URI'],
'object_type' => ['type' => 'varchar', 'length' => 32, 'not null' => true, 'description' => 'the name of the table this object refers to'],
'object_id' => ['type' => 'int', 'not null' => true, 'description' => 'id in the referenced table'],
'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],
'modified' => ['type' => 'timestamp', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was modified'],
],
'primary key' => ['object_uri'],
'indexes' => [
'activity_object_uri_idx' => ['object_uri'],
],
];
}
}

View File

@ -31,6 +31,7 @@ declare(strict_types=1);
namespace Plugin\ActivityPub\Util\Model;
use ActivityPhp\Type;
use ActivityPhp\Type\AbstractObject;
use App\Core\DB\DB;
use App\Core\Event;
@ -40,10 +41,15 @@ use App\Util\Exception\ClientException;
use App\Util\Exception\NoSuchActorException;
use DateTime;
use DateTimeInterface;
use Exception;
use InvalidArgumentException;
use Plugin\ActivityPub\ActivityPub;
use Plugin\ActivityPub\Entity\ActivitypubActivity;
use Plugin\ActivityPub\Util\Model;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
/**
* This class handles translation between JSON and ActivityPub Activities
@ -60,72 +66,75 @@ class Activity extends Model
* @param string|AbstractObject $json
* @param array $options
* @return ActivitypubActivity
* @throws ClientException
* @throws NoSuchActorException
* @throws ClientExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ServerExceptionInterface
* @throws TransportExceptionInterface
*/
public static function fromJson(string|AbstractObject $json, array $options = []): ActivitypubActivity
{
$type_activity = is_string($json) ? self::jsonToType($json) : $json;
$source = $options['source'];
$activity_stream_two_verb_to_gs_verb = fn(string $verb): string => match ($verb) {
'Create' => 'create',
default => throw new ClientException('Invalid verb'),
};
$activity_stream_two_object_type_to_gs_table = fn(string $object): string => match ($object) {
'Note' => 'note',
default => throw new ClientException('Invalid verb'),
};
// Ditch known activities
$ap_act = ActivitypubActivity::getWithPK(['activity_uri' => $type_activity->get('id')]);
if (is_null($ap_act)) {
$actor = ActivityPub::getActorByUri($type_activity->get('actor'));
if (!$type_activity->has('object') || !$type_activity->get('object')->has('type')) {
throw new InvalidArgumentException('Activity Object or Activity Object Type is missing.');
}
// Store Object if new
$ap_act = ActivitypubActivity::getWithPK(['object_uri' => $type_activity->get('object')->get('id')]);
if (!is_null($ap_act)) {
$obj = $ap_act->getActivity()->getObject();
return $ap_act;
}
// Find Actor and Object
$actor = ActivityPub::getActorByUri($type_activity->get('actor'));
$type_object = $type_activity->get('object');
if (is_string($type_object)) { // Retrieve it
$type_object = ActivityPub::getObjectByUri($type_object, try_online: true);
} else { // Encapsulated, if we have it locally, prefer it
$type_object = ActivityPub::getObjectByUri($type_object->get('id'), try_online: false) ?? $type_object;
}
if (($type_object instanceof Type\AbstractObject)) { // It's a new object apparently
if (Event::handle('NewActivityPubActivity', [$actor, $type_activity, $type_object, &$ap_act]) !== Event::stop) {
return self::handle_core_activity($actor, $type_activity, $type_object, $ap_act);
}
} else { // Object was already stored locally then
if (Event::handle('NewActivityPubActivityWithObject', [$actor, $type_activity, $type_object, &$ap_act]) !== Event::stop) {
return self::handle_core_activity($actor, $type_activity, $type_object, $ap_act);
}
}
return $ap_act;
}
private static function handle_core_activity(\App\Entity\Actor $actor, AbstractObject $type_activity, mixed $type_object, ?ActivitypubActivity &$ap_act): ActivitypubActivity
{
if ($type_activity->get('type') === 'Create' && $type_object->get('type') === 'Note') {
if ($type_object instanceof AbstractObject) {
$note = Note::fromJson($type_object, ['test_authority' => true, 'actor_uri' => $type_activity->get('actor'), 'actor' => $actor, 'actor_id' => $actor->getId()]);
} else {
$obj = null;
switch ($type_activity->get('object')->get('type')) {
case 'Note':
$obj = Note::fromJson($type_activity->get('object'), ['source' => $source, 'actor_uri' => $type_activity->get('actor'), 'actor_id' => $actor->getId()]);
break;
default:
if (!Event::handle('ActivityPubObject', [$type_activity->get('object')->get('type'), $type_activity->get('object'), &$obj])) {
throw new ClientException('Unsupported Object type.');
if ($type_object instanceof \App\Entity\Note) {
$note = $type_object;
} else {
throw new Exception('dunno bro');
}
break;
}
DB::persist($obj);
}
// Store Activity
$act = GSActivity::create([
'actor_id' => $actor->getId(),
'verb' => $activity_stream_two_verb_to_gs_verb($type_activity->get('type')),
'object_type' => $activity_stream_two_object_type_to_gs_table($type_activity->get('object')->get('type')),
'object_id' => $obj->getId(),
'is_local' => false,
'verb' => 'create',
'object_type' => 'note',
'object_id' => $note->getId(),
'created' => new DateTime($type_activity->get('published') ?? 'now'),
'source' => $source,
'source' => 'ActivityPub',
]);
DB::persist($act);
// Store ActivityPub Activity
$ap_act = ActivitypubActivity::create([
'activity_id' => $act->getId(),
'activity_uri' => $type_activity->get('id'),
'object_uri' => $type_activity->get('object')->get('id'),
'is_local' => false,
'created' => new DateTime($type_activity->get('published') ?? 'now'),
'modified' => new DateTime(),
]);
DB::persist($ap_act);
}
Event::handle('ActivityPubNewActivity', [&$ap_act, &$act, &$obj]);
return $ap_act;
}

View File

@ -44,7 +44,6 @@ use App\Util\Exception\ServerException;
use App\Util\Formatting;
use App\Util\TemporaryFile;
use Component\Avatar\Avatar;
use Component\Avatar\Exception\NoAvatarException;
use DateTime;
use DateTimeInterface;
use Exception;
@ -120,7 +119,7 @@ class Actor extends Model
}
// Avatar
if ($person->has('icon')) {
if ($person->has('icon') && !empty($person->get('icon'))) {
try {
// Retrieve media
$get_response = HTTPClient::get($person->get('icon')->get('url'));
@ -161,7 +160,6 @@ class Actor extends Model
}
}
Event::handle('ActivityPubNewActor', [&$ap_actor, &$actor, &$apRSA]);
return $ap_actor;
}

View File

@ -37,11 +37,13 @@ use App\Core\Event;
use App\Core\GSFile;
use App\Core\HTTPClient;
use App\Core\Router\Router;
use App\Entity\Actor;
use App\Entity\Language;
use App\Entity\Note as GSNote;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\DuplicateFoundException;
use App\Util\Exception\NoSuchActorException;
use App\Util\Exception\ServerException;
use App\Util\Formatting;
use App\Util\TemporaryFile;
use Component\Attachment\Entity\ActorToAttachment;
@ -51,9 +53,15 @@ use DateTimeInterface;
use Exception;
use InvalidArgumentException;
use Plugin\ActivityPub\ActivityPub;
use Plugin\ActivityPub\Entity\ActivitypubObject;
use Plugin\ActivityPub\Util\Model;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use function App\Core\I18n\_m;
use function is_null;
use function is_string;
/**
* This class handles translation between JSON and GSNotes
@ -71,22 +79,46 @@ class Note extends Model
* @param string|AbstractObject $json
* @param array $options
* @return GSNote
* @throws Exception
* @throws ClientException
* @throws DuplicateFoundException
* @throws NoSuchActorException
* @throws ServerException
* @throws ClientExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ServerExceptionInterface
* @throws TransportExceptionInterface
*/
public static function fromJson(string|AbstractObject $json, array $options = []): GSNote
{
$source = $options['source'];
$source = $options['source'] ?? 'ActivityPub';
$type_note = is_string($json) ? self::jsonToType($json) : $json;
$actor = null;
$actor_id = null;
if ($json instanceof AbstractObject
&& array_key_exists('test_authority', $options)
&& $options['test_authority']
&& array_key_exists('actor_uri', $options)
) {
$actor_uri = $options['actor_uri'];
$actor_id = $options['actor_id'];
$type_note = \is_string($json) ? self::jsonToType($json) : $json;
if ($actor_uri !== $type_note->get('attributedTo')) {
if (parse_url($actor_uri)['host'] !== parse_url($type_note->get('attributedTo'))['host']) {
throw new Exception('You don\'t seem to have enough authority to create this note.');
}
} else {
$actor = $options['actor'] ?? null;
$actor_id = $options['actor_id'] ?? $actor?->getId();
}
}
if (\is_null($actor_uri) || $actor_uri !== $type_note->get('attributedTo')) {
$actor_id = ActivityPub::getActorByUri($type_note->get('attributedTo'))->getId();
if (is_null($actor_id)) {
$actor = ActivityPub::getActorByUri($type_note->get('attributedTo'));
$actor_id = $actor->getId();
}
$map = [
'is_local' => false,
'created' => new DateTime($type_note->get('published') ?? 'now'),
'content' => $type_note->get('content') ?? null,
'rendered' => null,
'content_type' => 'text/html',
'language_id' => $type_note->get('contentLang') ?? null,
'url' => $type_note->get('url') ?? $type_note->get('id'),
@ -100,7 +132,7 @@ class Note extends Model
$map['content'],
$map['content_type'],
&$map['rendered'],
Actor::getById($actor_id),
$actor,
$map['language_id'],
&$mentions,
]);
@ -108,7 +140,7 @@ class Note extends Model
$obj = new GSNote();
if (!\is_null($map['language_id'])) {
if (!is_null($map['language_id'])) {
$map['language_id'] = Language::getByLocale($map['language_id'])->getId();
} else {
$map['language_id'] = null;
@ -148,7 +180,6 @@ class Note extends Model
DB::persist($obj);
// Need file and note ids for the next step
$obj->setUrl(Router::url('note_view', ['id' => $obj->getId()], Router::ABSOLUTE_URL));
Event::handle('ProcessNoteContent', [$obj, $obj->getContent(), $obj->getContentType(), $process_note_content_extra_args = []]);
if ($processed_attachments !== []) {
@ -160,7 +191,20 @@ class Note extends Model
}
}
Event::handle('ActivityPubNewNote', [&$obj]);
$map = [
'object_uri' => $type_note->get('id'),
'object_type' => 'note',
'object_id' => $obj->getId(),
'created' => new DateTime($type_note->get('published') ?? 'now'),
'modified' => new DateTime(),
];
$ap_obj = new ActivitypubObject();
foreach ($map as $prop => $val) {
$set = Formatting::snakeCaseToCamelCase("set_{$prop}");
$ap_obj->{$set}($val);
}
DB::persist($ap_obj);
return $obj;
}

View File

@ -51,7 +51,6 @@ class Activity extends Entity
private string $verb;
private string $object_type;
private int $object_id;
private bool $is_local;
private ?string $source;
private \DateTimeInterface $created;
@ -110,17 +109,6 @@ class Activity extends Entity
return $this->object_id;
}
public function setIsLocal(bool $is_local): self
{
$this->is_local = $is_local;
return $this;
}
public function getIsLocal(): bool
{
return $this->is_local;
}
public function setSource(?string $source): self
{
$this->source = $source;
@ -212,7 +200,6 @@ class Activity extends Entity
'verb' => ['type' => 'varchar', 'length' => 32, 'not null' => true, 'description' => 'internal activity verb, influenced by activity pub verbs'],
'object_type' => ['type' => 'varchar', 'length' => 32, 'not null' => true, 'description' => 'the name of the table this object refers to'],
'object_id' => ['type' => 'int', 'not null' => true, 'description' => 'id in the referenced table'],
'is_local' => ['type' => 'bool', 'not null' => true, 'description' => 'whether this was a locally generated or an imported activity'],
'source' => ['type' => 'varchar', 'length' => 32, 'description' => 'the source of this activity'],
'created' => ['type' => 'datetime', 'not null' => true, 'description' => 'date this record was created', 'default' => 'CURRENT_TIMESTAMP'],
],