389 lines
12 KiB
Go
389 lines
12 KiB
Go
package server
|
||
|
||
import (
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"database/sql"
|
||
"encoding/csv"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"io"
|
||
"strings"
|
||
"unicode"
|
||
"unicode/utf8"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/jmoiron/sqlx"
|
||
"vdi-cloud/platform/internal/store"
|
||
)
|
||
|
||
type importInput struct {
|
||
Kind string `json:"kind" binding:"required,oneof=users terminals bindings"`
|
||
CSV string `json:"csv" binding:"required,max=524288"`
|
||
PreviewToken string `json:"previewToken"`
|
||
}
|
||
type importRow struct {
|
||
Line int `json:"line"`
|
||
Key string `json:"key"`
|
||
Values map[string]string `json:"values"`
|
||
Errors []string `json:"errors"`
|
||
userID, terminalID string
|
||
desktops []string
|
||
state string
|
||
}
|
||
type importPreview struct {
|
||
Kind string `json:"kind"`
|
||
TotalRows int `json:"totalRows"`
|
||
ValidRows int `json:"validRows"`
|
||
ErrorRows int `json:"errorRows"`
|
||
PreviewToken string `json:"previewToken"`
|
||
Rows []importRow `json:"rows"`
|
||
}
|
||
type importItem struct {
|
||
Line int `json:"line"`
|
||
Key string `json:"key"`
|
||
ID string `json:"id"`
|
||
}
|
||
type activationItem struct {
|
||
SN string `json:"sn"`
|
||
ActivationSecret string `json:"activationSecret"`
|
||
}
|
||
type importResult struct {
|
||
ID string `json:"id"`
|
||
Kind string `json:"kind"`
|
||
Count int `json:"count"`
|
||
Items []importItem `json:"items"`
|
||
ActivationSecrets []activationItem `json:"activationSecrets"`
|
||
Replayed bool `json:"replayed"`
|
||
}
|
||
|
||
func importText(v string, min, max int) bool {
|
||
n := utf8.RuneCountInString(v)
|
||
return n >= min && n <= max && !strings.ContainsFunc(v, func(r rune) bool { return unicode.IsControl(r) })
|
||
}
|
||
func parseImport(in importInput) ([]importRow, string) {
|
||
if !utf8.ValidString(in.CSV) || len(in.CSV) > 524288 {
|
||
return nil, "请使用不超过 512 KiB 的 UTF-8 CSV 文件"
|
||
}
|
||
r := csv.NewReader(strings.NewReader(strings.TrimPrefix(in.CSV, "\ufeff")))
|
||
r.FieldsPerRecord = -1
|
||
header, err := r.Read()
|
||
if err != nil {
|
||
return nil, "无法读取 CSV 表头"
|
||
}
|
||
required := map[string][]string{"users": {"username", "displayName"}, "terminals": {"sn", "name", "group"}, "bindings": {"username", "terminalSn", "desktopIds"}}[in.Kind]
|
||
if len(header) != len(required) {
|
||
return nil, "CSV 表头必须为:" + strings.Join(required, ",")
|
||
}
|
||
positions := map[string]int{}
|
||
for i, h := range header {
|
||
h = strings.TrimSpace(h)
|
||
if _, ok := positions[h]; ok {
|
||
return nil, "CSV 表头重复"
|
||
}
|
||
positions[h] = i
|
||
}
|
||
for _, h := range required {
|
||
if _, ok := positions[h]; !ok {
|
||
return nil, "CSV 表头必须为:" + strings.Join(required, ",")
|
||
}
|
||
}
|
||
rows := []importRow{}
|
||
seen := map[string]bool{}
|
||
seenSN := map[string]bool{}
|
||
for {
|
||
cells, err := r.Read()
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
return nil, "CSV 引号或字段格式错误,请检查文件"
|
||
}
|
||
if len(rows) >= 200 {
|
||
return nil, "每次最多导入 200 行,请拆分文件"
|
||
}
|
||
line, _ := r.FieldPos(0)
|
||
row := importRow{Line: line, Values: map[string]string{}, Errors: []string{}}
|
||
if len(cells) != len(header) {
|
||
row.Errors = append(row.Errors, "字段数量与表头不一致")
|
||
rows = append(rows, row)
|
||
continue
|
||
}
|
||
for h, i := range positions {
|
||
row.Values[h] = strings.TrimSpace(cells[i])
|
||
}
|
||
row.Key = row.Values["username"]
|
||
if in.Kind == "terminals" {
|
||
row.Key = row.Values["sn"]
|
||
}
|
||
if seen[row.Key] {
|
||
row.Errors = append(row.Errors, "文件内账号或 SN 重复")
|
||
}
|
||
seen[row.Key] = true
|
||
if in.Kind == "users" {
|
||
if !importText(row.Key, 2, 80) || !importText(row.Values["displayName"], 1, 80) {
|
||
row.Errors = append(row.Errors, "账号需 2~80 字符,姓名需 1~80 字符,不能包含控制字符")
|
||
}
|
||
} else if in.Kind == "terminals" {
|
||
if !importText(row.Key, 3, 100) || !importText(row.Values["name"], 1, 80) || !importText(row.Values["group"], 0, 80) {
|
||
row.Errors = append(row.Errors, "SN 需 3~100 字符,名称需 1~80 字符,分组最多 80 字符,不能包含控制字符")
|
||
}
|
||
} else {
|
||
sn := row.Values["terminalSn"]
|
||
if !importText(row.Key, 2, 80) || !importText(sn, 0, 100) {
|
||
row.Errors = append(row.Errors, "用户账号或终端 SN 格式无效")
|
||
}
|
||
if sn != "" {
|
||
if seenSN[sn] {
|
||
row.Errors = append(row.Errors, "文件内同一终端不能分配给多位用户")
|
||
}
|
||
seenSN[sn] = true
|
||
}
|
||
if row.Values["desktopIds"] != "" {
|
||
row.desktops = uniqueIDs(strings.Split(row.Values["desktopIds"], ";"))
|
||
for _, id := range row.desktops {
|
||
if !importText(id, 1, 100) {
|
||
row.Errors = append(row.Errors, "桌面 ID 格式无效,多个 ID 使用英文分号分隔")
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if len(row.desktops) > 100 {
|
||
row.Errors = append(row.Errors, "每位用户最多追加 100 台桌面")
|
||
}
|
||
if sn == "" && len(row.desktops) == 0 {
|
||
row.Errors = append(row.Errors, "终端 SN 和桌面 ID 至少填写一项")
|
||
}
|
||
}
|
||
rows = append(rows, row)
|
||
}
|
||
if len(rows) == 0 {
|
||
return nil, "CSV 没有数据行"
|
||
}
|
||
return rows, ""
|
||
}
|
||
func (s *Server) validateImport(c *gin.Context, tx *sqlx.Tx, in importInput) (importPreview, bool) {
|
||
rows, message := parseImport(in)
|
||
if message != "" {
|
||
s.fail(c, 400, "invalid_csv", message)
|
||
return importPreview{}, false
|
||
}
|
||
ctx := c.Request.Context()
|
||
state := []string{}
|
||
for i := range rows {
|
||
row := &rows[i]
|
||
if len(row.Errors) > 0 {
|
||
continue
|
||
}
|
||
if in.Kind == "users" || in.Kind == "terminals" {
|
||
var exists bool
|
||
query := "SELECT EXISTS(SELECT 1 FROM users WHERE username=$1)"
|
||
if in.Kind == "terminals" {
|
||
query = "SELECT EXISTS(SELECT 1 FROM terminals WHERE sn=$1)"
|
||
}
|
||
if err := tx.GetContext(ctx, &exists, query, row.Key); err != nil {
|
||
s.dbError(c, err)
|
||
return importPreview{}, false
|
||
}
|
||
if exists {
|
||
row.Errors = append(row.Errors, "该账号或 SN 已存在;导入仅新增,不覆盖现有资料")
|
||
}
|
||
} else {
|
||
var u struct {
|
||
ID string `db:"id"`
|
||
TerminalID string `db:"terminal_id"`
|
||
Enabled bool `db:"enabled"`
|
||
}
|
||
err := tx.GetContext(ctx, &u, "SELECT id,COALESCE(terminal_id,'') terminal_id,enabled FROM users WHERE username=$1", row.Key)
|
||
if err == sql.ErrNoRows {
|
||
row.Errors = append(row.Errors, "用户不存在,请先导入用户")
|
||
continue
|
||
}
|
||
if err != nil {
|
||
s.dbError(c, err)
|
||
return importPreview{}, false
|
||
}
|
||
row.userID = u.ID
|
||
oldDesktops := []string{}
|
||
if err = tx.SelectContext(ctx, &oldDesktops, "SELECT desktop_id FROM bindings WHERE user_id=$1 ORDER BY desktop_id", u.ID); err != nil {
|
||
s.dbError(c, err)
|
||
return importPreview{}, false
|
||
}
|
||
snapshot, _ := json.Marshal([]any{u.ID, u.TerminalID, u.Enabled, oldDesktops})
|
||
row.state = string(snapshot)
|
||
if sn := row.Values["terminalSn"]; sn != "" {
|
||
var t struct {
|
||
ID string `db:"id"`
|
||
Enabled bool `db:"enabled"`
|
||
Owner string `db:"owner"`
|
||
}
|
||
err = tx.GetContext(ctx, &t, "SELECT t.id,t.enabled,COALESCE(u.id,'') owner FROM terminals t LEFT JOIN users u ON u.terminal_id=t.id WHERE t.sn=$1", sn)
|
||
if err == sql.ErrNoRows {
|
||
row.Errors = append(row.Errors, "终端 SN 不存在")
|
||
} else if err != nil {
|
||
s.dbError(c, err)
|
||
return importPreview{}, false
|
||
} else {
|
||
if !t.Enabled {
|
||
row.Errors = append(row.Errors, "终端已停用")
|
||
}
|
||
if t.Owner != "" && t.Owner != u.ID {
|
||
row.Errors = append(row.Errors, "该终端已关联其他用户")
|
||
}
|
||
if u.TerminalID != "" && u.TerminalID != t.ID {
|
||
row.Errors = append(row.Errors, "该用户已关联其他终端,请在用户页面明确换绑")
|
||
}
|
||
row.terminalID = t.ID
|
||
row.state += "|" + t.ID
|
||
}
|
||
}
|
||
if len(row.desktops) > 0 {
|
||
var count int
|
||
if err = tx.GetContext(ctx, &count, "SELECT count(*) FROM desktops WHERE id=ANY($1)", row.desktops); err != nil {
|
||
s.dbError(c, err)
|
||
return importPreview{}, false
|
||
}
|
||
if count != len(row.desktops) {
|
||
row.Errors = append(row.Errors, "存在未纳管的桌面 ID")
|
||
}
|
||
}
|
||
}
|
||
state = append(state, row.state)
|
||
}
|
||
out := importPreview{Kind: in.Kind, TotalRows: len(rows), Rows: rows}
|
||
for _, row := range rows {
|
||
if len(row.Errors) == 0 {
|
||
out.ValidRows++
|
||
} else {
|
||
out.ErrorRows++
|
||
}
|
||
}
|
||
if out.ErrorRows == 0 {
|
||
raw, _ := json.Marshal([]any{in.Kind, in.CSV, state})
|
||
mac := hmac.New(sha256.New, []byte(s.Config.JWTSecret))
|
||
mac.Write(raw)
|
||
out.PreviewToken = hex.EncodeToString(mac.Sum(nil))
|
||
}
|
||
return out, true
|
||
}
|
||
func (s *Server) previewImport(c *gin.Context) {
|
||
var in importInput
|
||
if !s.decode(c, &in) {
|
||
return
|
||
}
|
||
tx, err := s.DB.BeginTxx(c.Request.Context(), &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
|
||
if err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
if out, ok := s.validateImport(c, tx, in); ok {
|
||
c.JSON(200, out)
|
||
}
|
||
}
|
||
func (s *Server) commitImport(c *gin.Context) {
|
||
var in importInput
|
||
if !s.decode(c, &in) {
|
||
return
|
||
}
|
||
key := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
|
||
if key == "" || len(key) > 100 || len(in.PreviewToken) != 64 {
|
||
s.fail(c, 400, "invalid_request", "请先完成预检,并携带有效请求标识")
|
||
return
|
||
}
|
||
raw, _ := json.Marshal([]string{in.Kind, in.CSV})
|
||
hash := store.Hash(string(raw))
|
||
ctx := c.Request.Context()
|
||
actor := s.admin(c)
|
||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||
if err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
if !s.lockAuthorization(c, tx) {
|
||
return
|
||
}
|
||
var previous struct {
|
||
Hash string `db:"request_hash"`
|
||
Result []byte `db:"result"`
|
||
}
|
||
err = tx.GetContext(ctx, &previous, "SELECT request_hash,result FROM import_batches WHERE actor_id=$1 AND idempotency_key=$2", actor.ID, key)
|
||
if err == nil {
|
||
if hash != previous.Hash {
|
||
s.fail(c, 409, "idempotency_conflict", "请求标识已用于其他导入内容")
|
||
return
|
||
}
|
||
var out importResult
|
||
if err = json.Unmarshal(previous.Result, &out); err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
out.Replayed = true
|
||
c.JSON(200, out)
|
||
return
|
||
}
|
||
if err != sql.ErrNoRows {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
err = nil
|
||
preview, ok := s.validateImport(c, tx, in)
|
||
if !ok {
|
||
return
|
||
}
|
||
if preview.ErrorRows > 0 || !hmac.Equal([]byte(preview.PreviewToken), []byte(in.PreviewToken)) {
|
||
s.fail(c, 409, "import_changed", "数据或授权状态已变化,请重新预检;本次没有导入任何行")
|
||
return
|
||
}
|
||
out := importResult{ID: store.ID("import_"), Kind: in.Kind, Count: len(preview.Rows), Items: []importItem{}, ActivationSecrets: []activationItem{}}
|
||
for _, row := range preview.Rows {
|
||
id := row.userID
|
||
switch in.Kind {
|
||
case "users":
|
||
id = store.ID("user_")
|
||
_, err = tx.ExecContext(ctx, "INSERT INTO users(id,username,display_name) VALUES($1,$2,$3)", id, row.Key, row.Values["displayName"])
|
||
case "terminals":
|
||
id = store.ID("term_")
|
||
secret := store.ID("activate_")
|
||
_, err = tx.ExecContext(ctx, "INSERT INTO terminals(id,sn,name,group_name,bootstrap_hash) VALUES($1,$2,$3,$4,$5)", id, row.Key, row.Values["name"], row.Values["group"], store.Hash(secret))
|
||
out.ActivationSecrets = append(out.ActivationSecrets, activationItem{row.Key, secret})
|
||
case "bindings":
|
||
if row.terminalID != "" {
|
||
_, err = tx.ExecContext(ctx, "UPDATE users SET terminal_id=$1 WHERE id=$2", row.terminalID, row.userID)
|
||
}
|
||
if err == nil && len(row.desktops) > 0 {
|
||
_, err = tx.ExecContext(ctx, "INSERT INTO bindings(user_id,desktop_id) SELECT $1,unnest($2::text[]) ON CONFLICT DO NOTHING", row.userID, row.desktops)
|
||
}
|
||
if err == nil {
|
||
_, err = tx.ExecContext(ctx, "DELETE FROM connection_tickets WHERE consumed_at IS NULL AND terminal_id=(SELECT terminal_id FROM users WHERE id=$1)", row.userID)
|
||
}
|
||
}
|
||
if conflict(err) {
|
||
s.fail(c, 409, "import_changed", "数据已变化,请重新预检;本次没有导入任何行")
|
||
return
|
||
}
|
||
if err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
out.Items = append(out.Items, importItem{row.Line, row.Key, id})
|
||
}
|
||
// Keep the reusable result, never plaintext activation credentials or source CSV.
|
||
safe := out
|
||
safe.ActivationSecrets = []activationItem{}
|
||
result, _ := json.Marshal(safe)
|
||
_, err = tx.ExecContext(ctx, "INSERT INTO import_batches(id,actor_id,kind,row_count,idempotency_key,request_hash,result) VALUES($1,$2,$3,$4,$5,$6,$7)", out.ID, actor.ID, in.Kind, out.Count, key, hash, string(result))
|
||
if err == nil {
|
||
err = accountAudit(ctx, tx, actor.Username, "import_"+in.Kind, out.ID, c.GetString("requestId"))
|
||
}
|
||
if err == nil {
|
||
err = tx.Commit()
|
||
}
|
||
if err != nil {
|
||
s.dbError(c, err)
|
||
return
|
||
}
|
||
c.JSON(200, out)
|
||
}
|