+
+
{trigger.name}
@@ -5323,7 +5365,7 @@ const AngularWorkflow = (defaultprops) => {
}
// AUTHENTICATION
- if (app.authentication.required) {
+ if (app.authentication !== undefined && app.authentication !== null && app.authentication.required === true) {
console.log("App auth is required!")
// Setup auth here :)
@@ -5448,6 +5490,7 @@ const AngularWorkflow = (defaultprops) => {
var description = ""
if (
+ app.actions[0].parameters !== undefined &&
app.actions[0].parameters !== null &&
app.actions[0].parameters.length > 0
) {
@@ -5455,6 +5498,8 @@ const AngularWorkflow = (defaultprops) => {
}
if (
+ app.actions[0].returns !== undefined &&
+ app.actions[0].returns !== null &&
app.actions[0].returns.example !== undefined &&
app.actions[0].returns.example !== null &&
app.actions[0].returns.example.length > 0
@@ -5510,6 +5555,7 @@ const AngularWorkflow = (defaultprops) => {
: "",
authentication_id: "",
finished: false,
+ template: app.template === true ? true : false,
};
// FIXME: overwrite category if the ACTION chosen has a different category
@@ -5534,10 +5580,12 @@ const AngularWorkflow = (defaultprops) => {
};
const AppView = (props) => {
- const { allApps, prioritizedApps, filteredApps } = props;
+ const { allApps, prioritizedApps, filteredApps, extraApps } = props;
+ //extraApps,
const [visibleApps, setVisibleApps] = React.useState(
- prioritizedApps.concat(
- filteredApps.filter((innerapp) => !internalIds.includes(innerapp.id))
+ Array.prototype.concat.apply(
+ prioritizedApps,
+ filteredApps.filter((innerapp) => !internalIds.includes(innerapp.id)),
)
);
@@ -5827,7 +5875,7 @@ const AngularWorkflow = (defaultprops) => {
runSearch(event.target.value);
}}
/>
- {visibleApps.length > 0 ? (
+ {visibleApps.length > extraApps.length ? (
{visibleApps.map((app, index) => {
if (app.invalid) {
@@ -7293,7 +7341,7 @@ const AngularWorkflow = (defaultprops) => {
}
})
.catch((error) => {
- console.log(error.toString());
+ console.log("Get gmail folder error: ", error.toString());
});
};
@@ -7347,7 +7395,7 @@ const AngularWorkflow = (defaultprops) => {
}
})
.catch((error) => {
- console.log(error.toString());
+ console.log("Get outlook folders error: ", error.toString());
});
};
@@ -7368,7 +7416,8 @@ const AngularWorkflow = (defaultprops) => {
setTriggerAuthentication(responseJson);
})
.catch((error) => {
- console.log(error.toString());
+ //console.log(error.toString());
+ console.log("Set outlook auth error: ", error.toString());
});
};
@@ -7458,7 +7507,7 @@ const AngularWorkflow = (defaultprops) => {
setGmailFolders();
})
.catch((error) => {
- console.log(error.toString());
+ console.log("Set gmail trigg error: ", error.toString());
});
}, 2500);
@@ -7556,7 +7605,7 @@ const AngularWorkflow = (defaultprops) => {
setOutlookFolders();
})
.catch((error) => {
- console.log(error.toString());
+ console.log("Set outlook trigger error: ", error.toString());
});
}, 2500);
@@ -9476,7 +9525,8 @@ const AngularWorkflow = (defaultprops) => {
}
})
.catch((error) => {
- alert.error(error.toString());
+ //alert.error(error.toString());
+ console.log("Stop mailsub error: ", error.toString());
});
};
@@ -9552,7 +9602,8 @@ const AngularWorkflow = (defaultprops) => {
}
})
.catch((error) => {
- alert.error(error.toString());
+ //alert.error(error.toString());
+ console.log("Start mailsub error: ", error.toString());
});
};
@@ -9636,7 +9687,8 @@ const AngularWorkflow = (defaultprops) => {
}
})
.catch((error) => {
- console.log(error.toString());
+ //console.log(error.toString());
+ console.log("New webhook error: ", error.toString());
});
};
@@ -9679,7 +9731,8 @@ const AngularWorkflow = (defaultprops) => {
setSelectedTrigger(trigger);
})
.catch((error) => {
- alert.error(error.toString());
+ //alert.error(error.toString());
+ alert.error("Delete webhook error: ", error.toString());
});
};
diff --git a/frontend/src/views/AppCreator.jsx b/frontend/src/views/AppCreator.jsx
index a8a85007..285ffc53 100644
--- a/frontend/src/views/AppCreator.jsx
+++ b/frontend/src/views/AppCreator.jsx
@@ -289,8 +289,8 @@ const AppCreator = (defaultprops) => {
type: "header",
example: "",
};
- const [extraAuth, setExtraAuth] = useState([]);
+ const [extraAuth, setExtraAuth] = useState([]);
const [app, setApp] = useState({});
const [appAuthentication, setAppAuthentication] = React.useState([]);
const [selectedAction, setSelectedAction] = useState({});
@@ -1420,6 +1420,8 @@ const AppCreator = (defaultprops) => {
//console.log("SECURITYSCHEMES: ", securitySchemes)
if (securitySchemes !== undefined) {
+ console.log("NEWAUTH: ", securitySchemes)
+ var valueset = false
// FIXME: Should add Oauth2 (Microsoft) and JWT (Wazuh)
//console.log("SECURITY: ", securitySchemes)
//if (Object.entries(securitySchemes) > 1 &&
@@ -1444,18 +1446,31 @@ const AppCreator = (defaultprops) => {
setAuthenticationRequired(true);
} else if (key === "ApiKeyAuth" || key === "Token" || ((value.in === "header" || value.in === "query") && value.name !== undefined)) {
- setAuthenticationOption("API key");
+ if (authenticationOption === "" || authenticationOption === "No authentication") {
+ setAuthenticationOption("API key");
+ }
- value.in = value.in.charAt(0).toUpperCase() + value.in.slice(1);
- setParameterLocation(value.in);
- if (!apikeySelection.includes(value.in)) {
- console.log("APIKEY SELECT: ", apikeySelection);
- alert.error("Might be error in setting up API key authentication");
- }
+ if (valueset === false) {
+ valueset = true
+ value.in = value.in.charAt(0).toUpperCase() + value.in.slice(1)
- console.log("PARAM NAME: ", value.name);
- setParameterName(value.name);
- setAuthenticationRequired(true);
+ setParameterLocation(value.in);
+ if (!apikeySelection.includes(value.in)) {
+ console.log("APIKEY SELECT: ", apikeySelection);
+ alert.error("Might be error in setting up API key authentication");
+ }
+
+ console.log("PARAM NAME: ", value.name);
+ setParameterName(value.name);
+ setAuthenticationRequired(true);
+ } else {
+ newauth.push({
+ "name": key,
+ "type": value.in.toLowerCase(),
+ "in": value.in.toLowerCase(),
+ "example": "",
+ })
+ }
if (value.description !== undefined && value.description !== null && value.description.length > 0) {
// Don't want a real description - just the ones we're replacing with
@@ -2365,6 +2380,7 @@ const AppCreator = (defaultprops) => {
)}
+
{extraAuth.map((value, index) => {
return (
{
+ const [hovered, setHovered] = useState("");
+
+ //console.log("Date: ", new Date("2019-11-14T08:00:00.000Z"))
+ console.log("Keys: ", keys)
+ var inputdata = keys.data
+
+ /*
+ const inputdata = [{
+ "key": "Intel",
+ "data": [
+ { key: new Date('11/22/2019'), data: 3, metadata: {color: "orange", "name": "Intel"}},
+ { key: new Date('11/24/2019'), data: 8, metadata: {color: "orange", "name": "Intel"}},
+ { key: new Date('11/29/2019'), data: 2, metadata: {color: "orange", "name": "Intel"}},
+ ]},
+ {
+ "key": "Popper",
+ "data": [
+ { key: new Date('11/24/2019'), data: 9, },
+ { key: new Date('11/29/2019'), data: 3, },
+ ]
+ }
+ ]
+ */
+
+ return (
+
+
+ {keys.title}
+
+
+ }
+ colorScheme={(colorInput) => {
+ var color = "cybertron"
+ if (colorInput !== undefined && colorInput.length > 0) {
+ color = colorInput[0].metadata !== undefined && colorInput[0].metadata.color !== undefined ? colorInput[0].metadata.color : color
+ }
+
+ return color
+ }}
+ tooltip={
+
{
+ if (hovered !== event.value.x) {
+ //setHovered(event.value.x)
+ }
+ }}
+ tooltip={
+ {
+ console.log("DATA: ", data)
+ const name = data.metadata !== undefined && data.metadata.name !== undefined ? data.metadata.name : "No"
+
+ return (
+
+
+ {name}
+
+
+ )
+ /*
+
+ )
+ */
+ }
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+ )
+}
+
+const RadialChart = ({keys, setSelectedCategory}) => {
+ const [hovered, setHovered] = useState("");
+
+ return (
+ {
+ console.log("Click: ", hovered)
+ if (setSelectedCategory !== undefined) {
+ setSelectedCategory(hovered)
+ }
+ }}>
+
}
+ series={
+
{
+ return '#f86a3e'
+ }}
+ animated={false}
+ id="workflow_series_id"
+ style={{cursor: "pointer",}}
+ line={
+ {
+ console.log("INFO: ", data, color)
+ return (
+ null
+ )
+ }}
+ />
+ }
+ tooltip={
+ {
+ if (hovered !== event.value.x) {
+ setHovered(event.value.x)
+ }
+ }}
+ tooltip={
+ {
+ return (
+
+
+ {data.x}
+
+
+ )
+ /*
+
+ )
+ */
+ }
+ }
+ />
+ }
+ />
+
+ }
+ />
+ }
+ />
+
+ )
+ //axis={}
+}
+
+// This is the start of a dashboard that can be used.
+// What data do we fill in here? Idk
+const Dashboard = (props) => {
+ const { globalUrl, isLoggedIn } = props;
+ const alert = useAlert();
+ const [bigChartData, setBgChartData] = useState("data1");
+ const [dayAmount, setDayAmount] = useState(7);
+ const [firstRequest, setFirstRequest] = useState(true);
+ const [stats, setStats] = useState({});
+ const [changeme, setChangeme] = useState("");
+ const [statsRan, setStatsRan] = useState(false);
+ const [keys, setKeys] = useState([])
+ const [treeKeys, setTreeKeys] = useState([])
+
+ const [selectedUsecaseCategory, setSelectedUsecaseCategory] = useState("");
+ const [selectedUsecases, setSelectedUsecases] = useState([]);
+ const [usecases, setUsecases] = useState([]);
+ const [workflows, setWorkflows] = useState([]);
+ const [frameworkData, setFrameworkData] = useState(undefined);
+
+ const [widgetData, setWidgetData] = useState(undefined);
+
+ let navigate = useNavigate();
+ const isCloud =
+ window.location.host === "localhost:3002" ||
+ window.location.host === "shuffler.io";
+
+
+ useEffect(() => {
+ if (selectedUsecaseCategory.length === 0) {
+ setSelectedUsecases(usecases)
+ } else {
+ const foundUsecase = usecases.find(data => data.name === selectedUsecaseCategory)
+ if (foundUsecase !== undefined && foundUsecase !== null) {
+ setSelectedUsecases([foundUsecase])
+ }
+ }
+ }, [selectedUsecaseCategory])
+
+ const checkSelectedParams = () => {
+ const urlSearchParams = new URLSearchParams(window.location.search)
+ const params = Object.fromEntries(urlSearchParams.entries())
+
+ const curpath = typeof window === "undefined" || window.location === undefined ? "" : window.location.pathname;
+ const cursearch = typeof window === "undefined" || window.location === undefined ? "" : window.location.search;
+
+ const foundQuery = params["selected"]
+ if (foundQuery !== null && foundQuery !== undefined) {
+ setSelectedUsecaseCategory(foundQuery)
+
+ const newitem = removeParam("selected", cursearch);
+ navigate(curpath + newitem)
+ }
+
+ const foundQuery2 = params["selected_object"]
+ if (foundQuery2 !== null && foundQuery2 !== undefined) {
+ console.log("Got selected_object: ", foundQuery2)
+
+ const queryName = foundQuery2.toLowerCase().replaceAll("_", " ")
+ // Waiting a bit for it to render
+ setTimeout(() => {
+ const foundItem = document.getElementById(queryName)
+ if (foundItem !== undefined && foundItem !== null) {
+ foundItem.click()
+ } else {
+ //console.log("Couldn't find item with name ", queryName)
+ }
+ }, 100);
+ }
+
+ }
+
+ useEffect(() => {
+ if (usecases.length > 0) {
+ console.log(usecases)
+ checkSelectedParams()
+ }
+ }, [usecases])
+
+ const getWidget = (dashboard, widget) => {
+ fetch(`${globalUrl}/api/v1/dashboards/${dashboard}/widgets/${widget}`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ credentials: "include",
+ })
+ .then((response) => {
+ if (response.status !== 200) {
+ console.log("Status not 200 for framework!");
+ }
+
+ return response.json();
+ })
+ .then((responseJson) => {
+ console.log("Resp: ", responseJson)
+ if (responseJson.success === false) {
+ if (responseJson.reason !== undefined) {
+ //alert.error("Failed loading: " + responseJson.reason)
+ } else {
+ //alert.error("Failed to load framework for your org.")
+ }
+ } else {
+ var tmpdata = responseJson
+ for (var key in tmpdata.data) {
+ for (var subkey in tmpdata.data[key].data) {
+ tmpdata.data[key].data[subkey].key = new Date(tmpdata.data[key].data[subkey].key)
+ }
+ }
+
+ console.log("VAl: ", tmpdata)
+
+ setWidgetData(tmpdata)
+ }
+ })
+ .catch((error) => {
+ //alert.error(error.toString());
+ })
+ }
+
+ document.title = "Shuffle - Dashboard";
+ var dayGraphLabels = [60, 80, 65, 130, 80, 105, 90, 130, 70, 115, 60, 130];
+ var dayGraphData = [60, 80, 65, 130, 80, 105, 90, 130, 70, 115, 60, 130];
+
+ const handleKeysetting = (categorydata) => {
+ var allCategories = []
+ var treeCategories = []
+ for (key in categorydata) {
+ const category = categorydata[key]
+ allCategories.push({"key": category.name, "data": category.list.length, "color": category.color})
+ treeCategories.push({"key": category.name, "data": 100, "color": category.color,})
+ for (var subkey in category.list) {
+ treeCategories.push({"key": category.list[subkey].name, "data": 20, "color": category.color})
+ }
+ }
+
+ setKeys(allCategories)
+ setTreeKeys(treeCategories)
+ }
+
+ const fetchUsecases = (workflows) => {
+ fetch(globalUrl + "/api/v1/workflows/usecases", {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ credentials: "include",
+ })
+ .then((response) => {
+ if (response.status !== 200) {
+ console.log("Status not 200 for usecases");
+ }
+
+ return response.json();
+ })
+ .then((responseJson) => {
+ // Matching workflows with usecases
+ if (responseJson.success !== false) {
+ if (workflows !== undefined && workflows !== null && workflows.length > 0) {
+ var categorydata = responseJson
+
+ var newcategories = []
+ for (var key in categorydata) {
+ var category = categorydata[key]
+ category.matches = []
+
+ for (var subcategorykey in category.list) {
+ var subcategory = category.list[subcategorykey]
+ subcategory.matches = []
+
+ for (var workflowkey in workflows) {
+ const workflow = workflows[workflowkey]
+
+ if (workflow.usecase_ids !== undefined && workflow.usecase_ids !== null) {
+ for (var usecasekey in workflow.usecase_ids) {
+ if (workflow.usecase_ids[usecasekey].toLowerCase() === subcategory.name.toLowerCase()) {
+
+ category.matches.push({
+ "workflow": workflow.id,
+ "category": subcategory.name,
+ })
+
+ subcategory.matches.push(workflow)
+ break
+ }
+ }
+ }
+
+ if (subcategory.matches.length > 0) {
+ break
+ }
+ }
+ }
+
+ newcategories.push(category)
+ }
+
+ if (newcategories !== undefined && newcategories !== null && newcategories.length > 0) {
+ handleKeysetting(newcategories)
+ setUsecases(newcategories)
+ setSelectedUsecases(newcategories)
+ } else {
+ handleKeysetting(responseJson)
+ setUsecases(responseJson)
+ setSelectedUsecases(responseJson)
+ }
+ } else {
+ handleKeysetting(responseJson)
+ setUsecases(responseJson)
+ setSelectedUsecases(responseJson)
+ }
+ }
+ })
+ .catch((error) => {
+ //alert.error("ERROR: " + error.toString());
+ console.log("ERROR: " + error.toString());
+ });
+ };
+
+ useEffect(() => {
+ getWidget("main", "tmp")
+ //fetchUsecases()
+ }, []);
+
+ const fetchdata = (stats_id) => {
+ fetch(globalUrl + "/api/v1/stats/" + stats_id, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ credentials: "include",
+ })
+ .then((response) => {
+ if (response.status !== 200) {
+ console.log("Status not 200 for " + stats_id);
+ }
+
+ return response.json();
+ })
+ .then((responseJson) => {
+ stats[stats_id] = responseJson;
+ setStats(stats);
+ // Used to force updates
+ setChangeme(stats_id);
+ })
+ .catch((error) => {
+ //alert.error("ERROR: " + error.toString());
+ console.log("ERROR: " + error.toString());
+ });
+ };
+
+ let chart1_2_options = {
+ maintainAspectRatio: false,
+ legend: {
+ display: false,
+ },
+ tooltips: {
+ backgroundColor: "#f5f5f5",
+ titleFontColor: "#333",
+ bodyFontColor: "#666",
+ bodySpacing: 4,
+ xPadding: 12,
+ mode: "nearest",
+ intersect: 0,
+ position: "nearest",
+ },
+ responsive: true,
+ scales: {
+ yAxes: [
+ {
+ barPercentage: 1.6,
+ gridLines: {
+ drawBorder: false,
+ color: "rgba(29,140,248,0.0)",
+ zeroLineColor: "transparent",
+ },
+ ticks: {
+ suggestedMin: 60,
+ suggestedMax: 125,
+ padding: 20,
+ fontColor: "#9a9a9a",
+ },
+ },
+ ],
+ xAxes: [
+ {
+ barPercentage: 1.6,
+ gridLines: {
+ drawBorder: false,
+ color: "rgba(29,140,248,0.1)",
+ zeroLineColor: "transparent",
+ },
+ ticks: {
+ padding: 20,
+ fontColor: "#9a9a9a",
+ },
+ },
+ ],
+ },
+ };
+
+ const dayGraph = {
+ data: (canvas) => {
+ let ctx = canvas.getContext("2d");
+
+ let gradientStroke = ctx.createLinearGradient(0, 230, 0, 50);
+
+ gradientStroke.addColorStop(1, "rgba(29,140,248,0.2)");
+ gradientStroke.addColorStop(0.4, "rgba(29,140,248,0.0)");
+ gradientStroke.addColorStop(0, "rgba(29,140,248,0)"); //blue colors
+
+ return {
+ labels: dayGraphLabels,
+ datasets: [
+ {
+ label: "My First dataset",
+ fill: true,
+ backgroundColor: gradientStroke,
+ borderColor: "#1f8ef1",
+ borderWidth: 2,
+ borderDash: [],
+ borderDashOffset: 0.0,
+ pointBackgroundColor: "#1f8ef1",
+ pointBorderColor: "rgba(255,255,255,0)",
+ pointHoverBackgroundColor: "#1f8ef1",
+ pointBorderWidth: 20,
+ pointHoverRadius: 4,
+ pointHoverBorderWidth: 15,
+ pointRadius: 4,
+ data: dayGraphData,
+ },
+ ],
+ };
+ },
+ options: chart1_2_options,
+ };
+
+ // All these are currently tracked.
+ const variables = [
+ "backend_executions",
+ "workflow_executions",
+ "workflow_executions_aborted",
+ "workflow_executions_success",
+ "total_apps_created",
+ "total_apps_loaded",
+ "openapi_apps_created",
+ "total_apps_deleted",
+ "total_webhooks_ran",
+ "total_workflows",
+ "total_workflow_actions",
+ "total_workflow_triggers",
+ ];
+
+ const runUpdate = () => {
+ for (var key in variables) {
+ fetchdata(variables[key]);
+ }
+ };
+
+ // Refresh every 60 seconds
+ const autoUpdate = 60000;
+ const { start, stop } = useInterval({
+ duration: autoUpdate,
+ startImmediate: false,
+ callback: () => {
+ runUpdate();
+ },
+ });
+
+ if (firstRequest) {
+ setFirstRequest(false);
+ //start();
+ //runUpdate();
+ } else if (!statsRan) {
+ // FIXME: Run this under runUpdate schedule?
+ // 1. Fix labels in dayGraphy.data
+ // 2. Add data to the daygraph
+
+ // Every time there's an update :)
+
+ // This should probably be done in the backend.. bleh
+ if (
+ stats["workflow_executions"] !== undefined &&
+ stats["workflow_executions"] !== null &&
+ stats["workflow_executions"].data !== undefined
+ ) {
+ setStatsRan(true);
+ //console.log("NEW DATA?: ", stats)
+ console.log("SET WORKFLOW: ", stats["workflow_executions"]);
+ //var curday = startDate.getDate()
+
+ // Index = what day are we on
+
+ // 0 = today
+ var newDayGraphLabels = [];
+ var newDayGraphData = [];
+ for (var i = dayAmount; i > 0; i--) {
+ var enddate = new Date();
+ enddate.setDate(-i);
+ enddate.setHours(23, 59, 59, 999);
+
+ var startdate = new Date();
+ startdate.setDate(-i);
+ startdate.setHours(0, 0, 0, 0);
+
+ var endtime = enddate.getTime() / 1000;
+ var starttime = startdate.getTime() / 1000;
+
+ console.log(
+ "START: ",
+ starttime,
+ "END: ",
+ endtime,
+ "Data: ",
+ stats["workflow_executions"]
+ );
+ for (var key in stats["workflow_executions"].data) {
+ const item = stats["workflow_executions"]["data"][key];
+ console.log("ITEM: ", item.timestamp, endtime);
+ console.log(endtime - starttime);
+ if (
+ endtime - starttime > endtime - item.timestamp &&
+ endtime.timestamp >= 0
+ ) {
+ console.log("HIT? ");
+ }
+ console.log(item.timestamp - endtime);
+ //console.log(item.timestamp-endtime)
+ break;
+ if (item.timestamp > endtime && item.timestamp < starttime) {
+ if (newDayGraphData[i - 1] === undefined) {
+ newDayGraphData[i - 1] = 1;
+ } else {
+ newDayGraphData[i - 1] += 1;
+ }
+
+ //break
+ }
+ }
+
+ newDayGraphLabels.push(i);
+ }
+
+ console.log(newDayGraphLabels);
+ console.log(newDayGraphData);
+ }
+ }
+
+ const newdata =
+ Object.getOwnPropertyNames(stats).length > 0 ? (
+
+ Autoupdate every {autoUpdate / 1000} seconds
+ {variables.map((data) => {
+ if (stats[data] === undefined || stats[data] === null) {
+ return null;
+ }
+
+ if (stats[data].total === undefined) {
+ return null;
+ }
+
+ return (
+
+ {data}: {stats[data].total}
+
+ );
+ })}
+
+ ) : null;
+
+ const data = (
+
+
+ {keys.length > 0 ?
+
+
+
+ : null}
+
+
+ {widgetData === undefined ? null :
+
+
+
+
+
+ }
+
+ );
+
+ const dataWrapper = (
+ {data}
+ );
+
+ return dataWrapper;
+};
+
+export default Dashboard;
diff --git a/frontend/src/views/SettingsPage.jsx b/frontend/src/views/SettingsPage.jsx
index a8e0e0e9..1bcd2344 100644
--- a/frontend/src/views/SettingsPage.jsx
+++ b/frontend/src/views/SettingsPage.jsx
@@ -733,7 +733,7 @@ const Settings = (props) => {
{isCloud ?
- By connecting your Github account, you agree to our Terms of Service, and acknowledge that your non-sensitive data will be turned into a creator account. This enables you to earn a passive income from Shuffle. This IS reversible.
+ By connecting your Github or Metamask account, you agree to our Terms of Service, and acknowledge that your non-sensitive data will be turned into a creator account. This enables you to earn a passive income from Shuffle. This IS reversible. Support: support@shuffler.io