From 6792fc1884c311b43f2c32ea0155926db9876c01 Mon Sep 17 00:00:00 2001 From: frikky Date: Wed, 14 Oct 2020 16:03:17 +0200 Subject: [PATCH 01/18] Fixed bad API key for abort workflow --- backend/go-app/walkoff.go | 59 ++++++++++++++++++++++++++++----------- docker-compose.yml | 2 +- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/backend/go-app/walkoff.go b/backend/go-app/walkoff.go index 3b725726..9d45971b 100644 --- a/backend/go-app/walkoff.go +++ b/backend/go-app/walkoff.go @@ -2118,17 +2118,7 @@ func abortExecution(resp http.ResponseWriter, request *http.Request) { return } - // FIXME: Check the execution if this fails. - user, err := handleApiAuthentication(resp, request) - if err != nil { - log.Printf("Api authentication failed in abort workflow: %s", err) - resp.WriteHeader(401) - resp.Write([]byte(`{"success": false}`)) - return - } - location := strings.Split(request.URL.String(), "/") - var fileId string if location[1] == "api" { if len(location) <= 4 { @@ -2162,12 +2152,34 @@ func abortExecution(resp http.ResponseWriter, request *http.Request) { return } - // FIXME - have a check for org etc too.. - if user.Id != workflowExecution.Workflow.Owner && user.Role != "admin" { - log.Printf("Wrong user (%s) for workflowexecution workflow %s", user.Username, workflowExecution.Workflow.ID) - resp.WriteHeader(401) - resp.Write([]byte(`{"success": false}`)) - return + apikey := request.Header.Get("Authorization") + parsedKey := "" + if strings.HasPrefix(apikey, "Bearer ") { + apikeyCheck := strings.Split(apikey, " ") + if len(apikeyCheck) == 2 { + parsedKey = apikeyCheck[1] + } + } + + if workflowExecution.Authorization != parsedKey { + // FIXME: Check the execution if this fails. + user, err := handleApiAuthentication(resp, request) + if err != nil { + log.Printf("Api authentication failed in abort workflow: %s", err) + resp.WriteHeader(401) + resp.Write([]byte(`{"success": false}`)) + return + } + + // FIXME - have a check for org etc too.. + if user.Id != workflowExecution.Workflow.Owner && user.Role != "admin" { + log.Printf("Wrong user (%s) for workflowexecution workflow %s", user.Username, workflowExecution.Workflow.ID) + resp.WriteHeader(401) + resp.Write([]byte(`{"success": false}`)) + return + } + } else { + log.Printf("API key %s is correct to abort %s", parsedKey, executionId) } if workflowExecution.Status == "ABORTED" || workflowExecution.Status == "FAILURE" || workflowExecution.Status == "FINISHED" { @@ -4685,7 +4697,22 @@ func iterateOpenApiGithub(fs billy.Filesystem, dir []os.FileInfo, extra string, case mode.IsRegular(): // Check the file filename := file.Name() + filteredNames := []string{"FUNDING.yml"} if strings.Contains(filename, "yaml") || strings.Contains(filename, "yml") { + + contOuter := false + for _, name := range filteredNames { + if filename == name { + contOuter = true + break + } + } + + if contOuter { + log.Printf("Skipping %s", filename) + continue + } + //log.Printf("File: %s", filename) //log.Printf("Found file: %s", filename) log.Printf("OpenAPI app: %s", filename) diff --git a/docker-compose.yml b/docker-compose.yml index 65f16565..9c9438cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: '3' services: frontend: - #build: ./frontend + build: ./frontend image: frikky/shuffle:frontend container_name: shuffle-frontend hostname: shuffle-frontend From 14582d16b39c905b43690c38b243221c4e91d821 Mon Sep 17 00:00:00 2001 From: frikky Date: Wed, 14 Oct 2020 16:55:50 +0200 Subject: [PATCH 02/18] @garanews: Fixed path issue in OpenAPI parser --- backend/go-app/codegen.go | 9 ++++--- frontend/src/views/AppCreator.jsx | 43 ++++++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/backend/go-app/codegen.go b/backend/go-app/codegen.go index c23070f3..ef7a12f2 100644 --- a/backend/go-app/codegen.go +++ b/backend/go-app/codegen.go @@ -396,10 +396,11 @@ func makePythoncode(swagger *openapi3.Swagger, name, url, method string, paramet bodyAddin, verifyAddin, ) - //if strings.Contains(functionname, "get_returns_the_vuln") { - // log.Println(data) - // log.Printf("Queries: %s", queryString) - //} + + if strings.Contains(functionname, "api_dumps_delete") { + log.Println(data) + log.Printf("Queries: %s", queryString) + } //log.Printf(data) return functionname, data diff --git a/frontend/src/views/AppCreator.jsx b/frontend/src/views/AppCreator.jsx index 7dab66d4..640e1c98 100644 --- a/frontend/src/views/AppCreator.jsx +++ b/frontend/src/views/AppCreator.jsx @@ -701,7 +701,7 @@ const AppCreator = (props) => { data.paths[item.url][item.method.toLowerCase()].parameters.push(newitem) //console.log(queryitem) } - } + } if (item.paths.length > 0) { for (querykey in item.paths) { @@ -720,6 +720,30 @@ const AppCreator = (props) => { newitem.description = queryitem.description } + data.paths[item.url][item.method.toLowerCase()].parameters.push(newitem) + //console.log(queryitem) + } + } else { + // Always goes here if they didn't click anything :/ + const values = getCurrentPaths(item.url) + const paths = values[0] + + for (querykey in paths) { + const queryitem = paths[querykey] + newitem = { + "in": "path", + "name": queryitem, + "description": "Generated by shuffler.io OpenAPI", + "required": true, + "schema": { + "type": "string", + }, + } + + if (queryitem.description !== undefined) { + newitem.description = queryitem.description + } + data.paths[item.url][item.method.toLowerCase()].parameters.push(newitem) //console.log(queryitem) } @@ -1220,7 +1244,7 @@ const AppCreator = (props) => { return errormessage } - const UrlPathParameters = () => { + const getCurrentPaths = (urlPath) => { var paths = [] var queries = [] @@ -1296,7 +1320,16 @@ const AppCreator = (props) => { } } + return [paths, queries] + } + + const UrlPathParameters = () => { + const values = getCurrentPaths(urlPath) + const paths = values[0] + const queries = values[1] + if (currentAction.paths !== paths && urlPath.length > 0) { + console.log("IN PATHS SETTER: !", paths) setActionField("paths", paths) } @@ -1563,6 +1596,8 @@ const AppCreator = (props) => { - }} - onChange={e => setOpenApiData(e.target.value)} - helperText={Must point to a version 2 or 3 specification.} - placeholder="OpenAPI text" - fullWidth - /> +

Or upload a YAML or JSON specification

