├── Dockerfile ├── LICENSE ├── README.md ├── cli.php ├── config.example.php ├── index.php ├── src ├── class │ └── curl.php ├── controller.php ├── function.php └── worker.php └── tools └── wxwclub.sql /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:7.3.32-cli-alpine 2 | 3 | RUN apk add --no-cache --virtual .build-deps \ 4 | autoconf \ 5 | g++ \ 6 | libtool \ 7 | make \ 8 | pcre-dev \ 9 | && apk add --no-cache\ 10 | freetype-dev \ 11 | tzdata \ 12 | unzip \ 13 | git \ 14 | libintl \ 15 | icu \ 16 | icu-dev \ 17 | libxml2-dev \ 18 | && docker-php-ext-configure opcache --enable-opcache \ 19 | && docker-php-ext-install pdo_mysql opcache pcntl \ 20 | && apk del .build-deps 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 wxw.moe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wxwClub 2 | 3 | A simple social groups compatible with ActivityPub. 4 | 5 | > 项目仍在开发阶段,不建议用在生产环境 ... 6 | 7 | ## 特性 8 | 9 | ### 已实现 10 | 11 | - 兼容 WebFinger 查找 12 | - 兼容 Mastodon 安全模式 13 | - 简单兼容 ActivityPub 协议 14 | - 响应 关注 / 取消关注 请求 15 | - 转发收到的 公开 / 不公开 消息 16 | - 收到旧消息 Tombstone 时撤销转发 17 | - 收到跨站用户 Delete 时清理关注关系 18 | - 单个群组 Actor 支持自定义修改 19 | - 个人资料页 头像、横幅、昵称 20 | - 中文简介、英文简介、简介模板 21 | - Push 任务队列,自动重试 22 | - Shared Inbox、Outbox 实现 23 | - 跨站消息 HTTP Signature 校验 24 | - 兼容 Mastodon、Misskey、Pleroma 25 | 26 | ### 待实现 27 | - 私信修改 Actor 信息 28 | - RsaSignature2017 生成 29 | 30 | ## 使用 31 | 32 | ### 环境要求 33 | - MySQL 数据库 34 | - PHP 版本 >= 7.0 35 | - 依赖 PHP 扩展:curl, json, pcntl, pdo_mysql 36 | 37 | ### 安装步骤 38 | 1. 编辑 `config.php` 参数 39 | 2. 导入 `tools/wxwclub.sql` 数据表 40 | 3. 重写请求至 `index.php`,例如 Nginx: 41 | ``` 42 | location / { 43 | try_files $uri $uri/ /index.php$is_args$args; 44 | } 45 | ``` 46 | 4. 运行 `wxwClub worker`,推荐 Docker: 47 | ``` 48 | 1. cd wxwClub/ 49 | 2. docker build -t 'wxwclub:worker' . 50 | 3. docker run -d --restart always -v $(pwd):/wxwClub \ 51 | --name wxwclub_worker wxwclub:worker php /wxwClub/cli.php worker 52 | ``` 53 | 5. (可选)运行多个 `wxwClub worker` 执行队列 54 | 55 | ## 版权声明 56 | 57 | > (> ʌ <) 都看到这了,点个 Star 吧 ~ 58 | 59 | 参考项目 60 | [wordpress-activitypub / MIT][1] 61 | [php-curl-class / Unlicense License][2] 62 | 63 | MIT © FGHRSH 64 | 65 | [1]: https://github.com/pfefferle/wordpress-activitypub "ActivityPub for WordPress" 66 | [2]: https://github.com/php-curl-class/php-curl-class "php-curl-class" 67 | -------------------------------------------------------------------------------- /cli.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | true]); 29 | if (isset($argv[1])) switch ($argv[1]) { 30 | case 'worker': echo date('[Y-m-d H:i:s]').' Start running worker ...',"\n"; while (!$stop) worker(); echo date('[Y-m-d H:i:s]').' Worker stopped',"\n"; break; 31 | default: echo 'Unknown parameters',"\n"; break; 32 | } 33 | } catch (PDOException $e) { 34 | exit('Error: '.$e->getMessage()."\n"); 35 | } -------------------------------------------------------------------------------- /config.example.php: -------------------------------------------------------------------------------- 1 | 'example.com', 4 | // 数据库信息 5 | 'mysql' => [ 6 | // 数据库地址 7 | 'host' => 'mysql', 8 | // 数据库名称 9 | 'database' => 'localhost', 10 | // 数据库用户 11 | 'username' => 'root', 12 | // 数据库密码 13 | 'password' => '' 14 | ], 15 | // 默认模板 16 | 'default' => [ 17 | // 头像外链 18 | 'avatar' => 'https://fp1.fghrsh.net/2021/11/03/1568571d1ed0bfaef26acdf6d5664826.png', 19 | // 横幅外链 20 | 'banner' => 'https://fp1.fghrsh.net/2021/10/25/86dbef8672928e061a5ce1e5722e8056.png', 21 | 22 | /**************************** 23 | * 预 设 标 签 * 24 | * ------------------------ * 25 | * :club_name: => 群组名 * 26 | * :local_domain: => 主域名 * 27 | ****************************/ 28 | 29 | // 简介模板 30 | 'summary' => '

这是一个关于 :infoname_cn: 的群组,关注以获取群组推送,引用可以分享到群组。

I\'m a group about :infoname_en:. Follow me to get all the group posts. Tag me to share with the group.

创建新群组可以 搜索 或 引用 @新群组名@:local_domain:。

Create other groups by searching for or tagging @yourGroupName@:local_domain:

