├── LICENSE.txt ├── README.md └── upload.pm /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Holger Weiss 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 9 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 11 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 12 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 13 | PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | upload.pm 2 | ========= 3 | 4 | This [Nginx][1] module implements the HTTP server's part of the XMPP extension 5 | [XEP-0363: HTTP File Upload][2]. It can be used with either ejabberd's 6 | [`mod_http_upload`][3] or Prosody's [`mod_http_upload_external`][4]. 7 | 8 | Nginx setup 9 | ----------- 10 | 11 | 1. Create a directory and move `upload.pm` into it, e.g.: 12 | 13 | ```sh 14 | # mkdir -p /usr/local/lib/perl 15 | # wget -O /usr/local/lib/perl/upload.pm https://git.io/fNZgL 16 | ``` 17 | 18 | 2. Install the [`ngx_http_perl_module`][5]. On Debian/Ubuntu-based 19 | distributions, the package is called `libnginx-mod-http-perl`, on 20 | RedHat/CentOS-based distributions, it's `nginx-mod-http-perl`. 21 | If you're using Docker, make sure you use the image ending in `-perl`. 22 | 23 | 3. Add the following snippets to the appropriate sections of your Nginx 24 | configuration: 25 | 26 | ```nginx configuration file 27 | # This directive was probably added by the distribution package already: 28 | load_module modules/ngx_http_perl_module.so; 29 | 30 | http { 31 | # Add the following two lines to the existing "http" block. 32 | perl_modules /usr/local/lib/perl; # Path to upload.pm. 33 | perl_require upload.pm; 34 | } 35 | 36 | server { 37 | # Specify directives such as "listen", "server_name", and TLS-related 38 | # settings for the "server" that handles the uploads. 39 | 40 | # Uploaded files will be stored below the "root" directory. To minimize 41 | # disk I/O, make sure the specified path is on the same file system as 42 | # the directory used by Nginx to store temporary files holding request 43 | # bodies ("client_body_temp_path", often some directory below /var). 44 | root /var/www/upload; 45 | 46 | # Specify this "location" block (if you don't use "/", see below): 47 | location / { 48 | perl upload::handle; 49 | } 50 | 51 | # Upload file size limit (default: 1m), also specified in your XMPP 52 | # server's upload module configuration (see below): 53 | client_max_body_size 100m; 54 | } 55 | ``` 56 | 57 | 4. Open `upload.pm` in an editor and adjust the configuration at the top of the 58 | file: 59 | 60 | - The `$external_secret` must match the one specified in your XMPP server's 61 | upload module configuration (see below). 62 | 63 | - If the root path of the upload URIs (the `location` specified in the Nginx 64 | `server` block) isn't `/` but `/some/prefix/`, `$uri_prefix_components` 65 | must be set to the number of directory levels. So, for `/some/prefix/`, it 66 | would be `2`. 67 | 68 | ejabberd setup 69 | -------------- 70 | 71 | Let the [`mod_http_upload`][3] option `put_url` point to Nginx, and specify 72 | exactly the same `external_secret` as in the `upload.pm` settings: 73 | 74 | ```yaml 75 | modules: 76 | mod_http_upload: 77 | put_url: "https://upload.example.com" 78 | external_secret: "it-is-secret" 79 | max_size: 104857600 # 100 MiB, also specified in the Nginx configuration. 80 | ``` 81 | 82 | Prosody setup 83 | ------------- 84 | 85 | Let the [`mod_http_upload_external`][4] option `http_upload_external_base_url` 86 | point to Nginx, and specify exactly the same `http_upload_external_secret` as in 87 | the `upload.pm` settings: 88 | 89 | ```lua 90 | http_upload_external_base_url = "https://upload.example.com" 91 | http_upload_external_secret = "it-is-secret" 92 | http_upload_external_file_size_limit = 104857600 -- 100 MiB 93 | ``` 94 | 95 | Contact 96 | ------- 97 | 98 | If you have any questions, you could ask in the ejabberd room: 99 | `ejabberd@conference.process-one.net` (the maintainer of this module is usually 100 | joined as _Holger_). 101 | 102 | [1]: https://nginx.org/en/ 103 | [2]: https://xmpp.org/extensions/xep-0363.html 104 | [3]: https://docs.ejabberd.im/admin/configuration/#mod-http-upload 105 | [4]: https://modules.prosody.im/mod_http_upload_external.html#implementation 106 | [5]: https://nginx.org/en/docs/http/ngx_http_perl_module.html 107 | -------------------------------------------------------------------------------- /upload.pm: -------------------------------------------------------------------------------- 1 | # Nginx module to handle file uploads and downloads for ejabberd's 2 | # mod_http_upload or Prosody's mod_http_upload_external. 3 | 4 | # Copyright (c) 2018 Holger Weiss 5 | # 6 | # Permission to use, copy, modify, and/or distribute this software for any 7 | # purpose with or without fee is hereby granted, provided that the above 8 | # copyright notice and this permission notice appear in all copies. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 11 | # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 12 | # AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 13 | # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 14 | # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 15 | # OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 16 | # PERFORMANCE OF THIS SOFTWARE. 17 | 18 | package upload; 19 | 20 | ## CONFIGURATION ----------------------------------------------------- 21 | 22 | my $external_secret = 'it-is-secret'; 23 | my $uri_prefix_components = 0; 24 | my $file_mode = 0640; 25 | my $dir_mode = 0750; 26 | my %custom_headers = ( 27 | 'Access-Control-Allow-Origin' => '*', 28 | 'Access-Control-Allow-Methods' => 'OPTIONS, HEAD, GET, PUT', 29 | 'Access-Control-Allow-Headers' => 'Authorization, Content-Type', 30 | 'Access-Control-Allow-Credentials' => 'true', 31 | ); 32 | 33 | ## END OF CONFIGURATION ---------------------------------------------- 34 | 35 | use warnings; 36 | use strict; 37 | use Carp; 38 | use Digest::SHA qw(hmac_sha256_hex); 39 | use Encode qw(decode :fallback_all); 40 | use Errno qw(:POSIX); 41 | use Fcntl; 42 | use File::Copy; 43 | use File::Basename; 44 | use File::Path qw(make_path); 45 | use nginx; 46 | 47 | sub handle { 48 | my $r = shift; 49 | 50 | add_custom_headers($r); 51 | 52 | if ($r->request_method eq 'GET' or $r->request_method eq 'HEAD') { 53 | return handle_get_or_head($r); 54 | } elsif ($r->request_method eq 'PUT') { 55 | return handle_put($r); 56 | } elsif ($r->request_method eq 'OPTIONS') { 57 | return handle_options($r); 58 | } else { 59 | return DECLINED; 60 | } 61 | } 62 | 63 | sub handle_get_or_head { 64 | my $r = shift; 65 | my $file_path = safe_filename($r); 66 | 67 | if (-r $file_path and -f _) { 68 | $r->header_out('Content-Length', -s _); 69 | $r->allow_ranges; 70 | $r->send_http_header; 71 | $r->sendfile($file_path) unless $r->header_only; 72 | return OK; 73 | } else { 74 | return DECLINED; 75 | } 76 | } 77 | 78 | sub handle_put { 79 | my $r = shift; 80 | my $len = $r->header_in('Content-Length') or return HTTP_LENGTH_REQUIRED; 81 | my $uri = $r->uri =~ s|(?:/[^/]+){$uri_prefix_components}/||r; 82 | my $provided_hmac; 83 | 84 | if (defined($r->args) and $r->args =~ /v=([[:xdigit:]]{64})/) { 85 | $provided_hmac = $1; 86 | } else { 87 | $r->log_error(0, 'Rejecting upload: No auth token provided'); 88 | return HTTP_FORBIDDEN; 89 | } 90 | 91 | my $expected_hmac = hmac_sha256_hex("$uri $len", $external_secret); 92 | 93 | if (not safe_eq(lc($provided_hmac), lc($expected_hmac))) { 94 | $r->log_error(0, 'Rejecting upload: Invalid auth token'); 95 | return HTTP_FORBIDDEN; 96 | } 97 | if (not $r->has_request_body(\&handle_put_body)) { 98 | $r->log_error(0, 'Rejecting upload: No data provided'); 99 | return HTTP_BAD_REQUEST; 100 | } 101 | return OK; 102 | } 103 | 104 | sub handle_put_body { 105 | my $r = shift; 106 | my $file_path = safe_filename($r); 107 | my $dir_path = dirname($file_path); 108 | 109 | make_path($dir_path, {chmod => $dir_mode, error => \my $error}); 110 | if (@$error) { 111 | return system_error($r, "Cannot create directory $dir_path"); 112 | } 113 | 114 | my $body = $r->request_body; 115 | my $body_file = $r->request_body_file; 116 | 117 | if ($body) { 118 | return store_body_from_buffer($r, $body, $file_path, $file_mode); 119 | } elsif ($body_file) { 120 | return store_body_from_file($r, $body_file, $file_path, $file_mode); 121 | } else { # Huh? 122 | $r->log_error(0, "Got no data to write to $file_path"); 123 | return HTTP_BAD_REQUEST; 124 | } 125 | } 126 | 127 | sub store_body_from_buffer { 128 | my ($r, $body, $dst_path, $mode) = @_; 129 | 130 | if (sysopen(my $fh, $dst_path, O_WRONLY|O_CREAT|O_EXCL, $mode)) { 131 | if (not binmode($fh)) { 132 | return system_error($r, "Cannot set binary mode for $dst_path"); 133 | } 134 | if (not syswrite($fh, $body)) { 135 | return system_error($r, "Cannot write $dst_path"); 136 | } 137 | if (not close($fh)) { 138 | return system_error($r, "Cannot close $dst_path"); 139 | } 140 | } else { 141 | return system_error($r, "Cannot create $dst_path"); 142 | } 143 | if (chmod($mode, $dst_path) != 1) { 144 | return system_error($r, "Cannot change permissions of $dst_path"); 145 | } 146 | return HTTP_CREATED; 147 | } 148 | 149 | sub store_body_from_file { 150 | my ($r, $src_path, $dst_path, $mode) = @_; 151 | 152 | # We could merge this with the store_body_from_buffer() code by handing over 153 | # the file handle created by sysopen() as the second argument to move(), but 154 | # we want to let move() use rename() if possible. 155 | 156 | if (-e $dst_path) { 157 | $r->log_error(0, "Won't overwrite $dst_path"); 158 | return HTTP_CONFLICT; 159 | } 160 | if (not move($src_path, $dst_path)) { 161 | return system_error($r, "Cannot move data to $dst_path"); 162 | } 163 | if (chmod($mode, $dst_path) != 1) { 164 | return system_error($r, "Cannot change permissions of $dst_path"); 165 | } 166 | return HTTP_CREATED; 167 | } 168 | 169 | sub handle_options { 170 | my $r = shift; 171 | 172 | $r->header_out('Allow', 'OPTIONS, HEAD, GET, PUT'); 173 | $r->send_http_header; 174 | return OK; 175 | } 176 | 177 | sub add_custom_headers { 178 | my $r = shift; 179 | 180 | while (my ($field, $value) = each(%custom_headers)) { 181 | $r->header_out($field, $value); 182 | } 183 | } 184 | 185 | sub safe_filename { 186 | my $r = shift; 187 | my $filename = decode('UTF-8', $r->filename, FB_DEFAULT | LEAVE_SRC); 188 | my $uri = decode('UTF-8', $r->uri, FB_DEFAULT | LEAVE_SRC); 189 | my $safe_uri = $uri =~ s|[^\p{Alnum}/_.-]|_|gr; 190 | 191 | return substr($filename, 0, -length($uri)) . $safe_uri; 192 | } 193 | 194 | sub safe_eq { 195 | my $a = shift; 196 | my $b = shift; 197 | my $n = length($a); 198 | my $r = 0; 199 | 200 | croak('safe_eq arguments differ in length') if length($b) != $n; 201 | $r |= ord(substr($a, $_)) ^ ord(substr($b, $_)) for 0 .. $n - 1; 202 | return $r == 0; 203 | } 204 | 205 | sub system_error { 206 | my ($r, $msg) = @_; 207 | 208 | $r->log_error($!, $msg); 209 | 210 | return HTTP_FORBIDDEN if $!{EACCES}; 211 | return HTTP_CONFLICT if $!{EEXIST}; 212 | return HTTP_INTERNAL_SERVER_ERROR; 213 | } 214 | 215 | 1; 216 | __END__ 217 | --------------------------------------------------------------------------------