31 Commits

Author SHA1 Message Date
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
16 changed files with 958 additions and 508 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
.git/
.gitignore
.dockerignore
Dockerfile
LICENSE
Makefile
*.md
*.jpg
docker-compose.yaml
prosody-filer-s3
!cmd/prosody-filer-s3

View File

@@ -0,0 +1,77 @@
---
name: Main workflow
on:
push:
branches:
- '**'
tags-ignore:
- '**'
jobs:
test:
name: Run unit tests
runs-on: ubuntu-22.04
timeout-minutes: 5
container:
image: alpine:edge
steps:
- name: Install dependencies
run: |
apk add -U \
curl \
git \
go \
minio \
nodejs
- name: Checkout repository
uses: actions/checkout@v3
- name: Start minio
run: |
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 ./...
build:
name: Build docker image
needs: test
runs-on: ubuntu-22.04
timeout-minutes: 5
container:
image: alpine:edge
steps:
- name: Install dependencies
run: |
apk add -U \
docker-cli \
docker-cli-buildx \
git \
nodejs
- name: Checkout repository
uses: actions/checkout@v3
- name: Login to registry
uses: actions/docker-login@v2
with:
registry: git.mug.lv
username: galen
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Setup buildx
uses: actions/docker-setup-buildx@v2
- name: Build image
uses: actions/docker-build-push@v4
# TODO: remove once fixed in upstream
env:
ACTIONS_RUNTIME_TOKEN: ""
with:
push: ${{ gitea.ref_name == 'main' }}
tags: git.mug.lv/galen/prosody-filer-s3:${{ gitea.ref_name == 'main' && 'latest' || gitea.ref_name }}

47
.gitea/workflows/tag.yaml Normal file
View File

@@ -0,0 +1,47 @@
---
name: Main workflow
on:
push:
branches-ignore:
- '**'
tags:
- '**'
jobs:
build:
name: Build docker image
runs-on: ubuntu-22.04
timeout-minutes: 5
container:
image: alpine:edge
steps:
- name: Install dependencies
run: |
apk add -U \
docker-cli \
docker-cli-buildx \
git \
nodejs
- name: Checkout repository
uses: actions/checkout@v3
- name: Login to registry
uses: actions/docker-login@v2
with:
registry: git.mug.lv
username: galen
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Setup buildx
uses: actions/docker-setup-buildx@v2
- name: Build image
uses: actions/docker-build-push@v4
# TODO: remove once fixed in upstream
env:
ACTIONS_RUNTIME_TOKEN: ""
with:
push: true
tags: git.mug.lv/galen/prosody-filer-s3:${{ gitea.ref_name }}

18
.gitignore vendored
View File

@@ -1,16 +1,2 @@
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
prosody-filer-s3
!cmd/prosody-filer-s3

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM golang:1.20-alpine@sha256:e7cc33118f807c67d9f2dfc811cc2cc8b79b3687d0b4ac891dd59bb2a5e4a8d3 AS build
WORKDIR /build
COPY . /build
RUN go build ./...
FROM alpine:3.18@sha256:82d1e9d7ed48a7523bdebc18cf6290bdb97b82302a8a9c27d4fe885949ea94d1
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 --from=build /build/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 ./...
.PHONY: clean
clean:
rm -f prosody-filer-s3
.PHONY: test
test:
docker compose up -d
go test ./... -count 1

View File

@@ -1,3 +1,42 @@
# Prosody Filer S3 fork (fork)
Originally forked from https://github.com/Wilm0r/prosody-filer-s3.
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 +120,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 +348,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,334 @@
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 {
subDir := config.UploadSubDir
if subDir != "" {
subDir = "/" + strings.Trim(subDir, "/") + "/"
}
getFilename := func(r *http.Request) string {
return strings.TrimPrefix(r.URL.Path, subDir)
}
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,
"UploadSubDir": config.UploadSubDir,
"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")
}
http.HandleFunc("/"+config.UploadSubDir, 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,298 @@
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 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"))
})
}
}

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
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"

34
go.mod Normal file
View File

@@ -0,0 +1,34 @@
module git.mug.lv/galen/prosody-filer-s3
go 1.20
require (
github.com/BurntSushi/toml v1.3.2
github.com/minio/minio-go/v7 v7.0.56
github.com/rs/zerolog v1.29.1
github.com/stretchr/testify v1.8.4
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.16.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/sirupsen/logrus v1.9.2 // indirect
golang.org/x/crypto v0.10.0 // indirect
golang.org/x/net v0.11.0 // indirect
golang.org/x/sys v0.9.0 // indirect
golang.org/x/text v0.10.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

73
go.sum Normal file
View File

@@ -0,0 +1,73 @@
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI=
github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
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.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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.56 h1:pkZplIEHu8vinjkmhsexcXpWth2tjVLphrTZx6fBVZY=
github.com/minio/minio-go/v7 v7.0.56/go.mod h1:NUDy4A4oXPq1l2yK6LTSvCEzAMeIcoz9lcj5dbzSrRE=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
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.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc=
github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU=
github.com/sirupsen/logrus v1.9.2 h1:oxx1eChJGI6Uks2ZC4W1zpLlVgqB8ner4EuQwV4Ik1Y=
github.com/sirupsen/logrus v1.9.2/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU=
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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())
}
}