面向公开仓库脱敏:高德 URI API 的 src 原先在服务端与前端各写死一份个人域名。 - src/helpers.php / templates/nodes/form.php:src 统一取 `config.php` 的 `amap.nav_src`。 前端模板用 json_encode 输出,与后端同源 —— 原先这个值在两处各写一遍, 注释里也提醒着「改一边就得改另一边」,现在只改配置一处即可。 - config.example.php:新增 `amap.nav_src`,默认占位 `trip.example.com` (src 只是高德侧的来源统计标识,填任意英数字串都能正常工作), 并注明部署时按自己的域名改这一行。 - LICENSE:版权署名由个人昵称改为 The TripLog Authors。 线上配置已同步补上 `nav_src`(保持原有取值),因此**线上行为不变**: 以线上真实代码 + 真实配置跑过 amap_nav_url(),PHP 7.4 与 8.1 产出的 URL 与改动前逐字节一致。 验证:php -l 全过;amap_nav_url 单测 25/25(新增「src 必须来自配置」「源码里不得出现个人域名」 两条守卫,并做了反向验证 —— 把域名写回去时确实会红);渲染检查 386/386; 部署 10 步全绿(双版本 php -l + 静态断言 + 哈希校验 + 数据库迁移 + HTTP 冒烟 + 真实渲染)。
321 lines
11 KiB
PHP
321 lines
11 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
function e($value): string
|
||
{
|
||
return htmlspecialchars((string)($value ?? ''), ENT_QUOTES, 'UTF-8');
|
||
}
|
||
|
||
/**
|
||
* 读取 config.php(每个请求只读一次)
|
||
* 页面里的第三方 Key 一律从这里取,避免同一个值散落在多个文件里
|
||
*/
|
||
function app_config(): array
|
||
{
|
||
static $config = null;
|
||
if ($config === null) {
|
||
$config = require dirname(__DIR__) . '/config.php';
|
||
}
|
||
return $config;
|
||
}
|
||
|
||
function url(string $route, array $params = []): string
|
||
{
|
||
$params = array_merge(['r' => $route], $params);
|
||
return '/index.php?' . http_build_query($params);
|
||
}
|
||
|
||
/**
|
||
* 静态资源地址 + 内容版本号(cache busting)。
|
||
*
|
||
* 为什么必须有:CSS/JS 的响应没有 Cache-Control/Expires,浏览器会退回到自己的
|
||
* 「启发式缓存」——在它自行估算的新鲜期内**根本不发请求**。文件换了它也不知道,
|
||
* 因为地址一直没变。表现就是「服务端明明更新了,用户看到的还是旧样式」。
|
||
* 拼上随文件变化的版本号后,文件一改地址就变,浏览器只能重新拉。
|
||
*
|
||
* 版本号用 mtime + 文件大小:只用 mtime 时,同一秒内改两次会漏;
|
||
* 补上大小能挡住绝大多数「内容变了但 mtime 没变」的情况。
|
||
* 取不到文件信息就不加版本号,退化成原来的行为 —— 不影响可用性。
|
||
*/
|
||
function asset(string $path): string
|
||
{
|
||
$path = '/' . ltrim($path, '/');
|
||
$file = dirname(__DIR__) . $path;
|
||
$mtime = @filemtime($file);
|
||
if ($mtime === false) {
|
||
return $path;
|
||
}
|
||
return $path . '?v=' . $mtime . '-' . (int)@filesize($file);
|
||
}
|
||
|
||
function redirect(string $route, array $params = [], string $anchor = ''): void
|
||
{
|
||
$location = url($route, $params);
|
||
if ($anchor !== '') {
|
||
$location .= '#' . ltrim($anchor, '#');
|
||
}
|
||
header('Location: ' . $location);
|
||
exit;
|
||
}
|
||
|
||
function view(string $template, array $data = []): void
|
||
{
|
||
extract($data, EXTR_SKIP);
|
||
$basePath = dirname(__DIR__);
|
||
$content = $basePath . '/templates/' . $template . '.php';
|
||
if (!file_exists($content)) {
|
||
throw new \RuntimeException("模板不存在: $template");
|
||
}
|
||
require $basePath . '/templates/layout.php';
|
||
}
|
||
|
||
function flash(string $key, ?string $value = null): ?string
|
||
{
|
||
if ($value !== null) {
|
||
$_SESSION['_flash'][$key] = $value;
|
||
return null;
|
||
}
|
||
$v = $_SESSION['_flash'][$key] ?? null;
|
||
unset($_SESSION['_flash'][$key]);
|
||
return $v;
|
||
}
|
||
|
||
function csrf_token(): string
|
||
{
|
||
if (empty($_SESSION['_csrf'])) {
|
||
$_SESSION['_csrf'] = bin2hex(random_bytes(16));
|
||
}
|
||
return $_SESSION['_csrf'];
|
||
}
|
||
|
||
/**
|
||
* 把这次提交的原始字段存进 session,供紧接着的那次渲染回填。
|
||
*
|
||
* 为什么需要:控制器校验失败时只能 redirect(POST/Redirect/GET),一重定向表单就被重新渲染成
|
||
* 空的了——用户填了半天的内容全没了。把 $_POST 原样留着,模板用 old() 取回来即可。
|
||
*
|
||
* 生命周期是「一次渲染」:layout.php 渲染完正文就 unset(见那里的注释),
|
||
* 所以它不会跟着用户跑到别的页面,也不会把上一次的残留混进下一次新建。
|
||
*/
|
||
function flash_old(array $data): void
|
||
{
|
||
$_SESSION['_old'] = $data;
|
||
}
|
||
|
||
/** 回填值:读取上一次提交的同名字段;没有就给 $default。数组字段(如 xxx[])不参与回填 */
|
||
function old(string $key, ?string $default = null): ?string
|
||
{
|
||
$v = $_SESSION['_old'][$key] ?? null;
|
||
if ($v === null || is_array($v)) {
|
||
return $default;
|
||
}
|
||
return (string)$v;
|
||
}
|
||
|
||
/** 回填「多选」字段(如 delete_attachments[])——用于恢复勾选状态 */
|
||
function old_array(string $key): array
|
||
{
|
||
$v = $_SESSION['_old'][$key] ?? null;
|
||
if (!is_array($v)) {
|
||
return [];
|
||
}
|
||
return array_map(static fn($x) => (string)$x, $v);
|
||
}
|
||
|
||
/**
|
||
* 回填「日期 + 时:分」组合控件(见 templates/_datetime.php)。
|
||
*
|
||
* 三个子字段要合并回一个 "Y-m-d H:i:s" 才好喂给模板里的拆分逻辑;
|
||
* 只要日期存在就认为用户在填(时分缺省按 00 补)。
|
||
*/
|
||
function old_dt(string $name, ?string $fallback): string
|
||
{
|
||
$date = old($name . '_date');
|
||
if ($date === null || trim($date) === '') {
|
||
return (string)($fallback ?? '');
|
||
}
|
||
$h = old($name . '_hour', '00');
|
||
$m = old($name . '_minute', '00');
|
||
return trim($date) . ' ' . str_pad((string)$h, 2, '0', STR_PAD_LEFT)
|
||
. ':' . str_pad((string)$m, 2, '0', STR_PAD_LEFT) . ':00';
|
||
}
|
||
|
||
/** 把各种写法的时间串统一成 MySQL 认的 "Y-m-d H:i:s";认不出来返回 null */
|
||
function normalize_dt_string(string $value): ?string
|
||
{
|
||
$value = trim($value);
|
||
if ($value === '') {
|
||
return null;
|
||
}
|
||
// datetime-local 传上来是 2026-09-12T10:30
|
||
$value = str_replace('T', ' ', $value);
|
||
$ts = strtotime($value);
|
||
if ($ts === false) {
|
||
return null;
|
||
}
|
||
return date('Y-m-d H:i:s', $ts);
|
||
}
|
||
|
||
/**
|
||
* 读取「日期 + 24 小时制时/分」三个控件拼成的时间。
|
||
*
|
||
* ⚠️ 为什么不用 <input type="datetime-local">:它的**显示格式由浏览器 UI 语言 /
|
||
* 系统时间格式决定**,页面和元素的 lang 属性完全无效。实测(chromium 1243):
|
||
* --lang=zh-CN → 「2026/09/23 下午 03:30」(12 小时制)
|
||
* --lang=en-GB → 「23/09/2026 15:30」(24 小时制)
|
||
* 连 <html lang="zh-CN"> + 元素 lang="en-GB" 都压不过它,所以只能换成
|
||
* 「原生日期选择器 + 时/分两个下拉」,24 小时制是控件本身决定的,不可能被浏览器改掉。
|
||
*
|
||
* 兼容旧的单个字段名(值形如 2026-09-23T15:30)——旧书签、接口测试可能还在这么传。
|
||
*/
|
||
function read_dt_input(string $name): ?string
|
||
{
|
||
$date = trim((string)($_POST[$name . '_date'] ?? ''));
|
||
$hRaw = trim((string)($_POST[$name . '_hour'] ?? ''));
|
||
$mRaw = trim((string)($_POST[$name . '_minute'] ?? ''));
|
||
|
||
if ($date === '' && $hRaw === '' && $mRaw === '') {
|
||
return normalize_dt_string((string)($_POST[$name] ?? ''));
|
||
}
|
||
if ($date === '') {
|
||
return null; // 只选了时分、没选日期 → 视为没填
|
||
}
|
||
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $mt)) {
|
||
return null;
|
||
}
|
||
if (!checkdate((int)$mt[2], (int)$mt[3], (int)$mt[1])) {
|
||
return null;
|
||
}
|
||
|
||
$h = $hRaw === '' ? 0 : (int)$hRaw;
|
||
$m = $mRaw === '' ? 0 : (int)$mRaw;
|
||
if ($h < 0 || $h > 23 || $m < 0 || $m > 59) {
|
||
return null;
|
||
}
|
||
return sprintf('%s %02d:%02d:00', $date, $h, $m);
|
||
}
|
||
|
||
/**
|
||
* 校验 CSRF。两个通道都收:
|
||
* 表单 → 隐藏字段 _csrf
|
||
* XHR → 请求头 X-CSRF-Token(自定义头跨站加不上,同样安全,且不必把 token 塞进 body)
|
||
*/
|
||
function csrf_check(): void
|
||
{
|
||
$token = $_POST['_csrf'] ?? '';
|
||
if ($token === '') {
|
||
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||
}
|
||
if (!hash_equals($_SESSION['_csrf'] ?? '', (string)$token)) {
|
||
http_response_code(419);
|
||
exit('CSRF token 校验失败');
|
||
}
|
||
}
|
||
|
||
/** 把 php.ini 里的 "8M" / "512K" 这类值换算成字节 */
|
||
function ini_bytes(string $value): int
|
||
{
|
||
$value = trim($value);
|
||
if ($value === '' || $value === '-1') {
|
||
return 0; // 不限
|
||
}
|
||
$unit = strtolower(substr($value, -1));
|
||
$num = (int)$value;
|
||
switch ($unit) {
|
||
case 'g': return $num * 1024 * 1024 * 1024;
|
||
case 'm': return $num * 1024 * 1024;
|
||
case 'k': return $num * 1024;
|
||
default: return $num;
|
||
}
|
||
}
|
||
|
||
function json_decode_field($json, $default = [])
|
||
{
|
||
if (empty($json)) return $default;
|
||
if (is_array($json)) return $json;
|
||
$decoded = json_decode($json, true);
|
||
return $decoded ?? $default;
|
||
}
|
||
|
||
/**
|
||
* 附件的查看地址 —— 走 media/show 代理,页面里**永远不出现存储后端的真实地址**
|
||
* (OSS 桶是私有的,直链既取不到、也不该暴露给浏览器)。
|
||
*/
|
||
function media_att(int $attachmentId, bool $download = false): string
|
||
{
|
||
return url('media/show', $download ? ['id' => $attachmentId, 'dl' => 1] : ['id' => $attachmentId]);
|
||
}
|
||
|
||
/** 活动封面地址(同样走代理) */
|
||
function media_cover(int $activityId): string
|
||
{
|
||
return url('media/cover', ['id' => $activityId]);
|
||
}
|
||
|
||
/**
|
||
* 头像地址。头像是本站本地文件,但仍做一次前缀校验,
|
||
* 避免历史数据里塞了外部地址把图片挂到第三方去。
|
||
*/
|
||
function media_avatar(?string $url): string
|
||
{
|
||
$url = trim((string)$url);
|
||
if ($url === '' || strpos($url, '/uploads/avatars/') !== 0 || strpos($url, '..') !== false) {
|
||
return '';
|
||
}
|
||
return $url;
|
||
}
|
||
|
||
/**
|
||
* 高德导航链接:点一下直接导航到这个位置(手机端会尝试唤起高德 App)。
|
||
*
|
||
* 格式依据高德 URI API:https://uri.amap.com/navigation?to=lon,lat,name&mode=car
|
||
* ⚠️ 坐标顺序是「经度,纬度」(lon 在前),写反了会导航到地球另一边。
|
||
* ⚠️ 只给 to 不给 from:from 留空时移动端会自动用「当前位置」当起点,
|
||
* 这正是「导航到这个位置」的语义。
|
||
*
|
||
* src 参数取 `config.php` 的 `amap.nav_src`(仅统计用,默认占位 `trip.example.com`)——
|
||
* 部署时按自己的域名改这一处即可,服务端与前端自动一致。
|
||
*
|
||
* 没坐标时退化成关键词搜索 —— 用户在表单里只敲了名字、没从下拉里选中的情况很常见,
|
||
* 这种节点也给一个能用的链接,总比什么都不显示强。
|
||
*
|
||
* @param array $loc 形如 ['name'=>..,'address'=>..,'lat'=>..,'lng'=>..]
|
||
* @return string 无可用信息时返回空串(模板据此决定要不要渲染链接)
|
||
*/
|
||
function amap_nav_url(array $loc, string $mode = 'car'): string
|
||
{
|
||
$name = trim((string)($loc['name'] ?? ''));
|
||
$lng = trim((string)($loc['lng'] ?? ''));
|
||
$lat = trim((string)($loc['lat'] ?? ''));
|
||
|
||
// 高德 URI API 要求带 src(「调用来源」,只用于统计,任意英数字串都行)。
|
||
// 取值统一放 config.php 的 amap.nav_src,与前端 templates/nodes/form.php 里的
|
||
// NAV_SRC 同源 —— 想换成自己的域名做统计时,只改配置一处即可。
|
||
$src = (string)(app_config()['amap']['nav_src'] ?? 'trip.example.com');
|
||
|
||
if (is_numeric($lng) && is_numeric($lat)) {
|
||
// 只对名称做 URL 编码(逗号保持字面量,否则高德解析不出三段式);
|
||
// 名称里若含 & 或 = 也能被正确转义。
|
||
$to = $lng . ',' . $lat . ($name !== '' ? ',' . rawurlencode($name) : '');
|
||
return 'https://uri.amap.com/navigation?to=' . $to
|
||
. '&mode=' . rawurlencode($mode)
|
||
. '&policy=1&src=' . rawurlencode($src) . '&coordinate=gaode&callnative=1';
|
||
}
|
||
|
||
$keyword = $name !== '' ? $name : trim((string)($loc['address'] ?? ''));
|
||
if ($keyword === '') {
|
||
return '';
|
||
}
|
||
return 'https://uri.amap.com/search?keyword=' . rawurlencode($keyword)
|
||
. '&src=' . rawurlencode($src);
|
||
}
|
||
|
||
/** 人类可读的文件大小 */
|
||
function human_size(?int $bytes): string
|
||
{
|
||
$bytes = (int)$bytes;
|
||
if ($bytes <= 0) return '';
|
||
if ($bytes < 1024) return $bytes . ' B';
|
||
if ($bytes < 1024 * 1024) return round($bytes / 1024, 1) . ' KB';
|
||
return round($bytes / 1024 / 1024, 2) . ' MB';
|
||
} |