- md 页签从裸 textarea 升级 CodeMirror 6:markdown 语法高亮、行内历史,粘贴/拖图直传; markdown 仍是唯一真相源,转存/切换用 syncCM 全量灌回,工具栏选区辅助改为面向 CM - POST /api/admin/files/import:外链抓取复用 linkmeta SSRF 防护拨号与跳转限制, 二进制传输放宽 30s;类型按内容嗅探反查白名单,与手动上传共用去重/落盘(persistFile 抽取共享) - 上传走 XHR 进度回调 + 编辑器 toast 栈:类型/大小前置校验(与后端白名单一致)、逐文件进度 - persistFile 去重路径补 URL 解析——此前命中去重返回的行没有 URL,转存替换会把正文图链清空(已修复受损数据) - hub nil 安全(测试环境未装配时不 panic)
190 lines
7.7 KiB
JavaScript
190 lines
7.7 KiB
JavaScript
const base = ''
|
||
|
||
// 侧栏数据(标签 / 最近更新)每页都会重新挂载请求,这里做 30s 的
|
||
// 模块级缓存 + 并发去重;失败不缓存,下一次调用会重新请求。
|
||
function cached(fn, ttl = 30000) {
|
||
let p = null
|
||
let at = 0
|
||
return (...args) => {
|
||
const now = Date.now()
|
||
if (p && now - at < ttl) return p
|
||
at = now
|
||
const cur = fn(...args)
|
||
p = cur
|
||
cur.catch(() => {
|
||
if (p === cur) p = null
|
||
})
|
||
return cur
|
||
}
|
||
}
|
||
|
||
async function request(path, { method = 'GET', body, auth = false } = {}) {
|
||
const headers = { 'Content-Type': 'application/json' }
|
||
const res = await fetch(base + path, {
|
||
method,
|
||
headers,
|
||
credentials: 'include',
|
||
body: body === undefined ? undefined : JSON.stringify(body)
|
||
})
|
||
if (res.status === 401) {
|
||
window.dispatchEvent(new CustomEvent('one:unauthorized'))
|
||
const err = new Error('未登录或登录已过期')
|
||
err.status = 401
|
||
throw err
|
||
}
|
||
if (!res.ok) {
|
||
let msg = `请求失败(${res.status})`
|
||
try {
|
||
const data = await res.json()
|
||
if (data && data.error) msg = data.error
|
||
} catch (_) {
|
||
/* 非 JSON 响应 */
|
||
}
|
||
const err = new Error(msg)
|
||
err.status = res.status
|
||
throw err
|
||
}
|
||
if (res.status === 204) return null
|
||
return res.json()
|
||
}
|
||
|
||
export const publicApi = {
|
||
site: () => request('/api/site'),
|
||
posts: (params = {}) => request('/api/posts?' + new URLSearchParams(params)),
|
||
post: (slug) => request('/api/posts/' + encodeURIComponent(slug)),
|
||
archive: () => request('/api/archive'),
|
||
tags: cached(() => request('/api/tags')),
|
||
projects: cached(() => request('/api/projects')),
|
||
latest: cached(() => request('/api/posts?size=5'))
|
||
}
|
||
|
||
export const adminApi = {
|
||
login: (username, password) =>
|
||
request('/api/admin/login', { method: 'POST', body: { username, password } }),
|
||
logout: () => request('/api/admin/logout', { method: 'POST' }),
|
||
me: () => request('/api/admin/me'),
|
||
dashboard: () => request('/api/admin/dashboard'),
|
||
posts: (params = {}) => request('/api/admin/posts?' + new URLSearchParams(params)),
|
||
post: (id) => request('/api/admin/posts/' + id),
|
||
createPost: (body) => request('/api/admin/posts', { method: 'POST', body }),
|
||
updatePost: (id, body) => request('/api/admin/posts/' + id, { method: 'PUT', body }),
|
||
deletePost: (id) => request('/api/admin/posts/' + id, { method: 'DELETE' }),
|
||
bulkPosts: (ids, action) =>
|
||
request('/api/admin/posts/bulk', { method: 'POST', body: { ids, action } }),
|
||
tags: () => request('/api/admin/tags'),
|
||
createTag: (body) => request('/api/admin/tags', { method: 'POST', body }),
|
||
updateTag: (id, body) => request('/api/admin/tags/' + id, { method: 'PUT', body }),
|
||
deleteTag: (id) => request('/api/admin/tags/' + id, { method: 'DELETE' }),
|
||
mergeTag: (id, toId) =>
|
||
request('/api/admin/tags/' + id + '/merge', { method: 'POST', body: { to_id: toId } }),
|
||
projects: (params = {}) => request('/api/admin/projects?' + new URLSearchParams(params)),
|
||
createProject: (body) => request('/api/admin/projects', { method: 'POST', body }),
|
||
updateProject: (id, body) => request('/api/admin/projects/' + id, { method: 'PUT', body }),
|
||
deleteProject: (id) => request('/api/admin/projects/' + id, { method: 'DELETE' }),
|
||
settings: () => request('/api/admin/settings'),
|
||
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body }),
|
||
// ---------- 评论管理 ----------
|
||
comments: (params = {}) => request('/api/admin/comments?' + new URLSearchParams(params)),
|
||
approveComment: (id) =>
|
||
request('/api/admin/comments/' + id, { method: 'PUT', body: { status: 'visible' } }),
|
||
deleteComment: (id) => request('/api/admin/comments/' + id, { method: 'DELETE' }),
|
||
readers: (params = {}) => request('/api/admin/readers?' + new URLSearchParams(params)),
|
||
setReaderBanned: (id, banned) =>
|
||
request('/api/admin/readers/' + id + '/ban', { method: 'POST', body: { banned } }),
|
||
// ---------- 文件上传 ----------
|
||
files: (params = {}) => request('/api/admin/files?' + new URLSearchParams(params)),
|
||
deleteFile: (id) => request('/api/admin/files/' + id, { method: 'DELETE' }),
|
||
// 上传走 FormData:request() 是 JSON helper,这里单独 fetch。
|
||
// 401 同样广播 one:unauthorized,错误消息从 JSON body 里取(与 request 一致)。
|
||
// 单文件上传(XHR):fetch 拿不到上传进度,编辑器的进度提示走这里
|
||
uploadFile: (file, onProgress) =>
|
||
new Promise((resolve, reject) => {
|
||
const xhr = new XMLHttpRequest()
|
||
xhr.open('POST', base + '/api/admin/files')
|
||
xhr.withCredentials = true
|
||
xhr.upload.onprogress = (e) => {
|
||
if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total)
|
||
}
|
||
xhr.onload = () => {
|
||
if (xhr.status === 401) {
|
||
window.dispatchEvent(new CustomEvent('one:unauthorized'))
|
||
reject(new Error('未登录或登录已过期'))
|
||
return
|
||
}
|
||
let data = {}
|
||
try {
|
||
data = JSON.parse(xhr.responseText)
|
||
} catch {}
|
||
if (xhr.status >= 200 && xhr.status < 300) resolve(data)
|
||
else reject(new Error(data.error || `上传失败(${xhr.status})`))
|
||
}
|
||
xhr.onerror = () => reject(new Error('网络错误,上传中止'))
|
||
const fd = new FormData()
|
||
fd.append('file', file)
|
||
xhr.send(fd)
|
||
}),
|
||
// 外链转存:把正文里的外链图片抓回自己的存储,返回 { files, errors }
|
||
importFiles: (urls) => request('/api/admin/files/import', { method: 'POST', body: { urls } }),
|
||
uploadFiles: async (formData) => {
|
||
const res = await fetch(base + '/api/admin/files', {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
body: formData
|
||
})
|
||
if (res.status === 401) {
|
||
window.dispatchEvent(new CustomEvent('one:unauthorized'))
|
||
const err = new Error('未登录或登录已过期')
|
||
err.status = 401
|
||
throw err
|
||
}
|
||
const data = await res.json().catch(() => ({}))
|
||
if (!res.ok) {
|
||
const err = new Error(data.error || `上传失败(${res.status})`)
|
||
err.status = res.status
|
||
throw err
|
||
}
|
||
return data
|
||
}
|
||
}
|
||
|
||
// 读者(评论区)身份。会话是服务端 httpOnly cookie(one_reader),
|
||
// 前端只拿 /api/auth/me 的结果做展示,不存 token。
|
||
// 注意 me 在匿名时返回 200 {user:null} 而不是 401 —— request() 对每个 401
|
||
// 都会派发 one:unauthorized,匿名访客每次开页都会刷一遍事件。
|
||
export const readerApi = {
|
||
me: () => request('/api/auth/me'),
|
||
providers: () => request('/api/auth/providers'),
|
||
logout: () => request('/api/auth/logout', { method: 'POST' }),
|
||
// Telegram 是 Login Widget:官方脚本回调里直接带着签名字段,没有 code 可换
|
||
telegram: (payload) => request('/api/auth/telegram', { method: 'POST', body: payload }),
|
||
|
||
comments: (postId, { sort = 'default', page = 1, size = 10 } = {}) =>
|
||
request('/api/comments?' + new URLSearchParams({ post_id: postId, sort, page, size })),
|
||
thread: (rootId, cursor = '', size = 10) =>
|
||
request(`/api/comments/${rootId}/thread?` + new URLSearchParams({ cursor, size })),
|
||
create: (postId, bodyMd, parentId = 0) =>
|
||
request('/api/comments', {
|
||
method: 'POST',
|
||
body: { post_id: postId, parent_id: parentId, body_md: bodyMd }
|
||
}),
|
||
edit: (id, bodyMd) =>
|
||
request(`/api/comments/${id}`, { method: 'PUT', body: { body_md: bodyMd } }),
|
||
remove: (id) => request(`/api/comments/${id}`, { method: 'DELETE' })
|
||
}
|
||
|
||
// 后台登录态:cookie 由服务端下发(httpOnly),这里只存一份展示用的用户名
|
||
const USER_KEY = 'one.admin.user'
|
||
|
||
export const session = {
|
||
get user() {
|
||
return localStorage.getItem(USER_KEY) || ''
|
||
},
|
||
set user(v) {
|
||
if (v) localStorage.setItem(USER_KEY, v)
|
||
else localStorage.removeItem(USER_KEY)
|
||
},
|
||
clear() {
|
||
localStorage.removeItem(USER_KEY)
|
||
}
|
||
}
|