Connect FUC announcements through isolated signed ATOM protocol
This commit is contained in:
parent
fce8b3c9f6
commit
5ab97694d4
@ -13,6 +13,8 @@ use App\Middleware\VerifyNasaToken;
|
|||||||
use Slim\Routing\RouteCollectorProxy;
|
use Slim\Routing\RouteCollectorProxy;
|
||||||
|
|
||||||
return static function (Slim\App $app): void {
|
return static function (Slim\App $app): void {
|
||||||
|
// Independent, disabled-by-default ATOM announcement protocol.
|
||||||
|
$app->post('/api/atom/v1/announcements', App\Controllers\Api\Atom\AnnouncementController::class . ':handle');
|
||||||
|
|
||||||
// test
|
// test
|
||||||
$app->get('/my', App\Controllers\MyController::class . ':index');
|
$app->get('/my', App\Controllers\MyController::class . ':index');
|
||||||
|
|||||||
@ -146,3 +146,11 @@ $_ENV['r2_account_id'] = '';
|
|||||||
$_ENV['r2_access_key_id'] = '';
|
$_ENV['r2_access_key_id'] = '';
|
||||||
$_ENV['r2_access_key_secret'] = '';
|
$_ENV['r2_access_key_secret'] = '';
|
||||||
$_ENV['r2_client_download_timeout'] = 10;
|
$_ENV['r2_client_download_timeout'] = 10;
|
||||||
|
|
||||||
|
// ATOM multi-ship announcement adapter: enable only after applying its additive SQL.
|
||||||
|
// Use a dedicated random >=32-character key for each environment/instance.
|
||||||
|
$_ENV['atom_announcements_enabled'] = false;
|
||||||
|
$_ENV['atom_announcements_key'] = '';
|
||||||
|
$_ENV['atom_announcements_project'] = 'fuc';
|
||||||
|
$_ENV['atom_announcements_environment'] = 'local';
|
||||||
|
$_ENV['atom_announcements_instance'] = 'default';
|
||||||
|
|||||||
13
db/2026.10.26_atom_announcements.sql
Normal file
13
db/2026.10.26_atom_announcements.sql
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
-- Additive migration only. Existing announcements are not modified or imported.
|
||||||
|
CREATE TABLE IF NOT EXISTS `atom_announcement_nonces` (
|
||||||
|
`nonce_hash` char(64) NOT NULL,
|
||||||
|
`expires_at` bigint NOT NULL,
|
||||||
|
PRIMARY KEY (`nonce_hash`), KEY `expires_at` (`expires_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
CREATE TABLE IF NOT EXISTS `atom_announcement_operations` (
|
||||||
|
`operation_id` char(36) NOT NULL,
|
||||||
|
`fingerprint` char(64) NOT NULL,
|
||||||
|
`result` longtext NOT NULL,
|
||||||
|
`created_at` bigint NOT NULL,
|
||||||
|
PRIMARY KEY (`operation_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
21
docs/maintenance/ATOM_ANNOUNCEMENTS.md
Normal file
21
docs/maintenance/ATOM_ANNOUNCEMENTS.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# ATOM 公告接收适配器
|
||||||
|
|
||||||
|
2026-10-26本地实现与隔离联调完成,未部署/启用生产。ATOM界面、通用协议、双端操作与回滚唯一正文见[Ship公告专题](/Users/hellcat/Documents/GemCodes/atom/codex/output/docs/ship-announcements.md)。此处只维护FUC接收端配置与业务影响。
|
||||||
|
|
||||||
|
`app/routes.php` 注册 POST `/api/atom/v1/announcements` → `Controllers/Api/Atom/AnnouncementController` → `Services/Atom/AnnouncementBridge`。使用独立HMAC、nonce、精确project/environment/instance,不沿用旧Nasa API或管理员会话;默认关闭。公告保留在原`announcement`表,支持list/get/create/update,status=0撤下,不物理删除;普通编辑保留date和post,显式refresh_date才刷新日期。标题去标签、正文AntiXSS清理;不调用支付、邮件、Telegram或Cron。原日报仍依已有计划可包含可见公告。
|
||||||
|
|
||||||
|
开启前在目标库备份后执行追加SQL `db/2026.10.26_atom_announcements.sql`(InnoDB nonce和操作回执表,IF NOT EXISTS,不删除/重写业务数据)。在私有`.config.php`中配置下列字段,样例也见`config/.config.example.php`:
|
||||||
|
|
||||||
|
- atom_announcements_enabled:布尔true才开启;回滚设false。
|
||||||
|
- atom_announcements_key:每个环境/实例独立至少32字节随机秘密,安全交给ATOM目标encrypted字段,不放文档或日志。
|
||||||
|
- atom_announcements_project:对应ATOM project slug,默认fuc。
|
||||||
|
- atom_announcements_environment:local或production,样例local,生产需显式改。
|
||||||
|
- atom_announcements_instance:对应登记实例,默认default。
|
||||||
|
|
||||||
|
`AnnouncementBridge`直接使用已初始化的default数据库连接。生产必须核实announcement表为事务引擎InnoDB;非事务表不得启用此适配器,否则无法保证公告/回执原子性。以现有路由加载现场核实,不为验证执行真实Cron或支付。生产首次服务器侧只读检查capabilities/list/get、原公告数量和内容;写公告需独立内容确认。生产发布仍须Git明确提交、私有配置恢复点及数据库备份,不以本地测试代替线上验收。
|
||||||
|
|
||||||
|
幂等:操作UUID+正文SHA256事务预留并保存完整回执;相同UUID相同内容重试返回原回执,内容变化拒绝。更新先锁行并校验内容version;其他入口修改后拒绝覆盖。nonce防重放与操作去重用途不同。回执表不能按nonce过期策略清理,否则失去重试去重保障。
|
||||||
|
|
||||||
|
本次另修复Ann::visible及UserController首页/列表/未读、Services/Cron日报仅取status=1;隐藏后不在用户入口出现,全部隐藏时未读不报错。保留原后台/Telegram入口,不迁移旧Nasa权限。
|
||||||
|
|
||||||
|
验证:`php vendor/bin/phpunit tests/App/Services/Atom/AnnouncementBridgeTest.php --do-not-cache-result`,6 tests / 47 assertions,内存SQLite,无真实配置/通知。双ship真实HTTP隔离11项通过;ATOM本地桌面页面新增隐藏公告确认回执通过;尚未做移动视口或生产验收。MariaDB真实FUC表引擎、生产HTTPS链路尚待获批发布时核实,不能称已接通线上。
|
||||||
30
src/Controllers/Api/Atom/AnnouncementController.php
Normal file
30
src/Controllers/Api/Atom/AnnouncementController.php
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers\Api\Atom;
|
||||||
|
|
||||||
|
use App\Models\Ann;
|
||||||
|
use App\Services\Atom\AnnouncementBridge;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
use Slim\Http\ServerRequest;
|
||||||
|
|
||||||
|
final class AnnouncementController
|
||||||
|
{
|
||||||
|
public function handle(ServerRequest $request, Response $response, array $args = []): ResponseInterface
|
||||||
|
{
|
||||||
|
$settings = [];
|
||||||
|
foreach (['enabled', 'key', 'project', 'environment', 'instance'] as $field) {
|
||||||
|
$settings[$field] = $_ENV['atom_announcements_'.$field] ?? null;
|
||||||
|
}
|
||||||
|
$bridge = new AnnouncementBridge(Ann::resolveConnection('default'), $settings);
|
||||||
|
$result = $bridge->handle($request->getMethod(), $request->getUri()->getPath(), $request->getUri()->getQuery(), (string) $request->getBody(), [
|
||||||
|
'time' => $request->getHeaderLine('X-Ship-Time'), 'nonce' => $request->getHeaderLine('X-Ship-Nonce'), 'signature' => $request->getHeaderLine('X-Ship-Signature'),
|
||||||
|
]);
|
||||||
|
foreach ($result['headers'] as $name => $value) {
|
||||||
|
$response = $response->withHeader($name, $value);
|
||||||
|
}
|
||||||
|
return $response->withStatus($result['status'])->write($result['body']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -44,8 +44,8 @@ final class UserController extends BaseController
|
|||||||
$captcha = Captcha::generate();
|
$captcha = Captcha::generate();
|
||||||
}
|
}
|
||||||
|
|
||||||
$oAnn = Ann::where("sort", ">=", 1)->orderBy('date', 'desc')->first();
|
$oAnn = Ann::visible()->where("sort", ">=", 1)->orderByDesc('sort')->orderBy('date', 'desc')->first();
|
||||||
$oAnnLast = Ann::where("sort", 0)->orderBy('date', 'desc')->first();
|
$oAnnLast = Ann::visible()->where("sort", 0)->orderBy('date', 'desc')->first();
|
||||||
|
|
||||||
// $oHuodong = Huodong::DateHit();
|
// $oHuodong = Huodong::DateHit();
|
||||||
|
|
||||||
@ -216,7 +216,7 @@ final class UserController extends BaseController
|
|||||||
$this->user->ann_last_time = time();
|
$this->user->ann_last_time = time();
|
||||||
$this->user->save();
|
$this->user->save();
|
||||||
|
|
||||||
$anns = (new Ann())->where("status", 1)->orderBy('date', 'desc')->get();
|
$anns = (new Ann())->visible()->orderByDesc('sort')->orderBy('date', 'desc')->get();
|
||||||
|
|
||||||
// cmd端需要做验证,不然传错就bug了
|
// cmd端需要做验证,不然传错就bug了
|
||||||
$aMapIcon = [
|
$aMapIcon = [
|
||||||
@ -239,8 +239,8 @@ final class UserController extends BaseController
|
|||||||
$bNotifiHas = false;
|
$bNotifiHas = false;
|
||||||
$aNotifi = [];
|
$aNotifi = [];
|
||||||
|
|
||||||
$sAnnDate = (new Ann())->orderBy('date', 'desc')->first()?->date;
|
$sAnnDate = (new Ann())->visible()->orderBy('date', 'desc')->first()?->date;
|
||||||
$sAnnTime = strtotime($sAnnDate);
|
$sAnnTime = $sAnnDate !== null ? strtotime($sAnnDate) : 0;
|
||||||
|
|
||||||
if ($sAnnTime > $this->user->ann_last_time) {
|
if ($sAnnTime > $this->user->ann_last_time) {
|
||||||
$aNotifi[] = [
|
$aNotifi[] = [
|
||||||
|
|||||||
@ -18,6 +18,12 @@ final class Ann extends Model
|
|||||||
protected $connection = 'default';
|
protected $connection = 'default';
|
||||||
protected $table = 'announcement';
|
protected $table = 'announcement';
|
||||||
|
|
||||||
|
public function scopeVisible($query)
|
||||||
|
{
|
||||||
|
return $query->where('status', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public function setRankAttribute($value)
|
public function setRankAttribute($value)
|
||||||
{
|
{
|
||||||
$allowedStatuses = ['none', 'warning', 'danger', 'success'];
|
$allowedStatuses = ['none', 'warning', 'danger', 'success'];
|
||||||
|
|||||||
219
src/Services/Atom/AnnouncementBridge.php
Normal file
219
src/Services/Atom/AnnouncementBridge.php
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Atom;
|
||||||
|
|
||||||
|
use Illuminate\Database\Connection;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
|
use voku\helper\AntiXSS;
|
||||||
|
|
||||||
|
/** A narrow, independently authenticated adapter. It never calls notification services. */
|
||||||
|
final class AnnouncementBridge
|
||||||
|
{
|
||||||
|
public const SCHEMA = 'ship-announcements.v1';
|
||||||
|
public const PATH = '/api/atom/v1/announcements';
|
||||||
|
private array $settings;
|
||||||
|
|
||||||
|
public function __construct(private Connection $db, array $settings)
|
||||||
|
{
|
||||||
|
$this->settings = $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canonical(array $data): string
|
||||||
|
{
|
||||||
|
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function signature(string $key, string $time, string $nonce, string $method, string $path, string $body): string
|
||||||
|
{
|
||||||
|
return hash_hmac('sha256', $time."\n".$nonce."\n".$method."\n".$path."\n".hash('sha256', $body), $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{status:int,body:string,headers:array} */
|
||||||
|
public function handle(string $method, string $path, string $query, string $body, array $headers): array
|
||||||
|
{
|
||||||
|
$authenticated = false;
|
||||||
|
$time = $headers['time'] ?? '';
|
||||||
|
$nonce = $headers['nonce'] ?? '';
|
||||||
|
$key = (string) ($this->settings['key'] ?? '');
|
||||||
|
try {
|
||||||
|
if (($this->settings['enabled'] ?? false) !== true || strlen($key) < 32) {
|
||||||
|
throw new AnnouncementException(503, 'adapter_disabled');
|
||||||
|
}
|
||||||
|
if ($method !== 'POST' || $path !== self::PATH || $query !== '' || strlen($body) > 131072) {
|
||||||
|
throw new AnnouncementException(400, 'invalid_request');
|
||||||
|
}
|
||||||
|
if (! preg_match('/^\d{10}$/D', $time) || abs(time() - (int) $time) > 300
|
||||||
|
|| ! preg_match('/^[a-f0-9]{32}$/D', $nonce)
|
||||||
|
|| ! hash_equals(self::signature($key, $time, $nonce, $method, $path, $body), $headers['signature'] ?? '')) {
|
||||||
|
throw new AnnouncementException(401, 'invalid_signature');
|
||||||
|
}
|
||||||
|
$authenticated = true;
|
||||||
|
$data = json_decode($body, true, 32, JSON_THROW_ON_ERROR);
|
||||||
|
if (! is_array($data) || ($data['schema'] ?? '') !== self::SCHEMA) {
|
||||||
|
throw new AnnouncementException(422, 'invalid_schema');
|
||||||
|
}
|
||||||
|
foreach (['project', 'environment', 'instance'] as $field) {
|
||||||
|
if (! is_string($this->settings[$field] ?? null) || $this->settings[$field] === '' || ($data[$field] ?? null) !== $this->settings[$field]) {
|
||||||
|
throw new AnnouncementException(403, 'target_mismatch');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! in_array($data['environment'], ['local', 'production'], true)) {
|
||||||
|
throw new AnnouncementException(403, 'environment_mismatch');
|
||||||
|
}
|
||||||
|
// Unique nonce is claimed before doing any operation. No session or legacy Nasa key is accepted.
|
||||||
|
$this->db->table('atom_announcement_nonces')->where('expires_at', '<', time())->delete();
|
||||||
|
try {
|
||||||
|
$this->db->table('atom_announcement_nonces')->insert(['nonce_hash' => hash('sha256', $nonce), 'expires_at' => time() + 600]);
|
||||||
|
} catch (QueryException $e) {
|
||||||
|
if ($this->db->table('atom_announcement_nonces')->where('nonce_hash', hash('sha256', $nonce))->exists()) {
|
||||||
|
throw new AnnouncementException(409, 'nonce_replayed');
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
$action = $data['action'] ?? '';
|
||||||
|
$result = match ($action) {
|
||||||
|
'capabilities' => ['actions' => ['list', 'get', 'create', 'update'], 'notifications' => false, 'withdrawal' => 'status=0'],
|
||||||
|
'list' => $this->listing($data),
|
||||||
|
'get' => ['announcement' => $this->record($this->find($this->id($data)))],
|
||||||
|
'create', 'update' => $this->write($data, $body),
|
||||||
|
default => throw new AnnouncementException(422, 'unsupported_action'),
|
||||||
|
};
|
||||||
|
return $this->response(200, ['ok' => true, 'data' => $result], $key, $time, $nonce);
|
||||||
|
} catch (AnnouncementException $e) {
|
||||||
|
return $this->response($e->status, ['ok' => false, 'error' => $e->reason], $authenticated ? $key : '', $time, $nonce);
|
||||||
|
} catch (\JsonException) {
|
||||||
|
return $this->response(422, ['ok' => false, 'error' => 'invalid_json'], $authenticated ? $key : '', $time, $nonce);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// SQL bindings and original exception text must never leave this adapter.
|
||||||
|
return $this->response(503, ['ok' => false, 'error' => 'adapter_unavailable'], $authenticated ? $key : '', $time, $nonce);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function response(int $status, array $data, string $key, string $time, string $nonce): array
|
||||||
|
{
|
||||||
|
$body = self::canonical(['schema' => self::SCHEMA, 'project' => $this->settings['project'] ?? '', 'environment' => $this->settings['environment'] ?? '', 'instance' => $this->settings['instance'] ?? '', ...$data]);
|
||||||
|
$headers = ['Cache-Control' => 'no-store', 'Content-Type' => 'application/json; charset=utf-8'];
|
||||||
|
if ($key !== '') {
|
||||||
|
$headers['X-Ship-Signature'] = self::signature($key, $time, $nonce, 'RESPONSE', (string) $status, $body);
|
||||||
|
}
|
||||||
|
return compact('status', 'body', 'headers');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function id(array $data): int
|
||||||
|
{
|
||||||
|
if (! is_int($data['id'] ?? null) || $data['id'] < 1) {
|
||||||
|
throw new AnnouncementException(422, 'invalid_id');
|
||||||
|
}
|
||||||
|
return $data['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function find(int $id, bool $lock = false): object
|
||||||
|
{
|
||||||
|
$q = $this->db->table('announcement')->where('id', $id);
|
||||||
|
$row = ($lock ? $q->lockForUpdate() : $q)->first();
|
||||||
|
if (! $row) {
|
||||||
|
throw new AnnouncementException(404, 'announcement_not_found');
|
||||||
|
}
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function record(object $row): array
|
||||||
|
{
|
||||||
|
$data = ['id' => (int) $row->id, 'title' => (string) $row->title, 'content' => (string) $row->content,
|
||||||
|
'rank' => (string) $row->rank, 'sort' => (int) $row->sort, 'status' => (int) $row->status,
|
||||||
|
'date' => (string) $row->date, 'post' => (int) $row->post];
|
||||||
|
return [...$data, 'version' => hash('sha256', self::canonical($data))];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listing(array $data): array
|
||||||
|
{
|
||||||
|
$page = $data['page'] ?? 1;
|
||||||
|
if (! is_int($page) || $page < 1 || $page > 100000) {
|
||||||
|
throw new AnnouncementException(422, 'invalid_page');
|
||||||
|
}
|
||||||
|
$q = $this->db->table('announcement');
|
||||||
|
$total = $q->count();
|
||||||
|
$rows = $q->orderByDesc('sort')->orderByDesc('date')->orderByDesc('id')->offset(($page - 1) * 20)->limit(20)->get();
|
||||||
|
$items = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$item = $this->record($row);
|
||||||
|
$item['summary'] = mb_substr(strip_tags($item['content']), 0, 200);
|
||||||
|
unset($item['content']);
|
||||||
|
$items[] = $item;
|
||||||
|
}
|
||||||
|
return ['items' => $items, 'page' => $page, 'per_page' => 20, 'total' => $total];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fields(array $data): array
|
||||||
|
{
|
||||||
|
$fields = $data['fields'] ?? null;
|
||||||
|
if (! is_array($fields) || array_diff(array_keys($fields), ['title', 'content', 'rank', 'sort', 'status'])) {
|
||||||
|
throw new AnnouncementException(422, 'invalid_fields');
|
||||||
|
}
|
||||||
|
if (! is_string($fields['title'] ?? null) || trim($fields['title']) === '' || mb_strlen($fields['title']) > 200
|
||||||
|
|| ! is_string($fields['content'] ?? null) || trim($fields['content']) === '' || strlen($fields['content']) > 65536
|
||||||
|
|| ! in_array($fields['rank'] ?? null, ['none', 'success', 'warning', 'danger'], true)
|
||||||
|
|| ! is_int($fields['sort'] ?? null) || $fields['sort'] < 0 || $fields['sort'] > 9999
|
||||||
|
|| ! in_array($fields['status'] ?? null, [0, 1], true)) {
|
||||||
|
throw new AnnouncementException(422, 'invalid_fields');
|
||||||
|
}
|
||||||
|
$fields['content'] = (new AntiXSS())->xss_clean($fields['content']);
|
||||||
|
$fields['title'] = strip_tags($fields['title']);
|
||||||
|
if (trim($fields['title']) === '' || trim(strip_tags($fields['content'])) === '') {
|
||||||
|
throw new AnnouncementException(422, 'empty_announcement');
|
||||||
|
}
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function write(array $data, string $body): array
|
||||||
|
{
|
||||||
|
$operation = $data['operation'] ?? '';
|
||||||
|
if (! is_string($operation) || ! preg_match('/^[a-f0-9-]{36}$/D', $operation)) {
|
||||||
|
throw new AnnouncementException(422, 'invalid_operation');
|
||||||
|
}
|
||||||
|
$fingerprint = hash('sha256', $body);
|
||||||
|
try {
|
||||||
|
return $this->db->transaction(function () use ($data, $operation, $fingerprint) {
|
||||||
|
$old = $this->db->table('atom_announcement_operations')->where('operation_id', $operation)->lockForUpdate()->first();
|
||||||
|
if ($old) {
|
||||||
|
return $this->replay($old, $fingerprint);
|
||||||
|
}
|
||||||
|
// Reserve before modifying announcement; unique PK serializes simultaneous retries.
|
||||||
|
$this->db->table('atom_announcement_operations')->insert(['operation_id' => $operation, 'fingerprint' => $fingerprint, 'result' => '{}', 'created_at' => time()]);
|
||||||
|
$fields = $this->fields($data);
|
||||||
|
if ($data['action'] === 'create') {
|
||||||
|
$id = $this->db->table('announcement')->insertGetId([...$fields, 'date' => date('Y-m-d H:i:s'), 'post' => 0]);
|
||||||
|
} else {
|
||||||
|
$id = $this->id($data);
|
||||||
|
$old = $this->record($this->find($id, true));
|
||||||
|
if (! is_string($data['expected_version'] ?? null) || ! hash_equals($old['version'], $data['expected_version'])) {
|
||||||
|
throw new AnnouncementException(409, 'version_conflict');
|
||||||
|
}
|
||||||
|
if (($data['refresh_date'] ?? false) === true) {
|
||||||
|
$fields['date'] = date('Y-m-d H:i:s');
|
||||||
|
}
|
||||||
|
$this->db->table('announcement')->where('id', $id)->update($fields);
|
||||||
|
}
|
||||||
|
$result = ['operation' => $operation, 'announcement' => $this->record($this->find($id))];
|
||||||
|
$this->db->table('atom_announcement_operations')->where('operation_id', $operation)->update(['result' => self::canonical($result)]);
|
||||||
|
return $result;
|
||||||
|
});
|
||||||
|
} catch (QueryException $e) {
|
||||||
|
$old = $this->db->table('atom_announcement_operations')->where('operation_id', $operation)->first();
|
||||||
|
if ($old) {
|
||||||
|
return $this->replay($old, $fingerprint);
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function replay(object $old, string $fingerprint): array
|
||||||
|
{
|
||||||
|
if (! hash_equals($old->fingerprint, $fingerprint)) {
|
||||||
|
throw new AnnouncementException(409, 'operation_payload_changed');
|
||||||
|
}
|
||||||
|
return json_decode($old->result, true, 32, JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/Services/Atom/AnnouncementException.php
Normal file
13
src/Services/Atom/AnnouncementException.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Atom;
|
||||||
|
|
||||||
|
final class AnnouncementException extends \RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(public readonly int $status, public readonly string $reason)
|
||||||
|
{
|
||||||
|
parent::__construct($reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2566,7 +2566,7 @@ final class Cron
|
|||||||
public static function sendDailyTrafficReport(): void
|
public static function sendDailyTrafficReport(): void
|
||||||
{
|
{
|
||||||
$users = (new User())->whereIn('daily_mail_enable', [1, 2])->get();
|
$users = (new User())->whereIn('daily_mail_enable', [1, 2])->get();
|
||||||
$ann_latest_raw = (new Ann())->orderBy('date', 'desc')->first();
|
$ann_latest_raw = (new Ann())->visible()->orderBy('date', 'desc')->first();
|
||||||
|
|
||||||
if ($ann_latest_raw === null) {
|
if ($ann_latest_raw === null) {
|
||||||
$ann_latest = '<br><br>';
|
$ann_latest = '<br><br>';
|
||||||
|
|||||||
129
tests/App/Services/Atom/AnnouncementBridgeTest.php
Normal file
129
tests/App/Services/Atom/AnnouncementBridgeTest.php
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\App\Services\Atom;
|
||||||
|
|
||||||
|
use App\Models\Ann;
|
||||||
|
use App\Services\Atom\AnnouncementBridge;
|
||||||
|
use Illuminate\Database\Capsule\Manager;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class AnnouncementBridgeTest extends TestCase
|
||||||
|
{
|
||||||
|
private Manager $db;
|
||||||
|
private array $config;
|
||||||
|
private AnnouncementBridge $bridge;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->db = new Manager();
|
||||||
|
$this->db->addConnection(['driver' => 'sqlite', 'database' => ':memory:']);
|
||||||
|
$this->db->setAsGlobal();
|
||||||
|
$this->db->bootEloquent();
|
||||||
|
foreach ([
|
||||||
|
'announcement' => 'id integer primary key autoincrement, title text, content text, rank text, sort integer, status integer, date text, post integer',
|
||||||
|
'atom_announcement_nonces' => 'nonce_hash text primary key, expires_at integer',
|
||||||
|
'atom_announcement_operations' => 'operation_id text primary key, fingerprint text, result text, created_at integer',
|
||||||
|
] as $name => $columns) {
|
||||||
|
$this->db->getConnection()->statement('CREATE TABLE '.$name.' ('.$columns.')');
|
||||||
|
}
|
||||||
|
$this->config = ['enabled' => true, 'key' => str_repeat('fixture-key-', 4), 'project' => 'fuc', 'environment' => 'local', 'instance' => 'default'];
|
||||||
|
$this->bridge = new AnnouncementBridge($this->db->getConnection(), $this->config);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function envelope(array $data): array
|
||||||
|
{
|
||||||
|
return ['schema' => AnnouncementBridge::SCHEMA, 'project' => 'fuc', 'environment' => 'local', 'instance' => 'default', ...$data];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function request(array $data, ?array $headers = null): array
|
||||||
|
{
|
||||||
|
$body = AnnouncementBridge::canonical($this->envelope($data));
|
||||||
|
$time = (string) time(); $nonce = bin2hex(random_bytes(16));
|
||||||
|
$headers ??= ['time' => $time, 'nonce' => $nonce, 'signature' => AnnouncementBridge::signature($this->config['key'], $time, $nonce, 'POST', AnnouncementBridge::PATH, $body)];
|
||||||
|
$result = $this->bridge->handle('POST', AnnouncementBridge::PATH, '', $body, $headers);
|
||||||
|
if (isset($result['headers']['X-Ship-Signature'])) {
|
||||||
|
self::assertSame(AnnouncementBridge::signature($this->config['key'], $headers['time'], $headers['nonce'], 'RESPONSE', (string) $result['status'], $result['body']), $result['headers']['X-Ship-Signature']);
|
||||||
|
}
|
||||||
|
return ['status' => $result['status'], ...json_decode($result['body'], true)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function create(array $overrides = []): array
|
||||||
|
{
|
||||||
|
return ['action' => 'create', 'operation' => 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', 'fields' => ['title' => '公告', 'content' => '<p>测试公告</p>', 'rank' => 'none', 'sort' => 0, 'status' => 1], ...$overrides];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_disabled_and_unsigned_requests_fail_closed_without_database_changes(): void
|
||||||
|
{
|
||||||
|
$off = new AnnouncementBridge($this->db->getConnection(), ['enabled' => false]);
|
||||||
|
self::assertSame(503, $off->handle('POST', AnnouncementBridge::PATH, '', '{}', [])['status']);
|
||||||
|
self::assertSame(401, $this->request(['action' => 'list'], [])['status']);
|
||||||
|
self::assertSame(0, $this->db->table('announcement')->count());
|
||||||
|
self::assertSame(0, $this->db->table('atom_announcement_nonces')->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_signature_binds_body_and_nonce_and_time(): void
|
||||||
|
{
|
||||||
|
$body = AnnouncementBridge::canonical($this->envelope(['action' => 'list']));
|
||||||
|
$time = (string) time(); $nonce = bin2hex(random_bytes(16));
|
||||||
|
$h = ['time' => $time, 'nonce' => $nonce, 'signature' => AnnouncementBridge::signature($this->config['key'], $time, $nonce, 'POST', AnnouncementBridge::PATH, $body)];
|
||||||
|
self::assertSame(200, $this->request(['action' => 'list'], $h)['status']);
|
||||||
|
self::assertSame('nonce_replayed', $this->request(['action' => 'list'], $h)['error']);
|
||||||
|
self::assertSame(401, $this->request($this->create(), $h)['status']);
|
||||||
|
$h['time'] = (string) (time() - 600);
|
||||||
|
$h['signature'] = AnnouncementBridge::signature($this->config['key'], $h['time'], $nonce, 'POST', AnnouncementBridge::PATH, $body);
|
||||||
|
self::assertSame(401, $this->request(['action' => 'list'], $h)['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_environment_project_and_instance_are_separate_even_with_same_key(): void
|
||||||
|
{
|
||||||
|
foreach (['project' => 'other-ship', 'environment' => 'production', 'instance' => 'another'] as $key => $value) {
|
||||||
|
self::assertSame(403, $this->request(['action' => 'list', $key => $value])['status']);
|
||||||
|
}
|
||||||
|
self::assertSame(0, $this->db->table('atom_announcement_nonces')->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_retry_is_exactly_once_and_changed_payload_cannot_reuse_operation(): void
|
||||||
|
{
|
||||||
|
$first = $this->request($this->create());
|
||||||
|
self::assertSame(200, $first['status']);
|
||||||
|
self::assertSame($first['data'], $this->request($this->create())['data']);
|
||||||
|
self::assertSame(1, $this->db->table('announcement')->count());
|
||||||
|
$changed = $this->create(); $changed['fields']['title'] = '不同内容';
|
||||||
|
self::assertSame('operation_payload_changed', $this->request($changed)['error']);
|
||||||
|
self::assertSame(1, $this->db->table('announcement')->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_native_edit_conflicts_and_withdrawn_announcements_are_not_visible(): void
|
||||||
|
{
|
||||||
|
$a = $this->request($this->create())['data']['announcement'];
|
||||||
|
$this->db->table('announcement')->where('id', $a['id'])->update(['title' => '由原后台修改']);
|
||||||
|
$update = ['action' => 'update', 'id' => $a['id'], 'operation' => 'bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee', 'expected_version' => $a['version'], 'fields' => $this->create()['fields']];
|
||||||
|
self::assertSame('version_conflict', $this->request($update)['error']);
|
||||||
|
self::assertSame('由原后台修改', $this->db->table('announcement')->value('title'));
|
||||||
|
$fresh = $this->request(['action' => 'get', 'id' => $a['id']])['data']['announcement'];
|
||||||
|
$update['expected_version'] = $fresh['version']; $update['fields']['status'] = 0;
|
||||||
|
$result = $this->request($update);
|
||||||
|
self::assertSame(200, $result['status']);
|
||||||
|
self::assertSame($a['date'], $result['data']['announcement']['date']);
|
||||||
|
self::assertSame(0, Ann::visible()->count());
|
||||||
|
self::assertSame(1, Ann::count());
|
||||||
|
self::assertSame(0, $this->db->table('announcement')->value('post'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_sanitization_and_validation_do_not_modify_unrelated_rows(): void
|
||||||
|
{
|
||||||
|
$data = $this->create(); $data['fields']['content'] = '<p>正文<script>alert(1)</script></p>';
|
||||||
|
$a = $this->request($data);
|
||||||
|
self::assertSame(200, $a['status']);
|
||||||
|
self::assertStringNotContainsString('<script', $a['data']['announcement']['content']);
|
||||||
|
$data['operation'] = 'cccccccc-bbbb-4ccc-8ddd-eeeeeeeeeeee'; $data['fields']['status'] = 2;
|
||||||
|
self::assertSame(422, $this->request($data)['status']);
|
||||||
|
self::assertSame(1, $this->db->table('announcement')->count());
|
||||||
|
$list = $this->request(['action' => 'list']);
|
||||||
|
self::assertArrayNotHasKey('content', $list['data']['items'][0]);
|
||||||
|
self::assertSame(1, $list['data']['total']);
|
||||||
|
self::assertSame(422, $this->request(['action' => 'delete', 'id' => 1])['status']);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
tests/Fixtures/atom-announcements-router.php
Normal file
25
tests/Fixtures/atom-announcements-router.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
// Local test server only. Never includes the site's configuration or Web/Cron bootstrap.
|
||||||
|
declare(strict_types=1);
|
||||||
|
require dirname(__DIR__, 2).'/vendor/autoload.php';
|
||||||
|
if (getenv('SHIP_ANNOUNCEMENTS_FIXTURE') !== '1') { http_response_code(503); exit; }
|
||||||
|
if (parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) === '/__ready') { echo 'fixture-ready'; exit; }
|
||||||
|
$db = new Illuminate\Database\Capsule\Manager();
|
||||||
|
$db->addConnection(['driver' => 'sqlite', 'database' => getenv('SHIP_FIXTURE_DB')]);
|
||||||
|
$db->setAsGlobal();
|
||||||
|
$c = $db->getConnection();
|
||||||
|
$c->statement('CREATE TABLE IF NOT EXISTS announcement (id integer primary key autoincrement, title text, content text, rank text, sort integer, status integer, date text, post integer)');
|
||||||
|
$c->statement('CREATE TABLE IF NOT EXISTS atom_announcement_nonces (nonce_hash text primary key, expires_at integer)');
|
||||||
|
$c->statement('CREATE TABLE IF NOT EXISTS atom_announcement_operations (operation_id text primary key, fingerprint text, result text, created_at integer)');
|
||||||
|
if ($c->table('announcement')->count() === 0) { $c->table('announcement')->insert(['title' => '原有公告(本地测试数据)', 'content' => '<p>原有内容</p>', 'rank' => 'none', 'sort' => 0, 'status' => 1, 'date' => '2026-10-01 00:00:00', 'post' => 0]); }
|
||||||
|
$bridge = new App\Services\Atom\AnnouncementBridge($c, ['enabled' => true, 'key' => getenv('SHIP_FIXTURE_KEY'), 'project' => getenv('SHIP_FIXTURE_PROJECT'), 'environment' => 'local', 'instance' => getenv('SHIP_FIXTURE_INSTANCE')]);
|
||||||
|
$body = file_get_contents('php://input');
|
||||||
|
$result = $bridge->handle($_SERVER['REQUEST_METHOD'], parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), $_SERVER['QUERY_STRING'] ?? '', $body, ['time' => $_SERVER['HTTP_X_SHIP_TIME'] ?? '', 'nonce' => $_SERVER['HTTP_X_SHIP_NONCE'] ?? '', 'signature' => $_SERVER['HTTP_X_SHIP_SIGNATURE'] ?? '']);
|
||||||
|
// Deliberately lose one post-commit acknowledgement to exercise durable idempotent recovery.
|
||||||
|
$drop = getenv('SHIP_FIXTURE_DROP_FIRST');
|
||||||
|
if ($result['status'] === 200 && (json_decode($body, true)['action'] ?? '') === 'create' && $drop && is_file($drop)) {
|
||||||
|
unlink($drop); http_response_code(503); echo 'LOCAL_FIXTURE_LOST_ACK'; exit;
|
||||||
|
}
|
||||||
|
http_response_code($result['status']);
|
||||||
|
foreach ($result['headers'] as $k => $v) { header($k.': '.$v); }
|
||||||
|
echo $result['body'];
|
||||||
Loading…
Reference in New Issue
Block a user