jamesread 3 месяцев назад
Родитель
Сommit
f44d554a03
31 измененных файлов с 1688 добавлено и 179 удалено
  1. 29 4
      config.yaml
  2. 10 0
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts
  3. 0 0
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js
  4. 344 43
      frontend/resources/vue/views/ArgumentForm.vue
  5. 2 0
      proto/olivetin/api/v1/olivetin.proto
  6. 21 2
      service/gen/olivetin/api/v1/olivetin.pb.go
  7. 2 1
      service/go.mod
  8. 2 0
      service/go.sum
  9. 1 1
      service/internal/api/api.go
  10. 2 0
      service/internal/api/apiActions.go
  11. 15 7
      service/internal/api/api_test.go
  12. 33 0
      service/internal/api/upload.go
  13. 139 0
      service/internal/api/upload_helpers.go
  14. 17 0
      service/internal/auth/authcheck.go
  15. 42 0
      service/internal/config/config.go
  16. 2 1
      service/internal/config/config_reloader.go
  17. 103 0
      service/internal/config/human_readable_bytes.go
  18. 60 0
      service/internal/config/human_readable_bytes_test.go
  19. 65 40
      service/internal/executor/arguments.go
  20. 50 31
      service/internal/executor/arguments_test.go
  21. 128 0
      service/internal/executor/arguments_upload.go
  22. 51 10
      service/internal/executor/executor.go
  23. 1 1
      service/internal/executor/executor_actions.go
  24. 47 0
      service/internal/fileupload/mime.go
  25. 38 0
      service/internal/fileupload/mime_test.go
  26. 301 0
      service/internal/fileupload/registry.go
  27. 64 0
      service/internal/fileupload/registry_test.go
  28. 50 0
      service/internal/fileupload/staging.go
  29. 2 0
      service/internal/httpservers/frontend.go
  30. 34 14
      service/internal/tpl/templates.go
  31. 33 24
      service/internal/tpl/templates_test.go

+ 29 - 4
config.yaml

@@ -6,7 +6,7 @@
 listenAddressSingleHTTPFrontend: 0.0.0.0:1337
 
 # Choose from INFO (default), WARN and DEBUG
-# Docs: https://docs.olivetin.app/advanced_configuration/logs.html 
+# Docs: https://docs.olivetin.app/advanced_configuration/logs.html
 logLevel: "INFO"
 
 # Actions are commands that are executed by OliveTin, and normally show up as
@@ -87,6 +87,31 @@ actions:
         default: 3
         description: How many times to do you want to ping?
 
+  # Upload a file from the browser; OliveTin stages it on the server and passes
+  # the temp path into the command (.Arguments.<name>.TmpName). Original name,
+  # size, and MIME type are available on the same object.
+  - title: Log file analysis
+    id: log_file_analysis
+    exec:
+      - echo
+      - "File: {{ .Arguments.logfile.Name }} {{ .Arguments.logfile.TmpName }} ({{ .Arguments.logfile.Size }} bytes)"
+    icon: logs
+    popupOnStart: execution-dialog-stdout-only
+    arguments:
+      - name: Test name
+        title: Test name
+        type: ascii_identifier
+        default: example
+
+      - name: logfile
+        title: Log file
+        type: file_upload
+        description: Upload a log file to preview line count and the first lines.
+        rejectNull: true
+        maxUploadBytes: "10 MB"
+        allowedMimeTypes:
+          - text/plain
+
   # OliveTin can control containers - docker is just a command line app.
   #
   # However, if you are running in a container you will need to do some setup,
@@ -124,7 +149,7 @@ actions:
   #
   # Docs: https://docs.olivetin.app/reference/reference_themes_for_users.html
   - title: Get OliveTin Theme
-    exec: 
+    exec:
       - "olivetin-get-theme"
       - "{{ themeGitRepo }}"
       - "{{ themeFolderName }}"
@@ -341,7 +366,7 @@ dashboards:
 
 # Security - Authentication
 
-# This setting effectively enables or disables guests. 
+# This setting effectively enables or disables guests.
 # If set to "true", then users will have to login to do anything.
 authRequireGuestsToLogin: false
 
@@ -350,7 +375,7 @@ authRequireGuestsToLogin: false
 # and JWT authentication which are documented separately.
 #
 # Docs: https://docs.olivetin.app/security/local.html
-# 
+#
 # How to get a hashed password:
 # Docs: https://docs.olivetin.app/security/local.html#_get_a_argon2id_hashed_password
 authLocalUsers:

+ 10 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts

@@ -111,6 +111,16 @@ export declare type ActionArgument = Message<"olivetin.api.v1.ActionArgument"> &
    * @generated from field: string suggestions_browser_key = 8;
    */
   suggestionsBrowserKey: string;
