Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ Makefile
docs
.eslintcache
.gocache
/web/node_modules
# 依赖一律在镜像内 bun install,任何层级的 node_modules 都不需要进构建上下文
# (Dependencies are installed inside the image; no node_modules at any depth.)
**/node_modules
# web/dist 由 builder 阶段产出并 COPY --from,构建上下文里的产物一律忽略
# (web/dist is produced by the builder stage; ignore any prebuilt output here.)
/web/dist
/web/classic/dist
/web/default/dist
# 运行时目录,由 compose 以 bind mount 挂载
# (Runtime directories, provided by compose bind mounts.)
/data
/logs
!THIRD-PARTY-LICENSES.md
18 changes: 16 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder

WORKDIR /build/web
COPY web/package.json web/bun.lock ./
RUN bun install --frozen-lockfile
# .npmrc must land before `bun install`, otherwise the registry it configures is
# ignored (it would only arrive with the later `COPY ./web ./`).
COPY web/package.json web/bun.lock web/.npmrc ./
# Point at a nearby mirror when the default registry is slow, e.g.
# docker build --build-arg NPM_REGISTRY=https://repo.huaweicloud.com/repository/npm/ .
ARG NPM_REGISTRY=
# Bun's default fetch concurrency corrupts large tarballs (react-icons is 22MB,
# @hugeicons/core-free-icons larger still) on constrained links, surfacing as
# "Integrity check failed" on a different package each run. Cap concurrency and
# retry so a single dropped connection doesn't fail the whole build.
RUN if [ -n "$NPM_REGISTRY" ]; then echo "registry=$NPM_REGISTRY" > .npmrc; fi \
&& for attempt in 1 2 3; do \
echo "bun install attempt $attempt" \
&& BUN_CONFIG_MAX_HTTP_REQUESTS=8 bun install --frozen-lockfile && break \
|| { [ "$attempt" = 3 ] && exit 1; echo "retrying..."; sleep 5; }; \
done
COPY ./web ./
COPY ./VERSION /build/VERSION
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
Expand Down
70 changes: 4 additions & 66 deletions common/body_storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,6 @@ type BodyStorage interface {
Size() int64
// IsDisk 是否是磁盘存储
IsDisk() bool
// NewReader returns an independent reader positioned at the start of the
// stored payload. Each call returns a reader with its own cursor, so
// callers (e.g. http.Request.GetBody) can replay the body concurrently
// with, or after, other readers without sharing seek state. Closing the
// returned reader releases only that reader, never the storage itself;
// after the storage has been closed, NewReader returns ErrStorageClosed.
NewReader() (io.ReadCloser, error)
}

// ReplayableBody is an outbound request body that can report its byte size and
// create independent readers for transport-level retries.
type ReplayableBody interface {
io.Reader
Size() int64
NewReader() (io.ReadCloser, error)
}

// ErrStorageClosed 存储已关闭错误
Expand Down Expand Up @@ -95,18 +80,6 @@ func (m *memoryStorage) Bytes() ([]byte, error) {
return m.data, nil
}

func (m *memoryStorage) NewReader() (io.ReadCloser, error) {
m.mu.Lock()
defer m.mu.Unlock()
if atomic.LoadInt32(&m.closed) == 1 {
return nil, ErrStorageClosed
}
// A fresh bytes.Reader over the shared immutable backing array: an
// independent cursor at zero copy cost. NopCloser keeps Close a no-op, so
// the storage lifecycle stays owned by whoever holds the storage itself.
return io.NopCloser(bytes.NewReader(m.data)), nil
}

func (m *memoryStorage) Size() int64 {
return m.size
}
Expand Down Expand Up @@ -256,24 +229,6 @@ func (d *diskStorage) Bytes() ([]byte, error) {
return data, nil
}

func (d *diskStorage) NewReader() (io.ReadCloser, error) {
d.mu.Lock()
defer d.mu.Unlock()
if atomic.LoadInt32(&d.closed) == 1 {
return nil, ErrStorageClosed
}
// A separate file descriptor over the same cache file: an independent
// cursor at zero copy cost. Closing the returned reader closes only that
// descriptor; the storage keeps owning the primary descriptor and the
// file's lifetime. Readers opened before Close stay usable even after the
// file is unlinked, as the descriptor keeps the inode alive.
file, err := os.Open(d.filePath)
if err != nil {
return nil, fmt.Errorf("failed to open body cache file for replay: %w", err)
}
return file, nil
}

