Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Healthcheck URL construction is sensitive to APP_BASE_PATH formatting.

The expansion http://localhost:3000${APP_BASE_PATH}/api/status assumes APP_BASE_PATH has no trailing slash. If a user sets APP_BASE_PATH=/new-api/, the resulting URL becomes http://localhost:3000/new-api//api/status, which won't match the Gin routes (backend normalizes AppBasePath by trimming trailing slashes, so the registered route is /new-api/api/status). Consider documenting this constraint in the adjacent comment on line 37, or using a shell normalization in the healthcheck command.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docker-compose.yml` at line 53, The healthcheck command assumes APP_BASE_PATH
has no trailing slash and will break if it does; update the docker-compose.yml
healthcheck to normalize APP_BASE_PATH in-shell (e.g., strip a trailing slash
via POSIX parameter expansion like using "${APP_BASE_PATH%/}" in the wget URL)
or alternatively add a clear comment next to APP_BASE_PATH explaining it must
not end with a slash; target the healthcheck test line and the APP_BASE_PATH
documentation/comment so the URL becomes
http://localhost:3000${NORMALIZED_APP_BASE_PATH}/api/status and always matches
the registered Gin route.

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
Loading