Skip to content

Commit f5594d5

Browse files
committed
v0.3.0 add site management signature security export and logs
新增 app/Controllers/LogController.php resources/views/dashboard/logs.php resources/views/dashboard/site-edit.php database/upgrade-v0.3.0.sql examples/php-signed-forwarder.php 重点更新 public/index.php app/Controllers/InquiryController.php app/Controllers/SiteController.php app/Controllers/Api/InquiryApiController.php app/Services/InquiryReceiveService.php app/Models/Site.php app/Models/Inquiry.php app/Models/InquiryLog.php resources/views/dashboard/sites.php resources/views/dashboard/inquiries.php resources/views/dashboard/inquiry-detail.php README.md CHANGELOG.md VERSION.txt
1 parent 9d0a872 commit f5594d5

32 files changed

Lines changed: 1153 additions & 166 deletions

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Changelog
22

3+
## v0.3.0
4+
5+
- Added site create and edit functions
6+
- Added API token rotation
7+
- Added signature secret rotation
8+
- Added optional HMAC signature verification per site
9+
- Added system logs page
10+
- Added CSV export from inquiry list
11+
- Added blocked IP delete action
12+
- Updated dashboard with v0.3.0 summary and recent logs
13+
- Added database upgrade script for v0.3.0
14+
- Added signed PHP forwarder example
15+
316
## v0.2.0
417

518
- Added unified receive API endpoint

README.md

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
11
# Inquiry Management System
22

3-
Version: **v0.2.0**
3+
Version: **v0.3.0**
44

55
A pure PHP + MySQL inquiry management system for collecting inquiry forms from multiple websites into one centralized backend.
66

7-
## v0.2.0 Highlights
7+
## v0.3.0 Highlights
88

9-
- Unified receive API is ready: `/api/v1/inquiries/submit`
10-
- `site_key + api_token` validation
11-
- Required field validation for `name`, `email`, `content`
12-
- Stores both `extra_data` and `raw_payload`
13-
- Basic anti-spam checks
14-
- Blocked IP validation
15-
- Inquiry filters and quick status actions in the backend
16-
- GitHub Actions ZIP packaging workflow included
9+
- Site management now supports create, edit, and key rotation
10+
- Optional HMAC request signature verification per site
11+
- Inquiry CSV export is available from the inquiry list page
12+
- System log page is now available
13+
- Blocked IP entries can be removed in the backend
14+
- Health endpoint now returns the application version automatically
1715

1816
## Environment
1917

@@ -31,6 +29,19 @@ A pure PHP + MySQL inquiry management system for collecting inquiry forms from m
3129
4. Point your web root to `public/`
3230
5. Open the project in your browser
3331

32+
## Upgrading from v0.2.0
33+
34+
If you already have a v0.2.0 database, run:
35+
36+
- `database/upgrade-v0.3.0.sql`
37+
38+
This adds:
39+
40+
- `signature_secret`
41+
- `require_signature`
42+
43+
to the `inquiry_sites` table.
44+
3445
## Default Admin Account
3546

3647
- Username: `admin`
@@ -41,7 +52,10 @@ A pure PHP + MySQL inquiry management system for collecting inquiry forms from m
4152
- `/login`
4253
- `/dashboard`
4354
- `/inquiries`
55+
- `/inquiries/export`
4456
- `/sites`
57+
- `/sites/edit?id=1`
58+
- `/logs`
4559
- `/tools/blacklist-ips`
4660
- `/profile`
4761

@@ -91,9 +105,23 @@ Supported payload types:
91105

92106
Unknown fields will also be merged into `extra_data` automatically.
93107

108+
## Signed Request Mode
109+
110+
For sites with **Require HMAC signature** enabled:
111+
112+
- Header: `X-Timestamp` = unix timestamp in seconds
113+
- Header: `X-Signature` = `hash_hmac('sha256', X-Timestamp + "\n" + raw_body, signature_secret)`
114+
115+
Recommended usage:
116+
117+
1. Your website backend builds the final request body
118+
2. Your website backend signs the raw body with the site's signature secret
119+
3. Your website backend sends the request to the central inquiry system
120+
94121
## Example Files
95122

96123
- `examples/php-forwarder.php`
124+
- `examples/php-signed-forwarder.php`
97125
- `examples/javascript-fetch-example.js`
98126

99127
## GitHub Actions
@@ -105,8 +133,8 @@ Workflow file:
105133
It creates a ZIP package automatically when you push a tag like:
106134

107135
```bash
108-
git tag v0.2.0
109-
git push origin v0.2.0
136+
git tag v0.3.0
137+
git push origin v0.3.0
110138
```
111139

112140
## Notes

VERSION.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v0.2.0
1+
v0.3.0

app/Controllers/Api/InquiryApiController.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ public function submit(): void
4444
'user_agent_summary' => substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
4545
'accept_language' => $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '',
4646
'api_token' => $payload['api_token'] ?? '',
47+
'raw_body' => request_raw_body(),
48+
'signature' => request_header('X-Signature'),
49+
'timestamp' => request_header('X-Timestamp'),
4750
]);
4851

4952
json_response($result['body'], $result['status_code']);
@@ -55,7 +58,7 @@ public function health(): void
5558
json_response([
5659
'success' => true,
5760
'message' => 'Inquiry receive API is ready.',
58-
'version' => (string) config('app.api.version', 'v1'),
61+
'version' => app_version(),
5962
'timestamp' => date('c'),
6063
]);
6164
}
@@ -73,7 +76,7 @@ private function sendCorsHeaders(): void
7376
}
7477