func (d *diskStorage) Size() int64 {
return d.size
}
Expand Down Expand Up @@ -347,27 +302,10 @@ func CreateBodyStorageFromReader(reader io.Reader, contentLength int64, maxBytes
return storage, nil
}

type replayableBodyReader struct {
storage BodyStorage
}

func (r replayableBodyReader) Read(p []byte) (int, error) {
return r.storage.Read(p)
}

func (r replayableBodyReader) Size() int64 {
return r.storage.Size()
}

func (r replayableBodyReader) NewReader() (io.ReadCloser, error) {
return r.storage.NewReader()
}

// NewReplayableBodyReader exposes the replay capabilities of storage without
// exposing io.Closer. This keeps ownership of the storage lifecycle with the
// caller instead of allowing net/http to close it as the request body.
func NewReplayableBodyReader(storage BodyStorage) ReplayableBody {
return replayableBodyReader{storage: storage}
// ReaderOnly wraps an io.Reader to hide io.Closer, preventing http.NewRequest
// from type-asserting io.ReadCloser and closing the underlying BodyStorage.
func ReaderOnly(r io.Reader) io.Reader {
return struct{ io.Reader }{r}
}

// CleanupOldCacheFiles 清理旧的缓存文件(用于启动时清理残留)
Expand Down
2 changes: 1 addition & 1 deletion common/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (

var StartTime = time.Now().Unix() // unit: second
var Version = "v0.0.0" // this hard coding will be replaced automatically when building, no need to manually change
var SystemName = "New API"
var SystemName = "OriginFlow"
var Footer = ""
var Logo = ""
var TopUpLink = ""
Expand Down
28 changes: 28 additions & 0 deletions common/redirect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package common

import (
"net/url"
"strings"
)

// IsSafeRedirect 校验重定向目标是否安全,防止开放重定向(Open Redirect)。
// 允许:空串(无重定向)、同源相对路径(以 / 开头且非协议相对 //host)、指向已知官方域名。
func IsSafeRedirect(u string) bool {
u = strings.TrimSpace(u)
if u == "" {
return true
}
// 相对路径,但排除协议相对地址(//evil.com)
if strings.HasPrefix(u, "/") {
return !strings.HasPrefix(u, "//")
}
parsed, err := url.Parse(u)
if err != nil {
return false
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return false
}
host := parsed.Host
return host == "91flow.com" || strings.HasSuffix(host, ".91flow.com")
}
20 changes: 19 additions & 1 deletion constant/context_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ const (
ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled"
ContextKeyTokenModelLimit ContextKey = "token_model_limit"
ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry"
ContextKeyTokenAutoGroups ContextKey = "token_auto_groups"

/* channel related keys */
ContextKeyChannelId ContextKey = "channel_id"
Expand Down Expand Up @@ -73,4 +72,23 @@ const (
// fallback in authHelper (finishAdminAudit) skips its record to avoid
// duplicate entries.
ContextKeyAuditLogged ContextKey = "audit_logged"

// ContextKeyRequestRegion stores the normalized region of the current request,
// used by region-based channel routing (see model.ResolveRegionRouting).
// It is distinct from the vertex-specific "region" key set in the distributor.
ContextKeyRequestRegion ContextKey = "request_region"

// ContextKeyUserRegionPreference stores the authenticated user's preferred
// region (see User.RegionPreference). Region-based channel routing falls back
// to it when no X-Region header or request-region context is present.
ContextKeyUserRegionPreference ContextKey = "user_region_preference"

// ContextKeyUserTeamId stores the authenticated user's enterprise team id
// (see User.TeamId). Consume logs are stamped with it for per-team billing
// aggregation.
ContextKeyUserTeamId ContextKey = "user_team_id"
)

// HeaderRegion is the request header declaring the caller's region for
// region-based channel routing, e.g. `X-Region: cn`.
const HeaderRegion = "X-Region"
Loading
Loading