账户中心:站主资料可编辑 + 身份绑定 + passkey 登录
后台新增 /admin/account 一页,四块:公开资料、密码、第三方账号、Passkey。 - schema:users 加 role(默认 reader),新表 user_identities、passkeys。 user_identities 上双 UNIQUE —— (provider, extern_uid) 防一个外部账号顶两个身份, (user_id, provider) 防一站主绑两个同平台号,绑错也劫持不了。 extern_uid 存平台稳定 ID,不存用户名(用户名可改)。 - 头像存 files 里的 key 而非 URL,换存储/CDN 不失效;单 key SetSetting 写入, 避开 UpdateSettings 的整表替换会把它抹掉。站主名/简介从设置页挪到账户页, 一个字段只留一个编辑入口。 - OAuth 绑定要求先有后台会话(绑定动作本身是提权路径);已绑的站主身份登录后 直接发 one_session,读者身份仍发 one_reader。 - passkey 走 go-webauthn v0.15.0(最后一条吃 go 1.24 的版本线),可发现凭据登录。 必须显式设 ONE_WEBAUTHN_ORIGINS 才启用,不配就安静关掉。 签名计数只记克隆警告、不硬拦 —— 云同步 passkey 的计数本就不单调。 - 密码故意留在 ONE_ADMIN_PASSWORD,不做哈希入库:这是「解绑一切、删光 passkey 也还能进门」的保底,比 env 明文更值得守。memos 那个 SSO 建号随机密码无重置 入口的坑,从设计上绕开。 已知限制:会话仍是有状态无关的 HMAC cookie,删 passkey / 解绑不会让已发出的 7 天后台会话失效 —— 要修得加一张吊销表。
This commit is contained in:
1 parent
7e302c51a6
commit
f1e639e0ba
30 files changed
+2523
-105
No files matched your search
@@ -0,0 +1,362 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { adminApi } from '../api'
|
||||
import { toastOk, toastErr } from './toast'
|
||||
import { isSupported as waSupported, register as waRegister } from '../webauthn'
|
||||
|
||||
// 账户页:我是谁(头像 / 昵称 / 简介)+ 我能用什么方式登录
|
||||
// (密码走环境变量、第三方身份绑定、passkey)。
|
||||
//
|
||||
// 和「设置」的分工是刻意的:站点的事在设置,账号的事在这里。
|
||||
// 作者昵称/简介以前在设置里,已移过来 —— 同一字段两处编辑迟早漂移。
|
||||
|
||||
const acct = ref(null)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const uploading = ref(false)
|
||||
const regBusy = ref(false)
|
||||
const fileInput = ref(null)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
acct.value = await adminApi.account()
|
||||
} catch (e) {
|
||||
toastErr(e.message || '读取账户信息失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定完成是从 provider 整页跳回来的,用 query 带平台名:
|
||||
// 提示一次就把参数抹掉,免得刷新页面重复弹。
|
||||
async function reportBindResult() {
|
||||
const p = route.query.bound
|
||||
if (typeof p !== 'string' || !p) return
|
||||
toastOk(p === 'github' ? '已绑定 GitHub' : '已绑定 ' + p)
|
||||
router.replace({ path: route.path })
|
||||
}
|
||||
|
||||
async function save(patch) {
|
||||
saving.value = true
|
||||
try {
|
||||
acct.value = await adminApi.saveAccount(patch)
|
||||
toastOk('已保存')
|
||||
} catch (e) {
|
||||
toastErr(e.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onPickAvatar(ev) {
|
||||
const file = ev.target.files && ev.target.files[0]
|
||||
ev.target.value = '' // 允许连续选同一个文件
|
||||
if (!file) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const data = await adminApi.uploadFile(file)
|
||||
const f = Array.isArray(data) ? data[0] : null
|
||||
if (!f || !f.key) throw new Error('上传失败')
|
||||
await save({ avatar_key: f.key })
|
||||
} catch (e) {
|
||||
toastErr(e.message || '头像上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAvatar() {
|
||||
await save({ avatar_key: '' })
|
||||
}
|
||||
|
||||
function startBind(provider) {
|
||||
// 整页跳转:OAuth 回来后由后端重定向回本页并带 ?bound=
|
||||
window.location.assign('/api/auth/' + provider + '/bind')
|
||||
}
|
||||
|
||||
async function unbind(provider) {
|
||||
const label = provider === 'github' ? 'GitHub' : provider
|
||||
if (!window.confirm('解绑 ' + label + '?之后就不能再用它登录后台了。')) return
|
||||
try {
|
||||
await adminApi.unbindIdentity(provider)
|
||||
toastOk('已解绑')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toastErr(e.message || '解绑失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function addPasskey() {
|
||||
if (!waSupported()) {
|
||||
toastErr('这个浏览器不支持 passkey')
|
||||
return
|
||||
}
|
||||
const name = window.prompt('给这把凭据起个名字(如「MacBook 指纹」)', '')
|
||||
if (name === null) return
|
||||
regBusy.value = true
|
||||
try {
|
||||
const { options, token } = await adminApi.passkeyBegin()
|
||||
const cred = await waRegister(options)
|
||||
await adminApi.passkeyFinish({ token, name: name.trim(), credential: cred })
|
||||
toastOk('passkey 已添加')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toastErr(e.message || 'passkey 注册失败')
|
||||
} finally {
|
||||
regBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removePasskey(pk) {
|
||||
if (!window.confirm('删除 passkey「' + (pk.name || '未命名') + '」?')) return
|
||||
try {
|
||||
await adminApi.deletePasskey(pk.id)
|
||||
toastOk('已删除')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toastErr(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDate(s) {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
return Number.isNaN(d.getTime()) ? s : d.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
const providerLabel = { github: 'GitHub', google: 'Google', telegram: 'Telegram' }
|
||||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
reportBindResult()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="loading" class="loading">载入中…</section>
|
||||
<section v-else-if="acct">
|
||||
<h1 style="font-family: var(--serif); font-size: 22px; margin-bottom: 4px;">账户</h1>
|
||||
<p class="field-hint" style="margin-bottom: 20px;">
|
||||
头像、昵称与登录方式。站点标题、皮肤这些站点级配置在
|
||||
<router-link to="/admin/settings">设置</router-link>。
|
||||
</p>
|
||||
|
||||
<!-- ---------- 资料 ---------- -->
|
||||
<div class="panel">
|
||||
<div class="panel-title"><h2>公开资料</h2></div>
|
||||
|
||||
<div class="field">
|
||||
<label>头像</label>
|
||||
<div class="avatar-row">
|
||||
<span class="avatar-box">
|
||||
<img v-if="acct.avatar_url" :src="acct.avatar_url" alt="" />
|
||||
<span v-else class="avatar-ph">无</span>
|
||||
</span>
|
||||
<div class="avatar-acts">
|
||||
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/gif" hidden @change="onPickAvatar" />
|
||||
<button class="btn" :disabled="uploading" @click="fileInput.click()">
|
||||
{{ uploading ? '上传中…' : (acct.avatar_key ? '换一个' : '上传头像') }}
|
||||
</button>
|
||||
<button v-if="acct.avatar_key" class="btn" :disabled="saving" @click="removeAvatar">移除</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
头像会出现在时间线、文章页与关于页。没设置时那些位置显示站标。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>昵称</label>
|
||||
<input v-model="acct.name" class="input" maxlength="40" spellcheck="false" />
|
||||
<p class="field-hint">评论区与文章署名用的名字。</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>简介</label>
|
||||
<textarea v-model="acct.bio" class="textarea" rows="2" maxlength="200" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>用户名(handle)</label>
|
||||
<input :value="acct.handle" class="input" disabled />
|
||||
<p class="field-hint">
|
||||
由环境变量 <code>ONE_ADMIN_USER</code> 决定,不在这里改 ——
|
||||
它是账号的身份锚点,改了要连密码一起换。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save({ name: acct.name, bio: acct.bio })">
|
||||
{{ saving ? '保存中…' : '保存资料' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ---------- 登录方式 ---------- -->
|
||||
<div class="panel" style="margin-top: 18px;">
|
||||
<div class="panel-title"><h2>登录方式</h2></div>
|
||||
|
||||
<div class="method">
|
||||
<div class="method-head">
|
||||
<b>密码</b>
|
||||
<span class="tag on">始终可用</span>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
站主密码由环境变量 <code>ONE_ADMIN_PASSWORD</code> 管理,不存在数据库里,
|
||||
因此也就无法在这里被改掉或解绑。这条退路保证:哪怕解绑了所有第三方、
|
||||
删光所有 passkey,你仍然能登录进来。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<div class="method-head">
|
||||
<b>第三方账号</b>
|
||||
<span v-if="acct.identities.length" class="tag on">已绑定 {{ acct.identities.length }}</span>
|
||||
<span v-else class="tag">未绑定</span>
|
||||
</div>
|
||||
<ul v-if="acct.identities.length" class="list">
|
||||
<li v-for="it in acct.identities" :key="it.id">
|
||||
<span>{{ providerLabel[it.provider] || it.provider }}</span>
|
||||
<code>{{ it.display }}</code>
|
||||
<span class="when">绑于 {{ fmtDate(it.created_at) }}</span>
|
||||
<button class="btn btn-sm" @click="unbind(it.provider)">解绑</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="field-hint">还没有绑定任何第三方账号。</p>
|
||||
<div v-if="acct.providers.length" class="bind-acts">
|
||||
<button
|
||||
v-for="p in acct.providers"
|
||||
:key="p"
|
||||
class="btn btn-sm"
|
||||
:disabled="acct.identities.some((i) => i.provider === p)"
|
||||
@click="startBind(p)"
|
||||
>
|
||||
绑定 {{ providerLabel[p] || p }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
绑定后,用该账号 OAuth 登录会<b>直接进入后台</b>(等于站主身份)。
|
||||
所以绑定动作必须在已登录后台时发起,且一个外部账号只能属于一个用户。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="method">
|
||||
<div class="method-head">
|
||||
<b>Passkey</b>
|
||||
<span v-if="acct.passkeys.length" class="tag on">{{ acct.passkeys.length }} 把</span>
|
||||
<span v-else class="tag">未添加</span>
|
||||
</div>
|
||||
<ul v-if="acct.passkeys.length" class="list">
|
||||
<li v-for="pk in acct.passkeys" :key="pk.id">
|
||||
<span>{{ pk.name || '未命名设备' }}</span>
|
||||
<span class="when">
|
||||
加于 {{ fmtDate(pk.created_at) }}<template v-if="pk.last_used_at"> · 上次用 {{ fmtDate(pk.last_used_at) }}</template>
|
||||
</span>
|
||||
<button class="btn btn-sm" @click="removePasskey(pk)">删除</button>
|
||||
</li>
|
||||
</ul>
|
||||
<button class="btn btn-sm" :disabled="regBusy" @click="addPasskey">
|
||||
{{ regBusy ? '等待浏览器…' : '添加 passkey' }}
|
||||
</button>
|
||||
<p class="field-hint">
|
||||
用设备指纹 / 面容 / 安全密钥登录,无需密码,也无需先输用户名。
|
||||
<template v-if="!acct.passkeys.length && !acct.providers.length">
|
||||
服务端需配置 <code>ONE_WEBAUTHN_ORIGINS</code> 才会开放此入口。
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.avatar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.avatar-box {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--admin-sunken, #f3efe6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
}
|
||||
.avatar-box img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.avatar-ph {
|
||||
font-size: 12px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
.avatar-acts {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.method {
|
||||
padding: 14px 0;
|
||||
border-top: 1px solid var(--admin-line, #e6e0d4);
|
||||
}
|
||||
.method:first-of-type {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
.method-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.tag {
|
||||
font-size: 11px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--admin-line, #e6e0d4);
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
.tag.on {
|
||||
border-color: var(--accent-line, rgba(61, 127, 156, 0.35));
|
||||
color: var(--admin-accent, #3d7f9c);
|
||||
}
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0 0 10px;
|
||||
padding: 0;
|
||||
}
|
||||
.list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.list code {
|
||||
font-size: 12px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
.when {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
.bind-acts {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 4px 12px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
</style>
|
||||
@@ -140,6 +140,10 @@ watch(() => route.path, () => loadCounts())
|
||||
<span class="ic">⚙</span>
|
||||
<span>设置</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/admin/account" class="item">
|
||||
<span class="ic">◉</span>
|
||||
<span>账户</span>
|
||||
</RouterLink>
|
||||
<div class="group">前台</div>
|
||||
<a href="/" target="_blank" class="item">
|
||||
<span class="ic">↗</span>
|
||||
|
||||
@@ -220,18 +220,17 @@ async function save() {
|
||||
<label>站点副标题</label>
|
||||
<input v-model="settings.site_desc" class="input" spellcheck="false" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>作者昵称</label>
|
||||
<input v-model="settings.author_name" class="input" spellcheck="false" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>作者简介</label>
|
||||
<textarea v-model="settings.author_bio" class="textarea" rows="3" />
|
||||
</div>
|
||||
<!-- 作者昵称/简介不在这里改:它们和头像、登录方式同属「账户」,
|
||||
两处编辑同一个字段迟早漂移,所以收归账户页独占。 -->
|
||||
<div class="field">
|
||||
<label>页脚备注</label>
|
||||
<input v-model="settings.footer_note" class="input" spellcheck="false" />
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
作者昵称、简介与头像在
|
||||
<RouterLink to="/admin/account">账户</RouterLink>
|
||||
页设置(它们和登录方式属于同一件事,两处编辑同一个字段迟早漂移)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- theme -->
|
||||
|
||||
@@ -83,6 +83,17 @@ export const adminApi = {
|
||||
deleteProject: (id) => request('/api/admin/projects/' + id, { method: 'DELETE' }),
|
||||
settings: () => request('/api/admin/settings'),
|
||||
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body }),
|
||||
// ---------- 账户(资料 / 身份绑定 / passkey) ----------
|
||||
account: () => request('/api/admin/account'),
|
||||
saveAccount: (body) => request('/api/admin/account', { method: 'PATCH', body }),
|
||||
unbindIdentity: (provider) =>
|
||||
request('/api/admin/account/identities/' + encodeURIComponent(provider), { method: 'DELETE' }),
|
||||
passkeyBegin: () => request('/api/admin/account/passkeys/begin', { method: 'POST' }),
|
||||
passkeyFinish: (body) => request('/api/admin/account/passkeys/finish', { method: 'POST', body }),
|
||||
deletePasskey: (id) => request('/api/admin/account/passkeys/' + id, { method: 'DELETE' }),
|
||||
// 前台 passkey 登录(公开端点)
|
||||
passkeyLoginBegin: () => request('/api/auth/passkey/begin', { method: 'POST' }),
|
||||
passkeyLoginFinish: (body) => request('/api/auth/passkey/finish', { method: 'POST', body }),
|
||||
// ---------- 评论管理 ----------
|
||||
comments: (params = {}) => request('/api/admin/comments?' + new URLSearchParams(params)),
|
||||
approveComment: (id) =>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { site } from '../site'
|
||||
import { thumbURL } from '../utils'
|
||||
import LogoMark from './LogoMark.vue'
|
||||
|
||||
// 作者头像位:站主设了头像就用图,没设就回落到站标「一横」。
|
||||
// 尺寸由外层容器决定(和 LogoMark 一样靠 width/height:100%)。
|
||||
//
|
||||
// 走缩略图路由:头像位最大也才 64px,没必要拉原图。
|
||||
// thumbURL 的签名是 (url, width) —— 公开域名由 site.js 经 setThumbBase 注入,
|
||||
// 不在参数里传。
|
||||
const url = computed(() => thumbURL(site.author_avatar_url, 128))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img v-if="url" class="author-avatar" :src="url" alt="" decoding="async" />
|
||||
<LogoMark v-else />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.author-avatar {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@ import { site } from '../site'
|
||||
import { openLightbox } from '../lightbox'
|
||||
import { relativeDate, stripTags, minutesLabel, sanitizeHtml, thumbURL, thumbifyHtml } from '../utils'
|
||||
import LinkCard from './LinkCard.vue'
|
||||
import LogoMark from './LogoMark.vue'
|
||||
import AuthorAvatar from './AuthorAvatar.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -41,7 +41,7 @@ const gridW = computed(() => (props.post.images && props.post.images.length ===
|
||||
|
||||
<template>
|
||||
<article class="row-feed" :class="{ short: isShort }" @click="openPost">
|
||||
<div class="avatar"><LogoMark /></div>
|
||||
<div class="avatar"><AuthorAvatar /></div>
|
||||
|
||||
<div class="body">
|
||||
<div class="byline">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { adminApi } from '../api'
|
||||
import LogoMark from './LogoMark.vue'
|
||||
import AuthorAvatar from './AuthorAvatar.vue'
|
||||
|
||||
// Twitter 式短文发布盒,挂在时间线顶部。
|
||||
//
|
||||
@@ -108,7 +108,7 @@ async function publish() {
|
||||
<!-- 桌面:内联 -->
|
||||
<div class="qs-inline">
|
||||
<div class="qs-card">
|
||||
<span class="qs-av"><LogoMark /></span>
|
||||
<span class="qs-av"><AuthorAvatar /></span>
|
||||
<div class="qs-body">
|
||||
<textarea v-model="draft" class="qs-input" rows="2" placeholder="记点短的…" @keydown="onKey" />
|
||||
<div v-if="images.length" class="qs-thumbs">
|
||||
|
||||
@@ -22,7 +22,8 @@ const routes = [
|
||||
{ path: ':id', name: 'admin-edit', component: () => import('./admin/EditorView.vue') },
|
||||
{ path: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') },
|
||||
{ path: 'projects', name: 'admin-projects', component: () => import('./admin/ProjectsView.vue') },
|
||||
{ path: 'settings', name: 'admin-settings', component: () => import('./admin/SettingsView.vue') }
|
||||
{ path: 'settings', name: 'admin-settings', component: () => import('./admin/SettingsView.vue') },
|
||||
{ path: 'account', name: 'admin-account', component: () => import('./admin/AccountView.vue') }
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
@@ -97,6 +97,11 @@ export const site = reactive({
|
||||
custom_css: {},
|
||||
// 站主自定义 JS(统计脚本等),公开页注入一次,/admin 不注入
|
||||
custom_js: '',
|
||||
// 上传文件的公开域名(R2 直链前缀)。前端靠它判断哪些图片可以转本站缩略图,
|
||||
// 也用它解析头像 URL。
|
||||
uploads_public_base: '',
|
||||
// 站主头像的解析结果(后端按 owner_avatar_key + 存储端算出来;空=未设置)
|
||||
author_avatar_url: '',
|
||||
themeMode: readMode(),
|
||||
loaded: false
|
||||
})
|
||||
@@ -157,6 +162,7 @@ export async function loadSite() {
|
||||
// 响应结构:{ settings: {…}, uploads_public_base }
|
||||
const { uploads_public_base, settings } = resp || {}
|
||||
setThumbBase(uploads_public_base)
|
||||
site.uploads_public_base = uploads_public_base || ''
|
||||
Object.assign(site, settings)
|
||||
// 防御性:服务端返回的 light_skin_id 必须是白名单之一,否则兜底 paper
|
||||
if (!isValidLightSkin(site.light_skin_id)) site.light_skin_id = 'paper'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { site } from '../../site'
|
||||
import NavBurger from '../../components/NavBurger.vue'
|
||||
import LogoMark from '../../components/LogoMark.vue'
|
||||
import AuthorAvatar from '../../components/AuthorAvatar.vue'
|
||||
|
||||
// 余白顶栏。对应设计稿的 <header class="top">:品牌 / 导航 / 操作三块,
|
||||
// 滚动过 8px 加 .scrolled(设计稿的「四态里的第一态」)。
|
||||
@@ -94,7 +94,7 @@ function toggleTheme() {
|
||||
<header class="top" :class="{ scrolled }">
|
||||
<div class="top-in">
|
||||
<RouterLink class="brand" to="/">
|
||||
<span class="av"><LogoMark /></span>
|
||||
<span class="av"><AuthorAvatar /></span>
|
||||
<span>{{ title() }}</span>
|
||||
</RouterLink>
|
||||
<nav class="nav" aria-label="站点导航">
|
||||
@@ -115,7 +115,7 @@ function toggleTheme() {
|
||||
必须待在 .top 外面:.top 有 backdrop-filter,会给 fixed 后代当包含块。 -->
|
||||
<div class="dock">
|
||||
<RouterLink class="dock-brand" to="/">
|
||||
<span class="av"><LogoMark /></span>
|
||||
<span class="av"><AuthorAvatar /></span>
|
||||
<span>{{ title() }}</span>
|
||||
</RouterLink>
|
||||
<NavBurger
|
||||
|
||||
@@ -6,7 +6,7 @@ import RightRail from '../components/RightRail.vue'
|
||||
import YohakuArticle from '../ui/yohaku/YohakuArticle.vue'
|
||||
import CommentSection from '../components/comments/CommentSection.vue'
|
||||
import LinkCard from '../components/LinkCard.vue'
|
||||
import LogoMark from '../components/LogoMark.vue'
|
||||
import AuthorAvatar from '../components/AuthorAvatar.vue'
|
||||
import { openLightbox } from '../lightbox'
|
||||
import { publicApi } from '../api'
|
||||
import { site, applyDocTitle } from '../site'
|
||||
@@ -100,7 +100,7 @@ const isShort = computed(() => post.value && post.value.kind === 'short')
|
||||
<article v-else class="wrap" :class="{ short: isShort }">
|
||||
<header class="head">
|
||||
<div class="byline">
|
||||
<div class="avatar"><LogoMark /></div>
|
||||
<div class="avatar"><AuthorAvatar /></div>
|
||||
<div class="who">
|
||||
<span class="author">{{ site.author_name || 'ONE' }}</span>
|
||||
<span class="sub">
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// WebAuthn 的浏览器侧胶水。
|
||||
//
|
||||
// 存在的理由只有一句话:navigator.credentials 要 ArrayBuffer,
|
||||
// 后端 go-webauthn 要 base64url 字符串,两边不会自动转换。
|
||||
// JSON.stringify 一个 PublicKeyCredential 会把 ArrayBuffer 变成 {},
|
||||
// 所以响应必须手工组装成 base64url。
|
||||
//
|
||||
// 只用标准 Web API,不引 @simplewebauthn/browser 之类的包。
|
||||
|
||||
export function isSupported() {
|
||||
return typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials
|
||||
}
|
||||
|
||||
// base64url 字符串 -> ArrayBuffer
|
||||
function toBuf(s) {
|
||||
const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(s.length / 4) * 4, '='))
|
||||
const out = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
||||
return out.buffer
|
||||
}
|
||||
|
||||
// ArrayBuffer -> base64url(无填充)
|
||||
function toB64(buf) {
|
||||
const bytes = new Uint8Array(buf)
|
||||
let bin = ''
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
// 服务端给的 options 里这些字段是 base64url,要用前逐个还原成 ArrayBuffer
|
||||
function decodeOptions(pk) {
|
||||
const out = { ...pk }
|
||||
out.challenge = toBuf(pk.challenge)
|
||||
if (pk.user) {
|
||||
out.user = { ...pk.user, id: toBuf(pk.user.id) }
|
||||
}
|
||||
if (Array.isArray(pk.excludeCredentials)) {
|
||||
out.excludeCredentials = pk.excludeCredentials.map((c) => ({ ...c, id: toBuf(c.id) }))
|
||||
}
|
||||
if (Array.isArray(pk.allowCredentials)) {
|
||||
out.allowCredentials = pk.allowCredentials.map((c) => ({ ...c, id: toBuf(c.id) }))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 浏览器返回的凭据 -> 后端能解析的 JSON(全部 base64url)
|
||||
function encodeResponse(cred) {
|
||||
const r = cred.response
|
||||
const pick = ['clientDataJSON', 'attestationObject', 'authenticatorData', 'signature']
|
||||
const response = {}
|
||||
for (const k of pick) {
|
||||
if (r[k] != null) response[k] = toB64(r[k])
|
||||
}
|
||||
// userHandle 可能是 null(发现式登录未回填时),保留 null 而不是转成字符串
|
||||
if ('userHandle' in r) response.userHandle = r.userHandle ? toB64(r.userHandle) : null
|
||||
return {
|
||||
id: cred.id,
|
||||
rawId: toB64(cred.rawId),
|
||||
type: cred.type,
|
||||
authenticatorAttachment: cred.authenticatorAttachment || undefined,
|
||||
clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {},
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
// register 注册一把新凭据。options 是后端 BeginRegistration 的产物。
|
||||
export async function register(options) {
|
||||
if (!isSupported()) throw new Error('这个浏览器不支持 passkey')
|
||||
const cred = await navigator.credentials.create({ publicKey: decodeOptions(options.publicKey || options) })
|
||||
return encodeResponse(cred)
|
||||
}
|
||||
|
||||
// assert 用已有凭据登录。options 是后端 BeginLogin 的产物。
|
||||
export async function assert(options) {
|
||||
if (!isSupported()) throw new Error('这个浏览器不支持 passkey')
|
||||
const cred = await navigator.credentials.get({ publicKey: decodeOptions(options.publicKey || options) })
|
||||
return encodeResponse(cred)
|
||||
}
|
||||
Reference in new issue
Block a user