Skip to content

Commit

Permalink
Refactor and fix media uploads.
Browse files Browse the repository at this point in the history
- Fix path related issues in filesystem and S3.
- Add checks for S3 "/" path prefix.
- Add support for custom S3 domain names.
- Remove obsolete `width` and `height` columns from media table (breaking)
- Add `provider` field to media table (breaking)
  • Loading branch information
knadh committed Jul 5, 2020
1 parent 7f9a811 commit 24192a3
Show file tree
Hide file tree
Showing 15 changed files with 91 additions and 69 deletions.
14 changes: 8 additions & 6 deletions admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import (
)

type configScript struct {
RootURL string `json:"rootURL"`
FromEmail string `json:"fromEmail"`
Messengers []string `json:"messengers"`
RootURL string `json:"rootURL"`
FromEmail string `json:"fromEmail"`
Messengers []string `json:"messengers"`
MediaProvider string `json:"media_provider"`
}

// handleGetConfigScript returns general configuration as a Javascript
Expand All @@ -22,9 +23,10 @@ func handleGetConfigScript(c echo.Context) error {
var (
app = c.Get("app").(*App)
out = configScript{
RootURL: app.constants.RootURL,
FromEmail: app.constants.FromEmail,
Messengers: app.manager.GetMessengerNames(),
RootURL: app.constants.RootURL,
FromEmail: app.constants.FromEmail,
Messengers: app.manager.GetMessengerNames(),
MediaProvider: app.constants.MediaProvider,
}

b = bytes.Buffer{}
Expand Down
15 changes: 9 additions & 6 deletions config.toml.sample
Original file line number Diff line number Diff line change
Expand Up @@ -166,20 +166,23 @@ provider = "filesystem"
aws_secret_access_key = ""

# AWS Region where S3 bucket is hosted.
aws_default_region="ap-south-1"
aws_default_region = "ap-south-1"

# Bucket name.
bucket=""
bucket = ""

# Path where the files will be stored inside bucket. Empty for root.
bucket_path=""
# Path where the files will be stored inside bucket. Default is "/".
bucket_path = "/"

# Optional full URL to the bucket. eg: https://files.mycustom.com
bucket_url = ""

# "private" or "public".
bucket_type="public"
bucket_type = "public"

# (Optional) Specify TTL (in seconds) for the generated presigned URL.
# Expiry value is used only if the bucket is private.
expiry="86400"
expiry = 86400

[upload.filesystem]
# Path to the uploads directory where media will be uploaded.
Expand Down
2 changes: 1 addition & 1 deletion frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ It's best if the `listmonk/frontend` directory is opened in an IDE as a separate
For developer setup instructions, refer to the main project's README.

## Globals
`main.js` is where Buefy is injected globally into Vue. In addition two controllers, `$api` (collection of API calls from `api/index.js`) and `$utils` (util functions from `util.js`), are also attached globaly to Vue. They are accessible within Vue as `this.$api` and `this.$utils`.
`main.js` is where Buefy is injected globally into Vue. In addition two controllers, `$api` (collection of API calls from `api/index.js`), `$utils` (util functions from `util.js`), `$serverConfig` (loaded form /api/config.js) are also attached globaly to Vue. They are accessible within Vue as `this.$api` and `this.$utils`.

Some constants are defined in `constants.js`.

Expand Down
3 changes: 1 addition & 2 deletions frontend/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ const http = axios.create({
return resp;
}

const data = humps.camelizeKeys(resp.data);
return data;
return humps.camelizeKeys(resp.data);
},
],

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Editor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export default {
},
onMediaSelect(m) {
this.$refs.quill.quill.insertEmbed(10, 'image', m.uri);
this.$refs.quill.quill.insertEmbed(10, 'image', m.url);
},
},
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/main.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Vue from 'vue';
import Buefy from 'buefy';
import humps from 'humps';

import App from './App.vue';
import router from './router';
Expand All @@ -14,6 +15,9 @@ Vue.config.productionTip = false;
Vue.prototype.$api = api;
Vue.prototype.$utils = utils;

