diff --git a/README.md b/README.md index 3f73baad..02a77127 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,9 @@ go build * **Mitch Roote** - [roote.ca](https://roote.ca) +## Special Thanks +- **mickael9** for reverseengineering the factorio-save-file: https://forums.factorio.com/viewtopic.php?f=5&t=8568# + ## License This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details diff --git a/src/mods_handler.go b/src/mods_handler.go index 94abb4c2..101e6489 100644 --- a/src/mods_handler.go +++ b/src/mods_handler.go @@ -12,8 +12,28 @@ import ( "net/http" "os" "path/filepath" + "factorioSave" + "time" ) +type ModPortalStruct struct { + DownloadsCount int `json:"downloads_count"` + Name string `json:"name"` + Owner string `json:"owner"` + Releases []struct { + DownloadURL string `json:"download_url"` + FileName string `json:"file_name"` + InfoJSON struct { + FactorioVersion string `json:"factorio_version"` + } `json:"info_json"` + ReleasedAt time.Time `json:"released_at"` + Sha1 string `json:"sha1"` + Version string `json:"version"` + } `json:"releases"` + Summary string `json:"summary"` + Title string `json:"title"` +} + // Returns JSON response of all mods installed in factorio/mods func listInstalledModsHandler(w http.ResponseWriter, r *http.Request) { var err error @@ -211,7 +231,7 @@ func ModPortalInstallHandler(w http.ResponseWriter, r *http.Request) { } if err != nil { - w.WriteHeader(500) + w.WriteHeader(http.StatusInternalServerError) resp.Data = fmt.Sprintf("Error in installMod: %s", err) if err := json.NewEncoder(w).Encode(resp); err != nil { log.Printf("Error in installMod: %s", err) @@ -227,6 +247,90 @@ func ModPortalInstallHandler(w http.ResponseWriter, r *http.Request) { } } +func ModPortalInstallMultipleHandler(w http.ResponseWriter, r *http.Request) { + var err error + resp := JSONResponse{ + Success: false, + } + + w.Header().Set("Content-Type", "application/json;charset=UTF-8") + r.ParseForm() + + modsList := make([]string, 0) + versionsList := make([]string, 0) + + //Parse incoming data + for key, values := range r.PostForm { + if key == "mod_name" { + for _, v := range values { + modsList = append(modsList, v) + } + } else if key == "mod_version" { + for _, v := range values { + versionsList = append(versionsList, v) + } + } + } + + mods, err := newMods(config.FactorioModsDir) + if err != nil { + log.Printf("error creating mods: %s", err) + + w.WriteHeader(http.StatusInternalServerError) + resp.Data = fmt.Sprintf("Error in searchModPortal: %s", err) + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Printf("Error in searchModPortal: %s", err) + } + return + } + + for modIndex, mod := range modsList { + var err error + + //get details of mod + modDetails, err, statusCode := getModDetails(mod) + if err != nil { + w.WriteHeader(statusCode) + resp.Data = fmt.Sprintf("Error in searchModPortal: %s", err) + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Printf("Error in searchModPortal: %s", err) + } + return + } + + modDetailsArray := []byte(modDetails) + var modDetailsStruct ModPortalStruct + + //read mod-data into Struct + err = json.Unmarshal(modDetailsArray, &modDetailsStruct) + if err != nil { + log.Printf("error reading modPortalDetails: %s", err) + + w.WriteHeader(http.StatusInternalServerError) + resp.Data = fmt.Sprintf("Error in searchModPortal: %s", err) + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Printf("Error in searchModPortal: %s", err) + } + return + } + + //find correct mod-version + for _, release := range modDetailsStruct.Releases { + if release.Version == versionsList[modIndex] { + mods.downloadMod(release.DownloadURL, release.FileName, modDetailsStruct.Name) + break + } + } + } + + resp.Data = mods.listInstalledMods() + + resp.Success = true + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Printf("Error in ToggleModHandler: %s", err) + } +} + func ToggleModHandler(w http.ResponseWriter, r *http.Request) { var err error resp := JSONResponse{ @@ -244,7 +348,7 @@ func ToggleModHandler(w http.ResponseWriter, r *http.Request) { } if err != nil { - w.WriteHeader(500) + w.WriteHeader(http.StatusInternalServerError) resp.Data = fmt.Sprintf("Error in listInstalledModsByFolder: %s", err) if err := json.NewEncoder(w).Encode(resp); err != nil { log.Printf("Error in listInstalledModsByFolder: %s", err) @@ -276,7 +380,7 @@ func DeleteModHandler(w http.ResponseWriter, r *http.Request) { } if err != nil { - w.WriteHeader(500) + w.WriteHeader(http.StatusInternalServerError) resp.Data = fmt.Sprintf("Error in deleteMod: %s", err) if err := json.NewEncoder(w).Encode(resp); err != nil { log.Printf("Error in DeleteModHandler: %s", err) @@ -455,6 +559,45 @@ func DownloadModsHandler(w http.ResponseWriter, r *http.Request) { writerHeader.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", "all_installed_mods.zip")) } +//Returns JSON response with the found mods +func LoadModsFromSaveHandler(w http.ResponseWriter, r *http.Request) { + var err error + resp := JSONResponse{ + Success: false, + } + + w.Header().Set("Content-Type", "application/json;charset=UTF-8") + + //Get Data out of the request + SaveFile := r.FormValue("saveFile") + + SaveFileComplete := filepath.Join(config.FactorioSavesDir, SaveFile) + resp.Data, err = factorioSave.ReadHeader(SaveFileComplete) + + if err == factorioSave.ErrorIncompatible { + w.WriteHeader(http.StatusInternalServerError) + resp.Data = fmt.Sprintf("%s
Only can read 0.16.x save files", err) + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Printf("Error in loadModsFromSave: %s", err) + } + return + } + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + resp.Data = fmt.Sprintf("Error in searchModPortal: %s", err) + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Printf("Error in loadModsFromSave: %s", err) + } + return + } + + resp.Success = true + + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Printf("Error in LoadModsFromSave: %s", err) + } +} + func ListModPacksHandler(w http.ResponseWriter, r *http.Request) { var err error resp := JSONResponse{ diff --git a/src/routes.go b/src/routes.go index 8d9c7134..4ee0d329 100644 --- a/src/routes.go +++ b/src/routes.go @@ -183,6 +183,11 @@ var apiRoutes = Routes{ "POST", "/mods/install", ModPortalInstallHandler, + }, { + "ModPortalInstallMultiple", + "POST", + "/mods/install/multiple", + ModPortalInstallMultipleHandler, }, { "ToggleMod", "POST", @@ -213,6 +218,11 @@ var apiRoutes = Routes{ "GET", "/mods/download", DownloadModsHandler, + }, { + "LoadModsFromSave", + "POST", + "/mods/save/load", + LoadModsFromSaveHandler, }, { "ListSaves", "GET", diff --git a/src/vendor/factorioSave/factorioSave.go b/src/vendor/factorioSave/factorioSave.go new file mode 100644 index 00000000..b258575f --- /dev/null +++ b/src/vendor/factorioSave/factorioSave.go @@ -0,0 +1,405 @@ +package factorioSave + +import ( + "log" + "encoding/binary" + "errors" + "io" + "strconv" + "github.com/Masterminds/semver" +) + +type version16 struct { + versionShort16 + Revision uint16 `json:"revision"` +} +type versionShort16 struct { + Major uint16 `json:"major"` + Minor uint16 `json:"minor"` + Build uint16 `json:"build"` +} +type versionShort8 struct { + Major uint8 `json:"major"` + Minor uint8 `json:"minor"` + Build uint8 `json:"build"` +} +type Header struct { + FactorioVersion version16 `json:"factorio_version"` + Campaign string `json:"campaign"` + Name string `json:"name"` + BaseMod string `json:"base_mod"` + Difficulty uint8 `json:"difficulty"` + Finished bool `json:"finished"` + PlayerWon bool `json:"player_won"` + NextLevel string `json:"next_level"` + CanContinue bool `json:"can_continue"` + FinishedButContinuing bool `json:"finished_but_continuing"` + SavingReplay bool `json:"saving_replay"` + AllowNonAdminDebugOptions bool `json:"allow_non_admin_debug_options"` + LoadedFrom versionShort8 `json:"loaded_from"` + LoadedFromBuild uint16 `json:"loaded_from_build"` + AllowedCommads uint8 `json:"allowed_commads"` + NumMods uint32 `json:"num_mods"` + Mods []singleMod `json:"mods"` +} +type singleMod struct { + Name string `json:"name"` + Version versionShort8 `json:"version"` + CRC uint32 `json:"crc"` +} + +var ErrorIncompatible = errors.New("incompatible save") +var data Header +var constraintGreaterThan016 *semver.Constraints + +func ReadHeader(filePath string) (Header, error) { + var err error + data = Header{} + + constraintGreaterThan016, _ = semver.NewConstraint(">= 0.16.0") + + datFile, err := openSave(filePath) + if err != nil { + log.Printf("error opening file: %s", err) + return data, err + } + defer datFile.Close() + + data.FactorioVersion, err = readVersion16(datFile) + if err != nil { + log.Printf("Cant read FactorioVersion: %s", err) + return data, err + } + + Constraint, _ := semver.NewConstraint("0.14.14 - 0.17.0") + Compatible, err := data.FactorioVersion.CheckCompatibility(Constraint) + if err != nil { + log.Printf("Error checking compatibility: %s", err) + return data, err + } + if !Compatible { + log.Printf("NOT COMPATIBLE Save-File") + log.Println(data) + return data, ErrorIncompatible + } + + data.Campaign, err = readUTF8String(datFile, false) + if err != nil { + log.Printf("Cant read Campaign: %s", err) + return data, err + } + + data.Name, err = readUTF8String(datFile, false) + if err != nil { + log.Printf("Cant read Name: %s", err) + return data, err + } + + data.BaseMod, err = readUTF8String(datFile, false) + if err != nil { + log.Printf("Cant read BaseMod: %s", err) + return data, err + } + + data.Difficulty, err = readUint8(datFile) + if err != nil { + log.Printf("Cant read Difficulty: %s", err) + return data, err + } + + data.Finished, err = readBool(datFile) + if err != nil { + log.Printf("Couln't read Finished bool: %s", err) + return data, err + } + + data.PlayerWon, err = readBool(datFile) + if err != nil { + log.Printf("Couldn't read PlayerWon: %s", err) + return data, err + } + + data.NextLevel, err = readUTF8String(datFile, false) + if err != nil { + log.Printf("Couldn't read NextLevel: %s", err) + return data, err + } + + data.CanContinue, err = readBool(datFile) + if err != nil { + log.Printf("Couldn't read CanContinue: %s", err) + return data, err + } + + data.FinishedButContinuing, err = readBool(datFile) + if err != nil { + log.Printf("Couldn't read FinishedButContinuing: %s", err) + return data, err + } + + data.SavingReplay, err = readBool(datFile) + if err != nil { + log.Printf("Couldn't read SavingReplay: %s", err) + return data, err + } + + Used, err := data.FactorioVersion.CheckCompatibility(constraintGreaterThan016) + if err != nil { + log.Printf("Error checking if used: %s", err) + return data, err + } + if Used { + data.AllowNonAdminDebugOptions, err = readBool(datFile) + if err != nil { + log.Printf("Couldn't read allow_non_admin_debug_options: %s", err) + return data, err + } + } + + data.LoadedFrom, err = readVersionShort8(datFile) + if err != nil { + log.Printf("Couldn't read LoadedFrom: %s", err) + return data, err + } + + data.LoadedFromBuild, err = readUint16(datFile) + if err != nil { + log.Printf("Couldn't read LoadedFromBuild: %s", err) + return data, err + } + + data.AllowedCommads, err = readUint8(datFile) + if err != nil { + log.Printf("Couldn't read AllowedCommands: %s", err) + return data, err + } + + New, err := data.FactorioVersion.CheckCompatibility(constraintGreaterThan016) + if err != nil { + log.Printf("error checking compatibility: %s", err) + return data, err + } + + if New { + numMods8, err2 := readUint8(datFile) //TODO read Optim. int + err = err2 + data.NumMods = uint32(numMods8) + } else { + data.NumMods, err = readUint32(datFile) + } + if err != nil { + log.Printf("Couldn't read NumMods: %s", err) + return data, err + } + + for i := uint32(0); i < data.NumMods; i++ { + SingleMod, err := readSingleMod(datFile) + if err != nil { + log.Printf("Couldn't read SingleMod: %s", err) + return data, err + } + + data.Mods = append(data.Mods, SingleMod) + } + + return data, nil +} + +func readUTF8String(file io.ReadCloser, forcedOptim bool) (string, error) { + var err error + + New, err := data.FactorioVersion.CheckCompatibility(constraintGreaterThan016) + if err != nil { + log.Printf("Couldn't checkCompatibility: %s", err) + return "", err + } + + var infoByteStringLength uint32 + if New || forcedOptim { + infoByteInt8, err2 := readUint8(file) //TODO read optimized int + err = err2 + infoByteStringLength = uint32(infoByteInt8) + } else { + infoByteStringLength, err = readUint32(file) //uint32 + } + + if err != nil { + log.Printf("Couldn't read infoByteStringLength: %s", err) + return "", err + } + + stringBytes := make([]byte, infoByteStringLength) + _, err = file.Read(stringBytes) + if err != nil { + log.Printf("error reading bytes: %s", err) + return "", err + } + finalizedString := string(stringBytes[:]) + + return finalizedString, nil +} + +func readUint8(file io.ReadCloser) (uint8, error) { + var err error + var temp [1]byte + _, err = file.Read(temp[:]) + if err != nil { + log.Printf("error reading byte: %s", err) + return 0, nil + } + + return uint8(temp[0]), nil +} + +func readUint16(file io.ReadCloser) (uint16, error) { + var err error + var temp [2]byte + + _, err = file.Read(temp[:]) + if err != nil { + log.Printf("error reading bytes: %s", err) + return 0, err + } + + return binary.LittleEndian.Uint16(temp[:]), nil +} + +func readUint32(file io.ReadCloser) (uint32, error) { + var err error + var temp [4]byte + + _, err = file.Read(temp[:]) + if err != nil { + log.Printf("error reading bytes: %s", err) + return 0, err + } + + return binary.LittleEndian.Uint32(temp[:]), nil +} + +func readBool(file io.ReadCloser) (bool, error) { + byteAsInt, err := readUint8(file) + if err != nil { + log.Printf("error loading Uint8: %s", err) + return false, err + } + + return byteAsInt != 0, nil +} + +func readVersion16(file io.ReadCloser) (version16, error) { + var Version version16 + var VersionShort versionShort16 + var err error + + VersionShort, err = readVersionShort16(file) + if err != nil { + log.Printf("error reading VersionShort") + return Version, err + } + + Version.Major = VersionShort.Major + Version.Minor = VersionShort.Minor + Version.Build = VersionShort.Build + + Version.Revision, err = readUint16(file) + if err != nil { + log.Printf("error reading revision: %s", err) + return Version, err + } + + return Version, nil +} + +func readVersionShort16(file io.ReadCloser) (versionShort16, error) { + var Version versionShort16 + var err error + + Version.Major, err = readUint16(file) + if err != nil { + log.Printf("error reading major: %s", err) + return Version, err + } + + Version.Minor, err = readUint16(file) + if err != nil { + log.Printf("error reading minor: %s", err) + return Version, err + } + + Version.Build, err = readUint16(file) + if err != nil { + log.Printf("error reading build: %s", err) + return Version, err + } + + return Version, err +} + +func readVersionShort8(file io.ReadCloser) (versionShort8, error) { + var Version versionShort8 + var err error + + Version.Major, err = readUint8(file) + if err != nil { + log.Printf("error reading major: %s", err) + return Version, err + } + + Version.Minor, err = readUint8(file) + if err != nil { + log.Printf("error reading minor: %s", err) + return Version, err + } + + Version.Build, err = readUint8(file) + if err != nil { + log.Printf("error reading build: %s", err) + return Version, err + } + + return Version, nil +} + +func readSingleMod(file io.ReadCloser) (singleMod, error) { + var Mod singleMod + var err error + + Mod.Name, err = readUTF8String(file, true) + if err != nil { + log.Printf("error loading modName: %s", err) + return Mod, err + } + + Mod.Version, err = readVersionShort8(file) + if err != nil { + log.Printf("error loading modVersion: %s", err) + return Mod, err + } + + Constraint, _ := semver.NewConstraint("> 0.15.0") + Used, err := data.FactorioVersion.CheckCompatibility(Constraint) + if err != nil { + log.Printf("Error checking used of CRC: %s", err) + return Mod, err + } + if Used { + Mod.CRC, err = readUint32(file) + if err != nil { + log.Printf("error loading CRC: %s", err) + return Mod, err + } + } + + return Mod, err +} + +func (Version *versionShort16) CheckCompatibility(constraints *semver.Constraints) (bool, error) { + Ver, err := semver.NewVersion(strconv.Itoa(int(Version.Major)) + "." + strconv.Itoa(int(Version.Minor)) + "." + strconv.Itoa(int(Version.Build))) + if err != nil { + log.Printf("Error creating semver-version: %s", err) + return false, err + } + + return constraints.Check(Ver), nil +} diff --git a/src/vendor/factorioSave/openSave.go b/src/vendor/factorioSave/openSave.go new file mode 100644 index 00000000..bd5c426d --- /dev/null +++ b/src/vendor/factorioSave/openSave.go @@ -0,0 +1,35 @@ +package factorioSave + +import ( + "archive/zip" + "log" + "io" + "errors" +) + +var ErrorLevelDatNotFound = errors.New("couldn't find level.dat") + +func openSave(filePath string) (io.ReadCloser, error) { + var err error + + saveFile, err := zip.OpenReader(filePath) + if err != nil { + log.Printf("error opening saveFile: %s", err) + return nil, err + } + + for _, singleFile := range saveFile.File { + if singleFile.FileInfo().Name() == "level.dat" { + //open level.dat + rc, err := singleFile.Open() + if err != nil { + log.Printf("Couldn't open level.dat: %s", err) + return nil, err + } + + return rc, nil + } + } + + return nil, ErrorLevelDatNotFound +} diff --git a/ui/App/components/Mods/ModLoadSave.jsx b/ui/App/components/Mods/ModLoadSave.jsx new file mode 100644 index 00000000..cb6d1bfc --- /dev/null +++ b/ui/App/components/Mods/ModLoadSave.jsx @@ -0,0 +1,160 @@ +import React from 'react'; +import ReactDOMServer from 'react-dom/server'; +import {instanceOfModsContent} from "./ModsPropTypes"; + +class ModLoadSave extends React.Component { + constructor(props) { + super(props); + + this.loadMods = this.loadMods.bind(this); + this.loadModsSwalHandler = this.loadModsSwalHandler.bind(this); + } + + componentDidMount() { + //Load Saves + this.props.getSaves(); + } + + loadMods(e) { + e.preventDefault(); + + $.ajax({ + url: "/api/mods/save/load", + method: "POST", + data: $(e.target).serialize(), + dataType: "JSON", + success: (data) => { + let checkboxes = []; + + data.data.mods.forEach((mod) => { + if(mod.name == "base") return; + + let modVersion = mod.version.major + "." + mod.version.minor + "." + mod.version.build; + let singleCheckbox = + + {mod.name} + + + + {modVersion} + + + + + checkboxes.push(singleCheckbox); + }); + + if(checkboxes.length == 0) { + swal({ + title: "No mods in this save!", + type: "error" + }); + return; + } + + let table =
+ All Mods will be installed +
+
+ + + + + + + + + {checkboxes} + +
+ ModName + + ModVersion +
+
+
+
+ + swal({ + title: "Mods to install", + text: ReactDOMServer.renderToStaticMarkup(table), + html: true, + type: 'info', + showCancelButton: true, + closeOnConfirm: false, + confirmButtonText: "Download Mods!", + cancelButtonText: "Cancel", + showLoaderOnConfirm: true + }, this.loadModsSwalHandler); + }, + error: (jqXHR) => { + swal({ + title: jqXHR.responseJSON.data, + html: true, + type: "error", + }); + } + }); + } + + loadModsSwalHandler() { + $.ajax({ + url: "/api/mods/install/multiple", + method: "POST", + dataType: "JSON", + data: $("#swalForm").serialize(), + success: (data) => { + swal({ + title: "All Mods installed successfully!", + type: "success" + }); + + this.props.modContentClass.setState({ + installedMods: data.data.mods + }); + }, + error: (jqXHR) => { + let json_data = JSON.parse(jqXHR.responseJSON.data); + + swal({ + title: json_data.detail, + type: "error", + }); + } + }) + } + + render() { + let saves = []; + this.props.saves.forEach((value, index) => { + if(index != this.props.saves.length - 1) { + saves.push( + + ) + } + }); + + return ( +
+
+
+ +
+ +
+
+
+
+ ) + } +} + +ModLoadSave.propTypes = { + modContentClass: instanceOfModsContent.isRequired, +} + +export default ModLoadSave; diff --git a/ui/App/components/Mods/ModOverview.jsx b/ui/App/components/Mods/ModOverview.jsx index d872b9d9..251f2fe9 100644 --- a/ui/App/components/Mods/ModOverview.jsx +++ b/ui/App/components/Mods/ModOverview.jsx @@ -5,6 +5,7 @@ import ModUpload from "./ModUpload.jsx"; import ModManager from "./ModManager.jsx"; import ModPacks from "./packs/ModPackOverview.jsx"; import {instanceOfModsContent} from "./ModsPropTypes.js"; +import ModLoadSave from "./ModLoadSave.jsx"; class ModOverview extends React.Component { constructor(props) { @@ -83,6 +84,17 @@ class ModOverview extends React.Component { /> +
+
+ +

Load Mods From Save

+
+ + +
+