+ + {errorText} From 57d4fa77191d0432ac55dc0e47133f47320dce18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABlann=20Barciet?= Date: Thu, 22 Oct 2020 22:34:39 +0200 Subject: [PATCH 08/18] add dropzone of OpenAPI files --- frontend/src/components/Dropzone.js | 84 +++++++++++++++++++++++++++++ frontend/src/views/Apps.jsx | 61 ++++++++++++--------- 2 files changed, 120 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/Dropzone.js diff --git a/frontend/src/components/Dropzone.js b/frontend/src/components/Dropzone.js new file mode 100644 index 00000000..621e74b5 --- /dev/null +++ b/frontend/src/components/Dropzone.js @@ -0,0 +1,84 @@ +import React, { useRef, useState } from 'react'; +import { useEffect } from 'react'; +import BackupIcon from '@material-ui/icons/Backup'; + +const dragOverStyle = { + backgroundColor: 'rgba(0,0,0,0.8)', + border: '5px dashed white', + borderRadius: '8px', + width: '100%', + height: '100%', + position: 'absolute', + overflow: 'hidden', + zIndex: 100, + display: 'flex', + alignItems: 'center', + justifyContent: 'center' +}; + +const Dropzone = ({ children, style, onDrop }) => { + const dropzoneRef = useRef(null); + const [dragging, setDragging] = useState(false); + + let dragCounter = 0; + + const handleDragOver = (e) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDragEnter = (e) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter++; + if (e.dataTransfer.items && e.dataTransfer.items.length > 0) + setDragging(true); + }; + + const handleDragLeave = (e) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter--; + if (dragCounter === 0) setDragging(false); + }; + + const handleDrop = (e) => { + e.preventDefault(); + e.stopPropagation(); + setDragging(false); + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + onDrop(e); + e.dataTransfer.clearData(); + dragCounter = 0; + } + }; + + useEffect(() => { + if (!dropzoneRef.current) return; + + dropzoneRef.current.addEventListener('dragover', handleDragOver); + dropzoneRef.current.addEventListener('dragenter', handleDragEnter); + dropzoneRef.current.addEventListener('dragleave', handleDragLeave); + dropzoneRef.current.addEventListener('drop', handleDrop); + + return () => { + dropzoneRef.current.removeEventListener('dragover', handleDragOver); + dropzoneRef.current.removeEventListener('dragenter', handleDragEnter); + dropzoneRef.current.removeEventListener('dragleave', handleDragLeave); + dropzoneRef.current.removeEventListener('drop', handleDrop); + }; + }, [dropzoneRef]); + + return ( +
+ {dragging && ( +
+ +
+ )} + {children} +
+ ); +}; + +export default Dropzone; diff --git a/frontend/src/views/Apps.jsx b/frontend/src/views/Apps.jsx index d5f0cddc..fe2fd6ad 100644 --- a/frontend/src/views/Apps.jsx +++ b/frontend/src/views/Apps.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect } from 'react'; import { useInterval } from 'react-powerhooks'; @@ -37,6 +37,7 @@ import DialogActions from '@material-ui/core/DialogActions'; import DialogContent from '@material-ui/core/DialogContent'; import CircularProgress from '@material-ui/core/CircularProgress'; +import Dropzone from '../components/Dropzone'; const surfaceColor = "#27292D" const inputColor = "#383B40" @@ -134,7 +135,8 @@ const Apps = (props) => { const [cursearch, setCursearch] = React.useState("") const [sharingConfiguration, setSharingConfiguration] = React.useState("you") - const upload = useRef(null); + const [isDropzone, setIsDropzone] = React.useState(false); + const upload = React.useRef(null); const { start, stop } = useInterval({ duration: 5000, @@ -179,7 +181,6 @@ const Apps = (props) => { color: "#ffffff", width: "100%", display: "flex", - margin: 20, } const paperAppStyle = { @@ -792,8 +793,37 @@ const Apps = (props) => { //} } + const uploadFile = (e) => { + const isDropzone = e.dataTransfer?.files.length > 0; + const files = isDropzone ? e.dataTransfer.files : e.target.files; + + const reader = new FileReader(); + + reader.addEventListener('load', (e) => { + const content = e.target.result; + setOpenApiData(content); + setIsDropzone(isDropzone); + }); + + reader.readAsText(files[0]); + }; + + useEffect(() => { + if (openApiData.length > 0) { + setOpenApiError(''); + validateOpenApi(openApiData); + } + }, [openApiData]); + + useEffect(() => { + if (appValidation && isDropzone) { + redirectOpenApi(); + setIsDropzone(false); + } + }, [appValidation, isDropzone]); + const appView = isLoggedIn ? -
1366 ? 1366 : 1200, margin: "auto",}}> + 1366 ? 1366 : 1200, margin: "auto", padding: 20 }} onDrop={uploadFile}>
@@ -905,7 +935,7 @@ const Apps = (props) => {
- + : null @@ -1176,7 +1206,7 @@ const Apps = (props) => { }) .then((responseJson) => { if (responseJson.success) { - setAppValidation(responseJson.id) + setAppValidation(responseJson.id); } else { if (responseJson.reason !== undefined) { setOpenApiError(responseJson.reason) @@ -1200,25 +1230,6 @@ const Apps = (props) => { setLoadAppsModalOpen(false) } - const uploadFile = (e) => { - const file = e.target.files[0]; - const reader = new FileReader(); - - reader.addEventListener('load', (e) => { - const content = e.target.result; - setOpenApiData(content); - }); - - reader.readAsText(file); - }; - - useEffect(() => { - if(openApiData.length > 0) { - setOpenApiError(''); - validateOpenApi(openApiData); - } - }, [openApiData]); - const deleteModal = deleteModalOpen ? Date: Fri, 23 Oct 2020 02:06:48 +0200 Subject: [PATCH 09/18] Fixed minor backend return bug and JSON in frontend --- backend/app_gen/openapi-parsers/swimlane.py | 130 +++++++++++++++----- backend/go-app/main.go | 2 +- backend/go-app/walkoff.go | 9 +- frontend/src/views/AngularWorkflow.jsx | 27 ++-- frontend/src/views/AppCreator.jsx | 5 +- frontend/src/views/Apps.jsx | 2 +- functions/onprem/worker/worker.go | 15 +-- 7 files changed, 128 insertions(+), 62 deletions(-) diff --git a/backend/app_gen/openapi-parsers/swimlane.py b/backend/app_gen/openapi-parsers/swimlane.py index 6333da59..d65b601a 100644 --- a/backend/app_gen/openapi-parsers/swimlane.py +++ b/backend/app_gen/openapi-parsers/swimlane.py @@ -2,6 +2,16 @@ import requests import yaml import json import os +import io +import base64 +from PIL import Image +#import tkinter +#import _tkinter +#tkinter._test() + + +#sudo apt-get install python-imaging-tk +#sudo apt-get install python3-tk # USAGE: # 1. Find the item here: @@ -194,54 +204,108 @@ def dump_data(filename, openapi, category): with open(generatedfile, "w+") as tmp: tmp.write(yaml.dump(openapi)) except FileNotFoundError: - os.mkdir("generated/%s" % category) + try: + os.mkdir("generated/%s" % category) + with open(generatedfile, "w+") as tmp: + tmp.write(yaml.dump(openapi)) - with open(generatedfile, "w+") as tmp: - tmp.write(yaml.dump(openapi)) - - print("Generated %s" % generatedfile) + except FileExistsError: + pass if __name__ == "__main__": - number = 1 #https://apphub.swimlane.com/ categories = [ + "Investigation", "Endpoint Security & Management", + "Network Security & Management", + "Communication", "SIEM & Log Management", + "Governance & Risk Management", + "Vulnerability & Patch Management", "Ticket Management", + "DevOps & Application Security", + "Identity & Access Management", + "Infrastructure", + "Miscellaneous", ] search_category = categories[2] total = 0 - while(True): - url = "https://apphub.swimlane.io/api/search/swimbundles?page=%d" % number - - json = {"fields": {"family": search_category}} - ret = requests.post( - url, - json=json, - ) + for search_category in categories: + number = 1 + innertotal = 0 - if ret.status_code != 201: - print("RET NOT 201: %d" % ret.status_code) - break + while(True): + url = "https://apphub.swimlane.io/api/search/swimbundles?page=%d" % number + + json = {"fields": {"family": search_category}} + ret = requests.post( + url, + json=json, + ) - parsed = ret.json() - try: - category = parsed["data"][0]["swimbundleMeta"]["family"][0] - except KeyError: - category = "" - except IndexError: - category = "" + if ret.status_code != 201: + print("RET NOT 201: %d" % ret.status_code) + break - if category == "": - break + parsed = ret.json() + try: + category = parsed["data"][0]["swimbundleMeta"]["family"][0] + except KeyError: + category = "" + except IndexError: + category = "" - for data in parsed["data"]: - filename, openapi = parse_data(data) - openapi["tags"] = [category] - dump_data(filename, openapi, category) - total += 1 + if category == "": + break - number += 1 + for data in parsed["data"]: + try: + filename, openapi = parse_data(data) + except: + try: + print("Skipping %s %s because of an error" % (data["vendor"], data["product"])) + except KeyError: + pass - print("Created %d openapi specs from Swimlane with category %s" % (total, search_category)) + continue + + openapi["tags"] = [ + { + "name": category, + } + ] + + appid = data["swimbundleMeta"]["logo"]["id"] + logoUrl = "https://apphub.swimlane.io/api/logos/%s" % appid + logodata = requests.get(logoUrl) + if logodata.status_code == 200: + logojson = logodata.json() + try: + logobase64 = logojson["data"]["base64"] + #.split(",")[1] + + openapi["info"]["x-logo"] = logobase64 + #print(logobase64) + #msg = base64.b64decode(logobase64) + #with io.BytesIO(msg) as buf: + # with Image.open(buf) as tempImg: + # newWidth = 174 / tempImg.width # change this to what ever width you need. + # newHeight = 174 / tempImg.height # change this to what ever height you need. + # newSize = (int(newWidth * tempImg.width), int(newHeight * tempImg.height)) + # newImg1 = tempImg.resize(newSize) + # lbl1.IMG = ImageTk.PhotoImage(image=newImg1) + # lbl1.configure(image=lbl1.IMG) + except KeyError: + print("Failed logo parsing for %s" % appid) + pass + + dump_data(filename, openapi, category) + innertotal += 1 + total += 1 + + number += 1 + + print("Created %d openapi specs from Swimlane with category %s" % (innertotal, search_category)) + + print("\nCreated %d TOTAL openapi specs from Swimlane" % (total)) diff --git a/backend/go-app/main.go b/backend/go-app/main.go index 4a78d089..1e6c35a7 100644 --- a/backend/go-app/main.go +++ b/backend/go-app/main.go @@ -6575,7 +6575,7 @@ func runInit(ctx context.Context) { log.Printf("Getting remote workflow apps") workflowapps, err := getAllWorkflowApps(ctx) if err != nil { - log.Printf("Failed getting apps: %s", err) + log.Printf("Failed getting apps (runInit): %s", err) } else if err == nil && len(workflowapps) == 0 { log.Printf("Downloading default workflow apps") fs := memfs.New() diff --git a/backend/go-app/walkoff.go b/backend/go-app/walkoff.go index 96734540..b5a505ce 100644 --- a/backend/go-app/walkoff.go +++ b/backend/go-app/walkoff.go @@ -3991,7 +3991,7 @@ func getWorkflowApps(resp http.ResponseWriter, request *http.Request) { workflowapps, err := getAllWorkflowApps(ctx) if err != nil { - log.Printf("Failed getting apps: %s", err) + log.Printf("Failed getting apps (getworkflowapps): %s", err) resp.WriteHeader(401) resp.Write([]byte(`{"success": false}`)) return @@ -4554,8 +4554,9 @@ func handleAppHotloadRequest(resp http.ResponseWriter, request *http.Request) { log.Printf("Hotloading from %s", location) err = handleAppHotload(location, true) if err != nil { + log.Printf("Failed app hotload: %s", err) resp.WriteHeader(500) - resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "Failed loading apps: %s"}`))) + resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "Failed loading apps: %s"}`, err))) return } @@ -4720,7 +4721,7 @@ func iterateOpenApiGithub(fs billy.Filesystem, dir []os.FileInfo, extra string, //log.Printf("File: %s", filename) //log.Printf("Found file: %s", filename) - log.Printf("OpenAPI app: %s", filename) + //log.Printf("OpenAPI app: %s", filename) tmpExtra := fmt.Sprintf("%s%s/", extra, file.Name()) fileReader, err := fs.Open(tmpExtra) @@ -5307,7 +5308,7 @@ func getAllSchedules(ctx context.Context) ([]ScheduleOld, error) { func getAllWorkflowApps(ctx context.Context) ([]WorkflowApp, error) { var allworkflowapps []WorkflowApp - q := datastore.NewQuery("workflowapp") + q := datastore.NewQuery("workflowapp").Limit(50) _, err := dbclient.GetAll(ctx, q, &allworkflowapps) if err != nil { diff --git a/frontend/src/views/AngularWorkflow.jsx b/frontend/src/views/AngularWorkflow.jsx index ac5925d0..edb3f2fa 100644 --- a/frontend/src/views/AngularWorkflow.jsx +++ b/frontend/src/views/AngularWorkflow.jsx @@ -2538,9 +2538,9 @@ const AngularWorkflow = (props) => { var jsonvalid = true try { const tmp = String(JSON.parse(foundResult.result)) - //if (!tmp.includes("{") && !tmp.includes("[")) { - // jsonvalid = false - //} + if (!foundResult.result.includes("{") && !foundResult.result.includes("[")) { + jsonvalid = false + } } catch (e) { jsonvalid = false } @@ -2627,9 +2627,9 @@ const AngularWorkflow = (props) => { var jsonvalid = true try { const tmp = String(JSON.parse(actionItem.example)) - //if (!tmp.includes("{") && !tmp.includes("[")) { - // jsonvalid = false - //} + if (!actionItem.example.includes("{") && !actionItem.example.includes("[")) { + jsonvalid = false + } } catch (e) { jsonvalid = false } @@ -5352,9 +5352,9 @@ const AngularWorkflow = (props) => { var jsonvalid = true try { const tmp = String(JSON.parse(showResult)) - //if (!tmp.includes("{") && !tmp.includes("[")) { - // jsonvalid = false - //} + if (!showResult.includes("{") && !showResult.includes("[")) { + jsonvalid = false + } } catch (e) { jsonvalid = false } @@ -5549,11 +5549,12 @@ const AngularWorkflow = (props) => { var jsonvalid = true try { const tmp = String(JSON.parse(showResult)) - //if (!tmp.includes("{") && !tmp.includes("[")) { - // console.log("IN HERE") - // jsonvalid = false - //} + if (!showResult.includes("{") && !showResult.includes("[")) { + console.log("IN HERE: ", tmp) + jsonvalid = false + } } catch (e) { + console.log("Error: ", e) jsonvalid = false } diff --git a/frontend/src/views/AppCreator.jsx b/frontend/src/views/AppCreator.jsx index 640e1c98..4d5098bd 100644 --- a/frontend/src/views/AppCreator.jsx +++ b/frontend/src/views/AppCreator.jsx @@ -618,8 +618,7 @@ const AppCreator = (props) => { "id": props.match.params.appid, } - - if (basedata.info.contact !== undefined) { + if (basedata.info !== undefined && basedata.info.contact !== undefined) { data.info["contact"] = basedata.info.contact } else if (contact === "") { data.info["contact"] = { @@ -1739,7 +1738,7 @@ const AppCreator = (props) => { // const imageData = file.length > 0 ? file : fileBase64 - const imageInfo = + const imageInfo = // Random names for type & autoComplete. Didn't research :^) const landingpageDataBrowser = diff --git a/frontend/src/views/Apps.jsx b/frontend/src/views/Apps.jsx index 1245d15f..ccc56c87 100644 --- a/frontend/src/views/Apps.jsx +++ b/frontend/src/views/Apps.jsx @@ -733,7 +733,7 @@ const Apps = (props) => {  - OpenAPI directory  - OpenAPI Validator
- Apps interact with eachother in workflows. They are created with the app creator, using OpenAPI specification or manually in python. The links above are references to OpenAPI tools and other app repositories. There's ten thousands of them. + Apps interact with eachother in workflows. They are created with the app creator, using OpenAPI specification or manually in python. The links above are references to OpenAPI tools and other app repositories. There's thousands of them.
diff --git a/functions/onprem/worker/worker.go b/functions/onprem/worker/worker.go index 4fe03ce9..c6b3889a 100644 --- a/functions/onprem/worker/worker.go +++ b/functions/onprem/worker/worker.go @@ -694,15 +694,16 @@ func handleExecution(client *http.Client, req *http.Request, workflowExecution W // FIXME: Force killing a worker should result in a notification somewhere if len(nextActions) == 0 { log.Printf("No next action. Finished? Result vs Actions: %d - %d", len(workflowExecution.Results), len(workflowExecution.Workflow.Actions)) - //exit := true - //for _, item := range workflowExecution.Results { - // if item == "EXECUTING" { - // exit = false - // break - // } - //} + exit := true + for _, item := range workflowExecution.Results { + if item.Status == "EXECUTING" { + exit = false + break + } + } if exit && len(workflowExecution.Results) == len(workflowExecution.Workflow.Actions) { + log.Printf("Shutting down.") shutdown(workflowExecution.ExecutionId, workflowExecution.Workflow.ID) } From 5351b6eade880a2ccf75dde9297b42ff7e8a3357 Mon Sep 17 00:00:00 2001 From: frikky Date: Fri, 23 Oct 2020 02:14:52 +0200 Subject: [PATCH 10/18] #182: Added error popup if bad OpenAPI --- frontend/src/views/Apps.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/Apps.jsx b/frontend/src/views/Apps.jsx index fe2fd6ad..b30f47c5 100644 --- a/frontend/src/views/Apps.jsx +++ b/frontend/src/views/Apps.jsx @@ -803,7 +803,8 @@ const Apps = (props) => { const content = e.target.result; setOpenApiData(content); setIsDropzone(isDropzone); - }); + setOpenApiModal(true) + }) reader.readAsText(files[0]); }; @@ -1359,7 +1360,7 @@ const Apps = (props) => {
: null - const errorText = openApiError.length > 0 ?
Error: {openApiError}
: null + const errorText = openApiError.length > 0 ?
Error: {openApiError}
: null const modalView = openApiModal ? Date: Sat, 24 Oct 2020 20:37:17 +0200 Subject: [PATCH 11/18] Added basics of cloud sync --- backend/go-app/main.go | 161 +++++++++++++++++++++++++++-------- backend/go-app/walkoff.go | 14 ++- frontend/src/views/Admin.jsx | 121 +++++++++++++++++++++----- 3 files changed, 237 insertions(+), 59 deletions(-) diff --git a/backend/go-app/main.go b/backend/go-app/main.go index 1e6c35a7..5e8c84a2 100644 --- a/backend/go-app/main.go +++ b/backend/go-app/main.go @@ -161,6 +161,7 @@ type Environment struct { Default bool `datastore:"default" json:"default"` Archived bool `datastore:"archived" json:"archived"` Id string `datastore:"id" json:"id"` + OrgId string `datastore:"org_id" json:"org_id"` } type User struct { @@ -1026,7 +1027,7 @@ func handleSetEnvironments(resp http.ResponseWriter, request *http.Request) { ctx := context.Background() var environments []Environment - q := datastore.NewQuery("Environments") + q := datastore.NewQuery("Environments").Filter("org_id =", user.ActiveOrg.Id) _, err = dbclient.GetAll(ctx, q, &environments) if err != nil { resp.WriteHeader(401) @@ -1185,6 +1186,19 @@ func createNewUser(username, password, role, apikey string, org Org) error { log.Printf("Error adding User %s: %s", username, err) return err } + + neworg, err := getOrg(ctx, org.Id) + if err == nil { + log.Printf("Updating org %s with user %s", org.Name, newUser.Username) + neworg.Users = append(org.Users, *newUser) + err = setOrg(ctx, *neworg, neworg.Id) + if err != nil { + log.Printf("Failed updating org with user %s", newUser.Username) + } else { + log.Printf("Successfully updated org with user %s!", newUser.Username) + } + } + // url := fmt.Sprintf("https://shuffler.io/register/%s", verifyToken.String()) // const verifyMessage = ` //Registration URL :) @@ -1253,7 +1267,22 @@ func handleRegister(resp http.ResponseWriter, request *http.Request) { role = "admin" } - err = createNewUser(data.Username, data.Password, role, "", user.ActiveOrg) + ctx := context.Background() + currentOrg := user.ActiveOrg + if user.ActiveOrg.Id == "" { + log.Printf("There's no active org for the user. Checking if there's a single one to assing it to.") + + var orgs []Org + q := datastore.NewQuery("Organizations") + _, err = dbclient.GetAll(ctx, q, &orgs) + if err == nil && len(orgs) == 1 { + log.Printf("No org exists in auth. Setting to default (first one)") + currentOrg = orgs[0] + } + + } + + err = createNewUser(data.Username, data.Password, role, "", currentOrg) if err != nil { log.Printf("Failed registering user: %s", err) resp.WriteHeader(401) @@ -1502,7 +1531,6 @@ func handleUpdateUser(resp http.ResponseWriter, request *http.Request) { } func handleApiGeneration(resp http.ResponseWriter, request *http.Request) { - log.Printf("APIGEN!") cors := handleCors(resp, request) if cors { return @@ -1515,7 +1543,6 @@ func handleApiGeneration(resp http.ResponseWriter, request *http.Request) { resp.Write([]byte(`{"success": false}`)) return } - log.Printf("APIKEY") ctx := context.Background() if request.Method == "GET" { @@ -2192,7 +2219,7 @@ func handleGetEnvironments(resp http.ResponseWriter, request *http.Request) { return } - _, err := handleApiAuthentication(resp, request) + user, err := handleApiAuthentication(resp, request) if err != nil { log.Printf("Api authentication failed in set new workflowhandler: %s", err) resp.WriteHeader(401) @@ -2202,7 +2229,7 @@ func handleGetEnvironments(resp http.ResponseWriter, request *http.Request) { ctx := context.Background() var environments []Environment - q := datastore.NewQuery("Environments") + q := datastore.NewQuery("Environments").Filter("org_id =", user.ActiveOrg.Id) _, err = dbclient.GetAll(ctx, q, &environments) if err != nil { resp.WriteHeader(401) @@ -2299,6 +2326,7 @@ func handleGetUsers(resp http.ResponseWriter, request *http.Request) { return } + // FIXME: Check by org. ctx := context.Background() var users []User q := datastore.NewQuery("Users") @@ -6454,6 +6482,14 @@ func runInit(ctx context.Context) { log.Printf("Should add %d users to organization default", len(users)) } + if len(activeOrgs) == 0 { + orgQuery := datastore.NewQuery("Organizations") + _, err = dbclient.GetAll(ctx, orgQuery, &activeOrgs) + if err != nil { + log.Printf("Failed getting orgs the second time around") + } + } + // Fix active users etc q := datastore.NewQuery("Users").Filter("active =", true) var activeusers []User @@ -6520,24 +6556,54 @@ func runInit(ctx context.Context) { } } } else { - //log.Printf("Found %d users.", len(users)) + log.Printf("Found %d users.", len(users)) + if len(activeOrgs) == 1 && len(users) > 0 { + for _, user := range users { + if user.ActiveOrg.Id == "" { + user.ActiveOrg = activeOrgs[0] + err = setUser(ctx, &user) + if err != nil { + log.Printf("Failed updating user %s", user.Username) + } + } + } + } //log.Printf(users[0].Username) } } // Gets environments and inits if it doesn't exist - log.Printf("Setting up environments") count, err := getEnvironmentCount() - if count == 0 && err == nil { + if count == 0 && err == nil && len(activeOrgs) == 1 { + log.Printf("Setting up environment with org %s", activeOrgs[0].Id) item := Environment{ - Name: "Shuffle", - Type: "onprem", + Name: "Shuffle", + Type: "onprem", + OrgId: activeOrgs[0].Id, } err = setEnvironment(ctx, &item) if err != nil { log.Printf("Failed setting up new environment") } + } else if len(activeOrgs) == 1 { + log.Printf("Setting up all environments with org %s", activeOrgs[0].Id) + var environments []Environment + q := datastore.NewQuery("Environments") + _, err = dbclient.GetAll(ctx, q, &environments) + if err == nil { + for _, item := range environments { + if item.OrgId == activeOrgs[0].Id { + continue + } + + item.OrgId = activeOrgs[0].Id + err = setEnvironment(ctx, &item) + if err != nil { + log.Printf("Failed adding environment to org %s", activeOrgs[0].Id) + } + } + } } // Gets schedules and starts them @@ -6765,9 +6831,9 @@ func handleCloudSetup(resp http.ResponseWriter, request *http.Request) { log.Printf("Apidata: %s", tmpData.Apikey) + // FIXME: Path client := &http.Client{} syncPath := "http://192.168.3.6:5002/api/v1/cloud/sync" - type requestStruct struct { ApiKey string `json:"api_key"` } @@ -6780,7 +6846,7 @@ func handleCloudSetup(resp http.ResponseWriter, request *http.Request) { if err != nil { log.Printf("Failed marshaling api key data: %s", err) resp.WriteHeader(401) - resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "Failed cloud sync."`, err))) + resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "Failed cloud sync."}`, err))) return } @@ -6793,49 +6859,74 @@ func handleCloudSetup(resp http.ResponseWriter, request *http.Request) { newresp, err := client.Do(req) if err != nil { resp.WriteHeader(400) - resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "Failed cloud sync: %s"`, err))) + resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "Failed cloud sync: %s. Contact support."}`, err))) //setBadMemcache(ctx, docPath) return } - if newresp.StatusCode != 200 { - resp.WriteHeader(400) - resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "Response code %d during sync. Expecting 200."`, newresp.StatusCode))) - return - } - respBody, err := ioutil.ReadAll(newresp.Body) if err != nil { resp.WriteHeader(500) - resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "Can't parse sync data"`))) + resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "Can't parse sync data. Contact support."}`))) return } - type responseStruct struct { - Success bool `json:"success"` - Reason string `json:"reason"` + type retStruct struct { + Success bool `json:"success"` + SyncFeatures SyncFeatures `json:"sync_features"` + SessionKey string `json:"session_key"` + IntervalSeconds int64 `json:"interval_seconds"` + Reason string `json:"reason"` } - log.Printf("Respbody: %s", string(respBody)) - responseData := responseStruct{} + log.Printf("Respbody: %s", string(respBody)) + responseData := retStruct{} err = json.Unmarshal(respBody, &responseData) if err != nil { resp.WriteHeader(500) - resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "Failed handling cloud data"`))) + resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "Failed handling cloud data"}`))) return } - if responseData.Success { - resp.WriteHeader(200) - if len(responseData.Reason) > 0 { - resp.Write([]byte(fmt.Sprintf(`{"success": true, "reason": "%s"}`, responseData.Reason))) - } else { - resp.Write([]byte(fmt.Sprintf(`{"success": true}`))) - } - } else { + if newresp.StatusCode != 200 { + resp.WriteHeader(401) + resp.Write(respBody) + return + } + + if !responseData.Success { resp.WriteHeader(400) resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "%s"}`, responseData.Reason))) + return } + + // FIXME: + // 1. Set cloudsync for org to be active + // 2. Add iterative sync schedule for interval seconds + // 3. Add another environment for the org's users + org.CloudSync = true + org.SyncFeatures = responseData.SyncFeatures + + org.SyncConfig = SyncConfig{ + Apikey: responseData.SessionKey, + Interval: responseData.IntervalSeconds, + } + + err = setOrg(ctx, *org, org.Id) + if err != nil { + log.Printf("ERROR: Failed updating org even though there was success: %s", err) + resp.WriteHeader(400) + resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "Failed setting up org after sync success. Contact support."}`))) + return + } + + if responseData.IntervalSeconds > 0 { + // FIXME: + log.Printf("Should set up interval for %d with session key %s for org %s", responseData.IntervalSeconds, responseData.SessionKey, org.Name) + } + + resp.WriteHeader(200) + resp.Write(respBody) } func initHandlers() { diff --git a/backend/go-app/walkoff.go b/backend/go-app/walkoff.go index b5a505ce..e2f5a3a3 100644 --- a/backend/go-app/walkoff.go +++ b/backend/go-app/walkoff.go @@ -68,16 +68,21 @@ type ExecutionRequest struct { type SyncFeatures struct { Apps SyncData `json:"apps" datastore:"apps"` - Workflows SyncData `json:"apps" datastore:"apps"` - Schedules SyncData `json:"apps" datastore:"apps"` - Autocomplete SyncData `json:"apps" datastore:"apps"` - Authentication SyncData `json:"apps" datastore:"apps"` + Workflows SyncData `json:"workflows" datastore:"workflows"` + Schedules SyncData `json:"schedules" datastore:"schedules"` + Autocomplete SyncData `json:"autocomplete" datastore:"autocomplete"` + Authentication SyncData `json:"authentication" datastore:"authentication"` } type SyncData struct { Active bool `json:"active" datastore:"active"` } +type SyncConfig struct { + Interval int64 `json:"interval" datastore:"interval"` + Apikey string `json:"api_key" datastore:"api_key"` +} + // Role is just used for feedback for a user type Org struct { Name string `json:"name" datastore:"name"` @@ -87,6 +92,7 @@ type Org struct { Role string `json:"role" datastore:"role"` Roles []string `json:"roles" datastore:"roles"` CloudSync bool `json:"cloud_sync" datastore:"CloudSync"` + SyncConfig SyncConfig `json:"sync_config" datastore:"sync_config"` SyncFeatures SyncFeatures `json:"sync_features" datastore:"sync_features"` } diff --git a/frontend/src/views/Admin.jsx b/frontend/src/views/Admin.jsx index cc4cb29a..0eb76a20 100644 --- a/frontend/src/views/Admin.jsx +++ b/frontend/src/views/Admin.jsx @@ -3,24 +3,36 @@ import React, { useEffect} from 'react'; import {Link} from 'react-router-dom'; import Paper from '@material-ui/core/Paper'; import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Typography from '@material-ui/core/Typography'; import Switch from '@material-ui/core/Switch'; import Select from '@material-ui/core/Select'; import MenuItem from '@material-ui/core/MenuItem'; -import List from '@material-ui/core/List'; import Divider from '@material-ui/core/Divider'; import TextField from '@material-ui/core/TextField'; -import ListItem from '@material-ui/core/ListItem'; import Button from '@material-ui/core/Button'; import Tabs from '@material-ui/core/Tabs'; import Tab from '@material-ui/core/Tab'; +import Grid from '@material-ui/core/Grid'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; import ListItemText from '@material-ui/core/ListItemText'; +import ListItemAvatar from '@material-ui/core/ListItemAvatar'; import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; +import IconButton from '@material-ui/core/IconButton'; +import Avatar from '@material-ui/core/Avatar'; + import { useAlert } from "react-alert"; import { Dialog, DialogTitle, DialogActions, DialogContent } from '@material-ui/core'; import { useTheme } from '@material-ui/core/styles'; +import PolymerIcon from '@material-ui/icons/Polymer'; +import CheckCircleIcon from '@material-ui/icons/CheckCircle'; +import CloseIcon from '@material-ui/icons/Close'; +import AppsIcon from '@material-ui/icons/Apps'; +import ImageIcon from '@material-ui/icons/Image'; +import DeleteIcon from '@material-ui/icons/Delete'; import CachedIcon from '@material-ui/icons/Cached'; import AccessibilityNewIcon from '@material-ui/icons/AccessibilityNew'; import LockIcon from '@material-ui/icons/Lock'; @@ -46,6 +58,8 @@ const Admin = (props) => { const [curTab, setCurTab] = React.useState(0); const [users, setUsers] = React.useState([]); const [organizations, setOrganizations] = React.useState([]); + const [orgSyncResponse, setOrgSyncResponse] = React.useState(""); + const [environments, setEnvironments] = React.useState([]); const [authentication, setAuthentication] = React.useState([]); const [schedules, setSchedules] = React.useState([]) @@ -183,6 +197,8 @@ const Admin = (props) => { } const enableCloudSync = (apikey, organization) => { + setOrgSyncResponse("") + const data = { apikey: apikey, organization: organization, @@ -200,21 +216,32 @@ const Admin = (props) => { 'Content-Type': 'application/json; charset=utf-8', }, }) - .then(response => - response.json().then(responseJson => { - setLoading(false) - console.log(responseJson) - if (responseJson["success"] === false) { - alert.error("Failed setting up cloud sync") - } else { - alert.success("Set up cloud sync!") - } - }), - ) - .catch(error => { - setLoading(false) - alert.error("Err: " + error.toString()) - }); + .then(response => { + setLoading(false) + if (response.status === 200) { + console.log("Cloud sync success?") + } else { + console.log("Cloud sync fail?") + } + + return response.json() + }) + .then((responseJson) => { + if (!responseJson.success && responseJson.reason !== undefined) { + setOrgSyncResponse(responseJson.reason) + alert.error("Failed to sync: "+responseJson.reason) + } else if (!responseJson.success) { + alert.error("Failed to sync.") + } else { + alert.success("Sync set up!") + getOrgs() + //setCloudSyncModalOpen(false) + } + }) + .catch(error => { + setLoading(false) + alert.error("Err: " + error.toString()) + }) } const onPasswordChange = () => { @@ -780,6 +807,49 @@ const Admin = (props) => { + const GridItem = (props) => { + const primary = props.data.primary + const secondary = props.data.secondary + const primaryIcon = props.data.icon + const secondaryIcon = props.data.active ? + + : + + + return ( + + + + + {primaryIcon} + + + + {secondaryIcon} + + + ) + } + + const itemColor = "black" + var syncList = [ + { + "primary": "Workflows", + "secondary": "", + "active": false, + "icon": , + }, + { + "primary": "Apps", + "secondary": "", + "active": false, + "icon": , + }, + ] + const cloudSyncModal = { What does cloud sync do? -
- Cloud Apikey
{ Test sync
+ {orgSyncResponse.length > 0 ? + + Error: {orgSyncResponse} + + : null + } + + {syncList.map((data, index) => { + return ( + + ) + })} + * New triggers (userinput, hotmail realtime)
* Execute in the cloud rather than onprem
* Apps can be built in the cloud
@@ -1301,7 +1382,7 @@ const Admin = (props) => { style={{minWidth: 150, maxWidth: 150}} /> - {environments === undefined ? null : environments.map((environment, index)=> { + {environments === undefined || environments === null ? null : environments.map((environment, index)=> { if (!showArchived && environment.archived) { return null } From 4b784dfc11d6445d205341c90374dbecf09fd0fc Mon Sep 17 00:00:00 2001 From: frikky Date: Sun, 25 Oct 2020 12:31:36 +0100 Subject: [PATCH 12/18] Almost finished with cloud executions --- backend/go-app/main.go | 278 +++++++++++++++++++++++-- backend/go-app/walkoff.go | 188 +++++++++++++++-- frontend/src/views/Admin.jsx | 46 +++- frontend/src/views/AngularWorkflow.jsx | 2 +- frontend/src/views/Workflows.jsx | 3 + 5 files changed, 472 insertions(+), 45 deletions(-) diff --git a/backend/go-app/main.go b/backend/go-app/main.go index 5e8c84a2..4ef276a6 100644 --- a/backend/go-app/main.go +++ b/backend/go-app/main.go @@ -72,6 +72,7 @@ var gceProject = "shuffle" var bucketName = "shuffler.appspot.com" var baseAppPath = "/home/frikky/git/shaffuru/tmp/apps" var baseDockerName = "frikky/shuffle" +var syncUrl = "http://192.168.3.6:5002" var dbclient *datastore.Client @@ -139,6 +140,14 @@ type UserLimits struct { MaxWorkflows int64 `json:"max_workflows" datastore:"max_workflows"` } +type retStruct struct { + Success bool `json:"success"` + SyncFeatures SyncFeatures `json:"sync_features"` + SessionKey string `json:"session_key"` + IntervalSeconds int64 `json:"interval_seconds"` + Reason string `json:"reason"` +} + // Saves some data, not sure what to have here lol type UserAuth struct { Description string `json:"description" datastore:"description,noindex" yaml:"description"` @@ -1082,6 +1091,10 @@ func handleSetEnvironments(resp http.ResponseWriter, request *http.Request) { } for _, item := range newEnvironments { + if item.OrgId == "" { + item.OrgId = user.ActiveOrg.Id + } + err = setEnvironment(ctx, &item) if err != nil { resp.WriteHeader(401) @@ -3327,8 +3340,8 @@ func handleWebhookCallback(resp http.ResponseWriter, request *http.Request) { Body: ioutil.NopCloser(strings.NewReader(bodyWrapper)), } + // OrgId: activeOrgs[0].Id, workflowExecution, executionResp, err := handleExecution(item, workflow, newRequest) - if err == nil { err = increaseStatisticsField(ctx, "total_webhooks_ran", workflowExecution.Workflow.ID, 1) if err != nil { @@ -6559,11 +6572,13 @@ func runInit(ctx context.Context) { log.Printf("Found %d users.", len(users)) if len(activeOrgs) == 1 && len(users) > 0 { for _, user := range users { - if user.ActiveOrg.Id == "" { + if user.ActiveOrg.Id == "" && len(user.Username) > 0 { user.ActiveOrg = activeOrgs[0] err = setUser(ctx, &user) if err != nil { - log.Printf("Failed updating user %s", user.Username) + log.Printf("Failed updating user %s with org", user.Username) + } else { + log.Printf("Updated user %s to have org", user.Username) } } } @@ -6606,6 +6621,35 @@ func runInit(ctx context.Context) { } } + // Fixing workflows to have real activeorg IDs + if len(activeOrgs) == 1 { + q := datastore.NewQuery("workflow") + var workflows []Workflow + _, err = dbclient.GetAll(ctx, q, &workflows) + if err != nil { + log.Printf("Error getting workflows in runinit: %s", err) + } else { + updated := 0 + for _, workflow := range workflows { + if workflow.ExecutingOrg.Id == "" { + workflow.ExecutingOrg = activeOrgs[0] + + err = setWorkflow(ctx, workflow, workflow.ID) + if err != nil { + log.Printf("Failed setting workflow in init: %s", err) + } else { + log.Printf("Fixed workflow %s to have the right org.", workflow.ID) + updated += 1 + } + } + } + + if updated > 0 { + log.Printf("Set workflow orgs for %d workflows", updated) + } + } + } + // Gets schedules and starts them log.Printf("Relaunching schedules") schedules, err := getAllSchedules(ctx) @@ -6746,7 +6790,136 @@ func runInit(ctx context.Context) { log.Printf("Finished INIT") } +func handleVerifyCloudsync(orgId string) (SyncFeatures, error) { + ctx := context.Background() + org, err := getOrg(ctx, orgId) + if err != nil { + return SyncFeatures{}, err + } + + //r.HandleFunc("/api/v1/getorgs", handleGetOrgs).Methods("GET", "OPTIONS") + + syncURL := fmt.Sprintf("%s/api/v1/cloud/sync/get_access", syncUrl) + client := &http.Client{} + req, err := http.NewRequest( + "GET", + syncURL, + nil, + ) + + req.Header.Add("Authorization", fmt.Sprintf(`Bearer %s`, org.SyncConfig.Apikey)) + newresp, err := client.Do(req) + if err != nil { + return SyncFeatures{}, err + } + + respBody, err := ioutil.ReadAll(newresp.Body) + if err != nil { + return SyncFeatures{}, err + } + + responseData := retStruct{} + err = json.Unmarshal(respBody, &responseData) + if err != nil { + return SyncFeatures{}, err + } + + if newresp.StatusCode != 200 { + return SyncFeatures{}, errors.New(fmt.Sprintf("Got status code %d when getting org remotely. Expected 200. Contact support.", newresp.StatusCode)) + } + + if !responseData.Success { + return SyncFeatures{}, errors.New(responseData.Reason) + } + + return responseData.SyncFeatures, nil +} + +// Actually stops syncing with cloud for an org. +// Disables potential schedules, removes environments, breaks workflows etc. +func handleStopCloudSync(syncUrl string, org Org) error { + if len(org.SyncConfig.Apikey) == 0 { + return errors.New(fmt.Sprintf("Couldn't find any sync key to disable org %s", org.Id)) + } + + log.Printf("Should run cloud sync disable for org %s with URL %s and sync key %s", org.Id, syncUrl, org.SyncConfig.Apikey) + + client := &http.Client{} + req, err := http.NewRequest( + "DELETE", + syncUrl, + nil, + ) + + req.Header.Add("Authorization", fmt.Sprintf(`Bearer %s`, org.SyncConfig.Apikey)) + newresp, err := client.Do(req) + if err != nil { + return err + } + + respBody, err := ioutil.ReadAll(newresp.Body) + if err != nil { + return err + } + log.Printf("Remote disable ret: %s", string(respBody)) + + responseData := retStruct{} + err = json.Unmarshal(respBody, &responseData) + if err != nil { + return err + } + + if newresp.StatusCode != 200 { + return errors.New(fmt.Sprintf("Got status code %d when disabling org remotely. Expected 200. Contact support.", newresp.StatusCode)) + } + + if !responseData.Success { + //log.Printf("Success reason: %s", responseData.Reason) + return errors.New(responseData.Reason) + } + + log.Printf("Everything is success. Should disable org sync for %s", org.Id) + + ctx := context.Background() + org.CloudSync = false + org.SyncFeatures = SyncFeatures{} + org.SyncConfig = SyncConfig{} + + err = setOrg(ctx, org, org.Id) + if err != nil { + newerror := fmt.Sprintf("ERROR: Failed updating even though there was success: %s", err) + log.Printf(newerror) + return errors.New(newerror) + } + + var environments []Environment + q := datastore.NewQuery("Environments").Filter("org_id =", org.Id) + _, err = dbclient.GetAll(ctx, q, &environments) + if err != nil { + return err + } + + // Don't disable, this will be deleted entirely + for _, environment := range environments { + if environment.Type == "cloud" { + environment.Name = "Cloud" + environment.Archived = true + err = setEnvironment(ctx, &environment) + if err == nil { + log.Printf("Updated cloud environment %s", environment.Name) + } else { + log.Printf("Failed to update cloud environment %s", environment.Name) + } + } + } + + return nil +} + // INFO: https://docs.google.com/drawings/d/1JJebpPeEVEbmH_qsAC6zf9Noygp7PytvesrkhE19QrY/edit +/* + This is here to both enable and disable cloud sync features for an organization +*/ func handleCloudSetup(resp http.ResponseWriter, request *http.Request) { cors := handleCors(resp, request) if cors { @@ -6778,6 +6951,7 @@ func handleCloudSetup(resp http.ResponseWriter, request *http.Request) { type ReturnData struct { Apikey string `datastore:"apikey"` Organization Org `datastore:"organization"` + Disable bool `datastore:"disable"` } var tmpData ReturnData @@ -6833,7 +7007,42 @@ func handleCloudSetup(resp http.ResponseWriter, request *http.Request) { // FIXME: Path client := &http.Client{} - syncPath := "http://192.168.3.6:5002/api/v1/cloud/sync" + apiPath := "/api/v1/cloud/sync" + if tmpData.Disable { + if !org.CloudSync { + log.Printf("Org %s isn't syncing. Can't stop.", org.Id) + resp.WriteHeader(401) + resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "Skipped cloud sync setup. Already syncing."}`))) + return + } + + log.Printf("Should disable sync for org %s", org.Id) + apiPath := "/api/v1/cloud/sync/stop" + syncUrl = fmt.Sprintf("%s%s", syncUrl, apiPath) + + err = handleStopCloudSync(syncUrl, *org) + if err != nil { + resp.WriteHeader(401) + resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "%s"}`, err))) + } else { + resp.WriteHeader(200) + resp.Write([]byte(fmt.Sprintf(`{"success": true, "reason": "Successfully disabled cloud sync for org."}`))) + } + + return + } + + // Everything below here is to SET UP CLOUD SYNC. + // If you want to disable cloud sync, see previous section. + if org.CloudSync { + log.Printf("Org %s is already syncing. Skip", org.Id) + resp.WriteHeader(401) + resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "Org is already syncing. Nothing to set up."}`))) + return + } + + syncPath := fmt.Sprintf("%s%s", syncUrl, apiPath) + type requestStruct struct { ApiKey string `json:"api_key"` } @@ -6871,14 +7080,6 @@ func handleCloudSetup(resp http.ResponseWriter, request *http.Request) { return } - type retStruct struct { - Success bool `json:"success"` - SyncFeatures SyncFeatures `json:"sync_features"` - SessionKey string `json:"session_key"` - IntervalSeconds int64 `json:"interval_seconds"` - Reason string `json:"reason"` - } - log.Printf("Respbody: %s", string(respBody)) responseData := retStruct{} err = json.Unmarshal(respBody, &responseData) @@ -6912,6 +7113,59 @@ func handleCloudSetup(resp http.ResponseWriter, request *http.Request) { Interval: responseData.IntervalSeconds, } + // FIXME: Add this for every feature + if org.SyncFeatures.Workflows.Active { + log.Printf("Should activate cloud workflows for org %s!", org.Id) + + // 1. Find environment + // 2. If cloud env found, enable it (un-archive) + // 3. If it doesn't create it + var environments []Environment + q := datastore.NewQuery("Environments").Filter("org_id =", org.Id) + _, err = dbclient.GetAll(ctx, q, &environments) + if err == nil { + + // Don't disable, this will be deleted entirely + found := false + for _, environment := range environments { + if environment.Type == "cloud" { + environment.Name = "Cloud" + environment.Archived = false + err = setEnvironment(ctx, &environment) + if err == nil { + log.Printf("Re-added cloud environment %s", environment.Name) + } else { + log.Printf("Failed to re-enable cloud environment %s", environment.Name) + } + + found = true + break + } + } + + if !found { + log.Printf("Env for cloud not found. Should add it!") + newEnv := Environment{ + Name: "Cloud", + Type: "cloud", + Archived: false, + Registered: true, + Default: false, + OrgId: org.Id, + } + + err = setEnvironment(ctx, &newEnv) + if err != nil { + log.Printf("Failed setting up NEW org environment for org %s: %s", org.Id, err) + } else { + log.Printf("Successfully added new environment for org %s", org.Id) + } + } + } else { + log.Printf("Failed setting org environment, because none were found: %s", err) + } + } + err = setOrg(ctx, *org, org.Id) if err != nil { log.Printf("ERROR: Failed updating org even though there was success: %s", err) diff --git a/backend/go-app/walkoff.go b/backend/go-app/walkoff.go index e2f5a3a3..e0eb0eda 100644 --- a/backend/go-app/walkoff.go +++ b/backend/go-app/walkoff.go @@ -204,6 +204,7 @@ type WorkflowExecution struct { ExecutionArgument string `json:"execution_argument" datastore:"execution_argument,noindex"` ExecutionId string `json:"execution_id" datastore:"execution_id"` ExecutionSource string `json:"execution_source" datastore:"execution_source"` + ExecutionOrg string `json:"execution_org" datastore:"execution_org"` WorkflowId string `json:"workflow_id" datastore:"workflow_id"` LastNode string `json:"last_node" datastore:"last_node"` Authorization string `json:"authorization" datastore:"authorization"` @@ -481,7 +482,7 @@ func getWorkflowQueue(ctx context.Context, id string) (ExecutionRequestWrapper, //} // Frequency = cronjob OR minutes between execution -func createSchedule(ctx context.Context, scheduleId, workflowId, name, startNode, frequency string, body []byte) error { +func createSchedule(ctx context.Context, scheduleId, workflowId, name, startNode, frequency, orgId string, body []byte) error { var err error testSplit := strings.Split(frequency, "*") cronJob := "" @@ -525,7 +526,7 @@ func createSchedule(ctx context.Context, scheduleId, workflowId, name, startNode Body: ioutil.NopCloser(strings.NewReader(bodyWrapper)), } - _, _, err := handleExecution(workflowId, Workflow{}, request) + _, _, err := handleExecution(workflowId, Workflow{ExecutingOrg: Org{Id: orgId}}, request) if err != nil { log.Printf("Failed to execute %s: %s", workflowId, err) } @@ -1355,6 +1356,7 @@ func setNewWorkflow(resp http.ResponseWriter, request *http.Request) { workflow.ID = uuid.NewV4().String() workflow.Owner = user.Id workflow.Sharing = "private" + workflow.ExecutingOrg = user.ActiveOrg ctx := context.Background() log.Printf("Saved new workflow %s with name %s", workflow.ID, workflow.Name) @@ -1395,7 +1397,7 @@ func setNewWorkflow(resp http.ResponseWriter, request *http.Request) { if err == nil { // FIXME: Add real env envName := "Shuffle" - environments, err := getEnvironments(ctx) + environments, err := getEnvironments(ctx, user.ActiveOrg.Id) if err == nil { for _, env := range environments { if env.Default { @@ -1679,8 +1681,6 @@ func saveWorkflow(resp http.ResponseWriter, request *http.Request) { return } - //Actions []Action `json:"actions" datastore:"actions,noindex"` - body, err := ioutil.ReadAll(request.Body) if err != nil { log.Printf("Failed hook unmarshaling: %s", err) @@ -1712,6 +1712,10 @@ func saveWorkflow(resp http.ResponseWriter, request *http.Request) { workflow.Owner = user.Id } + if len(workflow.ExecutingOrg.Id) == 0 { + workflow.ExecutingOrg = user.ActiveOrg + } + // FIXME - this shouldn't be necessary with proper API checks newActions := []Action{} allNodes := []string{} @@ -2305,6 +2309,11 @@ func handleExecution(id string, workflow Workflow, request *http.Request) (Workf workflow = *tmpworkflow } + if len(workflow.ExecutingOrg.Id) == 0 { + log.Printf("Stopped execution because there is no executing org for workflow %s", workflow.ID) + return WorkflowExecution{}, fmt.Sprintf("Workflow has no executing org defined"), errors.New("Workflow has no executing org defined") + } + if len(workflow.Actions) == 0 { workflow.Actions = []Action{} } @@ -2397,6 +2406,8 @@ func handleExecution(id string, workflow Workflow, request *http.Request) (Workf } } else { // Check for parameters of start and ExecutionId + // This is mostly used for user input trigger + start, startok := request.URL.Query()["start"] answer, answerok := request.URL.Query()["answer"] referenceId, referenceok := request.URL.Query()["reference_execution"] @@ -2642,31 +2653,83 @@ func handleExecution(id string, workflow Workflow, request *http.Request) (Workf // Verification for execution environments workflowExecution.Results = defaultResults workflowExecution.Workflow.Actions = newActions - onpremExecution := false + onpremExecution := true environments := []string{} + if len(workflowExecution.ExecutionOrg) == 0 && len(workflow.ExecutingOrg.Id) > 0 { + workflowExecution.ExecutionOrg = workflow.ExecutingOrg.Id + } + + var allEnvs []Environment + if len(workflowExecution.ExecutionOrg) > 0 { + log.Printf("Executing ORG: %s", workflowExecution.ExecutionOrg) + + allEnvironments, err := getEnvironments(ctx, workflowExecution.ExecutionOrg) + if err != nil { + log.Printf("Failed finding environments: %s", err) + return WorkflowExecution{}, fmt.Sprintf("Workflow environments not found for this org"), errors.New(fmt.Sprintf("Workflow environments not found for this org")) + } + + for _, curenv := range allEnvironments { + if curenv.Archived { + continue + } + + allEnvs = append(allEnvs, curenv) + } + } else { + log.Printf("[ERROR] No org identified for execution of %s. Returning", workflowExecution.Workflow.ID) + return WorkflowExecution{}, "No org identified for execution", errors.New("No org identified for execution") + } + + if len(allEnvs) == 0 { + log.Printf("[ERROR] No active environments found for org", workflowExecution.ExecutionOrg) + return WorkflowExecution{}, "No active environments found", errors.New(fmt.Sprintf("No active env found for org %s", workflowExecution.ExecutionOrg)) + } + // Check if the actions are children of the startnode? imageNames := []string{} + cloudExec := false for _, action := range workflowExecution.Workflow.Actions { - if action.Environment != cloudname { - found := false - for _, env := range environments { - if env == action.Environment { - found = true - break + // Verify if the action environment exists and append + found := false + for _, env := range allEnvs { + if env.Name == action.Environment { + found = true + + if env.Type == "cloud" { + cloudExec = true + } else if env.Type == "onprem" { + onpremExecution = true + } else { + log.Printf("[ERROR] No handler for environment type %s", env.Type) + return WorkflowExecution{}, "No active environments found", errors.New(fmt.Sprintf("No handler for environment type %s", env.Type)) } + break } + } - // Check if the app exists? - newName := action.AppName - newName = strings.ReplaceAll(newName, " ", "-") - imageNames = append(imageNames, fmt.Sprintf("%s:%s_%s", baseDockerName, newName, action.AppVersion)) + if !found { + log.Printf("[ERROR] Couldn't find environment %s. Maybe it's inactive?", action.Environment) + return WorkflowExecution{}, "Couldn't find the environment", errors.New(fmt.Sprintf("Couldn't find env %s in org %s", action.Environment, workflowExecution.ExecutionOrg)) + } - if !found { - environments = append(environments, action.Environment) + found = false + for _, env := range environments { + if env == action.Environment { + + found = true + break } + } - onpremExecution = true + // Check if the app exists? + newName := action.AppName + newName = strings.ReplaceAll(newName, " ", "-") + imageNames = append(imageNames, fmt.Sprintf("%s:%s_%s", baseDockerName, newName, action.AppVersion)) + + if !found { + environments = append(environments, action.Environment) } } @@ -2711,8 +2774,22 @@ func handleExecution(id string, workflow Workflow, request *http.Request) (Workf log.Printf("Failed adding to db: %s", err) } } - } else { - log.Printf("[ERROR] Cloud not implemented yet") + } + + // Verifies and runs cloud executions + if cloudExec { + featuresList, err := handleVerifyCloudsync(workflowExecution.ExecutionOrg) + if !featuresList.Workflows.Active || err != nil { + log.Printf("Error: %s", err) + log.Printf("[ERROR] Cloud not implemented yet. May need to work on app checking and such") + return WorkflowExecution{}, "Cloud not implemented yet", errors.New("Cloud not implemented yet") + } + + if len(workflowExecution.Workflow.Actions) == 1 { + log.Printf("Should execute directly with cloud instead of worker because only one action") + cloudExecuteAction(workflowExecution.ExecutionId, workflowExecution.Workflow.Actions[0], workflowExecution.ExecutionOrg) + return WorkflowExecution{}, "Cloud not implemented yet", errors.New("Cloud not implemented yet") + } } err = increaseStatisticsField(ctx, "workflow_executions", workflow.ID, 1) @@ -2723,6 +2800,69 @@ func handleExecution(id string, workflow Workflow, request *http.Request) (Workf return workflowExecution, "", nil } +// This updates stuff locally from remote executions +func cloudExecuteAction(workflowExecutionId string, action Action, orgId string) error { + log.Printf("Executing action: %#v in execution ID %s", action, workflowExecutionId) + ctx := context.Background() + org, err := getOrg(ctx, orgId) + if err != nil { + return err + } + + type ExecutionStruct struct { + ID string `json:"id"` + Action Action `json:"action"` + } + data := ExecutionStruct{ + ID: workflowExecutionId, + Action: action, + } + + b, err := json.Marshal(data) + if err != nil { + log.Printf("Failed marshaling api key data: %s", err) + return err + } + + syncURL := fmt.Sprintf("%s/api/v1/cloud/sync/execute_node", syncUrl) + client := &http.Client{} + req, err := http.NewRequest( + "POST", + syncURL, + bytes.NewBuffer(b), + ) + + req.Header.Add("Authorization", fmt.Sprintf(`Bearer %s`, org.SyncConfig.Apikey)) + newresp, err := client.Do(req) + if err != nil { + return err + } + + respBody, err := ioutil.ReadAll(newresp.Body) + if err != nil { + return err + } + + log.Printf("Finished request. Data: %s", string(respBody)) + log.Printf("Status code: %d", newresp.StatusCode) + + responseData := retStruct{} + err = json.Unmarshal(respBody, &responseData) + if err != nil { + return err + } + + if newresp.StatusCode != 200 { + return errors.New(fmt.Sprintf("Got status code %d when executing remotely. Expected 200. Contact support.", newresp.StatusCode)) + } + + if !responseData.Success { + return errors.New(responseData.Reason) + } + + return nil +} + func executeWorkflow(resp http.ResponseWriter, request *http.Request) { cors := handleCors(resp, request) if cors { @@ -2776,6 +2916,7 @@ func executeWorkflow(resp http.ResponseWriter, request *http.Request) { } log.Printf("[INFO] Starting execution of %s!", fileId) + workflow.ExecutingOrg = user.ActiveOrg workflowExecution, executionResp, err := handleExecution(fileId, *workflow, request) if err == nil { @@ -3137,6 +3278,7 @@ func scheduleWorkflow(resp http.ResponseWriter, request *http.Request) { schedule.Name, startNode, schedule.Frequency, + user.ActiveOrg.Id, []byte(parsedBody), ) @@ -3336,9 +3478,9 @@ func getWorkflow(ctx context.Context, id string) (*Workflow, error) { return workflow, nil } -func getEnvironments(ctx context.Context) ([]Environment, error) { +func getEnvironments(ctx context.Context, OrgId string) ([]Environment, error) { var environments []Environment - q := datastore.NewQuery("Environments") + q := datastore.NewQuery("Environments").Filter("org_id =", OrgId) _, err := dbclient.GetAll(ctx, q, &environments) if err != nil { diff --git a/frontend/src/views/Admin.jsx b/frontend/src/views/Admin.jsx index 0eb76a20..298080f8 100644 --- a/frontend/src/views/Admin.jsx +++ b/frontend/src/views/Admin.jsx @@ -196,12 +196,13 @@ const Admin = (props) => { }); } - const enableCloudSync = (apikey, organization) => { + const enableCloudSync = (apikey, organization, disableSync) => { setOrgSyncResponse("") const data = { apikey: apikey, organization: organization, + disable: disableSync, } const url = globalUrl + '/api/v1/cloud/setup'; @@ -229,13 +230,20 @@ const Admin = (props) => { .then((responseJson) => { if (!responseJson.success && responseJson.reason !== undefined) { setOrgSyncResponse(responseJson.reason) - alert.error("Failed to sync: "+responseJson.reason) + alert.error("Failed to handle sync: "+responseJson.reason) } else if (!responseJson.success) { - alert.error("Failed to sync.") + alert.error("Failed to handle sync.") } else { - alert.success("Sync set up!") getOrgs() - //setCloudSyncModalOpen(false) + if (disableSync) { + alert.success("Successfully disabled sync!") + } else { + alert.success("Sync successfully set up!") + } + + selectedOrganization.cloud_sync = !selectedOrganization.cloud_sync + setSelectedOrganization(selectedOrganization) + setCloudSyncApikey("") } }) .catch(error => { @@ -389,9 +397,15 @@ const Admin = (props) => { for (var key in environments) { if (environments[key].Name == name) { if (environments[key].default) { - alert.info("Can't delete the default environment") + alert.error("Can't delete the default environment") return } + + if (environments[key].type === "cloud") { + alert.error("Can't delete the cloud environments") + return + } + environments[key].archived = true } @@ -881,6 +895,7 @@ const Admin = (props) => { }} required fullWidth={true} + disabled={selectedOrganization.cloud_sync} autoComplete="cloud apikey" id="apikey_field" margin="normal" @@ -890,14 +905,19 @@ const Admin = (props) => { setCloudSyncApikey(event.target.value) }} /> -
{orgSyncResponse.length > 0 ? @@ -910,7 +930,7 @@ const Admin = (props) => { {syncList.map((data, index) => { return ( - + ) })} @@ -1369,6 +1389,10 @@ const Admin = (props) => { primary="Orborus running (TBD)" style={{minWidth: 200, maxWidth: 200}} /> + { primary={"TBD"} style={{minWidth: 200, maxWidth: 200, overflow: "hidden"}} /> + { jsonvalid = false } } catch (e) { - console.log("Error: ", e) + //console.log("Error: ", e) jsonvalid = false } diff --git a/frontend/src/views/Workflows.jsx b/frontend/src/views/Workflows.jsx index ad27220d..94a86e06 100644 --- a/frontend/src/views/Workflows.jsx +++ b/frontend/src/views/Workflows.jsx @@ -337,6 +337,9 @@ const Workflows = (props) => { } } + data.execution_org = {"id": ""} + console.log(data) + let linkElement = document.createElement('a'); linkElement.setAttribute('href', dataUri); linkElement.setAttribute('download', exportFileDefaultName); From 63e5d82637e15533f9e31c776977cb45b19195a1 Mon Sep 17 00:00:00 2001 From: frikky Date: Mon, 26 Oct 2020 11:13:44 +0100 Subject: [PATCH 13/18] #188: Fixed app sdk value within string --- backend/app_sdk/app_base.py | 45 +++++++++++++++++++++++++++---------- docker-compose.yml | 2 +- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/backend/app_sdk/app_base.py b/backend/app_sdk/app_base.py index 701a8b3f..a70b44ec 100644 --- a/backend/app_sdk/app_base.py +++ b/backend/app_sdk/app_base.py @@ -460,23 +460,44 @@ class AppBase: # Returns a string if the result is single, or a list if it's a list def get_json_value(execution_data, input_data): parsersplit = input_data.split(".") - actionname = parsersplit[0][1:].replace(" ", "_", -1) + actionname_lower = parsersplit[0][1:].lower() + #Actionname: Start_node print(f"Actionname: {actionname}") - + # 1. Find the action baseresult = "" - actionname_lower = actionname.lower() + + appendresult = "" + print("Parsersplit length: %d" % len(parsersplit)) + if (actionname_lower.startswith("exec ") or actionname_lower.startswith("webhook ") or actionname_lower.startswith("schedule ") or actionname_lower.startswith("userinput ") or actionname_lower.startswith("email_trigger ") or actionname_lower.startswith("trigger ")) and len(parsersplit) == 1: + record = False + for char in actionname_lower: + if char == " ": + record = True + + if record: + appendresult += char + + actionname_lower = "exec" + + actionname_lower = actionname_lower.replace(" ", "_", -1) + try: if actionname_lower == "exec" or actionname_lower == "webhook" or actionname_lower == "schedule" or actionname_lower == "userinput" or actionname_lower == "email_trigger" or actionname_lower == "trigger": baseresult = execution_data["execution_argument"] else: - for result in execution_data["results"]: - resultlabel = result["action"]["label"].replace(" ", "_", -1).lower() - if resultlabel.lower() == actionname_lower: - baseresult = result["result"] - break + #print("Within execution data check. Execution data: %s", execution_data["results"]) + if execution_data["results"] != None: + for result in execution_data["results"]: + resultlabel = result["action"]["label"].replace(" ", "_", -1).lower() + if resultlabel.lower() == actionname_lower: + baseresult = result["result"] + break + else: + print("No results to get values from.") + baseresult = "$" + parsersplit[0][1:] print("BEFORE VARIABLES!") if len(baseresult) == 0: @@ -518,17 +539,17 @@ class AppBase: # 2. Find the JSON data if len(baseresult) == 0: - return "", False + return ""+appendresult, False if len(parsersplit) == 1: - return baseresult, False + return baseresult+appendresult, False baseresult = baseresult.replace("\'", "\"") basejson = {} try: basejson = json.loads(baseresult) except json.decoder.JSONDecodeError as e: - return baseresult, False + return baseresult+appendresult, False data, is_loop = recurse_json(basejson, parsersplit[1:]) parseditem = data @@ -542,7 +563,7 @@ class AppBase: print("SET DATA WRAPPER TO %s!" % parsersplit[-1]) parseditem = "${%s%s}$" % (parsersplit[-1], json.dumps(data)) - return parseditem, is_loop + return parseditem+appendresult, is_loop # Parses parameters sent to it and returns whether it did it successfully with the values found def parse_params(action, fullexecution, parameter): diff --git a/docker-compose.yml b/docker-compose.yml index d5fa531d..e01176d0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,7 @@ services: depends_on: - backend backend: - #build: ./backend + build: ./backend image: frikky/shuffle:backend container_name: shuffle-backend hostname: ${BACKEND_HOSTNAME} From ca798de0cdcb78d77f06241ef5cb3d782cb1e6fc Mon Sep 17 00:00:00 2001 From: frikky Date: Mon, 26 Oct 2020 11:22:15 +0100 Subject: [PATCH 14/18] Re-fixed workflow imports with master branch --- backend/go-app/main.go | 4 ++-- backend/go-app/walkoff.go | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/go-app/main.go b/backend/go-app/main.go index 4ef276a6..84b9c10b 100644 --- a/backend/go-app/main.go +++ b/backend/go-app/main.go @@ -6710,7 +6710,7 @@ func runInit(ctx context.Context) { } } branch := os.Getenv("SHUFFLE_DOWNLOAD_AUTH_BRANCH") - if len(branch) > 0 { + if len(branch) > 0 && branch != "master" && branch != "main" { cloneOptions.ReferenceName = plumbing.ReferenceName(branch) } @@ -6719,7 +6719,7 @@ func runInit(ctx context.Context) { r, err := git.Clone(storer, fs, cloneOptions) if err != nil { - log.Printf("Failed loading repo into memory: %s", err) + log.Printf("Failed loading repo into memory (init): %s", err) } dir, err := fs.ReadDir("") diff --git a/backend/go-app/walkoff.go b/backend/go-app/walkoff.go index e0eb0eda..0f52aabd 100644 --- a/backend/go-app/walkoff.go +++ b/backend/go-app/walkoff.go @@ -1713,6 +1713,7 @@ func saveWorkflow(resp http.ResponseWriter, request *http.Request) { } if len(workflow.ExecutingOrg.Id) == 0 { + log.Printf("setting executing org") workflow.ExecutingOrg = user.ActiveOrg } @@ -4549,6 +4550,7 @@ func deployWebhookFunction(ctx context.Context, name, localization, applocation func loadGithubWorkflows(url, username, password, userId, branch string) error { fs := memfs.New() + log.Printf("Starting load of %s with branch %s", url, branch) if strings.Contains(url, "github") || strings.Contains(url, "gitlab") || strings.Contains(url, "bitbucket") { cloneOptions := &git.CloneOptions{ URL: url, @@ -4563,14 +4565,15 @@ func loadGithubWorkflows(url, username, password, userId, branch string) error { } } - if len(branch) > 0 { + // main is the new master + if len(branch) > 0 && branch != "main" && branch != "master" { cloneOptions.ReferenceName = plumbing.ReferenceName(branch) } storer := memory.NewStorage() r, err := git.Clone(storer, fs, cloneOptions) if err != nil { - log.Printf("Failed loading repo into memory: %s", err) + log.Printf("Failed loading repo %s into memory (github workflows): %s", url, err) return err } @@ -4772,7 +4775,7 @@ func loadSpecificApps(resp http.ResponseWriter, request *http.Request) { storer := memory.NewStorage() r, err := git.Clone(storer, fs, cloneOptions) if err != nil { - log.Printf("Failed loading repo into memory: %s", err) + log.Printf("Failed loading repo %s into memory (github workflows 2): %s", tmpBody.URL, err) resp.WriteHeader(401) resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "%s"}`, err))) return From dd4af346d33db2125b686f88796111afda6bdf26 Mon Sep 17 00:00:00 2001 From: frikky Date: Wed, 28 Oct 2020 05:42:13 +0100 Subject: [PATCH 15/18] Fixed options field bug in workflow --- frontend/src/views/AngularWorkflow.jsx | 57 +++++++++++++++----------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/frontend/src/views/AngularWorkflow.jsx b/frontend/src/views/AngularWorkflow.jsx index 7edf7894..8a19fbed 100644 --- a/frontend/src/views/AngularWorkflow.jsx +++ b/frontend/src/views/AngularWorkflow.jsx @@ -2853,7 +2853,7 @@ const AngularWorkflow = (props) => { ))} - } else if (data.variant === "STATIC_VALUE") { + } else if (data.variant === "STATIC_VALUE") { staticcolor = "#f85a3e" } else if (data.variant === "ACTION_RESULT") { // Gets the parents of the current node @@ -3137,6 +3137,8 @@ const AngularWorkflow = (props) => { return (
+ + {data.configuration === true ? { @@ -3149,32 +3151,37 @@ const AngularWorkflow = (props) => {
{data.name}
- -
{ + + {selectedActionParameters[count].options !== undefined && selectedActionParameters[count].options !== null && selectedActionParameters[count].options.length > 0 ? null : +
+ +
{ + e.preventDefault() + changeActionParameterVariant("STATIC_VALUE", count) + }}> + +
+
+  |  + +
{ e.preventDefault() - changeActionParameterVariant("STATIC_VALUE", count) + changeActionParameterVariant("ACTION_RESULT", count) }}> - -
-
-  |  - -
{ - e.preventDefault() - changeActionParameterVariant("ACTION_RESULT", count) - }}> - -
-
-  |  - -
{ - e.preventDefault() - changeActionParameterVariant("WORKFLOW_VARIABLE", count) - }}> - -
-
+ +
+ +  |  + +
{ + e.preventDefault() + changeActionParameterVariant("WORKFLOW_VARIABLE", count) + }}> + +
+
+
+ }
{datafield} {showDropdown && showDropdownNumber === count && data.variant === "STATIC_VALUE" && jsonList.length > 0 ? From fdba86f9c361b468ede482bcee4bff6e7ee5993e Mon Sep 17 00:00:00 2001 From: frikky Date: Thu, 29 Oct 2020 10:57:05 +0100 Subject: [PATCH 16/18] #141: Started file fixing. Added file download handler --- backend/app_sdk/app_base.py | 4 +- backend/app_sdk/build.sh | 2 +- backend/go-app/main.go | 221 ++++++++++++++++++++++++++-- backend/tests/file_download.sh | 3 + frontend/src/components/Header.js | 40 +++-- frontend/src/views/Admin.jsx | 2 +- frontend/src/views/SettingsPage.jsx | 52 +++---- 7 files changed, 272 insertions(+), 52 deletions(-) create mode 100644 backend/tests/file_download.sh diff --git a/backend/app_sdk/app_base.py b/backend/app_sdk/app_base.py index a70b44ec..d25297eb 100644 --- a/backend/app_sdk/app_base.py +++ b/backend/app_sdk/app_base.py @@ -400,7 +400,7 @@ class AppBase: # Means it's a single item -> continue if seconditem == "": - print("In first - handling %s", seconditem) + print("In first - handling %s" % seconditem) tmpitem = basejson[int(firstitem)] try: newvalue, is_loop = recurse_json(tmpitem, parsersplit[outercnt+1:]) @@ -884,7 +884,7 @@ class AppBase: # Custom format for ${name[0,1,2,...]}$ #submatch = "([${]{2}([0-9a-zA-Z_-]+)(\[.*\])[}$]{2})" - submatch = "([${]{2}([0-9a-zA-Z_-]+)(\[.*\])[}$]{2})" + submatch = "([${]{2}#?([0-9a-zA-Z_-]+)#?(\[.*\])[}$]{2})" actualitem = re.findall(submatch, value, re.MULTILINE) try: if action["skip_multicheck"]: diff --git a/backend/app_sdk/build.sh b/backend/app_sdk/build.sh index 2f622fcd..bfd63f22 100644 --- a/backend/app_sdk/build.sh +++ b/backend/app_sdk/build.sh @@ -1,6 +1,6 @@ #!/bin/bash NAME=app_sdk -VERSION=0.7.5 +VERSION=0.7.6 docker rmi docker.pkg.github.com/frikky/shuffle/$NAME:$VERSION --force docker build . -t frikky/shuffle:$NAME -t frikky/$NAME:$VERSION -t docker.pkg.github.com/frikky/shuffle/$NAME:$VERSION -t ghcr.io/frikky/$NAME:$VERSION diff --git a/backend/go-app/main.go b/backend/go-app/main.go index 84b9c10b..bd0f2153 100644 --- a/backend/go-app/main.go +++ b/backend/go-app/main.go @@ -1790,16 +1790,16 @@ func handleInfo(resp http.ResponseWriter, request *http.Request) { currentOrg = []byte("{}") } - returnData := fmt.Sprintf(` - { - "success": true, - "admin": %s, - "tutorials": [], - "id": "%s", - "orgs": [%s], - "active_org": %s, - "cookies": [{"key": "session_token", "value": "%s", "expiration": %d}] - }`, parsedAdmin, userInfo.Id, currentOrg, currentOrg, userInfo.Session, expiration.Unix()) + returnData := fmt.Sprintf(`{ + "success": true, + "username": "%s", + "admin": %s, + "tutorials": [], + "id": "%s", + "orgs": [%s], + "active_org": %s, + "cookies": [{"key": "session_token", "value": "%s", "expiration": %d}] +}`, userInfo.Username, parsedAdmin, userInfo.Id, currentOrg, currentOrg, userInfo.Session, expiration.Unix()) resp.WriteHeader(200) resp.Write([]byte(returnData)) @@ -2537,6 +2537,28 @@ func getSession(ctx context.Context, thissession string) (*session, error) { return curUser, nil } +// ListBooks returns a list of books, ordered by title. +func getFile(ctx context.Context, id string) (*File, error) { + key := datastore.NameKey("Files", id, nil) + curFile := &File{} + if err := dbclient.Get(ctx, key, curFile); err != nil { + return &File{}, err + } + + return curFile, nil +} + +func setFile(ctx context.Context, file File) error { + // clear session_token and API_token for user + k := datastore.NameKey("Files", file.Id, nil) + if _, err := dbclient.Put(ctx, k, &file); err != nil { + log.Println(err) + return err + } + + return nil +} + // ListBooks returns a list of books, ordered by title. func getOrg(ctx context.Context, id string) (*Org, error) { key := datastore.NameKey("Organizations", id, nil) @@ -6648,6 +6670,38 @@ func runInit(ctx context.Context) { log.Printf("Set workflow orgs for %d workflows", updated) } } + + fileq := datastore.NewQuery("Files").Limit(1) + count, err := dbclient.Count(ctx, fileq) + + if err == nil && count == 0 { + basepath := "." + filename := "testfile.txt" + fileId := uuid.NewV4().String() + log.Printf("Creating new file reference %s because none exist!", fileId) + workflowId := "2e9d6474-402c-4dcc-bb53-45f638ca18d3" + downloadPath := fmt.Sprintf("%s/%s/%s/%s", basepath, activeOrgs[0].Id, workflowId, fileId) + + timeNow := time.Now().Unix() + newFile := File{ + Id: fileId, + CreatedAt: timeNow, + UpdatedAt: timeNow, + Description: "Created by system for testing", + Status: "active", + Filename: filename, + OrgId: activeOrgs[0].Id, + WorkflowId: workflowId, + DownloadPath: downloadPath, + } + + err = setFile(ctx, newFile) + if err != nil { + log.Printf("Failed setting file: %s", err) + } else { + log.Printf("Created file %s in init", newFile.DownloadPath) + } + } } // Gets schedules and starts them @@ -7183,6 +7237,141 @@ func handleCloudSetup(resp http.ResponseWriter, request *http.Request) { resp.Write(respBody) } +type File struct { + Id string `json:"id" datastore:"id"` + Type string `json:"type" datastore:"type"` + CreatedAt int64 `json:"created_at" datastore:"created_at"` + UpdatedAt int64 `json:"updated_at" datastore:"updated_at"` + CreatedBy struct { + ID int `json:"id" datastore:"id"` + Type string `json:"type" datastore:"type"` + Login string `json:"login" datastore:"login"` + Name string `json:"name" datastore:"name"` + } `json:"created_by" datastore:"created_by"` + Description string `json:"description" datastore:"description"` + Etag int `json:"etag" datastore:"etag"` + ExpiresAt string `json:"expires_at" datastore:"expires_at"` + Folder struct { + ID int `json:"id" datastore:"id"` + Type string `json:"type" datastore:"type"` + Etag int `json:"etag" datastore:"etag"` + Name string `json:"name" datastore:"name"` + SequenceID int `json:"sequence_id" datastore:"sequence_id"` + } `json:"folder" datastore:"folder"` + Status string `json:"status" datastore:"status"` + Filename string `json:"filename" datastore:"filename"` + UpdatedBy struct { + ID int `json:"id" datastore:"id"` + Type string `json:"type" datastore:"type"` + Login string `json:"login" datastore:"login"` + Name string `json:"name" datastore:"name"` + } `json:"updated_by" datastore:"updated_by"` + URL string `json:"url" datastore:"org"` + OrgId string `json:"org_id" datastore:"org_id"` + WorkflowId string `json:"workflow_id" datastore:"workflow_id"` + Workflows []string `json:"workflows" datastore:"workflows"` + DownloadPath string `json:"download_path" datastore:"download_path"` +} + +func handleGetFileContent(resp http.ResponseWriter, request *http.Request) { + cors := handleCors(resp, request) + if cors { + return + } + + var fileId string + location := strings.Split(request.URL.String(), "/") + if location[1] == "api" { + if len(location) <= 4 { + log.Printf("Path too short: %d", len(location)) + resp.WriteHeader(401) + resp.Write([]byte(`{"success": false}`)) + return + } + + fileId = location[4] + } + + //log.Printf("In file download") + user, err := handleApiAuthentication(resp, request) + if err != nil { + log.Printf("Api authentication failed in file download: %s", err) + resp.WriteHeader(401) + resp.Write([]byte(`{"success": false}`)) + return + } + + // 1. Verify if the user has access to the file: org_id and workflow + + log.Printf("Should get file %s", fileId) + ctx := context.Background() + file, err := getFile(ctx, fileId) + if err != nil { + log.Printf("File %s not found: %s", fileId, err) + resp.WriteHeader(401) + resp.Write([]byte(`{"success": false}`)) + return + } + + found := false + if file.OrgId == user.ActiveOrg.Id { + found = true + } else { + for _, item := range user.Orgs { + if item == file.OrgId { + found = true + break + } + } + } + + if !found { + log.Printf("User %s doesn't have access to %s", user.Username, fileId) + resp.WriteHeader(401) + resp.Write([]byte(`{"success": false}`)) + return + } + + // Fixme: More auth: org and workflow! + downloadPath := file.DownloadPath + log.Printf("Downloadpath: %s", downloadPath) + Openfile, err := os.Open(downloadPath) + defer Openfile.Close() //Close after function return + if err != nil { + //File not found, send 404 + http.Error(resp, "File not found.", 404) + return + } + + //File is found, create and send the correct headers + //Get the Content-Type of the file + //Create a buffer to store the header of the file in + FileHeader := make([]byte, 512) + //Copy the headers into the FileHeader buffer + Openfile.Read(FileHeader) + //Get content type of file + FileContentType := http.DetectContentType(FileHeader) + + //Get the file size + FileStat, _ := Openfile.Stat() //Get info from file + FileSize := strconv.FormatInt(FileStat.Size(), 10) //Get file size as a string + + //Send the headers + resp.Header().Set("Content-Disposition", "attachment; filename="+fileId) + resp.Header().Set("Content-Type", FileContentType) + resp.Header().Set("Content-Length", FileSize) + + //Send the file + //We read 512 bytes from the file already, so we reset the offset back to 0 + Openfile.Seek(0, 0) + io.Copy(resp, Openfile) //'Copy' the file to the client + return + + //log.Printf("Should download file %s", downloadPath) + //resp.WriteHeader(200) + //resp.Write([]byte("OK")) +} + func initHandlers() { var err error ctx := context.Background() @@ -7302,7 +7491,17 @@ func initHandlers() { // NEW for 0.8.0 r.HandleFunc("/api/v1/cloud/setup", handleCloudSetup).Methods("POST", "OPTIONS") - r.HandleFunc("/api/v1/getorgs", handleGetOrgs).Methods("GET", "OPTIONS") + r.HandleFunc("/api/v1/orgs", handleGetOrgs).Methods("GET", "OPTIONS") + + // Important for email, IDS etc. Create this by: + // PS: For cloud, this has to use cloud storage. + // https://developer.box.com/reference/get-files-id-content/ + // 1. Creating the "get file" option. Make it possible to run this in the frontend. + r.HandleFunc("/api/v1/files/{fileId}/content", handleGetFileContent).Methods("GET", "OPTIONS") + //r.HandleFunc("/api/v1/files/create", handleGetFile).Methods("POST", "OPTIONS") + //r.HandleFunc("/api/v1/files/upload", handleGetFile).Methods("POST", "OPTIONS") + //r.HandleFunc("/api/v1/files/{fileId}", handleGetFile).Methods("GET", "OPTIONS") + //r.HandleFunc("/api/v1/files/{fileId}", handleGetFile).Methods("DELETE", "OPTIONS") http.Handle("/", r) } diff --git a/backend/tests/file_download.sh b/backend/tests/file_download.sh new file mode 100644 index 00000000..32bb0787 --- /dev/null +++ b/backend/tests/file_download.sh @@ -0,0 +1,3 @@ +# ./b199646b-16d2-456d-9fd6-b9972e929466/2e9d6474-402c-4dcc-bb53-45f638ca18d3/0d676d72-5d53-4803-a6b0-4afb464df828 +# org_id / workflow_id / file_id +curl http://192.168.3.6:5001/api/v1/files/0d676d72-5d53-4803-a6b0-4afb464df828/content -H "Authorization: Bearer 093b576f-19ea-4353-b685-362ab50f39f4" diff --git a/frontend/src/components/Header.js b/frontend/src/components/Header.js index 6902eb8f..4bc78430 100644 --- a/frontend/src/components/Header.js +++ b/frontend/src/components/Header.js @@ -35,7 +35,7 @@ const Header = props => { // DEBUG HERE const handleClickLogout = () => { - console.log("SHOULD LOG OUT") + console.log("SHOULD LOG OUT") console.log(isLoggedIn) // Don't really care about the logout @@ -47,10 +47,9 @@ const Header = props => { }, }) .then(() => { - // Log out anyway - console.log("Hey") - removeCookie("session_token", {path: "/"}) - window.location.pathname = "/" + // Log out anyway + removeCookie("session_token", {path: "/"}) + //window.location.pathname = "/" }) .catch(error => { console.log(error) @@ -158,6 +157,16 @@ const Header = props => {
+ {/* + + +
+ + Pricing +
+ +
+ */} {/* @@ -183,6 +192,19 @@ const Header = props => { color="primary"> Settings + {/* + + + + + + */} {userdata === undefined || userdata.admin === undefined || userdata.admin === null || !userdata.admin ? null : @@ -299,8 +321,8 @@ const Header = props => {
// - const loadedCheck = isLoaded ? -
+ const loadedCheck = +
{loginTextBrowser} @@ -308,10 +330,6 @@ const Header = props => { {loginTextMobile}
- : -
-
- //
return (
diff --git a/frontend/src/views/Admin.jsx b/frontend/src/views/Admin.jsx index 298080f8..9978dd97 100644 --- a/frontend/src/views/Admin.jsx +++ b/frontend/src/views/Admin.jsx @@ -555,7 +555,7 @@ const Admin = (props) => { } const getOrgs = () => { - fetch(globalUrl + "/api/v1/getorgs", { + fetch(globalUrl + "/api/v1/orgs", { method: 'GET', headers: { 'Content-Type': 'application/json', diff --git a/frontend/src/views/SettingsPage.jsx b/frontend/src/views/SettingsPage.jsx index d91d74fd..0f7c572a 100644 --- a/frontend/src/views/SettingsPage.jsx +++ b/frontend/src/views/SettingsPage.jsx @@ -109,13 +109,13 @@ const Settings = (props) => { const getSettings = () => { fetch(globalUrl+"/api/v1/getsettings", { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - credentials: "include", - }) + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + credentials: "include", + }) .then((response) => { if (response.status !== 200) { console.log("Status not 200 for WORKFLOW EXECUTION :O!") @@ -137,24 +137,24 @@ const Settings = (props) => { if (userInfo.username.length > 0) { setUsername(userInfo.username) } - if (userInfo.firstname.length > 0) { - setFirstname(userInfo.firstname) - } - if (userInfo.lastname.length > 0) { - setLastname(userInfo.lastname) - } - if (userInfo.title.length > 0) { - setTitle(userInfo.title) - } - if (userInfo.companyname.length > 0) { - setCompanyname(userInfo.companyname) - } - if (userInfo.phone.length > 0) { - setPhone(userInfo.phone) - } - if (userInfo.email.length > 0) { - setEmail(userInfo.email) - } + //if (userInfo.firstname.length > 0) { + // setFirstname(userInfo.firstname) + //} + //if (userInfo.lastname.length > 0) { + // setLastname(userInfo.lastname) + //} + //if (userInfo.title.length > 0) { + // setTitle(userInfo.title) + //} + //if (userInfo.companyname.length > 0) { + // setCompanyname(userInfo.companyname) + //} + //if (userInfo.phone.length > 0) { + // setPhone(userInfo.phone) + //} + //if (userInfo.email.length > 0) { + // setEmail(userInfo.email) + //} } } @@ -175,7 +175,7 @@ const Settings = (props) => {

APIKEY

- What is the API key used for? + What is the API key used for? Date: Sat, 31 Oct 2020 09:11:00 +0100 Subject: [PATCH 17/18] Fixed more org related functionality --- backend/app_sdk/app_base.py | 23 +- backend/go-app/main.go | 403 +++++++++++++++---------- backend/go-app/walkoff.go | 51 +++- docker-compose.yml | 2 +- frontend/src/App.jsx | 37 +-- frontend/src/components/Header.js | 41 +-- frontend/src/views/Admin.jsx | 4 +- frontend/src/views/AngularWorkflow.jsx | 47 +-- frontend/src/views/LoginPage.jsx | 4 - frontend/src/views/Workflows.jsx | 40 ++- 10 files changed, 405 insertions(+), 247 deletions(-) diff --git a/backend/app_sdk/app_base.py b/backend/app_sdk/app_base.py index d25297eb..1492305a 100644 --- a/backend/app_sdk/app_base.py +++ b/backend/app_sdk/app_base.py @@ -308,7 +308,18 @@ class AppBase: print("DATA: %s\n" % data) return parse_wrapper(data) + # Looks for parantheses to grab special cases within a string, e.g: + # int(1) lower(HELLO) or length(what's the length) + # FIXME: + # There is an issue in here where it returns data wrong. Example: + # Authorization=Bearer authkey + # = + # Authorization=Bearer authkey + # ^ Double space. def parse_wrapper_start(data): + if "(" not in data or ")" not in data: + return data + newdata = [] newstring = "" record = True @@ -337,7 +348,7 @@ class AppBase: if len(newstring) > 0: newdata.append(newstring) - #print(newdata) + print("Newdata: ", newdata) parsedlist = [] non_string = False for item in newdata: @@ -348,17 +359,20 @@ class AppBase: parsedlist.append(ret) if len(parsedlist) > 0 and not non_string: + print("Returning parsed list: ", parsedlist) return " ".join(parsedlist) elif len(parsedlist) == 1 and non_string: return parsedlist[0] else: - #print("Casting back to string because multi: ", parsedlist) + print("Casting back to string because multi: ", parsedlist) newlist = [] for item in parsedlist: try: newlist.append(str(item)) except ValueError: newlist.append("parsing_error") + + # Does this create the issue? return " ".join(newlist) # Parses JSON loops and such down to the item you're looking for @@ -989,6 +1003,7 @@ class AppBase: print("Normal parsing (not looping) with data %s" % value) value = parse_wrapper_start(value) + print("POST data value: %s" % value) params[parameter["name"]] = value multi_parameters[parameter["name"]] = value @@ -1019,8 +1034,9 @@ class AppBase: #for i in range(calltimes): if not multiexecution: print("APP_SDK DONE: Starting NORMAL execution of function") + print("Running with params %s" % params) newres = await func(**params) - #print("NEWRES: ", newres) + print("Return from execution: %s" % newres) if isinstance(newres, str): result += newres else: @@ -1101,6 +1117,7 @@ class AppBase: print("Running with params %s" % baseparams) ret = await func(**baseparams) + print("Return from execution: %s" % ret) if isinstance(ret, dict) or isinstance(ret, list): results.append(ret) json_object = True diff --git a/backend/go-app/main.go b/backend/go-app/main.go index bd0f2153..e88e8a9e 100644 --- a/backend/go-app/main.go +++ b/backend/go-app/main.go @@ -390,6 +390,7 @@ type Hook struct { Status string `json:"status" datastore:"status"` Workflows []string `json:"workflows" datastore:"workflows"` Running bool `json:"running" datastore:"running"` + OrgId string `json:"org_id" datastore:"org_id"` } func createFileFromFile(ctx context.Context, bucket *storage.BucketHandle, remotePath, localPath string) error { @@ -689,47 +690,14 @@ func handleApiAuthentication(resp http.ResponseWriter, request *http.Request) (U authorization = authorizationArr[0] } _ = authorization - - //if item, err := memcache.Get(ctx, authorization); err == memcache.ErrCacheMiss { - // // Doesn't exist :( - // log.Printf("Couldn't find %s in cache!", authorization) - // return User{}, err - //} else if err != nil { - // log.Printf("Error getting item: %v", err) - // return User{}, err - //} else { - // log.Printf("%#v", item.Value) - // var Userdata User - - // log.Printf("Deleting key %s", authorization) - // memcache.Delete(ctx, authorization) - // err = json.Unmarshal(item.Value, &Userdata) - // if err == nil { - // return Userdata, nil - // } - - // return User{}, err - //} } c, err := request.Cookie("session_token") if err == nil { - //if item, err := memcache.Get(ctx, c.Value); err == memcache.ErrCacheMiss { - // // Not in cache - //} else if err != nil { - // log.Printf("Error getting item: %v", err) - //} else { - // var Userdata User - // err = json.Unmarshal(item.Value, &Userdata) - // if err == nil { - // return Userdata, nil - // } - //} - sessionToken := c.Value session, err := getSession(ctx, sessionToken) if err != nil { - log.Printf("Session %s doesn't exist (api auth): %s", sessionToken, err) + log.Printf("Session %s doesn't exist (session auth): %s", sessionToken, err) return User{}, err } @@ -865,7 +833,7 @@ func deleteUser(resp http.ResponseWriter, request *http.Request) { return } - user, userErr := handleApiAuthentication(resp, request) + userInfo, userErr := handleApiAuthentication(resp, request) if userErr != nil { log.Printf("Api authentication failed in edit workflow: %s", userErr) resp.WriteHeader(401) @@ -873,8 +841,8 @@ func deleteUser(resp http.ResponseWriter, request *http.Request) { return } - if user.Role != "admin" { - log.Printf("Wrong user (%s) when deleting - must be admin", user.Username) + if userInfo.Role != "admin" { + log.Printf("Wrong user (%s) when deleting - must be admin", userInfo.Username) resp.WriteHeader(401) resp.Write([]byte(`{"success": false, "reason": "Must be admin"}`)) return @@ -892,46 +860,57 @@ func deleteUser(resp http.ResponseWriter, request *http.Request) { userId = location[4] } - if userId == user.Id { + if userId == userInfo.Id { resp.WriteHeader(401) resp.Write([]byte(`{"success": false, "reason": "Can't deactivate yourself"}`)) return } ctx := context.Background() - q := datastore.NewQuery("Users").Filter("id =", userId) - var users []User - _, err := dbclient.GetAll(ctx, q, &users) + foundUser, err := getUser(ctx, userId) if err != nil { - log.Printf("Error getting users apikey (deleteuser): %s", err) + log.Printf("Can't find user %s (delete user): %s", userId, err) resp.WriteHeader(401) - resp.Write([]byte(`{"success": false, "reason": "Failed getting users for verification"}`)) + resp.Write([]byte(fmt.Sprintf(`{"success": false}`))) return } - if len(users) != 1 { - log.Printf("Found too many users!") - resp.WriteHeader(500) - resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "Backend error: too many or too few users with id %s: %d"}`, userId, len(users)))) + orgFound := false + if userInfo.ActiveOrg.Id == foundUser.ActiveOrg.Id { + orgFound = true + } else { + log.Printf("FoundUser: %#v", foundUser.Orgs) + for _, item := range foundUser.Orgs { + if item == userInfo.ActiveOrg.Id { + orgFound = true + break + } + } + } + + if !orgFound { + log.Printf("User %s is admin, but can't delete users outside their own org.", userInfo.Id) + resp.WriteHeader(401) + resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "Can't change users outside your org."}`))) return } // Invert. No user deletion. - if users[0].Active { - users[0].Active = false + if foundUser.Active { + foundUser.Active = false } else { - users[0].Active = true + foundUser.Active = true } - err = setUser(ctx, &users[0]) + err = setUser(ctx, foundUser) if err != nil { - log.Printf("Failed swapping active for user %s (%s)", users[0].Username, users[0].Id) + log.Printf("Failed swapping active for user %s (%s)", foundUser, foundUser.Username, foundUser.Id) resp.WriteHeader(401) - resp.Write([]byte(fmt.Sprintf(`{"success": true"}`))) + resp.Write([]byte(fmt.Sprintf(`{"success": false}`))) return } - log.Printf("Successfully inverted %s", users[0].Username) + log.Printf("Successfully inverted %s", foundUser.Username) resp.WriteHeader(200) resp.Write([]byte(`{"success": true}`)) @@ -1091,7 +1070,7 @@ func handleSetEnvironments(resp http.ResponseWriter, request *http.Request) { } for _, item := range newEnvironments { - if item.OrgId == "" { + if item.OrgId != user.ActiveOrg.Id { item.OrgId = user.ActiveOrg.Id } @@ -1327,51 +1306,53 @@ func handleLogout(resp http.ResponseWriter, request *http.Request) { return } - // Check cookie - c, err := request.Cookie("session_token") + userInfo, err := handleApiAuthentication(resp, request) if err != nil { - resp.WriteHeader(200) - resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "%s"}`, err))) - return - } else { - log.Printf("Session cookie is set!") - } - - var Userdata User - ctx := context.Background() - //item, err := memcache.Get(ctx, c.Value) - sessionToken := "" - //// Memcache handling for logout - //if err == nil { - // err = json.Unmarshal(item.Value, &Userdata) - // if err != nil { - // log.Printf("Failed unmarshaling: %s", err) - // resp.WriteHeader(401) - // resp.Write([]byte(fmt.Sprintf(`{"success": false}`))) - // return - // } - - // sessionToken = Userdata.Session - //} else { - // // Validate with User - sessionToken = c.Value - session, err := getSession(ctx, sessionToken) - if err != nil { - log.Printf("Session %s doesn't exist (logout): %s", session.Session, err) + log.Printf("Api authentication failed in handleLogout: %s", err) resp.WriteHeader(401) - resp.Write([]byte(`{"success": false, "reason": ""}`)) + resp.Write([]byte(`{"success": false}`)) return } + ctx := context.Background() + session, err := getSession(ctx, userInfo.Session) + if err != nil { + log.Printf("Session %#v doesn't exist: %s", session, err) + resp.WriteHeader(401) + resp.Write([]byte(`{"success": false, "reason": "No session"}`)) + return + } + + // Check cookie + //c, err := request.Cookie("session_token") + //if err != nil { + // resp.WriteHeader(200) + // resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "%s"}`, err))) + // return + //} else { + // log.Printf("Session cookie is set to %s!", c.Value) + //} + + //var Userdata User + //ctx := context.Background() + //sessionToken = c.Value + //session, err := getSession(ctx, sessionToken) + //if err != nil { + // log.Printf("Session %s doesn't exist (logout): %s", sessionToken, err) + // resp.WriteHeader(401) + // resp.Write([]byte(`{"success": false, "reason": "Couldn't find your session"}`)) + // return + //} + // Get session first // Should basically never happen - _, err = getUser(ctx, session.Id) - if err != nil { - log.Printf("Username %s doesn't exist (logout): %s", session.Username, err) - resp.WriteHeader(401) - resp.Write([]byte(`{"success": false, "reason": "Username and/or password is incorrect"}`)) - return - } + //_, err = getUser(ctx, session.Id) + //if err != nil { + // log.Printf("Username %s doesn't exist (logout): %s", session.Username, err) + // resp.WriteHeader(401) + // resp.Write([]byte(`{"success": false, "reason": "Username and/or password is incorrect"}`)) + // return + //} // Userdata = *tmpdata //} @@ -1379,7 +1360,7 @@ func handleLogout(resp http.ResponseWriter, request *http.Request) { // FIXME // Session might delete someone elses here? // No need to think about before possible scale..? - err = SetSession(ctx, Userdata, "") + err = SetSession(ctx, userInfo, "") if err != nil { log.Printf("Error removing session for: %s", err) resp.WriteHeader(401) @@ -1387,16 +1368,16 @@ func handleLogout(resp http.ResponseWriter, request *http.Request) { return } - err = DeleteKey(ctx, "sessions", sessionToken) + err = DeleteKey(ctx, "sessions", userInfo.Session) if err != nil { - log.Printf("Error deleting key %s for %s: %s", c.Value, Userdata.Username, err) + log.Printf("Error deleting key %s for %s: %s", userInfo.Session, userInfo.Username, err) resp.WriteHeader(401) resp.Write([]byte(`{"success": false, "reason": "Username and/or password is incorrect"}`)) return } - Userdata.Session = "" - err = setUser(ctx, &Userdata) + userInfo.Session = "" + err = setUser(ctx, &userInfo) if err != nil { log.Printf("Failed updating user: %s", err) resp.WriteHeader(401) @@ -1405,10 +1386,10 @@ func handleLogout(resp http.ResponseWriter, request *http.Request) { } //memcache.Delete(request.Context(), sessionToken) + //http.SetCookie(resp, c) resp.WriteHeader(200) resp.Write([]byte(`{"success": false, "reason": "Successfully logged out"}`)) - http.SetCookie(resp, c) } func generateApikey(ctx context.Context, userInfo User) (User, error) { @@ -1471,6 +1452,8 @@ func handleUpdateUser(resp http.ResponseWriter, request *http.Request) { return } + // Should this role reflect the users' org access? + // When you change org -> change user role if userInfo.Role != "admin" { log.Printf("%s tried to update user %s", userInfo.Username, t.UserId) resp.WriteHeader(401) @@ -1486,6 +1469,21 @@ func handleUpdateUser(resp http.ResponseWriter, request *http.Request) { return } + orgFound := false + for _, item := range foundUser.Orgs { + if item == userInfo.ActiveOrg.Id { + orgFound = true + break + } + } + + if !orgFound { + log.Printf("User %s is admin, but can't edit users outside their own org.", userInfo.Id) + resp.WriteHeader(401) + resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "Can't change users outside your org."}`))) + return + } + if t.Role != "admin" && t.Role != "user" { log.Printf("%s tried and failed to update user %s", userInfo.Username, t.UserId) resp.WriteHeader(401) @@ -1657,13 +1655,13 @@ func handleInfo(resp http.ResponseWriter, request *http.Request) { } ctx := context.Background() - session, err := getSession(ctx, userInfo.Session) - if err != nil { - log.Printf("Session %#v doesn't exist: %s", session, err) - resp.WriteHeader(401) - resp.Write([]byte(`{"success": false, "reason": "No session"}`)) - return - } + //session, err := getSession(ctx, userInfo.Session) + //if err != nil { + // log.Printf("Session %#v doesn't exist: %s", session, err) + // resp.WriteHeader(401) + // resp.Write([]byte(`{"success": false, "reason": "No session"}`)) + // return + //} // This is a long check to see if an inactive admin can access the site parsedAdmin := "false" @@ -1726,12 +1724,12 @@ func handleInfo(resp http.ResponseWriter, request *http.Request) { } //log.Printf("%s %s", session.Session, UserInfo.Session) - if session.Session != userInfo.Session { - log.Printf("Session %s is not the same as %s for %s. %s", userInfo.Session, session.Session, userInfo.Username, err) - resp.WriteHeader(401) - resp.Write([]byte(`{"success": false, "reason": ""}`)) - return - } + //if session.Session != userInfo.Session { + // log.Printf("Session %s is not the same as %s for %s. %s", userInfo.Session, session.Session, userInfo.Username, err) + // resp.WriteHeader(401) + // resp.Write([]byte(`{"success": false, "reason": ""}`)) + // return + //} expiration := time.Now().Add(3600 * time.Second) http.SetCookie(resp, &http.Cookie{ @@ -2015,7 +2013,7 @@ func handlePasswordChange(resp http.ResponseWriter, request *http.Request) { return } - user, err := handleApiAuthentication(resp, request) + userInfo, err := handleApiAuthentication(resp, request) if err != nil { log.Printf("Api authentication failed in set new workflowhandler: %s", err) resp.WriteHeader(401) @@ -2024,15 +2022,15 @@ func handlePasswordChange(resp http.ResponseWriter, request *http.Request) { } curUserFound := false - if t.Username != user.Username && user.Role != "admin" { + if t.Username != userInfo.Username && userInfo.Role != "admin" { resp.WriteHeader(401) resp.Write([]byte(`{"success": false, "reason": "Admin required to change others' passwords"}`)) return - } else if t.Username == user.Username { + } else if t.Username == userInfo.Username { curUserFound = true } - if user.Role != "admin" { + if userInfo.Role != "admin" { if t.Newpassword != t.Newpassword2 { err := "Passwords don't match" resp.WriteHeader(401) @@ -2046,6 +2044,8 @@ func handlePasswordChange(resp http.ResponseWriter, request *http.Request) { resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "%s"}`, err))) return } + } else { + // Check ORG HERE? } // Current password @@ -2058,6 +2058,7 @@ func handlePasswordChange(resp http.ResponseWriter, request *http.Request) { } ctx := context.Background() + foundUser := User{} if !curUserFound { log.Printf("Have to find a different user") q := datastore.NewQuery("Users").Filter("Username =", strings.ToLower(t.Username)) @@ -2077,13 +2078,32 @@ func handlePasswordChange(resp http.ResponseWriter, request *http.Request) { return } - user = users[0] + foundUser = users[0] + orgFound := false + if userInfo.ActiveOrg.Id == foundUser.ActiveOrg.Id { + orgFound = true + } else { + log.Printf("FoundUser: %#v", foundUser.Orgs) + for _, item := range foundUser.Orgs { + if item == userInfo.ActiveOrg.Id { + orgFound = true + break + } + } + } + + if !orgFound { + log.Printf("User %s is admin, but can't change user's passowrd outside their own org.", userInfo.Id) + resp.WriteHeader(401) + resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "Can't change users outside your org."}`))) + return + } } else { // Admins can re-generate others' passwords as well. - if user.Role != "admin" { - err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(t.Newpassword)) + if userInfo.Role != "admin" { + err = bcrypt.CompareHashAndPassword([]byte(userInfo.Password), []byte(t.Newpassword)) if err != nil { - log.Printf("Bad password for %s: %s", user.Username, err) + log.Printf("Bad password for %s: %s", userInfo.Username, err) resp.WriteHeader(401) resp.Write([]byte(`{"success": false, "reason": "Username and/or password is incorrect"}`)) return @@ -2091,18 +2111,25 @@ func handlePasswordChange(resp http.ResponseWriter, request *http.Request) { } } + if len(foundUser.Id) == 0 { + log.Printf("Something went wrong in password reset", err) + resp.WriteHeader(500) + resp.Write([]byte(`{"success": false}`)) + return + } + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(t.Newpassword), 8) if err != nil { - log.Printf("New password failure for %s: %s", user.Username, err) + log.Printf("New password failure for %s: %s", userInfo.Username, err) resp.WriteHeader(401) resp.Write([]byte(`{"success": false, "reason": "Username and/or password is incorrect"}`)) return } - user.Password = string(hashedPassword) - err = setUser(ctx, &user) + userInfo.Password = string(hashedPassword) + err = setUser(ctx, &foundUser) if err != nil { - log.Printf("Error fixing password for user %s: %s", user.Username, err) + log.Printf("Error fixing password for user %s: %s", userInfo.Username, err) resp.WriteHeader(401) resp.Write([]byte(`{"success": false, "reason": "Username and/or password is incorrect"}`)) return @@ -2341,17 +2368,15 @@ func handleGetUsers(resp http.ResponseWriter, request *http.Request) { // FIXME: Check by org. ctx := context.Background() - var users []User - q := datastore.NewQuery("Users") - _, err = dbclient.GetAll(ctx, q, &users) + org, err := getOrg(ctx, user.ActiveOrg.Id) if err != nil { resp.WriteHeader(401) - resp.Write([]byte(`{"success": false, "reason": "Can't get users"}`)) + resp.Write([]byte(`{"success": false, "reason": "Failed getting org users"}`)) return } newUsers := []User{} - for _, item := range users { + for _, item := range org.Users { if len(item.Username) == 0 { continue } @@ -2458,19 +2483,10 @@ func handleLogin(resp http.ResponseWriter, request *http.Request) { return } - log.Printf("%s SUCCESSFULLY LOGGED IN with session %s", data.Username, Userdata.Session) - //if !Userdata.Verified { - // log.Printf("User %s is not verified", data.Username) - // resp.WriteHeader(403) - // resp.Write([]byte(`{"success": false, "reason": "Successful login, but your email address isn't verified. Check your mailbox."}`)) - // return - //} - - loginData := `{"success": true}` - // FIXME - have timeout here + loginData := `{"success": true}` if len(Userdata.Session) != 0 { - //log.Println("Nonexisting session") + log.Println("User session exists - resetting") expiration := time.Now().Add(3600 * time.Second) http.SetCookie(resp, &http.Cookie{ @@ -2490,20 +2506,36 @@ func handleLogin(resp http.ResponseWriter, request *http.Request) { resp.WriteHeader(200) resp.Write([]byte(loginData)) return + } else { + log.Printf("User session is empty - create one!") + + sessionToken := uuid.NewV4().String() + expiration := time.Now().Add(3600 * time.Second) + http.SetCookie(resp, &http.Cookie{ + Name: "session_token", + Value: sessionToken, + Expires: expiration, + }) + + // ADD TO DATABASE + err = SetSession(ctx, Userdata, sessionToken) + if err != nil { + log.Printf("Error adding session to database: %s", err) + } + + Userdata.Session = sessionToken + err = setUser(ctx, &Userdata) + if err != nil { + log.Printf("Failed updating user when setting session: %s", err) + resp.WriteHeader(500) + resp.Write([]byte(`{"success": false}`)) + return + } + + loginData = fmt.Sprintf(`{"success": true, "cookies": [{"key": "session_token", "value": "%s", "expiration": %d}]}`, sessionToken, expiration.Unix()) } - sessionToken := uuid.NewV4() - http.SetCookie(resp, &http.Cookie{ - Name: "session_token", - Value: sessionToken.String(), - Expires: time.Now().Add(3600 * time.Second), - }) - - // ADD TO DATABASE - err = SetSession(ctx, Userdata, sessionToken.String()) - if err != nil { - log.Printf("Error adding session to database: %s", err) - } + log.Printf("%s SUCCESSFULLY LOGGED IN with session %s", data.Username, Userdata.Session) resp.WriteHeader(200) resp.Write([]byte(loginData)) @@ -2685,8 +2717,61 @@ func setEnvironment(ctx context.Context, data *Environment) error { return nil } +func fixUserOrg(ctx context.Context, user *User) *User { + found := false + for _, id := range user.Orgs { + if user.ActiveOrg.Id == id { + found = true + break + } + } + + if !found { + user.Orgs = append(user.Orgs, user.ActiveOrg.Id) + } + + log.Printf("Updating %d orgs for user %s", len(user.Orgs), user.Id) + // Might be vulnerable to timing attacks. + for _, orgId := range user.Orgs { + if len(orgId) == 0 { + continue + } + + org, err := getOrg(ctx, orgId) + if err != nil { + log.Printf("Error getting org %s", orgId) + continue + } + + orgIndex := 0 + userFound := false + for index, orgUser := range org.Users { + if orgUser.Id == user.Id { + orgIndex = index + userFound = true + break + } + } + + if userFound { + org.Users[orgIndex] = *user + } else { + org.Users = append(org.Users, *user) + } + + err = setOrg(ctx, *org, orgId) + if err != nil { + log.Printf("Failed setting org %s", orgId) + } + } + + return user +} + // ListBooks returns a list of books, ordered by title. func setUser(ctx context.Context, data *User) error { + data = fixUserOrg(ctx, data) + // clear session_token and API_token for user k := datastore.NameKey("Users", data.Id, nil) if _, err := dbclient.Put(ctx, k, data); err != nil { @@ -3481,6 +3566,7 @@ func handleNewHook(resp http.ResponseWriter, request *http.Request) { }, }, Running: false, + OrgId: user.ActiveOrg.Id, } hook.Status = "running" @@ -7027,6 +7113,7 @@ func handleCloudSetup(resp http.ResponseWriter, request *http.Request) { } // FIXME: Check if user is admin of this org + log.Printf("Checking org %s", org.Name) userFound := false admin := false for _, inneruser := range org.Users { @@ -7061,7 +7148,7 @@ func handleCloudSetup(resp http.ResponseWriter, request *http.Request) { // FIXME: Path client := &http.Client{} - apiPath := "/api/v1/cloud/sync" + apiPath := "/api/v1/cloud/sync/setup" if tmpData.Disable { if !org.CloudSync { log.Printf("Org %s isn't syncing. Can't stop.", org.Id) @@ -7072,9 +7159,9 @@ func handleCloudSetup(resp http.ResponseWriter, request *http.Request) { log.Printf("Should disable sync for org %s", org.Id) apiPath := "/api/v1/cloud/sync/stop" - syncUrl = fmt.Sprintf("%s%s", syncUrl, apiPath) + syncPath := fmt.Sprintf("%s%s", syncUrl, apiPath) - err = handleStopCloudSync(syncUrl, *org) + err = handleStopCloudSync(syncPath, *org) if err != nil { resp.WriteHeader(401) resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "%s"}`, err))) @@ -7396,9 +7483,9 @@ func initHandlers() { r.HandleFunc("/api/v1/users/logout", handleLogout).Methods("POST", "OPTIONS") r.HandleFunc("/api/v1/users/register", handleRegister).Methods("POST", "OPTIONS") r.HandleFunc("/api/v1/users/checkusers", checkAdminLogin).Methods("GET", "OPTIONS") - r.HandleFunc("/api/v1/users/getusers", handleGetUsers).Methods("GET", "OPTIONS") r.HandleFunc("/api/v1/users/getinfo", handleInfo).Methods("GET", "OPTIONS") r.HandleFunc("/api/v1/users/getsettings", handleSettings).Methods("GET", "OPTIONS") + r.HandleFunc("/api/v1/users/getusers", handleGetUsers).Methods("GET", "OPTIONS") r.HandleFunc("/api/v1/users/updateuser", handleUpdateUser).Methods("PUT", "OPTIONS") r.HandleFunc("/api/v1/users/{user}", deleteUser).Methods("DELETE", "OPTIONS") r.HandleFunc("/api/v1/users/passwordchange", handlePasswordChange).Methods("POST", "OPTIONS") @@ -7413,10 +7500,10 @@ func initHandlers() { r.HandleFunc("/api/v1/getinfo", handleInfo).Methods("GET", "OPTIONS") r.HandleFunc("/api/v1/getsettings", handleSettings).Methods("GET", "OPTIONS") r.HandleFunc("/api/v1/generateapikey", handleApiGeneration).Methods("GET", "POST", "OPTIONS") + r.HandleFunc("/api/v1/passwordchange", handlePasswordChange).Methods("POST", "OPTIONS") r.HandleFunc("/api/v1/getenvironments", handleGetEnvironments).Methods("GET", "OPTIONS") r.HandleFunc("/api/v1/setenvironments", handleSetEnvironments).Methods("PUT", "OPTIONS") - r.HandleFunc("/api/v1/passwordchange", handlePasswordChange).Methods("POST", "OPTIONS") r.HandleFunc("/api/v1/docs", getDocList).Methods("GET", "OPTIONS") r.HandleFunc("/api/v1/docs/{key}", getDocs).Methods("GET", "OPTIONS") @@ -7433,6 +7520,7 @@ func initHandlers() { r.HandleFunc("/api/v1/workflows/queue/confirm", handleGetWorkflowqueueConfirm).Methods("POST") // App specific + // From here down isnt checked for org specific r.HandleFunc("/api/v1/apps/run_hotload", handleAppHotloadRequest).Methods("GET", "OPTIONS") r.HandleFunc("/api/v1/apps/get_existing", loadSpecificApps).Methods("POST", "OPTIONS") r.HandleFunc("/api/v1/apps/download_remote", loadSpecificApps).Methods("POST", "OPTIONS") @@ -7472,7 +7560,6 @@ func initHandlers() { r.HandleFunc("/api/v1/workflows/{key}", deleteWorkflow).Methods("DELETE", "OPTIONS") // Triggers - // Webhook redirect to the correct cloud function r.HandleFunc("/api/v1/hooks/new", handleNewHook).Methods("POST", "OPTIONS") r.HandleFunc("/api/v1/hooks/{key}", handleWebhookCallback).Methods("POST", "OPTIONS") r.HandleFunc("/api/v1/hooks/{key}/delete", handleDeleteHook).Methods("DELETE", "OPTIONS") diff --git a/backend/go-app/walkoff.go b/backend/go-app/walkoff.go index 0f52aabd..c0c643d3 100644 --- a/backend/go-app/walkoff.go +++ b/backend/go-app/walkoff.go @@ -105,6 +105,7 @@ type AppAuthenticationStorage struct { Usage []AuthenticationUsage `json:"usage" datastore:"usage"` WorkflowCount int64 `json:"workflow_count" datastore:"workflow_count"` NodeCount int64 `json:"node_count" datastore:"node_count"` + OrgId string `json:"org_id" datastore:"org_id"` } type AuthenticationUsage struct { @@ -197,6 +198,7 @@ type WorkflowAppAction struct { } // FIXME: Generate a callback authentication ID? +// FIXME: Add org check .. type WorkflowExecution struct { Type string `json:"type" datastore:"type"` Status string `json:"status" datastore:"status"` @@ -221,6 +223,7 @@ type WorkflowExecution struct { Name string `json:"name" datastore:"name"` Value string `json:"value" datastore:"value,noindex"` } `json:"execution_variables,omitempty" datastore:"execution_variables,omitempty"` + OrgId string `json:"org_id" datastore:"org_id"` } // This is for the nodes in a workflow, NOT the app action itself. @@ -303,6 +306,7 @@ type Schedule struct { Frequency string `json:"frequency" datastore:"frequency"` ExecutionArgument string `json:"execution_argument" datastore:"execution_argument,noindex"` Id string `json:"id" datastore:"id"` + OrgId string `json:"org_id" datastore:"org_id"` } type Workflow struct { @@ -1047,6 +1051,7 @@ func handleWorkflowQueue(resp http.ResponseWriter, request *http.Request) { extraInputs := 0 for _, result := range workflowExecution.Results { if result.Action.Name == "User Input" && result.Action.AppName == "User Input" { + log.Printf("Found User Input node - prepare cloud?") extraInputs += 1 } } @@ -1904,7 +1909,7 @@ func saveWorkflow(resp http.ResponseWriter, request *http.Request) { } } - allAuths, err := getAllWorkflowAppAuth(ctx) + allAuths, err := getAllWorkflowAppAuth(ctx, user.ActiveOrg.Id) if userErr != nil { log.Printf("Api authentication failed in get all apps: %s", userErr) resp.WriteHeader(401) @@ -2575,7 +2580,7 @@ func handleExecution(id string, workflow Workflow, request *http.Request) (Workf // FIXME: Authentication parameters if len(action.AuthenticationId) > 0 { if len(allAuths) == 0 { - allAuths, err = getAllWorkflowAppAuth(ctx) + allAuths, err = getAllWorkflowAppAuth(ctx, workflow.ExecutingOrg.Id) if err != nil { log.Printf("Api authentication failed in get all app auth: %s", err) return WorkflowExecution{}, fmt.Sprintf("Api authentication failed in get all app auth: %s", err), err @@ -2786,10 +2791,18 @@ func handleExecution(id string, workflow Workflow, request *http.Request) (Workf return WorkflowExecution{}, "Cloud not implemented yet", errors.New("Cloud not implemented yet") } + // What it needs to know: + // 1. Parameters if len(workflowExecution.Workflow.Actions) == 1 { log.Printf("Should execute directly with cloud instead of worker because only one action") - cloudExecuteAction(workflowExecution.ExecutionId, workflowExecution.Workflow.Actions[0], workflowExecution.ExecutionOrg) - return WorkflowExecution{}, "Cloud not implemented yet", errors.New("Cloud not implemented yet") + + //cloudExecuteAction(workflowExecution.ExecutionId, workflowExecution.Workflow.Actions[0], workflowExecution.ExecutionOrg, workflowExecution.Workflow.ID) + cloudExecuteAction(workflowExecution) + return WorkflowExecution{}, "Cloud not implemented yet (1)", errors.New("Cloud not implemented yet") + } else { + // If it's here, it should be controlled by Worker. + // If worker, should this backend be a proxy? I think so. + return WorkflowExecution{}, "Cloud not implemented yet (2)", errors.New("Cloud not implemented yet") } } @@ -2802,22 +2815,30 @@ func handleExecution(id string, workflow Workflow, request *http.Request) (Workf } // This updates stuff locally from remote executions -func cloudExecuteAction(workflowExecutionId string, action Action, orgId string) error { - log.Printf("Executing action: %#v in execution ID %s", action, workflowExecutionId) +func cloudExecuteAction(execution WorkflowExecution) error { ctx := context.Background() - org, err := getOrg(ctx, orgId) + org, err := getOrg(ctx, execution.ExecutionOrg) if err != nil { return err } type ExecutionStruct struct { - ID string `json:"id"` - Action Action `json:"action"` + ExecutionId string `json:"execution_id" datastore:"execution_id"` + Action Action `json:"action" datastore:"action"` + Authorization string `json:"authorization" datastore:"authorization"` + Results []ActionResult `json:"results" datastore:"results,noindex"` + ExecutionArgument string `json:"execution_argument" datastore:"execution_argument,noindex"` + WorkflowId string `json:"workflow_id" datastore:"workflow_id"` + ExecutionSource string `json:"execution_source" datastore:"execution_source"` } + data := ExecutionStruct{ - ID: workflowExecutionId, - Action: action, + ExecutionId: execution.ExecutionId, + WorkflowId: execution.Workflow.ID, + Action: execution.Workflow.Actions[0], + Authorization: execution.Authorization, } + log.Printf("Executing action: %#v in execution ID %s", data.Action, data.ExecutionId) b, err := json.Marshal(data) if err != nil { @@ -3899,7 +3920,7 @@ func getAppAuthentication(resp http.ResponseWriter, request *http.Request) { return } - _, userErr := handleApiAuthentication(resp, request) + user, userErr := handleApiAuthentication(resp, request) if userErr != nil { log.Printf("Api authentication failed in get all apps: %s", userErr) resp.WriteHeader(401) @@ -3915,7 +3936,7 @@ func getAppAuthentication(resp http.ResponseWriter, request *http.Request) { // return //} ctx := context.Background() - allAuths, err := getAllWorkflowAppAuth(ctx) + allAuths, err := getAllWorkflowAppAuth(ctx, user.ActiveOrg.Id) if err != nil { log.Printf("Api authentication failed in get all app auth: %s", err) resp.WriteHeader(401) @@ -5469,9 +5490,9 @@ func getAllWorkflowApps(ctx context.Context) ([]WorkflowApp, error) { return allworkflowapps, nil } -func getAllWorkflowAppAuth(ctx context.Context) ([]AppAuthenticationStorage, error) { +func getAllWorkflowAppAuth(ctx context.Context, OrgId string) ([]AppAuthenticationStorage, error) { var allworkflowapps []AppAuthenticationStorage - q := datastore.NewQuery("workflowappauth") + q := datastore.NewQuery("workflowappauth").Filter("org_id = ", OrgId) _, err := dbclient.GetAll(ctx, q, &allworkflowapps) if err != nil { diff --git a/docker-compose.yml b/docker-compose.yml index e01176d0..37e61122 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: '3' services: frontend: - build: ./frontend + #build: ./frontend image: frikky/shuffle:frontend container_name: shuffle-frontend hostname: shuffle-frontend diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 053f0ab5..446ab4df 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react'; import { Route } from 'react-router'; import { BrowserRouter } from 'react-router-dom'; import { CookiesProvider } from 'react-cookie'; -import { useCookies } from 'react-cookie'; +import { removeCookies, useCookies } from 'react-cookie'; import EditSchedule from "./views/EditSchedule"; import Schedules from "./views/Schedules"; @@ -93,23 +93,24 @@ const App = (message, props) => { 'Content-Type': 'application/json', }, }) - .then(response => response.json()) - .then(responseJson => { - if (responseJson.success === true) { - //console.log(responseJson.success) - setUserData(responseJson) - setIsLoggedIn(true) + .then(response => response.json()) + .then(responseJson => { + if (responseJson.success === true) { + //console.log(responseJson.success) + setUserData(responseJson) + setIsLoggedIn(true) + console.log("Cookies: ", cookies) - // Updating cookie every request - for (var key in responseJson["cookies"]) { - setCookie(responseJson["cookies"][key].key, responseJson["cookies"][key].value, { path: "/" }) - } + // Updating cookie every request + for (var key in responseJson["cookies"]) { + setCookie(responseJson["cookies"][key].key, responseJson["cookies"][key].value, { path: "/" }) } - setIsLoaded(true) - }) - .catch(error => { - setIsLoaded(true) - }); + } + setIsLoaded(true) + }) + .catch(error => { + setIsLoaded(true) + }); } // Dumb for content load (per now), but good for making the site not suddenly reload parts (ajax thingies) @@ -124,7 +125,7 @@ const App = (message, props) => { } />
:
-
+
} /> } /> } /> @@ -140,7 +141,7 @@ const App = (message, props) => { } /> } /> } /> - } /> + } /> } /> } /> { window.location.pathname = "/docs/about" }} /> diff --git a/frontend/src/components/Header.js b/frontend/src/components/Header.js index 4bc78430..11dcf59c 100644 --- a/frontend/src/components/Header.js +++ b/frontend/src/components/Header.js @@ -19,7 +19,7 @@ const hoverColor = "#f85a3e" const hoverOutColor = "#e8eaf6" const Header = props => { - const { globalUrl, isLoggedIn, removeCookie, homePage, isLoaded, userdata } = props; + const { globalUrl, isLoggedIn, removeCookie, homePage, isLoaded, userdata, cookies } = props; const theme = useTheme(); const [HomeHoverColor, setHomeHoverColor] = useState(hoverOutColor); @@ -35,26 +35,31 @@ const Header = props => { // DEBUG HERE const handleClickLogout = () => { - console.log("SHOULD LOG OUT") - console.log(isLoggedIn) - + console.log("COOKIES: ", cookies, "Remover: ", removeCookie) // Don't really care about the logout - fetch(globalUrl+"/api/v1/logout", { + fetch(globalUrl+"/api/v1/logout", { credentials: "include", - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }) - .then(() => { - // Log out anyway - removeCookie("session_token", {path: "/"}) - //window.location.pathname = "/" - }) + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + .then(() => { + // Log out anyway + //cookies.remove("session_token") + //window.location.pathname = "/" + console.log("Should've logged out") + removeCookie("session_token", {path: "/"}) + removeCookie("session_token", {path: "/workflows"}) + window.location.reload() + }) .catch(error => { - console.log(error) - }); - } + console.log("Error in logout: ", error) + removeCookie("session_token", {path: "/"}) + window.location.reload() + //removeCookie("session_token", {path: "/"}) + }) + } // Rofl this is weird const handleDocsHover = () => { diff --git a/frontend/src/views/Admin.jsx b/frontend/src/views/Admin.jsx index 9978dd97..34141a0e 100644 --- a/frontend/src/views/Admin.jsx +++ b/frontend/src/views/Admin.jsx @@ -1136,9 +1136,9 @@ const Admin = (props) => { }} > Edit user - + - + ) })} diff --git a/frontend/src/views/AngularWorkflow.jsx b/frontend/src/views/AngularWorkflow.jsx index 8a19fbed..fa480e33 100644 --- a/frontend/src/views/AngularWorkflow.jsx +++ b/frontend/src/views/AngularWorkflow.jsx @@ -189,6 +189,8 @@ const AngularWorkflow = (props) => { const [workflowExecutions, setWorkflowExecutions] = React.useState([]); const [defaultEnvironmentIndex, setDefaultEnvironmentIndex] = React.useState(0) + const cloudSyncEnabled = props.userdata !== undefined && props.userdata.active_org !== null && props.userdata.active_org !== undefined ? props.userdata.active_org.cloud_sync === true : false + const unloadText = 'Are you sure you want to leave without saving (CTRL+S)?' useBeforeunload(() => { if (!lastSaved) { @@ -1896,7 +1898,6 @@ const AngularWorkflow = (props) => { ) } - const syncEnabled = props.userdata !== undefined && props.userdata.selected_org !== null && props.userdata.selected_org !== undefined ? props.userdata.selected_org.cloud_sync === true : false const triggers = [{ "name": "Webhook", "type": "TRIGGER", @@ -1918,7 +1919,7 @@ const AngularWorkflow = (props) => { "description": "Wait for user input", "trigger_type": "USERINPUT", "errors": null, - "is_valid": syncEnabled, + "is_valid": cloudSyncEnabled, "label": "User input", "environment": environments[defaultEnvironmentIndex] === undefined ? {} : environments[defaultEnvironmentIndex].Name, "long_description": "Take user input to continue execution", @@ -1943,7 +1944,7 @@ const AngularWorkflow = (props) => { "description": "Add your email provider", "trigger_type": "EMAIL", "errors": null, - "is_valid": syncEnabled, + "is_valid": cloudSyncEnabled, "label": "Email", "environment": environments[defaultEnvironmentIndex] === undefined ? {} : environments[defaultEnvironmentIndex].Name, "large_image": 'data:image/png;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/hAytodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6Nzg4QTJBMjVEMDI1MTFFN0EwQUVDODc5QjYyQkFCMUQiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6Nzg4QTJBMjZEMDI1MTFFN0EwQUVDODc5QjYyQkFCMUQiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3ODhBMkEyM0QwMjUxMUU3QTBBRUM4NzlCNjJCQUIxRCIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3ODhBMkEyNEQwMjUxMUU3QTBBRUM4NzlCNjJCQUIxRCIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pv/bAEMAAwICAgICAwICAgMDAwMEBgQEBAQECAYGBQYJCAoKCQgJCQoMDwwKCw4LCQkNEQ0ODxAQERAKDBITEhATDxAQEP/AAAsIAGQAZAEBEQD/xAAeAAABAwUBAQAAAAAAAAAAAAAAAQgJAgQFBwoGA//EAEoQAAECBAMEBwMDEQgDAAAAAAECAwAEBREGBxIIITFRCRMiMkFSYRRicSNCQxUWGBk2U1dYc3WBlJWzwdLTJDNjZXKDkeEmgqH/2gAIAQEAAD8Ak8JKipSlBwuDSpSeDw8qeRguQQrUAQNAV4JH3s+sA7OnT8n1fcv9Bfzc7wWAAToIAOsJ8Uq++H0gI1XSUlYWdSkji6fMnkBAdS9SidesWUpI3OjyjkYXtXCgbEDSFW3JT5D6+sIOzp0/J9X3NX0F/NzvBYABOggA6wnxSr74fSAjVdJSVhZ1KSOLp8yeQEBJUSVKCysaVKHB0eVPIwXIIVqAIGgK8Ej72fWFS640kNtzjcukcGli6k/GENwVagAQO0EcEjmj1g33AATe1wD3SnmffgG/Tp337mv535T+EaB2k9tzInZilVyuMq+up4iUjWxh+mFLs8s23dbv0stnmsgnwBiNPOHpddozHExMSmWsrSsA0tZIaMs0JudCfV50FKf/AFQPjDYsT7S+0LjJ8zGJc68aTqiSrSqtPoQD6ISoJH6BGFkM5c3qW8Jim5qYvlXArUFNVuZSb89y43Tlv0ju15ltMtKZzUmsRSbZGuSxC0mebcHIrV8r/wALEP02c+l2ywzAmZXDGeND+saqvEIRVWXFP0x1Z3AOE9thPx1JHioQ/un1Kn1eQZqtLn2ZuSmkJdamZVwOIWlQuktKTuUg8xFybgnUACB2gjgkc0e9BvuAAm9rgHulPM+/CpDik3bal1p8FPd8/GEtp7Ojq+r36OPUe96wWv2dF79vR5/8T/qI/ukM6RMZMGcyWyUqDMzjZ5vRWKwmy26UlQ3IQOBmLHx3IFibncIeqvWKtiCqTVbrtSmahUJ11T0zNTLqnHXnFG5UpSiSSeZi0ggggh2GxRt8Y92X69K4cr83N1zLuadCZumLXrcp4Ue0/KXPZPiW+6r0O+JxsF4zwvmFhSl42wXV5eo0Sqy6ZySmWFakJbUO/wDHiCk7wQQd4jN2v2dF79vR5v8AE/6g6rrflPYfar/S69Or9EIAAE6QoAHshfFJ5r9Ibpt3bTrOzBkbPYhpb7f11V5aqZh9le8iZUm65i3i20ntcirQPGIA6nU6jWqlNVirzr05PTzy5iZmHllTjrqyVKWoneSSSSYtoIIIIIIkI6J/axmsA4+Rs84yqZ+tvFb5VQ1vL7EjVCNyN/Bt4C1vOEn5xiYndYghVr3IHeKuY9yKVJbJu63MrV4qY7h+EVA6rEKKwvclSuLp8quQiDnpWM5ZnMrafn8HS04pykZfy6KMw2D2BNEByZUPXWQj/aEM0h3uwvk5PYmoeLM4HcnqHmth/CU1KytdwrNy5M+uUdQtZmZBYIu83oN2z3wbcbWk7ym2Zej6zuwXJ49y5yXwbUqXNgpUPZlpelnh32XmyrU24k7ik7/0WMey+wI2OPxesJfqyv5oPsCNjj8XrCX6sr+aD7AjY4/F6wl+rq/mhs2b2SGzXjPGc3s9bKuzngiq4zZGjEWJ3pRTlKwkyrcVOKCrOzVr6GRex73AiIe65TvqRWqhSet632Kadl9enTq0LKb28L24R86ZUp6jVKUrFMmVy85IvtzMu8g2U24hQUlQPMEAx0h7O+abGdWR+DM0mnAF16lMuzRTxamgNDzQHIOJWI2IpxDZ0Lm3ZdQ4tti6U/CEdeDaHJhxYWAklahwdAF9KeRjmXzRxJMYxzLxXiyacUt2sVqdnlFRuflHlq/jHmIlv6D37is1T/mlM/cvQ5zNnZ7xtl/jWc2htlFUtIYrfs7iXCLy+rpeLGk7zcDczN2vpdFrnvcSTsrIPaHwNtBYcmKlhsv02t0h4ydfw7UE9XUKPOJJC2Xmzv3KBAWNyrbvEDaDjjbTa3XVpQhAKlKUbBIHEk+ENLxdnDj/AGr8T1LJ7Zgra6NgymPmSxhmU0LhB+kkaV4OPkGynu6gG4PAlwGUuT2AMjsDy2BMuqGin06XBcdWTrfm3j3333D2nHFHeVH/AOCwjmnxr92Ve/Oc1+9VGGibDogMVP1vZWmKI+4b4dxJOybS1G4S06ht7QPipxf/ADD4kurbGhE21LpHBtwXUn4xbVNpb9OnGAAFrl3EkI4JukgFHrHMFWpdyUrE/KvAhxmZdbUDxBCyDFnEuHQej/wjNU/5rTP3L0PQ2hs1cR4f+pOUWU/VTGZeOtbFK1jW3SZNO6YqkwPBtlJ7IPfcKUi++NdYh2H5LB1CoWLtnXFD2Fs1sLS6rV+ZUXG8SqWouPtVVP0yXnCo6+8gqFtwAGAbkdpzbFcTgXNLB1Qyay9pBEri1iXm9VQxPNo/vJeVdT/dyJ3XcG9YNgTvt6TFODKZsY4nlc2MsaEmRypn25em45oMi2eqpiUANsVllA8gsiYtvUiyzcpJhz8rOylSkGqjITTUzKzTKXmHmlhSHG1C6VJI3EEEEGOXnGv3ZV785zX71UYaJjehfk3mdn/GM4oHRM4sWEBfcsiUZ1Eeu+JBUhxSbttS60+Cnu+fjCAaDbR1fV9rRx6n3vW8c5+2Bl1MZV7TGYmDXmlIaZrkxNyhIsFy0wrr2lD00OJjT0SfdE5mphvJnIrOXHmJutdalatSmZSSlxqmKhNuNOpYlWU8VOOLISAOdzuBiQLZ5yrxJQTVs382Q0/mVjrQ9VAk6m6RJp3y9Llz4NtA9ojvuFSjfdG54N8fGekZOpyUxTajKtTMrNNLYfYdSFIdbULKSpJ3EEEgiG4ZXz07s0Zis7OuJpp1zAmJFvP5cVSYWSJVYut2hurPzkC62Ce83dHFFogGxr92Ve/Oc1+9VGGiezo0MupnLzY/wezPy5bm8RqmMROsqFiUvr+SWf8AaQ2besOl6rrflPYfar/S69Or9EIAAE6QoAHshfFJ5r9Ii76Y7Z4mJgUHaSw7IqWhlCKHiLQN6RcmWmP9Nypsn8mIizj3GWGdOY2T9Xka1gSuJlH6bO/VKWbflm5hlubDam0v9U4lSC4lClBKiLp1G1iY3v8AbSdtr8LTP7Ekf6UL9tJ22vwss/sOR/pQfbSdtr8LLP7Dkf6UH20nba/C0z+w5H+lHmMxOkB2qc1MOKwrjjMNmfkPaGZxrTSZRl1iYZWFtPNOobC21pULhSSDx8DDe5uamJ6aenZt1Tr8w4p11auKlqNyT8SY2ZszZH1raIzqw1ldSG1hqozSXKlMAHTKyLZCn3VHwsi4HvKSPGOjSj0im0CjyVBpMqJen06XalZZhG7Q22kJQE+4AAIulJbJu63MrV4qY7h+EVA6rEKKwvclSuLp8quQjB45wVhrMbB9YwNjGnIn6JW5VyQnWVjihYtoTysbEKHAgGOf7a72U8abKeZszhStMOzVAnVreoNXCfk5uXvuSojcHUAgLTz3jcRGi4IIIIIuqVSqnXKnK0ajSD89PzzyJeWlpdsrcecUbJQlI3kkkAAROd0eGxqjZiy7XiLF8sy5j/FjaFVEiyhJMDeiSB9D2lkbiqw4JEO58CrUQAdJV4pPkHu+sIpxDZ0Lm3ZdQ4tti6U/CFJKiVKUFle5Sk8HR5U8jBcghWoAgaArwSPIfe9Y8PnJkvl1nzgScy7zMoDVQpMyLtlXZekHfmvNucULHgR8DcEiIZtqro1s58gZucxFg6QmsbYJQVOonpFkqnJNrw9pYTcgAfSJuk8Tp4Qz9SSklKgQQbEHwgggjYeTOz9m7n/iFGHMq8Fz1YdCgJiZSjRKSiT8955XYQB6m58AYmN2LejtwJsyoYxri52XxVmC43unA3/ZpAEb0ygVvv4F09ojgEgm7wSSq5Kgsr3KUODo8qeRguQQrUAQNAV4JHkPvesKl1bY0Im2pdI4NuC6k/GENwVagAQO0EcEjmj1g33AATe1wD3SnmffgG/Tp337mv535T+EG4i4KiCbAnvE8j7kaHzf2Hdl/O1+YqONcraexU3jd6p0i8jNFfPU1ZLnxWlUNjxL0LOTs6+tzC+bWLKSkHUWpmXl5xKR4BJAbJjD0/oTcEoeH1Uz5rbzfe0sUZlolH+pTigFelo3Nlr0UuyZgSYZn6vQ6zjKaQQpr6uz3yBI462WQhNvRVxDscMYVwvgujMYfwfh+n0Wly/ZZlJCVQw2k8tCABp9YypsL6iQAe0U8Unkj3YDcE6gAQO0EcEjmj3oN9wAE3tcA90p5n34VIcUm7bUutPgp7vn4wOpS05MNtiyZdAW0PKo+MASkuIbIulbPXKHNfOEa+V9m6zf7Vq633rcIpSoqbQ6T2lvdQo80coVxRbRMLRuVLuBts+VJ4iKnEhtb6ECwl0BxseVR8YAlJcQ2RdK2euUOa+cI18r7N1m/wBq1db71uEUpUVNodJ7S3uoUeaOUK4otomFo3Kl3A22fKk8RFTiQ2t9CBYS6A42PKo+MASkuIbIulbPXKHNfOPtKSkvNy6JiYaC3F71KJO+P//Z', @@ -1963,7 +1964,7 @@ const AngularWorkflow = (props) => { return (
- {triggers.map(trigger => { + {triggers.map((trigger, index) => { var imageline = trigger.large_image.length === 0 ? : @@ -1972,6 +1973,7 @@ const AngularWorkflow = (props) => { const color = trigger.is_valid ? "green" : "orange" return( {handleTriggerDrag(e, trigger)}} onStop={(e) => {handleDragStop(e)}} dragging={false} @@ -4896,28 +4898,29 @@ const AngularWorkflow = (props) => { { - setTriggerOptionsWrapper("email") - }} - color="primary" - value="email" - /> - } + { + setTriggerOptionsWrapper("email") + }} + color="primary" + value="email" + /> + } label={
Email
} /> { - setTriggerOptionsWrapper("sms") - }} - color="primary" - value="sms" /> - } - label={
SMS
} + { + setTriggerOptionsWrapper("sms") + }} + color="primary" + value="sms" + /> + } + label={
SMS
} />
diff --git a/frontend/src/views/LoginPage.jsx b/frontend/src/views/LoginPage.jsx index 6ed887f3..ef746a6d 100644 --- a/frontend/src/views/LoginPage.jsx +++ b/frontend/src/views/LoginPage.jsx @@ -160,10 +160,6 @@ const LoginDialog = props => { //var loginChange = register ? (

Want to register? Click here.

) : (

Go back to login? Click here.

); var formtitle = register ?
Login
:
Register
- - // {formtitle} - - console.log("THEME: ", theme.palette.surfaceColor) const basedata =
{ - const { globalUrl, isLoggedIn, isLoaded, } = props; + const { globalUrl, isLoggedIn, isLoaded, removeCookie, cookies} = props; document.title = "Shuffle - Workflows" const alert = useAlert() @@ -83,6 +82,31 @@ const Workflows = (props) => { } }) + // DEBUG HERE + const handleClickLogout = () => { + //console.log("Cookies: ", cookies) + //console.log("SHOULD LOG OUT") + //console.log(isLoggedIn) + + // Don't really care about the logout + //fetch(globalUrl+"/api/v1/logout", { + // credentials: "include", + // method: 'POST', + // headers: { + // 'Content-Type': 'application/json', + // }, + //}) + //.then(() => { + // // Log out anyway + // removeCookie("session_token", {path: "/"}) + // //window.location = "/login" + //}) + //.catch(error => { + // console.log(error) + // removeCookie("session_token", {path: "/"}) + //}); + } + const deleteModal = deleteModalOpen ? { const getAvailableWorkflows = () => { fetch(globalUrl+"/api/v1/workflows", { - method: 'GET', + method: 'GET', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, - credentials: "include", - }) + credentials: "include", + }) .then((response) => { if (response.status !== 200) { console.log("Status not 200 for workflows :O!") @@ -149,7 +173,7 @@ const Workflows = (props) => { if (isLoggedIn) { alert.error("An error occurred while loading workflows") } else { - window.location = "/login" + handleClickLogout() } return @@ -586,6 +610,10 @@ const Workflows = (props) => { return null } + if (data.workflow.actions === null || data.workflow.actions === undefined) { + return null + } + var actions = data.workflow.actions.length if (data.results !== null) { var results = data.results.length From e06ac4cae132511aa3d9c8235ec724a1ca054814 Mon Sep 17 00:00:00 2001 From: frikky Date: Sat, 31 Oct 2020 17:34:26 +0100 Subject: [PATCH 18/18] Changed favicon --- backend/go-app/walkoff.go | 1 + frontend/public/favicon.ico | Bin 119717 -> 1150 bytes 2 files changed, 1 insertion(+) diff --git a/backend/go-app/walkoff.go b/backend/go-app/walkoff.go index c0c643d3..c6c08c2f 100644 --- a/backend/go-app/walkoff.go +++ b/backend/go-app/walkoff.go @@ -558,6 +558,7 @@ func createSchedule(ctx context.Context, scheduleId, workflowId, name, startNode CreationTime: timeNow, LastModificationtime: timeNow, LastRuntime: timeNow, + Org: orgId, } err = setSchedule(ctx, schedule) diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index 09c34ad45d4810ead458f22c79fa13b53f1675cf..9210cc67ff856e4b0bf5ee152dd60f51914ca34a 100644 GIT binary patch literal 1150 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