// window.CONFIG is loaded from /api/config.js directly in a <script> tag.
Vue.prototype.$serverConfig = humps.camelizeKeys(window.CONFIG);

new Vue({
router,
store,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export default class utils {
message: msg,
type: !typ ? 'is-success' : typ,
queue: false,
duration: 3000,
});
};
}
10 changes: 6 additions & 4 deletions frontend/src/views/Media.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<section class="media-files">
<h1 class="title is-4">Media
<span v-if="media.length > 0">({{ media.length }})</span>

<span class="has-text-grey-light"> / {{ $serverConfig.mediaProvider }}</span>
</h1>

<b-loading :active="isProcessing || loading.media"></b-loading>
Expand Down Expand Up @@ -45,16 +47,16 @@

<div class="thumbs">
<div v-for="m in group.items" :key="m.id" class="box thumb">
<a @click="(e) => onMediaSelect(m, e)" :href="m.uri" target="_blank">
<img :src="m.thumbUri" :title="m.filename" />
<a @click="(e) => onMediaSelect(m, e)" :href="m.url" target="_blank">
<img :src="m.thumbUrl" :title="m.filename" />
</a>
<span class="caption is-size-7" :title="m.filename">{{ m.filename }}</span>

<div class="actions has-text-right">
<a :href="m.uri" target="_blank">
<a :href="m.url" target="_blank">
<b-icon icon="arrow-top-right" size="is-small" />
</a>
<a href="#" @click.prevent="deleteMedia(m.id)">
<a href="#" @click.prevent="$utils.confirm(null, () => deleteMedia(m.id))">
<b-icon icon="trash-can-outline" size="is-small" />
</a>
</div>
Expand Down
5 changes: 4 additions & 1 deletion init.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ type constants struct {
ViewTrackURL string
OptinURL string
MessageURL string

MediaProvider string
}

