upload.ex (7264B)
1 defmodule Plug.UploadError do 2 defexception [:message] 3 end 4 5 defmodule Plug.Upload do 6 @moduledoc """ 7 A server (a `GenServer` specifically) that manages uploaded files. 8 9 Uploaded files are stored in a temporary directory 10 and removed from that directory after the process that 11 requested the file dies. 12 13 During the request, files are represented with 14 a `Plug.Upload` struct that contains three fields: 15 16 * `:path` - the path to the uploaded file on the filesystem 17 * `:content_type` - the content type of the uploaded file 18 * `:filename` - the filename of the uploaded file given in the request 19 20 **Note**: as mentioned in the documentation for `Plug.Parsers`, the `:plug` 21 application has to be started in order to upload files and use the 22 `Plug.Upload` module. 23 24 ## Security 25 26 The `:content_type` and `:filename` fields in the `Plug.Upload` struct are 27 client-controlled. These values should be validated, via file content 28 inspection or similar, before being trusted. 29 """ 30 31 use GenServer 32 defstruct [:path, :content_type, :filename] 33 34 @type t :: %__MODULE__{ 35 path: Path.t(), 36 filename: binary, 37 content_type: binary | nil 38 } 39 40 @dir_table __MODULE__.Dir 41 @path_table __MODULE__.Path 42 @max_attempts 10 43 @temp_env_vars ~w(PLUG_TMPDIR TMPDIR TMP TEMP)s 44 45 @doc """ 46 Requests a random file to be created in the upload directory 47 with the given prefix. 48 """ 49 @spec random_file(binary) :: 50 {:ok, binary} 51 | {:too_many_attempts, binary, pos_integer} 52 | {:no_tmp, [binary]} 53 def random_file(prefix) do 54 case ensure_tmp() do 55 {:ok, tmp} -> 56 open_random_file(prefix, tmp, 0) 57 58 {:no_tmp, tmps} -> 59 {:no_tmp, tmps} 60 end 61 end 62 63 @doc """ 64 Assign ownership of the given upload file to another process. 65 66 Useful if you want to do some work on an uploaded file in another process 67 since it means that the file will survive the end of the request. 68 """ 69 @spec give_away(t | binary, pid, pid) :: :ok | {:error, :unknown_path} 70 def give_away(upload, to_pid, from_pid \\ self()) 71 72 def give_away(%__MODULE__{path: path}, to_pid, from_pid) do 73 give_away(path, to_pid, from_pid) 74 end 75 76 def give_away(path, to_pid, from_pid) 77 when is_binary(path) and is_pid(to_pid) and is_pid(from_pid) do 78 with [{^from_pid, _tmp}] <- :ets.lookup(@dir_table, from_pid), 79 true <- path_owner?(from_pid, path) do 80 case :ets.lookup(@dir_table, to_pid) do 81 [{^to_pid, _tmp}] -> 82 :ets.insert(@path_table, {to_pid, path}) 83 :ets.delete_object(@path_table, {from_pid, path}) 84 85 :ok 86 87 [] -> 88 server = plug_server() 89 {:ok, tmp} = generate_tmp_dir() 90 :ok = GenServer.call(server, {:give_away, to_pid, tmp, path}) 91 :ets.delete_object(@path_table, {from_pid, path}) 92 :ok 93 end 94 else 95 _ -> 96 {:error, :unknown_path} 97 end 98 end 99 100 defp ensure_tmp() do 101 pid = self() 102 103 case :ets.lookup(@dir_table, pid) do 104 [{^pid, tmp}] -> 105 {:ok, tmp} 106 107 [] -> 108 server = plug_server() 109 GenServer.cast(server, {:monitor, pid}) 110 111 with {:ok, tmp} <- generate_tmp_dir() do 112 true = :ets.insert_new(@dir_table, {pid, tmp}) 113 {:ok, tmp} 114 end 115 end 116 end 117 118 defp generate_tmp_dir() do 119 tmp_roots = :persistent_term.get(__MODULE__) 120 {mega, _, _} = :os.timestamp() 121 subdir = "/plug-" <> i(mega) 122 123 if tmp = Enum.find_value(tmp_roots, &make_tmp_dir(&1 <> subdir)) do 124 {:ok, tmp} 125 else 126 {:no_tmp, tmp_roots} 127 end 128 end 129 130 defp make_tmp_dir(path) do 131 case File.mkdir_p(path) do 132 :ok -> path 133 {:error, _} -> nil 134 end 135 end 136 137 defp open_random_file(prefix, tmp, attempts) when attempts < @max_attempts do 138 path = path(prefix, tmp) 139 140 case :file.write_file(path, "", [:write, :raw, :exclusive, :binary]) do 141 :ok -> 142 :ets.insert(@path_table, {self(), path}) 143 {:ok, path} 144 145 {:error, reason} when reason in [:eexist, :eacces] -> 146 open_random_file(prefix, tmp, attempts + 1) 147 end 148 end 149 150 defp open_random_file(_prefix, tmp, attempts) do 151 {:too_many_attempts, tmp, attempts} 152 end 153 154 defp path(prefix, tmp) do 155 sec = :os.system_time(:second) 156 rand = :rand.uniform(999_999_999_999) 157 scheduler_id = :erlang.system_info(:scheduler_id) 158 tmp <> "/" <> prefix <> "-" <> i(sec) <> "-" <> i(rand) <> "-" <> i(scheduler_id) 159 end 160 161 defp path_owner?(pid, path) do 162 owned_paths = :ets.lookup(@path_table, pid) 163 Enum.any?(owned_paths, fn {_pid, p} -> p == path end) 164 end 165 166 @compile {:inline, i: 1} 167 defp i(integer), do: Integer.to_string(integer) 168 169 @doc """ 170 Requests a random file to be created in the upload directory 171 with the given prefix. Raises on failure. 172 """ 173 @spec random_file!(binary) :: binary | no_return 174 def random_file!(prefix) do 175 case random_file(prefix) do 176 {:ok, path} -> 177 path 178 179 {:too_many_attempts, tmp, attempts} -> 180 raise Plug.UploadError, 181 "tried #{attempts} times to create an uploaded file at #{tmp} but failed. " <> 182 "Set PLUG_TMPDIR to a directory with write permission" 183 184 {:no_tmp, _tmps} -> 185 raise Plug.UploadError, 186 "could not create a tmp directory to store uploads. " <> 187 "Set PLUG_TMPDIR to a directory with write permission" 188 end 189 end 190 191 defp plug_server do 192 Process.whereis(__MODULE__) || 193 raise Plug.UploadError, 194 "could not find process Plug.Upload. Have you started the :plug application?" 195 end 196 197 @doc false 198 def start_link(_) do 199 GenServer.start_link(__MODULE__, :ok, name: __MODULE__) 200 end 201 202 ## Callbacks 203 204 @impl true 205 def init(:ok) do 206 Process.flag(:trap_exit, true) 207 tmp = Enum.find_value(@temp_env_vars, "/tmp", &System.get_env/1) |> Path.expand() 208 cwd = Path.join(File.cwd!(), "tmp") 209 :persistent_term.put(__MODULE__, [tmp, cwd]) 210 211 :ets.new(@dir_table, [:named_table, :public, :set]) 212 :ets.new(@path_table, [:named_table, :public, :duplicate_bag]) 213 {:ok, %{}} 214 end 215 216 @impl true 217 def handle_call({:give_away, pid, tmp, path}, _from, state) do 218 # Since we are writing in behalf of another process, we need to make sure 219 # the monitor and writing to the tables happen within the same operation. 220 Process.monitor(pid) 221 :ets.insert_new(@dir_table, {pid, tmp}) 222 :ets.insert(@path_table, {pid, path}) 223 224 {:reply, :ok, state} 225 end 226 227 @impl true 228 def handle_cast({:monitor, pid}, state) do 229 Process.monitor(pid) 230 {:noreply, state} 231 end 232 233 @impl true 234 def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do 235 case :ets.lookup(@dir_table, pid) do 236 [{pid, _tmp}] -> 237 :ets.delete(@dir_table, pid) 238 239 @path_table 240 |> :ets.lookup(pid) 241 |> Enum.each(&delete_path/1) 242 243 :ets.delete(@path_table, pid) 244 245 [] -> 246 :ok 247 end 248 249 {:noreply, state} 250 end 251 252 def handle_info(_msg, state) do 253 {:noreply, state} 254 end 255 256 @impl true 257 def terminate(_reason, _state) do 258 folder = fn entry, :ok -> delete_path(entry) end 259 :ets.foldl(folder, :ok, @path_table) 260 end 261 262 defp delete_path({_pid, path}) do 263 :file.delete(path) 264 :ok 265 end 266 end