Merge pull request 'WIP micropub' (#28) from micropub into main

Reviewed-on: #28
This commit is contained in:
h4kor 2022-11-19 15:35:30 +00:00
commit b5dca6fa53
8 changed files with 340 additions and 12 deletions

View File

@ -8,6 +8,7 @@ import (
"os" "os"
"path" "path"
"strings" "strings"
"time"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
) )
@ -249,6 +250,78 @@ func postMediaHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Requ
} }
} }
func userMicropubHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
// parse request form
err = r.ParseForm()
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Bad request"))
return
}
// verify access token
token := r.Header.Get("Authorization")
if token == "" {
token = r.Form.Get("access_token")
} else {
token = strings.TrimPrefix(token, "Bearer ")
}
valid, _ := user.ValidateAccessToken(token)
if !valid {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorized"))
return
}
h := r.Form.Get("h")
content := r.Form.Get("content")
name := r.Form.Get("name")
inReplyTo := r.Form.Get("in-reply-to")
if h != "entry" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Bad request. h must be entry. Got " + h))
return
}
if content == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Bad request. content is required"))
return
}
// create post
post, err := user.CreateNewPostFull(
owl.PostMeta{
Title: name,
Reply: owl.Reply{
Url: inReplyTo,
},
Date: time.Now(),
},
content,
)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal server error"))
return
}
w.Header().Add("Location", post.FullUrl())
w.WriteHeader(http.StatusCreated)
}
}
func userMediaHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) { func userMediaHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
filepath := ps.ByName("filepath") filepath := ps.ByName("filepath")

View File

@ -0,0 +1,183 @@
package web_test
import (
"h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/test/assertions"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
)
func TestMicropubMinimalArticle(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
code, _ := user.GenerateAuthCode(
"test", "test", "test", "test", "test",
)
token, _, _ := user.GenerateAccessToken(owl.AuthCode{
Code: code,
ClientId: "test",
RedirectUri: "test",
CodeChallenge: "test",
CodeChallengeMethod: "test",
Scope: "test",
})
// Create Request and Response
form := url.Values{}
form.Add("h", "entry")
form.Add("name", "Test Article")
form.Add("content", "Test Content")
req, err := http.NewRequest("POST", user.MicropubUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.Header.Add("Authorization", "Bearer "+token)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusCreated)
}
func TestMicropubWithoutName(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
code, _ := user.GenerateAuthCode(
"test", "test", "test", "test", "test",
)
token, _, _ := user.GenerateAccessToken(owl.AuthCode{
Code: code,
ClientId: "test",
RedirectUri: "test",
CodeChallenge: "test",
CodeChallengeMethod: "test",
Scope: "test",
})
// Create Request and Response
form := url.Values{}
form.Add("h", "entry")
form.Add("content", "Test Content")
req, err := http.NewRequest("POST", user.MicropubUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.Header.Add("Authorization", "Bearer "+token)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusCreated)
loc_header := rr.Header().Get("Location")
assertions.Assert(t, loc_header != "", "Location header should be set")
}
func TestMicropubAccessTokenInBody(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
code, _ := user.GenerateAuthCode(
"test", "test", "test", "test", "test",
)
token, _, _ := user.GenerateAccessToken(owl.AuthCode{
Code: code,
ClientId: "test",
RedirectUri: "test",
CodeChallenge: "test",
CodeChallengeMethod: "test",
Scope: "test",
})
// Create Request and Response
form := url.Values{}
form.Add("h", "entry")
form.Add("content", "Test Content")
form.Add("access_token", token)
req, err := http.NewRequest("POST", user.MicropubUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusCreated)
loc_header := rr.Header().Get("Location")
assertions.Assert(t, loc_header != "", "Location header should be set")
}
// func TestMicropubAccessTokenInBoth(t *testing.T) {
// repo, user := getSingleUserTestRepo()
// user.ResetPassword("testpassword")
// code, _ := user.GenerateAuthCode(
// "test", "test", "test", "test", "test",
// )
// token, _, _ := user.GenerateAccessToken(owl.AuthCode{
// Code: code,
// ClientId: "test",
// RedirectUri: "test",
// CodeChallenge: "test",
// CodeChallengeMethod: "test",
// Scope: "test",
// })
// // Create Request and Response
// form := url.Values{}
// form.Add("h", "entry")
// form.Add("content", "Test Content")
// form.Add("access_token", token)
// req, err := http.NewRequest("POST", user.MicropubUrl(), strings.NewReader(form.Encode()))
// req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
// req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
// req.Header.Add("Authorization", "Bearer "+token)
// assertions.AssertNoError(t, err, "Error creating request")
// rr := httptest.NewRecorder()
// router := main.SingleUserRouter(&repo)
// router.ServeHTTP(rr, req)
// assertions.AssertStatus(t, rr, http.StatusBadRequest)
// }
func TestMicropubNoAccessToken(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
code, _ := user.GenerateAuthCode(
"test", "test", "test", "test", "test",
)
user.GenerateAccessToken(owl.AuthCode{
Code: code,
ClientId: "test",
RedirectUri: "test",
CodeChallenge: "test",
CodeChallengeMethod: "test",
Scope: "test",
})
// Create Request and Response
form := url.Values{}
form.Add("h", "entry")
form.Add("content", "Test Content")
req, err := http.NewRequest("POST", user.MicropubUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusUnauthorized)
}

View File

@ -23,6 +23,7 @@ func Router(repo *owl.Repository) http.Handler {
router.GET("/user/:user/posts/:post/", postHandler(repo)) router.GET("/user/:user/posts/:post/", postHandler(repo))
router.GET("/user/:user/posts/:post/media/*filepath", postMediaHandler(repo)) router.GET("/user/:user/posts/:post/media/*filepath", postMediaHandler(repo))
router.POST("/user/:user/webmention/", userWebmentionHandler(repo)) router.POST("/user/:user/webmention/", userWebmentionHandler(repo))
router.POST("/user/:user/micropub/", userMicropubHandler(repo))
router.GET("/user/:user/.well-known/oauth-authorization-server", userAuthMetadataHandler(repo)) router.GET("/user/:user/.well-known/oauth-authorization-server", userAuthMetadataHandler(repo))
router.NotFound = http.HandlerFunc(notFoundHandler(repo)) router.NotFound = http.HandlerFunc(notFoundHandler(repo))
return router return router
@ -41,6 +42,7 @@ func SingleUserRouter(repo *owl.Repository) http.Handler {
router.GET("/posts/:post/", postHandler(repo)) router.GET("/posts/:post/", postHandler(repo))
router.GET("/posts/:post/media/*filepath", postMediaHandler(repo)) router.GET("/posts/:post/media/*filepath", postMediaHandler(repo))
router.POST("/webmention/", userWebmentionHandler(repo)) router.POST("/webmention/", userWebmentionHandler(repo))
router.POST("/micropub/", userMicropubHandler(repo))
router.GET("/.well-known/oauth-authorization-server", userAuthMetadataHandler(repo)) router.GET("/.well-known/oauth-authorization-server", userAuthMetadataHandler(repo))
router.NotFound = http.HandlerFunc(notFoundHandler(repo)) router.NotFound = http.HandlerFunc(notFoundHandler(repo))
return router return router

View File

@ -31,6 +31,7 @@
<link rel="indieauth-metadata" href="{{ .User.IndieauthMetadataUrl }}"> <link rel="indieauth-metadata" href="{{ .User.IndieauthMetadataUrl }}">
<link rel="authorization_endpoint" href="{{ .User.AuthUrl}}"> <link rel="authorization_endpoint" href="{{ .User.AuthUrl}}">
<link rel="token_endpoint" href="{{ .User.TokenUrl}}"> <link rel="token_endpoint" href="{{ .User.TokenUrl}}">
<link rel="micropub" href="{{ .User.MicropubUrl}}">
{{ end }} {{ end }}
<style> <style>
header { header {

View File

@ -2,7 +2,9 @@
{{range .}} {{range .}}
<div class="h-entry"> <div class="h-entry">
<hgroup> <hgroup>
<h3><a class="u-url" href="{{.UrlPath}}">{{.Title}}</a></h3> <h3><a class="u-url" href="{{.UrlPath}}">
{{ if .Title }}{{.Title}}{{ else }}Note: {{.Id}}{{ end }}
</a></h3>
<small> <small>
Published: Published:
<time class="dt-published" datetime="{{.Meta.Date}}"> <time class="dt-published" datetime="{{.Meta.Date}}">

View File

@ -86,6 +86,14 @@ func TestCanRenderIndexPage(t *testing.T) {
assertions.AssertContains(t, result, "testpost2") assertions.AssertContains(t, result, "testpost2")
} }
func TestCanRenderIndexPageNoTitle(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPostFull(owl.PostMeta{}, "hi")
result, _ := owl.RenderIndexPage(user)
assertions.AssertContains(t, result, post.Id())
assertions.AssertContains(t, result, "Note: ")
}
func TestIndexPageContainsHFeedContainer(t *testing.T) { func TestIndexPageContainsHFeedContainer(t *testing.T) {
user := getTestUser() user := getTestUser()
user.CreateNewPost("testpost1", false) user.CreateNewPost("testpost1", false)

45
user.go
View File

@ -88,6 +88,11 @@ func (user User) WebmentionUrl() string {
return url return url
} }
func (user User) MicropubUrl() string {
url, _ := url.JoinPath(user.FullUrl(), "micropub/")
return url
}
func (user User) MediaUrl() string { func (user User) MediaUrl() string {
url, _ := url.JoinPath(user.UrlPath(), "media") url, _ := url.JoinPath(user.UrlPath(), "media")
return url return url
@ -203,8 +208,12 @@ func (user User) GetPost(id string) (*Post, error) {
return &post, nil return &post, nil
} }
func (user User) CreateNewPost(title string, draft bool) (*Post, error) { func (user User) CreateNewPostFull(meta PostMeta, content string) (*Post, error) {
folder_name := toDirectoryName(title) slugHint := meta.Title
if slugHint == "" {
slugHint = "note"
}
folder_name := toDirectoryName(slugHint)
post_dir := path.Join(user.Dir(), "public", folder_name) post_dir := path.Join(user.Dir(), "public", folder_name)
// if post already exists, add -n to the end of the name // if post already exists, add -n to the end of the name
@ -212,19 +221,13 @@ func (user User) CreateNewPost(title string, draft bool) (*Post, error) {
for { for {
if dirExists(post_dir) { if dirExists(post_dir) {
i++ i++
folder_name = toDirectoryName(fmt.Sprintf("%s-%d", title, i)) folder_name = toDirectoryName(fmt.Sprintf("%s-%d", slugHint, i))
post_dir = path.Join(user.Dir(), "public", folder_name) post_dir = path.Join(user.Dir(), "public", folder_name)
} else { } else {
break break
} }
} }
post := Post{user: &user, id: folder_name, title: title} post := Post{user: &user, id: folder_name, title: slugHint}
meta := PostMeta{
Title: title,
Date: time.Now(),
Aliases: []string{},
Draft: draft,
}
initial_content := "" initial_content := ""
initial_content += "---\n" initial_content += "---\n"
@ -236,7 +239,7 @@ func (user User) CreateNewPost(title string, draft bool) (*Post, error) {
initial_content += string(meta_bytes) initial_content += string(meta_bytes)
initial_content += "---\n" initial_content += "---\n"
initial_content += "\n" initial_content += "\n"
initial_content += "Write your post here.\n" initial_content += content
// create post file // create post file
os.Mkdir(post_dir, 0755) os.Mkdir(post_dir, 0755)
@ -246,6 +249,16 @@ func (user User) CreateNewPost(title string, draft bool) (*Post, error) {
return &post, nil return &post, nil
} }
func (user User) CreateNewPost(title string, draft bool) (*Post, error) {
meta := PostMeta{
Title: title,
Date: time.Now(),
Aliases: []string{},
Draft: draft,
}
return user.CreateNewPostFull(meta, title)
}
func (user User) Template() (string, error) { func (user User) Template() (string, error) {
// load base.html // load base.html
path := path.Join(user.Dir(), "meta", "base.html") path := path.Join(user.Dir(), "meta", "base.html")
@ -379,3 +392,13 @@ func (user User) GenerateAccessToken(authCode AuthCode) (string, int, error) {
Created: time.Now(), Created: time.Now(),
}) })
} }
func (user User) ValidateAccessToken(token string) (bool, AccessToken) {
tokens := user.getAccessTokens()
for _, t := range tokens {
if t.Token == token {
return true, t
}
}
return false, AccessToken{}
}

View File

@ -314,3 +314,39 @@ func TestVerifyPassword(t *testing.T) {
assertions.Assert(t, !user.VerifyPassword("0000000"), "Password should be incorrect") assertions.Assert(t, !user.VerifyPassword("0000000"), "Password should be incorrect")
} }
func TestValidateAccessTokenWrongToken(t *testing.T) {
user := getTestUser()
code, _ := user.GenerateAuthCode(
"test", "test", "test", "test", "test",
)
user.GenerateAccessToken(owl.AuthCode{
Code: code,
ClientId: "test",
RedirectUri: "test",
CodeChallenge: "test",
CodeChallengeMethod: "test",
Scope: "test",
})
valid, _ := user.ValidateAccessToken("test")
assertions.Assert(t, !valid, "Token should be invalid")
}
func TestValidateAccessTokenCorrectToken(t *testing.T) {
user := getTestUser()
code, _ := user.GenerateAuthCode(
"test", "test", "test", "test", "test",
)
token, _, _ := user.GenerateAccessToken(owl.AuthCode{
Code: code,
ClientId: "test",
RedirectUri: "test",
CodeChallenge: "test",
CodeChallengeMethod: "test",
Scope: "test",
})
valid, aToken := user.ValidateAccessToken(token)
assertions.Assert(t, valid, "Token should be valid")
assertions.Assert(t, aToken.ClientId == "test", "Token should be valid")
assertions.Assert(t, aToken.Token == token, "Token should be valid")
}