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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ docker run --name new-api -d --restart always \

🎉 After deployment is complete, visit `http://localhost:3000` to start using!

If you want to serve the app behind a sub-path such as `http://localhost:3000/new-api`, set `APP_BASE_PATH=/new-api` before starting the service. When you also configure `ServerAddress` for OAuth or payment callbacks, include the same sub-path in that value. If `FRONTEND_BASE_URL` is used to redirect web traffic to a separate frontend host, the backend appends the original request URI to it; with `APP_BASE_PATH=/new-api`, set `FRONTEND_BASE_URL=https://cdn.example.com` so `/new-api/console` redirects to `https://cdn.example.com/new-api/console`, and do not include `/new-api` in both variables.

📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/en/docs/installation)

---
Expand Down
2 changes: 2 additions & 0 deletions README.zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ docker run --name new-api -d --restart always \

🎉 部署完成后,访问 `http://localhost:3000` 即可使用!

如果你希望通过 `http://localhost:3000/new-api` 这样的子路径访问服务,请在启动前设置 `APP_BASE_PATH=/new-api`。若你还配置了用于 OAuth 或支付回调的 `ServerAddress`,请确保它也包含相同的子路径。若使用 `FRONTEND_BASE_URL` 将网页流量重定向到独立前端域名,后端会把原始请求 URI 追加到该地址后面;当 `APP_BASE_PATH=/new-api` 时,通常设置 `FRONTEND_BASE_URL=https://cdn.example.com`,这样 `/new-api/console` 会重定向到 `https://cdn.example.com/new-api/console`,不要在两个变量里重复配置同一个 `/new-api`。

📖 更多部署方式请参考 [部署指南](https://docs.newapi.pro/zh/docs/installation)

---
Expand Down
82 changes: 82 additions & 0 deletions common/base_path.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package common

import (
"fmt"
"path"
"strings"
)

var AppBasePath = ""

func NormalizeBasePath(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" || trimmed == "/" {
return "", nil
}
if !strings.HasPrefix(trimmed, "/") {
return "", fmt.Errorf("APP_BASE_PATH must start with '/'")
}
if strings.ContainsAny(trimmed, "?#") {
return "", fmt.Errorf("APP_BASE_PATH must not contain query or fragment")
}

normalized := strings.TrimRight(trimmed, "/")
if normalized == "" {
return "", nil
}
cleaned := path.Clean(normalized)
if cleaned != normalized {
return "", fmt.Errorf("APP_BASE_PATH contains invalid path segments")
}
return normalized, nil
}

func SessionCookiePath() string {
if AppBasePath == "" {
return "/"
}
return AppBasePath
}

func WithAppBasePath(routePath string) string {
if AppBasePath == "" {
if routePath == "" {
return "/"
}
return routePath
}

if routePath == "" || routePath == "/" {
return AppBasePath
}

normalized := routePath
if !strings.HasPrefix(normalized, "/") {
normalized = "/" + normalized
}
if normalized == AppBasePath || strings.HasPrefix(normalized, AppBasePath+"/") {
return normalized
}
return AppBasePath + normalized
}

func StripAppBasePath(requestPath string) (string, bool) {
if AppBasePath == "" {
if requestPath == "" {
return "/", true
}
return requestPath, true
}

if requestPath == AppBasePath {
return "/", true
}
if strings.HasPrefix(requestPath, AppBasePath+"/") {
stripped := strings.TrimPrefix(requestPath, AppBasePath)
if stripped == "" {
return "/", true
}
return stripped, true
}
return "", false
}
69 changes: 69 additions & 0 deletions common/base_path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package common

import "testing"

