mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-10 19:27:41 +08:00
164 lines
5.4 KiB
Go
164 lines
5.4 KiB
Go
package webui
|
|
|
|
import (
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"ds2api/internal/config"
|
|
)
|
|
|
|
const welcomeHTML = `<!DOCTYPE html>
|
|
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>DS2API</title>
|
|
<style>body{font-family:Inter,system-ui,sans-serif;background:#030712;color:#f9fafb;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}a{color:#f59e0b;text-decoration:none}main{max-width:700px;padding:24px;text-align:center}h1{font-size:48px;margin:0 0 12px}.links{display:flex;gap:16px;justify-content:center;margin-top:20px;flex-wrap:wrap}</style>
|
|
</head><body><main><h1>DS2API</h1><p>DeepSeek to OpenAI & Claude Compatible API</p><div class="links"><a href="/admin">管理面板</a><a href="/v1/models">API 状态</a><a href="https://github.com/CJackHwang/ds2api" target="_blank">GitHub</a></div></main></body></html>`
|
|
|
|
type Handler struct {
|
|
StaticDir string
|
|
}
|
|
|
|
func NewHandler() *Handler {
|
|
return &Handler{StaticDir: resolveStaticAdminDir(config.StaticAdminDir())}
|
|
}
|
|
|
|
func RegisterRoutes(r chi.Router, h *Handler) {
|
|
r.Get("/", h.index)
|
|
r.Get("/admin", h.admin)
|
|
}
|
|
|
|
func (h *Handler) HandleAdminFallback(w http.ResponseWriter, r *http.Request) bool {
|
|
if r.Method != http.MethodGet {
|
|
return false
|
|
}
|
|
if !strings.HasPrefix(r.URL.Path, "/admin/") {
|
|
return false
|
|
}
|
|
h.admin(w, r)
|
|
return true
|
|
}
|
|
|
|
func (h *Handler) index(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(welcomeHTML))
|
|
}
|
|
|
|
func (h *Handler) admin(w http.ResponseWriter, r *http.Request) {
|
|
staticDir := resolveStaticAdminDir(h.StaticDir)
|
|
if fi, err := os.Stat(staticDir); err == nil && fi.IsDir() {
|
|
h.serveFromDisk(w, r, staticDir)
|
|
return
|
|
}
|
|
http.Error(w, "WebUI not built. Run `cd webui && npm run build` first.", http.StatusNotFound)
|
|
}
|
|
|
|
// staticContentTypes pins the Content-Type of common WebUI assets so we do not
|
|
// rely on mime.TypeByExtension, which on Windows consults the registry and can
|
|
// return the wrong type (e.g. application/xml for .css) when third-party
|
|
// software has overwritten HKEY_CLASSES_ROOT entries. Browsers strictly enforce
|
|
// stylesheet/script MIME types and will refuse to apply a misidentified asset,
|
|
// breaking the /admin page on affected machines.
|
|
var staticContentTypes = map[string]string{
|
|
".css": "text/css; charset=utf-8",
|
|
".js": "text/javascript; charset=utf-8",
|
|
".mjs": "text/javascript; charset=utf-8",
|
|
".html": "text/html; charset=utf-8",
|
|
".htm": "text/html; charset=utf-8",
|
|
".json": "application/json; charset=utf-8",
|
|
".map": "application/json; charset=utf-8",
|
|
".svg": "image/svg+xml",
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".gif": "image/gif",
|
|
".webp": "image/webp",
|
|
".ico": "image/x-icon",
|
|
".woff": "font/woff",
|
|
".woff2": "font/woff2",
|
|
".ttf": "font/ttf",
|
|
".otf": "font/otf",
|
|
".txt": "text/plain; charset=utf-8",
|
|
".wasm": "application/wasm",
|
|
}
|
|
|
|
// setStaticContentType pins the response Content-Type by file extension so that
|
|
// http.ServeFile does not fall back to mime.TypeByExtension (which on Windows
|
|
// reads the registry and may return an incorrect type).
|
|
func setStaticContentType(w http.ResponseWriter, fullPath string) {
|
|
ext := strings.ToLower(filepath.Ext(fullPath))
|
|
if ct, ok := staticContentTypes[ext]; ok {
|
|
w.Header().Set("Content-Type", ct)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDir string) {
|
|
root := filepath.Clean(staticDir)
|
|
path := strings.TrimPrefix(r.URL.Path, "/admin")
|
|
path = strings.TrimPrefix(path, "/")
|
|
if path != "" && strings.Contains(path, ".") {
|
|
full := filepath.Join(root, filepath.Clean(path))
|
|
if full != root && !strings.HasPrefix(full, root+string(os.PathSeparator)) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if _, err := os.Stat(full); err == nil {
|
|
if strings.HasPrefix(path, "assets/") {
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
} else {
|
|
w.Header().Set("Cache-Control", "no-store, must-revalidate")
|
|
}
|
|
setStaticContentType(w, full)
|
|
http.ServeFile(w, r, full)
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
index := filepath.Join(root, "index.html")
|
|
if _, err := os.Stat(index); err != nil {
|
|
http.Error(w, "index.html not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store, must-revalidate")
|
|
setStaticContentType(w, index)
|
|
http.ServeFile(w, r, index)
|
|
}
|
|
|
|
func resolveStaticAdminDir(preferred string) string {
|
|
if strings.TrimSpace(os.Getenv("DS2API_STATIC_ADMIN_DIR")) != "" {
|
|
return filepath.Clean(preferred)
|
|
}
|
|
candidates := []string{preferred}
|
|
if wd, err := os.Getwd(); err == nil {
|
|
candidates = append(candidates, filepath.Join(wd, "static/admin"))
|
|
}
|
|
if exe, err := os.Executable(); err == nil {
|
|
exeDir := filepath.Dir(exe)
|
|
candidates = append(candidates,
|
|
filepath.Join(exeDir, "static/admin"),
|
|
filepath.Join(filepath.Dir(exeDir), "static/admin"),
|
|
)
|
|
}
|
|
// Common serverless locations.
|
|
candidates = append(candidates, "/var/task/static/admin", "/var/task/user/static/admin")
|
|
|
|
seen := map[string]struct{}{}
|
|
for _, c := range candidates {
|
|
c = filepath.Clean(strings.TrimSpace(c))
|
|
if c == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[c]; ok {
|
|
continue
|
|
}
|
|
seen[c] = struct{}{}
|
|
if fi, err := os.Stat(c); err == nil && fi.IsDir() {
|
|
return c
|
|
}
|
|
}
|
|
return filepath.Clean(preferred)
|
|
}
|