mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 04:50:52 +08:00
- Renamed constants from uppercase to CamelCase for consistency. - Replaced redundant file-based auth handling logic with the new `util.CountAuthFiles` helper. - Fixed various error-handling inconsistencies and enhanced robustness in file operations. - Streamlined auth client reload logic in server and watcher components. - Applied minor code readability improvements across multiple packages.
395 lines
9.9 KiB
Go
395 lines
9.9 KiB
Go
package geminiwebapi
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
// Image helpers ------------------------------------------------------------
|
|
|
|
type Image struct {
|
|
URL string
|
|
Title string
|
|
Alt string
|
|
Proxy string
|
|
}
|
|
|
|
func (i Image) String() string {
|
|
short := i.URL
|
|
if len(short) > 20 {
|
|
short = short[:8] + "..." + short[len(short)-12:]
|
|
}
|
|
return fmt.Sprintf("Image(title='%s', alt='%s', url='%s')", i.Title, i.Alt, short)
|
|
}
|
|
|
|
func (i Image) Save(path string, filename string, cookies map[string]string, verbose bool, skipInvalidFilename bool, insecure bool) (string, error) {
|
|
if filename == "" {
|
|
// Try to parse filename from URL.
|
|
u := i.URL
|
|
if p := strings.Split(u, "/"); len(p) > 0 {
|
|
filename = p[len(p)-1]
|
|
}
|
|
if q := strings.Split(filename, "?"); len(q) > 0 {
|
|
filename = q[0]
|
|
}
|
|
}
|
|
// Regex validation (align with Python: ^(.*\.\w+)) to extract name with extension.
|
|
if filename != "" {
|
|
re := regexp.MustCompile(`^(.*\.\w+)`)
|
|
if m := re.FindStringSubmatch(filename); len(m) >= 2 {
|
|
filename = m[1]
|
|
} else {
|
|
if verbose {
|
|
Warning("Invalid filename: %s", filename)
|
|
}
|
|
if skipInvalidFilename {
|
|
return "", nil
|
|
}
|
|
}
|
|
}
|
|
// Build client with cookie jar so cookies persist across redirects.
|
|
tr := &http.Transport{}
|
|
if i.Proxy != "" {
|
|
if pu, err := url.Parse(i.Proxy); err == nil {
|
|
tr.Proxy = http.ProxyURL(pu)
|
|
}
|
|
}
|
|
if insecure {
|
|
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
|
}
|
|
jar, _ := cookiejar.New(nil)
|
|
client := &http.Client{Transport: tr, Timeout: 120 * time.Second, Jar: jar}
|
|
|
|
// Helper to set raw Cookie header using provided cookies (to mirror Python client behavior).
|
|
buildCookieHeader := func(m map[string]string) string {
|
|
if len(m) == 0 {
|
|
return ""
|
|
}
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
parts := make([]string, 0, len(keys))
|
|
for _, k := range keys {
|
|
parts = append(parts, fmt.Sprintf("%s=%s", k, m[k]))
|
|
}
|
|
return strings.Join(parts, "; ")
|
|
}
|
|
rawCookie := buildCookieHeader(cookies)
|
|
|
|
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
// Ensure provided cookies are always sent across redirects (domain-agnostic).
|
|
if rawCookie != "" {
|
|
req.Header.Set("Cookie", rawCookie)
|
|
}
|
|
if len(via) >= 10 {
|
|
return errors.New("stopped after 10 redirects")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, i.URL, nil)
|
|
if rawCookie != "" {
|
|
req.Header.Set("Cookie", rawCookie)
|
|
}
|
|
// Add browser-like headers to improve compatibility.
|
|
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8")
|
|
req.Header.Set("Connection", "keep-alive")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() {
|
|
_ = resp.Body.Close()
|
|
}()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("error downloading image: %d %s", resp.StatusCode, resp.Status)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(strings.ToLower(ct), "image") {
|
|
Warning("Content type of %s is not image, but %s.", filename, ct)
|
|
}
|
|
if path == "" {
|
|
path = "temp"
|
|
}
|
|
if err = os.MkdirAll(path, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
dest := filepath.Join(path, filename)
|
|
f, err := os.Create(dest)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
_, err = io.Copy(f, resp.Body)
|
|
_ = f.Close()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if verbose {
|
|
Info("Image saved as %s", dest)
|
|
}
|
|
abspath, _ := filepath.Abs(dest)
|
|
return abspath, nil
|
|
}
|
|
|
|
type WebImage struct{ Image }
|
|
|
|
type GeneratedImage struct {
|
|
Image
|
|
Cookies map[string]string
|
|
}
|
|
|
|
func (g GeneratedImage) Save(path string, filename string, fullSize bool, verbose bool, skipInvalidFilename bool, insecure bool) (string, error) {
|
|
if len(g.Cookies) == 0 {
|
|
return "", &ValueError{Msg: "GeneratedImage requires cookies."}
|
|
}
|
|
strURL := g.URL
|
|
if fullSize {
|
|
strURL = strURL + "=s2048"
|
|
}
|
|
if filename == "" {
|
|
name := time.Now().Format("20060102150405")
|
|
if len(strURL) >= 10 {
|
|
name = fmt.Sprintf("%s_%s.png", name, strURL[len(strURL)-10:])
|
|
} else {
|
|
name += ".png"
|
|
}
|
|
filename = name
|
|
}
|
|
tmp := g.Image
|
|
tmp.URL = strURL
|
|
return tmp.Save(path, filename, g.Cookies, verbose, skipInvalidFilename, insecure)
|
|
}
|
|
|
|
// Request parsing & file helpers -------------------------------------------
|
|
|
|
func ParseMessagesAndFiles(rawJSON []byte) ([]RoleText, [][]byte, []string, [][]int, error) {
|
|
var messages []RoleText
|
|
var files [][]byte
|
|
var mimes []string
|
|
var perMsgFileIdx [][]int
|
|
|
|
contents := gjson.GetBytes(rawJSON, "contents")
|
|
if contents.Exists() {
|
|
contents.ForEach(func(_, content gjson.Result) bool {
|
|
role := NormalizeRole(content.Get("role").String())
|
|
var b strings.Builder
|
|
startFile := len(files)
|
|
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
|
if text := part.Get("text"); text.Exists() {
|
|
if b.Len() > 0 {
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString(text.String())
|
|
}
|
|
if inlineData := part.Get("inlineData"); inlineData.Exists() {
|
|
data := inlineData.Get("data").String()
|
|
if data != "" {
|
|
if dec, err := base64.StdEncoding.DecodeString(data); err == nil {
|
|
files = append(files, dec)
|
|
m := inlineData.Get("mimeType").String()
|
|
if m == "" {
|
|
m = inlineData.Get("mime_type").String()
|
|
}
|
|
mimes = append(mimes, m)
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
messages = append(messages, RoleText{Role: role, Text: b.String()})
|
|
endFile := len(files)
|
|
if endFile > startFile {
|
|
idxs := make([]int, 0, endFile-startFile)
|
|
for i := startFile; i < endFile; i++ {
|
|
idxs = append(idxs, i)
|
|
}
|
|
perMsgFileIdx = append(perMsgFileIdx, idxs)
|
|
} else {
|
|
perMsgFileIdx = append(perMsgFileIdx, nil)
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
return messages, files, mimes, perMsgFileIdx, nil
|
|
}
|
|
|
|
func MaterializeInlineFiles(files [][]byte, mimes []string) ([]string, *interfaces.ErrorMessage) {
|
|
if len(files) == 0 {
|
|
return nil, nil
|
|
}
|
|
paths := make([]string, 0, len(files))
|
|
for i, data := range files {
|
|
ext := MimeToExt(mimes, i)
|
|
f, err := os.CreateTemp("", "gemini-upload-*"+ext)
|
|
if err != nil {
|
|
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: fmt.Errorf("failed to create temp file: %w", err)}
|
|
}
|
|
if _, err = f.Write(data); err != nil {
|
|
_ = f.Close()
|
|
_ = os.Remove(f.Name())
|
|
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: fmt.Errorf("failed to write temp file: %w", err)}
|
|
}
|
|
if err = f.Close(); err != nil {
|
|
_ = os.Remove(f.Name())
|
|
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: fmt.Errorf("failed to close temp file: %w", err)}
|
|
}
|
|
paths = append(paths, f.Name())
|
|
}
|
|
return paths, nil
|
|
}
|
|
|
|
func CleanupFiles(paths []string) {
|
|
for _, p := range paths {
|
|
if p != "" {
|
|
_ = os.Remove(p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func FetchGeneratedImageData(gi GeneratedImage) (string, string, error) {
|
|
path, err := gi.Save("", "", true, false, true, false)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer func() { _ = os.Remove(path) }()
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
mime := http.DetectContentType(b)
|
|
if !strings.HasPrefix(mime, "image/") {
|
|
if guessed := mimeFromExtension(filepath.Ext(path)); guessed != "" {
|
|
mime = guessed
|
|
} else {
|
|
mime = "image/png"
|
|
}
|
|
}
|
|
return mime, base64.StdEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
func MimeToExt(mimes []string, i int) string {
|
|
if i < len(mimes) {
|
|
return MimeToPreferredExt(strings.ToLower(mimes[i]))
|
|
}
|
|
return ".png"
|
|
}
|
|
|
|
var preferredExtByMIME = map[string]string{
|
|
"image/png": ".png",
|
|
"image/jpeg": ".jpg",
|
|
"image/jpg": ".jpg",
|
|
"image/webp": ".webp",
|
|
"image/gif": ".gif",
|
|
"image/bmp": ".bmp",
|
|
"image/heic": ".heic",
|
|
"application/pdf": ".pdf",
|
|
}
|
|
|
|
func MimeToPreferredExt(mime string) string {
|
|
normalized := strings.ToLower(strings.TrimSpace(mime))
|
|
if normalized == "" {
|
|
return ".png"
|
|
}
|
|
if ext, ok := preferredExtByMIME[normalized]; ok {
|
|
return ext
|
|
}
|
|
return ".png"
|
|
}
|
|
|
|
func mimeFromExtension(ext string) string {
|
|
cleaned := strings.TrimPrefix(strings.ToLower(ext), ".")
|
|
if cleaned == "" {
|
|
return ""
|
|
}
|
|
if mt, ok := misc.MimeTypes[cleaned]; ok && mt != "" {
|
|
return mt
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// File upload helpers ------------------------------------------------------
|
|
|
|
func uploadFile(path string, proxy string, insecure bool) (string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() {
|
|
_ = f.Close()
|
|
}()
|
|
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
fw, err := mw.CreateFormFile("file", filepath.Base(path))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, err = io.Copy(fw, f); err != nil {
|
|
return "", err
|
|
}
|
|
_ = mw.Close()
|
|
|
|
tr := &http.Transport{}
|
|
if proxy != "" {
|
|
if pu, errParse := url.Parse(proxy); errParse == nil {
|
|
tr.Proxy = http.ProxyURL(pu)
|
|
}
|
|
}
|
|
if insecure {
|
|
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
|
}
|
|
client := &http.Client{Transport: tr, Timeout: 300 * time.Second}
|
|
|
|
req, _ := http.NewRequest(http.MethodPost, EndpointUpload, &buf)
|
|
for k, v := range HeadersUpload {
|
|
for _, vv := range v {
|
|
req.Header.Add(k, vv)
|
|
}
|
|
}
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
req.Header.Set("Accept", "*/*")
|
|
req.Header.Set("Connection", "keep-alive")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() {
|
|
_ = resp.Body.Close()
|
|
}()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return "", &APIError{Msg: resp.Status}
|
|
}
|
|
b, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
func parseFileName(path string) (string, error) {
|
|
if st, err := os.Stat(path); err != nil || st.IsDir() {
|
|
return "", &ValueError{Msg: path + " is not a valid file."}
|
|
}
|
|
return filepath.Base(path), nil
|
|
}
|