From d38f198b026ac0ac950b13eb1e861f9378e74def Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Sat, 2 Jan 2021 23:59:00 -0700
Subject: [PATCH] Add antispam plugin support + OCR example

---
 CHANGELOG.md                                  |   5 +-
 Dockerfile                                    |   2 +
 cmd/media_repo/reloads.go                     |  17 ++
 cmd/plugin_antispam_ocr/main.go               | 186 ++++++++++++++++++
 common/config/conf_main.go                    |   2 +
 common/config/models_main.go                  |   5 +
 common/config/watch.go                        |   3 +
 common/globals/reload.go                      |   3 +-
 common/runtime/init.go                        |   2 +
 config.sample.yaml                            |  34 ++++
 .../upload_controller/upload_controller.go    |  45 ++++-
 go.mod                                        |   2 +
 go.sum                                        |  24 +++
 matrix/admin.go                               |   5 +-
 matrix/auth.go                                |   3 +-
 matrix/client_server.go                       |  14 --
 plugins/manager.go                            |  64 ++++++
 plugins/mmr_plugin.go                         |  63 ++++++
 plugins/plugin_common/handshake.go            |  12 ++
 plugins/plugin_interfaces/antispam.go         |  78 ++++++++
 util/streams.go                               |   4 +
 util/urls.go                                  |  15 ++
 22 files changed, 568 insertions(+), 20 deletions(-)
 create mode 100644 cmd/plugin_antispam_ocr/main.go
 create mode 100644 plugins/manager.go
 create mode 100644 plugins/mmr_plugin.go
 create mode 100644 plugins/plugin_common/handshake.go
 create mode 100644 plugins/plugin_interfaces/antispam.go
 create mode 100644 util/urls.go

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2bea509f..b13ba0b6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 
 ## [Unreleased]
 
-*Nothing yet.*
+### Added
+
+* Introduced early plugin support (only for antispam for now).
+  * Includes a simple OCR plugin to help mitigate text-based image spam.
 
 ## [1.2.2] - December 8th, 2020
 
diff --git a/Dockerfile b/Dockerfile
index 6474057e..e07b8289 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -14,6 +14,8 @@ RUN ./build.sh
 # Final runtime stage.
 FROM alpine
 
+RUN mkdir /plugins
+COPY --from=builder /opt/bin/plugin_antispam_ocr /plugins/
 COPY --from=builder /opt/bin/media_repo /opt/bin/import_synapse /opt/bin/gdpr_export /opt/bin/gdpr_import /usr/local/bin/
 
 RUN apk add --no-cache \
diff --git a/cmd/media_repo/reloads.go b/cmd/media_repo/reloads.go
index 6deb426d..a51a1c28 100644
--- a/cmd/media_repo/reloads.go
+++ b/cmd/media_repo/reloads.go
@@ -8,6 +8,7 @@ import (
 	"github.com/turt2live/matrix-media-repo/internal_cache"
 	"github.com/turt2live/matrix-media-repo/ipfs_proxy"
 	"github.com/turt2live/matrix-media-repo/metrics"
+	"github.com/turt2live/matrix-media-repo/plugins"
 	"github.com/turt2live/matrix-media-repo/storage"
 	"github.com/turt2live/matrix-media-repo/tasks"
 )
@@ -21,6 +22,7 @@ func setupReloads() {
 	reloadIpfsOnChan(globals.IPFSReloadChan)
 	reloadAccessTokensOnChan(globals.AccessTokenReloadChan)
 	reloadCacheOnChan(globals.CacheReplaceChan)
+	reloadPluginsOnChan(globals.PluginReloadChan)
 }
 
 func stopReloads() {
@@ -33,6 +35,7 @@ func stopReloads() {
 	globals.RecurringTasksReloadChan <- false
 	globals.IPFSReloadChan <- false
 	globals.CacheReplaceChan <- false
+	globals.PluginReloadChan <- false
 }
 
 func reloadWebOnChan(reloadChan chan bool) {
@@ -147,3 +150,17 @@ func reloadCacheOnChan(reloadChan chan bool) {
 		}
 	}()
 }
