diff --git a/internal/app/action/action.go b/internal/app/action/action.go index 0b08a10..e09f1a5 100644 --- a/internal/app/action/action.go +++ b/internal/app/action/action.go @@ -12,6 +12,7 @@ import ( "io/fs" "net/http" "net/url" + "os" "path" "slices" "strconv" @@ -177,7 +178,7 @@ func (a *Action) runAction(w http.ResponseWriter, r *http.Request) { } isHtmxRequest := r.Header.Get("HX-Request") == "true" - r.ParseForm() + r.ParseMultipartForm(10 << 20) // 10 MB max file size var err error dryRun := false dryRunStr := r.Form.Get("dry-run") @@ -210,23 +211,71 @@ func (a *Action) runAction(w http.ResponseWriter, r *http.Request) { qsParams := url.Values{} + var tempDir string // Update args with submitted form values for _, param := range a.params { - formValue := r.Form.Get(param.Name) - if formValue == "" { - if param.Type == starlark_type.BOOLEAN { - // Form does not submit unchecked checkboxes, set to false - args[param.Name] = starlark.Bool(false) - qsParams.Add(param.Name, "false") + if a.hidden[param.Name] { + continue + } + + if param.DisplayType == apptype.DisplayTypeFileUpload { + f, fh, err := r.FormFile(param.Name) + if err == http.ErrMissingFile { + args[param.Name] = starlark.String("") + continue } - } else { - newVal, err := apptype.ParamStringToType(param.Name, param.Type, formValue) + + if err != nil { + http.Error(w, fmt.Sprintf("error getting file %s: %s", param.Name, err), http.StatusBadRequest) + return + } + + if tempDir == "" { + tempDir, err = os.MkdirTemp("", "clace-file-upload-*") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + defer func() { + if remErr := os.RemoveAll(tempDir); remErr != nil { + a.Error().Err(remErr).Msg("error removing temp dir") + } + }() + } + + fullPath := path.Join(tempDir, fh.Filename) + destFile, err := os.Create(fullPath) if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer destFile.Close() + + // Write contents of uploaded file to destFile + if _, err = io.Copy(destFile, f); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) return } - args[param.Name] = newVal - qsParams.Add(param.Name, formValue) + args[param.Name] = starlark.String(fullPath) + } else { + // Not file upload, regular param + formValue := r.Form.Get(param.Name) + if formValue == "" { + if param.Type == starlark_type.BOOLEAN { + // Form does not submit unchecked checkboxes, set to false + args[param.Name] = starlark.Bool(false) + qsParams.Add(param.Name, "false") + } + } else { + newVal, err := apptype.ParamStringToType(param.Name, param.Type, formValue) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + args[param.Name] = newVal + qsParams.Add(param.Name, formValue) + } } } @@ -540,11 +589,13 @@ func RunDeferredCleanup(thread *starlark.Thread) error { } type ParamDef struct { - Name string - Description string - Value any - InputType string - Options []string + Name string + Description string + Value any + InputType string + Options []string + DisplayType string + DisplayTypeOptions string } const ( @@ -570,6 +621,7 @@ func (a *Action) getForm(w http.ResponseWriter, r *http.Request) { } } + hasFileUpload := false for _, p := range a.params { if strings.HasPrefix(p.Name, OPTIONS_PREFIX) || a.hidden[p.Name] { continue @@ -610,6 +662,24 @@ func (a *Action) getForm(w http.ResponseWriter, r *http.Request) { param.Value = value } + if p.DisplayType != "" { + switch p.DisplayType { + case apptype.DisplayTypePassword: + param.DisplayType = "password" + case apptype.DisplayTypeTextArea: + param.DisplayType = "textarea" + case apptype.DisplayTypeFileUpload: + param.DisplayType = "file" + hasFileUpload = true + default: + http.Error(w, fmt.Sprintf("invalid display type for %s: %s", p.Name, p.DisplayType), http.StatusInternalServerError) + return + } + param.DisplayTypeOptions = p.DisplayTypeOptions + } else { + param.DisplayType = "text" + } + params = append(params, param) } @@ -624,16 +694,17 @@ func (a *Action) getForm(w http.ResponseWriter, r *http.Request) { } input := map[string]any{ - "dev": a.isDev, - "name": a.name, - "description": a.description, - "appPath": a.appPath, - "pagePath": a.pagePath, - "params": params, - "styleType": string(a.StyleType), - "lightTheme": a.LightTheme, - "darkTheme": a.DarkTheme, - "links": linksWithQS, + "dev": a.isDev, + "name": a.name, + "description": a.description, + "appPath": a.appPath, + "pagePath": a.pagePath, + "params": params, + "styleType": string(a.StyleType), + "lightTheme": a.LightTheme, + "darkTheme": a.DarkTheme, + "links": linksWithQS, + "hasFileUpload": hasFileUpload, } err := a.actionTemplate.ExecuteTemplate(w, "form.go.html", input) if err != nil { diff --git a/internal/app/action/form.go.html b/internal/app/action/form.go.html index 82d7041..c832522 100644 --- a/internal/app/action/form.go.html +++ b/internal/app/action/form.go.html @@ -36,8 +36,11 @@

-
- + {{ range .params }}
{{ else }}
- + {{ if eq .DisplayType "textarea" }} + + {{ else if eq .DisplayType "password" }} + + {{ else if eq .DisplayType "file" }} + + {{ else }} + + {{ end }}
{{ end }} diff --git a/internal/app/apptype/param_loader.go b/internal/app/apptype/param_loader.go index 5881438..4f7f5cf 100644 --- a/internal/app/apptype/param_loader.go +++ b/internal/app/apptype/param_loader.go @@ -8,6 +8,7 @@ import ( "fmt" "regexp" "strconv" + "strings" "github.com/claceio/clace/internal/app/starlark_type" "go.starlark.net/starlark" @@ -18,14 +19,24 @@ const ( PARAM = "param" ) +type DisplayType string + +const ( + DisplayTypePassword DisplayType = "password" + DisplayTypeTextArea DisplayType = "textarea" + DisplayTypeFileUpload DisplayType = "file" +) + // AppParam represents a parameter in an app. type AppParam struct { - Index int - Name string - Description string - Required bool - Type starlark_type.TypeName - DefaultValue starlark.Value + Index int + Name string + Description string + Required bool + Type starlark_type.TypeName + DefaultValue starlark.Value + DisplayType DisplayType + DisplayTypeOptions string } func ReadParamInfo(fileName string, inp []byte) (map[string]AppParam, error) { @@ -80,6 +91,14 @@ func validateParamInfo(paramInfo map[string]AppParam) error { default: return fmt.Errorf("unknown type %s for %s", p.Type, p.Name) } + + if p.DisplayType != "" && p.DisplayType != DisplayTypePassword && p.DisplayType != DisplayTypeTextArea && p.DisplayType != DisplayTypeFileUpload { + return fmt.Errorf("unknown display type %s for %s", p.DisplayType, p.Name) + } + + if p.DisplayType != "" && p.Type != starlark_type.STRING { + return fmt.Errorf("display_type %s is allowed for string type %s only", p.DisplayType, p.Name) + } } return nil } @@ -89,12 +108,12 @@ func LoadParamInfo(fileName string, data []byte) (map[string]AppParam, error) { index := 0 paramBuiltin := func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { - var name, description, dataType starlark.String + var name, description, dataType, displayType starlark.String var defaultValue starlark.Value = starlark.None var required starlark.Bool = starlark.Bool(true) if err := starlark.UnpackArgs(PARAM, args, kwargs, "name", &name, "type?", &dataType, "default?", &defaultValue, - "description?", &description, "required?", &required); err != nil { + "description?", &description, "required?", &required, "display_type?", &displayType); err != nil { return nil, err } @@ -126,34 +145,43 @@ func LoadParamInfo(fileName string, data []byte) (map[string]AppParam, error) { } } + dt, dto, _ := strings.Cut(string(displayType), ":") + index += 1 definedParams[string(name)] = AppParam{ - Index: index, - Name: string(name), - Type: typeVal, - DefaultValue: defaultValue, - Description: string(description), - Required: bool(required), + Index: index, + Name: string(name), + Type: typeVal, + DefaultValue: defaultValue, + Description: string(description), + Required: bool(required), + DisplayType: DisplayType(dt), + DisplayTypeOptions: dto, } paramDict := starlark.StringDict{ - "index": starlark.MakeInt(index), - "name": name, - "type": dataType, - "default": defaultValue, - "description": description, - "required": required, + "index": starlark.MakeInt(index), + "name": name, + "type": dataType, + "default": defaultValue, + "description": description, + "required": required, + "display_type": displayType, + "display_type_options": starlark.String(dto), } return starlarkstruct.FromStringDict(starlark.String(PARAM), paramDict), nil } builtins := starlark.StringDict{ - PARAM: starlark.NewBuiltin(PARAM, paramBuiltin), - string(starlark_type.INT): starlark.String(starlark_type.INT), - string(starlark_type.STRING): starlark.String(starlark_type.STRING), - string(starlark_type.BOOLEAN): starlark.String(starlark_type.BOOLEAN), - string(starlark_type.DICT): starlark.String(starlark_type.DICT), - string(starlark_type.LIST): starlark.String(starlark_type.LIST), + PARAM: starlark.NewBuiltin(PARAM, paramBuiltin), + string(starlark_type.INT): starlark.String(starlark_type.INT), + string(starlark_type.STRING): starlark.String(starlark_type.STRING), + string(starlark_type.BOOLEAN): starlark.String(starlark_type.BOOLEAN), + string(starlark_type.DICT): starlark.String(starlark_type.DICT), + string(starlark_type.LIST): starlark.String(starlark_type.LIST), + strings.ToUpper(string(DisplayTypePassword)): starlark.String(DisplayTypePassword), + strings.ToUpper(string(DisplayTypeTextArea)): starlark.String(DisplayTypeTextArea), + strings.ToUpper(string(DisplayTypeFileUpload)): starlark.String(DisplayTypeFileUpload), } thread := &starlark.Thread{ diff --git a/internal/app/tests/appaction_test.go b/internal/app/tests/appaction_test.go index e349ad8..146d709 100644 --- a/internal/app/tests/appaction_test.go +++ b/internal/app/tests/appaction_test.go @@ -853,3 +853,49 @@ app = ace.app("testApp", } testutil.AssertStringContains(t, body, `
  • test1Action
  • `) } + +func TestDisplayTypes(t *testing.T) { + logger := testutil.TestLogger() + fileData := map[string]string{ + "app.star": ` +def handler(dry_run, args): + return ace.result(status="done", values=[{"a": 1, "b": "abc"}]) + +app = ace.app("testApp", + actions=[ace.action("test1Action", "/test1", handler)]) + `, + "params.star": `param("param1", description="param1 description", default="myvalue", display_type=FILE) +param("param2", description="param2 description", default="myvalue", display_type=PASSWORD) +param("param3", description="param3 description", default="myvalue", display_type=TEXTAREA)`, + } + a, _, err := CreateTestApp(logger, fileData) + if err != nil { + t.Fatalf("Error %s", err) + } + + request := httptest.NewRequest("GET", "/test/test1", nil) + response := httptest.NewRecorder() + a.ServeHTTP(response, request) + + testutil.AssertEqualsInt(t, "code", 200, response.Code) + body := response.Body.String() + testutil.AssertStringContains(t, body, `type="file"`) + testutil.AssertStringContains(t, body, `type="password"`) + testutil.AssertStringContains(t, body, `textarea`) +} + +func TestDisplayTypesError(t *testing.T) { + logger := testutil.TestLogger() + fileData := map[string]string{ + "app.star": ` +def handler(dry_run, args): + return ace.result(status="done", values=[{"a": 1, "b": "abc"}]) + +app = ace.app("testApp", + actions=[ace.action("test1Action", "/test1", handler)]) + `, + "params.star": `param("param1", description="param1 description", type=BOOLEAN, default=False, display_type=FILE)`, + } + _, _, err := CreateTestApp(logger, fileData) + testutil.AssertErrorContains(t, err, "display_type file is allowed for string type param1 only") +}