func TestNormalizeBasePath(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr bool
}{
{name: "empty", input: "", want: ""},
{name: "root", input: "/", want: ""},
{name: "single segment", input: "/new-api", want: "/new-api"},
{name: "strip trailing slash", input: "/new-api/", want: "/new-api"},
{name: "nested path", input: "/foo/bar", want: "/foo/bar"},
{name: "missing leading slash", input: "new-api", wantErr: true},
{name: "double slash", input: "/foo//bar", wantErr: true},
{name: "dot segment", input: "/foo/./bar", wantErr: true},
{name: "dot dot segment", input: "/foo/../bar", wantErr: true},
{name: "query fragment", input: "/foo?bar", wantErr: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := NormalizeBasePath(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got nil and %q", got)
}
return
}
if err != nil {
t.Fatalf("NormalizeBasePath() error = %v", err)
}
if got != tt.want {
t.Fatalf("NormalizeBasePath() = %q, want %q", got, tt.want)
}
})
}
}

func TestWithAppBasePath(t *testing.T) {
original := AppBasePath
t.Cleanup(func() {
AppBasePath = original
})

AppBasePath = "/new-api"

tests := []struct {
name string
path string
want string
}{
{name: "root", path: "/", want: "/new-api"},
{name: "console", path: "/console", want: "/new-api/console"},
{name: "already prefixed", path: "/new-api/console", want: "/new-api/console"},
{name: "empty", path: "", want: "/new-api"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := WithAppBasePath(tt.path); got != tt.want {
t.Fatalf("WithAppBasePath() = %q, want %q", got, tt.want)
}
})
}
}
3 changes: 1 addition & 2 deletions common/embed-file-system.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package common

