Files
geo/integrations/pbootcms/apps/api/controller/GeoPublisherController.php
T

749 lines
25 KiB
PHP
Raw Normal View History

<?php
/**
* Geo SaaS universal publisher endpoint for PBootCMS.
*
* Install path in a PBootCMS site:
* apps/api/controller/GeoPublisherController.php
*
* The controller only depends on PBootCMS core Controller/Model and the
* standard content/sort tables, so users can drop the apps directory into
* the PBootCMS root and use it without extra database migrations.
*/
namespace app\api\controller;
use core\basic\Controller;
use core\basic\Model;
class GeoPublisherController extends Controller
{
const CMS_TYPE = 'pbootcms';
const PLUGIN_VERSION = '1.2.0';
private $model;
private $payload = array();
private $contentFields = null;
public function __construct()
{
if (function_exists('cache_config')) {
cache_config();
}
$this->model = new Model();
$this->payload = $this->readPayload();
$this->assertApiAccess();
}
public function ping()
{
$this->success(array(
'cms_type' => self::CMS_TYPE,
'plugin_version' => self::PLUGIN_VERSION,
'site_url' => $this->siteUrl(),
'site_name' => $this->config('cmsname') ?: 'PBootCMS',
'api_auth' => (bool) $this->config('api_auth'),
'time' => date('Y-m-d H:i:s')
));
}
public function categories()
{
$acode = $this->param('acode', $this->defaultAcode());
$mcode = $this->param('mcode', $this->param('model_hash', '2'));
if (! $this->isSafeCode($acode) || ! $this->isSafeCode($mcode)) {
$this->fail(1400, 'invalid acode or mcode');
}
$rows = $this->model->table('ay_content_sort')
->where("acode='" . $this->sqlValue($acode) . "'")
->where("mcode='" . $this->sqlValue($mcode) . "'")
->where('status=1')
->field('scode,pcode,mcode,name,filename,sorting')
->order('sorting ASC,id ASC')
->select();
$items = array();
if ($rows) {
foreach ($rows as $row) {
$items[] = array(
'id' => (string) $row->scode,
'scode' => (string) $row->scode,
'parent_id' => (string) $row->pcode,
'mcode' => (string) $row->mcode,
'title' => (string) $row->name,
'name' => (string) $row->name,
'filename' => (string) $row->filename,
'sorting' => (int) $row->sorting
);
}
}
$this->success($items);
}
public function publish()
{
$data = $this->buildContentData(false);
$id = $this->model->table('ay_content')->autoTime()->insertGetId($this->filterContentFields($data));
if (! $id) {
$this->fail(1403, 'insert ay_content error', '文章发布失败');
}
$this->clearRuntimeCache();
$this->success(array(
'id' => (int) $id,
'cms_type' => self::CMS_TYPE,
'status' => (int) $data['status'],
'url' => $this->contentUrl(
$id,
$this->param('scode', $this->param('category_id', '')),
$this->param('filename', '')
)
), 'published');
}
public function update()
{
$id = (int) $this->param('id', 0);
if ($id <= 0) {
$this->fail(1404, 'id is empty', '文章 ID 不能为空');
}
$current = $this->model->table('ay_content')->where('id=' . $id)->find();
if (! $current) {
$this->fail(1404, 'content not found', '文章不存在');
}
$data = $this->buildContentData(true, $current);
$ok = $this->model->table('ay_content')->where('id=' . $id)->autoTime()->update($this->filterContentFields($data));
if ($ok === false) {
$this->fail(1403, 'update ay_content error', '文章更新失败');
}
$this->clearRuntimeCache();
$this->success(array(
'id' => $id,
'cms_type' => self::CMS_TYPE,
'status' => (int) $data['status'],
'url' => $this->contentUrl(
$id,
$this->param('scode', $this->param('category_id', $current->scode)),
$this->param('filename', $current->filename)
)
), 'updated');
}
public function status()
{
$id = (int) $this->param('id', 0);
if ($id <= 0) {
$this->fail(1404, 'id is empty', '文章 ID 不能为空');
}
$row = $this->model->table('ay_content')->where('id=' . $id)->find();
if (! $row) {
$this->fail(1404, 'content not found', '文章不存在');
}
$this->success(array(
'id' => (int) $row->id,
'title' => (string) $row->title,
'scode' => (string) $row->scode,
'status' => (int) $row->status,
'date' => (string) $row->date,
'url' => $this->contentUrl($row->id, $row->scode, $row->filename)
));
}
// Backward compatible names used by older Aiseo-style plugins.
public function categoryLists()
{
$this->categories();
}
public function articleAdd()
{
$this->publish();
}
private function buildContentData($isUpdate = false, $current = null)
{
$title = $this->param('title', $isUpdate && $current ? $current->title : '');
if (! $title) {
$this->fail(1404, 'title is empty', '标题不能为空');
}
$content = $this->param('content', $isUpdate && $current ? $current->content : '');
if (! $content) {
$this->fail(1404, 'content is empty', '内容不能为空');
}
$scode = $this->param('scode', $this->param('category_id', $isUpdate && $current ? $current->scode : ''));
if (! $scode) {
$this->fail(1404, 'scode is empty', '内容栏目编码不能为空');
}
if (! $this->isSafeCode($scode)) {
$this->fail(1400, 'invalid scode', '栏目编码不合法');
}
$sort = $this->getSort($scode);
if (! $sort) {
$this->fail(1404, 'sort not found', '栏目不存在或不可用');
}
$content = $this->localizeContentImages($content);
$ico = $this->param('ico', $isUpdate && $current ? $current->ico : '');
if ($ico && ($this->isRemoteUrl($ico) || $this->isDataImageUrl($ico))) {
$ico = $this->localizeImage($ico);
}
$status = $this->param('status', $isUpdate && $current ? $current->status : 1);
if ($status === '' || $status === null) {
$status = 1;
}
return array(
'acode' => $this->sqlValue($this->param('acode', $isUpdate && $current ? $current->acode : $this->defaultAcode())),
'scode' => $this->sqlValue($scode),
'subscode' => $this->sqlValue($this->param('subscode', $isUpdate && $current ? $current->subscode : '')),
'title' => $this->sqlValue($title),
'titlecolor' => $this->sqlValue($this->param('titlecolor', $isUpdate && $current ? $current->titlecolor : '#333333')),
'subtitle' => $this->sqlValue($this->param('subtitle', $isUpdate && $current ? $current->subtitle : '')),
'filename' => $this->sqlValue($this->param('filename', $isUpdate && $current ? $current->filename : '')),
'author' => $this->sqlValue($this->param('author', $isUpdate && $current ? $current->author : 'Geo SaaS')),
'source' => $this->sqlValue($this->param('source', $isUpdate && $current ? $current->source : 'Geo SaaS')),
'outlink' => $this->sqlValue($this->param('outlink', $isUpdate && $current ? $current->outlink : '')),
'date' => $this->sqlValue($this->normalizeDate($this->param('date', $isUpdate && $current ? $current->date : ''))),
'ico' => $this->sqlValue($ico),
'pics' => $this->sqlValue($this->normalizePics($this->param('pics', $isUpdate && $current ? $current->pics : ''))),
'content' => $this->sqlValue($content),
'tags' => $this->sqlValue($this->param('tags', $isUpdate && $current ? $current->tags : '')),
'enclosure' => $this->sqlValue($this->param('enclosure', $isUpdate && $current ? $current->enclosure : '')),
'keywords' => $this->sqlValue($this->param('keywords', $isUpdate && $current ? $current->keywords : '')),
'description' => $this->sqlValue($this->param('description', $isUpdate && $current ? $current->description : $this->summary($content))),
'sorting' => (int) $this->param('sorting', $isUpdate && $current ? $current->sorting : 255),
'status' => (int) $status,
'istop' => (int) $this->param('istop', $isUpdate && $current ? $current->istop : 0),
'isrecommend' => (int) $this->param('isrecommend', $isUpdate && $current ? $current->isrecommend : 0),
'isheadline' => (int) $this->param('isheadline', $isUpdate && $current ? $current->isheadline : 0),
'visits' => $isUpdate && $current ? (int) $current->visits : 0,
'likes' => $isUpdate && $current ? (int) $current->likes : 0,
'oppose' => $isUpdate && $current ? (int) $current->oppose : 0,
'create_user' => $this->sqlValue($isUpdate && $current ? $current->create_user : 'geo_saas'),
'update_user' => $this->sqlValue('geo_saas'),
'gid' => (int) $this->param('gid', $isUpdate && $current ? $current->gid : 0),
'gtype' => (int) $this->param('gtype', $isUpdate && $current ? $current->gtype : 4),
'gnote' => $this->sqlValue($this->param('gnote', $isUpdate && $current ? $current->gnote : '')),
'picstitle' => $this->sqlValue($this->param('picstitle', $isUpdate && $current ? $current->picstitle : ''))
);
}
private function getSort($scode)
{
return $this->model->table('ay_content_sort a')
->field('a.*,m.urlname,m.type')
->join(array(
array('ay_model m', 'a.mcode=m.mcode', 'LEFT')
))
->where("a.scode='" . $this->sqlValue($scode) . "'")
->where('a.status=1')
->find();
}
private function contentUrl($id, $scode, $filename = '')
{
$sort = $this->getSort($scode);
if (! $sort) {
return $this->siteUrl();
}
try {
if (class_exists('\\app\\home\\controller\\ParserController')) {
$parserClass = '\\app\\home\\controller\\ParserController';
$parser = new $parserClass();
$link = $parser->parserLink(
$sort->type,
$sort->urlname,
'content',
$scode,
$sort->filename,
$id,
$filename
);
return $this->absoluteUrl($link);
}
} catch (\Exception $e) {
}
$urlName = $sort->urlname ?: $sort->filename;
if (! $urlName) {
$urlName = $scode;
}
return $this->absoluteUrl('/?' . $urlName . '/' . $id . '.html');
}
private function filterContentFields($data)
{
$fields = $this->contentFields();
if (! $fields) {
return $data;
}
$filtered = array();
foreach ($data as $key => $value) {
if (isset($fields[$key])) {
$filtered[$key] = $value;
}
}
return $filtered;
}
private function contentFields()
{
if ($this->contentFields !== null) {
return $this->contentFields;
}
$this->contentFields = array();
try {
$fields = $this->model->tableFields('ay_content');
if (is_array($fields)) {
foreach ($fields as $field) {
$field = trim((string) $field);
if ($field !== '') {
$this->contentFields[$field] = true;
}
}
}
} catch (\Exception $e) {
$this->contentFields = array();
}
return $this->contentFields;
}
private function readPayload()
{
$payload = array();
$raw = file_get_contents('php://input');
if ($raw) {
$json = json_decode($raw, true);
if (is_array($json)) {
$payload = $json;
}
}
foreach ($_REQUEST as $key => $value) {
$payload[$key] = $value;
}
return $payload;
}
private function param($name, $default = '')
{
if (array_key_exists($name, $this->payload)) {
return $this->payload[$name];
}
return $default;
}
private function assertApiAccess()
{
$config = $this->config();
if (! isset($config['api_open']) || ! $config['api_open']) {
$this->fail(1401, 'api is closed', '系统尚未开启 API 功能');
}
if (! empty($config['api_auth'])) {
if (empty($config['api_appid']) || empty($config['api_secret'])) {
$this->fail(1401, 'api credential is not configured', '后台 API 认证配置不完整');
}
$appid = $this->param('appid', '');
$timestamp = $this->param('timestamp', '');
$signature = $this->param('signature', '');
if (! $appid || ! $timestamp || ! $signature) {
$this->fail(1401, 'missing appid timestamp or signature', '缺少 API 认证参数');
}
if ((string) $appid !== (string) $config['api_appid']) {
$this->fail(1401, 'invalid appid', 'API 用户不正确');
}
if (! is_numeric($timestamp) || abs(time() - (int) $timestamp) > 300) {
$this->fail(1401, 'timestamp expired', 'API 时间戳已过期');
}
$expected = md5(md5($config['api_appid'] . $config['api_secret'] . $timestamp));
if (! $this->hashEquals($expected, $signature)) {
$this->fail(1401, 'invalid signature', 'API 签名不正确');
}
}
}
private function hashEquals($known, $user)
{
if (function_exists('hash_equals')) {
return hash_equals((string) $known, (string) $user);
}
return (string) $known === (string) $user;
}
private function localizeContentImages($content)
{
return preg_replace_callback('/<img\\b[^>]*>/i', function ($imgMatch) {
$tag = $imgMatch[0];
preg_match_all('/\\b(src|data-src|_src)=[\"\']([^\"\']+)[\"\']/i', $tag, $attrMatches, PREG_SET_ORDER);
if (! $attrMatches) {
return $tag;
}
$replacements = array();
foreach ($attrMatches as $attrMatch) {
$url = trim($attrMatch[2]);
if (! $this->isRemoteUrl($url) && ! $this->isDataImageUrl($url)) {
continue;
}
if (! isset($replacements[$url])) {
$replacements[$url] = $this->localizeImage($url);
}
}
foreach ($replacements as $remote => $local) {
$tag = str_replace($remote, $local, $tag);
}
return $tag;
}, $content);
}
private function normalizePics($pics)
{
if (! $pics) {
return '';
}
if (is_array($pics)) {
$items = $pics;
} else {
$items = explode(',', $pics);
}
$result = array();
foreach ($items as $item) {
$item = trim((string) $item);
if (! $item) {
continue;
}
if ($this->isRemoteUrl($item)) {
$item = $this->localizeImage($item);
} elseif ($this->isDataImageUrl($item)) {
$item = $this->localizeImage($item);
}
if ($item) {
$result[] = $item;
}
}
return implode(',', $result);
}
private function localizeImage($value)
{
if ($this->isDataImageUrl($value)) {
$decoded = $this->decodeDataImage($value);
return $this->saveImageData($decoded['data'], $decoded['ext']);
}
return $this->downloadImage($value);
}
private function downloadImage($url)
{
if (! $this->isRemoteUrl($url)) {
return $url;
}
$data = $this->httpGet($url);
if (! $data) {
$this->fail(1405, 'download image failed: ' . $url, '图片下载失败');
}
$path = parse_url($url, PHP_URL_PATH);
if (! $path) {
$path = '';
}
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if (! in_array($ext, array('jpg', 'jpeg', 'png', 'gif', 'webp'))) {
$ext = $this->imageExtensionFromData($data);
}
if (! $ext) {
$ext = 'jpg';
}
return $this->saveImageData($data, $ext);
}
private function saveImageData($data, $ext = '')
{
if (! $data || ! $this->isImageData($data, '')) {
$this->fail(1405, 'invalid image data', '图片数据无效');
}
if (! $ext || ! in_array(strtolower($ext), array('jpg', 'jpeg', 'png', 'gif', 'webp'))) {
$ext = $this->imageExtensionFromData($data);
}
if (! $ext) {
$ext = 'jpg';
}
$relativeDir = '/static/upload/image/' . date('Ymd');
$absoluteDir = $this->rootPath() . $relativeDir;
if (! is_dir($absoluteDir) && ! mkdir($absoluteDir, 0777, true)) {
$this->fail(1405, 'create upload directory failed', '图片保存目录创建失败');
}
$fileName = 'geo_' . date('His') . '_' . mt_rand(1000, 9999) . '.' . $ext;
$absoluteFile = $absoluteDir . '/' . $fileName;
$relativeFile = $relativeDir . '/' . $fileName;
if (file_put_contents($absoluteFile, $data) === false) {
$this->fail(1405, 'save image failed', '图片保存失败');
}
return $relativeFile;
}
private function decodeDataImage($value)
{
if (! preg_match('/^data:image\\/(jpeg|jpg|png|gif|webp);base64,([A-Za-z0-9+\\/\\r\\n=]+)$/i', trim((string) $value), $matches)) {
$this->fail(1405, 'invalid data image', '图片数据无效');
}
$data = base64_decode(preg_replace('/\\s+/', '', $matches[2]), true);
if (! $data) {
$this->fail(1405, 'decode data image failed', '图片数据无效');
}
$ext = strtolower($matches[1]);
if ($ext === 'jpeg') {
$ext = 'jpg';
}
return array('data' => $data, 'ext' => $ext);
}
private function httpGet($url)
{
if (function_exists('curl_init')) {
$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $url);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 8);
curl_setopt($curl, CURLOPT_TIMEOUT, 20);
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($curl, CURLOPT_USERAGENT, 'GeoPublisher/' . self::PLUGIN_VERSION);
$data = curl_exec($curl);
$code = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
$contentType = (string) curl_getinfo($curl, CURLINFO_CONTENT_TYPE);
curl_close($curl);
if ($code >= 200 && $code < 300 && $data && $this->isImageData($data, $contentType)) {
return $data;
}
return false;
}
$context = stream_context_create(array(
'http' => array(
'method' => 'GET',
'timeout' => 20,
'header' => "User-Agent: GeoPublisher/" . self::PLUGIN_VERSION . "\r\n"
)
));
$data = @file_get_contents($url, false, $context);
return $data && $this->isImageData($data, '') ? $data : false;
}
private function isImageData($data, $contentType = '')
{
if ($contentType && stripos($contentType, 'image/') !== false) {
return true;
}
if (function_exists('getimagesizefromstring')) {
if (@getimagesizefromstring($data) !== false) {
return true;
}
}
return $this->isWebPData($data);
}
private function isWebPData($data)
{
return strlen($data) >= 12 && substr($data, 0, 4) === 'RIFF' && substr($data, 8, 4) === 'WEBP';
}
private function imageExtensionFromData($data)
{
if ($this->isWebPData($data)) {
return 'webp';
}
if (! function_exists('getimagesizefromstring')) {
return 'jpg';
}
$info = @getimagesizefromstring($data);
if (! $info || empty($info['mime'])) {
return 'jpg';
}
switch (strtolower($info['mime'])) {
case 'image/jpeg':
return 'jpg';
case 'image/png':
return 'png';
case 'image/gif':
return 'gif';
case 'image/webp':
return 'webp';
default:
return 'jpg';
}
}
private function rootPath()
{
if (defined('ROOT_PATH') && ROOT_PATH) {
return rtrim(ROOT_PATH, '/\\');
}
return rtrim(dirname(dirname(dirname(dirname(__FILE__)))), '/\\');
}
private function normalizeDate($value)
{
if (! $value) {
return date('Y-m-d H:i:s');
}
if (is_numeric($value)) {
return date('Y-m-d H:i:s', (int) $value);
}
$time = strtotime($value);
return $time ? date('Y-m-d H:i:s', $time) : date('Y-m-d H:i:s');
}
private function summary($content)
{
$text = strip_tags($content);
if (function_exists('clear_html_blank')) {
$text = clear_html_blank($text);
}
if (function_exists('substr_both')) {
return substr_both($text, 0, 150);
}
if (function_exists('mb_substr')) {
return mb_substr($text, 0, 150, 'UTF-8');
}
return substr($text, 0, 150);
}
private function defaultAcode()
{
if (function_exists('get_default_lg')) {
return get_default_lg();
}
return 'cn';
}
private function isSafeCode($value)
{
return (bool) preg_match('/^[A-Za-z0-9_\\-]+$/', (string) $value);
}
private function isRemoteUrl($url)
{
return preg_match('/^https?:\\/\\//i', (string) $url);
}
private function isDataImageUrl($url)
{
return preg_match('/^data:image\\/(jpeg|jpg|png|gif|webp);base64,/i', trim((string) $url));
}
private function sqlValue($value)
{
if (is_array($value) || is_object($value)) {
$value = json_encode($value);
}
$value = (string) $value;
if (function_exists('get_magic_quotes_gpc') && get_magic_quotes_gpc()) {
$value = stripslashes($value);
}
return addslashes($value);
}
private function absoluteUrl($link)
{
if (preg_match('/^https?:\\/\\//i', $link)) {
return $link;
}
$base = $this->siteUrl();
if (! $link) {
return $base;
}
return rtrim($base, '/') . '/' . ltrim($link, '/');
}
private function siteUrl()
{
if (function_exists('get_http_url')) {
return get_http_url();
}
$https = isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) === 'on';
$host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '';
return ($https ? 'https://' : 'http://') . $host;
}
private function clearRuntimeCache()
{
if (defined('RUN_PATH') && function_exists('path_delete')) {
@path_delete(RUN_PATH . '/cache');
@path_delete(RUN_PATH . '/complile');
@path_delete(RUN_PATH . '/config');
@path_delete(RUN_PATH . '/upgrade');
}
if (function_exists('cache_config')) {
@cache_config(true);
}
if (function_exists('opcache_reset') && extension_loaded('Zend OPcache')) {
@opcache_reset();
}
}
private function success($data = array(), $msg = '')
{
$this->respond(1, $data, $msg);
}
private function fail($code, $data = '', $msg = '')
{
$this->respond($code, $data, $msg);
}
private function respond($code, $data = '', $msg = '')
{
if (! headers_sent()) {
header('Content-Type: application/json; charset=utf-8');
}
echo json_encode(array(
'code' => $code,
'data' => $data,
'msg' => $msg
));
exit();
}
}