commit 7c07e4fdd9b8ecefd1ff9e9a9171c5e5d7f6cfd6 Author: Jan-Lukas Else Date: Thu Nov 7 11:00:24 2019 +0100 Init 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") +}