Compare commits

..

No commits in common. "5bac6cf0f1c2e9621607eba364fa9a70a3d411a1" and "102f555cbc0b7f1f335d2cb5d444e13336ba990a" have entirely different histories.

23 changed files with 987 additions and 817 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

2
.gitignore vendored
View File

@ -1,4 +1,2 @@
.idea .idea
*.iml *.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 ADD . /app
WORKDIR /app WORKDIR /app
RUN go build RUN go build
FROM alpine:3.12 FROM alpine:3.11
RUN apk add --no-cache tzdata ca-certificates RUN apk add --no-cache tzdata ca-certificates
COPY --from=build /app/hugo-micropub /bin/ COPY --from=build /app/hugo-micropub /bin/
EXPOSE 5555 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")
}

198
config.go
View File

@ -2,23 +2,20 @@ package main
import ( import (
"errors" "errors"
"github.com/caarlos0/env/v6"
"log" "log"
"os"
"strings"
"sync"
"gopkg.in/yaml.v3"
) )
var ( var (
BlogUrl string BlogUrl string
MediaEndpointUrl string IgnoredWebmentionUrls []string
SyndicationTargets []SyndicationTarget SyndicationTargets []SyndicationTarget
SelectedStorage Storage SelectedStorage Storage
SelectedMediaStorage MediaStorage SelectedMediaStorage MediaStorage
SelectedImageCompression ImageCompression SelectedCdn Cdn
DefaultLanguage string SelectedSocials Socials
Languages map[string]Language SelectedNotificationServices NotificationServices
MediaEndpointUrl string
) )
type SyndicationTarget struct { type SyndicationTarget struct {
@ -26,101 +23,34 @@ type SyndicationTarget struct {
Name string `json:"name"` Name string `json:"name"`
} }
type YamlConfig struct { type config struct {
BlogUrl string `yaml:"blogUrl"` BlogUrl string `env:"BLOG_URL,required"`
BaseUrl string `yaml:"baseUrl"` BaseUrl string `env:"BASE_URL,required"`
MediaUrl string `yaml:"mediaUrl"` MediaUrl string `env:"MEDIA_URL"`
DefaultLanguage string `yaml:"defaultLang"` GiteaEndpoint string `env:"GITEA_ENDPOINT"`
Languages map[string]Language `yaml:"languages"` GiteaToken string `env:"GITEA_TOKEN"`
Git GitConfig `yaml:"git"` BunnyCdnKey string `env:"BUNNY_CDN_KEY"`
BunnyCdn BunnyCdnConfig `yaml:"bunnyCdn"` BunnyCdnStorageKey string `env:"BUNNY_CDN_STORAGE_KEY"`
Tinify TinifyConfig `yaml:"tinify"` BunnyCdnStorageName string `env:"BUNNY_CDN_STORAGE_NAME"`
SyndicationTargets []string `yaml:"syndication"` MicroblogUrl string `env:"MICROBLOG_URL"`
} MicroblogToken string `env:"MICROBLOG_TOKEN"`
TelegramUserId int64 `env:"TELEGRAM_USER_ID"`
type BunnyCdnConfig struct { TelegramBotToken string `env:"TELEGRAM_BOT_TOKEN"`
StorageKey string `yaml:"storageKey"` IgnoredWebmentionUrls []string `env:"WEBMENTION_IGNORED" envSeparator:","`
StorageName string `yaml:"storageName"` SyndicationTargets []string `env:"SYNDICATION" envSeparator:","`
}
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"`
} }
func initConfig() (err error) { func initConfig() (err error) {
configFileName, configSet := os.LookupEnv("CONFIG") cfg := config{}
if !configSet { if err := env.Parse(&cfg); err != nil {
configFileName = "config.yml" return errors.New("failed to parse config, probably not all required env vars set")
}
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")
} }
// Blog URL (required) // 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 BlogUrl = cfg.BlogUrl
// Media endpoint (required) // Media endpoint
if len(cfg.BaseUrl) < 1 { MediaEndpointUrl = cfg.BaseUrl + "/media"
return errors.New("baseUrl not configured") // Ignored Webmention URLs (optional)
} IgnoredWebmentionUrls = cfg.IgnoredWebmentionUrls
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")
}
// Syndication Targets (optional) // Syndication Targets (optional)
targets := make([]SyndicationTarget, 0) targets := make([]SyndicationTarget, 0)
for _, url := range cfg.SyndicationTargets { for _, url := range cfg.SyndicationTargets {
@ -132,16 +62,11 @@ func initConfig() (err error) {
SyndicationTargets = targets SyndicationTargets = targets
// Find selected storage // Find selected storage
SelectedStorage = func() Storage { SelectedStorage = func() Storage {
// Git // Gitea
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 { if len(cfg.GiteaEndpoint) > 0 && len(cfg.GiteaToken) >= 0 {
return &Git{ return &Gitea{
filepath: cfg.Git.Filepath, endpoint: cfg.GiteaEndpoint,
url: cfg.Git.Url, token: cfg.GiteaToken,
username: cfg.Git.Username,
password: cfg.Git.Password,
name: cfg.Git.AuthorName,
email: cfg.Git.AuthorEmail,
lock: &sync.Mutex{},
} }
} }
return nil return nil
@ -149,34 +74,61 @@ func initConfig() (err error) {
if SelectedStorage == nil { if SelectedStorage == nil {
return errors.New("no storage configured") return errors.New("no storage configured")
} }
// Find selected media storage (Optional) // Find selected media storage
SelectedMediaStorage = func() MediaStorage { SelectedMediaStorage = func() MediaStorage {
// BunnyCDN // BunnyCDN
// MEDIA_URL needs trailing slash too if len(cfg.BunnyCdnStorageKey) > 0 && len(cfg.BunnyCdnStorageName) > 0 && len(cfg.MediaUrl) > 0 {
if len(cfg.BunnyCdn.StorageKey) > 0 && len(cfg.BunnyCdn.StorageName) > 0 && len(cfg.MediaUrl) > 0 && strings.HasSuffix(cfg.MediaUrl, "/") {
return &BunnyCdnStorage{ return &BunnyCdnStorage{
key: cfg.BunnyCdn.StorageKey, key: cfg.BunnyCdnStorageKey,
storageZoneName: cfg.BunnyCdn.StorageName, storageZoneName: cfg.BunnyCdnStorageName,
baseLocation: cfg.MediaUrl, baseLocation: cfg.MediaUrl,
} }
} }
return nil return nil
}() }()
if SelectedMediaStorage == nil { if SelectedMediaStorage == nil {
log.Println("no media storage configured") return errors.New("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 return nil
}() }()
if SelectedImageCompression == nil { if SelectedCdn == nil {
log.Println("no image compression configured") 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 return nil
} }

218
entry.go
View File

@ -6,10 +6,11 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io/ioutil"
"math/rand" "math/rand"
"net/http" "net/http"
"net/url"
"strings" "strings"
"text/template"
"time" "time"
) )
@ -20,7 +21,6 @@ type Entry struct {
lastmod string lastmod string
section string section string
tags []string tags []string
series []string
link string link string
slug string slug string
replyLink string replyLink string
@ -28,27 +28,22 @@ type Entry struct {
likeLink string likeLink string
likeTitle string likeTitle string
syndicate []string syndicate []string
language string
translationKey string
images []Image
audio string
filename string filename string
location string location string
token string token string
} }
type Image struct {
url string
alt string
}
func CreateEntry(contentType ContentType, r *http.Request) (*Entry, error) { func CreateEntry(contentType ContentType, r *http.Request) (*Entry, error) {
if contentType == WwwForm { if contentType == WwwForm {
err := r.ParseForm() bodyString, err := parseRequestBody(r)
if err != nil { 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 { } else if contentType == Multipart {
err := r.ParseMultipartForm(1024 * 1024 * 16) err := r.ParseMultipartForm(1024 * 1024 * 16)
if err != nil { if err != nil {
@ -56,9 +51,12 @@ func CreateEntry(contentType ContentType, r *http.Request) (*Entry, error) {
} }
return createEntryFromValueMap(r.MultipartForm.Value) return createEntryFromValueMap(r.MultipartForm.Value)
} else if contentType == Json { } else if contentType == Json {
decoder := json.NewDecoder(r.Body) bodyString, err := parseRequestBody(r)
if err != nil {
return nil, err
}
parsedMfItem := &MicroformatItem{} parsedMfItem := &MicroformatItem{}
err := decoder.Decode(&parsedMfItem) err = json.Unmarshal([]byte(bodyString), &parsedMfItem)
if err != nil { if err != nil {
return nil, errors.New("failed to parse Json") return nil, errors.New("failed to parse Json")
} }
@ -68,13 +66,22 @@ 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 createEntryFromValueMap(values map[string][]string) (*Entry, error) { func createEntryFromValueMap(values map[string][]string) (*Entry, error) {
if h, ok := values["h"]; ok && (len(h) != 1 || h[0] != "entry") { if h, ok := values["h"]; ok && (len(h) != 1 || h[0] != "entry") {
return nil, errors.New("only entry type is supported so far") return nil, errors.New("only entry type is supported so far")
} }
entry := &Entry{} if _, ok := values["content"]; ok {
if content, ok := values["content"]; ok { entry := &Entry{
entry.content = content[0] content: values["content"][0],
} }
if name, ok := values["name"]; ok { if name, ok := values["name"]; ok {
entry.title = name[0] entry.title = name[0]
@ -83,6 +90,8 @@ func createEntryFromValueMap(values map[string][]string) (*Entry, error) {
entry.tags = category entry.tags = category
} else if categories, ok := values["category[]"]; ok { } else if categories, ok := values["category[]"]; ok {
entry.tags = categories entry.tags = categories
} else {
entry.tags = nil
} }
if slug, ok := values["mp-slug"]; ok && len(slug) > 0 && slug[0] != "" { if slug, ok := values["mp-slug"]; ok && len(slug) > 0 && slug[0] != "" {
entry.slug = slug[0] entry.slug = slug[0]
@ -100,29 +109,8 @@ func createEntryFromValueMap(values map[string][]string) (*Entry, error) {
entry.syndicate = syndicate entry.syndicate = syndicate
} else if syndicates, ok := values["mp-syndicate-to[]"]; ok { } else if syndicates, ok := values["mp-syndicate-to[]"]; ok {
entry.syndicate = syndicates entry.syndicate = syndicates
} } else {
if photo, ok := values["photo"]; ok { entry.syndicate = nil
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]
} }
if token, ok := values["access_token"]; ok { if token, ok := values["access_token"]; ok {
entry.token = "Bearer " + token[0] entry.token = "Bearer " + token[0]
@ -132,15 +120,17 @@ func createEntryFromValueMap(values map[string][]string) (*Entry, error) {
return nil, err return nil, err
} }
return entry, nil return entry, nil
}
return nil, errors.New("error parsing the entry")
} }
func createEntryFromMicroformat(mfEntry *MicroformatItem) (*Entry, error) { func createEntryFromMicroformat(mfEntry *MicroformatItem) (*Entry, error) {
if len(mfEntry.Type) != 1 || mfEntry.Type[0] != "h-entry" { if len(mfEntry.Type) != 1 || mfEntry.Type[0] != "h-entry" {
return nil, errors.New("only entry type is supported so far") 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 { 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 { if len(mfEntry.Properties.Name) == 1 {
entry.title = mfEntry.Properties.Name[0] entry.title = mfEntry.Properties.Name[0]
@ -163,31 +153,13 @@ func createEntryFromMicroformat(mfEntry *MicroformatItem) (*Entry, error) {
if len(mfEntry.Properties.MpSyndicateTo) > 0 { if len(mfEntry.Properties.MpSyndicateTo) > 0 {
entry.syndicate = mfEntry.Properties.MpSyndicateTo 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) err := computeExtraSettings(entry)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return entry, nil return entry, nil
}
return nil, errors.New("error parsing the entry")
} }
func computeExtraSettings(entry *Entry) error { func computeExtraSettings(entry *Entry) error {
@ -211,9 +183,6 @@ func computeExtraSettings(entry *Entry) error {
} else if strings.HasPrefix(text, "tags: ") { } else if strings.HasPrefix(text, "tags: ") {
// Tags // Tags
entry.tags = strings.Split(strings.TrimPrefix(text, "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: ") { } else if strings.HasPrefix(text, "link: ") {
// Link // Link
entry.link = strings.TrimPrefix(text, "link: ") entry.link = strings.TrimPrefix(text, "link: ")
@ -229,96 +198,28 @@ func computeExtraSettings(entry *Entry) error {
} else if strings.HasPrefix(text, "like-title: ") { } else if strings.HasPrefix(text, "like-title: ") {
// Like title // Like title
entry.likeTitle = strings.TrimPrefix(text, "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 { } else {
_, _ = fmt.Fprintln(&filteredContent, text) _, _ = fmt.Fprintln(&filteredContent, text)
} }
} }
entry.content = filteredContent.String() 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 // Compute slug if empty
if len(entry.slug) == 0 || entry.slug == "" { if len(entry.slug) == 0 || entry.slug == "" {
random := generateRandomString(now, 5) random := generateRandomString(now, 5)
entry.slug = fmt.Sprintf("%v-%02d-%02d-%v", now.Year(), int(now.Month()), now.Day(), random) 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 // Compute filename and location
lang := Languages[entry.language] if len(entry.section) < 1 {
contentFolder := lang.ContentDir entry.section = "micro"
localizedBlogUrl := BlogUrl
if len(lang.BlogUrl) != 0 {
localizedBlogUrl = lang.BlogUrl
}
if len(entry.section) == 0 {
entry.section = lang.DefaultSection
} }
entry.section = strings.ToLower(entry.section) entry.section = strings.ToLower(entry.section)
section := lang.Sections[entry.section] if entry.section == "thoughts" || entry.section == "links" || entry.section == "micro" {
pathVars := struct { entry.filename = fmt.Sprintf("content/%v/%02d/%02d/%v.md", entry.section, now.Year(), int(now.Month()), entry.slug)
LocalContentFolder string entry.location = fmt.Sprintf("%v%v/%02d/%02d/%v/", BlogUrl, entry.section, now.Year(), int(now.Month()), entry.slug)
LocalBlogUrl string } else {
Year int entry.filename = fmt.Sprintf("content/%v/%v.md", entry.section, entry.slug)
Month int entry.location = fmt.Sprintf("%v%v/%v/", BlogUrl, entry.section, entry.slug)
Slug string
Section string
}{
LocalContentFolder: contentFolder,
LocalBlogUrl: localizedBlogUrl,
Year: now.Year(),
Month: int(now.Month()),
Slug: entry.slug,
Section: entry.section,
} }
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 return nil
} }
@ -344,3 +245,34 @@ func WriteEntry(entry *Entry) (location string, err error) {
location = entry.location location = entry.location
return 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 ( require (
codeberg.org/jlelse/tinify v0.0.0-20200123222407-7fc9c21822b0 github.com/caarlos0/env/v6 v6.1.0
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/go-telegram-bot-api/telegram-bot-api v4.6.5-0.20190904012038-b33efeebc785+incompatible
github.com/gliderlabs/ssh v0.3.0 // indirect github.com/technoweenie/multipartstreamer v1.0.1 // indirect
github.com/go-git/go-git/v5 v5.1.0 gopkg.in/yaml.v2 v2.2.7
github.com/google/go-cmp v0.5.2 // indirect willnorris.com/go/webmention v0.0.0-20191104072158-c7fb13569b62
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
) )

123
go.sum
View File

@ -1,115 +1,26 @@
codeberg.org/jlelse/tinify v0.0.0-20200123222407-7fc9c21822b0 h1:pJX79kTd01NtxEnzhfd4OU2SY9fquKVoO47DUeNKe+8= github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o=
codeberg.org/jlelse/tinify v0.0.0-20200123222407-7fc9c21822b0/go.mod h1:X6cM4Sn0aL/4VQ/ku11yxmiV0WIk5XAaYEPHQLQjFFM= github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs= github.com/caarlos0/env/v6 v6.1.0 h1:4FbM+HmZA/Q5wdSrH2kj0KQXm7xnhuO8y3TuOTnOvqc=
github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= github.com/caarlos0/env/v6 v6.1.0/go.mod h1:iUA6X3VCAOwDhoqvgKlTGjjwJzQseIJaFYApUqQkt+8=
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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/go-telegram-bot-api/telegram-bot-api v4.6.5-0.20190904012038-b33efeebc785+incompatible h1:OT02onvXX618RBcjxeUA4H7d1PSm5Apg4IET72VgVlE=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-telegram-bot-api/telegram-bot-api v4.6.5-0.20190904012038-b33efeebc785+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM=
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/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= 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.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/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/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 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70= github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM=
github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3 h1:czFLhve3vsQetD6JOJ8NZZvGQIXlnN3/yXxbT6/awxI=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 h1:xMPOj6Pz6UipU1wXLkrtqpHbR0AVFnyPEQq/wRWz9lM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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.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.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= willnorris.com/go/webmention v0.0.0-20191104072158-c7fb13569b62 h1:jqC8A1S2/9WjXSOK/Nl2rYwVgxU7DCnZ/zpOTL1BErI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= willnorris.com/go/webmention v0.0.0-20191104072158-c7fb13569b62/go.mod h1:p+ZRAsZS2pzZ6kX3GKWYurf3WZI2ygj7VbR8NM8qwfM=
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=

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 ( import (
"log" "log"
"net/http" "net/http"
"strings"
"time" "time"
) )
@ -14,7 +15,9 @@ func main() {
log.Println("Starting micropub server...") log.Println("Starting micropub server...")
log.Println("Current time: " + time.Now().Format(time.RFC3339)) log.Println("Current time: " + time.Now().Format(time.RFC3339))
log.Println("Blog URL: " + BlogUrl) log.Println("Blog URL: " + BlogUrl)
log.Println("Ignored URLs for Webmention: " + strings.Join(IgnoredWebmentionUrls, ", "))
http.HandleFunc("/micropub", HandleMicroPub) http.HandleFunc("/micropub", HandleMicroPub)
http.HandleFunc("/media", HandleMedia) http.HandleFunc("/media", HandleMedia)
http.HandleFunc("/webmention", HandleWebmention)
log.Fatal(http.ListenAndServe(":5555", nil)) log.Fatal(http.ListenAndServe(":5555", nil))
} }

View File

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

View File

@ -1,10 +1,7 @@
package main package main
import ( import (
"crypto/sha256"
"errors" "errors"
"fmt"
"io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/url" "net/url"
@ -26,9 +23,9 @@ type BunnyCdnStorage struct {
var bunnyCdnStorageUrl = "https://storage.bunnycdn.com" var bunnyCdnStorageUrl = "https://storage.bunnycdn.com"
func (b *BunnyCdnStorage) Upload(fileName string, file multipart.File) (location string, err error) { func (b BunnyCdnStorage) Upload(fileName string, file multipart.File) (location string, err error) {
client := http.DefaultClient client := &http.Client{}
req, _ := http.NewRequest(http.MethodPut, bunnyCdnStorageUrl+"/"+url.PathEscape(b.storageZoneName)+"/"+url.PathEscape(fileName), file) req, _ := http.NewRequest("PUT", bunnyCdnStorageUrl+"/"+url.PathEscape(b.storageZoneName)+"/"+url.PathEscape("/"+fileName), file)
req.Header.Add("AccessKey", b.key) req.Header.Add("AccessKey", b.key)
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil || resp.StatusCode != 201 { 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 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"` BookmarkOf []string `json:"bookmark-of,omitempty"`
MpSlug []string `json:"mp-slug,omitempty"` MpSlug []string `json:"mp-slug,omitempty"`
MpSyndicateTo []string `json:"mp-syndicate-to,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 ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv"
"time"
) )
type MicropubConfig struct { type MicropubConfig struct {
@ -16,10 +18,29 @@ func HandleMicroPub(w http.ResponseWriter, r *http.Request) {
if q := r.URL.Query().Get("q"); q == "config" || q == "syndicate-to" { if q := r.URL.Query().Get("q"); q == "config" || q == "syndicate-to" {
w.Header().Add("Content-type", "application/json") w.Header().Add("Content-type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(&MicropubConfig{ jsonBytes, err := json.Marshal(&MicropubConfig{
SyndicateTo: SyndicationTargets, SyndicateTo: SyndicationTargets,
MediaEndpoint: MediaEndpointUrl, MediaEndpoint: MediaEndpointUrl,
}) })
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 return
} else { } else {
w.Header().Add("Content-type", "application/json") w.Header().Add("Content-type", "application/json")
@ -62,8 +83,23 @@ func HandleMicroPub(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("There was an error committing the entry to the repository")) _, _ = w.Write([]byte("There was an error committing the entry to the repository"))
return return
} else { } else {
w.Header().Add("Location", location+"?cache=0") w.Header().Add("Location", location)
w.WriteHeader(http.StatusAccepted) 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 return
} }
} else { } 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 ( import (
"bytes" "bytes"
"errors" "errors"
"gopkg.in/yaml.v2"
"gopkg.in/yaml.v3" "strings"
) )
type HugoFrontMatter struct { type HugoFrontmatter struct {
Title string `yaml:"title,omitempty"` Title string `yaml:"title,omitempty"`
Published string `yaml:"date,omitempty"` Date string `yaml:"date,omitempty"`
Updated string `yaml:"lastmod,omitempty"` Lastmod string `yaml:"lastmod,omitempty"`
Tags []string `yaml:"tags,omitempty"` Tags []string `yaml:"tags,omitempty"`
Series []string `yaml:"series,omitempty"`
ExternalURL string `yaml:"externalURL,omitempty"` ExternalURL string `yaml:"externalURL,omitempty"`
IndieWeb HugoFrontMatterIndieWeb `yaml:"indieweb,omitempty"` Indieweb HugoFrontmatterIndieweb `yaml:"indieweb,omitempty"`
Syndicate []string `yaml:"syndicate,omitempty"` Syndicate []string `yaml:"syndicate,omitempty"`
TranslationKey string `yaml:"translationKey,omitempty"`
Images []string `yaml:"images,omitempty"`
Audio string `yaml:"audio,omitempty"`
} }
type HugoFrontMatterIndieWeb struct { type HugoFrontmatterIndieweb struct {
Reply HugoFrontMatterReply `yaml:"reply,omitempty"` Reply HugoFrontmatterReply `yaml:"reply,omitempty"`
Like HugoFrontMatterLike `yaml:"like,omitempty"` Like HugoFrontmatterLike `yaml:"like,omitempty"`
} }
type HugoFrontMatterReply struct { type HugoFrontmatterReply struct {
Link string `yaml:"link,omitempty"` Link string `yaml:"link,omitempty"`
Title string `yaml:"title,omitempty"` Title string `yaml:"title,omitempty"`
} }
type HugoFrontMatterLike struct { type HugoFrontmatterLike struct {
Link string `yaml:"link,omitempty"` Link string `yaml:"link,omitempty"`
Title string `yaml:"title,omitempty"` Title string `yaml:"title,omitempty"`
} }
func writeFrontMatter(entry *Entry) (string, error) { func writeFrontMatter(entry *Entry) (frontmatter string, err error) {
frontMatter := &HugoFrontMatter{ var buff bytes.Buffer
writeFrontmatter := &HugoFrontmatter{
Title: entry.title, Title: entry.title,
Published: entry.date, Date: entry.date,
Updated: entry.lastmod, Lastmod: entry.lastmod,
Tags: entry.tags, Tags: entry.tags,
Series: entry.series,
ExternalURL: entry.link, ExternalURL: entry.link,
IndieWeb: HugoFrontMatterIndieWeb{ Indieweb: HugoFrontmatterIndieweb{
Reply: HugoFrontMatterReply{ Reply: HugoFrontmatterReply{
Link: entry.replyLink, Link: entry.replyLink,
Title: entry.replyTitle, Title: entry.replyTitle,
}, },
Like: HugoFrontMatterLike{ Like: HugoFrontmatterLike{
Link: entry.likeLink, Link: entry.likeLink,
Title: entry.likeTitle, Title: entry.likeTitle,
}, },
}, },
Syndicate: entry.syndicate, Syndicate: entry.syndicate,
TranslationKey: entry.translationKey,
Audio: entry.audio,
} }
for _, image := range entry.images { yamlBytes, err := yaml.Marshal(&writeFrontmatter)
frontMatter.Images = append(frontMatter.Images, image.url)
}
writer := new(bytes.Buffer)
writer.WriteString("---\n")
err := yaml.NewEncoder(writer).Encode(&frontMatter)
if err != nil { if err != nil {
return "", errors.New("failed encoding frontmatter") err = errors.New("failed marshaling frontmatter")
return
} }
writer.WriteString("---\n") buff.WriteString("---\n")
return writer.String(), nil buff.Write(yamlBytes)
buff.WriteString("---\n")
frontmatter = buff.String()
return
} }
func WriteHugoPost(entry *Entry) (string, error) { func WriteHugoPost(entry *Entry) (string, error) {
buff := new(bytes.Buffer) var buff bytes.Buffer
f, err := writeFrontMatter(entry) frontmatter, err := writeFrontMatter(entry)
if err != nil { if err != nil {
return "", err return "", err
} }
buff.WriteString(f) buff.WriteString(frontmatter)
if len(entry.content) > 0 {
buff.WriteString(entry.content) buff.WriteString(entry.content)
}
return buff.String(), nil 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 package main
import ( import (
"bytes"
"encoding/base64"
"encoding/json"
"errors" "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" "io/ioutil"
"os" "net/http"
"path" "net/url"
"sync" "strings"
"time"
) )
type Storage interface { type Storage interface {
CreateFile(path string, file string, message string) (err error) CreateFile(path string, file string, message string) (err error)
UpdateFile(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 { // Gitea
filepath string type Gitea struct {
url string endpoint string
username string token string
password string
name string
email string
lock *sync.Mutex
} }
func (g *Git) init() (r *git.Repository, w *git.Worktree, err error) { type giteaCommitRequest struct {
// Open repo Content string `json:"content"`
r, err = git.PlainOpen(g.filepath) Message string `json:"message"`
if err == nil { SHA string `json:"sha,omitempty"`
// Try to get work tree }
w, err = r.Worktree()
if err == nil { type giteaReadResponse struct {
// Try to pull Type string `json:"type"`
err = w.Pull(&git.PullOptions{ Content string `json:"content"`
Auth: &gitHttp.BasicAuth{ SHA string `json:"sha"`
Username: g.username, }
Password: g.password,
}, type giteaErrorResponse struct {
SingleBranch: true, Message string `json:"message"`
}) }
if err == git.NoErrAlreadyUpToDate {
err = nil 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 { if err != nil {
// Delete old things return errors.New("failed to marshal json before committing")
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,
})
if err != nil {
err = errors.New("failed to clone repo")
} }
if err == nil { resp, err := http.Post(gitea.endpoint+url.QueryEscape(path)+"?access_token="+gitea.token, "application/json", bytes.NewBuffer(bytesRepresentation))
w, err = r.Worktree() if err != nil || resp.StatusCode != 201 {
if err != nil { return errors.New("failed to create file in repo")
err = errors.New("failed to get work tree")
} }
}
}
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 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) { func (gitea *Gitea) UpdateFile(path string, file string, message string) (err error) {
g.lock.Lock() _, sha, exists, err := gitea.ReadFile(path)
defer g.lock.Unlock()
_, _, err = g.init()
if err != nil { if err != nil {
err = errors.New("failed to initialize repo") return err
}
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 {
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 return
} }
joinedPath := path.Join(g.filepath, filepath) errorResponse := &giteaErrorResponse{}
if _, e := os.Stat(joinedPath); e == nil { marshalErr := json.Unmarshal(body, &errorResponse)
return errors.New("file already exists") if marshalErr != nil {
} else { err = errors.New("failed parsing Gitea error response")
return g.unsafeUpdateFile(filepath, file, message) return
} }
} exists = !strings.Contains(errorResponse.Message, "does not exist")
if !exists {
func (g *Git) UpdateFile(filepath string, file string, message string) error { return
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 { exists = true
repo, w, err := g.init() defer resp.Body.Close()
if err != nil { body, err := ioutil.ReadAll(resp.Body)
return errors.New("failed to initialize repo") if err != nil {
} err = errors.New("failed reading file in repo")
joinedPath := path.Join(g.filepath, filepath) return
_ = os.MkdirAll(path.Dir(joinedPath), 0755) }
err = ioutil.WriteFile(joinedPath, []byte(file), 0644) readResponse := &giteaReadResponse{}
if err != nil { err = json.Unmarshal(body, &readResponse)
return errors.New("failed to write to file") if err != nil {
} err = errors.New("failed parsing Gitea response")
status, err := w.Status() return
if err == nil && status.IsClean() { }
// No file changes, prevent empty commit decodedContentBytes, err := base64.StdEncoding.DecodeString(readResponse.Content)
return nil if err != nil {
} else { err = errors.New("failed decoding file content")
err = nil }
} content = string(decodedContentBytes)
_, err = w.Add(filepath) sha = readResponse.SHA
if err != nil { return
return errors.New("failed to stage file")
}
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)
} }

View File

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