Browse Source

Merge branch 'nightly' of git@git.gnu.io:gnu/gnu-social.git into nightly

# By Mikael Nordfeldth
# Via Mikael Nordfeldth
* 'nightly' of git@git.gnu.io:gnu/gnu-social.git: (51 commits)
  Since ActivityContext::CONVERSATION changed to 'conversation' instead of 'ostatus:conversation' we need to add it ourselves
  What just happened? Not sure if me or git caused duplicate code.
  ...and make sure we checkschema on Notice_prefs on upgrade...
  Revert some of 8a4bec811b07a0ed9d76d0aceb03855c91a67242
  Notice_prefs now available (I just copied Profile_prefs)
  Output proper HTML and XML headers for single Atom entry
  Output selfLink from notice asActivity[Object]
  Return false immediately if $url is empty for common_valid_http_url
  Notices start saving selfLink from activities/objects
  Handle selfLink in ActivityObject
  ...and make sure we checkschema on Notice_prefs on upgrade...
  Revert some of 8a4bec811b07a0ed9d76d0aceb03855c91a67242
  Notice_prefs now available (I just copied Profile_prefs)
  Output proper HTML and XML headers for single Atom entry
  Output selfLink from notice asActivity[Object]
  Return false immediately if $url is empty for common_valid_http_url
  Notices start saving selfLink from activities/objects
  Handle selfLink in ActivityObject
  default to #addtag on !group mention
  Fixed the parsing of ostatus:conversation etc.
  ...

# Conflicts:
#	classes/Conversation.php
#	classes/File.php
#	classes/Notice.php
#	lib/activity.php
#	lib/activitycontext.php
#	lib/activityhandlerplugin.php
#	lib/activityobject.php
#	lib/activityutils.php
#	plugins/Blacklist/BlacklistPlugin.php
#	plugins/Blacklist/actions/blacklistadminpanel.php
#	plugins/FeedPoller/FeedPollerPlugin.php
#	plugins/FeedPoller/README
#	plugins/FeedPoller/lib/feedpollqueuehandler.php
#	plugins/FeedPoller/scripts/pollfeed.php
#	plugins/LRDD/lib/discovery.php
#	plugins/LRDD/lib/lrddmethod.php
#	plugins/OStatus/OStatusPlugin.php
#	plugins/OStatus/actions/pushcallback.php
#	plugins/OStatus/actions/pushhub.php
#	plugins/OStatus/classes/FeedSub.php
#	plugins/OStatus/classes/HubSub.php
#	plugins/OStatus/classes/Ostatus_profile.php
#	plugins/OStatus/lib/feeddiscovery.php
#	plugins/OStatus/lib/hubconfqueuehandler.php
#	plugins/OStatus/lib/huboutqueuehandler.php
#	plugins/OStatus/lib/hubprepqueuehandler.php
#	plugins/OStatus/lib/ostatusqueuehandler.php
#	plugins/OStatus/lib/pushinqueuehandler.php
#	plugins/OStatus/lib/pushrenewqueuehandler.php
#	plugins/OStatus/scripts/resub-feed.php
#	plugins/OStatus/scripts/testfeed.php
#	plugins/OStatus/scripts/update-profile.php
#	plugins/OStatus/tests/remote-tests.php
#	plugins/Oembed/classes/File_oembed.php
#	plugins/SubMirror/README
Maiyannah Bishop 1 year ago
parent
commit
7c0438f6b3
39 changed files with 13753 additions and 135 deletions
  1. 15 12
      actions/newnotice.php
  2. 170 0
      classes/Conversation.php
  3. 2 2
      classes/File_redirection.php
  4. 3270 0
      classes/Notice.php
  5. 172 0
      classes/Notice_prefs.php
  6. 122 106
      classes/User.php
  7. 1 0
      db/core.php
  8. 768 0
      lib/activity.php
  9. 221 0
      lib/activitycontext.php
  10. 640 0
      lib/activityhandlerplugin.php
  11. 962 0
      lib/activityobject.php
  12. 459 0
      lib/activityutils.php
  13. 2 1
      lib/apiaction.php
  14. 2 1
      lib/default.php
  15. 4 0
      lib/util.php
  16. 6 5
      modules/OStatus/README
  17. 94 0
      plugins/Blacklist/forms/blacklistadminpanel.php
  18. 223 0
      plugins/LRDD/lib/discovery.php
  19. 58 0
      plugins/LRDD/lib/lrddmethod.php
  20. 1528 0
      plugins/OStatus/OStatusPlugin.php
  21. 656 0
      plugins/OStatus/classes/FeedSub.php
  22. 350 0
      plugins/OStatus/classes/HubSub.php
  23. 1894 0
      plugins/OStatus/classes/Ostatus_profile.php
  24. 12 0
      plugins/OStatus/lib/feeddbexception.php
  25. 295 0
      plugins/OStatus/lib/feeddiscovery.php
  26. 5 0
      plugins/OStatus/lib/feedsubbadpushsignatureexception.php
  27. 57 0
      plugins/OStatus/lib/hubconfqueuehandler.php
  28. 65 0
      plugins/OStatus/lib/huboutqueuehandler.php
  29. 91 0
      plugins/OStatus/lib/hubprepqueuehandler.php
  30. 320 0
      plugins/OStatus/lib/ostatusqueuehandler.php
  31. 52 0
      plugins/OStatus/lib/pushinqueuehandler.php
  32. 49 0
      plugins/OStatus/lib/pushrenewqueuehandler.php
  33. 88 0
      plugins/OStatus/scripts/resub-feed.php
  34. 95 0
      plugins/OStatus/scripts/testfeed.php
  35. 174 0
      plugins/OStatus/scripts/update-profile.php
  36. 672 0
      plugins/OStatus/tests/remote-tests.php
  37. 149 0
      plugins/Oembed/classes/File_oembed.php
  38. 4 3
      scripts/resend_confirm_address.php
  39. 6 5
      theme/base/css/display.css

+ 15 - 12
actions/newnotice.php

@@ -69,6 +69,8 @@ class NewnoticeAction extends FormAction
69 69
 {
70 70
     protected $form = 'Notice';
71 71
 
72
+    protected $inreplyto = null;
73
+
72 74
     /**
73 75
      * Title of the page
74 76
      *
@@ -97,6 +99,11 @@ class NewnoticeAction extends FormAction
97 99
             }
98 100
         }
99 101
 
102
+        if ($this->int('inreplyto')) {
103
+            // Throws exception if the inreplyto Notice is given but not found.
104
+            $this->inreplyto = Notice::getByID($this->int('inreplyto'));
105
+        }
106
+
100 107
         // Backwards compatibility for "share this" widget things.
101 108
         // If no 'content', use 'status_textarea'
102 109
         $this->formOpts['content'] = $this->trimmed('content') ?: $this->trimmed('status_textarea');
@@ -154,13 +161,6 @@ class NewnoticeAction extends FormAction
154 161
             return;
155 162
         }
156 163
 
157
-        if ($this->int('inreplyto')) {
158
-            // Throws exception if the inreplyto Notice is given but not found.
159
-            $parent = Notice::getByID($this->int('inreplyto'));
160
-        } else {
161
-            $parent = null;
162
-        }
163
-
164 164
         $act = new Activity();
165 165
         $act->verb = ActivityVerb::POST;
166 166
         $act->time = time();
@@ -179,9 +179,9 @@ class NewnoticeAction extends FormAction
179 179
 
180 180
         $act->context = new ActivityContext();
181 181
 
182
-        if ($parent instanceof Notice) {
183
-            $act->context->replyToID = $parent->getUri();
184
-            $act->context->replyToUrl = $parent->getUrl(true);  // maybe we don't have to send true here to force a URL?
182
+        if ($this->inreplyto instanceof Notice) {
183
+            $act->context->replyToID = $this->inreplyto->getUri();
184
+            $act->context->replyToUrl = $this->inreplyto->getUrl(true);  // maybe we don't have to send true here to force a URL?
185 185
         }
186 186
 
187 187
         if ($this->scoped->shareLocation()) {
@@ -210,14 +210,14 @@ class NewnoticeAction extends FormAction
210 210
 
211 211
             // FIXME: We should be able to get the attentions from common_render_content!
212 212
             // and maybe even directly save whether they're local or not!
213
-            $act->context->attention = common_get_attentions($content, $this->scoped, $parent);
213
+            $act->context->attention = common_get_attentions($content, $this->scoped, $this->inreplyto);
214 214
 
215 215
             // $options gets filled with possible scoping settings
216 216
             ToSelector::fillActivity($this, $act, $options);
217 217
 
218 218
             $actobj = new ActivityObject();
219 219
             $actobj->type = ActivityObject::NOTE;
220
-            $actobj->content = common_render_content($content, $this->scoped, $parent);
220
+            $actobj->content = common_render_content($content, $this->scoped, $this->inreplyto);
221 221
 
222 222
             // Finally add the activity object to our activity
223 223
             $act->objects[] = $actobj;
@@ -246,6 +246,9 @@ class NewnoticeAction extends FormAction
246 246
         if ($this->getInfo() && $this->stored instanceof Notice) {
247 247
             $this->showNotice($this->stored);
248 248
         } elseif (!$this->getError()) {
249
+            if (!GNUsocial::isAjax() && $this->inreplyto instanceof Notice) {
250
+                $this->showNotice($this->inreplyto);
251
+            }
249 252
             parent::showContent();
250 253
         }
251 254
     }

+ 170 - 0
classes/Conversation.php

@@ -0,0 +1,170 @@
1
+<?php
2
+/**
3
+ * StatusNet, the distributed open-source microblogging tool
4
+ *
5
+ * Data class for Conversations
6
+ *
7
+ * PHP version 5
8
+ *
9
+ * LICENCE: This program is free software: you can redistribute it and/or modify
10
+ * it under the terms of the GNU Affero General Public License as published by
11
+ * the Free Software Foundation, either version 3 of the License, or
12
+ * (at your option) any later version.
13
+ *
14
+ * This program is distributed in the hope that it will be useful,
15
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
16
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17
+ * GNU Affero General Public License for more details.
18
+ *
19
+ * You should have received a copy of the GNU Affero General Public License
20
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
21
+ *
22
+ * @category  Data
23
+ * @package   StatusNet
24
+ * @author    Zach Copley <zach@status.net>
25
+ * @author    Mikael Nordfeldth <mmn@hethane.se>
26
+ * @copyright 2010 StatusNet Inc.
27
+ * @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
28
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
29
+ * @link      http://status.net/
30
+ */
31
+
32
+if (!defined('GNUSOCIAL')) { exit(1); }
33
+
34
+class Conversation extends Managed_DataObject
35
+{
36
+    public $__table = 'conversation';        // table name
37
+    public $id;                              // int(4)  primary_key not_null auto_increment
38
+    public $uri;                             // varchar(191)  unique_key   not 255 because utf8mb4 takes more space
39
+    public $url;                             // varchar(191)  unique_key   not 255 because utf8mb4 takes more space
40
+    public $created;                         // datetime   not_null
41
+    public $modified;                        // timestamp   not_null default_CURRENT_TIMESTAMP
42
+
43
+    public static function schemaDef()
44
+    {
45
+        return array(
46
+            'fields' => array(
47
+                'id' => array('type' => 'serial', 'not null' => true, 'description' => 'Unique identifier, (again) unrelated to notice id since 2016-01-06'),
48
+                'uri' => array('type' => 'varchar', 'not null'=>true, 'length' => 191, 'description' => 'URI of the conversation'),
49
+                'url' => array('type' => 'varchar', 'length' => 191, 'description' => 'Resolvable URL, preferrably remote (local can be generated on the fly)'),
50
+                'created' => array('type' => 'datetime', 'not null' => true, 'description' => 'date this record was created'),
51
+                'modified' => array('type' => 'timestamp', 'not null' => true, 'description' => 'date this record was modified'),
52
+            ),
53
+            'primary key' => array('id'),
54
+            'unique keys' => array(
55
+                'conversation_uri_key' => array('uri'),
56
+            ),
57
+        );
58
+    }
59
+
60
+    static public function beforeSchemaUpdate()
61
+    {
62
+        $table = strtolower(get_called_class());
63
+        $schema = Schema::get();
64
+        $schemadef = $schema->getTableDef($table);
65
+
66
+        // 2016-01-06 We have to make sure there is no conversation with id==0 since it will screw up auto increment resequencing
67
+        if ($schemadef['fields']['id']['auto_increment']) {
68
+            // since we already have auto incrementing ('serial') we can continue
69
+            return;
70
+        }
71
+
72
+        // The conversation will be recreated in upgrade.php, which will
73
+        // generate a new URI, but that's collateral damage for you.
74
+        $conv = new Conversation();
75
+        $conv->id = 0;
76
+        if ($conv->find()) {
77
+            while ($conv->fetch()) {
78
+                // Since we have filtered on 0 this only deletes such entries
79
+                // which I have been afraid wouldn't work, but apparently does!
80
+                // (I thought it would act as null or something and find _all_ conversation entries)
81
+                $conv->delete();
82
+            }
83
+        }
84
+    }
85
+
86
+    /**
87
+     * Factory method for creating a new conversation.
88
+     *
89
+     * Use this for locally initiated conversations. Remote notices should
90
+     * preferrably supply their own conversation URIs in the OStatus feed.
91
+     *
92
+     * @return Conversation the new conversation DO
93
+     */
94
+    static function create(ActivityContext $ctx=null, $created=null)
95
+    {
96
+        // Be aware that the Notice does not have an id yet since it's not inserted!
97
+        $conv = new Conversation();
98
+        $conv->created = $created ?: common_sql_now();
99
+        if ($ctx instanceof ActivityContext) {
100
+            $conv->uri = $ctx->conversation;
101
+            $conv->url = $ctx->conversation_url;
102
+        } else {
103
+            $conv->uri = sprintf('%s%s=%s:%s=%s',
104
+                             TagURI::mint(),
105
+                             'objectType', 'thread',
106
+                             'nonce', common_random_hexstr(8));
107
+            $conv->url = null;  // locally generated Conversation objects don't get static URLs stored
108
+        }
109
+        // This insert throws exceptions on failure
110
+        $conv->insert();
111
+
112
+        return $conv;
113
+    }
114
+
115
+    static function noticeCount($id)
116
+    {
117
+        $keypart = sprintf('conversation:notice_count:%d', $id);
118
+
119
+        $cnt = self::cacheGet($keypart);
120
+
121
+        if ($cnt !== false) {
122
+            return $cnt;
123
+        }
124
+
125
+        $notice               = new Notice();
126
+        $notice->conversation = $id;
127
+        $notice->whereAddIn('verb', array(ActivityVerb::POST, ActivityUtils::resolveUri(ActivityVerb::POST, true)), $notice->columnType('verb'));
128
+        $cnt                  = $notice->count();
129
+
130
+        self::cacheSet($keypart, $cnt);
131
+
132
+        return $cnt;
133
+    }
134
+
135
+    static public function getUrlFromNotice(Notice $notice, $anchor=true)
136
+    {
137
+        $conv = Conversation::getByID($notice->conversation);
138
+        return $conv->getUrl($anchor ? $notice->getID() : null);
139
+    }
140
+
141
+    public function getUri()
142
+    {
143
+        return $this->uri;
144
+    }
145
+
146
+    public function getUrl($noticeId=null)
147
+    {
148
+        // FIXME: the URL router should take notice-id as an argument...
149
+        return common_local_url('conversation', array('id' => $this->getID())) .
150
+                ($noticeId===null ? '' : "#notice-{$noticeId}");
151
+    }
152
+
153
+    // FIXME: ...will 500 ever be too low? Taken from ConversationAction::MAX_NOTICES
154
+    public function getNotices(Profile $scoped=null, $offset=0, $limit=500)
155
+    {
156
+        $stream = new ConversationNoticeStream($this->getID(), $scoped);
157
+        $notices = $stream->getNotices($offset, $limit);
158
+        return $notices;
159
+    }
160
+
161
+    public function insert()
162
+    {
163
+        $result = parent::insert();
164
+        if ($result === false) {
165
+            common_log_db_error($this, 'INSERT', __FILE__);
166
+            throw new ServerException(_('Failed to insert Conversation into database'));
167
+        }
168
+        return $result;
169
+    }
170
+}

+ 2 - 2
classes/File_redirection.php

@@ -479,8 +479,8 @@ class File_redirection extends Managed_DataObject
479 479
     }