7578
header('Access-Control-Allow-Methods: POST, OPTIONS, GET');
76-
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Site-Token');
79+
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Site-Token, X-Signature, X-Timestamp');
7780
header('Access-Control-Max-Age: 86400');
7881
}
7982
}

app/Controllers/DashboardController.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use App\Core\Auth;
88
use App\Core\Controller;
99
use App\Models\Inquiry;
10+
use App\Models\InquiryLog;
1011
use App\Models\Site;
1112

1213
final class DashboardController extends Controller
@@ -15,13 +16,15 @@ public function index(): void
1516
{
1617
$inquiryModel = new Inquiry();
1718
$siteModel = new Site();
19+
$logModel = new InquiryLog();
1820

1921
$this->view('dashboard/index', [
2022
'pageTitle' => 'Dashboard',
2123
'user' => Auth::user(),
2224
'stats' => $inquiryModel->stats(),
2325
'latestInquiries' => $inquiryModel->latest(6),
24-
'sites' => $siteModel->all(),
26+
'sites' => $siteModel->allWithStats(),
27+
'recentLogs' => $logModel->paginate(1, 6)['data'],
2528
'apiEndpoint' => base_url('api/v1/inquiries/submit'),
2629
]);
2730
}

app/Controllers/InquiryController.php

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,7 @@ public function index(): void
1919
$user = Auth::user();
2020
$perPage = max(10, min(100, (int) ($user['page_size'] ?? 20)));
2121

22-
$filters = [
23-
'status' => trim((string) ($_GET['status'] ?? '')),
24-
'site_id' => (int) ($_GET['site_id'] ?? 0),
25-
'keyword' => trim((string) ($_GET['keyword'] ?? '')),
26-
'date_from' => trim((string) ($_GET['date_from'] ?? '')),
27-
'date_to' => trim((string) ($_GET['date_to'] ?? '')),
28-
];
29-
30-
if ($filters['site_id'] === 0) {
31-
$filters['site_id'] = null;
32-
}
22+
$filters = $this->collectFilters();
3323

3424
$inquiryModel = new Inquiry();
3525
$siteModel = new Site();
@@ -48,6 +38,7 @@ public function show(): void
4838
{
4939
$id = (int) ($_GET['id'] ?? 0);
5040
$inquiryModel = new Inquiry();
41+
$logModel = new InquiryLog();
5142
$inquiry = $inquiryModel->find($id);
5243

5344
if (!$inquiry) {
@@ -58,7 +49,7 @@ public function show(): void
5849

5950
if ($inquiry['status'] === 'unread') {
6051
$inquiryModel->updateStatus($id, 'read');
61-
(new InquiryLog())->create($id, Auth::id(), 'viewed', 'Marked as read from detail page');
52+
$logModel->create($id, Auth::id(), 'viewed', 'Marked as read from detail page');
6253
$inquiry = $inquiryModel->find($id);
6354
}
6455

@@ -78,6 +69,7 @@ public function show(): void
7869
'inquiry' => $inquiry,
7970
'extraData' => $extraData,
8071
'rawPayload' => $rawPayload,
72+
'logs' => $logModel->latestForInquiry($id, 8),
8173
'csrfToken' => Csrf::token(),
8274
]);
8375
}
@@ -111,4 +103,40 @@ public function updateStatus(): void
111103
$back = trim((string) ($_POST['back'] ?? 'inquiries'));
112104
redirect($back);
113105
}
106+
107+
public function exportCsv(): void
108+
{
109+
$filters = $this->collectFilters();
110+
$rows = (new Inquiry())->exportRows($filters, 5000);
111+
112+
(new InquiryLog())->create(null, Auth::id(), 'inquiries_exported', 'Exported ' . count($rows) . ' rows as CSV');
113+
114+
send_csv_download(
115+
'inquiries-' . date('Ymd-His') . '.csv',
116+
[
117+
'id', 'site_name', 'form_key', 'status', 'name', 'email', 'title', 'content',
118+
'country', 'phone', 'address', 'from_company', 'source_url', 'referer_url',
119+
'ip', 'browser', 'device_type', 'language', 'admin_note', 'submitted_at',
120+
'created_at', 'updated_at', 'extra_data',
121+
],
122+
$rows
123+
);
124+
}
125+
126+
private function collectFilters(): array
127+
{
128+
$filters = [
129+
'status' => trim((string) ($_GET['status'] ?? '')),
130+
'site_id' => (int) ($_GET['site_id'] ?? 0),
131+
'keyword' => trim((string) ($_GET['keyword'] ?? '')),
132+
'date_from' => trim((string) ($_GET['date_from'] ?? '')),
133+
'date_to' => trim((string) ($_GET['date_to'] ?? '')),
134+
];
135+
136+
if ($filters['site_id'] === 0) {
137+
$filters['site_id'] = null;
138+
}
139+
140+
return $filters;
141+
}
114142
}

app/Controllers/LogController.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Controllers;
6+
7+
use App\Core\Auth;
8+
use App\Core\Controller;
9+
use App\Models\InquiryLog;
10+
11+
final class LogController extends Controller
12+
{
13+
public function index(): void
14+
{
15+
$page = max(1, (int) ($_GET['page'] ?? 1));
16+
$user = Auth::user();
17+
$perPage = max(20, min(100, (int) ($user['page_size'] ?? 20)));
18+
$pagination = (new InquiryLog())->paginate($page, $perPage);
19+
20+
$this->view('dashboard/logs', [
21+
'pageTitle' => 'System Logs',
22+
'pagination' => $pagination,
23+
]);
24+
}
25+
}

0 commit comments

Comments
 (0)