vdi-cloud/platform/internal/server/imports.go

389 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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