+
+  /**
+   * @generated from field: int64 max_upload_bytes = 9;
+   */
+  maxUploadBytes: bigint;
+
+  /**
+   * @generated from field: repeated string allowed_mime_types = 10;
+   */
+  allowedMimeTypes: string[];
 };
 
 /**

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 344 - 43
frontend/resources/vue/views/ArgumentForm.vue

@@ -12,32 +12,65 @@
                 {{ formatLabel(arg.title) }}
               </label>
 
-              <datalist v-if="(arg.suggestions && Object.keys(arg.suggestions).length > 0) || getBrowserSuggestions(arg).length > 0" :id="`${arg.name}-choices`">
-                <option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key">
-                  {{ suggestion }}
-                </option>
-                <option v-for="(suggestion, index) in getBrowserSuggestions(arg)" :key="`browser-${index}`" :value="suggestion">
-                  {{ suggestion }}
-                </option>
-              </datalist>
-
-              <select v-if="getInputComponent(arg) === 'select'" :id="arg.name" :name="arg.name" :value="getArgumentValue(arg)"
-                :required="arg.required" @input="handleInput(arg, $event)" @change="handleChange(arg, $event)">
-                <option v-for="choice in arg.choices" :key="choice.value" :value="choice.value">
-                  {{ choice.title || choice.value }}
-                </option>
-              </select>
-              
-              <component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name" 
-                :value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
-                :checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined"
-                :list="(arg.suggestions || getBrowserSuggestions(arg).length > 0) ? `${arg.name}-choices` : undefined" 
-                :type="getInputComponent(arg) !== 'select' ? getInputType(arg) : undefined"
-                :rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
-                :step="arg.type === 'datetime' ? 1 : undefined" :pattern="getPattern(arg)"
-                @input="handleInput(arg, $event)" @change="handleChange(arg, $event)" />
-
-            <span class="argument-description" v-html="arg.description"></span>
+              <template v-if="arg.type === 'file_upload'">
+                <div class="file-upload-field">
+                  <div
+                    class="file-upload-dropzone"
+                    :class="{ 'file-upload-dropzone--active': (fileUploadDragDepth[arg.name] || 0) > 0 }"
+                    @dragenter.prevent="onFileDragEnter(arg)"
+                    @dragover.prevent="onFileDragOver"
+                    @dragleave="onFileDragLeave(arg)"
+                    @drop.prevent="onFileDrop(arg, $event)"
+                  >
+                    <input
+                      :id="arg.name"
+                      :name="arg.name"
+                      type="file"
+                      class="file-upload-input-overlay"
+                      :accept="getFileAccept(arg)"
+                      @change="handleChange(arg, $event)"
+                    />
+                    <div class="file-upload-dropzone-inner">
+                      <span class="file-upload-prompt">{{ fileUploadPrompt(arg) }}</span>
+                      <span v-if="formErrors[arg.name]" class="file-upload-error">{{ formErrors[arg.name] }}</span>
+                    </div>
+                    </div>
+                </div>
+              <span class="argument-description">
+                <p v-html="arg.description"></p>
+                <p v-if="maxUploadSizeSummary(arg)" class="file-upload-mime-types">{{ maxUploadSizeSummary(arg) }}</p>
+                <p v-if="mimeTypesSummary(arg)" class="file-upload-mime-types">{{ mimeTypesSummary(arg) }}</p>
+              </span>
+          </template>
+
+              <template v-else>
+                <datalist v-if="(arg.suggestions && Object.keys(arg.suggestions).length > 0) || getBrowserSuggestions(arg).length > 0" :id="`${arg.name}-choices`">
+                  <option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key">
+                    {{ suggestion }}
+                  </option>
+                  <option v-for="(suggestion, index) in getBrowserSuggestions(arg)" :key="`browser-${index}`" :value="suggestion">
+                    {{ suggestion }}
+                  </option>
+                </datalist>
+
+                <select v-if="getInputComponent(arg) === 'select'" :id="arg.name" :name="arg.name" :value="getArgumentValue(arg)"
+                  :required="arg.required" @input="handleInput(arg, $event)" @change="handleChange(arg, $event)">
+                  <option v-for="choice in arg.choices" :key="choice.value" :value="choice.value">
+                    {{ choice.title || choice.value }}
+                  </option>
+                </select>
+
+                <component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name"
+                  :value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
+                  :checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined"
+                  :list="(arg.suggestions || getBrowserSuggestions(arg).length > 0) ? `${arg.name}-choices` : undefined"
+                  :type="getInputComponent(arg) !== 'select' ? getInputType(arg) : undefined"
+                  :rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
+                  :step="arg.type === 'datetime' ? 1 : undefined" :pattern="getPattern(arg)"
+                  @input="handleInput(arg, $event)" @change="handleChange(arg, $event)" />
+
+                <span class="argument-description" v-html="arg.description"></span>
+              </template>
           </template>
         </template>
         <div v-else>
@@ -58,7 +91,7 @@
 </template>
 
 <script setup>
-import { ref, onMounted, nextTick } from 'vue'
+import { ref, reactive, onMounted, nextTick } from 'vue'
 import { useRouter } from 'vue-router'
 
 const router = useRouter()
@@ -74,6 +107,8 @@ const hasConfirmation = ref(false)
 const formErrors = ref({})
 const actionArguments = ref([])
 const popupOnStart = ref('')
+const fileUploadDragDepth = reactive({})
+const fileUploadDisplayName = reactive({})
 
 // Computed properties
 
@@ -98,6 +133,12 @@ async function setup() {
   actionArguments.value = action.arguments || []
   argValues.value = {}
   formErrors.value = {}
+  for (const key of Object.keys(fileUploadDragDepth)) {
+    delete fileUploadDragDepth[key]
+  }
+  for (const key of Object.keys(fileUploadDisplayName)) {
+    delete fileUploadDisplayName[key]
+  }
   confirmationChecked.value = false
   hasConfirmation.value = false
 
@@ -134,7 +175,7 @@ async function setup() {
   // Run initial validation on all fields after DOM is updated
   await nextTick()
   for (const arg of actionArguments.value) {
-    if (arg.type && !arg.type.startsWith('regex:') && arg.type !== 'select' && arg.type !== '' && arg.type !== 'confirmation' && arg.type !== 'checkbox') {
+    if (arg.type && !arg.type.startsWith('regex:') && arg.type !== 'select' && arg.type !== '' && arg.type !== 'confirmation' && arg.type !== 'checkbox' && arg.type !== 'file_upload') {
       await validateArgument(arg, argValues.value[arg.name] || '')
     }
   }
@@ -165,6 +206,91 @@ function getInputComponent(arg) {
   }
 }
 
+function getFileAccept(arg) {
+  if (arg.type !== 'file_upload' || !arg.allowedMimeTypes || arg.allowedMimeTypes.length === 0) {
+    return undefined
+  }
+  return arg.allowedMimeTypes.join(',')
+}
+
+function mimeTypesSummary(arg) {
+  if (arg.type !== 'file_upload' || !arg.allowedMimeTypes || arg.allowedMimeTypes.length === 0) {
+    return ''
+  }
+  return 'Supported MIME types: ' + arg.allowedMimeTypes.join(', ')
+}
+
+/** SI byte formatting (matches server-side humanize-style defaults such as "10 MB"). */
+function formatBytesDecimal(numBytes) {
+  if (!Number.isFinite(numBytes) || numBytes < 0) {
+    return ''
+  }
+  const n = Math.floor(numBytes)
+  if (n < 1000) {
+    return `${n} B`
+  }
+  const units = ['kB', 'MB', 'GB', 'TB']
+  let v = n
+  let i = 0
+  while (v >= 1000 && i < units.length) {
+    v /= 1000
+    i++
+  }
+  const unit = units[i - 1]
+  const rounded = v < 10 ? Math.round(v * 10) / 10 : Math.round(v)
+  return `${rounded} ${unit}`
+}
+
+function maxUploadSizeSummary(arg) {
+  if (arg.type !== 'file_upload') {
+    return ''
+  }
+  const max = maxUploadBytesNumber(arg)
+  if (max <= 0) {
+    return ''
+  }
+  return `Max file size: ${formatBytesDecimal(max)}`
+}
+
+function fileUploadPrompt(arg) {
+  if (fileUploadDisplayName[arg.name]) {
+    return fileUploadDisplayName[arg.name]
+  }
+  return 'Drop a file here or click to browse'
+}
+
+function onFileDragEnter(arg) {
+  fileUploadDragDepth[arg.name] = (fileUploadDragDepth[arg.name] || 0) + 1
+}
+
+function onFileDragLeave(arg) {
+  const next = Math.max(0, (fileUploadDragDepth[arg.name] || 0) - 1)
+  if (next === 0) {
+    delete fileUploadDragDepth[arg.name]
+  } else {
+    fileUploadDragDepth[arg.name] = next
+  }
+}
+
+function onFileDragOver(event) {
+  event.dataTransfer.dropEffect = 'copy'
+}
+
+function onFileDrop(arg, event) {
+  delete fileUploadDragDepth[arg.name]
+  const file = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]
+  if (file) {
+    processStagedFileUpload(arg, file)
+  }
+}
+
+function maxUploadBytesNumber(arg) {
+  if (arg.maxUploadBytes === undefined || arg.maxUploadBytes === null) {
+    return 0
+  }
+  return typeof arg.maxUploadBytes === 'bigint' ? Number(arg.maxUploadBytes) : Number(arg.maxUploadBytes)
+}
+
 function getInputType(arg) {
   if (arg.type === 'html' || arg.type === 'raw_string_multiline' || arg.type === 'select') {
     return undefined
@@ -174,6 +300,10 @@ function getInputType(arg) {
     return 'checkbox'
   }
 
+  if (arg.type === 'file_upload') {
+    return 'file'
+  }
+
   if (arg.type === 'ascii_identifier' || arg.type === 'ascii') {
     return 'text'
   }
@@ -205,16 +335,94 @@ function handleInput(arg, event) {
   updateUrlWithArg(arg.name, value)
 }
 
+async function uploadStagedFile(arg, file) {
+  const formData = new FormData()
+  formData.append('binding_id', props.bindingId)
+  formData.append('argument_name', arg.name)
+  formData.append('file', file)
+
+  const res = await fetch('/api/upload/action-argument', {
+    method: 'POST',
+    body: formData,
+    credentials: 'same-origin'
+  })
+  const text = await res.text()
+  if (!res.ok) {
+    throw new Error(text || `Upload failed (${res.status})`)
+  }
+  let data
+  try {
+    data = JSON.parse(text)
+  } catch (e) {
+    throw new Error('Invalid upload response')
+  }
+  if (!data.uploadToken) {
+    throw new Error('Upload response missing token')
+  }
+  return data.uploadToken
+}
+
 function handleChange(arg, event) {
   if (arg.type === 'confirmation') {
     confirmationChecked.value = event.target.checked
     return
   }
 
+  if (arg.type === 'file_upload') {
+    handleFileUploadChange(arg, event)
+    return
+  }
+
   // Validate the input
   validateArgument(arg, event.target.value)
 }
 
+async function processStagedFileUpload(arg, file) {
+  const inputEl = document.getElementById(arg.name)
+  if (!file) {
+    argValues.value[arg.name] = ''
+    delete fileUploadDisplayName[arg.name]
+    if (inputEl) {
+      inputEl.setCustomValidity('')
+    }
+    return
+  }
+  const maxBytes = maxUploadBytesNumber(arg)
+  if (maxBytes > 0 && file.size > maxBytes) {
+    const msg = `File is too large (max ${formatBytesDecimal(maxBytes)})`
+    if (inputEl) {
+      inputEl.setCustomValidity(msg)
+    }
+    formErrors.value[arg.name] = msg
+    delete fileUploadDisplayName[arg.name]
+    return
+  }
+  try {
+    const token = await uploadStagedFile(arg, file)
+    argValues.value[arg.name] = token
+    fileUploadDisplayName[arg.name] = file.name
+    if (inputEl) {
+      inputEl.setCustomValidity('')
+    }
+    delete formErrors.value[arg.name]
+    await validateArgument(arg, token)
+  } catch (err) {
+    console.warn('Upload failed:', err)
+    const msg = err.message || 'Upload failed'
+    formErrors.value[arg.name] = msg
+    if (inputEl) {
+      inputEl.setCustomValidity(msg)
+    }
+    argValues.value[arg.name] = ''
+    delete fileUploadDisplayName[arg.name]
+  }
+}
+
+async function handleFileUploadChange(arg, event) {
+  const file = event.target.files && event.target.files[0]
+  await processStagedFileUpload(arg, file)
+}
+
 async function validateArgument(arg, value) {
   if (!arg.type || arg.type.startsWith('regex:')) {
     return
@@ -252,7 +460,7 @@ async function validateArgument(arg, value) {
 
     // Get the input element to set custom validity
     const inputElement = document.getElementById(arg.name)
-    
+
     if (validation.valid) {
       delete formErrors.value[arg.name]
       // Clear custom validity message
@@ -268,9 +476,14 @@ async function validateArgument(arg, value) {
     }
   } catch (err) {
     console.warn('Validation failed:', err)
-    // On error, clear any custom validity
     const inputElement = document.getElementById(arg.name)
-    if (inputElement) {
+    if (arg.type === 'file_upload') {
+      const msg = 'Could not validate upload; try again or check your connection'
+      formErrors.value[arg.name] = msg
+      if (inputElement) {
+        inputElement.setCustomValidity(msg)
+      }
+    } else if (inputElement) {
       inputElement.setCustomValidity('')
     }
   }
@@ -282,7 +495,7 @@ function updateUrlWithArg(name, value) {
 
     // Don't add passwords to URL
     const arg = actionArguments.value.find(a => a.name === name)
-    if (arg && arg.type === 'password') {
+    if (arg && (arg.type === 'password' || arg.type === 'file_upload')) {
       return
     }
 
@@ -322,7 +535,7 @@ function getBrowserSuggestions(arg) {
   if (!arg.suggestionsBrowserKey) {
     return []
   }
-  
+
   try {
     const stored = localStorage.getItem(`olivetin-suggestions-${arg.suggestionsBrowserKey}`)
     if (stored) {
@@ -332,7 +545,7 @@ function getBrowserSuggestions(arg) {
   } catch (err) {
     console.warn('Failed to load browser suggestions:', err)
   }
-  
+
   return []
 }
 
@@ -340,21 +553,21 @@ function saveBrowserSuggestions() {
   for (const arg of actionArguments.value) {
     if (arg.suggestionsBrowserKey) {
       const value = argValues.value[arg.name]
-      
+
       // Only save non-empty values for non-checkbox/confirmation/password types
-      if (value && value !== '' && arg.type !== 'checkbox' && arg.type !== 'confirmation' && arg.type !== 'password') {
+      if (value && value !== '' && arg.type !== 'checkbox' && arg.type !== 'confirmation' && arg.type !== 'password' && arg.type !== 'file_upload') {
         try {
           const key = `olivetin-suggestions-${arg.suggestionsBrowserKey}`
           const stored = localStorage.getItem(key)
           let suggestions = []
-          
+
           if (stored) {
             suggestions = JSON.parse(stored)
             if (!Array.isArray(suggestions)) {
               suggestions = []
             }
           }
-          
+
           // Add value if not already present
           if (!suggestions.includes(value)) {
             suggestions.unshift(value) // Add to beginning
@@ -394,7 +607,7 @@ async function handleSubmit(event) {
   for (const arg of actionArguments.value) {
     const value = argValues.value[arg.name]
     const inputElement = document.getElementById(arg.name)
-    
+
     if (arg.required && (!value || value === '')) {
       formErrors.value[arg.name] = 'This field is required'
       // Set custom validity for required field validation
@@ -411,13 +624,13 @@ async function handleSubmit(event) {
   }
 
   event.preventDefault()
-  
+
   const argvs = getArgumentValues()
   console.log('argument form has elements that passed validation')
-  
+
   // Save values to localStorage for arguments with suggestionsBrowserKey
   saveBrowserSuggestions()
-  
+
   try {
     const response = await startAction(argvs)
     if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
@@ -471,6 +684,94 @@ form {
   grid-template-columns: max-content auto auto;
 }
 
+.file-upload-field {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+  min-width: 0;
+}
+
+.file-upload-dropzone {
+  position: relative;
+  min-height: 5.5rem;
+  border: 2px dashed #bbb;
+  border-radius: 0.5rem;
+  background: #fafafa;
+  transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
+}
+
+.file-upload-dropzone:hover:not(.file-upload-dropzone--active) {
+  border-color: #7a9bbb;
+  background: #f3f7fb;
+  box-shadow: 0 2px 8px rgba(68, 136, 204, 0.12);
+}
+
+.file-upload-dropzone--active {
+  border-color: #4488cc;
+  background: #f0f6fc;
+}
+
+@media (prefers-color-scheme: dark) {
+  .file-upload-dropzone {
+    border-color: #555;
+    background: #222;
+  }
+  .file-upload-dropzone:hover:not(.file-upload-dropzone--active) {
+    border-color: #7a9bbb;
+    background: #222;
+    box-shadow: 0 2px 8px rgba(68, 136, 204, 0.12);
+  }
+  .file-upload-dropzone--active {
+    border-color: #4488cc;
+    background: #2a3b4c;
+  }
+}
+
+
+
+.file-upload-input-overlay {
+  position: absolute;
+  inset: 0;
+  width: 100%;
+  height: 100%;
+  margin: 0;
+  padding: 0;
+  opacity: 0;
+  cursor: pointer;
+  z-index: 2;
+  font-size: 0;
+}
+
+.file-upload-dropzone-inner {
+  position: relative;
+  z-index: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 0.35rem;
+  min-height: 5.5rem;
+  padding: 0.75rem 1rem;
+  text-align: center;
+  pointer-events: none;
+}
+
+.file-upload-prompt {
+  font-size: 0.9375rem;
+  word-break: break-word;
+}
+
+.file-upload-error {
+  font-size: 0.8125rem;
+  color: #b00020;
+}
+
+.file-upload-mime-types {
+  font-size: 0.8125rem;
+  color: #555;
+  margin: 0;
+  margin-top: 0.15rem;
+}
 
 .argument-description {
   font-size: 0.875rem;
@@ -496,4 +797,4 @@ form {
   display: inline;
   font-weight: normal;
 }
-</style>
+</style>

+ 2 - 0
proto/olivetin/api/v1/olivetin.proto

@@ -27,6 +27,8 @@ message ActionArgument {
 	string description = 6;
 	map<string, string> suggestions = 7;
 	string suggestions_browser_key = 8;
+	int64 max_upload_bytes = 9;
+	repeated string allowed_mime_types = 10;
 }
 
 message ActionArgumentChoice {

+ 21 - 2
service/gen/olivetin/api/v1/olivetin.pb.go

@@ -139,6 +139,8 @@ type ActionArgument struct {
 	Description           string                  `protobuf:"bytes,6,opt,name=description,proto3" json:"description,omitempty"`
 	Suggestions           map[string]string       `protobuf:"bytes,7,rep,name=suggestions,proto3" json:"suggestions,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
 	SuggestionsBrowserKey string                  `protobuf:"bytes,8,opt,name=suggestions_browser_key,json=suggestionsBrowserKey,proto3" json:"suggestions_browser_key,omitempty"`
+	MaxUploadBytes        int64                   `protobuf:"varint,9,opt,name=max_upload_bytes,json=maxUploadBytes,proto3" json:"max_upload_bytes,omitempty"`
+	AllowedMimeTypes      []string                `protobuf:"bytes,10,rep,name=allowed_mime_types,json=allowedMimeTypes,proto3" json:"allowed_mime_types,omitempty"`
 	unknownFields         protoimpl.UnknownFields
 	sizeCache             protoimpl.SizeCache
 }
@@ -229,6 +231,20 @@ func (x *ActionArgument) GetSuggestionsBrowserKey() string {
 	return ""
 }
 
+func (x *ActionArgument) GetMaxUploadBytes() int64 {
+	if x != nil {
+		return x.MaxUploadBytes
+	}
+	return 0
+}
+
+func (x *ActionArgument) GetAllowedMimeTypes() []string {
+	if x != nil {
+		return x.AllowedMimeTypes
+	}
+	return nil
+}
+
 type ActionArgumentChoice struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	Value         string                 `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"`
@@ -3914,7 +3930,7 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\x0epopup_on_start\x18\x06 \x01(\tR\fpopupOnStart\x12\x14\n" +
 	"\x05order\x18\a \x01(\x05R\x05order\x12\x18\n" +
 	"\atimeout\x18\b \x01(\x05R\atimeout\x12=\n" +
-	"\x1bdatetime_rate_limit_expires\x18\t \x01(\tR\x18datetimeRateLimitExpires\"\xa2\x03\n" +
+	"\x1bdatetime_rate_limit_expires\x18\t \x01(\tR\x18datetimeRateLimitExpires\"\xfa\x03\n" +
 	"\x0eActionArgument\x12\x12\n" +
 	"\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" +
 	"\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" +
@@ -3923,7 +3939,10 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\achoices\x18\x05 \x03(\v2%.olivetin.api.v1.ActionArgumentChoiceR\achoices\x12 \n" +
 	"\vdescription\x18\x06 \x01(\tR\vdescription\x12R\n" +
 	"\vsuggestions\x18\a \x03(\v20.olivetin.api.v1.ActionArgument.SuggestionsEntryR\vsuggestions\x126\n" +
-	"\x17suggestions_browser_key\x18\b \x01(\tR\x15suggestionsBrowserKey\x1a>\n" +
+	"\x17suggestions_browser_key\x18\b \x01(\tR\x15suggestionsBrowserKey\x12(\n" +
+	"\x10max_upload_bytes\x18\t \x01(\x03R\x0emaxUploadBytes\x12,\n" +
+	"\x12allowed_mime_types\x18\n" +
+	" \x03(\tR\x10allowedMimeTypes\x1a>\n" +
 	"\x10SuggestionsEntry\x12\x10\n" +
 	"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
 	"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"B\n" +

+ 2 - 1
service/go.mod

@@ -11,9 +11,11 @@ require (
 	github.com/PaesslerAG/jsonpath v0.1.1
 	github.com/alexedwards/argon2id v1.0.0
 	github.com/bufbuild/buf v1.66.0
+	github.com/dustin/go-humanize v1.0.1
 	github.com/fsnotify/fsnotify v1.9.0
 	github.com/fzipp/gocyclo v0.6.0
 	github.com/go-critic/go-critic v0.14.3
+	github.com/go-viper/mapstructure/v2 v2.5.0
 	github.com/golang-jwt/jwt/v5 v5.3.1
 	github.com/google/uuid v1.6.0
 	github.com/jamesread/golure v0.0.0-20260104005024-ad0d6ec8c0ac
@@ -86,7 +88,6 @@ require (
 	github.com/go-toolsmith/pkgload v1.2.2 // indirect
 	github.com/go-toolsmith/strparse v1.1.0 // indirect
 	github.com/go-toolsmith/typep v1.1.0 // indirect
-	github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
 	github.com/gofrs/flock v0.13.0 // indirect
 	github.com/google/cel-go v0.27.0 // indirect
 	github.com/google/go-cmp v0.7.0 // indirect

+ 2 - 0
service/go.sum

@@ -146,6 +146,8 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM
 github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
 github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=

+ 1 - 1
service/internal/api/api.go

@@ -750,7 +750,7 @@ func (api *oliveTinAPI) validateArgumentTypeInternal(msg *apiv1.ValidateArgument
 		return fmt.Errorf("argument not found")
 	}
 
-	return executor.ValidateArgument(arg, msg.Value, action)
+	return executor.ValidateArgument(arg, msg.Value, action, api.executor.UploadRegistry, msg.BindingId)
 }
 
 func (api *oliveTinAPI) findArgumentForValidation(bindingId string, argumentName string) (*config.ActionArgument, *config.Action) {

+ 2 - 0
service/internal/api/apiActions.go

@@ -166,6 +166,8 @@ func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderReque
 			Choices:               buildChoices(cfgArg),
 			Suggestions:           cfgArg.Suggestions,
 			SuggestionsBrowserKey: cfgArg.SuggestionsBrowserKey,
+			MaxUploadBytes:        cfgArg.EffectiveFileUploadMaxBytes(rr.cfg),
+			AllowedMimeTypes:      cfgArg.EffectiveFileUploadAllowedMimeTypes(rr.cfg),
 		}
 
 		btn.Arguments = append(btn.Arguments, &pbArg)

+ 15 - 7
service/internal/api/api_test.go

@@ -665,15 +665,23 @@ func recvEventStreamOne(ch <-chan *apiv1.EventStreamResponse, timeout time.Durat
 func drainEventStreamWithTimeout(ch <-chan *apiv1.EventStreamResponse, timeout time.Duration) []*apiv1.EventStreamResponse {
 	var out []*apiv1.EventStreamResponse
 	for {
-		select {
-		case ev, ok := <-ch:
-			if !ok {
-				return out
-			}
-			out = append(out, ev)
-		case <-time.After(timeout):
+		ev, stop := recvOneEventOrTimeout(ch, timeout)
+		if stop {
 			return out
 		}
+		out = append(out, ev)
+	}
+}
+
+func recvOneEventOrTimeout(ch <-chan *apiv1.EventStreamResponse, timeout time.Duration) (*apiv1.EventStreamResponse, bool) {
+	select {
+	case ev, ok := <-ch:
+		if !ok {
+			return nil, true
+		}
+		return ev, false
+	case <-time.After(timeout):
+		return nil, true
 	}
 }
 

+ 33 - 0
service/internal/api/upload.go

@@ -0,0 +1,33 @@
+package api
+
+import (
+	"net/http"
+
+	executor "github.com/OliveTin/OliveTin/internal/executor"
+)
+
+// GetActionArgumentUploadHandler serves POST multipart uploads for file_upload action arguments.
+func GetActionArgumentUploadHandler(ex *executor.Executor) http.HandlerFunc {
+	api := &oliveTinAPI{
+		executor:         ex,
+		cfg:              ex.Cfg,
+		streamingClients: make(map[*streamingClient]struct{}),
+	}
+	return api.handleActionArgumentUpload
+}
+
+func (api *oliveTinAPI) handleActionArgumentUpload(w http.ResponseWriter, r *http.Request) {
+	if !api.uploadPrelude(w, r) {
+		return
+	}
+	if !api.parseUploadForm(w, r) {
+		return
+	}
+	defer api.uploadCleanupForm(r)
+
+	token, ok := api.tryProcessUpload(w, r)
+	if !ok {
+		return
+	}
+	api.writeUploadTokenResponse(w, token)
+}

+ 139 - 0
service/internal/api/upload_helpers.go

@@ -0,0 +1,139 @@
+package api
+
+import (
+	"encoding/json"
+	"mime/multipart"
+	"net/http"
+
+	acl "github.com/OliveTin/OliveTin/internal/acl"
+	auth "github.com/OliveTin/OliveTin/internal/auth"
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	config "github.com/OliveTin/OliveTin/internal/config"
+	executor "github.com/OliveTin/OliveTin/internal/executor"
+	log "github.com/sirupsen/logrus"
+)
+
+func (api *oliveTinAPI) uploadPrelude(w http.ResponseWriter, r *http.Request) bool {
+	if r.Method != http.MethodPost {
+		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+		return false
+	}
+	if api.executor.UploadRegistry == nil {
+		http.Error(w, "file uploads are not available", http.StatusServiceUnavailable)
+		return false
+	}
+	return true
+}
+
+func uploadMaxBodyBytes(cfg *config.Config) int64 {
+	maxBody := int64(cfg.FileUploads.MaxBytes)
+	if maxBody <= 0 {
+		maxBody = int64(config.DefaultFileUploadMaxBytes)
+	}
+	return maxBody
+}
+
+func (api *oliveTinAPI) parseUploadForm(w http.ResponseWriter, r *http.Request) bool {
+	maxBody := uploadMaxBodyBytes(api.cfg)
+	r.Body = http.MaxBytesReader(w, r.Body, maxBody+(1<<20))
+	if err := r.ParseMultipartForm(32 << 20); err != nil {
+		http.Error(w, "invalid multipart form", http.StatusBadRequest)
+		return false
+	}
+	return true
+}
+
+func uploadFormIDs(r *http.Request) (string, string, bool) {
+	bindingID := r.FormValue("binding_id")
+	argName := r.FormValue("argument_name")
+	if bindingID == "" || argName == "" {
+		return "", "", false
+	}
+	return bindingID, argName, true
+}
+
+func (api *oliveTinAPI) uploadCleanupForm(r *http.Request) {
+	if r.MultipartForm != nil {
+		_ = r.MultipartForm.RemoveAll()
+	}
+}
+
+func (api *oliveTinAPI) bindingForUpload(w http.ResponseWriter, bindingID string) *executor.ActionBinding {
+	pair := api.executor.FindBindingByID(bindingID)
+	if pair == nil || pair.Action == nil {
+		http.Error(w, "action not found", http.StatusNotFound)
+		return nil
+	}
+	return pair
+}
+
+func (api *oliveTinAPI) authorizeUploadRequest(w http.ResponseWriter, r *http.Request, bindingID, argName string) (*executor.ActionBinding, *config.ActionArgument) {
+	user := auth.UserFromHTTPRequest(r, api.cfg)
+	pair := api.bindingForUpload(w, bindingID)
+	if pair == nil {
+		return nil, nil
+	}
+	if !uploadExecAllowed(api, user, pair) {
+		http.Error(w, "forbidden", http.StatusForbidden)
+		return nil, nil
+	}
+	return uploadFileArgOrError(api, w, pair, argName)
+}
+
+func uploadExecAllowed(api *oliveTinAPI, user *authpublic.AuthenticatedUser, pair *executor.ActionBinding) bool {
+	return acl.IsAllowedExec(api.cfg, user, pair.Action)
+}
+
+func uploadFileArgOrError(api *oliveTinAPI, w http.ResponseWriter, pair *executor.ActionBinding, argName string) (*executor.ActionBinding, *config.ActionArgument) {
+	arg := api.findArgumentByName(pair.Action, argName)
+	if arg == nil || arg.Type != "file_upload" {
+		http.Error(w, "invalid file argument", http.StatusBadRequest)
+		return nil, nil
+	}
+	return pair, arg
+}
+
+func (api *oliveTinAPI) openUploadedFormFile(w http.ResponseWriter, r *http.Request) (multipart.File, *multipart.FileHeader, bool) {
+	file, hdr, err := r.FormFile("file")
+	if err != nil {
+		http.Error(w, "file field is required", http.StatusBadRequest)
+		return nil, nil, false
+	}
+	return file, hdr, true
+}
+
+func (api *oliveTinAPI) tryProcessUpload(w http.ResponseWriter, r *http.Request) (string, bool) {
+	bindingID, argName, ok := uploadFormIDs(r)
+	if !ok {
+		http.Error(w, "binding_id and argument_name are required", http.StatusBadRequest)
+		return "", false
+	}
+	_, arg := api.authorizeUploadRequest(w, r, bindingID, argName)
+	if arg == nil {
+		return "", false
+	}
+	return api.stageUploadFromForm(w, r, bindingID, arg)
+}
+
+func (api *oliveTinAPI) stageUploadFromForm(w http.ResponseWriter, r *http.Request, bindingID string, arg *config.ActionArgument) (string, bool) {
+	file, hdr, ok := api.openUploadedFormFile(w, r)
+	if !ok {
+		return "", false
+	}
+	defer file.Close()
+	token, err := api.executor.UploadRegistry.StageFromMultipart(file, hdr.Filename, bindingID, arg)
+	if err != nil {
+		log.WithError(err).Warn("upload rejected")
+		http.Error(w, "upload rejected", http.StatusBadRequest)
+		return "", false
+	}
+	return token, true
+}
+
+func (api *oliveTinAPI) writeUploadTokenResponse(w http.ResponseWriter, token string) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(http.StatusOK)
+	if err := json.NewEncoder(w).Encode(map[string]string{"uploadToken": token}); err != nil {
+		log.WithError(err).Warn("upload response encode failed")
+	}
+}

+ 17 - 0
service/internal/auth/authcheck.go

@@ -68,3 +68,20 @@ func UserFromApiCall[T any](ctx context.Context, req *connect.Request[T], cfg *c
 
 	return user
 }
+
+// UserFromHTTPRequest resolves the authenticated user from a plain HTTP request (same chain as Connect RPC).
+func UserFromHTTPRequest(r *http.Request, cfg *config.Config) *types.AuthenticatedUser {
+	authCtx := &types.AuthCheckingContext{
+		Request: r,
+		Config:  cfg,
+	}
+	var user *types.AuthenticatedUser
+	for _, check := range authChain {
+		user = check(authCtx)
+		if user != nil && user.Username != "" {
+			user.BuildUserAcls(cfg)
+			return user
+		}
+	}
+	return UserGuest(cfg)
+}

+ 42 - 0
service/internal/config/config.go

@@ -44,6 +44,8 @@ type ActionArgument struct {
 	RejectNull            bool                   `koanf:"rejectNull"`
 	Suggestions           map[string]string      `koanf:"suggestions"`
 	SuggestionsBrowserKey string                 `koanf:"suggestionsBrowserKey"`
+	MaxUploadBytes        HumanReadableBytes     `koanf:"maxUploadBytes"`
+	AllowedMimeTypes      []string               `koanf:"allowedMimeTypes"`
 }
 
 // ActionArgumentChoice represents a predefined choice for an argument.
@@ -182,10 +184,19 @@ type Config struct {
 	BannerMessage                   string                     `koanf:"bannerMessage"`
 	BannerCSS                       string                     `koanf:"bannerCss"`
 	Include                         string                     `koanf:"include"`
+	FileUploads                     FileUploadsConfig          `koanf:"fileUploads"`
 
 	sourceFiles []string
 }
 
+// FileUploadsConfig controls server-side staging of uploaded action argument files.
+type FileUploadsConfig struct {
+	TempDirectory           string             `koanf:"tempDirectory"`
+	MaxBytes                HumanReadableBytes `koanf:"maxBytes"`
+	TokenTTLSeconds         int                `koanf:"tokenTTLSeconds"`
+	DefaultAllowedMimeTypes []string           `koanf:"defaultAllowedMimeTypes"`
+}
+
 type AuthLocalUsersConfig struct {
 	Enabled bool         `koanf:"enabled"`
 	Users   []*LocalUser `koanf:"users"`
@@ -300,5 +311,36 @@ func DefaultConfigWithBasePort(basePort int) *Config {
 	config.DefaultPolicy.ShowLogList = true
 	config.DefaultPolicy.ShowVersionNumber = true
 
+	config.FileUploads.MaxBytes = DefaultFileUploadMaxBytes
+	config.FileUploads.TokenTTLSeconds = DefaultFileUploadTokenTTLSeconds
+
 	return &config
 }
+
+const (
+	// DefaultFileUploadMaxBytes is 10 MB (SI), matching humanize.ParseBytes("10 MB").
+	DefaultFileUploadMaxBytes        HumanReadableBytes = 10_000_000
+	DefaultFileUploadTokenTTLSeconds                    = 300
+)
+
+// EffectiveFileUploadMaxBytes returns the maximum upload size for an action argument.
+func (a *ActionArgument) EffectiveFileUploadMaxBytes(cfg *Config) int64 {
+	if a.MaxUploadBytes > 0 {
+		return int64(a.MaxUploadBytes)
+	}
+	if cfg != nil && cfg.FileUploads.MaxBytes > 0 {
+		return int64(cfg.FileUploads.MaxBytes)
+	}
+	return int64(DefaultFileUploadMaxBytes)
+}
+
+// EffectiveFileUploadAllowedMimeTypes returns allowed MIME types (empty means none allowed).
+func (a *ActionArgument) EffectiveFileUploadAllowedMimeTypes(cfg *Config) []string {
+	if len(a.AllowedMimeTypes) > 0 {
+		return a.AllowedMimeTypes
+	}
+	if cfg != nil && len(cfg.FileUploads.DefaultAllowedMimeTypes) > 0 {
+		return cfg.FileUploads.DefaultAllowedMimeTypes
+	}
+	return nil
+}

+ 2 - 1
service/internal/config/config_reloader.go

@@ -50,7 +50,8 @@ func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) {
 
 func unmarshalRoot(k *koanf.Koanf, cfg *Config) bool {
 	err := k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{
-		Tag: "koanf",
+		Tag:           "koanf",
+		DecoderConfig: newDefaultUnmarshalDecoderConfig(),
 	})
 
 	if err != nil {

+ 103 - 0
service/internal/config/human_readable_bytes.go

@@ -0,0 +1,103 @@
+package config
+
+import (
+	"fmt"
+	"reflect"
+	"strings"
+
+	"github.com/dustin/go-humanize"
+	mapstructure "github.com/go-viper/mapstructure/v2"
+)
+
+// HumanReadableBytes is a byte size from config: a plain number (bytes) or a string
+// parsed with github.com/dustin/go-humanize (e.g. "10 MB", "1 GiB", "512 kb").
+type HumanReadableBytes int64
+
+// UnmarshalText implements encoding.TextUnmarshaler for YAML string values.
+func (h *HumanReadableBytes) UnmarshalText(text []byte) error {
+	s := strings.TrimSpace(string(text))
+	if s == "" {
+		*h = 0
+		return nil
+	}
+	n, err := humanize.ParseBytes(s)
+	if err != nil {
+		return fmt.Errorf("parse file size: %w", err)
+	}
+	*h = HumanReadableBytes(n)
+	return nil
+}
+
+func humanReadableBytesNumericHook() mapstructure.DecodeHookFunc {
+	var zero HumanReadableBytes
+	target := reflect.TypeOf(zero)
+	return func(from reflect.Type, to reflect.Type, data any) (any, error) {
+		if to != target {
+			return data, nil
+		}
+		if v, ok := humanReadableBytesFromDecodedValue(data); ok {
+			return v, nil
+		}
+		return data, nil
+	}
+}
+
+func humanReadableBytesFromDecodedValue(data any) (HumanReadableBytes, bool) {
+	v, ok := ptrHumanReadableBytes(data)
+	if ok {
+		return v, true
+	}
+	if v, ok := data.(HumanReadableBytes); ok {
+		return v, true
+	}
+	n, ok := int64FromYAMLNumber(data)
+	if !ok {
+		return 0, false
+	}
+	return HumanReadableBytes(n), true
+}
+
+func ptrHumanReadableBytes(data any) (HumanReadableBytes, bool) {
+	p, ok := data.(*HumanReadableBytes)
+	if !ok {
+		return 0, false
+	}
+	if p == nil {
+		return 0, true
+	}
+	return *p, true
+}
+
+func int64FromYAMLNumber(data any) (int64, bool) {
+	if v, ok := int64FromFloatOrInt(data); ok {
+		return v, true
+	}
+	if v, ok := data.(int64); ok {
+		return v, true
+	}
+	if v, ok := data.(uint64); ok {
+		return int64(v), true
+	}
+	return 0, false
+}
+
+func int64FromFloatOrInt(data any) (int64, bool) {
+	if v, ok := data.(float64); ok {
+		return int64(v), true
+	}
+	if v, ok := data.(int); ok {
+		return int64(v), true
+	}
+	return 0, false
+}
+
+func newDefaultUnmarshalDecoderConfig() *mapstructure.DecoderConfig {
+	return &mapstructure.DecoderConfig{
+		DecodeHook: mapstructure.ComposeDecodeHookFunc(
+			mapstructure.StringToTimeDurationHookFunc(),
+			mapstructure.TextUnmarshallerHookFunc(),
+			humanReadableBytesNumericHook(),
+		),
+		WeaklyTypedInput: true,
+	}
+}

+ 60 - 0
service/internal/config/human_readable_bytes_test.go

@@ -0,0 +1,60 @@
+package config
+
+import (
+	"testing"
+
+	"github.com/dustin/go-humanize"
+	"github.com/knadh/koanf/parsers/yaml"
+	"github.com/knadh/koanf/providers/rawbytes"
+	"github.com/knadh/koanf/v2"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestDefaultFileUploadMaxBytesMatchesHumanize(t *testing.T) {
+	n, err := humanize.ParseBytes("10 MB")
+	require.NoError(t, err)
+	assert.Equal(t, int64(n), int64(DefaultFileUploadMaxBytes))
+}
+
+func TestUnmarshalFileUploadsHumanReadableBytes(t *testing.T) {
+	yamlText := `
+fileUploads:
+  maxBytes: "512 kb"
+actions:
+  - title: x
+    shell: echo
+    arguments:
+      - name: f
+        type: file_upload
+        maxUploadBytes: "2 MiB"
+`
+	k := koanf.New(".")
+	require.NoError(t, k.Load(rawbytes.Provider([]byte(yamlText)), yaml.Parser()))
+	cfg := &Config{}
+	require.NoError(t, k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{
+		Tag:           "koanf",
+		DecoderConfig: newDefaultUnmarshalDecoderConfig(),
+	}))
+
+	assert.Equal(t, HumanReadableBytes(512000), cfg.FileUploads.MaxBytes)
+	require.Len(t, cfg.Actions, 1)
+	require.Len(t, cfg.Actions[0].Arguments, 1)
+	assert.Equal(t, HumanReadableBytes(2*1024*1024), cfg.Actions[0].Arguments[0].MaxUploadBytes)
+}
+
+func TestUnmarshalFileUploadsMaxBytesNumericStillWorks(t *testing.T) {
+	yamlText := `
+fileUploads:
+  maxBytes: 1048576
+`
+	k := koanf.New(".")
+	require.NoError(t, k.Load(rawbytes.Provider([]byte(yamlText)), yaml.Parser()))
+	cfg := &Config{}
+	require.NoError(t, k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{
+		Tag:           "koanf",
+		DecoderConfig: newDefaultUnmarshalDecoderConfig(),
+	}))
+
+	assert.Equal(t, HumanReadableBytes(1048576), cfg.FileUploads.MaxBytes)
+}

+ 65 - 40
service/internal/executor/arguments.go

@@ -3,6 +3,7 @@ package executor
 import (
 	config "github.com/OliveTin/OliveTin/internal/config"
 	"github.com/OliveTin/OliveTin/internal/entities"
+	"github.com/OliveTin/OliveTin/internal/fileupload"
 	"github.com/OliveTin/OliveTin/internal/tpl"
 	log "github.com/sirupsen/logrus"
 
@@ -26,11 +27,11 @@ var (
 )
 
 // parseExecArray parses all exec arguments in the action.
-func parseExecArray(action *config.Action, values map[string]string, entity *entities.Entity) ([]string, error) {
+func parseExecArray(action *config.Action, templateArgs map[string]any, entity *entities.Entity) ([]string, error) {
 	parsed := make([]string, len(action.Exec))
 
 	for i, segment := range action.Exec {
-		out, err := parseExecSegment(segment, values, entity)
+		out, err := parseExecSegment(segment, templateArgs, entity)
 		if err != nil {
 			return nil, err
 		}
@@ -39,34 +40,57 @@ func parseExecArray(action *config.Action, values map[string]string, entity *ent
 	return parsed, nil
 }
 
-func parseActionExec(values map[string]string, action *config.Action, entity *entities.Entity) ([]string, error) {
-	if action == nil {
+func prepareExecArguments(req *ExecutionRequest) error {
+	if err := validateArguments(req); err != nil {
+		return err
+	}
+	return finalizeFileUploadArguments(req)
+}
+
+func execRequestAction(req *ExecutionRequest) (*config.Action, error) {
+	if req == nil || req.Binding == nil {
+		return nil, fmt.Errorf("request or binding is nil")
+	}
+	if req.Binding.Action == nil {
 		return nil, fmt.Errorf("action is nil")
 	}
-	if err := validateArguments(values, action); err != nil {
+	return req.Binding.Action, nil
+}
+
+func parseActionExec(req *ExecutionRequest) ([]string, error) {
+	action, err := execRequestAction(req)
+	if err != nil {
+		return nil, err
+	}
+	if err := prepareExecArguments(req); err != nil {
 		return nil, err
 	}
-
-	parsed, err := parseExecArray(action, values, entity)
-
+	tmpl := buildTemplateArgumentMap(req)
+	parsed, err := parseExecArray(action, tmpl, req.Binding.Entity)
 	if err != nil {
 		return nil, err
 	}
 
-	logParsedExec(action, parsed, values)
+	logParsedExec(action, parsed, req.Arguments)
 	return parsed, nil
 }
 
-func parseExecSegment(arg string, values map[string]string, entity *entities.Entity) (string, error) {
-	return tpl.ParseTemplateWithActionContext(arg, entity, values)
+func parseExecSegment(arg string, templateArgs map[string]any, entity *entities.Entity) (string, error) {
+	return tpl.ParseTemplateWithActionContext(arg, entity, templateArgs)
 }
 
-func validateArguments(values map[string]string, action *config.Action) error {
+func validateArguments(req *ExecutionRequest) error {
+	action := req.Binding.Action
+	if action == nil {
+		return fmt.Errorf("action is nil")
+	}
+	reg := req.executor.UploadRegistry
+	bindingID := req.Binding.ID
 	for _, arg := range action.Arguments {
-		if err := typecheckActionArgument(&arg, values[arg.Name], action); err != nil {
+		if err := typecheckActionArgument(&arg, req.Arguments[arg.Name], action, reg, bindingID); err != nil {
 			return err
 		}
-		log.WithFields(log.Fields{"name": arg.Name, "value": values[arg.Name]}).Debugf("Arg assigned")
+		log.WithFields(log.Fields{"name": arg.Name, "value": req.Arguments[arg.Name]}).Debugf("Arg assigned")
 	}
 	return nil
 }
@@ -82,23 +106,12 @@ func parseActionArguments(req *ExecutionRequest) (string, error) {
 		"cmd":         req.Binding.Action.Shell,
 	}).Infof("Action parse args - Before")
 
-	for _, arg := range req.Binding.Action.Arguments {
-		argName := arg.Name
-		argValue := req.Arguments[argName]
-
-		err := typecheckActionArgument(&arg, argValue, req.Binding.Action)
-
-		if err != nil {
-			return "", err
-		}
-
-		log.WithFields(log.Fields{
-			"name":  argName,
-			"value": argValue,
-		}).Debugf("Arg assigned")
+	if err := prepareExecArguments(req); err != nil {
+		return "", err
 	}
+	tmpl := buildTemplateArgumentMap(req)
 
-	parsedShellCommand, err := tpl.ParseTemplateWithActionContext(req.Binding.Action.Shell, req.Binding.Entity, req.Arguments)
+	parsedShellCommand, err := tpl.ParseTemplateWithActionContext(req.Binding.Action.Shell, req.Binding.Entity, tmpl)
 
 	if err != nil {
 		return "", err
@@ -117,7 +130,7 @@ func parseActionArguments(req *ExecutionRequest) (string, error) {
 //gocyclo:ignore
 func redactShellCommand(shellCommand string, arguments []config.ActionArgument, argumentValues map[string]string) string {
 	for _, arg := range arguments {
-		if arg.Type == "password" {
+		if arg.Type == "password" || arg.Type == "file_upload" {
 			argValue, exists := argumentValues[arg.Name]
 
 			if !exists {
@@ -145,7 +158,7 @@ func redactExecArgs(execArgs []string, arguments []config.ActionArgument, argume
 	return redacted
 }
 
-func typecheckActionArgument(arg *config.ActionArgument, value string, action *config.Action) error {
+func typecheckActionArgument(arg *config.ActionArgument, value string, action *config.Action, reg *fileupload.Registry, bindingID string) error {
 	if arg.Type == "confirmation" {
 		return nil
 	}
@@ -154,13 +167,17 @@ func typecheckActionArgument(arg *config.ActionArgument, value string, action *c
 		return fmt.Errorf("argument name cannot be empty")
 	}
 
+	if arg.Type == "file_upload" {
+		return validateFileUploadArg(value, arg, reg, bindingID)
+	}
+
 	return typecheckActionArgumentFound(value, arg)
 }
 
 // ValidateArgument validates a single argument value using the same logic as the executor.
 // It applies mangling transformations and performs full validation including null checks,
 // choice validation, and type safety checks.
-func ValidateArgument(arg *config.ActionArgument, value string, action *config.Action) error {
+func ValidateArgument(arg *config.ActionArgument, value string, action *config.Action, uploadRegistry *fileupload.Registry, bindingID string) error {
 	if arg == nil {
 		return fmt.Errorf("ValidateArgument: arg is nil")
 	}
@@ -173,7 +190,7 @@ func ValidateArgument(arg *config.ActionArgument, value string, action *config.A
 	mangledValue := MangleArgumentValue(arg, value, action.Title)
 
 	// Use the same validation path as the executor
-	return typecheckActionArgument(arg, mangledValue, action)
+	return typecheckActionArgument(arg, mangledValue, action, uploadRegistry, bindingID)
 }
 
 func typecheckActionArgumentFound(value string, arg *config.ActionArgument) error {
@@ -197,6 +214,8 @@ func TypeSafetyCheck(name string, value string, argumentType string) error {
 	switch argumentType {
 	case "password":
 		return nil
+	case "file_upload":
+		return nil
 	case "raw_string_multiline":
 		return nil
 	case "checkbox":
@@ -307,7 +326,9 @@ func checkShellArgumentSafety(action *config.Action) error {
 	if action.Shell == "" {
 		return nil
 	}
-	unsafe := map[string]struct{}{"url": {}, "email": {}, "raw_string_multiline": {}, "very_dangerous_raw_string": {}, "password": {}}
+	unsafe := map[string]struct{}{
+		"url": {}, "email": {}, "raw_string_multiline": {}, "very_dangerous_raw_string": {}, "password": {}, "file_upload": {},
+	}
 	for _, arg := range action.Arguments {
 		if _, bad := unsafe[arg.Type]; bad {
 			return fmt.Errorf("unsafe argument type '%s' cannot be used with Shell execution. Use 'exec' instead. See https://docs.olivetin.app/action_execution/shellvsexec.html", arg.Type)
@@ -378,16 +399,20 @@ func MangleArgumentValue(arg *config.ActionArgument, value string, actionTitle s
 		log.Debugf("MangleArgumentValue called with nil arg, returning value unchanged")
 		return value
 	}
+	return mangleArgumentValueByType(arg, value, actionTitle)
+}
 
-	if arg.Type == "datetime" {
+func mangleArgumentValueByType(arg *config.ActionArgument, value string, actionTitle string) string {
+	switch arg.Type {
+	case "file_upload":
+		return value
+	case "datetime":
 		return mangleDatetimeValue(arg, value, actionTitle)
-	}
-
-	if arg.Type == "checkbox" {
+	case "checkbox":
 		return mangleCheckboxValue(arg, value, actionTitle)
+	default:
+		return value
 	}
-
-	return value
 }
 
 func mangleDatetimeValue(arg *config.ActionArgument, value string, actionTitle string) string {

+ 50 - 31
service/internal/executor/arguments_test.go

@@ -34,10 +34,10 @@ func TestValidateArgumentCheckboxDefaultValues(t *testing.T) {
 	}
 
 	// Default checkbox values without choices should accept "1" and "0"
-	err := ValidateArgument(&arg, "1", &action)
+	err := ValidateArgument(&arg, "1", &action, nil, "")
 	assert.Nil(t, err, "Expected checkbox value \"1\" to be accepted without choices")
 
-	err = ValidateArgument(&arg, "0", &action)
+	err = ValidateArgument(&arg, "0", &action, nil, "")
 	assert.Nil(t, err, "Expected checkbox value \"0\" to be accepted without choices")
 }
 
@@ -104,23 +104,26 @@ func TestValidateArgumentCheckboxWithChoices(t *testing.T) {
 	}
 
 	// Titles should be accepted once mangled to their values
-	err := ValidateArgument(&arg, "Enabled", &action)
+	err := ValidateArgument(&arg, "Enabled", &action, nil, "")
 	assert.Nil(t, err, "Expected checkbox title \"Enabled\" to be accepted after mangling to choice value")
 
-	err = ValidateArgument(&arg, "Disabled", &action)
+	err = ValidateArgument(&arg, "Disabled", &action, nil, "")
 	assert.Nil(t, err, "Expected checkbox title \"Disabled\" to be accepted after mangling to choice value")
 
 	// Unknown titles should be rejected because they do not match any choice value
-	err = ValidateArgument(&arg, "Maybe", &action)
+	err = ValidateArgument(&arg, "Maybe", &action, nil, "")
 	assert.NotNil(t, err, "Expected unknown checkbox title to be rejected against choices")
 }
 
 func newExecRequest() *ExecutionRequest {
+	ex := &Executor{}
 	return &ExecutionRequest{
 		Arguments: make(map[string]string),
 		Binding: &ActionBinding{
+			ID:     "test-binding",
 			Action: &config.Action{},
 		},
+		executor: ex,
 	}
 }
 
@@ -207,7 +210,7 @@ func TestExecArrayParsing(t *testing.T) {
 
 	req.Arguments = map[string]string{}
 
-	out, err := parseActionExec(req.Arguments, req.Binding.Action, req.Binding.Entity)
+	out, err := parseActionExec(req)
 
 	assert.Nil(t, err)
 	assert.Equal(t, []string{"ls", "-alh"}, out)
@@ -225,11 +228,13 @@ func TestExecArrayWithTemplateReplacement(t *testing.T) {
 		},
 	}
 
-	values := map[string]string{
+	req := newExecRequest()
+	req.Binding.Action = &a1
+	req.Arguments = map[string]string{
 		"path": "tmp",
 	}
 
-	out, err := parseActionExec(values, &a1, nil)
+	out, err := parseActionExec(req)
 
 	assert.Nil(t, err)
 	assert.Equal(t, []string{"ls", "-alh", "tmp"}, out)
@@ -612,7 +617,7 @@ func TestTypecheckActionArgumentEmptyName(t *testing.T) {
 	}
 	action := config.Action{Title: "Test"}
 
-	err := typecheckActionArgument(&arg, "test", &action)
+	err := typecheckActionArgument(&arg, "test", &action, nil, "")
 	assert.NotNil(t, err)
 	assert.Contains(t, err.Error(), "argument name cannot be empty")
 }
@@ -624,19 +629,39 @@ func TestTypecheckActionArgumentConfirmation(t *testing.T) {
 	}
 	action := config.Action{Title: "Test"}
 
-	err := typecheckActionArgument(&arg, "any_value", &action)
+	err := typecheckActionArgument(&arg, "any_value", &action, nil, "")
 	assert.Nil(t, err, "Confirmation type should always pass validation")
 }
 
+type parseReplacementCase struct {
+	name           string
+	shellCommand   string
+	values         map[string]string
+	expectedOutput string
+	expectError    bool
+	errorContains  string
+}
+
+func (tt parseReplacementCase) run(t *testing.T) {
+	t.Helper()
+	anyVals := make(map[string]any)
+	for k, v := range tt.values {
+		anyVals[k] = v
+	}
+	output, err := tpl.ParseTemplateWithActionContext(tt.shellCommand, nil, anyVals)
+	if tt.expectError {
+		assert.NotNil(t, err, "Expected error but got none")
+		if tt.errorContains != "" {
+			assert.Contains(t, err.Error(), tt.errorContains)
+		}
+		return
+	}
+	assert.Nil(t, err, "Expected no error but got: %v", err)
+	assert.Equal(t, tt.expectedOutput, output)
+}
+
 func TestParseCommandForReplacements(t *testing.T) {
-	tests := []struct {
-		name           string
-		shellCommand   string
-		values         map[string]string
-		expectedOutput string
-		expectError    bool
-		errorContains  string
-	}{
+	tests := []parseReplacementCase{
 		{
 			name:           "Simple replacement",
 			shellCommand:   "echo {{ name }}",
@@ -683,19 +708,7 @@ func TestParseCommandForReplacements(t *testing.T) {
 	}
 
 	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			output, err := tpl.ParseTemplateWithActionContext(tt.shellCommand, nil, tt.values)
-
-			if tt.expectError {
-				assert.NotNil(t, err, "Expected error but got none")
-				if tt.errorContains != "" {
-					assert.Contains(t, err.Error(), tt.errorContains)
-				}
-			} else {
-				assert.Nil(t, err, "Expected no error but got: %v", err)
-				assert.Equal(t, tt.expectedOutput, output)
-			}
-		})
+		t.Run(tt.name, tt.run)
 	}
 }
 
@@ -709,7 +722,9 @@ func TestArgumentChoicesValidation(t *testing.T) {
 		{
 			name: "Valid choice",
 			req: &ExecutionRequest{
+				executor: &Executor{},
 				Binding: &ActionBinding{
+					ID: "test-binding",
 					Action: &config.Action{
 						Title: "Test choices",
 						Shell: "echo {{ option }}",
@@ -733,7 +748,9 @@ func TestArgumentChoicesValidation(t *testing.T) {
 		{
 			name: "Invalid choice",
 			req: &ExecutionRequest{
+				executor: &Executor{},
 				Binding: &ActionBinding{
+					ID: "test-binding",
 					Action: &config.Action{
 						Title: "Test choices",
 						Shell: "echo {{ option }}",
@@ -757,7 +774,9 @@ func TestArgumentChoicesValidation(t *testing.T) {
 		{
 			name: "Invalid choice",
 			req: &ExecutionRequest{
+				executor: &Executor{},
 				Binding: &ActionBinding{
+					ID: "test-binding",
 					Action: &config.Action{
 						Title: "Test choices",
 						Shell: "echo {{ option }}",

+ 128 - 0
service/internal/executor/arguments_upload.go

@@ -0,0 +1,128 @@
+package executor
+
+import (
+	"fmt"
+	"regexp"
+
+	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/fileupload"
+	"github.com/OliveTin/OliveTin/internal/tpl"
+)
+
+var fileUploadTokenPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
+
+func validateFileUploadArg(value string, arg *config.ActionArgument, reg *fileupload.Registry, bindingID string) error {
+	if value == "" {
+		return typecheckNull(arg)
+	}
+	if !fileUploadTokenPattern.MatchString(value) {
+		return fmt.Errorf("invalid upload token")
+	}
+	if reg == nil {
+		return errUploadsUnavailable()
+	}
+	return reg.ValidatePeekToken(value, bindingID, arg.Name)
+}
+
+func finalizeFileUploadArguments(req *ExecutionRequest) error {
+	if !hasActionForFileFinalize(req) {
+		return nil
+	}
+	if req.FileArgData == nil {
+		req.FileArgData = make(map[string]*tpl.FileUpload)
+	}
+	return finalizeEachFileUploadArg(req)
+}
+
+func finalizeEachFileUploadArg(req *ExecutionRequest) error {
+	for i := range req.Binding.Action.Arguments {
+		arg := &req.Binding.Action.Arguments[i]
+		if arg.Type != "file_upload" {
+			continue
+		}
+		if err := finalizeOneFileUpload(req, arg); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func hasActionForFileFinalize(req *ExecutionRequest) bool {
+	return req != nil && req.Binding != nil && req.Binding.Action != nil
+}
+
+func finalizeOneFileUpload(req *ExecutionRequest, arg *config.ActionArgument) error {
+	raw := req.Arguments[arg.Name]
+	if raw == "" {
+		return finalizeEmptyFileArg(req, arg)
+	}
+	reg := req.executor.UploadRegistry
+	if reg == nil {
+		return errUploadsUnavailable()
+	}
+	staged, err := reg.ConsumeToken(raw, req.Binding.ID, arg.Name)
+	if err != nil {
+		return err
+	}
+	applyConsumedStagedFile(req, arg, staged)
+	return nil
+}
+
+func finalizeEmptyFileArg(req *ExecutionRequest, arg *config.ActionArgument) error {
+	if arg.RejectNull {
+		return errRejectNullFile(arg.Name)
+	}
+	req.FileArgData[arg.Name] = nil
+	return nil
+}
+
+func applyConsumedStagedFile(req *ExecutionRequest, arg *config.ActionArgument, staged *fileupload.StagedFile) {
+	req.UploadTempPaths = append(req.UploadTempPaths, staged.Path)
+	req.Arguments[arg.Name] = staged.Path
+	req.FileArgData[arg.Name] = &tpl.FileUpload{
+		TmpName:  staged.Path,
+		Name:     staged.OriginalName,
+		MimeType: staged.MimeType,
+		Size:     staged.Size,
+	}
+}
+
+func buildTemplateArgumentMap(req *ExecutionRequest) map[string]any {
+	out := make(map[string]any)
+	for k, v := range req.Arguments {
+		if fu, ok := req.FileArgData[k]; ok {
+			out[k] = fu
+			continue
+		}
+		out[k] = v
+	}
+	return out
+}
+
+func triggerArgumentsWithoutUploads(req *ExecutionRequest) map[string]string {
+	if !hasBindingAndAction(req) {
+		return nil
+	}
+	out := make(map[string]string, len(req.Arguments))
+	for k, v := range req.Arguments {
+		out[k] = v
+	}
+	clearFileUploadArgs(out, req.Binding.Action.Arguments)
+	return out
+}
+
+func clearFileUploadArgs(out map[string]string, args []config.ActionArgument) {
+	for i := range args {
+		if args[i].Type == "file_upload" {
+			out[args[i].Name] = ""
+		}
+	}
+}
+
+func errRejectNullFile(name string) error {
+	return fmt.Errorf("argument %s requires a file", name)
+}
+
+func errUploadsUnavailable() error {
+	return fmt.Errorf("file uploads are not available on this server")
+}

+ 51 - 10
service/internal/executor/executor.go

@@ -6,6 +6,7 @@ import (
 	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	"github.com/OliveTin/OliveTin/internal/entities"
+	"github.com/OliveTin/OliveTin/internal/fileupload"
 	"github.com/OliveTin/OliveTin/internal/tpl"
 	"github.com/google/uuid"
 	log "github.com/sirupsen/logrus"
@@ -68,6 +69,8 @@ type Executor struct {
 
 	Cfg *config.Config
 
+	UploadRegistry *fileupload.Registry
+
 	listeners []listener
 
 	chainOfCommand []executorStepFunc
@@ -84,6 +87,9 @@ type ExecutionRequest struct {
 	AuthenticatedUser *authpublic.AuthenticatedUser
 	TriggerDepth      int
 
+	FileArgData     map[string]*tpl.FileUpload
+	UploadTempPaths []string
+
 	logEntry           *InternalLogEntry
 	finalParsedCommand string
 	execArgs           []string
@@ -154,6 +160,15 @@ func DefaultExecutor(cfg *config.Config) *Executor {
 		stepLogFinish,
 		stepSaveLog,
 		stepTrigger,
+		stepCleanupUploadTemps,
+	}
+
+	reg, err := fileupload.NewRegistry(cfg)
+	if err != nil {
+		log.WithError(err).Warn("File uploads are disabled (could not initialize staging directory)")
+	} else {
+		e.UploadRegistry = reg
+		reg.StartPeriodicPrune()
 	}
 
 	return &e
@@ -683,7 +698,7 @@ func stepParseArgs(req *ExecutionRequest) bool {
 }
 
 func handleExecBranch(req *ExecutionRequest) bool {
-	args, err := parseActionExec(req.Arguments, req.Binding.Action, req.Binding.Entity)
+	args, err := parseActionExec(req)
 
 	if err != nil {
 		return fail(req, err)
@@ -753,6 +768,9 @@ func injectSystemArgs(req *ExecutionRequest) {
 }
 
 func hasBindingAndAction(req *ExecutionRequest) bool {
+	if req == nil {
+		return false
+	}
 	return !(req.Binding == nil || req.Binding.Action == nil)
 }
 
@@ -763,9 +781,33 @@ func hasExec(req *ExecutionRequest) bool {
 func fail(req *ExecutionRequest, err error) bool {
 	req.logEntry.Output = err.Error()
 	log.Warn(err.Error())
+	removeUploadTempFiles(req)
 	return false
 }
 
+func stepCleanupUploadTemps(req *ExecutionRequest) bool {
+	removeUploadTempFiles(req)
+	return true
+}
+
+func removeUploadTempFiles(req *ExecutionRequest) {
+	reg := registryForUploadCleanup(req)
+	if reg == nil {
+		return
+	}
+	for _, p := range req.UploadTempPaths {
+		reg.DeleteTempFile(p)
+	}
+	req.UploadTempPaths = nil
+}
+
+func registryForUploadCleanup(req *ExecutionRequest) *fileupload.Registry {
+	if req == nil || req.executor == nil || req.executor.UploadRegistry == nil {
+		return nil
+	}
+	return req.executor.UploadRegistry
+}
+
 func stepRequestAction(req *ExecutionRequest) bool {
 	metricActionsRequested.Inc()
 
@@ -949,14 +991,13 @@ func stepExecAfter(req *ExecutionRequest) bool {
 	var stdout bytes.Buffer
 	var stderr bytes.Buffer
 
-	args := map[string]string{
-		"output":                 req.logEntry.Output,
-		"exitCode":               fmt.Sprintf("%v", req.logEntry.ExitCode),
-		"ot_executionTrackingId": req.TrackingID,
-		"ot_username":            req.AuthenticatedUser.Username,
-	}
+	merged := buildTemplateArgumentMap(req)
+	merged["output"] = req.logEntry.Output
+	merged["exitCode"] = fmt.Sprintf("%v", req.logEntry.ExitCode)
+	merged["ot_executionTrackingId"] = req.TrackingID
+	merged["ot_username"] = req.AuthenticatedUser.Username
 
-	finalParsedCommand, err := tpl.ParseTemplateWithActionContext(req.Binding.Action.ShellAfterCompleted, req.Binding.Entity, args)
+	finalParsedCommand, err := tpl.ParseTemplateWithActionContext(req.Binding.Action.ShellAfterCompleted, req.Binding.Entity, merged)
 
 	if err != nil {
 		msg := "Could not prepare shellAfterCompleted command: " + err.Error() + "\n"
@@ -969,7 +1010,7 @@ func stepExecAfter(req *ExecutionRequest) bool {
 	cmd.Stdout = &stdout
 	cmd.Stderr = &stderr
 
-	cmd.Env = buildEnv(args)
+	cmd.Env = buildEnv(req.Arguments)
 
 	runerr := cmd.Start()
 	ctx.setProcess(cmd.Process)
@@ -1037,7 +1078,7 @@ func triggerLoop(req *ExecutionRequest) {
 			TrackingID:        uuid.NewString(),
 			Tags:              []string{"trigger"},
 			AuthenticatedUser: req.AuthenticatedUser,
-			Arguments:         req.Arguments,
+			Arguments:         triggerArgumentsWithoutUploads(req),
 			Cfg:               req.Cfg,
 			TriggerDepth:      req.TriggerDepth + 1,
 		}

+ 1 - 1
service/internal/executor/executor_actions.go

@@ -63,7 +63,7 @@ func validateArgumentDefault(action *config.Action, arg *config.ActionArgument)
 	if arg.Default == "" {
 		return
 	}
-	if err := ValidateArgument(arg, arg.Default, action); err != nil {
+	if err := ValidateArgument(arg, arg.Default, action, nil, ""); err != nil {
 		log.WithFields(log.Fields{
 			"actionTitle": action.Title,
 			"argName":     arg.Name,

+ 47 - 0
service/internal/fileupload/mime.go

@@ -0,0 +1,47 @@
+package fileupload
+
+import (
+	"mime"
+	"strings"
+)
+
+func normalizeMediaType(s string) string {
+	s = strings.TrimSpace(s)
+	if s == "" {
+		return ""
+	}
+	mt, _, err := mime.ParseMediaType(s)
+	if err != nil {
+		if idx := strings.Index(s, ";"); idx >= 0 {
+			return strings.ToLower(strings.TrimSpace(s[:idx]))
+		}
+		return strings.ToLower(s)
+	}
+	return strings.ToLower(mt)
+}
+
+func mimeAllowed(detected string, allowed []string) bool {
+	d := normalizeMediaType(detected)
+	if d == "" {
+		return false
+	}
+	for _, raw := range allowed {
+		if mimeRuleMatches(d, raw) {
+			return true
+		}
+	}
+	return false
+}
+
+func mimeRuleMatches(detectedNormalized, raw string) bool {
+	a := strings.TrimSpace(strings.ToLower(raw))
+	if a == "" {
+		return false
+	}
+	if !strings.HasSuffix(a, "/*") {
+		rule := normalizeMediaType(raw)
+		return rule != "" && rule == detectedNormalized
+	}
+	prefix := strings.TrimSuffix(a, "/*")
+	return strings.HasPrefix(detectedNormalized, prefix+"/")
+}

+ 38 - 0
service/internal/fileupload/mime_test.go

@@ -0,0 +1,38 @@
+package fileupload
+
+import "testing"
+
+func TestMimeAllowed_plainWithCharset(t *testing.T) {
+	allowed := []string{"text/plain"}
+	if !mimeAllowed("text/plain; charset=utf-8", allowed) {
+		t.Fatal("text/plain rule should allow text/plain with charset parameter")
+	}
+}
+
+func TestMimeAllowed_exactPlain(t *testing.T) {
+	allowed := []string{"text/plain"}
+	if !mimeAllowed("text/plain", allowed) {
+		t.Fatal("text/plain should match text/plain")
+	}
+}
+
+func TestMimeAllowed_wildcard(t *testing.T) {
+	allowed := []string{"text/*"}
+	if !mimeAllowed("text/plain; charset=utf-8", allowed) {
+		t.Fatal("text/* should allow text/plain with charset")
+	}
+}
+
+func TestMimeAllowed_rejectOther(t *testing.T) {
+	allowed := []string{"text/plain"}
+	if mimeAllowed("application/octet-stream", allowed) {
+		t.Fatal("application/octet-stream should not match text/plain")
+	}
+}
+
+func TestMimeAllowed_ruleWithParams(t *testing.T) {
+	allowed := []string{"text/plain; charset=utf-8"}
+	if !mimeAllowed("text/plain; charset=utf-8", allowed) {
+		t.Fatal("config with charset should still match")
+	}
+}

+ 301 - 0
service/internal/fileupload/registry.go

@@ -0,0 +1,301 @@
+package fileupload
+
+import (
+	"crypto/rand"
+	"encoding/hex"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+
+	config "github.com/OliveTin/OliveTin/internal/config"
+	log "github.com/sirupsen/logrus"
+)
+
+// StagedFile is a validated upload ready for template expansion and command execution.
+type StagedFile struct {
+	Path         string
+	OriginalName string
+	MimeType     string
+	Size         int64
+}
+
+// Registry stores single-use upload tokens mapped to temp files on disk.
+type Registry struct {
+	mu      sync.Mutex
+	pending map[string]*pendingEntry
+	cfg     *config.Config
+	baseDir string
+}
+
+type pendingEntry struct {
+	path         string
+	bindingID    string
+	argName      string
+	originalName string
+	mimeType     string
+	size         int64
+	expires      time.Time
+}
+
+// NewRegistry creates an upload registry and ensures the staging directory exists.
+func NewRegistry(cfg *config.Config) (*Registry, error) {
+	if cfg == nil {
+		return nil, fmt.Errorf("fileupload: config is nil")
+	}
+	abs, err := resolveUploadBaseDir(cfg)
+	if err != nil {
+		return nil, fmt.Errorf("fileupload: temp directory: %w", err)
+	}
+	return &Registry{
+		cfg:     cfg,
+		pending: make(map[string]*pendingEntry),
+		baseDir: abs,
+	}, nil
+}
+
+// StartPeriodicPrune runs a background loop that removes pending uploads past their TTL.
+// Without this, staged files are only deleted when some other registry operation runs prune.
+func (r *Registry) StartPeriodicPrune() {
+	go r.periodicPruneLoop()
+}
+
+func (r *Registry) periodicPruneLoop() {
+	ticker := time.NewTicker(30 * time.Second)
+	defer ticker.Stop()
+	for range ticker.C {
+		r.pruneExpired()
+	}
+}
+
+func (r *Registry) pruneExpired() {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	r.pruneLocked()
+}
+
+func resolveUploadBaseDir(cfg *config.Config) (string, error) {
+	base := cfg.FileUploads.TempDirectory
+	if base == "" {
+		base = filepath.Join(os.TempDir(), "olivetin-uploads")
+	}
+	abs, err := filepath.Abs(base)
+	if err != nil {
+		return "", err
+	}
+	if err := os.MkdirAll(abs, 0o700); err != nil {
+		return "", err
+	}
+	return abs, nil
+}
+
+func (r *Registry) tokenTTL() time.Duration {
+	sec := r.cfg.FileUploads.TokenTTLSeconds
+	if sec <= 0 {
+		sec = config.DefaultFileUploadTokenTTLSeconds
+	}
+	return time.Duration(sec) * time.Second
+}
+
+// StageFromMultipart saves the body to a private temp file, validates MIME type, and returns an opaque token.
+func (r *Registry) StageFromMultipart(
+	file io.Reader,
+	filenameHint string,
+	bindingID string,
+	arg *config.ActionArgument,
+) (string, error) {
+	if err := validateStageArgument(arg); err != nil {
+		return "", err
+	}
+	maxBytes := arg.EffectiveFileUploadMaxBytes(r.cfg)
+	allowed := arg.EffectiveFileUploadAllowedMimeTypes(r.cfg)
+	if len(allowed) == 0 {
+		return "", fmt.Errorf("no allowedMimeTypes configured for this argument (configure argument or fileUploads.defaultAllowedMimeTypes)")
+	}
+	return r.stageMultipartBody(file, filenameHint, bindingID, arg, maxBytes, allowed)
+}
+
+func (r *Registry) stageMultipartBody(
+	file io.Reader,
+	filenameHint, bindingID string,
+	arg *config.ActionArgument,
+	maxBytes int64,
+	allowed []string,
+) (string, error) {
+	tmpPath, n, err := r.copyLimitedToTemp(file, maxBytes)
+	if err != nil {
+		return "", err
+	}
+	if arg.RejectNull && n == 0 {
+		_ = os.Remove(tmpPath)
+		return "", fmt.Errorf("empty file not allowed")
+	}
+	return r.finishStagedFile(tmpPath, filenameHint, bindingID, arg, n, allowed)
+}
+
+func (r *Registry) finishStagedFile(
+	tmpPath, filenameHint, bindingID string,
+	arg *config.ActionArgument,
+	n int64,
+	allowed []string,
+) (string, error) {
+	detected, err := detectMimeFromPath(tmpPath)
+	if err != nil {
+		_ = os.Remove(tmpPath)
+		return "", err
+	}
+	if !mimeAllowed(detected, allowed) {
+		_ = os.Remove(tmpPath)
+		return "", fmt.Errorf("MIME type %q is not allowed", detected)
+	}
+	token, err := r.storeStagedUpload(tmpPath, bindingID, filenameHint, arg, detected, n)
+	if err != nil {
+		_ = os.Remove(tmpPath)
+		return "", err
+	}
+	log.WithFields(log.Fields{
+		"bindingId": bindingID,
+		"arg":       arg.Name,
+		"mime":      detected,
+		"bytes":     n,
+	}).Debug("staged file upload")
+	return token, nil
+}
+
+func (r *Registry) storeStagedUpload(tmpPath, bindingID, filenameHint string, arg *config.ActionArgument, detected string, n int64) (string, error) {
+	token, err := newUploadToken()
+	if err != nil {
+		return "", err
+	}
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	r.pruneLocked()
+	r.pending[token] = &pendingEntry{
+		path:         tmpPath,
+		bindingID:    bindingID,
+		argName:      arg.Name,
+		originalName: sanitizeFilename(filenameHint),
+		mimeType:     detected,
+		size:         n,
+		expires:      time.Now().Add(r.tokenTTL()),
+	}
+	return token, nil
+}
+
+func validateStageArgument(arg *config.ActionArgument) error {
+	if arg == nil || arg.Type != "file_upload" {
+		return fmt.Errorf("invalid file upload argument")
+	}
+	return nil
+}
+
+func (r *Registry) pruneLocked() {
+	now := time.Now()
+	for k, v := range r.pending {
+		if now.After(v.expires) {
+			_ = os.Remove(v.path)
+			delete(r.pending, k)
+		}
+	}
+}
+
+// ValidatePeekToken checks that a token exists and matches the binding and argument (does not consume).
+func (r *Registry) ValidatePeekToken(token, bindingID, argName string) error {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	r.pruneLocked()
+	return r.peekLocked(token, bindingID, argName)
+}
+
+func (r *Registry) peekLocked(token, bindingID, argName string) error {
+	ent, ok := r.pending[token]
+	if !ok {
+		return fmt.Errorf("unknown or expired upload token")
+	}
+	if r.pendingEntryExpired(ent, token) {
+		return fmt.Errorf("unknown or expired upload token")
+	}
+	return r.pendingEntryMatches(ent, bindingID, argName)
+}
+
+func (r *Registry) pendingEntryExpired(ent *pendingEntry, token string) bool {
+	if !time.Now().After(ent.expires) {
+		return false
+	}
+	_ = os.Remove(ent.path)
+	delete(r.pending, token)
+	return true
+}
+
+func (r *Registry) pendingEntryMatches(ent *pendingEntry, bindingID, argName string) error {
+	if ent.bindingID != bindingID || ent.argName != argName {
+		return fmt.Errorf("upload token does not match this action argument")
+	}
+	if !r.fileWithinBase(ent.path) {
+		return fmt.Errorf("invalid staged file path")
+	}
+	return nil
+}
+
+// ConsumeToken removes the token and returns the staged file (single use).
+func (r *Registry) ConsumeToken(token, bindingID, argName string) (*StagedFile, error) {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	r.pruneLocked()
+	if err := r.peekLocked(token, bindingID, argName); err != nil {
+		return nil, err
+	}
+	ent := r.pending[token]
+	delete(r.pending, token)
+	return &StagedFile{
+		Path:         ent.path,
+		OriginalName: ent.originalName,
+		MimeType:     ent.mimeType,
+		Size:         ent.size,
+	}, nil
+}
+
+// DeleteTempFile removes a temp file if it resides under the registry base directory.
+func (r *Registry) DeleteTempFile(path string) {
+	if !r.fileWithinBase(path) {
+		log.Warnf("refusing to delete path outside upload base: %s", path)
+		return
+	}
+	if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+		log.Warnf("remove upload temp file: %v", err)
+	}
+}
+
+func (r *Registry) fileWithinBase(path string) bool {
+	absFile, err := filepath.Abs(path)
+	if err != nil {
+		return false
+	}
+	rel, err := filepath.Rel(r.baseDir, absFile)
+	if err != nil || strings.HasPrefix(rel, "..") {
+		return false
+	}
+	return true
+}
+
+func newUploadToken() (string, error) {
+	b := make([]byte, 32)
+	if _, err := rand.Read(b); err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(b), nil
+}
+
+func sanitizeFilename(name string) string {
+	base := filepath.Base(name)
+	if base == "." || base == string(filepath.Separator) {
+		return "upload"
+	}
+	if len(base) > 255 {
+		base = base[:255]
+	}
+	return base
+}

+ 64 - 0
service/internal/fileupload/registry_test.go

@@ -0,0 +1,64 @@
+package fileupload
+
+import (
+	"os"
+	"testing"
+	"time"
+
+	config "github.com/OliveTin/OliveTin/internal/config"
+)
+
+func TestPruneExpiredRemovesStalePendingFile(t *testing.T) {
+	r := mustRegistry(t)
+	path := mustTempUnder(t, r.baseDir)
+	seedExpiredPending(r, path)
+	r.pruneExpired()
+	assertNoFile(t, path)
+	assertPendingGone(t, r)
+}
+
+func mustRegistry(t *testing.T) *Registry {
+	t.Helper()
+	r, err := NewRegistry(config.DefaultConfig())
+	if err != nil {
+		t.Fatalf("NewRegistry: %v", err)
+	}
+	return r
+}
+
+func mustTempUnder(t *testing.T, dir string) string {
+	t.Helper()
+	f, err := os.CreateTemp(dir, "olu-")
+	if err != nil {
+		t.Fatalf("CreateTemp: %v", err)
+	}
+	path := f.Name()
+	_ = f.Close()
+	return path
+}
+
+func seedExpiredPending(r *Registry, path string) {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	r.pending["testtoken"] = &pendingEntry{
+		path:    path,
+		expires: time.Now().Add(-time.Minute),
+	}
+}
+
+func assertNoFile(t *testing.T, path string) {
+	t.Helper()
+	_, err := os.Stat(path)
+	if !os.IsNotExist(err) {
+		t.Fatalf("expected temp file removed, stat err=%v", err)
+	}
+}
+
+func assertPendingGone(t *testing.T, r *Registry) {
+	t.Helper()
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	if _, ok := r.pending["testtoken"]; ok {
+		t.Fatal("expected pending entry deleted")
+	}
+}

+ 50 - 0
service/internal/fileupload/staging.go

@@ -0,0 +1,50 @@
+package fileupload
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+)
+
+func (r *Registry) copyLimitedToTemp(file io.Reader, maxBytes int64) (string, int64, error) {
+	tmp, err := os.CreateTemp(r.baseDir, "olu-")
+	if err != nil {
+		return "", 0, fmt.Errorf("create temp file: %w", err)
+	}
+	path := tmp.Name()
+	n, err := io.Copy(tmp, io.LimitReader(file, maxBytes+1))
+	cerr := tmp.Close()
+	return finalizeLimitedCopy(path, n, err, cerr, maxBytes)
+}
+
+func finalizeLimitedCopy(path string, n int64, err, cerr error, maxBytes int64) (string, int64, error) {
+	if err != nil {
+		_ = os.Remove(path)
+		return "", 0, fmt.Errorf("read upload: %w", err)
+	}
+	if cerr != nil {
+		_ = os.Remove(path)
+		return "", 0, fmt.Errorf("close temp file: %w", cerr)
+	}
+	if n > maxBytes {
+		_ = os.Remove(path)
+		return "", n, fmt.Errorf("file exceeds maximum size of %d bytes", maxBytes)
+	}
+	return path, n, nil
+}
+
+func detectMimeFromPath(path string) (string, error) {
+	header := make([]byte, 512)
+	f, err := os.Open(path)
+	if err != nil {
+		return "", err
+	}
+	defer f.Close()
+	readN, readErr := f.Read(header)
+	if readErr != nil && !errors.Is(readErr, io.EOF) {
+		return "", fmt.Errorf("read sniff buffer: %w", readErr)
+	}
+	return http.DetectContentType(header[:readN]), nil
+}

+ 2 - 0
service/internal/httpservers/frontend.go

@@ -78,6 +78,8 @@ func StartFrontendMux(cfg *config.Config, ex *executor.Executor) {
 
 	mux := http.NewServeMux()
 
+	mux.HandleFunc("/api/upload/action-argument", api.GetActionArgumentUploadHandler(ex))
+
 	apiPath, apiHandler := api.GetNewHandler(ex)
 
 	log.Infof("API path is %s", apiPath)

+ 34 - 14
service/internal/tpl/templates.go

@@ -41,9 +41,18 @@ type generalTemplateContext struct {
 	Env      map[string]string
 }
 
+// FileUpload is exposed in action templates as .Arguments.<name> for type file_upload.
+// TmpName is the absolute path of the staged file on the server (similar to PHP's tmp_name).
+type FileUpload struct {
+	TmpName  string
+	Name     string
+	MimeType string
+	Size     int64
+}
+
 type actionTemplateContext struct {
 	CurrentEntity interface{}
-	Arguments     map[string]string
+	Arguments     map[string]any
 
 	// These are deliberately repeated because embedding structs
 	// won't work in text/template.
@@ -51,6 +60,15 @@ type actionTemplateContext struct {
 	Env      map[string]string
 }
 
+func newActionTemplateContext(entdata any, args map[string]any) *actionTemplateContext {
+	return &actionTemplateContext{
+		OliveTin:      cachedOliveTinInfo,
+		Env:           cachedEnvMap,
+		Arguments:     args,
+		CurrentEntity: entdata,
+	}
+}
+
 var (
 	cachedOliveTinInfo olivetinInfo
 	cachedEnvMap       map[string]string
@@ -131,34 +149,36 @@ func migrateLegacyArgumentNames(rawShellCommand string) string {
 	return rawShellCommand
 }
 
-func ParseTemplateWithActionContext(source string, ent *entities.Entity, args map[string]string) (string, error) {
+func ParseTemplateWithActionContext(source string, ent *entities.Entity, args map[string]any) (string, error) {
 	source = migrateLegacyArgumentNames(source)
 	source = migrateLegacyEntityProperties(source)
+	return parseTemplateWithEntityAndArgs(source, ent, args)
+}
 
-	var entdata any
+func parseTemplateWithEntityAndArgs(source string, ent *entities.Entity, args map[string]any) (string, error) {
+	result, err := execActionTemplateParse(source, ent, args)
+	return finishActionTemplateParse(result, err)
+}
 
+func execActionTemplateParse(source string, ent *entities.Entity, args map[string]any) (string, error) {
+	var entdata any
 	if ent != nil {
 		entdata = ent.Data
 	}
-
-	templateVariables := &actionTemplateContext{
-		OliveTin: cachedOliveTinInfo,
-		Env:      cachedEnvMap,
-
-		Arguments:     args,
-		CurrentEntity: entdata,
+	if args == nil {
+		args = map[string]any{}
 	}
+	templateVariables := newActionTemplateContext(entdata, args)
+	return parseTemplate(source, templateVariables)
+}
 
-	result, err := parseTemplate(source, templateVariables)
-
+func finishActionTemplateParse(result string, err error) (string, error) {
 	if isMissingArgumentError, argName := checkMissingArgumentError(err); isMissingArgumentError {
 		return "", fmt.Errorf("required arg not provided: %s", argName)
 	}
-
 	if err != nil {
 		return "", err
 	}
-
 	return result, nil
 }
 

+ 33 - 24
service/internal/tpl/templates_test.go

@@ -2,6 +2,7 @@ package tpl
 
 import (
 	"encoding/json"
+	"fmt"
 	"strings"
 	"testing"
 
@@ -9,21 +10,41 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
+type templateJsonCase struct {
+	name           string
+	source         string
+	ent            *entities.Entity
+	args           map[string]any
+	expectedOutput string
+	expectError    bool
+	checkJsonOnly  bool
+}
+
+func (tt templateJsonCase) run(t *testing.T) {
+	output, err := ParseTemplateWithActionContext(tt.source, tt.ent, tt.args)
+	if tt.expectError {
+		assert.Error(t, err)
+		return
+	}
+	assert.NoError(t, err)
+	if tt.checkJsonOnly {
+		strArgs := make(map[string]string)
+		for k, v := range tt.args {
+			strArgs[k] = fmt.Sprintf("%v", v)
+		}
+		assertJsonOutput(t, output, tt.expectedOutput, strArgs)
+		return
+	}
+	assert.Equal(t, tt.expectedOutput, output)
+}
+
 func TestParseTemplateWithActionContext_Json(t *testing.T) {
-	tests := []struct {
-		name           string
-		source         string
-		ent            *entities.Entity
-		args           map[string]string
-		expectedOutput string
-		expectError    bool
-		checkJsonOnly  bool
-	}{
+	tests := []templateJsonCase{
 		{
 			name:           "Arguments piped to Json",
 			source:         `echo {{ .Arguments | Json }}`,
 			ent:            nil,
-			args:           map[string]string{"value": "true", "ot_username": "alice"},
+			args:           map[string]any{"value": "true", "ot_username": "alice"},
 			expectedOutput: `echo `,
 			expectError:    false,
 			checkJsonOnly:  true,
@@ -48,25 +69,13 @@ func TestParseTemplateWithActionContext_Json(t *testing.T) {
 			name:           "Single argument value as Json",
 			source:         `echo {{ .Arguments.value | Json }}`,
 			ent:            nil,
-			args:           map[string]string{"value": "hello"},
+			args:           map[string]any{"value": "hello"},
 			expectedOutput: `echo "hello"`,
 			expectError:    false,
 		},
 	}
 	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			output, err := ParseTemplateWithActionContext(tt.source, tt.ent, tt.args)
-			if tt.expectError {
-				assert.Error(t, err)
-				return
-			}
-			assert.NoError(t, err)
-			if tt.checkJsonOnly {
-				assertJsonOutput(t, output, tt.expectedOutput, tt.args)
-			} else {
-				assert.Equal(t, tt.expectedOutput, output)
-			}
-		})
+		t.Run(tt.name, tt.run)
 	}
 }
 

Некоторые файлы не были показаны из-за большого количества измененных файлов