Compare commits

..

3 Commits

Author SHA1 Message Date
Kevin C. Coram 6aeb4836a2
Make media storage optional
If generating the SelectedMediaStorage configuration results in an
error (because there is no MediaUrl or Bunny CDN is not configured)
then don't configure the `/media` end-point, and make sure that the
`/micropub?q=config` query does not return a media-endpoint URL.
2020-01-04 21:58:48 -05:00
Kevin C. Coram 802109cf31
Better support for Quill's "favorite" request
Quill doesn't send a `content` parameter along with the `like-of` data
when performing a "favorite" request.
2020-01-04 21:58:35 -05:00
Kevin C. Coram e0fc3147f7
Ignore `hugo-micropub` binary file 2020-01-04 21:13:50 -05:00
23 changed files with 1011 additions and 815 deletions

View File

@ -1,20 +0,0 @@
kind: pipeline
name: default
steps:
- name: publish
image: plugins/docker
settings:
username:
from_secret: docker_username
password:
from_secret: docker_password
repo: quay.io/jlelse/hugo-micropub
registry: quay.io
tags: latest
when:
branch:
- master
event:
exclude:
- pull_request

1
.gitignore vendored
View File

@ -1,4 +1,3 @@
.idea
*.iml
config.yml
hugo-micropub

View File

@ -1,9 +1,9 @@
FROM golang:1.15-alpine3.12 as build
FROM golang:1.13-alpine as build
ADD . /app
WORKDIR /app
RUN go build
FROM alpine:3.12
FROM alpine:3.11
RUN apk add --no-cache tzdata ca-certificates
COPY --from=build /app/hugo-micropub /bin/
EXPOSE 5555

27
cdn.go Normal file
View File

@ -0,0 +1,27 @@
package main
import (
"net/http"
)
type Cdn interface {
// Purge url from CDN
Purge(url string)
}
// BunnyCDN
type BunnyCdn struct {
// Access Key
key string
}
var bunnyCdnUrl = "https://bunnycdn.com"
func (cdn *BunnyCdn) Purge(url string) {
client := &http.Client{}
req, _ := http.NewRequest("POST", bunnyCdnUrl+"/api/purge?url="+url, nil)
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Accept", "application/json")
req.Header.Add("AccessKey", cdn.key)
_, _ = client.Do(req)
}

30
cdn_test.go Normal file
View File

@ -0,0 +1,30 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestBunnyCdn_Purge(t *testing.T) {
cdn := &BunnyCdn{
key: "testkey",
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if expectedUrl := "/api/purge?url=https://test.url/test"; r.URL.String() != expectedUrl {
t.Errorf("Wrong URL: Got %s, but expected %s", r.URL.String(), expectedUrl)
}
if expectedContentType := "application/json"; r.Header.Get("Content-Type") != expectedContentType {
t.Errorf("Wrong Content-Type Header: Got %s, but expected %s", r.Header.Get("Content-Type"), expectedContentType)
}
if expectedAccept := "application/json"; r.Header.Get("Accept") != expectedAccept {
t.Errorf("Wrong Accept Header: Got %s, but expected %s", r.Header.Get("Accept"), expectedAccept)
}
if r.Header.Get("AccessKey") != cdn.key {
t.Errorf("Wrong AccessKey Header: Got %s, but expected %s", r.Header.Get("AccessKey"), cdn.key)
}
}))
defer ts.Close()
bunnyCdnUrl = ts.URL
cdn.Purge("https://test.url/test")
}

196
config.go
View File