func initConstants() *constants {
Expand All @@ -159,6 +161,7 @@ func initConstants() *constants {
}
c.RootURL = strings.TrimRight(c.RootURL, "/")
c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable"))
c.MediaProvider = ko.String("upload.provider")

// Static URLS.
// url.com/subscription/{campaign_uuid}/{subscriber_uuid}
Expand All @@ -175,7 +178,6 @@ func initConstants() *constants {

// url.com/campaign/{campaign_uuid}/{subscriber_uuid}/px.png
c.ViewTrackURL = fmt.Sprintf("%s/campaign/%%s/%%s/px.png", c.RootURL)

return &c
}

Expand Down Expand Up @@ -272,6 +274,7 @@ func initMediaStore() media.Store {
case "filesystem":
var opts filesystem.Opts
ko.Unmarshal("upload.filesystem", &opts)
opts.RootURL = ko.String("app.root")
opts.UploadPath = filepath.Clean(opts.UploadPath)
opts.UploadURI = filepath.Clean(opts.UploadURI)
uplder, err := filesystem.NewDiskStore(opts)
Expand Down
10 changes: 5 additions & 5 deletions internal/media/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ type Media struct {
ID int `db:"id" json:"id"`
UUID string `db:"uuid" json:"uuid"`
Filename string `db:"filename" json:"filename"`
Width int `db:"width" json:"width"`
Height int `db:"height" json:"height"`
Thumb string `db:"thumb" json:"thumb"`
CreatedAt null.Time `db:"created_at" json:"created_at"`
ThumbURI string `json:"thumb_uri"`
URI string `json:"uri"`
ThumbURL string `json:"thumb_url"`
Provider string `json:"provider"`
URL string `json:"url"`
}

// Store represents set of methods to perform upload/delete operations.
// Store represents functions to store and retrieve media (files).
type Store interface {
Put(string, string, io.ReadSeeker) (string, error)
Delete(string) error
Expand Down
25 changes: 13 additions & 12 deletions internal/media/providers/filesystem/filesystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,20 @@ const tmpFilePrefix = "listmonk"
type Opts struct {
UploadPath string `koanf:"upload_path"`
UploadURI string `koanf:"upload_uri"`
RootURL string `koanf:"root_url"`
}

// Client implements `media.Store`
type Client struct {
opts Opts
}

// This matches filenames, sans extensions, of the format
// filename_(number). The number is incremented in case
// new file uploads conflict with existing filenames
// on the filesystem.
var fnameRegexp = regexp.MustCompile(`(.+?)_([0-9]+)$`)

// NewDiskStore initialises store for Filesystem provider.
func NewDiskStore(opts Opts) (media.Store, error) {
return &Client{
Expand All @@ -34,7 +41,7 @@ func NewDiskStore(opts Opts) (media.Store, error) {
}

// Put accepts the filename, the content type and file object itself and stores the file in disk.
func (e *Client) Put(filename string, cType string, src io.ReadSeeker) (string, error) {
func (c *Client) Put(filename string, cType string, src io.ReadSeeker) (string, error) {
var out *os.File
// There's no explicit name. Use the one posted in the HTTP request.
if filename == "" {
Expand All @@ -44,7 +51,7 @@ func (e *Client) Put(filename string, cType string, src io.ReadSeeker) (string,
}
}
// Get the directory path
dir := getDir(e.opts.UploadPath)
dir := getDir(c.opts.UploadPath)
filename = assertUniqueFilename(dir, filename)
o, err := os.OpenFile(filepath.Join(dir, filename), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0664)
if err != nil {
Expand All @@ -60,26 +67,20 @@ func (e *Client) Put(filename string, cType string, src io.ReadSeeker) (string,
}

// Get accepts a filename and retrieves the full path from disk.
func (e *Client) Get(name string) string {
return fmt.Sprintf("%s/%s", e.opts.UploadURI, name)
func (c *Client) Get(name string) string {
return fmt.Sprintf("%s%s/%s", c.opts.RootURL, c.opts.UploadURI, name)
}

// Delete accepts a filename and removes it from disk.
func (e *Client) Delete(file string) error {
dir := getDir(e.opts.UploadPath)
func (c *Client) Delete(file string) error {
dir := getDir(c.opts.UploadPath)
err := os.Remove(filepath.Join(dir, file))
if err != nil {
return err
}
return nil
}

// This matches filenames, sans extensions, of the format
// filename_(number). The number is incremented in case
// new file uploads conflict with existing filenames
// on the filesystem.
var fnameRegexp = regexp.MustCompile(`(.+?)_([0-9]+)$`)

// assertUniqueFilename takes a file path and check if it exists on the disk. If it doesn't,
// it returns the same name and if it does, it adds a small random hash to the filename
// and returns that.
Expand Down
54 changes: 31 additions & 23 deletions internal/media/providers/s3/s3.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import (
"errors"
"fmt"
"io"
"strings"
"time"

"github.com/knadh/listmonk/internal/media"
"github.com/rhnvrm/simples3"
)

const amznS3PublicURL = "https://%s.s3.%s.amazonaws.com/%s"
const amznS3PublicURL = "https://%s.s3.%s.amazonaws.com%s"

// Opts represents AWS S3 specific params
type Opts struct {
Expand All @@ -19,6 +20,7 @@ type Opts struct {
Region string `koanf:"aws_default_region"`
Bucket string `koanf:"bucket"`
BucketPath string `koanf:"bucket_path"`
BucketURL string `koanf:"bucket_url"`
BucketType string `koanf:"bucket_type"`
Expiry int `koanf:"expiry"`
}
Expand Down Expand Up @@ -54,55 +56,61 @@ func NewS3Store(opts Opts) (media.Store, error) {
}

// Put takes in the filename, the content type and file object itself and uploads to S3.
func (e *Client) Put(name string, cType string, file io.ReadSeeker) (string, error) {
func (c *Client) Put(name string, cType string, file io.ReadSeeker) (string, error) {
// Upload input parameters
upParams := simples3.UploadInput{
Bucket: e.opts.Bucket,
ObjectKey: getBucketPath(e.opts.BucketPath, name),
Bucket: c.opts.Bucket,
ContentType: cType,
FileName: name,
Body: file,

// Paths inside the bucket should not start with /.
ObjectKey: strings.TrimPrefix(makeBucketPath(c.opts.BucketPath, name), "/"),
}
// Perform an upload.
_, err := e.s3.FileUpload(upParams)
if err != nil {
if _, err := c.s3.FileUpload(upParams); err != nil {
return "", err
}
return name, nil
}

// Get accepts the filename of the object stored and retrieves from S3.
func (e *Client) Get(name string) string {
func (c *Client) Get(name string) string {
// Generate a private S3 pre-signed URL if it's a private bucket.
if e.opts.BucketType == "private" {
url := e.s3.GeneratePresignedURL(simples3.PresignedInput{
Bucket: e.opts.Bucket,
ObjectKey: getBucketPath(e.opts.BucketPath, name),
if c.opts.BucketType == "private" {
url := c.s3.GeneratePresignedURL(simples3.PresignedInput{
Bucket: c.opts.Bucket,
ObjectKey: makeBucketPath(c.opts.BucketPath, name),
Method: "GET",
Timestamp: time.Now(),
ExpirySeconds: e.opts.Expiry,
ExpirySeconds: c.opts.Expiry,
})
return url
}

// Generate a public S3 URL if it's a public bucket.
url := fmt.Sprintf(amznS3PublicURL, e.opts.Bucket, e.opts.Region, getBucketPath(e.opts.BucketPath, name))
url := ""
if c.opts.BucketURL != "" {
url = c.opts.BucketURL + makeBucketPath(c.opts.BucketPath, name)
} else {
url = fmt.Sprintf(amznS3PublicURL, c.opts.Bucket, c.opts.Region,
makeBucketPath(c.opts.BucketPath, name))
}
return url
}

// Delete accepts the filename of the object and deletes from S3.
func (e *Client) Delete(name string) error {
err := e.s3.FileDelete(simples3.DeleteInput{
Bucket: e.opts.Bucket,
ObjectKey: getBucketPath(e.opts.BucketPath, name),
func (c *Client) Delete(name string) error {
err := c.s3.FileDelete(simples3.DeleteInput{
Bucket: c.opts.Bucket,
ObjectKey: strings.TrimPrefix(makeBucketPath(c.opts.BucketPath, name), "/"),
})
return err
}

// getBucketPath constructs the key for the object stored in S3.
// If path is empty, the key is the combination of root of S3 bucket and filename.
func getBucketPath(path string, name string) string {
if path == "" {
return fmt.Sprintf("%s", name)
func makeBucketPath(bucketPath string, name string) string {
if bucketPath == "/" {
return "/" + name
}
return fmt.Sprintf("%s/%s", path, name)
return fmt.Sprintf("%s/%s", bucketPath, name)
}
8 changes: 4 additions & 4 deletions media.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func handleUploadMedia(c echo.Context) error {
}

// Write to the DB.
if _, err := app.queries.InsertMedia.Exec(uu, fName, thumbfName, 0, 0); err != nil {
if _, err := app.queries.InsertMedia.Exec(uu, fName, thumbfName, app.constants.MediaProvider); err != nil {
cleanUp = true
app.log.Printf("error inserting uploaded file to db: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
Expand All @@ -115,14 +115,14 @@ func handleGetMedia(c echo.Context) error {
out = []media.Media{}
)

if err := app.queries.GetMedia.Select(&out); err != nil {
if err := app.queries.GetMedia.Select(&out, app.constants.MediaProvider); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching media list: %s", pqErrMsg(err)))
}

for i := 0; i < len(out); i++ {
out[i].URI = app.media.Get(out[i].Filename)
out[i].ThumbURI = app.media.Get(thumbPrefix + out[i].Filename)
out[i].URL = app.media.Get(out[i].Filename)
out[i].ThumbURL = app.media.Get(out[i].Thumb)
}

return c.JSON(http.StatusOK, okResp{out})
Expand Down
Loading

0 comments on commit 24192a3

Please sign in to comment.