Files
FireflyGo_Proxy/script/publish/publish.go
T
2026-06-12 10:31:40 +07:00

706 lines
20 KiB
Go

package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
apiRequestTimeout = 45 * time.Second
maxHTTPAttempts = 5
)
// API responses structures
type CommonResponse struct {
Status bool `json:"status"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
Errors any `json:"errors"`
}
type AuthResponse struct {
AccessToken string `json:"access_token"`
}
type PreSignedResponse struct {
TokenID string `json:"token_id"`
UploadUrl string `json:"upload_url"`
StorageKey string `json:"storage_key"`
SignedHeaders map[string]string `json:"signed_headers"`
}
type MediaResponse struct {
ID string `json:"id"`
StorageKey string `json:"storage_key"`
OriginalName string `json:"original_name"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
}
type PreSignedCompleteDto struct {
TokenID string `json:"token_id"`
FileMetadata json.RawMessage `json:"file_metadata,omitempty"`
}
type CreateComponentRequest struct {
Type string `json:"type"`
Platform string `json:"platform"`
Status string `json:"status"`
Version string `json:"version"`
Description *string `json:"description,omitempty"`
Hash *string `json:"hash,omitempty"`
MediaIDs []string `json:"media_ids"`
GameIDs []string `json:"game_ids"`
}
type UpdateComponentRequest struct {
Type *string `json:"type,omitempty"`
Platform *string `json:"platform,omitempty"`
Status *string `json:"status,omitempty"`
Version *string `json:"version,omitempty"`
Description *string `json:"description,omitempty"`
Hash *string `json:"hash,omitempty"`
MediaIDs []string `json:"media_ids,omitempty"`
GameIDs []string `json:"game_ids,omitempty"`
}
func readFile(path string) string {
data, err := os.ReadFile(path)
if err != nil {
panic(fmt.Sprintf("Failed to read %s: %v", path, err))
}
return string(data)
}
func main() {
// Flag definitions (useful for manual testing, but optional in CI/CD)
apiURLFlag := flag.String("api-url", "https://api.punklorde.org", "Base URL of the management API")
gameIdsFlag := flag.String("game-ids", "", "Comma-separated Game IDs (defaults to ENV_GAME_IDS)")
filesFlag := flag.String("files", "", "Comma-separated list of files to upload (defaults to scanning prebuild/)")
cTypeFlag := flag.String("type", "", "Component type: LAUNCHER, PROXY, SERVER (defaults to ENV_COMPONENT_TYPE or PROXY)")
flag.Parse()
// 1. Resolve settings from Env or Flags
apiURL := *apiURLFlag
if envAPI := os.Getenv("ENV_API_URL"); envAPI != "" {
apiURL = envAPI
}
gameIdsStr := *gameIdsFlag
if gameIdsStr == "" {
gameIdsStr = os.Getenv("ENV_GAME_IDS")
}
if gameIdsStr == "" {
fmt.Fprintln(os.Stderr, "Error: game IDs must be specified via -game-ids flag or ENV_GAME_IDS environment variable")
os.Exit(1)
}
gameIDs := splitCommaSeparated(gameIdsStr)
cType := *cTypeFlag
if cType == "" {
cType = os.Getenv("ENV_COMPONENT_TYPE")
}
if cType == "" {
cType = "PROXY" // Default component type
}
// 2. Read release metadata from files
releaseJSON := readFile("script/release.json")
var meta map[string]string
if err := json.Unmarshal([]byte(releaseJSON), &meta); err != nil {
fmt.Fprintf(os.Stderr, "Failed to parse release.json: %v\n", err)
os.Exit(1)
}
version := meta["tag"]
if version == "" {
fmt.Fprintln(os.Stderr, "Error: 'tag' is missing in release.json")
os.Exit(1)
}
var description *string
if bodyBytes, err := os.ReadFile("script/README_Note.md"); err == nil && len(bodyBytes) > 0 {
descStr := string(bodyBytes)
description = &descStr
}
// 3. Read robot token from environment
robotToken := os.Getenv("ENV_ROBOT_TOKEN")
if robotToken == "" {
fmt.Fprintln(os.Stderr, "Error: ENV_ROBOT_TOKEN environment variable is missing")
os.Exit(1)
}
fmt.Println("Refreshing access token using robot token...")
accessToken, err := refreshRobotToken(apiURL, robotToken)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to refresh token: %v\n", err)
os.Exit(1)
}
fmt.Println("Access token successfully obtained.")
// 4. Resolve files to publish
var filePaths []string
filesStr := *filesFlag
if filesStr == "" {
filesStr = os.Getenv("ENV_FILES")
}
if filesStr != "" {
filePaths = splitCommaSeparated(filesStr)
} else {
// Fallback to scanning prebuild/
prebuildDir := "prebuild"
files, err := os.ReadDir(prebuildDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot read prebuild folder: %v\n", err)
os.Exit(1)
}
for _, file := range files {
if !file.IsDir() && filepath.Ext(file.Name()) == ".zip" {
filePaths = append(filePaths, filepath.Join(prebuildDir, file.Name()))
}
}
}
var processedCount int
for _, filePath := range filePaths {
fileName := filepath.Base(filePath)
fmt.Printf("\n--- Processing asset: %s ---\n", fileName)
// Map filename to platform
platform := detectPlatformFromFilename(fileName)
fmt.Printf("Mapped Platform: %s\n", platform)
// Calculate size and SHA256
size, hash, err := getFileInfoAndHash(filePath)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to process file %s: %v\n", fileName, err)
os.Exit(1)
}
fmt.Printf("Size: %d bytes, SHA256: %s\n", size, hash)
// Request presigned URL
contentType := "application/octet-stream"
if filepath.Ext(fileName) == ".zip" {
contentType = "application/zip"
}
fmt.Println("Requesting presigned URL...")
presigned, err := getPresignedURL(apiURL, accessToken, fileName, contentType, size)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to get presigned URL: %v\n", err)
os.Exit(1)
}
// Upload file to S3
fmt.Println("Uploading file to storage...")
err = uploadFileToS3(filePath, presigned)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to upload file to storage: %v\n", err)
os.Exit(1)
}
// Confirm upload completion
fmt.Println("Confirming upload completion...")
mediaID, err := completePreSignedUpload(apiURL, accessToken, presigned.TokenID, hash)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to complete upload: %v\n", err)
os.Exit(1)
}
fmt.Printf("Media ID generated: %s\n", mediaID)
fmt.Println("Checking if component already exists...")
existingID, err := findExistingComponent(apiURL, accessToken, cType, platform, version)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to check existing component: %v\n", err)
}
var compID string
if existingID != "" {
fmt.Printf("Component already exists with ID: %s. Updating it...\n", existingID)
updateReq := UpdateComponentRequest{
Type: &cType,
Platform: &platform,
Description: description,
Hash: &hash,
MediaIDs: []string{mediaID},
GameIDs: gameIDs,
}
compID, err = updateComponent(apiURL, accessToken, existingID, updateReq)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to update component: %v\n", err)
os.Exit(1)
}
fmt.Printf("SUCCESS! Component updated with ID: %s for Platform: %s\n", compID, platform)
} else {
fmt.Println("Component does not exist. Registering on the API...")
reqBody := CreateComponentRequest{
Type: cType,
Platform: platform,
Status: "ACTIVE",
Version: version,
Description: description,
Hash: &hash,
MediaIDs: []string{mediaID},
GameIDs: gameIDs,
}
compID, err = createComponent(apiURL, accessToken, reqBody)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to register component: %v\n", err)
os.Exit(1)
}
fmt.Printf("SUCCESS! Component created with ID: %s for Platform: %s\n", compID, platform)
}
processedCount++
}
if processedCount == 0 {
fmt.Println("No component files found to publish.")
} else {
fmt.Printf("\nAll %d components successfully published to API.\n", processedCount)
}
}
func detectPlatformFromFilename(name string) string {
nameLower := strings.ToLower(name)
if strings.Contains(nameLower, "mac_arm") || strings.Contains(nameLower, "macos-arm64") {
return "MACOS_ARM64"
}
if strings.Contains(nameLower, "mac_x86") || strings.Contains(nameLower, "macos-amd64") {
return "MACOS_X64"
}
if strings.Contains(nameLower, "win_arm") || strings.Contains(nameLower, "win-arm") || strings.Contains(nameLower, "windows-arm") {
return "WINDOWS_ARM64"
}
if strings.Contains(nameLower, "win_x86") || strings.Contains(nameLower, "win_x64") || strings.Contains(nameLower, "win") {
return "WINDOWS_X64"
}
if strings.Contains(nameLower, "android_arm64") {
return "ANDROID_ARM64"
}
if strings.Contains(nameLower, "linux_x64") || strings.Contains(nameLower, "linux-amd64") || strings.Contains(nameLower, "linux-x64") {
return "LINUX_X64"
}
if strings.Contains(nameLower, "linux_arm64") || strings.Contains(nameLower, "linux-arm64") {
return "LINUX_ARM64"
}
return "WINDOWS_X64" // Fallback default
}
func splitCommaSeparated(s string) []string {
parts := strings.Split(s, ",")
var result []string
for _, p := range parts {
trimmed := strings.TrimSpace(p)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
func getFileInfoAndHash(path string) (int64, string, error) {
file, err := os.Open(path)
if err != nil {
return 0, "", err
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
return 0, "", err
}
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return 0, "", err
}
return stat.Size(), hex.EncodeToString(hasher.Sum(nil)), nil
}
func apiEndpoint(apiURL, path string) string {
return strings.TrimRight(apiURL, "/") + path
}
func retryDelay(attempt int) time.Duration {
delay := time.Duration(1<<uint(attempt-1)) * time.Second
if delay > 20*time.Second {
return 20 * time.Second
}
return delay
}
func isRetryableStatus(statusCode int) bool {
return statusCode == http.StatusRequestTimeout ||
statusCode == http.StatusTooEarly ||
statusCode == http.StatusTooManyRequests ||
statusCode >= http.StatusInternalServerError
}
func isAccepted(statusCode int, accepted ...int) bool {
for _, code := range accepted {
if statusCode == code {
return true
}
}
return false
}
func doHTTPWithRetry(label string, client *http.Client, buildRequest func() (*http.Request, error), accepted ...int) ([]byte, int, error) {
var lastErr error
for attempt := 1; attempt <= maxHTTPAttempts; attempt++ {
req, err := buildRequest()
if err != nil {
return nil, 0, err
}
resp, err := client.Do(req)
if err != nil {
lastErr = err
if attempt < maxHTTPAttempts {
delay := retryDelay(attempt)
fmt.Fprintf(os.Stderr, "%s attempt %d/%d failed: %v. Retrying in %s...\n", label, attempt, maxHTTPAttempts, err, delay)
time.Sleep(delay)
continue
}
return nil, 0, err
}
bodyBytes, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if readErr != nil {
return nil, resp.StatusCode, readErr
}
if isAccepted(resp.StatusCode, accepted...) {
return bodyBytes, resp.StatusCode, nil
}
if !isRetryableStatus(resp.StatusCode) || attempt == maxHTTPAttempts {
return bodyBytes, resp.StatusCode, nil
}
lastErr = fmt.Errorf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
delay := retryDelay(attempt)
fmt.Fprintf(os.Stderr, "%s attempt %d/%d returned %v. Retrying in %s...\n", label, attempt, maxHTTPAttempts, lastErr, delay)
time.Sleep(delay)
}
return nil, 0, lastErr
}
func refreshRobotToken(apiURL, robotToken string) (string, error) {
endpoint := apiEndpoint(apiURL, "/robot-tokens/refresh")
client := &http.Client{Timeout: apiRequestTimeout}
bodyBytes, statusCode, err := doHTTPWithRetry("refresh robot token", client, func() (*http.Request, error) {
req, err := http.NewRequest("POST", endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+robotToken)
return req, nil
}, http.StatusOK)
if err != nil {
return "", err
}
if statusCode != http.StatusOK {
return "", fmt.Errorf("refresh token failed with status %d: %s", statusCode, string(bodyBytes))
}
var cr CommonResponse
if err := json.Unmarshal(bodyBytes, &cr); err != nil {
return "", fmt.Errorf("failed to parse common response: %w", err)
}
if !cr.Status {
return "", fmt.Errorf("API error: %s", cr.Message)
}
var auth AuthResponse
if err := json.Unmarshal(cr.Data, &auth); err != nil {
return "", fmt.Errorf("failed to parse auth response data: %w", err)
}
return auth.AccessToken, nil
}
func getPresignedURL(apiURL, accessToken, fileName, contentType string, size int64) (*PreSignedResponse, error) {
endpoint, err := url.Parse(apiEndpoint(apiURL, "/media/presigned"))
if err != nil {
return nil, err
}
query := endpoint.Query()
query.Set("fileName", fileName)
query.Set("content_type", contentType)
query.Set("size", strconv.FormatInt(size, 10))
endpoint.RawQuery = query.Encode()
client := &http.Client{Timeout: apiRequestTimeout}
bodyBytes, statusCode, err := doHTTPWithRetry("get presigned URL", client, func() (*http.Request, error) {
req, err := http.NewRequest("GET", endpoint.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
return req, nil
}, http.StatusOK)
if err != nil {
return nil, err
}
if statusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get presigned URL (status %d): %s", statusCode, string(bodyBytes))
}
var cr CommonResponse
if err := json.Unmarshal(bodyBytes, &cr); err != nil {
return nil, fmt.Errorf("failed to parse common response: %w", err)
}
if !cr.Status {
return nil, fmt.Errorf("API error: %s", cr.Message)
}
var presigned PreSignedResponse
if err := json.Unmarshal(cr.Data, &presigned); err != nil {
return nil, fmt.Errorf("failed to parse presigned data: %w", err)
}
return &presigned, nil
}
func uploadFileToS3(path string, presigned *PreSignedResponse) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
return err
}
req, err := http.NewRequest("PUT", presigned.UploadUrl, file)
if err != nil {
return err
}
// Set S3 signature headers
for k, v := range presigned.SignedHeaders {
req.Header.Set(k, v)
}
req.ContentLength = stat.Size()
if req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/zip")
}
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("S3 upload failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
return nil
}
func completePreSignedUpload(apiURL, accessToken, tokenID, hash string) (string, error) {
endpoint := apiEndpoint(apiURL, "/media/presigned/complete")
metaJSON, _ := json.Marshal(map[string]string{"sha256": hash})
dto := PreSignedCompleteDto{
TokenID: tokenID,
FileMetadata: json.RawMessage(metaJSON),
}
payload, _ := json.Marshal(dto)
client := &http.Client{Timeout: apiRequestTimeout}
bodyBytes, statusCode, err := doHTTPWithRetry("complete presigned upload", client, func() (*http.Request, error) {
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
return req, nil
}, http.StatusOK)
if err != nil {
return "", err
}
if statusCode != http.StatusOK {
return "", fmt.Errorf("complete upload failed (status %d): %s", statusCode, string(bodyBytes))
}
var cr CommonResponse
if err := json.Unmarshal(bodyBytes, &cr); err != nil {
return "", fmt.Errorf("failed to parse common response: %w", err)
}
if !cr.Status {
return "", fmt.Errorf("API error: %s", cr.Message)
}
var media MediaResponse
if err := json.Unmarshal(cr.Data, &media); err != nil {
return "", fmt.Errorf("failed to parse media data: %w", err)
}
return media.ID, nil
}
func createComponent(apiURL, accessToken string, dto CreateComponentRequest) (string, error) {
endpoint := apiEndpoint(apiURL, "/components")
payload, _ := json.Marshal(dto)
client := &http.Client{Timeout: apiRequestTimeout}
bodyBytes, statusCode, err := doHTTPWithRetry("create component", client, func() (*http.Request, error) {
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
return req, nil
}, http.StatusCreated, http.StatusOK)
if err != nil {
return "", err
}
if statusCode != http.StatusCreated && statusCode != http.StatusOK {
return "", fmt.Errorf("create component failed (status %d): %s", statusCode, string(bodyBytes))
}
var cr CommonResponse
if err := json.Unmarshal(bodyBytes, &cr); err != nil {
return "", fmt.Errorf("failed to parse common response: %w", err)
}
if !cr.Status {
return "", fmt.Errorf("API error: %s", cr.Message)
}
var component struct {
ID string `json:"id"`
}
if err := json.Unmarshal(cr.Data, &component); err != nil {
return "", fmt.Errorf("failed to parse component data: %w", err)
}
return component.ID, nil
}
func findExistingComponent(apiURL, accessToken, cType, platform, version string) (string, error) {
endpoint, err := url.Parse(apiEndpoint(apiURL, "/components"))
if err != nil {
return "", err
}
query := endpoint.Query()
query.Set("type", cType)
query.Set("platform", platform)
query.Set("search", version)
endpoint.RawQuery = query.Encode()
client := &http.Client{Timeout: apiRequestTimeout}
bodyBytes, statusCode, err := doHTTPWithRetry("search existing component", client, func() (*http.Request, error) {
req, err := http.NewRequest("GET", endpoint.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
return req, nil
}, http.StatusOK)
if err != nil {
return "", err
}
if statusCode != http.StatusOK {
return "", fmt.Errorf("failed to search components (status %d): %s", statusCode, string(bodyBytes))
}
var pr struct {
Status bool `json:"status"`
Data []struct {
ID string `json:"id"`
ComponentType string `json:"component_type"`
Platform string `json:"platform"`
Version string `json:"version"`
} `json:"data"`
}
if err := json.Unmarshal(bodyBytes, &pr); err != nil {
return "", fmt.Errorf("failed to parse components search response: %w", err)
}
if !pr.Status {
return "", fmt.Errorf("search components returned unsuccessful status")
}
for _, item := range pr.Data {
if item.ComponentType == cType && item.Platform == platform && item.Version == version {
return item.ID, nil
}
}
return "", nil
}
func updateComponent(apiURL, accessToken, id string, dto UpdateComponentRequest) (string, error) {
endpoint := apiEndpoint(apiURL, "/components/"+url.PathEscape(id))
payload, _ := json.Marshal(dto)
client := &http.Client{Timeout: apiRequestTimeout}
bodyBytes, statusCode, err := doHTTPWithRetry("update component", client, func() (*http.Request, error) {
req, err := http.NewRequest("PUT", endpoint, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
return req, nil
}, http.StatusOK)
if err != nil {
return "", err
}
if statusCode != http.StatusOK {
return "", fmt.Errorf("update component failed (status %d): %s", statusCode, string(bodyBytes))
}
var cr CommonResponse
if err := json.Unmarshal(bodyBytes, &cr); err != nil {
return "", fmt.Errorf("failed to parse common response: %w", err)
}
if !cr.Status {
return "", fmt.Errorf("API error: %s", cr.Message)
}
var component struct {
ID string `json:"id"`
}
if err := json.Unmarshal(cr.Data, &component); err != nil {
return "", fmt.Errorf("failed to parse component data: %w", err)
}
return component.ID, nil
}