+
+func reloadPluginsOnChan(reloadChan chan bool) {
+	go func() {
+		defer close(reloadChan)
+		for {
+			shouldReload := <-reloadChan
+			if shouldReload {
+				plugins.ReloadPlugins()
+			} else {
+				plugins.StopPlugins()
+			}
+		}
+	}()
+}
diff --git a/cmd/plugin_antispam_ocr/main.go b/cmd/plugin_antispam_ocr/main.go
new file mode 100644
index 00000000..2edbb78c
--- /dev/null
+++ b/cmd/plugin_antispam_ocr/main.go
@@ -0,0 +1,186 @@
+package main
+
+import (
+	"bytes"
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"image"
+	"io/ioutil"
+	"math"
+	"net/http"
+	"os"
+	"regexp"
+	"strings"
+	"time"
+
+	"github.com/disintegration/imaging"
+	"github.com/hashicorp/go-hclog"
+	"github.com/hashicorp/go-plugin"
+	"github.com/turt2live/matrix-media-repo/plugins/plugin_common"
+	"github.com/turt2live/matrix-media-repo/plugins/plugin_interfaces"
+	"github.com/turt2live/matrix-media-repo/util"
+)
+
+type AntispamOCR struct {
+	logger hclog.Logger
+	config map[string]interface{}
+
+	userIdRegex   *regexp.Regexp
+	contentTypes  []string
+	minSize       int
+	maxSize       int
+	keywordGroups [][]string
+	ocrServer     string
+	topPercentage float64
+}
+
+func (a *AntispamOCR) HandleConfig(config map[string]interface{}) error {
+	a.config = config
+
+	a.ocrServer = a.config["ocrServer"].(string)
+	a.minSize = int(a.config["minSizeBytes"].(float64))
+	a.maxSize = int(a.config["maxSizeBytes"].(float64))
+	a.topPercentage = a.config["percentageOfHeight"].(float64)
+
+	ctypes := make([]string, 0)
+	for _, t := range a.config["types"].([]interface{}) {
+		ctypes = append(ctypes, fmt.Sprintf("%v", t))
+	}
+	a.contentTypes = ctypes
+
+	kwg := make([][]string, 0)
+	for _, c := range a.config["keywordGroups"].([]interface{}) {
+		kwg2 := make([]string, 0)
+		for _, kw := range c.([]interface{}) {
+			kwg2 = append(kwg2, fmt.Sprintf("%v", kw))
+		}
+		kwg = append(kwg, kwg2)
+	}
+	a.keywordGroups = kwg
+
+	r, err := regexp.Compile(a.config["userIds"].(string))
+	if err != nil {
+		return err
+	}
+	a.userIdRegex = r
+
+	return nil
+}
+
+func (a *AntispamOCR) CheckForSpam(b64 string, filename string, contentType string, userId string, origin string, mediaId string) (bool, error) {
+	b, err := base64.StdEncoding.DecodeString(b64)
+	if err != nil {
+		return false, err
+	}
+
+	if len(b) < a.minSize || len(b) > a.maxSize {
+		return false, nil
+	}
+	if !util.ArrayContains(a.contentTypes, contentType) {
+		return false, nil
+	}
+	if !a.userIdRegex.MatchString(userId) {
+		return false, nil
+	}
+
+	img, _, err := image.Decode(bytes.NewBuffer(b))
+	if err != nil {
+		return false, err
+	}
+
+	// For certain kinds of spam we don't really need to consider the whole image but just the upper third.
+	if a.topPercentage < 1.0 && a.topPercentage > 0 {
+		newHeight := int(math.Round(float64(img.Bounds().Max.Y) * a.topPercentage))
+		img = imaging.Fill(img, img.Bounds().Max.X, newHeight, imaging.Top, imaging.Linear)
+	}
+
+	// Steps:
+	// 1. Crush the image to reasonable dimensions (helps with later transforms). Use Lanczos to soften lines on letters.
+	// 2. Double the image size, using Lanczos again to do a second round of softening.
+	// 3. Try to remove any background noise (usually introduced during upload and by resizing).
+	// 4. Adjust contrast to make text more obvious on the background.
+	// 5. Convert to grayscale, thus avoiding any colour issues with the OCR.
+	img = imaging.Fit(img, 512, 512, imaging.Lanczos)
+	img = imaging.Fill(img, img.Bounds().Max.X * 2, img.Bounds().Max.Y * 2, imaging.Top, imaging.Lanczos)
+	img = imaging.Sharpen(img, 50)
+	img = imaging.AdjustContrast(img, 2)
+	img = imaging.Grayscale(img)
+
+	imgData := &bytes.Buffer{}
+	err = imaging.Encode(imgData, img, imaging.PNG)
+	if err != nil {
+		return false, err
+	}
+	b64 = base64.StdEncoding.EncodeToString(imgData.Bytes())
+
+	bodyBytes, err := json.Marshal(map[string]interface{}{
+		"base64": b64,
+		"trim":   "\n",
+	})
+	if err != nil {
+		return false, err
+	}
+
+	ocrUrl := util.MakeUrl(a.ocrServer, "/base64")
+	req, err := http.NewRequest("POST", ocrUrl, bytes.NewBuffer(bodyBytes))
+	if err != nil {
+		return false, err
+	}
+	req.Header.Set("User-Agent", "matrix-media-repo")
+	client := &http.Client{
+		Timeout: 20 * time.Second,
+	}
+	res, err := client.Do(req)
+	if err != nil {
+		a.logger.Error("non-fatal error checking spam: ", err)
+		return false, nil
+	}
+	contents, err := ioutil.ReadAll(res.Body)
+	if err != nil {
+		return false, err
+	}
+	var resp map[string]interface{}
+	err = json.Unmarshal(contents, &resp)
+	if err != nil {
+		return false, err
+	}
+	if res.StatusCode != http.StatusOK {
+		return false, errors.New(fmt.Sprintf("unexpected status code: %d", res.StatusCode))
+	}
+	ocr := strings.ToLower(resp["result"].(string))
+
+	for _, kwg := range a.keywordGroups {
+		hasKeyword := false
+		for _, kw := range kwg {
+			if strings.Contains(ocr, kw) {
+				hasKeyword = true
+				break
+			}
+		}
+		if !hasKeyword {
+			return false, nil
+		}
+	}
+
+	a.logger.Warn("spam detected")
+	return true, nil
+}
+
+func main() {
+	logger := hclog.New(&hclog.LoggerOptions{
+		Level:      hclog.Trace,
+		Output:     os.Stderr,
+		JSONFormat: true,
+	})
+
+	antispam := &AntispamOCR{logger: logger}
+
+	plugin.Serve(&plugin.ServeConfig{
+		HandshakeConfig: plugin_common.HandshakeConfig,
+		Plugins: map[string]plugin.Plugin{
+			"antispam": &plugin_interfaces.AntispamPlugin{Impl: antispam},
+		},
+	})
+}
diff --git a/common/config/conf_main.go b/common/config/conf_main.go
index e87aa506..559ef2c2 100644
--- a/common/config/conf_main.go
+++ b/common/config/conf_main.go
@@ -13,6 +13,7 @@ type MainRepoConfig struct {
 	Metrics           MetricsConfig         `yaml:"metrics"`
 	SharedSecret      SharedSecretConfig    `yaml:"sharedSecretAuth"`
 	Federation        FederationConfig      `yaml:"federation"`
+	Plugins           []PluginConfig        `yaml:"plugins,flow"`
 }
 
 func NewDefaultMainConfig() MainRepoConfig {
@@ -124,5 +125,6 @@ func NewDefaultMainConfig() MainRepoConfig {
 		Federation: FederationConfig{
 			BackoffAt: 20,
 		},
+		Plugins: []PluginConfig{},
 	}
 }
