89 Commits
v1.0.2 ... main

Author SHA1 Message Date
db368bbfd4 Update actions/checkout action to v6
All checks were successful
Main workflow / Run unit tests (push) Successful in 2m27s
Main workflow / build (push) Successful in 47s
2025-12-01 00:02:03 +00:00
79c0529823 Update actions/setup-go action to v6
All checks were successful
Main workflow / Run unit tests (push) Successful in 32s
Main workflow / build (push) Successful in 41s
2025-11-29 12:31:49 +00:00
e1b2d99b23 Update all non-major dependencies
All checks were successful
Main workflow / Run unit tests (push) Successful in 2m6s
Main workflow / build (push) Successful in 1m16s
2025-11-05 20:01:38 +00:00
6175a9857c Update all non-major dependencies
All checks were successful
Main workflow / Run unit tests (push) Successful in 3m23s
Main workflow / build (push) Successful in 42s
2025-08-24 17:01:17 +00:00
7441ce0a83 Update all non-major dependencies
All checks were successful
Main workflow / Run unit tests (push) Successful in 43s
Main workflow / build (push) Successful in 42s
2025-05-01 00:01:57 +00:00
1dc5a2b56b Update dependency ubuntu to v24
All checks were successful
Main workflow / Run unit tests (push) Successful in 3m26s
Main workflow / build (push) Successful in 42s
2025-04-01 00:01:08 +00:00
2eaa0ada05 Update all non-major dependencies
All checks were successful
Main workflow / Run unit tests (push) Successful in 41s
Main workflow / build (push) Successful in 50s
2025-03-31 14:18:47 +00:00
1e67af6bba Use metadata-action
All checks were successful
Main workflow / Run unit tests (push) Successful in 30s
Main workflow / build (push) Successful in 31s
2025-03-31 16:17:05 +02:00
db8529e560 Update renovate config
All checks were successful
Main workflow / Run unit tests (push) Successful in 3m5s
Main workflow / build (push) Successful in 25s
2025-03-31 15:18:28 +02:00
114edffa0a Improve CI
All checks were successful
Main workflow / Run unit tests (push) Successful in 26s
Main workflow / build (push) Successful in 24s
2025-03-31 15:14:34 +02:00
5732fb7aa0 Update renovate settings
All checks were successful
Main workflow / Run unit tests (push) Successful in 1m55s
Main workflow / Build docker image (push) Successful in 2m6s
2024-12-19 15:47:49 +01:00
45c80c1686 Update workflows
All checks were successful
Main workflow / Run unit tests (push) Successful in 1m52s
Main workflow / Build docker image (push) Successful in 1m54s
2024-10-13 11:43:17 +02:00
40e13b716c Update Dockerfile
Some checks failed
Main workflow / Run unit tests (push) Failing after 52s
Main workflow / Build docker image (push) Has been skipped
2024-10-13 11:21:33 +02:00
3079f15783 Update deps 2024-10-13 11:21:28 +02:00
fd26ac21af Go mod tidy 2024-10-03 15:24:23 +02:00
3a90641e40 Update golang:1.22-alpine Docker digest to 89c315d
All checks were successful
Main workflow / Run unit tests (push) Successful in 47s
Main workflow / Build docker image (push) Successful in 1m15s
2024-03-25 10:40:23 +00:00
757e38bc32 Update golang Docker tag to v1.22
All checks were successful
Main workflow / Run unit tests (push) Successful in 46s
Main workflow / Build docker image (push) Successful in 1m8s
2024-03-05 20:40:23 +00:00
3d821b7844 Update module github.com/minio/minio-go/v7 to v7.0.68
All checks were successful
Main workflow / Run unit tests (push) Successful in 43s
Main workflow / Build docker image (push) Successful in 1m10s
2024-03-03 00:40:04 +00:00
31bf2fb3f6 Update module github.com/stretchr/testify to v1.9.0
All checks were successful
Main workflow / Run unit tests (push) Successful in 42s
Main workflow / Build docker image (push) Successful in 1m5s
2024-03-01 12:40:30 +00:00
008cceed07 Update module github.com/minio/minio-go/v7 to v7.0.67
All checks were successful
Main workflow / Run unit tests (push) Successful in 52s
Main workflow / Build docker image (push) Successful in 1m9s
2024-02-11 00:40:10 +00:00
8a9eb0f5ea Update module github.com/rs/zerolog to v1.32.0
All checks were successful
Main workflow / Run unit tests (push) Successful in 44s
Main workflow / Build docker image (push) Successful in 1m7s
2024-02-04 15:40:07 +00:00
be7c5a3164 Update alpine:3.19 Docker digest to c5b1261
All checks were successful
Main workflow / Run unit tests (push) Successful in 48s
Main workflow / Build docker image (push) Successful in 2m21s
2024-01-28 11:11:01 +00:00
e15ae6070f Update golang:1.21-alpine Docker digest to bb943c4
Some checks failed
Main workflow / Build docker image (push) Blocked by required conditions
Main workflow / Run unit tests (push) Has been cancelled
2024-01-27 02:22:46 +00:00
d5330dcfbc Update golang:1.21-alpine Docker digest to 0f7e0b1
All checks were successful
Main workflow / Run unit tests (push) Successful in 45s
Main workflow / Build docker image (push) Successful in 1m13s
2024-01-23 20:22:43 +00:00
fad4b5fd34 Update golang:1.21-alpine Docker digest to 2523a6f
All checks were successful
Main workflow / Run unit tests (push) Successful in 50s
Main workflow / Build docker image (push) Successful in 1m8s
2024-01-09 23:22:35 +00:00
fc0e2d8fdd Update module github.com/minio/minio-go/v7 to v7.0.66
All checks were successful
Main workflow / Run unit tests (push) Successful in 44s
Main workflow / Build docker image (push) Successful in 1m10s
2023-12-14 09:23:13 +00:00
6794b64658 Update alpine Docker tag to v3.19
All checks were successful
Main workflow / Run unit tests (push) Successful in 46s
Main workflow / Build docker image (push) Successful in 1m9s
2023-12-09 09:01:53 +00:00
c6358b3b6c Update golang:1.21-alpine Docker digest to 55f7162
All checks were successful
Main workflow / Run unit tests (push) Successful in 48s
Main workflow / Build docker image (push) Successful in 1m8s
2023-12-09 06:23:21 +00:00
5f8b6c5ba4 Update module github.com/minio/minio-go/v7 to v7.0.65
All checks were successful
Main workflow / Run unit tests (push) Successful in 44s
Main workflow / Build docker image (push) Successful in 1m9s
2023-12-02 17:21:26 +00:00
3dec173a40 Update alpine:3.18 Docker digest to 34871e7
All checks were successful
Main workflow / Run unit tests (push) Successful in 44s
Main workflow / Build docker image (push) Successful in 1m12s
2023-12-01 07:53:33 +00:00
9d1eb48368 Update golang:1.21-alpine Docker digest to 4e09ca4
Some checks failed
Main workflow / Build docker image (push) Blocked by required conditions
Main workflow / Run unit tests (push) Has been cancelled
2023-12-01 04:51:08 +00:00
321c3515f3 Update module github.com/minio/minio-go/v7 to v7.0.64
All checks were successful
Main workflow / Run unit tests (push) Successful in 51s
Main workflow / Build docker image (push) Successful in 1m13s
2023-11-20 23:51:36 +00:00
7870d7b3f2 Update golang:1.21-alpine Docker digest to f475434
All checks were successful
Main workflow / Run unit tests (push) Successful in 40s
Main workflow / Build docker image (push) Successful in 1m7s
2023-11-07 21:02:58 +00:00
c18f91270e Update golang:1.21-alpine Docker digest to 4f95f6b
All checks were successful
Main workflow / Run unit tests (push) Successful in 39s
Main workflow / Build docker image (push) Successful in 1m9s
2023-11-01 19:03:24 +00:00
19efe356ba Update golang:1.21-alpine Docker digest to 27c76dc
All checks were successful
Main workflow / Run unit tests (push) Successful in 36s
Main workflow / Build docker image (push) Successful in 1m10s
2023-10-10 19:55:35 +00:00
dc4bd52fac Update golang:1.21-alpine Docker digest to ae2875f
All checks were successful
Main workflow / Run unit tests (push) Successful in 35s
Main workflow / Build docker image (push) Successful in 1m6s
2023-10-05 21:54:26 +00:00
bce638e2bc Update alpine:3.18 Docker digest to eece025
All checks were successful
Main workflow / Run unit tests (push) Successful in 35s
Main workflow / Build docker image (push) Successful in 1m6s
2023-09-29 06:54:32 +00:00
6146fd6b1b Update golang:1.21-alpine Docker digest to 3380a7e
All checks were successful
Main workflow / Run unit tests (push) Successful in 35s
Main workflow / Build docker image (push) Successful in 1m5s
2023-09-28 23:54:27 +00:00
d206f845b1 Update golang:1.21-alpine Docker digest to 6799466
All checks were successful
Main workflow / Run unit tests (push) Successful in 35s
Main workflow / Build docker image (push) Successful in 1m9s
2023-09-27 02:54:29 +00:00
17e4a4e1f1 Update module github.com/minio/minio-go/v7 to v7.0.63
All checks were successful
Main workflow / Run unit tests (push) Successful in 35s
Main workflow / Build docker image (push) Successful in 1m11s
2023-09-25 11:54:26 +00:00
f8dfc345f0 Update module github.com/rs/zerolog to v1.31.0
All checks were successful
Main workflow / Run unit tests (push) Successful in 35s
Main workflow / Build docker image (push) Successful in 1m7s
2023-09-25 10:54:41 +00:00
87e5dbbc81 Update golang:1.21-alpine Docker digest to 0c860c7
All checks were successful
Main workflow / Run unit tests (push) Successful in 38s
Main workflow / Build docker image (push) Successful in 1m14s
2023-09-06 19:13:48 +00:00
605b81b307 Update golang Docker tag to v1.21
All checks were successful
Main workflow / Run unit tests (push) Successful in 36s
Main workflow / Build docker image (push) Successful in 1m9s
2023-08-09 07:17:07 +00:00
dc6258e386 Update alpine:3.18 Docker digest to 7144f7b
All checks were successful
Main workflow / Run unit tests (push) Successful in 36s
Main workflow / Build docker image (push) Successful in 1m12s
2023-08-07 22:17:08 +00:00
79651834b4 Update golang:1.20-alpine Docker digest to 29a2b23
All checks were successful
Main workflow / Run unit tests (push) Successful in 35s
Main workflow / Build docker image (push) Successful in 1m9s
2023-08-01 22:17:03 +00:00
a81fce0eb9 Update module github.com/rs/zerolog to v1.30.0
All checks were successful
Main workflow / Run unit tests (push) Successful in 37s
Main workflow / Build docker image (push) Successful in 1m17s
2023-07-30 00:17:34 +00:00
85ca8771c3 Update module github.com/minio/minio-go/v7 to v7.0.61
All checks were successful
Main workflow / Run unit tests (push) Successful in 35s
Main workflow / Build docker image (push) Successful in 1m12s
2023-07-17 17:06:14 +00:00
7e2f59de50 Fix pipeline
All checks were successful
Main workflow / Run unit tests (push) Successful in 36s
Main workflow / Build docker image (push) Successful in 1m11s
2023-07-14 13:50:31 +02:00
ad184314d7 Update golang:1.20-alpine Docker digest to 6f592e0
Some checks failed
Main workflow / Run unit tests (push) Successful in 43s
Main workflow / Build docker image (push) Failing after 51s
2023-07-13 21:05:59 +00:00
33848cbbd2 Update golang:1.20-alpine Docker digest to 5bb5c69
All checks were successful
Main workflow / Run unit tests (push) Successful in 37s
Main workflow / Build docker image (push) Successful in 57s
2023-07-12 07:07:47 +00:00
4dfa67af5d Update module github.com/minio/minio-go/v7 to v7.0.60
All checks were successful
Main workflow / Run unit tests (push) Successful in 35s
Main workflow / Build docker image (push) Successful in 59s
2023-07-11 21:06:06 +00:00
6d744ada2a Remove old workaround
All checks were successful
Main workflow / Run unit tests (push) Successful in 37s
Main workflow / Build docker image (push) Successful in 59s
2023-07-02 11:33:07 +02:00
e88fbf2aa9 Fix CI tag generation 2023-07-02 11:32:56 +02:00
e0c1df7043 Update module github.com/minio/minio-go/v7 to v7.0.59
Some checks failed
Main workflow / Run unit tests (push) Successful in 38s
Main workflow / Build docker image (push) Failing after 21s
2023-07-02 09:20:10 +00:00
ce9ce97837 Update README
All checks were successful
Main workflow / Run unit tests (push) Successful in 38s
Main workflow / Build docker image (push) Successful in 58s
2023-07-02 11:12:48 +02:00
ac0313cee3 Add example config.toml 2023-07-02 11:08:19 +02:00
1ada753fcb Add renovate config 2023-07-02 11:08:03 +02:00
3ddfebcd24 Allow empty upload prefix
All checks were successful
Main workflow / Run unit tests (push) Successful in 38s
Main workflow / Build docker image (push) Successful in 56s
2023-06-17 13:54:59 +02:00
5db166c2b9 Add CI
All checks were successful
Main workflow / Run unit tests (push) Successful in 37s
Main workflow / Build docker image (push) Successful in 58s
2023-06-17 12:02:19 +02:00
23df3a1aa4 Support v2 auth 2023-06-17 11:29:59 +02:00
9865b14f2c Use testify asserts for tests 2023-06-17 11:08:36 +02:00
f9b561e6e7 Ensure content-type/disposition is correct in test 2023-06-17 11:00:21 +02:00
99bbae5b94 Use StatObject instead of GetObject for HEAD 2023-06-17 10:57:25 +02:00
f0d33eb150 Return status conflict if filename already exists 2023-06-17 10:56:06 +02:00
7fa8dae48f Refactor config env loading & add validation 2023-06-16 22:47:13 +02:00
a79b0235e7 Cleanup Dockerfile 2023-06-16 22:47:13 +02:00
bac7eaeffe Restructure command 2023-06-16 22:47:13 +02:00
09ac2e3e36 Replace build script with Makefile 2023-06-16 22:47:13 +02:00
c9f9891489 Add docker-compose for minio 2023-06-16 22:47:13 +02:00
f98aa04bba Remove global config & s3 client 2023-06-16 22:47:13 +02:00
6a9d1c8eaf Refactor S3 initialization 2023-06-16 22:47:13 +02:00
a14562ae50 Support environment var configuration
BREAKING CHANGE: rename ListenPort option to Address
2023-06-16 22:47:13 +02:00
0ec0a19b9d Cleanup HTTP handlers 2023-06-16 22:47:13 +02:00
3e891b87f5 Switch logger to zerolog 2023-06-16 22:47:13 +02:00
0714176d6e Use http constants instead of numbers 2023-06-16 22:47:13 +02:00
6eb1ee50ac Make S3 config naming consistent 2023-06-16 22:47:11 +02:00
ccaccd27d9 Explicitly set TOML keys 2023-06-16 15:26:45 +02:00
61e4038bd4 Return file size on HEAD request 2023-06-16 15:20:02 +02:00
28dcdc72ab Add go.mod, update deps & support S3 region 2023-06-14 23:02:27 +02:00
e6e87a16ac Add attribution 2023-06-14 23:02:27 +02:00
Wilmer van der Gaast
bbbdcd650c First Dockerfile experiment. 2020-11-10 08:53:51 +00:00
Wilmer van der Gaast
9c4d7df1fc Doc tweaks, and weakened bucket exists check because Scaleway is weird. 2020-11-09 22:57:20 +00:00
Wilmer van der Gaast
ff4065a5d3 Doc + test update. 2020-11-03 08:03:58 +00:00
Wilmer van der Gaast
c5bc33f50a S3 client code. 2020-10-31 18:28:18 +00:00
Thomas Leister
90c525b324 Merge pull request #20 from kousu/patch-1
Protect against shell-injection attacks
2020-08-04 20:21:33 +02:00
Nick
cabca4963c Protect against shell-injection attacks
By using `-print0`, filenames to purge are delimited by nuls instead of newlines, which can't be found in filenames on unix. Previously, someone who uploaded a file could inject an *extra set* of files to try to erase. For example, by uploading a file to:

