diff --git a/cmd/scout-server/main.go b/cmd/scout-server/main.go index eb5e703..e04d347 100644 --- a/cmd/scout-server/main.go +++ b/cmd/scout-server/main.go @@ -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) } } diff --git a/frontend/src/views/Home/Home.css b/frontend/src/views/Home/Home.css index 28f76dd..188d037 100644 --- a/frontend/src/views/Home/Home.css +++ b/frontend/src/views/Home/Home.css @@ -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; + } +} diff --git a/frontend/src/views/Home/Home.js b/frontend/src/views/Home/Home.js index e153cc6..2fe111d 100644 --- a/frontend/src/views/Home/Home.js +++ b/frontend/src/views/Home/Home.js @@ -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, diff --git a/frontend/src/views/Home/Home.vue b/frontend/src/views/Home/Home.vue index 8a4cc9e..3b6aecc 100644 --- a/frontend/src/views/Home/Home.vue +++ b/frontend/src/views/Home/Home.vue @@ -1,4 +1,4 @@ -