Init
commit
7c07e4fdd9
|
@ -0,0 +1,2 @@
|
||||||
|
.idea
|
||||||
|
*.iml
|
|
@ -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"]
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
Loading…
Reference in New Issue