From 7c07e4fdd9b8ecefd1ff9e9a9171c5e5d7f6cfd6 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Else Date: Thu, 7 Nov 2019 11:00:24 +0100 Subject: [PATCH] Init --- .gitignore | 2 + Dockerfile | 10 ++++ config.go | 30 +++++++++++ entry.go | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++ gitea.go | 35 +++++++++++++ go.mod | 3 ++ go.sum | 0 main.go | 75 +++++++++++++++++++++++++++ post.go | 35 +++++++++++++ validation.go | 111 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 441 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 config.go create mode 100644 entry.go create mode 100644 gitea.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 post.go create mode 100644 validation.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29b636a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +*.iml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..734e36c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM golang:1.13-alpine as build +ADD . /app +WORKDIR /app +RUN go build + +FROM alpine:3.10 +RUN apk add --no-cache tzdata ca-certificates && update-ca-certificates +COPY --from=build /app/hugo-micropub /bin/ +EXPOSE 5555 +CMD ["hugo-micropub"] \ No newline at end of file diff --git a/config.go b/config.go new file mode 100644 index 0000000..500e29a --- /dev/null +++ b/config.go @@ -0,0 +1,30 @@ +package main + +import ( + "errors" + "os" +) + +func GetGiteaEndpoint() (string, error) { + giteaEndpoint := os.Getenv("GITEA_ENDPOINT") + if len(giteaEndpoint) == 0 || giteaEndpoint == "" { + return "", errors.New("GITEA_ENDPOINT not specified") + } + return giteaEndpoint, nil +} + +func GetGiteaToken() (string, error) { + giteaToken := os.Getenv("GITEA_TOKEN") + if len(giteaToken) == 0 || giteaToken == "" { + return "", errors.New("GITEA_TOKEN not specified") + } + return giteaToken, nil +} + +func GetBlogURL() (string, error) { + blogURL := os.Getenv("BLOG_URL") + if len(blogURL) == 0 || blogURL == "" { + return "", errors.New("BLOG_URL not specified") + } + return blogURL, nil +} diff --git a/entry.go b/entry.go new file mode 100644 index 0000000..8b2cd27 --- /dev/null +++ b/entry.go @@ -0,0 +1,140 @@ +package main + +import ( + "errors" + "fmt" + "math/rand" + "net/url" + "strings" + "time" +) + +type Entry struct { + Content string + Name string + Categories []string + Slug string + Summary string + InReplyTo string + LikeOf string + RepostOf string + section string + location string + filename string + token string +} + +func CreateEntry(contentType ContentType, body string) (*Entry, error) { + if contentType == WwwForm { + bodyValues, err := url.ParseQuery(body) + if err != nil { + return nil, errors.New("failed to parse query") + } + return createEntryFromURLValues(bodyValues) + } else if contentType == Json || contentType == Multipart { + return nil, errors.New("multipart and json content-type are not implemented yet") + } else { + return nil, errors.New("unsupported content-type") + } +} + +func createEntryFromURLValues(bodyValues url.Values) (*Entry, error) { + if h, ok := bodyValues["h"]; ok && len(h) == 1 && h[0] != "entry" { + return nil, errors.New("only entry type is supported so far") + } + if _, ok := bodyValues["content"]; ok { + entry := new(Entry) + entry.Content = bodyValues["content"][0] + if name, ok := bodyValues["name"]; ok { + entry.Name = name[0] + } + if category, ok := bodyValues["category"]; ok { + entry.Categories = category + } else if categories, ok := bodyValues["category[]"]; ok { + entry.Categories = categories + } else { + entry.Categories = nil + } + if slug, ok := bodyValues["mp-slug"]; ok && len(slug) > 0 && slug[0] != "" { + entry.Slug = slug[0] + } + if summary, ok := bodyValues["summary"]; ok { + entry.Summary = summary[0] + } + if inReplyTo, ok := bodyValues["in-reply-to"]; ok { + entry.InReplyTo = inReplyTo[0] + } + if likeOf, ok := bodyValues["like-of"]; ok { + entry.LikeOf = likeOf[0] + } + if repostOf, ok := bodyValues["repost-of"]; ok { + entry.RepostOf = repostOf[0] + } + if token, ok := bodyValues["access_token"]; ok { + entry.token = "Bearer " + token[0] + } + err := computeExtraSettings(entry) + if err != nil { + return nil, err + } + return entry, nil + } + return nil, errors.New("error parsing the entry from URL Values") +} + +func computeExtraSettings(entry *Entry) error { + now := time.Now() + entry.section = "micro" + // Find settings hidden in category strings + filteredCategories := make([]string, 0) + for _, category := range entry.Categories { + if strings.HasPrefix(category, "section-") { + entry.section = strings.TrimPrefix(category, "section-") + } else if strings.HasPrefix(category, "slug-") { + entry.Slug = strings.TrimPrefix(category, "slug-") + } else { + filteredCategories = append(filteredCategories, category) + } + } + entry.Categories = filteredCategories + // 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) + } + // Compute filename and location + blogURL, err := GetBlogURL() + if err != nil { + return err + } + if entry.section == "posts" { + entry.filename = "content/" + entry.section + "/" + entry.Slug + ".md" + entry.location = blogURL + entry.section + "/" + entry.Slug + } else if entry.section == "thoughts" || entry.section == "links" { + 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 = "content/" + entry.section + "/" + entry.Slug + ".md" + entry.location = blogURL + entry.section + "/" + entry.Slug + } + return nil +} + +func generateRandomString(now time.Time, n int) string { + rand.Seed(now.UnixNano()) + letters := []rune("abcdefghijklmnopqrstuvwxyz") + b := make([]rune, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} + +func WriteEntry(entry *Entry) (string, error) { + file := WriteHugoPost(entry) + err := CommitEntry(entry.filename, file, entry.Name) + if err != nil { + return "", err + } + return entry.location, nil +} diff --git a/gitea.go b/gitea.go new file mode 100644 index 0000000..8ed4944 --- /dev/null +++ b/gitea.go @@ -0,0 +1,35 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/url" +) + +func CommitEntry(path string, file string, name string) error { + giteaEndpoint, err := GetGiteaEndpoint() + if err != nil { + return err + } + giteaToken, err := GetGiteaToken() + if err != nil { + return err + } + message := map[string]interface{}{ + "message": name, + "content": base64.StdEncoding.EncodeToString([]byte(file)), + } + bytesRepresentation, err := json.Marshal(message) + if err != nil { + return errors.New("failed to marshal json before committing") + } + // TODO: handle file updating + resp, err := http.Post(giteaEndpoint+url.QueryEscape(path)+"?access_token="+giteaToken, "application/json", bytes.NewBuffer(bytesRepresentation)) + if err != nil || resp.StatusCode != 201 { + return errors.New("failed to create file in repo") + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a8d42a8 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module codeberg.org/jlelse/hugo-micropub + +go 1.13 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/main.go b/main.go new file mode 100644 index 0000000..053c7a9 --- /dev/null +++ b/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "io/ioutil" + "log" + "net/http" + "time" +) + +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 == "syndicate-to" { + w.Header().Add("Content-type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("[]")) + return + } else { + w.Header().Add("Content-type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{}")) + return + } + } + // check if the request is a POST + if r.Method != "POST" { + w.WriteHeader(http.StatusMethodNotAllowed) + _, _ = w.Write([]byte("The HTTP method is not allowed, make a POST request")) + return + } + // check content type + contentType, err := GetContentType(r.Header.Get("content-type")) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(err.Error())) + return + } + // Create entry + defer r.Body.Close() + bodyBytes, err := ioutil.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + bodyString := string(bodyBytes) + entry, err := CreateEntry(contentType, bodyString) + if entry == nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("There was an error creating the entry")) + return + } + if CheckAuthorization(entry, r.Header.Get("authorization")) { + location, err := WriteEntry(entry) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("There was an error committing the entry to the repository")) + return + } else { + w.Header().Add("Location", location) + w.WriteHeader(http.StatusAccepted) + return + } + } else { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("Forbidden, there was a problem with the provided access token")) + return + } +} + +func main() { + http.HandleFunc("/", handleMicroPub) + log.Println("Starting micropub server...") + log.Println("Current time: " + time.Now().Format(time.RFC3339)) + log.Fatal(http.ListenAndServe(":5555", nil)) +} diff --git a/post.go b/post.go new file mode 100644 index 0000000..9a7e867 --- /dev/null +++ b/post.go @@ -0,0 +1,35 @@ +package main + +import ( + "bytes" + "time" +) + +func writeFrontMatter(entry *Entry) string { + var buff bytes.Buffer + t := time.Now().Format(time.RFC3339) + buff.WriteString("---\n") + if len(entry.Name) > 0 { + buff.WriteString("title: \"" + entry.Name + "\"\n") + } + buff.WriteString("date: " + t + "\n") + buff.WriteString("tags:\n") + for _, tag := range entry.Categories { + buff.WriteString("- " + tag + "\n") + } + buff.WriteString("indieweb:\n") + if len(entry.InReplyTo) > 0 { + buff.WriteString(" reply:\n link: " + entry.InReplyTo + "\n") + } + buff.WriteString("---\n") + return buff.String() +} + +func WriteHugoPost(entry *Entry) string { + var buff bytes.Buffer + buff.WriteString(writeFrontMatter(entry)) + if len(entry.Content) > 0 { + buff.WriteString(entry.Content + "\n") + } + return buff.String() +} diff --git a/validation.go b/validation.go new file mode 100644 index 0000000..5afe4d7 --- /dev/null +++ b/validation.go @@ -0,0 +1,111 @@ +package main + +import ( + "encoding/json" + "errors" + "io/ioutil" + "net/http" + "strings" +) + +type ContentType int + +const ( + WwwForm ContentType = iota + Json + Multipart + UnsupportedType +) + +const ( + indieAuthTokenUrl = "https://tokens.indieauth.com/token" +) + +type IndieAuthRes struct { + Me string `json:"me"` + ClientId string `json:"client_id"` + Scope string `json:"scope"` + Issue int `json:"issued_at"` + Nonce int `json:"nonce"` +} + +func checkAccess(token string) (bool, error) { + if token == "" { + return false, errors.New("token string is empty") + } + // form the request to check the token + 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") + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", token) + // send the request + res, err := client.Do(req) + if err != nil { + return false, errors.New("error sending the request for checking token access") + } + defer res.Body.Close() + // parse the response + 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()) + } + // verify results of the response + blogURL, err := GetBlogURL() + if err != nil { + return false, err + } + if indieAuthRes.Me != blogURL { + return false, errors.New("me does not match") + } + scopes := strings.Fields(indieAuthRes.Scope) + postPresent := false + for _, scope := range scopes { + if scope == "post" || scope == "create" || scope == "update" { + postPresent = true + break + } + } + if !postPresent { + return false, errors.New("post is not present in the scope") + } + return true, nil +} + +func CheckAuthorization(entry *Entry, token string) bool { + if len(token) < 1 { // there is no token provided + return false + } else { + entry.token = token + } + if ok, err := checkAccess(entry.token); ok { + return true + } else if err != nil { + return false + } else { + return false + } +} + +func GetContentType(contentType string) (ContentType, error) { + if contentType != "" { + if strings.Contains(contentType, "application/x-www-form-urlencoded") { + return WwwForm, nil + } + if strings.Contains(contentType, "application/json") { + return Json, nil + } + if strings.Contains(contentType, "multipart/form-data") { + return Multipart, nil + } + return UnsupportedType, errors.New("content-type " + contentType + " is not supported, use application/x-www-form-urlencoded") + } + return UnsupportedType, errors.New("content-type is not provided in the request") +}