-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathSettings.php
More file actions
517 lines (463 loc) · 17 KB
/
Copy pathSettings.php
File metadata and controls
517 lines (463 loc) · 17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
<?php
namespace TypechoPlugin\Access;
use Typecho\Config;
if (!defined('__TYPECHO_ROOT_DIR__')) {
exit;
}
/**
* 插件配置的文件化:启用时从 config/current.yaml 载入,禁用时写回同一个文件
*
* 用途是让插件配置可以随代码一起部署:容器重建、站点迁移之后,
* 把 config/current.yaml 放回去,启用插件即可恢复全部设置,
* 不必再到后台一项项填。
*
* 语义按「文件即事实」处理:文件里写了的项按文件走,没写的项用默认值,
* 也就是说加载一次相当于把插件配置整体替换成文件描述的样子。
* 这与「禁用时导出、启用时导入」的往返用法是一致的。
*
* YAML 只支持够用的一个子集(见 parse()),不依赖任何扩展;
* 装了 PHP 的 yaml 扩展时优先用它解析,两者对本文件格式的结果一致。
*/
final class Settings
{
/**
* 全部配置项及其默认值
*
* 必须与 Plugin::config() 里各表单控件的名字和默认值保持一致:
* 这里是「文件里没写的项用什么」的唯一依据。
*/
public const DEFAULTS = [
'pageSize' => '20',
'isDrop' => '0',
'writeType' => '1',
'isPaid' => '0',
'isToken' => '',
'socks5Host' => '',
'socks5Auth' => '',
'redisCache' => '0',
'redisHost' => '127.0.0.1',
'redisPort' => '6379',
'redisAuth' => '',
'writeQueue' => '1',
# 队列归属变更时的行为闸门;'force' 只对一次保存生效,随后自动复位
'queueSwitch' => 'safe',
'queueFlushSize' => '100',
'queueFlushInterval' => '60',
'dbType' => 'follow',
'dbHost' => '127.0.0.1',
'dbPort' => '',
'dbUser' => '',
'dbPass' => '',
'dbName' => '',
'dbPrefix' => 'typecho_',
];
/** 顶层的 YAML 键名 */
public const ROOT_KEY = 'access';
/**
* 配置文件路径
*
* @return string
*/
public static function file(): string
{
return __DIR__ . '/config/current.yaml';
}
/**
* 从配置文件读取设置
*
* @param string|null $path 默认为 config/current.yaml
* @return array|null 读到的设置(已补全默认值);文件不存在时返回 null
* @throws \RuntimeException 文件存在但读不了或解析不出内容
*/
public static function load(?string $path = null): ?array
{
$path = $path ?? self::file();
if (!is_file($path)) {
return null;
}
if (!is_readable($path)) {
throw new \RuntimeException(_t('配置文件 %s 不可读,请检查文件权限', $path));
}
$raw = @file_get_contents($path);
if ($raw === false) {
throw new \RuntimeException(_t('配置文件 %s 读取失败', $path));
}
$issues = [];
$parsed = self::parse($raw, $issues);
if ($parsed === null) {
throw new \RuntimeException(_t('配置文件 %s 解析失败,请检查 YAML 格式', $path));
}
/*
* 有认不出来的行就整份拒绝,而不是「能读几项算几项」。
*
* 部分加载是最糟的一种:读进去的项生效了,写坏的那项悄悄用默认值,
* 而调用方看到的是「加载成功」。宁可让启用时报一句明确的错 ——
* 调用方(Plugin::configHandle)收到异常会回退到 options 里已有的配置,
* 那比回退成表单默认值安全得多。
*/
if (!empty($issues)) {
throw new \RuntimeException(_t(
'配置文件 %s 有 %d 处无法解析,为避免只加载一半,本次整份不采用:%s',
$path,
count($issues),
implode(';', array_slice($issues, 0, 5)) . (count($issues) > 5 ? ' …' : '')
));
}
# 只认识 DEFAULTS 里的键,其余忽略;没写的项用默认值
$settings = self::DEFAULTS;
$known = 0;
foreach ($parsed as $key => $value) {
if (array_key_exists($key, self::DEFAULTS)) {
$settings[$key] = $value;
$known++;
}
}
if ($known === 0) {
throw new \RuntimeException(_t(
'配置文件 %s 里没有任何可识别的配置项,请确认顶层为 %s:',
$path,
self::ROOT_KEY
));
}
return $settings;
}
/**
* 把当前设置写入配置文件
*
* 失败时只返回 false,不抛异常:禁用插件不该因为写不了文件而失败。
*
* @param array|Config $settings
* @param string|null $path
* @return bool
*/
public static function save(array|Config $settings, ?string $path = null): bool
{
$path = $path ?? self::file();
$dir = dirname($path);
try {
if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) {
return false;
}
self::protect($dir);
$body = self::emit(self::normalize($settings));
# 先写临时文件再改名,避免写到一半被读到半截内容
$tmp = $path . '.' . getmypid() . '.tmp';
if (@file_put_contents($tmp, $body, LOCK_EX) === false) {
return false;
}
@chmod($tmp, 0600);
if (!@rename($tmp, $path)) {
@unlink($tmp);
return false;
}
@chmod($path, 0600);
return true;
} catch (\Throwable $e) {
return false;
}
}
/**
* 把插件配置对象或数组归一化成「键 => 字符串」
*
* @param array|Config $source
* @return array
*/
public static function normalize(array|Config $source): array
{
$result = [];
foreach (self::DEFAULTS as $key => $default) {
$value = null;
if (is_array($source)) {
$value = $source[$key] ?? null;
} elseif ($source instanceof Config) {
$value = $source->$key ?? null;
}
$result[$key] = $value === null ? $default : self::text($value);
}
return $result;
}
/**
* 解析 YAML
*
* 支持的子集(足够表达本插件的配置,不依赖任何扩展):
* - 顶层 `access:` 段,段内每行一个 `键: 值`(也接受没有顶层段的平铺写法)
* - 值可以是裸标量、单引号或双引号字符串、空
* - `#` 起的注释、空行、文档分隔符 `---` / `...` 会被跳过
* - true/false/yes/no/on/off 归一为 '1' / '0',null / ~ 归一为空串
* 不支持列表、多行标量、锚点等,本插件的配置也用不到。
*
* @param string $raw
* @return array|null 解析不出任何键值对时返回 null
*/
public static function parse(string $raw, array &$issues = []): ?array
{
$issues = [];
# 装了 yaml 扩展就用它,对格式更宽容;结果再走一遍同样的归一化
if (function_exists('yaml_parse')) {
$doc = @yaml_parse($raw);
if (is_array($doc)) {
$section = $doc[self::ROOT_KEY] ?? null;
$flat = is_array($section) ? $section : $doc;
$result = [];
foreach ($flat as $key => $value) {
if (is_scalar($value) || $value === null) {
# 扩展已经把 true / 1 / "1" 解析成了对应的 PHP 类型,
# 这里只做类型到字符串的折算,不再对字符串内容做二次解释
$result[(string)$key] = self::text($value);
}
}
if (!empty($result)) {
return $result;
}
}
# 扩展没解析出东西时不直接判失败,继续走下面的内置解析
}
$raw = str_replace(["\r\n", "\r"], "\n", $raw);
$result = [];
$section = null; // 当前顶层段名,null 表示还没遇到段
$seenSection = false;
$lineNo = 0;
foreach (explode("\n", $raw) as $line) {
$lineNo++;
$line = self::stripComment($line);
if (trim($line) === '' || preg_match('/^\s*(---|\.\.\.)\s*$/', $line)) {
continue;
}
/*
* 认不出来的行**要记下来**,不能像原来那样一个 continue 了事。
*
* 空行、注释、文档标记都在上面滤掉了,走到这儿还匹配不上的就是真的写坏了。
* 静默跳过的后果是:那一项悄悄回退成默认值,而文件里其余项照常生效 ——
* 用户改了统计库地址却写错一个字符,看到的是「配置已加载」,
* 实际上统计写回了主库,没有任何提示。
*/
if (!preg_match('/^(\s*)([A-Za-z_][A-Za-z0-9_.\-]*)\s*:\s*(.*)$/', $line, $m)) {
$issues[] = sprintf('第 %d 行无法解析:%s', $lineNo, trim($line));
continue;
}
[, $indent, $key, $value] = $m;
$value = rtrim($value);
# 顶层且没有值的行是段名
if ($indent === '' && $value === '') {
$section = $key;
$seenSection = true;
continue;
}
# 有顶层段时只认 access: 段里的项,避免把别的段的同名键读进来
if ($seenSection && $section !== self::ROOT_KEY) {
continue;
}
/*
* 引号没闭合的同样算写坏了。unquote() 对 `"abc` 会原样返回,
* 于是那对引号变成值的一部分 —— 悄悄存进去一个带引号的字符串。
*/
if ($value !== '' && ($value[0] === '"' || $value[0] === "'")
&& substr($value, -1) !== $value[0]) {
$issues[] = sprintf('第 %d 行引号没有闭合:%s', $lineNo, trim($line));
continue;
}
[$text, $quoted] = self::unquote($value);
# 带引号的一律当字面量,`dbName: "off"` 就是字符串 off 而不是布尔假
$result[$key] = $quoted ? $text : self::bare($text);
}
return empty($result) ? null : $result;
}
/**
* 生成 YAML 文本
*
* @param array $settings 已归一化的设置
* @return string
*/
public static function emit(array $settings): string
{
$lines = [
'# Access 插件配置',
'#',
'# 禁用插件时自动写出,启用插件时自动读入。',
'# 文件里写了的项按文件走,没写的项使用插件默认值。',
'#',
'# 注意:本文件含有数据库密码、Redis 密码、接口令牌等敏感信息,',
'# 权限已设为 0600,请勿提交到公开仓库,也不要放到可被下载的位置。',
'#',
'# 生成时间:' . self::now(),
'',
self::ROOT_KEY . ':',
];
foreach (self::DEFAULTS as $key => $default) {
$value = (string)($settings[$key] ?? $default);
# 纯数字不加引号,其余一律双引号,这样读回来一定还是原样
$literal = preg_match('/^-?\d+$/', $value) === 1 ? $value : self::quote($value);
$lines[] = ' ' . $key . ': ' . $literal;
}
return implode("\n", $lines) . "\n";
}
/**
* 当前时间,按站点配置的时区
*
* 直接用 date() 拿到的是 PHP 进程的时区(容器里通常是 UTC),
* 和后台各处显示的时间对不上,看文件的人会以为时间错了。
* 这里跟 Typecho 自己一样按站点时区折算,并把偏移量一并写出来,
* 免得脱离站点看这个文件时还要猜。
*
* @return string
*/
private static function now(): string
{
try {
$options = \Widget\Options::alloc();
$offset = (int)$options->timezone;
# gmtTime 是 GMT 时间戳,加上站点偏移后用 gmdate 格式化,避免再叠一次进程时区
$stamp = (int)$options->gmtTime + $offset;
return gmdate('Y-m-d H:i:s', $stamp) . sprintf(
' UTC%s%02d:%02d',
$offset < 0 ? '-' : '+',
intdiv(abs($offset), 3600),
intdiv(abs($offset) % 3600, 60)
);
} catch (\Throwable $e) {
return date('Y-m-d H:i:s');
}
}
/**
* 在配置目录下放置阻止 Web 直接访问的文件
*
* Nginx 下这些文件不起作用,需要自行在站点配置里拒绝 /usr/plugins/Access/config/。
*
* @param string $dir
* @return void
*/
private static function protect(string $dir): void
{
$htaccess = $dir . '/.htaccess';
if (!file_exists($htaccess)) {
@file_put_contents(
$htaccess,
"# Apache 2.4\n<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n"
. "# Apache 2.2\n<IfModule !mod_authz_core.c>\n Order allow,deny\n Deny from all\n</IfModule>\n"
);
}
$index = $dir . '/index.html';
if (!file_exists($index)) {
@file_put_contents($index, '');
}
}
/**
* 去掉行内注释(引号里的 # 不算注释)
*
* @param string $line
* @return string
*/
private static function stripComment(string $line): string
{
$out = '';
$quote = null;
$length = strlen($line);
for ($i = 0; $i < $length; $i++) {
$char = $line[$i];
if ($quote !== null) {
$out .= $char;
if ($char === '\\' && $quote === '"' && $i + 1 < $length) {
$out .= $line[++$i];
} elseif ($char === $quote) {
$quote = null;
}
continue;
}
if ($char === '"' || $char === "'") {
$quote = $char;
$out .= $char;
continue;
}
# 只有行首或前面是空白的 # 才算注释起点
if ($char === '#' && ($out === '' || preg_match('/\s$/', $out))) {
break;
}
$out .= $char;
}
return $out;
}
/**
* 去掉标量外层的引号并处理转义
*
* @param string $value
* @return array{0: string, 1: bool} 值本身,以及它原本是否带引号
*/
private static function unquote(string $value): array
{
$value = trim($value);
$length = strlen($value);
if ($length < 2) {
return [$value, false];
}
$first = $value[0];
$last = $value[$length - 1];
if ($first === "'" && $last === "'") {
# 单引号里只有 '' 表示一个单引号
return [str_replace("''", "'", substr($value, 1, -1)), true];
}
if ($first === '"' && $last === '"') {
$inner = substr($value, 1, -1);
return [strtr($inner, [
'\\n' => "\n",
'\\t' => "\t",
'\\"' => '"',
'\\\\' => '\\',
]), true];
}
return [$value, false];
}
/**
* 生成双引号字符串
*
* @param string $value
* @return string
*/
private static function quote(string $value): string
{
return '"' . strtr($value, [
'\\' => '\\\\',
'"' => '\\"',
"\n" => '\\n',
"\t" => '\\t',
]) . '"';
}
/**
* 把任意标量折算成插件配置使用的字符串
*
* 插件的配置值一律以字符串保存(表单控件本来也只产生字符串)。
*
* @param mixed $value
* @return string
*/
private static function text(mixed $value): string
{
if (is_bool($value)) {
return $value ? '1' : '0';
}
if ($value === null) {
return '';
}
if (is_int($value) || is_float($value)) {
return (string)$value;
}
return trim((string)$value);
}
/**
* 解释不带引号的裸标量
*
* 于是 `redisCache: true`、`redisCache: 1`、`redisCache: \"1\"` 三种写法等价。
* 带引号的值不会走到这里,`dbName: \"off\"` 仍然是字符串 off。
*
* @param string $text
* @return string
*/
private static function bare(string $text): string
{
return match (strtolower($text)) {
'true', 'yes', 'on' => '1',
'false', 'no', 'off' => '0',
'null', '~' => '',
default => $text,
};
}
}