import (
"embed"
"io/fs"
"net/http"
"os"
Expand Down Expand Up @@ -32,7 +31,7 @@ func (e *embedFileSystem) Open(name string) (http.File, error) {
return e.FileSystem.Open(name)
}

func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
func EmbedFolder(fsEmbed fs.FS, targetPath string) static.ServeFileSystem {
efs, err := fs.Sub(fsEmbed, targetPath)
if err != nil {
panic(err)
Expand Down
5 changes: 5 additions & 0 deletions common/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ func InitEnv() {
if os.Getenv("SQLITE_PATH") != "" {
SQLitePath = os.Getenv("SQLITE_PATH")
}
appBasePath, err := NormalizeBasePath(os.Getenv("APP_BASE_PATH"))
if err != nil {
log.Fatalf("invalid APP_BASE_PATH: %v", err)
}
AppBasePath = appBasePath
if *LogDir != "" {
var err error
*LogDir, err = filepath.Abs(*LogDir)
Expand Down
12 changes: 10 additions & 2 deletions common/sys_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,20 @@ func LogStartupSuccess(startTime time.Time, port string) {
fmt.Fprintf(gin.DefaultWriter, "\n")

if !IsRunningInContainer() {
fmt.Fprintf(gin.DefaultWriter, " \033[1mLocal:\033[0m http://localhost:%s/\n", port)
fmt.Fprintf(gin.DefaultWriter, " -> \033[1mLocal:\033[0m %s\n", startupURL("localhost", port))
}

for _, ip := range networkIps {
fmt.Fprintf(gin.DefaultWriter, " \033[1mNetwork:\033[0m http://%s:%s/\n", ip, port)
fmt.Fprintf(gin.DefaultWriter, " -> \033[1mNetwork:\033[0m %s\n", startupURL(ip, port))
}

fmt.Fprintf(gin.DefaultWriter, "\n")
}

func startupURL(host string, port string) string {
rootPath := "/"
if AppBasePath != "" {
rootPath = AppBasePath + "/"
}
return fmt.Sprintf("http://%s:%s%s", host, port, rootPath)
}
37 changes: 37 additions & 0 deletions common/sys_log_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package common

import "testing"

func TestStartupURL(t *testing.T) {
original := AppBasePath
t.Cleanup(func() {
AppBasePath = original
})

tests := []struct {
name string
basePath string
want string
}{
{
name: "without base path",
basePath: "",
want: "http://localhost:3000/",
},
{
name: "with base path",
basePath: "/app",
want: "http://localhost:3000/app/",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
AppBasePath = tt.basePath

if got := startupURL("localhost", "3000"); got != tt.want {
t.Fatalf("startupURL() = %q, want %q", got, tt.want)
}
})
}
}
2 changes: 1 addition & 1 deletion controller/telegram.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func TelegramBind(c *gin.Context) {
return
}

c.Redirect(302, "/console/personal")
c.Redirect(302, common.WithAppBasePath("/console/personal"))
}

func TelegramLogin(c *gin.Context) {
Expand Down
5 changes: 4 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# Quick Start:
# 1. docker-compose up -d
# 2. Access at http://localhost:3000
# 3. Optional: set APP_BASE_PATH=/new-api to serve the UI and API under /new-api
#
# Using MySQL instead of PostgreSQL:
# 1. Comment out the postgres service and SQL_DSN line 15
Expand Down Expand Up @@ -33,6 +34,8 @@ services:
- ERROR_LOG_ENABLED=true # 是否启用错误日志记录 (Whether to enable error log recording)
- BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update)
- NODE_NAME=new-api-node-1 # 节点名称,用于审计日志中标识节点身份;多节点/容器部署时建议设置 (Node name used in audit logs; recommended when running multiple instances or in containers)
# - APP_BASE_PATH=/new-api # 可选:将前端和 API 都挂载到子路径,例如 /new-api (Optional: serve the UI and API under a sub-path such as /new-api)
# - SERVER_ADDRESS=http://localhost:3000/new-api # 若使用 OAuth/支付回调,建议包含同样的子路径 (Include the same sub-path when configuring OAuth/payment callback URLs)
# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions)
# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! (multi-node deployment, set this to a random string!!!!!!!)
# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
Expand All @@ -47,7 +50,7 @@ services:
networks:
- new-api-network
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"]
test: ["CMD-SHELL", "wget -q -O - \"http://localhost:3000$${APP_BASE_PATH}/api/status\" | grep -o '\"success\":\\s*true' || exit 1"]
interval: 30s
timeout: 10s
retries: 3
Expand Down
39 changes: 38 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"embed"
"fmt"
"html"
"log"
"net/http"
"os"
Expand Down Expand Up @@ -171,14 +172,15 @@ func main() {
// Initialize session store
store := cookie.NewStore([]byte(common.SessionSecret))
store.Options(sessions.Options{
Path: "/",
Path: common.SessionCookiePath(),
MaxAge: 2592000, // 30 days
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteStrictMode,
})
server.Use(sessions.Sessions("session", store))

InjectAppBasePath()
InjectUmamiAnalytics()
InjectGoogleAnalytics()

Expand All @@ -198,6 +200,41 @@ func main() {
}
}

func InjectAppBasePath() {
indexPage = injectAppBasePath(indexPage, common.AppBasePath)
}

func injectAppBasePath(page []byte, appBasePath string) []byte {
page = bytes.ReplaceAll(
page,
[]byte(`"__APP_BASE_PATH_PLACEHOLDER__"`),
[]byte(strconv.Quote(appBasePath)),
)
return injectAppBaseHref(page, appBaseHref(appBasePath))
}

func injectAppBaseHref(page []byte, href string) []byte {
baseTag := []byte(`<base href="` + html.EscapeString(href) + `" />`)
for _, candidate := range []string{
`<base href="./" />`,
`<base href="/" />`,
`<base href="%BASE_URL%" />`,
`<base href="__APP_BASE_PATH_HREF_PLACEHOLDER__" />`,
} {
if bytes.Contains(page, []byte(candidate)) {
return bytes.Replace(page, []byte(candidate), baseTag, 1)
}
}
return bytes.Replace(page, []byte(`<head>`), []byte("<head>\n "+string(baseTag)), 1)
}

func appBaseHref(appBasePath string) string {
if appBasePath == "" {
return "/"
}
return strings.TrimRight(appBasePath, "/") + "/"
}

func InjectUmamiAnalytics() {
analyticsInjectBuilder := &strings.Builder{}
if os.Getenv("UMAMI_WEBSITE_ID") != "" {
Expand Down
Loading