Files
2026-07-30 15:18:10 +08:00

517 lines
18 KiB
Go

package api
import (
"compress/gzip"
"embed"
"io"
"io/fs"
"log/slog"
"mime"
"net/http"
"path"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"litepan/internal/account"
"litepan/internal/adminauth"
"litepan/internal/apikey"
"litepan/internal/auth"
"litepan/internal/automation"
"litepan/internal/cache"
"litepan/internal/cacheretention"
"litepan/internal/crosstransfer"
"litepan/internal/domain"
"litepan/internal/embyproxy"
"litepan/internal/favorites"
"litepan/internal/file"
"litepan/internal/fnosproxy"
"litepan/internal/fusemount"
"litepan/internal/logx"
"litepan/internal/mediaorganize"
"litepan/internal/notification"
"litepan/internal/offlinedownload"
"litepan/internal/playback"
"litepan/internal/settings"
"litepan/internal/share/dav"
"litepan/internal/strm"
"litepan/internal/strmscrape"
"litepan/internal/upload"
)
//go:embed web
var webFS embed.FS
// Deps 是 API 层所需依赖(只依赖接口/服务,不感知具体存储实现)。
type Deps struct {
Logs *logx.Manager
AccountSvc *account.Service
Accounts domain.AccountRepository
Configs domain.ConfigRepository
Settings *settings.Service
Cache *cache.Service
ListHitTracker *cache.HitTracker
Files *file.Service
Favorites *favorites.Service
Uploads *upload.Manager
OfflineDownloads *offlinedownload.Service
Playback *playback.Service
Strm *strm.Service
CacheRetention *cacheretention.Service
MediaOrganize *mediaorganize.Service
StrmScrape *strmscrape.Service
Automation *automation.Service
Fuse *fusemount.Service
CrossTransfer *crosstransfer.Service
EmbyProxy *embyproxy.Service
FnosProxy *fnosproxy.Service
ApiKeys *apikey.Service
Auth *auth.Service
AuthSched *auth.Scheduler
AdminAuth *adminauth.Service
Notifications *notification.Service
DataDir string
OnSettingsUpdated func(map[string]string)
}
// Handler 持有处理请求所需的依赖。
type Handler struct {
logs *logx.Manager
log *slog.Logger
accountSvc *account.Service
settings *settings.Service
cache *cache.Service
listHits *cache.HitTracker
files *file.Service
favorites *favorites.Service
uploads *upload.Manager
offlineDownloads *offlinedownload.Service
playback *playback.Service
strm *strm.Service
cacheRetention *cacheretention.Service
mediaOrganize *mediaorganize.Service
strmScrape *strmscrape.Service
automation *automation.Service
fuse *fusemount.Service
crossTransfer *crosstransfer.Service
embyProxy *embyproxy.Service
fnosProxy *fnosproxy.Service
apiKeys *apikey.Service
auth *auth.Service
authSched *auth.Scheduler
adminAuth *adminauth.Service
notifications *notification.Service
onSettingsUpdated func(map[string]string)
}
// NewRouter 装配并返回 HTTP 路由(含内嵌管理页面)。
func NewRouter(d Deps) http.Handler {
apiLog := slog.Default()
if d.Logs != nil {
apiLog = d.Logs.For(logx.ModuleAPI)
}
h := &Handler{
logs: d.Logs,
log: apiLog,
accountSvc: d.AccountSvc,
settings: d.Settings,
cache: d.Cache,
listHits: d.ListHitTracker,
files: d.Files,
favorites: d.Favorites,
uploads: d.Uploads,
offlineDownloads: d.OfflineDownloads,
playback: d.Playback,
strm: d.Strm,
cacheRetention: d.CacheRetention,
mediaOrganize: d.MediaOrganize,
strmScrape: d.StrmScrape,
automation: d.Automation,
fuse: d.Fuse,
crossTransfer: d.CrossTransfer,
embyProxy: d.EmbyProxy,
fnosProxy: d.FnosProxy,
apiKeys: d.ApiKeys,
auth: d.Auth,
authSched: d.AuthSched,
adminAuth: d.AdminAuth,
notifications: d.Notifications,
onSettingsUpdated: d.OnSettingsUpdated,
}
r := chi.NewRouter()
r.Use(trackResponseCommit)
r.Use(chimw.RequestID)
r.Use(h.attachRequestLogger)
r.Use(chimw.Recoverer)
r.Route("/api", func(r chi.Router) {
r.Get("/health", h.health)
r.Get("/auth/status", h.authStatus)
r.Post("/auth/login", h.authLogin)
r.Post("/auth/logout", h.authLogout)
r.Post("/auth/reset-password", h.authResetPassword)
r.Get("/strm/play/{account_id}/{file_key}/t/{token}/n/{filename}", h.strmPlay)
r.Head("/strm/play/{account_id}/{file_key}/t/{token}/n/{filename}", h.strmPlay)
r.Get("/strm/play/{account_id}/{file_key}/t/{token}/n/{filename}/s/{signature}", h.strmPlay)
r.Head("/strm/play/{account_id}/{file_key}/t/{token}/n/{filename}/s/{signature}", h.strmPlay)
r.Route("/public", func(r chi.Router) {
r.Use(h.requirePublicOrAdmin)
r.Get("/accounts", h.publicAccounts)
r.Get("/system-config", h.publicSystemConfig)
r.Get("/cache/hit-rate", h.publicCacheHitRate)
})
r.Route("/open", func(r chi.Router) {
r.Post("/automation/events", h.automationWebhook)
})
r.Group(func(r chi.Router) {
r.Use(h.requireAdmin)
r.Route("/cross-transfer", func(r chi.Router) {
r.Get("/routes", h.crossTransferRoutes)
r.Post("/scan", h.crossTransferScan)
r.Post("/scan/stream", h.crossTransferScanStream)
r.Post("/probe", h.crossTransferProbe)
r.Post("/execute", h.crossTransferExecute)
r.Get("/relay/tasks", h.crossTransferRelayTasks)
r.Get("/relay/tasks/stream", h.crossTransferRelayStream)
r.Post("/relay/tasks/batch-delete", h.crossTransferRelayBatchDelete)
})
r.Get("/logs", h.listLogs)
r.Get("/logs/stats", h.logStats)
r.Post("/logs/cleanup", h.cleanupLogs)
r.Post("/logs/cleanup/keep-today", h.cleanupLogsKeepToday)
r.Post("/logs/cleanup/all", h.cleanupLogsAll)
r.Route("/admin", func(r chi.Router) {
r.Get("/system-config", h.adminSystemConfig)
r.Post("/update-credentials", h.adminUpdateCredentials)
r.Post("/webdav-config", h.adminWebDAVConfig)
r.Get("/emby/config", h.getEmbyConfig)
r.Put("/emby/config", h.updateEmbyConfig)
r.Post("/emby/test", h.testEmbyConfig)
r.Post("/emby/refresh", h.refreshEmbyLibrary)
r.Get("/fnos/config", h.getFnosConfig)
r.Put("/fnos/config", h.updateFnosConfig)
r.Post("/fnos/test", h.testFnosConfig)
r.Get("/local-fs/browse", h.browseLocalFS)
r.Get("/drivers", h.listDrivers)
r.Get("/accounts", h.listAccounts)
r.Post("/accounts", h.createAccount)
r.Get("/accounts/{id}", h.getAccount)
r.Put("/accounts/{id}", h.updateAccount)
r.Delete("/accounts/{id}", h.deleteAccount)
r.Post("/accounts/{id}/toggle", h.toggleAccount)
r.Post("/accounts/{id}/set-default", h.setDefaultAccount)
r.Post("/accounts/{id}/refresh-auth", h.refreshAccountAuth)
r.Get("/settings", h.getSettings)
r.Put("/settings", h.updateSettings)
r.Route("/api-keys", func(r chi.Router) {
r.Get("/", h.listApiKeys)
r.Post("/", h.createApiKey)
r.Post("/strm/rotate", h.rotateStrmKey)
r.Put("/{id}", h.updateApiKey)
r.Post("/{id}/toggle", h.toggleApiKey)
r.Delete("/{id}", h.deleteApiKey)
})
r.Get("/cache/stats", h.cacheStats)
r.Get("/cache/stats/{id}", h.accountCacheStats)
r.Post("/clear-cache", h.clearCache)
r.Route("/cache-retention", func(r chi.Router) {
r.Get("/configs", h.listRetentionTasks)
r.Get("/stats", h.getRetentionStats)
r.Get("/defaults", h.retentionDefaults)
r.Get("/startup", h.retentionStartupRemaining)
r.Post("/configs", h.createRetentionTask)
r.Put("/configs/{id}", h.updateRetentionTask)
r.Delete("/configs/{id}", h.deleteRetentionTask)
r.Post("/configs/{id}/toggle", h.toggleRetentionTask)
r.Post("/configs/{id}/refresh", h.refreshRetentionTask)
r.Post("/configs/{id}/force-stop", h.forceStopRetentionTask)
r.Post("/configs/{id}/ack-scope-warn", h.ackRetentionScopeWarn)
})
r.Get("/notifications", h.listNotifications)
r.Get("/notifications/unread-count", h.notificationUnreadCount)
r.Post("/notifications/read-all", h.markAllNotificationsRead)
r.Delete("/notifications", h.deleteAllNotifications)
r.Post("/notifications/{id}/read", h.markNotificationRead)
r.Delete("/notifications/{id}", h.deleteNotification)
r.Route("/strm", func(r chi.Router) {
r.Get("/startup", h.strmStartupRemaining)
r.Get("/tasks", h.listStrmTasks)
r.Post("/tasks", h.createStrmTask)
r.Put("/tasks/{id}", h.updateStrmTask)
r.Delete("/tasks/{id}", h.deleteStrmTask)
r.Post("/tasks/{id}/toggle", h.toggleStrmTask)
r.Post("/tasks/{id}/run", h.runStrmTaskNow)
r.Post("/tasks/{id}/force-stop", h.forceStopStrmTask)
r.Get("/tasks/{id}/branches", h.listStrmBranches)
r.Post("/tasks/{id}/branches", h.createStrmBranch)
r.Put("/tasks/{id}/branches/{branch_id}", h.updateStrmBranch)
r.Delete("/tasks/{id}/branches/{branch_id}", h.deleteStrmBranch)
r.Get("/settings", h.getStrmSettings)
r.Put("/settings", h.updateStrmSettings)
r.Post("/replace-base-url", h.replaceStrmBaseURL)
r.Post("/tasks/precheck-account-repair", h.precheckStrmAccountRepair)
r.Post("/tasks/repair-account-references", h.repairStrmAccountReferences)
r.Post("/generate-current-directory", h.generateCurrentDirectoryStrm)
r.Post("/directory-status", h.checkStrmDirectoryStatus)
})
r.Route("/media-organize", func(r chi.Router) {
r.Get("/tasks", h.listMediaOrganizeTasks)
r.Post("/tasks", h.createMediaOrganizeTask)
r.Put("/tasks/{id}", h.updateMediaOrganizeTask)
r.Delete("/tasks/{id}", h.deleteMediaOrganizeTask)
r.Post("/tasks/{id}/plan", h.planMediaOrganizeTask)
r.Get("/tasks/{id}/plan", h.getMediaOrganizePlan)
r.Delete("/tasks/{id}/plan", h.deleteMediaOrganizePlan)
r.Put("/tasks/{id}/plan/actions/{action_id}", h.updateMediaOrganizePlanAction)
r.Delete("/tasks/{id}/plan/actions/{action_id}", h.deleteMediaOrganizePlanAction)
r.Post("/tasks/{id}/plan/actions/batch-delete", h.batchDeleteMediaOrganizePlanActions)
r.Post("/tasks/{id}/apply", h.applyMediaOrganizeTask)
r.Post("/tasks/{id}/run", h.runMediaOrganizeTask)
r.Post("/tasks/{id}/stop", h.stopMediaOrganizeTask)
r.Get("/tasks/{id}/logs", h.getMediaOrganizeLogs)
r.Get("/tasks/{id}/progress", h.getMediaOrganizeProgress)
r.Get("/settings", h.getMediaOrganizeSettings)
r.Put("/settings", h.updateMediaOrganizeSettings)
r.Get("/guess-file", h.guessMediaOrganizeFile)
r.Post("/test-tmdb", h.testMediaOrganizeTMDB)
r.Get("/search-tmdb", h.searchMediaOrganizeTMDB)
})
r.Route("/strm-scrape", func(r chi.Router) {
r.Get("/settings", h.getStrmScrapeSettings)
r.Put("/settings", h.updateStrmScrapeSettings)
r.Post("/run", h.runStrmScrape)
r.Post("/stop", h.stopStrmScrape)
r.Get("/progress", h.getStrmScrapeProgress)
r.Get("/items", h.listStrmScrapeItems)
r.Post("/refresh-index", h.refreshStrmScrapeIndex)
r.Post("/rematch", h.rematchStrmScrapeItem)
r.Post("/rescrape", h.rescrapeStrmScrapeItem)
r.Post("/mark-normal", h.markStrmScrapeNormal)
r.Get("/poster", h.getStrmScrapePoster)
})
r.Route("/automation", func(r chi.Router) {
r.Get("/rules", h.listAutomationRules)
r.Post("/rules", h.createAutomationRule)
r.Put("/rules/{id}", h.updateAutomationRule)
r.Delete("/rules/{id}", h.deleteAutomationRule)
r.Post("/rules/{id}/toggle", h.toggleAutomationRule)
r.Post("/rules/{id}/run", h.runAutomationRule)
r.Post("/validate", h.validateAutomationRule)
r.Get("/runs", h.listAutomationRuns)
r.Post("/runs/clear", h.clearAutomationRuns)
r.Get("/options", h.automationOptions)
})
r.Route("/fuse", func(r chi.Router) {
r.Get("/status", h.fuseStatus)
r.Put("/config", h.updateFuseConfig)
r.Get("/read-cache", h.getFuseReadCache)
r.Put("/read-cache", h.updateFuseReadCache)
r.Post("/read-cache/clear", h.clearFuseReadCache)
r.Get("/mounts", h.listFuseMounts)
r.Post("/mounts", h.createFuseMount)
r.Put("/mounts/{id}", h.updateFuseMount)
r.Delete("/mounts/{id}", h.deleteFuseMount)
r.Post("/mounts/{id}/mount", h.mountFuse)
r.Post("/mounts/{id}/unmount", h.unmountFuse)
})
})
r.Post("/oauth/start", h.startOAuth)
r.Get("/oauth/status/{session_id}", h.oauthStatus)
r.Post("/oauth/confirm-received/{session_id}", h.oauthConfirmReceived)
r.Post("/qr/start", h.startQRLogin)
r.Post("/qr/poll", h.pollQRLogin)
})
r.Route("/files", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(h.requirePublicOrAdmin)
r.Get("/list", h.listFiles)
r.Get("/info", h.fileInfo)
r.Get("/download", h.downloadFile)
r.Head("/download", h.downloadFile)
})
r.Group(func(r chi.Router) {
r.Use(h.requireAdmin)
r.Delete("/delete", h.deleteFiles)
r.Post("/move", h.moveFiles)
r.Post("/copy", h.copyFiles)
r.Put("/rename", h.renameFile)
r.Get("/favorites", h.getFavorites)
r.Put("/favorites", h.saveFavorites)
r.Post("/name-align/preview", h.previewNameAlign)
r.Post("/name-align/apply", h.applyNameAlign)
r.Post("/create-folder", h.createFolder)
r.Post("/upload-task", h.createUploadTask)
r.Get("/upload/runtime", h.getUploadRuntime)
r.Put("/upload/runtime", h.updateUploadRuntime)
r.Get("/upload/tasks", h.listUploadTasks)
r.Get("/upload/tasks/stream", h.streamUploadTasks)
r.Get("/upload/tasks/{taskID}", h.getUploadTask)
r.Post("/upload/tasks/{taskID}/pause", h.pauseUploadTask)
r.Post("/upload/tasks/{taskID}/resume", h.resumeUploadTask)
r.Delete("/upload/tasks/{taskID}", h.deleteUploadTask)
r.Post("/upload/tasks/batch-delete", h.batchDeleteUploadTasks)
r.Route("/offline-download", func(r chi.Router) {
r.Get("/capabilities", h.offlineDownloadCapabilities)
r.Post("/urls", h.addOfflineURLs)
r.Post("/torrent/prepare", h.prepareOfflineTorrent)
r.Post("/torrent", h.addOfflineTorrent)
r.Get("/tasks", h.listOfflineDownloadTasks)
r.Post("/tasks/refresh", h.refreshOfflineDownloadTasks)
r.Post("/tasks/batch-delete", h.batchDeleteOfflineDownloadTasks)
r.Delete("/tasks/{taskID}", h.deleteOfflineDownloadTask)
})
})
})
})
davLog := apiLog
if d.Logs != nil {
davLog = d.Logs.For(logx.ModuleWebDAV)
}
davSrv := dav.New(dav.Deps{
Logs: davLog,
Files: d.Files,
Playback: d.Playback,
Accounts: d.Accounts,
Configs: d.Configs,
Cache: d.Cache,
Settings: d.Settings,
DataDir: d.DataDir,
TempRegistry: d.Uploads.TempRegistry(),
})
sub, err := fs.Sub(webFS, "web")
if err != nil {
panic(err) // 编译期内嵌,理论上不会失败
}
r.Handle("/*", spaHandler(sub))
return davBypass(davSrv, r)
}
// chi 不认 WebDAV 方法,/dav 须在外层旁路
func davBypass(dav http.Handler, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/dav" || strings.HasPrefix(r.URL.Path, "/dav/") {
dav.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
})
}
func spaHandler(fsys fs.FS) http.Handler {
fileServer := http.FileServer(http.FS(fsys))
index, _ := fs.ReadFile(fsys, "index.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if upath == "" {
upath = "index.html"
}
if _, statErr := fs.Stat(fsys, upath); statErr == nil {
setStaticCacheHeader(w, upath)
fileServer.ServeHTTP(w, r)
return
}
if _, statErr := fs.Stat(fsys, upath+".gz"); statErr == nil {
serveCompressedAsset(w, r, fsys, upath)
return
}
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if r.Method != http.MethodHead {
_, _ = w.Write(index)
}
})
}
func serveCompressedAsset(w http.ResponseWriter, r *http.Request, fsys fs.FS, upath string) {
compressed, err := fsys.Open(upath + ".gz")
if err != nil {
http.NotFound(w, r)
return
}
defer compressed.Close()
info, err := compressed.Stat()
if err != nil {
http.Error(w, "静态资源读取失败", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("Content-Type", staticContentType(upath))
w.Header().Set("Vary", "Accept-Encoding")
if acceptsGzip(r.Header.Get("Accept-Encoding")) {
w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
if r.Method != http.MethodHead {
_, _ = io.Copy(w, compressed)
}
return
}
if r.Method == http.MethodHead {
return
}
reader, err := gzip.NewReader(compressed)
if err != nil {
http.Error(w, "静态资源解压失败", http.StatusInternalServerError)
return
}
defer reader.Close()
_, _ = io.Copy(w, reader)
}
func setStaticCacheHeader(w http.ResponseWriter, upath string) {
if strings.HasPrefix(upath, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
return
}
w.Header().Set("Cache-Control", "no-cache")
}
func staticContentType(name string) string {
if contentType := mime.TypeByExtension(path.Ext(name)); contentType != "" {
return contentType
}
switch path.Ext(name) {
case ".mjs", ".js":
return "text/javascript; charset=utf-8"
case ".wasm":
return "application/wasm"
default:
return "application/octet-stream"
}
}
func acceptsGzip(header string) bool {
wildcard := false
for _, value := range strings.Split(header, ",") {
parts := strings.Split(strings.TrimSpace(value), ";")
coding := strings.ToLower(strings.TrimSpace(parts[0]))
quality := 1.0
for _, parameter := range parts[1:] {
key, raw, found := strings.Cut(strings.TrimSpace(parameter), "=")
if !found || !strings.EqualFold(key, "q") {
continue
}
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil {
quality = 0
} else {
quality = parsed
}
}
if coding == "gzip" {
return quality > 0
}
if coding == "*" {
wildcard = quality > 0
}
}
return wildcard
}