diff --git a/common/config/models_main.go b/common/config/models_main.go
index 86649ffe..9a93db0b 100644
--- a/common/config/models_main.go
+++ b/common/config/models_main.go
@@ -74,3 +74,8 @@ type SharedSecretConfig struct {
 type FederationConfig struct {
 	BackoffAt int `yaml:"backoffAt"`
 }
+
+type PluginConfig struct {
+	Executable string                 `yaml:"exec"`
+	Config     map[string]interface{} `yaml:"config"`
+}
diff --git a/common/config/watch.go b/common/config/watch.go
index 5dede9f0..3b224e7f 100644
--- a/common/config/watch.go
+++ b/common/config/watch.go
@@ -117,6 +117,9 @@ func onFileChanged() {
 	logrus.Warn("Updating datastores to ensure accuracy")
 	globals.DatastoresReloadChan <- true
 
+	logrus.Info("Reloading all plugins")
+	globals.PluginReloadChan <- true
+
 	logrus.Info("Restarting recurring tasks")
 	globals.RecurringTasksReloadChan <- true
 }
diff --git a/common/globals/reload.go b/common/globals/reload.go
index f1aa971f..4bd8978e 100644
--- a/common/globals/reload.go
+++ b/common/globals/reload.go
@@ -7,4 +7,5 @@ var DatastoresReloadChan = make(chan bool)
 var RecurringTasksReloadChan = make(chan bool)
 var IPFSReloadChan = make(chan bool)
 var AccessTokenReloadChan = make(chan bool)
-var CacheReplaceChan = make(chan bool)
\ No newline at end of file
+var CacheReplaceChan = make(chan bool)
+var PluginReloadChan = make(chan bool)
diff --git a/common/runtime/init.go b/common/runtime/init.go
index fdb6bcbd..a573dbfc 100644
--- a/common/runtime/init.go
+++ b/common/runtime/init.go
@@ -8,6 +8,7 @@ import (
 	"github.com/turt2live/matrix-media-repo/common/rcontext"
 	"github.com/turt2live/matrix-media-repo/common/version"
 	"github.com/turt2live/matrix-media-repo/ipfs_proxy"
+	"github.com/turt2live/matrix-media-repo/plugins"
 	"github.com/turt2live/matrix-media-repo/storage"
 	"github.com/turt2live/matrix-media-repo/storage/datastore"
 	"github.com/turt2live/matrix-media-repo/storage/datastore/ds_s3"
@@ -19,6 +20,7 @@ func RunStartupSequence() {
 	config.CheckDeprecations()
 	LoadDatabase()
 	LoadDatastores()
+	plugins.ReloadPlugins()
 
 	logrus.Info("Starting IPFS (if enabled)...")
 	ipfs_proxy.Reload()
diff --git a/config.sample.yaml b/config.sample.yaml
index f237af47..9839689c 100644
--- a/config.sample.yaml
+++ b/config.sample.yaml
@@ -484,6 +484,40 @@ metrics:
   # The port to listen on. Cannot be the same as the general web server port.
   port: 9000
 
+# Plugins are optional pieces of the media repo used to extend the functionality offered.
+# Currently there are only antispam plugins, but in future there should be more options.
+# Plugins are not supported on per-domain paths and are instead repo-wide. For more
+# information on writing plugins, please visit #matrix-media-repo:t2bot.io on Matrix.
+plugins:
+  - exec: /path/to/plugin/executable
+    # Note: the exact config varies by plugin.
+    config:
+      hello: world
+
+  # An example OCR plugin to block images with certain text. Note that the Docker image
+  # for the media repo automatically ships this at /plugins/plugin_antispam_ocr
+#  - exec: /plugins/plugin_antispam_ocr
+#    config:
+#      # The URL to your OCR server (https://github.com/otiai10/ocrserver)
+#      ocrServer: "http://localhost:8080"
+#      # The keywords to scan for. The image must contain at least one of the keywords
+#      # from each list to qualify for spam.
+#      keywordGroups:
+#        - - elon
+#          - musk
+#          - elonmusk
+#        - - bitcoin
+#      # The minimum (and maximum) sizes of images to process.
+#      minSizeBytes: 20000
+#      maxSizeBytes: 200000
+#      # The types of files to process
+#      types: ["image/png", "image/jpeg", "image/jpg"]
+#      # The user ID regex to check against
+#      userIds: "@telegram_.*"
+#      # How much of the image's height, starting from the top, to consider before
+#      # discarding the rest. Set to 1.0 to consider the whole image.
+#      percentageOfHeight: 0.35
+
 # Options for controlling various MSCs/unstable features of the media repo
 # Sections of this config might disappear or be added over time. By default all
 # features are disabled in here and must be explicitly enabled to be used.
diff --git a/controllers/upload_controller/upload_controller.go b/controllers/upload_controller/upload_controller.go
index 1cf789d1..8dda0699 100644
--- a/controllers/upload_controller/upload_controller.go
+++ b/controllers/upload_controller/upload_controller.go
@@ -13,6 +13,7 @@ import (
 	"github.com/turt2live/matrix-media-repo/common"
 	"github.com/turt2live/matrix-media-repo/common/rcontext"
 	"github.com/turt2live/matrix-media-repo/internal_cache"
+	"github.com/turt2live/matrix-media-repo/plugins"
 	"github.com/turt2live/matrix-media-repo/storage"
 	"github.com/turt2live/matrix-media-repo/storage/datastore"
 	"github.com/turt2live/matrix-media-repo/types"
@@ -175,9 +176,23 @@ func trackUploadAsLastAccess(ctx rcontext.RequestContext, media *types.Media) {
 	}
 }
 
+func checkSpam(contents []byte, filename string, contentType string, userId string, origin string, mediaId string) error {
+	spam, err := plugins.CheckForSpam(contents, filename, contentType, userId, origin, mediaId)
+	if err != nil {
+		logrus.Warn("Error checking spam - assuming not spam: " + err.Error())
+		return nil
+	}
+	if spam {
+		return common.ErrMediaQuarantined
+	}
+	return nil
+}
+
 func StoreDirect(f *AlreadyUploadedFile, contents io.ReadCloser, expectedSize int64, contentType string, filename string, userId string, origin string, mediaId string, kind string, ctx rcontext.RequestContext, filterUserDuplicates bool) (*types.Media, error) {
+	var err error
 	var ds *datastore.DatastoreRef
 	var info *types.ObjectInfo
+	var contentBytes []byte
 	if f == nil {
 		dsPicked, err := datastore.PickDatastore(kind, ctx)
 		if err != nil {
@@ -185,7 +200,12 @@ func StoreDirect(f *AlreadyUploadedFile, contents io.ReadCloser, expectedSize in
 		}
 		ds = dsPicked
 
-		fInfo, err := ds.UploadFile(contents, expectedSize, ctx)
+		contentBytes, err = ioutil.ReadAll(contents)
+		if err != nil {
+			return nil, err
+		}
+
+		fInfo, err := ds.UploadFile(util.BytesToStream(contentBytes), expectedSize, ctx)
 		if err != nil {
 			return nil, err
 		}
@@ -193,6 +213,16 @@ func StoreDirect(f *AlreadyUploadedFile, contents io.ReadCloser, expectedSize in
 	} else {
 		ds = f.DS
 		info = f.ObjectInfo
+
+		// download the contents for antispam
+		contents, err = ds.DownloadFile(info.Location)
+		if err != nil {
+			return nil, err
+		}
+		contentBytes, err = ioutil.ReadAll(contents)
+		if err != nil {
+			return nil, err
+		}
 	}
 
 	db := storage.GetDatabase().GetMediaStore(ctx)
@@ -223,9 +253,16 @@ func StoreDirect(f *AlreadyUploadedFile, contents io.ReadCloser, expectedSize in
 			}
 		}
 
+		err = checkSpam(contentBytes, filename, contentType, userId, origin, mediaId)
+		if err != nil {
+			ds.DeleteObject(info.Location) // delete temp object
+			return nil, err
+		}
+
 		// We'll use the location from the first record
 		record := records[0]
 		if record.Quarantined {
+			ds.DeleteObject(info.Location) // delete temp object
 			ctx.Log.Warn("User attempted to upload quarantined content - rejecting")
 			return nil, common.ErrMediaQuarantined
 		}
@@ -276,6 +313,12 @@ func StoreDirect(f *AlreadyUploadedFile, contents io.ReadCloser, expectedSize in
 		return nil, errors.New("file has no contents")
 	}
 
+	err = checkSpam(contentBytes, filename, contentType, userId, origin, mediaId)
+	if err != nil {
+		ds.DeleteObject(info.Location) // delete temp object
+		return nil, err
+	}
+
 	ctx.Log.Info("Persisting new media record")
 
 	media := &types.Media{
diff --git a/go.mod b/go.mod
index eb34d640..97d8a8f2 100644
--- a/go.mod
+++ b/go.mod
@@ -30,6 +30,8 @@ require (
 	github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
 	github.com/golang/snappy v0.0.1 // indirect
 	github.com/gorilla/mux v1.7.4
+	github.com/hashicorp/go-hclog v0.14.1
+	github.com/hashicorp/go-plugin v1.4.0
 	github.com/ipfs/go-cid v0.0.4
 	github.com/ipfs/go-ipfs v0.4.22-0.20191119151441-b8ec598d5801
 	github.com/ipfs/go-ipfs-config v0.0.11
diff --git a/go.sum b/go.sum
index 0b9099b8..63cc8fdb 100644
--- a/go.sum
+++ b/go.sum
@@ -119,6 +119,7 @@ github.com/dyatlov/go-opengraph v0.0.0-20180429202543-816b6608b3c8 h1:6muCmMJat6
 github.com/dyatlov/go-opengraph v0.0.0-20180429202543-816b6608b3c8/go.mod h1:nYia/MIs9OyvXXYboPmNOj0gVWo97Wx0sde+ZuKkoM4=
 github.com/elgris/jsondiff v0.0.0-20160530203242-765b5c24c302/go.mod h1:qBlWZqWeVx9BjvqBsnC/8RUlAYpIFmPvgROcw0n1scE=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/facebookgo/atomicfile v0.0.0-20151019160806-2de1f203e7d5 h1:BBso6MBKW8ncyZLv37o+KNyy0HrrHgfnOaGQC2qvN+A=
@@ -129,6 +130,7 @@ github.com/faiface/beep v1.0.2 h1:UB5DiRNmA4erfUYnHbgU4UB6DlBOrsdEFRtcc8sCkdQ=
 github.com/faiface/beep v1.0.2/go.mod h1:1yLb5yRdHMsovYYWVqYLioXkVuziCSITW1oarTeduQM=
 github.com/fastly/go-utils v0.0.0-20180712184237-d95a45783239 h1:Ghm4eQYC0nEPnSJdVkTrXpu9KtoVCSo1hg7mtI7G9KU=
 github.com/fastly/go-utils v0.0.0-20180712184237-d95a45783239/go.mod h1:Gdwt2ce0yfBxPvZrHkprdPPTTS3N5rwmLE8T22KBXlw=
+github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fd/go-nat v1.0.0/go.mod h1:BTBu/CKvMmOMUPkKVef1pngt2WFH/lg7E6yQnulfp6E=
 github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
@@ -172,6 +174,7 @@ github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
 github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
 github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
 github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
 github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
 github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
@@ -217,13 +220,19 @@ github.com/hajimehoshi/oto v0.1.1/go.mod h1:hUiLWeBQnbDu4pZsAhOnGqMI1ZGibS6e2qhQ
 github.com/hajimehoshi/oto v0.3.1/go.mod h1:e9eTLBB9iZto045HLbzfHJIc+jP3xaKrjZTghvb6fdM=
 github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-hclog v0.14.1 h1:nQcJDQwIAGnmoUWp8ubocEX40cCml/17YkF6csQLReU=
+github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
 github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o=
 github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
+github.com/hashicorp/go-plugin v1.4.0 h1:b0O7rs5uiJ99Iu9HugEzsM67afboErkHUWddUSpUO3A=
+github.com/hashicorp/go-plugin v1.4.0/go.mod h1:5fGEH17QVwTTcR0zV7yhDPLLmFX9YSZ38b18Udy6vYQ=
 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru v0.5.3 h1:YPkqC67at8FYaadspW/6uE0COsBxS2656RLEr8Bppgk=
 github.com/hashicorp/golang-lru v0.5.3/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb h1:b5rjCoWHc7eqmAS4/qyk21ZsHyb6Mxv/jykxvNTkU4M=
+github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
 github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/huin/goupnp v0.0.0-20180415215157-1395d1447324/go.mod h1:MZ2ZmwcBpvOoJ22IJsc7va19ZwoheaBk43rKg12SKag=
@@ -400,6 +409,8 @@ github.com/jfreymuth/oggvorbis v1.0.0 h1:aOpiihGrFLXpsh2osOlEvTcg5/aluzGQeC7m3uY
 github.com/jfreymuth/oggvorbis v1.0.0/go.mod h1:abe6F9QRjuU9l+2jek3gj46lu40N4qlYxh2grqkLEDM=
 github.com/jfreymuth/vorbis v1.0.0 h1:SmDf783s82lIjGZi8EGUUaS7YxPHgRj4ZXW/h7rUi7U=
 github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0=
+github.com/jhump/protoreflect v1.6.0 h1:h5jfMVslIg6l29nsMs0D8Wj17RDVdNYti0vDN/PZZoE=
+github.com/jhump/protoreflect v1.6.0/go.mod h1:eaTn3RZAmMBcV0fifFvlm6VHNz3wSkYyXYWUh7ymB74=
 github.com/jonboulle/clockwork v0.1.0 h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo=
 github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
 github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
@@ -662,10 +673,14 @@ github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaO
 github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
 github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
 github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
+github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
 github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
 github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
 github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE=
 github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.10 h1:qxFzApOv4WsAL965uUPIsXzAKCZxN2p9UqdhFS4ZW10=
+github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
 github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
 github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
 github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
@@ -689,6 +704,8 @@ github.com/minio/sha256-simd v0.1.1 h1:5QHSlgo3nt5yKOJrC7W8w7X+NFl8cMPZm96iu8kKU
 github.com/minio/sha256-simd v0.1.1/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM=
 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77 h1:7GoSOOW2jpsfkntVKaS2rAr1TJqfcxotyaUcuxoZSzg=
+github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -737,6 +754,8 @@ github.com/multiformats/go-multistream v0.1.0/go.mod h1:fJTiDfXJVmItycydCnNx4+wS
 github.com/multiformats/go-varint v0.0.1 h1:TR/0rdQtnNxuN2IhiB639xC3tWM4IUi7DkTBVTdGW/M=
 github.com/multiformats/go-varint v0.0.1/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE=
 github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw=
+github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
 github.com/olebedev/emitter v0.0.0-20190110104742-e8d1457e6aee h1:IquUs3fIykn10zWDIyddanhpTqBvAHMaPnFhQuyYw5U=
 github.com/olebedev/emitter v0.0.0-20190110104742-e8d1457e6aee/go.mod h1:eT2/Pcsim3XBjbvldGiJBvvgiqZkAFyiOJJsDKXs/ts=
 github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@@ -941,6 +960,7 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB
 golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
 golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180524181706-dfa909b99c79/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -994,6 +1014,7 @@ golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190926180325-855e68c8590b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200122134326-e047566fdf82 h1:ywK/j/KkyTHcdyYSZNXGjMwgmDSfjglYZ3vStQ/gSCU=
 golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1021,6 +1042,7 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IV
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/genproto v0.0.0-20170818010345-ee236bd376b0/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
 google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
 google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
@@ -1028,10 +1050,12 @@ google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRn
 google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
 google.golang.org/genproto v0.0.0-20191009194640-548a555dbc03 h1:4HYDjxeNXAOTv3o1N2tjo8UUSlhQgAD52FVkwxnWgM8=
 google.golang.org/genproto v0.0.0-20191009194640-548a555dbc03/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
 google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
+google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
 google.golang.org/grpc v1.30.0 h1:M5a8xTlYTxwMn5ZFkwhRabsygDY5G8TYLyQDBxJNAxE=
 google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
diff --git a/matrix/admin.go b/matrix/admin.go
index ad662223..be399ab1 100644
--- a/matrix/admin.go
+++ b/matrix/admin.go
@@ -4,6 +4,7 @@ import (
 	"time"
 
 	"github.com/turt2live/matrix-media-repo/common/rcontext"
+	"github.com/turt2live/matrix-media-repo/util"
 )
 
 func IsUserAdmin(ctx rcontext.RequestContext, serverName string, accessToken string, ipAddr string) (bool, error) {
@@ -19,7 +20,7 @@ func IsUserAdmin(ctx rcontext.RequestContext, serverName string, accessToken str
 		if hs.AdminApiKind == "synapse" {
 			path = "/_synapse/admin/v1/whois/"
 		}
-		url := makeUrl(hs.ClientServerApi, path, fakeUser)
+		url := util.MakeUrl(hs.ClientServerApi, path, fakeUser)
 		err := doRequest(ctx, "GET", url, nil, response, accessToken, ipAddr)
 		if err != nil {
 			err, replyError = filterError(err)
@@ -43,7 +44,7 @@ func ListMedia(ctx rcontext.RequestContext, serverName string, accessToken strin
 		if hs.AdminApiKind == "synapse" {
 			path = "/_synapse/admin/v1/room/"
 		}
-		url := makeUrl(hs.ClientServerApi, path, roomId, "/media")
+		url := util.MakeUrl(hs.ClientServerApi, path, roomId, "/media")
 		err := doRequest(ctx, "GET", url, nil, response, accessToken, ipAddr)
 		if err != nil {
 			err, replyError = filterError(err)
diff --git a/matrix/auth.go b/matrix/auth.go
index 2845c893..96f0704a 100644
--- a/matrix/auth.go
+++ b/matrix/auth.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/pkg/errors"
 	"github.com/turt2live/matrix-media-repo/common/rcontext"
+	"github.com/turt2live/matrix-media-repo/util"
 )
 
 var ErrInvalidToken = errors.New("Missing or invalid access token")
@@ -25,7 +26,7 @@ func doBreakerRequest(ctx rcontext.RequestContext, serverName string, accessToke
 			query["user_id"] = appserviceUserId
 		}
 
-		target, _ := url.Parse(makeUrl(hs.ClientServerApi, path))
+		target, _ := url.Parse(util.MakeUrl(hs.ClientServerApi, path))
 		q := target.Query()
 		for k, v := range query {
 			q.Set(k, v)
diff --git a/matrix/client_server.go b/matrix/client_server.go
index a9ef7e66..7e20e62a 100644
--- a/matrix/client_server.go
+++ b/matrix/client_server.go
@@ -72,17 +72,3 @@ func doRequest(ctx rcontext.RequestContext, method string, urlStr string, body i
 
 	return nil
 }
-
-func makeUrl(parts ...string) string {
-	res := ""
-	for i, p := range parts {
-		if p[len(p)-1:] == "/" {
-			res += p[:len(p)-1]
-		} else if p[0] != '/' && i > 0 {
-			res += "/" + p
-		} else {
-			res += p
-		}
-	}
-	return res
-}
diff --git a/plugins/manager.go b/plugins/manager.go
new file mode 100644
index 00000000..5b451c6d
--- /dev/null
+++ b/plugins/manager.go
@@ -0,0 +1,64 @@
+package plugins
+
+import (
+	"encoding/base64"
+
+	"github.com/hashicorp/go-plugin"
+	"github.com/sirupsen/logrus"
+	"github.com/turt2live/matrix-media-repo/common/config"
+	"github.com/turt2live/matrix-media-repo/plugins/plugin_interfaces"
+)
+
+var pluginTypes = map[string]plugin.Plugin{
+	"antispam": &plugin_interfaces.AntispamPlugin{},
+}
+
+
+var existingPlugins = make([]*mmrPlugin, 0)
+
+func ReloadPlugins() {
+	logrus.Info("Reloading plugins...")
+
+	for _, pl := range config.Get().Plugins {
+		logrus.Info("Loading plugin: ", pl.Executable)
+		mmr, err := newPlugin(pl.Executable, pl.Config)
+		if err != nil {
+			logrus.Errorf("failed to load plugin %s: %s", pl.Executable, err.Error())
+			continue
+		}
+
+		existingPlugins = append(existingPlugins, mmr)
+	}
+}
+
+func StopPlugins() {
+	if len(existingPlugins) == 0 {
+		return
+	}
+
+	logrus.Info("Stopping plugin instances...")
+	for _, pl := range existingPlugins {
+		pl.Stop()
+	}
+	existingPlugins = make([]*mmrPlugin, 0)
+}
+
+func CheckForSpam(contents []byte, filename string, contentType string, userId string, origin string, mediaId string) (bool, error) {
+	for _, pl := range existingPlugins {
+		as, err := pl.Antispam()
+		if err != nil {
+			logrus.Warnf("error loading antispam plugin: %s", err.Error())
+			continue
+		}
+
+		b64 := base64.StdEncoding.EncodeToString(contents)
+		spam, err := as.CheckForSpam(b64, filename, contentType, userId, origin, mediaId)
+		if err != nil {
+			return false, err
+		}
+		if spam {
+			return true, err
+		}
+	}
+	return false, nil
+}
diff --git a/plugins/mmr_plugin.go b/plugins/mmr_plugin.go
new file mode 100644
index 00000000..24bc040e
--- /dev/null
+++ b/plugins/mmr_plugin.go
@@ -0,0 +1,63 @@
+package plugins
+
+import (
+	"os/exec"
+
+	"github.com/hashicorp/go-hclog"
+	"github.com/hashicorp/go-plugin"
+	"github.com/sirupsen/logrus"
+	"github.com/turt2live/matrix-media-repo/plugins/plugin_common"
+	"github.com/turt2live/matrix-media-repo/plugins/plugin_interfaces"
+)
+
+type mmrPlugin struct {
+	hcClient  *plugin.Client
+	rpcClient plugin.ClientProtocol
+	config    map[string]interface{}
+
+	antispamPlugin plugin_interfaces.Antispam
+}
+
+func newPlugin(path string, config map[string]interface{}) (*mmrPlugin, error) {
+	logger := hclog.New(&hclog.LoggerOptions{
+		Name:   "plugin",
+		Output: logrus.WithField("plugin", path).Writer(),
+		Level:  hclog.Debug,
+	})
+	client := plugin.NewClient(&plugin.ClientConfig{
+		Cmd:             exec.Command(path),
+		Logger:          logger,
+		HandshakeConfig: plugin_common.HandshakeConfig,
+		Plugins:         pluginTypes,
+	})
+	rpcClient, err := client.Client()
+	if err != nil {
+		client.Kill()
+		return nil, err
+	}
+	return &mmrPlugin{
+		hcClient:  client,
+		rpcClient: rpcClient,
+		config:    config,
+	}, nil
+}
+
+func (p *mmrPlugin) Antispam() (plugin_interfaces.Antispam, error) {
+	if p.antispamPlugin != nil {
+		return p.antispamPlugin, nil
+	}
+
+	raw, err := p.rpcClient.Dispense("antispam")
+	if err != nil {
+		return nil, err
+	}
+
+	p.antispamPlugin = raw.(plugin_interfaces.Antispam)
+	p.antispamPlugin.HandleConfig(p.config)
+	return p.antispamPlugin, nil
+}
+
+func (p *mmrPlugin) Stop() {
+	p.antispamPlugin = nil
+	p.hcClient.Kill()
+}
diff --git a/plugins/plugin_common/handshake.go b/plugins/plugin_common/handshake.go
new file mode 100644
index 00000000..c0278fd7
--- /dev/null
+++ b/plugins/plugin_common/handshake.go
@@ -0,0 +1,12 @@
+package plugin_common
+
+import (
+	"github.com/hashicorp/go-plugin"
+)
+
+// UX, not security
+var HandshakeConfig = plugin.HandshakeConfig{
+	ProtocolVersion:  1,
+	MagicCookieKey:   "MEDIA_REPO_PLUGIN",
+	MagicCookieValue: "hello world - I am a media repo",
+}
\ No newline at end of file
diff --git a/plugins/plugin_interfaces/antispam.go b/plugins/plugin_interfaces/antispam.go
new file mode 100644
index 00000000..4c203ec9
--- /dev/null
+++ b/plugins/plugin_interfaces/antispam.go
@@ -0,0 +1,78 @@
+package plugin_interfaces
+
+import (
+	"encoding/json"
+	"net/rpc"
+
+	"github.com/hashicorp/go-plugin"
+)
+
+type Antispam interface {
+	HandleConfig(config map[string]interface{}) error
+	CheckForSpam(b64 string, filename string, contentType string, userId string, origin string, mediaId string) (bool, error)
+}
+
+type AntispamRPC struct {
+	client *rpc.Client
+}
+
+func (g *AntispamRPC) HandleConfig(config map[string]interface{}) error {
+	var i string
+	b, err := json.Marshal(config)
+	if err != nil {
+		return err
+	}
+	return g.client.Call("Plugin.HandleConfig", map[string]interface{}{"c": string(b)}, &i)
+}
+
+func (g *AntispamRPC) CheckForSpam(b64 string, filename string, contentType string, userId string, origin string, mediaId string) (bool, error) {
+	var resp bool
+	err := g.client.Call("Plugin.CheckForSpam", map[string]interface{}{
+		"b64":         b64,
+		"filename":    filename,
+		"contentType": contentType,
+		"userId":      userId,
+		"origin":      origin,
+		"mediaId":     mediaId,
+	}, &resp)
+	return resp, err
+}
+
+type AntispamRPCServer struct {
+	Impl Antispam
+}
+
+func (s *AntispamRPCServer) HandleConfig(args map[string]interface{}, resp *string) error {
+	*resp = "not_used"
+	var conf map[string]interface{}
+	err := json.Unmarshal(([]byte)(args["c"].(string)), &conf)
+	if err != nil {
+		return err
+	}
+	return s.Impl.HandleConfig(conf)
+}
+
+func (s *AntispamRPCServer) CheckForSpam(args map[string]interface{}, resp *bool) error {
+	var err error
+	*resp, err = s.Impl.CheckForSpam(
+		args["b64"].(string),
+		args["filename"].(string),
+		args["contentType"].(string),
+		args["userId"].(string),
+		args["origin"].(string),
+		args["mediaId"].(string),
+	)
+	return err
+}
+
+type AntispamPlugin struct {
+	Impl Antispam
+}
+
+func (p *AntispamPlugin) Server(broker *plugin.MuxBroker) (interface{}, error) {
+	return &AntispamRPCServer{Impl: p.Impl}, nil
+}
+
+func (p *AntispamPlugin) Client(broker *plugin.MuxBroker, c *rpc.Client) (interface{}, error) {
+	return &AntispamRPC{client: c}, nil
+}
diff --git a/util/streams.go b/util/streams.go
index 6c6c513a..9cafc8ba 100644
--- a/util/streams.go
+++ b/util/streams.go
@@ -16,6 +16,10 @@ func BufferToStream(buf *bytes.Buffer) io.ReadCloser {
 	return ioutil.NopCloser(newBuf)
 }
 
+func BytesToStream(b []byte) io.ReadCloser {
+	return ioutil.NopCloser(bytes.NewBuffer(b))
+}
+
 func CloneReader(input io.ReadCloser, numReaders int) []io.ReadCloser {
 	readers := make([]io.ReadCloser, 0)
 	writers := make([]io.WriteCloser, 0)
diff --git a/util/urls.go b/util/urls.go
new file mode 100644
index 00000000..7b02ede3
--- /dev/null
+++ b/util/urls.go
@@ -0,0 +1,15 @@
+package util
+
+func MakeUrl(parts ...string) string {
+	res := ""
+	for i, p := range parts {
+		if p[len(p)-1:] == "/" {
+			res += p[:len(p)-1]
+		} else if p[0] != '/' && i > 0 {
+			res += "/" + p
+		} else {
+			res += p
+		}
+	}
+	return res
+}
\ No newline at end of file
-- 
GitLab