├── .github └── workflows │ └── go.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── build.sh ├── catmetal.jpg ├── config.example.toml ├── go.mod ├── go.sum ├── prosody-filer.go └── prosody-filer_test.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "develop" ] 9 | pull_request: 10 | branches: [ "develop" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.20' 23 | 24 | - name: Copy example config 25 | run: cp config.example.toml config.toml 26 | 27 | - name: Build 28 | run: go build -v ./... 29 | 30 | - name: Test 31 | run: go test -v ./... 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.toml 2 | main 3 | prosody-filer 4 | 5 | # Binaries for programs and plugins 6 | *.exe 7 | *.exe~ 8 | *.dll 9 | *.so 10 | *.dylib 11 | 12 | # Test binary, build with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:buster as build 2 | WORKDIR /app 3 | ADD ./ /app 4 | RUN ./build.sh 5 | 6 | FROM scratch 7 | COPY --from=build /app/prosody-filer /prosody-filer 8 | ENTRYPOINT ["/prosody-filer"] 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Thomas Leister 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Prosody Filer 2 | 3 | A simple file server for handling XMPP http_upload requests. This server is meant to be used with the Prosody [mod_http_upload_external](https://modules.prosody.im/mod_http_upload_external.html) module. 4 | 5 | **Despite the name, this server is also compatible with Ejabberd and Ejabberd's http_upload module!** 6 | 7 | --- 8 | 9 | ## Why should I use this server? 10 | 11 | Originally this software was written to circumvent memory limitations / issues with the Prosody-internal http_upload implementation at the time. These limitations do not exist, anymore. Still this software can be used with Ejabberd and Prosody as an alternative to the internal http_upload servers. 12 | 13 | 14 | ## Download 15 | 16 | If you are using regular x86_64 Linux, you can download a finished binary for your system on the [release page](https://github.com/ThomasLeister/prosody-filer/releases). **No need to compile this application yourself**. 17 | 18 | 19 | ## Build (optional) 20 | 21 | If you're using something different than a x64 Linux, you need to compile this application yourself. 22 | 23 | To compile the server, you need a full Golang development environment. This can be set up quickly: https://golang.org/doc/install#install 24 | 25 | Then checkout this repo: 26 | 27 | ```sh 28 | go install github.com/ThomasLeister/prosody-filer 29 | ``` 30 | 31 | and switch to the new directory: 32 | 33 | ```sh 34 | cd $GOPATH/src/github.com/ThomasLeister/prosody-filer 35 | ``` 36 | 37 | The application can now be build: 38 | 39 | ```sh 40 | ### Build static binary 41 | ./build.sh 42 | 43 | ### OR regular Go build 44 | go build prosody-filer.go 45 | ``` 46 | 47 | 48 | ## Set up / configuration 49 | 50 | 51 | ### Setup Prosody Filer environment 52 | 53 | Create a new user for Prosody Filer to run as: 54 | 55 | ```sh 56 | adduser --disabled-login --disabled-password prosody-filer 57 | ``` 58 | 59 | Switch to the new user: 60 | 61 | ```sh 62 | su - prosody-filer 63 | ``` 64 | 65 | Copy 66 | 67 | * the binary ```prosody-filer``` and 68 | * config ```config.example.toml``` 69 | 70 | to ```/home/prosody-filer/```. Rename the configuration to ```config.toml```. 71 | 72 | Make sure the `prosody-filer` binary is executable: 73 | 74 | ``` 75 | chmod u+x prosody-filer 76 | ``` 77 | 78 | 79 | ### Configure Prosody 80 | 81 | Back in your root shell make sure ```mod_http_upload``` is **dis**abled and ```mod_http_upload_external``` is **en**abled! Then configure the external upload module: 82 | 83 | ```lua 84 | Component "uploads.myserver.tld" "http_upload_external" 85 | http_upload_external_base_url = "https://uploads.myserver.tld/upload/" 86 | http_upload_external_secret = "mysecret" 87 | http_upload_external_file_size_limit = 50000000 -- 50 MB 88 | ``` 89 | 90 | Restart Prosody when you are finished: 91 | 92 | systemctl restart prosody 93 | 94 | 95 | ### Alternative: Configure Ejabberd 96 | 97 | Although this tool is named after Prosody, it can be used with Ejabberd, too! Make sure you have a Ejabberd configuration similar to this: 98 | 99 | ```yaml 100 | mod_http_upload: 101 | put_url: "https://uploads.@HOST@/upload" 102 | external_secret: "mysecret" 103 | max_size: 52428800 104 | ``` 105 | 106 | 107 | ### Configure Prosody Filer 108 | 109 | Prosody Filer configuration is done via the `config.toml` file in TOML syntax. There's not much to be configured. The most important piece is the `secret` setting, which **needs to match the secret defined in your mod_http_upload_external settings!** 110 | 111 | ```toml 112 | ### IP address and port to listen to, e.g. "[::]:5050" 113 | listenport = "[::1]:5050" 114 | 115 | ### Secret (must match the one in prosody.conf.lua!) 116 | secret = "mysecret" 117 | 118 | ### Where to store the uploaded files 119 | storeDir = "./upload/" 120 | 121 | ### Subdirectory for HTTP upload / download requests (usually "upload/") 122 | uploadSubDir = "upload/" 123 | ``` 124 | 125 | 126 | In addition to that, make sure that the nginx user or group can read the files uploaded 127 | via prosody-filer if you want to have them served by nginx directly. 128 | 129 | 130 | ### Docker usage 131 | 132 | To build container: 133 | 134 | ```docker build . -t prosody-filer:latest``` 135 | 136 | To run container use: 137 | 138 | ```docker run -it --rm -v $PWD/config.example.toml:/config.toml prosody-filer -config /config.toml``` 139 | 140 | 141 | ### Systemd service file 142 | 143 | Create a new Systemd service file: ```/etc/systemd/system/prosody-filer.service``` 144 | 145 | [Unit] 146 | Description=Prosody file upload server 147 | 148 | [Service] 149 | Type=simple 150 | ExecStart=/home/prosody-filer/prosody-filer 151 | Restart=always 152 | WorkingDirectory=/home/prosody-filer 153 | User=prosody-filer 154 | Group=prosody-filer 155 | # Group=nginx # if the files should get served by nginx directly: 156 | 157 | [Install] 158 | WantedBy=multi-user.target 159 | 160 | Reload the service definitions, enable the service and start it: 161 | 162 | systemctl daemon-reload 163 | systemctl enable prosody-filer 164 | systemctl start prosody-filer 165 | 166 | Done! Prosody Filer is now listening on the specified port and waiting for requests. 167 | 168 | ### FreeBSD service file 169 | 170 | - First, install go and build the static binary with `go install github.com/ThomasLeister/prosody-filer`. 171 | The binary should be in `~/go/bin/prosody-filer`. 172 | - Create an user with `pw user add -n prosodyfiler -c 'Prosody Filer' -d /home/prosodyfiler -m -s /usr/sbin/nologin` 173 | and copy the binary into `/home/prosodyfiler/`. 174 | - Put this rc script in `/etc/rc.d/prosody-filer`: 175 | ``` 176 | #!/bin/sh 177 | 178 | # PROVIDE: prosody_filer 179 | # REQUIRE: FILESYSTEMS networking 180 | # KEYWORDS: upload 181 | 182 | . /etc/rc.subr 183 | 184 | name="prosody_filer" 185 | program_name="prosody-filer" 186 | title="Prosody-Filer" 187 | rcvar=prosody_filer_enable 188 | prosody_filer_user="prosodyfiler" 189 | prosody_filer_group="prosodyfiler" 190 | prosody_filer_chdir="/home/prosodyfiler/" 191 | 192 | pidfile="/home/prosodyfiler/${program_name}.pid" 193 | required_files="/home/prosodyfiler/config.toml" 194 | exec_path="/home/prosodyfiler/${program_name}" 195 | output_file="/var/log/${program_name}.log" 196 | 197 | command="/usr/sbin/daemon" 198 | command_args="-r -t ${title} -o ${output_file} -P ${pidfile} -f ${exec_path}" 199 | 200 | load_rc_config $name 201 | ``` 202 | - Execute `sysrc prosody_filer_enable=YES` 203 | 204 | You can now start the service via `service prosody_filer start` and check 205 | it's log via `/var/log/prosody-filer.log`. 206 | 207 | ### Configure Nginx 208 | 209 | Create a new config file ```/etc/nginx/sites-available/uploads.myserver.tld```: 210 | 211 | ```nginx 212 | server { 213 | listen 80; 214 | listen [::]:80; 215 | listen 443 ssl; 216 | listen [::]:443 ssl; 217 | 218 | server_name uploads.myserver.tld; 219 | 220 | ssl_certificate /etc/letsencrypt/live/uploads.myserver.tld/fullchain.pem; 221 | ssl_certificate_key /etc/letsencrypt/live/uploads.myserver.tld/privkey.pem; 222 | 223 | client_max_body_size 50m; 224 | 225 | location /upload/ { 226 | if ( $request_method = OPTIONS ) { 227 | add_header Access-Control-Allow-Origin '*'; 228 | add_header Access-Control-Allow-Methods 'PUT, GET, OPTIONS, HEAD'; 229 | add_header Access-Control-Allow-Headers 'Authorization, Content-Type'; 230 | add_header Access-Control-Allow-Credentials 'true'; 231 | add_header Content-Length 0; 232 | add_header Content-Type text/plain; 233 | return 200; 234 | } 235 | 236 | proxy_pass http://[::]:5050/upload/; 237 | proxy_request_buffering off; 238 | } 239 | } 240 | ``` 241 | 242 | Enable the new config: 243 | 244 | ln -s /etc/nginx/sites-available/uploads.myserver.tld /etc/nginx/sites-enabled/ 245 | 246 | Check Nginx config: 247 | 248 | nginx -t 249 | 250 | Reload Nginx: 251 | 252 | systemctl reload nginx 253 | 254 | 255 | #### Alternative configuration for letting Nginx serve the uploaded files 256 | 257 | *(not officially supported - user contribution!)* 258 | 259 | ```nginx 260 | server { 261 | listen 80; 262 | listen [::]:80; 263 | listen 443 ssl; 264 | listen [::]:443 ssl; 265 | 266 | server_name uploads.myserver.tld; 267 | 268 | ssl_certificate /etc/letsencrypt/live/uploads.myserver.tld/fullchain.pem; 269 | ssl_certificate_key /etc/letsencrypt/live/uploads.myserver.tld/privkey.pem; 270 | 271 | location /upload/ { 272 | if ( $request_method = OPTIONS ) { 273 | add_header Access-Control-Allow-Origin '*'; 274 | add_header Access-Control-Allow-Methods 'PUT, GET, OPTIONS, HEAD'; 275 | add_header Access-Control-Allow-Headers 'Authorization, Content-Type'; 276 | add_header Access-Control-Allow-Credentials 'true'; 277 | add_header Content-Length 0; 278 | add_header Content-Type text/plain; 279 | return 200; 280 | } 281 | 282 | root /home/prosody-filer; 283 | autoindex off; 284 | client_max_body_size 51m; 285 | client_body_buffer_size 51m; 286 | try_files $uri $uri/ @prosodyfiler; 287 | } 288 | location @prosodyfiler { 289 | proxy_pass http://[::1]:5050; 290 | proxy_buffering off; 291 | proxy_set_header Host $host; 292 | proxy_set_header X-Real-IP $remote_addr; 293 | proxy_set_header X-Forwarded-Proto $scheme; 294 | proxy_set_header X-Forwarded-Host $host:$server_port; 295 | proxy_set_header X-Forwarded-Server $host; 296 | proxy_set_header X-Forwarded-For $remote_addr; 297 | } 298 | } 299 | ``` 300 | 301 | ## apache2 configuration (alternative to Nginx) 302 | 303 | *(This configuration was provided by a user and has never been tested by the author of Prosody Filer. It might be outdated and might not work anymore)* 304 | 305 | ``` 306 | 307 | ServerName upload.example.eu 308 | RedirectPermanent / https://upload.example.eu/ 309 | 310 | 311 | 312 | ServerName upload.example.eu 313 | SSLEngine on 314 | 315 | SSLCertificateFile "Path to the ca file" 316 | SSLCertificateKeyFile "Path to the key file" 317 | 318 | Header always set Public-Key-Pins: '' 319 | Header always set Strict-Transport-Security "max-age=63072000; includeSubdomains; preload" 320 | H2Direct on 321 | 322 | 323 | Header always set Access-Control-Allow-Origin "*" 324 | Header always set Access-Control-Allow-Headers "Content-Type" 325 | Header always set Access-Control-Allow-Methods "OPTIONS, PUT, GET" 326 | 327 | RewriteEngine On 328 | 329 | RewriteCond %{REQUEST_METHOD} OPTIONS 330 | RewriteRule ^(.*)$ $1 [R=200,L] 331 | 332 | 333 | SSLProxyEngine on 334 | 335 | ProxyPreserveHost On 336 | ProxyRequests Off 337 | ProxyPass / http://localhost:5050/ 338 | ProxyPassReverse / http://localhost:5050/ 339 | 340 | ``` 341 | 342 | 343 | ## Automatic purge 344 | 345 | Prosody Filer has no immediate knowlegde over all the stored files and the time they were uploaded, since no database exists for that. Also Prosody is not capable to do auto deletion if *mod_http_upload_external* is used. Therefore the suggested way of purging the uploads directory is to execute a purge command via a cron job: 346 | 347 | @daily find /home/prosody-filer/upload/ -mindepth 1 -type d -mtime +28 -print0 | xargs -0 -- rm -rf 348 | 349 | This will delete uploads older than 28 days. 350 | 351 | 352 | ## Check if it works 353 | 354 | Get the log via 355 | 356 | journalctl -f -u prosody-filer 357 | 358 | If your XMPP clients uploads or downloads any file, there should be some log messages on the screen. 359 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ### get VERSIONSTRING 4 | VERSIONSTRING="$(git describe --tags --exact-match || git rev-parse --short HEAD)" 5 | 6 | echo "Building version ${VERSIONSTRING} of Prosody-Filer ..." 7 | 8 | ### Compile and link statically 9 | CGO_ENABLED=0 GOOS=linux go build -a -ldflags "-extldflags '-static' -w -s -X main.versionString=${VERSIONSTRING}" prosody-filer.go 10 | 11 | -------------------------------------------------------------------------------- /catmetal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ThomasLeister/prosody-filer/c97fa660078926efa897ba6f52071a758e106041/catmetal.jpg -------------------------------------------------------------------------------- /config.example.toml: -------------------------------------------------------------------------------- 1 | ### 2 | ### Server configuration 3 | ### (rename this file to "config.toml"!) 4 | 5 | ### IP address and port to listen to, e.g. "[::]:5050" to listen to ipv6 and ipv4 addresses 6 | listenPort = "[::]:5050" 7 | 8 | ### Secret (must match the one in prosody.conf.lua!) 9 | secret = "mysecret" 10 | 11 | ### Where to store the uploaded files 12 | storeDir = "./uploads/" 13 | 14 | ### Subdirectory for HTTP upload / download requests (usually "upload/") 15 | uploadSubDir = "upload/" 16 | 17 | ### Log level: "info", "warn" or "error" 18 | logLevel = "warn" 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ThomasLeister/prosody-filer 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.3.2 7 | github.com/sirupsen/logrus v1.9.3 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= 2 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 7 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 8 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 9 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 10 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 11 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 12 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 13 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= 14 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 16 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 17 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 18 | -------------------------------------------------------------------------------- /prosody-filer.go: -------------------------------------------------------------------------------- 1 | /* 2 | * This module allows upload via mod_http_upload_external 3 | * Also see: https://modules.prosody.im/mod_http_upload_external.html 4 | */ 5 | 6 | package main 7 | 8 | import ( 9 | "crypto/hmac" 10 | "crypto/sha256" 11 | "encoding/hex" 12 | "flag" 13 | "fmt" 14 | "io" 15 | "mime" 16 | "net" 17 | "net/http" 18 | "net/url" 19 | "os" 20 | "path" 21 | "path/filepath" 22 | "strconv" 23 | "strings" 24 | 25 | "github.com/BurntSushi/toml" 26 | "github.com/sirupsen/logrus" 27 | ) 28 | 29 | /* 30 | * Configuration of this server 31 | */ 32 | type Config struct { 33 | ListenPort string 34 | UnixSocket bool 35 | Secret string 36 | StoreDir string 37 | UploadSubDir string 38 | LogLevel string 39 | } 40 | 41 | var conf Config 42 | var versionString string = "0.0.0" 43 | 44 | var log = &logrus.Logger{ 45 | Out: os.Stdout, 46 | Formatter: new(logrus.TextFormatter), 47 | Hooks: make(logrus.LevelHooks), 48 | Level: logrus.DebugLevel, 49 | } 50 | 51 | var ALLOWED_METHODS string = strings.Join( 52 | []string{ 53 | http.MethodOptions, 54 | http.MethodHead, 55 | http.MethodGet, 56 | http.MethodPut, 57 | }, 58 | ", ", 59 | ) 60 | 61 | /* 62 | * Sets CORS headers 63 | */ 64 | func addCORSheaders(w http.ResponseWriter) { 65 | w.Header().Set("Access-Control-Allow-Origin", "*") 66 | w.Header().Set("Access-Control-Allow-Methods", ALLOWED_METHODS) 67 | w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") 68 | w.Header().Set("Access-Control-Allow-Credentials", "true") 69 | w.Header().Set("Access-Control-Max-Age", "7200") 70 | } 71 | 72 | /* 73 | * Request handler 74 | * Is activated when a clients requests the file, file information or an upload 75 | */ 76 | func handleRequest(w http.ResponseWriter, r *http.Request) { 77 | log.Info("Incoming request: ", r.Method, r.URL.String()) 78 | 79 | // Parse URL and args 80 | p := r.URL.Path 81 | 82 | a, err := url.ParseQuery(r.URL.RawQuery) 83 | if err != nil { 84 | log.Warn("Failed to parse query") 85 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 86 | return 87 | } 88 | 89 | subDir := path.Join("/", conf.UploadSubDir) 90 | fileStorePath := strings.TrimPrefix(p, subDir) 91 | if fileStorePath == "" || fileStorePath == "/" { 92 | log.Warn("Access to / forbidden") 93 | http.Error(w, "Forbidden", http.StatusForbidden) 94 | return 95 | } else if fileStorePath[0] == '/' { 96 | fileStorePath = fileStorePath[1:] 97 | } 98 | 99 | absFilename := filepath.Join(conf.StoreDir, fileStorePath) 100 | 101 | // Add CORS headers 102 | addCORSheaders(w) 103 | 104 | if r.Method == http.MethodPut { 105 | /* 106 | * User client tries to upload file 107 | */ 108 | 109 | /* 110 | Check if MAC is attached to URL and check protocol version. 111 | Ejabberd: supports "v" and probably "v2" Doc: https://docs.ejabberd.im/archive/20_12/modules/#mod-http-upload 112 | Prosody: supports "v" and "v2" Doc: https://modules.prosody.im/mod_http_upload_external.html 113 | Metronome: supports: "token" (meaning "v2") Doc: https://archon.im/metronome-im/documentation/external-upload-protocol/) 114 | */ 115 | var protocolVersion string 116 | if a["v2"] != nil { 117 | protocolVersion = "v2" 118 | } else if a["token"] != nil { 119 | protocolVersion = "token" 120 | } else if a["v"] != nil { 121 | protocolVersion = "v" 122 | } else { 123 | log.Warn("No HMAC attached to URL. Expecting URL with \"v\", \"v2\" or \"token\" parameter as MAC") 124 | http.Error(w, "No HMAC attached to URL. Expecting URL with \"v\", \"v2\" or \"token\" parameter as MAC", http.StatusForbidden) 125 | return 126 | } 127 | 128 | // Init HMAC 129 | mac := hmac.New(sha256.New, []byte(conf.Secret)) 130 | macString := "" 131 | 132 | // Calculate MAC, depending on protocolVersion 133 | if protocolVersion == "v" { 134 | // use a space character (0x20) between components of MAC 135 | mac.Write([]byte(fileStorePath + "\x20" + strconv.FormatInt(r.ContentLength, 10))) 136 | macString = hex.EncodeToString(mac.Sum(nil)) 137 | } else if protocolVersion == "v2" || protocolVersion == "token" { 138 | // Get content type (for v2 / token) 139 | contentType := mime.TypeByExtension(filepath.Ext(fileStorePath)) 140 | if contentType == "" { 141 | contentType = "application/octet-stream" 142 | } 143 | 144 | // use a null byte character (0x00) between components of MAC 145 | mac.Write([]byte(fileStorePath + "\x00" + strconv.FormatInt(r.ContentLength, 10) + "\x00" + contentType)) 146 | macString = hex.EncodeToString(mac.Sum(nil)) 147 | } 148 | 149 | /* 150 | * Check whether calculated (expected) MAC is the MAC that client send in "v" URL parameter 151 | */ 152 | if hmac.Equal([]byte(macString), []byte(a[protocolVersion][0])) { 153 | err = createFile(absFilename, fileStorePath, w, r) 154 | if err != nil { 155 | log.Error(err) 156 | } 157 | return 158 | } else { 159 | log.Warning("Invalid MAC.") 160 | http.Error(w, "Invalid MAC", http.StatusForbidden) 161 | return 162 | } 163 | } else if r.Method == http.MethodHead || r.Method == http.MethodGet { 164 | /* 165 | * User client tries to download a file 166 | */ 167 | 168 | fileInfo, err := os.Stat(absFilename) 169 | if err != nil { 170 | log.Error("Getting file information failed:", err) 171 | http.Error(w, "Not Found", http.StatusNotFound) 172 | return 173 | } else if fileInfo.IsDir() { 174 | log.Warning("Directory listing forbidden!") 175 | http.Error(w, "Forbidden", http.StatusForbidden) 176 | return 177 | } 178 | 179 | /* 180 | * Find out the content type to sent correct header. There is a Go function for retrieving the 181 | * MIME content type, but this does not work with encrypted files (=> OMEMO). Therefore we're just 182 | * relying on file extensions. 183 | */ 184 | contentType := mime.TypeByExtension(filepath.Ext(fileStorePath)) 185 | if contentType == "" { 186 | contentType = "application/octet-stream" 187 | } 188 | w.Header().Set("Content-Type", contentType) 189 | 190 | if r.Method == http.MethodHead { 191 | w.Header().Set("Content-Length", strconv.FormatInt(fileInfo.Size(), 10)) 192 | } else { 193 | http.ServeFile(w, r, absFilename) 194 | } 195 | 196 | return 197 | } else if r.Method == http.MethodOptions { 198 | // Client CORS request: Return allowed methods 199 | w.Header().Set("Allow", ALLOWED_METHODS) 200 | return 201 | } else { 202 | // Client is using a prohibited / unsupported method 203 | log.Warn("Invalid method", r.Method, "for access to ", conf.UploadSubDir) 204 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 205 | return 206 | } 207 | } 208 | 209 | func createFile(absFilename string, fileStorePath string, w http.ResponseWriter, r *http.Request) error { 210 | // Make sure the directory path exists 211 | absDirectory := filepath.Dir(absFilename) 212 | err := os.MkdirAll(absDirectory, os.ModePerm) 213 | if err != nil { 214 | http.Error(w, "Internal server error", http.StatusInternalServerError) 215 | return fmt.Errorf("failed to create directory %s: %s", absDirectory, err) 216 | } 217 | 218 | // Make sure the target file exists (MUST NOT exist before! -> O_EXCL) 219 | targetFile, err := os.OpenFile(absFilename, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) 220 | if err != nil { 221 | http.Error(w, "Conflict", http.StatusConflict) 222 | return fmt.Errorf("failed to create file %s: %s", absFilename, err) 223 | } 224 | defer targetFile.Close() 225 | 226 | // Copy file contents to file 227 | _, err = io.Copy(targetFile, r.Body) 228 | if err != nil { 229 | http.Error(w, "Internal server error", http.StatusInternalServerError) 230 | return fmt.Errorf("failed to copy file contents to %s: %s", absFilename, err) 231 | } 232 | 233 | w.WriteHeader(http.StatusCreated) 234 | return nil 235 | } 236 | 237 | func readConfig(configFilename string, conf *Config) error { 238 | configData, err := os.ReadFile(configFilename) 239 | if err != nil { 240 | log.Fatal("Configuration file config.toml cannot be read:", err, "...Exiting.") 241 | return err 242 | } 243 | 244 | if _, err := toml.Decode(string(configData), conf); err != nil { 245 | log.Fatal("Config file config.toml is invalid:", err) 246 | return err 247 | } 248 | 249 | return nil 250 | } 251 | 252 | func setLogLevel() { 253 | switch conf.LogLevel { 254 | case "info": 255 | log.SetLevel(logrus.InfoLevel) 256 | case "warn": 257 | log.SetLevel(logrus.WarnLevel) 258 | case "error": 259 | log.SetLevel(logrus.ErrorLevel) 260 | default: 261 | log.SetLevel(logrus.WarnLevel) 262 | fmt.Print("Invalid log level set in config. Defaulting to \"warn\"") 263 | } 264 | } 265 | 266 | /* 267 | * Main function 268 | */ 269 | func main() { 270 | var configFile string 271 | var proto string 272 | 273 | /* 274 | * Read startup arguments 275 | */ 276 | flag.StringVar(&configFile, "config", "./config.toml", "Path to configuration file \"config.toml\".") 277 | flag.Parse() 278 | 279 | if !flag.Parsed() { 280 | log.Fatalln("Could not parse flags") 281 | } 282 | 283 | /* 284 | * Read config file 285 | */ 286 | err := readConfig(configFile, &conf) 287 | if err != nil { 288 | log.Fatalln("There was an error while reading the configuration file:", err) 289 | } 290 | 291 | // Select proto 292 | if conf.UnixSocket { 293 | proto = "unix" 294 | } else { 295 | proto = "tcp" 296 | } 297 | 298 | /* 299 | * Start HTTP server 300 | */ 301 | log.Println("Starting prosody-filer", versionString, "...") 302 | listener, err := net.Listen(proto, conf.ListenPort) 303 | if err != nil { 304 | log.Fatalln("Could not open listening socket:", err) 305 | } 306 | 307 | subpath := path.Join("/", conf.UploadSubDir) 308 | subpath = strings.TrimRight(subpath, "/") 309 | subpath += "/" 310 | http.HandleFunc(subpath, handleRequest) 311 | log.Printf("Server started on port %s. Waiting for requests.\n", conf.ListenPort) 312 | 313 | // Set log level 314 | setLogLevel() 315 | 316 | http.Serve(listener, nil) 317 | // This line will only be reached when quitting 318 | } 319 | -------------------------------------------------------------------------------- /prosody-filer_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | /* 4 | * Manual testing with CURL 5 | * Send with: 6 | * curl -X PUT "http://localhost:5050/upload/thomas/abc/catmetal.jpg?v=7b8879e2d1c733b423a70cde30cecc3a3c64a03f790d1b5bcbb2a6aca52b477e" --data-binary '@catmetal.jpg' 7 | * HMAC: 7b8879e2d1c733b423a70cde30cecc3a3c64a03f790d1b5bcbb2a6aca52b477e 8 | */ 9 | 10 | import ( 11 | "bytes" 12 | "io" 13 | "net/http" 14 | "net/http/httptest" 15 | "os" 16 | "path/filepath" 17 | "testing" 18 | 19 | "github.com/sirupsen/logrus" 20 | ) 21 | 22 | func mockUpload() { 23 | os.MkdirAll(filepath.Join(conf.StoreDir, "thomas/abc/"), os.ModePerm) 24 | from, err := os.Open("./catmetal.jpg") 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | defer from.Close() 29 | 30 | to, err := os.OpenFile(filepath.Join(conf.StoreDir, "thomas/abc/catmetal.jpg"), os.O_RDWR|os.O_CREATE, 0660) 31 | if err != nil { 32 | log.Fatal(err) 33 | } 34 | defer to.Close() 35 | 36 | _, err = io.Copy(to, from) 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | } 41 | 42 | /* 43 | * Remove all uploaded files after an upload test 44 | */ 45 | func cleanup() { 46 | // Clean up 47 | if _, err := os.Stat(conf.StoreDir); err == nil { 48 | err := os.RemoveAll(conf.StoreDir) 49 | if err != nil { 50 | log.Println("Error while cleaning up:", err) 51 | } 52 | } 53 | } 54 | 55 | /* 56 | * Test if reading the config file works 57 | */ 58 | func TestReadConfig(t *testing.T) { 59 | // Set config 60 | err := readConfig("config.toml", &conf) 61 | if err != nil { 62 | t.Fatal(err) 63 | } 64 | 65 | log.SetLevel(logrus.FatalLevel) 66 | } 67 | 68 | /* 69 | * Run an upload test using the v1 / v MAC parameter 70 | */ 71 | func TestUploadValidV1(t *testing.T) { 72 | // Remove uploaded file after test 73 | defer cleanup() 74 | 75 | // Set config 76 | readConfig("config.toml", &conf) 77 | 78 | // Read catmetal file 79 | catMetalFile, err := os.ReadFile("catmetal.jpg") 80 | if err != nil { 81 | t.Fatal(err) 82 | } 83 | 84 | // Create request 85 | req, err := http.NewRequest("PUT", "/upload/thomas/abc/catmetal.jpg", bytes.NewBuffer(catMetalFile)) 86 | q := req.URL.Query() 87 | q.Add("v", "7b8879e2d1c733b423a70cde30cecc3a3c64a03f790d1b5bcbb2a6aca52b477e") 88 | req.URL.RawQuery = q.Encode() 89 | 90 | if err != nil { 91 | t.Fatal(err) 92 | } 93 | 94 | rr := httptest.NewRecorder() 95 | handler := http.HandlerFunc(handleRequest) 96 | 97 | // Send request and record response 98 | handler.ServeHTTP(rr, req) 99 | 100 | // Check status code 101 | if status := rr.Code; status != http.StatusCreated { 102 | t.Errorf("handler returned wrong status code: got %v want %v. HTTP body: %s", status, http.StatusCreated, rr.Body.String()) 103 | } 104 | } 105 | 106 | /* 107 | * Run an upload test using the v2 MAC parameter 108 | */ 109 | func TestUploadValidV2(t *testing.T) { 110 | // Remove uploaded file after test 111 | defer cleanup() 112 | 113 | // Set config 114 | readConfig("config.toml", &conf) 115 | 116 | // Read catmetal file 117 | catMetalFile, err := os.ReadFile("catmetal.jpg") 118 | if err != nil { 119 | t.Fatal(err) 120 | } 121 | 122 | // Create request 123 | req, err := http.NewRequest("PUT", "/upload/thomas/abc/catmetal.jpg", bytes.NewBuffer(catMetalFile)) 124 | q := req.URL.Query() 125 | q.Add("v2", "7318cd44d4c40731e3b2ff869f553ab2326eae631868e7b8054db20d4aee1c06") 126 | req.URL.RawQuery = q.Encode() 127 | 128 | if err != nil { 129 | t.Fatal(err) 130 | } 131 | 132 | rr := httptest.NewRecorder() 133 | handler := http.HandlerFunc(handleRequest) 134 | 135 | // Send request and record response 136 | handler.ServeHTTP(rr, req) 137 | 138 | // Check status code 139 | if status := rr.Code; status != http.StatusCreated { 140 | t.Errorf("handler returned wrong status code: got %v want %v. HTTP body: %s", status, http.StatusCreated, rr.Body.String()) 141 | } 142 | } 143 | 144 | /* 145 | * Run an upload test using the token MAC parameter 146 | */ 147 | func TestUploadValidMetronomeToken(t *testing.T) { 148 | // Remove uploaded file after test 149 | defer cleanup() 150 | 151 | // Set config 152 | readConfig("config.toml", &conf) 153 | 154 | // Read catmetal file 155 | catMetalFile, err := os.ReadFile("catmetal.jpg") 156 | if err != nil { 157 | t.Fatal(err) 158 | } 159 | 160 | // Create request 161 | req, err := http.NewRequest("PUT", "/upload/thomas/abc/catmetal.jpg", bytes.NewBuffer(catMetalFile)) 162 | q := req.URL.Query() 163 | q.Add("token", "7318cd44d4c40731e3b2ff869f553ab2326eae631868e7b8054db20d4aee1c06") 164 | req.URL.RawQuery = q.Encode() 165 | 166 | if err != nil { 167 | t.Fatal(err) 168 | } 169 | 170 | rr := httptest.NewRecorder() 171 | handler := http.HandlerFunc(handleRequest) 172 | 173 | // Send request and record response 174 | handler.ServeHTTP(rr, req) 175 | 176 | // Check status code 177 | if status := rr.Code; status != http.StatusCreated { 178 | t.Errorf("handler returned wrong status code: got %v want %v. HTTP body: %s", status, http.StatusCreated, rr.Body.String()) 179 | } 180 | } 181 | 182 | /* 183 | * Run an upload test using no MAC parameter 184 | */ 185 | func TestUploadMissingMAC(t *testing.T) { 186 | // Remove uploaded file after test 187 | defer cleanup() 188 | 189 | // Set config 190 | readConfig("config.toml", &conf) 191 | 192 | // Read catmetal file 193 | catMetalFile, err := os.ReadFile("catmetal.jpg") 194 | if err != nil { 195 | t.Fatal(err) 196 | } 197 | 198 | // Create request 199 | req, err := http.NewRequest("PUT", "/upload/thomas/abc/catmetal.jpg", bytes.NewBuffer(catMetalFile)) 200 | 201 | if err != nil { 202 | t.Fatal(err) 203 | } 204 | 205 | rr := httptest.NewRecorder() 206 | handler := http.HandlerFunc(handleRequest) 207 | 208 | // Send request and record response 209 | handler.ServeHTTP(rr, req) 210 | 211 | // Check status code 212 | if status := rr.Code; status != http.StatusForbidden { 213 | t.Errorf("handler returned wrong status code: got %v want %v. HTTP body: %s", status, http.StatusForbidden, rr.Body.String()) 214 | } 215 | } 216 | 217 | /* 218 | * Run an upload test using an invalid MAC parameter 219 | */ 220 | func TestUploadInvalidMAC(t *testing.T) { 221 | // Remove uploaded file after test 222 | defer cleanup() 223 | 224 | // Set config 225 | readConfig("config.toml", &conf) 226 | 227 | // Read catmetal file 228 | catMetalFile, err := os.ReadFile("catmetal.jpg") 229 | if err != nil { 230 | t.Fatal(err) 231 | } 232 | 233 | // Create request 234 | req, err := http.NewRequest("PUT", "/upload/thomas/abc/catmetal.jpg", bytes.NewBuffer(catMetalFile)) 235 | q := req.URL.Query() 236 | q.Add("v", "thisisinvalid") 237 | req.URL.RawQuery = q.Encode() 238 | 239 | if err != nil { 240 | t.Fatal(err) 241 | } 242 | 243 | rr := httptest.NewRecorder() 244 | handler := http.HandlerFunc(handleRequest) 245 | 246 | // Send request and record response 247 | handler.ServeHTTP(rr, req) 248 | 249 | // Check status code 250 | if status := rr.Code; status != http.StatusForbidden { 251 | t.Errorf("handler returned wrong status code: got %v want %v. HTTP body: %s", status, http.StatusForbidden, rr.Body.String()) 252 | } 253 | } 254 | 255 | /* 256 | * Test upload using an invalid HTTP method (POST) 257 | */ 258 | func TestUploadInvalidMethod(t *testing.T) { 259 | // Remove uploaded file after test 260 | defer cleanup() 261 | 262 | // Set config 263 | readConfig("config.toml", &conf) 264 | 265 | // Read catmetal file 266 | catMetalFile, err := os.ReadFile("catmetal.jpg") 267 | if err != nil { 268 | t.Fatal(err) 269 | } 270 | 271 | // Create request 272 | req, err := http.NewRequest("POST", "/upload/thomas/abc/catmetal.jpg", bytes.NewBuffer(catMetalFile)) 273 | 274 | if err != nil { 275 | t.Fatal(err) 276 | } 277 | 278 | rr := httptest.NewRecorder() 279 | handler := http.HandlerFunc(handleRequest) 280 | 281 | // Send request and record response 282 | handler.ServeHTTP(rr, req) 283 | 284 | // Check status code 285 | if status := rr.Code; status != http.StatusMethodNotAllowed { 286 | t.Errorf("handler returned wrong status code: got %v want %v. HTTP body: %s", status, http.StatusMethodNotAllowed, rr.Body.String()) 287 | } 288 | } 289 | 290 | /* 291 | * Test if HEAD requests work 292 | */ 293 | func TestDownloadHead(t *testing.T) { 294 | // Set config 295 | readConfig("config.toml", &conf) 296 | 297 | // Mock upload 298 | mockUpload() 299 | defer cleanup() 300 | 301 | // Create request 302 | req, err := http.NewRequest("HEAD", "/upload/thomas/abc/catmetal.jpg", nil) 303 | 304 | if err != nil { 305 | t.Fatal(err) 306 | } 307 | 308 | rr := httptest.NewRecorder() 309 | handler := http.HandlerFunc(handleRequest) 310 | 311 | // Send request and record response 312 | handler.ServeHTTP(rr, req) 313 | 314 | // Check status code 315 | if status := rr.Code; status != http.StatusOK { 316 | t.Errorf("handler returned wrong status code: got %v want %v. HTTP body: %s", status, http.StatusOK, rr.Body.String()) 317 | } 318 | } 319 | 320 | /* 321 | * Test if GET download requests work 322 | */ 323 | func TestDownloadGet(t *testing.T) { 324 | // Set config 325 | readConfig("config.toml", &conf) 326 | 327 | // moch upload 328 | mockUpload() 329 | defer cleanup() 330 | 331 | // Create request 332 | req, err := http.NewRequest("GET", "/upload/thomas/abc/catmetal.jpg", nil) 333 | 334 | if err != nil { 335 | t.Fatal(err) 336 | } 337 | 338 | rr := httptest.NewRecorder() 339 | handler := http.HandlerFunc(handleRequest) 340 | 341 | // Send request and record response 342 | handler.ServeHTTP(rr, req) 343 | 344 | // Check status code 345 | if status := rr.Code; status != http.StatusOK { 346 | t.Errorf("handler returned wrong status code: got %v want %v. HTTP body: %s", status, http.StatusOK, rr.Body.String()) 347 | } 348 | } 349 | 350 | /* 351 | * Test if asking for an empty file name works 352 | */ 353 | func TestEmptyGet(t *testing.T) { 354 | // Set config 355 | readConfig("config.toml", &conf) 356 | 357 | // Create request 358 | req, err := http.NewRequest("GET", "", nil) 359 | 360 | if err != nil { 361 | t.Fatal(err) 362 | } 363 | 364 | rr := httptest.NewRecorder() 365 | handler := http.HandlerFunc(handleRequest) 366 | 367 | // Send request and record response 368 | handler.ServeHTTP(rr, req) 369 | 370 | // Check status code 371 | if status := rr.Code; status != http.StatusForbidden { 372 | t.Errorf("handler returned wrong status code: got %v want %v. HTTP body: %s", status, http.StatusForbidden, rr.Body.String()) 373 | } 374 | } 375 | 376 | /* 377 | * Check if access to subdirectory is forbidden. 378 | * PASS if access is blocked with HTTP "Forbidden" response. 379 | * FAIL if there is any other response or even a directory listing exposed. 380 | * Introduced to check issue #14 (resolved in 7dff0209) 381 | */ 382 | func TestDirListing(t *testing.T) { 383 | // Set config 384 | readConfig("config.toml", &conf) 385 | 386 | mockUpload() 387 | defer cleanup() 388 | 389 | // Create request 390 | req, err := http.NewRequest("GET", "/upload/thomas/", nil) 391 | if err != nil { 392 | t.Fatal(err) 393 | } 394 | 395 | rr := httptest.NewRecorder() 396 | handler := http.HandlerFunc(handleRequest) 397 | 398 | // Send request and record response 399 | handler.ServeHTTP(rr, req) 400 | 401 | // Check status code 402 | if status := rr.Code; status != http.StatusForbidden { 403 | t.Errorf("handler returned wrong status code: got %v want %v. HTTP body: %s", status, http.StatusForbidden, rr.Body.String()) 404 | } 405 | } 406 | --------------------------------------------------------------------------------