更新
This commit is contained in:
@@ -0,0 +1,554 @@
|
||||
// Command import-generated-test-users validates a 100-person generated fixture
|
||||
// set, publishes original/display/thumbnail objects to the configured Tencent
|
||||
// COS bucket, and atomically creates explicitly labelled non-loginable users.
|
||||
// It is operator-only and is never called during application startup.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
cos "github.com/tencentyun/cos-go-sdk-v5"
|
||||
)
|
||||
|
||||
const (
|
||||
batchName = "cn-realistic-18-30-20260902-v1"
|
||||
recordCount = 100
|
||||
)
|
||||
|
||||
type manifestRecord struct {
|
||||
ID int `json:"id"`
|
||||
Gender string `json:"gender"`
|
||||
Age int `json:"age"`
|
||||
}
|
||||
|
||||
type assetPlan struct {
|
||||
ID int `json:"id"`
|
||||
Gender string `json:"gender"`
|
||||
Age int `json:"age"`
|
||||
File string `json:"file"`
|
||||
SHA256 string `json:"sha256"`
|
||||
OriginalKey string `json:"originalKey"`
|
||||
DisplayKey string `json:"displayKey"`
|
||||
ThumbnailKey string `json:"thumbnailKey"`
|
||||
OriginalURL string `json:"originalUrl"`
|
||||
DisplayURL string `json:"displayUrl"`
|
||||
ThumbnailURL string `json:"thumbnailUrl"`
|
||||
}
|
||||
|
||||
type uploadResult struct {
|
||||
OriginalUploaded bool `json:"originalUploaded"`
|
||||
DisplayUploaded bool `json:"displayUploaded"`
|
||||
ThumbnailUploaded bool `json:"thumbnailUploaded"`
|
||||
}
|
||||
|
||||
type city struct {
|
||||
Code string
|
||||
Name string
|
||||
Lat float64
|
||||
Lng float64
|
||||
}
|
||||
|
||||
var cities = []city{
|
||||
{"310100", "上海", 31.2304, 121.4737},
|
||||
{"440100", "广州", 23.1291, 113.2644},
|
||||
{"440300", "深圳", 22.5431, 114.0579},
|
||||
{"330100", "杭州", 30.2741, 120.1551},
|
||||
{"510100", "成都", 30.5728, 104.0668},
|
||||
{"420100", "武汉", 30.5928, 114.3055},
|
||||
{"320100", "南京", 32.0603, 118.7969},
|
||||
{"350200", "厦门", 24.4798, 118.0894},
|
||||
{"610100", "西安", 34.3416, 108.9398},
|
||||
{"370200", "青岛", 36.0671, 120.3826},
|
||||
}
|
||||
|
||||
func decrypt(value, configKey, jwtKey string) (string, error) {
|
||||
if !strings.HasPrefix(value, "enc:v1:") {
|
||||
return value, nil
|
||||
}
|
||||
payload, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(value, "enc:v1:"))
|
||||
if err != nil {
|
||||
return "", errors.New("invalid encrypted COS credential")
|
||||
}
|
||||
for _, raw := range []string{configKey, jwtKey + ":integration-config"} {
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
key := sha256.Sum256([]byte(raw))
|
||||
block, _ := aes.NewCipher(key[:])
|
||||
gcm, _ := cipher.NewGCM(block)
|
||||
if len(payload) < gcm.NonceSize() {
|
||||
continue
|
||||
}
|
||||
plain, openErr := gcm.Open(nil, payload[:gcm.NonceSize()], payload[gcm.NonceSize():], nil)
|
||||
if openErr == nil {
|
||||
return string(plain), nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("cannot decrypt COS credential with server encryption key")
|
||||
}
|
||||
|
||||
func validHTTPS(raw string) (*url.URL, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||
return nil, errors.New("COS endpoint/public base must be HTTPS without credentials, query, or fragment")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func loadManifest(file string) ([]manifestRecord, error) {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var records []manifestRecord
|
||||
if err = json.Unmarshal(data, &records); err != nil {
|
||||
return nil, errors.New("manifest is not valid JSON")
|
||||
}
|
||||
if len(records) != recordCount {
|
||||
return nil, fmt.Errorf("manifest must contain exactly %d records", recordCount)
|
||||
}
|
||||
sort.Slice(records, func(i, j int) bool { return records[i].ID < records[j].ID })
|
||||
female := 0
|
||||
for index, item := range records {
|
||||
if item.ID != index+1 || (item.Gender != "male" && item.Gender != "female") || item.Age < 18 || item.Age > 30 {
|
||||
return nil, fmt.Errorf("invalid manifest record at position %d", index+1)
|
||||
}
|
||||
if item.Gender == "female" {
|
||||
female++
|
||||
}
|
||||
}
|
||||
if female <= recordCount/2 {
|
||||
return nil, errors.New("manifest must contain more female than male accounts")
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func avatarFilename(item manifestRecord) string {
|
||||
return fmt.Sprintf("avatar-%03d-%s.png", item.ID, item.Gender)
|
||||
}
|
||||
|
||||
func planAssets(records []manifestRecord, directory, prefix, publicBase string) ([]assetPlan, error) {
|
||||
if !filepath.IsAbs(directory) {
|
||||
return nil, errors.New("avatars directory must be absolute")
|
||||
}
|
||||
if !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9/_-]{0,119}$`).MatchString(prefix) || strings.Contains(prefix, "//") || path.Clean(prefix) != prefix {
|
||||
return nil, errors.New("invalid configured object prefix")
|
||||
}
|
||||
if _, err := validHTTPS(publicBase); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base := strings.TrimRight(publicBase, "/")
|
||||
plans := make([]assetPlan, 0, len(records))
|
||||
for _, item := range records {
|
||||
name := avatarFilename(item)
|
||||
file := filepath.Join(directory, name)
|
||||
info, err := os.Lstat(file)
|
||||
if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > 12<<20 {
|
||||
return nil, fmt.Errorf("missing or invalid avatar %s", name)
|
||||
}
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read avatar %s", name)
|
||||
}
|
||||
cfg, err := png.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil || cfg.Width < 640 || cfg.Height < 640 || int64(cfg.Width)*int64(cfg.Height) > 24_000_000 {
|
||||
return nil, fmt.Errorf("avatar must be a valid PNG between 640px and 24MP: %s", name)
|
||||
}
|
||||
hash := sha256.Sum256(data)
|
||||
digest := hex.EncodeToString(hash[:])
|
||||
stem := path.Join(prefix, "test-users", batchName, fmt.Sprintf("%03d-%s-%s", item.ID, item.Gender, digest[:20]))
|
||||
plan := assetPlan{
|
||||
ID: item.ID, Gender: item.Gender, Age: item.Age, File: name, SHA256: digest,
|
||||
OriginalKey: stem + "-original.png", DisplayKey: stem + "-av1.jpg", ThumbnailKey: stem + "-av1-thumb.jpg",
|
||||
}
|
||||
plan.OriginalURL = base + "/" + plan.OriginalKey
|
||||
plan.DisplayURL = base + "/" + plan.DisplayKey
|
||||
plan.ThumbnailURL = base + "/" + plan.ThumbnailKey
|
||||
plans = append(plans, plan)
|
||||
}
|
||||
return plans, nil
|
||||
}
|
||||
|
||||
func jpegBytes(src image.Image, quality int) ([]byte, error) {
|
||||
opaque := image.NewRGBA(src.Bounds())
|
||||
draw.Draw(opaque, opaque.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
|
||||
draw.Draw(opaque, opaque.Bounds(), src, src.Bounds().Min, draw.Over)
|
||||
var out bytes.Buffer
|
||||
err := jpeg.Encode(&out, opaque, &jpeg.Options{Quality: quality})
|
||||
return out.Bytes(), err
|
||||
}
|
||||
|
||||
func renderVariants(file string) (original, display, thumbnail []byte, err error) {
|
||||
original, err = os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
src, err := png.Decode(bytes.NewReader(original))
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
displayImage := imaging.Fit(src, 640, 640, imaging.Lanczos)
|
||||
thumbImage := imaging.Fit(displayImage, 256, 256, imaging.Lanczos)
|
||||
display, err = jpegBytes(displayImage, 82)
|
||||
if err == nil {
|
||||
thumbnail, err = jpegBytes(thumbImage, 78)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func cosFailure(operation string, err error) error {
|
||||
var apiErr *cos.ErrorResponse
|
||||
if errors.As(err, &apiErr) {
|
||||
return fmt.Errorf("COS %s failed: %s (request %s)", operation, apiErr.Code, apiErr.RequestID)
|
||||
}
|
||||
return fmt.Errorf("COS %s failed; credentials were not logged", operation)
|
||||
}
|
||||
|
||||
func verifyPublic(ctx context.Context, client *http.Client, objectURL, contentType string, expected []byte) (int, error) {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, objectURL, nil)
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, errors.New("public COS request failed")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return res.StatusCode, fmt.Errorf("public COS URL returned HTTP %d", res.StatusCode)
|
||||
}
|
||||
actual, err := io.ReadAll(io.LimitReader(res.Body, int64(len(expected))+1))
|
||||
if err != nil || !bytes.Equal(actual, expected) || !strings.HasPrefix(res.Header.Get("Content-Type"), contentType) {
|
||||
return res.StatusCode, errors.New("public COS object content mismatch")
|
||||
}
|
||||
return res.StatusCode, nil
|
||||
}
|
||||
|
||||
func uploadObject(ctx context.Context, client *cos.Client, public *http.Client, key, objectURL, contentType string, data []byte) (bool, error) {
|
||||
uploaded := false
|
||||
res, err := client.Object.Get(ctx, key, nil)
|
||||
if err == nil {
|
||||
actual, readErr := io.ReadAll(io.LimitReader(res.Body, int64(len(data))+1))
|
||||
res.Body.Close()
|
||||
if readErr != nil || !bytes.Equal(actual, data) {
|
||||
return false, errors.New("existing COS object differs; no overwrite performed")
|
||||
}
|
||||
} else {
|
||||
var apiErr *cos.ErrorResponse
|
||||
if !errors.As(err, &apiErr) || apiErr.Code != "NoSuchKey" {
|
||||
return false, cosFailure("lookup", err)
|
||||
}
|
||||
headers := http.Header{}
|
||||
headers.Set("x-cos-forbid-overwrite", "true")
|
||||
_, err = client.Object.Put(ctx, key, bytes.NewReader(data), &cos.ObjectPutOptions{ObjectPutHeaderOptions: &cos.ObjectPutHeaderOptions{
|
||||
ContentType: contentType, ContentLength: int64(len(data)), CacheControl: "public, max-age=31536000, immutable", XOptionHeader: &headers,
|
||||
}})
|
||||
if err != nil {
|
||||
return false, cosFailure("upload", err)
|
||||
}
|
||||
uploaded = true
|
||||
}
|
||||
status, err := verifyPublic(ctx, public, objectURL, contentType, data)
|
||||
if err != nil && status == http.StatusForbidden && uploaded {
|
||||
_, aclErr := client.Object.PutACL(ctx, key, &cos.ObjectPutACLOptions{Header: &cos.ACLHeaderOptions{XCosACL: "public-read"}})
|
||||
if aclErr != nil {
|
||||
return uploaded, cosFailure("object public-read ACL", aclErr)
|
||||
}
|
||||
_, err = verifyPublic(ctx, public, objectURL, contentType, data)
|
||||
}
|
||||
return uploaded, err
|
||||
}
|
||||
|
||||
func writeJSON(file string, value any) error {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(file, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err = f.Write(append(data, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Sync()
|
||||
}
|
||||
|
||||
func nickname(item manifestRecord) string {
|
||||
surnames := []string{"陈", "林", "周", "许", "苏", "沈", "陆", "顾", "方", "季", "唐", "宋", "夏", "叶", "温", "乔", "江", "程", "简", "安"}
|
||||
female := []string{"可欣", "清禾", "知夏", "若宁", "语桐", "星遥", "念安", "晚晴", "雨眠", "予柔"}
|
||||
male := []string{"知远", "景行", "沐川", "星河", "予安", "亦辰", "云舟", "修远", "言澈", "嘉树"}
|
||||
given := male
|
||||
if item.Gender == "female" {
|
||||
given = female
|
||||
}
|
||||
return "测试·" + surnames[(item.ID-1)%len(surnames)] + given[((item.ID-1)/len(surnames))%len(given)]
|
||||
}
|
||||
|
||||
func createUsers(ctx context.Context, db *sql.DB, records []manifestRecord, plans []assetPlan, confirmDatabase string) (created, skipped int, ids []int64, err error) {
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
var database string
|
||||
if err = conn.QueryRowContext(ctx, `SELECT DATABASE()`).Scan(&database); err != nil || confirmDatabase == "" || database != confirmDatabase {
|
||||
return 0, 0, nil, errors.New("database confirmation does not match connected database")
|
||||
}
|
||||
var locked int
|
||||
if err = conn.QueryRowContext(ctx, `SELECT GET_LOCK(?,10)`, "xingyu:testusers:"+batchName).Scan(&locked); err != nil || locked != 1 {
|
||||
return 0, 0, nil, errors.New("could not acquire generated test-user batch lock")
|
||||
}
|
||||
defer func() {
|
||||
_, _ = conn.ExecContext(context.Background(), `DO RELEASE_LOCK(?)`, "xingyu:testusers:"+batchName)
|
||||
}()
|
||||
tx, err := conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var batchCount int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE test_batch=?`, batchName).Scan(&batchCount); err != nil {
|
||||
return 0, 0, nil, errors.New("test-user schema unavailable; migration 028 is required")
|
||||
}
|
||||
if batchCount != 0 && batchCount != recordCount {
|
||||
return 0, 0, nil, fmt.Errorf("batch has %d users; refusing to modify partial data", batchCount)
|
||||
}
|
||||
jobs := []string{"设计师", "工程师", "教师", "摄影师", "产品经理", "运营", "编辑", "自由职业"}
|
||||
hobbies := []string{"城市漫步", "阅读与咖啡", "跑步与音乐", "旅行与美食", "电影与绘画", "徒步与摄影"}
|
||||
now := time.Now()
|
||||
ids = make([]int64, 0, recordCount)
|
||||
for index, item := range records {
|
||||
publicID := fmt.Sprintf("TESTIM%06d", item.ID)
|
||||
gender := 1
|
||||
if item.Gender == "female" {
|
||||
gender = 2
|
||||
}
|
||||
var id int64
|
||||
var existingTest bool
|
||||
var existingBatch string
|
||||
var deleted sql.NullTime
|
||||
queryErr := tx.QueryRowContext(ctx, `SELECT id,is_test,test_batch,deleted_at FROM users WHERE public_id=? FOR UPDATE`, publicID).Scan(&id, &existingTest, &existingBatch, &deleted)
|
||||
if queryErr == nil {
|
||||
if batchCount != recordCount || !existingTest || existingBatch != batchName || deleted.Valid {
|
||||
return 0, 0, nil, fmt.Errorf("public ID %s conflicts with an existing user", publicID)
|
||||
}
|
||||
var actualGender int
|
||||
var actualAvatar string
|
||||
if err = tx.QueryRowContext(ctx, `SELECT gender,avatar_url FROM user_profiles WHERE user_id=?`, id).Scan(&actualGender, &actualAvatar); err != nil || actualGender != gender || actualAvatar != plans[index].DisplayURL {
|
||||
return 0, 0, nil, fmt.Errorf("existing generated profile %s was modified or is incomplete", publicID)
|
||||
}
|
||||
skipped++
|
||||
} else if queryErr == sql.ErrNoRows && batchCount == 0 {
|
||||
result, insertErr := tx.ExecContext(ctx, `INSERT INTO users(public_id,password_hash,is_test,test_batch,status) VALUES(?,'!GENERATED_TEST_ACCOUNT_NO_LOGIN',1,?,1)`, publicID, batchName)
|
||||
if insertErr != nil {
|
||||
return 0, 0, nil, insertErr
|
||||
}
|
||||
id, err = result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
place := cities[(item.ID-1)%len(cities)]
|
||||
birthday := now.AddDate(-item.Age, 0, -(item.ID%180 + 1)).Format("2006-01-02")
|
||||
height := 168 + item.ID%12
|
||||
if gender == 2 {
|
||||
height = 158 + item.ID%11
|
||||
}
|
||||
bio := "【测试数据】虚构资料与AI生成头像,仅用于产品功能测试,非真实用户。兴趣示例:" + hobbies[(item.ID-1)%len(hobbies)] + "。"
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO user_profiles(user_id,nickname,avatar_url,cover_url,gender,birthday,height_cm,city_code,city_name,occupation,bio,profile_score,last_active_at) VALUES(?,?,?,'',?,?,?,?,?,?,?,80,NOW(3))`, id, nickname(item), plans[index].DisplayURL, gender, birthday, height, place.Code, place.Name, jobs[(item.ID-1)%len(jobs)], bio)
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO user_privacy_settings(user_id,nearby_visible,distance_visible,online_visible,last_active_visible,allow_stranger_message,allow_profile_visit_record,allow_search) VALUES(?,1,1,1,0,1,0,1)`, id)
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
lat := place.Lat + float64((item.ID%7)-3)*0.003
|
||||
lng := place.Lng + float64((item.ID%9)-4)*0.003
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO user_location_states(user_id,city_code,location_cell,latitude,longitude,source) VALUES(?,?,'fixture',?,?,'fixture')`, id, place.Code, lat, lng)
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
created++
|
||||
} else {
|
||||
if queryErr == sql.ErrNoRows {
|
||||
queryErr = errors.New("batch identity mismatch")
|
||||
}
|
||||
return 0, 0, nil, queryErr
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, 0, nil, errors.New("user transaction commit result uncertain; verify database before retry")
|
||||
}
|
||||
return created, skipped, ids, nil
|
||||
}
|
||||
|
||||
func run() error {
|
||||
apply := flag.Bool("apply", false, "upload objects and create the test-user batch")
|
||||
confirmDB := flag.String("confirm-database", "", "exact production database name")
|
||||
confirmBucket := flag.String("confirm-bucket", "", "exact configured COS bucket")
|
||||
manifestFile := flag.String("manifest", "", "absolute path to manifest.json")
|
||||
avatarsDir := flag.String("avatars-dir", "", "absolute directory containing generated PNG files")
|
||||
backupDir := flag.String("backup-dir", "", "new scoped backup directory under /www/backup; required with --apply")
|
||||
flag.Parse()
|
||||
if os.Getenv("IM_ENV") != "production" {
|
||||
return errors.New("this scoped importer requires IM_ENV=production")
|
||||
}
|
||||
if !filepath.IsAbs(*manifestFile) || !filepath.IsAbs(*avatarsDir) {
|
||||
return errors.New("manifest and avatars directory must be absolute paths")
|
||||
}
|
||||
dsn := os.Getenv("IM_DB_DSN")
|
||||
dsnConfig, err := mysql.ParseDSN(dsn)
|
||||
if err != nil || dsn == "" || *confirmDB != "xim" || dsnConfig.DBName != *confirmDB || dsnConfig.Net != "tcp" || dsnConfig.Addr != "127.0.0.1:3306" {
|
||||
return errors.New("database target confirmation failed")
|
||||
}
|
||||
dsnConfig.ParseTime = true
|
||||
db, err := sql.Open("mysql", dsnConfig.FormatDSN())
|
||||
if err != nil {
|
||||
return errors.New("cannot open database")
|
||||
}
|
||||
defer db.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Minute)
|
||||
defer cancel()
|
||||
if err = db.PingContext(ctx); err != nil {
|
||||
return errors.New("database connection failed")
|
||||
}
|
||||
settings := map[string]string{}
|
||||
rows, err := db.QueryContext(ctx, `SELECT config_key,config_value,value_type FROM system_configs WHERE config_key LIKE 'storage.tencent_cos.%' OR config_key IN ('storage.provider','storage.object_prefix')`)
|
||||
if err != nil {
|
||||
return errors.New("cannot read storage configuration")
|
||||
}
|
||||
for rows.Next() {
|
||||
var key, value, kind string
|
||||
if err = rows.Scan(&key, &value, &kind); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if kind == "secret" {
|
||||
value, err = decrypt(value, os.Getenv("IM_CONFIG_ENCRYPTION_KEY"), os.Getenv("IM_JWT_SECRET"))
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
settings[key] = strings.TrimSpace(value)
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bucket := settings["storage.tencent_cos.bucket"]
|
||||
if settings["storage.provider"] != "tencent_cos" || bucket == "" || bucket != *confirmBucket {
|
||||
return errors.New("configured storage provider/bucket does not match confirmation")
|
||||
}
|
||||
endpoint, err := validHTTPS(settings["storage.tencent_cos.endpoint"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if endpoint.Port() != "" || !regexp.MustCompile(`^`+regexp.QuoteMeta(bucket)+`\.cos\.[a-z0-9-]+\.myqcloud\.com$`).MatchString(endpoint.Hostname()) || strings.Trim(endpoint.Path, "/") != "" {
|
||||
return errors.New("authenticated endpoint is not the configured Tencent COS bucket hostname")
|
||||
}
|
||||
if settings["storage.tencent_cos.secret_id"] == "" || settings["storage.tencent_cos.secret_key"] == "" {
|
||||
return errors.New("COS credentials are not configured")
|
||||
}
|
||||
records, err := loadManifest(*manifestFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plans, err := planAssets(records, *avatarsDir, settings["storage.object_prefix"], settings["storage.tencent_cos.public_base_url"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
female := 0
|
||||
for _, item := range records {
|
||||
if item.Gender == "female" {
|
||||
female++
|
||||
}
|
||||
}
|
||||
if !*apply {
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]any{"dryRun": true, "batch": batchName, "bucket": bucket, "users": len(records), "female": female, "male": len(records) - female, "objects": len(plans) * 3})
|
||||
}
|
||||
cleanBackup := filepath.Clean(*backupDir)
|
||||
if filepath.Dir(cleanBackup) != "/www/backup" || !strings.HasPrefix(filepath.Base(cleanBackup), "xim-generated-users-") {
|
||||
return errors.New("backup directory must be a new /www/backup/xim-generated-users-* directory")
|
||||
}
|
||||
if err = os.Mkdir(cleanBackup, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = writeJSON(filepath.Join(cleanBackup, "plan.json"), map[string]any{"batch": batchName, "bucket": bucket, "records": records, "objects": plans}); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("BACKUP=" + cleanBackup)
|
||||
noRedirect := func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
public := &http.Client{Timeout: 60 * time.Second, CheckRedirect: noRedirect}
|
||||
endpoint.Path = ""
|
||||
cosClient := cos.NewClient(&cos.BaseURL{BucketURL: endpoint}, &http.Client{Timeout: 90 * time.Second, CheckRedirect: noRedirect, Transport: &cos.AuthorizationTransport{SecretID: settings["storage.tencent_cos.secret_id"], SecretKey: settings["storage.tencent_cos.secret_key"]}})
|
||||
uploads := make([]uploadResult, 0, len(plans))
|
||||
for _, plan := range plans {
|
||||
original, display, thumbnail, renderErr := renderVariants(filepath.Join(*avatarsDir, plan.File))
|
||||
if renderErr != nil {
|
||||
return fmt.Errorf("%s: cannot render avatar variants; database unchanged", plan.File)
|
||||
}
|
||||
originalUploaded, uploadErr := uploadObject(ctx, cosClient, public, plan.OriginalKey, plan.OriginalURL, "image/png", original)
|
||||
if uploadErr != nil {
|
||||
return fmt.Errorf("%s original: %w; database unchanged", plan.File, uploadErr)
|
||||
}
|
||||
displayUploaded, uploadErr := uploadObject(ctx, cosClient, public, plan.DisplayKey, plan.DisplayURL, "image/jpeg", display)
|
||||
if uploadErr != nil {
|
||||
return fmt.Errorf("%s display: %w; database unchanged", plan.File, uploadErr)
|
||||
}
|
||||
thumbUploaded, uploadErr := uploadObject(ctx, cosClient, public, plan.ThumbnailKey, plan.ThumbnailURL, "image/jpeg", thumbnail)
|
||||
if uploadErr != nil {
|
||||
return fmt.Errorf("%s thumbnail: %w; database unchanged", plan.File, uploadErr)
|
||||
}
|
||||
uploads = append(uploads, uploadResult{originalUploaded, displayUploaded, thumbUploaded})
|
||||
fmt.Printf("VERIFIED %03d/100 original=%t display=%t thumbnail=%t\n", plan.ID, originalUploaded, displayUploaded, thumbUploaded)
|
||||
}
|
||||
if err = writeJSON(filepath.Join(cleanBackup, "uploaded.json"), map[string]any{"plans": plans, "results": uploads}); err != nil {
|
||||
return err
|
||||
}
|
||||
created, skipped, ids, err := createUsers(ctx, db, records, plans, *confirmDB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := map[string]any{"batch": batchName, "bucket": bucket, "created": created, "skipped": skipped, "female": female, "male": len(records) - female, "objectsVerified": len(plans) * 3, "ids": ids, "backup": cleanBackup, "loginDisabled": true, "globalConfigUnchanged": true}
|
||||
if err = writeJSON(filepath.Join(cleanBackup, "result.json"), result); err != nil {
|
||||
return errors.New("users committed but result record could not be written; inspect database")
|
||||
}
|
||||
return json.NewEncoder(os.Stdout).Encode(result)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user