diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d80d8c6..1fbaad1 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -38,5 +38,5 @@ jobs: with: push: true tags: | - ghcr.io/${{ github.repository }}/${{ github.event.repository.name }}:dev + ghcr.io/${{ github.repository }}:dev ${{ steps.meta.outputs.tags }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2f7296..917993c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: # list of Docker images to use as base name for tags images: | ${{ github.repository }} - ghcr.io/${{ github.repository }}/${{ github.event.repository.name }} + ghcr.io/${{ github.repository }} # generate Docker tags based on the following events/attributes tags: | type=semver,pattern=v{{version}} @@ -44,4 +44,23 @@ jobs: uses: docker/build-push-action@v2 with: push: true - tags: ${{ steps.meta.outputs.tags }} \ No newline at end of file + tags: ${{ steps.meta.outputs.tags }} + + - name: Setup Go Environment + uses: actions/setup-go@v2 + with: + go-version: '^1.20.0' + + - name: Build Binaries + run: | + mkdir -p builds/compressed + go install github.com/mitchellh/gox@latest + cd saveshare + gox --output "../../builds/saveshare-{{.OS}}-{{.Arch}}" -osarch 'darwin/amd64 linux/amd64 windows/amd64' + cd ../../builds + find . -maxdepth 1 -type f -execdir zip 'compressed/{}.zip' '{}' \; + + - name: Upload Binaries + run: | + go install github.com/tcnksm/ghr@latest + ghr -t ${{ secrets.GITHUB_TOKEN }} --delete Latest builds/compressed/ \ No newline at end of file diff --git a/README.md b/README.md index b66a57d..e8e8c49 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ This is a Dockerized version of the [Satisfactory](https://store.steampowered.com/app/526870/Satisfactory/) dedicated server. +If the server feels too buggy for you, you can try the [saveshare](saveshare/README.md) instead (which relies on client-hosting). + ## Setup Recent updates consume 4GB - 6GB RAM, but [the official wiki](https://satisfactory.wiki.gg/wiki/Dedicated_servers#Requirements) recommends allocating 12GB - 16GB RAM. diff --git a/saveshare/README.md b/saveshare/README.md new file mode 100755 index 0000000..fa4d1c9 --- /dev/null +++ b/saveshare/README.md @@ -0,0 +1,36 @@ +# Satisfactory Save Sharing + +**_Note: this is a work in progress. The group I play with have been relying on solely this for the last few months, but I'm still working on making it more user-friendly._** + +The dedicated server for Satisfactory introduces a few unique bugs to the game, where multiplayer (through joining a friend) doesn't. This application introduces save sharing with friends. It's designed to function similarly to how the game Grounded handles saves. + +Everybody runs the client in the background; when the host's game saves, those files are uploaded to a remote SFTP server (deployed through the Docker Compose below), which the other clients pull from in realtime. This way, if the host leaves, anyone else can pick up from where they left off. + +## Setup + +Download the release from the releases tab. When you initially run it, it'll ask for the following information: +- Server address (IP and port, e.g. `localhost:15770`) +- Server password (the SFTP password) +- Session name (this must be EXACTLY as it is formatted within Satisfactory) + +### Docker Compose + +If you're using [Docker Compose](https://docs.docker.com/compose/): + +```yaml +version: '3' +services: + satisfactory-saveshare: + container_name: satisfactory-saveshare + image: atmoz/sftp:latest + volumes: + - /opt/saveshare:/home/saveshare/upload + ports: + - "15770:22" + command: saveshare:PASSWORD_HERE:1001 +``` + +_Note: Do not change the username (`saveshare`) or the UID (`1001`). Only change the password._ + +### Known Issues +You can't delete blueprints, unless you manually stop everyone from running the application and everyone deletes the blueprint locally (and server-side) \ No newline at end of file diff --git a/saveshare/config.go b/saveshare/config.go new file mode 100755 index 0000000..eaf3022 --- /dev/null +++ b/saveshare/config.go @@ -0,0 +1,98 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type Config struct { + gamePath string + path string + BlueprintPath string `yaml:"blueprintPath"` + SavePath string `yaml:"savePath"` + ServerAddress string `yaml:"serverAddress"` + ServerPassword string `yaml:"serverPassword"` + SessionName string `yaml:"sessionName"` +} + +func NewConfig(configDir string) (*Config, error) { + cfg := Config{ + path: configDir + slash + "config.yml", + } + + // Check if the file exists + if _, err := os.Stat(cfg.path); os.IsNotExist(err) { + // If the file doesn't exist, create an empty file + if _, err = os.Create(cfg.path); err != nil { + return nil, fmt.Errorf("could not create config file: %w", err) + } + } + + // Read the YAML file + yamlData, err := os.ReadFile(cfg.path) + if err != nil { + return nil, fmt.Errorf("could not read config file: %w", err) + } + + if err = yaml.Unmarshal(yamlData, &cfg); err != nil { + return nil, fmt.Errorf("could not unmarshal config file: %w", err) + } + + // populate the blueprint and save paths + appDataPath, err := os.UserCacheDir() + if err != nil { + return nil, fmt.Errorf("could not get appdata path: %w", err) + } + + cfg.gamePath = appDataPath + slash + "FactoryGame" + slash + "Saved" + slash + "SaveGames" + slash + + if cfg.SessionName != "" { + cfg.BlueprintPath = cfg.gamePath + "blueprints" + slash + cfg.SessionName + } + + // check if the game path exists + if _, err = os.Stat(cfg.gamePath); os.IsNotExist(err) { + return nil, fmt.Errorf("game path does not exist: %w", err) + } + + // get the save path + if err = filepath.Walk(cfg.gamePath, func(path string, info os.FileInfo, err error) error { + if info == nil { + return errors.New("path does not exist") + } + + if info.IsDir() { + if cfg.SavePath == "" && path != cfg.gamePath { + cfg.SavePath = path + } + return nil + } + + return nil + }); err != nil { + return nil, fmt.Errorf("could not walk over path: %w", err) + } + + if cfg.SavePath == "" { + return nil, fmt.Errorf("could not find save path") + } + + return &cfg, nil +} + +func (c *Config) Save() error { + yamlData, err := yaml.Marshal(c) + if err != nil { + return fmt.Errorf("could not marshal config file: %w", err) + } + + if err = os.WriteFile(c.path, yamlData, 0o644); err != nil { + return fmt.Errorf("could not write config file: %w", err) + } + + return nil +} diff --git a/saveshare/docker-compose.yml b/saveshare/docker-compose.yml new file mode 100644 index 0000000..929d03d --- /dev/null +++ b/saveshare/docker-compose.yml @@ -0,0 +1,10 @@ +version: '3' +services: + satisfactory-saveshare: + container_name: satisfactory-saveshare + image: atmoz/sftp:latest + volumes: + - /opt/saveshare:/home/saveshare/upload + ports: + - "15770:22" + command: saveshare:PASSWORD_HERE:1001 \ No newline at end of file diff --git a/saveshare/file.go b/saveshare/file.go new file mode 100755 index 0000000..e8fa1e6 --- /dev/null +++ b/saveshare/file.go @@ -0,0 +1,67 @@ +package main + +import ( + "fmt" + "io" + "os" + + "github.com/pkg/sftp" +) + +func downloadFile(sftpClient *sftp.Client, remotePath, localPath string) error { + remoteFile, err := sftpClient.Open(remotePath) + if err != nil { + return fmt.Errorf("error opening remote file: %v: %w", remotePath, err) + } + defer remoteFile.Close() + + localFile, err := os.Create(localPath) + if err != nil { + return fmt.Errorf("error creating local file: %w", err) + } + defer localFile.Close() + + if _, err = io.Copy(localFile, remoteFile); err != nil { + return fmt.Errorf("error copying file: %w", err) + } + + fileInfo, err := remoteFile.Stat() + if err != nil { + return fmt.Errorf("error getting remote file info: %w", err) + } + + if err = os.Chtimes(localPath, fileInfo.ModTime(), fileInfo.ModTime()); err != nil { + return fmt.Errorf("error setting local file mod time: %w", err) + } + + return nil +} + +func uploadFile(sftpClient *sftp.Client, localPath, remotePath string) error { + localFile, err := os.Open(localPath) + if err != nil { + return fmt.Errorf("error opening local file: %w", err) + } + defer localFile.Close() + + remoteFile, err := sftpClient.Create(remotePath) + if err != nil { + return fmt.Errorf("error creating remote file: %w", err) + } + defer remoteFile.Close() + + if _, err = io.Copy(remoteFile, localFile); err != nil { + return fmt.Errorf("error copying file: %w", err) + } + + fileInfo, err := localFile.Stat() + if err != nil { + return fmt.Errorf("error getting local file info: %w", err) + } + + if err = sftpClient.Chtimes(remotePath, fileInfo.ModTime(), fileInfo.ModTime()); err != nil { + return fmt.Errorf("error setting remote file mod time: %w", err) + } + + return nil +} diff --git a/saveshare/go.mod b/saveshare/go.mod new file mode 100755 index 0000000..16f6d2a --- /dev/null +++ b/saveshare/go.mod @@ -0,0 +1,17 @@ +module github.com/wolveix/satisfactory-server/saveshare + +go 1.20 + +require ( + github.com/pkg/sftp v1.13.6 + github.com/rs/zerolog v1.30.0 + golang.org/x/crypto v0.13.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/kr/fs v0.1.0 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + golang.org/x/sys v0.12.0 // indirect +) diff --git a/saveshare/go.sum b/saveshare/go.sum new file mode 100755 index 0000000..64c8646 --- /dev/null +++ b/saveshare/go.sum @@ -0,0 +1,64 @@ +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= +github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c= +github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= +golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/saveshare/main.go b/saveshare/main.go new file mode 100755 index 0000000..4449071 --- /dev/null +++ b/saveshare/main.go @@ -0,0 +1,272 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/pkg/sftp" + "github.com/rs/zerolog" + "golang.org/x/crypto/ssh" +) + +var ( + logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339}).With().Timestamp().Logger().Level(zerolog.InfoLevel) + logfile = "" + slash = string(os.PathSeparator) +) + +func main() { + configDir, err := os.UserConfigDir() + if err != nil { + logger.Fatal().Err(err).Msg("Failed to get user config directory") + } + + configDir = filepath.Clean(configDir + slash + "FactoryGameSaveShare") + logfile = configDir + slash + "log.txt" + + if err = os.MkdirAll(configDir, 0o755); err != nil { + logger.Fatal().Err(err).Msg("Failed to create config directory") + } + + // check if the log file exists, and create it if it doesn't + if _, err = os.Stat(logfile); os.IsNotExist(err) { + if _, err = os.Create(logfile); err != nil { + logger.Fatal().Err(err).Msg("Failed to create log file") + } + } + + // open the log file + logFile, err := os.OpenFile(logfile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) + if err != nil { + logger.Fatal().Err(err).Msg("Failed to open log file") + } + + // replace the default logger with one that writes to the log file as well as to the console + logger = zerolog.New(zerolog.MultiLevelWriter(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339}, logFile)).With().Timestamp().Logger().Level(zerolog.InfoLevel) + + logger.Info().Msg("Satisfactory Save Share Client v1.6.1") + logger.Info().Msg("https://github.com/wolveix/satisfactory-server/saveshare") + logger.Info().Msg("Initializing config...") + + cfg, err := NewConfig(configDir) + if err != nil { + logger.Fatal().Err(err).Msg("Failed to load config") + return + } + + if cfg.SessionName == "" { + fmt.Printf("Please input your session name: ") + + if _, err = fmt.Scanln(&cfg.SessionName); err != nil { + fmt.Printf("\nFailed to read session name: %v\n", err) + return + } + + if err = cfg.Save(); err != nil { + logger.Fatal().Err(err).Msg("Failed to save config") + } + + cfg.BlueprintPath = cfg.gamePath + slash + "blueprints" + slash + cfg.SessionName + } + + if cfg.ServerAddress == "" { + fmt.Printf("Please input your server address: ") + + if _, err = fmt.Scanln(&cfg.ServerAddress); err != nil { + fmt.Printf("\nFailed to read server address: %v\n", err) + return + } + + if err = cfg.Save(); err != nil { + logger.Fatal().Err(err).Msg("Failed to save config") + } + } + + if cfg.ServerPassword == "" { + fmt.Printf("Please input your server password: ") + + if _, err = fmt.Scanln(&cfg.ServerPassword); err != nil { + fmt.Printf("\nFailed to read server password: %v\n", err) + return + } + + if err = cfg.Save(); err != nil { + logger.Fatal().Err(err).Msg("Failed to save config") + } + } + + logger.Info().Msg("Config loaded successfully!") + + // Establish an SSH connection to the server + sshConfig := &ssh.ClientConfig{ + User: "saveshare", + Auth: []ssh.AuthMethod{ + ssh.Password(cfg.ServerPassword), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + + sshClient, err := ssh.Dial("tcp", cfg.ServerAddress, sshConfig) + if err != nil { + logger.Fatal().Err(err).Msg("Error connecting to server") + } + defer sshClient.Close() + + // Establish an SFTP session + sftpClient, err := sftp.NewClient(sshClient) + if err != nil { + logger.Fatal().Err(err).Msg("Error creating SFTP client") + return + } + defer sftpClient.Close() + + logger.Info().Msg("Connected to server!") + + remotePath := "upload/" + cfg.SessionName + + // create the remote directories if they don't already exist + if err = sftpClient.MkdirAll(remotePath + "/blueprints"); err != nil { + fmt.Printf("Error creating remote blueprints directory: %v\n", err) + return + } + + if err = sftpClient.MkdirAll(remotePath + "/saves"); err != nil { + fmt.Printf("Error creating remote saves directory: %v\n", err) + return + } + + for { + logger.Info().Msg("Syncing...") + + if err = syncLocalUpdates(sftpClient, cfg.BlueprintPath, remotePath+"/blueprints", ""); err != nil { + logger.Error().Err(err).Msg("Unexpected error while checking for blueprint updates") + } + + if err = syncLocalUpdates(sftpClient, cfg.SavePath, remotePath+"/saves", cfg.SessionName); err != nil { + logger.Error().Err(err).Msg("Unexpected error while checking for save updates") + } + + if err = syncRemoteUpdates(sftpClient, cfg.BlueprintPath, remotePath+"/blueprints"); err != nil { + logger.Error().Err(err).Msg("Unexpected error while checking for blueprint updates") + } + + if err = syncRemoteUpdates(sftpClient, cfg.SavePath, remotePath+"/saves"); err != nil { + logger.Error().Err(err).Msg("Unexpected error while checking for save updates") + } + + time.Sleep(2 * time.Minute) + } +} + +// syncLocalUpdates walks through the local directories and syncs files to/from the remote server +func syncLocalUpdates(sftpClient *sftp.Client, localPath string, remotePath string, sessionName string) error { + return filepath.Walk(localPath, func(localFilePath string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + remoteFilePath := remotePath + "/" + info.Name() + + // save files are named like: "SESSIONNAME_autosave_0.sav" + if sessionName != "" { + if !strings.HasPrefix(info.Name(), sessionName) { + return nil + } + } + + // Check if the file exists on the remote server + remoteFile, err := sftpClient.Stat(remoteFilePath) + if err != nil { + if os.IsNotExist(err) { + logger.Info().Msg("Uploading " + localFilePath + " to " + remoteFilePath) + return uploadFile(sftpClient, localFilePath, remoteFilePath) + } else { + return fmt.Errorf("error checking remote file: %w", err) + } + } + + // Compare local and remote file timestamps + localModTime := info.ModTime().Truncate(1 * time.Second) + remoteModTime := remoteFile.ModTime().Truncate(1 * time.Second) + + if localModTime == remoteModTime { + return nil + } + + if localModTime.After(remoteModTime) { + // Local file is newer, so upload it + logger.Info().Msg("Uploading " + localFilePath + " to " + remoteFilePath) + return uploadFile(sftpClient, localFilePath, remoteFilePath) + } else { + // Remote file is newer, so download it + logger.Info().Msg("Downloading " + remoteFilePath + " to " + localFilePath) + return downloadFile(sftpClient, remoteFilePath, localFilePath) + } + }) +} + +func syncRemoteUpdates(sftpClient *sftp.Client, localPath string, remotePath string) error { + // Get a list of files and directories in the remote directory + remoteFiles, err := sftpClient.ReadDir(remotePath) + if err != nil { + return fmt.Errorf("error reading remote directory: %w", err) + } + + // Iterate through remote files and directories + for _, remoteFile := range remoteFiles { + if remoteFile.IsDir() { + return nil + } + + localFilePath := localPath + slash + remoteFile.Name() + remoteFilePath := remotePath + "/" + remoteFile.Name() + + // Check if the file exists on the local side + localFile, err := os.Stat(localFilePath) + if err != nil { + if os.IsNotExist(err) { + // Download the remote file + logger.Info().Msg("Downloading " + remoteFilePath + " to " + localFilePath) + if err = downloadFile(sftpClient, remoteFilePath, localFilePath); err != nil { + return err + } + + continue + } + + return fmt.Errorf("error checking local file: %w", err) + } + + // Compare remote and local file timestamps + localModTime := localFile.ModTime().Truncate(1 * time.Second) + remoteModTime := remoteFile.ModTime().Truncate(1 * time.Second) + + // Compare timestamps and synchronize as needed + if localModTime == remoteModTime { + continue + } + + if remoteModTime.After(localModTime) { + // Remote file is newer, download it + logger.Info().Msg("Downloading " + remoteFilePath + " to " + localFilePath) + if err = downloadFile(sftpClient, remoteFilePath, localFilePath); err != nil { + return err + } + } else { + // Local file is newer, upload it + logger.Info().Msg("Uploading " + localFilePath + " to " + remoteFilePath) + if err = uploadFile(sftpClient, localFilePath, remoteFilePath); err != nil { + return err + } + } + } + + return nil +}