mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
Added an onboarding flow
This commit is contained in:
42
README.md
42
README.md
@@ -6,23 +6,15 @@
|
|||||||
|
|
||||||
See a demo [here](https://vimeo.com/275537038) (password is stashapp).
|
See a demo [here](https://vimeo.com/275537038) (password is stashapp).
|
||||||
|
|
||||||
TODO: This is not match the features of the Rails project quite yet. Consider using that until this project is complete.
|
TODO: This does not match the features of the Rails project quite yet and is still a little buggy. Fall back to the Rails project if you run into issues as an existing user.
|
||||||
|
|
||||||
## Setup
|
# Install
|
||||||
|
|
||||||
TODO: This is not final. There is more work to be done to ease this process.
|
Stash supports macOS, Windows, and Linux. Download the [latest release here](https://github.com/stashapp/stash/releases).
|
||||||
|
|
||||||
### OSX / Linux
|
Simply run the executable and navigate to either https://localhost:9999 or http://localhost:9998 to get started.
|
||||||
|
|
||||||
1. `mkdir ~/.stash` && `cd ~/.stash`
|
*Note for Windows users:* Running the app might present a security prompt since the binary isn't signed yet. Just click more info and then the run anyway button.
|
||||||
2. Create a `config.json` file (see below).
|
|
||||||
3. Run stash with `./stash` and visit `http://localhost:9998` or `https://localhost:9999`
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
|
|
||||||
1. Create a new folder at `C:\Users\YourUsername\.stash`
|
|
||||||
2. Create a `config.json` file (see below)
|
|
||||||
3. Run stash with `./stash` and visit `http://localhost:9998` or `https://localhost:9999`
|
|
||||||
|
|
||||||
#### FFMPEG
|
#### FFMPEG
|
||||||
|
|
||||||
@@ -34,29 +26,11 @@ If stash is unable to find or download FFMPEG then download it yourself from the
|
|||||||
|
|
||||||
The `ffmpeg(.exe)` and `ffprobe(.exe)` files should be placed in `~/.stash` on macOS / Linux or `C:\Users\YourUsername\.stash` on Windows.
|
The `ffmpeg(.exe)` and `ffprobe(.exe)` files should be placed in `~/.stash` on macOS / Linux or `C:\Users\YourUsername\.stash` on Windows.
|
||||||
|
|
||||||
#### Config.json
|
# FAQ
|
||||||
|
|
||||||
Example:
|
> Does stash support multiple folders?
|
||||||
|
|
||||||
*OSX / Linux*
|
Not yet, but this will come in the future.
|
||||||
```
|
|
||||||
{
|
|
||||||
"stash": "/Volumes/Drobo/videos",
|
|
||||||
"metadata": "/Volumes/Drobo/stash/metadata",
|
|
||||||
"cache": "/Volumes/Drobo/stash/cache",
|
|
||||||
"downloads": "/Volumes/Drobo/stash/downloads"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
*Windows*
|
|
||||||
```
|
|
||||||
{
|
|
||||||
"stash": "C:\\Videos",
|
|
||||||
"metadata": "C:\\stash\\metadata",
|
|
||||||
"cache": "C:\\stash\\cache",
|
|
||||||
"downloads": "C:\\stash\\downloads"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,14 @@ import (
|
|||||||
"github.com/gobuffalo/packr/v2"
|
"github.com/gobuffalo/packr/v2"
|
||||||
"github.com/rs/cors"
|
"github.com/rs/cors"
|
||||||
"github.com/stashapp/stash/logger"
|
"github.com/stashapp/stash/logger"
|
||||||
|
"github.com/stashapp/stash/manager"
|
||||||
|
"github.com/stashapp/stash/manager/jsonschema"
|
||||||
"github.com/stashapp/stash/models"
|
"github.com/stashapp/stash/models"
|
||||||
|
"github.com/stashapp/stash/utils"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"path/filepath"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -23,6 +28,7 @@ const httpsPort = "9999"
|
|||||||
|
|
||||||
var certsBox *packr.Box
|
var certsBox *packr.Box
|
||||||
var uiBox *packr.Box
|
var uiBox *packr.Box
|
||||||
|
var setupUIBox *packr.Box
|
||||||
|
|
||||||
func Start() {
|
func Start() {
|
||||||
//port := os.Getenv("PORT")
|
//port := os.Getenv("PORT")
|
||||||
@@ -32,6 +38,7 @@ func Start() {
|
|||||||
|
|
||||||
certsBox = packr.New("Cert Box", "../certs")
|
certsBox = packr.New("Cert Box", "../certs")
|
||||||
uiBox = packr.New("UI Box", "../ui/v1/dist/stash-frontend")
|
uiBox = packr.New("UI Box", "../ui/v1/dist/stash-frontend")
|
||||||
|
setupUIBox = packr.New("Setup UI Box", "../ui/setup")
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
@@ -41,6 +48,7 @@ func Start() {
|
|||||||
r.Use(middleware.StripSlashes)
|
r.Use(middleware.StripSlashes)
|
||||||
r.Use(cors.AllowAll().Handler)
|
r.Use(cors.AllowAll().Handler)
|
||||||
r.Use(BaseURLMiddleware)
|
r.Use(BaseURLMiddleware)
|
||||||
|
r.Use(ConfigCheckMiddleware)
|
||||||
|
|
||||||
recoverFunc := handler.RecoverFunc(func(ctx context.Context, err interface{}) error {
|
recoverFunc := handler.RecoverFunc(func(ctx context.Context, err interface{}) error {
|
||||||
logger.Error(err)
|
logger.Error(err)
|
||||||
@@ -66,6 +74,65 @@ func Start() {
|
|||||||
r.Mount("/scene", sceneRoutes{}.Routes())
|
r.Mount("/scene", sceneRoutes{}.Routes())
|
||||||
r.Mount("/studio", studioRoutes{}.Routes())
|
r.Mount("/studio", studioRoutes{}.Routes())
|
||||||
|
|
||||||
|
// Serve the setup UI
|
||||||
|
r.HandleFunc("/setup*", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ext := path.Ext(r.URL.Path)
|
||||||
|
if ext == ".html" || ext == "" {
|
||||||
|
data := setupUIBox.Bytes("index.html")
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
} else {
|
||||||
|
r.URL.Path = strings.Replace(r.URL.Path, "/setup", "", 1)
|
||||||
|
http.FileServer(setupUIBox).ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
r.Post("/init", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("error: %s", err), 500)
|
||||||
|
}
|
||||||
|
stash := filepath.Clean(r.Form.Get("stash"))
|
||||||
|
metadata := filepath.Clean(r.Form.Get("metadata"))
|
||||||
|
cache := filepath.Clean(r.Form.Get("cache"))
|
||||||
|
//downloads := filepath.Clean(r.Form.Get("downloads")) // TODO
|
||||||
|
downloads := filepath.Join(metadata, "downloads")
|
||||||
|
|
||||||
|
exists, _ := utils.FileExists(stash)
|
||||||
|
fileInfo, _ := os.Stat(stash)
|
||||||
|
if !exists || !fileInfo.IsDir() {
|
||||||
|
http.Error(w, fmt.Sprintf("the stash path either doesn't exist, or is not a directory <%s>. Go back and try again.", stash), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, _ = utils.FileExists(metadata)
|
||||||
|
fileInfo, _ = os.Stat(metadata)
|
||||||
|
if !exists || !fileInfo.IsDir() {
|
||||||
|
http.Error(w, fmt.Sprintf("the metadata path either doesn't exist, or is not a directory <%s> Go back and try again.", metadata), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, _ = utils.FileExists(cache)
|
||||||
|
fileInfo, _ = os.Stat(cache)
|
||||||
|
if !exists || !fileInfo.IsDir() {
|
||||||
|
http.Error(w, fmt.Sprintf("the cache path either doesn't exist, or is not a directory <%s> Go back and try again.", cache), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = os.Mkdir(downloads, 0755)
|
||||||
|
|
||||||
|
config := &jsonschema.Config{
|
||||||
|
Stash: stash,
|
||||||
|
Metadata: metadata,
|
||||||
|
Cache: cache,
|
||||||
|
Downloads: downloads,
|
||||||
|
}
|
||||||
|
if err := manager.GetInstance().SaveConfig(config); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("there was an error saving the config file: %s", err), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/", 301)
|
||||||
|
})
|
||||||
|
|
||||||
// Serve the angular app
|
// Serve the angular app
|
||||||
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
|
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||||
ext := path.Ext(r.URL.Path)
|
ext := path.Ext(r.URL.Path)
|
||||||
@@ -92,8 +159,10 @@ func Start() {
|
|||||||
logger.Fatal(server.ListenAndServe())
|
logger.Fatal(server.ListenAndServe())
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
logger.Infof("stash is running on HTTPS at https://localhost:9999/")
|
logger.Infof("stash is running on HTTPS at https://localhost:9999/")
|
||||||
logger.Fatal(httpsServer.ListenAndServeTLS("", ""))
|
logger.Fatal(httpsServer.ListenAndServeTLS("", ""))
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeTLSConfig() *tls.Config {
|
func makeTLSConfig() *tls.Config {
|
||||||
@@ -137,3 +206,17 @@ func BaseURLMiddleware(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
return http.HandlerFunc(fn)
|
return http.HandlerFunc(fn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ConfigCheckMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ext := path.Ext(r.URL.Path)
|
||||||
|
shouldRedirect := ext == "" && r.Method == "GET" && r.URL.Path != "/init"
|
||||||
|
if !manager.HasValidConfig() && shouldRedirect {
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/setup") {
|
||||||
|
http.Redirect(w, r, "/setup", 301)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
6
main.go
6
main.go
@@ -12,6 +12,10 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
managerInstance := manager.Initialize()
|
managerInstance := manager.Initialize()
|
||||||
database.Initialize(managerInstance.StaticPaths.DatabaseFile)
|
database.Initialize(managerInstance.StaticPaths.DatabaseFile)
|
||||||
|
|
||||||
api.Start()
|
api.Start()
|
||||||
|
blockForever()
|
||||||
|
}
|
||||||
|
|
||||||
|
func blockForever() {
|
||||||
|
select {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package jsonschema
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"github.com/stashapp/stash/logger"
|
"github.com/stashapp/stash/logger"
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
@@ -23,6 +24,15 @@ func LoadConfigFile(file string) *Config {
|
|||||||
}
|
}
|
||||||
jsonParser := json.NewDecoder(configFile)
|
jsonParser := json.NewDecoder(configFile)
|
||||||
parseError := jsonParser.Decode(&config)
|
parseError := jsonParser.Decode(&config)
|
||||||
if parseError != nil { panic(parseError) }
|
if parseError != nil {
|
||||||
|
logger.Errorf("config file parse error: %s", parseError)
|
||||||
|
}
|
||||||
return &config
|
return &config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SaveConfigFile(filePath string, config *Config) error {
|
||||||
|
if config == nil {
|
||||||
|
return fmt.Errorf("config must not be nil")
|
||||||
|
}
|
||||||
|
return marshalToFile(filePath, config)
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ package manager
|
|||||||
import (
|
import (
|
||||||
"github.com/stashapp/stash/ffmpeg"
|
"github.com/stashapp/stash/ffmpeg"
|
||||||
"github.com/stashapp/stash/logger"
|
"github.com/stashapp/stash/logger"
|
||||||
|
"github.com/stashapp/stash/manager/jsonschema"
|
||||||
"github.com/stashapp/stash/manager/paths"
|
"github.com/stashapp/stash/manager/paths"
|
||||||
|
"github.com/stashapp/stash/utils"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,13 +26,16 @@ func GetInstance() *singleton {
|
|||||||
|
|
||||||
func Initialize() *singleton {
|
func Initialize() *singleton {
|
||||||
once.Do(func() {
|
once.Do(func() {
|
||||||
|
configFile := jsonschema.LoadConfigFile(paths.StaticPaths.ConfigFile)
|
||||||
instance = &singleton{
|
instance = &singleton{
|
||||||
Status: Idle,
|
Status: Idle,
|
||||||
Paths: paths.RefreshPaths(),
|
Paths: paths.NewPaths(configFile),
|
||||||
StaticPaths: &paths.StaticPaths,
|
StaticPaths: &paths.StaticPaths,
|
||||||
JSON: &jsonUtils{},
|
JSON: &jsonUtils{},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
instance.refreshConfig(configFile)
|
||||||
|
|
||||||
initFFMPEG()
|
initFFMPEG()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -56,3 +61,41 @@ The error was: %s
|
|||||||
instance.StaticPaths.FFMPEG = ffmpegPath
|
instance.StaticPaths.FFMPEG = ffmpegPath
|
||||||
instance.StaticPaths.FFProbe = ffprobePath
|
instance.StaticPaths.FFProbe = ffprobePath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func HasValidConfig() bool {
|
||||||
|
configFileExists, _ := utils.FileExists(instance.StaticPaths.ConfigFile) // TODO: Verify JSON is correct
|
||||||
|
if configFileExists && instance.Paths.Config != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *singleton) SaveConfig(config *jsonschema.Config) error {
|
||||||
|
if err := jsonschema.SaveConfigFile(s.StaticPaths.ConfigFile, config); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload the config
|
||||||
|
s.refreshConfig(config)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *singleton) refreshConfig(config *jsonschema.Config) {
|
||||||
|
if config == nil {
|
||||||
|
config = jsonschema.LoadConfigFile(s.StaticPaths.ConfigFile)
|
||||||
|
}
|
||||||
|
s.Paths = paths.NewPaths(config)
|
||||||
|
|
||||||
|
if HasValidConfig() {
|
||||||
|
_ = utils.EnsureDir(s.Paths.Generated.Screenshots)
|
||||||
|
_ = utils.EnsureDir(s.Paths.Generated.Vtt)
|
||||||
|
_ = utils.EnsureDir(s.Paths.Generated.Markers)
|
||||||
|
_ = utils.EnsureDir(s.Paths.Generated.Transcodes)
|
||||||
|
|
||||||
|
_ = utils.EnsureDir(s.Paths.JSON.Performers)
|
||||||
|
_ = utils.EnsureDir(s.Paths.JSON.Scenes)
|
||||||
|
_ = utils.EnsureDir(s.Paths.JSON.Galleries)
|
||||||
|
_ = utils.EnsureDir(s.Paths.JSON.Studios)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package paths
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stashapp/stash/manager/jsonschema"
|
"github.com/stashapp/stash/manager/jsonschema"
|
||||||
"github.com/stashapp/stash/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Paths struct {
|
type Paths struct {
|
||||||
@@ -15,14 +14,9 @@ type Paths struct {
|
|||||||
SceneMarkers *sceneMarkerPaths
|
SceneMarkers *sceneMarkerPaths
|
||||||
}
|
}
|
||||||
|
|
||||||
func RefreshPaths() *Paths {
|
func NewPaths(config *jsonschema.Config) *Paths {
|
||||||
ensureConfigFile()
|
|
||||||
return newPaths()
|
|
||||||
}
|
|
||||||
|
|
||||||
func newPaths() *Paths {
|
|
||||||
p := Paths{}
|
p := Paths{}
|
||||||
p.Config = jsonschema.LoadConfigFile(StaticPaths.ConfigFile)
|
p.Config = config
|
||||||
p.Generated = newGeneratedPaths(p)
|
p.Generated = newGeneratedPaths(p)
|
||||||
p.JSON = newJSONPaths(p)
|
p.JSON = newJSONPaths(p)
|
||||||
|
|
||||||
@@ -31,12 +25,3 @@ func newPaths() *Paths {
|
|||||||
p.SceneMarkers = newSceneMarkerPaths(p)
|
p.SceneMarkers = newSceneMarkerPaths(p)
|
||||||
return &p
|
return &p
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureConfigFile() {
|
|
||||||
configFileExists, _ := utils.FileExists(StaticPaths.ConfigFile) // TODO: Verify JSON is correct. Pass verified
|
|
||||||
if configFileExists {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
panic("No config file found")
|
|
||||||
}
|
|
||||||
@@ -20,11 +20,6 @@ func newGeneratedPaths(p Paths) *generatedPaths {
|
|||||||
gp.Markers = filepath.Join(p.Config.Metadata, "markers")
|
gp.Markers = filepath.Join(p.Config.Metadata, "markers")
|
||||||
gp.Transcodes = filepath.Join(p.Config.Metadata, "transcodes")
|
gp.Transcodes = filepath.Join(p.Config.Metadata, "transcodes")
|
||||||
gp.Tmp = filepath.Join(p.Config.Metadata, "tmp")
|
gp.Tmp = filepath.Join(p.Config.Metadata, "tmp")
|
||||||
|
|
||||||
_ = utils.EnsureDir(gp.Screenshots)
|
|
||||||
_ = utils.EnsureDir(gp.Vtt)
|
|
||||||
_ = utils.EnsureDir(gp.Markers)
|
|
||||||
_ = utils.EnsureDir(gp.Transcodes)
|
|
||||||
return &gp
|
return &gp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package paths
|
package paths
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stashapp/stash/utils"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,11 +22,6 @@ func newJSONPaths(p Paths) *jsonPaths {
|
|||||||
jp.Scenes = filepath.Join(p.Config.Metadata, "scenes")
|
jp.Scenes = filepath.Join(p.Config.Metadata, "scenes")
|
||||||
jp.Galleries = filepath.Join(p.Config.Metadata, "galleries")
|
jp.Galleries = filepath.Join(p.Config.Metadata, "galleries")
|
||||||
jp.Studios = filepath.Join(p.Config.Metadata, "studios")
|
jp.Studios = filepath.Join(p.Config.Metadata, "studios")
|
||||||
|
|
||||||
_ = utils.EnsureDir(jp.Performers)
|
|
||||||
_ = utils.EnsureDir(jp.Scenes)
|
|
||||||
_ = utils.EnsureDir(jp.Galleries)
|
|
||||||
_ = utils.EnsureDir(jp.Studios)
|
|
||||||
return &jp
|
return &jp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
35
ui/setup/index.html
Normal file
35
ui/setup/index.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Stash</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic">
|
||||||
|
<link rel="stylesheet" href="//cdn.rawgit.com/necolas/normalize.css/master/normalize.css">
|
||||||
|
<link rel="stylesheet" href="/setup/milligram.min.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<form action="/init" method="POST">
|
||||||
|
<fieldset>
|
||||||
|
<label for="stash">Where is your porn located (mp4, wmv, zip, etc)?</label>
|
||||||
|
<input name="stash" type="text" placeholder="EX: C:\videos (Windows) or /User/StashApp/Videos (macOS / Linux)" />
|
||||||
|
|
||||||
|
<label for="metadata">Where would you like to save metadata? Metadata includes generated videos / images and backup JSON files.</label>
|
||||||
|
<input name="metadata" type="text" placeholder="EX: C:\stash\metadata (Windows) or /User/StashApp/stash/metadata (macOS / Linux)" />
|
||||||
|
|
||||||
|
<label for="cache">Where do you want to Stash to save cache / temporary files it might need to create?</label>
|
||||||
|
<input name="cache" type="text" placeholder="EX: C:\stash\cache (Windows) or /User/StashApp/stash/cache (macOS / Linux)" />
|
||||||
|
|
||||||
|
<input hidden name="downloads" value="">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input class="button button-black" type="submit" value="Submit">
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
11
ui/setup/milligram.min.css
vendored
Executable file
11
ui/setup/milligram.min.css
vendored
Executable file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user