', 31 | // 默认昵称 32 | 'nickname' => ':club_name: 组', 33 | // 自定标签 34 | 'infoname' => [':infoname_cn:' => ':club_name:', ':infoname_en:' => ':club_name:'] 35 | ], 36 | // 实例名称 37 | 'nodeName' => 'example.com', 38 | // 实例时区 39 | 'nodeTimezone' => 'Asia/Shanghai', 40 | // 调试模式 41 | 'nodeDebugging' => 0, // 0: 禁用日志,1: 记录所有,2: 只记错误 42 | // 安全模式 43 | 'nodeInboxVerify' => false, 44 | // 管理信息 45 | 'nodeMaintainer' => ['name' => '@admin', 'email' => 'support@example.com'], 46 | // 实例描述 47 | 'nodeDescription' => 'A simple social groups compatible with ActivityPub.', 48 | // 禁用的群组名称 49 | 'nodeSuspendedName' => ['yourgroupname'], 50 | // 开放新群组注册 51 | 'openRegistrations' => true 52 | ]; -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | true]); 23 | controller(); 24 | } catch (PDOException $e) { 25 | http_response_code(500); 26 | exit('Error: '.$e->getMessage()); 27 | } -------------------------------------------------------------------------------- /src/class/curl.php: -------------------------------------------------------------------------------- 1 | curl = curl_init(); 48 | $this->initialize($base_url); 49 | } 50 | 51 | public function call() { 52 | $args = func_get_args(); 53 | $function = array_shift($args); 54 | if (is_callable($function)) { 55 | array_unshift($args, $this); 56 | call_user_func_array($function, $args); 57 | } 58 | } 59 | 60 | public function get($url) { 61 | $this->setUrl($url); 62 | $this->setOpt(CURLOPT_CUSTOMREQUEST, 'GET'); 63 | $this->setOpt(CURLOPT_HTTPGET, true); 64 | return $this->exec(); 65 | } 66 | 67 | public function post($url, $data = '') { 68 | $this->setUrl($url); 69 | $this->setOpt(CURLOPT_POST, true); 70 | $this->setOpt(CURLOPT_POSTFIELDS, $data); 71 | $this->setOpt(CURLOPT_CUSTOMREQUEST, 'POST'); 72 | return $this->exec(); 73 | } 74 | 75 | public function exec($ch = null) { 76 | $this->attempts += 1; 77 | 78 | if ($ch === null) { 79 | $this->responseCookies = []; 80 | $this->call($this->beforeSendCallback); 81 | $this->rawResponse = curl_exec($this->curl); 82 | $this->curlErrorCode = curl_errno($this->curl); 83 | $this->curlErrorMessage = curl_error($this->curl); 84 | } else { 85 | $this->rawResponse = curl_multi_getcontent($ch); 86 | $this->curlErrorMessage = curl_error($ch); 87 | } $this->curlError = $this->curlErrorCode !== 0; 88 | 89 | $this->rawResponseHeaders = $this->headerCallbackData->rawResponseHeaders; 90 | $this->responseCookies = $this->headerCallbackData->responseCookies; 91 | $this->headerCallbackData->rawResponseHeaders = ''; 92 | $this->headerCallbackData->responseCookies = []; 93 | 94 | if ($this->curlError && function_exists('curl_strerror')) { 95 | $this->curlErrorMessage = 96 | curl_strerror($this->curlErrorCode) . ( 97 | empty($this->curlErrorMessage) ? '' : ': ' . $this->curlErrorMessage 98 | ); 99 | } 100 | 101 | $this->httpStatusCode = $this->getInfo(CURLINFO_HTTP_CODE); 102 | $this->httpError = in_array((int) floor($this->httpStatusCode / 100), [4, 5], true); 103 | $this->error = $this->curlError || $this->httpError; 104 | $this->errorCode = $this->error ? ($this->curlError ? $this->curlErrorCode : $this->httpStatusCode) : 0; 105 | 106 | if ($this->getOpt(CURLINFO_HEADER_OUT) === true) 107 | $this->requestHeaders = $this->parseRequestHeaders($this->getInfo(CURLINFO_HEADER_OUT)); 108 | $this->responseHeaders = $this->parseResponseHeaders($this->rawResponseHeaders); 109 | $this->response = $this->rawResponse; 110 | 111 | if ($this->error) { 112 | if (isset($this->responseHeaders['Status-Line'])) { 113 | $this->httpErrorMessage = $this->responseHeaders['Status-Line']; 114 | } 115 | } $this->errorMessage = $this->curlError ? $this->curlErrorMessage : $this->httpErrorMessage; 116 | 117 | unset($this->effectiveUrl); 118 | unset($this->totalTime); 119 | 120 | if ($this->attemptRetry()) return $this->exec($ch); 121 | 122 | $this->execDone(); 123 | return $this->response; 124 | } 125 | 126 | public function execDone() { 127 | if ($this->error) 128 | $this->call($this->errorCallback); 129 | else $this->call($this->successCallback); 130 | $this->call($this->completeCallback); 131 | } 132 | 133 | public function close() { 134 | if (is_resource($this->curl) || $this->curl instanceof \CurlHandle) 135 | curl_close($this->curl); 136 | $this->curl = null; 137 | $this->options = null; 138 | } 139 | 140 | public function getOpt($option) { return isset($this->options[$option]) ? $this->options[$option] : null; } 141 | 142 | public function getInfo($opt = null) { 143 | $args[] = $this->curl; 144 | if (func_num_args()) $args[] = $opt; 145 | return call_user_func_array('curl_getinfo', $args); 146 | } 147 | 148 | public function setUrl($url) { 149 | $this->url = $url; 150 | $this->setOpt(CURLOPT_URL, $this->url); 151 | } 152 | 153 | public function setOpt($option, $value) { 154 | $required_options = [CURLOPT_RETURNTRANSFER => 'CURLOPT_RETURNTRANSFER']; 155 | if (in_array($option, array_keys($required_options), true) && $value !== true) 156 | trigger_error($required_options[$option] . ' is a required option', E_USER_WARNING); 157 | if ($success = curl_setopt($this->curl, $option, $value)) 158 | $this->options[$option] = $value; 159 | return $success; 160 | } 161 | 162 | public function setHeader($key, $value) { 163 | $this->headers[$key] = $value; 164 | foreach ($this->headers as $key => $value) 165 | $headers[] = $key . ': ' . $value; 166 | $this->setOpt(CURLOPT_HTTPHEADER, $headers); 167 | } 168 | 169 | public function setTimeout($seconds) { $this->setOpt(CURLOPT_TIMEOUT, $seconds); } 170 | public function setUserAgent($user_agent) { $this->setOpt(CURLOPT_USERAGENT, $user_agent); } 171 | public function setConnectTimeout($seconds) { $this->setOpt(CURLOPT_CONNECTTIMEOUT, $seconds); } 172 | public function setMaximumRedirects($maximum_redirects) { $this->setOpt(CURLOPT_MAXREDIRS, $maximum_redirects); } 173 | 174 | public function attemptRetry() { 175 | $attempt_retry = false; 176 | if ($this->error) { 177 | $attempt_retry = ($this->retryDecider === null) ? 178 | $this->remainingRetries >= 1 : call_user_func($this->retryDecider, $this); 179 | if ($attempt_retry) { 180 | $this->retries += 1; 181 | if ($this->remainingRetries) 182 | $this->remainingRetries -= 1; 183 | } 184 | } return $attempt_retry; 185 | } 186 | 187 | public function unsetHeader($key) { 188 | unset($this->headers[$key]); $headers = []; 189 | foreach ($this->headers as $key => $value) { 190 | $headers[] = $key . ': ' . $value; 191 | } $this->setOpt(CURLOPT_HTTPHEADER, $headers); 192 | } 193 | 194 | private function initialize() { 195 | $this->headers = []; 196 | $this->id = uniqid('', true); 197 | $header_callback_data = new \stdClass(); 198 | $header_callback_data->rawResponseHeaders = ''; 199 | $header_callback_data->responseCookies = []; 200 | $this->headerCallbackData = $header_callback_data; 201 | $this->setOpt(CURLINFO_HEADER_OUT, true); 202 | $this->setOpt(CURLOPT_RETURNTRANSFER, true); 203 | $this->setOpt(CURLOPT_HEADERFUNCTION, $this->createHeaderCallback($header_callback_data)); 204 | } 205 | 206 | private function parseHeaders($raw_headers) { 207 | $http_headers = []; 208 | $raw_headers = preg_split('/\r\n/', (string)$raw_headers, -1, PREG_SPLIT_NO_EMPTY); 209 | $raw_headers_count = count($raw_headers); 210 | for ($i = 1; $i < $raw_headers_count; $i++) { 211 | if (strpos($raw_headers[$i], ':') !== false) { 212 | list($key, $value) = explode(':', $raw_headers[$i], 2); 213 | $key = trim($key); $value = trim($value); 214 | // Use isset() as array_key_exists() and ArrayAccess are not compatible. 215 | if (isset($http_headers[$key])) 216 | $http_headers[$key] .= ',' . $value; 217 | else $http_headers[$key] = $value; 218 | } 219 | } return [isset($raw_headers['0']) ? $raw_headers['0'] : '', $http_headers]; 220 | } 221 | 222 | private function parseRequestHeaders($raw_headers) { 223 | $first_line = $headers = $request_headers = []; 224 | list($first_line, $headers) = $this->parseHeaders($raw_headers); 225 | $request_headers['Request-Line'] = $first_line; 226 | foreach ($headers as $key => $value) 227 | $request_headers[$key] = $value; 228 | return $request_headers; 229 | } 230 | 231 | private function parseResponseHeaders($raw_response_headers) { 232 | $response_header = ''; 233 | $first_line = $headers = $response_headers = []; 234 | $response_header_array = explode("\r\n\r\n", $raw_response_headers); 235 | for ($i = count($response_header_array) - 1; $i >= 0; $i--) { 236 | if (stripos($response_header_array[$i], 'HTTP/') === 0) { 237 | $response_header = $response_header_array[$i]; 238 | break; 239 | } 240 | } 241 | list($first_line, $headers) = $this->parseHeaders($response_header); 242 | $response_headers['Status-Line'] = $first_line; 243 | foreach ($headers as $key => $value) 244 | $response_headers[$key] = $value; 245 | return $response_headers; 246 | } 247 | 248 | private function createHeaderCallback($header_callback_data) { 249 | return function ($ch, $header) use ($header_callback_data) { 250 | if (preg_match('/^Set-Cookie:\s*([^=]+)=([^;]+)/mi', $header, $cookie) === 1) 251 | $header_callback_data->responseCookies[$cookie[1]] = trim($cookie[2], " \n\r\t\0\x0B"); 252 | $header_callback_data->rawResponseHeaders .= $header; 253 | return strlen($header); 254 | }; 255 | } 256 | } -------------------------------------------------------------------------------- /src/controller.php: -------------------------------------------------------------------------------- 1 | ['to' => 'index', 'strict' => 1], 8 | '/club' => ['to' => 'club', 'strict' => 0], 9 | '/inbox' => ['to' => 'inbox', 'strict' => 1], 10 | '/nodeinfo/2.0' => ['to' => 'nodeinfo2', 'strict' => 1], 11 | '/.well-known/nodeinfo' => ['to' => 'nodeinfo', 'strict' => 1], 12 | '/.well-known/webfinger' => ['to' => 'webfinger', 'strict' => 1] 13 | ]; 14 | 15 | $to = ''; $uri = explode('?', $_SERVER['REQUEST_URI'])[0]; 16 | foreach ($router as $k => $v) 17 | if ($k == ($v['strict'] ? $uri : substr($uri, 0, strlen($k)))) $to = $v['to']; 18 | 19 | switch ($to) { 20 | 21 | case 'club': 22 | if ($club = Club_Exist(($uri = explode('/', $uri))[2])) { 23 | $club_url = $base.'/club/'.$club; 24 | if (isset($uri[3])) switch ($uri[3]) { 25 | case 'inbox': 26 | if ($_SERVER['REQUEST_METHOD'] == 'POST') { 27 | $jsonld = json_decode($input = file_get_contents('php://input'), 1); 28 | if (isset($jsonld['actor']) && parse_url($jsonld['actor'])['host'] != $config['base']) { 29 | 30 | if ($jsonld['type'] == 'Delete' && $jsonld['actor'] == $jsonld['object']) { 31 | if (ActivityPub_Verification($input, false)) { 32 | $pdo = $db->prepare('delete from `users` where `actor` = :actor'); 33 | $pdo->execute([':actor' => $jsonld['actor']]); 34 | } break; 35 | } else $verify = ActivityPub_Verification($input); 36 | if ($config['nodeDebugging']) { 37 | $file_name = date('Y-m-d_H:i:s_').$club.'_'.$jsonld['type']; 38 | file_put_contents(APP_ROOT.'/logs/inbox/'.$file_name.'_input.json', $input); 39 | if ($config['nodeDebugging'] == 1) 40 | file_put_contents(APP_ROOT.'/logs/inbox/'.$file_name.'_server.json', Club_Json_Encode($_SERVER)); 41 | if (!$verify) file_put_contents(APP_ROOT.'/logs/inbox/'.$file_name.'_verify_failed.txt', $_SERVER['HTTP_SIGNATURE']); 42 | } 43 | if ($config['nodeInboxVerify'] && !$verify) break; 44 | 45 | switch ($jsonld['type']) { 46 | case 'Create': Club_Announce_Process($jsonld); break; 47 | case 'Follow': Club_Follow_Process($jsonld); break; 48 | case 'Undo': Club_Undo_Process($jsonld); break; 49 | case 'Delete': 50 | if (isset($jsonld['object']['type'])) switch ($jsonld['object']['type']) { 51 | case 'Tombstone': Club_Tombstone_Process($jsonld); break; 52 | default: break; 53 | } else { 54 | $jsonld['object'] = ['id' => $jsonld['object']]; 55 | Club_Tombstone_Process($jsonld); 56 | } break; 57 | default: break; 58 | } 59 | } else Club_Json_Output(['message' => 'Request is invalid'], 0, 400); 60 | } else header('Content-type: application/activity+json'); break; 61 | 62 | case 'outbox': 63 | if (isset($_GET['page'])) { 64 | $arr = [ 65 | '@context' => 'https://www.w3.org/ns/activitystreams', 66 | 'id' => $club_url.'/outbox?page='.($page = (int)$_GET['page']), 67 | 'type' => 'OrderedCollectionPage', 68 | 'next' => $club_url.'/outbox?page=', 69 | 'prev' => $club_url.'/outbox?page=', 70 | 'partOf' => $club_url.'/outbox', 71 | 'orderedItems' => [] 72 | ]; 73 | if ($page < 0) { 74 | $order = ''; 75 | $arr['next'] .= $page - 1; 76 | $arr['prev'] .= ($page == -1 ? $page - 1 : $page) + 1; 77 | $page = abs($page); 78 | } else { 79 | $order = ' desc'; 80 | if ($page == 0) $page = 1; 81 | $arr['next'] .= $page + 1; 82 | $arr['prev'] .= $page - 1; 83 | } 84 | $pdo = $db->prepare('select u.actor, a.activity, b.object, b.timestamp from `announces` `a`'. 85 | ' left join `clubs` `c` on a.cid = c.cid left join `users` `u` on a.uid = u.uid left join `activities` `b` on a.activity = b.id'. 86 | ' where c.name = :club order by b.timestamp'.$order.' limit '.(($page-1)*20).', 20'); 87 | $pdo->execute([':club' => $club]); 88 | foreach ($pdo->fetchAll(PDO::FETCH_ASSOC) as $announce) { 89 | $arr['orderedItems'][] = [ 90 | '@context' => 'https://www.w3.org/ns/activitystreams', 91 | 'id' => $club_url.'/activity#'.$announce['activity'].'/announce', 92 | 'type' => 'Announce', 93 | 'actor' => $club_url, 94 | 'published' => gmdate('Y-m-d\TH:i:s\Z', $announce['timestamp']), 95 | 'to' => [$club_url.'/followers'], 96 | 'cc' => [$announce['actor'], $public_streams], 97 | 'object' => $announce['object'] 98 | ]; 99 | } Club_Json_Output($arr, 2); 100 | } else { 101 | $pdo = $db->prepare('select count(a.id) from `announces` `a` left join `clubs` `c` on a.cid = c.cid where c.name = :club'); 102 | $pdo->execute([':club' => $club]); 103 | $count = (int)$pdo->fetch(PDO::FETCH_COLUMN, 0); 104 | Club_Get_OrderedCollection($club_url.'/outbox', [ 105 | 'totalItems' => $count, 106 | 'first' => $club_url.'/outbox?page=1', 107 | 'last' => $club_url.'/outbox?page=-1', 108 | ]); 109 | } break; 110 | 111 | case 'following': Club_Get_OrderedCollection($club_url.'/following'); break; 112 | case 'followers': 113 | $pdo = $db->prepare('select count(f.id) from `followers` `f` left join `clubs` `c` on f.cid = c.cid where c.name = :club'); 114 | $pdo->execute([':club' => $club]); 115 | $count = (int)$pdo->fetch(PDO::FETCH_COLUMN, 0); 116 | Club_Get_OrderedCollection($club_url.'/followers', [ 117 | 'totalItems' => $count, 118 | ]); break; 119 | case 'collections': 120 | if (isset($uri[4])) switch ($uri[4]) { 121 | case 'featured': Club_Get_OrderedCollection($club_url.'/collections/featured'); break; 122 | case 'tags': Club_Get_OrderedCollection($club_url.'/collections/tags', ['type' => 'Collection']); break; 123 | case 'devices': Club_Get_OrderedCollection($club_url.'/collections/devices', ['type' => 'Collection']); break; 124 | default: break; 125 | } break; 126 | default: Club_Json_Output(['message' => 'Error: Route Not Found!'], 0, 404); break; 127 | } else { 128 | $pdo = $db->prepare('select `cid`,`nickname`,`infoname`,`summary`,`avatar`,`banner`,`public_key`,`timestamp` from `clubs` where `name` = :club'); 129 | $pdo->execute([':club' => $club]); 130 | $pdo = $pdo->fetch(PDO::FETCH_ASSOC); 131 | $nametag = array_merge($config['default']['infoname'], json_decode($pdo['infoname'], 1) ?: []); 132 | $summary = $pdo['summary'] ?: Club_NameTag_Render($club, $config['default']['summary'], $nametag); 133 | $nickname = $pdo['nickname'] ?: Club_NameTag_Render($club, $config['default']['nickname'], $nametag); 134 | if (isset($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'json')) { 135 | Club_Json_Output([ 136 | '@context' => [ 137 | 'https://www.w3.org/ns/activitystreams', 138 | 'https://w3id.org/security/v1', 139 | [ 140 | 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers', 141 | 'toot' => 'http://joinmastodon.org/ns#', 142 | 'featured' => ['@id' => 'toot:featured', '@type' => '@id'], 143 | 'featuredTags' => ['@id' => 'toot:featuredTags', '@type' => '@id'], 144 | 'alsoKnownAs' => ['@id' => 'as:alsoKnownAs', '@type' => '@id'], 145 | 'movedTo' => ['@id' => 'as:movedTo', '@type' => '@id'], 146 | 'schema' => 'http://schema.org#', 147 | 'PropertyValue' => 'schema:PropertyValue', 148 | 'value' => 'schema:value', 149 | 'IdentityProof' => 'toot:IdentityProof', 150 | 'discoverable' => 'toot:discoverable', 151 | 'Device' => 'toot:Device', 152 | 'Ed25519Signature' => 'toot:Ed25519Signature', 153 | 'Ed25519Key' => 'toot:Ed25519Key', 154 | 'Curve25519Key' => 'toot:Curve25519Key', 155 | 'EncryptedMessage' => 'toot:EncryptedMessage', 156 | 'publicKeyBase64' => 'toot:publicKeyBase64', 157 | 'deviceId' => 'toot:deviceId', 158 | 'claim' => ['@type' => '@id', '@id' => 'toot:claim'], 159 | 'fingerprintKey' => ['@type' => '@id', '@id' => 'toot:fingerprintKey'], 160 | 'identityKey' => ['@type' => '@id', '@id' => 'toot:identityKey'], 161 | 'devices' => ['@type' => '@id', '@id' => 'toot:devices'], 162 | 'messageFranking' => 'toot:messageFranking', 163 | 'messageType' => 'toot:messageType', 164 | 'cipherText' => 'toot:cipherText', 165 | 'suspended' => 'toot:suspended', 166 | 'Emoji' => 'toot:Emoji', 167 | 'focalPoint' => ['@container' => '@list', '@id' => 'toot:focalPoint'] 168 | ] 169 | ], 170 | 'id' => $club_url, 171 | 'type' => 'Group', 172 | 'following' => $club_url.'/following', 173 | 'followers' => $club_url.'/followers', 174 | 'inbox' => $club_url.'/inbox', 175 | 'outbox' => $club_url.'/outbox', 176 | 'featured' => $club_url.'/collections/featured', 177 | 'featuredTags' => $club_url.'/collections/tags', 178 | 'preferredUsername' => $club, 179 | 'name' => $nickname, 180 | 'summary' => $summary, 181 | 'url' => $club_url, 182 | 'manuallyApprovesFollowers' => false, 183 | 'discoverable' => false, 184 | 'published' => gmdate('Y-m-d\TH:i:s\Z', $pdo['timestamp']), 185 | 'devices' => $club_url.'/collections/devices', 186 | 'publicKey' => [ 187 | 'id' => $club_url.'#main-key', 188 | 'owner' => $club_url, 189 | 'publicKeyPem' => $pdo['public_key'] 190 | ], 191 | 'tag' => [], 192 | 'attachment' => [], 193 | 'endpoints' => ['sharedInbox' => $base.'/inbox'], 194 | 'icon' => [ 195 | 'type' => 'Image', 196 | 'url' => $pdo['avatar'] ?: $config['default']['avatar'] 197 | ], 198 | 'image' => [ 199 | 'type' => 'Image', 200 | 'url' => $pdo['banner'] ?: $config['default']['banner'] 201 | ] 202 | ], 2); 203 | } else { 204 | echo '',$nickname,' (@',$club,'@',$config['base'],')', 205 | '', 206 | '', 207 | '', 208 | '', 209 | '', 210 | '', 211 | '', 212 | '', 213 | '', 214 | '', 215 | '', 216 | '', 217 | '', 218 | '', 220 | '


