Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db368bbfd4 | |||
| 79c0529823 | |||
| e1b2d99b23 | |||
| 6175a9857c | |||
| 7441ce0a83 | |||
| 1dc5a2b56b | |||
| 2eaa0ada05 | |||
| 1e67af6bba | |||
| db8529e560 | |||
| 114edffa0a | |||
| 5732fb7aa0 | |||
| 45c80c1686 | |||
| 40e13b716c | |||
| 3079f15783 | |||
| fd26ac21af | |||
| 3a90641e40 | |||
| 757e38bc32 | |||
| 3d821b7844 | |||
| 31bf2fb3f6 | |||
| 008cceed07 | |||
| 8a9eb0f5ea | |||
| be7c5a3164 | |||
| e15ae6070f | |||
| d5330dcfbc | |||
| fad4b5fd34 | |||
| fc0e2d8fdd | |||
| 6794b64658 | |||
| c6358b3b6c | |||
| 5f8b6c5ba4 | |||
| 3dec173a40 | |||
| 9d1eb48368 | |||
| 321c3515f3 | |||
| 7870d7b3f2 | |||
| c18f91270e | |||
| 19efe356ba | |||
| dc4bd52fac | |||
| bce638e2bc | |||
| 6146fd6b1b | |||
| d206f845b1 | |||
| 17e4a4e1f1 | |||
| f8dfc345f0 | |||
| 87e5dbbc81 | |||
| 605b81b307 | |||
| dc6258e386 | |||
| 79651834b4 | |||
| a81fce0eb9 | |||
| 85ca8771c3 | |||
| 7e2f59de50 | |||
| ad184314d7 | |||
| 33848cbbd2 | |||
| 4dfa67af5d | |||
| 6d744ada2a | |||
| e88fbf2aa9 | |||
| e0c1df7043 | |||
| ce9ce97837 | |||
| ac0313cee3 | |||
| 1ada753fcb | |||
| 3ddfebcd24 | |||
| 5db166c2b9 | |||
| 23df3a1aa4 | |||
| 9865b14f2c | |||
| f9b561e6e7 | |||
| 99bbae5b94 | |||
| f0d33eb150 | |||
| 7fa8dae48f | |||
| a79b0235e7 | |||
| bac7eaeffe | |||
| 09ac2e3e36 | |||
| c9f9891489 | |||
| f98aa04bba | |||
| 6a9d1c8eaf | |||
| a14562ae50 | |||
| 0ec0a19b9d | |||
| 3e891b87f5 | |||
| 0714176d6e | |||
| 6eb1ee50ac | |||
| ccaccd27d9 | |||
| 61e4038bd4 | |||
| 28dcdc72ab | |||
| e6e87a16ac | |||
|
|
bbbdcd650c | ||
|
|
9c4d7df1fc | ||
|
|
ff4065a5d3 | ||
|
|
c5bc33f50a | ||
|
|
90c525b324 | ||
|
|
cabca4963c | ||
|
|
a4cd80b34d | ||
|
|
448c35c0ef | ||
|
|
c56646025a | ||
|
|
07d0882c65 | ||
|
|
016ffd9fa4 | ||
|
|
95c3ac899f | ||
|
|
3bc8446a6d | ||
|
|
aabe581148 | ||
|
|
7dff0209f5 | ||
|
|
65ca295289 | ||
|
|
3e06fb8d3d | ||
|
|
6e287dbe56 | ||
|
|
37234aadd5 | ||
|
|
75a5bf2316 | ||
|
|
669e4c89e0 | ||
|
|
6a2e8cde1f |
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!bin/
|
||||
60
.github/workflows/build.yaml
vendored
Normal file
60
.github/workflows/build.yaml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: Build image
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
push:
|
||||
description: Whether to push the image
|
||||
type: boolean
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build docker image
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Login to registry
|
||||
if: ${{ inputs.push }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.mug.lv
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Setup buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Setup go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build binary
|
||||
run: make build
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
|
||||
- name: Tag image
|
||||
uses: docker/metadata-action@v5
|
||||
id: meta
|
||||
with:
|
||||
images: |
|
||||
git.mug.lv/${{ github.repository }}
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=ref,event=tag
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: ${{ inputs.push }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
46
.github/workflows/main.yaml
vendored
Normal file
46
.github/workflows/main.yaml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: Main workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags-ignore:
|
||||
- '**'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run unit tests
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run minio
|
||||
run: |
|
||||
wget -q https://dl.min.io/server/minio/release/linux-amd64/minio
|
||||
chmod +x minio
|
||||
./minio server /data &
|
||||
while ! curl -s -f http://localhost:9000/minio/health/live; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run unit tests
|
||||
run: go test -v ./...
|
||||
|
||||
- name: Stop minio
|
||||
if: always()
|
||||
run: pkill -f minio
|
||||
|
||||
build:
|
||||
needs: test
|
||||
uses: ./.github/workflows/build.yaml
|
||||
with:
|
||||
push: ${{ github.ref_name == 'main' }}
|
||||
secrets: inherit
|
||||
16
.github/workflows/tag.yaml
vendored
Normal file
16
.github/workflows/tag.yaml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: Main workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- '**'
|
||||
tags:
|
||||
- '**'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./.github/workflows/build.yaml
|
||||
with:
|
||||
push: true
|
||||
secrets: inherit
|
||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -1,16 +1 @@
|
||||
config.toml
|
||||
main
|
||||
prosody-filer
|
||||
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, build with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
bin/
|
||||
|
||||
11
.renovaterc.json5
Normal file
11
.renovaterc.json5
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"dependencyDashboard": true,
|
||||
"extends": [
|
||||
"group:allNonMajor",
|
||||
"schedule:monthly",
|
||||
],
|
||||
"enabledManagers": ["github-actions", "gomod"],
|
||||
"osvVulnerabilityAlerts": true,
|
||||
"postUpdateOptions": ["gomodTidy"]
|
||||
}
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN apk add -U --no-cache ca-certificates tini && \
|
||||
addgroup -g 1000 prosody-filer-s3 && \
|
||||
adduser -h /var/lib/empty -G prosody-filer-s3 -s /sbin/nologin -D -H -u 1000 prosody-filer-s3
|
||||
|
||||
COPY bin/prosody-filer-s3 /usr/local/bin/prosody-filer-s3
|
||||
|
||||
USER 1000
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/prosody-filer-s3"]
|
||||
12
Makefile
Normal file
12
Makefile
Normal file
@@ -0,0 +1,12 @@
|
||||
.PHONY: build
|
||||
build:
|
||||
go build -o bin/ ./cmd/prosody-filer-s3
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -f prosody-filer-s3
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
docker compose up -d
|
||||
go test ./... -count 1
|
||||
216
README.md
216
README.md
@@ -1,8 +1,55 @@
|
||||
# Prosody Filer S3
|
||||
|
||||
Originally forked from https://github.com/Wilm0r/prosody-filer-s3.
|
||||
|
||||
Refactored to cleanup code base & fix some issues with the original implementation (e.g. HEAD requests not returning the file size). The `v2` upload API has also been implemented.
|
||||
|
||||
See [config.toml](./config.toml) for an example configuration file. It's also now possible to [configure using environment variables](./cmd/prosody-filer-s3/main.go#L255).
|
||||
|
||||
# Prosody Filer S3 fork
|
||||
|
||||
A simple XMPP upload server (tested with Prosody only so far) that relies on S3 API compatible storage (tested against my own Ceph and [Scaleway (free 75GB)](https://www.scaleway.com/en/object-storage/)) instead of local disk, so that you can run it without local state on Kubernetes/Docker.
|
||||
|
||||
Like the original version, it aims to be thin and simple. It streams PUT operations directly to S3, and fetch operations will by default redirect to a signed request to S3, instead of sitting in between as an unnecessary bottleneck. (If you do prefer proxying, it can be enabled using the `ProxyMode` setting.)
|
||||
|
||||
If you want automatic purging, just set [lifecycle policies](https://docs.aws.amazon.com/AmazonS3/latest/dev/lifecycle-configuration-examples.html) on your S3 bucket.
|
||||
|
||||
## `config.toml` example
|
||||
|
||||
```ini
|
||||
### IP address and port to listen to, e.g. "[::]:5050"
|
||||
ListenPort = "0.0.0.0:5280"
|
||||
### Secret (must match the one in prosody.conf.lua!)
|
||||
Secret =
|
||||
### Subdirectory for HTTP upload / download requests (usually "upload/",
|
||||
### NO to LEADING slash, YES to trailing!)
|
||||
UploadSubDir = "upload/"
|
||||
|
||||
### Hostname of S3 compatible endpoint
|
||||
S3Endpoint = "xmpp-filer.s3.nl-ams.scw.cloud"
|
||||
### HTTPS. True by default obviously, set to false if you must.
|
||||
S3TLS = true
|
||||
### Credentials. Use AWS_ACCESS_KEY_ID environment variable if you prefer.
|
||||
S3AccessKey = "..."
|
||||
### Or AWS_SECRET_ACCESS_KEY environment variable.
|
||||
S3Secret = "..."
|
||||
### Our S3 bucket name.
|
||||
S3Bucket = "xmpp-filer"
|
||||
|
||||
### If your client doesn't deal well with the 302 redirects or signed URLs,
|
||||
### enable this setting so Filer will proxy the data for you.
|
||||
ProxyMode = false
|
||||
```
|
||||
|
||||
The rest of this manual covers the local-storage original Filer. The majority of it (except for Prosody/Ejabberd configuration) shouldn't apply to you if you're planning to run this Filer on k8s.
|
||||
|
||||
# Prosody Filer
|
||||
|
||||
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.
|
||||
|
||||
*(This module can also be used with future versions of Ejabberd: https://github.com/processone/ejabberd/commit/fface33d54f24c777dbec96fda6bd00e665327fe)*
|
||||
**Despite the name, this server is also compatible with Ejabberd and Ejabberd's http_upload module!**
|
||||
|
||||
---
|
||||
|
||||
## Why should I use this server?
|
||||
|
||||
@@ -18,7 +65,7 @@ A simple file server for handling XMPP http_upload requests. This server is mean
|
||||
> The GC will free the memory at some point, but the OS may still report that Prosody is using that memory due to the way the libc allocator works.
|
||||
> Most long lived processes behave this way (only increasing RAM, rarely decreasing)."
|
||||
* This server works without any script interpreters or additional dependencies. It is delivered as a binary.
|
||||
* Go is very good at serving HTTP requests.
|
||||
* Go is very good at serving HTTP requests and "made for this task".
|
||||
|
||||
|
||||
## Download
|
||||
@@ -34,19 +81,25 @@ To compile the server, you need a full Golang development environment. This can
|
||||
|
||||
Then checkout this repo:
|
||||
|
||||
go get github.com/ThomasLeister/prosody-filer
|
||||
```sh
|
||||
go get github.com/ThomasLeister/prosody-filer
|
||||
```
|
||||
|
||||
and switch to the new directory:
|
||||
|
||||
cd $GOPATH/src/github.com/ThomasLeister/prosody-filer
|
||||
```sh
|
||||
cd $GOPATH/src/github.com/ThomasLeister/prosody-filer
|
||||
```
|
||||
|
||||
The application can now be build:
|
||||
|
||||
### Build static binary
|
||||
./build.sh
|
||||
```sh
|
||||
### Build static binary
|
||||
./build.sh
|
||||
|
||||
### OR regular Go build
|
||||
go build main.go
|
||||
### OR regular Go build
|
||||
go build main.go
|
||||
```
|
||||
|
||||
|
||||
## Set up / configuration
|
||||
@@ -56,11 +109,15 @@ The application can now be build:
|
||||
|
||||
Create a new user for Prosody Filer to run as:
|
||||
|
||||
adduser --disabled-login --disabled-password prosody-filer
|
||||
```sh
|
||||
adduser --disabled-login --disabled-password prosody-filer
|
||||
```
|
||||
|
||||
Switch to the new user:
|
||||
|
||||
su - prosody-filer
|
||||
```sh
|
||||
su - prosody-filer
|
||||
```
|
||||
|
||||
Copy
|
||||
|
||||
@@ -69,12 +126,18 @@ Copy
|
||||
|
||||
to ```/home/prosody-filer/```. Rename the configuration to ```config.toml```.
|
||||
|
||||
Make sure the `prosody-filer` binary is executable:
|
||||
|
||||
```
|
||||
chmod u+x prosody-filer
|
||||
```
|
||||
|
||||
|
||||
### Configure Prosody
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
```lua
|
||||
http_upload_external_base_url = "https://uploads.myserver.tld/upload/"
|
||||
http_upload_external_secret = "mysecret"
|
||||
http_upload_external_file_size_limit = 50000000 -- 50 MB
|
||||
@@ -84,13 +147,26 @@ Restart Prosody when you are finished:
|
||||
|
||||
systemctl restart prosody
|
||||
|
||||
|
||||
### Alternative: Configure Ejabberd
|
||||
|
||||
Although this tool is named after Prosody, it can be used with Ejabberd, too! Make sure you have a Ejabberd configuration similar to this:
|
||||
|
||||
```yaml
|
||||
mod_http_upload:
|
||||
put_url: "https://uploads.@HOST@/upload"
|
||||
external_secret: "mysecret"
|
||||
max_size: 52428800
|
||||
```
|
||||
|
||||
|
||||
### Configure Prosody Filer
|
||||
|
||||
Prosody Filer configuration is done via the config.toml file in TOML syntax. There's not much to be configured:
|
||||
|
||||
```
|
||||
### IP address and port to listen to, e.g. "127.0.0.1:5050"
|
||||
listenport = "127.0.0.1:5050"
|
||||
```toml
|
||||
### IP address and port to listen to, e.g. "[::]:5050"
|
||||
listenport = "[::1]:5050"
|
||||
|
||||
### Secret (must match the one in prosody.conf.lua!)
|
||||
secret = "mysecret"
|
||||
@@ -142,24 +218,36 @@ Done! Prosody Filer is now listening on the specified port and waiting for reque
|
||||
|
||||
Create a new config file ```/etc/nginx/sites-available/uploads.myserver.tld```:
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
|
||||
server_name uploads.myserver.tld;
|
||||
server_name uploads.myserver.tld;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/uploads.myserver.tld/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/uploads.myserver.tld/privkey.pem;
|
||||
ssl_certificate /etc/letsencrypt/live/uploads.myserver.tld/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/uploads.myserver.tld/privkey.pem;
|
||||
|
||||
client_max_body_size 50m;
|
||||
client_max_body_size 50m;
|
||||
|
||||
location /upload/ {
|
||||
proxy_pass http://127.0.0.1:5050/upload/;
|
||||
proxy_request_buffering off;
|
||||
location /upload/ {
|
||||
if ( $request_method = OPTIONS ) {
|
||||
add_header Access-Control-Allow-Origin '*';
|
||||
add_header Access-Control-Allow-Methods 'PUT, GET, OPTIONS, HEAD';
|
||||
add_header Access-Control-Allow-Headers 'Authorization, Content-Type';
|
||||
add_header Access-Control-Allow-Credentials 'true';
|
||||
add_header Content-Length 0;
|
||||
add_header Content-Type text/plain;
|
||||
return 200;
|
||||
}
|
||||
|
||||
proxy_pass http://[::]:5050/upload/;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable the new config:
|
||||
|
||||
@@ -173,24 +261,42 @@ Reload Nginx:
|
||||
|
||||
systemctl reload nginx
|
||||
|
||||
#### Configuration for letting nginx serve the uploaded files
|
||||
|
||||
#### Alternative configuration for letting Nginx serve the uploaded files
|
||||
|
||||
*(not officially supported - user contribution!)*
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name xmppserver.tld;
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
|
||||
# ...
|
||||
server_name uploads.myserver.tld;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/uploads.myserver.tld/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/uploads.myserver.tld/privkey.pem;
|
||||
|
||||
location /upload/ {
|
||||
if ( $request_method = OPTIONS ) {
|
||||
add_header Access-Control-Allow-Origin '*';
|
||||
add_header Access-Control-Allow-Methods 'PUT, GET, OPTIONS, HEAD';
|
||||
add_header Access-Control-Allow-Headers 'Authorization, Content-Type';
|
||||
add_header Access-Control-Allow-Credentials 'true';
|
||||
add_header Content-Length 0;
|
||||
add_header Content-Type text/plain;
|
||||
return 200;
|
||||
}
|
||||
|
||||
root /home/prosody-filer;
|
||||
autoindex off;
|
||||
client_max_body_size 51m;
|
||||
client_body_buffer_size 51m;
|
||||
try_files $uri $uri/ @prosodyfiler;
|
||||
}
|
||||
location @prosodyfiler {
|
||||
proxy_pass http://127.0.0.1:5050;
|
||||
proxy_pass http://[::1]:5050;
|
||||
proxy_buffering off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -199,16 +305,56 @@ server {
|
||||
proxy_set_header X-Forwarded-Server $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
}
|
||||
|
||||
# ...
|
||||
}
|
||||
```
|
||||
|
||||
## apache2 configuration (alternative to Nginx)
|
||||
|
||||
*(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)*
|
||||
|
||||
```
|
||||
<VirtualHost *:80>
|
||||
ServerName upload.example.eu
|
||||
RedirectPermanent / https://upload.example.eu/
|
||||
</VirtualHost>
|
||||
|
||||
<VirtualHost *:443>
|
||||
ServerName upload.example.eu
|
||||
SSLEngine on
|
||||
|
||||
SSLCertificateFile "Path to the ca file"
|
||||
SSLCertificateKeyFile "Path to the key file"
|
||||
|
||||
Header always set Public-Key-Pins: ''
|
||||
Header always set Strict-Transport-Security "max-age=63072000; includeSubdomains; preload"
|
||||
H2Direct on
|
||||
|
||||
<Location /upload>
|
||||
Header always set Access-Control-Allow-Origin "*"
|
||||
Header always set Access-Control-Allow-Headers "Content-Type"
|
||||
Header always set Access-Control-Allow-Methods "OPTIONS, PUT, GET"
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
RewriteCond %{REQUEST_METHOD} OPTIONS
|
||||
RewriteRule ^(.*)$ $1 [R=200,L]
|
||||
</Location>
|
||||
|
||||
SSLProxyEngine on
|
||||
|
||||
ProxyPreserveHost On
|
||||
ProxyRequests Off
|
||||
ProxyPass / http://localhost:5050/
|
||||
ProxyPassReverse / http://localhost:5050/
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
|
||||
## Automatic purge
|
||||
|
||||
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:
|
||||
|
||||
@daily find /home/prosody-filer/upload/* -maxdepth 0 -type d -mtime +28 | xargs rm -rf
|
||||
@daily find /home/prosody-filer/upload/ -mindepth 1 -type d -mtime +28 -print0 | xargs -0 -- rm -rf
|
||||
|
||||
This will delete uploads older than 28 days.
|
||||
|
||||
|
||||
7
build.sh
7
build.sh
@@ -1,7 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
##
|
||||
## Builds static prosody-filer binary
|
||||
##
|
||||
|
||||
CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-extldflags "-static"' .
|
||||
341
cmd/prosody-filer-s3/main.go
Normal file
341
cmd/prosody-filer-s3/main.go
Normal file
@@ -0,0 +1,341 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
minio "github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
/*
|
||||
* Configuration of this server
|
||||
*/
|
||||
type Config struct {
|
||||
Address string `toml:"address"`
|
||||
Secret string `toml:"secret"`
|
||||
UploadSubDir string `toml:"upload_sub_dir"`
|
||||
ProxyMode bool `toml:"proxy_mode"`
|
||||
S3Endpoint string `toml:"s3_endpoint"`
|
||||
S3AccessKey string `toml:"s3_access_key"`
|
||||
S3SecretKey string `toml:"s3_secret_key"`
|
||||
S3TLS bool `toml:"s3_tls"`
|
||||
S3Bucket string `toml:"s3_bucket"`
|
||||
S3Region string `toml:"s3_region"`
|
||||
}
|
||||
|
||||
const ALLOWED_METHODS = "OPTIONS, HEAD, GET, PUT"
|
||||
|
||||
// How long presigned S3 will remain valid
|
||||
const PRESIGNED_URL_EXPIRY_TIME = 5 * time.Minute
|
||||
|
||||
var mimeTypeRegex = regexp.MustCompile("((audio|image|video)/.*|text/plain)")
|
||||
|
||||
func setContentHeaders(h http.Header, filename string) {
|
||||
mimeType := mime.TypeByExtension(filepath.Ext(filename))
|
||||
h.Set("Content-Type", mimeType)
|
||||
if mimeTypeRegex.MatchString(mimeType) {
|
||||
h.Set("Content-Disposition", "inline")
|
||||
} else {
|
||||
h.Set("Content-Disposition", "attachment")
|
||||
}
|
||||
}
|
||||
|
||||
func handleHead(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
config *Config,
|
||||
client *minio.Client,
|
||||
filename string,
|
||||
) {
|
||||
info, err := client.StatObject(
|
||||
r.Context(),
|
||||
config.S3Bucket,
|
||||
filename,
|
||||
minio.StatObjectOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to get file stats from s3")
|
||||
http.Error(w, "Service Unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
setContentHeaders(w.Header(), filename)
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(info.Size, 10))
|
||||
}
|
||||
|
||||
func handleGet(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
config *Config,
|
||||
client *minio.Client,
|
||||
filename string,
|
||||
) {
|
||||
if config.ProxyMode {
|
||||
obj, err := client.GetObject(
|
||||
r.Context(),
|
||||
config.S3Bucket,
|
||||
filename,
|
||||
minio.GetObjectOptions{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to get file from s3")
|
||||
http.Error(w, "Service Unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer obj.Close()
|
||||
|
||||
setContentHeaders(w.Header(), filename)
|
||||
http.ServeContent(w, r, filename, time.Now(), obj)
|
||||
} else {
|
||||
objUrl, err := client.PresignedGetObject(
|
||||
r.Context(),
|
||||
config.S3Bucket,
|
||||
filename,
|
||||
PRESIGNED_URL_EXPIRY_TIME,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to get file from s3")
|
||||
http.Error(w, "Service Unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
setContentHeaders(w.Header(), filename)
|
||||
w.Header().Set("Location", objUrl.String())
|
||||
w.WriteHeader(http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
func requestHasValidHmac(r *http.Request, filename, secret string) bool {
|
||||
var actual string
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
|
||||
params := r.URL.Query()
|
||||
if params.Has("v2") {
|
||||
actual = params.Get("v2")
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
return false
|
||||
}
|
||||
contentLength := strconv.FormatInt(r.ContentLength, 10)
|
||||
mac.Write([]byte(filename + "\x00" + contentLength + "\x00" + contentType))
|
||||
} else if params.Has("v") {
|
||||
actual = params.Get("v")
|
||||
contentLength := strconv.FormatInt(r.ContentLength, 10)
|
||||
mac.Write([]byte(filename + " " + contentLength))
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return hmac.Equal([]byte(expected), []byte(actual))
|
||||
}
|
||||
|
||||
func handlePut(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
config *Config,
|
||||
client *minio.Client,
|
||||
filename string,
|
||||
) {
|
||||
log.Info().
|
||||
Str("filename", filename).
|
||||
Int64("size", r.ContentLength).
|
||||
Msg("uploading file")
|
||||
if !requestHasValidHmac(r, filename, config.Secret) {
|
||||
log.Warn().Msgf("invalid or missing hmac")
|
||||
http.Error(w, "Invalid HMAC", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := client.StatObject(
|
||||
context.Background(),
|
||||
config.S3Bucket,
|
||||
filename,
|
||||
minio.StatObjectOptions{},
|
||||
)
|
||||
if err == nil {
|
||||
log.Warn().Str("filename", filename).Msg("file already exists")
|
||||
http.Error(w, "File already exists", http.StatusConflict)
|
||||
return
|
||||
} else if minio.ToErrorResponse(err).Code != "NoSuchKey" {
|
||||
log.Error().Err(err).Msg("failed to stat file")
|
||||
http.Error(w, "Service Unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
h := make(http.Header)
|
||||
setContentHeaders(h, filename)
|
||||
opts := minio.PutObjectOptions{
|
||||
ContentType: h.Get("Content-Type"),
|
||||
ContentDisposition: h.Get("Content-Disposition"),
|
||||
}
|
||||
info, err := client.PutObject(
|
||||
r.Context(),
|
||||
config.S3Bucket,
|
||||
filename,
|
||||
r.Body,
|
||||
r.ContentLength,
|
||||
opts,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to upload file to s3")
|
||||
http.Error(w, "Service Unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("filename", filename).
|
||||
Str("etag", info.ETag).
|
||||
Msg("file uploaded")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
func newHandler(config *Config, client *minio.Client) http.HandlerFunc {
|
||||
var subPath string
|
||||
if config.UploadSubDir == "" || config.UploadSubDir == "/" {
|
||||
subPath = "/"
|
||||
} else {
|
||||
subPath = "/" + strings.Trim(config.UploadSubDir, "/") + "/"
|
||||
}
|
||||
getFilename := func(r *http.Request) string {
|
||||
return strings.TrimPrefix(r.URL.Path, subPath)
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Info().Str("method", r.Method).Str("url", r.URL.String()).Msg("")
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodHead:
|
||||
handleHead(w, r, config, client, getFilename(r))
|
||||
case http.MethodGet:
|
||||
handleGet(w, r, config, client, getFilename(r))
|
||||
case http.MethodOptions:
|
||||
w.Header().Set("Allow", ALLOWED_METHODS)
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", ALLOWED_METHODS)
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Max-Age", "7200")
|
||||
case http.MethodPut:
|
||||
handlePut(w, r, config, client, getFilename(r))
|
||||
default:
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig(filename string) (*Config, error) {
|
||||
config := &Config{S3TLS: true}
|
||||
if filename != "" {
|
||||
if _, err := toml.DecodeFile(filename, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Environment vars override config file.
|
||||
for envVar, configVar := range map[string]interface{}{
|
||||
"PROSODY_FILER_ADDRESS": &config.Address,
|
||||
"PROSODY_FILER_SECRET": &config.Secret,
|
||||
"PROSODY_FILER_UPLOAD_SUB_DIR": &config.UploadSubDir,
|
||||
"PROSODY_FILER_PROXY_MODE": &config.ProxyMode,
|
||||
"PROSODY_FILER_S3_ENDPOINT": &config.S3Endpoint,
|
||||
"AWS_ACCESS_KEY_ID": &config.S3AccessKey,
|
||||
"PROSODY_FILER_S3_ACCESS_KEY": &config.S3AccessKey,
|
||||
"AWS_SECRET_ACCESS_KEY": &config.S3SecretKey,
|
||||
"PROSODY_FILER_S3_SECRET_KEY": &config.S3SecretKey,
|
||||
"PROSODY_FILER_S3_BUCKET": &config.S3Bucket,
|
||||
"PROSODY_FILER_S3_REGION": &config.S3Region,
|
||||
"PROSODY_FILER_S3_TLS": &config.S3TLS,
|
||||
} {
|
||||
if v, ok := os.LookupEnv(envVar); ok {
|
||||
switch t := configVar.(type) {
|
||||
case *string:
|
||||
*t = v
|
||||
case *bool:
|
||||
*t = strings.ToLower(v) == "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for name, value := range map[string]string{
|
||||
"Address": config.Address,
|
||||
"Secret": config.Secret,
|
||||
"S3Endpoint": config.S3Endpoint,
|
||||
"S3AccessKey": config.S3AccessKey,
|
||||
"S3SecretKey": config.S3SecretKey,
|
||||
"S3Bucket": config.S3Bucket,
|
||||
} {
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("missing required config value %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func initS3Client(config *Config) (*minio.Client, error) {
|
||||
client, err := minio.New(config.S3Endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(config.S3AccessKey, config.S3SecretKey, ""),
|
||||
Region: config.S3Region,
|
||||
Secure: config.S3TLS,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
exists, err := client.BucketExists(context.Background(), config.S3Bucket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
// Some servers don't correctly report this.
|
||||
log.Warn().Msg("bucket does not seem to exist")
|
||||
}
|
||||
|
||||
return client, err
|
||||
}
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "", "path to config file")
|
||||
flag.Parse()
|
||||
|
||||
config, err := loadConfig(*configPath)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed to load config")
|
||||
}
|
||||
|
||||
client, err := initS3Client(config)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed to initialize s3 client")
|
||||
}
|
||||
|
||||
var pattern string
|
||||
if config.UploadSubDir == "" || config.UploadSubDir == "/" {
|
||||
pattern = "/"
|
||||
} else {
|
||||
pattern = "/" + strings.TrimPrefix(config.UploadSubDir, "/")
|
||||
}
|
||||
http.HandleFunc(pattern, newHandler(config, client))
|
||||
log.Info().Msgf("starting server on %s", config.Address)
|
||||
if err := http.ListenAndServe(config.Address, nil); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed to start server")
|
||||
}
|
||||
}
|
||||
347
cmd/prosody-filer-s3/main_test.go
Normal file
347
cmd/prosody-filer-s3/main_test.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func setupBucket(t *testing.T, config *Config, client *minio.Client) {
|
||||
err := client.MakeBucket(
|
||||
context.Background(),
|
||||
config.S3Bucket,
|
||||
minio.MakeBucketOptions{},
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func teardownBucket(t *testing.T, config *Config, client *minio.Client) {
|
||||
err := client.RemoveBucketWithOptions(
|
||||
context.Background(),
|
||||
config.S3Bucket,
|
||||
minio.RemoveBucketOptions{ForceDelete: true},
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func uploadFile(t *testing.T, config *Config, client *minio.Client) {
|
||||
f, err := os.Open("../../catmetal.jpg")
|
||||
assert.NoError(t, err)
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = client.PutObject(
|
||||
context.Background(),
|
||||
config.S3Bucket,
|
||||
"catmetal.jpg",
|
||||
f,
|
||||
info.Size(),
|
||||
minio.PutObjectOptions{
|
||||
ContentType: "image/jpeg",
|
||||
ContentDisposition: "inline",
|
||||
},
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func testConfig() *Config {
|
||||
return &Config{
|
||||
Secret: "mysecret",
|
||||
UploadSubDir: "/upload",
|
||||
S3Endpoint: "localhost:9000",
|
||||
S3AccessKey: "minioadmin",
|
||||
S3SecretKey: "minioadmin",
|
||||
S3TLS: false,
|
||||
S3Bucket: "xmpp",
|
||||
S3Region: "default",
|
||||
}
|
||||
}
|
||||
|
||||
const config = `address = "[::]:5050"
|
||||
secret = "mysecret"
|
||||
upload_sub_dir = "/upload"
|
||||
proxy_mode = true
|
||||
s3_endpoint = "s3.com"
|
||||
s3_access_key = "1234567890"
|
||||
s3_secret_key = "abcdefghijklmnopqrstuvwxyz"
|
||||
s3_tls = false
|
||||
s3_bucket = "mybucket"
|
||||
s3_region = "mordor"`
|
||||
|
||||
func TestConfigFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.toml")
|
||||
|
||||
err := os.WriteFile(configPath, []byte(config), 0o600)
|
||||
assert.NoError(t, err)
|
||||
|
||||
config, err := loadConfig(configPath)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, &Config{
|
||||
Address: "[::]:5050",
|
||||
Secret: "mysecret",
|
||||
UploadSubDir: "/upload",
|
||||
ProxyMode: true,
|
||||
S3Endpoint: "s3.com",
|
||||
S3AccessKey: "1234567890",
|
||||
S3SecretKey: "abcdefghijklmnopqrstuvwxyz",
|
||||
S3TLS: false,
|
||||
S3Bucket: "mybucket",
|
||||
S3Region: "mordor",
|
||||
}, config)
|
||||
}
|
||||
|
||||
func TestConfigEnv(t *testing.T) {
|
||||
t.Setenv("PROSODY_FILER_ADDRESS", "[::]:5050")
|
||||
t.Setenv("PROSODY_FILER_SECRET", "mysecret")
|
||||
t.Setenv("PROSODY_FILER_UPLOAD_SUB_DIR", "/upload")
|
||||
t.Setenv("PROSODY_FILER_PROXY_MODE", "true")
|
||||
t.Setenv("PROSODY_FILER_S3_ENDPOINT", "s3.com")
|
||||
t.Setenv("PROSODY_FILER_S3_ACCESS_KEY", "1234567890")
|
||||
t.Setenv("PROSODY_FILER_S3_SECRET_KEY", "abcdefghijklmnopqrstuvwxyz")
|
||||
t.Setenv("PROSODY_FILER_S3_BUCKET", "mybucket")
|
||||
t.Setenv("PROSODY_FILER_S3_REGION", "mordor")
|
||||
t.Setenv("PROSODY_FILER_S3_TLS", "false")
|
||||
|
||||
config, err := loadConfig("")
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, &Config{
|
||||
Address: "[::]:5050",
|
||||
Secret: "mysecret",
|
||||
UploadSubDir: "/upload",
|
||||
ProxyMode: true,
|
||||
S3Endpoint: "s3.com",
|
||||
S3AccessKey: "1234567890",
|
||||
S3SecretKey: "abcdefghijklmnopqrstuvwxyz",
|
||||
S3TLS: false,
|
||||
S3Bucket: "mybucket",
|
||||
S3Region: "mordor",
|
||||
}, config)
|
||||
}
|
||||
|
||||
func TestUploadValid(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
authVersion string
|
||||
hmac string
|
||||
}{
|
||||
{authVersion: "v", hmac: "dde65647d5e4726adf6ff4282cd3a0648df84436b5387039dd6aea28d04c4917"},
|
||||
{authVersion: "v2", hmac: "26a2b6e27b451bf695f769c8cbb23f8856e80a1fb5963a831f1c79a19873365d"},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("upload with auth %s", tc.authVersion), func(t *testing.T) {
|
||||
config := testConfig()
|
||||
client, err := initS3Client(config)
|
||||
assert.NoError(t, err)
|
||||
|
||||
setupBucket(t, config, client)
|
||||
defer teardownBucket(t, config, client)
|
||||
|
||||
f, err := os.ReadFile("../../catmetal.jpg")
|
||||
assert.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequest("PUT", "/upload/catmetal.jpg", bytes.NewBuffer(f))
|
||||
assert.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "image/jpeg")
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Set(tc.authVersion, tc.hmac)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(newHandler(config, client))
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusCreated, rr.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadValidNoPrefix(t *testing.T) {
|
||||
config := testConfig()
|
||||
config.UploadSubDir = ""
|
||||
client, err := initS3Client(config)
|
||||
assert.NoError(t, err)
|
||||
|
||||
setupBucket(t, config, client)
|
||||
defer teardownBucket(t, config, client)
|
||||
|
||||
f, err := os.ReadFile("../../catmetal.jpg")
|
||||
assert.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequest("PUT", "/catmetal.jpg", bytes.NewBuffer(f))
|
||||
assert.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "image/jpeg")
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Set("v2", "26a2b6e27b451bf695f769c8cbb23f8856e80a1fb5963a831f1c79a19873365d")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(newHandler(config, client))
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusCreated, rr.Code)
|
||||
}
|
||||
|
||||
func TestUploadMissingMAC(t *testing.T) {
|
||||
f, err := os.ReadFile("../../catmetal.jpg")
|
||||
assert.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequest("PUT", "/upload/catmetal.jpg", bytes.NewBuffer(f))
|
||||
assert.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(newHandler(testConfig(), nil))
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusForbidden, rr.Code)
|
||||
}
|
||||
|
||||
func TestUploadInvalidMAC(t *testing.T) {
|
||||
for _, version := range []string{"v", "v2"} {
|
||||
t.Run(version, func(t *testing.T) {
|
||||
f, err := os.ReadFile("../../catmetal.jpg")
|
||||
assert.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequest("PUT", "/upload/catmetal.jpg", bytes.NewBuffer(f))
|
||||
assert.NoError(t, err)
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Set(version, "abc")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(newHandler(testConfig(), nil))
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusForbidden, rr.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestUploadInvalidV2WithoutContentType(t *testing.T) {
|
||||
config := testConfig()
|
||||
client, err := initS3Client(config)
|
||||
assert.NoError(t, err)
|
||||
|
||||
setupBucket(t, config, client)
|
||||
defer teardownBucket(t, config, client)
|
||||
|
||||
f, err := os.ReadFile("../../catmetal.jpg")
|
||||
assert.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequest("PUT", "/upload/catmetal.jpg", bytes.NewBuffer(f))
|
||||
assert.NoError(t, err)
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Set("v2", "26a2b6e27b451bf695f769c8cbb23f8856e80a1fb5963a831f1c79a19873365d")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(newHandler(config, client))
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusForbidden, rr.Code)
|
||||
}
|
||||
|
||||
func TestUploadInvalidMethod(t *testing.T) {
|
||||
f, err := os.ReadFile("../../catmetal.jpg")
|
||||
assert.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequest("POST", "/upload/catmetal.jpg", bytes.NewBuffer(f))
|
||||
assert.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(newHandler(testConfig(), nil))
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, rr.Code)
|
||||
}
|
||||
|
||||
func TestUploadDuplicate(t *testing.T) {
|
||||
config := testConfig()
|
||||
client, err := initS3Client(config)
|
||||
assert.NoError(t, err)
|
||||
|
||||
setupBucket(t, config, client)
|
||||
defer teardownBucket(t, config, client)
|
||||
|
||||
uploadFile(t, config, client)
|
||||
|
||||
f, err := os.ReadFile("../../catmetal.jpg")
|
||||
assert.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequest("PUT", "/upload/catmetal.jpg", bytes.NewBuffer(f))
|
||||
assert.NoError(t, err)
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Add("v", "dde65647d5e4726adf6ff4282cd3a0648df84436b5387039dd6aea28d04c4917")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(newHandler(config, client))
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusConflict, rr.Code)
|
||||
}
|
||||
|
||||
func TestDownloadOK(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
method string
|
||||
proxy bool
|
||||
expectedStatus int
|
||||
}{
|
||||
{method: http.MethodHead, proxy: true, expectedStatus: http.StatusOK},
|
||||
{method: http.MethodHead, proxy: false, expectedStatus: http.StatusOK},
|
||||
{method: http.MethodGet, proxy: true, expectedStatus: http.StatusOK},
|
||||
{method: http.MethodGet, proxy: false, expectedStatus: http.StatusFound},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("method %s with proxy %t", tc.method, tc.proxy), func(t *testing.T) {
|
||||
config := testConfig()
|
||||
config.ProxyMode = tc.proxy
|
||||
client, err := initS3Client(config)
|
||||
assert.NoError(t, err)
|
||||
|
||||
setupBucket(t, config, client)
|
||||
defer teardownBucket(t, config, client)
|
||||
|
||||
uploadFile(t, config, client)
|
||||
|
||||
req, err := http.NewRequest(tc.method, "/upload/catmetal.jpg", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(newHandler(config, client))
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, tc.expectedStatus, rr.Code)
|
||||
assert.Equal(t, "image/jpeg", rr.Header().Get("Content-Type"))
|
||||
assert.Equal(t, "inline", rr.Header().Get("Content-Disposition"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadValidNoPrefix(t *testing.T) {
|
||||
config := testConfig()
|
||||
config.ProxyMode = true
|
||||
config.UploadSubDir = ""
|
||||
client, err := initS3Client(config)
|
||||
assert.NoError(t, err)
|
||||
|
||||
setupBucket(t, config, client)
|
||||
defer teardownBucket(t, config, client)
|
||||
|
||||
uploadFile(t, config, client)
|
||||
|
||||
req, err := http.NewRequest("GET", "/catmetal.jpg", nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(newHandler(config, client))
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
assert.Equal(t, "image/jpeg", rr.Header().Get("Content-Type"))
|
||||
assert.Equal(t, "inline", rr.Header().Get("Content-Disposition"))
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
###
|
||||
### Server configuration
|
||||
### (rename this file to "config.toml"!)
|
||||
|
||||
### IP address and port to listen to, e.g. "127.0.0.1:5050"
|
||||
listenport = "127.0.0.1:5050"
|
||||
|
||||
### Secret (must match the one in prosody.conf.lua!)
|
||||
secret = "mysecret"
|
||||
|
||||
### Where to store the uploaded files
|
||||
storeDir = "./uploads/"
|
||||
|
||||
### Subdirectory for HTTP upload / download requests (usually "upload/")
|
||||
uploadSubDir = "upload/"
|
||||
10
config.toml
Normal file
10
config.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
address = "[::]:5050"
|
||||
secret = "mysecret"
|
||||
upload_sub_dir = ""
|
||||
proxy_mode = true
|
||||
s3_endpoint = "localhost:9000"
|
||||
s3_access_key = "minioadmin"
|
||||
s3_secret_key = "minioadmin"
|
||||
s3_tls = false
|
||||
s3_bucket = "bucket"
|
||||
s3_region = ""
|
||||
10
docker-compose.yaml
Normal file
10
docker-compose.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
command:
|
||||
- server
|
||||
- /data
|
||||
ports:
|
||||
- "9000:9000"
|
||||
35
go.mod
Normal file
35
go.mod
Normal file
@@ -0,0 +1,35 @@
|
||||
module git.mug.lv/galen/prosody-filer-s3
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.25.4
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
github.com/minio/minio-go/v7 v7.0.97
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/minio/crc64nvme v1.1.0 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
golang.org/x/crypto v0.39.0 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
59
go.sum
Normal file
59
go.sum
Normal file
@@ -0,0 +1,59 @@
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q=
|
||||
github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
|
||||
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
|
||||
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
198
main.go
198
main.go
@@ -1,198 +0,0 @@
|
||||
/*
|
||||
* This module allows upload via mod_http_upload_external
|
||||
* Also see: https://modules.prosody.im/mod_http_upload_external.html
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
/*
|
||||
* Configuration of this server
|
||||
*/
|
||||
type Config struct {
|
||||
Listenport string
|
||||
Secret string
|
||||
Storedir string
|
||||
UploadSubDir string
|
||||
}
|
||||
|
||||
var conf Config
|
||||
|
||||
/*
|
||||
* Sets CORS headers
|
||||
*/
|
||||
func addCORSheaders(w http.ResponseWriter) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "OPTIONS, HEAD, GET, PUT")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Max-Age", "7200")
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Request handler
|
||||
* Is activated when a clients requests the file, file information or an upload
|
||||
*/
|
||||
func handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
log.Println("Incoming request:", r.Method, r.URL.String())
|
||||
|
||||
// Parse URL and args
|
||||
u, err := url.Parse(r.URL.String())
|
||||
if err != nil {
|
||||
log.Println("Failed to parse URL:", err)
|
||||
}
|
||||
|
||||
a, err := url.ParseQuery(u.RawQuery)
|
||||
if err != nil {
|
||||
log.Println("Failed to parse URL query params:", err)
|
||||
}
|
||||
|
||||
fileStorePath := strings.TrimPrefix(u.Path, "/" + conf.UploadSubDir)
|
||||
|
||||
// Add CORS headers
|
||||
addCORSheaders(w)
|
||||
|
||||
if r.Method == "PUT" {
|
||||
// Check if MAC is attached to URL
|
||||
if a["v"] == nil {
|
||||
log.Println("Error: No HMAC attached to URL.")
|
||||
http.Error(w, "409 Conflict", 409)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("MAC sent: ", a["v"][0])
|
||||
|
||||
/*
|
||||
* Check if the request is valid
|
||||
*/
|
||||
mac := hmac.New(sha256.New, []byte(conf.Secret))
|
||||
log.Println("fileStorePath:", fileStorePath)
|
||||
log.Println("ContentLength:", strconv.FormatInt(r.ContentLength, 10))
|
||||
mac.Write([]byte(fileStorePath + " " + strconv.FormatInt(r.ContentLength, 10)))
|
||||
macString := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
/*
|
||||
* Check whether calculated (expected) MAC is the MAC that client send in "v" URL parameter
|
||||
*/
|
||||
if hmac.Equal([]byte(macString), []byte(a["v"][0])) {
|
||||
// Make sure the path exists
|
||||
os.MkdirAll(filepath.Dir(conf.Storedir+fileStorePath), os.ModePerm)
|
||||
|
||||
file, err := os.OpenFile(conf.Storedir+fileStorePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0755)
|
||||
defer file.Close()
|
||||
if err != nil {
|
||||
log.Println("Creating new file failed:", err)
|
||||
http.Error(w, "409 Conflict", 409)
|
||||
return
|
||||
}
|
||||
|
||||
n, err := io.Copy(file, r.Body)
|
||||
if err != nil {
|
||||
log.Println("Writing to new file failed:", err)
|
||||
http.Error(w, "500 Internal Server Error", 500)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Successfully written", n, "bytes to file", fileStorePath)
|
||||
} else {
|
||||
log.Println("Invalid MAC.")
|
||||
http.Error(w, "403 Forbidden", 403)
|
||||
return
|
||||
}
|
||||
} else if r.Method == "HEAD" {
|
||||
fileinfo, err := os.Stat(conf.Storedir + fileStorePath)
|
||||
if err != nil {
|
||||
log.Println("Getting file information failed:", err)
|
||||
http.Error(w, "404 Not Found", 404)
|
||||
return
|
||||
}
|
||||
|
||||
/*
|
||||
* Find out the content type to sent correct header. There is a Go function for retrieving the
|
||||
* MIME content type, but this does not work with encrypted files (=> OMEMO). Therefore we're just
|
||||
* relying on file extensions.
|
||||
*/
|
||||
contentType := mime.TypeByExtension(filepath.Ext(fileStorePath))
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(fileinfo.Size(), 10))
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
} else if r.Method == "GET" {
|
||||
contentType := mime.TypeByExtension(filepath.Ext(fileStorePath))
|
||||
if fileStorePath == "" {
|
||||
http.Error(w, "403 Forbidden", 403)
|
||||
return
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
http.ServeFile(w, r, conf.Storedir+fileStorePath)
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
} else {
|
||||
log.Println("Invalid method", r.Method, "for access to ", conf.UploadSubDir)
|
||||
http.Error(w, "405 Method Not Allowed", 405)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func readConfig(configfilename string, conf *Config) error {
|
||||
log.Println("Reading configuration ...")
|
||||
|
||||
configdata, err := ioutil.ReadFile(configfilename)
|
||||
if err != nil {
|
||||
log.Fatal("Configuration file config.toml cannot be read:", err, "...Exiting.")
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := toml.Decode(string(configdata), conf); err != nil {
|
||||
log.Fatal("Config file config.toml is invalid:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/*
|
||||
* Main function
|
||||
*/
|
||||
func main() {
|
||||
/*
|
||||
* Read startup arguments
|
||||
*/
|
||||
var argConfigFile = flag.String("config", "./config.toml", "Path to configuration file \"config.toml\".")
|
||||
flag.Parse()
|
||||
|
||||
/*
|
||||
* Read config file
|
||||
*/
|
||||
err := readConfig(*argConfigFile, &conf)
|
||||
if err != nil {
|
||||
log.Println("There was an error while reading the configuration file:", err)
|
||||
}
|
||||
|
||||
/*
|
||||
* Start HTTP server
|
||||
*/
|
||||
log.Println("Starting up XMPP HTTP upload server ...")
|
||||
http.HandleFunc("/"+conf.UploadSubDir, handleRequest)
|
||||
log.Printf("Server started on port %s. Waiting for requests.\n", conf.Listenport)
|
||||
http.ListenAndServe(conf.Listenport, nil)
|
||||
}
|
||||
265
main_test.go
265
main_test.go
@@ -1,265 +0,0 @@
|
||||
package main
|
||||
|
||||
/*
|
||||
* Manual testing with CURL
|
||||
* Send with:
|
||||
* curl -X PUT "http://localhost:5050/upload/thomas/abc/catmetal.jpg?v=e17531b1e88bc9a5cbf816eca8a82fc09969c9245250f3e1b2e473bb564e4be0" --data-binary '@catmetal.jpg'
|
||||
* HMAC: e17531b1e88bc9a5cbf816eca8a82fc09969c9245250f3e1b2e473bb564e4be0
|
||||
*/
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mockUpload() {
|
||||
os.MkdirAll(filepath.Dir(conf.Storedir+"thomas/abc/"), os.ModePerm)
|
||||
from, err := os.Open("./catmetal.jpg")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer from.Close()
|
||||
|
||||
to, err := os.OpenFile(conf.Storedir+"thomas/abc/catmetal.jpg", os.O_RDWR|os.O_CREATE, 0660)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer to.Close()
|
||||
|
||||
_, err = io.Copy(to, from)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanup() {
|
||||
// Clean up
|
||||
if _, err := os.Stat(conf.Storedir); err == nil {
|
||||
// Delete existing catmetal picture
|
||||
err := os.RemoveAll(conf.Storedir)
|
||||
if err != nil {
|
||||
log.Println("Error while cleaning up:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfig(t *testing.T) {
|
||||
// Set config
|
||||
err := readConfig("config.toml", &conf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadValid(t *testing.T) {
|
||||
// Set config
|
||||
readConfig("config.toml", &conf)
|
||||
|
||||
// Read catmetal file
|
||||
catmetalfile, err := ioutil.ReadFile("catmetal.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("PUT", "/upload/thomas/abc/catmetal.jpg", bytes.NewBuffer(catmetalfile))
|
||||
q := req.URL.Query()
|
||||
q.Add("v", "e17531b1e88bc9a5cbf816eca8a82fc09969c9245250f3e1b2e473bb564e4be0")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(handleRequest)
|
||||
|
||||
// Send request and record response
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
// Check status code
|
||||
if status := rr.Code; status != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got %v want %v. HTTP body: %s", status, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
|
||||
// clean up
|
||||
cleanup()
|
||||
}
|
||||
|
||||
func TestUploadMissingMAC(t *testing.T) {
|
||||
// Set config
|
||||
readConfig("config.toml", &conf)
|
||||
|
||||
// Read catmetal file
|
||||
catmetalfile, err := ioutil.ReadFile("catmetal.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("PUT", "/upload/thomas/abc/catmetal.jpg", bytes.NewBuffer(catmetalfile))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(handleRequest)
|
||||
|
||||
// Send request and record response
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
// Check status code
|
||||
if status := rr.Code; status != http.StatusConflict {
|
||||
t.Errorf("handler returned wrong status code: got %v want %v. HTTP body: %s", status, http.StatusConflict, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadInvalidMAC(t *testing.T) {
|
||||
// Set config
|
||||
readConfig("config.toml", &conf)
|
||||
|
||||
// Read catmetal file
|
||||
catmetalfile, err := ioutil.ReadFile("catmetal.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("PUT", "/upload/thomas/abc/catmetal.jpg", bytes.NewBuffer(catmetalfile))
|
||||
q := req.URL.Query()
|
||||
q.Add("v", "abc")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(handleRequest)
|
||||
|
||||
// Send request and record response
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
// Check status code
|
||||
if status := rr.Code; status != http.StatusForbidden {
|
||||
t.Errorf("handler returned wrong status code: got %v want %v. HTTP body: %s", status, http.StatusForbidden, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadInvalidMethod(t *testing.T) {
|
||||
// Set config
|
||||
readConfig("config.toml", &conf)
|
||||
|
||||
// Read catmetal file
|
||||
catmetalfile, err := ioutil.ReadFile("catmetal.jpg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("POST", "/upload/thomas/abc/catmetal.jpg", bytes.NewBuffer(catmetalfile))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(handleRequest)
|
||||
|
||||
// Send request and record response
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
// Check status code
|
||||
if status := rr.Code; status != http.StatusMethodNotAllowed {
|
||||
t.Errorf("handler returned wrong status code: got %v want %v. HTTP body: %s", status, http.StatusMethodNotAllowed, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadHead(t *testing.T) {
|
||||
// Set config
|
||||
readConfig("config.toml", &conf)
|
||||
|
||||
// Mock upload
|
||||
mockUpload()
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("HEAD", "/upload/thomas/abc/catmetal.jpg", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(handleRequest)
|
||||
|
||||
// Send request and record response
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
// Check status code
|
||||
if status := rr.Code; status != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got %v want %v. HTTP body: %s", status, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
|
||||
// cleanup
|
||||
cleanup()
|
||||
}
|
||||
|
||||
func TestDownloadGet(t *testing.T) {
|
||||
// Set config
|
||||
readConfig("config.toml", &conf)
|
||||
|
||||
// moch upload
|
||||
mockUpload()
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("GET", "/upload/thomas/abc/catmetal.jpg", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(handleRequest)
|
||||
|
||||
// Send request and record response
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
// Check status code
|
||||
if status := rr.Code; status != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got %v want %v. HTTP body: %s", status, http.StatusOK, rr.Body.String())
|
||||
}
|
||||
|
||||
// cleanup
|
||||
cleanup()
|
||||
}
|
||||
|
||||
func TestEmptyGet(t *testing.T) {
|
||||
// Set config
|
||||
readConfig("config.toml", &conf)
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("GET", "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(handleRequest)
|
||||
|
||||
// Send request and record response
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
// Check status code
|
||||
if status := rr.Code; status != http.StatusForbidden {
|
||||
t.Errorf("handler returned wrong status code: got %v want %v. HTTP body: %s", status, http.StatusForbidden, rr.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user