feature (workflow): first release of the workflow engine

This commit is contained in:
MickaelK 2025-10-02 13:12:23 +10:00
parent 882b036615
commit e8e83250f7
32 changed files with 1497 additions and 312 deletions

View file

@ -5,12 +5,15 @@ import (
"sync"
"github.com/gorilla/mux"
_ "github.com/mattn/go-sqlite3"
"github.com/mickael-kerjean/filestash"
"github.com/mickael-kerjean/filestash/server"
. "github.com/mickael-kerjean/filestash/server/common"
"github.com/mickael-kerjean/filestash/server/ctrl"
"github.com/mickael-kerjean/filestash/server/model"
"github.com/mickael-kerjean/filestash/server/workflow"
. "github.com/mickael-kerjean/filestash/server/common"
_ "github.com/mickael-kerjean/filestash/server/plugin"
)
@ -22,6 +25,7 @@ func Run(router *mux.Router, app App) {
Log.Info("Filestash %s starting", APP_VERSION)
check(InitLogger(), "Logger init failed. err=%s")
check(InitConfig(), "Config init failed. err=%s")
check(workflow.Init(), "Worklow Initialisation failure. err=%s")
check(model.PluginDiscovery(), "Plugin Discovery failed. err=%s")
check(ctrl.InitPluginList(embed.EmbedPluginList, model.PLUGINS), "Plugin Initialisation failed. err=%s")
if len(Hooks.Get.Starter()) == 0 {

1
go.mod
View file

@ -72,6 +72,7 @@ require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59 // indirect
github.com/beevik/etree v1.4.0 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/calebcase/tmpfile v1.0.3 // indirect
github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect

2
go.sum
View file

@ -68,6 +68,8 @@ github.com/beevik/etree v1.4.0 h1:oz1UedHRepuY3p4N5OjE0nK1WLCqtzHf25bxplKOHLs=
github.com/beevik/etree v1.4.0/go.mod h1:cyWiXwGoasx60gHvtnEh5x8+uIjUVnjWqBvEnhnqKDA=
github.com/bluekeyes/go-gitdiff v0.7.3 h1:SElKwtm/IQPOwKs0vdowW5uAlip+P+jatagmUU8E0r4=
github.com/bluekeyes/go-gitdiff v0.7.3/go.mod h1:QpfYYO1E0fTVHVZAZKiRjtSGY9823iCdvGXBcEzHGbM=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/calebcase/tmpfile v1.0.3 h1:BZrOWZ79gJqQ3XbAQlihYZf/YCV0H4KPIdM5K5oMpJo=

View file

@ -6,6 +6,16 @@
padding: 3px 5px;
margin: 0 2px;
}
.component_page_workflow h2 input {
padding: 0 0 0 20px;
border: none;
font-family: inherit;
color: inherit;
font-size: inherit;
width: 100%;
position: relative;
bottom: 2px;
}
.component_page_workflow .box {
display: block;
background: white;
@ -25,7 +35,7 @@
}
.component_page_workflow .box h3 span {
font-weight: 300;
font-size: 0.95rem;
font-size: 0.8rem;
font-style: italic;
color: var(--light);
}
@ -33,6 +43,7 @@
float: left;
width: 25px;
fill: var(--light);
opacity: 0.8;
}
.component_page_workflow .box .workflow-summary button {
color: var(--light);
@ -56,10 +67,10 @@
overflow: hidden;
}
.component_page_workflow .box [data-bind="form"] .formbuilder {
padding: 10px;
padding: 10px 10px 0px 10px;
border-radius: 5px;
margin-top: 10px;
border: 2px solid #ebebec;
border-top: 2px solid #ebebec;
background: transparent;
}
.component_page_workflow .box.disabled [data-bind="form"] .formbuilder {
@ -90,6 +101,13 @@
border-radius: 15px;
}
/* list page */
.component_page_workflow h3.empty {
padding: 30px;
border: 2px dashed var(--light);
color: var(--light);
}
/* Workflow creation modal */
.component_workflow_create {
font-size: 1rem;
@ -158,11 +176,14 @@
border-bottom-left-radius: 10px;
font-weight: 600;
}
.workflow-fab svg {
.workflow-fab button > * {
height: 30px;
fill: var(--bg-color);
padding: 7px;
}
.workflow-fab button > component-icon img {
filter: brightness(0) invert(1);
}
/* ADD BUTTON */
.component_page_workflow [data-bind="add"] {
@ -183,8 +204,10 @@
color: var(--bg-color);
font-family: monospace;
text-transform: uppercase;
padding-left: 0;
padding-right: 0;
background:var(--border);
font-size: 0.85rem;
letter-spacing: -0.5px;
margin-right: 5px;
}
.component_page_workflow [data-bind="add"] .box svg {
margin-right: 0;
@ -199,9 +222,7 @@
backdrop-filter: blur(2px);
padding: 3px 8px;
}
.component_page_workflow [data-bind="add"] .box .sub {
padding: 0 5px;
}
.component_page_workflow [data-bind="add"] .box > .flex {
padding: 0 5px 0 0;
margin-left: -5px;
}

View file

@ -2,251 +2,23 @@ import { createElement } from "../../lib/skeleton/index.js";
import { loadCSS } from "../../helpers/loader.js";
import AdminHOC from "./decorator.js";
import { workflowAll } from "./model_workflow.js";
import ctrlList from "./ctrl_workflow_list.js";
import ctrlDetails from "./ctrl_workflow_details.js";
const workflows = [
{
id: "dummy",
name: "My First Workflow",
published: false,
trigger: { name: "user" },
actions: [
{
name: "tools/debug",
},
{
name: "notify/email",
}
],
},
{
id: "uuid0",
name: "Detection de fichier d'inbox",
published: true,
lastEdited: "2 days ago",
trigger: { name: "watch" },
actions: [
{
name: "run/program",
},
{
name: "notify/email",
},
],
},
{
id: "uuid1",
name: "Notify team when file is moved",
published: true,
lastEdited: "2 days ago",
history: [],
trigger: { name: "operation" },
actions: [
{
name: "notify/email",
},
]
},
{
id: "uuid2",
name: "Any change to the contract folder",
published: true,
history: [],
trigger: { name: "operation", values: {} },
actions: [
{
name: "notify/email",
// values: {} // TODO
},
]
},
];
const triggers = [
{
name: "schedule",
title: "On a Schedule",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M528 320C528 434.9 434.9 528 320 528C205.1 528 112 434.9 112 320C112 205.1 205.1 112 320 112C434.9 112 528 205.1 528 320zM64 320C64 461.4 178.6 576 320 576C461.4 576 576 461.4 576 320C576 178.6 461.4 64 320 64C178.6 64 64 178.6 64 320zM296 184L296 320C296 328 300 335.5 306.7 340L402.7 404C413.7 411.4 428.6 408.4 436 397.3C443.4 386.2 440.4 371.4 429.3 364L344 307.2L344 184C344 170.7 333.3 160 320 160C306.7 160 296 170.7 296 184z"></path></svg>`,
specs: {
"start": {
name: "test",
type: "datetime",
},
"frequency": {
type: "select",
options: ["hourly", "weekly", "monthly", "yearly"],
},
},
},
{
name: "user",
title: "When a User Creates a Request",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M240 192C240 147.8 275.8 112 320 112C364.2 112 400 147.8 400 192C400 236.2 364.2 272 320 272C275.8 272 240 236.2 240 192zM448 192C448 121.3 390.7 64 320 64C249.3 64 192 121.3 192 192C192 262.7 249.3 320 320 320C390.7 320 448 262.7 448 192zM144 544C144 473.3 201.3 416 272 416L368 416C438.7 416 496 473.3 496 544L496 552C496 565.3 506.7 576 520 576C533.3 576 544 565.3 544 552L544 544C544 446.8 465.2 368 368 368L272 368C174.8 368 96 446.8 96 544L96 552C96 565.3 106.7 576 120 576C133.3 576 144 565.3 144 552L144 544z"/></svg>`,
specs: {
name: { type: "text" },
form: { type: "text", placeholder: "Optional form user should submit" },
visibility: { type: "text" },
},
},
{
name: "operation",
title: "When a File Action Happens",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M192 64C156.7 64 128 92.7 128 128L128 368L310.1 368L279.1 337C269.7 327.6 269.7 312.4 279.1 303.1C288.5 293.8 303.7 293.7 313 303.1L385 375.1C394.4 384.5 394.4 399.7 385 409L313 481C303.6 490.4 288.4 490.4 279.1 481C269.8 471.6 269.7 456.4 279.1 447.1L310.1 416.1L128 416.1L128 512.1C128 547.4 156.7 576.1 192 576.1L448 576.1C483.3 576.1 512 547.4 512 512.1L512 234.6C512 217.6 505.3 201.3 493.3 189.3L386.7 82.7C374.7 70.7 358.5 64 341.5 64L192 64zM453.5 240L360 240C346.7 240 336 229.3 336 216L336 122.5L453.5 240z"/></svg>`,
specs: {
event: {
type: "text",
datalist: ["ls", "cat", "mkdir", "mv", "rm", "touch"],
multi: true,
},
path: {
type: "text",
},
},
},
{
name: "watch",
title: "When the Filesystem Changes",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M128 64C92.7 64 64 92.7 64 128L64 512C64 547.3 92.7 576 128 576L308 576C285.3 544.5 272 505.8 272 464C272 363.4 349.4 280.8 448 272.7L448 234.6C448 217.6 441.3 201.3 429.3 189.3L322.7 82.7C310.7 70.7 294.5 64 277.5 64L128 64zM389.5 240L296 240C282.7 240 272 229.3 272 216L272 122.5L389.5 240zM464 608C543.5 608 608 543.5 608 464C608 384.5 543.5 320 464 320C384.5 320 320 384.5 320 464C320 543.5 384.5 608 464 608zM480 400L480 448L528 448C536.8 448 544 455.2 544 464C544 472.8 536.8 480 528 480L480 480L480 528C480 536.8 472.8 544 464 544C455.2 544 448 536.8 448 528L448 480L400 480C391.2 480 384 472.8 384 464C384 455.2 391.2 448 400 448L448 448L448 400C448 391.2 455.2 384 464 384C472.8 384 480 391.2 480 400z"/></svg>`,
specs: {
token: {
type: "text",
},
path: {
type: "text",
},
},
},
{
name: "webhook",
title: "From a webhook",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M392.8 65.2C375.8 60.3 358.1 70.2 353.2 87.2L225.2 535.2C220.3 552.2 230.2 569.9 247.2 574.8C264.2 579.7 281.9 569.8 286.8 552.8L414.8 104.8C419.7 87.8 409.8 70.1 392.8 65.2zM457.4 201.3C444.9 213.8 444.9 234.1 457.4 246.6L530.8 320L457.4 393.4C444.9 405.9 444.9 426.2 457.4 438.7C469.9 451.2 490.2 451.2 502.7 438.7L598.7 342.7C611.2 330.2 611.2 309.9 598.7 297.4L502.7 201.4C490.2 188.9 469.9 188.9 457.4 201.4zM182.7 201.3C170.2 188.8 149.9 188.8 137.4 201.3L41.4 297.3C28.9 309.8 28.9 330.1 41.4 342.6L137.4 438.6C149.9 451.1 170.2 451.1 182.7 438.6C195.2 426.1 195.2 405.8 182.7 393.3L109.3 320L182.6 246.6C195.1 234.1 195.1 213.8 182.6 201.3z"/></svg>`,
specs: {
url: {
type: "text",
readonly: true,
value: "http://example.com/workflow/webhook?id=generatedID",
}
},
},
];
const actions = [
{
name: "run/program",
title: "Execute Program",
subtitle: "name",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M96 160C96 124.7 124.7 96 160 96L480 96C515.3 96 544 124.7 544 160L544 480C544 515.3 515.3 544 480 544L160 544C124.7 544 96 515.3 96 480L96 160zM240 164C215.7 164 196 183.7 196 208L196 256C196 280.3 215.7 300 240 300L272 300C296.3 300 316 280.3 316 256L316 208C316 183.7 296.3 164 272 164L240 164zM236 208C236 205.8 237.8 204 240 204L272 204C274.2 204 276 205.8 276 208L276 256C276 258.2 274.2 260 272 260L240 260C237.8 260 236 258.2 236 256L236 208zM376 164C365 164 356 173 356 184C356 193.7 362.9 201.7 372 203.6L372 280C372 291 381 300 392 300C403 300 412 291 412 280L412 184C412 173 403 164 392 164L376 164zM228 360C228 369.7 234.9 377.7 244 379.6L244 456C244 467 253 476 264 476C275 476 284 467 284 456L284 360C284 349 275 340 264 340L248 340C237 340 228 349 228 360zM324 384L324 432C324 456.3 343.7 476 368 476L400 476C424.3 476 444 456.3 444 432L444 384C444 359.7 424.3 340 400 340L368 340C343.7 340 324 359.7 324 384zM368 380L400 380C402.2 380 404 381.8 404 384L404 432C404 434.2 402.2 436 400 436L368 436C365.8 436 364 434.2 364 432L364 384C364 381.8 365.8 380 368 380z"></path></svg>`,
specs: {
name: {
type: "select",
options: ["ocr", "duplicate", "expiry", "sign"],
}
},
},
{
name: "run/api",
title: "Make API Call",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M96 160C96 124.7 124.7 96 160 96L480 96C515.3 96 544 124.7 544 160L544 480C544 515.3 515.3 544 480 544L160 544C124.7 544 96 515.3 96 480L96 160zM240 164C215.7 164 196 183.7 196 208L196 256C196 280.3 215.7 300 240 300L272 300C296.3 300 316 280.3 316 256L316 208C316 183.7 296.3 164 272 164L240 164zM236 208C236 205.8 237.8 204 240 204L272 204C274.2 204 276 205.8 276 208L276 256C276 258.2 274.2 260 272 260L240 260C237.8 260 236 258.2 236 256L236 208zM376 164C365 164 356 173 356 184C356 193.7 362.9 201.7 372 203.6L372 280C372 291 381 300 392 300C403 300 412 291 412 280L412 184C412 173 403 164 392 164L376 164zM228 360C228 369.7 234.9 377.7 244 379.6L244 456C244 467 253 476 264 476C275 476 284 467 284 456L284 360C284 349 275 340 264 340L248 340C237 340 228 349 228 360zM324 384L324 432C324 456.3 343.7 476 368 476L400 476C424.3 476 444 456.3 444 432L444 384C444 359.7 424.3 340 400 340L368 340C343.7 340 324 359.7 324 384zM368 380L400 380C402.2 380 404 381.8 404 384L404 432C404 434.2 402.2 436 400 436L368 436C365.8 436 364 434.2 364 432L364 384C364 381.8 365.8 380 368 380z"></path></svg>`,
specs: {
url: {
type: "text",
},
method: {
options: ["POST", "PUT", "GET"],
},
headers: {
type: "long_text",
},
body: {
type: "long_text",
},
},
},
{
name: "notify/email",
title: "Notify",
subtitle: "email",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M112 128C85.5 128 64 149.5 64 176C64 191.1 71.1 205.3 83.2 214.4L291.2 370.4C308.3 383.2 331.7 383.2 348.8 370.4L556.8 214.4C568.9 205.3 576 191.1 576 176C576 149.5 554.5 128 528 128L112 128zM64 260L64 448C64 483.3 92.7 512 128 512L512 512C547.3 512 576 483.3 576 448L576 260L377.6 408.8C343.5 434.4 296.5 434.4 262.4 408.8L64 260z"></path></svg>`,
specs: {
email: {
type: "text",
},
message: {
type: "long_text",
}
},
},
{
name: "approval/email",
title: "Approval",
subtitle: "email",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M112 128C85.5 128 64 149.5 64 176C64 191.1 71.1 205.3 83.2 214.4L291.2 370.4C308.3 383.2 331.7 383.2 348.8 370.4L556.8 214.4C568.9 205.3 576 191.1 576 176C576 149.5 554.5 128 528 128L112 128zM64 260L64 448C64 483.3 92.7 512 128 512L512 512C547.3 512 576 483.3 576 448L576 260L377.6 408.8C343.5 434.4 296.5 434.4 262.4 408.8L64 260z"></path></svg>`,
specs: {
email: {
type: "text",
},
message: {
type: "long_text",
},
},
},
{
name: "tools/debug",
title: "Debug",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.0.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M102.8 57.3C108.2 51.9 116.6 51.1 123 55.3L241.9 134.5C250.8 140.4 256.1 150.4 256.1 161.1L256.1 210.7L346.9 301.5C380.2 286.5 420.8 292.6 448.1 320L574.2 446.1C592.9 464.8 592.9 495.2 574.2 514L514.1 574.1C495.4 592.8 465 592.8 446.2 574.1L320.1 448C292.7 420.6 286.6 380.1 301.6 346.8L210.8 256L161.2 256C150.5 256 140.5 250.7 134.6 241.8L55.4 122.9C51.2 116.6 52 108.1 57.4 102.7L102.8 57.3zM247.8 360.8C241.5 397.7 250.1 436.7 274 468L179.1 563C151 591.1 105.4 591.1 77.3 563C49.2 534.9 49.2 489.3 77.3 461.2L212.7 325.7L247.9 360.8zM416.1 64C436.2 64 455.5 67.7 473.2 74.5C483.2 78.3 485 91 477.5 98.6L420.8 155.3C417.8 158.3 416.1 162.4 416.1 166.6L416.1 208C416.1 216.8 423.3 224 432.1 224L473.5 224C477.7 224 481.8 222.3 484.8 219.3L541.5 162.6C549.1 155.1 561.8 156.9 565.6 166.9C572.4 184.6 576.1 203.9 576.1 224C576.1 267.2 558.9 306.3 531.1 335.1L482 286C448.9 253 403.5 240.3 360.9 247.6L304.1 190.8L304.1 161.1L303.9 156.1C303.1 143.7 299.5 131.8 293.4 121.2C322.8 86.2 366.8 64 416.1 63.9z"/></svg>`,
specs: {},
},
{
name: "tools/map",
title: "Map",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.0.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M102.8 57.3C108.2 51.9 116.6 51.1 123 55.3L241.9 134.5C250.8 140.4 256.1 150.4 256.1 161.1L256.1 210.7L346.9 301.5C380.2 286.5 420.8 292.6 448.1 320L574.2 446.1C592.9 464.8 592.9 495.2 574.2 514L514.1 574.1C495.4 592.8 465 592.8 446.2 574.1L320.1 448C292.7 420.6 286.6 380.1 301.6 346.8L210.8 256L161.2 256C150.5 256 140.5 250.7 134.6 241.8L55.4 122.9C51.2 116.6 52 108.1 57.4 102.7L102.8 57.3zM247.8 360.8C241.5 397.7 250.1 436.7 274 468L179.1 563C151 591.1 105.4 591.1 77.3 563C49.2 534.9 49.2 489.3 77.3 461.2L212.7 325.7L247.9 360.8zM416.1 64C436.2 64 455.5 67.7 473.2 74.5C483.2 78.3 485 91 477.5 98.6L420.8 155.3C417.8 158.3 416.1 162.4 416.1 166.6L416.1 208C416.1 216.8 423.3 224 432.1 224L473.5 224C477.7 224 481.8 222.3 484.8 219.3L541.5 162.6C549.1 155.1 561.8 156.9 565.6 166.9C572.4 184.6 576.1 203.9 576.1 224C576.1 267.2 558.9 306.3 531.1 335.1L482 286C448.9 253 403.5 240.3 360.9 247.6L304.1 190.8L304.1 161.1L303.9 156.1C303.1 143.7 299.5 131.8 293.4 121.2C322.8 86.2 366.8 64 416.1 63.9z"/></svg>`,
specs: {
transform: {
type: "long_text",
}
},
}
// TODO: expand macros
];
const macros = [
{
name: "files/mv",
specs: [
{ name: "from", type: "text" },
{ name: "to", type: "text" },
],
run: [
{
name: "run/api",
values: [
{ name: "url", value: "{{ .endpoint }}/api/files/mv?from={{ .from }}&to={{ .to }}" },
{ name: "method", value: "POST" },
{ name: "headers", value: "Authorization: Bearer {{ .authorization }}" },
]
},
],
},
// {
// name: "metadata/add",
// }
]
export default AdminHOC(async function(render) {
await loadCSS(import.meta.url, "./ctrl_workflow.css");
render(createElement("<component-loader inlined></component-loader>"));
const { workflows, triggers, actions } = await workflowAll().toPromise();
const specs = getSpecs();
if (specs) ctrlDetails(render, { workflow: specs, triggers, actions });
else {
await new Promise((done) => setTimeout(() => done(), 100));
ctrlList(render, { workflows, triggers, actions });
}
else ctrlList(render, { workflows, triggers, actions });
});
function getSpecs() {
const GET = new URLSearchParams(location.search)
const GET = new URLSearchParams(location.search);
try {
return JSON.parse(atob(GET.get("specs")));
} catch (err) {

View file

@ -1,31 +1,33 @@
import { createElement, createFragment } from "../../lib/skeleton/index.js";
import { createElement, createFragment, navigate } from "../../lib/skeleton/index.js";
import rxjs, { effect, onClick } from "../../lib/rx.js";
import { animate, slideXIn, slideXOut } from "../../lib/animate.js";
import { qs, qsa } from "../../lib/dom.js";
import { qs, qsa, safe } from "../../lib/dom.js";
import { createForm, mutateForm } from "../../lib/form.js";
import { formTmpl } from "../../components/form.js";
import { renderLeaf, useForm$, formObjToJSON$ } from "./helper_form.js";
import { workflowUpsert, workflowDelete, workflowGet } from "./model_workflow.js";
import transition from "./animate.js";
// TODO: auto id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
export default async function(render, { workflow, triggers, actions }) {
const { id } = workflow;
const $page = createElement(`
<div class="component_page_workflow">
<h2 class="ellipsis">
<h2 class="ellipsis flex">
<a href="./admin/workflow" data-link>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" style="width:20px;fill:var(--color);">
<path d="M169.4 297.4C156.9 309.9 156.9 330.2 169.4 342.7L361.4 534.7C373.9 547.2 394.2 547.2 406.7 534.7C419.2 522.2 419.2 501.9 406.7 489.4L237.3 320L406.6 150.6C419.1 138.1 419.1 117.8 406.6 105.3C394.1 92.8 373.8 92.8 361.3 105.3L169.3 297.3z"/>
</svg>
</a>
${workflow.name}
<form class="full-width"><input type="text" value="${safe(workflow.name)}" name="name" /></form>
</h2>
<div data-bind="trigger"></div>
<div data-bind="actions"></div>
<div data-bind="add"></div>
<h2 class="ellipsis hidden">History</h2>
<h2 class="ellipsis">Activity</h2>
<pre data-bind="history" class="scroll-y" style="max-height:300px">...</pre>
<style>.component_page_admin .page_container h2:after { display: none; }</style>
</div>
`);
@ -33,12 +35,12 @@ export default async function(render, { workflow, triggers, actions }) {
// feature1: setup trigger
const $trigger = qs($page, `[data-bind="trigger"]`);
$trigger.appendChild(await createTrigger({ workflow, triggers }));
$trigger.appendChild(await createTrigger({ trigger: workflow.trigger, triggers }));
// feature2: setup actions
const $actions = qs($page, `[data-bind="actions"]`);
for (let i=0; i<workflow.actions.length; i++) {
$actions.appendChild(await createAction({ action: workflow.actions[i], actions }))
for (let i=0; i<(workflow.actions || []).length; i++) {
$actions.appendChild(await createAction({ action: workflow.actions[i], actions }));
}
// feature3: add a step
@ -55,7 +57,43 @@ export default async function(render, { workflow, triggers, actions }) {
}));
// feature4: save button
$page.parentElement.appendChild(createSave());
effect(rxjs.of(createSave({ withDelete: !!id })).pipe(
rxjs.tap(($fab) => $page.parentElement.appendChild($fab)),
rxjs.mergeMap(($fab) => onClick(qs($fab, "button"))),
rxjs.tap(($button) => {
$button.setAttribute("disabled", "true");
$button.firstElementChild.classList.add("hidden");
$button.firstElementChild.nextElementSibling.classList.remove("hidden");
}),
rxjs.mergeMap(($button) => {
let action = rxjs.of(null);
let redirect = !id;
const workflow = formData($page, { id });
if (workflow.published === "delete") {
if (window.confirm("delete this workflow?")) {
action = workflowDelete(id);
redirect = true;
}
} else {
workflow.published = workflow.published === "publish";
action = workflowUpsert(workflow);
}
const start = new Date();
return action.pipe(
rxjs.switchMap((result) => {
const elapsed = new Date() - start;
return rxjs.of(result).pipe(rxjs.delay(Math.max(0, 400 - elapsed)));
}),
rxjs.finalize(() => {
$button.removeAttribute("disabled");
$button.firstElementChild.classList.remove("hidden");
$button.firstElementChild.nextElementSibling.classList.add("hidden");
if (redirect) navigate(window.location.pathname);
else history.replaceState(null, "", window.location.pathname + "?specs=" + encodeURIComponent(btoa(JSON.stringify(workflow))));
}),
);
}),
));
// feature5: toggle form visibility
qsa($page, `[data-bind="form"]`).forEach(($form) => {
@ -67,46 +105,92 @@ export default async function(render, { workflow, triggers, actions }) {
qsa($page, `button[alt="delete"]`).forEach(($delete) => $delete.onclick = (e) => {
removeAction(e.target);
});
// feature7: history
const $history = qs($page, `[data-bind="history"]`);
if (!workflow.id) {
$history.previousElementSibling.remove();
$history.remove();
} else effect(rxjs.of(null).pipe(
rxjs.mergeMap(() => workflowGet(workflow.id)),
rxjs.tap(({ history }) => {
if (history.length === 0) $history.innerText = "Ø";
else $history.innerText = history.map(({ id, created_at, status, steps }) => {
const [date, time] = created_at.split(" ");
let msg = `date=${safe(date)} time=${safe(time)} id=${safe(String(id))} status=${safe(status)}`;
try { steps = JSON.parse(steps); }
catch (err) { steps = []; }
if (steps.length > 0) {
const s = new Array(steps.length);
for (let i = 0; i < steps.length; i++) {
const { name, done = false } = steps[i];
const out = name.split("/")[1] || "na";
if (status === "FAILURE" && !done) s[i] = out + "[✗]";
else if (status === "RUNNING" && !done && (steps[i-1] ? steps[i-1].done : true)) s[i] = out + "[○]";
else if (["RUNNING", "PENDING"].indexOf(status) !== -1 && done) s[i] = out + "[✓]";
else if (["READY", "CLAIMED"].indexOf(status) !== -1) s[i] = out + "[○]";
else s[i] = out;
}
msg += "[" + s.join(" ➜ ") + "]";
}
return msg;
}).join("\n");
}),
rxjs.catchError(() => rxjs.of(null)),
rxjs.delay(5000),
rxjs.repeat(),
));
}
async function createTrigger({ workflow, triggers }) {
const trigger = triggers.find(({ name }) => name === workflow.trigger.name);
if (!trigger) return createElement(`<div class="box disabled">Trigger not found "${workflow.trigger.name}"</div>`);
const { title, icon } = trigger;
async function createTrigger({ trigger, triggers }) {
const currentTrigger = triggers.find(({ name }) => name === trigger.name);
if (!currentTrigger) return createElement(`<div class="box disabled">Trigger not found "${safe(trigger.name)}"</div>`);
const { title, icon } = currentTrigger;
const $trigger = createFragment(`
<div class="box disabled">
${icon}
<h3 class="ellipsis no-select">
${title}
&nbsp;${safe(title)}
<button alt="configure" class="pull-right"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M259.1 73.5C262.1 58.7 275.2 48 290.4 48L350.2 48C365.4 48 378.5 58.7 381.5 73.5L396 143.5C410.1 149.5 423.3 157.2 435.3 166.3L503.1 143.8C517.5 139 533.3 145 540.9 158.2L570.8 210C578.4 223.2 575.7 239.8 564.3 249.9L511 297.3C511.9 304.7 512.3 312.3 512.3 320C512.3 327.7 511.8 335.3 511 342.7L564.4 390.2C575.8 400.3 578.4 417 570.9 430.1L541 481.9C533.4 495 517.6 501.1 503.2 496.3L435.4 473.8C423.3 482.9 410.1 490.5 396.1 496.6L381.7 566.5C378.6 581.4 365.5 592 350.4 592L290.6 592C275.4 592 262.3 581.3 259.3 566.5L244.9 496.6C230.8 490.6 217.7 482.9 205.6 473.8L137.5 496.3C123.1 501.1 107.3 495.1 99.7 481.9L69.8 430.1C62.2 416.9 64.9 400.3 76.3 390.2L129.7 342.7C128.8 335.3 128.4 327.7 128.4 320C128.4 312.3 128.9 304.7 129.7 297.3L76.3 249.8C64.9 239.7 62.3 223 69.8 209.9L99.7 158.1C107.3 144.9 123.1 138.9 137.5 143.7L205.3 166.2C217.4 157.1 230.6 149.5 244.6 143.4L259.1 73.5zM320.3 400C364.5 399.8 400.2 363.9 400 319.7C399.8 275.5 363.9 239.8 319.7 240C275.5 240.2 239.8 276.1 240 320.3C240.2 364.5 276.1 400.2 320.3 400z"/></svg></button>
</h3>
<div data-bind="form"></div>
<form data-bind="form" data-key="trigger" data-step="${safe(trigger.name)}"></form>
</div><hr>
`);
const $form = await createForm(trigger.specs, formTmpl());
const $form = await createForm(
mutateForm(currentTrigger.specs, trigger.params || {}),
formTmpl(),
);
qs($trigger, `[data-bind="form"]`).appendChild($form);
return $trigger;
}
async function createAction({ action, actions }) {
const selected = actions.find((_action) => _action.name === action.name);
if (!selected) return createElement(`<div class="box disabled">Action not found "${action.name}"</div>`);
const subtitle = selected.subtitle ? `({{ ${action.subtitle} }})` : "";
if (!selected) return createElement(`
<div>
<div class="box disabled">Action not found "${safe(action.name)}"</div>
<hr>
</div>
`);
const subtitle = selected.subtitle && action.params && action.params[selected.subtitle] ? `(${safe(action.params[selected.subtitle])})` : "";
const $action = createElement(`
<div>
<div class="box">
${selected.icon}
<h3 class="ellipsis no-select">
${selected.title} <span>${subtitle}</span>
&nbsp;${safe(selected.title)} <span>${safe(subtitle)}</span>
<button alt="delete" class="pull-right"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M183.1 137.4C170.6 124.9 150.3 124.9 137.8 137.4C125.3 149.9 125.3 170.2 137.8 182.7L275.2 320L137.9 457.4C125.4 469.9 125.4 490.2 137.9 502.7C150.4 515.2 170.7 515.2 183.2 502.7L320.5 365.3L457.9 502.6C470.4 515.1 490.7 515.1 503.2 502.6C515.7 490.1 515.7 469.8 503.2 457.3L365.8 320L503.1 182.6C515.6 170.1 515.6 149.8 503.1 137.3C490.6 124.8 470.3 124.8 457.8 137.3L320.5 274.7L183.1 137.4z"/></svg></button>
<button alt="configure" class="pull-right ${Object.keys(selected.specs).length === 0 ? "hidden": ""}"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M259.1 73.5C262.1 58.7 275.2 48 290.4 48L350.2 48C365.4 48 378.5 58.7 381.5 73.5L396 143.5C410.1 149.5 423.3 157.2 435.3 166.3L503.1 143.8C517.5 139 533.3 145 540.9 158.2L570.8 210C578.4 223.2 575.7 239.8 564.3 249.9L511 297.3C511.9 304.7 512.3 312.3 512.3 320C512.3 327.7 511.8 335.3 511 342.7L564.4 390.2C575.8 400.3 578.4 417 570.9 430.1L541 481.9C533.4 495 517.6 501.1 503.2 496.3L435.4 473.8C423.3 482.9 410.1 490.5 396.1 496.6L381.7 566.5C378.6 581.4 365.5 592 350.4 592L290.6 592C275.4 592 262.3 581.3 259.3 566.5L244.9 496.6C230.8 490.6 217.7 482.9 205.6 473.8L137.5 496.3C123.1 501.1 107.3 495.1 99.7 481.9L69.8 430.1C62.2 416.9 64.9 400.3 76.3 390.2L129.7 342.7C128.8 335.3 128.4 327.7 128.4 320C128.4 312.3 128.9 304.7 129.7 297.3L76.3 249.8C64.9 239.7 62.3 223 69.8 209.9L99.7 158.1C107.3 144.9 123.1 138.9 137.5 143.7L205.3 166.2C217.4 157.1 230.6 149.5 244.6 143.4L259.1 73.5zM320.3 400C364.5 399.8 400.2 363.9 400 319.7C399.8 275.5 363.9 239.8 319.7 240C275.5 240.2 239.8 276.1 240 320.3C240.2 364.5 276.1 400.2 320.3 400z"/></svg></button>
</h3>
<div data-bind="form"></div>
<form data-bind="form" data-key="actions" data-step="${safe(action.name)}" data-array></form>
</div>
<hr>
</div>
`);
const $form = await createForm(selected.specs, formTmpl());
const $form = await createForm(
mutateForm(selected.specs, action.params || {}),
formTmpl(),
);
qs($action, `[data-bind="form"]`).appendChild($form);
return $action;
}
@ -130,20 +214,9 @@ async function createAdd({ actions, createAction }) {
</button>
</div>
`);
const categories = actions.reduce((acc, { name }) => {
const s = name.split("/");
if (!acc[s[0]]) acc[s[0]] = [];
acc[s[0]].push(name);
return acc;
}, {});
const $categories = createElement(`
<div class="hidden flex">
`+ Object.keys(categories).map((category) => `
<button class="item">${category}</button>
`+categories[category].map((subcategory) => `
<button class="sub" style="background:var(--border)" data-name="${subcategory}">${subcategory.split("/")[1]}</button>
`).join("")+`
`).join("")+`
<div class="hidden flex no-select">
`+actions.map(({ name }) => `<button data-name="${safe(name)}">${safe(name)}</button>`).join("")+`
</div>
`);
const $item = qs($el, ".item");
@ -175,7 +248,7 @@ async function createAdd({ actions, createAction }) {
}
};
qsa($el, ".sub").forEach(($action) => $action.onclick = () => {
qsa($el, "[data-name]").forEach(($action) => $action.onclick = () => {
const action = actions.find(({ name }) => name === $action.getAttribute("data-name"));
if (action) createAction({ action, actions });
$item.onclick();
@ -183,17 +256,21 @@ async function createAdd({ actions, createAction }) {
return $el;
}
function createSave() {
function createSave({ withDelete = true }) {
const $fab = createElement(`
<div class="workflow-fab flex no-select">
<select>
<form class="workflow-fab flex no-select">
<select name="published">
<option>publish</option>
<option>unpublish</option>
${withDelete ? "<option>delete</option>" : ""}
</select>
<button style="display:contents;" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path d="M160 96C124.7 96 96 124.7 96 160L96 480C96 515.3 124.7 544 160 544L480 544C515.3 544 544 515.3 544 480L544 237.3C544 220.3 537.3 204 525.3 192L448 114.7C436 102.7 419.7 96 402.7 96L160 96zM192 192C192 174.3 206.3 160 224 160L384 160C401.7 160 416 174.3 416 192L416 256C416 273.7 401.7 288 384 288L224 288C206.3 288 192 273.7 192 256L192 192zM320 352C355.3 352 384 380.7 384 416C384 451.3 355.3 480 320 480C284.7 480 256 451.3 256 416C256 380.7 284.7 352 320 352z"/>
</svg>
</div>
<component-icon class="hidden" name="loading"></component-icon>
</button>
</form>
`);
animate($fab, { time: 100, keyframes: slideXIn(5) });
return $fab;
@ -206,7 +283,7 @@ function withToggle($form) {
const shouldOpen = $form.classList.contains("hidden");
if (shouldOpen) {
animate($form, {
time: 120,
time: Math.max(120, height/2),
keyframes: [{ height: "0px" }, { height: `${height}px` }],
});
$form.classList.remove("hidden");
@ -219,3 +296,34 @@ function withToggle($form) {
}
};
}
function formData($page, { id }) {
const result = {
id: id || Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
};
qsa($page.parentElement, "form").forEach(($form) => {
const params = [...new FormData($form)].reduce((acc, [key, value]) => {
if (value) acc[key] = value;
return acc;
}, {});
const key = $form.getAttribute("data-key");
if (key) {
const name = $form.getAttribute("data-step");
if ($form.hasAttribute("data-array")) {
if (!result[key]) result[key] = [];
const step = { name };
if (Object.keys(params).length > 0) step.params = params;
result[key].push(step);
} else {
const step = { name };
if (Object.keys(params).length > 0) step.params = params;
result[key] = step;
}
} else {
Object.keys(params).forEach((key) => {
result[key] = params[key];
});
}
});
return result;
}

View file

@ -1,6 +1,6 @@
import { createElement } from "../../lib/skeleton/index.js";
import rxjs, { effect, onClick } from "../../lib/rx.js";
import { qs } from "../../lib/dom.js";
import { qs, safe } from "../../lib/dom.js";
import { animate } from "../../lib/animate.js";
import { createModal } from "../../components/modal.js";
import { generateSkeleton } from "../../components/skeleton.js";
@ -19,7 +19,9 @@ export default async function(render, { workflows, triggers }) {
</div>
`);
render(transition($page));
workflows.forEach((workflow) => qs($page, `[data-bind="workflows"]`).appendChild(createWorkflow(workflow)));
const $workflows = qs($page, `[data-bind="workflows"]`);
workflows.forEach((workflow) => $workflows.appendChild(createWorkflow(workflow)));
if (workflows.length === 0) $workflows.appendChild(createEmptyWorkflow());
effect(onClick(qs($page, "h2 > a")).pipe(
rxjs.tap((a) => ctrlModal(createModal(), { triggers })),
@ -27,22 +29,31 @@ export default async function(render, { workflows, triggers }) {
}
function createWorkflow(specs) {
const { name, published, actions, trigger } = specs;
const { name, published, actions, trigger, updated_at } = specs;
const summaryHTML = {
trigger: `<button class="light">${trigger.name}</button>`,
actions: actions.map(({ name }) => `<button class="light">${name.split("/")[0]}</button>`).join(""),
trigger: `<button class="light">${safe(trigger.name)}</button>`,
actions: (actions || []).map(({ name }) => `<button class="light">${safe(name.split("/")[0])}</button>`).join(""),
};
const $workflow = createElement(`
<a href="./admin/workflow?specs=${btoa(JSON.stringify(specs))}" class="box ${published ? "" : "disabled"}" data-link>
<h3 class="ellipsis">${name} <span>(2 weeks ago)</span></h3>
<a href="./admin/workflow?specs=${encodeURIComponent(btoa(JSON.stringify(specs)))}" class="box ${published ? "" : "disabled"}" data-link>
<h3 class="ellipsis">
${safe(name)}
<span>(${Intl.DateTimeFormat(navigator.language).format(new Date(safe(updated_at)))})</span>
</h3>
<div class="workflow-summary">
${summaryHTML.trigger} ${summaryHTML.actions}
${summaryHTML.trigger} ${summaryHTML.actions ? "→" + summaryHTML.actions : ""}
</div>
</a>
`);
return $workflow;
}
function createEmptyWorkflow() {
return createElement(`
<h3 class="center empty no-select">Add a new workflow to get started</h3>
`);
}
function ctrlModal(render, { triggers }) {
const $page = createElement(`
<div class="component_workflow_create">
@ -61,7 +72,7 @@ function ctrlModal(render, { triggers }) {
const $input = qs($page, "input");
effect(rxjs.of(triggers).pipe(
rxjs.map((arr) => arr.map(({ name, title, icon }) => createElement(`
<a class="item flex no-select ellipsis" data-name="${name}">${icon} ${title}</a>
<a class="item flex no-select ellipsis" data-name="${safe(name)}">${icon} ${safe(title)}</a>
`))),
rxjs.map(($els) => {
$list.innerHTML = "";
@ -79,21 +90,21 @@ function ctrlModal(render, { triggers }) {
rxjs.tap((e) => {
const shouldOpen = e.target.value.length > 0;
if ($list.clientHeight === 0 && shouldOpen) animate($list, {
time: Math.max(50, Math.min(height, 150)),
time: 300,
keyframes: [{ height: "0" }, { height: `${height}px` }],
onExit: () => $list.style.height = "",
});
else if ($list.clientHeight > 0 && !shouldOpen) animate($list, {
time: Math.max(50, Math.min(height, 150)),
time: 100,
keyframes: [{ height: `${height}px` }, { height: "0" }],
onExit: () => $list.style.height = "0",
});
$els.forEach(($el) => $el.setAttribute("href", "./admin/workflow?specs="+btoa(JSON.stringify({
$els.forEach(($el) => $el.setAttribute("href", "./admin/workflow?specs="+encodeURIComponent(btoa(JSON.stringify({
name: e.target.value,
published: false,
trigger: { name: $el.getAttribute("data-name") },
actions: [{ name: "tools/debug" }]
}))));
})))));
}),
)),
));

View file

@ -31,15 +31,15 @@ export default function(ctrl) {
</a>
</li>
<li>
<a href="${toHref("/admin/logs")}" data-link>
Activity
</a>
</li>
<li class=${new URLSearchParams(location.search).has("canary") ? "" : "hidden"}>
<a href="${toHref("/admin/workflow")}" data-link>
Workflow
</a>
</li>
<li>
<a href="${toHref("/admin/logs")}" data-link>
Activity
</a>
</li>
<li>
<a href="${toHref("/admin/settings")}" data-link>
Settings

View file

@ -110,7 +110,7 @@
font-size: 0.9em;
padding: 10px;
margin-bottom: 0;
border-radius: 2px;
border-radius: 3px;
color: white;
max-width: 100%;
overflow-x: auto;

View file

@ -0,0 +1,46 @@
import rxjs from "../../lib/rx.js";
import ajax from "../../lib/ajax.js";
export function workflowAll() {
return ajax({
url: "admin/api/workflow",
responseType: "json",
}).pipe(
rxjs.map(({ responseJSON }) => responseJSON.result),
rxjs.map(({ workflows, triggers, actions }) => ({
workflows,
triggers,
actions,
})),
);
}
export function workflowUpsert(body) {
return ajax({
url: "admin/api/workflow",
responseType: "json",
method: "POST",
body,
}).pipe(
rxjs.map(({ responseJSON }) => responseJSON.result)
);
}
export function workflowDelete(id) {
return ajax({
url: "admin/api/workflow?id=" + id,
responseType: "json",
method: "DELETE",
}).pipe(
rxjs.map(({ responseJSON }) => responseJSON.result)
);
}
export function workflowGet(id) {
return ajax({
url: "admin/api/workflow/" + id,
responseType: "json",
}).pipe(
rxjs.map(({ responseJSON }) => responseJSON.result)
);
}

View file

@ -5,6 +5,8 @@ import (
"os"
"path/filepath"
"strings"
"github.com/bmatcuk/doublestar/v4"
)
var MOCK_CURRENT_DIR string
@ -106,6 +108,11 @@ func SafeOsRename(from string, to string) error {
return processError(os.Rename(from, to))
}
func GlobMatch(pattern, name string) bool {
m, _ := doublestar.Match(pattern, name)
return m
}
func safePath(path string) error {
p, err := filepath.EvalSymlinks(path)
if err != nil {

View file

@ -6,6 +6,7 @@ import (
"io/fs"
"net/http"
"path/filepath"
"sort"
"strings"
"github.com/gorilla/mux"
@ -286,6 +287,30 @@ func (this Get) Metadata() IMetadata {
return meta
}
var workflow_triggers []ITrigger
func (this Register) WorkflowTrigger(t ITrigger) {
workflow_triggers = append(workflow_triggers, t)
sort.Slice(workflow_triggers, func(i, j int) bool {
return workflow_triggers[i].Manifest().Order < workflow_triggers[j].Manifest().Order
})
}
func (this Get) WorkflowTriggers() []ITrigger {
return workflow_triggers
}
var workflow_actions []IAction
func (this Register) WorkflowAction(a IAction) {
workflow_actions = append(workflow_actions, a)
sort.Slice(workflow_actions, func(i, j int) bool {
return workflow_actions[i].Manifest().Order < workflow_actions[j].Manifest().Order
})
}
func (this Get) WorkflowActions() []IAction {
return workflow_actions
}
func init() {
Hooks.Register.FrontendOverrides(OverrideVideoSourceMapper)
}

View file

@ -78,6 +78,30 @@ type IMetadata interface {
Search(ctx *App, path string, facets map[string]any) (map[string][]FormElement, error)
}
type ITrigger interface {
Manifest() WorkflowSpecs
Init() (chan ITriggerEvent, error)
}
type IAction interface {
Manifest() WorkflowSpecs
Execute(params map[string]string, input map[string]string) (map[string]string, error)
}
type ITriggerEvent interface {
WorkflowID() string
Input() map[string]string
}
type WorkflowSpecs struct {
Name string `json:"name"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Icon string `json:"icon"`
Specs map[string]FormElement `json:"specs"`
Order int `json:"-"`
}
type File struct {
FName string `json:"name"`
FType string `json:"type"`

View file

@ -13,6 +13,7 @@ import (
. "github.com/mickael-kerjean/filestash/server/common"
. "github.com/mickael-kerjean/filestash/server/ctrl"
. "github.com/mickael-kerjean/filestash/server/middleware"
. "github.com/mickael-kerjean/filestash/server/workflow"
)
func Build(r *mux.Router, a App) {
@ -39,6 +40,10 @@ func Build(r *mux.Router, a App) {
middlewares = []Middleware{ApiHeaders, AdminOnly, SecureOrigin, PluginInjector}
admin.HandleFunc("/config", NewMiddlewareChain(PrivateConfigHandler, middlewares, a)).Methods("GET")
admin.HandleFunc("/config", NewMiddlewareChain(PrivateConfigUpdateHandler, middlewares, a)).Methods("POST")
admin.HandleFunc("/workflow", NewMiddlewareChain(WorkflowAll, middlewares, a)).Methods("GET")
admin.HandleFunc("/workflow/{workflowID}", NewMiddlewareChain(WorkflowGet, middlewares, a)).Methods("GET")
admin.HandleFunc("/workflow", NewMiddlewareChain(WorkflowUpsert, middlewares, a)).Methods("POST")
admin.HandleFunc("/workflow", NewMiddlewareChain(WorkflowDelete, middlewares, a)).Methods("DELETE")
admin.HandleFunc("/middlewares/authentication", NewMiddlewareChain(AdminAuthenticationMiddleware, middlewares, a)).Methods("GET")
admin.HandleFunc("/audit", NewMiddlewareChain(FetchAuditHandler, middlewares, a)).Methods("GET")
middlewares = []Middleware{IndexHeaders, AdminOnly, PluginInjector}

24
server/workflow/action.go Normal file
View file

@ -0,0 +1,24 @@
package workflow
import (
. "github.com/mickael-kerjean/filestash/server/common"
_ "github.com/mickael-kerjean/filestash/server/workflow/actions"
. "github.com/mickael-kerjean/filestash/server/workflow/model"
)
func ExecuteAction(action Step, input map[string]string) (map[string]string, error) {
currAction, err := findAction(action.Name)
if err != nil {
return nil, err
}
return currAction.Execute(action.Params, input)
}
func findAction(action string) (IAction, error) {
for _, currAction := range Hooks.Get.WorkflowActions() {
if currAction.Manifest().Name == action {
return currAction, nil
}
}
return nil, ErrNotFound
}

View file

@ -0,0 +1,61 @@
package actions
import (
. "github.com/mickael-kerjean/filestash/server/common"
"gopkg.in/gomail.v2"
)
func init() {
Hooks.Register.WorkflowAction(&ActionNotifyEmail{})
}
type ActionNotifyEmail struct{}
func (this *ActionNotifyEmail) Manifest() WorkflowSpecs {
return WorkflowSpecs{
Name: "notify/email",
Title: "Notify",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M112 128C85.5 128 64 149.5 64 176C64 191.1 71.1 205.3 83.2 214.4L291.2 370.4C308.3 383.2 331.7 383.2 348.8 370.4L556.8 214.4C568.9 205.3 576 191.1 576 176C576 149.5 554.5 128 528 128L112 128zM64 260L64 448C64 483.3 92.7 512 128 512L512 512C547.3 512 576 483.3 576 448L576 260L377.6 408.8C343.5 434.4 296.5 434.4 262.4 408.8L64 260z"></path></svg>`,
Specs: map[string]FormElement{
"email": {
Type: "text",
},
"subject": {
Type: "text",
},
"message": {
Type: "long_text",
},
},
}
}
func (this *ActionNotifyEmail) Execute(params map[string]string, input map[string]string) (map[string]string, error) {
email := struct {
Hostname string
Port int
Username string
Password string
From string
To string
Subject string
Message string
}{
Hostname: Config.Get("email.server").String(),
Port: Config.Get("email.port").Int(),
Username: Config.Get("email.username").String(),
Password: Config.Get("email.password").String(),
From: Config.Get("email.from").String(),
To: params["email"],
Subject: params["subject"],
Message: params["message"],
}
m := gomail.NewMessage()
m.SetHeader("From", email.From)
m.SetHeader("To", email.To)
m.SetHeader("Subject", email.Subject)
m.SetBody("text/html", email.Message)
mail := gomail.NewDialer(email.Hostname, email.Port, email.Username, email.Password)
return input, mail.DialAndSend(m)
}

View file

@ -0,0 +1,79 @@
package actions
import (
"bytes"
"fmt"
"io"
"net/http"
"slices"
"strings"
. "github.com/mickael-kerjean/filestash/server/common"
)
func init() {
Hooks.Register.WorkflowAction(&RunApi{})
}
type RunApi struct{}
func (this *RunApi) Manifest() WorkflowSpecs {
return WorkflowSpecs{
Name: "run/api",
Title: "Make API Call",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M96 160C96 124.7 124.7 96 160 96L480 96C515.3 96 544 124.7 544 160L544 480C544 515.3 515.3 544 480 544L160 544C124.7 544 96 515.3 96 480L96 160zM240 164C215.7 164 196 183.7 196 208L196 256C196 280.3 215.7 300 240 300L272 300C296.3 300 316 280.3 316 256L316 208C316 183.7 296.3 164 272 164L240 164zM236 208C236 205.8 237.8 204 240 204L272 204C274.2 204 276 205.8 276 208L276 256C276 258.2 274.2 260 272 260L240 260C237.8 260 236 258.2 236 256L236 208zM376 164C365 164 356 173 356 184C356 193.7 362.9 201.7 372 203.6L372 280C372 291 381 300 392 300C403 300 412 291 412 280L412 184C412 173 403 164 392 164L376 164zM228 360C228 369.7 234.9 377.7 244 379.6L244 456C244 467 253 476 264 476C275 476 284 467 284 456L284 360C284 349 275 340 264 340L248 340C237 340 228 349 228 360zM324 384L324 432C324 456.3 343.7 476 368 476L400 476C424.3 476 444 456.3 444 432L444 384C444 359.7 424.3 340 400 340L368 340C343.7 340 324 359.7 324 384zM368 380L400 380C402.2 380 404 381.8 404 384L404 432C404 434.2 402.2 436 400 436L368 436C365.8 436 364 434.2 364 432L364 384C364 381.8 365.8 380 368 380z"></path></svg>`,
Specs: map[string]FormElement{
"url": {
Type: "text",
},
"method": {
Type: "select",
Opts: []string{"POST", "PUT", "GET", "PATCH"},
},
"headers": {
Type: "long_text",
},
"body": {
Type: "long_text",
},
},
}
}
func (this *RunApi) Execute(params map[string]string, input map[string]string) (map[string]string, error) {
req, err := http.NewRequest(params["method"], params["url"], bytes.NewBufferString(params["body"]))
if err != nil {
return input, err
} else if params["headers"] != "" {
for _, header := range strings.Split(params["headers"], "\n") {
if parts := strings.SplitN(strings.TrimSpace(header), ":", 2); len(parts) == 2 {
req.Header.Add(
strings.TrimSpace(parts[0]),
strings.TrimSpace(parts[1]),
)
}
}
}
if params["body"] != "" && slices.Contains([]string{"POST", "PUT", "PATCH"}, params["method"]) && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := HTTP.Do(req)
if err != nil {
return input, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return input, NewError(fmt.Sprintf("received status code is %d", resp.StatusCode), resp.StatusCode)
}
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return input, err
}
output := make(map[string]string)
for k, v := range input {
output[k] = v
}
output["http::status"] = string(resp.StatusCode)
output["http::response"] = string(responseBody)
return output, nil
}

View file

@ -0,0 +1,25 @@
package actions
import (
. "github.com/mickael-kerjean/filestash/server/common"
)
func init() {
Hooks.Register.WorkflowAction(&ToolsDebug{})
}
type ToolsDebug struct{}
func (this *ToolsDebug) Manifest() WorkflowSpecs {
return WorkflowSpecs{
Name: "tools/debug",
Title: "Debug",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M102.8 57.3C108.2 51.9 116.6 51.1 123 55.3L241.9 134.5C250.8 140.4 256.1 150.4 256.1 161.1L256.1 210.7L346.9 301.5C380.2 286.5 420.8 292.6 448.1 320L574.2 446.1C592.9 464.8 592.9 495.2 574.2 514L514.1 574.1C495.4 592.8 465 592.8 446.2 574.1L320.1 448C292.7 420.6 286.6 380.1 301.6 346.8L210.8 256L161.2 256C150.5 256 140.5 250.7 134.6 241.8L55.4 122.9C51.2 116.6 52 108.1 57.4 102.7L102.8 57.3zM247.8 360.8C241.5 397.7 250.1 436.7 274 468L179.1 563C151 591.1 105.4 591.1 77.3 563C49.2 534.9 49.2 489.3 77.3 461.2L212.7 325.7L247.9 360.8zM416.1 64C436.2 64 455.5 67.7 473.2 74.5C483.2 78.3 485 91 477.5 98.6L420.8 155.3C417.8 158.3 416.1 162.4 416.1 166.6L416.1 208C416.1 216.8 423.3 224 432.1 224L473.5 224C477.7 224 481.8 222.3 484.8 219.3L541.5 162.6C549.1 155.1 561.8 156.9 565.6 166.9C572.4 184.6 576.1 203.9 576.1 224C576.1 267.2 558.9 306.3 531.1 335.1L482 286C448.9 253 403.5 240.3 360.9 247.6L304.1 190.8L304.1 161.1L303.9 156.1C303.1 143.7 299.5 131.8 293.4 121.2C322.8 86.2 366.8 64 416.1 63.9z"></path></svg>`,
Specs: map[string]FormElement{},
}
}
func (this *ToolsDebug) Execute(params map[string]string, input map[string]string) (map[string]string, error) {
Log.Info("[workflow] action=tools/debug input=%v", input)
return input, nil
}

40
server/workflow/config.go Normal file
View file

@ -0,0 +1,40 @@
package workflow
import (
. "github.com/mickael-kerjean/filestash/server/common"
)
func init() {
Hooks.Register.Onload(func() {
PluginEnable()
PluginNumberWorker()
})
}
var PluginEnable = func() bool {
return Config.Get("features.workflow.enable").Schema(func(f *FormElement) *FormElement {
if f == nil {
f = &FormElement{}
}
f.Name = "enable"
f.Type = "enable"
f.Target = []string{"workflow_workers"}
f.Description = "Enable/Disable workflows"
f.Default = true
return f
}).Bool()
}
var PluginNumberWorker = func() int {
return Config.Get("features.workflow.workers").Schema(func(f *FormElement) *FormElement {
if f == nil {
f = &FormElement{}
}
f.Id = "workflow_workers"
f.Name = "workers"
f.Type = "number"
f.Description = "Number of workers running in parallel. Default: 1"
f.Default = 1
return f
}).Int()
}

View file

@ -0,0 +1,73 @@
package workflow
import (
"encoding/json"
"net/http"
. "github.com/mickael-kerjean/filestash/server/common"
. "github.com/mickael-kerjean/filestash/server/workflow/model"
"github.com/gorilla/mux"
)
func WorkflowAll(ctx *App, res http.ResponseWriter, req *http.Request) {
workflows, err := AllWorkflows()
if err != nil {
SendErrorResult(res, err)
return
}
triggers := Hooks.Get.WorkflowTriggers()
tm := make([]WorkflowSpecs, len(triggers))
for i, t := range triggers {
tm[i] = t.Manifest()
}
actions := Hooks.Get.WorkflowActions()
am := make([]WorkflowSpecs, len(actions))
for i, a := range actions {
am[i] = a.Manifest()
}
SendSuccessResult(res, map[string]any{
"workflows": workflows,
"triggers": tm,
"actions": am,
})
}
func WorkflowUpsert(ctx *App, res http.ResponseWriter, req *http.Request) {
var workflow Workflow
if err := json.NewDecoder(req.Body).Decode(&workflow); err != nil {
SendErrorResult(res, ErrInternal)
return
}
if err := UpsertWorkflow(workflow); err != nil {
SendErrorResult(res, err)
return
}
SendSuccessResult(res, nil)
}
func WorkflowGet(ctx *App, res http.ResponseWriter, req *http.Request) {
workflowID := mux.Vars(req)["workflowID"]
if workflowID == "" {
SendErrorResult(res, ErrNotValid)
return
}
workflow, err := GetWorkflow(workflowID)
if err != nil {
SendErrorResult(res, err)
return
}
SendSuccessResult(res, workflow)
}
func WorkflowDelete(ctx *App, res http.ResponseWriter, req *http.Request) {
id := req.URL.Query().Get("id")
if id == "" {
SendErrorResult(res, ErrNotValid)
return
} else if err := DeleteWorkflow(id); err != nil {
SendErrorResult(res, err)
return
}
SendSuccessResult(res, nil)
}

64
server/workflow/index.go Normal file
View file

@ -0,0 +1,64 @@
package workflow
import (
"time"
. "github.com/mickael-kerjean/filestash/server/common"
. "github.com/mickael-kerjean/filestash/server/workflow/model"
)
var (
job_event = make(chan interface{}, 100)
workflow_enable = false
)
func Init() error {
if err := InitState(); err != nil {
return err
} else if PluginEnable() == false {
Log.Debug("[workflow] state=disabled")
return nil
}
Log.Debug("[workflow] state=enabled worker=%d", PluginNumberWorker())
triggers := Hooks.Get.WorkflowTriggers()
for i := 0; i < len(triggers); i++ {
t, err := triggers[i].Init()
if err != nil {
return err
}
go func(t chan ITriggerEvent) {
for trigger := range t {
if err := CreateJob(trigger.WorkflowID(), trigger.Input()); err != nil {
Log.Error("[workflow] action=createJob err=%s", err.Error())
}
select {
case job_event <- nil:
default:
}
}
}(t)
}
for i := 0; i < PluginNumberWorker(); i++ {
go func(i int) {
time.Sleep(time.Duration((i+1)*100) * time.Millisecond)
for {
select {
case <-job_event:
case <-time.After(60 * time.Second):
}
jobID, workflow, input, err := NextJob()
if err == ErrNotFound {
continue
} else if err != nil {
Log.Error("[workflow] type=worker err=%s", err.Error())
time.Sleep(10 * time.Second)
continue
}
ExecuteJob(jobID, workflow, input)
}
}(i)
}
return nil
}

29
server/workflow/job.go Normal file
View file

@ -0,0 +1,29 @@
package workflow
import (
. "github.com/mickael-kerjean/filestash/server/workflow/model"
)
func ExecuteJob(jobID string, workflow Workflow, input map[string]string) {
var err error
UpdateJob(jobID, "RUNNING", workflow.Actions, input)
for i := 0; i < len(workflow.Actions); i++ {
if workflow.Actions[i].Done {
continue
}
input, err = ExecuteAction(workflow.Actions[i], input)
workflow.Actions[i].Done = true
if err != nil {
status := "FAILURE"
workflow.Actions[i].Done = false
if input["status"] == "PENDING" {
status = "PENDING"
}
UpdateJob(jobID, status, workflow.Actions, input)
return
}
UpdateJob(jobID, "RUNNING", workflow.Actions, input)
}
UpdateJob(jobID, "SUCCESS", workflow.Actions, map[string]string{})
return
}

View file

@ -0,0 +1,13 @@
package model
import (
. "github.com/mickael-kerjean/filestash/server/common"
)
type StepDefinition struct {
Name string `json:"name"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Icon string `json:"icon"`
Specs map[string]FormElement `json:"specs"`
}

View file

@ -0,0 +1,50 @@
package model
import (
"database/sql"
_ "github.com/mattn/go-sqlite3"
"github.com/mickael-kerjean/filestash/server/common"
)
var db *sql.DB
func InitState() (err error) {
db, err = sql.Open("sqlite3", common.GetAbsolutePath(common.DB_PATH, "workflow.sql"))
if err != nil {
return err
}
db.Exec(`
CREATE TABLE IF NOT EXISTS workflows (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
published BOOLEAN DEFAULT 0,
trigger TEXT NOT NULL, -- JSON encoded Step
actions TEXT NOT NULL, -- JSON encoded []Step
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_workflows_trigger_name ON workflows(json_extract(trigger, '$.name'));`)
db.Exec(`
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
related_workflow TEXT NOT NULL,
status TEXT CHECK(status IN ('READY', 'PENDING', 'CLAIMED', 'RUNNING', 'SUCCESS', 'FAILURE')) DEFAULT 'READY',
steps TEXT NOT NULL,
input TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (related_workflow) REFERENCES workflows(id)
);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
CREATE INDEX IF NOT EXISTS idx_jobs_workflow ON jobs(related_workflow, created_at DESC);`)
db.Exec(`
UPDATE jobs
SET status = 'READY', updated_at = CURRENT_TIMESTAMP
WHERE status IN ('RUNNING', 'CLAIMED')`)
return nil
}

View file

@ -0,0 +1,116 @@
package model
import (
"database/sql"
"encoding/json"
. "github.com/mickael-kerjean/filestash/server/common"
)
type Job struct {
ID int `json:"id"`
RelatedWorkflow string `json:"related_workflow"`
Status string `json:"status"`
Steps []Step `json:"steps"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func CreateJob(workflowID string, input map[string]string) error {
workflow, err := GetWorkflow(workflowID)
if err != nil {
return err
}
stepsJSON, err := json.Marshal(workflow.Actions)
if err != nil {
return err
}
inputJSON, err := json.Marshal(input)
if err != nil {
return err
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.Exec(`
INSERT INTO jobs (related_workflow, status, steps, input)
VALUES (?, 'READY', ?, ?)
`, workflowID, string(stepsJSON), string(inputJSON)); err != nil {
return err
}
if _, err = tx.Exec(`DELETE FROM jobs WHERE related_workflow = ? AND id NOT IN (
SELECT id FROM jobs
WHERE related_workflow = ?
ORDER BY created_at DESC
LIMIT 1000
)`, workflowID, workflowID); err != nil {
return err
}
return tx.Commit()
}
func NextJob() (string, Workflow, map[string]string, error) {
tx, err := db.Begin()
if err != nil {
return "", Workflow{}, nil, err
}
defer tx.Rollback()
query := `
SELECT id, related_workflow, steps, input
FROM jobs
WHERE status = 'READY'
ORDER BY updated_at ASC
LIMIT 1`
var (
jobID string
workflowID string
stepsJSON string
inputJSON string
)
if err = tx.QueryRow(query).Scan(&jobID, &workflowID, &stepsJSON, &inputJSON); err != nil {
if err == sql.ErrNoRows {
return "", Workflow{}, nil, ErrNotFound
}
return "", Workflow{}, nil, err
}
if _, err = tx.Exec(`UPDATE jobs SET status = 'CLAIMED', updated_at = CURRENT_TIMESTAMP WHERE id = ?`, jobID); err != nil {
return "", Workflow{}, nil, err
} else if err = tx.Commit(); err != nil {
return "", Workflow{}, nil, err
}
workflow, err := GetWorkflow(workflowID)
if err != nil {
return "", Workflow{}, nil, err
}
var input map[string]string
if err = json.Unmarshal([]byte(inputJSON), &input); err != nil {
return "", Workflow{}, nil, err
}
input["jobID"] = jobID
input["workflowID"] = workflow.ID
input["trigger"] = workflow.Trigger.Name
return jobID, workflow, input, nil
}
func UpdateJob(jobID string, status string, steps []Step, input map[string]string) {
stepsJSON, err := json.Marshal(steps)
if err != nil {
Log.Error("[workflow] from=job on=updateJob step=marshal err=%s", err.Error())
return
}
inputJSON, err := json.Marshal(input)
if err != nil {
Log.Error("[workflow] from=job on=updateJob step=marshal err=%s", err.Error())
return
}
query := `
UPDATE jobs
SET status = ?, steps = ?, input = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
if _, err := db.Exec(query, status, string(stepsJSON), string(inputJSON), jobID); err != nil {
Log.Error("[workflow] from=job on=updateJob err=%s", err.Error())
}
}

View file

@ -0,0 +1,150 @@
package model
import (
"database/sql"
"encoding/json"
. "github.com/mickael-kerjean/filestash/server/common"
)
type Workflow struct {
ID string `json:"id"`
Name string `json:"name"`
Published bool `json:"published"`
Trigger Step `json:"trigger"`
Actions []Step `json:"actions"`
UpdatedAt string `json:"updated_at"`
CreatedAt string `json:"created_at"`
History []any `json:"history"`
}
type Step struct {
Name string `json:"name"`
Params map[string]string `json:"params",omitzero`
Done bool `json:"done,omitempty"`
}
func FindWorkflows(triggerName string) ([]Workflow, error) {
rows, err := db.Query(`
SELECT w.id, w.name, w.published, w.trigger, w.actions, w.created_at, w.updated_at
FROM workflows w
WHERE json_extract(w.trigger, '$.name') = ?
ORDER BY w.created_at DESC
`, triggerName)
if err != nil {
return nil, err
}
defer rows.Close()
var workflows = []Workflow{}
for rows.Next() {
var w Workflow
var triggerJSON, actionsJSON string
if err := rows.Scan(&w.ID, &w.Name, &w.Published, &triggerJSON, &actionsJSON, &w.CreatedAt, &w.UpdatedAt); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(triggerJSON), &w.Trigger); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(actionsJSON), &w.Actions); err != nil {
return nil, err
}
workflows = append(workflows, w)
}
return workflows, rows.Err()
}
func AllWorkflows() ([]Workflow, error) {
rows, err := db.Query(`
SELECT id, name, published, trigger, actions, created_at, updated_at
FROM workflows
ORDER BY created_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var workflows = []Workflow{}
for rows.Next() {
var w Workflow
var triggerJSON, actionsJSON string
err := rows.Scan(&w.ID, &w.Name, &w.Published, &triggerJSON, &actionsJSON, &w.CreatedAt, &w.UpdatedAt)
if err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(triggerJSON), &w.Trigger); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(actionsJSON), &w.Actions); err != nil {
return nil, err
}
workflows = append(workflows, w)
}
return workflows, rows.Err()
}
func UpsertWorkflow(workflow Workflow) error {
triggerJSON, err := json.Marshal(workflow.Trigger)
if err != nil {
return err
}
actionsJSON, err := json.Marshal(workflow.Actions)
if err != nil {
return err
}
query := `
INSERT OR REPLACE INTO workflows (id, name, published, trigger, actions, updated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`
_, err = db.Exec(query, workflow.ID, workflow.Name, workflow.Published, string(triggerJSON), string(actionsJSON))
return err
}
func GetWorkflow(id string) (Workflow, error) {
query := `
SELECT w.id, w.name, w.published, w.trigger, w.actions, w.created_at, w.updated_at,
(SELECT COALESCE(JSON_GROUP_ARRAY(JSON_OBJECT(
'id', j.id, 'status', j.status, 'created_at', j.created_at, 'steps', j.steps
)), JSON_ARRAY()) FROM (
SELECT * FROM jobs j
WHERE j.related_workflow = w.id
ORDER BY j.created_at DESC
LIMIT 3000
) j) as history
FROM workflows w
WHERE w.id = ?`
row := db.QueryRow(query, id)
var w Workflow
var triggerJSON, actionsJSON, historyJSON string
if err := row.Scan(&w.ID, &w.Name, &w.Published, &triggerJSON, &actionsJSON, &w.CreatedAt, &w.UpdatedAt, &historyJSON); err != nil {
if err == sql.ErrNoRows {
return Workflow{}, ErrNotFound
}
return Workflow{}, err
}
if err := json.Unmarshal([]byte(triggerJSON), &w.Trigger); err != nil {
return Workflow{}, err
}
if err := json.Unmarshal([]byte(actionsJSON), &w.Actions); err != nil {
return Workflow{}, err
}
if err := json.Unmarshal([]byte(historyJSON), &w.History); err != nil {
return Workflow{}, err
}
return w, nil
}
func DeleteWorkflow(id string) error {
result, err := db.Exec(`DELETE FROM workflows WHERE id = ?`, id)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
} else if rowsAffected == 0 {
return ErrNotFound
}
return nil
}

View file

@ -0,0 +1,5 @@
package workflow
import (
_ "github.com/mickael-kerjean/filestash/server/workflow/trigger"
)

View file

@ -0,0 +1,123 @@
package trigger
import (
"strings"
. "github.com/mickael-kerjean/filestash/server/common"
)
var (
fileaction_event = make(chan ITriggerEvent, 1)
fileaction_name = "event"
)
func init() {
Hooks.Register.WorkflowTrigger(&FileEventTrigger{})
Hooks.Register.AuthorisationMiddleware(hookAuthorisation{})
}
type hookAuthorisation struct{}
func (this hookAuthorisation) Ls(ctx *App, path string) error {
processFileAction(ctx, map[string]string{"event": "ls", "path": path})
return nil
}
func (this hookAuthorisation) Cat(ctx *App, path string) error {
processFileAction(ctx, map[string]string{"event": "cat", "path": path})
return nil
}
func (this hookAuthorisation) Mkdir(ctx *App, path string) error {
processFileAction(ctx, map[string]string{"event": "mkdir", "path": path})
return nil
}
func (this hookAuthorisation) Rm(ctx *App, path string) error {
processFileAction(ctx, map[string]string{"event": "rm", "path": path})
return nil
}
func (this hookAuthorisation) Mv(ctx *App, from string, to string) error {
processFileAction(ctx, map[string]string{"event": "mv", "path": from + ", " + to})
return nil
}
func (this hookAuthorisation) Save(ctx *App, path string) error {
processFileAction(ctx, map[string]string{"event": "save", "path": path})
return nil
}
func (this hookAuthorisation) Touch(ctx *App, path string) error {
processFileAction(ctx, map[string]string{"event": "touch", "path": path})
return nil
}
type FileEventTrigger struct{}
func (this *FileEventTrigger) Manifest() WorkflowSpecs {
return WorkflowSpecs{
Name: fileaction_name,
Title: "When Something Happen",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M192 64C156.7 64 128 92.7 128 128L128 368L310.1 368L279.1 337C269.7 327.6 269.7 312.4 279.1 303.1C288.5 293.8 303.7 293.7 313 303.1L385 375.1C394.4 384.5 394.4 399.7 385 409L313 481C303.6 490.4 288.4 490.4 279.1 481C269.8 471.6 269.7 456.4 279.1 447.1L310.1 416.1L128 416.1L128 512.1C128 547.4 156.7 576.1 192 576.1L448 576.1C483.3 576.1 512 547.4 512 512.1L512 234.6C512 217.6 505.3 201.3 493.3 189.3L386.7 82.7C374.7 70.7 358.5 64 341.5 64L192 64zM453.5 240L360 240C346.7 240 336 229.3 336 216L336 122.5L453.5 240z"/></svg>`,
Specs: map[string]FormElement{
"event": {
Type: "text",
Datalist: []string{"ls", "cat", "mkdir", "mv", "rm", "touch"},
MultiValue: true,
},
"path": {
Type: "text",
},
},
Order: 3,
}
}
func (this *FileEventTrigger) Init() (chan ITriggerEvent, error) {
return fileaction_event, nil
}
func processFileAction(ctx *App, params map[string]string) {
if ctx.Context.Value("AUDIT") == false {
return
}
if err := triggerEvents(fileaction_event, fileaction_name, fileactionCallback(params)); err != nil {
Log.Error("[workflow] trigger=event step=triggerEvents err=%s", err.Error())
}
}
func fileactionCallback(out map[string]string) func(map[string]string) (map[string]string, bool) {
return func(params map[string]string) (map[string]string, bool) {
if !matchEvent(params["event"], out["event"]) {
return out, false
} else if !matchPath(params["path"], out["path"]) {
return out, false
}
return out, true
}
}
func matchEvent(paramValue string, eventValue string) bool {
if paramValue == "" {
return true
}
for _, pvalue := range strings.Split(paramValue, ",") {
if strings.TrimSpace(pvalue) == eventValue {
return true
}
}
return false
}
func matchPath(paramValue string, eventValue string) bool {
if paramValue == "" {
return true
}
for _, epath := range strings.Split(eventValue, ",") {
if GlobMatch(paramValue, strings.TrimSpace(epath)) {
return true
}
}
return false
}

View file

@ -0,0 +1,117 @@
package trigger
import (
"context"
"encoding/json"
"os"
"sync"
"time"
. "github.com/mickael-kerjean/filestash/server/common"
"github.com/mickael-kerjean/filestash/server/model"
)
var (
filewatch_event = make(chan ITriggerEvent, 1)
filewatch_name = "watch"
filewatch_state sync.Map
)
func init() {
Hooks.Register.WorkflowTrigger(&WatchTrigger{})
}
type WatchTrigger struct{}
func (this *WatchTrigger) Manifest() WorkflowSpecs {
return WorkflowSpecs{
Name: "watch",
Title: "When the Filesystem Changes",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M128 64C92.7 64 64 92.7 64 128L64 512C64 547.3 92.7 576 128 576L308 576C285.3 544.5 272 505.8 272 464C272 363.4 349.4 280.8 448 272.7L448 234.6C448 217.6 441.3 201.3 429.3 189.3L322.7 82.7C310.7 70.7 294.5 64 277.5 64L128 64zM389.5 240L296 240C282.7 240 272 229.3 272 216L272 122.5L389.5 240zM464 608C543.5 608 608 543.5 608 464C608 384.5 543.5 320 464 320C384.5 320 320 384.5 320 464C320 543.5 384.5 608 464 608zM480 400L480 448L528 448C536.8 448 544 455.2 544 464C544 472.8 536.8 480 528 480L480 480L480 528C480 536.8 472.8 544 464 544C455.2 544 448 536.8 448 528L448 480L400 480C391.2 480 384 472.8 384 464C384 455.2 391.2 448 400 448L448 448L448 400C448 391.2 455.2 384 464 384C472.8 384 480 391.2 480 400z"/></svg>`,
Specs: map[string]FormElement{
"token": {
Type: "text",
},
"path": {
Type: "text",
},
},
Order: 4,
}
}
func (this *WatchTrigger) Init() (chan ITriggerEvent, error) {
go func() {
for {
if err := triggerEvents(filewatch_event, filewatch_name, filewatchCallback); err != nil {
Log.Error("[workflow] trigger=watch step=triggerEvents err=%s", err.Error())
}
time.Sleep(10 * time.Second)
}
}()
return filewatch_event, nil
}
func filewatchCallback(params map[string]string) (map[string]string, bool) {
out := map[string]string{"path": params["path"]}
backend, session, err := createBackend(params["token"])
if err != nil {
Log.Error("[workflow] trigger=filewatch step=callback::init err=%s", err.Error())
return out, false
}
files, err := backend.Ls(params["path"])
if err != nil {
Log.Error("[workflow] trigger=filewatch step=callback::ls err=%s", err.Error())
return out, false
}
key := GenerateID(session) + params["path"]
fincache, exists := filewatch_state.Load(key)
if !exists {
filewatch_state.Store(key, files)
return out, false
}
prevFiles := fincache.([]os.FileInfo)
if len(files) != len(prevFiles) {
filewatch_state.Store(key, files)
return out, true
}
changes := []string{}
for i := 0; i < len(files); i++ {
hasChange := false
if files[i].Name() != prevFiles[i].Name() {
hasChange = true
} else if files[i].Size() != prevFiles[i].Size() {
hasChange = true
} else if files[i].ModTime() != prevFiles[i].ModTime() {
hasChange = true
}
if hasChange {
p := JoinPath(params["path"], files[i].Name())
if files[i].IsDir() {
p = EnforceDirectory(p)
}
changes = append(changes, p)
}
}
if len(changes) > 0 {
filewatch_state.Store(key, files)
return out, true
}
return out, false
}
func createBackend(token string) (IBackend, map[string]string, error) {
session := map[string]string{}
str, err := DecryptString(SECRET_KEY_DERIVATE_FOR_USER, token)
if err != nil {
return nil, session, err
}
if err = json.Unmarshal([]byte(str), &session); err != nil {
return nil, session, err
}
backend, err := model.NewBackend(
&App{Context: context.Background()},
session,
)
return backend, session, err
}

View file

@ -0,0 +1,55 @@
package trigger
import (
"encoding/json"
"net/http"
. "github.com/mickael-kerjean/filestash/server/common"
. "github.com/mickael-kerjean/filestash/server/workflow/model"
)
type TriggerEvent struct {
ID string
Params map[string]string
}
func (this TriggerEvent) Input() map[string]string {
return this.Params
}
func (this *TriggerEvent) WorkflowID() string {
return this.ID
}
func triggerEvents(event chan ITriggerEvent, triggerID string, callback func(params map[string]string) (map[string]string, bool)) error {
workflows, err := FindWorkflows(triggerID)
if err != nil {
return err
}
for _, workflow := range workflows {
if !workflow.Published {
continue
}
params, emit := callback(workflow.Trigger.Params)
if !emit {
continue
}
select {
case event <- &TriggerEvent{
ID: workflow.ID,
Params: params,
}:
default:
return NewError("Workflow is busy", http.StatusServiceUnavailable)
}
}
return nil
}
func toJSON(val any) string {
b, err := json.Marshal(val)
if err != nil {
return "{}"
}
return string(b)
}

View file

@ -0,0 +1,66 @@
package trigger
import (
"time"
. "github.com/mickael-kerjean/filestash/server/common"
)
var (
cron_name = "schedule"
cron_event = make(chan ITriggerEvent, 10)
)
func init() {
Hooks.Register.WorkflowTrigger(&ScheduleTrigger{})
}
type ScheduleTrigger struct{}
func (this *ScheduleTrigger) Manifest() WorkflowSpecs {
return WorkflowSpecs{
Name: cron_name,
Title: "On a Schedule",
Subtitle: "frequency",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M528 320C528 434.9 434.9 528 320 528C205.1 528 112 434.9 112 320C112 205.1 205.1 112 320 112C434.9 112 528 205.1 528 320zM64 320C64 461.4 178.6 576 320 576C461.4 576 576 461.4 576 320C576 178.6 461.4 64 320 64C178.6 64 64 178.6 64 320zM296 184L296 320C296 328 300 335.5 306.7 340L402.7 404C413.7 411.4 428.6 408.4 436 397.3C443.4 386.2 440.4 371.4 429.3 364L344 307.2L344 184C344 170.7 333.3 160 320 160C306.7 160 296 170.7 296 184z"></path></svg>`,
Specs: map[string]FormElement{
"frequency": {
Type: "select",
Opts: []string{"per-minute", "hourly", "daily", "weekly", "monthly"},
Value: "daily",
},
},
Order: 1,
}
}
func (this *ScheduleTrigger) Init() (chan ITriggerEvent, error) {
go func() {
for {
if err := triggerEvents(cron_event, cron_name, scheduleCallback); err != nil {
Log.Error("[workflow] trigger=schedule step=triggerEvents err=%s", err.Error())
}
time.Sleep(60 * time.Second)
}
}()
return cron_event, nil
}
func scheduleCallback(params map[string]string) (map[string]string, bool) {
shouldTrigger := false
now := time.Now()
out := map[string]string{"frequency": params["frequency"]}
switch params["frequency"] {
case "per-minute":
shouldTrigger = true
case "hourly":
shouldTrigger = now.Minute() == 0
case "daily":
shouldTrigger = now.Hour() == 0 && now.Minute() == 0
case "weekly":
shouldTrigger = now.Weekday() == time.Sunday && now.Hour() == 0 && now.Minute() == 0
case "monthly":
shouldTrigger = now.Day() == 1 && now.Hour() == 0 && now.Minute() == 0
}
return out, shouldTrigger
}

View file

@ -0,0 +1,69 @@
package trigger
import (
"net/http"
"strings"
. "github.com/mickael-kerjean/filestash/server/common"
"github.com/gorilla/mux"
)
var (
webhook_event = make(chan ITriggerEvent, 5)
webhook_name = "webhook"
)
func init() {
Hooks.Register.WorkflowTrigger(&WebhookTrigger{})
Hooks.Register.HttpEndpoint(func(r *mux.Router, app *App) error {
r.HandleFunc(WithBase("/api/workflow/webhook"), func(w http.ResponseWriter, r *http.Request) {
if err := triggerEvents(webhook_event, webhook_name, webhookCallback(r)); err != nil {
SendErrorResult(w, err)
return
}
SendSuccessResult(w, nil)
}).Methods("GET", "POST")
return nil
})
}
func webhookCallback(r *http.Request) func(params map[string]string) (map[string]string, bool) {
return func(params map[string]string) (map[string]string, bool) {
headers := map[string]any{}
for k, v := range r.Header {
headers[k] = strings.Join(v, ", ")
}
query := map[string]any{}
for k, v := range r.URL.Query() {
query[k] = strings.Join(v, ", ")
}
return map[string]string{
"method": r.Method,
"headers": toJSON(headers),
"query": toJSON(query),
}, true
}
}
type WebhookTrigger struct{}
func (this *WebhookTrigger) Manifest() WorkflowSpecs {
return WorkflowSpecs{
Name: webhook_name,
Title: "From a WebHook",
Icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M392.8 65.2C375.8 60.3 358.1 70.2 353.2 87.2L225.2 535.2C220.3 552.2 230.2 569.9 247.2 574.8C264.2 579.7 281.9 569.8 286.8 552.8L414.8 104.8C419.7 87.8 409.8 70.1 392.8 65.2zM457.4 201.3C444.9 213.8 444.9 234.1 457.4 246.6L530.8 320L457.4 393.4C444.9 405.9 444.9 426.2 457.4 438.7C469.9 451.2 490.2 451.2 502.7 438.7L598.7 342.7C611.2 330.2 611.2 309.9 598.7 297.4L502.7 201.4C490.2 188.9 469.9 188.9 457.4 201.4zM182.7 201.3C170.2 188.8 149.9 188.8 137.4 201.3L41.4 297.3C28.9 309.8 28.9 330.1 41.4 342.6L137.4 438.6C149.9 451.1 170.2 451.1 182.7 438.6C195.2 426.1 195.2 405.8 182.7 393.3L109.3 320L182.6 246.6C195.1 234.1 195.1 213.8 182.6 201.3z"/></svg>`,
Specs: map[string]FormElement{
"url": {
Type: "text",
ReadOnly: true,
Value: "/api/workflow/webhook",
},
},
Order: 5,
}
}
func (this *WebhookTrigger) Init() (chan ITriggerEvent, error) {
return webhook_event, nil
}