', 221 | '

',$nickname,' (@',$club,'@',$config['base'],')

', 222 | '
',$summary,'


', 223 | '

近期活动:

'; 224 | $page = (int)($_GET['page'] ?? 1); 225 | $activities = $db->prepare('select u.name, act.object, a.summary, a.content, a.timestamp from `announces` as `a` left join `users` as `u` on a.uid = u.uid '. 226 | 'left join `activities` as `act` on a.activity = act.id where a.cid = :cid order by a.timestamp desc limit '.(($page - 1) * 20).', 20'); 227 | $activities->execute([':cid' => $pdo['cid']]); 228 | if ($activities = $activities->fetchAll(PDO::FETCH_ASSOC)) 229 | foreach ($activities as $activity) 230 | echo $activity['summary'] ? '
['.date('Y-m-d H:i:s', $activity['timestamp']).'] '.$activity['name'].': [CW] '.$activity['summary']. 231 | '

'.$activity['content'].'

': 232 | '

['.date('Y-m-d H:i:s', $activity['timestamp']).'] '. 233 | ''.$activity['name'].': '.$activity['content'].'

'; 234 | else echo '

群组还没有活动,快来发送一条吧 ~

'; 235 | echo '

',($page > 1 ? '上一页' : '上一页'),' | ' 236 | ,(count($activities) == 20 ? '下一页' : '下一页'),'

