311 lines
14 KiB
PHP
311 lines
14 KiB
PHP
<?php
|
||||
|
|
/**
|
|||
|
|
* 存储设置(仅管理员)
|
|||
|
|
*
|
|||
|
|
* 两件事在这里定:照片存哪(本站 / 阿里云 OSS)、OSS 怎么鉴权(ECS 角色 / AccessKey)。
|
|||
|
|
* 无论存哪里,页面里的照片地址都是本站的 media/show 代理 ——
|
|||
|
|
* OSS 桶保持私有,前端拿不到、也不该拿到直链。
|
|||
|
|
*/
|
|||
|
|
$s = $s ?? [];
|
|||
|
|
$isOss = ($s['storage_driver'] ?? 'local') === 'oss';
|
|||
|
|
$auth = $s['oss_auth'] ?? 'ecs_role';
|
|||
|
|
$hasSecret = (string)($s['oss_ak_secret'] ?? '') !== '';
|
|||
|
|
$needMigrate = (int)$schemaNow < (int)$schemaTarget;
|
|||
|
|
$locked = !$tableExists; // app_settings 还没建,保存必然失败
|
|||
|
|
?>
|
|||
|
|
<div class="page-header">
|
|||
|
|
<div>
|
|||
|
|
<h1>存储设置</h1>
|
|||
|
|
<p class="muted">照片存哪里、怎么鉴权 —— 影响所有活动的附件</p>
|
|||
|
|
</div>
|
|||
|
|
<div class="actions">
|
|||
|
|
<a href="<?= url('activities/index') ?>" class="btn">返回活动</a>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="settings-status">
|
|||
|
|
<div class="settings-chip <?= $driver === 'oss' ? 'ok' : '' ?>">
|
|||
|
|
<div>当前生效</div>
|
|||
|
|
<strong><?= e(\App\Storage::label($driver)) ?></strong>
|
|||
|
|
<?php if ($misconfigured): ?>
|
|||
|
|
<div class="text-danger">已选 OSS 但配置不全,实际回退到本站服务器</div>
|
|||
|
|
<?php endif; ?>
|
|||
|
|
</div>
|
|||
|
|
<div class="settings-chip <?= $needMigrate ? 'warn' : 'ok' ?>">
|
|||
|
|
<div>数据库结构</div>
|
|||
|
|
<strong>v<?= (int)$schemaNow ?> / v<?= (int)$schemaTarget ?></strong>
|
|||
|
|
<div><?= $needMigrate ? '需要执行迁移' : '已是最新' ?></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="settings-chip <?= $isEcs ? 'ok' : 'warn' ?>">
|
|||
|
|
<div>运行环境</div>
|
|||
|
|
<strong><?= $isEcs ? '阿里云 ECS' : '非 ECS / 探测不到' ?></strong>
|
|||
|
|
<div><?= $isEcs ? '角色:' . e(implode('、', $ecsRoles)) : 'ECS 角色鉴权需部署在阿里云 ECS 上' ?></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<?php if ($needMigrate): ?>
|
|||
|
|
<div class="alert alert-warn">
|
|||
|
|
数据库结构还是 v<?= (int)$schemaNow ?>,低于程序要求的 v<?= (int)$schemaTarget ?>。
|
|||
|
|
请在服务器上以站点用户执行 <code>php tools/migrate.php</code>(CLI 专用,HTTP 访问不到)。
|
|||
|
|
<?php if (!$tableExists): ?>
|
|||
|
|
<br>当前 <code>app_settings</code> 表还不存在,本页「保存」会失败。
|
|||
|
|
<?php endif; ?>
|
|||
|
|
</div>
|
|||
|
|
<?php endif; ?>
|
|||
|
|
|
|||
|
|
<form method="post" action="<?= url('settings/save') ?>" class="form" id="settings-form">
|
|||
|
|
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
|
|||
|
|
|
|||
|
|
<div class="panel">
|
|||
|
|
<h2 class="section-title">照片存放在哪里</h2>
|
|||
|
|
|
|||
|
|
<div class="radio-group">
|
|||
|
|
<label class="radio-label">
|
|||
|
|
<input type="radio" name="storage_driver" value="local" <?= $isOss ? '' : 'checked' ?>>
|
|||
|
|
本站服务器
|
|||
|
|
</label>
|
|||
|
|
<label class="radio-label">
|
|||
|
|
<input type="radio" name="storage_driver" value="oss" <?= $isOss ? 'checked' : '' ?>>
|
|||
|
|
阿里云 OSS
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<p class="settings-hint">
|
|||
|
|
本站服务器:存到站点 <code>uploads/attachments/</code>,零配置。<br>
|
|||
|
|
阿里云 OSS:桶保持 <b>私有</b>,读取由 PHP 代理转发。<br>
|
|||
|
|
两种方式下页面里的照片地址都是 <code><?= e(url('media/show')) ?>?id=…</code>,看不出存储后端。
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
<div id="oss-fields" class="oss-fields<?= $isOss ? ' show' : '' ?>">
|
|||
|
|
<div class="form-row">
|
|||
|
|
<label for="oss_endpoint">Endpoint <span class="req">*</span></label>
|
|||
|
|
<input type="text" id="oss_endpoint" name="oss_endpoint"
|
|||
|
|
value="<?= e($s['oss_endpoint'] ?? '') ?>"
|
|||
|
|
placeholder="oss-cn-qingdao.aliyuncs.com" autocomplete="off">
|
|||
|
|
<p class="settings-hint">只填域名,别带 https://,地域要与桶一致。</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="form-row">
|
|||
|
|
<label for="oss_bucket">Bucket 名称 <span class="req">*</span></label>
|
|||
|
|
<input type="text" id="oss_bucket" name="oss_bucket"
|
|||
|
|
value="<?= e($s['oss_bucket'] ?? '') ?>"
|
|||
|
|
placeholder="my-bucket" autocomplete="off">
|
|||
|
|
<p class="settings-hint">桶的读写权限请保持「私有」。</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="form-row">
|
|||
|
|
<label for="oss_prefix">对象前缀</label>
|
|||
|
|
<input type="text" id="oss_prefix" name="oss_prefix"
|
|||
|
|
value="<?= e($s['oss_prefix'] ?? '') ?>"
|
|||
|
|
placeholder="triplog/" autocomplete="off">
|
|||
|
|
<p class="settings-hint">相当于桶里的目录,末尾会自动补 <code>/</code>。</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="form-row">
|
|||
|
|
<label for="photo_max_mb">单张照片上限(MB)</label>
|
|||
|
|
<input type="number" id="photo_max_mb" name="photo_max_mb" min="1" max="50"
|
|||
|
|
value="<?= (int)($s['photo_max_mb'] ?? 12) ?>">
|
|||
|
|
<p class="settings-hint">只限制图片;PDF 等其它附件仍按 20MB 处理。</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="form-row">
|
|||
|
|
<label class="checkbox-label">
|
|||
|
|
<input type="checkbox" name="oss_internal" value="1"
|
|||
|
|
<?= (($s['oss_internal'] ?? '1') === '1') ? 'checked' : '' ?>>
|
|||
|
|
<span>使用内网 Endpoint(服务器与桶同地域时勾选,省流量费)</span>
|
|||
|
|
</label>
|
|||
|
|
<p class="settings-hint">勾上后会自动把 <code>-internal</code> 插进域名。</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<h3 class="section-title">鉴权方式</h3>
|
|||
|
|
|
|||
|
|
<div class="radio-group">
|
|||
|
|
<label class="radio-label">
|
|||
|
|
<input type="radio" name="oss_auth" value="ecs_role" <?= $auth === 'ecs_role' ? 'checked' : '' ?>>
|
|||
|
|
ECS 实例角色(推荐)
|
|||
|
|
</label>
|
|||
|
|
<label class="radio-label">
|
|||
|
|
<input type="radio" name="oss_auth" value="access_key" <?= $auth === 'access_key' ? 'checked' : '' ?>>
|
|||
|
|
AccessKey
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div id="ecs-fields" class="oss-fields<?= $auth === 'ecs_role' ? ' show' : '' ?>">
|
|||
|
|
<div class="form-row">
|
|||
|
|
<label for="oss_role_name">实例 RAM 角色名 <span class="req">*</span></label>
|
|||
|
|
<div class="inline-row">
|
|||
|
|
<input type="text" id="oss_role_name" name="oss_role_name"
|
|||
|
|
value="<?= e($s['oss_role_name'] ?? '') ?>"
|
|||
|
|
placeholder="例如 SyncCalendarOssRole" autocomplete="off">
|
|||
|
|
<button type="button" class="btn" id="btn-detect-roles">检测可用角色</button>
|
|||
|
|
</div>
|
|||
|
|
<?php if ($isEcs): ?>
|
|||
|
|
<div class="role-chips" id="roles-result">
|
|||
|
|
<?php foreach ($ecsRoles as $role): ?>
|
|||
|
|
<button type="button" class="role-chip" data-role="<?= e($role) ?>"><?= e($role) ?></button>
|
|||
|
|
<?php endforeach; ?>
|
|||
|
|
</div>
|
|||
|
|
<?php else: ?>
|
|||
|
|
<div id="roles-result" class="settings-hint"></div>
|
|||
|
|
<?php endif; ?>
|
|||
|
|
<p class="settings-hint">
|
|||
|
|
该角色需要对目标桶有读写权限。STS 临时凭证按分钟缓存在系统的 <code>/tmp</code>,
|
|||
|
|
不会写进站点目录,也不会落到数据库。
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div id="ak-fields" class="oss-fields<?= $auth === 'access_key' ? ' show' : '' ?>">
|
|||
|
|
<div class="form-row">
|
|||
|
|
<label for="oss_ak_id">AccessKeyId</label>
|
|||
|
|
<input type="text" id="oss_ak_id" name="oss_ak_id"
|
|||
|
|
value="<?= e($s['oss_ak_id'] ?? '') ?>"
|
|||
|
|
placeholder="LTAI…" autocomplete="off">
|
|||
|
|
</div>
|
|||
|
|
<div class="form-row">
|
|||
|
|
<label for="oss_ak_secret">AccessKeySecret</label>
|
|||
|
|
<input type="password" id="oss_ak_secret" name="oss_ak_secret"
|
|||
|
|
value="" autocomplete="new-password"
|
|||
|
|
placeholder="<?= $hasSecret ? '(已保存,留空表示不修改)' : '填写 Secret' ?>">
|
|||
|
|
<p class="settings-hint">
|
|||
|
|
<?= $hasSecret ? '已保存一把密钥,留空即不修改。' : '尚未保存密钥。' ?>
|
|||
|
|
能用 ECS 角色就别用这个 —— AccessKey 是以明文存进数据库的。
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="inline-row">
|
|||
|
|
<button type="button" class="btn" id="btn-test">测试连接</button>
|
|||
|
|
<span class="settings-hint">用表单里当前填的值实测「上传 → 读取 → 删除」,不必先保存。</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="test-result" id="test-result"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="form-actions">
|
|||
|
|
<button type="submit" class="btn-primary" <?= $locked ? 'disabled' : '' ?>>保存设置</button>
|
|||
|
|
<?php if ($locked): ?>
|
|||
|
|
<span class="settings-hint">跑完迁移后才能保存。</span>
|
|||
|
|
<?php endif; ?>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
(function () {
|
|||
|
|
'use strict';
|
|||
|
|
|
|||
|
|
var form = document.getElementById('settings-form');
|
|||
|
|
var ossBox = document.getElementById('oss-fields');
|
|||
|
|
var ecsBox = document.getElementById('ecs-fields');
|
|||
|
|
var akBox = document.getElementById('ak-fields');
|
|||
|
|
var testBox = document.getElementById('test-result');
|
|||
|
|
var btnTest = document.getElementById('btn-test');
|
|||
|
|
var rolesBox = document.getElementById('roles-result');
|
|||
|
|
var csrf = <?= json_encode(csrf_token()) ?>;
|
|||
|
|
|
|||
|
|
function radioValue(name, fallback) {
|
|||
|
|
var el = form.querySelector('input[name="' + name + '"]:checked');
|
|||
|
|
return el ? el.value : fallback;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sync() {
|
|||
|
|
ossBox.classList.toggle('show', radioValue('storage_driver', 'local') === 'oss');
|
|||
|
|
var auth = radioValue('oss_auth', 'ecs_role');
|
|||
|
|
ecsBox.classList.toggle('show', auth === 'ecs_role');
|
|||
|
|
akBox.classList.toggle('show', auth === 'access_key');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
form.addEventListener('change', sync);
|
|||
|
|
sync();
|
|||
|
|
|
|||
|
|
function esc(s) {
|
|||
|
|
return String(s == null ? '' : s)
|
|||
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|||
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showResult(ok, message, steps, extra) {
|
|||
|
|
testBox.className = 'test-result show ' + (ok ? 'ok' : 'bad');
|
|||
|
|
var lines = [(ok ? '✓ ' : '✕ ') + (message || '')];
|
|||
|
|
if (extra) lines.push(extra);
|
|||
|
|
(steps || []).forEach(function (step) {
|
|||
|
|
var good = step.ok !== false;
|
|||
|
|
lines.push((good ? ' ✓ ' : ' ✕ ') + (step.name || step.step || '')
|
|||
|
|
+ (step.message ? ' — ' + step.message : ''));
|
|||
|
|
});
|
|||
|
|
testBox.textContent = lines.join('\n');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
btnTest.addEventListener('click', function () {
|
|||
|
|
btnTest.disabled = true;
|
|||
|
|
testBox.className = 'test-result show';
|
|||
|
|
testBox.textContent = '正在测试…';
|
|||
|
|
|
|||
|
|
var fd = new FormData(form);
|
|||
|
|
fd.set('storage_driver', 'oss'); // 测试永远针对 OSS
|
|||
|
|
fd.delete('_csrf'); // token 走请求头
|
|||
|
|
|
|||
|
|
var xhr = new XMLHttpRequest();
|
|||
|
|
xhr.open('POST', <?= json_encode(url('settings/test')) ?>, true);
|
|||
|
|
xhr.setRequestHeader('X-CSRF-Token', csrf);
|
|||
|
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
|||
|
|
|
|||
|
|
xhr.onload = function () {
|
|||
|
|
btnTest.disabled = false;
|
|||
|
|
var data = null;
|
|||
|
|
try { data = JSON.parse(xhr.responseText); } catch (e) {}
|
|||
|
|
if (!data) {
|
|||
|
|
var text = String(xhr.responseText || '').replace(/<[^>]*>/g, ' ')
|
|||
|
|
.replace(/\s+/g, ' ').trim().slice(0, 200);
|
|||
|
|
showResult(false, '接口没返回 JSON(HTTP ' + xhr.status + '):' + text);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
showResult(!!data.ok, data.message, data.steps,
|
|||
|
|
data.endpointResolved ? '实际请求地址:' + data.endpointResolved : '');
|
|||
|
|
};
|
|||
|
|
xhr.onerror = function () {
|
|||
|
|
btnTest.disabled = false;
|
|||
|
|
showResult(false, '网络错误,请求没能发出去');
|
|||
|
|
};
|
|||
|
|
xhr.send(fd);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.getElementById('btn-detect-roles').addEventListener('click', function () {
|
|||
|
|
var btn = this;
|
|||
|
|
btn.disabled = true;
|
|||
|
|
|
|||
|
|
fetch(<?= json_encode(url('settings/roles')) ?>, { credentials: 'same-origin' })
|
|||
|
|
.then(function (r) { return r.json(); })
|
|||
|
|
.then(function (data) {
|
|||
|
|
btn.disabled = false;
|
|||
|
|
var roles = (data && data.roles) || [];
|
|||
|
|
if (!roles.length) {
|
|||
|
|
rolesBox.innerHTML = '<span class="text-danger">没探测到角色。</span> '
|
|||
|
|
+ '本机可能不是阿里云 ECS,或元数据服务被挡住了 —— 这种情况请改用 AccessKey。';
|
|||
|
|
rolesBox.className = 'role-chips';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
var input = document.getElementById('oss_role_name');
|
|||
|
|
if (!input.value) input.value = roles[0];
|
|||
|
|
rolesBox.className = 'role-chips';
|
|||
|
|
rolesBox.innerHTML = roles.map(function (r) {
|
|||
|
|
return '<button type="button" class="role-chip" data-role="' + esc(r) + '">'
|
|||
|
|
+ esc(r) + '</button>';
|
|||
|
|
}).join('');
|
|||
|
|
})
|
|||
|
|
.catch(function (e) {
|
|||
|
|
btn.disabled = false;
|
|||
|
|
rolesBox.className = 'role-chips';
|
|||
|
|
rolesBox.innerHTML = '<span class="text-danger">探测失败:</span>' + esc(e.message);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
rolesBox.addEventListener('click', function (ev) {
|
|||
|
|
var chip = ev.target.closest ? ev.target.closest('button[data-role]') : null;
|
|||
|
|
if (!chip) return;
|
|||
|
|
document.getElementById('oss_role_name').value = chip.getAttribute('data-role');
|
|||
|
|
});
|
|||
|
|
})();
|
|||
|
|
</script>
|