mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
506 lines
14 KiB
Go
506 lines
14 KiB
Go
package browser
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func extractExtensionIDFromURL(rawURL string) string {
|
|
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
|
if err != nil || parsed.Host == "" {
|
|
return ""
|
|
}
|
|
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
|
for i := len(parts) - 1; i >= 0; i-- {
|
|
candidate := strings.ToLower(strings.TrimSpace(parts[i]))
|
|
if extensionIDPattern.MatchString(candidate) {
|
|
return candidate
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func downloadChromeExtensionCRX(ctx context.Context, extensionID string, client *http.Client) ([]byte, error) {
|
|
if client == nil {
|
|
client = &http.Client{Timeout: extensionDownloadTimeout}
|
|
}
|
|
downloadURL := BuildChromeExtensionDownloadURL(extensionID)
|
|
var lastErr error
|
|
for attempt := 1; attempt <= 3; attempt++ {
|
|
data, err := downloadChromeExtensionCRXOnce(ctx, client, downloadURL)
|
|
if err == nil {
|
|
return data, nil
|
|
}
|
|
lastErr = err
|
|
if !isRetryableExtensionDownloadError(err) {
|
|
break
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(time.Duration(attempt) * 250 * time.Millisecond):
|
|
}
|
|
}
|
|
return nil, formatExtensionDownloadError(lastErr)
|
|
}
|
|
|
|
func downloadChromeExtensionCRXOnce(ctx context.Context, client *http.Client, downloadURL string) ([]byte, error) {
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
request.Header.Set("User-Agent", "Mozilla/5.0 AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36")
|
|
request.Header.Set("Accept", "*/*")
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("下载插件失败: %w", err)
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("下载插件失败: HTTP %d", response.StatusCode)
|
|
}
|
|
limited := io.LimitReader(response.Body, extensionMaxPackageBytes+1)
|
|
data, err := io.ReadAll(limited)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("读取插件包失败: %w", err)
|
|
}
|
|
if len(data) > extensionMaxPackageBytes {
|
|
return nil, fmt.Errorf("插件包超过限制")
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func isRetryableExtensionDownloadError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
message := strings.ToLower(err.Error())
|
|
return strings.Contains(message, "eof") ||
|
|
strings.Contains(message, "connection reset") ||
|
|
strings.Contains(message, "connection refused") ||
|
|
strings.Contains(message, "timeout") ||
|
|
strings.Contains(message, "temporarily unavailable")
|
|
}
|
|
|
|
func formatExtensionDownloadError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
message := strings.ToLower(err.Error())
|
|
if strings.Contains(message, "eof") {
|
|
return fmt.Errorf("下载插件失败: 连接在下载过程中提前关闭(EOF),已重试 3 次仍失败;请换一个下载代理节点或稍后重试")
|
|
}
|
|
if strings.Contains(message, "connectex") || strings.Contains(message, "dial tcp") || strings.Contains(message, "i/o timeout") {
|
|
return fmt.Errorf("下载插件失败: 无法连接 Chrome 插件下载服务,请确认网络或下载代理可访问 clients2.google.com: %w", err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func normalizeExtensionArchiveData(data []byte) ([]byte, error) {
|
|
if bytes.HasPrefix(data, []byte("PK\x03\x04")) {
|
|
return data, nil
|
|
}
|
|
zipOffset := bytes.Index(data, []byte("PK\x03\x04"))
|
|
if zipOffset < 0 {
|
|
return nil, fmt.Errorf("插件包不是有效的 CRX/ZIP 文件")
|
|
}
|
|
return data[zipOffset:], nil
|
|
}
|
|
|
|
func readExtensionManifestFromZip(data []byte) ([]byte, error) {
|
|
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("打开插件包失败: %w", err)
|
|
}
|
|
for _, file := range reader.File {
|
|
if normalizeZipEntryPath(file.Name) == "manifest.json" {
|
|
return readZipFile(file, 2<<20)
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("插件包缺少 manifest.json")
|
|
}
|
|
|
|
func parseExtensionManifest(data []byte) (extensionManifest, error) {
|
|
var manifest extensionManifest
|
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
|
return manifest, fmt.Errorf("解析 manifest.json 失败: %w", err)
|
|
}
|
|
if strings.TrimSpace(manifest.Version) == "" {
|
|
return manifest, fmt.Errorf("manifest.json 缺少 version")
|
|
}
|
|
return manifest, nil
|
|
}
|
|
|
|
func readExtensionLocaleMessagesFromZip(data []byte, manifest extensionManifest) map[string]string {
|
|
locale := resolveExtensionLocale(manifest)
|
|
if locale == "" {
|
|
return nil
|
|
}
|
|
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
for _, file := range reader.File {
|
|
if file.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
if strings.EqualFold(normalizeZipEntryPath(file.Name), "_locales/"+locale+"/messages.json") {
|
|
content, err := readZipFile(file, 1<<20)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return parseExtensionLocaleMessages(content)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readExtensionLocaleMessagesFromDir(sourceDir string, manifest extensionManifest) map[string]string {
|
|
locale := resolveExtensionLocale(manifest)
|
|
if locale == "" {
|
|
return nil
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(sourceDir, "_locales", locale, "messages.json"))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return parseExtensionLocaleMessages(data)
|
|
}
|
|
|
|
func parseExtensionLocaleMessages(data []byte) map[string]string {
|
|
var raw map[string]struct {
|
|
Message string `json:"message"`
|
|
}
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return nil
|
|
}
|
|
messages := make(map[string]string, len(raw))
|
|
for key, value := range raw {
|
|
if key = strings.TrimSpace(key); key != "" {
|
|
messages[key] = strings.TrimSpace(value.Message)
|
|
}
|
|
}
|
|
return messages
|
|
}
|
|
|
|
func resolveExtensionLocale(manifest extensionManifest) string {
|
|
locale := strings.TrimSpace(manifest.DefaultLocale)
|
|
locale = strings.Trim(locale, "/\\. ")
|
|
if locale == "" || strings.Contains(locale, "/") || strings.Contains(locale, "\\") {
|
|
return ""
|
|
}
|
|
return locale
|
|
}
|
|
|
|
func resolveExtensionDescription(manifest extensionManifest, messages map[string]string) string {
|
|
return resolveExtensionMessage(strings.TrimSpace(manifest.Description), messages)
|
|
}
|
|
|
|
func resolveExtensionMessage(value string, messages map[string]string) string {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
if !strings.HasPrefix(trimmed, "__MSG_") || !strings.HasSuffix(trimmed, "__") {
|
|
return trimmed
|
|
}
|
|
key := strings.TrimSuffix(strings.TrimPrefix(trimmed, "__MSG_"), "__")
|
|
if message := strings.TrimSpace(messages[key]); message != "" {
|
|
return message
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
func readExtensionIconDataURLFromZip(data []byte, manifest extensionManifest) string {
|
|
iconPath := resolveExtensionIconPath(manifest)
|
|
if iconPath == "" {
|
|
return ""
|
|
}
|
|
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, file := range reader.File {
|
|
if file.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
if strings.EqualFold(normalizeZipEntryPath(file.Name), iconPath) {
|
|
content, err := readZipFile(file, 1<<20)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return extensionIconDataURL(iconPath, content)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func readExtensionIconDataURLFromDir(sourceDir string, manifest extensionManifest) string {
|
|
iconPath := resolveExtensionIconPath(manifest)
|
|
if iconPath == "" {
|
|
return ""
|
|
}
|
|
fullPath := filepath.Join(sourceDir, filepath.FromSlash(iconPath))
|
|
content, err := os.ReadFile(fullPath)
|
|
if err != nil || len(content) > 1<<20 {
|
|
return ""
|
|
}
|
|
return extensionIconDataURL(iconPath, content)
|
|
}
|
|
|
|
func resolveExtensionIconPath(manifest extensionManifest) string {
|
|
for _, candidate := range []map[string]any{manifest.Action, manifest.BrowserAction} {
|
|
if path := mapStringValue(candidate, "default_icon"); path != "" {
|
|
return normalizeExtensionAssetPath(path)
|
|
}
|
|
}
|
|
bestSize := -1
|
|
bestPath := ""
|
|
for size, path := range manifest.Icons {
|
|
if normalizedPath := normalizeExtensionAssetPath(path); normalizedPath != "" {
|
|
parsedSize := parseExtensionIconSize(size)
|
|
if parsedSize > bestSize {
|
|
bestSize = parsedSize
|
|
bestPath = normalizedPath
|
|
}
|
|
}
|
|
}
|
|
return bestPath
|
|
}
|
|
|
|
func mapStringValue(values map[string]any, key string) string {
|
|
if values == nil {
|
|
return ""
|
|
}
|
|
if value, ok := values[key].(string); ok {
|
|
return value
|
|
}
|
|
if nested, ok := values[key].(map[string]any); ok {
|
|
bestSize := -1
|
|
bestPath := ""
|
|
for size, rawPath := range nested {
|
|
path, ok := rawPath.(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
parsedSize := parseExtensionIconSize(size)
|
|
if parsedSize > bestSize {
|
|
bestSize = parsedSize
|
|
bestPath = path
|
|
}
|
|
}
|
|
return bestPath
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizeExtensionAssetPath(value string) string {
|
|
path := strings.TrimSpace(filepath.ToSlash(value))
|
|
path = strings.TrimLeft(path, "/")
|
|
if path == "" || strings.Contains(path, "..") || filepath.IsAbs(path) {
|
|
return ""
|
|
}
|
|
return path
|
|
}
|
|
|
|
func parseExtensionIconSize(value string) int {
|
|
var size int
|
|
_, _ = fmt.Sscanf(strings.TrimSpace(value), "%d", &size)
|
|
return size
|
|
}
|
|
|
|
func extensionIconDataURL(path string, data []byte) string {
|
|
if len(data) == 0 || len(data) > 1<<20 {
|
|
return ""
|
|
}
|
|
mimeType := mime.TypeByExtension(strings.ToLower(filepath.Ext(path)))
|
|
if mimeType == "" {
|
|
mimeType = http.DetectContentType(data)
|
|
}
|
|
if !strings.HasPrefix(mimeType, "image/") {
|
|
return ""
|
|
}
|
|
return "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data)
|
|
}
|
|
|
|
func replaceExtensionDirFromZip(data []byte, installDir string) error {
|
|
tmpDir := installDir + ".tmp"
|
|
_ = os.RemoveAll(tmpDir)
|
|
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
|
return fmt.Errorf("创建插件目录失败: %w", err)
|
|
}
|
|
success := false
|
|
defer func() {
|
|
if !success {
|
|
_ = os.RemoveAll(tmpDir)
|
|
}
|
|
}()
|
|
|
|
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
if err != nil {
|
|
return fmt.Errorf("打开插件包失败: %w", err)
|
|
}
|
|
for _, file := range reader.File {
|
|
if file.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
relativePath := normalizeZipEntryPath(file.Name)
|
|
if relativePath == "" {
|
|
continue
|
|
}
|
|
targetPath := filepath.Join(tmpDir, filepath.FromSlash(relativePath))
|
|
if !strings.HasPrefix(filepath.Clean(targetPath), filepath.Clean(tmpDir)+string(os.PathSeparator)) {
|
|
return fmt.Errorf("插件包包含非法路径: %s", file.Name)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
|
return fmt.Errorf("创建插件文件目录失败: %w", err)
|
|
}
|
|
content, err := readZipFile(file, extensionMaxPackageBytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(targetPath, content, 0o644); err != nil {
|
|
return fmt.Errorf("写入插件文件失败: %w", err)
|
|
}
|
|
}
|
|
if err := os.RemoveAll(installDir); err != nil {
|
|
return fmt.Errorf("清理旧插件失败: %w", err)
|
|
}
|
|
if err := os.Rename(tmpDir, installDir); err != nil {
|
|
return fmt.Errorf("安装插件失败: %w", err)
|
|
}
|
|
success = true
|
|
return nil
|
|
}
|
|
|
|
func copyExtensionDirectory(sourceDir string, installDir string) error {
|
|
tmpDir := installDir + ".tmp"
|
|
_ = os.RemoveAll(tmpDir)
|
|
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
|
return fmt.Errorf("创建插件目录失败: %w", err)
|
|
}
|
|
success := false
|
|
defer func() {
|
|
if !success {
|
|
_ = os.RemoveAll(tmpDir)
|
|
}
|
|
}()
|
|
|
|
sourceClean, err := filepath.Abs(sourceDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := filepath.WalkDir(sourceClean, func(path string, entry os.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if entry.Type()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("插件目录包含符号链接: %s", path)
|
|
}
|
|
if entry.IsDir() {
|
|
name := entry.Name()
|
|
if name == ".git" || name == "node_modules" {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Size() > extensionMaxPackageBytes {
|
|
return fmt.Errorf("插件文件过大: %s", path)
|
|
}
|
|
relativePath, err := filepath.Rel(sourceClean, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
targetPath := filepath.Join(tmpDir, relativePath)
|
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(targetPath, data, 0o644)
|
|
}); err != nil {
|
|
return fmt.Errorf("复制插件目录失败: %w", err)
|
|
}
|
|
if err := os.RemoveAll(installDir); err != nil {
|
|
return fmt.Errorf("清理旧插件失败: %w", err)
|
|
}
|
|
if err := os.Rename(tmpDir, installDir); err != nil {
|
|
return fmt.Errorf("安装插件失败: %w", err)
|
|
}
|
|
success = true
|
|
return nil
|
|
}
|
|
|
|
func normalizeZipEntryPath(value string) string {
|
|
path := strings.TrimSpace(filepath.ToSlash(value))
|
|
path = strings.TrimLeft(path, "/")
|
|
if path == "" || strings.Contains(path, "..") || filepath.IsAbs(path) {
|
|
return ""
|
|
}
|
|
parts := strings.Split(path, "/")
|
|
if len(parts) > 1 && parts[0] != "" && parts[1] == "manifest.json" {
|
|
return strings.Join(parts[1:], "/")
|
|
}
|
|
return path
|
|
}
|
|
|
|
func readZipFile(file *zip.File, limit int64) ([]byte, error) {
|
|
reader, err := file.Open()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("读取插件文件失败: %w", err)
|
|
}
|
|
defer reader.Close()
|
|
data, err := io.ReadAll(io.LimitReader(reader, limit+1))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("读取插件文件失败: %w", err)
|
|
}
|
|
if int64(len(data)) > limit {
|
|
return nil, fmt.Errorf("插件文件过大: %s", file.Name)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func extensionIDFromManifest(manifestData []byte) string {
|
|
sum := sha256.Sum256(manifestData)
|
|
hexValue := hex.EncodeToString(sum[:16])
|
|
var builder strings.Builder
|
|
for _, char := range hexValue {
|
|
if char >= '0' && char <= '9' {
|
|
builder.WriteByte(byte('a' + char - '0'))
|
|
continue
|
|
}
|
|
builder.WriteByte(byte('k' + char - 'a'))
|
|
}
|
|
return builder.String()
|
|
}
|
|
|
|
func resolveExtensionName(manifest extensionManifest, fallback string) string {
|
|
for _, value := range []string{manifest.Name, manifest.ShortName, fallback} {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return "Chrome 插件"
|
|
}
|