'; 237 | echo '
'; 238 | } 239 | } 240 | } else Club_Json_Output(['message' => 'User not found'], 0, 404); break; 241 | 242 | case 'inbox': 243 | if ($_SERVER['REQUEST_METHOD'] == 'POST') { 244 | $jsonld = json_decode($input = file_get_contents('php://input'), 1); 245 | if (isset($jsonld['actor']) && parse_url($jsonld['actor'])['host'] != $config['base']) { 246 | 247 | if ($jsonld['type'] == 'Delete' && $jsonld['actor'] == $jsonld['object']) { 248 | if (ActivityPub_Verification($input, false)) { 249 | $pdo = $db->prepare('delete from `users` where `actor` = :actor'); 250 | $pdo->execute([':actor' => $jsonld['actor']]); 251 | } break; 252 | } else $verify = ActivityPub_Verification($input); 253 | if ($config['nodeDebugging']) { 254 | $file_name = date('Y-m-d_H:i:s').'_shared_inbox_'.$jsonld['type']; 255 | file_put_contents(APP_ROOT.'/logs/inbox/'.$file_name.'_input.json', $input); 256 | if ($config['nodeDebugging'] == 1) file_put_contents(APP_ROOT.'/logs/inbox/'.$file_name.'_server.json', Club_Json_Encode($_SERVER)); 257 | if (!$verify) file_put_contents(APP_ROOT.'/logs/inbox/'.$file_name.'_verify_failed.txt', $_SERVER['HTTP_SIGNATURE']); 258 | } 259 | if ($config['nodeInboxVerify'] && !$verify) break; 260 | 261 | switch ($jsonld['type']) { 262 | case 'Create': Club_Announce_Process($jsonld); break; 263 | case 'Delete': 264 | if (isset($jsonld['object']['type'])) switch ($jsonld['object']['type']) { 265 | case 'Tombstone': Club_Tombstone_Process($jsonld); break; 266 | default: break; 267 | } else { 268 | $jsonld['object'] = ['id' => $jsonld['object']]; 269 | Club_Tombstone_Process($jsonld); 270 | } break; 271 | case 'Follow': Club_Follow_Process($jsonld); break; 272 | case 'Undo': Club_Undo_Process($jsonld); break; 273 | default: break; 274 | } 275 | } 276 | } else header('Content-type: application/activity+json'); break; 277 | 278 | case 'nodeinfo': 279 | Club_Json_Output(['links' => [['rel' => 'http://nodeinfo.diaspora.software/ns/schema/2.0', 'href' => $base.'/nodeinfo/2.0']]]); break; 280 | 281 | case 'nodeinfo2': 282 | $pdo = $db->prepare('select (select count(cid) from clubs) as clubs, (select count(id) from announces) as announces, (select count(distinct cid) from announces where timestamp >= :month) as activeMonth, (select count(distinct cid) from announces where timestamp >= :halfyear) as activeHalfyear'); 283 | $pdo->execute([':month' => time()-86400*30, ':halfyear' => time()-86400*30*6]); 284 | $usage = $pdo->fetch(PDO::FETCH_ASSOC); 285 | Club_Json_Output([ 286 | 'version' => '2.0', 287 | 'software' => ['name' => 'wxwClub', 'version' => $ver], 288 | 'protocols' => ['activitypub'], 289 | 'services' => ['inbound' => [], 'outbound' => []], 290 | 'openRegistrations' => $config['openRegistrations'], 291 | 'usage' => [ 292 | 'users' => [ 293 | 'total' => $usage['clubs'] ?? null, 294 | 'activeMonth' => $usage['activeMonth'] ?? null, 295 | 'activeHalfyear' => $usage['activeHalfyear'] ?? null 296 | ], 297 | 'localPosts' => $usage['announces'] ?? 0 298 | ], 299 | 'metadata' => [ 300 | 'nodeName' => $config['nodeName'], 301 | 'nodeDescription' => $config['nodeDescription'], 302 | 'maintainer' => $config['nodeMaintainer'], 303 | 'repositoryUrl' => 'https://github.com/wxwmoe/wxwClub', 304 | 'feedbackUrl' => 'https://github.com/wxwmoe/wxwClub/issues/new' 305 | ] 306 | ]); break; 307 | 308 | case 'webfinger': 309 | $resource = $_GET['resource']; 310 | if ($config['nodeDebugging'] == 1) { 311 | $file_name = date('Y-m-d_H:i:s').'_'.str_replace(['/', ' ', '\\'], ['Ⳇ', '_', 'Ⳇ'], $resource); 312 | file_put_contents(APP_ROOT.'/logs/webfinger/'.$file_name.'.json', Club_Json_Encode($_SERVER)); 313 | } 314 | if (preg_match('/^acct:([^@]+)@(.+)$/', $resource, $matches)) { 315 | $resource_identifier = $matches[1]; 316 | if (($resource_host = $matches[2]) != $config['base']) { 317 | Club_Json_Output(['message' => 'Resource host does not match'], 0, 404); 318 | break; 319 | } 320 | } elseif (preg_match('/^acct:([a-zA-Z_][a-zA-Z0-9_]+)$/', $resource, $matches)) { 321 | $resource_host = $config['base']; 322 | $resource_identifier = $matches[1]; 323 | } else { 324 | Club_Json_Output(['message' => 'Resource is invalid'], 0, 400); 325 | break; 326 | } 327 | 328 | if ($club = Club_Exist($resource_identifier)) { 329 | $club_url = $base.'/club/'.$club; 330 | Club_Json_Output([ 331 | 'subject' => 'acct:'.$club.'@'.$config['base'], 332 | 'links' => [ 333 | [ 334 | 'rel' => 'http://webfinger.net/rel/profile-page', 335 | 'type' => 'text/html', 336 | 'href' => $club_url 337 | ], 338 | [ 339 | 'rel' => 'self', 340 | 'type' => 'application/activity+json', 341 | 'href' => $club_url 342 | ] 343 | ]]); 344 | } else Club_Json_Output(['message' => 'User not found'], 0, 404); break; 345 | 346 | case 'index': 347 | echo ''.$config['nodeName'].''; 348 | echo ''; 349 | echo '

