registry.go 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. package fileupload
  2. import (
  3. "context"
  4. "crypto/rand"
  5. "encoding/hex"
  6. "fmt"
  7. "io"
  8. "os"
  9. "path/filepath"
  10. "strings"
  11. "sync"
  12. "time"
  13. config "github.com/OliveTin/OliveTin/internal/config"
  14. log "github.com/sirupsen/logrus"
  15. )
  16. // StagedFile is a validated upload ready for template expansion and command execution.
  17. type StagedFile struct {
  18. Path string
  19. OriginalName string
  20. MimeType string
  21. Size int64
  22. }
  23. // Registry stores single-use upload tokens mapped to temp files on disk.
  24. type Registry struct {
  25. mu sync.Mutex
  26. pending map[string]*pendingEntry
  27. cfg *config.Config
  28. baseDir string
  29. pruneMu sync.Mutex
  30. pruneCancel context.CancelFunc
  31. }
  32. type pendingEntry struct {
  33. path string
  34. bindingID string
  35. argName string
  36. originalName string
  37. mimeType string
  38. size int64
  39. expires time.Time
  40. }
  41. // NewRegistry creates an upload registry and ensures the staging directory exists.
  42. func NewRegistry(cfg *config.Config) (*Registry, error) {
  43. if cfg == nil {
  44. return nil, fmt.Errorf("fileupload: config is nil")
  45. }
  46. abs, err := resolveUploadBaseDir(cfg)
  47. if err != nil {
  48. return nil, fmt.Errorf("fileupload: temp directory: %w", err)
  49. }
  50. return &Registry{
  51. cfg: cfg,
  52. pending: make(map[string]*pendingEntry),
  53. baseDir: abs,
  54. }, nil
  55. }
  56. // StartPeriodicPrune runs a background loop that removes pending uploads past their TTL.
  57. // Without this, staged files are only deleted when some other registry operation runs prune.
  58. // Call Stop to end the loop. Starting again replaces any previous loop.
  59. func (r *Registry) StartPeriodicPrune() {
  60. ctx, cancel := context.WithCancel(context.Background())
  61. r.pruneMu.Lock()
  62. if r.pruneCancel != nil {
  63. r.pruneCancel()
  64. }
  65. r.pruneCancel = cancel
  66. r.pruneMu.Unlock()
  67. go r.periodicPruneLoop(ctx)
  68. }
  69. // Stop cancels the periodic prune goroutine started by StartPeriodicPrune. It is safe to call
  70. // multiple times or when pruning was never started.
  71. func (r *Registry) Stop() {
  72. r.pruneMu.Lock()
  73. defer r.pruneMu.Unlock()
  74. if r.pruneCancel != nil {
  75. r.pruneCancel()
  76. r.pruneCancel = nil
  77. }
  78. }
  79. func (r *Registry) periodicPruneLoop(ctx context.Context) {
  80. ticker := time.NewTicker(30 * time.Second)
  81. defer ticker.Stop()
  82. for {
  83. select {
  84. case <-ctx.Done():
  85. return
  86. case <-ticker.C:
  87. r.pruneExpired()
  88. }
  89. }
  90. }
  91. func (r *Registry) pruneExpired() {
  92. r.mu.Lock()
  93. defer r.mu.Unlock()
  94. r.pruneLocked()
  95. }
  96. func resolveUploadBaseDir(cfg *config.Config) (string, error) {
  97. base := cfg.FileUploads.TempDirectory
  98. if base == "" {
  99. base = filepath.Join(os.TempDir(), "olivetin-uploads")
  100. }
  101. abs, err := filepath.Abs(base)
  102. if err != nil {
  103. return "", err
  104. }
  105. if err := os.MkdirAll(abs, 0o700); err != nil {
  106. return "", err
  107. }
  108. return abs, nil
  109. }
  110. func (r *Registry) tokenTTL() time.Duration {
  111. sec := r.cfg.FileUploads.TokenTTLSeconds
  112. if sec <= 0 {
  113. sec = config.DefaultFileUploadTokenTTLSeconds
  114. }
  115. return time.Duration(sec) * time.Second
  116. }
  117. // StageFromMultipart saves the body to a private temp file, validates MIME type, and returns an opaque token.
  118. func (r *Registry) StageFromMultipart(
  119. file io.Reader,
  120. filenameHint string,
  121. bindingID string,
  122. arg *config.ActionArgument,
  123. ) (string, error) {
  124. if err := validateStageArgument(arg); err != nil {
  125. return "", err
  126. }
  127. maxBytes := arg.EffectiveFileUploadMaxBytes(r.cfg)
  128. allowed := arg.EffectiveFileUploadAllowedMimeTypes(r.cfg)
  129. if len(allowed) == 0 {
  130. return "", fmt.Errorf("no allowedMimeTypes configured for this argument (configure argument or fileUploads.defaultAllowedMimeTypes)")
  131. }
  132. return r.stageMultipartBody(file, filenameHint, bindingID, arg, maxBytes, allowed)
  133. }
  134. func (r *Registry) stageMultipartBody(
  135. file io.Reader,
  136. filenameHint, bindingID string,
  137. arg *config.ActionArgument,
  138. maxBytes int64,
  139. allowed []string,
  140. ) (string, error) {
  141. tmpPath, n, err := r.copyLimitedToTemp(file, maxBytes)
  142. if err != nil {
  143. return "", err
  144. }
  145. if arg.RejectNull && n == 0 {
  146. _ = os.Remove(tmpPath)
  147. return "", fmt.Errorf("empty file not allowed")
  148. }
  149. return r.finishStagedFile(tmpPath, filenameHint, bindingID, arg, n, allowed)
  150. }
  151. func (r *Registry) finishStagedFile(
  152. tmpPath, filenameHint, bindingID string,
  153. arg *config.ActionArgument,
  154. n int64,
  155. allowed []string,
  156. ) (string, error) {
  157. detected, err := detectMimeFromPath(tmpPath)
  158. if err != nil {
  159. _ = os.Remove(tmpPath)
  160. return "", err
  161. }
  162. if !mimeAllowed(detected, allowed) {
  163. _ = os.Remove(tmpPath)
  164. return "", fmt.Errorf("MIME type %q is not allowed", detected)
  165. }
  166. token, err := r.storeStagedUpload(tmpPath, bindingID, filenameHint, arg, detected, n)
  167. if err != nil {
  168. _ = os.Remove(tmpPath)
  169. return "", err
  170. }
  171. log.WithFields(log.Fields{
  172. "bindingId": bindingID,
  173. "arg": arg.Name,
  174. "mime": detected,
  175. "bytes": n,
  176. }).Debug("staged file upload")
  177. return token, nil
  178. }
  179. func (r *Registry) storeStagedUpload(tmpPath, bindingID, filenameHint string, arg *config.ActionArgument, detected string, n int64) (string, error) {
  180. token, err := newUploadToken()
  181. if err != nil {
  182. return "", err
  183. }
  184. r.mu.Lock()
  185. defer r.mu.Unlock()
  186. r.pruneLocked()
  187. r.pending[token] = &pendingEntry{
  188. path: tmpPath,
  189. bindingID: bindingID,
  190. argName: arg.Name,
  191. originalName: sanitizeFilename(filenameHint),
  192. mimeType: detected,
  193. size: n,
  194. expires: time.Now().Add(r.tokenTTL()),
  195. }
  196. return token, nil
  197. }
  198. func validateStageArgument(arg *config.ActionArgument) error {
  199. if arg == nil || arg.Type != "file_upload" {
  200. return fmt.Errorf("invalid file upload argument")
  201. }
  202. return nil
  203. }
  204. func (r *Registry) pruneLocked() {
  205. now := time.Now()
  206. for k, v := range r.pending {
  207. if now.After(v.expires) {
  208. _ = os.Remove(v.path)
  209. delete(r.pending, k)
  210. }
  211. }
  212. }
  213. // ValidatePeekToken checks that a token exists and matches the binding and argument (does not consume).
  214. func (r *Registry) ValidatePeekToken(token, bindingID, argName string) error {
  215. r.mu.Lock()
  216. defer r.mu.Unlock()
  217. r.pruneLocked()
  218. return r.peekLocked(token, bindingID, argName)
  219. }
  220. func (r *Registry) peekLocked(token, bindingID, argName string) error {
  221. ent, ok := r.pending[token]
  222. if !ok {
  223. return fmt.Errorf("unknown or expired upload token")
  224. }
  225. if r.pendingEntryExpired(ent, token) {
  226. return fmt.Errorf("unknown or expired upload token")
  227. }
  228. return r.pendingEntryMatches(ent, bindingID, argName)
  229. }
  230. func (r *Registry) pendingEntryExpired(ent *pendingEntry, token string) bool {
  231. if !time.Now().After(ent.expires) {
  232. return false
  233. }
  234. _ = os.Remove(ent.path)
  235. delete(r.pending, token)
  236. return true
  237. }
  238. func (r *Registry) pendingEntryMatches(ent *pendingEntry, bindingID, argName string) error {
  239. if ent.bindingID != bindingID || ent.argName != argName {
  240. return fmt.Errorf("upload token does not match this action argument")
  241. }
  242. if !r.fileWithinBase(ent.path) {
  243. return fmt.Errorf("invalid staged file path")
  244. }
  245. return nil
  246. }
  247. // ConsumeToken removes the token and returns the staged file (single use).
  248. func (r *Registry) ConsumeToken(token, bindingID, argName string) (*StagedFile, error) {
  249. r.mu.Lock()
  250. defer r.mu.Unlock()
  251. r.pruneLocked()
  252. if err := r.peekLocked(token, bindingID, argName); err != nil {
  253. return nil, err
  254. }
  255. ent := r.pending[token]
  256. delete(r.pending, token)
  257. return &StagedFile{
  258. Path: ent.path,
  259. OriginalName: ent.originalName,
  260. MimeType: ent.mimeType,
  261. Size: ent.size,
  262. }, nil
  263. }
  264. // DeleteTempFile removes a temp file if it resides under the registry base directory.
  265. func (r *Registry) DeleteTempFile(path string) {
  266. if !r.fileWithinBase(path) {
  267. log.Warnf("refusing to delete path outside upload base: %s", path)
  268. return
  269. }
  270. if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
  271. log.Warnf("remove upload temp file: %v", err)
  272. }
  273. }
  274. func (r *Registry) fileWithinBase(path string) bool {
  275. absFile, err := filepath.Abs(path)
  276. if err != nil {
  277. return false
  278. }
  279. rel, err := filepath.Rel(r.baseDir, absFile)
  280. if err != nil || strings.HasPrefix(rel, "..") {
  281. return false
  282. }
  283. return true
  284. }
  285. func newUploadToken() (string, error) {
  286. b := make([]byte, 32)
  287. if _, err := rand.Read(b); err != nil {
  288. return "", err
  289. }
  290. return hex.EncodeToString(b), nil
  291. }
  292. const uploadFilenameSafeRunes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_"
  293. func finalizeSanitizedUploadFilename(out string) string {
  294. if out == "" || out == "." {
  295. return "upload"
  296. }
  297. if len(out) > 255 {
  298. return out[:255]
  299. }
  300. return out
  301. }
  302. func sanitizeFilename(name string) string {
  303. base := filepath.Base(name)
  304. var b strings.Builder
  305. b.Grow(len(base))
  306. for _, r := range base {
  307. if r < 128 && strings.ContainsRune(uploadFilenameSafeRunes, r) {
  308. b.WriteByte(byte(r))
  309. } else {
  310. b.WriteByte('_')
  311. }
  312. }
  313. return finalizeSanitizedUploadFilename(b.String())
  314. }
  315. // SanitizeUploadFilename normalizes a client file name for safe use in shell commands and action templates.
  316. // It is applied when staging uploads; call again when building template context if the value may not
  317. // have passed through staging.
  318. func SanitizeUploadFilename(name string) string {
  319. return sanitizeFilename(name)
  320. }