upd
This commit is contained in:
80
internal/capture/images.go
Normal file
80
internal/capture/images.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.gulenok.ru/greenhaze/gaslight/internal/domain"
|
||||
)
|
||||
|
||||
type ImageSaver struct {
|
||||
outputDir string
|
||||
counter uint64
|
||||
}
|
||||
|
||||
func NewImageSaver(outputDir string) *ImageSaver {
|
||||
return &ImageSaver{outputDir: outputDir}
|
||||
}
|
||||
|
||||
func (s *ImageSaver) Save(resp *http.Response, body []byte) error {
|
||||
n := atomic.AddUint64(&s.counter, 1)
|
||||
ts := time.Now().UTC().Format("20060102T150405.000000000Z")
|
||||
host := sanitizeName(domain.HostWithoutPort(resp.Request.Host))
|
||||
ext := imageExtension(resp.Header.Get("Content-Type"), resp.Request.URL.Path)
|
||||
name := fmt.Sprintf("%s_%06d_%s%s", ts, n, host, ext)
|
||||
return os.WriteFile(filepath.Join(s.outputDir, name), body, 0o644)
|
||||
}
|
||||
|
||||
func IsImageResponse(contentType, path string) bool {
|
||||
if strings.HasPrefix(strings.ToLower(contentType), "image/") {
|
||||
return true
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(path)) {
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg", ".ico", ".avif":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func imageExtension(contentType, requestPath string) string {
|
||||
if ext := strings.ToLower(filepath.Ext(requestPath)); ext != "" && ext != "." {
|
||||
return ext
|
||||
}
|
||||
|
||||
base := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
|
||||
if base != "" {
|
||||
if exts, err := mime.ExtensionsByType(base); err == nil && len(exts) > 0 {
|
||||
return exts[0]
|
||||
}
|
||||
}
|
||||
return ".img"
|
||||
}
|
||||
|
||||
func sanitizeName(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
if s == "" {
|
||||
return "unknown-host"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '.', r == '-', r == '_':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
12
internal/cheating/determine_question_type.go
Normal file
12
internal/cheating/determine_question_type.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package cheating
|
||||
|
||||
type QuestionType int
|
||||
|
||||
const (
|
||||
QuestionWithPicture QuestionType = iota
|
||||
FullTextQuestion QuestionType = iota
|
||||
)
|
||||
|
||||
func DetermineQuestionType(html string) QuestionType {
|
||||
return FullTextQuestion
|
||||
}
|
||||
25
internal/config/config.go
Normal file
25
internal/config/config.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package config
|
||||
|
||||
import "flag"
|
||||
|
||||
type Config struct {
|
||||
ListenAddr string
|
||||
OutputDir string
|
||||
CertPath string
|
||||
KeyPath string
|
||||
}
|
||||
|
||||
func ParseFlags() Config {
|
||||
listenAddr := flag.String("listen", ":1199", "proxy listen address")
|
||||
outputDir := flag.String("out-dir", "./captures/images", "directory for captured image responses")
|
||||
certPath := flag.String("cert", "./cert.pem", "MITM CA certificate path (PEM)")
|
||||
keyPath := flag.String("key", "./key.pem", "MITM CA private key path (PEM)")
|
||||
flag.Parse()
|
||||
|
||||
return Config{
|
||||
ListenAddr: *listenAddr,
|
||||
OutputDir: *outputDir,
|
||||
CertPath: *certPath,
|
||||
KeyPath: *keyPath,
|
||||
}
|
||||
}
|
||||
16
internal/domain/domain.go
Normal file
16
internal/domain/domain.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package domain
|
||||
|
||||
import "strings"
|
||||
|
||||
func HostWithoutPort(host string) string {
|
||||
host = strings.TrimSpace(strings.ToLower(host))
|
||||
if i := strings.IndexByte(host, ':'); i >= 0 {
|
||||
return host[:i]
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func IsMckoDomain(host string) bool {
|
||||
host = HostWithoutPort(host)
|
||||
return strings.Contains(host, ".mcko.") || strings.HasPrefix(host, "mcko.")
|
||||
}
|
||||
57
internal/latex/latex2ascii.go
Normal file
57
internal/latex/latex2ascii.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package latex
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ConvertLaTeXToASCII(input string) string {
|
||||
s := input
|
||||
|
||||
replacements := []struct {
|
||||
pattern *regexp.Regexp
|
||||
replace string
|
||||
}{
|
||||
// Fractions: \frac{a}{b} -> (a)/(b)
|
||||
{regexp.MustCompile(`\\frac\{([^{}]+)\}\{([^{}]+)\}`), `($1)/($2)`},
|
||||
|
||||
// Square roots: \sqrt{x} -> sqrt(x)
|
||||
{regexp.MustCompile(`\\sqrt\{([^{}]+)\}`), `sqrt($1)`},
|
||||
|
||||
// Powers: x^{y} -> x^y
|
||||
{regexp.MustCompile(`\{?\^\{([^{}]+)\}\}?`), `^$1`},
|
||||
|
||||
// Subscripts: x_{i} -> x_i
|
||||
{regexp.MustCompile(`_\{([^{}]+)\}`), `_$1`},
|
||||
|
||||
// Greek letters (common subset)
|
||||
{regexp.MustCompile(`\\alpha`), "alpha"},
|
||||
{regexp.MustCompile(`\\beta`), "beta"},
|
||||
{regexp.MustCompile(`\\gamma`), "gamma"},
|
||||
{regexp.MustCompile(`\\delta`), "delta"},
|
||||
{regexp.MustCompile(`\\theta`), "theta"},
|
||||
{regexp.MustCompile(`\\pi`), "pi"},
|
||||
{regexp.MustCompile(`\\lambda`), "lambda"},
|
||||
|
||||
// Common symbols
|
||||
{regexp.MustCompile(`\\times`), "*"},
|
||||
{regexp.MustCompile(`\\cdot`), "*"},
|
||||
{regexp.MustCompile(`\\pm`), "+/-"},
|
||||
{regexp.MustCompile(`\\leq`), "<="},
|
||||
{regexp.MustCompile(`\\geq`), ">="},
|
||||
{regexp.MustCompile(`\\neq`), "!="},
|
||||
|
||||
// Remove LaTeX math wrappers
|
||||
{regexp.MustCompile(`\\left|\\right`), ""},
|
||||
{regexp.MustCompile(`\\[|\\]|\$`), ""},
|
||||
}
|
||||
|
||||
for _, r := range replacements {
|
||||
s = r.pattern.ReplaceAllString(s, r.replace)
|
||||
}
|
||||
|
||||
// Clean extra spaces
|
||||
s = strings.TrimSpace(strings.Join(strings.Fields(s), " "))
|
||||
|
||||
return s
|
||||
}
|
||||
176
internal/openrouter/openrouter.go
Normal file
176
internal/openrouter/openrouter.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package openrouter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
var Orclient OpenRouterClient
|
||||
|
||||
type OpenRouterClient struct {
|
||||
APIKey string
|
||||
Model string
|
||||
}
|
||||
type OpenRouterRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
|
||||
type OpenRouterResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
func AskOpenRouter(prompt string) (string, error) {
|
||||
url := "https://openrouter.ai/api/v1/chat/completions"
|
||||
apiKey := Orclient.APIKey
|
||||
model := Orclient.Model
|
||||
reqBody := OpenRouterRequest{
|
||||
Model: model,
|
||||
}
|
||||
|
||||
reqBody.Messages = append(reqBody.Messages, struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}{
|
||||
Role: "user",
|
||||
Content: prompt,
|
||||
})
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("HTTP-Referer", "https://yourapp.com") // optional but recommended
|
||||
req.Header.Set("X-Title", "My App") // optional
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("error: %s", string(body))
|
||||
}
|
||||
|
||||
var result OpenRouterResponse
|
||||
err = json.Unmarshal(body, &result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(result.Choices) == 0 {
|
||||
return "", fmt.Errorf("no response from model")
|
||||
}
|
||||
|
||||
return result.Choices[0].Message.Content, nil
|
||||
}
|
||||
|
||||
func AskOpenRouterWithLocalImage(prompt, imagePath string) (string, error) {
|
||||
url := "https://openrouter.ai/api/v1/chat/completions"
|
||||
apiKey := Orclient.APIKey
|
||||
model := Orclient.Model
|
||||
// Read image file
|
||||
imageBytes, err := os.ReadFile(imagePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Detect mime type (optional but good practice)
|
||||
mimeType := http.DetectContentType(imageBytes)
|
||||
|
||||
// Encode to base64
|
||||
base64Image := base64.StdEncoding.EncodeToString(imageBytes)
|
||||
|
||||
imageData := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Image)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"model": model,
|
||||
"messages": []map[string]interface{}{
|
||||
{
|
||||
"role": "user",
|
||||
"content": []map[string]interface{}{
|
||||
{
|
||||
"type": "text",
|
||||
"text": prompt,
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]string{
|
||||
"url": imageData,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("HTTP-Referer", "https://yourapp.com")
|
||||
req.Header.Set("X-Title", "My App")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("error: %s", string(body))
|
||||
}
|
||||
|
||||
var result OpenRouterResponse
|
||||
err = json.Unmarshal(body, &result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(result.Choices) == 0 {
|
||||
return "", fmt.Errorf("no response")
|
||||
}
|
||||
|
||||
return result.Choices[0].Message.Content, nil
|
||||
}
|
||||
87
internal/proxy/server.go
Normal file
87
internal/proxy/server.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/elazarl/goproxy"
|
||||
|
||||
"git.gulenok.ru/greenhaze/gaslight/internal/capture"
|
||||
"git.gulenok.ru/greenhaze/gaslight/internal/config"
|
||||
"git.gulenok.ru/greenhaze/gaslight/internal/domain"
|
||||
"git.gulenok.ru/greenhaze/gaslight/internal/rewrite"
|
||||
"git.gulenok.ru/greenhaze/gaslight/internal/tlsutil"
|
||||
)
|
||||
|
||||
func Run(cfg config.Config) error {
|
||||
if err := os.MkdirAll(cfg.OutputDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
caCert, err := tlsutil.LoadCACert(cfg.CertPath, cfg.KeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load CA certificate/key: %w", err)
|
||||
}
|
||||
goproxy.GoproxyCa = *caCert
|
||||
|
||||
imageSaver := capture.NewImageSaver(cfg.OutputDir)
|
||||
rewriter := rewrite.NewProcessor(rewrite.DefaultQuestionTestHook)
|
||||
|
||||
p := goproxy.NewProxyHttpServer()
|
||||
p.Verbose = false
|
||||
p.OnRequest().HandleConnect(goproxy.AlwaysMitm)
|
||||
p.OnResponse().DoFunc(func(resp *http.Response, _ *goproxy.ProxyCtx) *http.Response {
|
||||
if resp == nil || resp.Request == nil || resp.Body == nil {
|
||||
return resp
|
||||
}
|
||||
|
||||
if !domain.IsMckoDomain(resp.Request.Host) {
|
||||
return resp
|
||||
}
|
||||
|
||||
original, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
log.Printf("read body failed for %s: %v", resp.Request.URL.String(), err)
|
||||
return resp
|
||||
}
|
||||
|
||||
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
|
||||
if capture.IsImageResponse(contentType, resp.Request.URL.Path) {
|
||||
if err := imageSaver.Save(resp, original); err != nil {
|
||||
log.Printf("save image failed for %s: %v", resp.Request.URL.String(), err)
|
||||
}
|
||||
setResponseBody(resp, original)
|
||||
return resp
|
||||
}
|
||||
|
||||
if !rewrite.IsHTMLResponse(contentType) {
|
||||
setResponseBody(resp, original)
|
||||
return resp
|
||||
}
|
||||
|
||||
modified, err := rewriter.RewriteIfNeeded(resp.Request, resp.Header.Get("Content-Encoding"), original)
|
||||
if err != nil {
|
||||
log.Printf("html rewrite failed for %s: %v", resp.Request.URL.String(), err)
|
||||
setResponseBody(resp, original)
|
||||
return resp
|
||||
}
|
||||
|
||||
setResponseBody(resp, modified)
|
||||
return resp
|
||||
})
|
||||
|
||||
log.Printf("proxy listening on %s", cfg.ListenAddr)
|
||||
return http.ListenAndServe(cfg.ListenAddr, p)
|
||||
}
|
||||
|
||||
func setResponseBody(resp *http.Response, body []byte) {
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
resp.ContentLength = int64(len(body))
|
||||
resp.Header.Set("Content-Length", fmt.Sprint(len(body)))
|
||||
}
|
||||
124
internal/rewrite/html.go
Normal file
124
internal/rewrite/html.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package rewrite
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.gulenok.ru/greenhaze/gaslight/internal/cheating"
|
||||
"git.gulenok.ru/greenhaze/gaslight/internal/latex"
|
||||
"git.gulenok.ru/greenhaze/gaslight/internal/openrouter"
|
||||
)
|
||||
|
||||
var questionTestDivRe = regexp.MustCompile(`(?is)<div[^>]*\bid\s*=\s*["'][^"']*\bQuestionTest\b[^"']*["'][^>]*>`)
|
||||
|
||||
type HTMLHook func(req *http.Request, html []byte) ([]byte, error)
|
||||
|
||||
type Processor struct {
|
||||
hook HTMLHook
|
||||
}
|
||||
|
||||
func NewProcessor(hook HTMLHook) *Processor {
|
||||
return &Processor{hook: hook}
|
||||
}
|
||||
|
||||
func DefaultQuestionTestHook(_ *http.Request, html []byte) ([]byte, error) {
|
||||
switch cheating.DetermineQuestionType(string(html)) {
|
||||
case cheating.FullTextQuestion:
|
||||
{
|
||||
|
||||
aiResponce, err := openrouter.AskOpenRouter(fmt.Sprintf("РЕШИ ЗАДАНИЕ И ДАЙ МАКСИМАЛЬНО КРАТКИЙ ОТВЕТ: %s", string(html)))
|
||||
if err != nil {
|
||||
log.Printf("openrouter failed %s", err)
|
||||
return html, nil
|
||||
}
|
||||
return []byte(strings.ReplaceAll(string(html), "generated", latex.ConvertLaTeXToASCII(aiResponce))), nil
|
||||
}
|
||||
case cheating.QuestionWithPicture:
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return []byte(strings.ReplaceAll(string(html), "generated", "pwned")), nil
|
||||
}
|
||||
|
||||
func IsHTMLResponse(contentType string) bool {
|
||||
contentType = strings.ToLower(contentType)
|
||||
return strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml")
|
||||
}
|
||||
|
||||
func (p *Processor) RewriteIfNeeded(req *http.Request, contentEncoding string, body []byte) ([]byte, error) {
|
||||
decoded, err := decodeBody(contentEncoding, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !questionTestDivRe.Match(decoded) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
modifiedDecoded, err := p.hook(req, decoded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeBody(contentEncoding, modifiedDecoded)
|
||||
}
|
||||
|
||||
func decodeBody(encoding string, body []byte) ([]byte, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||
case "", "identity":
|
||||
return body, nil
|
||||
case "gzip":
|
||||
r, err := gzip.NewReader(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
return io.ReadAll(r)
|
||||
case "deflate":
|
||||
r := flate.NewReader(bytes.NewReader(body))
|
||||
defer r.Close()
|
||||
return io.ReadAll(r)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported content-encoding: %s", encoding)
|
||||
}
|
||||
}
|
||||
|
||||
func encodeBody(encoding string, decoded []byte) ([]byte, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||
case "", "identity":
|
||||
return decoded, nil
|
||||
case "gzip":
|
||||
var buf bytes.Buffer
|
||||
w := gzip.NewWriter(&buf)
|
||||
if _, err := w.Write(decoded); err != nil {
|
||||
_ = w.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
case "deflate":
|
||||
var buf bytes.Buffer
|
||||
w, err := flate.NewWriter(&buf, flate.DefaultCompression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := w.Write(decoded); err != nil {
|
||||
_ = w.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported content-encoding: %s", encoding)
|
||||
}
|
||||
}
|
||||
24
internal/tlsutil/ca.go
Normal file
24
internal/tlsutil/ca.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package tlsutil
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func LoadCACert(certPath, keyPath string) (*tls.Certificate, error) {
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cert.Certificate) == 0 {
|
||||
return nil, fmt.Errorf("empty certificate chain in %s", certPath)
|
||||
}
|
||||
|
||||
leaf, err := x509.ParseCertificate(cert.Certificate[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert.Leaf = leaf
|
||||
return &cert, nil
|
||||
}
|
||||
Reference in New Issue
Block a user