package api import ( "bytes" "context" "crypto/tls" "errors" "fmt" "io" "io/fs" "net/http" "os" "path" "runtime/debug" "strconv" "strings" "time" gqlHandler "github.com/99designs/gqlgen/graphql/handler" gqlExtension "github.com/99designs/gqlgen/graphql/handler/extension" gqlLru "github.com/99designs/gqlgen/graphql/handler/lru" gqlTransport "github.com/99designs/gqlgen/graphql/handler/transport" gqlPlayground "github.com/99designs/gqlgen/graphql/playground" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/go-chi/httplog" "github.com/gorilla/websocket" "github.com/vearutop/statigz" "github.com/vektah/gqlparser/v2/ast" "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/build" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/utils" "github.com/stashapp/stash/ui" ) const ( loginEndpoint = "/login" loginLocaleEndpoint = loginEndpoint + "/locale" logoutEndpoint = "/logout" gqlEndpoint = "/graphql" playgroundEndpoint = "/playground" ) type Server struct { http.Server displayAddress string manager *manager.Manager } // TODO - os.DirFS doesn't implement ReadDir, so re-implement it here // This can be removed when we upgrade go type osFS string func (dir osFS) ReadDir(name string) ([]os.DirEntry, error) { fullname := string(dir) + "/" + name entries, err := os.ReadDir(fullname) if err != nil { var e *os.PathError if errors.As(err, &e) { // See comment in dirFS.Open. e.Path = name } return nil, err } return entries, nil } func (dir osFS) Open(name string) (fs.File, error) { return os.DirFS(string(dir)).Open(name) } // Initialize creates a new [Server] instance. // It assumes that the [manager.Manager] instance has been initialised. func Initialize() (*Server, error) { mgr := manager.GetInstance() cfg := mgr.Config initCustomPerformerImages(cfg.GetCustomPerformerImageLocation()) displayHost := cfg.GetHost() if displayHost == "0.0.0.0" { displayHost = "localhost" } displayAddress := displayHost + ":" + strconv.Itoa(cfg.GetPort()) address := cfg.GetHost() + ":" + strconv.Itoa(cfg.GetPort()) tlsConfig, err := makeTLSConfig(cfg) if err != nil { // assume we don't want to start with a broken TLS configuration return nil, fmt.Errorf("error loading TLS config: %v", err) } if tlsConfig != nil { displayAddress = "https://" + displayAddress + "/" } else { displayAddress = "http://" + displayAddress + "/" } r := chi.NewRouter() server := &Server{ Server: http.Server{ Addr: address, Handler: r, TLSConfig: tlsConfig, // disable http/2 support by default // when http/2 is enabled, we are unable to hijack and close // the connection/request. This is necessary to stop running // streams when deleting a scene file. TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), }, displayAddress: displayAddress, manager: mgr, } r.Use(middleware.Heartbeat("/healthz")) r.Use(cors.AllowAll().Handler) r.Use(authenticateHandler()) visitedPluginHandler := mgr.SessionStore.VisitedPluginHandler() r.Use(visitedPluginHandler) r.Use(middleware.Recoverer) if cfg.GetLogAccess() { httpLogger := httplog.NewLogger("Stash", httplog.Options{ Concise: true, }) r.Use(httplog.RequestLogger(httpLogger)) } r.Use(SecurityHeadersMiddleware) r.Use(middleware.Compress(4)) r.Use(middleware.StripSlashes) r.Use(BaseURLMiddleware) recoverFunc := func(ctx context.Context, err interface{}) error { logger.Error(err) debug.PrintStack() message := fmt.Sprintf("Internal system error. Error <%v>", err) return errors.New(message) } repo := mgr.Repository dataloaders := loaders.Middleware{ Repository: repo, } r.Use(dataloaders.Middleware) pluginCache := mgr.PluginCache sceneService := mgr.SceneService imageService := mgr.ImageService galleryService := mgr.GalleryService groupService := mgr.GroupService resolver := &Resolver{ repository: repo, sceneService: sceneService, imageService: imageService, galleryService: galleryService, groupService: groupService, hookExecutor: pluginCache, } gqlSrv := gqlHandler.New(NewExecutableSchema(Config{Resolvers: resolver})) gqlSrv.SetRecoverFunc(recoverFunc) gqlSrv.AddTransport(gqlTransport.Websocket{ Upgrader: websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, }, KeepAlivePingInterval: 10 * time.Second, }) gqlSrv.AddTransport(gqlTransport.Options{}) gqlSrv.AddTransport(gqlTransport.GET{}) gqlSrv.AddTransport(gqlTransport.POST{}) gqlSrv.AddTransport(gqlTransport.MultipartForm{ MaxUploadSize: cfg.GetMaxUploadSize(), }) gqlSrv.SetQueryCache(gqlLru.New[*ast.QueryDocument](1000)) gqlSrv.Use(gqlExtension.Introspection{}) gqlSrv.SetErrorPresenter(gqlErrorHandler) gqlHandlerFunc := func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") gqlSrv.ServeHTTP(w, r) } // register GQL handler with plugin cache // chain the visited plugin handler // also requires the dataloader middleware gqlHandler := visitedPluginHandler(dataloaders.Middleware(http.HandlerFunc(gqlHandlerFunc))) pluginCache.RegisterGQLHandler(gqlHandler) r.HandleFunc(gqlEndpoint, gqlHandlerFunc) r.HandleFunc(playgroundEndpoint, func(w http.ResponseWriter, r *http.Request) { setPageSecurityHeaders(w, r, pluginCache.ListPlugins()) endpoint := getProxyPrefix(r) + gqlEndpoint gqlPlayground.Handler("GraphQL playground", endpoint, gqlPlayground.WithGraphiqlEnablePluginExplorer(true))(w, r) }) r.Mount("/performer", server.getPerformerRoutes()) r.Mount("/scene", server.getSceneRoutes()) r.Mount("/gallery", server.getGalleryRoutes()) r.Mount("/image", server.getImageRoutes()) r.Mount("/studio", server.getStudioRoutes()) r.Mount("/group", server.getGroupRoutes()) r.Mount("/tag", server.getTagRoutes()) r.Mount("/downloads", server.getDownloadsRoutes()) r.Mount("/plugin", server.getPluginRoutes()) r.HandleFunc("/css", cssHandler(cfg)) r.HandleFunc("/javascript", javascriptHandler(cfg)) r.HandleFunc("/customlocales", customLocalesHandler(cfg)) staticLoginUI := statigz.FileServer(ui.LoginUIBox.(fs.ReadDirFS)) r.Get(loginEndpoint, handleLogin()) r.Post(loginEndpoint, handleLoginPost()) r.Get(logoutEndpoint, handleLogout()) r.Get(loginLocaleEndpoint, handleLoginLocale(cfg)) r.HandleFunc(loginEndpoint+"/*", func(w http.ResponseWriter, r *http.Request) { r.URL.Path = strings.TrimPrefix(r.URL.Path, loginEndpoint) w.Header().Set("Cache-Control", "no-cache") staticLoginUI.ServeHTTP(w, r) }) // Serve static folders customServedFolders := cfg.GetCustomServedFolders() if customServedFolders != nil { r.Mount("/custom", getCustomRoutes(customServedFolders)) } var uiFS fs.FS var staticUI *statigz.Server customUILocation := cfg.GetUILocation() if customUILocation != "" { logger.Debugf("Serving UI from %s", customUILocation) uiFS = osFS(customUILocation) staticUI = statigz.FileServer(uiFS.(fs.ReadDirFS)) } else { logger.Debug("Serving embedded UI") uiFS = ui.UIBox staticUI = statigz.FileServer(ui.UIBox.(fs.ReadDirFS)) } // Serve the web app r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { ext := path.Ext(r.URL.Path) if ext == ".html" || ext == "" { w.Header().Set("Content-Type", "text/html") setPageSecurityHeaders(w, r, pluginCache.ListPlugins()) } if ext == "" || r.URL.Path == "/" || r.URL.Path == "/index.html" { themeColor := cfg.GetThemeColor() data, err := fs.ReadFile(uiFS, "index.html") if err != nil { panic(err) } indexHtml := string(data) prefix := getProxyPrefix(r) indexHtml = strings.ReplaceAll(indexHtml, "%COLOR%", themeColor) indexHtml = strings.Replace(indexHtml, `