diff --git a/.gitignore b/.gitignore index de499fb..5dc0f05 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ vendor/ tmp composer.lock .idea +composer.phar diff --git a/Action.php b/Action.php index 8ccd5e2..1e49392 100644 --- a/Action.php +++ b/Action.php @@ -1,27 +1,42 @@ db = Typecho_Db::get(); - $this->options = $this->widget('Widget_Options'); + $this->db = Db::get(); + $this->options = Options::alloc(); $this->config = $this->options->plugin('Restful'); $this->request = $request; $this->response = $response; @@ -80,6 +104,10 @@ public function execute() { $this->sendCORS(); $this->parseRequest(); + + $url = $this->request->getPathInfo(); + $url = str_replace('/api/', '', $url); + $this->{$url . "Action"}(); } public function action() @@ -88,17 +116,15 @@ public function action() /** * 发送跨域 HEADER - * - * @return void */ private function sendCORS() { $httpOrigin = $this->request->getServer('HTTP_ORIGIN'); $this->response->setHeader('Access-Control-Allow-Credentials', 'true'); - $allowedHttpOrigins = explode("\n", str_replace("\r", "", $this->config->origin)); + $allowedHttpOrigins = explode("\n", str_replace("\r", "", $this->config->offsetGet('origin'))); if (!$httpOrigin) { - return; + $this->throwError('非法请求!'); } if (in_array($httpOrigin, $allowedHttpOrigins)) { @@ -121,9 +147,20 @@ private function sendCORS() private function parseRequest() { if ($this->request->isPost()) { + $pathInfo = (string)$this->request->getPathInfo(); + $prefix = defined('__TYPECHO_RESTFUL_PREFIX__') ? __TYPECHO_RESTFUL_PREFIX__ : '/api/'; + $shortRoute = trim(str_replace($prefix, '', $pathInfo), '/'); + if (false !== ($pos = strpos($shortRoute, '/'))) { + $shortRoute = substr($shortRoute, 0, $pos); + } $data = file_get_contents('php://input'); $data = json_decode($data, true); - if (json_last_error() != JSON_ERROR_NONE) { + if ($data !== '' && json_last_error() != JSON_ERROR_NONE) { + // 命中跳过列表则不抛错 + if (in_array($shortRoute, $this->jsonParseSkipRoutes, true)) { + $this->httpParams = array(); + return; + } $this->throwError('Parse JSON error'); } $this->httpParams = $data; @@ -241,6 +278,7 @@ public function getCustomFields($cid) ); } } + return $result; } @@ -295,7 +333,7 @@ public function postsAction() )); } else { foreach ($cids as $key => $cid) { - $cids[$key] = $cids[$key]['cid']; + $cids[$key] = $cid['cid']; } } } @@ -308,7 +346,7 @@ public function postsAction() ->where('status = ?', 'publish') ->where('created < ?', time()) ->where('password IS NULL') - ->order('created', Typecho_Db::SORT_DESC); + ->order('created', Db::SORT_DESC); if (isset($cids)) { $cidStr = implode(',', $cids); $select->where('cid IN (' . $cidStr . ')'); @@ -332,19 +370,19 @@ public function postsAction() explode("", $result[$key]['text'])[0] ); - $result[$key] = $this->filter($result[$key]); + $result[$key] = $this->articleFilter($result[$key]); } elseif ($showDigest === 'excerpt') { // if you use 'excerpt', plugin will truncate for certain number of text $limit = (int)trim($this->getParams('limit', '200')); - $result[$key] = $this->filter($result[$key]); + $result[$key] = $this->articleFilter($value); $result[$key]['digest'] = mb_substr( - htmlspecialchars_decode(strip_tags($result[$key]['text'])), - 0, - $limit, - 'utf-8' - ) . "..."; + htmlspecialchars_decode(strip_tags($result[$key]['text'])), + 0, + $limit, + 'utf-8' + ) . "..."; } else { - $result[$key] = $this->filter($result[$key]); + $result[$key] = $this->articleFilter($value); } @@ -379,7 +417,7 @@ public function pagesAction() ->where('status = ?', 'publish') ->where('created < ?', time()) ->where('password IS NULL') - ->order('order', Typecho_Db::SORT_ASC); + ->order('order', Db::SORT_ASC); $result = $this->db->fetchAll($select); $count = count($result); @@ -399,16 +437,9 @@ public function categoriesAction() { $this->lockMethod('get'); $this->checkState('categories'); - $categories = $this->widget('Widget_Metas_Category_List'); - - if (isset($categories->stack)) { - $this->throwData($categories->stack); - } else { - $reflect = new ReflectionObject($categories); - $map = $reflect->getProperty('_map'); - $map->setAccessible(true); - $this->throwData(array_merge($map->getValue($categories))); - } +// $categories = $this->db->fetchAll(Contents::alloc()->select('*')->where('type = ?', 'page')); + $categories = $this->db->fetchAll(Metas::alloc()->select('*')->where('type = ?', 'category')); + $this->throwData($categories); } /** @@ -426,11 +457,7 @@ public function tagsAction() ->from('table.metas') ->where("type = 'tag'"); $result = $this->db->fetchAll($tags); - if (count($result) != 0) { - $this->throwData($result); - } else { - $this->throwError('no tag', 404); - } + $this->throwData($result); } /** @@ -458,8 +485,8 @@ public function postAction() } $result = $this->db->fetchRow($select); - if (count($result) != 0) { - $result = $this->filter($result); + if (!empty($result) && count($result) != 0) { + $result = $this->articleFilter($result); $result['csrfToken'] = $this->generateCsrfToken($result['permalink']); $this->throwData($result); } else { @@ -482,7 +509,7 @@ public function recentCommentsAction() ->select('coid', 'cid', 'author', 'text') ->from('table.comments') ->where('type = ? AND status = ?', 'comment', 'approved') - ->order('created', Typecho_Db::SORT_DESC) + ->order('created', Db::SORT_DESC) ->limit($size); $result = $this->db->fetchAll($query); @@ -511,8 +538,8 @@ public function commentsAction() $order = strtolower($this->getParams('order', '')); // 为带 cookie 请求的用户显示正在等待审核的评论 - $author = Typecho_Cookie::get('__typecho_remember_author'); - $mail = Typecho_Cookie::get('__typecho_remember_mail'); + $author = Cookie::get('__typecho_remember_author'); + $mail = Cookie::get('__typecho_remember_mail'); if (empty($cid) && empty($slug)) { $this->throwError('No specified posts.', 404); @@ -521,10 +548,10 @@ public function commentsAction() $select = $this->db ->select('table.comments.coid', 'table.comments.parent', 'table.comments.cid', 'table.comments.created', 'table.comments.author', 'table.comments.mail', 'table.comments.url', 'table.comments.text', 'table.comments.status') ->from('table.comments') - ->join('table.contents', 'table.comments.cid = table.contents.cid', Typecho_Db::LEFT_JOIN) + ->join('table.contents', 'table.comments.cid = table.contents.cid', Db::LEFT_JOIN) ->where('table.comments.type = ?', 'comment') ->group('table.comments.coid') - ->order('table.comments.created', $order === 'asc' ? Typecho_Db::SORT_ASC : Typecho_Db::SORT_DESC); + ->order('table.comments.created', $order === 'asc' ? Db::SORT_ASC : Db::SORT_DESC); if (empty($author)) { $select->where('table.comments.status = ?', 'approved'); @@ -571,9 +598,9 @@ public function commentAction() $this->checkState('comment'); $comments = new Comments($this->request, $this->response); - $check_key = [ + $check_key = array( 'text', 'mail', 'author', 'token' - ]; + ); foreach ($check_key as $key) { if (!$this->getParams($key, '')) { $this->throwError('missing ' . $key); @@ -600,7 +627,7 @@ public function commentAction() $result = $this->db->fetchRow($select); if (count($result) != 0) { - $result = $this->filter($result); + $result = $this->articleFilter($result); } else { $this->throwError('post not exists', 404); } @@ -621,7 +648,7 @@ public function commentAction() $ownerId = $this->getParams('ownerId', ''); $url = $this->getParams('url', ''); - $uid = Typecho_Cookie::get('__typecho_uid'); // 登录的话忽略传值 + $uid = Cookie::get('__typecho_uid'); // 登录的话忽略传值 if (!empty($uid)) { $authorId = $uid; } @@ -642,7 +669,7 @@ public function commentAction() $query = $this->db->select() ->from('table.comments') ->where('author = ?', $this->getParams('author', '')) - ->order('created', Typecho_Db::SORT_DESC); + ->order('created', Db::SORT_DESC); $res = $this->db->fetchRow($query); $this->throwData($res); } @@ -716,7 +743,7 @@ public function usersAction() ->where('authorId = ?', $value['uid']); $posts = $this->db->fetchAll($postSelector); foreach ($posts as $postNumber => $post) { - $posts[$postNumber] = $this->filter($post); + $posts[$postNumber] = $this->articleFilter($post); } array_push($users, array( @@ -753,7 +780,7 @@ public function archivesAction() ->where('status = ?', 'publish') ->where('password IS NULL') ->where('type = ?', 'post') - ->order('created', $order === 'asc' ? Typecho_Db::SORT_ASC : Typecho_Db::SORT_DESC); + ->order('created', $order === 'asc' ? Db::SORT_ASC : Db::SORT_DESC); $posts = $this->db->fetchAll($select); $archives = array(); @@ -767,18 +794,18 @@ public function archivesAction() explode("", $post['text'])[0] ); - $post = $this->filter($post); + $post = $this->articleFilter($post); } elseif ($showDigest === 'excerpt') { $limit = (int)trim($this->getParams('limit', '200')); - $post = $this->filter($post); + $post = $this->articleFilter($post); $post['digest'] = mb_substr( - htmlspecialchars_decode(strip_tags($post['text'])), - 0, - $limit, - 'utf-8' - ) . "..."; + htmlspecialchars_decode(strip_tags($post['text'])), + 0, + $limit, + 'utf-8' + ) . "..."; } else { - $post = $this->filter($post); + $post = $this->articleFilter($post); } if (!$showContent) { @@ -838,15 +865,13 @@ public function postArticleAction() $this->lockMethod('post'); $this->checkState('postArticle'); - if ($this->config->validateLogin == 1 && !$this->widget('Widget_User')->hasLogin()) { - $this->throwError('User must be logged in', 401); - } + $this->checkLogin(); $contents = new Contents($this->request, $this->response); - $check_key = [ + $check_key = array( 'title', 'text', 'authorId' - ]; + ); foreach ($check_key as $key) { if (!$this->getParams($key, '')) { $this->throwError('missing ' . $key); @@ -869,12 +894,12 @@ public function postArticleAction() } $articleData = $this->db->fetchRow($article); - $postData = [ + $postData = array( 'title' => $title, 'text' => $text, 'authorId' => $authorId, 'slug' => $slug, - ]; + ); $type = 'add'; if (!empty($articleData)) { // 更新 @@ -900,7 +925,7 @@ public function postArticleAction() } $midArray = explode(',', $mid); - $values = []; + $values = array(); foreach ($midArray as $mid) { $values[] = '(' . $cid . ', ' . $mid . ')'; } @@ -924,9 +949,7 @@ public function addMetasAction() { $this->lockMethod('post'); $this->checkState('addMetas'); - if ($this->config->validateLogin == 1 && !$this->widget('Widget_User')->hasLogin()) { - $this->throwError('User must be logged in', 401); - } + $this->checkLogin(); $name = $this->getParams('name', ''); $type = $this->getParams('type', ''); $slug = $this->getParams('slug', ''); @@ -936,14 +959,169 @@ public function addMetasAction() if ($type != 'category' && $type != 'tag') { $this->throwError('type must be category or tag'); } - $res = $this->db->query($this->db->insert('table.metas')->rows([ + $res = $this->db->query($this->db->insert('table.metas')->rows(array( 'name' => $name, 'type' => $type, 'slug' => empty($slug) ? $name : $slug, - ])); + ))); $this->throwData($res); } + /** + * 上传文件 + */ + public function uploadAction() + { + $this->lockMethod('post'); + $this->checkState('upload'); + $this->checkLogin(); + $file = ''; + try { + $file = Util::getUploadFile($_FILES, $this->request); + } catch (\Exception $e) { + $this->throwError($e->getMessage()); + } + $cid = $this->request->get('cid'); + $authorId = $this->request->get('authorId'); + $result = Upload::uploadHandle($file); + if (false === $result) { + $this->throwError('upload handle failed'); + } + $u = new Upload($this->request, $this->response); + $struct = array( + 'title' => $result['name'], + 'slug' => $result['name'], + 'type' => 'attachment', + 'status' => 'publish', + 'text' => json_encode($result), + 'allowComment' => 1, + 'allowPing' => 0, + 'allowFeed' => 1 + ); + if (!empty($cid)) { + $struct['parent'] = $cid; + } + if (!empty($authorId)) { + $struct['authorId'] = $authorId; + } + $insertId = $u->insert($struct); + $this->db->fetchRow( + $u->select()->where('table.contents.cid = ?', $insertId) + ->where('table.contents.type = ?', 'attachment'), + array($u, 'push') + ); + $payload = array( + 'cid' => $insertId, + 'title' => $result['name'], + 'type' => $result['type'], + 'size' => $result['size'], + 'bytes' => number_format(ceil($result['size'] / 1024)) . ' Kb', + 'url' => $result['path'], + 'host' => (defined('__TYPECHO_UPLOAD_URL__') ? __TYPECHO_UPLOAD_URL__ : $this->options->siteUrl) + ); + $this->throwData($payload); + } + + /** + * 删除文件 + */ + public function deleteFileAction() + { + $this->lockMethod('post'); + $this->checkState('deleteFile'); + $this->checkLogin(); + $cid = $this->getParams('cid'); + if (empty($cid)) { + $this->throwError('missing cid'); + } + $u = new Upload($this->request, $this->response); + $select = $this->db->select('cid,text')->where('table.contents.cid = ?', $cid) + ->where('table.contents.type = ?', 'attachment')->from('table.contents')->limit(1); + $row = $this->db->fetchRow($select); + if (empty($row)) { + $this->throwError('file not found', 404); + } + $row['text'] = json_decode($row['text'], true); + @unlink(__TYPECHO_ROOT_DIR__ . '/' . $row['text']['path']); + $affected = $u->delete($this->db->sql()->where('cid = ?', $cid)); + if ($affected <= 0) { + $this->throwError('delete db failed', 500); + } + $this->throwData(array('deleted' => true, 'cid' => (int)$cid)); + } + + /** + * 文件列表 + * GET /api/fileList?page=1&pageSize=10 + */ + public function fileListAction() + { + $this->lockMethod('get'); + $this->checkState('fileList'); + $page = max(1, (int)$this->request->get('page', 1)); + $pageSize = max(1, min(100, (int)$this->request->get('pageSize', 10))); + $authorId = (int)$this->request->get('authorId'); + $offset = ($page - 1) * $pageSize; + + $countQuery = $this->db->select(array('COUNT(1)' => 'num'))->from('table.contents') + ->where('type = ?', 'attachment'); + if ($authorId > 0) { + $countQuery->where('authorId = ?', $authorId); + } + $count = (int)$this->db->fetchObject($countQuery)->num; + + $listQuery = $this->db->select('cid', 'title', 'text', 'created', 'authorId') + ->from('table.contents') + ->where('type = ?', 'attachment') + ->order('created', \Typecho\Db::SORT_DESC) + ->offset($offset)->limit($pageSize); + if ($authorId > 0) { + $listQuery->where('authorId = ?', $authorId); + } + + $rows = $this->db->fetchAll($listQuery); + + $list = array(); + foreach ($rows as $r) { + $meta = array(); + if (!empty($r['text'])) { + $decoded = json_decode($r['text'], true); + if (is_array($decoded)) { + $meta = $decoded; + } + } + $url = null; + if (isset($meta['path'])) { + $cfg = new \Typecho\Config($meta); + if (version_compare(\Typecho\Common::VERSION, '1.3.0', '>=')) { + $url = \Widget\Upload::attachmentHandle($cfg); + } else { + $url = \Widget\Upload::attachmentHandle($cfg->toArray()); + } + } + $list[] = array( + 'cid' => (int)$r['cid'], + 'title' => $r['title'], + 'size' => isset($meta['size']) ? (int)$meta['size'] : null, + 'type' => $meta['type'] ?? null, + 'mime' => $meta['mime'] ?? null, + 'path' => $meta['path'] ?? null, + 'url' => $url, + 'created' => (int)$r['created'], + 'createdAt' => date('Y-m-d H:i:s', (int)$r['created']), + 'authorId' => (int)$r['authorId'], + ); + } + + $this->throwData(array( + 'dataSet' => $list, + 'page' => $page, + 'pageSize' => $pageSize, + 'count' => $count, + 'pages' => (int)ceil($count / $pageSize), + )); + } + /** * 插件更新接口 * @@ -953,18 +1131,13 @@ public function upgradeAction() { $this->lockMethod('get'); - $isAdmin = call_user_func(function () { - $hasLogin = $this->widget('Widget_User')->hasLogin(); - $isAdmin = false; - if (!$hasLogin) { - return false; - } - $isAdmin = $this->widget('Widget_User')->pass('administrator', true); - return $isAdmin; - }, $this); - - if (!$isAdmin) { - $this->throwError('must be admin'); + $user = $this->widget('Widget_User'); + $hasLogin = $user->hasLogin(); + if (!$hasLogin) { + $this->throwError('must be admin', 403); + } + if (!$user->pass('administrator', true)) { + $this->throwError('must be admin', 403); } $localPluginPath = __DIR__ . '/Plugin.php'; @@ -1057,10 +1230,7 @@ private function safelyParseMarkdown($content) if (is_null($content) || empty($content)) { return ''; } - $contentWidget = $this->widget('Widget_Abstract_Contents'); - return method_exists($contentWidget, 'markdown') - ? $contentWidget->markdown($content) - : MarkdownExtraExtended::defaultTransform($content); + return Markdown::convert($content); } /** @@ -1069,9 +1239,9 @@ private function safelyParseMarkdown($content) * @param array $value 文章详细信息数组 * @return array */ - private function filter($value) + private function articleFilter($value) { - $contentWidget = $this->widget('Widget_Abstract_Contents'); + $contentWidget = Contents::alloc(); $value['text'] = isset($value['text']) ? $value['text'] : null; $value['digest'] = isset($value['digest']) ? $value['digest'] : null; @@ -1094,6 +1264,16 @@ private function filter($value) // Custom fields $value['fields'] = $this->getCustomFields($value['cid']); + // 生成permalink,1.3源码没有生成 + $type = $value['type']; + $routeExists = (null != Router::get($type)); + $value['pathinfo'] = $routeExists ? Router::url($type, $value) : '#'; + $value['url'] = $value['permalink'] = Common::url($value['pathinfo'], $this->options->index); + // 补充日期 + $value['year'] = $value['date']->year; + $value['month'] = $value['date']->month; + $value['day'] = $value['date']->day; + return $value; } @@ -1132,6 +1312,16 @@ private function checkCsrfToken($key, $token) return hash_equals($token, $this->generateCsrfToken($key)); } + /** + * 检测登录状态 + */ + public function checkLogin() + { + if ($this->config->validateLogin == 1 && !$this->widget('Widget_User')->hasLogin()) { + $this->throwError('User must be logged in', 401); + } + } + /** * 刷新Metas数量 * @param array $midArray diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c16499..9519782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 变更日志 +### 2025-09-15 +- 新增文件管理接口 + +### 2025-07-21 + +- 适配typecho1.3 + ### 2025-05-04 - 适配typecho1.2.x diff --git a/Plugin.php b/Plugin.php index bdf8e3d..d080af9 100644 --- a/Plugin.php +++ b/Plugin.php @@ -1,7 +1,20 @@ comment = array(__CLASS__, 'comment'); + TypechoPlugin::factory('Widget_Feedback')->comment = array(__CLASS__, 'comment'); return '_(:з」∠)_'; } @@ -37,8 +50,8 @@ public static function activate() * * @static * @access public - * @return void - * @throws Typecho_Plugin_Exception + * @return string + * @throws Exception */ public static function deactivate() { @@ -54,10 +67,10 @@ public static function deactivate() * 获取插件配置面板 * * @access public - * @param Typecho_Widget_Helper_Form $form 配置面板 + * @param Form $form 配置面板 * @return void */ - public static function config(Typecho_Widget_Helper_Form $form) + public static function config(Form $form) { echo ''; @@ -70,34 +83,34 @@ public static function config(Typecho_Widget_Helper_Form $form) if ($route['shortName'] == 'upgrade') { continue; } - $tmp = new Typecho_Widget_Helper_Form_Element_Radio($route['shortName'], array( + $tmp = new Radio($route['shortName'], array( 0 => _t('禁用'), 1 => _t('启用'), ), 1, $route['uri'], _t($route['description'])); $form->addInput($tmp); } /* cross-origin settings */ - $origin = new Typecho_Widget_Helper_Form_Element_Textarea('origin', null, null, _t('域名列表'), _t('一行一个
以下是例子qwq
http://localhost:8080
https://blog.example.com
若不限制跨域域名,请使用通配符 *。')); + $origin = new Textarea('origin', null, null, _t('域名列表'), _t('一行一个
以下是例子qwq
http://localhost:8080
https://blog.example.com
若不限制跨域域名,请使用通配符 *。')); $form->addInput($origin); /* custom field privacy */ - $fieldsPrivacy = new Typecho_Widget_Helper_Form_Element_Text('fieldsPrivacy', null, null, _t('自定义字段过滤'), _t('过滤掉不希望在获取文章信息时显示的自定义字段名称。使用半角英文逗号分隔,例如 fields1,fields2 .')); + $fieldsPrivacy = new Text('fieldsPrivacy', null, null, _t('自定义字段过滤'), _t('过滤掉不希望在获取文章信息时显示的自定义字段名称。使用半角英文逗号分隔,例如 fields1,fields2 .')); $form->addInput($fieldsPrivacy); /* allowed options attribute */ - $allowedOptions = new Typecho_Widget_Helper_Form_Element_Text('allowedOptions', null, null, _t('自定义设置项白名单'), _t('默认情况下 /api/settings 只会返回一些安全的站点设置信息。若有需要你可以在这里指定允许返回的存在于 typecho_options 表中的字段,并通过 ?key= 参数请求。使用半角英文逗号分隔每个 key, 例如 keywords,theme .')); + $allowedOptions = new Text('allowedOptions', null, null, _t('自定义设置项白名单'), _t('默认情况下 /api/settings 只会返回一些安全的站点设置信息。若有需要你可以在这里指定允许返回的存在于 typecho_options 表中的字段,并通过 ?key= 参数请求。使用半角英文逗号分隔每个 key, 例如 keywords,theme .')); $form->addInput($allowedOptions); /* CSRF token salt */ - $csrfSalt = new Typecho_Widget_Helper_Form_Element_Text('csrfSalt', null, '05faabd6637f7e30c797973a558d4372', _t('CSRF加密盐'), _t('请务必修改本参数,以防止跨站攻击。')); + $csrfSalt = new Text('csrfSalt', null, '05faabd6637f7e30c797973a558d4372', _t('CSRF加密盐'), _t('请务必修改本参数,以防止跨站攻击。')); $form->addInput($csrfSalt); /* API token */ - $apiToken = new Typecho_Widget_Helper_Form_Element_Text('apiToken', null, '123456', _t('APITOKEN'), _t('api请求需要携带的token,设置为空就不校验。')); + $apiToken = new Text('apiToken', null, '123456', _t('APITOKEN'), _t('api请求需要携带的token,设置为空就不校验。')); $form->addInput($apiToken); /* 高敏接口是否校验登录用户 */ - $validateLogin = new Typecho_Widget_Helper_Form_Element_Radio('validateLogin', array( + $validateLogin = new Radio('validateLogin', array( 0 => _t('否'), 1 => _t('是'), ), 0, _t('高敏接口是否校验登录'), _t('开启后,高敏接口需要携带Cookie才能访问')); @@ -138,10 +151,10 @@ function restfulUpgrade(e) { * 个人用户的配置面板 * * @access public - * @param Typecho_Widget_Helper_Form $form + * @param Form $form * @return void */ - public static function personalConfig(Typecho_Widget_Helper_Form $form) + public static function personalConfig(Form $form) { } @@ -152,7 +165,7 @@ public static function personalConfig(Typecho_Widget_Helper_Form $form) */ public static function comment($comment, $post) { - $request = Typecho_Request::getInstance(); + $request = Request::getInstance(); $customIp = $request->getServer('HTTP_X_TYPECHO_RESTFUL_IP'); if ($customIp != null) { diff --git a/README.md b/README.md index 9cc88f1..8bbce8e 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,34 @@ PS: mid是因为typecho分类跟标签是同一个表。 | type | string | 类型(category/tag) | 必须 | | slug | string | 别名 | 可选 | +### 上传文件 + +`POST /api/upload` + +| 参数 | 类型 | 描述 | | +|----------|------|------|----| +| file | file | 文件 | 必须 | +| cid | int | 文章id | 可选 | +| authorId | int | 作者id | 可选 | + +### 删除文件 + +`POST /api/deleteFile` + +| 参数 | 类型 | 描述 | | +|-----|-----|------|----| +| cid | int | 文件id | 必须 | + +### 文件列表 + +`POST /api/fileList` + +| 参数 | 类型 | 描述 | | +|----------|-----|------|----| +| page | int | 第几页 | 可选 | +| pageSize | int | 每页几个 | 可选 | +| authorId | int | 作者id | 可选 | + ## 其它 ### 自定义 URI 前缀 diff --git a/Util.php b/Util.php new file mode 100644 index 0000000..3a7709b --- /dev/null +++ b/Util.php @@ -0,0 +1,98 @@ +get('file', null, $exists); + $fileNameParam = $request->get('fileName') ?: $request->get('name'); + + // 2) 若取不到,则尝试解析原始请求体(常见于 application/json) + if (!$exists || $postBytes === null) { + $raw = file_get_contents('php://input'); + if (is_string($raw) && $raw !== '') { + $json = json_decode($raw, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($json)) { + if (isset($json['file'])) { + $postBytes = $json['file']; + } + if (!$fileNameParam && isset($json['fileName'])) { + $fileNameParam = $json['fileName']; + } elseif (!$fileNameParam && isset($json['name'])) { + $fileNameParam = $json['name']; + } + } + } + } + + if ($postBytes === null) { + throw new \Exception('missing file'); + } + + // 3) 处理可能的多种表示形式 + // - 数字数组(Uint8Array) + // - base64 字符串(data:*;base64,xxx 或纯 base64) + // - 纯二进制字符串(不推荐,但做兜底) + $binary = null; + if (is_array($postBytes)) { + if (empty($postBytes)) { + throw new \Exception('missing file'); + } + $bytes = array_map(static function ($v) { + $v = (int)$v; + if ($v < 0) { + $v = 0; + } + if ($v > 255) { + $v = 255; + } + return $v; + }, $postBytes); + $binary = pack('C*', ...$bytes); + } elseif (is_string($postBytes)) { + // data URL 或 base64 + if (strpos($postBytes, 'base64,') !== false) { + $base64 = substr($postBytes, strpos($postBytes, 'base64,') + 7); + $binary = base64_decode($base64, true); + } else { + // 尝试按 base64 解码,不合法则当作原始二进制 + $decoded = base64_decode($postBytes, true); + $binary = ($decoded !== false) ? $decoded : $postBytes; + } + } else { + throw new \Exception('missing file'); + } + + $name = $fileNameParam ?: 'upload.bin'; + if ($request->isAjax() && $name) { + $name = urldecode($name); + } + + $file = array( + 'name' => $name, + 'bytes' => $binary, + 'size' => strlen($binary), + ); + } else { + $file = array_pop($files); + if (!isset($file['error']) || 0 !== (int)$file['error'] || !is_uploaded_file($file['tmp_name'])) { + throw new \Exception('upload failed'); + } + if ($request->isAjax() && isset($file['name'])) { + $file['name'] = urldecode($file['name']); + } + } + return $file; + } +} diff --git a/phpunit.xml b/phpunit.xml index ab91570..7f5d7ba 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -32,6 +32,7 @@ + diff --git a/tests/RestfulTest.php b/tests/RestfulTest.php index 433cc12..d78c6aa 100644 --- a/tests/RestfulTest.php +++ b/tests/RestfulTest.php @@ -25,7 +25,8 @@ public static function setUpBeforeClass() 'proxy' => false, 'headers' => array( - 'token' => getenv('WEB_SERVER_TOKEN') + 'token' => getenv('WEB_SERVER_TOKEN'), + 'Origin' => 'unit' ) )); @@ -35,6 +36,7 @@ public static function setUpBeforeClass() 'server' => getenv('MYSQL_HOST'), 'username' => getenv('MYSQL_USER'), 'password' => getenv('MYSQL_PWD'), + 'port' => getenv('MYSQL_PORT') )); } @@ -234,4 +236,50 @@ public function testAddMetas() )); $this->assertTrue(is_numeric($count)); } + + public function testUpload() + { + $filePath = __DIR__ . '/test.jpg'; + $this->assertFileExists($filePath, '测试文件不存在'); + + $response = self::$client->post('/index.php/api/upload', array( + 'multipart' => array( + array( + 'name' => 'authorId', + 'contents' => '1', + ), + array( + 'name' => 'file', + 'contents' => fopen($filePath, 'r'), + 'filename' => basename($filePath), + ), + ), + )); + + $result = json_decode($response->getBody(), true); + $this->assertEquals('success', $result['status']); + $this->assertTrue(is_string($result['data']['url'] ?? null)); + + $count = self::$db->count('typecho_contents', array( + 'type' => 'attachment', + 'cid' => $result['data']['cid'] + )); + $this->assertGreaterThan(0, $count, '数据库中应存在该文件'); + + $listResponse = self::$client->get('/index.php/api/fileList?page=1&pageSize=1&authorId=1'); + $listResult = json_decode($listResponse->getBody(), true); + $this->assertEquals('success', $listResult['status']); + $this->assertTrue(is_array($listResult['data'])); + + $delResponse = self::$client->post('/index.php/api/deleteFile', array( + RequestOptions::JSON => array( + 'cid' => $listResult['data']['dataSet'][0]['cid'], + 'pageSize' => '1', + 'authorId' => '1', + ), + )); + $delResult = json_decode($delResponse->getBody(), true); + $this->assertEquals('success', $delResult['status']); + $this->assertTrue($delResult['data']['deleted'] ?? false); + } } diff --git a/tests/Util.php b/tests/Util.php index f2d7a4b..0ce416d 100644 --- a/tests/Util.php +++ b/tests/Util.php @@ -1,4 +1,5 @@ "%s", "password" => "%s", "charset" => "utf8mb4", - "port" => "3306", + "port" => "%s", "database" => "%s", "engine" => "InnoDB", ), Typecho_Db::READ | Typecho_Db::WRITE); @@ -181,7 +184,7 @@ public static function installTypecho() define("WEB_SERVER_PORT", "%s"); define("FORKED_WEB_SERVER_PORT", "%s"); define("IN_PHPUNIT_SERVER", true); -', getenv('MYSQL_HOST'), getenv('MYSQL_USER'), getenv('MYSQL_PWD'), getenv('MYSQL_DB'), getenv('WEB_SERVER_PORT'), getenv('FORKED_WEB_SERVER_PORT')); +', getenv('MYSQL_HOST'), getenv('MYSQL_USER'), getenv('MYSQL_PWD'), getenv('MYSQL_PORT'), getenv('MYSQL_DB'), getenv('WEB_SERVER_PORT'), getenv('FORKED_WEB_SERVER_PORT')); file_put_contents(self::$typechoDir . '/config.inc.php', $configFileContent); @@ -193,6 +196,7 @@ public static function installTypecho() self::mkdirs($pluginDir); copy(__DIR__ . '/../Plugin.php', $pluginDir . '/Plugin.php'); copy(__DIR__ . '/../Action.php', $pluginDir . '/Action.php'); + copy(__DIR__ . '/../Util.php', $pluginDir . '/Util.php'); file_put_contents(self::$typechoDir . '/restful.php', "