├── .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 |
--------------------------------------------------------------------------------