加入用户管理安全认证和邮箱通知

This commit is contained in:
Scout开发者 2026-08-30 23:27:30 +08:00
parent c7aecccc17
commit 3afcccbd84
12 changed files with 1925 additions and 210 deletions

View File

@ -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)
}
}

View File

@ -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;
}
}

View File

@ -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,

View File

@ -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>

View 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
}

View File

@ -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
View 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
}

View File

@ -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
View 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
View 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("退出后仍可获取当前用户")
}
}

View File

@ -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
}

View File

@ -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
```