Files
TripLog/src/helpers.php
T
laoyang cd37f89807 chore: 导航链接的 src 改为读配置,去掉代码里的个人域名
面向公开仓库脱敏:高德 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 冒烟 + 真实渲染)。
2026-09-28 19:12:59 +08:00

321 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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';
}