重組前檢查點:根目錄 main package

This commit is contained in:
2026-10-03 09:23:38 +08:00
commit f373cb8d37
45 changed files with 5332 additions and 0 deletions
+209
View File
@@ -0,0 +1,209 @@
package main
import (
"errors"
"log"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
"alterminal/internal/jwk"
)
// adminKeyRow 為金鑰管理頁表格的單列視圖。
type adminKeyRow struct {
ID uint
Kid string
Algorithm string
CreatedAt string // 本地時間顯示
Active bool // 使用中(未退休)
LastActive bool // 使用中且為最後一把:退休按鈕停用
}
// adminKeysPageData 為金鑰管理頁的模板資料。
type adminKeysPageData struct {
Error string
Username string // 側欄頁尾使用者資訊
Email string
CSRF string // 產生/退休表單的 CSRF token
Keys []adminKeyRow
ActiveCount int // 使用中金鑰數;0 時頁面顯示警告
}
// newAdminKeyRows 將金鑰模型轉為表格視圖,並回傳使用中的金鑰數。
// 純函式,便於單元測試。
func newAdminKeyRows(keys []jwk.SigningKey) (rows []adminKeyRow, active int) {
for _, k := range keys {
if k.Active() {
active++
}
}
rows = make([]adminKeyRow, 0, len(keys))
for _, k := range keys {
rows = append(rows, adminKeyRow{
ID: k.ID,
Kid: k.Kid,
Algorithm: k.Algorithm,
CreatedAt: k.CreatedAt.Local().Format("2006-01-02 15:04:05 MST"),
Active: k.Active(),
// 僅剩一把使用中金鑰時禁止退休,確保隨時都有金鑰可簽發 JWT。
LastActive: k.Active() && active == 1,
})
}
return rows, active
}
// requireAdmin 驗證請求來自持有效 Session 的管理員:未登入或 Session
// 過期時導向 /login(登入後可再試),已登入但非管理員回 403。
// 回傳 Session(含 User)與是否繼續處理。
func requireAdmin(db *gorm.DB, w http.ResponseWriter, r *http.Request) (*Session, bool) {
c, err := r.Cookie(sessionCookieName)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return nil, false
}
s, err := getSession(db, c.Value)
switch {
case errors.Is(err, ErrSessionExpired):
http.Redirect(w, r, "/login", http.StatusSeeOther)
return nil, false
case err != nil:
log.Printf("admin: %v", err)
http.Error(w, "內部錯誤", http.StatusInternalServerError)
return nil, false
}
if s.User.Role != RoleAdmin {
log.Printf("admin: 非 admin 存取(user=%q)", s.User.Username)
http.Error(w, "需要管理員權限", http.StatusForbidden)
return nil, false
}
return s, true
}
// adminKeysPageHandler 處理 GET /admin/keys:列出簽章金鑰(kid、演算法、
// 建立時間、狀態)與產生/退休表單,僅管理員可存取。
func adminKeysPageHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, ok := requireAdmin(db, w, r)
if !ok {
return
}
renderAdminKeysPage(w, r, db, http.StatusOK, s, "")
}
}
// renderAdminKeysPage 查詢金鑰並輸出管理頁;errMsg 非空時以指定 status
// 重繪頁面並顯示錯誤(表單驗證失敗等)。s 供側欄頁尾顯示使用者資訊。
// 使用中的金鑰排前、新者在前。
func renderAdminKeysPage(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, s *Session, errMsg string) {
var keys []jwk.SigningKey
if err := db.Order("retired_at IS NULL DESC, created_at DESC").Find(&keys).Error; err != nil {
log.Printf("admin keys: %v", err)
http.Error(w, "內部錯誤", http.StatusInternalServerError)
return
}
token, err := newCSRFToken(w, r)
if err != nil {
log.Printf("csrf token: %v", err)
http.Error(w, "內部錯誤", http.StatusInternalServerError)
return
}
rows, active := newAdminKeyRows(keys)
renderHTML(w, status, adminKeysTmpl, adminKeysPageData{
Error: errMsg,
Username: s.User.Username,
Email: s.User.Email,
CSRF: token,
Keys: rows,
ActiveCount: active,
})
}
// adminKeysCreateHandler 處理 POST /admin/keys:產生並儲存新的 RSA
// 簽章金鑰,成功後 PRG 導回管理頁。
func adminKeysCreateHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, ok := requireAdmin(db, w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
renderAdminKeysPage(w, r, db, http.StatusBadRequest, s, "無法解析表單內容")
return
}
if !verifyCSRF(r) {
renderAdminKeysPage(w, r, db, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試")
return
}
k, err := jwk.NewSigningKey()
if err != nil {
log.Printf("admin keys: %v", err)
renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "金鑰產生失敗,請稍後再試")
return
}
if err := db.Create(k).Error; err != nil {
log.Printf("admin keys: %v", err)
renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "金鑰儲存失敗,請稍後再試")
return
}
http.Redirect(w, r, "/admin/keys", http.StatusSeeOther)
}
}
// adminKeysRetireHandler 處理 POST /admin/keys/{id}/retire:退休金鑰。
// 最後一把使用中金鑰不可退休(否則將無金鑰可簽發 JWT);已退休或不存在
// 的金鑰以錯誤訊息重繪頁面。
func adminKeysRetireHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, ok := requireAdmin(db, w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
renderAdminKeysPage(w, r, db, http.StatusBadRequest, s, "無法解析表單內容")
return
}
if !verifyCSRF(r) {
renderAdminKeysPage(w, r, db, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試")
return
}
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 64)
if err != nil {
renderAdminKeysPage(w, r, db, http.StatusNotFound, s, "金鑰不存在")
return
}
var k jwk.SigningKey
switch err := db.First(&k, id).Error; {
case errors.Is(err, gorm.ErrRecordNotFound):
renderAdminKeysPage(w, r, db, http.StatusNotFound, s, "金鑰不存在")
return
case err != nil:
log.Printf("admin keys: %v", err)
renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "內部錯誤")
return
}
if !k.Active() {
renderAdminKeysPage(w, r, db, http.StatusConflict, s, "金鑰已處於退休狀態")
return
}
var active int64
if err := db.Model(&jwk.SigningKey{}).Where("retired_at IS NULL").Count(&active).Error; err != nil {
log.Printf("admin keys: %v", err)
renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "內部錯誤")
return
}
if active <= 1 {
renderAdminKeysPage(w, r, db, http.StatusConflict, s, "至少須保留一把使用中的金鑰")
return
}
if err := db.Model(&k).Update("retired_at", time.Now()).Error; err != nil {
log.Printf("admin keys: %v", err)
renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "金鑰更新失敗,請稍後再試")
return
}
http.Redirect(w, r, "/admin/keys", http.StatusSeeOther)
}
}