Skip to content

Commit e5c3196

Browse files
committed
feat: Add blobstore package
This commit introduces a `blobstore` package and refactors the existing upload mechanism. Upload is now handled by `providers` and the two bundled providers are `S3` and `Filesystem`. `app.Blobstore` initialises the correct provider based on the configuration and handles `Put`, `Delete` and `Get` operations.
1 parent 7ee7116 commit e5c3196

13 files changed

+462
-158
lines changed

.dockerignore

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
**/.classpath
2+
**/.dockerignore
3+
**/.env
4+
**/.git
5+
**/.gitignore
6+
**/.project
7+
**/.settings
8+
**/.toolstarget
9+
**/.vs
10+
**/.vscode
11+
**/*.*proj.user
12+
**/*.dbmdl
13+
**/*.jfm
14+
**/azds.yaml
15+
**/bin
16+
**/charts
17+
**/docker-compose*
18+
**/Dockerfile*
19+
**/node_modules
20+
**/npm-debug.log
21+
**/obj
22+
**/secrets.dev.yaml
23+
**/values.dev.yaml
24+
LICENSE
25+
README.md

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ frontend/yarn.lock
66
config.toml
77
node_modules
88
listmonk
9+
dist/*

admin.go

-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ func handleGetConfigScript(c echo.Context) error {
2828
app = c.Get("app").(*App)
2929
out = configScript{
3030
RootURL: app.Constants.RootURL,
31-
UploadURI: app.Constants.UploadURI,
3231
FromEmail: app.Constants.FromEmail,
3332
Messengers: app.Manager.GetMessengerNames(),
3433
}

config.toml.sample

+30-8
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,6 @@ from_email = "listmonk <[email protected]>"
2424
# To disable notifications, set an empty list, eg: notify_emails = []
2525
notify_emails = ["[email protected]", "[email protected]"]
2626

27-
# Path to the uploads directory where media will be uploaded.
28-
upload_path = "uploads"
29-
30-
# Upload URI that's visible to the outside world. The media
31-
# uploaded to upload_path will be made available publicly
32-
# under this URI, for instance, list.yoursite.com/uploads.
33-
upload_uri = "/uploads"
34-
3527
# Maximum concurrent workers that will attempt to send messages
3628
# simultaneously. This should depend on the number of CPUs the
3729
# machine has and also the number of simultaenous e-mails the
@@ -110,3 +102,33 @@ ssl_mode = "disable"
110102

111103
# Maximum concurrent connections to the SMTP server.
112104
max_conns = 10
105+
106+
# Upload settings
107+
[upload]
108+
# Provider which will be used to host uploaded media. Bundled providers are "filesystem" and "s3".
109+
provider = "filesystem"
110+
111+
# S3 Provider settings
112+
[upload.s3]
113+
# (Optional). AWS Access Key and Secret Key for the user to access the bucket. Leaving it empty would default to use
114+
# instance IAM role.
115+
aws_access_key_id = ""
116+
aws_secret_access_key = ""
117+
# AWS Region where S3 bucket is hosted.
118+
aws_default_region="ap-south-1"
119+
# Specify bucket name.
120+
bucket=""
121+
# Path where the files will be stored inside bucket. Empty value ("") means the root of bucket.
122+
bucket_path=""
123+
# Bucket type can be "private" or "public".
124+
bucket_type="public"
125+
# (Optional) Specify TTL (in seconds) for the generated presigned URL. Expiry value is used only if the bucket is private.
126+
expiry="86400"
127+
128+
# Filesystem provider settings
129+
[upload.filesystem]
130+
# Path to the uploads directory where media will be uploaded. Leaving it empty ("") means current working directory.
131+
upload_path=""
132+
# Upload URI that's visible to the outside world. The media uploaded to upload_path will be made available publicly
133+
# under this URI, for instance, list.yoursite.com/uploads.
134+
upload_uri = "/uploads"

go.mod

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ module github.com/knadh/listmonk
22

33
require (
44
github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf
5+
github.com/aws/aws-sdk-go v1.25.12
56
github.com/disintegration/imaging v1.5.0
67
github.com/jinzhu/gorm v1.9.1
78
github.com/jmoiron/sqlx v1.2.0
@@ -15,6 +16,7 @@ require (
1516
github.com/lib/pq v1.0.0
1617
github.com/mattn/go-colorable v0.0.9 // indirect
1718
github.com/mattn/go-isatty v0.0.4 // indirect
19+
github.com/rhnvrm/simples3 v0.2.4-0.20191018074503-3d5b071ef727
1820
github.com/satori/go.uuid v1.2.0
1921
github.com/spf13/pflag v1.0.3
2022
github.com/stretchr/objx v0.2.0 // indirect
@@ -27,3 +29,5 @@ require (
2729
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
2830
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b
2931
)
32+
33+
go 1.13

go.sum

+9
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ
22
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
33
github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf h1:eg0MeVzsP1G42dRafH3vf+al2vQIJU0YHX+1Tw87oco=
44
github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
5+
github.com/aws/aws-sdk-go v1.25.12 h1:a4h2FxoUJq9h+hajSE/dsRiqoOniIh6BkzhxMjkepzY=
6+
github.com/aws/aws-sdk-go v1.25.12/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
57
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
68
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
79
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -16,6 +18,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
1618
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
1719
github.com/jinzhu/gorm v1.9.1 h1:lDSDtsCt5AGGSKTs8AHlSDbbgif4G4+CKJ8ETBDVHTA=
1820
github.com/jinzhu/gorm v1.9.1/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo=
21+
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM=
22+
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
1923
github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
2024
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
2125
github.com/jordan-wright/email v0.0.0-20181027021455-480bedc4908b h1:veTPVnbkOijplSJVywDYKDRPoZEN39kfuMDzzRKP0FA=
@@ -57,6 +61,10 @@ github.com/pelletier/go-toml v1.4.0 h1:u3Z1r+oOXJIkxqw34zVhyPgjBsm6X2wn21NWs/HfS
5761
github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo=
5862
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
5963
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
64+
github.com/rhnvrm/simples3 v0.2.3 h1:qNXPynabu8M3F4+69fspA5aWZR8jqVV1RQtv2xc1OVk=
65+
github.com/rhnvrm/simples3 v0.2.3/go.mod h1:iphavgjkW1uvoIiqLUX6D42XuuI9Cr+B/63xw3gb9qA=
66+
github.com/rhnvrm/simples3 v0.2.4-0.20191018074503-3d5b071ef727 h1:2josYcx2gm3CT0WMqi0jBagvg50V3UMWlYN/CnBEbSI=
67+
github.com/rhnvrm/simples3 v0.2.4-0.20191018074503-3d5b071ef727/go.mod h1:iphavgjkW1uvoIiqLUX6D42XuuI9Cr+B/63xw3gb9qA=
6068
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
6169
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
6270
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
@@ -73,6 +81,7 @@ golang.org/x/crypto v0.0.0-20181106171534-e4dc69e5b2fd h1:VtIkGDhk0ph3t+THbvXHfM
7381
golang.org/x/crypto v0.0.0-20181106171534-e4dc69e5b2fd/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
7482
golang.org/x/image v0.0.0-20181116024801-cd38e8056d9b h1:VHyIDlv3XkfCa5/a81uzaoDkHH4rr81Z62g+xlnO8uM=
7583
golang.org/x/image v0.0.0-20181116024801-cd38e8056d9b/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
84+
golang.org/x/net v0.0.0-20180724234803-3673e40ba225 h1:kNX+jCowfMYzvlSvJu5pQWEmyWFrBXJ3PBy10xKMXK8=
7685
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
7786
golang.org/x/sys v0.0.0-20180906133057-8cf3aee42992 h1:BH3eQWeGbwRU2+wxxuuPOdFBmaiBH81O8BugSjHeTFg=
7887
golang.org/x/sys v0.0.0-20180906133057-8cf3aee42992/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=

main.go

+37-5
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import (
1919
"github.com/knadh/koanf/providers/file"
2020
"github.com/knadh/koanf/providers/posflag"
2121
"github.com/knadh/listmonk/manager"
22+
"github.com/knadh/listmonk/media"
23+
"github.com/knadh/listmonk/media/providers/filesystem"
24+
"github.com/knadh/listmonk/media/providers/s3"
2225
"github.com/knadh/listmonk/messenger"
2326
"github.com/knadh/listmonk/subimporter"
2427
"github.com/knadh/stuffbin"
@@ -30,8 +33,6 @@ type constants struct {
3033
RootURL string `koanf:"root"`
3134
LogoURL string `koanf:"logo_url"`
3235
FaviconURL string `koanf:"favicon_url"`
33-
UploadPath string `koanf:"upload_path"`
34-
UploadURI string `koanf:"upload_uri"`
3536
FromEmail string `koanf:"from_email"`
3637
NotifyEmails []string `koanf:"notify_emails"`
3738
Privacy privacyOptions `koanf:"privacy"`
@@ -56,6 +57,7 @@ type App struct {
5657
Logger *log.Logger
5758
NotifTpls *template.Template
5859
Messenger messenger.Messenger
60+
Media media.Store
5961
}
6062

6163
var (
@@ -195,6 +197,33 @@ func initMessengers(r *manager.Manager) messenger.Messenger {
195197
return msgr
196198
}
197199

200+
// initMediaStore initializes Upload manager with a custom backend.
201+
func initMediaStore() media.Store {
202+
switch provider := ko.String("upload.provider"); provider {
203+
case "s3":
204+
var opts s3.Opts
205+
ko.Unmarshal("upload.s3", &opts)
206+
uplder, err := s3.NewS3Store(opts)
207+
if err != nil {
208+
logger.Fatalf("error initializing s3 upload provider %s", err)
209+
}
210+
return uplder
211+
case "filesystem":
212+
var opts filesystem.Opts
213+
ko.Unmarshal("upload.filesystem", &opts)
214+
opts.UploadPath = filepath.Clean(opts.UploadPath)
215+
opts.UploadURI = filepath.Clean(opts.UploadURI)
216+
uplder, err := filesystem.NewDiskStore(opts)
217+
if err != nil {
218+
logger.Fatalf("error initializing filesystem upload provider %s", err)
219+
}
220+
return uplder
221+
default:
222+
logger.Fatalf("unknown provider. please select one of either filesystem or s3")
223+
}
224+
return nil
225+
}
226+
198227
func main() {
199228
// Connect to the DB.
200229
db, err := connectDB(ko.String("db.host"),
@@ -216,8 +245,6 @@ func main() {
216245
log.Fatalf("error loading app config: %v", err)
217246
}
218247
c.RootURL = strings.TrimRight(c.RootURL, "/")
219-
c.UploadURI = filepath.Clean(c.UploadURI)
220-
c.UploadPath = filepath.Clean(c.UploadPath)
221248
c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable"))
222249

223250
// Initialize the static file system into which all
@@ -299,6 +326,9 @@ func main() {
299326
// Add messengers.
300327
app.Messenger = initMessengers(app.Manager)
301328

329+
// Add uploader
330+
app.Media = initMediaStore()
331+
302332
// Initialize the workers that push out messages.
303333
go m.Run(time.Second * 5)
304334
m.SpawnWorkers()
@@ -330,7 +360,9 @@ func main() {
330360
fSrv := app.FS.FileServer()
331361
srv.GET("/public/*", echo.WrapHandler(fSrv))
332362
srv.GET("/frontend/*", echo.WrapHandler(fSrv))
333-
srv.Static(c.UploadURI, c.UploadURI)
363+
if ko.String("upload.provider") == "filesystem" {
364+
srv.Static(ko.String("upload.filesystem.upload_uri"), ko.String("upload.filesystem.upload_path"))
365+
}
334366
registerHandlers(srv)
335367
srv.Logger.Fatal(srv.Start(ko.String("app.address")))
336368
}

media.go

+40-23
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,9 @@ package main
33
import (
44
"fmt"
55
"net/http"
6-
"os"
7-
"path/filepath"
86
"strconv"
97

10-
"github.com/disintegration/imaging"
11-
"github.com/knadh/listmonk/models"
8+
"github.com/knadh/listmonk/media"
129
"github.com/labstack/echo"
1310
uuid "github.com/satori/go.uuid"
1411
)
@@ -26,53 +23,72 @@ func handleUploadMedia(c echo.Context) error {
2623
app = c.Get("app").(*App)
2724
cleanUp = false
2825
)
29-
26+
file, err := c.FormFile("file")
27+
if err != nil {
28+
return echo.NewHTTPError(http.StatusBadRequest,
29+
fmt.Sprintf("Invalid file uploaded: %v", err))
30+
}
31+
// Validate MIME type with the list of allowed types.
32+
var typ = file.Header.Get("Content-type")
33+
ok := validateMIME(typ, imageMimes)
34+
if !ok {
35+
return echo.NewHTTPError(http.StatusBadRequest,
36+
fmt.Sprintf("Unsupported file type (%s) uploaded.", typ))
37+
}
38+
// Generate filename
39+
fName := generateFileName(file.Filename)
40+
// Read file contents in memory
41+
src, err := file.Open()
42+
if err != nil {
43+
return echo.NewHTTPError(http.StatusBadRequest,
44+
fmt.Sprintf("Error reading file: %s", err))
45+
}
46+
defer src.Close()
3047
// Upload the file.
31-
fName, err := uploadFile("file", app.Constants.UploadPath, "", imageMimes, c)
48+
fName, err = app.Media.Put(fName, typ, src)
3249
if err != nil {
50+
cleanUp = true
3351
return echo.NewHTTPError(http.StatusInternalServerError,
3452
fmt.Sprintf("Error uploading file: %s", err))
3553
}
36-
path := filepath.Join(app.Constants.UploadPath, fName)
3754

3855
defer func() {
3956
// If any of the subroutines in this function fail,
4057
// the uploaded image should be removed.
4158
if cleanUp {
42-
os.Remove(path)
59+
app.Media.Delete(fName)
60+
app.Media.Delete(thumbPrefix + fName)
4361
}
4462
}()
4563

46-
// Create a thumbnail.
47-
src, err := imaging.Open(path)
64+
// Create thumbnail from file.
65+
thumbFile, err := createThumbnail(file)
4866
if err != nil {
4967
cleanUp = true
5068
return echo.NewHTTPError(http.StatusInternalServerError,
5169
fmt.Sprintf("Error opening image for resizing: %s", err))
5270
}
53-
54-
t := imaging.Resize(src, thumbnailSize, 0, imaging.Lanczos)
55-
if err := imaging.Save(t, fmt.Sprintf("%s/%s%s", app.Constants.UploadPath, thumbPrefix, fName)); err != nil {
71+
// Upload thumbnail.
72+
thumbfName, err := app.Media.Put(thumbPrefix+fName, typ, thumbFile)
73+
if err != nil {
5674
cleanUp = true
5775
return echo.NewHTTPError(http.StatusInternalServerError,
5876
fmt.Sprintf("Error saving thumbnail: %s", err))
5977
}
60-
6178
// Write to the DB.
62-
if _, err := app.Queries.InsertMedia.Exec(uuid.NewV4(), fName, fmt.Sprintf("%s%s", thumbPrefix, fName), 0, 0); err != nil {
79+
if _, err := app.Queries.InsertMedia.Exec(uuid.NewV4(), fName, thumbfName, 0, 0); err != nil {
6380
cleanUp = true
6481
return echo.NewHTTPError(http.StatusInternalServerError,
65-
fmt.Sprintf("Error saving uploaded file: %s", pqErrMsg(err)))
82+
fmt.Sprintf("Error saving uploaded file to db: %s", pqErrMsg(err)))
6683
}
67-
6884
return c.JSON(http.StatusOK, okResp{true})
6985
}
7086

7187
// handleGetMedia handles retrieval of uploaded media.
7288
func handleGetMedia(c echo.Context) error {
7389
var (
7490
app = c.Get("app").(*App)
75-
out []models.Media
91+
out []media.Media
7692
)
7793

7894
if err := app.Queries.GetMedia.Select(&out); err != nil {
@@ -81,8 +97,8 @@ func handleGetMedia(c echo.Context) error {
8197
}
8298

8399
for i := 0; i < len(out); i++ {
84-
out[i].URI = fmt.Sprintf("%s/%s", app.Constants.UploadURI, out[i].Filename)
85-
out[i].ThumbURI = fmt.Sprintf("%s/%s%s", app.Constants.UploadURI, thumbPrefix, out[i].Filename)
100+
out[i].URI = app.Media.Get(out[i].Filename)
101+
out[i].ThumbURI = app.Media.Get(thumbPrefix + out[i].Filename)
86102
}
87103

88104
return c.JSON(http.StatusOK, okResp{out})
@@ -99,13 +115,14 @@ func handleDeleteMedia(c echo.Context) error {
99115
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
100116
}
101117

102-
var m models.Media
118+
var m media.Media
103119
if err := app.Queries.DeleteMedia.Get(&m, id); err != nil {
104120
return echo.NewHTTPError(http.StatusInternalServerError,
105121
fmt.Sprintf("Error deleting media: %s", pqErrMsg(err)))
106122
}
107-
os.Remove(filepath.Join(app.Constants.UploadPath, m.Filename))
108-
os.Remove(filepath.Join(app.Constants.UploadPath, fmt.Sprintf("%s%s", thumbPrefix, m.Filename)))
123+
124+
app.Media.Delete(m.Filename)
125+
app.Media.Delete(thumbPrefix + m.Filename)
109126

110127
return c.JSON(http.StatusOK, okResp{true})
111128
}

media/media.go

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package media
2+
3+
import (
4+
"io"
5+
6+
"gopkg.in/volatiletech/null.v6"
7+
)
8+
9+
// Media represents an uploaded object.
10+
type Media struct {
11+
ID int `db:"id" json:"id"`
12+
UUID string `db:"uuid" json:"uuid"`
13+
Filename string `db:"filename" json:"filename"`
14+
Width int `db:"width" json:"width"`
15+
Height int `db:"height" json:"height"`
16+
CreatedAt null.Time `db:"created_at" json:"created_at"`
17+
ThumbURI string `json:"thumb_uri"`
18+
URI string `json:"uri"`
19+
}
20+
21+
// Store represents set of methods to perform upload/delete operations.
22+
type Store interface {
23+
Put(string, string, io.ReadSeeker) (string, error)
24+
Delete(string) error
25+
Get(string) string
26+
}

0 commit comments

Comments
 (0)