@@ -6,18 +6,39 @@ import (
66 "fmt"
77 "log"
88 "net/http"
9+ "regexp"
910 "strings"
1011 "time"
1112
1213 wfv1 "github.com/argoproj/argo-workflows/v3/pkg/apis/workflow/v1alpha1"
1314 "github.com/danielgtaylor/huma/v2"
1415 _ "github.com/danielgtaylor/huma/v2/formats/cbor"
16+ k8serrors "k8s.io/apimachinery/pkg/api/errors"
1517
1618 "github.com/hotosm/scaleodm/app/config"
1719 "github.com/hotosm/scaleodm/app/s3"
1820 "github.com/hotosm/scaleodm/app/workflows"
1921)
2022
23+ // isNotFound checks whether an error (possibly wrapped) represents a
24+ // Kubernetes "not found" response.
25+ func isNotFound (err error ) bool {
26+ return k8serrors .IsNotFound (err )
27+ }
28+
29+ // shellSafePattern matches strings that are safe to embed in shell scripts.
30+ // Allows alphanumerics, hyphens, underscores, dots, forward slashes, colons,
31+ // and the equals sign. This prevents shell injection via user-supplied values.
32+ var shellSafePattern = regexp .MustCompile (`^[a-zA-Z0-9\-_./=:@]+$` )
33+
34+ // validateShellSafe checks that a string is safe to embed in a shell script.
35+ func validateShellSafe (value , fieldName string ) error {
36+ if ! shellSafePattern .MatchString (value ) {
37+ return fmt .Errorf ("%s contains invalid characters: only alphanumerics, hyphens, underscores, dots, slashes, colons, equals, and @ are allowed" , fieldName )
38+ }
39+ return nil
40+ }
41+
2142// NodeODM status codes
2243const (
2344 StatusCodeQueued = 10
@@ -167,7 +188,7 @@ func (a *API) registerNodeODMRoutes() {
167188 }
168189
169190 resp := & InfoResponse {}
170- resp .Body .Version = "0.1 .0" // The ScaleODM version (normally the NodeODM version)
191+ resp .Body .Version = "0.2 .0" // The ScaleODM version (normally the NodeODM version)
171192 resp .Body .TaskQueueCount = queueCount
172193 resp .Body .MaxImages = nil // Unlimited
173194 resp .Body .Engine = "odm"
@@ -338,6 +359,22 @@ func (a *API) registerNodeODMRoutes() {
338359 projectID = "odm-project"
339360 }
340361
362+ // Validate all values that will be embedded in shell scripts
363+ if err := validateShellSafe (projectID , "name" ); err != nil {
364+ return nil , huma .NewError (400 , err .Error ())
365+ }
366+ for _ , flag := range odmFlags {
367+ if err := validateShellSafe (flag , "options flag" ); err != nil {
368+ return nil , huma .NewError (400 , err .Error ())
369+ }
370+ }
371+ if err := validateShellSafe (readPath , "readS3Path" ); err != nil {
372+ return nil , huma .NewError (400 , err .Error ())
373+ }
374+ if err := validateShellSafe (writePath , "writeS3Path" ); err != nil {
375+ return nil , huma .NewError (400 , err .Error ())
376+ }
377+
341378 // Determine S3 region & optional endpoint
342379 s3Region := req .S3Region
343380 if s3Region == "" {
@@ -375,7 +412,9 @@ func (a *API) registerNodeODMRoutes() {
375412 s3Region ,
376413 )
377414
378- // Record metadata in database
415+ // Record metadata in database. If this fails, the workflow exists in
416+ // Argo but won't be visible via the API - treat as a hard error so the
417+ // caller knows to retry rather than losing track of the workflow.
379418 _ , err = a .metadataStore .CreateJob (
380419 ctx ,
381420 wf .Name ,
@@ -386,7 +425,8 @@ func (a *API) registerNodeODMRoutes() {
386425 s3Region ,
387426 )
388427 if err != nil {
389- log .Printf ("Warning: Failed to record job metadata: %v" , err )
428+ log .Printf ("ERROR: Failed to record job metadata for workflow %q: %v" , wf .Name , err )
429+ return nil , huma .NewError (500 , "Workflow created but failed to record metadata - retry the request" , err )
390430 }
391431
392432 resp := & TaskNewResponse {}
@@ -601,7 +641,7 @@ func (a *API) registerNodeODMRoutes() {
601641
602642 err := a .workflowClient .DeleteWorkflow (ctx , input .Body .UUID )
603643 if err != nil {
604- if strings . Contains (err . Error (), "not found" ) {
644+ if isNotFound (err ) {
605645 log .Printf ("POST /task/cancel: task %q not found" , input .Body .UUID )
606646 return nil , huma .NewError (404 , "Task not found" )
607647 }
@@ -636,7 +676,7 @@ func (a *API) registerNodeODMRoutes() {
636676
637677 // Delete from Argo
638678 err := a .workflowClient .DeleteWorkflow (ctx , input .Body .UUID )
639- if err != nil && ! strings . Contains (err . Error (), "not found" ) {
679+ if err != nil && ! isNotFound (err ) {
640680 log .Printf ("POST /task/remove: failed to delete workflow for %q: %v" , input .Body .UUID , err )
641681 return nil , huma .NewError (500 , "Failed to remove task" , err )
642682 }
@@ -696,7 +736,7 @@ func (a *API) registerNodeODMRoutes() {
696736 }
697737
698738 // Delete old workflow
699- if err := a .workflowClient .DeleteWorkflow (ctx , input .Body .UUID ); err != nil && ! strings . Contains (err . Error (), "not found" ) {
739+ if err := a .workflowClient .DeleteWorkflow (ctx , input .Body .UUID ); err != nil && ! isNotFound (err ) {
700740 log .Printf ("POST /task/restart: failed to delete old workflow for %q: %v" , input .Body .UUID , err )
701741 }
702742
@@ -736,94 +776,41 @@ func (a *API) registerNodeODMRoutes() {
736776 })
737777
738778 // GET /task/{uuid}/download/{asset} - Download task asset (redirects to pre-signed URL)
739- huma .Register (a .api , huma.Operation {
740- OperationID : "task-uuid-download-asset-get" ,
741- Method : http .MethodGet ,
742- Path : "/task/{uuid}/download/{asset}" ,
743- Summary : "Download task output asset" ,
744- Description : "Redirects to a pre-signed URL for downloading the asset directly from S3" ,
745- Tags : []string {"task" },
746- }, func (ctx context.Context , input * struct {
747- UUID string `path:"uuid" doc:"UUID of the task"`
748- Asset string `path:"asset" doc:"Asset type (all.zip, orthophoto.tif, etc)"`
749- Token string `query:"token" doc:"Authentication token (optional)"`
750- }) (* struct { Body string }, error ) {
751- log .Printf ("GET /task/%s/download/%s: token_provided=%t" , input .UUID , input .Asset , input .Token != "" )
752-
753- // Get job metadata to retrieve write path
754- metadata , err := a .metadataStore .GetJob (ctx , input .UUID )
779+ // Registered as a raw HTTP handler on the mux for reliable redirect support.
780+ // Huma handlers return structured responses which can't express HTTP redirects,
781+ // so we handle this endpoint outside Huma.
782+ a .downloadHandler = http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
783+ uuid := r .PathValue ("uuid" )
784+ asset := r .PathValue ("asset" )
785+ log .Printf ("GET /task/%s/download/%s" , uuid , asset )
786+
787+ metadata , err := a .metadataStore .GetJob (r .Context (), uuid )
755788 if err != nil {
756- log .Printf ("GET /task/%s/download/%s: failed to retrieve metadata: %v" , input .UUID , input .Asset , err )
757- return nil , huma .NewError (500 , "Failed to retrieve task metadata" , err )
789+ log .Printf ("GET /task/%s/download/%s: failed to retrieve metadata: %v" , uuid , asset , err )
790+ http .Error (w , `{"error":"Failed to retrieve task metadata"}` , http .StatusInternalServerError )
791+ return
758792 }
759-
760793 if metadata == nil {
761- log .Printf ("GET /task/%s/download/%s: task not found in metadata store" , input .UUID , input .Asset )
762- return nil , huma .NewError (404 , "Task not found" )
794+ log .Printf ("GET /task/%s/download/%s: task not found" , uuid , asset )
795+ http .Error (w , `{"error":"Task not found"}` , http .StatusNotFound )
796+ return
763797 }
764-
765798 if metadata .WriteS3Path == "" {
766- log .Printf ("GET /task/%s/download/%s: write S3 path not available" , input .UUID , input .Asset )
767- return nil , huma .NewError (400 , "Write S3 path not available for this task" )
799+ log .Printf ("GET /task/%s/download/%s: write S3 path not available" , uuid , asset )
800+ http .Error (w , `{"error":"Write S3 path not available for this task"}` , http .StatusBadRequest )
801+ return
768802 }
769803
770- // Get S3 client
771804 s3Client := s3 .GetS3Client ()
772-
773- // Generate pre-signed URL (valid for 1 hour)
774- presignedURL , err := s3 .GeneratePresignedURL (ctx , s3Client , metadata .WriteS3Path , input .Asset , 1 * time .Hour )
805+ presignedURL , err := s3 .GeneratePresignedURL (r .Context (), s3Client , metadata .WriteS3Path , asset , 1 * time .Hour )
775806 if err != nil {
776- log .Printf ("GET /task/%s/download/%s: failed to generate pre-signed URL: %v" , input .UUID , input .Asset , err )
777- return nil , huma .NewError (404 , fmt .Sprintf ("File not found: %s" , input .Asset ), err )
778- }
779-
780- log .Printf ("GET /task/%s/download/%s: redirecting to pre-signed URL (expires in 1 hour)" , input .UUID , input .Asset )
781-
782- // Access response writer and request from humago adapter's context
783- // The humago adapter stores the response writer using a specific context key
784- // We need to access it through the adapter's internal context storage
785- type responseWriterKey struct {}
786- type requestKey struct {}
787-
788- var rw http.ResponseWriter
789- var req * http.Request
790-
791- // Try accessing through typed context keys
792- if val := ctx .Value (responseWriterKey {}); val != nil {
793- rw , _ = val .(http.ResponseWriter )
794- }
795- if val := ctx .Value (requestKey {}); val != nil {
796- req , _ = val .(* http.Request )
797- }
798-
799- // Try string-based keys (some adapters use these)
800- if rw == nil {
801- if val := ctx .Value ("http.responseWriter" ); val != nil {
802- rw , _ = val .(http.ResponseWriter )
803- }
804- }
805- if req == nil {
806- if val := ctx .Value ("http.request" ); val != nil {
807- req , _ = val .(* http.Request )
808- }
807+ log .Printf ("GET /task/%s/download/%s: failed to generate pre-signed URL: %v" , uuid , asset , err )
808+ http .Error (w , fmt .Sprintf (`{"error":"File not found: %s"}` , asset ), http .StatusNotFound )
809+ return
809810 }
810-
811- // Try getting from http.ServerContextKey
812- if req == nil {
813- if httpReq , ok := ctx .Value (http .ServerContextKey ).(* http.Request ); ok && httpReq != nil {
814- req = httpReq
815- }
816- }
817-
818- if rw != nil && req != nil {
819- // Perform redirect
820- http .Redirect (rw , req , presignedURL , http .StatusFound )
821- return nil , nil
822- }
823-
824- // Fallback: Return the URL in response body if redirect not possible
825- // This provides graceful degradation - client can still get the URL
826- return & struct { Body string }{Body : presignedURL }, nil
811+
812+ log .Printf ("GET /task/%s/download/%s: redirecting to pre-signed URL (expires in 1 hour)" , uuid , asset )
813+ http .Redirect (w , r , presignedURL , http .StatusFound )
827814 })
828815}
829816
0 commit comments