480 480
 
481 481
     public function getFile() {
482
-        if(empty($this->file) && $this->file_id) {
483
-            $this->file = File::getKV('id', $this->file_id);
482
+        if (!$this->file instanceof File) {
483
+            $this->file = File::getByID($this->file_id);
484 484
         }
485 485
 
486 486
         return $this->file;

File diff suppressed because it is too large
+ 3270 - 0
classes/Notice.php


+ 172 - 0
classes/Notice_prefs.php

@@ -0,0 +1,172 @@
1
+<?php
2
+/**
3
+ * GNU social
4
+ *
5
+ * Data class for Notice preferences
6
+ *
7
+ * PHP version 5
8
+ *
9
+ * LICENCE: This program is free software: you can redistribute it and/or modify
10
+ * it under the terms of the GNU Affero General Public License as published by
11
+ * the Free Software Foundation, either version 3 of the License, or
12
+ * (at your option) any later version.
13
+ *
14
+ * This program is distributed in the hope that it will be useful,
15
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
16
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17
+ * GNU Affero General Public License for more details.
18
+ *
19
+ * You should have received a copy of the GNU Affero General Public License
20
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
21
+ *
22
+ * @category  Data
23
+ * @package   GNUsocial
24
+ * @author    Mikael Nordfeldth <mmn@hethane.se>
25
+ * @copyright 2013 Free Software Foundation, Inc.
26
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
27
+ * @link      http://www.gnu.org/software/social/
28
+ */
29
+
30
+class Notice_prefs extends Managed_DataObject
31
+{
32
+    public $__table = 'notice_prefs';       // table name
33
+    public $notice_id;                      // int(4)  primary_key not_null
34
+    public $namespace;                       // varchar(191)  not_null
35
+    public $topic;                           // varchar(191)  not_null
36
+    public $data;                            // text
37
+    public $created;                         // datetime   not_null default_0000-00-00%2000%3A00%3A00
38
+    public $modified;                        // timestamp   not_null default_CURRENT_TIMESTAMP
39
+
40
+    public static function schemaDef()
41
+    {
42
+        return array(
43
+            'fields' => array(
44
+                'notice_id' => array('type' => 'int', 'not null' => true, 'description' => 'user'),
45
+                'namespace' => array('type' => 'varchar', 'length' => 191, 'not null' => true, 'description' => 'namespace, like pluginname or category'),
46
+                'topic' => array('type' => 'varchar', 'length' => 191, 'not null' => true, 'description' => 'preference key, i.e. description, age...'),
47
+                'data' => array('type' => 'blob', 'description' => 'topic data, may be anything'),
48
+                'created' => array('type' => 'datetime', 'not null' => true, 'description' => 'date this record was created'),
49
+                'modified' => array('type' => 'timestamp', 'not null' => true, 'description' => 'date this record was modified'),
50
+            ),
51
+            'primary key' => array('notice_id', 'namespace', 'topic'),
52
+            'foreign keys' => array(
53
+                'notice_prefs_notice_id_fkey' => array('notice', array('notice_id' => 'id')),
54
+            ),
55
+            'indexes' => array(
56
+                'notice_prefs_notice_id_idx' => array('notice_id'),
57
+            ),
58
+        );
59
+    }
60
+
61
+    static function getNamespacePrefs(Notice $notice, $namespace, array $topic=array())
62
+    {
63
+        if (empty($topic)) {
64
+            $prefs = new Notice_prefs();
65
+            $prefs->notice_id = $notice->getID();
66
+            $prefs->namespace  = $namespace;
67
+            $prefs->find();
68
+        } else {
69
+            $prefs = self::pivotGet('notice_id', $notice->getID(), array('namespace'=>$namespace, 'topic'=>$topic));
70
+        }
71
+
72
+        if (empty($prefs->N)) {
73
+            throw new NoResultException($prefs);
74
+        }
75
+
76
+        return $prefs;
77
+    }
78
+
79
+    static function getNamespace(Notice $notice, $namespace, array $topic=array())
80
+    {
81
+        $prefs = self::getNamespacePrefs($notice, $namespace, $topic);
82
+        return $prefs->fetchAll();
83
+    }
84
+
85
+    static function getAll(Notice $notice)
86
+    {
87
+        try {
88
+            $prefs = self::listFind('notice_id', array($notice->getID()));
89
+        } catch (NoResultException $e) {
90
+            return array();
91
+        }
92
+
93
+        $list = array();
94
+        while ($prefs->fetch()) {
95
+            if (!isset($list[$prefs->namespace])) {
96
+                $list[$prefs->namespace] = array();
97
+            }
98
+            $list[$prefs->namespace][$prefs->topic] = $prefs->data;
99
+        }
100
+        return $list;
101
+    }
102
+
103
+    static function getTopic(Notice $notice, $namespace, $topic) {
104
+        return self::getByPK(array('notice_id' => $notice->getID(),
105
+                                            'namespace'  => $namespace,
106
+                                            'topic'      => $topic));
107
+    }
108
+
109
+    static function getData(Notice $notice, $namespace, $topic, $def=null) {
110
+        try {
111
+            $pref = self::getTopic($notice, $namespace, $topic);
112
+        } catch (NoResultException $e) {
113
+            if ($def === null) {
114
+                // If no default value was set, continue the exception.
115
+                throw $e;
116
+            }
117
+            // If there was a default value, return that.
118
+            return $def;
119
+        }
120
+        return $pref->data;
121
+    }
122
+
123
+    static function getConfigData(Notice $notice, $namespace, $topic) {
124
+        try {
125
+            $data = self::getData($notice, $namespace, $topic);
126
+        } catch (NoResultException $e) {
127
+            $data = common_config($namespace, $topic);
128
+        }
129
+        return $data;
130
+    }
131
+
132
+    /*
133
+     * Sets a notice preference based on Notice, namespace and topic
134
+     *
135
+     * @param  Notice  $notice    Which notice this is for
136
+     * @param  string  $namespace Under which namespace (pluginname etc.)
137
+     * @param  string  $topic     Preference name (think key in key-val store)
138
+     * @param  string  $data      Data to be put into preference storage, null means delete
139
+     *
140
+     * @return true if changes are made, false if no action taken
141
+     * @throws ServerException if preference could not be saved
142
+     */
143
+    static function setData(Notice $notice, $namespace, $topic, $data=null) {
144
+        try {
145
+            $pref = self::getTopic($notice, $namespace, $topic);
146
+            if (is_null($data)) {
147
+                $pref->delete();
148
+            } else {
149
+                $orig = clone($pref);
150
+                $pref->data = $data;
151
+                $pref->update($orig);
152
+            }
153
+            return true;
154
+        } catch (NoResultException $e) {
155
+            if (is_null($data)) {
156
+                return false; // No action taken
157
+            }
158
+        }
159
+
160
+        $pref = new Notice_prefs();
161
+        $pref->notice_id  = $notice->getID();
162
+        $pref->namespace  = $namespace;
163
+        $pref->topic      = $topic;
164
+        $pref->data       = $data;
165
+        $pref->created    = common_sql_now();
166
+        
167
+        if ($pref->insert() === false) {
168
+            throw new ServerException('Could not save notice preference.');
169
+        }
170
+        return true;
171
+    }
172
+}

+ 122 - 106
classes/User.php

@@ -827,125 +827,141 @@ class User extends Managed_DataObject {
827 827
     }
828 828
 
829 829
 
830
-    function repeatsOfMe($offset=0, $limit=20, $since_id=null, $max_id=null)
831
-    {
832
-        // FIXME: Use another way to get Profile::current() since we
833
-        // want to avoid confusion between session user and queue processing.
834
-        $stream = new RepeatsOfMeNoticeStream($this->getProfile(), Profile::current());
835
-        return $stream->getNotices($offset, $limit, $since_id, $max_id);
836
-    }
837
-
838
-    public function repeatedToMe($offset=0, $limit=20, $since_id=null, $max_id=null)
839
-    {
840
-        return $this->getProfile()->repeatedToMe($offset, $limit, $since_id, $max_id);
841
-    }
830
+   // -------------------------------------------------------------------------
831
+   // Function: repeatsOfMe
832
+   // Returns an array of notices of this user which have been repeated by 
833
+   // others, that we are aware of.
842 834
 
843
-    public static function siteOwner()
844
-    {
845
-        $owner = self::cacheGet('user:site_owner');
835
+   // FIXME:
836
+   // Use another way to get Profile::current() since we want to avoid 
837
+   // confusion between session user and queue processing.
838
+   function repeatsOfMe($offset=0, $limit=20, $since_id=null, $max_id=null) {
839
+      $stream = new RepeatsOfMeNoticeStream($this->getProfile(), Profile::current());
840
+      return $stream->getNotices($offset, $limit, $since_id, $max_id);
841
+   }
846 842
 
847
-        if ($owner === false) { // cache miss
848 843
 
849
-            $pr = new Profile_role();
850
-            $pr->role = Profile_role::OWNER;
851
-            $pr->orderBy('created');
852
-            $pr->limit(1);
844
+   // -------------------------------------------------------------------------
845
+   // Functions: repeatedToMe
846
+   // Returns an array of notices repeated to this user.
847
+   public function repeatedToMe($offset=0, $limit=20, $since_id=null, $max_id=null) {
848
+      return $this->getProfile()->repeatedToMe($offset, $limit, $since_id, $max_id);
849
+   }
853 850
 
854
-            if (!$pr->find(true)) {
855
-                throw new NoResultException($pr);
856
-            }
857 851
 
858
-            $owner = User::getKV('id', $pr->profile_id);
852
+   // -------------------------------------------------------------------------
853
+   // Function: siteOwner
854
+   // Returns the User object of the account labelled as the site owner.  Note
855
+   // that this will return the FIRST account found with such a role if
856
+   // multiple are assigned.
857
+   public static function siteOwner() {
858
+      $owner = self::cacheGet('user:site_owner');
859
+
860
+      if ($owner === false) { // cache miss
861
+         $pr = new Profile_role();
862
+         $pr->role = Profile_role::OWNER;
863
+         $pr->orderBy('created');
864
+         $pr->limit(1);
865
+         if (!$pr->find(true)) {
866
+            throw new NoResultException($pr);
867
+         }
859 868
 
860
-            self::cacheSet('user:site_owner', $owner);
861
-        }
869
+         $owner = User::getKV('id', $pr->profile_id);
870
+         self::cacheSet('user:site_owner', $owner);
871
+      }
872
+      if ($owner instanceof User) {
873
+         return $owner;
874
+      }
875
+      throw new ServerException(_('No site owner configured.'));
876
+   }
862 877
 
863
-        if ($owner instanceof User) {
864
-            return $owner;
865
-        }
866 878
 
867
-        throw new ServerException(_('No site owner configured.'));
868
-    }
879
+   // -------------------------------------------------------------------------
880
+   // Function: singleUser
881
+   // Pull the primary site account to use in single-user mode.
882
+   // If a valid user nickname is listed in 'singleuser':'nickname'
883
+   // in the config, this will be used; otherwise the site owner
884
+   // account is taken by default.
885
+   //
886
+   // Returns:
887
+   // o object User
888
+   //
889
+   // Error States:
890
+   // o throws ServerException if no valid single user account is present
891
+   // o throws ServerException if called when not in single-user mode
892
+   public static function singleUser() {
893
+      if (!common_config('singleuser', 'enabled')) {
894
+         // TRANS: Server exception.
895
+         throw new ServerException(_('Single-user mode code called when not enabled.'));
896
+      }
897
+      if ($nickname = common_config('singleuser', 'nickname')) {
898
+         $user = User::getKV('nickname', $nickname);
899
+         if ($user instanceof User) {
900
+            return $user;
901
+         }
902
+      }
869 903
 
870
-    /**
871
-     * Pull the primary site account to use in single-user mode.
872
-     * If a valid user nickname is listed in 'singleuser':'nickname'
873
-     * in the config, this will be used; otherwise the site owner
874
-     * account is taken by default.
875
-     *
876
-     * @return User
877
-     * @throws ServerException if no valid single user account is present
878
-     * @throws ServerException if called when not in single-user mode
879
-     */
880
-    public static function singleUser()
881
-    {
882
-        if (!common_config('singleuser', 'enabled')) {
883
-            // TRANS: Server exception.
884
-            throw new ServerException(_('Single-user mode code called when not enabled.'));
885
-        }
904
+      // If there was no nickname or no user by that nickname,
905
+      // try the site owner. Throws exception if not configured.
906
+      return User::siteOwner();
907
+   }
886 908
 
887
-        if ($nickname = common_config('singleuser', 'nickname')) {
888
-            $user = User::getKV('nickname', $nickname);
889
-            if ($user instanceof User) {
890
-                return $user;
891
-            }
892
-        }
893 909
 
894
-        // If there was no nickname or no user by that nickname,
895
-        // try the site owner. Throws exception if not configured.
896
-        return User::siteOwner();
897
-    }
910
+   // -------------------------------------------------------------------------
911
+   // Function: singleUserNickname
912
+   // This is kind of a hack for using external setup code that's trying to
913
+   // build single-user sites.
914
+   //
915
+   // Will still return a username if the config singleuser/nickname is set
916
+   // even if the account doesn't exist, which normally indicates that the
917
+   // site is horribly misconfigured.
918
+   //
919
+   // At the moment, we need to let it through so that router setup can
920
+   // complete, otherwise we won't be able to create the account.
921
+   //
922
+   // This will be easier when we can more easily create the account and
923
+   // *then* switch the site to 1user mode without jumping through hoops.
924
+   //
925
+   // Returns:
926
+   // o string
927
+   //
928
+   // Error States:
929
+   // o throws ServerException if no valid single user account is present
930
+   // o throws ServerException if called when not in single-user mode
931
+   static function singleUserNickname() {
932
+      try {
933
+         $user = User::singleUser();
934
+         return $user->nickname;
935
+      } catch (Exception $e) {
936
+         if (common_config('singleuser', 'enabled') && common_config('singleuser', 'nickname')) {
937
+            common_log(LOG_WARNING, "Warning: code attempting to pull single-user nickname when the account does not exist. If this is not setup time, this is probably a bug.");
938
+            return common_config('singleuser', 'nickname');
939
+         }
940
+         throw $e;
941
+      }
942
+   }
898 943
 
899
-    /**
900
-     * This is kind of a hack for using external setup code that's trying to
901
-     * build single-user sites.
902
-     *
903
-     * Will still return a username if the config singleuser/nickname is set
904
-     * even if the account doesn't exist, which normally indicates that the
905
-     * site is horribly misconfigured.
906
-     *
907
-     * At the moment, we need to let it through so that router setup can
908
-     * complete, otherwise we won't be able to create the account.
909
-     *
910
-     * This will be easier when we can more easily create the account and
911
-     * *then* switch the site to 1user mode without jumping through hoops.
912
-     *
913
-     * @return string
914
-     * @throws ServerException if no valid single user account is present
915
-     * @throws ServerException if called when not in single-user mode
916
-     */
917
-    static function singleUserNickname()
918
-    {
919
-        try {
920
-            $user = User::singleUser();
921
-            return $user->nickname;
922
-        } catch (Exception $e) {
923
-            if (common_config('singleuser', 'enabled') && common_config('singleuser', 'nickname')) {
924
-                common_log(LOG_WARNING, "Warning: code attempting to pull single-user nickname when the account does not exist. If this is not setup time, this is probably a bug.");
925
-                return common_config('singleuser', 'nickname');
926
-            }
927
-            throw $e;
928
-        }
929
-    }
930 944
 
931
-    /**
932
-     * Find and shorten links in the given text using this user's URL shortening
933
-     * settings.
934
-     *
935
-     * By default, links will be left untouched if the text is shorter than the
936
-     * configured maximum notice length. Pass true for the $always parameter
937
-     * to force all links to be shortened regardless.
938
-     *
939
-     * Side effects: may save file and file_redirection records for referenced URLs.
940
-     *
941
-     * @param string $text
942
-     * @param boolean $always
943
-     * @return string
944
-     */
945
-    public function shortenLinks($text, $always=false)
946
-    {
947
-        return common_shorten_links($text, $always, $this);
948
-    }
945
+   // -------------------------------------------------------------------------
946
+   // Function: shortenLinks
947
+   // Find and shorten links in the given text using this user's URL shortening
948
+   // settings.
949
+   //
950
+   // By default, links will be left untouched if the text is shorter than the
951
+   // configured maximum notice length. Pass true for the $always parameter
952
+   // to force all links to be shortened regardless.
953
+   //
954
+   // Side effects: may save file and file_redirection records for referenced URLs.
955
+   //
956
+   // Parameters:
957
+   // o string $text
958
+   // o boolean $always
959
+   //
960
+   // Returns:
961
+   // o string
962
+   public function shortenLinks($text, $always=false) {
963
+      return common_shorten_links($text, $always, $this);
964
+   }
949 965
 
950 966
 
951 967
    // -------------------------------------------------------------------------

+ 1 - 0
db/core.php

@@ -72,6 +72,7 @@ $classes = array('Schema_version',
72 72
                  'Notice',
73 73
                  'Notice_location',
74 74
                  'Notice_source',
75
+                 'Notice_prefs',
75 76
                  'Reply',
76 77
                  'Consumer',
77 78
                  'Token',

+ 768 - 0
lib/activity.php

@@ -0,0 +1,768 @@
1
+<?php
2
+/**
3
+ * StatusNet, the distributed open-source microblogging tool
4
+ *
5
+ * An activity
6
+ *
7
+ * PHP version 5
8
+ *
9
+ * LICENCE: This program is free software: you can redistribute it and/or modify
10
+ * it under the terms of the GNU Affero General Public License as published by
11
+ * the Free Software Foundation, either version 3 of the License, or
12
+ * (at your option) any later version.
13
+ *
14
+ * This program is distributed in the hope that it will be useful,
15
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
16
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17
+ * GNU Affero General Public License for more details.
18
+ *
19
+ * You should have received a copy of the GNU Affero General Public License
20
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
21
+ *
22
+ * @category  Feed
23
+ * @package   StatusNet
24
+ * @author    Evan Prodromou <evan@status.net>
25
+ * @author    Zach Copley <zach@status.net>
26
+ * @copyright 2010 StatusNet, Inc.
27
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
28
+ * @link      http://status.net/
29
+ */
30
+
31
+if (!defined('STATUSNET')) {
32
+    exit(1);
33
+}
34
+
35
+/**
36
+ * An activity in the ActivityStrea.ms world
37
+ *
38
+ * An activity is kind of like a sentence: someone did something
39
+ * to something else.
40
+ *
41
+ * 'someone' is the 'actor'; 'did something' is the verb;
42
+ * 'something else' is the object.
43
+ *
44
+ * @category  OStatus
45
+ * @package   StatusNet
46
+ * @author    Evan Prodromou <evan@status.net>
47
+ * @copyright 2010 StatusNet, Inc.
48
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
49
+ * @link      http://status.net/
50
+ */
51
+class Activity
52
+{
53
+    const SPEC   = 'http://activitystrea.ms/spec/1.0/';
54
+    const SCHEMA = 'http://activitystrea.ms/schema/1.0/';
55
+    const MEDIA  = 'http://purl.org/syndication/atommedia';
56
+
57
+    const VERB       = 'verb';
58
+    const OBJECT     = 'object';
59
+    const ACTOR      = 'actor';
60
+    const SUBJECT    = 'subject';
61
+    const OBJECTTYPE = 'object-type';
62
+    const CONTEXT    = 'context';
63
+    const TARGET     = 'target';
64
+
65
+    const ATOM = 'http://www.w3.org/2005/Atom';
66
+
67
+    const AUTHOR    = 'author';
68
+    const PUBLISHED = 'published';
69
+    const UPDATED   = 'updated';
70
+
71
+    const RSS = null; // no namespace!
72
+
73
+    const PUBDATE     = 'pubDate';
74
+    const DESCRIPTION = 'description';
75
+    const GUID        = 'guid';
76
+    const SELF        = 'self';
77
+    const IMAGE       = 'image';
78
+    const URL         = 'url';
79
+
80
+    const DC = 'http://purl.org/dc/elements/1.1/';
81
+
82
+    const CREATOR = 'creator';
83
+
84
+    const CONTENTNS = 'http://purl.org/rss/1.0/modules/content/';
85
+    const ENCODED = 'encoded';
86
+
87
+    public $actor;   // an ActivityObject
88
+    public $verb;    // a string (the URL)
89
+    public $objects = array();  // an array of ActivityObjects
90
+    public $target;  // an ActivityObject
91
+    public $context; // an ActivityObject
92
+    public $time;    // Time of the activity
93
+    public $link;    // an ActivityObject
94
+    public $entry;   // the source entry
95
+    public $feed;    // the source feed
96
+
97
+    public $summary; // summary of activity
98
+    public $content; // HTML content of activity
99
+    public $id;      // ID of the activity
100
+    public $title;   // title of the activity
101
+    public $categories = array(); // list of AtomCategory objects
102
+    public $enclosures = array(); // list of enclosure URL references
103
+    public $attachments = array(); // list of attachments
104
+
105
+    public $extra = array(); // extra elements as array(tag, attrs, content)
106
+    public $source;  // ActivitySource object representing 'home feed'
107
+    public $selfLink; // <link rel='self' type='application/atom+xml'>
108
+    public $editLink; // <link rel='edit' type='application/atom+xml'>
109
+    public $generator; // ActivityObject representing the generating application
110
+    /**
111
+     * Turns a regular old Atom <entry> into a magical activity
112
+     *
113
+     * @param DOMElement $entry Atom entry to poke at
114
+     * @param DOMElement $feed  Atom feed, for context
115
+     */
116
+    function __construct($entry = null, $feed = null)
117
+    {
118
+        if (is_null($entry)) {
119
+            return;
120
+        }
121
+
122
+        // Insist on a feed's root DOMElement; don't allow a DOMDocument
123
+        if ($feed instanceof DOMDocument) {
124
+            throw new ClientException(
125
+                // TRANS: Client exception thrown when a feed instance is a DOMDocument.
126
+                _('Expecting a root feed element but got a whole XML document.')
127
+            );
128
+        }
129
+
130
+        $this->entry = $entry;
131
+        $this->feed  = $feed;
132
+
133
+        if ($entry->namespaceURI == Activity::ATOM &&
134
+            $entry->localName == 'entry') {
135
+            $this->_fromAtomEntry($entry, $feed);
136
+        } else if ($entry->namespaceURI == Activity::RSS &&
137
+                   $entry->localName == 'item') {
138
+            $this->_fromRssItem($entry, $feed);
139
+        } else if ($entry->namespaceURI == Activity::SPEC &&
140
+                   $entry->localName == 'object') {
141
+            $this->_fromAtomEntry($entry, $feed);
142
+        } else {
143
+            // Low level exception. No need for i18n.
144
+            throw new Exception("Unknown DOM element: {$entry->namespaceURI} {$entry->localName}");
145
+        }
146
+    }
147
+
148
+    function _fromAtomEntry($entry, $feed)
149
+    {
150
+        $pubEl = $this->_child($entry, self::PUBLISHED, self::ATOM);
151
+
152
+        if (!empty($pubEl)) {
153
+            $this->time = strtotime($pubEl->textContent);
154
+        } else {
155
+            // XXX technically an error; being liberal. Good idea...?
156
+            $updateEl = $this->_child($entry, self::UPDATED, self::ATOM);
157
+            if (!empty($updateEl)) {
158
+                $this->time = strtotime($updateEl->textContent);
159
+            } else {
160
+                $this->time = null;
161
+            }
162
+        }
163
+
164
+        $this->link = ActivityUtils::getPermalink($entry);
165
+
166
+        $verbEl = $this->_child($entry, self::VERB);
167
+
168
+        if (!empty($verbEl)) {
169
+            $this->verb = trim($verbEl->textContent);
170
+        } else {
171
+            $this->verb = ActivityVerb::POST;
172
+            // XXX: do other implied stuff here
173
+        }
174
+
175
+        // get immediate object children
176
+
177
+        $objectEls = ActivityUtils::children($entry, self::OBJECT, self::SPEC);
178
+
179
+        if (count($objectEls) > 0) {
180
+            foreach ($objectEls as $objectEl) {
181
+                // Special case for embedded activities
182
+                $objectType = ActivityUtils::childContent($objectEl, self::OBJECTTYPE, self::SPEC);
183
+                if ((!empty($objectType) && $objectType == ActivityObject::ACTIVITY) || $this->verb == ActivityVerb::SHARE) {
184
+                    $this->objects[] = new Activity($objectEl);
185
+                } else {
186
+                    $this->objects[] = new ActivityObject($objectEl);
187
+                }
188
+            }
189
+        } else {
190
+            // XXX: really?
191
+            $this->objects[] = new ActivityObject($entry);
192
+        }
193
+
194
+        $actorEl = $this->_child($entry, self::ACTOR);
195
+
196
+        if (!empty($actorEl)) {
197
+            // Standalone <activity:actor> elements are a holdover from older
198
+            // versions of ActivityStreams. Newer feeds should have this data
199
+            // integrated straight into <atom:author>.
200
+
201
+            $this->actor = new ActivityObject($actorEl);
202
+
203
+            // Cliqset has bad actor IDs (just nickname of user). We
204
+            // work around it by getting the author data and using its
205
+            // id instead
206
+
207
+            if (!preg_match('/^\w+:/', $this->actor->id)) {
208
+                $authorEl = ActivityUtils::child($entry, 'author');
209
+                if (!empty($authorEl)) {
210
+                    $authorObj = new ActivityObject($authorEl);
211
+                    $this->actor->id = $authorObj->id;
212
+                }
213
+            }
214
+        } else if ($authorEl = $this->_child($entry, self::AUTHOR, self::ATOM)) {
215
+
216
+            // An <atom:author> in the entry overrides any author info on
217
+            // the surrounding feed.
218
+            $this->actor = new ActivityObject($authorEl);
219
+
220
+        } else if (!empty($feed) &&
221
+                   $subjectEl = $this->_child($feed, self::SUBJECT)) {
222
+
223
+            // Feed subject is used for things like groups.
224
+            // Should actually possibly not be interpreted as an actor...?
225
+            $this->actor = new ActivityObject($subjectEl);
226
+
227
+        } else if (!empty($feed) && $authorEl = $this->_child($feed, self::AUTHOR,
228
+                                                              self::ATOM)) {
229
+
230
+            // If there's no <atom:author> on the entry, it's safe to assume
231
+            // the containing feed's authorship info applies.
232
+            $this->actor = new ActivityObject($authorEl);
233
+        }
234
+
235
+        $contextEl = $this->_child($entry, self::CONTEXT);
236
+
237
+        if (!empty($contextEl)) {
238
+            $this->context = new ActivityContext($contextEl);
239
+        } else {
240
+            $this->context = new ActivityContext($entry);
241
+        }
242
+
243
+        $targetEl = $this->_child($entry, self::TARGET);
244
+
245
+        if (!empty($targetEl)) {
246
+            $this->target = new ActivityObject($targetEl);
247
+        } elseif (ActivityUtils::compareVerbs($this->verb, array(ActivityVerb::FAVORITE))) {
248
+            // StatusNet didn't send a 'target' for their Favorite atom entries
249
+            $this->target = clone($this->objects[0]);
250
+        }
251
+
252
+        $this->summary = ActivityUtils::childContent($entry, 'summary');
253
+        $this->id      = ActivityUtils::childContent($entry, 'id');
254
+        $this->content = ActivityUtils::getContent($entry);
255
+
256
+        $catEls = $entry->getElementsByTagNameNS(self::ATOM, 'category');
257
+        if ($catEls) {
258
+            for ($i = 0; $i < $catEls->length; $i++) {
259
+                $catEl = $catEls->item($i);
260
+                $this->categories[] = new AtomCategory($catEl);
261
+            }
262
+        }
263
+
264
+        foreach (ActivityUtils::getLinks($entry, 'enclosure') as $link) {
265
+            $this->enclosures[] = $link->getAttribute('href');
266
+        }
267
+
268
+        // From APP. Might be useful.
269
+
270
+        $this->selfLink = ActivityUtils::getSelfLink($entry);
271
+        $this->editLink = ActivityUtils::getLink($entry, 'edit', 'application/atom+xml');
272
+    }
273
+
274
+    function _fromRssItem($item, $channel)
275
+    {
276
+        $verbEl = $this->_child($item, self::VERB);
277
+
278
+        if (!empty($verbEl)) {
279
+            $this->verb = trim($verbEl->textContent);
280
+        } else {
281
+            $this->verb = ActivityVerb::POST;
282
+            // XXX: do other implied stuff here
283
+        }
284
+
285
+        $pubDateEl = $this->_child($item, self::PUBDATE, self::RSS);
286
+
287
+        if (!empty($pubDateEl)) {
288
+            $this->time = strtotime($pubDateEl->textContent);
289
+        }
290
+
291
+        if ($authorEl = $this->_child($item, self::AUTHOR, self::RSS)) {
292
+            $this->actor = ActivityObject::fromRssAuthor($authorEl);
293
+        } else if ($dcCreatorEl = $this->_child($item, self::CREATOR, self::DC)) {
294
+            $this->actor = ActivityObject::fromDcCreator($dcCreatorEl);
295
+        } else if ($posterousEl = $this->_child($item, ActivityObject::AUTHOR, ActivityObject::POSTEROUS)) {
296
+            // Special case for Posterous.com
297
+            $this->actor = ActivityObject::fromPosterousAuthor($posterousEl);
298
+        } else if (!empty($channel)) {
299
+            $this->actor = ActivityObject::fromRssChannel($channel);
300
+        } else {
301
+            // No actor!
302
+        }
303
+
304
+        $this->title = ActivityUtils::childContent($item, ActivityObject::TITLE, self::RSS);
305
+
306
+        $contentEl = ActivityUtils::child($item, self::ENCODED, self::CONTENTNS);
307
+
308
+        if (!empty($contentEl)) {
309
+            // <content:encoded> XML node's text content is HTML; no further processing needed.
310
+            $this->content = $contentEl->textContent;
311
+        } else {
312
+            $descriptionEl = ActivityUtils::child($item, self::DESCRIPTION, self::RSS);
313
+            if (!empty($descriptionEl)) {
314
+                // Per spec, <description> must be plaintext.
315
+                // In practice, often there's HTML... but these days good
316
+                // feeds are using <content:encoded> which is explicitly
317
+                // real HTML.
318
+                // We'll treat this following spec, and do HTML escaping
319
+                // to convert from plaintext to HTML.
320
+                $this->content = htmlspecialchars($descriptionEl->textContent);
321
+            }
322
+        }
323
+
324
+        $this->link = ActivityUtils::childContent($item, ActivityUtils::LINK, self::RSS);
325
+
326
+        // @fixme enclosures
327
+        // @fixme thumbnails... maybe
328
+
329
+        $guidEl = ActivityUtils::child($item, self::GUID, self::RSS);
330
+
331
+        if (!empty($guidEl)) {
332
+            $this->id = $guidEl->textContent;
333
+
334
+            if ($guidEl->hasAttribute('isPermaLink') && $guidEl->getAttribute('isPermaLink') != 'false') {
335
+                // overwrites <link>
336
+                $this->link = $this->id;
337
+            }
338
+        }
339
+
340
+        $this->objects[] = new ActivityObject($item);
341
+        $this->context   = new ActivityContext($item);
342
+    }
343
+
344
+    /**
345
+     * Returns an Atom <entry> based on this activity
346
+     *
347
+     * @return DOMElement Atom entry
348
+     */
349
+
350
+    function toAtomEntry()
351
+    {
352
+        return null;
353
+    }
354
+
355
+    /**
356
+     * Returns an array based on this activity suitable
357
+     * for encoding as a JSON object
358
+     *
359
+     * @return array $activity
360
+     */
361
+
362
+    function asArray()
363
+    {
364
+        $activity = array();
365
+
366
+        // actor
367
+        $activity['actor'] = $this->actor->asArray();
368
+
369
+        // content
370
+        $activity['content'] = $this->content;
371
+
372
+        // generator
373
+
374
+        if (!empty($this->generator)) {
375
+            $activity['generator'] = $this->generator->asArray();
376
+        }
377
+
378
+        // icon <-- possibly a mini object representing verb?
379
+
380
+        // id
381
+        $activity['id'] = $this->id;
382
+
383
+        // object
384
+
385
+        if (count($this->objects) == 0) {
386
+            common_log(LOG_ERR, "Can't save " . $this->id);
387
+        } else {
388
+            if (count($this->objects) > 1) {
389
+                common_log(LOG_WARNING, "Ignoring " . (count($this->objects) - 1) . " extra objects in JSON output for activity " . $this->id);
390
+            }
391
+            $object = $this->objects[0];
392
+
393
+            if ($object instanceof Activity) {
394
+                // Sharing a post activity is more like sharing the original object
395
+                if (ActivityVerb::canonical($this->verb) == ActivityVerb::canonical(ActivityVerb::SHARE) &&
396
+                    ActivityVerb::canonical($object->verb) == ActivityVerb::canonical(ActivityVerb::POST)) {
397
+                    // XXX: Here's one for the obfuscation record books
398
+                    $object = $object->objects[0];
399
+                }
400
+            }
401
+
402
+            $activity['object'] = $object->asArray();
403
+
404
+            if ($object instanceof Activity) {
405
+                $activity['object']['objectType'] = 'activity';
406
+            }
407
+
408
+            foreach ($this->attachments as $attachment) {
409
+                if (empty($activity['object']['attachments'])) {
410
+                    $activity['object']['attachments'] = array();
411
+                }
412
+                $activity['object']['attachments'][] = $attachment->asArray();
413
+            }
414
+        }
415
+        
416
+        // Context stuff.
417
+
418
+        if (!empty($this->context)) {
419
+
420
+            if (!empty($this->context->location)) {
421
+                $loc = $this->context->location;
422
+
423
+                $activity['location'] = array(
424
+                    'objectType' => 'place',
425
+                    'position' => sprintf("%+02.5F%+03.5F/", $loc->lat, $loc->lon),
426
+                    'lat' => $loc->lat,
427
+                    'lon' => $loc->lon
428
+                );
429
+
430
+                $name = $loc->getName();
431
+
432
+                if ($name) {
433
+                    $activity['location']['displayName'] = $name;
434
+                }
435
+                    
436
+                $url = $loc->getURL();
437
+
438
+                if ($url) {
439
+                    $activity['location']['url'] = $url;
440
+                }
441
+            }
442
+
443
+            $activity['to']      = $this->context->getToArray();
444
+
445
+            $ctxarr = $this->context->asArray();
446
+
447
+            if (array_key_exists('inReplyTo', $ctxarr)) {
448
+                $activity['object']['inReplyTo'] = $ctxarr['inReplyTo'];
449
+                unset($ctxarr['inReplyTo']);
450
+            }
451
+
452
+            if (!array_key_exists('status_net', $activity)) {
453
+                $activity['status_net'] = array();
454
+            }
455
+
456
+            foreach ($ctxarr as $key => $value) {
457
+                $activity['status_net'][$key] = $value;
458
+            }
459
+        }
460
+
461
+        // published
462
+        $activity['published'] = self::iso8601Date($this->time);
463
+
464
+        // provider
465
+        $provider = array(
466
+            'objectType' => 'service',
467
+            'displayName' => common_config('site', 'name'),
468
+            'url' => common_root_url()
469
+        );
470
+
471
+        $activity['provider'] = $provider;
472
+
473
+        // target
474
+        if (!empty($this->target)) {
475
+            $activity['target'] = $this->target->asArray();
476
+        }
477
+
478
+        // title
479
+        $activity['title'] = $this->title;
480
+
481
+        // updated <-- Optional. Should we use this to indicate the time we r
482
+        //             eceived a remote notice? Probably not.
483
+
484
+        // verb
485
+
486
+        $activity['verb'] = ActivityVerb::canonical($this->verb);
487
+
488
+        // url
489
+        if ($this->link) {
490
+            $activity['url'] = $this->link;
491
+        }
492
+
493
+        /* Purely extensions hereafter */
494
+
495
+        if ($activity['verb'] == 'post') {
496
+            $tags = array();
497
+            foreach ($this->categories as $cat) {
498
+                if (mb_strlen($cat->term) > 0) {
499
+                    // Couldn't figure out which object type to use, so...
500
+                    $tags[] = array('objectType' => 'http://activityschema.org/object/hashtag',
501
+                                    'displayName' => $cat->term);
502
+                }
503
+            }
504
+            if (count($tags) > 0) {
505
+                $activity['object']['tags'] = $tags;
506
+            }
507
+        }
508
+
509
+        // XXX: a bit of a hack... Since JSON isn't namespaced we probably
510
+        // shouldn't be using 'statusnet:notice_info', but this will work
511
+        // for the moment.
512
+
513
+        foreach ($this->extra as $e) {
514
+            list($objectName, $props, $txt) = $e;
515
+            if (!empty($objectName)) {
516
+                $parts = explode(":", $objectName);
517
+                if (count($parts) == 2 && $parts[0] == "statusnet") {
518
+                    if (!array_key_exists('status_net', $activity)) {
519
+                        $activity['status_net'] = array();
520
+                    }
521
+                    $activity['status_net'][$parts[1]] = $props;
522
+                } else {
523
+                    $activity[$objectName] = $props;
524
+                }
525
+            }
526
+        }
527
+
528
+        return array_filter($activity);
529
+    }
530
+
531
+    function asString($namespace=false, $author=true, $source=false)
532
+    {
533
+        $xs = new XMLStringer(true);
534
+        $this->outputTo($xs, $namespace, $author, $source);
535
+        return $xs->getString();
536
+    }
537
+
538
+    function outputTo($xs, $namespace=false, $author=true, $source=false, $tag='entry')
539
+    {
540
+        if ($namespace) {
541
+            $attrs = array('xmlns' => 'http://www.w3.org/2005/Atom',
542
+                           'xmlns:thr' => 'http://purl.org/syndication/thread/1.0',
543
+                           'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/',
544
+                           'xmlns:georss' => 'http://www.georss.org/georss',
545
+                           'xmlns:ostatus' => 'http://ostatus.org/schema/1.0',
546
+                           'xmlns:poco' => 'http://portablecontacts.net/spec/1.0',
547
+                           'xmlns:media' => 'http://purl.org/syndication/atommedia',
548
+                           'xmlns:statusnet' => 'http://status.net/schema/api/1/');
549
+        } else {
550
+            $attrs = array();
551
+        }
552
+
553
+        $xs->elementStart($tag, $attrs);
554
+
555
+        if ($tag != 'entry') {
556
+            $xs->element('activity:object-type', null, ActivityObject::ACTIVITY);
557
+        }
558
+
559
+        if ($this->verb == ActivityVerb::POST && count($this->objects) == 1 && $tag == 'entry') {
560
+
561
+            $obj = $this->objects[0];
562
+			$obj->outputTo($xs, null);
563
+
564
+        } else {
565
+            $xs->element('id', null, $this->id);
566
+
567
+            if ($this->title) {
568
+                $xs->element('title', null, $this->title);
569
+            } else {
570
+                // Require element
571
+                $xs->element('title', null, "");
572
+            }
573
+
574
+            $xs->element('content', array('type' => 'html'), $this->content);
575
+
576
+            if (!empty($this->summary)) {
577
+                $xs->element('summary', null, $this->summary);
578
+            }
579
+
580
+            if (!empty($this->link)) {
581
+                $xs->element('link', array('rel' => 'alternate',
582
+                                           'type' => 'text/html',
583
+                                           'href' => $this->link));
584
+            }
585
+
586
+        }
587
+
588
+        $xs->element('activity:verb', null, $this->verb);
589
+
590
+        $published = self::iso8601Date($this->time);
591
+
592
+        $xs->element('published', null, $published);
593
+        $xs->element('updated', null, $published);
594
+
595
+        if ($author) {
596
+            $this->actor->outputTo($xs, 'author');
597
+        }
598
+
599
+        if ($this->verb != ActivityVerb::POST || count($this->objects) != 1 || $tag != 'entry') {
600
+            foreach($this->objects as $object) {
601
+                if ($object instanceof Activity) {
602
+                    $object->outputTo($xs, false, true, true, 'activity:object');
603
+                } else {
604
+                    $object->outputTo($xs, 'activity:object');
605
+                }
606
+            }
607
+        }
608
+
609
+        if (!empty($this->context)) {
610
+
611
+            if (!empty($this->context->replyToID)) {
612
+                if (!empty($this->context->replyToUrl)) {
613
+                    $xs->element('thr:in-reply-to',
614
+                                 array('ref' => $this->context->replyToID,
615
+                                       'href' => $this->context->replyToUrl));
616
+                } else {
617
+                    $xs->element('thr:in-reply-to',
618
+                                 array('ref' => $this->context->replyToID));
619
+                }
620
+            }
621
+
622
+            if (!empty($this->context->replyToUrl)) {
623
+                $xs->element('link', array('rel' => 'related',
624
+                                           'href' => $this->context->replyToUrl));
625
+            }
626
+
627
+            if (!empty($this->context->conversation)) {
628
+                $convattr = [];
629
+                $conv = Conversation::getKV('uri', $this->context->conversation);
630
+                if ($conv instanceof Conversation) {
631
+                    $convattr['href'] = $conv->getUrl();
632
+                    $convattr['local_id'] = $conv->getID();
633
+                    $convattr['ref'] = $conv->getUri();
634
+                    $xs->element('link', array('rel' => 'ostatus:'.ActivityContext::CONVERSATION,
635
+                                                'href' => $convattr['href']));
636
+                } else {
637
+                    $convattr['ref'] = $this->context->conversation;
638
+                }
639
+                $xs->element('ostatus:'.ActivityContext::CONVERSATION,
640
+                                $convattr,
641
+                                $this->context->conversation);
642
+                /* Since we use XMLWriter we just use the previously hardcoded prefix for ostatus,
643
+                    otherwise we should use something like this:
644
+                $xs->elementNS(array(ActivityContext::OSTATUS => 'ostatus'),    // namespace
645
+                                ActivityContext::CONVERSATION,
646
+                                null,   // attributes
647
+                                $this->context->conversation);  // content
648
+                */
649
+            }
650
+
651
+            foreach ($this->context->attention as $attnURI=>$type) {
652
+                $xs->element('link', array('rel' => ActivityContext::MENTIONED,
653
+                                           ActivityContext::OBJECTTYPE => $type,  // FIXME: undocumented 
654
+                                           'href' => $attnURI));
655
+            }
656
+
657
+            if (!empty($this->context->location)) {
658
+                $loc = $this->context->location;
659
+                $xs->element('georss:point', null, $loc->lat . ' ' . $loc->lon);
660
+            }
661
+        }
662
+
663
+        if ($this->target) {
664
+            $this->target->outputTo($xs, 'activity:target');
665
+        }
666
+
667
+        foreach ($this->categories as $cat) {
668
+            $cat->outputTo($xs);
669
+        }
670
+
671
+        // can be either URLs or enclosure objects
672
+
673
+        foreach ($this->enclosures as $enclosure) {
674
+            if (is_string($enclosure)) {
675
+                $xs->element('link', array('rel' => 'enclosure',
676
+                                           'href' => $enclosure));
677
+            } else {
678
+                $attributes = array('rel' => 'enclosure',
679
+                                    'href' => $enclosure->url,
680
+                                    'type' => $enclosure->mimetype,
681
+                                    'length' => $enclosure->size);
682
+                if ($enclosure->title) {
683
+                    $attributes['title'] = $enclosure->title;
684
+                }
685
+                $xs->element('link', $attributes);
686
+            }
687
+        }
688
+
689
+        // Info on the source feed
690
+
691
+        if ($source && !empty($this->source)) {
692
+            $xs->elementStart('source');
693
+
694
+            $xs->element('id', null, $this->source->id);
695
+            $xs->element('title', null, $this->source->title);
696
+
697
+            if (array_key_exists('alternate', $this->source->links)) {
698
+                $xs->element('link', array('rel' => 'alternate',
699
+                                           'type' => 'text/html',
700
+                                           'href' => $this->source->links['alternate']));
701
+            }
702
+
703
+            if (array_key_exists('self', $this->source->links)) {
704
+                $xs->element('link', array('rel' => 'self',
705
+                                           'type' => 'application/atom+xml',
706
+                                           'href' => $this->source->links['self']));
707
+            }
708
+
709
+            if (array_key_exists('license', $this->source->links)) {
710
+                $xs->element('link', array('rel' => 'license',
711
+                                           'href' => $this->source->links['license']));
712
+            }
713
+
714
+            if (!empty($this->source->icon)) {
715
+                $xs->element('icon', null, $this->source->icon);
716
+            }
717
+
718
+            if (!empty($this->source->updated)) {
719
+                $xs->element('updated', null, $this->source->updated);
720
+            }
721
+
722
+            $xs->elementEnd('source');
723
+        }
724
+
725
+        if (!empty($this->selfLink)) {
726
+            $xs->element('link', array('rel' => 'self',
727
+                                       'type' => 'application/atom+xml',
728
+                                       'href' => $this->selfLink));
729
+        }
730
+
731
+        if (!empty($this->editLink)) {
732
+            $xs->element('link', array('rel' => 'edit',
733
+                                       'type' => 'application/atom+xml',
734
+                                       'href' => $this->editLink));
735
+        }
736
+
737
+        // For throwing in extra elements; used for statusnet:notice_info
738
+
739
+        foreach ($this->extra as $el) {
740
+            list($tag, $attrs, $content) = $el;
741
+            $xs->element($tag, $attrs, $content);
742
+        }
743
+
744
+        $xs->elementEnd($tag);
745
+
746
+        return;
747
+    }
748
+
749
+    private function _child($element, $tag, $namespace=self::SPEC)
750
+    {
751
+        return ActivityUtils::child($element, $tag, $namespace);
752
+    }
753
+
754
+    /**
755
+     * For consistency, we'll always output UTC rather than local time.
756
+     * Note that clients *should* accept any timezone we give them as long
757
+     * as it's properly formatted.
758
+     *
759
+     * @param int $tm Unix timestamp
760
+     * @return string
761
+     */
762
+    static function iso8601Date($tm)
763
+    {
764
+        $dateStr = date('d F Y H:i:s', $tm);
765
+        $d = new DateTime($dateStr, new DateTimeZone('UTC'));
766
+        return $d->format('c');
767
+    }
768
+}

+ 221 - 0
lib/activitycontext.php

@@ -0,0 +1,221 @@
1
+<?php
2
+/**
3
+ * StatusNet, the distributed open-source microblogging tool
4
+ *
5
+ * An activity
6
+ *
7
+ * PHP version 5
8
+ *
9
+ * LICENCE: This program is free software: you can redistribute it and/or modify
10
+ * it under the terms of the GNU Affero General Public License as published by
11
+ * the Free Software Foundation, either version 3 of the License, or
12
+ * (at your option) any later version.
13
+ *
14
+ * This program is distributed in the hope that it will be useful,
15
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
16
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17
+ * GNU Affero General Public License for more details.
18
+ *
19
+ * You should have received a copy of the GNU Affero General Public License
20
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
21
+ *
22
+ * @category  Feed
23
+ * @package   StatusNet
24
+ * @author    Evan Prodromou <evan@status.net>
25
+ * @author    Zach Copley <zach@status.net>
26
+ * @copyright 2010 StatusNet, Inc.
27
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
28
+ * @link      http://status.net/
29
+ */
30
+
31
+if (!defined('STATUSNET')) {
32
+    exit(1);
33
+}
34
+
35
+class ActivityContext
36
+{
37
+    public $replyToID;
38
+    public $replyToUrl;
39
+    public $location;
40
+    public $attention = array();    // 'uri' => 'type'
41
+    public $conversation;
42
+    public $conversation_url;
43
+    public $scope;
44
+
45
+    const THR     = 'http://purl.org/syndication/thread/1.0';
46
+    const GEORSS  = 'http://www.georss.org/georss';
47
+    const OSTATUS = 'http://ostatus.org/schema/1.0';
48
+
49
+    const INREPLYTO  = 'in-reply-to';
50
+    const REF        = 'ref';
51
+    const HREF       = 'href';
52
+
53
+    // OStatus element names with prefixes
54
+    const OBJECTTYPE = 'ostatus:object-type';   // FIXME: Undocumented!
55
+    const CONVERSATION = 'conversation';
56
+
57
+    const POINT     = 'point';
58
+
59
+    const MENTIONED    = 'mentioned';
60
+
61
+    const ATTN_PUBLIC  = 'http://activityschema.org/collection/public';
62
+
63
+    function __construct($element = null)
64
+    {
65
+        if (empty($element)) {
66
+            return;
67
+        }
68
+
69
+        $replyToEl = ActivityUtils::child($element, self::INREPLYTO, self::THR);
70
+
71
+        if (!empty($replyToEl)) {
72
+            $this->replyToID  = $replyToEl->getAttribute(self::REF);
73
+            $this->replyToUrl = $replyToEl->getAttribute(self::HREF);
74
+        }
75
+
76
+        $this->location = $this->getLocation($element);
77
+
78
+        foreach ($element->getElementsByTagNameNS(self::OSTATUS, self::CONVERSATION) as $conv) {
79
+            if ($conv->hasAttribute('ref')) {
80
+                $this->conversation = $conv->getAttribute('ref');
81
+                if ($conv->hasAttribute('href')) {
82
+                    $this->conversation_url = $conv->getAttribute('href');
83
+                }
84
+            } else {
85
+                $this->conversation = $conv->textContent;
86
+            }
87
+            if (!empty($this->conversation)) {
88
+                break;
89
+            }
90
+        }
91
+        if (empty($this->conversation)) {
92
+            // fallback to the atom:link rel="ostatus:conversation" element
93
+            $this->conversation = ActivityUtils::getLink($element, 'ostatus:'.self::CONVERSATION);
94
+        }
95
+
96
+        // Multiple attention links allowed
97
+
98
+        $links = $element->getElementsByTagNameNS(ActivityUtils::ATOM, ActivityUtils::LINK);
99
+
100
+        for ($i = 0; $i < $links->length; $i++) {
101
+            $link = $links->item($i);
102
+
103
+            $linkRel  = $link->getAttribute(ActivityUtils::REL);
104
+            $linkHref = $link->getAttribute(self::HREF);
105
+            if ($linkRel == self::MENTIONED && $linkHref !== '') {
106
+                $this->attention[$linkHref] = $link->getAttribute(ActivityContext::OBJECTTYPE);
107
+            }
108
+        }
109
+    }
110
+
111
+    /**
112
+     * Parse location given as a GeoRSS-simple point, if provided.
113
+     * http://www.georss.org/simple
114
+     *
115
+     * @param feed item $entry
116
+     * @return mixed Location or false
117
+     */
118
+    function getLocation($dom)
119
+    {
120
+        $points = $dom->getElementsByTagNameNS(self::GEORSS, self::POINT);
121
+
122
+        for ($i = 0; $i < $points->length; $i++) {
123
+            $point = $points->item($i)->textContent;
124
+            return self::locationFromPoint($point);
125
+        }
126
+
127
+        return null;
128
+    }
129
+
130
+    // XXX: Move to ActivityUtils or Location?
131
+    static function locationFromPoint($point)
132
+    {
133
+        $point = str_replace(',', ' ', $point); // per spec "treat commas as whitespace"
134
+        $point = preg_replace('/\s+/', ' ', $point);
135
+        $point = trim($point);
136
+        $coords = explode(' ', $point);
137
+        if (count($coords) == 2) {
138
+            list($lat, $lon) = $coords;
139
+            if (is_numeric($lat) && is_numeric($lon)) {
140
+                common_log(LOG_INFO, "Looking up location for $lat $lon from georss point");
141
+                return Location::fromLatLon($lat, $lon);
142
+            }
143
+        }
144
+        common_log(LOG_ERR, "Ignoring bogus georss:point value $point");
145
+        return null;
146
+    }
147
+
148
+    /**
149
+     * Returns context (StatusNet stuff) as an array suitable for serializing
150
+     * in JSON. Right now context stuff is an extension to Activity.
151
+     *
152
+     * @return array the context
153
+     */
154
+
155
+    function asArray()
156
+    {
157
+        $context = array();
158
+
159
+        $context['inReplyTo']    = $this->getInReplyToArray();
160
+        $context['conversation'] = $this->conversation;
161
+        $context['conversation_url'] = $this->conversation_url;
162
+
163
+        return array_filter($context);
164
+    }
165
+
166
+    /**
167
+     * Returns an array of arrays representing Activity Objects (intended to be
168
+     * serialized in JSON) that represent WHO the Activity is supposed to
169
+     * be received by. This is not really specified but appears in an example
170
+     * of the current spec as an extension. We might want to figure out a JSON
171
+     * serialization for OStatus and use that to express mentions instead.
172
+     *
173
+     * XXX: People's ideas on how to do this are all over the place
174
+     *
175
+     * @return array the array of recipients
176
+     */
177
+
178
+    function getToArray()
179
+    {
180
+        $tos = array();
181
+
182
+        foreach ($this->attention as $attnUrl => $attnType) {
183
+            $to = array(
184
+                'objectType' => $attnType,  // can be empty
185
+                'id'         => $attnUrl,
186
+            );
187
+            $tos[] = $to;
188
+        }
189
+
190
+        return $tos;
191
+    }
192
+
193
+    /**
194
+     * Return an array for the notices this notice is a reply to 
195
+     * suitable for serializing as JSON note objects.
196
+     *
197
+     * @return array the array of notes
198
+     */
199
+
200
+     function getInReplyToArray()
201
+     {
202
+         if (empty($this->replyToID) && empty($this->replyToUrl)) {
203
+             return null;
204
+         }
205
+
206
+         $replyToObj = array('objectType' => 'note');
207
+
208
+         // XXX: Possibly shorten this to just the numeric ID?
209
+         //      Currently, it's the full URI of the notice.
210
+         if (!empty($this->replyToID)) {
211
+             $replyToObj['id'] = $this->replyToID;
212
+         }
213
+         if (!empty($this->replyToUrl)) {
214
+             $replyToObj['url'] = $this->replyToUrl;
215
+         }
216
+
217
+         return $replyToObj;
218
+     }
219
+
220
+}
221
+

+ 640 - 0
lib/activityhandlerplugin.php

@@ -0,0 +1,640 @@
1
+<?php
2
+/*
3
+ * GNU Social - a federating social network
4
+ * Copyright (C) 2014, Free Software Foundation, Inc.
5
+ *
6
+ * This program is free software: you can redistribute it and/or modify
7
+ * it under the terms of the GNU Affero General Public License as published by
8
+ * the Free Software Foundation, either version 3 of the License, or
9
+ * (at your option) any later version.
10
+ *
11
+ * This program is distributed in the hope that it will be useful,
12
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
+ * GNU Affero General Public License for more details.
15
+ *
16
+ * You should have received a copy of the GNU Affero General Public License
17
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
18
+ */
19
+
20
+if (!defined('GNUSOCIAL')) { exit(1); }
21
+
22
+/**
23
+ * Superclass for plugins which add Activity types and such
24
+ *
25
+ * @category  Activity
26
+ * @package   GNUsocial
27
+ * @author    Mikael Nordfeldth <mmn@hethane.se>
28
+ * @copyright 2014 Free Software Foundation, Inc.
29
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0
30
+ * @link      http://gnu.io/social
31
+ */
32
+abstract class ActivityHandlerPlugin extends Plugin
33
+{
34
+    /** 
35
+     * Returns a key string which represents this activity in HTML classes,
36
+     * ids etc, as when offering selection of what type of post to make. 
37
+     * In MicroAppPlugin, this is paired with the user-visible localizable appTitle(). 
38
+     *
39
+     * @return string (compatible with HTML classes)
40
+     */ 
41
+    abstract function tag();
42
+
43
+    /**
44
+     * Return a list of ActivityStreams object type IRIs
45
+     * which this micro-app handles. Default implementations
46
+     * of the base class will use this list to check if a
47
+     * given ActivityStreams object belongs to us, via
48
+     * $this->isMyNotice() or $this->isMyActivity.
49
+     *
50
+     * An empty list means any type is ok. (Favorite verb etc.)
51
+     *
52
+     * @return array of strings
53
+     */
54
+    abstract function types();
55
+
56
+    /**
57
+     * Return a list of ActivityStreams verb IRIs which
58
+     * this micro-app handles. Default implementations
59
+     * of the base class will use this list to check if a
60
+     * given ActivityStreams verb belongs to us, via
61
+     * $this->isMyNotice() or $this->isMyActivity.
62
+     *
63
+     * All micro-app classes must override this method.
64
+     *
65
+     * @return array of strings
66
+     */
67
+    public function verbs() {
68
+        return array(ActivityVerb::POST);
69
+    }
70
+
71
+    /**
72
+     * Check if a given ActivityStreams activity should be handled by this
73
+     * micro-app plugin.
74
+     *
75
+     * The default implementation checks against the activity type list
76
+     * returned by $this->types(), and requires that exactly one matching
77
+     * object be present. You can override this method to expand
78
+     * your checks or to compare the activity's verb, etc.
79
+     *
80
+     * @param Activity $activity
81
+     * @return boolean
82
+     */
83
+    function isMyActivity(Activity $act) {
84
+        return (count($act->objects) == 1
85
+            && ($act->objects[0] instanceof ActivityObject)
86
+            && $this->isMyVerb($act->verb)
87
+            && $this->isMyType($act->objects[0]->type));
88
+    }
89
+
90
+    /**
91
+     * Check if a given notice object should be handled by this micro-app
92
+     * plugin.
93
+     *
94
+     * The default implementation checks against the activity type list
95
+     * returned by $this->types(). You can override this method to expand
96
+     * your checks, but follow the execution chain to get it right.
97
+     *
98
+     * @param Notice $notice
99
+     * @return boolean
100
+     */
101
+    function isMyNotice(Notice $notice) {
102
+        return $this->isMyVerb($notice->verb) && $this->isMyType($notice->object_type);
103
+    }
104
+
105
+    function isMyVerb($verb) {
106
+        $verb = $verb ?: ActivityVerb::POST;    // post is the default verb
107
+        return ActivityUtils::compareVerbs($verb, $this->verbs());
108
+    }
109
+
110
+    function isMyType($type) {
111
+        // Third argument to compareTypes is true, to allow for notices with empty object_type for example (verb-only)
112
+        return count($this->types())===0 || ActivityUtils::compareTypes($type, $this->types());
113
+    }
114
+
115
+    /**
116
+     * Given a parsed ActivityStreams activity, your plugin
117
+     * gets to figure out how to actually save it into a notice
118
+     * and any additional data structures you require.
119
+     *
120
+     * This function is deprecated and in the future, Notice::saveActivity
121
+     * should be called from onStartHandleFeedEntryWithProfile in this class
122
+     * (which instead turns to saveObjectFromActivity).
123
+     *
124
+     * @param Activity $activity
125
+     * @param Profile $actor
126
+     * @param array $options=array()
127
+     *
128
+     * @return Notice the resulting notice
129
+     */
130
+    public function saveNoticeFromActivity(Activity $activity, Profile $actor, array $options=array())
131
+    {
132
+        // Any plugin which has not implemented saveObjectFromActivity _must_
133
+        // override this function until they are migrated (this function will
134
+        // be deleted when all plugins are migrated to saveObjectFromActivity).
135
+
136
+        if (isset($this->oldSaveNew)) {
137
+            throw new ServerException('A function has been called for new saveActivity functionality, but is still set with an oldSaveNew configuration');
138
+        }
139
+
140
+        return Notice::saveActivity($activity, $actor, $options);
141
+    }
142
+
143
+    /**
144
+    * Given a parsed ActivityStreams activity, your plugin gets
145
+    * to figure out itself how to store the additional data into
146
+    * the database, besides the base data stored by the core.
147
+    *
148
+    * This will handle just about all events where an activity
149
+    * object gets saved, whether it is via AtomPub, OStatus
150
+    * (WebSub and Salmon transports), or ActivityStreams-based
151
+    * backup/restore of account data.
152
+    *
153
+    * You should be able to accept as input the output from an
154
+    * asActivity() call on the stored object. Where applicable,
155
+    * try to use existing ActivityStreams structures and object
156
+    * types, and be liberal in accepting input from what might
157
+    * be other compatible apps.
158
+    *
159
+    * All micro-app classes must override this method.
160
+    *
161
+    * @fixme are there any standard options?
162
+    *
163
+    * @param Activity $activity
164
+    * @param Notice   $stored       The notice in our database for this certain object
165
+    * @param array $options=array()
166
+    *
167
+    * @return object    If the verb handling plugin creates an object, it can be returned here (otherwise true)
168
+    * @throws exception On any error.
169
+    */
170
+    protected function saveObjectFromActivity(Activity $activity, Notice $stored, array $options=array())
171
+    {
172
+        throw new ServerException('This function should be abstract when all plugins have migrated to saveObjectFromActivity');
173
+    }
174
+
175
+    /*
176
+     * This usually gets called from Notice::saveActivity after a Notice object has been created,
177
+     * so it contains a proper id and a uri for the object to be saved.
178
+     */
179
+    public function onStoreActivityObject(Activity $act, Notice $stored, array $options, &$object) {
180
+        // $this->oldSaveNew is there during a migration period of plugins, to start using
181
+        // Notice::saveActivity instead of Notice::saveNew
182
+        if (!$this->isMyActivity($act) || isset($this->oldSaveNew)) {
183
+            return true;
184
+        }
185
+        $object = $this->saveObjectFromActivity($act, $stored, $options);
186
+        return false;
187
+    }
188
+
189
+    /**
190
+     * Given an existing Notice object, your plugin gets to
191
+     * figure out how to arrange it into an ActivityStreams
192
+     * object.
193
+     *
194
+     * This will be how your specialized notice gets output in
195
+     * Atom feeds and JSON-based ActivityStreams output, including
196
+     * account backup/restore and OStatus (WebSub and Salmon transports).
197
+     *
198
+     * You should be able to round-trip data from this format back
199
+     * through $this->saveNoticeFromActivity(). Where applicable, try
200
+     * to use existing ActivityStreams structures and object types,
201
+     * and consider interop with other compatible apps.
202
+     *
203
+     * All micro-app classes must override this method.
204
+     *
205
+     * @fixme this outputs an ActivityObject, not an Activity. Any compat issues?
206
+     *
207
+     * @param Notice $notice
208
+     *
209
+     * @return ActivityObject
210
+     */
211
+    abstract function activityObjectFromNotice(Notice $notice);
212
+
213
+    /**
214
+     * When a notice is deleted, you'll be called here for a chance
215
+     * to clean up any related resources.
216
+     *
217
+     * All micro-app classes must override this method.
218
+     *
219
+     * @param Notice $notice
220
+     */
221
+    abstract function deleteRelated(Notice $notice);
222
+
223
+    protected function notifyMentioned(Notice $stored, array &$mentioned_ids)
224
+    {
225
+        // pass through silently by default
226
+
227
+        // If we want to stop any other plugin from notifying based on this activity, return false instead.
228
+        return true;
229
+    }
230
+
231
+    /**
232
+     * Called when generating Atom XML ActivityStreams output from an
233
+     * ActivityObject belonging to this plugin. Gives the plugin
234
+     * a chance to add custom output.
235
+     *
236
+     * Note that you can only add output of additional XML elements,
237
+     * not change existing stuff here.
238
+     *
239
+     * If output is already handled by the base Activity classes,
240
+     * you can leave this base implementation as a no-op.
241
+     *
242
+     * @param ActivityObject $obj
243
+     * @param XMLOutputter $out to add elements at end of object
244
+     */
245
+    function activityObjectOutputAtom(ActivityObject $obj, XMLOutputter $out)
246
+    {
247
+        // default is a no-op
248
+    }
249
+
250
+    /**
251
+     * Called when generating JSON ActivityStreams output from an
252
+     * ActivityObject belonging to this plugin. Gives the plugin
253
+     * a chance to add custom output.
254
+     *
255
+     * Modify the array contents to your heart's content, and it'll
256
+     * all get serialized out as JSON.
257
+     *
258
+     * If output is already handled by the base Activity classes,
259
+     * you can leave this base implementation as a no-op.
260
+     *
261
+     * @param ActivityObject $obj
262
+     * @param array &$out JSON-targeted array which can be modified
263
+     */
264
+    public function activityObjectOutputJson(ActivityObject $obj, array &$out)
265
+    {
266
+        // default is a no-op
267
+    }
268
+
269
+    /**
270
+     * When a notice is deleted, delete the related objects
271
+     * by calling the overridable $this->deleteRelated().
272
+     *
273
+     * @param Notice $notice Notice being deleted
274
+     *
275
+     * @return boolean hook value
276
+     */
277
+    public function onNoticeDeleteRelated(Notice $notice)
278
+    {
279
+        if ($this->isMyNotice($notice)) {
280
+            try {
281
+                $this->deleteRelated($notice);
282
+            } catch (NoProfileException $e) {
283
+                // we failed because of database lookup failure, Notice has no recognized profile as creator
284
+                // so we skip this. If we want to remove missing notices we should do a SQL constraints check
285
+                // in the affected plugin.
286
+            } catch (AlreadyFulfilledException $e) {
287
+                // Nothing to see here, it's obviously already gone...
288
+            }
289
+        }
290
+
291
+        // Always continue this event in our activity handling plugins.
292
+        return true;
293
+    }
294
+
295
+    /**
296
+     * @param Notice $stored            The notice being distributed
297
+     * @param array  &$mentioned_ids    List of profiles (from $stored->getReplies())
298
+     */
299
+    public function onStartNotifyMentioned(Notice $stored, array &$mentioned_ids)
300
+    {
301
+        if (!$this->isMyNotice($stored)) {
302
+            return true;
303
+        }
304
+
305
+        return $this->notifyMentioned($stored, $mentioned_ids);
306
+    }
307
+
308
+    /**
309
+     * Render a notice as one of our objects
310
+     *
311
+     * @param Notice         $notice  Notice to render
312
+     * @param ActivityObject &$object Empty object to fill
313
+     *
314
+     * @return boolean hook value
315
+     */
316
+    function onStartActivityObjectFromNotice(Notice $notice, &$object)
317
+    {
318
+        if (!$this->isMyNotice($notice)) {
319
+            return true;
320
+        }
321
+
322
+        $object = $this->activityObjectFromNotice($notice);
323
+        return false;
324
+    }
325
+
326
+    /**
327
+     * Handle a posted object from WebSub
328
+     *
329
+     * @param Activity        $activity activity to handle
330
+     * @param Profile         $actor Profile for the feed
331
+     *
332
+     * @return boolean hook value
333
+     */
334
+    function onStartHandleFeedEntryWithProfile(Activity $activity, Profile $profile, &$notice)
335
+    {
336
+        if (!$this->isMyActivity($activity)) {
337
+            return true;
338
+        }
339
+
340
+        // We are guaranteed to get a Profile back from checkAuthorship (or it throws an exception)
341
+        $profile = ActivityUtils::checkAuthorship($activity, $profile);
342
+
343
+        $object = $activity->objects[0];
344
+
345
+        $options = array('uri' => $object->id,
346
+                         'url' => $object->link,
347
+                         'self' => $object->selfLink,
348
+                         'is_local' => Notice::REMOTE,
349
+                         'source' => 'ostatus');
350
+
351
+        if (!isset($this->oldSaveNew)) {
352
+            $notice = Notice::saveActivity($activity, $profile, $options);
353
+        } else {
354
+            $notice = $this->saveNoticeFromActivity($activity, $profile, $options);
355
+        }
356
+
357
+        return false;
358
+    }
359
+
360
+    /**
361
+     * Handle a posted object from Salmon
362
+     *
363
+     * @param Activity $activity activity to handle
364
+     * @param mixed    $target   user or group targeted
365
+     *
366
+     * @return boolean hook value
367
+     */
368
+
369
+    function onStartHandleSalmonTarget(Activity $activity, $target)
370
+    {
371
+        if (!$this->isMyActivity($activity)) {
372
+            return true;
373
+        }
374
+        if (!isset($this->oldSaveNew)) {
375
+            // Handle saveActivity in OStatus class for incoming salmon, remove this event
376
+            // handler when all plugins have gotten rid of "oldSaveNew".
377
+            return true;
378
+        }
379
+
380
+        $this->log(LOG_INFO, get_called_class()." checking {$activity->id} as a valid Salmon slap.");
381
+
382
+        if ($target instanceof User_group || $target->isGroup()) {
383
+            $uri = $target->getUri();
384
+            if (!array_key_exists($uri, $activity->context->attention)) {
385
+                // @todo FIXME: please document (i18n).
386
+                // TRANS: Client exception thrown when ...
387
+                throw new ClientException(_('Object not posted to this group.'));
388
+            }
389
+        } elseif ($target instanceof Profile && $target->isLocal()) {
390
+            $original = null;
391
+            // FIXME: Shouldn't favorites show up with a 'target' activityobject?
392
+            if (!ActivityUtils::compareVerbs($activity->verb, array(ActivityVerb::POST)) && isset($activity->objects[0])) {
393
+                // If this is not a post, it's a verb targeted at something (such as a Favorite attached to a note)
394
+                if (!empty($activity->objects[0]->id)) {
395
+                    $activity->context->replyToID = $activity->objects[0]->id;
396
+                }
397
+            }
398
+            if (!empty($activity->context->replyToID)) {
399
+                $original = Notice::getKV('uri', $activity->context->replyToID);
400
+            }
401
+            if ((!$original instanceof Notice || $original->profile_id != $target->id)
402
+                    && !array_key_exists($target->getUri(), $activity->context->attention)) {
403
+                // @todo FIXME: Please document (i18n).
404
+                // TRANS: Client exception when ...
405
+                throw new ClientException(_('Object not posted to this user.'));
406
+            }
407
+        } else {
408
+            // TRANS: Server exception thrown when a micro app plugin uses a target that cannot be handled.
409
+            throw new ServerException(_('Do not know how to handle this kind of target.'));
410
+        }
411
+
412
+        $oactor = Ostatus_profile::ensureActivityObjectProfile($activity->actor);
413
+        $actor = $oactor->localProfile();
414
+
415
+        // FIXME: will this work in all cases? I made it work for Favorite...
416
+        if (ActivityUtils::compareVerbs($activity->verb, array(ActivityVerb::POST))) {
417
+            $object = $activity->objects[0];
418
+        } else {
419
+            $object = $activity;
420
+        }
421
+
422
+        $options = array('uri' => $object->id,
423
+                         'url' => $object->link,
424
+                         'self' => $object->selfLink,
425
+                         'is_local' => Notice::REMOTE,
426
+                         'source' => 'ostatus');
427
+
428
+        $notice = $this->saveNoticeFromActivity($activity, $actor, $options);
429
+
430
+        return false;
431
+    }
432
+
433
+    /**
434
+     * Handle object posted via AtomPub
435
+     *
436
+     * @param Activity  $activity Activity that was posted
437
+     * @param Profile   $scoped   Profile of user posting
438
+     * @param Notice   &$notice   Resulting notice
439
+     *
440
+     * @return boolean hook value
441
+     */
442
+    public function onStartAtomPubNewActivity(Activity $activity, Profile $scoped, Notice &$notice=null)
443
+    {
444
+        if (!$this->isMyActivity($activity)) {
445
+            return true;
446
+        }
447
+
448
+        $options = array('source' => 'atompub');
449
+
450
+        $notice = $this->saveNoticeFromActivity($activity, $scoped, $options);
451
+
452
+        return false;
453
+    }
454
+
455
+    /**
456
+     * Handle object imported from a backup file
457
+     *
458
+     * @param User           $user     User to import for
459
+     * @param ActivityObject $author   Original author per import file
460
+     * @param Activity       $activity Activity to import
461
+     * @param boolean        $trusted  Is this a trusted user?
462
+     * @param boolean        &$done    Is this done (success or unrecoverable error)
463
+     *
464
+     * @return boolean hook value
465
+     */
466
+    function onStartImportActivity($user, $author, Activity $activity, $trusted, &$done)
467
+    {
468
+        if (!$this->isMyActivity($activity)) {
469
+            return true;
470
+        }
471
+
472
+        $obj = $activity->objects[0];
473
+
474
+        $options = array('uri' => $object->id,
475
+                         'url' => $object->link,
476
+                         'self' => $object->selfLink,
477
+                         'source' => 'restore');
478
+
479
+        // $user->getProfile() is a Profile
480
+        $saved = $this->saveNoticeFromActivity($activity,
481
+                                               $user->getProfile(),
482
+                                               $options);
483
+
484
+        if (!empty($saved)) {
485
+            $done = true;
486
+        }
487
+
488
+        return false;
489
+    }
490
+
491
+    /**
492
+     * Event handler gives the plugin a chance to add custom
493
+     * Atom XML ActivityStreams output from a previously filled-out
494
+     * ActivityObject.
495
+     *
496
+     * The atomOutput method is called if it's one of
497
+     * our matching types.
498
+     *
499
+     * @param ActivityObject $obj
500
+     * @param XMLOutputter $out to add elements at end of object
501
+     * @return boolean hook return value
502
+     */
503
+    function onEndActivityObjectOutputAtom(ActivityObject $obj, XMLOutputter $out)
504
+    {
505
+        if (in_array($obj->type, $this->types())) {
506
+            $this->activityObjectOutputAtom($obj, $out);
507
+        }
508
+        return true;
509
+    }
510
+
511
+    /**
512
+     * Event handler gives the plugin a chance to add custom
513
+     * JSON ActivityStreams output from a previously filled-out
514
+     * ActivityObject.
515
+     *
516
+     * The activityObjectOutputJson method is called if it's one of
517
+     * our matching types.
518
+     *
519
+     * @param ActivityObject $obj
520
+     * @param array &$out JSON-targeted array which can be modified
521
+     * @return boolean hook return value
522
+     */
523
+    function onEndActivityObjectOutputJson(ActivityObject $obj, array &$out)
524
+    {
525
+        if (in_array($obj->type, $this->types())) {
526
+            $this->activityObjectOutputJson($obj, $out);
527
+        }
528
+        return true;
529
+    }
530
+
531
+    public function onStartOpenNoticeListItemElement(NoticeListItem $nli)
532
+    {   
533
+        if (!$this->isMyNotice($nli->notice)) {
534
+            return true;
535
+        }
536
+
537
+        $this->openNoticeListItemElement($nli);
538
+
539
+        Event::handle('EndOpenNoticeListItemElement', array($nli));
540
+        return false;
541
+    }
542
+
543
+    public function onStartCloseNoticeListItemElement(NoticeListItem $nli)
544
+    {   
545
+        if (!$this->isMyNotice($nli->notice)) {
546
+            return true;
547
+        }
548
+
549
+        $this->closeNoticeListItemElement($nli);
550
+
551
+        Event::handle('EndCloseNoticeListItemElement', array($nli));
552
+        return false;
553
+    }
554
+
555
+    protected function openNoticeListItemElement(NoticeListItem $nli)
556
+    {
557
+        $id = (empty($nli->repeat)) ? $nli->notice->id : $nli->repeat->id;
558
+        $class = 'h-entry notice ' . $this->tag();
559
+        if ($nli->notice->scope != 0 && $nli->notice->scope != 1) {
560
+            $class .= ' limited-scope';
561
+        }
562
+        try {
563
+            $class .= ' notice-source-'.common_to_alphanumeric($nli->notice->source);
564
+        } catch (Exception $e) {
565
+            // either source or what we filtered out was a zero-length string
566
+        }
567
+        $nli->out->elementStart('li', array('class' => $class,
568
+                                            'id' => 'notice-' . $id));
569
+    }
570
+
571
+    protected function closeNoticeListItemElement(NoticeListItem $nli)
572
+    {
573
+        $nli->out->elementEnd('li');
574
+    }
575
+
576
+
577
+    // FIXME: This is overriden in MicroAppPlugin but shouldn't have to be
578
+    public function onStartShowNoticeItem(NoticeListItem $nli)
579
+    {   
580
+        if (!$this->isMyNotice($nli->notice)) {
581
+            return true;
582
+        }
583
+
584
+        try {
585
+            $this->showNoticeListItem($nli);
586
+        } catch (Exception $e) {
587
+            common_log(LOG_ERR, 'Error showing notice '.$nli->getNotice()->getID().': ' . $e->getMessage());
588
+            $nli->out->element('p', 'error', sprintf(_('Error showing notice: %s'), $e->getMessage()));
589
+        }
590
+
591
+        Event::handle('EndShowNoticeItem', array($nli));
592
+        return false;
593
+    }
594
+
595
+    protected function showNoticeListItem(NoticeListItem $nli)
596
+    {
597
+        $nli->showNoticeHeaders();
598
+        $nli->showContent();
599
+        $nli->showNoticeFooter();
600
+    }
601
+
602
+    public function onStartShowNoticeItemNotice(NoticeListItem $nli)
603
+    {
604
+        if (!$this->isMyNotice($nli->notice)) {
605
+            return true;
606
+        }
607
+
608
+        $this->showNoticeItemNotice($nli);
609
+
610
+        Event::handle('EndShowNoticeItemNotice', array($nli));
611
+        return false;
612
+    }
613
+
614
+    protected function showNoticeItemNotice(NoticeListItem $nli)
615
+    {
616
+        $nli->showNoticeTitle();
617
+        $nli->showAuthor();
618
+        $nli->showAddressees();
619
+        $nli->showContent();
620
+    }
621
+
622
+    public function onStartShowNoticeContent(Notice $stored, HTMLOutputter $out, Profile $scoped=null)
623
+    {
624
+        if (!$this->isMyNotice($stored)) {
625
+            return true;
626
+        }
627
+
628
+        try {
629
+            $this->showNoticeContent($stored, $out, $scoped);
630
+        } catch (Exception $e) {
631
+            $out->element('div', 'error', $e->getMessage());
632
+        }
633
+        return false;
634
+    }
635
+
636
+    protected function showNoticeContent(Notice $stored, HTMLOutputter $out, Profile $scoped=null)
637
+    {
638
+        $out->text($stored->getContent());
639
+    }
640
+}

+ 962 - 0
lib/activityobject.php

@@ -0,0 +1,962 @@
1
+<?php
2
+/**
3
+ * StatusNet, the distributed open-source microblogging tool
4
+ *
5
+ * An activity
6
+ *
7
+ * PHP version 5
8
+ *
9
+ * LICENCE: This program is free software: you can redistribute it and/or modify
10
+ * it under the terms of the GNU Affero General Public License as published by
11
+ * the Free Software Foundation, either version 3 of the License, or
12
+ * (at your option) any later version.
13
+ *
14
+ * This program is distributed in the hope that it will be useful,
15
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
16
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17
+ * GNU Affero General Public License for more details.
18
+ *
19
+ * You should have received a copy of the GNU Affero General Public License
20
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
21
+ *
22
+ * @category  Feed
23
+ * @package   StatusNet
24
+ * @author    Evan Prodromou <evan@status.net>
25
+ * @author    Zach Copley <zach@status.net>
26
+ * @copyright 2010 StatusNet, Inc.
27
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
28
+ * @link      http://status.net/
29
+ */
30
+
31
+if (!defined('GNUSOCIAL')) { exit(1); }
32
+
33
+require_once(INSTALLDIR.'/lib/activitystreamjsondocument.php');
34
+
35
+/**
36
+ * A noun-ish thing in the activity universe
37
+ *
38
+ * The activity streams spec talks about activity objects, while also having
39
+ * a tag activity:object, which is in fact an activity object. Aaaaaah!
40
+ *
41
+ * This is just a thing in the activity universe. Can be the subject, object,
42
+ * or indirect object (target!) of an activity verb. Rotten name, and I'm
43
+ * propagating it. *sigh*
44
+ *
45
+ * @category  OStatus
46
+ * @package   StatusNet
47
+ * @author    Evan Prodromou <evan@status.net>
48
+ * @copyright 2010 StatusNet, Inc.
49
+ * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
50
+ * @link      http://status.net/
51
+ */
52
+class ActivityObject
53
+{
54
+    const ARTICLE   = 'http://activitystrea.ms/schema/1.0/article';
55
+    const BLOGENTRY = 'http://activitystrea.ms/schema/1.0/blog-entry';
56
+    const NOTE      = 'http://activitystrea.ms/schema/1.0/note';
57
+    const STATUS    = 'http://activitystrea.ms/schema/1.0/status';
58
+    const FILE      = 'http://activitystrea.ms/schema/1.0/file';
59
+    const PHOTO     = 'http://activitystrea.ms/schema/1.0/photo';
60
+    const ALBUM     = 'http://activitystrea.ms/schema/1.0/photo-album';
61
+    const PLAYLIST  = 'http://activitystrea.ms/schema/1.0/playlist';
62
+    const VIDEO     = 'http://activitystrea.ms/schema/1.0/video';
63
+    const AUDIO     = 'http://activitystrea.ms/schema/1.0/audio';
64
+    const BOOKMARK  = 'http://activitystrea.ms/schema/1.0/bookmark';
65
+    const PERSON    = 'http://activitystrea.ms/schema/1.0/person';
66
+    const GROUP     = 'http://activitystrea.ms/schema/1.0/group';
67
+    const _LIST     = 'http://activitystrea.ms/schema/1.0/list'; // LIST is reserved
68
+    const PLACE     = 'http://activitystrea.ms/schema/1.0/place';
69
+    const COMMENT   = 'http://activitystrea.ms/schema/1.0/comment';
70
+    // ^^^^^^^^^^ tea!
71
+    const ACTIVITY = 'http://activitystrea.ms/schema/1.0/activity';
72
+    const SERVICE   = 'http://activitystrea.ms/schema/1.0/service';
73
+    const IMAGE     = 'http://activitystrea.ms/schema/1.0/image';
74
+    const COLLECTION = 'http://activitystrea.ms/schema/1.0/collection';
75
+    const APPLICATION = 'http://activitystrea.ms/schema/1.0/application';
76
+
77
+    // Atom elements we snarf
78
+
79
+    const TITLE   = 'title';
80
+    const SUMMARY = 'summary';
81
+    const ID      = 'id';
82
+    const SOURCE  = 'source';
83
+
84
+    const NAME  = 'name';
85
+    const URI   = 'uri';
86
+    const EMAIL = 'email';
87
+
88
+    const POSTEROUS   = 'http://posterous.com/help/rss/1.0';
89
+    const AUTHOR      = 'author';
90
+    const USERIMAGE   = 'userImage';
91
+    const PROFILEURL  = 'profileUrl';
92
+    const NICKNAME    = 'nickName';
93
+    const DISPLAYNAME = 'displayName';
94
+
95
+    public $element;
96
+    public $type;
97
+    public $id;
98
+    public $title;
99
+    public $summary;
100
+    public $content;
101
+    public $owner;
102
+    public $link;
103
+    public $selfLink;   // think APP (Atom Publishing Protocol)
104
+    public $source;
105
+    public $avatarLinks = array();
106
+    public $geopoint;
107
+    public $poco;
108
+    public $displayName;
109
+
110
+    // @todo move this stuff to it's own PHOTO activity object
111
+    const MEDIA_DESCRIPTION = 'description';
112
+
113
+    public $thumbnail;
114
+    public $largerImage;
115
+    public $description;
116
+    public $extra = array();
117
+
118
+    public $stream;
119
+
120
+    /**
121
+     * Constructor
122
+     *
123
+     * This probably needs to be refactored
124
+     * to generate a local class (ActivityPerson, ActivityFile, ...)
125
+     * based on the object type.
126
+     *
127
+     * @param DOMElement $element DOM thing to turn into an Activity thing
128
+     */
129
+    function __construct($element = null)
130
+    {
131
+        if (empty($element)) {
132
+            return;
133
+        }
134
+
135
+        $this->element = $element;
136
+
137
+        $this->geopoint = $this->_childContent(
138
+            $element,
139
+            ActivityContext::POINT,
140
+            ActivityContext::GEORSS
141
+        );
142
+
143
+        if ($element->tagName == 'author') {
144
+            $this->_fromAuthor($element);
145
+        } else if ($element->tagName == 'item') {
146
+            $this->_fromRssItem($element);
147
+        } else {
148
+            $this->_fromAtomEntry($element);
149
+        }
150
+
151
+        // Some per-type attributes...
152
+        if ($this->type == self::PERSON || $this->type == self::GROUP) {
153
+            $this->displayName = $this->title;
154
+
155
+            $photos = ActivityUtils::getLinks($element, 'photo');
156
+            if (count($photos)) {
157
+                foreach ($photos as $link) {
158
+                    $this->avatarLinks[] = new AvatarLink($link);
159
+                }
160
+            } else {
161
+                $avatars = ActivityUtils::getLinks($element, 'avatar');
162
+                foreach ($avatars as $link) {
163
+                    $this->avatarLinks[] = new AvatarLink($link);
164
+                }
165
+            }
166
+
167
+            $this->poco = new PoCo($element);
168
+        }
169
+
170
+        if ($this->type == self::PHOTO) {
171
+
172
+            $this->thumbnail   = ActivityUtils::getLink($element, 'preview');
173
+            $this->largerImage = ActivityUtils::getLink($element, 'enclosure');
174
+
175
+            $this->description = ActivityUtils::childContent(
176
+                $element,
177
+                ActivityObject::MEDIA_DESCRIPTION,
178
+                Activity::MEDIA
179
+            );
180
+        }
181
+        if ($this->type == self::_LIST) {
182
+            $owner = ActivityUtils::child($this->element, Activity::AUTHOR, Activity::SPEC);
183
+            $this->owner = new ActivityObject($owner);
184
+        }
185
+    }
186
+
187
+    private function _fromAuthor($element)
188
+    {
189
+        $this->type = $this->_childContent($element,
190
+                                           Activity::OBJECTTYPE,
191
+                                           Activity::SPEC);
192
+
193
+        if (empty($this->type)) {
194
+            $this->type = self::PERSON; // XXX: is this fair?
195
+        }
196
+
197
+
198
+        // Start with <poco::displayName>
199
+
200
+        $this->title = ActivityUtils::childContent($element, PoCo::DISPLAYNAME, PoCo::NS);
201
+
202
+        // try falling back to <atom:title>
203
+
204
+        if (empty($this->title)) {
205
+            $title = ActivityUtils::childHtmlContent($element, self::TITLE);
206
+
207
+            if (!empty($title)) {
208
+                $this->title = common_strip_html($title);
209
+            }
210
+        }
211
+
212
+        // fall back to <atom:name> as a last resort
213
+
214
+        if (empty($this->title)) {
215
+            $this->title = $this->_childContent($element, self::NAME);
216
+        }
217
+
218
+        // start with <atom:id>
219
+
220
+        $this->id = $this->_childContent($element, self::ID);
221
+
222
+        // fall back to <atom:uri>
223
+
224
+        if (empty($this->id)) {
225
+            $this->id = $this->_childContent($element, self::URI);
226
+        }
227
+
228
+        // fall further back to <atom:email>
229
+
230
+        if (empty($this->id)) {
231
+            $email = $this->_childContent($element, self::EMAIL);
232
+            if (!empty($email)) {
233
+                // XXX: acct: ?
234
+                $this->id = 'mailto:'.$email;
235
+            }
236
+        }
237
+
238
+        $this->link = ActivityUtils::getPermalink($element);
239
+
240
+        // fall finally back to <link rel=alternate>
241
+
242
+        if (empty($this->id) && !empty($this->link)) { // fallback if there's no ID
243
+            $this->id = $this->link;
244
+        }
245
+    }
246
+
247
+    private function _fromAtomEntry($element)
248
+    {
249
+        $this->type = $this->_childContent($element, Activity::OBJECTTYPE,
250
+                                           Activity::SPEC);
251
+
252
+        if (empty($this->type)) {
253
+            $this->type = ActivityObject::NOTE;
254
+        }
255
+
256
+        $this->summary = ActivityUtils::childHtmlContent($element, self::SUMMARY);
257
+        $this->content = ActivityUtils::getContent($element);
258
+
259
+        // We don't like HTML in our titles, although it's technically allowed
260
+        $this->title = common_strip_html(ActivityUtils::childHtmlContent($element, self::TITLE));
261
+
262
+        $this->source  = $this->_getSource($element);
263
+
264
+        $this->link = ActivityUtils::getPermalink($element);
265
+        $this->selfLink = ActivityUtils::getSelfLink($element);
266
+
267
+        $this->id = $this->_childContent($element, self::ID);
268
+
269
+        if (empty($this->id) && !empty($this->link)) { // fallback if there's no ID
270
+            $this->id = $this->link;
271
+        }
272
+
273
+        $els = $element->childNodes;
274
+        $out = array();
275
+
276
+        for ($i = 0; $i < $els->length; $i++) {
277
+            $link = $els->item($i);
278
+            if ($link->localName == ActivityUtils::LINK && $link->namespaceURI == ActivityUtils::ATOM) {
279
+                $attrs = array();
280
+                foreach ($link->attributes as $attrName=>$attrNode) {
281
+                    $attrs[$attrName] = $attrNode->nodeValue;
282
+                }
283
+                $this->extra[] = [$link->localName,
284
+                                    $attrs,
285
+                                    $link->nodeValue];
286
+            }
287
+        }
288
+    }
289
+
290
+    // @todo FIXME: rationalize with Activity::_fromRssItem()
291
+    private function _fromRssItem($item)
292
+    {
293
+        if (empty($this->type)) {
294
+            $this->type = ActivityObject::NOTE;
295
+        }
296
+
297
+        $this->title = ActivityUtils::childContent($item, ActivityObject::TITLE, Activity::RSS);
298
+
299
+        $contentEl = ActivityUtils::child($item, ActivityUtils::CONTENT, Activity::CONTENTNS);
300
+
301
+        if (!empty($contentEl)) {
302
+            $this->content = htmlspecialchars_decode($contentEl->textContent, ENT_QUOTES);
303
+        } else {
304
+            $descriptionEl = ActivityUtils::child($item, Activity::DESCRIPTION, Activity::RSS);
305
+            if (!empty($descriptionEl)) {
306
+                $this->content = htmlspecialchars_decode($descriptionEl->textContent, ENT_QUOTES);
307
+            }
308
+        }
309
+
310
+        $this->link = ActivityUtils::childContent($item, ActivityUtils::LINK, Activity::RSS);
311
+
312
+        $guidEl = ActivityUtils::child($item, Activity::GUID, Activity::RSS);
313
+
314
+        if (!empty($guidEl)) {
315
+            $this->id = $guidEl->textContent;
316
+
317
+            if ($guidEl->hasAttribute('isPermaLink') && $guidEl->getAttribute('isPermaLink') != 'false') {
318
+                // overwrites <link>
319
+                $this->link = $this->id;
320
+            }
321
+        }
322
+    }
323
+
324
+    public static function fromRssAuthor($el)
325
+    {
326
+        $text = $el->textContent;
327
+
328
+        if (preg_match('/^(.*?) \((.*)\)$/', $text, $match)) {