'.$config['nodeName'].' (wxwClub/'.$ver.')

'.$config['nodeDescription'].'

'; 350 | echo '


热门群组

'; 351 | $pdo = $db->prepare('select name, nickname from (select c.name, c.nickname, (@id:=@id+1) as `id` from `announces` as `a` '. 352 | 'left join `clubs` as `c` on a.cid = c.cid, (select @id:=0) as `i` order by a.timestamp desc) as `h` group by name limit 20'); 353 | $pdo->execute(); 354 | foreach ($pdo->fetchAll(PDO::FETCH_ASSOC) as $club) 355 | echo '

'.($club['nickname'] ?: $club['name']).' (@'.$club['name'].'@'.$config['base'].')

'; 356 | $maintainer = explode('@', $config['nodeMaintainer']['name']); 357 | $maintainer = ''.$config['nodeMaintainer']['name'].''; 358 | echo '

Maintainer: '.$maintainer.' (mail: '.$config['nodeMaintainer']['email'].')

'; break; 359 | 360 | default: Club_Json_Output(['message' => 'Error: Route Not Found!'], 0, 404); break; 361 | } 362 | } 363 | -------------------------------------------------------------------------------- /src/function.php: -------------------------------------------------------------------------------- 1 | ActivityPub_Signature($url, $club, $date) 7 | ]); 8 | } 9 | 10 | function ActivityPub_POST($url, $club, $jsonld) { 11 | $date = gmdate('D, d M Y H:i:s T'); 12 | $digest = base64_encode(hash('sha256', $jsonld, 1)); 13 | return ActivityPub_CURL($url, $date, [ 14 | 'Signature' => ActivityPub_Signature($url, $club, $date, $digest), 15 | 'Digest' => 'SHA-256='.$digest 16 | ], $jsonld); 17 | } 18 | 19 | function ActivityPub_CURL($url, $date, $head, $data = null) { 20 | global $ver, $base, $curl, $config; 21 | if (!isset($curl)) $curl = new Curl(); 22 | $curl->setTimeout(10); 23 | $curl->setConnectTimeout(3); 24 | $curl->setMaximumRedirects(3); 25 | $curl->setUserAgent('wxwClub '.$ver.'; '.$base); 26 | $curl->setHeader('Accept', 'application/activity+json'); 27 | $curl->setHeader('Content-Type', 'application/activity+json'); 28 | $curl->setHeader('Date', $date); 29 | foreach ($head as $k => $v) $curl->setHeader($k, $v); 30 | if (isset($data)) $curl->post($url, $data); else $curl->get($url); 31 | if ($config['nodeDebugging'] == 1) { 32 | $info = substr($curl->responseHeaders['Status-Line'], -1) == ' ' ? '' : ' '; 33 | $info = str_replace(['https://', '/', ' ', '\\'], ['', 'Ⳇ', '_', 'Ⳇ'], strtolower($curl->responseHeaders['Status-Line']).$info.$url); 34 | $file_name = date('Y-m-d_H:i:s_').(isset($data)?'post':'get').'_'.$info; 35 | file_put_contents(APP_ROOT.'/logs/curl/'.$file_name.'.json', Club_Json_Encode([ 36 | 'header' => $curl->responseHeaders, 'result' => $curl->response, 'error' => $curl->error 37 | ])); 38 | } return $curl->error ? false : ($curl->response ?: true); 39 | } 40 | 41 | function ActivityPub_Signature($url, $club, $date, $digest = null) { 42 | global $db, $base; $host = ($url_parts = parse_url($url))['host']; $path = '/'; 43 | 44 | if (!empty($url_parts['path'])) $path = $url_parts['path']; 45 | if (!empty($url_parts['query'])) $path .= '?' . $url_parts['query']; 46 | 47 | $signed_string = "(request-target): ".(empty($digest)?'get':'post')." $path\nhost: $host\ndate: $date".(empty($digest)?'':"\ndigest: SHA-256=$digest"); 48 | $pdo = $db->prepare('select `private_key` from `clubs` where `name` = :name'); 49 | $pdo->execute([':name' => $club]); 50 | if ($pdo = $pdo->fetch(PDO::FETCH_ASSOC)) { 51 | openssl_sign($signed_string, $signature, $pdo['private_key'], OPENSSL_ALGO_SHA256); 52 | return 'keyId="'.$base.'/club/'.$club.'#main-key'.'",algorithm="rsa-sha256",headers="(request-target) host date'.(empty($digest)?'':' digest').'",signature="'.base64_encode($signature).'"'; 53 | } return false; 54 | } 55 | 56 | function ActivityPub_Verification($input = null, $pull = true) { 57 | global $db; if (isset($_SERVER['HTTP_SIGNATURE'])) { 58 | preg_match_all('/[,\s]*(.*?)="(.*?)"/', $_SERVER['HTTP_SIGNATURE'], $matches); 59 | foreach ($matches[1] as $k => $v) $signature[$v] = $matches[2][$k]; 60 | if (($headers = explode(' ', $signature['headers']))[0] == '(request-target)') { 61 | $actor = str_replace(['#main-key', '/main-key'], '', $signature['keyId']); 62 | $pdo = $db->prepare('select `public_key` from `users` where `actor` = :actor'); 63 | $pdo->execute([':actor' => $actor]); 64 | if ($public_key = $pdo->fetch(PDO::FETCH_COLUMN, 0)) { 65 | $signed_string = '(request-target): '.strtolower($_SERVER['REQUEST_METHOD']).' '.$_SERVER['REQUEST_URI']; 66 | foreach (array_slice($headers, 1) as $header) $signed_string .= "\n".$header.': '.$_SERVER['HTTP_'.strtoupper(str_replace('-','_',$header))]; 67 | if (openssl_verify($signed_string, base64_decode($signature['signature']), $public_key, str_replace('hs2019', 'rsa-sha256', $signature['algorithm']))) { 68 | if (isset($_SERVER['HTTP_DIGEST'])) { 69 | preg_match('/^(.*?)=(.*?)$/', $_SERVER['HTTP_DIGEST'], $matches); 70 | return (hash(str_replace('-','',$matches[1]), $input, 1) == base64_decode($matches[2])); 71 | } return true; 72 | } 73 | } elseif ($pull) { 74 | $pdo = $db->query('select `name` from `clubs` limit 1'); 75 | $club = $pdo->fetch(PDO::FETCH_COLUMN, 0); 76 | if (Club_Get_Actor($club, $actor)) 77 | return ActivityPub_Verification($input, false); 78 | } 79 | } 80 | } return false; 81 | } 82 | 83 | function Club_Exist($club) { 84 | global $db, $config; 85 | if (strlen($club) <= 30 && preg_match('/^[a-zA-Z_][a-zA-Z0-9_]+$/u', $club)) { 86 | $pdo = $db->prepare('select `name` from `clubs` where `name` = :name'); $pdo->execute([':name' => $club]); 87 | return ($pdo = $pdo->fetch(PDO::FETCH_COLUMN, 0)) ? $pdo : ($config['openRegistrations'] ? Club_Create($club) : false); 88 | } return false; 89 | } 90 | 91 | function Club_Create($club) { 92 | global $db, $config; 93 | if (!in_array(strtolower($club), $config['nodeSuspendedName'])) { 94 | $key = openssl_pkey_new([ 95 | 'digest_alg' => 'sha512', 96 | 'private_key_bits' => 2048, 97 | 'private_key_type' => OPENSSL_KEYTYPE_RSA 98 | ]); openssl_pkey_export($key, $priv_key); 99 | $detail = openssl_pkey_get_details($key); 100 | $pdo = $db->prepare('insert into `clubs`(`name`,`public_key`,`private_key`,`timestamp`) values(:name, :public, :private, :timestamp)'); 101 | return $pdo->execute([':name' => $club, ':public' => $detail['key'], ':private' => $priv_key, ':timestamp' => time()]) ? $club : false; 102 | } return false; 103 | } 104 | 105 | function Club_Get_Actor($club, $actor) { 106 | global $db; $pdo = $db->prepare('select `uid`,`name`,`inbox` from `users` where `actor` = :actor'); 107 | $pdo->execute([':actor' => $actor]); 108 | if ($pdo = $pdo->fetch(PDO::FETCH_ASSOC)) { 109 | $uid = $pdo['uid']; 110 | $name = $pdo['name']; 111 | $inbox = $pdo['inbox']; 112 | } else { 113 | $jsonld = json_decode(ActivityPub_GET($actor, $club), 1); 114 | if ($jsonld['id'] == $actor) { 115 | $inbox = $jsonld['inbox']; 116 | $shared_inbox = $jsonld['endpoints']['sharedInbox'] ?: $jsonld['inbox']; 117 | $name = $jsonld['preferredUsername'].'@'.parse_url($jsonld['id'], PHP_URL_HOST); 118 | $pdo = $db->prepare('insert into `users`(`name`,`actor`,`inbox`,`public_key`,`shared_inbox`,`timestamp`) values (:name, :actor, :inbox, :public_key, :shared_inbox, :timestamp)'); 119 | $pdo->execute([ 120 | ':name' => $name, ':actor' => $jsonld['id'], ':inbox' => $jsonld['inbox'], ':timestamp' => time(), 121 | ':public_key' => $jsonld['publicKey']['publicKeyPem'], ':shared_inbox' => $shared_inbox 122 | ]); 123 | $pdo = $db->query('select last_insert_id()'); 124 | $uid = $pdo->fetch(PDO::FETCH_COLUMN, 0); 125 | } else return false; 126 | } return ['uid' => $uid, 'name' => $name, 'inbox' => $inbox]; 127 | } 128 | 129 | function Club_Task_Create($type, $club, $jsonld) { 130 | global $db; 131 | $pdo = $db->prepare('insert into `tasks`(`cid`,`type`,`jsonld`,`timestamp`) select `cid`, :type as `type`, :jsonld as `jsonld`, :timestamp as `timestamp` from `clubs` where `name` = :club'); 132 | $pdo->execute([':type' => $type, ':club' => $club, ':jsonld' => $jsonld, ':timestamp' => time()]); 133 | $pdo = $db->query('select last_insert_id()'); 134 | return $pdo->fetch(PDO::FETCH_COLUMN, 0); 135 | } 136 | 137 | function Club_Queue_Insert($task, $target) { 138 | global $db; 139 | $pdo = $db->prepare('select count(*) from `blacklist` where `target` = :target'); 140 | $pdo->execute([':target' => $target]); 141 | if (empty($pdo->fetch(PDO::FETCH_COLUMN, 0))) { 142 | $pdo = $db->prepare('insert into `queues`(`tid`,`target`,`timestamp`) values (:tid, :target, :timestamp)'); 143 | if ($pdo->execute([':tid' => $task, ':target' => $target, ':timestamp' => time()])) { 144 | $pdo = $db->prepare('update `tasks` set `queues` = `queues` + 1 where `tid` = :tid'); 145 | return $pdo->execute([':tid' => $task]); 146 | } 147 | } return false; 148 | } 149 | 150 | function Club_Push_Activity($club, $activity, $inbox = false) { 151 | global $db, $config; 152 | $type = $activity['type']; 153 | $activity = Club_Json_Encode($activity); 154 | if ($config['nodeDebugging']) { 155 | $file_name = date('Y-m-d_H:i:s_').$club.'_'.$type; 156 | file_put_contents(APP_ROOT.'/logs/outbox/'.$file_name.'_output.json', $activity); 157 | if ($config['nodeDebugging'] == 1) file_put_contents(APP_ROOT.'/logs/outbox/'.$file_name.'_server.json', Club_Json_Encode($_SERVER)); 158 | } 159 | $commit = false; 160 | $pdo = $db->beginTransaction(); 161 | if ($task = Club_Task_Create('push', $club, $activity)) { 162 | if ($inbox) Club_Queue_Insert($task, $inbox); 163 | else { 164 | $pdo = $db->prepare('select distinct u.shared_inbox from `followers` `f` join `clubs` `c` on f.cid = c.cid join `users` `u` on f.uid = u.uid where c.name = :club'); 165 | $pdo->execute([':club' => $club]); 166 | foreach ($pdo->fetchAll(PDO::FETCH_COLUMN, 0) as $inbox) Club_Queue_Insert($task, $inbox); 167 | } $commit = $db->commit(); 168 | } if (!$commit) { 169 | if ($config['nodeDebugging']) file_put_contents(APP_ROOT.'/logs/outbox/'.$file_name.'_commit_failed'); 170 | $pdo = $db->rollback(); 171 | } 172 | } 173 | 174 | function Club_Announce_Process($jsonld) { 175 | global $db, $base, $config, $public_streams; 176 | $pdo = $db->prepare('select `id` from `activities` where `object` = :object'); 177 | $pdo->execute([':object' => $jsonld['object']['id']]); 178 | if (!$pdo->fetch(PDO::FETCH_ASSOC)) { 179 | foreach ($to = array_merge(to_array($jsonld['to']), to_array($jsonld['cc'])) as $cc) 180 | if (($club_url = $base.'/club/') == substr($cc, 0, strlen($club_url))) 181 | if ($club = Club_Exist(explode('/', substr($cc, strlen($club_url)))[0])) $clubs[$club] = 1; 182 | if (!empty($clubs) && ($clubs = array_keys($clubs)) && in_array($public_streams, $to)) { 183 | if ($actor = Club_Get_Actor($clubs[0], $jsonld['actor'])) { 184 | $pdo = $db->prepare('insert into `activities`(`uid`,`type`,`clubs`,`object`,`timestamp`) values(:uid, :type, :clubs, :object, :timestamp)'); 185 | $pdo->execute([':uid' => $actor['uid'], ':type' => 'Create', ':clubs' => Club_Json_Encode($clubs), 'object' => $jsonld['object']['id'], 'timestamp' => ($time = time())]); 186 | $pdo = $db->query('select last_insert_id()'); 187 | if ($activity_id = $pdo->fetch(PDO::FETCH_COLUMN, 0)) { 188 | foreach ($clubs as $club) { 189 | if (in_array($club, ['board'])) { 190 | $pdo = $db->prepare('select count(id) from announces join clubs on announces.cid = clubs.cid 191 | where announces.uid = :uid and announces.timestamp >= :timestamp and clubs.name = :club'); 192 | $pdo->execute([':uid' => $actor['uid'], ':timestamp' => time() - 60 * 60 * 24, ':club' => $club]); 193 | if ($pdo->fetch(PDO::FETCH_COLUMN, 0) >= 10) { 194 | if ($config['nodeDebugging']) { 195 | $file_name = date('Y-m-d_H:i:s_').$club.'_spam'; 196 | file_put_contents(APP_ROOT.'/logs/filter/'.$file_name.'.json', Club_Json_Encode($jsonld)); 197 | } continue; 198 | } 199 | } $club_url = $base.'/club/'.$club; 200 | Club_Push_Activity($club, [ 201 | '@context' => 'https://www.w3.org/ns/activitystreams', 202 | 'id' => $club_url.'/activity#'.$activity_id.'/announce', 203 | 'type' => 'Announce', 204 | 'actor' => $club_url, 205 | 'published' => gmdate('Y-m-d\TH:i:s\Z', $time), 206 | 'to' => [$club_url.'/followers'], 207 | 'cc' => [$jsonld['actor'], $public_streams], 208 | 'object' => $jsonld['object']['id'] 209 | ]); 210 | $pdo = $db->prepare('insert into `announces`(`cid`,`uid`,`activity`,`summary`,`content`,`timestamp`)'. 211 | ' select `cid`, :uid as `uid`, :activity as `activity`, :summary as `summary`, :content as `content`, :timestamp as `timestamp` from `clubs` where `name` = :club'); 212 | $pdo->execute([':club' => $club, ':uid' => $actor['uid'], ':activity' => $activity_id, 213 | ':summary' => $jsonld['object']['summary'], ':content' => strip_tags($jsonld['object']['content']), ':timestamp' => strtotime($jsonld['object']['published'])]); 214 | } 215 | } 216 | } else Club_Json_Output(['message' => 'Actor not found'], 0, 400); 217 | } 218 | } 219 | } 220 | 221 | function Club_Follow_Process($jsonld) { 222 | global $db, $base; 223 | $club = explode('/club/', $jsonld['object'])[1]; 224 | if ($actor = Club_Get_Actor($club, $jsonld['actor'])) { 225 | $pdo = $db->prepare('insert into `followers`(`cid`,`uid`,`timestamp`) select `cid`, :uid as `uid`, :timestamp as `timestamp` from `clubs` where `name` = :club'); 226 | $pdo->execute([':club' => $club, ':uid' => $actor['uid'], ':timestamp' => time()]); 227 | $pdo = $db->prepare('select f.id from `followers` as f left join `clubs` as `c` on f.cid = c.cid where f.uid = :uid and c.name = :club'); 228 | $pdo->execute([':club' => $club, ':uid' => $actor['uid']]); 229 | if ($follow_id = $pdo->fetch(PDO::FETCH_COLUMN, 0) && $club_url = $base.'/club/'.$club) { 230 | Club_Push_Activity($club, [ 231 | '@context' => 'https://www.w3.org/ns/activitystreams', 232 | 'id' => $club_url.'#accepts/follows/'.$follow_id, 233 | 'type' => 'Accept', 234 | 'actor' => $club_url, 235 | 'object' => [ 236 | 'id' => $jsonld['id'], 237 | 'type' => 'Follow', 238 | 'actor' => $jsonld['actor'], 239 | 'object' => $club_url 240 | ] 241 | ], $actor['inbox']); 242 | } 243 | } else Club_Json_Output(['message' => 'Actor not found'], 0, 400); 244 | } 245 | 246 | function Club_Tombstone_Process($jsonld) { 247 | global $db, $base, $public_streams; 248 | $pdo = $db->prepare('select `id` from `activities` where `object` = :object'); 249 | $pdo->execute([':object' => $jsonld['id']]); 250 | if (!$pdo->fetch(PDO::FETCH_ASSOC)) { 251 | $pdo = $db->prepare('select `id`,`uid`,`clubs`,`object`,`timestamp` from `activities` where `object` = :object'); 252 | $pdo->execute([':object' => $jsonld['object']['id']]); 253 | if ($activity = $pdo->fetch(PDO::FETCH_ASSOC)) { 254 | $pdo = $db->prepare('insert into `activities`(`uid`,`type`,`clubs`,`object`,`timestamp`) values(:uid, :type, :clubs, :object, :timestamp)'); 255 | $pdo->execute([':uid' => $activity['uid'], ':type' => 'Delete', ':clubs' => $activity['clubs'], 'object' => $jsonld['id'], 'timestamp' => time()]); 256 | foreach (json_decode($activity['clubs'], 1) as $club) { 257 | $club_url = $base.'/club/'.$club; 258 | Club_Push_Activity($club, [ 259 | '@context' => 'https://www.w3.org/ns/activitystreams', 260 | 'id' => $club_url.'/activity#'.$activity['id'].'/undo', 261 | 'type' => 'Undo', 262 | 'actor' => $club_url, 263 | 'to' => $public_streams, 264 | 'object' => [ 265 | 'id' => $club_url.'/activity#'.$activity['id'].'/announce', 266 | 'type' => 'Announce', 267 | 'actor' => $club_url, 268 | 'published' => gmdate('Y-m-d\TH:i:s\Z', $activity['timestamp']), 269 | 'to' => [$club_url.'/followers'], 270 | 'cc' => [ 271 | $jsonld['actor'], 272 | $public_streams 273 | ], 274 | 'object' => $activity['object'] 275 | ] 276 | ]); 277 | } 278 | $pdo = $db->prepare('delete from `announces` where `activity` = :activity'); 279 | $pdo->execute([':activity' => $activity['id']]); 280 | } 281 | } 282 | } 283 | 284 | function Club_Undo_Process($jsonld) { 285 | global $db; switch ($jsonld['object']['type']) { 286 | case 'Follow': 287 | $club = explode('/club/', $jsonld['object']['object'])[1]; 288 | $pdo = $db->prepare('delete from `followers` where `cid` in (select cid from `clubs` where `name` = :club) and `uid` in (select uid from `users` where `actor` = :actor)'); 289 | $pdo->execute([':club' => $club, ':actor' => $jsonld['actor']]); break; 290 | default: break; 291 | } 292 | } 293 | 294 | function Club_Get_OrderedCollection($id, $arr = []) { 295 | $arr = array_merge([ 296 | '@context' => 'https://www.w3.org/ns/activitystreams', 297 | 'id' => $id, 298 | 'type' => 'OrderedCollection', 299 | 'totalItems' => 0 300 | ], $arr); 301 | Club_Json_Output($arr, 2); 302 | } 303 | 304 | function Club_NameTag_Render($club, $str, $tag) { 305 | global $config; 306 | $str = str_replace(array_keys($tag), array_values($tag), $str); 307 | return str_replace([':club_name:', ':local_domain:'], [$club, $config['base']], $str); 308 | } 309 | 310 | function Club_Json_Encode($data) { 311 | return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); 312 | } 313 | 314 | function Club_Json_Output($data, $format = 0, $status = 200) { 315 | switch ($format) { 316 | case 1: $format = 'jrd+json'; break; 317 | case 2: $format = 'activity+json'; break; 318 | default: $format = 'json'; break; 319 | } header('Content-type: application/'.$format.'; charset=utf-8'); 320 | 321 | if ($status != 200) { 322 | http_response_code($status); 323 | $data = array_merge(['code' => $status], $data); 324 | } echo Club_Json_Encode($data); 325 | } 326 | 327 | function to_array($data) { 328 | return is_array($data) ? $data : [$data]; 329 | } 330 | -------------------------------------------------------------------------------- /src/worker.php: -------------------------------------------------------------------------------- 1 | prepare('update `queues` set `id` = last_insert_id(id), `inuse` = 1, `timestamp` = :timestamp where `inuse` = 0 and `timestamp` <= :timestamp order by `retry`, `timestamp` asc limit 1'); 6 | $pdo->execute([':timestamp' => time()]); 7 | $pdo = $db->query('select q.id, c.name as club, t.tid, t.type, t.jsonld, q.target, q.retry from `queues` as `q` left join `tasks` as `t` on q.tid = t.tid left join `clubs` as `c` on t.cid = c.cid where `id` = last_insert_id() and row_count() <> 0'); 8 | if ($task = $pdo->fetch(PDO::FETCH_ASSOC)) { 9 | switch ($task['type']) { 10 | case 'push': 11 | $pdo = $db->prepare('select count(*) from `blacklist` where `target` = :target'); 12 | $pdo->execute([':target' => $task['target']]); 13 | if ($pdo->fetch(PDO::FETCH_COLUMN, 0)) { 14 | $pdo = $db->prepare('delete from `queues` where `id` = :id'); 15 | $pdo->execute([':id' => $task['id']]); 16 | $pdo = $db->prepare('update `tasks` set `queues` = `queues` - 1 where `tid` = :tid'); 17 | $pdo->execute([':tid' => $task['tid']]); 18 | } else { 19 | if (ActivityPub_POST($task['target'], $task['club'], $task['jsonld'])) { 20 | $pdo = $db->prepare('delete from `queues` where `id` = :id'); 21 | $pdo->execute([':id' => $task['id']]); 22 | $pdo = $db->prepare('update `tasks` set `queues` = `queues` - 1 where `tid` = :tid'); 23 | $pdo->execute([':tid' => $task['tid']]); 24 | } else { 25 | $retry = $task['retry'] + 1; 26 | if ($retry <= 3) $timestamp = time() + 60; 27 | elseif ($retry <= 5) $timestamp = time() + 300; 28 | elseif ($retry <= 10) $timestamp = time() + 600; 29 | elseif ($retry <= 100) $timestamp = time() + 3600; 30 | else $timestamp = time() + 86400; 31 | if ($retry == 127) { 32 | $pdo = $db->prepare('insert ignore into `blacklist`(`target`, `create`) values (:target, :create);'); 33 | $pdo->execute([':target' => $task['target'], ':create' => time()]); 34 | $pdo = $db->prepare('delete from `queues` where `id` = :id'); 35 | $pdo->execute([':id' => $task['id']]); 36 | $pdo = $db->prepare('update `tasks` set `queues` = `queues` - 1 where `tid` = :tid'); 37 | $pdo->execute([':tid' => $task['tid']]); 38 | } else { 39 | $pdo = $db->prepare('update `queues` set `inuse` = 0, `retry` = :retry, `timestamp` = :timestamp where `id` = :id'); 40 | $pdo->execute([':id' => $task['id'], ':retry' => $retry, ':timestamp' => $timestamp]); 41 | } 42 | } 43 | } break; 44 | default: break; 45 | } $cycle++; 46 | } else $idle = 1; 47 | if ($idle || $cycle > 9) { 48 | $pdo = $db->prepare('delete from `tasks` where `queues` < 1 and `timestamp` <= :timestamp'); 49 | $pdo->execute([':timestamp' => time() - 30]); 50 | $pdo = $db->prepare('update `queues` set `inuse` = 0 where `inuse` = 1 and `timestamp` <= :timestamp'); 51 | $pdo->execute([':timestamp' => time() - 30]); 52 | $pdo = $db->prepare('update `blacklist` set `inuse` = 0 where `inuse` = 1 and `timestamp` <= :timestamp'); 53 | $pdo->execute([':timestamp' => time() - 30]); 54 | $pdo = $db->prepare('update `blacklist` set `id` = last_insert_id(id), `inuse` = 1, `timestamp` = :timestamp where `inuse` = 0 and `timestamp` <= :timestamp order by `timestamp` asc limit 1'); 55 | $pdo->execute([':timestamp' => time()]); 56 | $pdo = $db->query('select `id`, `retry`, `target` from `blacklist` where `id` = last_insert_id() and row_count() <> 0'); 57 | if ($target = $pdo->fetch(PDO::FETCH_ASSOC)) { 58 | Club_Exist('blacklist_target_recheck'); 59 | if (ActivityPub_POST($target['target'], 'blacklist_target_recheck', '{}')) { 60 | $pdo = $db->prepare('delete from `blacklist` where `id` = :id'); 61 | $pdo->execute([':id' => $target['id']]); 62 | } else { 63 | $pdo = $db->prepare('update `blacklist` set `inuse` = 0, `retry` = :retry, `timestamp` = :timestamp where `id` = :id'); 64 | $pdo->execute([':id' => $target['id'], ':retry' => $target['retry'] + 1, ':timestamp' => time() + 86400]); 65 | } 66 | } elseif ($idle) sleep(1); $cycle = 0; 67 | } 68 | if (memory_get_usage(1) > 10 * 1024 * 1024) { 69 | global $stop; $stop = true; 70 | echo date('[Y-m-d H:i:s]').' Memory limit exceeded, stopping ...',"\n"; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tools/wxwclub.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `clubs` ( 2 | `cid` int NOT NULL AUTO_INCREMENT, 3 | `name` varchar(30) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, 4 | `nickname` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL, 5 | `infoname` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci, 6 | `summary` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci, 7 | `avatar` varchar(255) CHARACTER SET ascii COLLATE ascii_general_ci DEFAULT NULL, 8 | `banner` varchar(255) CHARACTER SET ascii COLLATE ascii_general_ci DEFAULT NULL, 9 | `public_key` text CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, 10 | `private_key` text CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, 11 | `timestamp` int NOT NULL, 12 | PRIMARY KEY (`cid`), 13 | UNIQUE KEY `name` (`name`) 14 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; 15 | 16 | CREATE TABLE `users` ( 17 | `uid` int NOT NULL AUTO_INCREMENT, 18 | `name` varchar(100) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, 19 | `actor` varchar(255) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, 20 | `inbox` varchar(255) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, 21 | `public_key` text CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, 22 | `shared_inbox` varchar(255) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, 23 | `timestamp` int NOT NULL, 24 | PRIMARY KEY (`uid`), 25 | UNIQUE KEY `name` (`name`), 26 | UNIQUE KEY `actor` (`actor`) 27 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; 28 | 29 | CREATE TABLE `activities` ( 30 | `id` int NOT NULL AUTO_INCREMENT, 31 | `uid` int NOT NULL, 32 | `type` varchar(10) COLLATE utf8mb4_general_ci NOT NULL, 33 | `clubs` varchar(255) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, 34 | `object` varchar(255) COLLATE utf8mb4_general_ci NOT NULL, 35 | `timestamp` int NOT NULL, 36 | PRIMARY KEY (`id`), 37 | UNIQUE KEY `object` (`object`), 38 | KEY `uid` (`uid`), 39 | CONSTRAINT `activities_ibfk_5` FOREIGN KEY (`uid`) REFERENCES `users` (`uid`) ON DELETE CASCADE ON UPDATE CASCADE 40 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; 41 | 42 | CREATE TABLE `followers` ( 43 | `id` int NOT NULL AUTO_INCREMENT, 44 | `cid` int NOT NULL, 45 | `uid` int NOT NULL, 46 | `timestamp` int NOT NULL, 47 | PRIMARY KEY (`id`), 48 | UNIQUE KEY `cid_uid` (`cid`,`uid`), 49 | KEY `cid` (`cid`), 50 | KEY `uid` (`uid`), 51 | CONSTRAINT `followers_ibfk_4` FOREIGN KEY (`cid`) REFERENCES `clubs` (`cid`) ON DELETE CASCADE ON UPDATE CASCADE, 52 | CONSTRAINT `followers_ibfk_5` FOREIGN KEY (`uid`) REFERENCES `users` (`uid`) ON DELETE CASCADE ON UPDATE CASCADE 53 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; 54 | 55 | CREATE TABLE `announces` ( 56 | `id` int NOT NULL AUTO_INCREMENT, 57 | `cid` int NOT NULL, 58 | `uid` int NOT NULL, 59 | `activity` int NOT NULL, 60 | `summary` text COLLATE utf8mb4_general_ci, 61 | `content` text COLLATE utf8mb4_general_ci NOT NULL, 62 | `timestamp` int NOT NULL, 63 | PRIMARY KEY (`id`), 64 | UNIQUE KEY `cid_activity` (`cid`,`activity`), 65 | KEY `uid` (`uid`), 66 | KEY `activity` (`activity`), 67 | KEY `timestamp` (`timestamp`), 68 | CONSTRAINT `announces_ibfk_3` FOREIGN KEY (`cid`) REFERENCES `clubs` (`cid`) ON DELETE CASCADE ON UPDATE CASCADE, 69 | CONSTRAINT `announces_ibfk_5` FOREIGN KEY (`uid`) REFERENCES `users` (`uid`) ON DELETE CASCADE ON UPDATE CASCADE, 70 | CONSTRAINT `announces_ibfk_7` FOREIGN KEY (`activity`) REFERENCES `activities` (`id`) ON DELETE CASCADE ON UPDATE CASCADE 71 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; 72 | 73 | CREATE TABLE `tasks` ( 74 | `tid` int NOT NULL AUTO_INCREMENT, 75 | `cid` int NOT NULL, 76 | `type` varchar(10) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, 77 | `jsonld` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, 78 | `queues` int NOT NULL DEFAULT '0', 79 | `timestamp` int NOT NULL, 80 | PRIMARY KEY (`tid`), 81 | KEY `cid` (`cid`), 82 | KEY `type` (`type`), 83 | KEY `queues` (`queues`), 84 | CONSTRAINT `tasks_ibfk_2` FOREIGN KEY (`cid`) REFERENCES `clubs` (`cid`) ON DELETE CASCADE ON UPDATE CASCADE 85 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; 86 | 87 | CREATE TABLE `queues` ( 88 | `id` int NOT NULL AUTO_INCREMENT, 89 | `tid` int NOT NULL, 90 | `target` varchar(255) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, 91 | `timestamp` int NOT NULL, 92 | `inuse` tinyint NOT NULL DEFAULT '0', 93 | `retry` tinyint NOT NULL DEFAULT '0', 94 | PRIMARY KEY (`id`), 95 | KEY `tid` (`tid`), 96 | KEY `timestamp` (`timestamp`), 97 | KEY `inuse` (`inuse`), 98 | KEY `retry` (`retry`), 99 | CONSTRAINT `queues_ibfk_2` FOREIGN KEY (`tid`) REFERENCES `tasks` (`tid`) ON DELETE CASCADE ON UPDATE CASCADE 100 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; 101 | 102 | CREATE TABLE `blacklist` ( 103 | `id` int NOT NULL AUTO_INCREMENT, 104 | `target` varchar(255) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, 105 | `create` int DEFAULT NULL, 106 | `timestamp` int NOT NULL DEFAULT '0', 107 | `inuse` tinyint NOT NULL DEFAULT '0', 108 | `retry` smallint NOT NULL DEFAULT '0', 109 | PRIMARY KEY (`id`), 110 | UNIQUE KEY `target` (`target`), 111 | KEY `timestamp` (`timestamp`), 112 | KEY `inuse` (`inuse`) 113 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; --------------------------------------------------------------------------------