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', "