"/path/to/dir/file1.png%09/var/log/messages%09/etc/passwd%09/home/user/something.png"

I can't take credit for this. This is from @horazont in https://github.com/horazont/xmpp-http-upload/issues/11#issuecomment-657131261
2020-07-11 17:41:33 -04:00
Thomas Leister
a4cd80b34d Documentation: Adds "mindepth" parameter to find command
Adds "mindepth 1" parameter to make sure the /uploads/ itself it never deleted.
This should not happen as long as any upload is made during storage period,
but we want to be sure :-)
2020-06-04 14:01:57 +02:00
Thomas Leister
448c35c0ef Merge pull request #19 from Lykos153/develop
Return status code 200 on OPTIONS requests
2020-05-31 16:50:01 +02:00
Silvio Ankermann
c56646025a Return status code 200 on OPTIONS requests
Some clients (eg. converse.js) send an OPTIONS request prior to PUT and abort if it fails
2020-05-28 01:20:04 +02:00
19 changed files with 1008 additions and 508 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
*
!bin/

60
.github/workflows/build.yaml vendored Normal file
View 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
View 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
View 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
View File

@@ -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
View 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
View 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
View 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

View File

@@ -1,3 +1,48 @@
# 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.
@@ -81,7 +126,7 @@ Copy
to ```/home/prosody-filer/```. Rename the configuration to ```config.toml```.
Make sure the `prosody-filer` binary is executable:
Make sure the `prosody-filer` binary is executable:
```
chmod u+x prosody-filer
@@ -309,7 +354,7 @@ server {
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/ -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.

View File

@@ -1,10 +0,0 @@
#!/bin/bash
### get VERSIONSTRING
VERSIONSTRING="$(git describe --tags --exact-match || git rev-parse --short HEAD)"
echo "Building version ${VERSIONSTRING} of Prosody-Filer ..."
### Compile and link statically
CGO_ENABLED=0 GOOS=linux go build -a -ldflags "-extldflags '-static' -w -s -X main.versionString=${VERSIONSTRING}" prosody-filer.go

View 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")
}
}

View 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"))
}

View File

@@ -1,15 +0,0 @@
###
### Server configuration
### (rename this file to "config.toml"!)
### IP address and port to listen to, e.g. "[::]:5050" to listen to ipv6 and ipv4 addresses
listenport = "[::]: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
View 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
View File

@@ -0,0 +1,10 @@
version: "3"
services:
minio:
image: minio/minio:latest
command:
- server
- /data
ports:
- "9000:9000"

35
go.mod Normal file
View 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
View 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=

View File

@@ -1,200 +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
var versionString string = "0.0.0"
/*
* 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)
w.WriteHeader(http.StatusCreated)
} 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 f, err := os.Stat(conf.Storedir + fileStorePath); err != nil || f.IsDir() {
log.Println("Directory listing forbidden!")
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 Prosody-Filer", versionString, "...")
http.HandleFunc("/"+conf.UploadSubDir, handleRequest)
log.Printf("Server started on port %s. Waiting for requests.\n", conf.Listenport)
http.ListenAndServe(conf.Listenport, nil)
}

View File

@@ -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())
}
}