@ -2,23 +2,20 @@ package main
import (
"errors"
"github.com/caarlos0/env/v6"
"log"
"os"
"strings"
"sync"
"gopkg.in/yaml.v3"
)
var (
BlogUrl string
MediaEndpointUrl string
IgnoredWebmentionUrls []string
SyndicationTargets []SyndicationTarget
SelectedStorage Storage
SelectedMediaStorage MediaStorage
SelectedImageCompression ImageCompression
DefaultLanguage string
Languages map[string]Language
SelectedCdn Cdn
SelectedSocials Socials
SelectedNotificationServices NotificationServices
MediaEndpointUrl string
)
type SyndicationTarget struct {
@ -26,101 +23,34 @@ type SyndicationTarget struct {
Name string `json:"name"`
}
type YamlConfig struct {
BlogUrl string `yaml:"blogUrl"`
BaseUrl string `yaml:"baseUrl"`
MediaUrl string `yaml:"mediaUrl"`
DefaultLanguage string `yaml:"defaultLang"`
Languages map[string]Language `yaml:"languages"`
Git GitConfig `yaml:"git"`
BunnyCdn BunnyCdnConfig `yaml:"bunnyCdn"`
Tinify TinifyConfig `yaml:"tinify"`
SyndicationTargets []string `yaml:"syndication"`
}
type BunnyCdnConfig struct {
StorageKey string `yaml:"storageKey"`
StorageName string `yaml:"storageName"`
}
type TinifyConfig struct {
Key string `yaml:"key"`
}
type GitConfig struct {
Filepath string `yaml:"filepath"`
Url string `yaml:"url"`
Username string `yaml:"username"`
Password string `yaml:"password"`
AuthorName string `yaml:"authorName"`
AuthorEmail string `yaml:"authorEmail"`
}
type Language struct {
BlogUrl string `yaml:"blogUrl"`
ContentDir string `yaml:"contentDir"`
DefaultSection string `yaml:"defaultSection"`
Sections map[string]Section `yaml:"sections"`
}
type Section struct {
FilenameTemplate string `yaml:"file"`
LocationTemplate string `yaml:"location"`
type config struct {
BlogUrl string `env:"BLOG_URL,required"`
BaseUrl string `env:"BASE_URL,required"`
MediaUrl string `env:"MEDIA_URL"`
GiteaEndpoint string `env:"GITEA_ENDPOINT"`
GiteaToken string `env:"GITEA_TOKEN"`
BunnyCdnKey string `env:"BUNNY_CDN_KEY"`
BunnyCdnStorageKey string `env:"BUNNY_CDN_STORAGE_KEY"`
BunnyCdnStorageName string `env:"BUNNY_CDN_STORAGE_NAME"`
MicroblogUrl string `env:"MICROBLOG_URL"`
MicroblogToken string `env:"MICROBLOG_TOKEN"`
TelegramUserId int64 `env:"TELEGRAM_USER_ID"`
TelegramBotToken string `env:"TELEGRAM_BOT_TOKEN"`
IgnoredWebmentionUrls []string `env:"WEBMENTION_IGNORED" envSeparator:","`
SyndicationTargets []string `env:"SYNDICATION" envSeparator:","`
}
func initConfig() (err error) {
configFileName, configSet := os.LookupEnv("CONFIG")
if !configSet {
configFileName = "config.yml"
}
configFile, err := os.Open(configFileName)
if err != nil {
return errors.New("failed to open config file")
}
cfg := YamlConfig{}
err = yaml.NewDecoder(configFile).Decode(&cfg)
if err != nil {
return errors.New("failed to parse yaml")
cfg := config{}
if err := env.Parse(&cfg); err != nil {
return errors.New("failed to parse config, probably not all required env vars set")
}
// Blog URL (required)
if len(cfg.BlogUrl) < 1 {
return errors.New("blogUrl not configured")
}
if !strings.HasSuffix(cfg.BlogUrl, "/") {
return errors.New("missing trailing slash in configured blogUrl")
}
BlogUrl = cfg.BlogUrl
// Media endpoint (required)
if len(cfg.BaseUrl) < 1 {
return errors.New("baseUrl not configured")
}
if len(cfg.MediaUrl) < 1 {
return errors.New("mediaUrl not configured")
}
if !strings.HasSuffix(cfg.BaseUrl, "/") {
return errors.New("missing trailing slash in configured baseUrl")
}
MediaEndpointUrl = cfg.BaseUrl + "media"
// Languages (required)
if len(cfg.DefaultLanguage) < 1 {
return errors.New("no default language configured")
}
DefaultLanguage = cfg.DefaultLanguage
if len(cfg.Languages) > 0 {
for _, lang := range cfg.Languages {
if len(lang.ContentDir) < 1 || len(lang.DefaultSection) < 1 || len(lang.Sections) < 1 {
return errors.New("language not completely configured")
}
for _, section := range lang.Sections {
if len(section.FilenameTemplate) < 1 || len(section.LocationTemplate) < 1 {
return errors.New("section not completely configured")
}
}
}
Languages = cfg.Languages
} else {
return errors.New("no languages configured")
}
// Media endpoint
MediaEndpointUrl = cfg.BaseUrl + "/media"
// Ignored Webmention URLs (optional)
IgnoredWebmentionUrls = cfg.IgnoredWebmentionUrls
// Syndication Targets (optional)
targets := make([]SyndicationTarget, 0)
for _, url := range cfg.SyndicationTargets {
@ -132,16 +62,11 @@ func initConfig() (err error) {
SyndicationTargets = targets
// Find selected storage
SelectedStorage = func() Storage {
// Git
if len(cfg.Git.Filepath) > 0 && len(cfg.Git.Url) > 0 && len(cfg.Git.Username) > 0 && len(cfg.Git.Password) > 0 && len(cfg.Git.AuthorName) > 0 && len(cfg.Git.AuthorEmail) > 0 {
return &Git{
filepath: cfg.Git.Filepath,
url: cfg.Git.Url,
username: cfg.Git.Username,
password: cfg.Git.Password,
name: cfg.Git.AuthorName,
email: cfg.Git.AuthorEmail,
lock: &sync.Mutex{},
// Gitea
if len(cfg.GiteaEndpoint) > 0 && len(cfg.GiteaToken) >= 0 {
return &Gitea{
endpoint: cfg.GiteaEndpoint,
token: cfg.GiteaToken,
}
}
return nil
@ -149,14 +74,13 @@ func initConfig() (err error) {
if SelectedStorage == nil {
return errors.New("no storage configured")
}
// Find selected media storage (Optional)
// Find selected media storage
SelectedMediaStorage = func() MediaStorage {
// BunnyCDN
// MEDIA_URL needs trailing slash too
if len(cfg.BunnyCdn.StorageKey) > 0 && len(cfg.BunnyCdn.StorageName) > 0 && len(cfg.MediaUrl) > 0 && strings.HasSuffix(cfg.MediaUrl, "/") {
if len(cfg.BunnyCdnStorageKey) > 0 && len(cfg.BunnyCdnStorageName) > 0 && len(cfg.MediaUrl) > 0 {
return &BunnyCdnStorage{
key: cfg.BunnyCdn.StorageKey,
storageZoneName: cfg.BunnyCdn.StorageName,
key: cfg.BunnyCdnStorageKey,
storageZoneName: cfg.BunnyCdnStorageName,
baseLocation: cfg.MediaUrl,
}
}
@ -165,18 +89,46 @@ func initConfig() (err error) {
if SelectedMediaStorage == nil {
log.Println("no media storage configured")
}
// Find configured image compression service (optional)
SelectedImageCompression = func() ImageCompression {
// Tinify
if len(cfg.Tinify.Key) > 0 {
return &Tinify{
key: cfg.Tinify.Key,
}
// Find selected CDN (optional)
SelectedCdn = func() Cdn {
// BunnyCDN (optional)
if len(cfg.BunnyCdnKey) > 0 {
return &BunnyCdn{key: cfg.BunnyCdnKey}
}
return nil
}()
if SelectedImageCompression == nil {
log.Println("no image compression configured")
if SelectedCdn == nil {
log.Println("no CDN configured")
}
// Find configured social networks (optional)
SelectedSocials = func() Socials {
var socials []Social = nil
// Microblog.pub
if len(cfg.MicroblogUrl) > 0 && len(cfg.MicroblogToken) > 0 {
socials = append(socials, &MicroblogPub{
url: cfg.MicroblogUrl,
token: cfg.MicroblogToken,
})
}
return socials
}()
if SelectedSocials == nil {
log.Println("no social networks configured")
}
// Find configured notification services (optional)
SelectedNotificationServices = func() NotificationServices {
var notificationServices []NotificationService = nil
// Telegram
if cfg.TelegramUserId > 0 && len(cfg.TelegramBotToken) > 0 {
notificationServices = append(notificationServices, &Telegram{
userId: cfg.TelegramUserId,
botToken: cfg.TelegramBotToken,
})
}
return notificationServices
}()
if SelectedNotificationServices == nil {
log.Println("No notification services configured")
}
return nil
}

222
entry.go
View File

@ -6,10 +6,11 @@ import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"math/rand"
"net/http"
"net/url"
"strings"
"text/template"
"time"
)
@ -20,7 +21,6 @@ type Entry struct {
lastmod string
section string
tags []string
series []string
link string
slug string
replyLink string
@ -28,27 +28,22 @@ type Entry struct {
likeLink string
likeTitle string
syndicate []string
language string
translationKey string
images []Image
audio string
filename string
location string
token string
}
type Image struct {
url string
alt string
}
func CreateEntry(contentType ContentType, r *http.Request) (*Entry, error) {
if contentType == WwwForm {
err := r.ParseForm()
bodyString, err := parseRequestBody(r)
if err != nil {
return nil, errors.New("failed to parse Form")
return nil, err
}
return createEntryFromValueMap(r.Form)
bodyValues, err := url.ParseQuery(bodyString)
if err != nil {
return nil, errors.New("failed to parse query")
}
return createEntryFromValueMap(bodyValues)
} else if contentType == Multipart {
err := r.ParseMultipartForm(1024 * 1024 * 16)
if err != nil {
@ -56,9 +51,12 @@ func CreateEntry(contentType ContentType, r *http.Request) (*Entry, error) {
}
return createEntryFromValueMap(r.MultipartForm.Value)
} else if contentType == Json {
decoder := json.NewDecoder(r.Body)
bodyString, err := parseRequestBody(r)
if err != nil {
return nil, err
}
parsedMfItem := &MicroformatItem{}
err := decoder.Decode(&parsedMfItem)
err = json.Unmarshal([]byte(bodyString), &parsedMfItem)
if err != nil {
return nil, errors.New("failed to parse Json")
}
@ -68,10 +66,29 @@ func CreateEntry(contentType ContentType, r *http.Request) (*Entry, error) {
}
}
func parseRequestBody(r *http.Request) (string, error) {
defer r.Body.Close()
bodyBytes, err := ioutil.ReadAll(r.Body)
if err != nil {
return "", errors.New("failed to read body")
}
return string(bodyBytes), nil
}
func validEntry(values map[string][]string) bool {
if _, ok := values["content"]; ok {
return true
} else if _, ok := values["like-of"]; ok {
return true
}
return false;
}
func createEntryFromValueMap(values map[string][]string) (*Entry, error) {
if h, ok := values["h"]; ok && (len(h) != 1 || h[0] != "entry") {
return nil, errors.New("only entry type is supported so far")
}
if validEntry(values) {
entry := &Entry{}
if content, ok := values["content"]; ok {
entry.content = content[0]
@ -83,6 +100,8 @@ func createEntryFromValueMap(values map[string][]string) (*Entry, error) {
entry.tags = category
} else if categories, ok := values["category[]"]; ok {
entry.tags = categories
} else {
entry.tags = nil
}
if slug, ok := values["mp-slug"]; ok && len(slug) > 0 && slug[0] != "" {
entry.slug = slug[0]
@ -100,29 +119,8 @@ func createEntryFromValueMap(values map[string][]string) (*Entry, error) {
entry.syndicate = syndicate
} else if syndicates, ok := values["mp-syndicate-to[]"]; ok {
entry.syndicate = syndicates
}
if photo, ok := values["photo"]; ok {
entry.images = append(entry.images, Image{url: photo[0]})
} else if photos, ok := values["photo[]"]; ok {
for _, photo := range photos {
entry.images = append(entry.images, Image{url: photo})
}
}
if photoAlt, ok := values["mp-photo-alt"]; ok {
if len(entry.images) > 0 {
entry.images[0].alt = photoAlt[0]
}
} else if photoAlts, ok := values["mp-photo-alt[]"]; ok {
for i, photoAlt := range photoAlts {
if len(entry.images) > i {
entry.images[i].alt = photoAlt
}
}
}
if audio, ok := values["audio"]; ok {
entry.audio = audio[0]
} else if audio, ok := values["audio[]"]; ok {
entry.audio = audio[0]
} else {
entry.syndicate = nil
}
if token, ok := values["access_token"]; ok {
entry.token = "Bearer " + token[0]
@ -133,14 +131,16 @@ func createEntryFromValueMap(values map[string][]string) (*Entry, error) {
}
return entry, nil
}
return nil, errors.New("error parsing the entry")
}
func createEntryFromMicroformat(mfEntry *MicroformatItem) (*Entry, error) {
if len(mfEntry.Type) != 1 || mfEntry.Type[0] != "h-entry" {
return nil, errors.New("only entry type is supported so far")
}
entry := &Entry{}
if mfEntry.Properties != nil && len(mfEntry.Properties.Content) == 1 && len(mfEntry.Properties.Content[0]) > 0 {
entry.content = mfEntry.Properties.Content[0]
entry := &Entry{
content: mfEntry.Properties.Content[0],
}
if len(mfEntry.Properties.Name) == 1 {
entry.title = mfEntry.Properties.Name[0]
@ -163,32 +163,14 @@ func createEntryFromMicroformat(mfEntry *MicroformatItem) (*Entry, error) {
if len(mfEntry.Properties.MpSyndicateTo) > 0 {
entry.syndicate = mfEntry.Properties.MpSyndicateTo
}
if len(mfEntry.Properties.Photo) > 0 {
for _, photo := range mfEntry.Properties.Photo {
if theString, justString := photo.(string); justString {
entry.images = append(entry.images, Image{url: theString})
} else if thePhoto, isPhoto := photo.(map[string]interface{}); isPhoto {
image := Image{}
// Micropub spec says "value" is correct, but not sure about that
if photoUrl, ok := thePhoto["value"].(string); ok {
image.url = photoUrl
}
if alt, ok := thePhoto["alt"].(string); ok {
image.alt = alt
}
entry.images = append(entry.images, image)
}
}
}
if len(mfEntry.Properties.Audio) > 0 {
entry.audio = mfEntry.Properties.Audio[0]
}
err := computeExtraSettings(entry)
if err != nil {
return nil, err
}
return entry, nil
}
return nil, errors.New("error parsing the entry")
}
func computeExtraSettings(entry *Entry) error {
now := time.Now()
@ -211,9 +193,6 @@ func computeExtraSettings(entry *Entry) error {
} else if strings.HasPrefix(text, "tags: ") {
// Tags
entry.tags = strings.Split(strings.TrimPrefix(text, "tags: "), ",")
} else if strings.HasPrefix(text, "series: ") {
// Series
entry.series = strings.Split(strings.TrimPrefix(text, "series: "), ",")
} else if strings.HasPrefix(text, "link: ") {
// Link
entry.link = strings.TrimPrefix(text, "link: ")
@ -229,96 +208,28 @@ func computeExtraSettings(entry *Entry) error {
} else if strings.HasPrefix(text, "like-title: ") {
// Like title
entry.likeTitle = strings.TrimPrefix(text, "like-title: ")
} else if strings.HasPrefix(text, "language: ") {
// Language
entry.language = strings.TrimPrefix(text, "language: ")
} else if strings.HasPrefix(text, "translationkey: ") {
// Translation key
entry.translationKey = strings.TrimPrefix(text, "translationkey: ")
} else if strings.HasPrefix(text, "images: ") {
// Images
for _, image := range strings.Split(strings.TrimPrefix(text, "images: "), ",") {
entry.images = append(entry.images, Image{url: image})
}
} else if strings.HasPrefix(text, "image-alts: ") {
// Image alt
for i, alt := range strings.Split(strings.TrimPrefix(text, "image-alts: "), ",") {
entry.images[i].alt = alt
}
} else if strings.HasPrefix(text, "audio: ") {
// Audio
entry.audio = strings.TrimPrefix(text, "audio: ")
} else {
_, _ = fmt.Fprintln(&filteredContent, text)
}
}
entry.content = filteredContent.String()
// Check if content contains images or add them
for _, image := range entry.images {
if !strings.Contains(entry.content, image.url) {
if len(image.alt) > 0 {
entry.content += "\n![" + image.alt + "](" + image.url + " \"" + image.alt + "\")\n"
} else {
entry.content += "\n![](" + image.url + ")\n"
}
}
}
// Compute slug if empty
if len(entry.slug) == 0 || entry.slug == "" {
random := generateRandomString(now, 5)
entry.slug = fmt.Sprintf("%v-%02d-%02d-%v", now.Year(), int(now.Month()), now.Day(), random)
}
// Set language
if len(entry.language) == 0 {
entry.language = DefaultLanguage
}
// Compute filename and location
lang := Languages[entry.language]
contentFolder := lang.ContentDir
localizedBlogUrl := BlogUrl
if len(lang.BlogUrl) != 0 {
localizedBlogUrl = lang.BlogUrl
}
if len(entry.section) == 0 {
entry.section = lang.DefaultSection
if len(entry.section) < 1 {
entry.section = "micro"
}
entry.section = strings.ToLower(entry.section)
section := lang.Sections[entry.section]
pathVars := struct {
LocalContentFolder string
LocalBlogUrl string
Year int
Month int
Slug string
Section string
}{
LocalContentFolder: contentFolder,
LocalBlogUrl: localizedBlogUrl,
Year: now.Year(),
Month: int(now.Month()),
Slug: entry.slug,
Section: entry.section,
if entry.section == "thoughts" || entry.section == "links" || entry.section == "micro" {
entry.filename = fmt.Sprintf("content/%v/%02d/%02d/%v.md", entry.section, now.Year(), int(now.Month()), entry.slug)
entry.location = fmt.Sprintf("%v%v/%02d/%02d/%v/", BlogUrl, entry.section, now.Year(), int(now.Month()), entry.slug)
} else {
entry.filename = fmt.Sprintf("content/%v/%v.md", entry.section, entry.slug)
entry.location = fmt.Sprintf("%v%v/%v/", BlogUrl, entry.section, entry.slug)
}
filenameTmpl, err := template.New("filename").Parse(section.FilenameTemplate)
if err != nil {
return errors.New("failed to parse filename template")
}
filename := new(bytes.Buffer)
err = filenameTmpl.Execute(filename, pathVars)
if err != nil {
return errors.New("failed to execute filename template")
}
entry.filename = filename.String()
locationTmpl, err := template.New("location").Parse(section.LocationTemplate)
if err != nil {
return errors.New("failed to parse location template")
}
location := new(bytes.Buffer)
err = locationTmpl.Execute(location, pathVars)
if err != nil {
return errors.New("failed to execute location template")
}
entry.location = location.String()
return nil
}
@ -344,3 +255,34 @@ func WriteEntry(entry *Entry) (location string, err error) {
location = entry.location
return
}
func analyzeURL(url string) (filePath string, section string, slug string, err error) {
if !strings.HasPrefix(url, BlogUrl) {
return
}
path := strings.TrimSuffix(strings.TrimPrefix(url, BlogUrl), "/")
pathParts := strings.Split(path, "/")
filePath = "content/" + path + ".md"
section = pathParts[0]
slug = pathParts[len(pathParts)-1]
return
}
func ReadEntry(url string) (entry *Entry, err error) {
filePath, section, slug, err := analyzeURL(url)
if err != nil {
return
}
fileContent, _, exists, err := SelectedStorage.ReadFile(filePath)
if err != nil || !exists {
return
}
entry, err = ReadHugoPost(fileContent)
if entry != nil {
entry.location = url
entry.filename = filePath
entry.section = section
entry.slug = slug
}
return
}

24
go.mod
View File

@ -1,21 +1,11 @@
module git.jlel.se/jlelse/hugo-micropub
module codeberg.org/jlelse/hugo-micropub
go 1.14
go 1.13
require (
codeberg.org/jlelse/tinify v0.0.0-20200123222407-7fc9c21822b0
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/gliderlabs/ssh v0.3.0 // indirect
github.com/go-git/go-git/v5 v5.1.0
github.com/google/go-cmp v0.5.2 // indirect
github.com/imdario/mergo v0.3.11 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/stretchr/testify v1.6.1 // indirect
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a // indirect
golang.org/x/net v0.0.0-20200904194848-62affa334b73 // indirect
golang.org/x/sys v0.0.0-20200909081042-eff7692f9009 // indirect
golang.org/x/text v0.3.3 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776
github.com/caarlos0/env/v6 v6.1.0
github.com/go-telegram-bot-api/telegram-bot-api v4.6.5-0.20190904012038-b33efeebc785+incompatible
github.com/technoweenie/multipartstreamer v1.0.1 // indirect
gopkg.in/yaml.v2 v2.2.7
willnorris.com/go/webmention v0.0.0-20191104072158-c7fb13569b62
)

123
go.sum
View File

@ -1,115 +1,26 @@
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=
github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs=
github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o=
github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/caarlos0/env/v6 v6.1.0 h1:4FbM+HmZA/Q5wdSrH2kj0KQXm7xnhuO8y3TuOTnOvqc=
github.com/caarlos0/env/v6 v6.1.0/go.mod h1:iUA6X3VCAOwDhoqvgKlTGjjwJzQseIJaFYApUqQkt+8=
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/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0=
github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
github.com/gliderlabs/ssh v0.3.0 h1:7GcKy4erEljCE/QeQ2jTVpu+3f3zkpZOxOJjFYkMqYU=
github.com/gliderlabs/ssh v0.3.0/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
github.com/go-git/go-billy/v5 v5.0.0 h1:7NQHvd9FVid8VL4qVUMm8XifBK+2xCoZ2lSk0agRrHM=
github.com/go-git/go-billy/v5 v5.0.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
github.com/go-git/go-git-fixtures/v4 v4.0.1 h1:q+IFMfLx200Q3scvt2hN79JsEzy4AmBTp/pqnefH+Bc=
github.com/go-git/go-git-fixtures/v4 v4.0.1/go.mod h1:m+ICp2rF3jDhFgEZ/8yziagdT1C+ZpZcrJjappBCDSw=
github.com/go-git/go-git/v5 v5.1.0 h1:HxJn9g/E7eYvKW3Fm7Jt4ee8LXfPOm/H1cdDu8vEssk=
github.com/go-git/go-git/v5 v5.1.0/go.mod h1:ZKfuPUoY1ZqIG4QG9BDBh3G4gLM5zvPuSJAozQrZuyM=
github.com/go-telegram-bot-api/telegram-bot-api v4.6.5-0.20190904012038-b33efeebc785+incompatible h1:OT02onvXX618RBcjxeUA4H7d1PSm5Apg4IET72VgVlE=
github.com/go-telegram-bot-api/telegram-bot-api v4.6.5-0.20190904012038-b33efeebc785+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM=
github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/imdario/mergo v0.3.9 h1:UauaLniWCFHWd+Jp9oCEkTBj8VO/9DKg3PV3VCNMDIg=
github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd h1:Coekwdh0v2wtGp9Gmz1Ze3eVRAWJMLokvN3QjdzCHLY=
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
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/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70=
github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4=
golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 h1:xMPOj6Pz6UipU1wXLkrtqpHbR0AVFnyPEQq/wRWz9lM=
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM=
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200904194848-62affa334b73 h1:MXfv8rhZWmFeqX3GNZRsd6vOLoaCHjYEX3qkRo3YBUA=
golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 h1:uYVVQ9WP/Ds2ROhcaGPeIdVq0RIXVLwsHlnvJ+cT1So=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200909081042-eff7692f9009 h1:W0lCpv29Hv0UaM1LXb9QlBHLNP8UFfcKjblhVCWftOM=
golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3 h1:czFLhve3vsQetD6JOJ8NZZvGQIXlnN3/yXxbT6/awxI=
golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
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/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
willnorris.com/go/webmention v0.0.0-20191104072158-c7fb13569b62 h1:jqC8A1S2/9WjXSOK/Nl2rYwVgxU7DCnZ/zpOTL1BErI=
willnorris.com/go/webmention v0.0.0-20191104072158-c7fb13569b62/go.mod h1:p+ZRAsZS2pzZ6kX3GKWYurf3WZI2ygj7VbR8NM8qwfM=

View File

@ -1,75 +0,0 @@
package main
import (
"errors"
"io/ioutil"
"os"
"sort"
"strings"
tfgo "codeberg.org/jlelse/tinify"
)
type ImageCompression interface {
Compress(url string) (location string, err error)
}
// Tinify
type Tinify struct {
// API Key
key string
}
func (t *Tinify) Compress(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)) {
err = errors.New("file not supported")
return
}
tfgo.SetKey(t.key)
s, e := tfgo.FromUrl(url)
if e != nil {
err = errors.New("failed to compress file")
return
}
e = s.Resize(&tfgo.ResizeOption{
Method: tfgo.ResizeMethodScale,
Width: 2000,
})
if e != nil {
err = errors.New("failed to resize file")
return
}
file, e := ioutil.TempFile("", "tiny-*."+fileExtension)
if e != nil {
err = errors.New("failed to create temporary file")
return
}
defer func() {
_ = file.Close()
_ = os.Remove(file.Name())
}()
e = s.ToFile(file.Name())
if e != nil {
err = errors.New("failed to save compressed file")
return
}
hashFile, e := os.Open(file.Name())
defer func() { _ = hashFile.Close() }()
if e != nil {
err = errors.New("failed to open temporary file")
return
}
fileName, err := getSHA256(hashFile)
if err != nil {
return
}
location, err = SelectedMediaStorage.Upload(fileName+"."+fileExtension, file)
return
}

View File

@ -3,6 +3,7 @@ package main
import (
"log"
"net/http"
"strings"
"time"
)
@ -14,7 +15,11 @@ func main() {
log.Println("Starting micropub server...")
log.Println("Current time: " + time.Now().Format(time.RFC3339))
log.Println("Blog URL: " + BlogUrl)
log.Println("Ignored URLs for Webmention: " + strings.Join(IgnoredWebmentionUrls, ", "))
http.HandleFunc("/micropub", HandleMicroPub)
if SelectedMediaStorage != nil {
http.HandleFunc("/media", HandleMedia)
}
http.HandleFunc("/webmention", HandleWebmention)
log.Fatal(http.ListenAndServe(":5555", nil))
}

View File

@ -1,6 +1,9 @@
package main
import (
"crypto/sha256"
"fmt"
"io"
"mime"
"net/http"
"path/filepath"
@ -9,9 +12,9 @@ import (
func HandleMedia(w http.ResponseWriter, r *http.Request) {
if SelectedMediaStorage == nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("No media storage configured"))
return
// This shouldn't happen since the HTTP handler isn't set up if there is no SelectedMediaStorage
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("No media endpoint configured"))
}
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
@ -30,8 +33,8 @@ func HandleMedia(w http.ResponseWriter, r *http.Request) {
return
}
authCode := r.Header.Get("authorization")
if formAuth := r.FormValue("access_token"); len(authCode) == 0 && len(formAuth) > 0 {
authCode = "Bearer " + formAuth
if formAuth := r.FormValue("authorization"); len(authCode) == 0 && len(formAuth) > 0 {
authCode = formAuth
}
if CheckAuthorization(authCode) {
file, header, err := r.FormFile("file")
@ -40,15 +43,15 @@ func HandleMedia(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("Failed to get file"))
return
}
defer func() { _ = file.Close() }()
hashFile, _, _ := r.FormFile("file")
h := sha256.New()
defer func() { _ = hashFile.Close() }()
fileName, err := getSHA256(hashFile)
if err != nil {
if _, err := io.Copy(h, hashFile); err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(err.Error()))
_, _ = w.Write([]byte("Failed to calculate hash of file"))
return
}
fileName := fmt.Sprintf("%x", h.Sum(nil))
fileExtension := filepath.Ext(header.Filename)
if len(fileExtension) == 0 {
// Find correct file extension if original filename does not contain one
@ -60,19 +63,13 @@ func HandleMedia(w http.ResponseWriter, r *http.Request) {
}
}
}
fileName += strings.ToLower(fileExtension)
fileName += fileExtension
location, err := SelectedMediaStorage.Upload(fileName, file)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("Failed to upload original file"))
_, _ = w.Write([]byte("Failed to upload file"))
return
}
if SelectedImageCompression != nil {
compressedLocation, err := SelectedImageCompression.Compress(location)
if err == nil && len(compressedLocation) > 0 {
location = compressedLocation
}
}
w.Header().Add("Location", location)
w.WriteHeader(http.StatusCreated)
} else {

View File

@ -1,10 +1,7 @@
package main
import (
"crypto/sha256"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
@ -26,9 +23,9 @@ type BunnyCdnStorage struct {
var bunnyCdnStorageUrl = "https://storage.bunnycdn.com"
func (b *BunnyCdnStorage) Upload(fileName string, file multipart.File) (location string, err error) {
client := http.DefaultClient
req, _ := http.NewRequest(http.MethodPut, bunnyCdnStorageUrl+"/"+url.PathEscape(b.storageZoneName)+"/"+url.PathEscape(fileName), file)
func (b BunnyCdnStorage) Upload(fileName string, file multipart.File) (location string, err error) {
client := &http.Client{}
req, _ := http.NewRequest("PUT", bunnyCdnStorageUrl+"/"+url.PathEscape(b.storageZoneName)+"/"+url.PathEscape("/"+fileName), file)
req.Header.Add("AccessKey", b.key)
resp, err := client.Do(req)
if err != nil || resp.StatusCode != 201 {
@ -36,12 +33,3 @@ func (b *BunnyCdnStorage) Upload(fileName string, file multipart.File) (location
}
return b.baseLocation + fileName, nil
}
func getSHA256(file multipart.File) (filename string, err error) {
h := sha256.New()
if _, e := io.Copy(h, file); e != nil {
err = errors.New("failed to calculate hash of file")
return
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}

View File

@ -17,6 +17,4 @@ type MicroformatProperties struct {
BookmarkOf []string `json:"bookmark-of,omitempty"`
MpSlug []string `json:"mp-slug,omitempty"`
MpSyndicateTo []string `json:"mp-syndicate-to,omitempty"`
Photo []interface{} `json:"photo,omitempty"`
Audio []string `json:"audio,omitempty"`
}

View File

@ -3,6 +3,8 @@ package main
import (
"encoding/json"
"net/http"
"strconv"
"time"
)
type MicropubConfig struct {
@ -10,16 +12,43 @@ type MicropubConfig struct {
MediaEndpoint string `json:"media-endpoint,omitempty"`
}
func getMediaEndpoint() string {
if SelectedMediaStorage != nil {
return MediaEndpointUrl
} else {
return ""
}
}
func HandleMicroPub(w http.ResponseWriter, r *http.Request) {
// a handler for GET requests, used for troubleshooting
if r.Method == "GET" {
if q := r.URL.Query().Get("q"); q == "config" || q == "syndicate-to" {
w.Header().Add("Content-type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(&MicropubConfig{
jsonBytes, err := json.Marshal(&MicropubConfig{
SyndicateTo: SyndicationTargets,
MediaEndpoint: MediaEndpointUrl,
MediaEndpoint: getMediaEndpoint(),
})
if err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(err.Error()))
return
}
_, _ = w.Write(jsonBytes)
return
} else if url := r.URL.Query().Get("url"); q == "source" {
limit := r.URL.Query().Get("limit")
limitInt, err := strconv.Atoi(limit)
jsonBytes, err := QueryURL(url, limitInt)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(err.Error()))
return
}
w.Header().Add("Content-type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(jsonBytes)
return
} else {
w.Header().Add("Content-type", "application/json")
@ -62,8 +91,23 @@ func HandleMicroPub(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("There was an error committing the entry to the repository"))
return
} else {
w.Header().Add("Location", location+"?cache=0")
w.Header().Add("Location", location)
w.WriteHeader(http.StatusAccepted)
// Purge CDN in 10 seconds, send webmentions, post to social media
go func() {
if SelectedCdn != nil {
time.Sleep(10 * time.Second)
SelectedCdn.Purge(location)
}
time.Sleep(10 * time.Second)
// Send webmentions
go SendWebmentions(location)
go func() {
if SelectedSocials != nil {
SelectedSocials.Post(location, entry.title)
}
}()
}()
return
}
} else {

40
notification.go Normal file
View File

@ -0,0 +1,40 @@
package main
import (
"fmt"
telegramBotApi "github.com/go-telegram-bot-api/telegram-bot-api"
)
type NotificationService interface {
Post(message string)
}
type NotificationServices []NotificationService
// Post to all Notification Services
func (notificationServices *NotificationServices) Post(message string) {
for _, notificationService := range *notificationServices {
notificationService.Post(message)
}
}
// Telegram
type Telegram struct {
userId int64
botToken string
}
func (t *Telegram) Post(message string) {
bot, err := telegramBotApi.NewBotAPI(t.botToken)
if err != nil {
fmt.Println("Failed to setup Telegram bot")
return
}
msg := telegramBotApi.NewMessage(t.userId, message)
_, err = bot.Send(msg)
if err != nil {
fmt.Println("Failed to send Telegram message")
return
}
return
}

123
post.go
View File

@ -3,81 +3,130 @@ package main
import (
"bytes"
"errors"
"gopkg.in/yaml.v3"
"gopkg.in/yaml.v2"
"strings"
)
type HugoFrontMatter struct {
type HugoFrontmatter struct {
Title string `yaml:"title,omitempty"`
Published string `yaml:"date,omitempty"`
Updated string `yaml:"lastmod,omitempty"`
Date string `yaml:"date,omitempty"`
Lastmod string `yaml:"lastmod,omitempty"`
Tags []string `yaml:"tags,omitempty"`
Series []string `yaml:"series,omitempty"`
ExternalURL string `yaml:"externalURL,omitempty"`
IndieWeb HugoFrontMatterIndieWeb `yaml:"indieweb,omitempty"`
Indieweb HugoFrontmatterIndieweb `yaml:"indieweb,omitempty"`
Syndicate []string `yaml:"syndicate,omitempty"`
TranslationKey string `yaml:"translationKey,omitempty"`
Images []string `yaml:"images,omitempty"`
Audio string `yaml:"audio,omitempty"`
}
type HugoFrontMatterIndieWeb struct {
Reply HugoFrontMatterReply `yaml:"reply,omitempty"`
Like HugoFrontMatterLike `yaml:"like,omitempty"`
type HugoFrontmatterIndieweb struct {
Reply HugoFrontmatterReply `yaml:"reply,omitempty"`
Like HugoFrontmatterLike `yaml:"like,omitempty"`
}
type HugoFrontMatterReply struct {
type HugoFrontmatterReply struct {
Link string `yaml:"link,omitempty"`
Title string `yaml:"title,omitempty"`
}
type HugoFrontMatterLike struct {
type HugoFrontmatterLike struct {
Link string `yaml:"link,omitempty"`
Title string `yaml:"title,omitempty"`
}
func writeFrontMatter(entry *Entry) (string, error) {
frontMatter := &HugoFrontMatter{
func writeFrontMatter(entry *Entry) (frontmatter string, err error) {
var buff bytes.Buffer
writeFrontmatter := &HugoFrontmatter{
Title: entry.title,
Published: entry.date,
Updated: entry.lastmod,
Date: entry.date,
Lastmod: entry.lastmod,
Tags: entry.tags,
Series: entry.series,
ExternalURL: entry.link,
IndieWeb: HugoFrontMatterIndieWeb{
Reply: HugoFrontMatterReply{
Indieweb: HugoFrontmatterIndieweb{
Reply: HugoFrontmatterReply{
Link: entry.replyLink,
Title: entry.replyTitle,
},
Like: HugoFrontMatterLike{
Like: HugoFrontmatterLike{
Link: entry.likeLink,
Title: entry.likeTitle,
},
},
Syndicate: entry.syndicate,
TranslationKey: entry.translationKey,
Audio: entry.audio,
}
for _, image := range entry.images {
frontMatter.Images = append(frontMatter.Images, image.url)
}
writer := new(bytes.Buffer)
writer.WriteString("---\n")
err := yaml.NewEncoder(writer).Encode(&frontMatter)
yamlBytes, err := yaml.Marshal(&writeFrontmatter)
if err != nil {
return "", errors.New("failed encoding frontmatter")
err = errors.New("failed marshaling frontmatter")
return
}
writer.WriteString("---\n")
return writer.String(), nil
buff.WriteString("---\n")
buff.Write(yamlBytes)
buff.WriteString("---\n")
frontmatter = buff.String()
return
}
func WriteHugoPost(entry *Entry) (string, error) {
buff := new(bytes.Buffer)
f, err := writeFrontMatter(entry)
var buff bytes.Buffer
frontmatter, err := writeFrontMatter(entry)
if err != nil {
return "", err
}
buff.WriteString(f)
buff.WriteString(frontmatter)
if len(entry.content) > 0 {
buff.WriteString(entry.content)
}
return buff.String(), nil
}
func readFrontMatter(frontmatter string, entry *Entry) (err error) {
parsedFrontmatter := &HugoFrontmatter{}
err = yaml.Unmarshal([]byte(frontmatter), &parsedFrontmatter)
if err != nil {
err = errors.New("failed parsing frontmatter")
}
if len(parsedFrontmatter.Title) > 0 {
entry.title = parsedFrontmatter.Title
}
if len(parsedFrontmatter.Date) > 0 {
entry.date = parsedFrontmatter.Date
}
if len(parsedFrontmatter.Lastmod) > 0 {
entry.lastmod = parsedFrontmatter.Lastmod
}
if len(parsedFrontmatter.Tags) > 0 {
entry.tags = parsedFrontmatter.Tags
}
if len(parsedFrontmatter.ExternalURL) > 0 {
entry.link = parsedFrontmatter.ExternalURL
}
if len(parsedFrontmatter.Indieweb.Reply.Link) > 0 {
entry.replyLink = parsedFrontmatter.Indieweb.Reply.Link
}
if len(parsedFrontmatter.Indieweb.Reply.Title) > 0 {
entry.replyTitle = parsedFrontmatter.Indieweb.Reply.Title
}
if len(parsedFrontmatter.Indieweb.Like.Link) > 0 {
entry.replyLink = parsedFrontmatter.Indieweb.Like.Link
}
if len(parsedFrontmatter.Indieweb.Like.Title) > 0 {
entry.replyTitle = parsedFrontmatter.Indieweb.Like.Title
}
if len(parsedFrontmatter.Syndicate) > 0 {
entry.syndicate = parsedFrontmatter.Syndicate
}
return
}
func ReadHugoPost(fileContent string) (entry *Entry, err error) {
parts := strings.Split(fileContent, "---\n")
if len(parts) != 3 {
err = errors.New("empty frontmatter or content")
return
}
entry = new(Entry)
err = readFrontMatter(parts[1], entry)
if err != nil {
return
}
entry.content = strings.TrimSuffix(parts[2], "\n")
return
}

94
query.go Normal file
View File

@ -0,0 +1,94 @@
package main
import (
"encoding/json"
"errors"
"io/ioutil"
"net/http"
)
type ItemList struct {
Items []*MicroformatItem `json:"items"`
}
func QueryURL(url string, limit int) ([]byte, error) {
if len(url) == 0 {
url = BlogUrl
}
if url == BlogUrl {
allPosts, err := allPosts(url)
if err != nil {
return nil, err
}
itemList := &ItemList{}
for i, postURL := range allPosts {
if limit != 0 && i == limit {
break
}
item, _ := getItem(postURL)
itemList.Items = append(itemList.Items, item)
}
jsonBytes, err := json.Marshal(itemList)
if err != nil {
err = errors.New("failed to marshal json")
return nil, err
}
return jsonBytes, err
} else {
item, err := getItem(url)
if err != nil {
return nil, err
}
jsonBytes, err := json.Marshal(item)
if err != nil {
err = errors.New("failed to marshal json")
return nil, err
}
return jsonBytes, err
}
}
func getItem(url string) (item *MicroformatItem, err error) {
entry, err := ReadEntry(url)
if err != nil {
return
}
item = &MicroformatItem{
Type: []string{"h-entry"},
Properties: &MicroformatProperties{
Name: []string{entry.title},
Published: []string{entry.date},
Updated: []string{entry.lastmod},
Category: entry.tags,
Content: []string{entry.content},
Url: []string{entry.location},
},
}
return
}
func allPosts(url string) ([]string, error) {
jsonFeed := &struct {
Items []struct {
Url string `json:"url"`
} `json:"items"`
}{}
resp, err := http.Get(url + "feed.json")
if err != nil {
return nil, errors.New("failed to get json feed")
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, errors.New("failed to read json feed")
}
err = json.Unmarshal(body, &jsonFeed)
if err != nil {
return nil, errors.New("failed to parse json feed")
}
var allUrls []string
for _, item := range jsonFeed.Items {
allUrls = append(allUrls, item.Url)
}
return allUrls, nil
}

46
social.go Normal file
View File

@ -0,0 +1,46 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
)
type Social interface {
Post(location string, text string)
}
type Socials []Social
// Post to all socials
func (socials *Socials) Post(location string, text string) {
for _, social := range *socials {
social.Post(location, text)
}
}
// Microblog.pub
type MicroblogPub struct {
url string
token string
}
func (social *MicroblogPub) Post(location string, text string) {
if len(text) == 0 {
text = location
}
note := &struct {
Content string `json:"content"`
}{
Content: "[" + text + "](" + location + ")",
}
bytesRepresentation, err := json.Marshal(note)
if err != nil {
return
}
client := &http.Client{}
req, _ := http.NewRequest("POST", social.url+"api/new_note", bytes.NewBuffer(bytesRepresentation))
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", "Bearer "+social.token)
_, _ = client.Do(req)
}

View File

@ -1,184 +1,134 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
gitHttp "github.com/go-git/go-git/v5/plumbing/transport/http"
"io/ioutil"
"os"
"path"
"sync"
"time"
"net/http"
"net/url"
"strings"
)
type Storage interface {
CreateFile(path string, file string, message string) (err error)
UpdateFile(path string, file string, message string) (err error)
DeleteFile(path string, message string) (err error)
ReadFile(path string) (content string, sha string, exists bool, err error)
}
type Git struct {
filepath string
url string
username string
password string
name string
email string
lock *sync.Mutex
// Gitea
type Gitea struct {
endpoint string
token string
}
func (g *Git) init() (r *git.Repository, w *git.Worktree, err error) {
// Open repo
r, err = git.PlainOpen(g.filepath)
if err == nil {
// Try to get work tree
w, err = r.Worktree()
if err == nil {
// Try to pull
err = w.Pull(&git.PullOptions{
Auth: &gitHttp.BasicAuth{
Username: g.username,
Password: g.password,
},
SingleBranch: true,
})
if err == git.NoErrAlreadyUpToDate {
err = nil
type giteaCommitRequest struct {
Content string `json:"content"`
Message string `json:"message"`
SHA string `json:"sha,omitempty"`
}
type giteaReadResponse struct {
Type string `json:"type"`
Content string `json:"content"`
SHA string `json:"sha"`
}
type giteaErrorResponse struct {
Message string `json:"message"`
}
func (gitea *Gitea) CreateFile(path string, file string, message string) (err error) {
request := &giteaCommitRequest{
Content: base64.StdEncoding.EncodeToString([]byte(file)),
Message: message,
}
bytesRepresentation, err := json.Marshal(request)
if err != nil {
// Delete old things
g.destroyRepo()
// Clone
r, err = git.PlainClone(g.filepath, false, &git.CloneOptions{
Auth: &gitHttp.BasicAuth{
Username: g.username,
Password: g.password,
},
URL: g.url,
Depth: 1,
SingleBranch: true,
})
return errors.New("failed to marshal json before committing")
}
resp, err := http.Post(gitea.endpoint+url.QueryEscape(path)+"?access_token="+gitea.token, "application/json", bytes.NewBuffer(bytesRepresentation))
if err != nil || resp.StatusCode != 201 {
return errors.New("failed to create file in repo")
}
return nil
}
func (gitea *Gitea) UpdateFile(path string, file string, message string) (err error) {
_, sha, exists, err := gitea.ReadFile(path)
if err != nil {
err = errors.New("failed to clone repo")
return err
}
if err == nil {
w, err = r.Worktree()
if !exists {
// File doesn't exist, create it
return gitea.CreateFile(path, file, message)
}
request := &giteaCommitRequest{
Content: base64.StdEncoding.EncodeToString([]byte(file)),
Message: message,
SHA: sha,
}
bytesRepresentation, err := json.Marshal(request)
if err != nil {
err = errors.New("failed to get work tree")
return errors.New("failed to marshal json before committing")
}
req, err := http.NewRequest(http.MethodPut, gitea.endpoint+url.QueryEscape(path)+"?access_token="+gitea.token, bytes.NewBuffer(bytesRepresentation))
if err != nil {
return errors.New("error making update request")
}
req.Header.Set("Content-type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil || resp.StatusCode != 200 {
return errors.New("failed to update file in repo")
}
return nil
}
func (gitea *Gitea) ReadFile(path string) (content string, sha string, exists bool, err error) {
exists = false
resp, err := http.Get(gitea.endpoint + url.QueryEscape(path) + "?access_token=" + gitea.token)
if err != nil || resp.StatusCode != 200 {
if resp != nil {
defer resp.Body.Close()
body, readErr := ioutil.ReadAll(resp.Body)
if readErr != nil {
err = errors.New("failed reading Gitea error response")
return
}
func (g *Git) destroyRepo() {
_ = os.RemoveAll(g.filepath)
}
func (g *Git) push(repository *git.Repository) error {
err := repository.Push(&git.PushOptions{
Auth: &gitHttp.BasicAuth{
Username: g.username,
Password: g.password,
},
})
if err == nil || err == git.NoErrAlreadyUpToDate {
return nil
} else {
// Destroy repo to prevent errors when trying to create same post again
g.destroyRepo()
return errors.New("failed to push to remote")
}
}
func (g *Git) CreateFile(filepath string, file string, message string) (err error) {
g.lock.Lock()
defer g.lock.Unlock()
_, _, err = g.init()
if err != nil {
err = errors.New("failed to initialize repo")
errorResponse := &giteaErrorResponse{}
marshalErr := json.Unmarshal(body, &errorResponse)
if marshalErr != nil {
err = errors.New("failed parsing Gitea error response")
return
}
joinedPath := path.Join(g.filepath, filepath)
if _, e := os.Stat(joinedPath); e == nil {
return errors.New("file already exists")
} else {
return g.unsafeUpdateFile(filepath, file, message)
exists = !strings.Contains(errorResponse.Message, "does not exist")
if !exists {
return
}
}
func (g *Git) UpdateFile(filepath string, file string, message string) error {
g.lock.Lock()
defer g.lock.Unlock()
return g.unsafeUpdateFile(filepath, file, message)
err = errors.New("failed to read file in repo")
return
}
func (g *Git) unsafeUpdateFile(filepath string, file string, message string) error {
repo, w, err := g.init()
exists = true
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return errors.New("failed to initialize repo")
err = errors.New("failed reading file in repo")
return
}
joinedPath := path.Join(g.filepath, filepath)
_ = os.MkdirAll(path.Dir(joinedPath), 0755)
err = ioutil.WriteFile(joinedPath, []byte(file), 0644)
readResponse := &giteaReadResponse{}
err = json.Unmarshal(body, &readResponse)
if err != nil {
return errors.New("failed to write to file")
err = errors.New("failed parsing Gitea response")
return
}
status, err := w.Status()
if err == nil && status.IsClean() {
// No file changes, prevent empty commit
return nil
} else {
err = nil
}
_, err = w.Add(filepath)
decodedContentBytes, err := base64.StdEncoding.DecodeString(readResponse.Content)
if err != nil {
return errors.New("failed to stage file")
err = errors.New("failed decoding file content")
}
if len(message) == 0 {
message = "Add " + filepath
}
_, err = w.Commit(message, &git.CommitOptions{
Author: &object.Signature{
Name: g.name,
Email: g.email,
When: time.Now(),
},
})
if err != nil {
return errors.New("failed to commit file")
}
return g.push(repo)
}
func (g *Git) DeleteFile(filepath string, message string) (err error) {
g.lock.Lock()
defer g.lock.Unlock()
repo, w, err := g.init()
if err != nil {
return errors.New("failed to initialize repo")
}
joinedPath := path.Join(g.filepath, filepath)
err = os.Remove(joinedPath)
if err != nil {
return errors.New("failed to delete file")
}
_, err = w.Add(filepath)
if err != nil {
return errors.New("failed to stage deletion")
}
_, err = w.Commit(message, &git.CommitOptions{
Author: &object.Signature{
Name: g.name,
Email: g.email,
When: time.Now(),
},
})
if err != nil {
return errors.New("failed to commit deletion")
}
return g.push(repo)
content = string(decodedContentBytes)
sha = readResponse.SHA
return
}

View File

@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"errors"
"io/ioutil"
"net/http"
"strings"
)
@ -33,7 +34,7 @@ func checkAccess(token string) (bool, error) {
return false, errors.New("token string is empty")
}
// form the request to check the token
client := http.DefaultClient
client := &http.Client{}
req, err := http.NewRequest("GET", indieAuthTokenUrl, nil)
if err != nil {
return false, errors.New("error making the request for checking token access")
@ -45,10 +46,14 @@ func checkAccess(token string) (bool, error) {
if err != nil {
return false, errors.New("error sending the request for checking token access")
}
defer res.Body.Close()
// parse the response
indieAuthRes := &IndieAuthRes{}
err = json.NewDecoder(res.Body).Decode(&indieAuthRes)
res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return false, errors.New("error parsing the response for checking token access")
}
var indieAuthRes = new(IndieAuthRes)
err = json.Unmarshal(body, &indieAuthRes)
if err != nil {
return false, errors.New("Error parsing the response into json for checking token access " + err.Error())
}

190
webmention.go Normal file
View File

@ -0,0 +1,190 @@
package main
import (
"crypto/md5"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
"willnorris.com/go/webmention"
)
type Mention struct {
Source string `json:"source"`
Target string `json:"target"`
Date string `json:"date"`
}
func SendWebmentions(url string) {
client := webmention.New(nil)
dl, err := client.DiscoverLinks(url, ".h-entry")
if err != nil {
return
}
// Send Webmentions
for _, link := range filterLinks(dl) {
endpoint, err := client.DiscoverEndpoint(link)
if err != nil || len(endpoint) < 1 {
continue
}
_, err = client.SendWebmention(endpoint, url, link)
if err != nil {
log.Println("Sent webmention to " + link + " failed")
continue
}
log.Println("Sent webmention to " + link)
}
}
func filterLinks(links []string) []string {
var filteredLinks []string
LINKFILTER:
for _, link := range links {
if strings.HasPrefix(link, BlogUrl) {
continue
}
for _, ignoredURL := range IgnoredWebmentionUrls {
if strings.HasPrefix(link, ignoredURL) {
continue LINKFILTER
}
}
filteredLinks = append(filteredLinks, link)
}
return filteredLinks
}
func HandleWebmention(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
_, _ = w.Write([]byte("The HTTP method is not allowed, make a POST request"))
return
}
sourceUrl, err := url.Parse(r.FormValue("source"))
if err != nil || !(sourceUrl.Scheme == "http" || sourceUrl.Scheme == "https") {
err = errors.New("failed to parse webmention source URL")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(err.Error()))
return
}
targetUrl, err := url.Parse(r.FormValue("target"))
if err != nil || !(sourceUrl.Scheme == "http" || sourceUrl.Scheme == "https") {
err = errors.New("failed to parse webmention target URL")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(err.Error()))
return
}
// Check if urls don't equal
if sourceUrl.String() == targetUrl.String() {
err = errors.New("source and target equal")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(err.Error()))
return
}
// Check if target is blog
if !strings.HasPrefix(targetUrl.String(), BlogUrl) {
err = errors.New("wrong webmention target")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(err.Error()))
return
}
// Check response code for source
respCode, err := responseCodeForSource(sourceUrl.String())
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(err.Error()))
return
}
if respCode < 200 || respCode >= 300 {
if respCode == 410 {
// Delete mention, because source is gone
// TODO: Implement deletion
returnSuccess(targetUrl.String(), w, r)
return
} else {
err = errors.New("source returned error")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(err.Error()))
return
}
}
// Check if source mentions target
if !sourceMentionsTarget(sourceUrl.String(), targetUrl.String()) {
err = errors.New("source doesn't mention target")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(err.Error()))
return
}
mention := &Mention{
Source: sourceUrl.String(),
Target: targetUrl.String(),
Date: time.Now().Format(time.RFC3339),
}
err = saveWebmention(mention)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(err.Error()))
return
}
returnSuccess(targetUrl.String(), w, r)
go func() {
if SelectedNotificationServices != nil {
SelectedNotificationServices.Post("New webmention: " + sourceUrl.String())
}
}()
return
}
func responseCodeForSource(source string) (int, error) {
client := &http.Client{}
resp, err := client.Get(source)
if err != nil || resp == nil {
return 0, err
}
return resp.StatusCode, nil
}
func sourceMentionsTarget(source string, target string) bool {
client := webmention.New(nil)
dl, err := client.DiscoverLinks(source, "")
if err != nil {
return false
}
for _, link := range dl {
if link == target {
return true
}
}
return false
}
func saveWebmention(mention *Mention) (err error) {
bytesRepresentation, err := json.Marshal(mention)
if err != nil {
return errors.New("failed to marshal json before committing")
}
filePath := fmt.Sprintf("data/mentions/%x/%x.json", md5.Sum([]byte(strings.ReplaceAll(mention.Target, "/", ""))), md5.Sum([]byte(mention.Source)))
err = SelectedStorage.UpdateFile(filePath, string(bytesRepresentation), "New webmention from "+mention.Source)
return
}
func returnSuccess(target string, w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.Header.Get("Accept"), "text/html") {
// Redirect browser
w.Header().Add("Location", target)
w.WriteHeader(http.StatusSeeOther)
} else {
w.WriteHeader(http.StatusOK)
}
// Purge CDN in 10 seconds
go func() {
if SelectedCdn != nil {
time.Sleep(10 * time.Second)
SelectedCdn.Purge(target)
}
}()
return
}

34
webmention_test.go Normal file
View File

@ -0,0 +1,34 @@
package main
import (
"net/http"
"net/http/httptest"
"strconv"
"testing"
)
func Test_responseCodeForSource(t *testing.T) {
for _, code := range []int{200, 404} {
t.Run(strconv.Itoa(code), func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(code)
}))
defer ts.Close()
got, err := responseCodeForSource(ts.URL)
if err != nil || got != code {
t.Errorf("Wrong response code: Got %d, but expected %d", got, code)
}
})
}
t.Run("Error", func(t *testing.T) {
ts := httptest.NewUnstartedServer(nil)
defer ts.Close()
got, err := responseCodeForSource(ts.URL)
if err == nil {
t.Errorf("Error is nil")
}
if got != 0 {
t.Errorf("Wrong response code: Got %d, but expected %d", got, 0)
}
})
}