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