diff --git a/config.go b/config.go index fe7eff9..ded2805 100644 --- a/config.go +++ b/config.go @@ -2,6 +2,7 @@ package main import ( "errors" + "strings" "github.com/spf13/viper" ) @@ -117,13 +118,21 @@ type frontmatter struct { } type configMicropub struct { - CategoryParam string `mapstructure:"categoryParam"` - ReplyParam string `mapstructure:"replyParam"` - LikeParam string `mapstructure:"likeParam"` - BookmarkParam string `mapstructure:"bookmarkParam"` - AudioParam string `mapstructure:"audioParam"` - PhotoParam string `mapstructure:"photoParam"` - PhotoDescriptionParam string `mapstructure:"photoDescriptionParam"` + CategoryParam string `mapstructure:"categoryParam"` + ReplyParam string `mapstructure:"replyParam"` + LikeParam string `mapstructure:"likeParam"` + BookmarkParam string `mapstructure:"bookmarkParam"` + AudioParam string `mapstructure:"audioParam"` + PhotoParam string `mapstructure:"photoParam"` + PhotoDescriptionParam string `mapstructure:"photoDescriptionParam"` + MediaStorage *configMicropubMedia `mapstructure:"mediaStorage"` +} + +type configMicropubMedia struct { + MediaURL string `mapstructure:"mediaUrl"` + BunnyStorageKey string `mapstructure:"bunnyStorageKey"` + BunnyStorageName string `mapstructure:"bunnyStorageName"` + TinifyKey string `mapstructure:"tinifyKey"` } var appConfig = &config{} @@ -172,5 +181,14 @@ func initConfig() error { if len(appConfig.DefaultBlog) == 0 || appConfig.Blogs[appConfig.DefaultBlog] == nil { return errors.New("no default blog or default blog not present") } + if appConfig.Micropub.MediaStorage != nil { + if appConfig.Micropub.MediaStorage.MediaURL == "" || + appConfig.Micropub.MediaStorage.BunnyStorageKey == "" || + appConfig.Micropub.MediaStorage.BunnyStorageName == "" { + appConfig.Micropub.MediaStorage = nil + } else { + appConfig.Micropub.MediaStorage.MediaURL = strings.TrimSuffix(appConfig.Micropub.MediaStorage.MediaURL, "/") + } + } return nil } diff --git a/go.mod b/go.mod index 6166b60..195e9fa 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module git.jlel.se/jlelse/GoBlog go 1.15 require ( + codeberg.org/jlelse/tinify v0.0.0-20200123222407-7fc9c21822b0 github.com/PuerkitoBio/goquery v1.6.0 github.com/andybalholm/cascadia v1.2.0 // indirect github.com/araddon/dateparse v0.0.0-20201001162425-8aadafed4dc4 @@ -22,7 +23,7 @@ require ( github.com/lopezator/migrator v0.3.0 github.com/magiconair/properties v1.8.4 // indirect github.com/mattn/go-sqlite3 v1.14.4 - github.com/miekg/dns v1.1.32 // indirect + github.com/miekg/dns v1.1.33 // indirect github.com/mitchellh/mapstructure v1.3.3 // indirect github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect github.com/pelletier/go-toml v1.8.1 // indirect @@ -43,8 +44,8 @@ require ( golang.org/x/lint v0.0.0-20200302205851-738671d3881b // indirect golang.org/x/net v0.0.0-20201010224723-4f7140c49acb // indirect golang.org/x/sync v0.0.0-20201008141435-b3e1573b7520 // indirect - golang.org/x/sys v0.0.0-20201013132646-2da7054afaeb // indirect - golang.org/x/tools v0.0.0-20201013183236-0112737ef124 // indirect + golang.org/x/sys v0.0.0-20201014080544-cc95f250f6bc // indirect + golang.org/x/tools v0.0.0-20201014170642-d1624618ad65 // indirect gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect gopkg.in/ini.v1 v1.62.0 // indirect gopkg.in/yaml.v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index 5084ebf..662ecaa 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7 cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +codeberg.org/jlelse/tinify v0.0.0-20200123222407-7fc9c21822b0 h1:pJX79kTd01NtxEnzhfd4OU2SY9fquKVoO47DUeNKe+8= +codeberg.org/jlelse/tinify v0.0.0-20200123222407-7fc9c21822b0/go.mod h1:X6cM4Sn0aL/4VQ/ku11yxmiV0WIk5XAaYEPHQLQjFFM= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -194,8 +196,8 @@ github.com/mholt/acmez v0.1.1 h1:KQODCqk+hBn3O7qfCRPj6L96uG65T5BSS95FKNEqtdA= github.com/mholt/acmez v0.1.1/go.mod h1:8qnn8QA/Ewx8E3ZSsmscqsIjhhpxuy9vqdgbX2ceceM= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.30/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= -github.com/miekg/dns v1.1.32 h1:MDaYYzWOYscpvDOEgPMT1c1mebCZmIdxZI/J161OdJU= -github.com/miekg/dns v1.1.32/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/miekg/dns v1.1.33 h1:8KUVEKrUw2dmu1Ys0aWnkEJgoRaLAzNysfCh2KSMWiI= +github.com/miekg/dns v1.1.33/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= @@ -414,8 +416,8 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c h1:UIcGWL6/wpCfyGuJnRFJRurA+yj8RrW7Q6x2YMCXt6c= golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201013132646-2da7054afaeb h1:HS9IzC4UFbpMBLQUDSQcU+ViVT1vdFCQVjdPVpTlZrs= -golang.org/x/sys v0.0.0-20201013132646-2da7054afaeb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201014080544-cc95f250f6bc h1:HVFDs9bKvTxP6bh1Rj9MCSo+UmafQtI8ZWDPVwVk9g4= +golang.org/x/sys v0.0.0-20201014080544-cc95f250f6bc/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= @@ -447,8 +449,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200410194907-79a7a3126eef/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20201013183236-0112737ef124 h1:CQRvWGvGfDDk3OMpDX19jvU16tKf5RlqlJc+ki94ZJs= -golang.org/x/tools v0.0.0-20201013183236-0112737ef124/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= +golang.org/x/tools v0.0.0-20201014170642-d1624618ad65 h1:q80OtYaeeySe8Kqg0vjXehHwj5fUTqe3xOvnbi5w3Gg= +golang.org/x/tools v0.0.0-20201014170642-d1624618ad65/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/http.go b/http.go index 85e82fc..b382ca9 100644 --- a/http.go +++ b/http.go @@ -85,7 +85,9 @@ func buildHandler() (http.Handler, error) { mpRouter.Use(middleware.NoCache, checkIndieAuth) mpRouter.Get("/", serveMicropubQuery) mpRouter.Post("/", serveMicropubPost) - mpRouter.Post(micropubMediaSubPath, serveMicropubMedia) + if appConfig.Micropub.MediaStorage != nil { + mpRouter.Post(micropubMediaSubPath, serveMicropubMedia) + } }) // IndieAuth diff --git a/micropub.go b/micropub.go index 83d24f2..af9a1ac 100644 --- a/micropub.go +++ b/micropub.go @@ -15,7 +15,6 @@ import ( ) const micropubPath = "/micropub" -const micropubMediaSubPath = "/media" type micropubConfig struct { MediaEndpoint string `json:"media-endpoint,omitempty"` @@ -26,10 +25,11 @@ func serveMicropubQuery(w http.ResponseWriter, r *http.Request) { case "config": w.Header().Add(contentType, contentTypeJSON) w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(µpubConfig{ - // TODO: Uncomment when media endpoint implemented - // MediaEndpoint: appConfig.Server.PublicAddress + micropubMediaPath, - }) + mc := µpubConfig{} + if appConfig.Micropub.MediaStorage != nil { + mc.MediaEndpoint = appConfig.Server.PublicAddress + micropubPath + micropubMediaSubPath + } + _ = json.NewEncoder(w).Encode(mc) case "source": var mf interface{} if urlString := r.URL.Query().Get("url"); urlString != "" { @@ -516,7 +516,3 @@ func micropubUpdate(w http.ResponseWriter, r *http.Request, u *url.URL, mf *micr return } } - -func serveMicropubMedia(w http.ResponseWriter, r *http.Request) { - // TODO: Implement media server -} diff --git a/micropubMedia.go b/micropubMedia.go new file mode 100644 index 0000000..bb74071 --- /dev/null +++ b/micropubMedia.go @@ -0,0 +1,147 @@ +package main + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "io/ioutil" + "mime" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + + tfgo "codeberg.org/jlelse/tinify" +) + +const micropubMediaSubPath = "/media" + +func serveMicropubMedia(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Context().Value("scope").(string), "media") { + http.Error(w, "media scope missing", http.StatusForbidden) + } + if appConfig.Micropub.MediaStorage == nil { + http.Error(w, "Not configured", http.StatusNotImplemented) + return + } + if ct := r.Header.Get(contentType); !strings.Contains(ct, contentTypeMultipartForm) { + http.Error(w, "wrong content-type", http.StatusBadRequest) + return + } + err := r.ParseMultipartForm(0) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + file, header, err := r.FormFile("file") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer func() { _ = file.Close() }() + hashFile, _, _ := r.FormFile("file") + defer func() { _ = hashFile.Close() }() + fileName, err := getSHA256(hashFile) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + fileExtension := filepath.Ext(header.Filename) + if len(fileExtension) == 0 { + // Find correct file extension if original filename does not contain one + mimeType := header.Header.Get(contentType) + if len(mimeType) > 0 { + allExtensions, _ := mime.ExtensionsByType(mimeType) + if len(allExtensions) > 0 { + fileExtension = allExtensions[0] + } + } + } + fileName += strings.ToLower(fileExtension) + location, err := appConfig.Micropub.MediaStorage.uploadToBunny(fileName, file) + if err != nil { + http.Error(w, "failed to upload original file: "+err.Error(), http.StatusInternalServerError) + return + } + if appConfig.Micropub.MediaStorage.TinifyKey != "" { + compressedLocation, err := appConfig.Micropub.MediaStorage.tinify(location) + if err != nil { + http.Error(w, "failed to compress file: "+err.Error(), http.StatusInternalServerError) + return + } else if compressedLocation != "" { + location = compressedLocation + } + } + w.Header().Add("Location", location) + w.WriteHeader(http.StatusCreated) +} + +func (mediaConf *configMicropubMedia) uploadToBunny(filename string, file multipart.File) (location string, err error) { + req, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("https://storage.bunnycdn.com/%s/%s", url.PathEscape(mediaConf.BunnyStorageName), url.PathEscape(filename)), file) + req.Header.Add("AccessKey", mediaConf.BunnyStorageKey) + resp, err := http.DefaultClient.Do(req) + if err != nil || resp.StatusCode != http.StatusCreated { + return "", errors.New("failed to upload file to BunnyCDN") + } + return mediaConf.MediaURL + "/" + filename, nil +} + +func (mediaConf *configMicropubMedia) tinify(url string) (location string, err error) { + fileExtension := func() string { + spliced := strings.Split(url, ".") + return spliced[len(spliced)-1] + }() + supportedTypes := []string{"jpg", "jpeg", "png"} + sort.Strings(supportedTypes) + i := sort.SearchStrings(supportedTypes, strings.ToLower(fileExtension)) + if !(i < len(supportedTypes) && supportedTypes[i] == strings.ToLower(fileExtension)) { + return "", nil + } + tfgo.SetKey(mediaConf.TinifyKey) + s, err := tfgo.FromUrl(url) + if err != nil { + return "", err + } + err = s.Resize(&tfgo.ResizeOption{ + Method: tfgo.ResizeMethodScale, + Width: 2000, + }) + if err != nil { + return "", err + } + file, err := ioutil.TempFile("", "tiny-*."+fileExtension) + if err != nil { + return "", err + } + defer func() { + _ = file.Close() + _ = os.Remove(file.Name()) + }() + err = s.ToFile(file.Name()) + if err != nil { + return "", err + } + hashFile, err := os.Open(file.Name()) + defer func() { _ = hashFile.Close() }() + if err != nil { + return "", err + } + fileName, err := getSHA256(hashFile) + if err != nil { + return "", err + } + location, err = mediaConf.uploadToBunny(fileName+"."+fileExtension, file) + return +} + +func getSHA256(file multipart.File) (filename string, err error) { + h := sha256.New() + if _, err = io.Copy(h, file); err != nil { + return "", err + } + return fmt.Sprintf("%x", h.Sum(nil)), nil +}