Кабинет: страницы собирает ядро, на хостинге — ретранслятор (Go и PHP)
- modules/lk: конверт запроса/ответа (PageRequest/PageResponse), ServeEnvelope — тот же обработчик страниц внутри ядра; клиент к API внутри процесса (Config.Local/LocalKey); режим ретранслятора (Config.Relay, BJ_RENDER=core, выбор в установщике и lk.json) - ядро: встроенный кабинет (lkcabinet.go), POST /api/lk/render для прямого режима, цикл режима ожидания выполняет конверты страниц; API через ретранслятор недостижимо - PHP-ретранслятор для обычного хостинга (modules/lk/relay-php: index.php, .htaccess, README): установщик в браузере, прямой режим через curl, режим ожидания через очередь в SQLite (BEGIN IMMEDIATE, busy_timeout), конфиг в bj-lk-data рядом с сайтом или в data под .htaccess; архив со страницы настроек (/admin/setup/lk/relay-php.zip) - настройки: карточка НПР/УДС перенесена из раздела «Лицензия» в «Экспорт QUIK»; кнопка «Обновить с сервера лицензий» видна при любом ключе - инструкция /admin/help/lk: два варианта размещения, шаги для PHP-хостинга - тесты: TestLKRelayRendersInCore (оба режима), TestLKPHPRelayAgentMode (настоящий PHP против цикла ядра, PHP_BIN), проверка безопасности через PHP-ретранслятор 34/34 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
package lk
|
||||
|
||||
// envelope.go — отрисовка страниц в ядре и тонкий ретранслятор.
|
||||
//
|
||||
// Страницы кабинета может собирать не программа на хостинге, а само ядро: у него все
|
||||
// данные и шаблоны. Тогда на хостинге остаётся ретранслятор — он упаковывает запрос
|
||||
// браузера в конверт (метод, путь, заголовки, тело), передаёт ядру и отдаёт браузеру
|
||||
// ответ из конверта (код, заголовки, тело). Ретранслятор ничего не разбирает и ничем
|
||||
// не управляет, поэтому его легко держать и на Go для своего сервера, и на PHP для
|
||||
// обычного хостинга — вся логика и защита живут в ядре в одном экземпляре.
|
||||
//
|
||||
// Этот же пакет, собранный внутрь ядра, разворачивает конверт обратно в http.Request и
|
||||
// прогоняет его через свой обычный обработчик страниц (ServeEnvelope). Данные пакет
|
||||
// берёт через тот же API /api/lk/*, что и снаружи, только вызовом внутри процесса.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PageRequest — запрос браузера в конверте.
|
||||
type PageRequest struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Kind string `json:"kind"` // page — страница кабинета; пусто — вызов API (старый кабинет)
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"` // путь с параметрами запроса
|
||||
Host string `json:"host,omitempty"`
|
||||
Scheme string `json:"scheme,omitempty"` // http | https — как браузер открыл кабинет
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
Body []byte `json:"body,omitempty"`
|
||||
Session string `json:"session,omitempty"` // только для вызовов API
|
||||
ClientIP string `json:"client_ip,omitempty"`
|
||||
ClientAgent string `json:"client_agent,omitempty"`
|
||||
}
|
||||
|
||||
// PageResponse — ответ ядра в конверте.
|
||||
type PageResponse struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Status int `json:"status"`
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
Body []byte `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
// forwardHeaders — заголовки браузера, которые ретранслятор передаёт ядру.
|
||||
// Остальное (Referer, Origin, служебные заголовки хостинга) ядру не нужно.
|
||||
var forwardHeaders = []string{"Cookie", "Accept", "Accept-Language", "Content-Type", "If-None-Match", "If-Modified-Since"}
|
||||
|
||||
// hopHeaders — заголовки соединения: их ретранслятор не переносит ни в одну сторону.
|
||||
var hopHeaders = map[string]bool{"Connection": true, "Keep-Alive": true, "Transfer-Encoding": true, "Upgrade": true,
|
||||
"Proxy-Authenticate": true, "Proxy-Authorization": true, "Te": true, "Trailer": true, "Content-Length": true}
|
||||
|
||||
// envelopeFrom — конверт из запроса браузера (ретранслятор).
|
||||
func envelopeFrom(r *http.Request, maxBody int64) (PageRequest, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxBody+1))
|
||||
if err != nil {
|
||||
return PageRequest{}, err
|
||||
}
|
||||
if int64(len(body)) > maxBody {
|
||||
return PageRequest{}, errRequestTooLarge
|
||||
}
|
||||
scheme := "http"
|
||||
if httpsRequest(r) {
|
||||
scheme = "https"
|
||||
}
|
||||
env := PageRequest{Kind: "page", Method: r.Method, Path: r.URL.RequestURI(), Host: r.Host, Scheme: scheme,
|
||||
Headers: map[string][]string{}, Body: body, ClientIP: clientIP(r), ClientAgent: r.UserAgent()}
|
||||
for _, h := range forwardHeaders {
|
||||
if v := r.Header.Values(h); len(v) > 0 {
|
||||
env.Headers[h] = v
|
||||
}
|
||||
}
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// writeEnvelope — ответ ядра браузеру (ретранслятор).
|
||||
func writeEnvelope(w http.ResponseWriter, res PageResponse) {
|
||||
for k, vs := range res.Headers {
|
||||
if hopHeaders[http.CanonicalHeaderKey(k)] {
|
||||
continue
|
||||
}
|
||||
for _, v := range vs {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
if res.Status == 0 {
|
||||
res.Status = http.StatusBadGateway
|
||||
}
|
||||
w.WriteHeader(res.Status)
|
||||
_, _ = w.Write(res.Body)
|
||||
}
|
||||
|
||||
// ServeEnvelope — развернуть конверт и выполнить запрос обычным обработчиком страниц (ядро).
|
||||
func (s *Server) ServeEnvelope(ctx context.Context, env PageRequest) PageResponse {
|
||||
if env.Method == "" {
|
||||
env.Method = http.MethodGet
|
||||
}
|
||||
u, err := url.ParseRequestURI(env.Path)
|
||||
if err != nil || strings.HasPrefix(env.Path, "//") {
|
||||
return PageResponse{ID: env.ID, Status: http.StatusBadRequest, Body: []byte("некорректный путь")}
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 40*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(cctx, env.Method, u.String(), bytes.NewReader(env.Body))
|
||||
if err != nil {
|
||||
return PageResponse{ID: env.ID, Status: http.StatusBadRequest, Body: []byte("некорректный запрос")}
|
||||
}
|
||||
for k, vs := range env.Headers {
|
||||
ck := http.CanonicalHeaderKey(k)
|
||||
if hopHeaders[ck] || strings.HasPrefix(ck, "X-Bj-") || strings.HasPrefix(ck, "X-Forwarded-") || ck == "X-Real-Ip" {
|
||||
continue // служебные заголовки ставим сами, чужие не пропускаем
|
||||
}
|
||||
for _, v := range vs {
|
||||
req.Header.Add(ck, v)
|
||||
}
|
||||
}
|
||||
if env.Host != "" {
|
||||
req.Host = env.Host
|
||||
}
|
||||
if env.Scheme == "https" {
|
||||
req.Header.Set("X-Forwarded-Proto", "https") // куки Secure, HSTS — как при прямом TLS
|
||||
}
|
||||
if env.ClientIP != "" {
|
||||
req.Header.Set("X-Real-IP", env.ClientIP)
|
||||
req.RemoteAddr = env.ClientIP + ":0"
|
||||
}
|
||||
if env.ClientAgent != "" {
|
||||
req.Header.Set("User-Agent", env.ClientAgent)
|
||||
}
|
||||
rec := &envelopeRecorder{status: http.StatusOK, hdr: http.Header{}}
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
return PageResponse{ID: env.ID, Status: rec.status, Headers: rec.hdr, Body: rec.buf.Bytes()}
|
||||
}
|
||||
|
||||
type envelopeRecorder struct {
|
||||
status int
|
||||
hdr http.Header
|
||||
buf bytes.Buffer
|
||||
wrote bool
|
||||
}
|
||||
|
||||
func (w *envelopeRecorder) Header() http.Header { return w.hdr }
|
||||
func (w *envelopeRecorder) Write(b []byte) (int, error) {
|
||||
w.wrote = true
|
||||
return w.buf.Write(b)
|
||||
}
|
||||
func (w *envelopeRecorder) WriteHeader(code int) {
|
||||
if !w.wrote {
|
||||
w.status = code
|
||||
}
|
||||
}
|
||||
|
||||
// relayMaxBody — сколько ретранслятор принимает от браузера (формы кабинета маленькие).
|
||||
const relayMaxBody = 1 << 20
|
||||
|
||||
// relay — обработчик ретранслятора: конверт → ядро → ответ браузеру.
|
||||
func (s *Server) relay(w http.ResponseWriter, r *http.Request) {
|
||||
env, err := envelopeFrom(r, relayMaxBody)
|
||||
if err != nil {
|
||||
http.Error(w, "Запрос слишком большой", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
res, err := s.bj().page(reqCtx(r), env)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
s.tpl.ExecuteTemplate(w, "relay_error", map[string]any{"Title": s.conf().Title, "Error": err.Error()}) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
writeEnvelope(w, res)
|
||||
}
|
||||
|
||||
// page — доставить конверт ядру: напрямую (POST /api/lk/render) или через очередь режима ожидания.
|
||||
func (b *bjClient) page(ctx context.Context, env PageRequest) (PageResponse, error) {
|
||||
if b.agent != nil {
|
||||
res, err := b.agent.pushEnvelope(ctx, env, 40*time.Second)
|
||||
if err != nil {
|
||||
return PageResponse{}, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
raw, _ := json.Marshal(env)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.cfg.BJURL+"/api/lk/render", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return PageResponse{}, err
|
||||
}
|
||||
req.Header.Set("X-BJ-Key", b.cfg.BJKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := b.hc.Do(req)
|
||||
if err != nil {
|
||||
return PageResponse{}, errNoCore(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var e struct{ Error string }
|
||||
_ = json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&e)
|
||||
if e.Error == "" {
|
||||
e.Error = "ядро ответило " + resp.Status
|
||||
}
|
||||
return PageResponse{}, errCore(e.Error)
|
||||
}
|
||||
var out PageResponse
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&out); err != nil {
|
||||
return PageResponse{}, errCore("ответ ядра не разобран")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user