This commit is contained in:
Your Name
2026-09-03 08:38:17 +08:00
parent 6cd4f1b1db
commit 842990b0e7
1853 changed files with 278406 additions and 361 deletions
@@ -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)
}
}
@@ -0,0 +1,421 @@
// Migrate only the explicitly labelled fixture batch to the configured COS bucket.
// This command never deletes files, changes bucket ACLs, or updates global config.
package main
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"image/jpeg"
"image/png"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/disintegration/imaging"
"github.com/example/xingyu/internal/testusers"
"github.com/go-sql-driver/mysql"
cos "github.com/tencentyun/cos-go-sdk-v5"
)
const oldBase = "https://im.bchongw.com/uploads"
type asset struct {
File string `json:"file"`
Key string `json:"key"`
URL string `json:"url"`
SHA256 string `json:"sha256"`
Data []byte `json:"-"`
ThumbnailKey string `json:"thumbnailKey"`
ThumbnailURL string `json:"thumbnailUrl"`
ThumbnailData []byte `json:"-"`
Uploaded bool `json:"uploaded"`
ThumbnailUploaded bool `json:"thumbnailUploaded"`
PublicReadSet bool `json:"publicReadSet"`
ThumbnailPublicReadSet bool `json:"thumbnailPublicReadSet"`
}
type replacement struct {
ID int64 `json:"id"`
PublicID string `json:"publicId"`
OldURL string `json:"oldUrl"`
NewURL string `json:"newUrl"`
}
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")
}
keys := []string{configKey, jwtKey + ":integration-config"}
for _, raw := range keys {
if raw == "" {
continue
}
key := sha256.Sum256([]byte(raw))
block, _ := aes.NewCipher(key[:])
gcm, _ := cipher.NewGCM(block)
if len(payload) < gcm.NonceSize() {
continue
}
plain, err := gcm.Open(nil, payload[:gcm.NonceSize()], payload[gcm.NonceSize():], nil)
if err == 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 an HTTPS URL without credentials or query parameters")
}
return u, nil
}
func loadAssets(directory, prefix, base string) ([]*asset, error) {
if !filepath.IsAbs(directory) {
return nil, errors.New("media 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(base); err != nil {
return nil, err
}
assets := []*asset{}
for _, name := range testusers.Avatars() {
file := filepath.Join(directory, name)
info, err := os.Lstat(file)
if err != nil || !info.Mode().IsRegular() || info.Size() > 10<<20 {
return nil, fmt.Errorf("missing/invalid local avatar: %s", name)
}
data, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("cannot read local avatar: %s", name)
}
cfg, err := png.DecodeConfig(bytes.NewReader(data))
if err != nil || cfg.Width < 256 || cfg.Height < 256 {
return nil, fmt.Errorf("invalid PNG: %s", name)
}
hash := sha256.Sum256(data)
digest := hex.EncodeToString(hash[:])
key := path.Join(prefix, "test-users", testusers.Batch, digest[:20]+"-"+name)
decoded, err := png.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("cannot decode PNG: %s", name)
}
thumbnail := imaging.Fit(decoded, 256, 256, imaging.Lanczos)
var thumbnailData bytes.Buffer
if err = jpeg.Encode(&thumbnailData, thumbnail, &jpeg.Options{Quality: 78}); err != nil {
return nil, fmt.Errorf("cannot encode thumbnail: %s", name)
}
thumbnailKey := strings.TrimSuffix(key, path.Ext(key)) + "-thumb.jpg"
base = strings.TrimRight(base, "/")
assets = append(assets, &asset{File: name, Key: key, URL: base + "/" + key, SHA256: digest, Data: data, ThumbnailKey: thumbnailKey, ThumbnailURL: base + "/" + thumbnailKey, ThumbnailData: thumbnailData.Bytes()})
}
return assets, nil
}
func planRows(ctx context.Context, db *sql.DB, assets []*asset) ([]replacement, error) {
fixtures, _ := testusers.Generate(oldBase)
byPublicID := map[string]string{}
byFile := map[string]*asset{}
for _, a := range assets {
byFile[a.File] = a
}
for _, p := range fixtures {
byPublicID[p.PublicID] = p.AvatarFile
}
rows, err := db.QueryContext(ctx, `SELECT u.id,u.public_id,p.avatar_url FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.is_test=1 AND u.test_batch=? AND u.deleted_at IS NULL ORDER BY u.id`, testusers.Batch)
if err != nil {
return nil, errors.New("cannot load fixture profiles")
}
defer rows.Close()
plan := []replacement{}
for rows.Next() {
var r replacement
if err = rows.Scan(&r.ID, &r.PublicID, &r.OldURL); err != nil {
return nil, err
}
a := byFile[byPublicID[r.PublicID]]
if a == nil {
return nil, errors.New("fixture identity differs from expected batch")
}
r.NewURL = a.URL
if r.OldURL != oldBase+"/"+a.File && r.OldURL != r.NewURL {
return nil, fmt.Errorf("profile %d has an edited avatar; refusing to overwrite it", r.ID)
}
plan = append(plan, r)
}
if err = rows.Err(); err != nil {
return nil, err
}
if len(plan) != testusers.Count {
return nil, fmt.Errorf("expected 100 fixture profiles; found %d", len(plan))
}
return plan, nil
}
// Verify bytes anonymously, without signed query parameters or Authorization.
func publicObjectCheck(ctx context.Context, client *http.Client, objectURL string, data []byte, contentType string) (int, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, objectURL, nil)
res, err := client.Do(req)
if err != nil {
return 0, errors.New("COS public URL request failed")
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return res.StatusCode, fmt.Errorf("COS public URL returned HTTP %d", res.StatusCode)
}
actual, err := io.ReadAll(io.LimitReader(res.Body, int64(len(data))+1))
if err != nil || !bytes.Equal(actual, data) || !strings.HasPrefix(res.Header.Get("Content-Type"), contentType) {
return res.StatusCode, errors.New("COS public image content differs from generated asset")
}
return res.StatusCode, nil
}
func publicCheck(ctx context.Context, client *http.Client, a *asset) (int, error) {
return publicObjectCheck(ctx, client, a.URL, a.Data, "image/png")
}
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 and request headers not logged)", operation)
}
func uploadObject(ctx context.Context, c *cos.Client, public *http.Client, key, objectURL, contentType string, data []byte, uploaded, publicReadSet *bool) error {
// Never replace a different existing object, even under this batch prefix.
res, err := c.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 errors.New("existing COS object differs; no overwrite performed")
}
} else {
var apiErr *cos.ErrorResponse
if !errors.As(err, &apiErr) || apiErr.Code != "NoSuchKey" {
return cosFailure("lookup", err)
}
headers := http.Header{}
headers.Set("x-cos-forbid-overwrite", "true")
_, err = c.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 cosFailure("upload", err)
}
*uploaded = true
}
status, err := publicObjectCheck(ctx, public, objectURL, data, contentType)
if err != nil && status == http.StatusForbidden && *uploaded {
// Only these newly uploaded, explicitly public test avatars may receive
// object-level public read. Never change the bucket or existing object ACLs.
_, aclErr := c.Object.PutACL(ctx, key, &cos.ObjectPutACLOptions{Header: &cos.ACLHeaderOptions{XCosACL: "public-read"}})
if aclErr != nil {
return cosFailure("test-avatar public-read ACL", aclErr)
}
*publicReadSet = true
_, err = publicObjectCheck(ctx, public, objectURL, data, contentType)
}
return err
}
func upload(ctx context.Context, c *cos.Client, public *http.Client, a *asset) error {
if err := uploadObject(ctx, c, public, a.Key, a.URL, "image/png", a.Data, &a.Uploaded, &a.PublicReadSet); err != nil {
return err
}
if len(a.ThumbnailData) == 0 {
return nil
}
return uploadObject(ctx, c, public, a.ThumbnailKey, a.ThumbnailURL, "image/jpeg", a.ThumbnailData, &a.ThumbnailUploaded, &a.ThumbnailPublicReadSet)
}
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 run() error {
apply := flag.Bool("apply", false, "upload objects and update fixture links; default is read-only")
confirmDB := flag.String("confirm-database", "", "exact production database name")
confirmBucket := flag.String("confirm-bucket", "", "exact configured COS bucket")
media := flag.String("media-dir", "", "absolute directory containing the ten original fixture PNGs")
backup := flag.String("backup-dir", "", "new private backup directory under /www/backup, required with --apply")
flag.Parse()
if os.Getenv("IM_ENV") != "production" {
return errors.New("this scoped tool requires IM_ENV=production")
}
dsn := os.Getenv("IM_DB_DSN")
cfg, err := mysql.ParseDSN(dsn)
if err != nil || dsn == "" || *confirmDB != "im" || cfg.DBName != *confirmDB || cfg.Net != "tcp" || cfg.Addr != "127.0.0.1:3307" {
return errors.New("database target confirmation failed")
}
cfg.ParseTime = true
db, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
return errors.New("cannot open database")
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
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 COS 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 COS bucket/provider 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 must be the configured Tencent COS bucket hostname")
}
endpoint.Path = ""
for _, key := range []string{"storage.tencent_cos.secret_id", "storage.tencent_cos.secret_key"} {
if settings[key] == "" {
return errors.New("COS credentials are not configured")
}
}
assets, err := loadAssets(*media, settings["storage.object_prefix"], settings["storage.tencent_cos.public_base_url"])
if err != nil {
return err
}
plan, err := planRows(ctx, db, assets)
if err != nil {
return err
}
if !*apply {
return json.NewEncoder(os.Stdout).Encode(map[string]any{"dryRun": true, "batch": testusers.Batch, "bucket": bucket, "users": len(plan), "objects": assets})
}
if filepath.Dir(filepath.Clean(*backup)) != "/www/backup" || !strings.HasPrefix(filepath.Base(*backup), "xingyu-cos-avatars-") {
return errors.New("backup directory must be a new scoped /www/backup/xingyu-cos-avatars-* directory")
}
if err = os.Mkdir(*backup, 0700); err != nil {
return err
}
if err = writeJSON(filepath.Join(*backup, "before.json"), map[string]any{"batch": testusers.Batch, "bucket": bucket, "replacements": plan, "objects": assets}); err != nil {
return err
}
fmt.Println("BACKUP=" + *backup)
noRedirect := func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
public := &http.Client{Timeout: 45 * time.Second, CheckRedirect: noRedirect}
c := cos.NewClient(&cos.BaseURL{BucketURL: endpoint}, &http.Client{Timeout: 60 * time.Second, CheckRedirect: noRedirect, Transport: &cos.AuthorizationTransport{SecretID: settings["storage.tencent_cos.secret_id"], SecretKey: settings["storage.tencent_cos.secret_key"]}})
for _, a := range assets {
if err = upload(ctx, c, public, a); err != nil {
return fmt.Errorf("%s: %w; database links unchanged", a.File, err)
}
fmt.Printf("VERIFIED %s original_uploaded=%t thumbnail_uploaded=%t\n", a.File, a.Uploaded, a.ThumbnailUploaded)
}
if err = writeJSON(filepath.Join(*backup, "uploaded.json"), assets); err != nil {
return err
}
// Lock and validate all scoped rows before changing any link. Any concurrent
// avatar edit causes rollback rather than overwriting a user's change.
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, r := range plan {
var current string
if err = tx.QueryRowContext(ctx, `SELECT p.avatar_url FROM user_profiles p JOIN users u ON u.id=p.user_id WHERE u.id=? AND u.public_id=? AND u.is_test=1 AND u.test_batch=? AND u.deleted_at IS NULL FOR UPDATE`, r.ID, r.PublicID, testusers.Batch).Scan(&current); err != nil || current != r.OldURL {
return errors.New("fixture avatar changed during upload; no database links updated")
}
}
changed := int64(0)
for _, r := range plan {
if r.OldURL == r.NewURL {
continue
}
res, updateErr := tx.ExecContext(ctx, `UPDATE user_profiles SET avatar_url=? WHERE user_id=? AND avatar_url=?`, r.NewURL, r.ID, r.OldURL)
if updateErr != nil {
return errors.New("avatar link update failed; transaction rolled back")
}
n, _ := res.RowsAffected()
if n != 1 {
return errors.New("avatar update count mismatch; transaction rolled back")
}
changed += n
}
if err = tx.Commit(); err != nil {
return errors.New("avatar transaction commit result uncertain; inspect backup and database before retrying")
}
verified, err := planRows(ctx, db, assets)
if err != nil {
return err
}
for _, r := range verified {
if r.OldURL != r.NewURL {
return errors.New("post-commit verification failed")
}
}
result := map[string]any{"batch": testusers.Batch, "bucket": bucket, "users": len(verified), "updated": changed, "objects": len(assets) * 2, "publicURLsVerified": len(assets) * 2, "backup": *backup, "localOriginalsKept": true, "globalConfigUnchanged": true}
if err = writeJSON(filepath.Join(*backup, "result.json"), result); err != nil {
return errors.New("links 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)
}
}
@@ -0,0 +1,180 @@
package main
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/base64"
"image"
"image/png"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/example/xingyu/internal/testusers"
cos "github.com/tencentyun/cos-go-sdk-v5"
)
func TestEncryptedConfigurationCompatibility(t *testing.T) {
seal := func(key string) string {
digest := sha256.Sum256([]byte(key))
block, _ := aes.NewCipher(digest[:])
gcm, _ := cipher.NewGCM(block)
nonce := make([]byte, gcm.NonceSize())
return "enc:v1:" + base64.RawStdEncoding.EncodeToString(gcm.Seal(nonce, nonce, []byte("test-only-value"), nil))
}
for _, encoded := range []string{seal("config-key"), seal("jwt-key:integration-config")} {
plain, err := decrypt(encoded, "config-key", "jwt-key")
if err != nil || plain != "test-only-value" {
t.Fatal("decryption compatibility failed")
}
}
if _, err := decrypt(seal("unrelated-key"), "config-key", "jwt-key"); err == nil {
t.Fatal("accepted wrong key")
}
if _, err := decrypt("enc:v1:bad!", "config-key", "jwt-key"); err == nil {
t.Fatal("accepted malformed ciphertext")
}
}
func TestAssetsAreScopedAndContentAddressed(t *testing.T) {
dir := t.TempDir()
var buf bytes.Buffer
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 256, 256))); err != nil {
t.Fatal(err)
}
for _, name := range testusers.Avatars() {
if err := os.WriteFile(filepath.Join(dir, name), buf.Bytes(), 0600); err != nil {
t.Fatal(err)
}
}
assets, err := loadAssets(dir, "media", "https://example.cos.ap-guangzhou.myqcloud.com/")
if err != nil || len(assets) != 10 {
t.Fatalf("count=%d err=%v", len(assets), err)
}
seen := map[string]bool{}
for _, a := range assets {
if seen[a.Key] || !strings.HasPrefix(a.Key, "media/test-users/"+testusers.Batch+"/") || !strings.Contains(a.Key, a.SHA256[:20]) {
t.Fatal("unscoped or nonunique object key")
}
if !strings.HasSuffix(a.ThumbnailKey, "-thumb.jpg") || !strings.HasSuffix(a.ThumbnailURL, "-thumb.jpg") || len(a.ThumbnailData) == 0 {
t.Fatal("missing deterministic thumbnail object")
}
config, format, decodeErr := image.DecodeConfig(bytes.NewReader(a.ThumbnailData))
if decodeErr != nil || format != "jpeg" || config.Width > 256 || config.Height > 256 {
t.Fatal("invalid generated thumbnail", decodeErr)
}
seen[a.Key] = true
}
for _, prefix := range []string{"../existing", "/root", "media//test", "media/../test"} {
if _, err := loadAssets(dir, prefix, "https://example.com"); err == nil {
t.Errorf("accepted prefix %q", prefix)
}
}
for _, raw := range []string{"http://example.com", "https://user:pass@example.com", "https://example.com?signature=temporary", "/uploads"} {
if _, err := validHTTPS(raw); err == nil {
t.Errorf("accepted unsafe base %q", raw)
}
}
}
func TestUploadIsVerifiedAndRepeatableWithoutOverwriting(t *testing.T) {
data := []byte("test-png-content")
var stored []byte
puts, aclWrites := 0, 0
publicReadable := true
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/batch/image.png" {
t.Errorf("unexpected path %s", r.URL.Path)
w.WriteHeader(404)
return
}
if r.Method == "PUT" && r.URL.Query().Has("acl") {
aclWrites++
publicReadable = true
w.WriteHeader(200)
return
}
if r.Method == "PUT" {
puts++
if r.Header.Get("x-cos-forbid-overwrite") != "true" {
t.Error("missing overwrite protection")
}
stored, _ = io.ReadAll(r.Body)
w.WriteHeader(200)
return
}
if stored == nil {
w.WriteHeader(404)
_, _ = io.WriteString(w, "<Error><Code>NoSuchKey</Code></Error>")
return
}
if r.Header.Get("Authorization") == "" && !publicReadable {
w.WriteHeader(403)
return
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(stored)
}))
defer server.Close()
u, _ := url.Parse(server.URL)
c := cos.NewClient(&cos.BaseURL{BucketURL: u}, &http.Client{Transport: &cos.AuthorizationTransport{SecretID: "test-id", SecretKey: "test-key"}})
c.Conf.EnableCRC = false
makeAsset := func() *asset {
return &asset{File: "test.png", Key: "batch/image.png", URL: server.URL + "/batch/image.png", Data: data}
}
a := makeAsset()
if err := upload(context.Background(), c, server.Client(), a); err != nil {
t.Fatal(err)
}
if puts != 1 || !a.Uploaded || aclWrites != 0 {
t.Fatal("unexpected new upload state")
}
if err := upload(context.Background(), c, server.Client(), makeAsset()); err != nil {
t.Fatal(err)
}
if puts != 1 {
t.Fatal("repeat upload wrote the object again")
}
stored = []byte("unrelated-object")
if err := upload(context.Background(), c, server.Client(), makeAsset()); err == nil || puts != 1 {
t.Fatal("overwrote a conflicting object")
}
stored = nil
publicReadable = false
a = makeAsset()
if err := upload(context.Background(), c, server.Client(), a); err != nil {
t.Fatal(err)
}
if aclWrites != 1 || !a.PublicReadSet {
t.Fatal("new public avatar ACL was not scoped to the object")
}
}
func TestPublicCheckRejectsUnreachableOrChangedImage(t *testing.T) {
status := http.StatusForbidden
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "" || r.URL.RawQuery != "" {
t.Error("public check must be anonymous and unsigned")
}
w.Header().Set("Content-Type", "image/png")
w.WriteHeader(status)
_, _ = w.Write([]byte("different"))
}))
defer server.Close()
a := &asset{URL: server.URL, Data: []byte("original")}
if code, err := publicCheck(context.Background(), server.Client(), a); err == nil || code != 403 {
t.Fatal("accepted inaccessible image")
}
status = http.StatusOK
if _, err := publicCheck(context.Background(), server.Client(), a); err == nil {
t.Fatal("accepted different image content")
}
}
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"time"
"github.com/example/xingyu/internal/testusers"
"github.com/go-sql-driver/mysql"
)
func main() {
apply := flag.Bool("apply", false, "write the 100-user batch (default: print JSON only)")
base := flag.String("public-base", "", "public media URL, e.g. https://im.bchongw.com/uploads")
confirm := flag.String("confirm-database", "", "exact target database name, required for --apply")
production := flag.Bool("allow-production-test-data", false, "confirm intentional production fixture import")
source := flag.String("avatars-dir", "../fixtures/test-users/avatars", "generated avatar source directory")
media := flag.String("media-dir", "", "actual configured storage.local.directory / IM_MEDIA_DIR")
flag.Parse()
profiles, err := testusers.Generate(*base)
if err != nil {
log.Fatal(err)
}
if !*apply {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err = encoder.Encode(profiles); err != nil {
log.Fatal(err)
}
return
}
if os.Getenv("IM_ENV") != "development" && os.Getenv("IM_ENV") != "test" && !*production {
log.Fatal("set IM_ENV=development/test, or explicitly confirm with --allow-production-test-data")
}
dsn := os.Getenv("IM_DB_DSN")
cfg, err := mysql.ParseDSN(dsn)
if err != nil || dsn == "" || *confirm == "" || cfg.DBName != *confirm {
log.Fatal("set IM_DB_DSN and matching --confirm-database; no default database is used")
}
cfg.ParseTime = true
db, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
log.Fatal("cannot open database")
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err = db.PingContext(ctx); err != nil {
log.Fatal("database connection failed (credentials not logged)")
}
if err = testusers.CopyAvatars(*source, *media); err != nil {
log.Fatal(err)
}
result, err := testusers.Seed(ctx, db, *base, *confirm)
if err != nil {
log.Fatal(err)
}
data, _ := json.MarshalIndent(result, "", " ")
fmt.Println(string(data))
}