Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates. Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
162 lines
7.2 KiB
Go
162 lines
7.2 KiB
Go
package gzev
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"crypto/subtle"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"html/template"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// UploadHandler accepts local SSH access or the configured HTTPS origin with a one-use recovery code.
|
||
type UploadHandler struct {
|
||
PublicOrigin string
|
||
RecoveryPath string
|
||
SessionPath string
|
||
Account string
|
||
Verify func(context.Context, Session, string) error
|
||
csrf string
|
||
}
|
||
|
||
func NewUploadHandler(path, account string) (*UploadHandler, error) {
|
||
nonce := make([]byte, 32)
|
||
if _, err := rand.Read(nonce); err != nil {
|
||
return nil, err
|
||
}
|
||
return &UploadHandler{SessionPath: path, Account: account, csrf: hex.EncodeToString(nonce), Verify: func(ctx context.Context, s Session, a string) error { return NewClient(s).VerifyAccount(ctx, a) }}, nil
|
||
}
|
||
func (h *UploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
// Keep the Origin header on same-origin form POSTs; no-referrer makes
|
||
// browsers send Origin: null, which the strict check below must reject.
|
||
w.Header().Set("Referrer-Policy", "same-origin")
|
||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
|
||
local := r.Host == "127.0.0.1:20312" || r.Host == "localhost:20312"
|
||
public := h.PublicOrigin != "" && "https://"+r.Host == h.PublicOrigin
|
||
if !local && !public {
|
||
http.Error(w, "仅允许通过 SSH 安全连接访问", 403)
|
||
return
|
||
}
|
||
if r.URL.Path != "/" && r.URL.Path != "/gzev/session/" {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
if r.Method == http.MethodGet {
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
uploadPage.Execute(w, map[string]string{"Account": h.Account, "CSRF": h.csrf, "Public": fmt.Sprint(h.PublicOrigin != "")})
|
||
return
|
||
}
|
||
if r.Method != http.MethodPost {
|
||
w.Header().Set("Allow", "GET, POST")
|
||
http.Error(w, "Method not allowed", 405)
|
||
return
|
||
}
|
||
expectedOrigin := "http://" + r.Host
|
||
if public {
|
||
expectedOrigin = h.PublicOrigin
|
||
}
|
||
if r.Header.Get("Origin") != expectedOrigin {
|
||
http.Error(w, "请求来源不正确,请刷新上传页面", 403)
|
||
return
|
||
}
|
||
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
|
||
if err := r.ParseMultipartForm(32 << 10); err != nil {
|
||
http.Error(w, "文件或表单无效,大小不能超过 32 KB", 400)
|
||
return
|
||
}
|
||
if r.MultipartForm != nil {
|
||
defer r.MultipartForm.RemoveAll()
|
||
}
|
||
if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(h.csrf)) != 1 {
|
||
http.Error(w, "页面已过期,请刷新后重新提交", 403)
|
||
return
|
||
}
|
||
var s Session
|
||
file, _, fileErr := r.FormFile("session")
|
||
if fileErr == nil {
|
||
defer file.Close()
|
||
dec := json.NewDecoder(file)
|
||
dec.DisallowUnknownFields()
|
||
if err := dec.Decode(&s); err != nil {
|
||
http.Error(w, "会话文件格式错误", 400)
|
||
return
|
||
}
|
||
if dec.Decode(new(any)) != io.EOF {
|
||
http.Error(w, "会话文件含多余内容", 400)
|
||
return
|
||
}
|
||
} else if fileErr == http.ErrMissingFile {
|
||
token := strings.TrimSpace(r.FormValue("token"))
|
||
token = strings.TrimPrefix(token, "Bearer ")
|
||
s = Session{Authorization: "Bearer " + token, Cookies: []SessionCookie{{Name: "token", Value: token}}}
|
||
if routing := strings.TrimSpace(r.FormValue("routing")); routing != "" {
|
||
s.Cookies = append(s.Cookies, SessionCookie{Name: "bigs", Value: routing})
|
||
}
|
||
} else {
|
||
http.Error(w, "无法读取上传文件", 400)
|
||
return
|
||
}
|
||
if ValidateSession(s) != nil {
|
||
http.Error(w, "会话格式错误,只接受 token 和可选的 bigs Cookie", 400)
|
||
return
|
||
}
|
||
action := func() error {
|
||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||
defer cancel()
|
||
if err := h.Verify(ctx, s, h.Account); err != nil {
|
||
return errors.New("verification failed")
|
||
}
|
||
return WriteSession(h.SessionPath, s)
|
||
}
|
||
var err error
|
||
if h.PublicOrigin != "" {
|
||
err = ConsumeRecovery(h.RecoveryPath, r.FormValue("recovery_code"), action)
|
||
} else {
|
||
err = action()
|
||
}
|
||
if err != nil {
|
||
http.Error(w, "验证或保存未通过。请检查恢复码、登录状态及账号;现有会话未覆盖。", 422)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
io.WriteString(w, "<!doctype html><html lang=zh-CN><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'><title>更新成功</title><body><h1>登录状态已更新</h1><p>验证通过,下一次定时采集将自动继续补齐里程数据。</p><a href=/>返回</a></body></html>")
|
||
}
|
||
func WriteSession(path string, s Session) error {
|
||
if err := ValidateSession(s); err != nil {
|
||
return err
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||
return err
|
||
}
|
||
f, err := os.CreateTemp(filepath.Dir(path), ".session-*")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
name := f.Name()
|
||
defer os.Remove(name)
|
||
if err := json.NewEncoder(f).Encode(s); err != nil {
|
||
f.Close()
|
||
return err
|
||
}
|
||
if err := f.Sync(); err != nil {
|
||
f.Close()
|
||
return err
|
||
}
|
||
if err := f.Close(); err != nil {
|
||
return err
|
||
}
|
||
return os.Rename(name, path)
|
||
}
|
||
|
||
var uploadPage = template.Must(template.New("upload").Parse(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>更新广州平台登录状态</title><style>body{font:16px/1.6 system-ui,sans-serif;background:#f4f6f8;color:#14212e;margin:0;padding:32px 16px}main{max-width:620px;margin:0 auto;background:white;padding:32px;border-radius:16px}h1{font-size:26px}label{display:block;margin-top:18px}input{display:block;box-sizing:border-box;width:100%;padding:12px;border:1px solid #aab5c0;border-radius:6px;font:inherit}button{margin-top:24px;background:#125acf;color:white;border:0;border-radius:8px;padding:12px 24px;font:inherit}small{color:#536273}details{margin-top:24px}hr{border:0;border-top:1px solid #ddd;margin:24px 0}</style></head><body><main><h1>更新广州平台登录状态</h1><p>请先登录广州平台,账号:<strong>{{.Account}}</strong>。</p><p>提交后会验证账号和登录状态。验证通过后,下一次定时采集自动继续。</p><form method="post" enctype="multipart/form-data" autocomplete="off"><input type="hidden" name="csrf" value="{{.CSRF}}">{{if eq .Public "true"}}<label>通知恢复码<input type="password" name="recovery_code" required autocomplete="off" maxlength="48"></label>{{end}}<label>粘贴 token<input type="password" name="token" autocomplete="off" maxlength="8192"></label><label>bigs Cookie(可选)<input type="password" name="routing" autocomplete="off" maxlength="4096"></label><hr><label>或上传会话 JSON 文件<input type="file" name="session" accept=".json,application/json"></label><small>上传文件时优先使用文件内容。不要提交账号密码或短信验证码。</small><br><button type="submit">验证并更新</button></form><details><summary>如何取得 token?</summary><ol><li>在 Chrome 登录广州平台,完成必要的短信和图形验证码。</li><li>打开开发者工具 → Application → Cookies → gzev.d.lnoneos.com。</li><li>复制 token 的值;如有 bigs,也一并复制。</li><li>回到此页面粘贴并提交。</li></ol></details></main></body></html>`))
|