kcoram/uplift
Jan-Lukas Else 2019-11-07 11:00:24 +01:00
commit 7c07e4fdd9
10 changed files with 441 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.idea
*.iml

10
Dockerfile Normal file
View File

@ -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"]

30
config.go Normal file
View File

@ -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
}

140
entry.go Normal file
View File

@ -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
}

35
gitea.go Normal file
View File

@ -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
}

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module codeberg.org/jlelse/hugo-micropub
go 1.13

0
go.sum Normal file
View File

75
main.go Normal file
View File

@ -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))
}

35
post.go Normal file
View File

@ -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()
}

111
validation.go Normal file
View File

@ -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")
}