加入用户管理安全认证和邮箱通知
This commit is contained in:
parent
c7aecccc17
commit
3afcccbd84
@ -4,26 +4,51 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"scout/internal/httpapi"
|
||||
"scout/internal/notify"
|
||||
"scout/internal/registry"
|
||||
"scout/internal/users"
|
||||
)
|
||||
|
||||
func main() {
|
||||
address := envOr("SCOUT_ADDR", ":8080")
|
||||
dataDir := envOr("SCOUT_DATA_DIR", "data")
|
||||
token := os.Getenv("SCOUT_DEVELOPER_TOKEN")
|
||||
if token == "" {
|
||||
log.Fatal("请设置 SCOUT_DEVELOPER_TOKEN 后再启动服务端")
|
||||
}
|
||||
logger := log.Default()
|
||||
store, err := registry.New(dataDir)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
logger.Fatal(err)
|
||||
}
|
||||
userStore, err := users.New(filepath.Join(dataDir, "users.json"))
|
||||
if err != nil {
|
||||
logger.Fatal(err)
|
||||
}
|
||||
if adminEmail := os.Getenv("SCOUT_ADMIN_EMAIL"); adminEmail != "" {
|
||||
if _, err := userStore.EnsureAdmin(adminEmail, os.Getenv("SCOUT_ADMIN_NAME"), os.Getenv("SCOUT_ADMIN_PASSWORD")); err != nil {
|
||||
logger.Fatal(err)
|
||||
}
|
||||
}
|
||||
server := &httpapi.Server{
|
||||
Registry: store,
|
||||
DeveloperToken: token,
|
||||
MaxUploadBytes: 512 << 20,
|
||||
Users: userStore,
|
||||
Mailer: notify.NewFromEnv(logger),
|
||||
SessionDuration: 24 * time.Hour,
|
||||
Logger: logger,
|
||||
}
|
||||
logger.Printf("Scout 包服务端正在监听 %s,数据目录为 %s", address, dataDir)
|
||||
if token == "" {
|
||||
logger.Printf("未配置 SCOUT_DEVELOPER_TOKEN,上传需要开发者账户或管理员账户")
|
||||
}
|
||||
if os.Getenv("SCOUT_SMTP_HOST") == "" {
|
||||
logger.Printf("未配置 SMTP,邮箱通知将只记录日志")
|
||||
}
|
||||
server := &httpapi.Server{Registry: store, DeveloperToken: token}
|
||||
log.Printf("Scout 包服务端正在监听 %s,数据目录为 %s", address, dataDir)
|
||||
if err := http.ListenAndServe(address, server.Handler()); err != nil {
|
||||
log.Fatal(err)
|
||||
logger.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -376,3 +376,113 @@ button:disabled {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-panel,
|
||||
.admin-panel {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.auth-form label,
|
||||
.toolbar label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.auth-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #176b87;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.text-button:hover {
|
||||
color: #0b4056;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.user-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: #254653;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.helper-text {
|
||||
margin: -0.5rem 0 1rem;
|
||||
color: #8a5a18;
|
||||
}
|
||||
|
||||
.user-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 10rem auto;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid #d9e5e8;
|
||||
border-radius: 0.85rem;
|
||||
background: #fbfdfd;
|
||||
}
|
||||
|
||||
.user-row div {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.user-row small {
|
||||
color: #6a7c83;
|
||||
}
|
||||
|
||||
.user-row select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.auth-form {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.user-row {
|
||||
grid-template-columns: 1fr 8rem;
|
||||
}
|
||||
|
||||
.user-row .text-button {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.auth-form,
|
||||
.toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.user-summary {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,18 @@
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const defaultApi = import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8080'
|
||||
const sessionKey = 'scout-session'
|
||||
|
||||
export function useHome() {
|
||||
const apiBase = ref(defaultApi)
|
||||
const developerToken = ref('')
|
||||
const session = ref(sessionStorage.getItem(sessionKey) || '')
|
||||
const user = ref(null)
|
||||
const authMode = ref('login')
|
||||
const authForm = ref({ email: '', name: '', password: '' })
|
||||
const resetForm = ref({ email: '', token: '', password: '' })
|
||||
const adminUsers = ref([])
|
||||
const search = ref('')
|
||||
const packages = ref([])
|
||||
const selectedPackage = ref(null)
|
||||
const versions = ref([])
|
||||
@ -18,14 +26,27 @@ export function useHome() {
|
||||
})
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const authenticating = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
let eventSource = null
|
||||
|
||||
const isLoggedIn = computed(() => Boolean(user.value && session.value))
|
||||
const canUpload = computed(() => user.value?.role === 'developer' || user.value?.role === 'admin')
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
|
||||
function roleText(role) {
|
||||
return { user: '普通用户', developer: '开发者', admin: '管理员' }[role] || role
|
||||
}
|
||||
|
||||
function endpoint(path) {
|
||||
return `${apiBase.value.replace(/\/$/, '')}${path}`
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
return session.value ? { Authorization: `Bearer ${session.value}` } : {}
|
||||
}
|
||||
|
||||
async function readResponse(response) {
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
const payload = contentType.includes('application/json')
|
||||
@ -37,13 +58,27 @@ export function useHome() {
|
||||
return payload
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const headers = { ...authHeaders(), ...(options.headers || {}) }
|
||||
const response = await fetch(endpoint(path), { ...options, headers })
|
||||
return readResponse(response)
|
||||
}
|
||||
|
||||
async function loadPackages() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await fetch(endpoint('/api/v1/packages'))
|
||||
const payload = await readResponse(response)
|
||||
const query = search.value.trim() ? `?query=${encodeURIComponent(search.value.trim())}` : ''
|
||||
const payload = await request(`/api/v1/packages${query}`)
|
||||
packages.value = payload.packages || []
|
||||
if (selectedPackage.value) {
|
||||
const latest = packages.value.find((item) => item.name === selectedPackage.value.name)
|
||||
if (latest) {
|
||||
await selectPackage(latest)
|
||||
}
|
||||
} else if (packages.value.length > 0) {
|
||||
await selectPackage(packages.value[0])
|
||||
}
|
||||
} catch (reason) {
|
||||
error.value = reason.message
|
||||
} finally {
|
||||
@ -55,8 +90,7 @@ export function useHome() {
|
||||
selectedPackage.value = item
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await fetch(endpoint(`/api/v1/packages/${encodeURIComponent(item.name)}/versions`))
|
||||
const payload = await readResponse(response)
|
||||
const payload = await request(`/api/v1/packages/${encodeURIComponent(item.name)}/versions`)
|
||||
versions.value = payload.versions || []
|
||||
} catch (reason) {
|
||||
error.value = reason.message
|
||||
@ -68,8 +102,8 @@ export function useHome() {
|
||||
}
|
||||
|
||||
async function uploadPackage() {
|
||||
if (!developerToken.value) {
|
||||
error.value = '请先填写开发者令牌'
|
||||
if (!canUpload.value && !developerToken.value) {
|
||||
error.value = '请先登录开发者账户,或填写兼容开发者令牌'
|
||||
return
|
||||
}
|
||||
if (!uploadForm.value.name || !uploadForm.value.version || !uploadForm.value.file) {
|
||||
@ -84,16 +118,19 @@ export function useHome() {
|
||||
if (value) formData.append(key, value)
|
||||
})
|
||||
try {
|
||||
const response = await fetch(endpoint('/api/v1/packages/upload'), {
|
||||
const headers = session.value
|
||||
? authHeaders()
|
||||
: { Authorization: `Bearer ${developerToken.value}` }
|
||||
const payload = await request('/api/v1/packages/upload', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${developerToken.value}` },
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
const payload = await readResponse(response)
|
||||
message.value = `上传成功:${payload.name} ${payload.version}`
|
||||
uploadForm.value.version = ''
|
||||
uploadForm.value.file = null
|
||||
document.querySelector('#package-file').value = ''
|
||||
const fileInput = document.querySelector('#package-file')
|
||||
if (fileInput) fileInput.value = ''
|
||||
await loadPackages()
|
||||
} catch (reason) {
|
||||
error.value = reason.message
|
||||
@ -102,6 +139,117 @@ export function useHome() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAuth() {
|
||||
authenticating.value = true
|
||||
error.value = ''
|
||||
message.value = ''
|
||||
try {
|
||||
if (authMode.value === 'register') {
|
||||
await request('/api/v1/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(authForm.value),
|
||||
})
|
||||
authMode.value = 'login'
|
||||
message.value = '注册成功,请使用邮箱和密码登录'
|
||||
} else {
|
||||
const payload = await request('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: authForm.value.email, password: authForm.value.password }),
|
||||
})
|
||||
session.value = payload.session
|
||||
user.value = payload.user
|
||||
sessionStorage.setItem(sessionKey, payload.session)
|
||||
message.value = `登录成功,欢迎你,${payload.user.name}`
|
||||
if (isAdmin.value) await loadAdminUsers()
|
||||
}
|
||||
authForm.value.password = ''
|
||||
} catch (reason) {
|
||||
error.value = reason.message
|
||||
} finally {
|
||||
authenticating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
if (!session.value) return
|
||||
try {
|
||||
const payload = await request('/api/v1/auth/me')
|
||||
user.value = payload.user
|
||||
if (user.value.role === 'admin') await loadAdminUsers()
|
||||
} catch {
|
||||
session.value = ''
|
||||
sessionStorage.removeItem(sessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
if (session.value) await request('/api/v1/auth/logout', { method: 'POST' })
|
||||
} catch {
|
||||
// 退出时即使服务端不可用,也清理本地会话。
|
||||
}
|
||||
session.value = ''
|
||||
user.value = null
|
||||
adminUsers.value = []
|
||||
sessionStorage.removeItem(sessionKey)
|
||||
message.value = '已退出登录'
|
||||
}
|
||||
|
||||
async function requestReset() {
|
||||
try {
|
||||
const payload = await request('/api/v1/auth/password/request', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: resetForm.value.email }),
|
||||
})
|
||||
message.value = payload.message
|
||||
} catch (reason) {
|
||||
error.value = reason.message
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPassword() {
|
||||
try {
|
||||
const payload = await request('/api/v1/auth/password/reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: resetForm.value.token, new_password: resetForm.value.password }),
|
||||
})
|
||||
message.value = payload.message
|
||||
resetForm.value.token = ''
|
||||
resetForm.value.password = ''
|
||||
authMode.value = 'login'
|
||||
} catch (reason) {
|
||||
error.value = reason.message
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAdminUsers() {
|
||||
if (!isAdmin.value) return
|
||||
try {
|
||||
const payload = await request('/api/v1/admin/users')
|
||||
adminUsers.value = payload.users || []
|
||||
} catch (reason) {
|
||||
error.value = reason.message
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUser(target, role, active) {
|
||||
try {
|
||||
await request(`/api/v1/admin/users/${encodeURIComponent(target.id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role, active }),
|
||||
})
|
||||
message.value = `已更新用户:${target.email}`
|
||||
await loadAdminUsers()
|
||||
} catch (reason) {
|
||||
error.value = reason.message
|
||||
}
|
||||
}
|
||||
|
||||
function downloadUrl(item) {
|
||||
return endpoint(`/api/v1/packages/${encodeURIComponent(item.name)}/${encodeURIComponent(item.version)}/download`)
|
||||
}
|
||||
@ -115,7 +263,7 @@ export function useHome() {
|
||||
await loadPackages()
|
||||
})
|
||||
eventSource.onerror = () => {
|
||||
message.value = '更新通知连接暂时中断,页面会继续提供手动刷新'
|
||||
message.value = '更新通知连接暂时中断,页面仍可手动刷新'
|
||||
}
|
||||
}
|
||||
|
||||
@ -127,18 +275,37 @@ export function useHome() {
|
||||
return {
|
||||
apiBase,
|
||||
developerToken,
|
||||
session,
|
||||
user,
|
||||
authMode,
|
||||
authForm,
|
||||
resetForm,
|
||||
adminUsers,
|
||||
search,
|
||||
packages,
|
||||
selectedPackage,
|
||||
versions,
|
||||
uploadForm,
|
||||
loading,
|
||||
uploading,
|
||||
authenticating,
|
||||
message,
|
||||
error,
|
||||
isLoggedIn,
|
||||
canUpload,
|
||||
isAdmin,
|
||||
roleText,
|
||||
loadPackages,
|
||||
selectPackage,
|
||||
onFileChange,
|
||||
uploadPackage,
|
||||
submitAuth,
|
||||
loadMe,
|
||||
logout,
|
||||
requestReset,
|
||||
resetPassword,
|
||||
loadAdminUsers,
|
||||
updateUser,
|
||||
downloadUrl,
|
||||
startNotifications,
|
||||
stopNotifications,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<script setup>
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useHome } from './Home.js'
|
||||
import './Home.css'
|
||||
@ -6,24 +6,43 @@ import './Home.css'
|
||||
const {
|
||||
apiBase,
|
||||
developerToken,
|
||||
user,
|
||||
authMode,
|
||||
authForm,
|
||||
resetForm,
|
||||
adminUsers,
|
||||
search,
|
||||
packages,
|
||||
selectedPackage,
|
||||
versions,
|
||||
uploadForm,
|
||||
loading,
|
||||
uploading,
|
||||
authenticating,
|
||||
message,
|
||||
error,
|
||||
isLoggedIn,
|
||||
canUpload,
|
||||
isAdmin,
|
||||
roleText,
|
||||
loadPackages,
|
||||
selectPackage,
|
||||
onFileChange,
|
||||
uploadPackage,
|
||||
submitAuth,
|
||||
loadMe,
|
||||
logout,
|
||||
requestReset,
|
||||
resetPassword,
|
||||
loadAdminUsers,
|
||||
updateUser,
|
||||
downloadUrl,
|
||||
startNotifications,
|
||||
stopNotifications,
|
||||
} = useHome()
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMe()
|
||||
await loadPackages()
|
||||
startNotifications()
|
||||
})
|
||||
@ -35,7 +54,7 @@ onUnmounted(stopNotifications)
|
||||
<main class="page-shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">SCOUT PACKAGE SERVICE</p>
|
||||
<p class="eyebrow">SCOUT</p>
|
||||
<h1>轻量化包管理中心</h1>
|
||||
<p class="subtitle">开发者上传,客户端发现更新并安全下载。</p>
|
||||
</div>
|
||||
@ -50,18 +69,77 @@ onUnmounted(stopNotifications)
|
||||
<span>服务端地址</span>
|
||||
<input v-model="apiBase" type="url" placeholder="http://127.0.0.1:8080" />
|
||||
</label>
|
||||
<label>
|
||||
<span>搜索包</span>
|
||||
<input v-model="search" type="search" placeholder="输入包名或描述" @keyup.enter="loadPackages" />
|
||||
</label>
|
||||
<button class="secondary-button" type="button" @click="loadPackages">刷新包列表</button>
|
||||
<div v-if="isLoggedIn" class="user-summary">
|
||||
<span>{{ user.name }}({{ roleText(user.role) }})</span>
|
||||
<button class="text-button" type="button" @click="logout">退出</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="message" class="notice success">{{ message }}</p>
|
||||
<p v-if="error" class="notice error">{{ error }}</p>
|
||||
|
||||
<section v-if="!isLoggedIn" class="panel auth-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="section-kicker">账户安全</p>
|
||||
<h2>{{ authMode === 'register' ? '注册账户' : authMode === 'reset' ? '重置密码' : '登录 Scout' }}</h2>
|
||||
</div>
|
||||
<span class="security-label">会话认证</span>
|
||||
</div>
|
||||
<form v-if="authMode !== 'reset'" class="auth-form" @submit.prevent="submitAuth">
|
||||
<label v-if="authMode === 'register'">
|
||||
<span>名称</span>
|
||||
<input v-model="authForm.name" type="text" maxlength="80" placeholder="你的名称" />
|
||||
</label>
|
||||
<label>
|
||||
<span>邮箱</span>
|
||||
<input v-model="authForm.email" type="email" required placeholder="name@example.com" />
|
||||
</label>
|
||||
<label>
|
||||
<span>密码</span>
|
||||
<input v-model="authForm.password" type="password" minlength="8" required placeholder="至少 8 个字符" />
|
||||
</label>
|
||||
<button class="primary-button" type="submit" :disabled="authenticating">
|
||||
{{ authenticating ? '处理中……' : authMode === 'register' ? '注册账户' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
<form v-else class="auth-form" @submit.prevent="resetPassword">
|
||||
<label>
|
||||
<span>邮箱</span>
|
||||
<input v-model="resetForm.email" type="email" placeholder="先输入邮箱申请令牌" />
|
||||
</label>
|
||||
<button class="secondary-button" type="button" @click="requestReset">发送重置邮件</button>
|
||||
<label>
|
||||
<span>重置令牌</span>
|
||||
<input v-model="resetForm.token" type="text" placeholder="从邮箱中复制令牌" />
|
||||
</label>
|
||||
<label>
|
||||
<span>新密码</span>
|
||||
<input v-model="resetForm.password" type="password" minlength="8" placeholder="至少 8 个字符" />
|
||||
</label>
|
||||
<button class="primary-button" type="submit">修改密码</button>
|
||||
</form>
|
||||
<div class="auth-links">
|
||||
<button class="text-button" type="button" @click="authMode = authMode === 'register' ? 'login' : 'register'">
|
||||
{{ authMode === 'register' ? '已有账户,立即登录' : '没有账户,立即注册' }}
|
||||
</button>
|
||||
<button class="text-button" type="button" @click="authMode = authMode === 'reset' ? 'login' : 'reset'">
|
||||
{{ authMode === 'reset' ? '返回登录' : '忘记密码' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="content-grid">
|
||||
<div class="panel package-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="section-kicker">PACKAGE CATALOG</p>
|
||||
<h2>包目录</h2>
|
||||
<p class="section-kicker">包目录</p>
|
||||
<h2>可用软件包</h2>
|
||||
</div>
|
||||
<span class="count-label">{{ packages.length }} 个包</span>
|
||||
</div>
|
||||
@ -76,7 +154,7 @@ onUnmounted(stopNotifications)
|
||||
type="button"
|
||||
@click="selectPackage(item)"
|
||||
>
|
||||
<span class="package-icon">PK</span>
|
||||
<span class="package-icon">包</span>
|
||||
<span class="package-copy">
|
||||
<strong>{{ item.name }}</strong>
|
||||
<small>{{ item.description || '暂无描述' }}</small>
|
||||
@ -89,7 +167,7 @@ onUnmounted(stopNotifications)
|
||||
<div class="panel detail-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="section-kicker">PACKAGE DETAIL</p>
|
||||
<p class="section-kicker">包详情</p>
|
||||
<h2>{{ selectedPackage?.name || '选择一个包' }}</h2>
|
||||
</div>
|
||||
<span v-if="selectedPackage" class="platform-label">
|
||||
@ -118,26 +196,27 @@ onUnmounted(stopNotifications)
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel upload-panel">
|
||||
<section v-if="canUpload || !isLoggedIn" class="panel upload-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="section-kicker">DEVELOPER UPLOAD</p>
|
||||
<p class="section-kicker">开发者操作</p>
|
||||
<h2>上传新包</h2>
|
||||
</div>
|
||||
<span class="security-label">需要开发者令牌</span>
|
||||
<span class="security-label">仅开发者或管理员</span>
|
||||
</div>
|
||||
<p v-if="isLoggedIn && !canUpload" class="helper-text">当前账户是普通用户,请联系管理员授予开发者权限。</p>
|
||||
<div class="upload-layout">
|
||||
<label>
|
||||
<span>开发者令牌</span>
|
||||
<input v-model="developerToken" type="password" placeholder="输入服务端配置的令牌" />
|
||||
<label v-if="!isLoggedIn">
|
||||
<span>兼容开发者令牌</span>
|
||||
<input v-model="developerToken" type="password" placeholder="仅用于旧版脚本兼容" />
|
||||
</label>
|
||||
<label>
|
||||
<span>包名</span>
|
||||
<input v-model="uploadForm.name" type="text" placeholder="例如:demo-core" />
|
||||
<input v-model="uploadForm.name" type="text" maxlength="64" placeholder="例如:demo-core" />
|
||||
</label>
|
||||
<label>
|
||||
<span>版本</span>
|
||||
<input v-model="uploadForm.version" type="text" placeholder="例如:1.0.0" />
|
||||
<input v-model="uploadForm.version" type="text" maxlength="64" placeholder="例如:1.2.0" />
|
||||
</label>
|
||||
<label>
|
||||
<span>平台</span>
|
||||
@ -149,16 +228,42 @@ onUnmounted(stopNotifications)
|
||||
</label>
|
||||
<label class="wide-field">
|
||||
<span>描述</span>
|
||||
<input v-model="uploadForm.description" type="text" placeholder="简要说明这个包的用途" />
|
||||
<input v-model="uploadForm.description" type="text" maxlength="200" placeholder="简要说明这个包的用途" />
|
||||
</label>
|
||||
<label class="file-field wide-field">
|
||||
<span>包文件</span>
|
||||
<input id="package-file" type="file" @change="onFileChange" />
|
||||
</label>
|
||||
</div>
|
||||
<button class="primary-button upload-button" type="button" :disabled="uploading" @click="uploadPackage">
|
||||
<button class="primary-button upload-button" type="button" :disabled="uploading || (isLoggedIn && !canUpload)" @click="uploadPackage">
|
||||
{{ uploading ? '正在上传……' : '上传并通知客户端' }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section v-if="isAdmin" class="panel admin-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<p class="section-kicker">管理员控制台</p>
|
||||
<h2>用户管理</h2>
|
||||
</div>
|
||||
<button class="secondary-button" type="button" @click="loadAdminUsers">刷新用户</button>
|
||||
</div>
|
||||
<div class="user-list">
|
||||
<div v-for="target in adminUsers" :key="target.id" class="user-row">
|
||||
<div>
|
||||
<strong>{{ target.name }}</strong>
|
||||
<small>{{ target.email }}</small>
|
||||
</div>
|
||||
<select :value="target.role" @change="updateUser(target, $event.target.value, target.active)">
|
||||
<option value="user">普通用户</option>
|
||||
<option value="developer">开发者</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
<button class="text-button" type="button" @click="updateUser(target, target.role, !target.active)">
|
||||
{{ target.active ? '禁用账户' : '启用账户' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
173
internal/httpapi/auth_test.go
Normal file
173
internal/httpapi/auth_test.go
Normal file
@ -0,0 +1,173 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"scout/internal/notify"
|
||||
"scout/internal/registry"
|
||||
"scout/internal/users"
|
||||
)
|
||||
|
||||
type testMailer struct {
|
||||
mu sync.Mutex
|
||||
messages []notify.Message
|
||||
}
|
||||
|
||||
func (mailer *testMailer) Send(_ context.Context, message notify.Message) error {
|
||||
mailer.mu.Lock()
|
||||
defer mailer.mu.Unlock()
|
||||
mailer.messages = append(mailer.messages, message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (mailer *testMailer) count() int {
|
||||
mailer.mu.Lock()
|
||||
defer mailer.mu.Unlock()
|
||||
return len(mailer.messages)
|
||||
}
|
||||
|
||||
func TestAuthenticationRolesAndNotifications(t *testing.T) {
|
||||
store, err := registry.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userStore, err := users.New(filepath.Join(t.TempDir(), "users.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin, err := userStore.EnsureAdmin("admin@example.com", "管理员", "admin-pass-123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mailer := &testMailer{}
|
||||
server := httptest.NewServer((&Server{Registry: store, Users: userStore, Mailer: mailer}).Handler())
|
||||
defer server.Close()
|
||||
|
||||
registerResponse := postJSONRequest(t, server.URL+"/api/v1/auth/register", map[string]string{"email": "dev@example.com", "name": "开发者", "password": "developer-pass-123"}, "")
|
||||
if registerResponse.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("注册状态错误: %d", registerResponse.StatusCode)
|
||||
}
|
||||
registerResponse.Body.Close()
|
||||
developer, ok := userStore.FindByEmail("dev@example.com")
|
||||
if !ok {
|
||||
t.Fatal("开发者用户不存在")
|
||||
}
|
||||
if _, err := userStore.Update(developer.ID, users.RoleDeveloper, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loginResponse := postJSONRequest(t, server.URL+"/api/v1/auth/login", map[string]string{"email": "dev@example.com", "password": "developer-pass-123"}, "")
|
||||
if loginResponse.StatusCode != http.StatusOK {
|
||||
t.Fatalf("登录状态错误: %d", loginResponse.StatusCode)
|
||||
}
|
||||
var login userResponse
|
||||
if err := json.NewDecoder(loginResponse.Body).Decode(&login); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loginResponse.Body.Close()
|
||||
if login.Session == "" || login.User.Role != users.RoleDeveloper {
|
||||
t.Fatalf("登录结果错误: %+v", login)
|
||||
}
|
||||
|
||||
ordinary, err := userStore.Register("user@example.com", "普通用户", "ordinary-pass-123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ordinarySession, _, err := userStore.CreateSession(ordinary.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ordinaryUpload := postMultipartRequest(t, server.URL+"/api/v1/packages/upload", ordinarySession, "auth-demo", "1.0.0", "demo.pkg", "content")
|
||||
if ordinaryUpload.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("普通用户上传状态错误: %d", ordinaryUpload.StatusCode)
|
||||
}
|
||||
ordinaryUpload.Body.Close()
|
||||
|
||||
developerUpload := postMultipartRequest(t, server.URL+"/api/v1/packages/upload", login.Session, "auth-demo", "1.0.0", "demo.pkg", "content")
|
||||
if developerUpload.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("开发者上传状态错误: %d", developerUpload.StatusCode)
|
||||
}
|
||||
developerUpload.Body.Close()
|
||||
if mailer.count() < 3 {
|
||||
t.Fatalf("邮件通知数量不足: %d", mailer.count())
|
||||
}
|
||||
|
||||
adminSession, _, err := userStore.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminResponse := getRequest(t, server.URL+"/api/v1/admin/users", adminSession)
|
||||
if adminResponse.StatusCode != http.StatusOK {
|
||||
t.Fatalf("管理员查询用户状态错误: %d", adminResponse.StatusCode)
|
||||
}
|
||||
adminResponse.Body.Close()
|
||||
}
|
||||
|
||||
func postJSONRequest(t *testing.T, target string, value any, token string) *http.Response {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodPost, target, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func postMultipartRequest(t *testing.T, target, token, name, version, fileName, content string) *http.Response {
|
||||
t.Helper()
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
_ = writer.WriteField("name", name)
|
||||
_ = writer.WriteField("version", version)
|
||||
fileWriter, err := writer.CreateFormFile("file", fileName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = fileWriter.Write([]byte(content))
|
||||
_ = writer.Close()
|
||||
request, err := http.NewRequest(http.MethodPost, target, body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func getRequest(t *testing.T, target, token string) *http.Response {
|
||||
t.Helper()
|
||||
request, err := http.NewRequest(http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return response
|
||||
}
|
||||
@ -1,25 +1,70 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"scout/internal/notify"
|
||||
"scout/internal/registry"
|
||||
"scout/internal/users"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Registry *registry.Registry
|
||||
DeveloperToken string
|
||||
MaxUploadBytes int64
|
||||
Registry *registry.Registry
|
||||
DeveloperToken string
|
||||
MaxUploadBytes int64
|
||||
Users *users.Store
|
||||
Mailer notify.Sender
|
||||
SessionDuration time.Duration
|
||||
Logger *log.Logger
|
||||
resetMu sync.Mutex
|
||||
resetTokens map[string]resetToken
|
||||
}
|
||||
|
||||
type resetToken struct {
|
||||
UserID string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type credentials struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type updateUserRequest struct {
|
||||
Role users.Role `json:"role"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
type userResponse struct {
|
||||
User users.User `json:"user"`
|
||||
Session string `json:"session,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", s.health)
|
||||
mux.HandleFunc("POST /api/v1/auth/register", s.register)
|
||||
mux.HandleFunc("POST /api/v1/auth/login", s.login)
|
||||
mux.HandleFunc("POST /api/v1/auth/logout", s.logout)
|
||||
mux.HandleFunc("GET /api/v1/auth/me", s.me)
|
||||
mux.HandleFunc("POST /api/v1/auth/password/request", s.requestPasswordReset)
|
||||
mux.HandleFunc("POST /api/v1/auth/password/reset", s.resetPassword)
|
||||
mux.HandleFunc("GET /api/v1/admin/users", s.adminUsers)
|
||||
mux.HandleFunc("PATCH /api/v1/admin/users/", s.adminUpdateUser)
|
||||
mux.HandleFunc("POST /api/v1/packages/upload", s.upload)
|
||||
mux.HandleFunc("GET /api/v1/updates/stream", s.stream)
|
||||
mux.HandleFunc("GET /api/v1/packages", s.packageList)
|
||||
@ -31,13 +76,200 @@ func (s *Server) health(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(writer, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) packageList(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(writer, http.StatusOK, map[string]any{"packages": s.Registry.List()})
|
||||
func (s *Server) register(writer http.ResponseWriter, request *http.Request) {
|
||||
if s.Users == nil {
|
||||
writeError(writer, http.StatusServiceUnavailable, "用户管理尚未启用")
|
||||
return
|
||||
}
|
||||
var input credentials
|
||||
if err := decodeJSON(request, &input); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
user, err := s.Users.Register(input.Email, input.Name, input.Password)
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.sendUserEmail(user, "Scout 账户已创建", fmt.Sprintf("你好,%s。你的 Scout 账户已创建成功。\n\n如果这不是你的操作,请立即联系管理员。", user.Name))
|
||||
s.sendAdminsEmail("Scout 有新用户注册", fmt.Sprintf("新用户 %s(%s)已注册 Scout 账户。", user.Name, user.Email))
|
||||
writeJSON(writer, http.StatusCreated, map[string]any{"user": user})
|
||||
}
|
||||
|
||||
func (s *Server) login(writer http.ResponseWriter, request *http.Request) {
|
||||
if s.Users == nil {
|
||||
writeError(writer, http.StatusServiceUnavailable, "用户管理尚未启用")
|
||||
return
|
||||
}
|
||||
var input credentials
|
||||
if err := decodeJSON(request, &input); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
user, err := s.Users.Authenticate(input.Email, input.Password)
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
duration := s.SessionDuration
|
||||
if duration <= 0 {
|
||||
duration = 24 * time.Hour
|
||||
}
|
||||
session, expiresAt, err := s.Users.CreateSession(user.ID, duration)
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.sendUserEmail(user, "Scout 登录安全提醒", fmt.Sprintf("你的 Scout 账户于 %s 登录。\n\n如果这不是你的操作,请立即修改密码。", time.Now().Format("2006-01-02 15:04:05")))
|
||||
writeJSON(writer, http.StatusOK, userResponse{User: user, Session: session, ExpiresAt: expiresAt})
|
||||
}
|
||||
|
||||
func (s *Server) logout(writer http.ResponseWriter, request *http.Request) {
|
||||
if s.Users != nil {
|
||||
s.Users.RevokeSession(bearerToken(request))
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, map[string]string{"message": "已退出登录"})
|
||||
}
|
||||
|
||||
func (s *Server) me(writer http.ResponseWriter, request *http.Request) {
|
||||
user, ok := s.authenticatedUser(request)
|
||||
if !ok {
|
||||
writeError(writer, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, map[string]any{"user": user})
|
||||
}
|
||||
|
||||
func (s *Server) requestPasswordReset(writer http.ResponseWriter, request *http.Request) {
|
||||
var input struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := decodeJSON(request, &input); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
response := map[string]string{"message": "如果邮箱已注册,密码重置说明将发送到该邮箱"}
|
||||
if s.Users == nil {
|
||||
writeJSON(writer, http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
user, ok := s.Users.FindByEmail(input.Email)
|
||||
if !ok || !user.Active {
|
||||
writeJSON(writer, http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
rawToken := make([]byte, 32)
|
||||
if _, err := rand.Read(rawToken); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "生成重置令牌失败"})
|
||||
return
|
||||
}
|
||||
token := hex.EncodeToString(rawToken)
|
||||
s.resetMu.Lock()
|
||||
if s.resetTokens == nil {
|
||||
s.resetTokens = make(map[string]resetToken)
|
||||
}
|
||||
s.resetTokens[token] = resetToken{UserID: user.ID, ExpiresAt: time.Now().UTC().Add(30 * time.Minute)}
|
||||
s.resetMu.Unlock()
|
||||
s.sendUserEmail(user, "Scout 密码重置", "你请求了 Scout 密码重置。\n\n请使用以下一次性令牌完成重置:\n"+token+"\n\n令牌 30 分钟内有效。如果这不是你的操作,请忽略此邮件。")
|
||||
writeJSON(writer, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (s *Server) resetPassword(writer http.ResponseWriter, request *http.Request) {
|
||||
var input struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := decodeJSON(request, &input); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.resetMu.Lock()
|
||||
entry, ok := s.resetTokens[strings.TrimSpace(input.Token)]
|
||||
if ok {
|
||||
delete(s.resetTokens, strings.TrimSpace(input.Token))
|
||||
}
|
||||
s.resetMu.Unlock()
|
||||
if !ok || time.Now().UTC().After(entry.ExpiresAt) || s.Users == nil {
|
||||
writeError(writer, http.StatusBadRequest, "密码重置令牌无效或已过期")
|
||||
return
|
||||
}
|
||||
if err := s.Users.SetPassword(entry.UserID, input.NewPassword); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if user, ok := s.Users.Get(entry.UserID); ok {
|
||||
s.sendUserEmail(user, "Scout 密码已修改", "你的 Scout 密码已修改成功。如果这不是你的操作,请立即联系管理员。")
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, map[string]string{"message": "密码修改成功,请重新登录"})
|
||||
}
|
||||
|
||||
func (s *Server) adminUsers(writer http.ResponseWriter, request *http.Request) {
|
||||
if !s.requireRole(writer, request, users.RoleAdmin) {
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, map[string]any{"users": s.Users.List()})
|
||||
}
|
||||
|
||||
func (s *Server) adminUpdateUser(writer http.ResponseWriter, request *http.Request) {
|
||||
if !s.requireRole(writer, request, users.RoleAdmin) {
|
||||
return
|
||||
}
|
||||
id := strings.TrimPrefix(request.URL.Path, "/api/v1/admin/users/")
|
||||
if id == "" {
|
||||
writeError(writer, http.StatusBadRequest, "用户编号不能为空")
|
||||
return
|
||||
}
|
||||
var input updateUserRequest
|
||||
if err := decodeJSON(request, &input); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
updated, err := s.Users.Update(id, input.Role, input.Active)
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.sendUserEmail(updated, "Scout 用户权限已更新", fmt.Sprintf("你的账户角色已调整为 %s,账户状态:%s。", updated.Role, statusText(updated.Active)))
|
||||
writeJSON(writer, http.StatusOK, map[string]any{"user": updated})
|
||||
}
|
||||
|
||||
func (s *Server) packageList(writer http.ResponseWriter, request *http.Request) {
|
||||
items := s.Registry.List()
|
||||
query := strings.ToLower(strings.TrimSpace(request.URL.Query().Get("query")))
|
||||
if query != "" {
|
||||
filtered := make([]registry.Package, 0, len(items))
|
||||
for _, item := range items {
|
||||
if strings.Contains(strings.ToLower(item.Name), query) || strings.Contains(strings.ToLower(item.Description), query) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
items = filtered
|
||||
}
|
||||
total := len(items)
|
||||
page := positiveInt(request.URL.Query().Get("page"), 1)
|
||||
pageSize := positiveInt(request.URL.Query().Get("page_size"), 50)
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
start := (page - 1) * pageSize
|
||||
if start > len(items) {
|
||||
start = len(items)
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, map[string]any{"packages": items[start:end], "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
func (s *Server) upload(writer http.ResponseWriter, request *http.Request) {
|
||||
if !s.authorized(request) {
|
||||
writeError(writer, http.StatusUnauthorized, "开发者令牌无效")
|
||||
uploader, authorized := s.uploadUser(request)
|
||||
if !authorized {
|
||||
if _, loggedIn := s.authenticatedUser(request); loggedIn {
|
||||
writeError(writer, http.StatusForbidden, "需要开发者或管理员权限")
|
||||
} else {
|
||||
writeError(writer, http.StatusUnauthorized, "请先登录开发者账户")
|
||||
}
|
||||
return
|
||||
}
|
||||
maxBytes := s.MaxUploadBytes
|
||||
@ -46,31 +278,43 @@ func (s *Server) upload(writer http.ResponseWriter, request *http.Request) {
|
||||
}
|
||||
request.Body = http.MaxBytesReader(writer, request.Body, maxBytes)
|
||||
if err := request.ParseMultipartForm(32 << 20); err != nil {
|
||||
s.notifyUploadFailure(uploader, "上传表单无效: "+err.Error())
|
||||
writeError(writer, http.StatusBadRequest, "上传表单无效: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, _, err := request.FormFile("file")
|
||||
file, header, err := request.FormFile("file")
|
||||
if err != nil {
|
||||
s.notifyUploadFailure(uploader, "缺少包文件字段 file")
|
||||
writeError(writer, http.StatusBadRequest, "缺少包文件字段 file")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fileName := header.Filename
|
||||
if err := registry.ValidateFileName(fileName); err != nil {
|
||||
s.notifyUploadFailure(uploader, err.Error())
|
||||
writeError(writer, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
item, err := s.Registry.AddPackage(registry.Package{
|
||||
Name: request.FormValue("name"),
|
||||
Version: request.FormValue("version"),
|
||||
Description: request.FormValue("description"),
|
||||
Platform: request.FormValue("platform"),
|
||||
Architecture: request.FormValue("architecture"),
|
||||
Name: request.FormValue("name"),
|
||||
Version: request.FormValue("version"),
|
||||
Description: request.FormValue("description"),
|
||||
Platform: request.FormValue("platform"),
|
||||
Architecture: request.FormValue("architecture"),
|
||||
FileName: fileName,
|
||||
UploaderID: uploader.ID,
|
||||
UploaderEmail: uploader.Email,
|
||||
}, file)
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if strings.Contains(err.Error(), "已经存在") {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
s.notifyUploadFailure(uploader, err.Error())
|
||||
writeError(writer, status, err.Error())
|
||||
return
|
||||
}
|
||||
s.sendUserEmail(uploader, "Scout 包上传成功", fmt.Sprintf("包 %s %s 已上传成功。\n\nSHA-256:%s\n大小:%d 字节", item.Name, item.Version, item.SHA256, item.Size))
|
||||
writeJSON(writer, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
@ -155,15 +399,127 @@ func (s *Server) stream(writer http.ResponseWriter, request *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) authorized(request *http.Request) bool {
|
||||
if s.DeveloperToken == "" {
|
||||
func (s *Server) authenticatedUser(request *http.Request) (users.User, bool) {
|
||||
if s.Users == nil {
|
||||
return users.User{}, false
|
||||
}
|
||||
return s.Users.UserBySession(bearerToken(request))
|
||||
}
|
||||
|
||||
func (s *Server) uploadUser(request *http.Request) (users.User, bool) {
|
||||
if s.DeveloperToken != "" {
|
||||
value := request.Header.Get("Authorization")
|
||||
if strings.HasPrefix(value, "Bearer ") && strings.TrimPrefix(value, "Bearer ") == s.DeveloperToken {
|
||||
return users.User{ID: "legacy-token", Email: "", Name: "兼容令牌", Role: users.RoleDeveloper, Active: true}, true
|
||||
}
|
||||
if request.Header.Get("X-Developer-Token") == s.DeveloperToken {
|
||||
return users.User{ID: "legacy-token", Email: "", Name: "兼容令牌", Role: users.RoleDeveloper, Active: true}, true
|
||||
}
|
||||
}
|
||||
user, ok := s.authenticatedUser(request)
|
||||
if !ok || (user.Role != users.RoleDeveloper && user.Role != users.RoleAdmin) {
|
||||
return users.User{}, false
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
|
||||
func (s *Server) requireRole(writer http.ResponseWriter, request *http.Request, role users.Role) bool {
|
||||
user, ok := s.authenticatedUser(request)
|
||||
if !ok {
|
||||
writeError(writer, http.StatusUnauthorized, "请先登录")
|
||||
return false
|
||||
}
|
||||
value := request.Header.Get("Authorization")
|
||||
if strings.HasPrefix(value, "Bearer ") {
|
||||
return strings.TrimPrefix(value, "Bearer ") == s.DeveloperToken
|
||||
if user.Role != role {
|
||||
writeError(writer, http.StatusForbidden, "没有执行此操作的权限")
|
||||
return false
|
||||
}
|
||||
return request.Header.Get("X-Developer-Token") == s.DeveloperToken
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) sendUserEmail(user users.User, subject, body string) {
|
||||
if user.Email == "" || s.mailer() == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.mailer().Send(ctx, notify.Message{To: []string{user.Email}, Subject: subject, Body: body}); err != nil {
|
||||
s.logger().Printf("发送用户通知失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) sendAdminsEmail(subject, body string) {
|
||||
if s.Users == nil || s.mailer() == nil {
|
||||
return
|
||||
}
|
||||
admins := s.Users.ListByRole(users.RoleAdmin)
|
||||
recipients := make([]string, 0, len(admins))
|
||||
for _, admin := range admins {
|
||||
if admin.Email != "" {
|
||||
recipients = append(recipients, admin.Email)
|
||||
}
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.mailer().Send(ctx, notify.Message{To: recipients, Subject: subject, Body: body}); err != nil {
|
||||
s.logger().Printf("发送管理员通知失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) notifyUploadFailure(user users.User, reason string) {
|
||||
if user.Email == "" {
|
||||
return
|
||||
}
|
||||
s.sendUserEmail(user, "Scout 包上传失败", "你的包上传操作失败。\n\n原因:"+reason)
|
||||
}
|
||||
|
||||
func (s *Server) mailer() notify.Sender {
|
||||
if s.Mailer != nil {
|
||||
return s.Mailer
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) logger() *log.Logger {
|
||||
if s.Logger != nil {
|
||||
return s.Logger
|
||||
}
|
||||
return log.Default()
|
||||
}
|
||||
|
||||
func bearerToken(request *http.Request) string {
|
||||
value := strings.TrimSpace(request.Header.Get("Authorization"))
|
||||
if strings.HasPrefix(value, "Bearer ") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(value, "Bearer "))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func decodeJSON(request *http.Request, target any) error {
|
||||
request.Body = io.NopCloser(io.LimitReader(request.Body, 1<<20+1))
|
||||
decoder := json.NewDecoder(request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return errors.New("请求 JSON 无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func positiveInt(value string, fallback int) int {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func statusText(active bool) string {
|
||||
if active {
|
||||
return "启用"
|
||||
}
|
||||
return "禁用"
|
||||
}
|
||||
|
||||
func writeJSON(writer http.ResponseWriter, status int, value any) {
|
||||
@ -180,7 +536,7 @@ func cors(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Developer-Token")
|
||||
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS")
|
||||
if request.Method == http.MethodOptions {
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
|
||||
143
internal/notify/email.go
Normal file
143
internal/notify/email.go
Normal file
@ -0,0 +1,143 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
To []string
|
||||
Subject string
|
||||
Body string
|
||||
}
|
||||
|
||||
type Sender interface {
|
||||
Send(context.Context, Message) error
|
||||
}
|
||||
|
||||
type SMTP struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
TLS bool
|
||||
}
|
||||
|
||||
type LogSender struct {
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
||||
func NewFromEnv(logger *log.Logger) Sender {
|
||||
host := strings.TrimSpace(os.Getenv("SCOUT_SMTP_HOST"))
|
||||
if host == "" {
|
||||
return LogSender{Logger: logger}
|
||||
}
|
||||
port, err := strconv.Atoi(envOr("SCOUT_SMTP_PORT", "587"))
|
||||
if err != nil || port <= 0 || port > 65535 {
|
||||
port = 587
|
||||
}
|
||||
return SMTP{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Username: os.Getenv("SCOUT_SMTP_USERNAME"),
|
||||
Password: os.Getenv("SCOUT_SMTP_PASSWORD"),
|
||||
From: envOr("SCOUT_SMTP_FROM", os.Getenv("SCOUT_SMTP_USERNAME")),
|
||||
TLS: strings.EqualFold(os.Getenv("SCOUT_SMTP_TLS"), "true"),
|
||||
}
|
||||
}
|
||||
|
||||
func (sender LogSender) Send(_ context.Context, message Message) error {
|
||||
if sender.Logger != nil {
|
||||
sender.Logger.Printf("邮箱通知未配置 SMTP,记录待发送邮件:收件人=%s,主题=%s", strings.Join(message.To, ","), message.Subject)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sender SMTP) Send(ctx context.Context, message Message) error {
|
||||
if len(message.To) == 0 {
|
||||
return nil
|
||||
}
|
||||
if sender.Host == "" || sender.From == "" {
|
||||
return fmt.Errorf("SMTP 配置不完整")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
address := fmt.Sprintf("%s:%d", sender.Host, sender.Port)
|
||||
headers := []string{
|
||||
"From: " + sender.From,
|
||||
"To: " + strings.Join(message.To, ", "),
|
||||
"Subject: " + message.Subject,
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
}
|
||||
data := []byte(strings.Join(headers, "\r\n") + "\r\n\r\n" + message.Body)
|
||||
if sender.TLS {
|
||||
connection, err := tls.Dial("tcp", address, &tls.Config{ServerName: sender.Host, MinVersion: tls.VersionTLS12})
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接 SMTP TLS 服务失败: %w", err)
|
||||
}
|
||||
defer connection.Close()
|
||||
client, err := smtp.NewClient(connection, sender.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 SMTP 客户端失败: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
if sender.Username != "" {
|
||||
if err := client.Auth(smtp.PlainAuth("", sender.Username, sender.Password, sender.Host)); err != nil {
|
||||
return fmt.Errorf("SMTP 认证失败: %w", err)
|
||||
}
|
||||
}
|
||||
if err := sendWithClient(client, sender.From, message.To, data); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
auth := smtp.PlainAuth("", sender.Username, sender.Password, sender.Host)
|
||||
if sender.Username == "" {
|
||||
auth = nil
|
||||
}
|
||||
if err := smtp.SendMail(address, auth, sender.From, message.To, data); err != nil {
|
||||
return fmt.Errorf("发送邮件失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendWithClient(client *smtp.Client, from string, recipients []string, data []byte) error {
|
||||
if err := client.Mail(from); err != nil {
|
||||
return fmt.Errorf("设置发件人失败: %w", err)
|
||||
}
|
||||
for _, recipient := range recipients {
|
||||
if err := client.Rcpt(recipient); err != nil {
|
||||
return fmt.Errorf("设置收件人失败: %w", err)
|
||||
}
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开邮件正文失败: %w", err)
|
||||
}
|
||||
if _, err := writer.Write(data); err != nil {
|
||||
writer.Close()
|
||||
return fmt.Errorf("写入邮件正文失败: %w", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return fmt.Errorf("关闭邮件正文失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func envOr(name, fallback string) string {
|
||||
if value := os.Getenv(name); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@ -19,17 +19,21 @@ import (
|
||||
var (
|
||||
packageNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
versionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.+_-]{0,63}$`)
|
||||
fileNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
)
|
||||
|
||||
type Package struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Architecture string `json:"architecture,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
UploadedAt time.Time `json:"uploaded_at"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Architecture string `json:"architecture,omitempty"`
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
UploaderID string `json:"uploader_id,omitempty"`
|
||||
UploaderEmail string `json:"uploader_email,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
UploadedAt time.Time `json:"uploaded_at"`
|
||||
}
|
||||
|
||||
type Update struct {
|
||||
@ -39,6 +43,7 @@ type Update struct {
|
||||
type Registry struct {
|
||||
root string
|
||||
mu sync.RWMutex
|
||||
writeMu sync.Mutex
|
||||
packages map[string]map[string]Package
|
||||
subs map[int]chan Update
|
||||
nextSub int
|
||||
@ -72,6 +77,14 @@ func ValidatePackage(name, version string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateFileName(fileName string) error {
|
||||
fileName = filepath.Base(strings.TrimSpace(fileName))
|
||||
if fileName == "." || fileName == "" || !fileNamePattern.MatchString(fileName) {
|
||||
return errors.New("包文件名无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) AddPackage(input Package, data io.Reader) (Package, error) {
|
||||
if err := ValidatePackage(input.Name, input.Version); err != nil {
|
||||
return Package{}, err
|
||||
@ -79,11 +92,28 @@ func (r *Registry) AddPackage(input Package, data io.Reader) (Package, error) {
|
||||
if data == nil {
|
||||
return Package{}, errors.New("包文件不能为空")
|
||||
}
|
||||
if input.FileName == "" {
|
||||
input.FileName = input.Name + "-" + input.Version + ".pkg"
|
||||
}
|
||||
input.FileName = filepath.Base(input.FileName)
|
||||
if err := ValidateFileName(input.FileName); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
|
||||
r.writeMu.Lock()
|
||||
defer r.writeMu.Unlock()
|
||||
packageDir := filepath.Join(r.root, input.Name)
|
||||
if err := os.MkdirAll(packageDir, 0o755); err != nil {
|
||||
return Package{}, fmt.Errorf("创建包目录失败: %w", err)
|
||||
}
|
||||
versionDir := filepath.Join(packageDir, input.Version)
|
||||
packagePath := filepath.Join(versionDir, "package.bin")
|
||||
metadataPath := filepath.Join(versionDir, "metadata.json")
|
||||
if _, err := os.Stat(packagePath); err == nil {
|
||||
return Package{}, errors.New("该包版本已经存在")
|
||||
} else if !os.IsNotExist(err) {
|
||||
return Package{}, fmt.Errorf("检查包版本失败: %w", err)
|
||||
}
|
||||
temp, err := os.CreateTemp(packageDir, ".upload-*")
|
||||
if err != nil {
|
||||
return Package{}, fmt.Errorf("创建临时文件失败: %w", err)
|
||||
@ -97,6 +127,10 @@ func (r *Registry) AddPackage(input Package, data io.Reader) (Package, error) {
|
||||
temp.Close()
|
||||
return Package{}, fmt.Errorf("写入包文件失败: %w", err)
|
||||
}
|
||||
if size == 0 {
|
||||
temp.Close()
|
||||
return Package{}, errors.New("包文件不能为空")
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return Package{}, fmt.Errorf("关闭包文件失败: %w", err)
|
||||
}
|
||||
@ -104,18 +138,9 @@ func (r *Registry) AddPackage(input Package, data io.Reader) (Package, error) {
|
||||
input.Size = size
|
||||
input.SHA256 = hex.EncodeToString(hash.Sum(nil))
|
||||
input.UploadedAt = time.Now().UTC()
|
||||
|
||||
versionDir := filepath.Join(packageDir, input.Version)
|
||||
if err := os.MkdirAll(versionDir, 0o755); err != nil {
|
||||
return Package{}, fmt.Errorf("创建版本目录失败: %w", err)
|
||||
}
|
||||
packagePath := filepath.Join(versionDir, "package.bin")
|
||||
metadataPath := filepath.Join(versionDir, "metadata.json")
|
||||
if _, err := os.Stat(packagePath); err == nil {
|
||||
return Package{}, errors.New("该包版本已经存在")
|
||||
} else if !os.IsNotExist(err) {
|
||||
return Package{}, fmt.Errorf("检查包版本失败: %w", err)
|
||||
}
|
||||
if err := os.Rename(tempName, packagePath); err != nil {
|
||||
return Package{}, fmt.Errorf("保存包文件失败: %w", err)
|
||||
}
|
||||
|
||||
451
internal/users/store.go
Normal file
451
internal/users/store.go
Normal file
@ -0,0 +1,451 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleUser Role = "user"
|
||||
RoleDeveloper Role = "developer"
|
||||
RoleAdmin Role = "admin"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Role Role `json:"role"`
|
||||
Active bool `json:"active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
PasswordHash string `json:"-"`
|
||||
}
|
||||
|
||||
type persistedUser struct {
|
||||
User
|
||||
PasswordHash string `json:"password_hash"`
|
||||
}
|
||||
|
||||
type database struct {
|
||||
Users []persistedUser `json:"users"`
|
||||
}
|
||||
|
||||
type session struct {
|
||||
UserID string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.RWMutex
|
||||
users map[string]persistedUser
|
||||
sessions map[string]session
|
||||
}
|
||||
|
||||
var (
|
||||
emailPattern = mail.Address{}
|
||||
allowedRoles = map[Role]bool{RoleUser: true, RoleDeveloper: true, RoleAdmin: true}
|
||||
passwordIterations = 150000
|
||||
)
|
||||
|
||||
func New(path string) (*Store, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, errors.New("用户数据文件不能为空")
|
||||
}
|
||||
store := &Store{path: path, users: make(map[string]persistedUser), sessions: make(map[string]session)}
|
||||
if err := store.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func ValidateEmail(value string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || len(value) > 254 {
|
||||
return errors.New("邮箱地址无效")
|
||||
}
|
||||
address, err := mail.ParseAddress(value)
|
||||
if err != nil || address.Address != value || !strings.Contains(value, "@") {
|
||||
return errors.New("邮箱地址无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidatePassword(value string) error {
|
||||
if len(value) < 8 || len(value) > 128 {
|
||||
return errors.New("密码长度必须为 8 到 128 个字符")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Register(email, name, password string) (User, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
name = strings.TrimSpace(name)
|
||||
if err := ValidateEmail(email); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if err := ValidatePassword(password); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if len(name) > 80 {
|
||||
return User{}, errors.New("用户名长度不能超过 80 个字符")
|
||||
}
|
||||
if name == "" {
|
||||
name = strings.Split(email, "@")[0]
|
||||
}
|
||||
salt, err := randomBytes(16)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("生成密码盐失败: %w", err)
|
||||
}
|
||||
stored := persistedUser{User: User{ID: newID(), Email: email, Name: name, Role: RoleUser, Active: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}, PasswordHash: encodePassword(password, salt)}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, existing := range s.users {
|
||||
if existing.Email == email {
|
||||
return User{}, errors.New("该邮箱已经注册")
|
||||
}
|
||||
}
|
||||
s.users[stored.ID] = stored
|
||||
if err := s.saveLocked(); err != nil {
|
||||
delete(s.users, stored.ID)
|
||||
return User{}, err
|
||||
}
|
||||
return stored.User, nil
|
||||
}
|
||||
|
||||
func (s *Store) EnsureAdmin(email, name, password string) (User, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email == "" && password == "" {
|
||||
return User{}, nil
|
||||
}
|
||||
if email == "" || password == "" {
|
||||
return User{}, errors.New("管理员邮箱和密码必须同时配置")
|
||||
}
|
||||
if err := ValidateEmail(email); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if err := ValidatePassword(password); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for id, existing := range s.users {
|
||||
if existing.Email == email {
|
||||
if existing.User.Role != RoleAdmin || !existing.User.Active {
|
||||
existing.User.Role = RoleAdmin
|
||||
existing.User.Active = true
|
||||
existing.User.UpdatedAt = time.Now().UTC()
|
||||
s.users[id] = existing
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
}
|
||||
return existing.User, nil
|
||||
}
|
||||
}
|
||||
salt, err := randomBytes(16)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("生成管理员密码盐失败: %w", err)
|
||||
}
|
||||
stored := persistedUser{User: User{ID: newID(), Email: email, Name: strings.TrimSpace(name), Role: RoleAdmin, Active: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}, PasswordHash: encodePassword(password, salt)}
|
||||
if stored.Name == "" {
|
||||
stored.Name = "管理员"
|
||||
}
|
||||
s.users[stored.ID] = stored
|
||||
if err := s.saveLocked(); err != nil {
|
||||
delete(s.users, stored.ID)
|
||||
return User{}, err
|
||||
}
|
||||
return stored.User, nil
|
||||
}
|
||||
|
||||
func (s *Store) Authenticate(email, password string) (User, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, candidate := range s.users {
|
||||
if candidate.Email != email {
|
||||
continue
|
||||
}
|
||||
if !candidate.User.Active || !verifyPassword(password, candidate.PasswordHash) {
|
||||
return User{}, errors.New("邮箱或密码错误")
|
||||
}
|
||||
return candidate.User, nil
|
||||
}
|
||||
return User{}, errors.New("邮箱或密码错误")
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(userID string, duration time.Duration) (string, time.Time, error) {
|
||||
if duration <= 0 {
|
||||
duration = 24 * time.Hour
|
||||
}
|
||||
if _, ok := s.Get(userID); !ok {
|
||||
return "", time.Time{}, errors.New("用户不存在")
|
||||
}
|
||||
tokenBytes, err := randomBytes(32)
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("生成会话令牌失败: %w", err)
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(tokenBytes)
|
||||
expiresAt := time.Now().UTC().Add(duration)
|
||||
s.mu.Lock()
|
||||
s.sessions[token] = session{UserID: userID, ExpiresAt: expiresAt}
|
||||
s.mu.Unlock()
|
||||
return token, expiresAt, nil
|
||||
}
|
||||
|
||||
func (s *Store) UserBySession(token string) (User, bool) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return User{}, false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
current, ok := s.sessions[token]
|
||||
if !ok {
|
||||
return User{}, false
|
||||
}
|
||||
if time.Now().UTC().After(current.ExpiresAt) {
|
||||
delete(s.sessions, token)
|
||||
return User{}, false
|
||||
}
|
||||
stored, ok := s.users[current.UserID]
|
||||
if !ok || !stored.User.Active {
|
||||
return User{}, false
|
||||
}
|
||||
return stored.User, true
|
||||
}
|
||||
|
||||
func (s *Store) RevokeSession(token string) {
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, strings.TrimSpace(token))
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Store) Get(id string) (User, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
stored, ok := s.users[id]
|
||||
return stored.User, ok
|
||||
}
|
||||
|
||||
func (s *Store) List() []User {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
result := make([]User, 0, len(s.users))
|
||||
for _, stored := range s.users {
|
||||
result = append(result, stored.User)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Store) ListByRole(role Role) []User {
|
||||
users := s.List()
|
||||
result := make([]User, 0)
|
||||
for _, user := range users {
|
||||
if user.Role == role && user.Active {
|
||||
result = append(result, user)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Store) Update(id string, role Role, active bool) (User, error) {
|
||||
if !allowedRoles[role] {
|
||||
return User{}, errors.New("用户角色无效")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
stored, ok := s.users[id]
|
||||
if !ok {
|
||||
return User{}, errors.New("用户不存在")
|
||||
}
|
||||
stored.User.Role = role
|
||||
stored.User.Active = active
|
||||
stored.User.UpdatedAt = time.Now().UTC()
|
||||
s.users[id] = stored
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return stored.User, nil
|
||||
}
|
||||
|
||||
func (s *Store) load() error {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取用户数据失败: %w", err)
|
||||
}
|
||||
var stored database
|
||||
if err := json.Unmarshal(data, &stored); err != nil {
|
||||
return fmt.Errorf("解析用户数据失败: %w", err)
|
||||
}
|
||||
for _, user := range stored.Users {
|
||||
if user.User.ID != "" && user.User.Email != "" {
|
||||
s.users[user.User.ID] = user
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) saveLocked() error {
|
||||
if err := os.MkdirAll(filepathDir(s.path), 0o755); err != nil {
|
||||
return fmt.Errorf("创建用户数据目录失败: %w", err)
|
||||
}
|
||||
stored := database{Users: make([]persistedUser, 0, len(s.users))}
|
||||
for _, user := range s.users {
|
||||
stored.Users = append(stored.Users, user)
|
||||
}
|
||||
data, err := json.MarshalIndent(stored, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("编码用户数据失败: %w", err)
|
||||
}
|
||||
temp, err := os.CreateTemp(filepathDir(s.path), ".users-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建用户临时文件失败: %w", err)
|
||||
}
|
||||
tempName := temp.Name()
|
||||
defer os.Remove(tempName)
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
temp.Close()
|
||||
return fmt.Errorf("写入用户数据失败: %w", err)
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return fmt.Errorf("关闭用户临时文件失败: %w", err)
|
||||
}
|
||||
if err := os.Rename(tempName, s.path); err != nil {
|
||||
return fmt.Errorf("保存用户数据失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodePassword(password string, salt []byte) string {
|
||||
key := derive(password, salt)
|
||||
return fmt.Sprintf("$scout$1$%d$%s$%s", passwordIterations, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key))
|
||||
}
|
||||
|
||||
func verifyPassword(password, encoded string) bool {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 || parts[1] != "scout" || parts[2] != "1" {
|
||||
return false
|
||||
}
|
||||
var iterations int
|
||||
if _, err := fmt.Sscanf(parts[3], "%d", &iterations); err != nil || iterations < 100000 {
|
||||
return false
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
expected, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
actual := deriveWithIterations(password, salt, iterations)
|
||||
return subtle.ConstantTimeCompare(actual, expected) == 1
|
||||
}
|
||||
|
||||
func derive(password string, salt []byte) []byte {
|
||||
return deriveWithIterations(password, salt, passwordIterations)
|
||||
}
|
||||
|
||||
func deriveWithIterations(password string, salt []byte, iterations int) []byte {
|
||||
result := make([]byte, 32)
|
||||
mac := hmac.New(sha256.New, []byte(password))
|
||||
mac.Write(salt)
|
||||
mac.Write([]byte{0, 0, 0, 1})
|
||||
previous := mac.Sum(nil)
|
||||
copy(result, previous)
|
||||
for index := 1; index < iterations; index++ {
|
||||
mac = hmac.New(sha256.New, []byte(password))
|
||||
mac.Write(previous)
|
||||
previous = mac.Sum(nil)
|
||||
for offset := range result {
|
||||
result[offset] ^= previous[offset]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func randomBytes(size int) ([]byte, error) {
|
||||
value := make([]byte, size)
|
||||
_, err := rand.Read(value)
|
||||
return value, err
|
||||
}
|
||||
|
||||
func newID() string {
|
||||
value, err := randomBytes(16)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("user-%d", time.Now().UnixNano())
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(value)
|
||||
}
|
||||
|
||||
func filepathDir(path string) string {
|
||||
index := strings.LastIndexAny(path, `/\\`)
|
||||
if index < 0 {
|
||||
return "."
|
||||
}
|
||||
return path[:index]
|
||||
}
|
||||
|
||||
func sanitizeUser(user User) User {
|
||||
user.PasswordHash = ""
|
||||
return user
|
||||
}
|
||||
|
||||
func (s *Store) SetPassword(id, password string) error {
|
||||
if err := ValidatePassword(password); err != nil {
|
||||
return err
|
||||
}
|
||||
salt, err := randomBytes(16)
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成密码盐失败: %w", err)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
stored, ok := s.users[id]
|
||||
if !ok {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
stored.PasswordHash = encodePassword(password, salt)
|
||||
stored.User.UpdatedAt = time.Now().UTC()
|
||||
s.users[id] = stored
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
for token, current := range s.sessions {
|
||||
if current.UserID == id {
|
||||
delete(s.sessions, token)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) FindByEmail(email string) (User, bool) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, stored := range s.users {
|
||||
if stored.Email == email {
|
||||
return stored.User, true
|
||||
}
|
||||
}
|
||||
return User{}, false
|
||||
}
|
||||
51
pkg/client/auth_test.go
Normal file
51
pkg/client/auth_test.go
Normal file
@ -0,0 +1,51 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"net/http/httptest"
|
||||
"scout/internal/httpapi"
|
||||
"scout/internal/registry"
|
||||
"scout/internal/users"
|
||||
)
|
||||
|
||||
func TestClientAuthentication(t *testing.T) {
|
||||
store, err := registry.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userStore, err := users.New(filepath.Join(t.TempDir(), "users.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer((&httpapi.Server{Registry: store, Users: userStore}).Handler())
|
||||
defer server.Close()
|
||||
|
||||
api, err := New(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if _, err := api.Register(ctx, "client@example.com", "客户端用户", "client-pass-123"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
login, err := api.Login(ctx, "client@example.com", "client-pass-123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if login.Session == "" || login.User.Email != "client@example.com" {
|
||||
t.Fatalf("登录结果错误: %+v", login)
|
||||
}
|
||||
current, err := api.Me(ctx)
|
||||
if err != nil || current.ID != login.User.ID {
|
||||
t.Fatalf("当前用户错误: %+v, %v", current, err)
|
||||
}
|
||||
if err := api.Logout(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := api.Me(ctx); err == nil {
|
||||
t.Fatal("退出后仍可获取当前用户")
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ package client
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@ -385,3 +386,100 @@ func numberPart(value string) (int64, bool) {
|
||||
}
|
||||
return number, true
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Active bool `json:"active"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type LoginResult struct {
|
||||
User User `json:"user"`
|
||||
Session string `json:"session"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
func WithSessionToken(token string) Option {
|
||||
return func(client *Client) {
|
||||
client.token = token
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Register(ctx context.Context, email, name, password string) (User, error) {
|
||||
var result struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
if err := c.postJSON(ctx, "/api/v1/auth/register", map[string]string{"email": email, "name": name, "password": password}, &result); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return result.User, nil
|
||||
}
|
||||
|
||||
func (c *Client) Login(ctx context.Context, email, password string) (LoginResult, error) {
|
||||
var result LoginResult
|
||||
if err := c.postJSON(ctx, "/api/v1/auth/login", map[string]string{"email": email, "password": password}, &result); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
c.token = result.Session
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) Me(ctx context.Context) (User, error) {
|
||||
var result struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
if err := c.getJSON(ctx, "/api/v1/auth/me", &result); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return result.User, nil
|
||||
}
|
||||
|
||||
func (c *Client) Logout(ctx context.Context) error {
|
||||
var result map[string]string
|
||||
if err := c.postJSON(ctx, "/api/v1/auth/logout", map[string]string{}, &result); err != nil {
|
||||
return err
|
||||
}
|
||||
c.token = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) RequestPasswordReset(ctx context.Context, email string) error {
|
||||
var result map[string]string
|
||||
return c.postJSON(ctx, "/api/v1/auth/password/request", map[string]string{"email": email}, &result)
|
||||
}
|
||||
|
||||
func (c *Client) ResetPassword(ctx context.Context, token, newPassword string) error {
|
||||
var result map[string]string
|
||||
return c.postJSON(ctx, "/api/v1/auth/password/reset", map[string]string{"token": token, "new_password": newPassword}, &result)
|
||||
}
|
||||
|
||||
func (c *Client) postJSON(ctx context.Context, path string, input any, target any) error {
|
||||
body, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("编码请求失败: %w", err)
|
||||
}
|
||||
request, err := c.newRequest(ctx, http.MethodPost, path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("请求失败: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if err := checkResponse(response); err != nil {
|
||||
return err
|
||||
}
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(target); err != nil {
|
||||
return fmt.Errorf("解析服务端响应失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
285
使用说明.md
285
使用说明.md
@ -1,13 +1,20 @@
|
||||
# Scout 轻量化包管理程序
|
||||
|
||||
Scout 是一个使用 **Go + Vue** 构建的轻量化包管理程序,提供以下能力:
|
||||
Scout 是一个使用 **Go + Vue** 构建的轻量化包管理程序,负责包上传、版本管理、客户端更新检查、实时更新通知和账户权限控制。
|
||||
|
||||
- 开发者通过令牌上传包文件和版本元数据。
|
||||
- 服务端使用本地文件目录保存包文件,重启后自动读取元数据。
|
||||
- 客户端查询包的最新版本和历史版本。
|
||||
- 客户端下载包时校验服务端返回的 SHA-256。
|
||||
- 服务端通过 SSE(Server-Sent Events)向客户端推送新包通知。
|
||||
- Vue 管理界面支持包目录、版本下载和开发者上传。
|
||||
## 当前能力
|
||||
|
||||
- 普通用户、开发者、管理员三种角色。
|
||||
- 用户注册、登录、退出和会话认证。
|
||||
- 密码使用带随机盐的 PBKDF2-HMAC-SHA256 方案保存,不保存明文密码。
|
||||
- 管理员可查看用户、授予开发者权限和启停账户。
|
||||
- 开发者或管理员可以上传包;旧版开发者令牌仍可兼容脚本调用。
|
||||
- 包名、版本号、文件名和空文件校验。
|
||||
- 重复版本拒绝覆盖,包文件保存后提供 SHA-256 校验值。
|
||||
- 包列表支持 `query` 搜索、`page` 分页和 `page_size` 条数限制。
|
||||
- 客户端通过 SSE 接收新包通知,通过下载接口获取包文件。
|
||||
- 邮件只用于管理员通知、上传用户操作通知和安全认证,不发送普通用户包更新订阅邮件。
|
||||
- 未配置 SMTP 时,邮件会以“待发送邮件”形式写入服务端日志,便于本地测试。
|
||||
|
||||
## 目录结构
|
||||
|
||||
@ -17,8 +24,11 @@ Scout/
|
||||
│ ├── scout-server/ # Go 服务端
|
||||
│ └── scout-client/ # Go 客户端命令行工具
|
||||
├── internal/
|
||||
│ ├── httpapi/ # HTTP API、上传、下载、SSE 通知
|
||||
│ └── registry/ # 包存储、元数据和版本比较
|
||||
│ ├── httpapi/ # HTTP API、认证、权限和通知
|
||||
│ ├── notify/ # SMTP 邮件发送抽象
|
||||
│ ├── registry/ # 包存储、元数据和版本比较
|
||||
│ └── users/ # 用户、角色、密码哈希和会话
|
||||
├── pkg/client/ # 可复用 Go 客户端模块
|
||||
└── frontend/ # Vue 3 + Vite 管理界面
|
||||
```
|
||||
|
||||
@ -28,18 +38,36 @@ PowerShell 示例:
|
||||
|
||||
```powershell
|
||||
$env:SCOUT_DEVELOPER_TOKEN = "请替换为实际开发者令牌"
|
||||
$env:SCOUT_ADMIN_EMAIL = "admin@example.com"
|
||||
$env:SCOUT_ADMIN_NAME = "系统管理员"
|
||||
$env:SCOUT_ADMIN_PASSWORD = "请使用长度至少 8 位的管理员密码"
|
||||
$env:SCOUT_ADDR = ":8080"
|
||||
$env:SCOUT_DATA_DIR = "data"
|
||||
go run .\cmd\scout-server
|
||||
```
|
||||
|
||||
启动后健康检查地址:
|
||||
服务端会在 `data/users.json` 保存用户和密码哈希,包文件仍保存在 `data/包名/版本/`。不要把 `data/` 目录提交到远程仓库。
|
||||
|
||||
健康检查:
|
||||
|
||||
```text
|
||||
GET http://127.0.0.1:8080/healthz
|
||||
```
|
||||
|
||||
生产环境不要使用示例令牌,并建议通过反向代理配置 HTTPS、访问来源限制、限流和更严格的上传大小限制。
|
||||
### SMTP 配置
|
||||
|
||||
需要真实发送邮件时配置:
|
||||
|
||||
```powershell
|
||||
$env:SCOUT_SMTP_HOST = "smtp.example.com"
|
||||
$env:SCOUT_SMTP_PORT = "587"
|
||||
$env:SCOUT_SMTP_USERNAME = "账号"
|
||||
$env:SCOUT_SMTP_PASSWORD = "密码"
|
||||
$env:SCOUT_SMTP_FROM = "Scout <noreply@example.com>"
|
||||
$env:SCOUT_SMTP_TLS = "false"
|
||||
```
|
||||
|
||||
SMTP 密码只通过环境变量或部署平台密钥管理提供,不要写入代码、配置文件或 Git 远程地址。生产环境建议在反向代理后使用 HTTPS、限制跨域来源、配置限流,并使用独立的开发者账户而不是兼容令牌。
|
||||
|
||||
## 启动 Vue 前端
|
||||
|
||||
@ -51,15 +79,79 @@ npm.cmd install
|
||||
npm.cmd run dev
|
||||
```
|
||||
|
||||
浏览器打开 Vite 输出的地址,默认通常为:
|
||||
浏览器打开 Vite 输出的地址。当前本地测试地址通常为:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:5173
|
||||
http://127.0.0.1:5174/
|
||||
```
|
||||
|
||||
页面中的服务端地址默认是 `http://127.0.0.1:8080`,也可以在页面顶部修改。
|
||||
页面顶部可以修改服务端地址。页面支持注册、登录、密码重置申请、包搜索、包下载和开发者上传。管理员登录后可以管理用户角色和账户状态。
|
||||
|
||||
## 客户端命令
|
||||
## HTTP 接口
|
||||
|
||||
### 账户和安全认证
|
||||
|
||||
| 方法 | 路径 | 用途 | 认证 |
|
||||
| --- | --- | --- | --- |
|
||||
| POST | `/api/v1/auth/register` | 注册用户 | 无 |
|
||||
| POST | `/api/v1/auth/login` | 登录并获取会话令牌 | 无 |
|
||||
| POST | `/api/v1/auth/logout` | 退出当前会话 | Bearer 会话令牌 |
|
||||
| GET | `/api/v1/auth/me` | 获取当前用户 | Bearer 会话令牌 |
|
||||
| POST | `/api/v1/auth/password/request` | 申请密码重置邮件 | 无 |
|
||||
| POST | `/api/v1/auth/password/reset` | 使用一次性令牌重置密码 | 无 |
|
||||
| GET | `/api/v1/admin/users` | 查看用户列表 | 管理员 |
|
||||
| PATCH | `/api/v1/admin/users/{id}` | 更新角色或启停账户 | 管理员 |
|
||||
|
||||
登录请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "admin@example.com",
|
||||
"password": "管理员密码"
|
||||
}
|
||||
```
|
||||
|
||||
登录响应中的 `session` 用于后续请求:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <session>
|
||||
```
|
||||
|
||||
### 包管理
|
||||
|
||||
| 方法 | 路径 | 用途 | 认证 |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/healthz` | 健康检查 | 无 |
|
||||
| GET | `/api/v1/packages?query=demo&page=1&page_size=50` | 搜索和分页获取最新包 | 无 |
|
||||
| GET | `/api/v1/packages/{name}` | 获取指定包最新版本 | 无 |
|
||||
| GET | `/api/v1/packages/{name}/versions` | 获取指定包全部版本 | 无 |
|
||||
| GET | `/api/v1/packages/{name}/{version}/download` | 下载指定版本 | 无 |
|
||||
| GET | `/api/v1/updates/stream?packages=a,b` | 监听包更新 SSE 通知 | 无 |
|
||||
| POST | `/api/v1/packages/upload` | 上传新包 | 开发者/管理员会话或兼容令牌 |
|
||||
|
||||
上传接口使用 `multipart/form-data`,字段如下:
|
||||
|
||||
- `name`:包名,只允许字母、数字、点、下划线和短横线。
|
||||
- `version`:版本号,只允许字母、数字、点、加号、下划线和短横线。
|
||||
- `file`:非空包文件,文件名必须安全且长度不超过 128。
|
||||
- `description`、`platform`、`architecture`:可选元数据。
|
||||
|
||||
兼容令牌请求头:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <SCOUT_DEVELOPER_TOKEN>
|
||||
```
|
||||
|
||||
### 邮件通知范围
|
||||
|
||||
- 新用户注册:通知注册用户,并通知管理员。
|
||||
- 用户登录:向当前用户发送安全登录提醒。
|
||||
- 密码重置:向用户发送一次性重置令牌。
|
||||
- 密码修改和账户权限变更:通知相关用户。
|
||||
- 包上传成功或失败:通知执行上传的开发者/管理员。
|
||||
- 普通用户不会因为包版本更新收到邮件,仍通过 SSE 或客户端主动检查获取更新。
|
||||
|
||||
## Go 客户端命令
|
||||
|
||||
编译命令行客户端:
|
||||
|
||||
@ -85,67 +177,38 @@ go build -o .\scout-client.exe .\cmd\scout-client
|
||||
.\scout-client.exe watch --server http://127.0.0.1:8080 --packages demo-core
|
||||
```
|
||||
|
||||
## 上传接口
|
||||
|
||||
上传接口为:
|
||||
|
||||
```text
|
||||
POST /api/v1/packages/upload
|
||||
```
|
||||
|
||||
请求必须包含以下 multipart 字段:
|
||||
|
||||
- `name`:包名,只允许字母、数字、点、下划线和短横线。
|
||||
- `version`:版本号,只允许字母、数字、点、加号、下划线和短横线。
|
||||
- `file`:包文件。
|
||||
- `description`、`platform`、`architecture`:可选元数据。
|
||||
|
||||
请求头使用以下任一种方式传递开发者令牌:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <令牌>
|
||||
X-Developer-Token: <令牌>
|
||||
```
|
||||
|
||||
## HTTP 接口
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/healthz` | 健康检查 |
|
||||
| GET | `/api/v1/packages` | 获取每个包的最新版本 |
|
||||
| GET | `/api/v1/packages/{name}` | 获取指定包的最新版本 |
|
||||
| GET | `/api/v1/packages/{name}/versions` | 获取指定包的全部版本 |
|
||||
| GET | `/api/v1/packages/{name}/{version}/download` | 下载指定版本 |
|
||||
| GET | `/api/v1/updates/stream?packages=a,b` | 监听包更新 SSE 通知 |
|
||||
| POST | `/api/v1/packages/upload` | 鉴权上传新包 |
|
||||
|
||||
## 测试
|
||||
使用兼容令牌上传:
|
||||
|
||||
```powershell
|
||||
go test ./...
|
||||
Set-Location .\frontend
|
||||
npm.cmd run build
|
||||
.\scout-client.exe upload `
|
||||
--server http://127.0.0.1:8080 `
|
||||
--token $env:SCOUT_DEVELOPER_TOKEN `
|
||||
--package demo-core `
|
||||
--version 1.2.0 `
|
||||
--file .\demo-core.pkg `
|
||||
--platform windows `
|
||||
--architecture amd64
|
||||
```
|
||||
|
||||
当前版本是可运行的最小版本。后续如果用于公网环境,建议继续增加用户/组织权限、签名包、断点续传、对象存储、数据库索引和通知鉴权。
|
||||
|
||||
## Go 客户端模块
|
||||
|
||||
如果你的客户端本身也是 Go 程序,可以直接导入 `scout/pkg/client`,不需要复制 HTTP 请求代码。
|
||||
如果客户端本身也是 Go 程序,可以直接导入 `scout/pkg/client`。
|
||||
|
||||
创建客户端:
|
||||
登录账户:
|
||||
|
||||
```go
|
||||
api, err := client.New(
|
||||
"http://127.0.0.1:8080",
|
||||
client.WithHTTPClient(http.DefaultClient),
|
||||
)
|
||||
api, err := client.New("http://127.0.0.1:8080")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
login, err := api.Login(ctx, "dev@example.com", "开发者密码")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("登录用户:", login.User.Email)
|
||||
```
|
||||
|
||||
检查更新:
|
||||
登录后检查更新、下载和监听:
|
||||
|
||||
```go
|
||||
latest, available, err := api.CheckUpdate(ctx, "demo-core", "1.0.0")
|
||||
@ -153,23 +216,8 @@ if err != nil {
|
||||
return err
|
||||
}
|
||||
if available {
|
||||
fmt.Println("发现新版本:", latest.Version)
|
||||
_, err = api.Download(ctx, "demo-core", latest.Version, "./demo-core.pkg")
|
||||
}
|
||||
```
|
||||
|
||||
下载并校验包:
|
||||
|
||||
```go
|
||||
item, err := api.Download(ctx, "demo-core", latest.Version, "./demo-core.pkg")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("下载完成:", item.SHA256)
|
||||
```
|
||||
|
||||
监听更新通知:
|
||||
|
||||
```go
|
||||
updates, err := api.WatchUpdates(ctx, "demo-core")
|
||||
if err != nil {
|
||||
return err
|
||||
@ -179,69 +227,32 @@ for update := range updates {
|
||||
}
|
||||
```
|
||||
|
||||
开发者上传包:
|
||||
|
||||
```go
|
||||
api, err := client.New(
|
||||
"http://127.0.0.1:8080",
|
||||
client.WithDeveloperToken(os.Getenv("SCOUT_DEVELOPER_TOKEN")),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Open("./demo-core.pkg")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
_, err = api.Upload(ctx, client.UploadRequest{
|
||||
Name: "demo-core",
|
||||
Version: "1.1.0",
|
||||
Description: "示例包",
|
||||
Platform: "windows",
|
||||
Architecture: "amd64",
|
||||
FileName: "demo-core.pkg",
|
||||
File: file,
|
||||
})
|
||||
return err
|
||||
```
|
||||
|
||||
### 客户端接口调用顺序
|
||||
|
||||
普通客户端更新流程如下:
|
||||
|
||||
```text
|
||||
1. GET /api/v1/packages/{name}
|
||||
2. 比较服务端 version 与客户端当前版本
|
||||
3. 如果服务端版本更新:
|
||||
4. GET /api/v1/packages/{name}/{version}/download
|
||||
5. 读取响应头 X-Scout-SHA256
|
||||
6. 下载过程中计算 SHA-256
|
||||
7. 校验通过后完成本地包更新
|
||||
```
|
||||
|
||||
实时更新流程如下:
|
||||
|
||||
```text
|
||||
1. GET /api/v1/updates/stream?packages=包名
|
||||
2. 保持 HTTP 连接
|
||||
3. 服务端上传新版本后发送 SSE data
|
||||
4. 客户端收到 package_updated 事件
|
||||
5. 再次调用最新版本接口确认元数据
|
||||
6. 调用下载接口获取新包
|
||||
```
|
||||
|
||||
`pkg/client` 当前提供的方法:
|
||||
`pkg/client` 提供的方法包括:
|
||||
|
||||
| 方法 | 用途 |
|
||||
| --- | --- |
|
||||
| `New` | 创建客户端 |
|
||||
| `ListPackages` | 获取全部包的最新版本 |
|
||||
| `GetLatest` | 获取指定包的最新版本 |
|
||||
| `ListVersions` | 获取指定包的全部版本 |
|
||||
| `WithDeveloperToken` | 配置兼容开发者令牌 |
|
||||
| `WithSessionToken` | 配置登录会话令牌 |
|
||||
| `Register` | 注册用户 |
|
||||
| `Login` | 登录并自动保存会话令牌 |
|
||||
| `Me` | 获取当前登录用户 |
|
||||
| `Logout` | 退出并清理会话令牌 |
|
||||
| `RequestPasswordReset` | 申请密码重置邮件 |
|
||||
| `ResetPassword` | 使用令牌重置密码 |
|
||||
| `ListPackages` | 获取包列表 |
|
||||
| `GetLatest` | 获取指定包最新版本 |
|
||||
| `ListVersions` | 获取指定包全部版本 |
|
||||
| `CheckUpdate` | 比较当前版本并判断是否有更新 |
|
||||
| `Download` | 下载指定版本或最新版本,并校验 SHA-256 |
|
||||
| `Download` | 下载并校验 SHA-256 |
|
||||
| `WatchUpdates` | 监听 SSE 更新通知 |
|
||||
| `Upload` | 使用开发者令牌上传包 |
|
||||
| `Upload` | 使用兼容令牌或会话上传包 |
|
||||
|
||||
命令行客户端 `cmd/scout-client` 现在也是基于这个模块实现的,可以作为客户端调用参考。
|
||||
## 测试
|
||||
|
||||
```powershell
|
||||
go test ./...
|
||||
go test -race ./...
|
||||
Set-Location .\frontend
|
||||
npm.cmd run build
|
||||
```
|
||||
|
||||
Loading…
Reference in New Issue
Block a user