This commit is contained in:
Niko Abeler 2023-07-19 20:59:43 +02:00
parent 10e0bde07b
commit 4540797cce
71 changed files with 0 additions and 8515 deletions

26
.gitignore vendored
View File

@ -1,26 +0,0 @@
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
users/
.vscode/
*.swp

View File

@ -1,3 +0,0 @@
{
"editor.formatOnSave": true,
}

View File

@ -1,33 +0,0 @@
##
## Build Container
##
FROM golang:1.19-alpine as build
RUN apk add --no-cache git
WORKDIR /tmp/owl
COPY go.mod .
COPY go.sum .
RUN go mod download
COPY . .
RUN go build -o ./out/owl ./cmd/owl
##
## Run Container
##
FROM alpine:3.9
RUN apk add ca-certificates
COPY --from=build /tmp/owl/out/ /bin/
# This container exposes port 8080 to the outside world
EXPOSE 8080
# Run the binary program produced by `go install`
ENTRYPOINT ["/bin/owl"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View File

@ -1,200 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="100mm"
height="100mm"
viewBox="0 0 100 100"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="owl.svg">
<defs
id="defs2">
<inkscape:path-effect
effect="mirror_symmetry"
start_point="101.5,113.98198"
end_point="101.5,177.55836"
center_point="101.5,145.77017"
id="path-effect4762"
is_visible="true"
mode="free"
discard_orig_path="false"
fuse_paths="true"
oposite_fuse="false" />
<inkscape:path-effect
effect="mirror_symmetry"
start_point="101.6,77.962793"
end_point="101.6,178.13471"
center_point="101.6,128.04875"
id="path-effect4630"
is_visible="true"
mode="free"
discard_orig_path="false"
fuse_paths="true"
oposite_fuse="false" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.98994949"
inkscape:cx="-5.4384962"
inkscape:cy="476.07169"
inkscape:document-units="mm"
inkscape:current-layer="layer5"
showgrid="false"
inkscape:window-width="2560"
inkscape:window-height="1391"
inkscape:window-x="0"
inkscape:window-y="25"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Body"
inkscape:groupmode="layer"
id="layer1"
style="display:inline"
transform="translate(0,-197)" />
<g
inkscape:groupmode="layer"
id="layer6"
inkscape:label="Front"
transform="translate(0,-197)" />
<g
inkscape:groupmode="layer"
id="layer5"
inkscape:label="Ref"
style="display:inline"
transform="translate(0,-197)" />
<g
inkscape:groupmode="layer"
id="layer4"
inkscape:label="Feet"
transform="translate(0,-197)" />
<g
inkscape:groupmode="layer"
id="layer3"
inkscape:label="Nose"
transform="translate(0,-197)" />
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="Eyes"
transform="translate(0,-197)">
<path
style="display:inline;opacity:1;fill:#686560;fill-opacity:1;stroke:#000000;stroke-width:1.46500003;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 72.665922,98.468004 c -3.804657,-4.064063 -8.937651,-7.726186 -7.559523,-20.505208 1.511903,2.173362 6.898064,8.031993 12.095236,8.031993 7.291682,0 22.616673,0.08369 24.398365,0.09355 1.78169,-0.0099 17.10668,-0.09355 24.39836,-0.09355 5.19718,0 10.58334,-5.858631 12.09524,-8.031993 1.37813,12.779022 -3.75487,16.441145 -7.55952,20.505208 0,0 4.84169,14.668796 9.88119,20.229646 10.76577,11.87955 9.28012,38.96737 -13.7775,54.51257 -4.9083,3.30912 -14.73063,5.13452 -25.03777,4.90519 -10.307138,0.22933 -20.129467,-1.59607 -25.037771,-4.90519 -23.057616,-15.5452 -24.543272,-42.63302 -13.777496,-54.51257 5.039494,-5.56085 9.881189,-20.229646 9.881189,-20.229646 z"
id="path46"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccsccssc"
inkscape:path-effect="#path-effect4630"
inkscape:original-d="m 72.665922,98.468004 c -3.804657,-4.064063 -8.937651,-7.726186 -7.559523,-20.505208 1.511903,2.173362 6.898064,8.031993 12.095236,8.031993 7.748513,0 24.568455,0.0945 24.568455,0.0945 9.17095,40.967981 -1.73329,86.382581 24.08866,86.819411 -14.73188,7.45015 -40.288984,6.3743 -49.296521,0.30152 -23.057616,-15.5452 -24.543272,-42.63302 -13.777496,-54.51257 5.039494,-5.56085 9.881189,-20.229646 9.881189,-20.229646 z"
transform="matrix(0.89013051,0,0,0.89013051,-40.43726,131.70004)" />
<path
style="display:inline;fill:#fbe9c4;fill-opacity:1;stroke:#262626;stroke-width:4.06500006;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 74.568197,129.95654 c -1.207452,8.188 -1.187282,34.70878 17.572973,44.03265 3.623482,1.36045 5.80487,3.13501 9.35883,3.62684 3.55396,-0.49183 5.73535,-2.26639 9.35883,-3.62684 18.76025,-9.32387 18.78042,-35.84465 17.57297,-44.03265 C 126.73222,117.4929 107.35585,115.8851 101.5,115.68436 95.644152,115.8851 76.267784,117.4929 74.568197,129.95654 Z"
id="path4760"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccscc"
inkscape:path-effect="#path-effect4762"
inkscape:original-d="m 74.568197,129.95654 c -1.207452,8.188 -1.187282,34.70878 17.572973,44.03265 4.717147,1.77107 6.990299,4.24396 13.02939,3.67496 5.2517,-4.28707 -4.51571,-38.74118 3.27405,-50.98166 11.76071,-18.48023 -5.27857,-11.02487 -5.27857,-11.02487 0,0 -26.593322,-0.4009 -28.597843,14.29892 z"
transform="matrix(0.89013051,0,0,0.89013051,-40.43726,131.70004)" />
<rect
style="opacity:1;fill:#674808;fill-opacity:1;stroke:#efc48c;stroke-width:2.72825003;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect4767"
width="23.046696"
height="2.0186887"
x="39.805622"
y="258.96619" />
<rect
style="opacity:1;fill:#674808;fill-opacity:1;stroke:#efc48c;stroke-width:2.72825003;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect4767-7"
width="23.046698"
height="2.0186887"
x="39.805622"
y="266.51617" />
<rect
style="opacity:1;fill:#674808;fill-opacity:1;stroke:#efc48c;stroke-width:2.72825003;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect4767-7-0"
width="23.046698"
height="2.0186887"
x="39.80563"
y="274.1886" />
<path
style="display:inline;opacity:1;fill:#f1cd6b;fill-opacity:1;stroke:#000000;stroke-width:1.21502817;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 37.113903,280.91958 a 11.943909,11.943909 0 0 0 -11.943581,11.94359 h 23.88762 A 11.943909,11.943909 0 0 0 37.113903,280.91958 Z"
id="path4732"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
style="display:inline;opacity:1;fill:#f1cd6b;fill-opacity:1;stroke:#000000;stroke-width:1.21502817;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 62.767977,281.00369 A 11.943909,11.943909 0 0 0 50.824392,292.94728 H 74.712015 A 11.943909,11.943909 0 0 0 62.767977,281.00369 Z"
id="path4732-2" />
<path
style="fill:#dd9829;fill-opacity:1;stroke:#000000;stroke-width:1.21502817;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 45.440938,246.0794 c 0,0 2.139571,9.72892 5.947619,9.7541 3.839356,0.0254 6.126056,-9.7541 6.126056,-9.7541 l -6.140761,-8.69398 z"
id="path4721"
inkscape:connector-curvature="0"
sodipodi:nodetypes="caccc" />
<path
style="opacity:1;fill:#fedf89;fill-opacity:1;stroke:#000000;stroke-width:1.95828712;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 34.506691,216.30236 a 18.692076,18.692076 0 0 0 -18.692058,18.69205 18.692076,18.692076 0 0 0 18.692058,18.69206 18.692076,18.692076 0 0 0 16.928008,-10.81661 18.692078,18.692078 0 0 0 16.968945,10.90079 18.692078,18.692078 0 0 0 18.69206,-18.69206 18.692078,18.692078 0 0 0 -18.69206,-18.69252 18.692078,18.692078 0 0 0 -16.929847,10.81983 18.692076,18.692076 0 0 0 -16.967106,-10.90354 z"
id="path4607"
inkscape:connector-curvature="0" />
<path
style="opacity:1;fill:#fffefc;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 64.955116,221.31207 a 13.794374,13.429993 0 0 0 -13.301924,9.88009 13.794374,13.429993 0 0 0 -13.277543,-9.79636 13.794374,13.429993 0 0 0 -13.794111,13.43025 13.794374,13.429993 0 0 0 13.794111,13.4298 13.794374,13.429993 0 0 0 13.301003,-9.92837 13.794374,13.429993 0 0 0 13.278464,9.8442 13.794374,13.429993 0 0 0 13.794572,-13.4298 13.794374,13.429993 0 0 0 -13.794572,-13.42981 z"
id="path4609"
inkscape:connector-curvature="0" />
<ellipse
style="opacity:1;fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path4611"
cx="40.394402"
cy="235.28864"
rx="8.9999876"
ry="8.8738194" />
<circle
style="opacity:1;fill:#fffefc;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path4609-3"
cy="230.89021"
cx="35.68087"
r="5.6899729" />
<ellipse
style="opacity:1;fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path4611-9"
cx="-63.020538"
cy="235.37274"
rx="8.9999876"
ry="8.8738194"
transform="scale(-1,1)" />
<circle
style="opacity:1;fill:#fffefc;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path4609-3-1"
cx="-67.734055"
cy="230.97433"
transform="scale(-1,1)"
r="5.0387244" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@ -1,48 +0,0 @@
package owl_test
import (
"h4kor/owl-blogs"
"h4kor/owl-blogs/test/assertions"
"net/http"
"testing"
)
func TestGetRedirctUrisLink(t *testing.T) {
html := []byte("<link rel=\"redirect_uri\" href=\"http://example.com/redirect\" />")
parser := &owl.OwlHtmlParser{}
uris, err := parser.GetRedirctUris(constructResponse(html))
assertions.AssertNoError(t, err, "Unable to parse feed")
assertions.AssertArrayContains(t, uris, "http://example.com/redirect")
}
func TestGetRedirctUrisLinkMultiple(t *testing.T) {
html := []byte(`
<link rel="redirect_uri" href="http://example.com/redirect1" />
<link rel="redirect_uri" href="http://example.com/redirect2" />
<link rel="redirect_uri" href="http://example.com/redirect3" />
<link rel="foo" href="http://example.com/redirect4" />
<link href="http://example.com/redirect5" />
`)
parser := &owl.OwlHtmlParser{}
uris, err := parser.GetRedirctUris(constructResponse(html))
assertions.AssertNoError(t, err, "Unable to parse feed")
assertions.AssertArrayContains(t, uris, "http://example.com/redirect1")
assertions.AssertArrayContains(t, uris, "http://example.com/redirect2")
assertions.AssertArrayContains(t, uris, "http://example.com/redirect3")
assertions.AssertLen(t, uris, 3)
}
func TestGetRedirectUrisLinkHeader(t *testing.T) {
html := []byte("")
parser := &owl.OwlHtmlParser{}
resp := constructResponse(html)
resp.Header = http.Header{"Link": []string{"<http://example.com/redirect>; rel=\"redirect_uri\""}}
uris, err := parser.GetRedirctUris(resp)
assertions.AssertNoError(t, err, "Unable to parse feed")
assertions.AssertArrayContains(t, uris, "http://example.com/redirect")
}

View File

@ -1,38 +0,0 @@
package main
import (
"h4kor/owl-blogs"
"github.com/spf13/cobra"
)
var domain string
var singleUser string
var unsafe bool
func init() {
rootCmd.AddCommand(initCmd)
initCmd.PersistentFlags().StringVar(&domain, "domain", "http://localhost:8080", "Domain to use")
initCmd.PersistentFlags().StringVar(&singleUser, "single-user", "", "Use single user mode with given username")
initCmd.PersistentFlags().BoolVar(&unsafe, "unsafe", false, "Allow raw html")
}
var initCmd = &cobra.Command{
Use: "init",
Short: "Creates a new repository",
Long: `Creates a new repository`,
Run: func(cmd *cobra.Command, args []string) {
_, err := owl.CreateRepository(repoPath, owl.RepoConfig{
Domain: domain,
SingleUser: singleUser,
AllowRawHtml: unsafe,
})
if err != nil {
println("Error creating repository: ", err.Error())
} else {
println("Repository created: ", repoPath)
}
},
}

View File

@ -1,32 +0,0 @@
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var repoPath string
var rootCmd = &cobra.Command{
Use: "owl",
Short: "Owl Blogs is a not so static blog generator",
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func init() {
rootCmd.PersistentFlags().StringVar(&repoPath, "repo", ".", "Path to the repository to use.")
rootCmd.PersistentFlags().StringVar(&user, "user", "", "Username. Required for some commands.")
}
func main() {
Execute()
}

View File

@ -1,51 +0,0 @@
package main
import (
"h4kor/owl-blogs"
"github.com/spf13/cobra"
)
var postTitle string
func init() {
rootCmd.AddCommand(newPostCmd)
newPostCmd.PersistentFlags().StringVar(&postTitle, "title", "", "Post title")
}
var newPostCmd = &cobra.Command{
Use: "new-post",
Short: "Creates a new post",
Long: `Creates a new post`,
Run: func(cmd *cobra.Command, args []string) {
if user == "" {
println("Username is required")
return
}
if postTitle == "" {
println("Post title is required")
return
}
repo, err := owl.OpenRepository(repoPath)
if err != nil {
println("Error opening repository: ", err.Error())
return
}
user, err := repo.GetUser(user)
if err != nil {
println("Error getting user: ", err.Error())
return
}
post, err := user.CreateNewPost(owl.PostMeta{Type: "article", Title: postTitle, Draft: true}, "")
if err != nil {
println("Error creating post: ", err.Error())
} else {
println("Post created: ", postTitle)
println("Edit: ", post.ContentFile())
}
},
}

View File

@ -1,38 +0,0 @@
package main
import (
"h4kor/owl-blogs"
"github.com/spf13/cobra"
)
var user string
func init() {
rootCmd.AddCommand(newUserCmd)
}
var newUserCmd = &cobra.Command{
Use: "new-user",
Short: "Creates a new user",
Long: `Creates a new user`,
Run: func(cmd *cobra.Command, args []string) {
if user == "" {
println("Username is required")
return
}
repo, err := owl.OpenRepository(repoPath)
if err != nil {
println("Error opening repository: ", err.Error())
return
}
_, err = repo.CreateUser(user)
if err != nil {
println("Error creating user: ", err.Error())
} else {
println("User created: ", user)
}
},
}

View File

@ -1,44 +0,0 @@
package main
import (
"fmt"
"h4kor/owl-blogs"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(resetPasswordCmd)
}
var resetPasswordCmd = &cobra.Command{
Use: "reset-password",
Short: "Reset the password for a user",
Long: `Reset the password for a user`,
Run: func(cmd *cobra.Command, args []string) {
if user == "" {
println("Username is required")
return
}
repo, err := owl.OpenRepository(repoPath)
if err != nil {
println("Error opening repository: ", err.Error())
return
}
user, err := repo.GetUser(user)
if err != nil {
println("Error getting user: ", err.Error())
return
}
// generate a random password and print it
password := owl.GenerateRandomString(16)
user.ResetPassword(password)
fmt.Println("User: ", user.Name())
fmt.Println("New Password: ", password)
},
}

View File

@ -1,24 +0,0 @@
package main
import (
web "h4kor/owl-blogs/cmd/owl/web"
"github.com/spf13/cobra"
)
var port int
func init() {
rootCmd.AddCommand(webCmd)
webCmd.PersistentFlags().IntVar(&port, "port", 8080, "Port to use")
}
var webCmd = &cobra.Command{
Use: "web",
Short: "Start the web server",
Long: `Start the web server`,
Run: func(cmd *cobra.Command, args []string) {
web.StartServer(repoPath, port)
},
}

View File

@ -1,191 +0,0 @@
package web_test
import (
"h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/test/assertions"
"net/http"
"net/http/httptest"
"os"
"testing"
)
func TestRedirectOnAliases(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "")
content := "---\n"
content += "title: Test\n"
content += "aliases: \n"
content += " - /foo/bar\n"
content += " - /foo/baz\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
// Create Request and Response
req, err := http.NewRequest("GET", "/foo/bar", nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusMovedPermanently)
// Check that Location header is set correctly
assertions.AssertEqual(t, rr.Header().Get("Location"), post.UrlPath())
}
func TestNoRedirectOnNonExistingAliases(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "")
content := "---\n"
content += "title: Test\n"
content += "aliases: \n"
content += " - /foo/bar\n"
content += " - /foo/baz\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
// Create Request and Response
req, err := http.NewRequest("GET", "/foo/bar2", nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusNotFound)
}
func TestNoRedirectIfValidPostUrl(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "")
post2, _ := user.CreateNewPost(owl.PostMeta{Title: "post-2"}, "")
content := "---\n"
content += "title: Test\n"
content += "aliases: \n"
content += " - " + post2.UrlPath() + "\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
// Create Request and Response
req, err := http.NewRequest("GET", post2.UrlPath(), nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
}
func TestRedirectIfInvalidPostUrl(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "")
content := "---\n"
content += "title: Test\n"
content += "aliases: \n"
content += " - " + user.UrlPath() + "posts/not-a-real-post/" + "\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
// Create Request and Response
req, err := http.NewRequest("GET", user.UrlPath()+"posts/not-a-real-post/", nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusMovedPermanently)
}
func TestRedirectIfInvalidUserUrl(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "")
content := "---\n"
content += "title: Test\n"
content += "aliases: \n"
content += " - /user/not-real/ \n"
content += "---\n"
content += "This is a test"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
// Create Request and Response
req, err := http.NewRequest("GET", "/user/not-real/", nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusMovedPermanently)
}
func TestRedirectIfInvalidMediaUrl(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "")
content := "---\n"
content += "title: Test\n"
content += "aliases: \n"
content += " - " + post.UrlMediaPath("not-real") + "\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
// Create Request and Response
req, err := http.NewRequest("GET", post.UrlMediaPath("not-real"), nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusMovedPermanently)
}
func TestDeepAliasInSingleUserMode(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{SingleUser: "test-1"})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "")
content := "---\n"
content += "title: Create tileable textures with GIMP\n"
content += "author: h4kor\n"
content += "type: post\n"
content += "date: Tue, 13 Sep 2016 16:19:09 +0000\n"
content += "aliases:\n"
content += " - /2016/09/13/create-tileable-textures-with-gimp/\n"
content += "categories:\n"
content += " - GameDev\n"
content += "tags:\n"
content += " - gamedev\n"
content += " - textures\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
// Create Request and Response
req, err := http.NewRequest("GET", "/2016/09/13/create-tileable-textures-with-gimp/", nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusMovedPermanently)
}

View File

@ -1,396 +0,0 @@
package web
import (
"encoding/json"
"fmt"
"h4kor/owl-blogs"
"net/http"
"net/url"
"strings"
"github.com/julienschmidt/httprouter"
)
type IndieauthMetaDataResponse struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
ScopesSupported []string `json:"scopes_supported"`
ResponseTypesSupported []string `json:"response_types_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
}
type MeProfileResponse struct {
Name string `json:"name"`
Url string `json:"url"`
Photo string `json:"photo"`
}
type MeResponse struct {
Me string `json:"me"`
Profile MeProfileResponse `json:"profile"`
}
type AccessTokenResponse struct {
Me string `json:"me"`
TokenType string `json:"token_type"`
AccessToken string `json:"access_token"`
Scope string `json:"scope"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
}
func jsonResponse(w http.ResponseWriter, response interface{}) {
jsonData, err := json.Marshal(response)
if err != nil {
println("Error marshalling json: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal server error"))
}
w.Header().Add("Content-Type", "application/json")
w.Write(jsonData)
}
func userAuthMetadataHandler(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
}
w.WriteHeader(http.StatusOK)
jsonResponse(w, IndieauthMetaDataResponse{
Issuer: user.FullUrl(),
AuthorizationEndpoint: user.AuthUrl(),
TokenEndpoint: user.TokenUrl(),
CodeChallengeMethodsSupported: []string{"S256", "plain"},
ScopesSupported: []string{"profile"},
ResponseTypesSupported: []string{"code"},
GrantTypesSupported: []string{"authorization_code"},
})
}
}
func userAuthHandler(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
}
// get me, cleint_id, redirect_uri, state and response_type from query
me := r.URL.Query().Get("me")
clientId := r.URL.Query().Get("client_id")
redirectUri := r.URL.Query().Get("redirect_uri")
state := r.URL.Query().Get("state")
responseType := r.URL.Query().Get("response_type")
codeChallenge := r.URL.Query().Get("code_challenge")
codeChallengeMethod := r.URL.Query().Get("code_challenge_method")
scope := r.URL.Query().Get("scope")
// check if request is valid
missing_params := []string{}
if clientId == "" {
missing_params = append(missing_params, "client_id")
}
if redirectUri == "" {
missing_params = append(missing_params, "redirect_uri")
}
if responseType == "" {
missing_params = append(missing_params, "response_type")
}
if len(missing_params) > 0 {
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Missing parameters",
Message: "Missing parameters: " + strings.Join(missing_params, ", "),
})
w.Write([]byte(html))
return
}
if responseType == "id" {
responseType = "code"
}
if responseType != "code" {
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Invalid response_type",
Message: "Must be 'code' ('id' converted to 'code' for legacy support).",
})
w.Write([]byte(html))
return
}
if codeChallengeMethod != "" && (codeChallengeMethod != "S256" && codeChallengeMethod != "plain") {
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Invalid code_challenge_method",
Message: "Must be 'S256' or 'plain'.",
})
w.Write([]byte(html))
return
}
client_id_url, err := url.Parse(clientId)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Invalid client_id",
Message: "Invalid client_id: " + clientId,
})
w.Write([]byte(html))
return
}
redirect_uri_url, err := url.Parse(redirectUri)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Invalid redirect_uri",
Message: "Invalid redirect_uri: " + redirectUri,
})
w.Write([]byte(html))
return
}
if client_id_url.Host != redirect_uri_url.Host || client_id_url.Scheme != redirect_uri_url.Scheme {
// check if redirect_uri is registered
resp, _ := repo.HttpClient.Get(clientId)
registered_redirects, _ := repo.Parser.GetRedirctUris(resp)
is_registered := false
for _, registered_redirect := range registered_redirects {
if registered_redirect == redirectUri {
// redirect_uri is registered
is_registered = true
break
}
}
if !is_registered {
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Invalid redirect_uri",
Message: redirectUri + " is not registered for " + clientId,
})
w.Write([]byte(html))
return
}
}
// Double Submit Cookie Pattern
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie
csrfToken := owl.GenerateRandomString(32)
cookie := http.Cookie{
Name: "csrf_token",
Value: csrfToken,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}
http.SetCookie(w, &cookie)
reqData := owl.AuthRequestData{
Me: me,
ClientId: clientId,
RedirectUri: redirectUri,
State: state,
Scope: scope,
ResponseType: responseType,
CodeChallenge: codeChallenge,
CodeChallengeMethod: codeChallengeMethod,
User: user,
CsrfToken: csrfToken,
}
html, err := owl.RenderUserAuthPage(reqData)
if err != nil {
println("Error rendering auth page: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal Server Error",
Message: "Internal Server Error",
})
w.Write([]byte(html))
return
}
w.Write([]byte(html))
}
}
func verifyAuthCodeRequest(user owl.User, w http.ResponseWriter, r *http.Request) (bool, owl.AuthCode) {
// get form data from post request
err := r.ParseForm()
if err != nil {
println("Error parsing form: ", err.Error())
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Error parsing form"))
return false, owl.AuthCode{}
}
code := r.Form.Get("code")
client_id := r.Form.Get("client_id")
redirect_uri := r.Form.Get("redirect_uri")
code_verifier := r.Form.Get("code_verifier")
// check if request is valid
valid, authCode := user.VerifyAuthCode(code, client_id, redirect_uri, code_verifier)
if !valid {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Invalid code"))
}
return valid, authCode
}
func userAuthProfileHandler(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
}
valid, _ := verifyAuthCodeRequest(user, w, r)
if valid {
w.WriteHeader(http.StatusOK)
jsonResponse(w, MeResponse{
Me: user.FullUrl(),
Profile: MeProfileResponse{
Name: user.Name(),
Url: user.FullUrl(),
Photo: user.AvatarUrl(),
},
})
return
}
}
}
func userAuthTokenHandler(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
}
valid, authCode := verifyAuthCodeRequest(user, w, r)
if valid {
if authCode.Scope == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Empty scope, no token issued"))
return
}
accessToken, duration, err := user.GenerateAccessToken(authCode)
if err != nil {
println("Error generating access token: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal server error"))
return
}
jsonResponse(w, AccessTokenResponse{
Me: user.FullUrl(),
TokenType: "Bearer",
AccessToken: accessToken,
Scope: authCode.Scope,
ExpiresIn: duration,
})
return
}
}
}
func userAuthVerifyHandler(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
}
// get form data from post request
err = r.ParseForm()
if err != nil {
println("Error parsing form: ", err.Error())
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Error parsing form",
Message: "Error parsing form",
})
w.Write([]byte(html))
return
}
password := r.FormValue("password")
client_id := r.FormValue("client_id")
redirect_uri := r.FormValue("redirect_uri")
response_type := r.FormValue("response_type")
state := r.FormValue("state")
code_challenge := r.FormValue("code_challenge")
code_challenge_method := r.FormValue("code_challenge_method")
scope := r.FormValue("scope")
// CSRF check
formCsrfToken := r.FormValue("csrf_token")
cookieCsrfToken, err := r.Cookie("csrf_token")
if err != nil {
println("Error getting csrf token from cookie: ", err.Error())
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "CSRF Error",
Message: "Error getting csrf token from cookie",
})
w.Write([]byte(html))
return
}
if formCsrfToken != cookieCsrfToken.Value {
println("Invalid csrf token")
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "CSRF Error",
Message: "Invalid csrf token",
})
w.Write([]byte(html))
return
}
password_valid := user.VerifyPassword(password)
if !password_valid {
redirect := fmt.Sprintf(
"%s?error=invalid_password&client_id=%s&redirect_uri=%s&response_type=%s&state=%s",
user.AuthUrl(), client_id, redirect_uri, response_type, state,
)
if code_challenge != "" {
redirect += fmt.Sprintf("&code_challenge=%s&code_challenge_method=%s", code_challenge, code_challenge_method)
}
http.Redirect(w, r,
redirect,
http.StatusFound,
)
return
} else {
// password is valid, generate code
code, err := user.GenerateAuthCode(
client_id, redirect_uri, code_challenge, code_challenge_method, scope)
if err != nil {
println("Error generating code: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal Server Error",
Message: "Error generating code",
})
w.Write([]byte(html))
return
}
http.Redirect(w, r,
fmt.Sprintf(
"%s?code=%s&state=%s&iss=%s",
redirect_uri, code, state,
user.FullUrl(),
),
http.StatusFound,
)
return
}
}
}

View File

@ -1,428 +0,0 @@
package web_test
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/test/assertions"
"h4kor/owl-blogs/test/mocks"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
)
func TestAuthPostWrongPassword(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("password", "wrongpassword")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("response_type", "code")
form.Add("state", "test_state")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("POST", user.AuthUrl()+"verify/", 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.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusFound)
assertions.AssertContains(t, rr.Header().Get("Location"), "error=invalid_password")
}
func TestAuthPostCorrectPassword(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("password", "testpassword")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("response_type", "code")
form.Add("state", "test_state")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("POST", user.AuthUrl()+"verify/", 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.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusFound)
assertions.AssertContains(t, rr.Header().Get("Location"), "code=")
assertions.AssertContains(t, rr.Header().Get("Location"), "state=test_state")
assertions.AssertContains(t, rr.Header().Get("Location"), "iss="+user.FullUrl())
assertions.AssertContains(t, rr.Header().Get("Location"), "http://example.com/response")
}
func TestAuthPostWithIncorrectCode(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
user.GenerateAuthCode("http://example.com", "http://example.com/response", "", "", "profile")
// Create Request and Response
form := url.Values{}
form.Add("code", "wrongcode")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
req, err := http.NewRequest("POST", user.AuthUrl(), 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)
}
func TestAuthPostWithCorrectCode(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", "", "", "profile")
// Create Request and Response
form := url.Values{}
form.Add("code", code)
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
req, err := http.NewRequest("POST", user.AuthUrl(), 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("Accept", "application/json")
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// parse response as json
type responseType struct {
Me string `json:"me"`
}
var response responseType
json.Unmarshal(rr.Body.Bytes(), &response)
assertions.AssertEqual(t, response.Me, user.FullUrl())
}
func TestAuthPostWithCorrectCodeAndPKCE(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
// Create Request and Response
code_verifier := "test_code_verifier"
// create code challenge
h := sha256.New()
h.Write([]byte(code_verifier))
code_challenge := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", code_challenge, "S256", "profile")
form := url.Values{}
form.Add("code", code)
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
form.Add("code_verifier", code_verifier)
req, err := http.NewRequest("POST", user.AuthUrl(), 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("Accept", "application/json")
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// parse response as json
type responseType struct {
Me string `json:"me"`
}
var response responseType
json.Unmarshal(rr.Body.Bytes(), &response)
assertions.AssertEqual(t, response.Me, user.FullUrl())
}
func TestAuthPostWithCorrectCodeAndWrongPKCE(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
// Create Request and Response
code_verifier := "test_code_verifier"
// create code challenge
h := sha256.New()
h.Write([]byte(code_verifier + "wrong"))
code_challenge := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", code_challenge, "S256", "profile")
form := url.Values{}
form.Add("code", code)
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
form.Add("code_verifier", code_verifier)
req, err := http.NewRequest("POST", user.AuthUrl(), 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("Accept", "application/json")
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusUnauthorized)
}
func TestAuthPostWithCorrectCodePKCEPlain(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
// Create Request and Response
code_verifier := "test_code_verifier"
code_challenge := code_verifier
code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", code_challenge, "plain", "profile")
form := url.Values{}
form.Add("code", code)
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
form.Add("code_verifier", code_verifier)
req, err := http.NewRequest("POST", user.AuthUrl(), 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("Accept", "application/json")
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
}
func TestAuthPostWithCorrectCodePKCEPlainWrong(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
// Create Request and Response
code_verifier := "test_code_verifier"
code_challenge := code_verifier + "wrong"
code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", code_challenge, "plain", "profile")
form := url.Values{}
form.Add("code", code)
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
form.Add("code_verifier", code_verifier)
req, err := http.NewRequest("POST", user.AuthUrl(), 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("Accept", "application/json")
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusUnauthorized)
}
func TestAuthRedirectUriNotSet(t *testing.T) {
repo, user := getSingleUserTestRepo()
repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &mocks.MockParseLinksHtmlParser{
Links: []string{"http://example2.com/response"},
}
user.ResetPassword("testpassword")
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("password", "wrongpassword")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example2.com/response_not_set")
form.Add("response_type", "code")
form.Add("state", "test_state")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("GET", user.AuthUrl()+"?"+form.Encode(), nil)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
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 TestAuthRedirectUriSet(t *testing.T) {
repo, user := getSingleUserTestRepo()
repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &mocks.MockParseLinksHtmlParser{
Links: []string{"http://example.com/response"},
}
user.ResetPassword("testpassword")
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("password", "wrongpassword")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("response_type", "code")
form.Add("state", "test_state")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("GET", user.AuthUrl()+"?"+form.Encode(), nil)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
}
func TestAuthRedirectUriSameHost(t *testing.T) {
repo, user := getSingleUserTestRepo()
repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &mocks.MockParseLinksHtmlParser{
Links: []string{},
}
user.ResetPassword("testpassword")
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("password", "wrongpassword")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("response_type", "code")
form.Add("state", "test_state")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("GET", user.AuthUrl()+"?"+form.Encode(), nil)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
}
func TestAccessTokenCorrectPassword(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", "", "", "profile create")
// Create Request and Response
form := url.Values{}
form.Add("code", code)
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
req, err := http.NewRequest("POST", user.AuthUrl()+"token/", 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.StatusOK)
// parse response as json
type responseType struct {
Me string `json:"me"`
TokenType string `json:"token_type"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
}
var response responseType
json.Unmarshal(rr.Body.Bytes(), &response)
assertions.AssertEqual(t, response.Me, user.FullUrl())
assertions.AssertEqual(t, response.TokenType, "Bearer")
assertions.AssertEqual(t, response.Scope, "profile create")
assertions.Assert(t, response.ExpiresIn > 0, "ExpiresIn should be greater than 0")
assertions.Assert(t, len(response.AccessToken) > 0, "AccessToken should be greater than 0")
}
func TestAccessTokenWithIncorrectCode(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
user.GenerateAuthCode("http://example.com", "http://example.com/response", "", "", "profile")
// Create Request and Response
form := url.Values{}
form.Add("code", "wrongcode")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
req, err := http.NewRequest("POST", user.AuthUrl()+"token/", 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)
}
func TestIndieauthMetadata(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
req, _ := http.NewRequest("GET", user.IndieauthMetadataUrl(), nil)
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// parse response as json
type responseType struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
ScopesSupported []string `json:"scopes_supported"`
ResponseTypesSupported []string `json:"response_types_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
}
var response responseType
json.Unmarshal(rr.Body.Bytes(), &response)
assertions.AssertEqual(t, response.Issuer, user.FullUrl())
assertions.AssertEqual(t, response.AuthorizationEndpoint, user.AuthUrl())
assertions.AssertEqual(t, response.TokenEndpoint, user.TokenUrl())
}

View File

@ -1,364 +0,0 @@
package web
import (
"fmt"
"h4kor/owl-blogs"
"io"
"mime/multipart"
"net/http"
"os"
"path"
"strings"
"sync"
"time"
"github.com/julienschmidt/httprouter"
)
func isUserLoggedIn(user *owl.User, r *http.Request) bool {
sessionCookie, err := r.Cookie("session")
if err != nil {
return false
}
return user.ValidateSession(sessionCookie.Value)
}
func setCSRFCookie(w http.ResponseWriter) string {
csrfToken := owl.GenerateRandomString(32)
cookie := http.Cookie{
Name: "csrf_token",
Value: csrfToken,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}
http.SetCookie(w, &cookie)
return csrfToken
}
func checkCSRF(r *http.Request) bool {
// CSRF check
formCsrfToken := r.FormValue("csrf_token")
cookieCsrfToken, err := r.Cookie("csrf_token")
if err != nil {
println("Error getting csrf token from cookie: ", err.Error())
return false
}
if formCsrfToken != cookieCsrfToken.Value {
println("Invalid csrf token")
return false
}
return true
}
func userLoginGetHandler(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
}
if isUserLoggedIn(&user, r) {
http.Redirect(w, r, user.EditorUrl(), http.StatusFound)
return
}
csrfToken := setCSRFCookie(w)
// get error from query
error_type := r.URL.Query().Get("error")
html, err := owl.RenderLoginPage(user, error_type, csrfToken)
if err != nil {
println("Error rendering login page: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
w.Write([]byte(html))
}
}
func userLoginPostHandler(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
}
err = r.ParseForm()
if err != nil {
println("Error parsing form: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
// CSRF check
if !checkCSRF(r) {
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "CSRF Error",
Message: "Invalid csrf token",
})
w.Write([]byte(html))
return
}
password := r.Form.Get("password")
if password == "" || !user.VerifyPassword(password) {
http.Redirect(w, r, user.EditorLoginUrl()+"?error=wrong_password", http.StatusFound)
return
}
// set session cookie
cookie := http.Cookie{
Name: "session",
Value: user.CreateNewSession(),
Path: "/",
Expires: time.Now().Add(30 * 24 * time.Hour),
HttpOnly: true,
}
http.SetCookie(w, &cookie)
http.Redirect(w, r, user.EditorUrl(), http.StatusFound)
}
}
func userEditorGetHandler(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
}
if !isUserLoggedIn(&user, r) {
http.Redirect(w, r, user.EditorLoginUrl(), http.StatusFound)
return
}
csrfToken := setCSRFCookie(w)
html, err := owl.RenderEditorPage(user, csrfToken)
if err != nil {
println("Error rendering editor page: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
w.Write([]byte(html))
}
}
func userEditorPostHandler(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
}
if !isUserLoggedIn(&user, r) {
http.Redirect(w, r, user.EditorLoginUrl(), http.StatusFound)
return
}
if strings.Split(r.Header.Get("Content-Type"), ";")[0] == "multipart/form-data" {
err = r.ParseMultipartForm(32 << 20)
} else {
err = r.ParseForm()
}
if err != nil {
println("Error parsing form: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
// CSRF check
if !checkCSRF(r) {
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "CSRF Error",
Message: "Invalid csrf token",
})
w.Write([]byte(html))
return
}
// get form values
post_type := r.Form.Get("type")
title := r.Form.Get("title")
description := r.Form.Get("description")
content := strings.ReplaceAll(r.Form.Get("content"), "\r", "")
draft := r.Form.Get("draft")
// recipe values
recipe_yield := r.Form.Get("yield")
recipe_ingredients := strings.ReplaceAll(r.Form.Get("ingredients"), "\r", "")
recipe_duration := r.Form.Get("duration")
// conditional values
reply_url := r.Form.Get("reply_url")
bookmark_url := r.Form.Get("bookmark_url")
// photo values
var photo_file multipart.File
var photo_header *multipart.FileHeader
if strings.Split(r.Header.Get("Content-Type"), ";")[0] == "multipart/form-data" {
photo_file, photo_header, err = r.FormFile("photo")
if err != nil && err != http.ErrMissingFile {
println("Error getting photo file: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
}
// validate form values
if post_type == "" {
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Missing post type",
Message: "Post type is required",
})
w.Write([]byte(html))
return
}
if (post_type == "article" || post_type == "page" || post_type == "recipe") && title == "" {
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Missing Title",
Message: "Articles and Pages must have a title",
})
w.Write([]byte(html))
return
}
if post_type == "reply" && reply_url == "" {
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Missing URL",
Message: "You must provide a URL to reply to",
})
w.Write([]byte(html))
return
}
if post_type == "bookmark" && bookmark_url == "" {
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Missing URL",
Message: "You must provide a URL to bookmark",
})
w.Write([]byte(html))
return
}
if post_type == "photo" && photo_file == nil {
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Missing Photo",
Message: "You must provide a photo to upload",
})
w.Write([]byte(html))
return
}
// TODO: scrape reply_url for title and description
// TODO: scrape bookmark_url for title and description
// create post
meta := owl.PostMeta{
Type: post_type,
Title: title,
Description: description,
Draft: draft == "on",
Date: time.Now(),
Reply: owl.ReplyData{
Url: reply_url,
},
Bookmark: owl.BookmarkData{
Url: bookmark_url,
},
Recipe: owl.RecipeData{
Yield: recipe_yield,
Ingredients: strings.Split(recipe_ingredients, "\n"),
Duration: recipe_duration,
},
}
if photo_file != nil {
meta.PhotoPath = photo_header.Filename
}
post, err := user.CreateNewPost(meta, content)
// save photo
if photo_file != nil {
println("Saving photo: ", photo_header.Filename)
photo_path := path.Join(post.MediaDir(), photo_header.Filename)
media_file, err := os.Create(photo_path)
if err != nil {
println("Error creating photo file: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
defer media_file.Close()
io.Copy(media_file, photo_file)
}
if err != nil {
println("Error creating post: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
// redirect to post
if !post.Meta().Draft {
// scan for webmentions
post.ScanForLinks()
webmentions := post.OutgoingWebmentions()
println("Found ", len(webmentions), " links")
wg := sync.WaitGroup{}
wg.Add(len(webmentions))
for _, mention := range post.OutgoingWebmentions() {
go func(mention owl.WebmentionOut) {
fmt.Printf("Sending webmention to %s", mention.Target)
defer wg.Done()
post.SendWebmention(mention)
}(mention)
}
wg.Wait()
http.Redirect(w, r, post.FullUrl(), http.StatusFound)
} else {
http.Redirect(w, r, user.EditorUrl(), http.StatusFound)
}
}
}

View File

@ -1,346 +0,0 @@
package web_test
import (
"bytes"
"h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/test/assertions"
"h4kor/owl-blogs/test/mocks"
"io"
"io/ioutil"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"path"
"strconv"
"strings"
"testing"
)
type CountMockHttpClient struct {
InvokedGet int
InvokedPost int
InvokedPostForm int
}
func (c *CountMockHttpClient) Get(url string) (resp *http.Response, err error) {
c.InvokedGet++
return &http.Response{}, nil
}
func (c *CountMockHttpClient) Post(url, contentType string, body io.Reader) (resp *http.Response, err error) {
c.InvokedPost++
return &http.Response{}, nil
}
func (c *CountMockHttpClient) PostForm(url string, data url.Values) (resp *http.Response, err error) {
c.InvokedPostForm++
return &http.Response{}, nil
}
func TestLoginWrongPassword(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("password", "wrongpassword")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("POST", user.EditorLoginUrl(), 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.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
// check redirect to login page
assertions.AssertEqual(t, rr.Header().Get("Location"), user.EditorLoginUrl()+"?error=wrong_password")
}
func TestLoginCorrectPassword(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("password", "testpassword")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("POST", user.EditorLoginUrl(), 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.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
// check redirect to login page
assertions.AssertStatus(t, rr, http.StatusFound)
assertions.AssertEqual(t, rr.Header().Get("Location"), user.EditorUrl())
}
func TestEditorWithoutSession(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
user.CreateNewSession()
req, err := http.NewRequest("GET", user.EditorUrl(), nil)
// req.AddCookie(&http.Cookie{Name: "session", Value: sessionId})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusFound)
assertions.AssertEqual(t, rr.Header().Get("Location"), user.EditorLoginUrl())
}
func TestEditorWithSession(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
sessionId := user.CreateNewSession()
req, err := http.NewRequest("GET", user.EditorUrl(), nil)
req.AddCookie(&http.Cookie{Name: "session", Value: sessionId})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
}
func TestEditorPostWithoutSession(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
user.CreateNewSession()
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("type", "article")
form.Add("title", "testtitle")
form.Add("content", "testcontent")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("POST", user.EditorUrl(), 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.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusFound)
assertions.AssertEqual(t, rr.Header().Get("Location"), user.EditorLoginUrl())
}
func TestEditorPostWithSession(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
sessionId := user.CreateNewSession()
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("type", "article")
form.Add("title", "testtitle")
form.Add("content", "testcontent")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("POST", user.EditorUrl(), 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.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
req.AddCookie(&http.Cookie{Name: "session", Value: sessionId})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
posts, _ := user.AllPosts()
assertions.AssertEqual(t, len(posts), 1)
post := posts[0]
assertions.AssertStatus(t, rr, http.StatusFound)
assertions.AssertEqual(t, rr.Header().Get("Location"), post.FullUrl())
}
func TestEditorPostWithSessionNote(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
sessionId := user.CreateNewSession()
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("type", "note")
form.Add("content", "testcontent")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("POST", user.EditorUrl(), 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.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
req.AddCookie(&http.Cookie{Name: "session", Value: sessionId})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
posts, _ := user.AllPosts()
assertions.AssertEqual(t, len(posts), 1)
post := posts[0]
assertions.AssertStatus(t, rr, http.StatusFound)
assertions.AssertEqual(t, rr.Header().Get("Location"), post.FullUrl())
}
func TestEditorSendsWebmentions(t *testing.T) {
repo, user := getSingleUserTestRepo()
repo.HttpClient = &CountMockHttpClient{}
repo.Parser = &mocks.MockHtmlParser{}
user.ResetPassword("testpassword")
mentioned_post, _ := user.CreateNewPost(owl.PostMeta{Title: "test"}, "")
sessionId := user.CreateNewSession()
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("type", "note")
form.Add("content", "[test]("+mentioned_post.FullUrl()+")")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("POST", user.EditorUrl(), 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.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
req.AddCookie(&http.Cookie{Name: "session", Value: sessionId})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
posts, _ := user.AllPosts()
assertions.AssertEqual(t, len(posts), 2)
post := posts[0]
assertions.AssertLen(t, post.OutgoingWebmentions(), 1)
assertions.AssertStatus(t, rr, http.StatusFound)
assertions.AssertEqual(t, repo.HttpClient.(*CountMockHttpClient).InvokedPostForm, 1)
}
func TestEditorPostWithSessionRecipe(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
sessionId := user.CreateNewSession()
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("type", "recipe")
form.Add("title", "testtitle")
form.Add("yield", "2")
form.Add("duration", "1 hour")
form.Add("ingredients", "water\nwheat")
form.Add("content", "testcontent")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("POST", user.EditorUrl(), 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.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
req.AddCookie(&http.Cookie{Name: "session", Value: sessionId})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
posts, _ := user.AllPosts()
assertions.AssertEqual(t, len(posts), 1)
post := posts[0]
assertions.AssertLen(t, post.Meta().Recipe.Ingredients, 2)
assertions.AssertStatus(t, rr, http.StatusFound)
assertions.AssertEqual(t, rr.Header().Get("Location"), post.FullUrl())
}
func TestEditorPostWithSessionPhoto(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
sessionId := user.CreateNewSession()
csrfToken := "test_csrf_token"
// read photo from file
photo_data, err := ioutil.ReadFile("../../../fixtures/image.png")
assertions.AssertNoError(t, err, "Error reading photo")
// Create Request and Response
bodyBuf := &bytes.Buffer{}
bodyWriter := multipart.NewWriter(bodyBuf)
// write photo
fileWriter, err := bodyWriter.CreateFormFile("photo", "../../../fixtures/image.png")
assertions.AssertNoError(t, err, "Error creating form file")
_, err = fileWriter.Write(photo_data)
assertions.AssertNoError(t, err, "Error writing photo")
// write other fields
bodyWriter.WriteField("type", "photo")
bodyWriter.WriteField("title", "testtitle")
bodyWriter.WriteField("content", "testcontent")
bodyWriter.WriteField("csrf_token", csrfToken)
// close body writer
err = bodyWriter.Close()
assertions.AssertNoError(t, err, "Error closing body writer")
req, err := http.NewRequest("POST", user.EditorUrl(), bodyBuf)
req.Header.Add("Content-Type", "multipart/form-data; boundary="+bodyWriter.Boundary())
req.Header.Add("Content-Length", strconv.Itoa(len(bodyBuf.Bytes())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
req.AddCookie(&http.Cookie{Name: "session", Value: sessionId})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusFound)
posts, _ := user.AllPosts()
assertions.AssertEqual(t, len(posts), 1)
post := posts[0]
assertions.AssertEqual(t, rr.Header().Get("Location"), post.FullUrl())
assertions.AssertNotEqual(t, post.Meta().PhotoPath, "")
ret_photo_data, err := ioutil.ReadFile(path.Join(post.MediaDir(), post.Meta().PhotoPath))
assertions.AssertNoError(t, err, "Error reading photo")
assertions.AssertEqual(t, len(photo_data), len(ret_photo_data))
if len(photo_data) == len(ret_photo_data) {
for i := range photo_data {
assertions.AssertEqual(t, photo_data[i], ret_photo_data[i])
}
}
}

View File

@ -1,407 +0,0 @@
package web
import (
"fmt"
"h4kor/owl-blogs"
"net/http"
"net/url"
"os"
"path"
"strings"
"time"
"github.com/julienschmidt/httprouter"
)
func getUserFromRepo(repo *owl.Repository, ps httprouter.Params) (owl.User, error) {
if config, _ := repo.Config(); config.SingleUser != "" {
return repo.GetUser(config.SingleUser)
}
userName := ps.ByName("user")
user, err := repo.GetUser(userName)
if err != nil {
return owl.User{}, err
}
return user, nil
}
func repoIndexHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
html, err := owl.RenderUserList(*repo)
if err != nil {
println("Error rendering index: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal server error"))
return
}
println("Rendering index")
w.Write([]byte(html))
}
}
func userIndexHandler(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
}
html, err := owl.RenderIndexPage(user)
if err != nil {
println("Error rendering index page: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
println("Rendering index page for user", user.Name())
w.Write([]byte(html))
}
}
func postListHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
listId := ps.ByName("list")
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
list, err := user.GetPostList(listId)
if err != nil {
println("Error getting post list: ", err.Error())
notFoundUserHandler(repo, user)(w, r)
return
}
html, err := owl.RenderPostList(user, list)
if err != nil {
println("Error rendering index page: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
println("Rendering index page for user", user.Name())
w.Write([]byte(html))
}
}
func userWebmentionHandler(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 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("User not found"))
return
}
err = r.ParseForm()
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Unable to parse form data"))
return
}
params := r.PostForm
target := params["target"]
source := params["source"]
if len(target) == 0 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("No target provided"))
return
}
if len(source) == 0 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("No source provided"))
return
}
if len(target[0]) < 7 || (target[0][:7] != "http://" && target[0][:8] != "https://") {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Not a valid target"))
return
}
if len(source[0]) < 7 || (source[0][:7] != "http://" && source[0][:8] != "https://") {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Not a valid source"))
return
}
if source[0] == target[0] {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("target and source are equal"))
return
}
tryAlias := func(target string) owl.Post {
parsedTarget, _ := url.Parse(target)
aliases, _ := repo.PostAliases()
fmt.Printf("aliases %v", aliases)
fmt.Printf("parsedTarget %v", parsedTarget)
if _, ok := aliases[parsedTarget.Path]; ok {
return aliases[parsedTarget.Path]
}
return nil
}
var aliasPost owl.Post
parts := strings.Split(target[0], "/")
if len(parts) < 2 {
aliasPost = tryAlias(target[0])
if aliasPost == nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Not found"))
return
}
}
postId := parts[len(parts)-2]
foundPost, err := user.GetPost(postId)
if err != nil && aliasPost == nil {
aliasPost = tryAlias(target[0])
if aliasPost == nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Post not found"))
return
}
}
if aliasPost != nil {
foundPost = aliasPost
}
err = foundPost.AddIncomingWebmention(source[0])
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Unable to process webmention"))
return
}
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(""))
}
}
func userRSSHandler(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
}
xml, err := owl.RenderRSSFeed(user)
if err != nil {
println("Error rendering index page: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
println("Rendering index page for user", user.Name())
w.Header().Set("Content-Type", "application/rss+xml")
w.Write([]byte(xml))
}
}
func postHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
postId := ps.ByName("post")
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
post, err := user.GetPost(postId)
if err != nil {
println("Error getting post: ", err.Error())
notFoundUserHandler(repo, user)(w, r)
return
}
meta := post.Meta()
if meta.Draft {
println("Post is a draft")
notFoundUserHandler(repo, user)(w, r)
return
}
html, err := owl.RenderPost(post)
if err != nil {
println("Error rendering post: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
println("Rendering post", postId)
w.Write([]byte(html))
}
}
func postMediaHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
postId := ps.ByName("post")
filepath := ps.ByName("filepath")
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
post, err := user.GetPost(postId)
if err != nil {
println("Error getting post: ", err.Error())
notFoundUserHandler(repo, user)(w, r)
return
}
filepath = path.Join(post.MediaDir(), filepath)
if _, err := os.Stat(filepath); err != nil {
println("Error getting file: ", err.Error())
notFoundUserHandler(repo, user)(w, r)
return
}
http.ServeFile(w, r, filepath)
}
}
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.CreateNewPost(
owl.PostMeta{
Title: name,
Reply: owl.ReplyData{
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) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
filepath := ps.ByName("filepath")
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
filepath = path.Join(user.MediaDir(), filepath)
if _, err := os.Stat(filepath); err != nil {
println("Error getting file: ", err.Error())
notFoundUserHandler(repo, user)(w, r)
return
}
http.ServeFile(w, r, filepath)
}
}
func notFoundHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
aliases, _ := repo.PostAliases()
if _, ok := aliases[path]; ok {
http.Redirect(w, r, aliases[path].UrlPath(), http.StatusMovedPermanently)
return
}
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("Not found"))
}
}
func notFoundUserHandler(repo *owl.Repository, user owl.User) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
aliases, _ := repo.PostAliases()
if _, ok := aliases[path]; ok {
http.Redirect(w, r, aliases[path].UrlPath(), http.StatusMovedPermanently)
return
}
w.WriteHeader(http.StatusNotFound)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Not found",
Message: "The page you requested could not be found",
})
w.Write([]byte(html))
}
}

View File

@ -1,183 +0,0 @@
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

@ -1,108 +0,0 @@
package web_test
import (
"h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/test/assertions"
"math/rand"
"net/http"
"net/http/httptest"
"os"
"path"
"testing"
"time"
)
func randomName() string {
rand.Seed(time.Now().UnixNano())
var letters = []rune("abcdefghijklmnopqrstuvwxyz")
b := make([]rune, 8)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
func testRepoName() string {
return "/tmp/" + randomName()
}
func getTestRepo(config owl.RepoConfig) owl.Repository {
repo, _ := owl.CreateRepository(testRepoName(), config)
return repo
}
func TestMultiUserRepoIndexHandler(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
repo.CreateUser("user_1")
repo.CreateUser("user_2")
// Create Request and Response
req, err := http.NewRequest("GET", "/", nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// Check the response body contains names of users
assertions.AssertContains(t, rr.Body.String(), "user_1")
assertions.AssertContains(t, rr.Body.String(), "user_2")
}
func TestMultiUserUserIndexHandler(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
// Create Request and Response
req, err := http.NewRequest("GET", user.UrlPath(), nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// Check the response body contains names of users
assertions.AssertContains(t, rr.Body.String(), "post-1")
}
func TestMultiUserPostHandler(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
// Create Request and Response
req, err := http.NewRequest("GET", post.UrlPath(), nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
}
func TestMultiUserPostMediaHandler(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
// Create test media file
path := path.Join(post.MediaDir(), "data.txt")
err := os.WriteFile(path, []byte("test"), 0644)
assertions.AssertNoError(t, err, "Error creating request")
// Create Request and Response
req, err := http.NewRequest("GET", post.UrlMediaPath("data.txt"), nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// Check the response body contains data of media file
assertions.Assert(t, rr.Body.String() == "test", "Response body is not equal to test")
}

View File

@ -1,34 +0,0 @@
package web_test
import (
"h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/test/assertions"
"net/http"
"net/http/httptest"
"os"
"testing"
)
func TestPostHandlerReturns404OnDrafts(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "")
content := "---\n"
content += "title: test\n"
content += "draft: true\n"
content += "---\n"
content += "\n"
content += "Write your post here.\n"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
// Create Request and Response
req, err := http.NewRequest("GET", post.UrlPath(), nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusNotFound)
}

View File

@ -1,31 +0,0 @@
package web_test
import (
"h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/test/assertions"
"net/http"
"net/http/httptest"
"testing"
)
func TestMultiUserUserRssIndexHandler(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
// Create Request and Response
req, err := http.NewRequest("GET", user.UrlPath()+"index.xml", nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// Check the response Content-Type is what we expect.
assertions.AssertContains(t, rr.Header().Get("Content-Type"), "application/rss+xml")
// Check the response body contains names of users
assertions.AssertContains(t, rr.Body.String(), "post-1")
}

View File

@ -1,95 +0,0 @@
package web
import (
"h4kor/owl-blogs"
"net/http"
"os"
"strconv"
"github.com/julienschmidt/httprouter"
)
func Router(repo *owl.Repository) http.Handler {
router := httprouter.New()
router.ServeFiles("/static/*filepath", http.Dir(repo.StaticDir()))
router.GET("/", repoIndexHandler(repo))
router.GET("/user/:user/", userIndexHandler(repo))
router.GET("/user/:user/lists/:list/", postListHandler(repo))
// Editor
router.GET("/user/:user/editor/auth/", userLoginGetHandler(repo))
router.POST("/user/:user/editor/auth/", userLoginPostHandler(repo))
router.GET("/user/:user/editor/", userEditorGetHandler(repo))
router.POST("/user/:user/editor/", userEditorPostHandler(repo))
// Media
router.GET("/user/:user/media/*filepath", userMediaHandler(repo))
// RSS
router.GET("/user/:user/index.xml", userRSSHandler(repo))
// Posts
router.GET("/user/:user/posts/:post/", postHandler(repo))
router.GET("/user/:user/posts/:post/media/*filepath", postMediaHandler(repo))
// Webmention
router.POST("/user/:user/webmention/", userWebmentionHandler(repo))
// Micropub
router.POST("/user/:user/micropub/", userMicropubHandler(repo))
// IndieAuth
router.GET("/user/:user/auth/", userAuthHandler(repo))
router.POST("/user/:user/auth/", userAuthProfileHandler(repo))
router.POST("/user/:user/auth/verify/", userAuthVerifyHandler(repo))
router.POST("/user/:user/auth/token/", userAuthTokenHandler(repo))
router.GET("/user/:user/.well-known/oauth-authorization-server", userAuthMetadataHandler(repo))
router.NotFound = http.HandlerFunc(notFoundHandler(repo))
return router
}
func SingleUserRouter(repo *owl.Repository) http.Handler {
router := httprouter.New()
router.ServeFiles("/static/*filepath", http.Dir(repo.StaticDir()))
router.GET("/", userIndexHandler(repo))
router.GET("/lists/:list/", postListHandler(repo))
// Editor
router.GET("/editor/auth/", userLoginGetHandler(repo))
router.POST("/editor/auth/", userLoginPostHandler(repo))
router.GET("/editor/", userEditorGetHandler(repo))
router.POST("/editor/", userEditorPostHandler(repo))
// Media
router.GET("/media/*filepath", userMediaHandler(repo))
// RSS
router.GET("/index.xml", userRSSHandler(repo))
// Posts
router.GET("/posts/:post/", postHandler(repo))
router.GET("/posts/:post/media/*filepath", postMediaHandler(repo))
// Webmention
router.POST("/webmention/", userWebmentionHandler(repo))
// Micropub
router.POST("/micropub/", userMicropubHandler(repo))
// IndieAuth
router.GET("/auth/", userAuthHandler(repo))
router.POST("/auth/", userAuthProfileHandler(repo))
router.POST("/auth/verify/", userAuthVerifyHandler(repo))
router.POST("/auth/token/", userAuthTokenHandler(repo))
router.GET("/.well-known/oauth-authorization-server", userAuthMetadataHandler(repo))
router.NotFound = http.HandlerFunc(notFoundHandler(repo))
return router
}
func StartServer(repoPath string, port int) {
var repo owl.Repository
var err error
repo, err = owl.OpenRepository(repoPath)
if err != nil {
println("Error opening repository: ", err.Error())
os.Exit(1)
}
var router http.Handler
if config, _ := repo.Config(); config.SingleUser != "" {
router = SingleUserRouter(&repo)
} else {
router = Router(&repo)
}
println("Listening on port", port)
http.ListenAndServe(":"+strconv.Itoa(port), router)
}

View File

@ -1,132 +0,0 @@
package web_test
import (
owl "h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/test/assertions"
"net/http"
"net/http/httptest"
"os"
"path"
"testing"
)
func getSingleUserTestRepo() (owl.Repository, owl.User) {
repo, _ := owl.CreateRepository(testRepoName(), owl.RepoConfig{SingleUser: "test-1"})
user, _ := repo.CreateUser("test-1")
return repo, user
}
func TestSingleUserUserIndexHandler(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
// Create Request and Response
req, err := http.NewRequest("GET", user.UrlPath(), nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// Check the response body contains names of users
assertions.AssertContains(t, rr.Body.String(), "post-1")
}
func TestSingleUserPostHandler(t *testing.T) {
repo, user := getSingleUserTestRepo()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
// Create Request and Response
req, err := http.NewRequest("GET", post.UrlPath(), nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
}
func TestSingleUserPostMediaHandler(t *testing.T) {
repo, user := getSingleUserTestRepo()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
// Create test media file
path := path.Join(post.MediaDir(), "data.txt")
err := os.WriteFile(path, []byte("test"), 0644)
assertions.AssertNoError(t, err, "Error creating request")
// Create Request and Response
req, err := http.NewRequest("GET", post.UrlMediaPath("data.txt"), nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// Check the response body contains data of media file
assertions.Assert(t, rr.Body.String() == "test", "Media file data not returned")
}
func TestHasNoDraftsInList(t *testing.T) {
repo, user := getSingleUserTestRepo()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
content := ""
content += "---\n"
content += "title: Articles September 2019\n"
content += "author: h4kor\n"
content += "type: post\n"
content += "date: -001-11-30T00:00:00+00:00\n"
content += "draft: true\n"
content += "url: /?p=426\n"
content += "categories:\n"
content += " - Uncategorised\n"
content += "\n"
content += "---\n"
content += "<https://nesslabs.com/time-anxiety>\n"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
// Create Request and Response
req, err := http.NewRequest("GET", "/", nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
// Check if title is in the response body
assertions.AssertNotContains(t, rr.Body.String(), "Articles September 2019")
}
func TestSingleUserUserPostListHandler(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.CreateNewPost(owl.PostMeta{
Title: "post-1",
Type: "article",
}, "hi")
user.CreateNewPost(owl.PostMeta{
Title: "post-2",
Type: "note",
}, "hi")
list := owl.PostList{
Title: "list-1",
Id: "list-1",
Include: []string{"article"},
}
user.AddPostList(list)
// Create Request and Response
req, err := http.NewRequest("GET", user.ListUrl(list), nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// Check the response body contains names of users
assertions.AssertContains(t, rr.Body.String(), "post-1")
assertions.AssertNotContains(t, rr.Body.String(), "post-2")
}

View File

@ -1,162 +0,0 @@
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"
"os"
"strconv"
"strings"
"testing"
)
func setupWebmentionTest(repo owl.Repository, user owl.User, target string, source string) (*httptest.ResponseRecorder, error) {
data := url.Values{}
data.Set("target", target)
data.Set("source", source)
// Create Request and Response
req, err := http.NewRequest("POST", user.UrlPath()+"webmention/", strings.NewReader(data.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(data.Encode())))
if err != nil {
return nil, err
}
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
return rr, nil
}
func TestWebmentionHandleAccepts(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
target := post.FullUrl()
source := "https://example.com"
rr, err := setupWebmentionTest(repo, user, target, source)
assertions.AssertNoError(t, err, "Error setting up webmention test")
assertions.AssertStatus(t, rr, http.StatusAccepted)
}
func TestWebmentionWrittenToPost(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
target := post.FullUrl()
source := "https://example.com"
rr, err := setupWebmentionTest(repo, user, target, source)
assertions.AssertNoError(t, err, "Error setting up webmention test")
assertions.AssertStatus(t, rr, http.StatusAccepted)
assertions.AssertLen(t, post.IncomingWebmentions(), 1)
}
//
// https://www.w3.org/TR/webmention/#h-request-verification
//
// The receiver MUST check that source and target are valid URLs [URL]
// and are of schemes that are supported by the receiver.
// (Most commonly this means checking that the source and target schemes are http or https).
func TestWebmentionSourceValidation(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
target := post.FullUrl()
source := "ftp://example.com"
rr, err := setupWebmentionTest(repo, user, target, source)
assertions.AssertNoError(t, err, "Error setting up webmention test")
assertions.AssertStatus(t, rr, http.StatusBadRequest)
}
func TestWebmentionTargetValidation(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
target := "ftp://example.com"
source := post.FullUrl()
rr, err := setupWebmentionTest(repo, user, target, source)
assertions.AssertNoError(t, err, "Error setting up webmention test")
assertions.AssertStatus(t, rr, http.StatusBadRequest)
}
// The receiver MUST reject the request if the source URL is the same as the target URL.
func TestWebmentionSameTargetAndSource(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
target := post.FullUrl()
source := post.FullUrl()
rr, err := setupWebmentionTest(repo, user, target, source)
assertions.AssertNoError(t, err, "Error setting up webmention test")
assertions.AssertStatus(t, rr, http.StatusBadRequest)
}
// The receiver SHOULD check that target is a valid resource for which it can accept Webmentions.
// This check SHOULD happen synchronously to reject invalid Webmentions before more in-depth verification begins.
// What a "valid resource" means is up to the receiver.
func TestValidationOfTarget(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
target := post.FullUrl()
target = target[:len(target)-1] + "invalid"
source := post.FullUrl()
rr, err := setupWebmentionTest(repo, user, target, source)
assertions.AssertNoError(t, err, "Error setting up webmention test")
assertions.AssertStatus(t, rr, http.StatusBadRequest)
}
func TestAcceptWebmentionForAlias(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
content := "---\n"
content += "title: Test\n"
content += "aliases: \n"
content += " - /foo/bar\n"
content += " - /foo/baz\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
target := "https://example.com/foo/bar"
source := "https://example.com"
rr, err := setupWebmentionTest(repo, user, target, source)
assertions.AssertNoError(t, err, "Error setting up webmention test")
assertions.AssertStatus(t, rr, http.StatusAccepted)
}

View File

@ -1,99 +0,0 @@
package main
import (
"h4kor/owl-blogs"
"sync"
"github.com/spf13/cobra"
)
var postId string
func init() {
rootCmd.AddCommand(webmentionCmd)
webmentionCmd.Flags().StringVar(
&postId, "post", "",
"specify the post to send webmentions for. Otherwise, all posts will be checked.",
)
}
var webmentionCmd = &cobra.Command{
Use: "webmention",
Short: "Send webmentions for posts, optionally for a specific user",
Long: `Send webmentions for posts, optionally for a specific user`,
Run: func(cmd *cobra.Command, args []string) {
repo, err := owl.OpenRepository(repoPath)
if err != nil {
println("Error opening repository: ", err.Error())
return
}
var users []owl.User
if user == "" {
// send webmentions for all users
users, err = repo.Users()
if err != nil {
println("Error getting users: ", err.Error())
return
}
} else {
// send webmentions for a specific user
user, err := repo.GetUser(user)
users = append(users, user)
if err != nil {
println("Error getting user: ", err.Error())
return
}
}
processPost := func(user owl.User, post owl.Post) error {
println("Webmentions for post: ", post.Title())
err := post.ScanForLinks()
if err != nil {
println("Error scanning post for links: ", err.Error())
return err
}
webmentions := post.OutgoingWebmentions()
println("Found ", len(webmentions), " links")
wg := sync.WaitGroup{}
wg.Add(len(webmentions))
for _, webmention := range webmentions {
go func(webmention owl.WebmentionOut) {
defer wg.Done()
sendErr := post.SendWebmention(webmention)
if sendErr != nil {
println("Error sending webmentions: ", sendErr.Error())
} else {
println("Webmention sent to ", webmention.Target)
}
}(webmention)
}
wg.Wait()
return nil
}
for _, user := range users {
if postId != "" {
// send webmentions for a specific post
post, err := user.GetPost(postId)
if err != nil {
println("Error getting post: ", err.Error())
return
}
processPost(user, post)
return
}
posts, err := user.PublishedPosts()
if err != nil {
println("Error getting posts: ", err.Error())
}
for _, post := range posts {
processPost(user, post)
}
}
},
}

View File

@ -1,45 +0,0 @@
package owl
import (
"os"
"strings"
)
func dirExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// lists all files/dirs in a directory, not recursive
func listDir(path string) []string {
dir, _ := os.Open(path)
defer dir.Close()
files, _ := dir.Readdirnames(-1)
return files
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func toDirectoryName(name string) string {
name = strings.ToLower(strings.ReplaceAll(name, " ", "-"))
// remove all non-alphanumeric characters
name = strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' {
return r
}
if r >= 'A' && r <= 'Z' {
return r
}
if r >= '0' && r <= '9' {
return r
}
if r == '-' {
return r
}
return -1
}, name)
return name
}

View File

@ -1,6 +0,0 @@
package owl
import "embed"
//go:embed embed/*
var embed_files embed.FS

View File

@ -1,60 +0,0 @@
<div class="h-entry">
<hgroup>
<h1 class="p-name">{{.Title}}</h1>
<small>
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
Published:
<time class="dt-published" datetime="{{.Post.Meta.Date}}">
{{.Post.Meta.FormattedDate}}
</time>
{{ if .Post.User.Config.AuthorName }}
by
<a class="p-author h-card" href="{{.Post.User.FullUrl}}">
{{ if .Post.User.AvatarUrl }}
<img class="u-photo u-logo" style="height: 1em;" src="{{ .Post.User.AvatarUrl }}"
alt="{{ .Post.User.Config.Title }}" />
{{ end }}
{{.Post.User.Config.AuthorName}}
</a>
{{ end }}
</small>
</hgroup>
<hr>
<br>
{{ if .Post.Meta.Bookmark.Url }}
<p style="font-style: italic;filter: opacity(80%);">
Bookmark: <a class="u-bookmark-of h-cite" href="{{.Post.Meta.Bookmark.Url}}">
{{ if .Post.Meta.Bookmark.Text }}
{{.Post.Meta.Bookmark.Text}}
{{ else }}
{{.Post.Meta.Bookmark.Url}}
{{ end }}
</a>
</p>
{{ end }}
<div class="e-content">
{{.Content}}
</div>
<hr>
{{if .Post.ApprovedIncomingWebmentions}}
<h3>
Webmentions
</h3>
<ul>
{{range .Post.ApprovedIncomingWebmentions}}
<li>
<a href="{{.Source}}">
{{if .Title}}
{{.Title}}
{{else}}
{{.Source}}
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>

View File

@ -1,24 +0,0 @@
<h3>Authorization for {{.ClientId}}</h3>
<h5>Requesting scope:</h5>
<ul>
{{range $index, $element := .Scopes}}
<li>{{$element}}</li>
{{end}}
</ul>
<br><br>
<form action="verify/" method="post">
<label for="password">Password</label>
<input type="password" name="password" placeholder="Password">
<input type="hidden" name="client_id" value="{{.ClientId}}">
<input type="hidden" name="redirect_uri" value="{{.RedirectUri}}">
<input type="hidden" name="response_type" value="{{.ResponseType}}">
<input type="hidden" name="state" value="{{.State}}">
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<input type="hidden" name="code_challenge" value="{{.CodeChallenge}}">
<input type="hidden" name="code_challenge_method" value="{{.CodeChallengeMethod}}">
<input type="hidden" name="scope" value="{{.Scope}}">
<input type="submit" value="Login">
</form>

View File

@ -1,72 +0,0 @@
<div class="h-entry">
<hgroup>
<h1 class="p-name">{{.Title}}</h1>
<small>
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
Published:
<time class="dt-published" datetime="{{.Post.Meta.Date}}">
{{.Post.Meta.FormattedDate}}
</time>
{{ if .Post.User.Config.AuthorName }}
by
<a class="p-author h-card" href="{{.Post.User.FullUrl}}">
{{ if .Post.User.AvatarUrl }}
<img class="u-photo u-logo" style="height: 1em;" src="{{ .Post.User.AvatarUrl }}"
alt="{{ .Post.User.Config.Title }}" />
{{ end }}
{{.Post.User.Config.AuthorName}}
</a>
{{ end }}
</small>
</hgroup>
<hr>
<br>
{{ if .Post.Meta.Reply.Url }}
<p style="font-style: italic;filter: opacity(80%);">
In reply to: <a class="u-in-reply-to h-cite" rel="in-reply-to" href="{{.Post.Meta.Reply.Url}}">
{{ if .Post.Meta.Reply.Text }}
{{.Post.Meta.Reply.Text}}
{{ else }}
{{.Post.Meta.Reply.Url}}
{{ end }}
</a>
</p>
{{ end }}
{{ if .Post.Meta.Bookmark.Url }}
<p style="font-style: italic;filter: opacity(80%);">
Bookmark: <a class="u-bookmark-of h-cite" href="{{.Post.Meta.Bookmark.Url}}">
{{ if .Post.Meta.Bookmark.Text }}
{{.Post.Meta.Bookmark.Text}}
{{ else }}
{{.Post.Meta.Bookmark.Url}}
{{ end }}
</a>
</p>
{{ end }}
<div class="e-content">
{{.Content}}
</div>
<hr>
{{if .Post.ApprovedIncomingWebmentions}}
<h3>
Webmentions
</h3>
<ul>
{{range .Post.ApprovedIncomingWebmentions}}
<li>
<a href="{{.Source}}">
{{if .Title}}
{{.Title}}
{{else}}
{{.Source}}
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>

View File

@ -1,127 +0,0 @@
<details>
<summary>Write Article/Page</summary>
<form action="" method="post">
<h2>Create New Article</h2>
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<select name="type">
<option value="article">Article</option>
<option value="page">Page</option>
</select>
<label for="title">Title</label>
<input type="text" name="title" placeholder="Title" />
<label for="description">Description</label>
<input type="text" name="description" placeholder="Description" />
<label for="content">Content</label>
<textarea name="content" placeholder="Content" rows="24"></textarea>
<input type="checkbox" name="draft" />
<label for="draft">Draft</label>
<br><br>
<input type="submit" value="Create Article" />
</form>
</details>
<details>
<summary>Upload Photo</summary>
<form action="" method="post" enctype="multipart/form-data">
<h2>Upload Photo</h2>
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<input type="hidden" name="type" value="photo">
<label for="title">Title</label>
<input type="text" name="title" placeholder="Title" />
<label for="description">Description</label>
<input type="text" name="description" placeholder="Description" />
<label for="content">Content</label>
<textarea name="content" placeholder="Content" rows="4"></textarea>
<label for="photo">Photo</label>
<input type="file" name="photo" placeholder="Photo" />
<br><br>
<input type="submit" value="Create Article" />
</form>
</details>
<details>
<summary>Write Recipe</summary>
<form action="" method="post">
<h2>Create new Recipe</h2>
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<input type="hidden" name="type" value="recipe">
<label for="title">Title</label>
<input type="text" name="title" placeholder="Title" />
<label for="yield">Yield</label>
<input type="text" name="yield" placeholder="Yield" />
<label for="duration">Duration</label>
<input type="text" name="duration" placeholder="Duration" />
<label for="description">Description</label>
<input type="text" name="description" placeholder="Description" />
<label for="ingredients">Ingredients (1 per line)</label>
<textarea name="ingredients" placeholder="Ingredients" rows="8"></textarea>
<label for="content">Instructions</label>
<textarea name="content" placeholder="Ingredients" rows="24"></textarea>
<br><br>
<input type="submit" value="Create Reply" />
</form>
</details>
<details>
<summary>Write Note</summary>
<form action="" method="post">
<h2>Create New Note</h2>
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<input type="hidden" name="type" value="note">
<label for="content">Content</label>
<textarea name="content" placeholder="Content" rows="8"></textarea>
<br><br>
<input type="submit" value="Create Note" />
</form>
</details>
<details>
<summary>Write Reply</summary>
<form action="" method="post">
<h2>Create New Reply</h2>
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<input type="hidden" name="type" value="reply">
<label for="reply_url">Reply To</label>
<input type="text" name="reply_url" placeholder="URL" />
<label for="title">Title</label>
<input type="text" name="title" placeholder="Title" />
<label for="content">Content</label>
<textarea name="content" placeholder="Content" rows="8"></textarea>
<br><br>
<input type="submit" value="Create Reply" />
</form>
</details>
<details>
<summary>Bookmark</summary>
<form action="" method="post">
<h2>Create Bookmark</h2>
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<input type="hidden" name="type" value="bookmark">
<label for="bookmark_url">Bookmark</label>
<input type="text" name="bookmark_url" placeholder="URL" />
<label for="title">Title</label>
<input type="text" name="title" placeholder="Title" />
<label for="content">Content</label>
<textarea name="content" placeholder="Content" rows="8"></textarea>
<br><br>
<input type="submit" value="Create Bookmark" />
</form>
</details>

View File

@ -1,13 +0,0 @@
{{ if eq .Error "wrong_password" }}
<article style="background-color: #dd867f;color: #481212;padding: 1em;">
Wrong Password
</article>
{{ end }}
<form action="" method="post">
<h2>Login to Editor</h2>
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<input type="password" name="password" />
<input type="submit" value="Login" />
</form>

View File

@ -1,4 +0,0 @@
<article style="background-color: #dd867f;color: #481212;">
<h3>{{ .Error }}</h3>
{{ .Message }}
</article>

View File

@ -1,146 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Title }} - {{ .User.Config.Title }}</title>
{{ if .User.FaviconUrl }}
<link rel="icon" href="{{ .User.FaviconUrl }}">
{{ else }}
<link rel="icon" href="data:,">
{{ end }}
<meta property="og:title" content="{{ .Title }}" />
{{ if .Description }}
<meta name="description" content="{{ .Description }}">
<meta property="og:description" content="{{ .Description }}" />
{{ end }}
{{ if .Type }}
<meta property="og:type" content="{{ .Type }}" />
{{ end }}
{{ if .SelfUrl }}
<meta property="og:url" content="{{ .SelfUrl }}" />
{{ end }}
<link rel="stylesheet" href="/static/pico.min.css">
<link rel="webmention" href="{{ .User.WebmentionUrl }}">
{{ if .User.AuthUrl }}
<link rel="indieauth-metadata" href="{{ .User.IndieauthMetadataUrl }}">
<link rel="authorization_endpoint" href="{{ .User.AuthUrl}}">
<link rel="token_endpoint" href="{{ .User.TokenUrl}}">
<link rel="micropub" href="{{ .User.MicropubUrl}}">
{{ end }}
<style>
header {
background-color: {{.User.Config.HeaderColor}};
padding-bottom: 1rem !important;
}
footer {
border-top: dashed 2px;
border-color: #ccc;
}
.avatar {
float: left;
margin-right: 1rem;
}
.header {
display: flex;
flex-flow: row wrap;
justify-content: space-between;
align-items: flex-start;
}
.header-title {
order: 0;
}
.header-profile {
order: 1;
}
hgroup h2 a { color: inherit; }
.photo-grid {
display: flex;
flex-wrap: wrap;
padding: 0 4px;
}
.photo-grid-item {
flex: 1 0 25%;
padding: 4px;
}
.photo-grid-item img {
width: 100%;
height: 100%;
aspect-ratio: 1 / 1 ;
object-fit: cover;
}
</style>
</head>
<body>
<header>
<div class="container header h-card">
<hgroup class="header-title">
<h2><a class="p-name u-url" href="{{ .User.UrlPath }}">{{ .User.Config.Title }}</a></h2>
<h3 class="p-note">{{ .User.Config.SubTitle }}</h3>
</hgroup>
<div class="header-profile">
{{ if .User.AvatarUrl }}
<img class="u-photo u-logo avatar" src="{{ .User.AvatarUrl }}" alt="{{ .User.Config.Title }}" width="100" height="100" />
{{ end }}
<div style="float: right; list-style: none;">
{{ range $me := .User.Config.Me }}
<li><a href="{{$me.Url}}" rel="me">{{$me.Name}}</a>
</li>
{{ end }}
</div>
</div>
</div>
<div class="container">
<nav>
<ul>
{{ range $link := .User.Config.HeaderMenu }}
{{ if $link.List }}
<li><a href="{{ listUrl $.User $link.List }}">{{ $link.Title }}</a></li>
{{ else if $link.Post }}
<li><a href="{{ postUrl $.User $link.Post }}">{{ $link.Title }}</a></li>
{{ else }}
<li><a href="{{ $link.Url }}">{{ $link.Title }}</a></li>
{{ end }}
{{ end }}
</ul>
</nav>
</div>
</header>
{{ .Content }}
</main>
<footer class="container">
<nav>
<ul>
{{ range $link := .User.Config.FooterMenu }}
{{ if $link.List }}
<li><a href="{{ listUrl $.User $link.List }}">{{ $link.Title }}</a></li>
{{ else if $link.Post }}
<li><a href="{{ postUrl $.User $link.Post }}">{{ $link.Title }}</a></li>
{{ else }}
<li><a href="{{ $link.Url }}">{{ $link.Title }}</a></li>
{{ end }}
{{ end }}
</ul>
</nav>
</footer>
</body>
</html>

View File

@ -1,5 +0,0 @@
<ul>
{{ range .UserLinks }}
<li><a href="{{.Href}}">{{.Text}}</a></li>
{{ end }}
</ul>

View File

@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Title }}</title>
<link rel="stylesheet" href="/static/pico.min.css">
</head>
<body>
{{ .Content }}
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -1,47 +0,0 @@
<div class="h-entry">
<hgroup>
<small>
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
Published:
<time class="dt-published" datetime="{{.Post.Meta.Date}}">
{{.Post.Meta.FormattedDate}}
</time>
{{ if .Post.User.Config.AuthorName }}
by
<a class="p-author h-card" href="{{.Post.User.FullUrl}}">
{{ if .Post.User.AvatarUrl }}
<img class="u-photo u-logo" style="height: 1em;" src="{{ .Post.User.AvatarUrl }}"
alt="{{ .Post.User.Config.Title }}" />
{{ end }}
{{.Post.User.Config.AuthorName}}
</a>
{{ end }}
</small>
</hgroup>
<hr>
<br>
<div class="e-content">
{{.Content}}
</div>
<hr>
{{if .Post.ApprovedIncomingWebmentions}}
<h3>
Webmentions
</h3>
<ul>
{{range .Post.ApprovedIncomingWebmentions}}
<li>
<a href="{{.Source}}">
{{if .Title}}
{{.Title}}
{{else}}
{{.Source}}
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>

View File

@ -1,34 +0,0 @@
<div class="h-entry">
<hgroup>
<h1 class="p-name">{{.Title}}</h1>
<small>
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
</small>
</hgroup>
<hr>
<br>
<div class="e-content">
{{.Content}}
</div>
<hr>
{{if .Post.ApprovedIncomingWebmentions}}
<h3>
Webmentions
</h3>
<ul>
{{range .Post.ApprovedIncomingWebmentions}}
<li>
<a href="{{.Source}}">
{{if .Title}}
{{.Title}}
{{else}}
{{.Source}}
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>

View File

@ -1,52 +0,0 @@
<div class="h-entry">
<hgroup>
<h1 class="p-name">{{.Title}}</h1>
<small>
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
Published:
<time class="dt-published" datetime="{{.Post.Meta.Date}}">
{{.Post.Meta.FormattedDate}}
</time>
{{ if .Post.User.Config.AuthorName }}
by
<a class="p-author h-card" href="{{.Post.User.FullUrl}}">
{{ if .Post.User.AvatarUrl }}
<img class="u-photo u-logo" style="height: 1em;" src="{{ .Post.User.AvatarUrl }}"
alt="{{ .Post.User.Config.Title }}" />
{{ end }}
{{.Post.User.Config.AuthorName}}
</a>
{{ end }}
</small>
</hgroup>
<hr>
<br>
{{ if .Post.Meta.PhotoPath }}
<img class="u-photo" src="media/{{.Post.Meta.PhotoPath}}" alt="{{.Post.Meta.Description}}" />
{{ end }}
<div class="e-content">
{{.Content}}
</div>
<hr>
{{if .Post.ApprovedIncomingWebmentions}}
<h3>
Webmentions
</h3>
<ul>
{{range .Post.ApprovedIncomingWebmentions}}
<li>
<a href="{{.Source}}">
{{if .Title}}
{{.Title}}
{{else}}
{{.Source}}
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>

View File

@ -1,9 +0,0 @@
<div class="h-feed photo-grid">
{{range .}}
<div class="h-entry photo-grid-item">
<a class="u-url" href="{{.UrlPath}}">
<img class="u-photo" src="{{.UrlPath}}media/{{.Meta.PhotoPath}}" alt="{{.Meta.Description}}" />
</a>
</div>
{{end}}
</div>

View File

@ -1,25 +0,0 @@
<div class="h-feed">
{{range .}}
<div class="h-entry">
<hgroup>
{{ if eq .Meta.Type "note"}}
<h6><a class="u-url" href="{{.UrlPath}}">
{{ if .Title }}{{.Title}}{{ else }}#{{.Id}}{{ end }}
</a></h6>
<p>{{.RenderedContent | noescape}}</p>
{{ else }}
<h3><a class="u-url" href="{{.UrlPath}}">
{{ if .Title }}{{.Title}}{{ else }}#{{.Id}}{{ end }}
</a></h3>
{{ end }}
<small style="font-size: 0.75em;">
Published:
<time class="dt-published" datetime="{{.Meta.Date}}">
{{.Meta.FormattedDate}}
</time>
</small>
</hgroup>
</div>
<hr>
{{end}}
</div>

View File

@ -1,71 +0,0 @@
<div class="h-entry">
<hgroup>
<h1 class="p-name">{{.Title}}</h1>
<small>
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
Published:
<time class="dt-published" datetime="{{.Post.Meta.Date}}">
{{.Post.Meta.FormattedDate}}
</time>
{{ if .Post.User.Config.AuthorName }}
by
<a class="p-author h-card" href="{{.Post.User.FullUrl}}">
{{ if .Post.User.AvatarUrl }}
<img class="u-photo u-logo" style="height: 1em;" src="{{ .Post.User.AvatarUrl }}" alt="{{ .Post.User.Config.Title }}" />
{{ end }}
{{.Post.User.Config.AuthorName}}
</a>
{{ end }}
</small>
</hgroup>
<hr>
<br>
{{ if .Post.Meta.Reply.Url }}
<p style="font-style: italic;filter: opacity(80%);">
In reply to: <a class="u-in-reply-to h-cite" rel="in-reply-to" href="{{.Post.Meta.Reply.Url}}">
{{ if .Post.Meta.Reply.Text }}
{{.Post.Meta.Reply.Text}}
{{ else }}
{{.Post.Meta.Reply.Url}}
{{ end }}
</a>
</p>
{{ end }}
{{ if .Post.Meta.Bookmark.Url }}
<p style="font-style: italic;filter: opacity(80%);">
Bookmark: <a class="u-bookmark-of h-cite" href="{{.Post.Meta.Bookmark.Url}}">
{{ if .Post.Meta.Bookmark.Text }}
{{.Post.Meta.Bookmark.Text}}
{{ else }}
{{.Post.Meta.Bookmark.Url}}
{{ end }}
</a>
</p>
{{ end }}
<div class="e-content">
{{.Content}}
</div>
<hr>
{{if .Post.ApprovedIncomingWebmentions}}
<h3>
Webmentions
</h3>
<ul>
{{range .Post.ApprovedIncomingWebmentions}}
<li>
<a href="{{.Source}}">
{{if .Title}}
{{.Title}}
{{else}}
{{.Source}}
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>

View File

@ -1,78 +0,0 @@
<div class="h-entry h-recipe">
<hgroup>
<h1 class="p-name">{{.Title}}</h1>
<small>
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
Published:
<time class="dt-published" datetime="{{.Post.Meta.Date}}">
{{.Post.Meta.FormattedDate}}
</time>
{{ if .Post.User.Config.AuthorName }}
by
<a class="p-author h-card" href="{{.Post.User.FullUrl}}">
{{ if .Post.User.AvatarUrl }}
<img class="u-photo u-logo" style="height: 1em;" src="{{ .Post.User.AvatarUrl }}"
alt="{{ .Post.User.Config.Title }}" />
{{ end }}
{{.Post.User.Config.AuthorName}}
</a>
{{ end }}
</small>
</hgroup>
<hr>
<br>
<div class="e-content">
<small>
{{ if .Post.Meta.Recipe.Yield }}
Servings: <span class="p-yield">{{ .Post.Meta.Recipe.Yield }}</span>
{{ if .Post.Meta.Recipe.Duration }}, {{end}}
{{ end }}
{{ if .Post.Meta.Recipe.Duration }}
Prep Time: <time class="dt-duration" value="{{ .Post.Meta.Recipe.Duration }}">
{{ .Post.Meta.Recipe.Duration }}
</time>
{{ end }}
</small>
<h2>Ingredients</h2>
<ul>
{{ range $ingredient := .Post.Meta.Recipe.Ingredients }}
<li class="p-ingredient">
{{ $ingredient }}
</li>
{{ end }}
</ul>
<h2>Instructions</h2>
<div class="e-instructions">
{{.Content}}
</div>
</div>
<hr>
{{if .Post.ApprovedIncomingWebmentions}}
<h3>
Webmentions
</h3>
<ul>
{{range .Post.ApprovedIncomingWebmentions}}
<li>
<a href="{{.Source}}">
{{if .Title}}
{{.Title}}
{{else}}
{{.Source}}
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>

View File

@ -1,60 +0,0 @@
<div class="h-entry">
<hgroup>
<h1 class="p-name">{{.Title}}</h1>
<small>
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
Published:
<time class="dt-published" datetime="{{.Post.Meta.Date}}">
{{.Post.Meta.FormattedDate}}
</time>
{{ if .Post.User.Config.AuthorName }}
by
<a class="p-author h-card" href="{{.Post.User.FullUrl}}">
{{ if .Post.User.AvatarUrl }}
<img class="u-photo u-logo" style="height: 1em;" src="{{ .Post.User.AvatarUrl }}"
alt="{{ .Post.User.Config.Title }}" />
{{ end }}
{{.Post.User.Config.AuthorName}}
</a>
{{ end }}
</small>
</hgroup>
<hr>
<br>
{{ if .Post.Meta.Reply.Url }}
<p style="font-style: italic;filter: opacity(80%);">
In reply to: <a class="u-in-reply-to h-cite" rel="in-reply-to" href="{{.Post.Meta.Reply.Url}}">
{{ if .Post.Meta.Reply.Text }}
{{.Post.Meta.Reply.Text}}
{{ else }}
{{.Post.Meta.Reply.Url}}
{{ end }}
</a>
</p>
{{ end }}
<div class="e-content">
{{.Content}}
</div>
<hr>
{{if .Post.ApprovedIncomingWebmentions}}
<h3>
Webmentions
</h3>
<ul>
{{range .Post.ApprovedIncomingWebmentions}}
<li>
<a href="{{.Source}}">
{{if .Title}}
{{.Title}}
{{else}}
{{.Source}}
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>

View File

@ -1,72 +0,0 @@
<div class="h-entry">
<hgroup>
<h1 class="p-name">{{.Title}}</h1>
<small>
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
Published:
<time class="dt-published" datetime="{{.Post.Meta.Date}}">
{{.Post.Meta.FormattedDate}}
</time>
{{ if .Post.User.Config.AuthorName }}
by
<a class="p-author h-card" href="{{.Post.User.FullUrl}}">
{{ if .Post.User.AvatarUrl }}
<img class="u-photo u-logo" style="height: 1em;" src="{{ .Post.User.AvatarUrl }}"
alt="{{ .Post.User.Config.Title }}" />
{{ end }}
{{.Post.User.Config.AuthorName}}
</a>
{{ end }}
</small>
</hgroup>
<hr>
<br>
{{ if .Post.Meta.Reply.Url }}
<p style="font-style: italic;filter: opacity(80%);">
In reply to: <a class="u-in-reply-to h-cite" rel="in-reply-to" href="{{.Post.Meta.Reply.Url}}">
{{ if .Post.Meta.Reply.Text }}
{{.Post.Meta.Reply.Text}}
{{ else }}
{{.Post.Meta.Reply.Url}}
{{ end }}
</a>
</p>
{{ end }}
{{ if .Post.Meta.Bookmark.Url }}
<p style="font-style: italic;filter: opacity(80%);">
Bookmark: <a class="u-bookmark-of h-cite" href="{{.Post.Meta.Bookmark.Url}}">
{{ if .Post.Meta.Bookmark.Text }}
{{.Post.Meta.Bookmark.Text}}
{{ else }}
{{.Post.Meta.Bookmark.Url}}
{{ end }}
</a>
</p>
{{ end }}
<div class="e-content">
{{.Content}}
</div>
<hr>
{{if .Post.ApprovedIncomingWebmentions}}
<h3>
Webmentions
</h3>
<ul>
{{range .Post.ApprovedIncomingWebmentions}}
<li>
<a href="{{.Source}}">
{{if .Title}}
{{.Title}}
{{else}}
{{.Source}}
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>

View File

@ -1,9 +0,0 @@
{{range .}}
<ul>
<li>
<a href="{{ .UrlPath }}">
{{ .Name }}
</a>
</li>
</ul>
{{end}}

View File

@ -1,23 +0,0 @@
package owl
import (
"os"
"gopkg.in/yaml.v2"
)
func saveToYaml(path string, data interface{}) error {
bytes, err := yaml.Marshal(data)
if err != nil {
return err
}
return os.WriteFile(path, bytes, 0644)
}
func loadFromYaml(path string, data interface{}) error {
bytes, err := os.ReadFile(path)
if err != nil {
return err
}
return yaml.Unmarshal(bytes, data)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

17
go.mod
View File

@ -1,17 +0,0 @@
module h4kor/owl-blogs
go 1.18
require (
github.com/julienschmidt/httprouter v1.3.0
github.com/spf13/cobra v1.5.0
github.com/yuin/goldmark v1.4.13
golang.org/x/net v0.1.0
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/crypto v0.1.0 // indirect
)

23
go.sum
View File

@ -1,23 +0,0 @@
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b h1:ZmngSVLe/wycRns9MKikG9OWIEjGcGAkacif7oYQaUY=
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

269
html.go
View File

@ -1,269 +0,0 @@
package owl
import (
"bytes"
"errors"
"io"
"net/http"
"net/url"
"strings"
"golang.org/x/net/html"
)
type HtmlParser interface {
ParseHEntry(resp *http.Response) (ParsedHEntry, error)
ParseLinks(resp *http.Response) ([]string, error)
ParseLinksFromString(string) ([]string, error)
GetWebmentionEndpoint(resp *http.Response) (string, error)
GetRedirctUris(resp *http.Response) ([]string, error)
}
type OwlHtmlParser struct{}
type ParsedHEntry struct {
Title string
}
func collectText(n *html.Node, buf *bytes.Buffer) {
if n.Type == html.TextNode {
buf.WriteString(n.Data)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
collectText(c, buf)
}
}
func readResponseBody(resp *http.Response) (string, error) {
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(bodyBytes), nil
}
func (OwlHtmlParser) ParseHEntry(resp *http.Response) (ParsedHEntry, error) {
htmlStr, err := readResponseBody(resp)
if err != nil {
return ParsedHEntry{}, err
}
doc, err := html.Parse(strings.NewReader(htmlStr))
if err != nil {
return ParsedHEntry{}, err
}
var interpretHFeed func(*html.Node, *ParsedHEntry, bool) (ParsedHEntry, error)
interpretHFeed = func(n *html.Node, curr *ParsedHEntry, parent bool) (ParsedHEntry, error) {
attrs := n.Attr
for _, attr := range attrs {
if attr.Key == "class" && strings.Contains(attr.Val, "p-name") {
buf := &bytes.Buffer{}
collectText(n, buf)
curr.Title = buf.String()
return *curr, nil
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
interpretHFeed(c, curr, false)
}
return *curr, nil
}
var findHFeed func(*html.Node) (ParsedHEntry, error)
findHFeed = func(n *html.Node) (ParsedHEntry, error) {
attrs := n.Attr
for _, attr := range attrs {
if attr.Key == "class" && strings.Contains(attr.Val, "h-entry") {
return interpretHFeed(n, &ParsedHEntry{}, true)
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
entry, err := findHFeed(c)
if err == nil {
return entry, nil
}
}
return ParsedHEntry{}, errors.New("no h-entry found")
}
return findHFeed(doc)
}
func (OwlHtmlParser) ParseLinks(resp *http.Response) ([]string, error) {
htmlStr, err := readResponseBody(resp)
if err != nil {
return []string{}, err
}
return OwlHtmlParser{}.ParseLinksFromString(htmlStr)
}
func (OwlHtmlParser) ParseLinksFromString(htmlStr string) ([]string, error) {
doc, err := html.Parse(strings.NewReader(htmlStr))
if err != nil {
return make([]string, 0), err
}
var findLinks func(*html.Node) ([]string, error)
findLinks = func(n *html.Node) ([]string, error) {
links := make([]string, 0)
if n.Type == html.ElementNode && n.Data == "a" {
for _, attr := range n.Attr {
if attr.Key == "href" {
links = append(links, attr.Val)
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
childLinks, _ := findLinks(c)
links = append(links, childLinks...)
}
return links, nil
}
return findLinks(doc)
}
func (OwlHtmlParser) GetWebmentionEndpoint(resp *http.Response) (string, error) {
//request url
requestUrl := resp.Request.URL
// Check link headers
for _, linkHeader := range resp.Header["Link"] {
linkHeaderParts := strings.Split(linkHeader, ",")
for _, linkHeaderPart := range linkHeaderParts {
linkHeaderPart = strings.TrimSpace(linkHeaderPart)
params := strings.Split(linkHeaderPart, ";")
if len(params) != 2 {
continue
}
for _, param := range params[1:] {
param = strings.TrimSpace(param)
if strings.Contains(param, "webmention") {
link := strings.Split(params[0], ";")[0]
link = strings.Trim(link, "<>")
linkUrl, err := url.Parse(link)
if err != nil {
return "", err
}
return requestUrl.ResolveReference(linkUrl).String(), nil
}
}
}
}
htmlStr, err := readResponseBody(resp)
if err != nil {
return "", err
}
doc, err := html.Parse(strings.NewReader(htmlStr))
if err != nil {
return "", err
}
var findEndpoint func(*html.Node) (string, error)
findEndpoint = func(n *html.Node) (string, error) {
if n.Type == html.ElementNode && (n.Data == "link" || n.Data == "a") {
for _, attr := range n.Attr {
if attr.Key == "rel" {
vals := strings.Split(attr.Val, " ")
for _, val := range vals {
if val == "webmention" {
for _, attr := range n.Attr {
if attr.Key == "href" {
return attr.Val, nil
}
}
}
}
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
endpoint, err := findEndpoint(c)
if err == nil {
return endpoint, nil
}
}
return "", errors.New("no webmention endpoint found")
}
linkUrlStr, err := findEndpoint(doc)
if err != nil {
return "", err
}
linkUrl, err := url.Parse(linkUrlStr)
if err != nil {
return "", err
}
return requestUrl.ResolveReference(linkUrl).String(), nil
}
func (OwlHtmlParser) GetRedirctUris(resp *http.Response) ([]string, error) {
//request url
requestUrl := resp.Request.URL
htmlStr, err := readResponseBody(resp)
if err != nil {
return make([]string, 0), err
}
doc, err := html.Parse(strings.NewReader(htmlStr))
if err != nil {
return make([]string, 0), err
}
var findLinks func(*html.Node) ([]string, error)
// Check link headers
header_links := make([]string, 0)
for _, linkHeader := range resp.Header["Link"] {
linkHeaderParts := strings.Split(linkHeader, ",")
for _, linkHeaderPart := range linkHeaderParts {
linkHeaderPart = strings.TrimSpace(linkHeaderPart)
params := strings.Split(linkHeaderPart, ";")
if len(params) != 2 {
continue
}
for _, param := range params[1:] {
param = strings.TrimSpace(param)
if strings.Contains(param, "redirect_uri") {
link := strings.Split(params[0], ";")[0]
link = strings.Trim(link, "<>")
linkUrl, err := url.Parse(link)
if err == nil {
header_links = append(header_links, requestUrl.ResolveReference(linkUrl).String())
}
}
}
}
}
findLinks = func(n *html.Node) ([]string, error) {
links := make([]string, 0)
if n.Type == html.ElementNode && n.Data == "link" {
// check for rel="redirect_uri"
rel := ""
href := ""
for _, attr := range n.Attr {
if attr.Key == "href" {
href = attr.Val
}
if attr.Key == "rel" {
rel = attr.Val
}
}
if rel == "redirect_uri" {
linkUrl, err := url.Parse(href)
if err == nil {
links = append(links, requestUrl.ResolveReference(linkUrl).String())
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
childLinks, _ := findLinks(c)
links = append(links, childLinks...)
}
return links, nil
}
body_links, err := findLinks(doc)
return append(body_links, header_links...), err
}

15
http.go
View File

@ -1,15 +0,0 @@
package owl
import (
"io"
"net/http"
"net/url"
)
type HttpClient interface {
Get(url string) (resp *http.Response, err error)
Post(url, contentType string, body io.Reader) (resp *http.Response, err error)
PostForm(url string, data url.Values) (resp *http.Response, err error)
}
type OwlHttpClient = http.Client

View File

@ -1,45 +0,0 @@
package owl_test
import (
"h4kor/owl-blogs"
"math/rand"
"time"
)
func randomName() string {
rand.Seed(time.Now().UnixNano())
var letters = []rune("abcdefghijklmnopqrstuvwxyz")
b := make([]rune, 8)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
func testRepoName() string {
return "/tmp/" + randomName()
}
func randomUserName() string {
return randomName()
}
func getTestUser() owl.User {
repo, _ := owl.CreateRepository(testRepoName(), owl.RepoConfig{})
user, _ := repo.CreateUser(randomUserName())
return user
}
func getTestRepo(config owl.RepoConfig) owl.Repository {
repo, _ := owl.CreateRepository(testRepoName(), config)
return repo
}
func contains(s []string, e string) bool {
for _, a := range s {
if a == e {
return true
}
}
return false
}

478
post.go
View File

@ -1,478 +0,0 @@
package owl
import (
"bytes"
"errors"
"net/url"
"os"
"path"
"sort"
"sync"
"time"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"gopkg.in/yaml.v2"
)
type GenericPost struct {
user *User
id string
metaLoaded bool
meta PostMeta
wmLock sync.Mutex
}
func (post *GenericPost) TemplateDir() string {
return post.Meta().Type
}
type Post interface {
TemplateDir() string
// Actual Data
User() *User
Id() string
Title() string
Meta() PostMeta
Content() []byte
RenderedContent() string
Aliases() []string
// Filesystem
Dir() string
MediaDir() string
ContentFile() string
// Urls
UrlPath() string
FullUrl() string
UrlMediaPath(filename string) string
// Webmentions Support
IncomingWebmentions() []WebmentionIn
OutgoingWebmentions() []WebmentionOut
PersistIncomingWebmention(webmention WebmentionIn) error
PersistOutgoingWebmention(webmention *WebmentionOut) error
AddIncomingWebmention(source string) error
EnrichWebmention(webmention WebmentionIn) error
ApprovedIncomingWebmentions() []WebmentionIn
ScanForLinks() error
SendWebmention(webmention WebmentionOut) error
}
type ReplyData struct {
Url string `yaml:"url"`
Text string `yaml:"text"`
}
type BookmarkData struct {
Url string `yaml:"url"`
Text string `yaml:"text"`
}
type RecipeData struct {
Yield string `yaml:"yield"`
Duration string `yaml:"duration"`
Ingredients []string `yaml:"ingredients"`
}
type PostMeta struct {
Type string `yaml:"type"`
Title string `yaml:"title"`
Description string `yaml:"description"`
Aliases []string `yaml:"aliases"`
Date time.Time `yaml:"date"`
Draft bool `yaml:"draft"`
Reply ReplyData `yaml:"reply"`
Bookmark BookmarkData `yaml:"bookmark"`
Recipe RecipeData `yaml:"recipe"`
PhotoPath string `yaml:"photo"`
}
func (pm PostMeta) FormattedDate() string {
return pm.Date.Format("02-01-2006 15:04:05")
}
func (pm *PostMeta) UnmarshalYAML(unmarshal func(interface{}) error) error {
type T struct {
Type string `yaml:"type"`
Title string `yaml:"title"`
Description string `yaml:"description"`
Aliases []string `yaml:"aliases"`
Draft bool `yaml:"draft"`
Reply ReplyData `yaml:"reply"`
Bookmark BookmarkData `yaml:"bookmark"`
Recipe RecipeData `yaml:"recipe"`
PhotoPath string `yaml:"photo"`
}
type S struct {
Date string `yaml:"date"`
}
var t T
var s S
if err := unmarshal(&t); err != nil {
return err
}
if err := unmarshal(&s); err != nil {
return err
}
pm.Type = t.Type
if pm.Type == "" {
pm.Type = "article"
}
pm.Title = t.Title
pm.Description = t.Description
pm.Aliases = t.Aliases
pm.Draft = t.Draft
pm.Reply = t.Reply
pm.Bookmark = t.Bookmark
pm.Recipe = t.Recipe
pm.PhotoPath = t.PhotoPath
possibleFormats := []string{
"2006-01-02",
time.Layout,
time.ANSIC,
time.UnixDate,
time.RubyDate,
time.RFC822,
time.RFC822Z,
time.RFC850,
time.RFC1123,
time.RFC1123Z,
time.RFC3339,
time.RFC3339Nano,
time.Stamp,
time.StampMilli,
time.StampMicro,
time.StampNano,
}
for _, format := range possibleFormats {
if t, err := time.Parse(format, s.Date); err == nil {
pm.Date = t
break
}
}
return nil
}
type PostWebmetions struct {
Incoming []WebmentionIn `ymal:"incoming"`
Outgoing []WebmentionOut `ymal:"outgoing"`
}
func (post *GenericPost) Id() string {
return post.id
}
func (post *GenericPost) User() *User {
return post.user
}
func (post *GenericPost) Dir() string {
return path.Join(post.user.Dir(), "public", post.id)
}
func (post *GenericPost) IncomingWebmentionsFile() string {
return path.Join(post.Dir(), "incoming_webmentions.yml")
}
func (post *GenericPost) OutgoingWebmentionsFile() string {
return path.Join(post.Dir(), "outgoing_webmentions.yml")
}
func (post *GenericPost) MediaDir() string {
return path.Join(post.Dir(), "media")
}
func (post *GenericPost) UrlPath() string {
return post.user.UrlPath() + "posts/" + post.id + "/"
}
func (post *GenericPost) FullUrl() string {
return post.user.FullUrl() + "posts/" + post.id + "/"
}
func (post *GenericPost) UrlMediaPath(filename string) string {
return post.UrlPath() + "media/" + filename
}
func (post *GenericPost) Title() string {
return post.Meta().Title
}
func (post *GenericPost) ContentFile() string {
return path.Join(post.Dir(), "index.md")
}
func (post *GenericPost) Meta() PostMeta {
if !post.metaLoaded {
post.LoadMeta()
}
return post.meta
}
func (post *GenericPost) Content() []byte {
// read file
data, _ := os.ReadFile(post.ContentFile())
return data
}
func (post *GenericPost) RenderedContent() string {
data := post.Content()
// trim yaml block
// TODO this can be done nicer
trimmedData := bytes.TrimSpace(data)
// ensure that data ends with a newline
trimmedData = append(trimmedData, []byte("\n")...)
// check first line is ---
if string(trimmedData[0:4]) == "---\n" {
trimmedData = trimmedData[4:]
// find --- end
end := bytes.Index(trimmedData, []byte("\n---\n"))
if end != -1 {
data = trimmedData[end+5:]
}
}
options := goldmark.WithRendererOptions()
if config, _ := post.user.repo.Config(); config.AllowRawHtml {
options = goldmark.WithRendererOptions(
html.WithUnsafe(),
)
}
markdown := goldmark.New(
options,
goldmark.WithExtensions(
// meta.Meta,
extension.GFM,
),
)
var buf bytes.Buffer
context := parser.NewContext()
if err := markdown.Convert(data, &buf, parser.WithContext(context)); err != nil {
panic(err)
}
return buf.String()
}
func (post *GenericPost) Aliases() []string {
return post.Meta().Aliases
}
func (post *GenericPost) LoadMeta() error {
data := post.Content()
// get yaml metadata block
meta := PostMeta{}
trimmedData := bytes.TrimSpace(data)
// ensure that data ends with a newline
trimmedData = append(trimmedData, []byte("\n")...)
// check first line is ---
if string(trimmedData[0:4]) == "---\n" {
trimmedData = trimmedData[4:]
// find --- end
end := bytes.Index(trimmedData, []byte("---\n"))
if end != -1 {
metaData := trimmedData[:end]
err := yaml.Unmarshal(metaData, &meta)
if err != nil {
return err
}
}
}
post.meta = meta
return nil
}
func (post *GenericPost) IncomingWebmentions() []WebmentionIn {
// return parsed webmentions
fileName := post.IncomingWebmentionsFile()
if !fileExists(fileName) {
return []WebmentionIn{}
}
webmentions := []WebmentionIn{}
loadFromYaml(fileName, &webmentions)
return webmentions
}
func (post *GenericPost) OutgoingWebmentions() []WebmentionOut {
// return parsed webmentions
fileName := post.OutgoingWebmentionsFile()
if !fileExists(fileName) {
return []WebmentionOut{}
}
webmentions := []WebmentionOut{}
loadFromYaml(fileName, &webmentions)
return webmentions
}
// PersistWebmentionOutgoing persists incoming webmention
func (post *GenericPost) PersistIncomingWebmention(webmention WebmentionIn) error {
post.wmLock.Lock()
defer post.wmLock.Unlock()
wms := post.IncomingWebmentions()
// if target is not in status, add it
replaced := false
for i, t := range wms {
if t.Source == webmention.Source {
wms[i].UpdateWith(webmention)
replaced = true
break
}
}
if !replaced {
wms = append(wms, webmention)
}
err := saveToYaml(post.IncomingWebmentionsFile(), wms)
if err != nil {
return err
}
return nil
}
// PersistOutgoingWebmention persists a webmention to the webmention file.
func (post *GenericPost) PersistOutgoingWebmention(webmention *WebmentionOut) error {
post.wmLock.Lock()
defer post.wmLock.Unlock()
wms := post.OutgoingWebmentions()
// if target is not in webmention, add it
replaced := false
for i, t := range wms {
if t.Target == webmention.Target {
wms[i].UpdateWith(*webmention)
replaced = true
break
}
}
if !replaced {
wms = append(wms, *webmention)
}
err := saveToYaml(post.OutgoingWebmentionsFile(), wms)
if err != nil {
return err
}
return nil
}
func (post *GenericPost) AddIncomingWebmention(source string) error {
// Check if file already exists
wm := WebmentionIn{
Source: source,
}
defer func() {
go post.EnrichWebmention(wm)
}()
return post.PersistIncomingWebmention(wm)
}
func (post *GenericPost) EnrichWebmention(webmention WebmentionIn) error {
resp, err := post.user.repo.HttpClient.Get(webmention.Source)
if err == nil {
entry, err := post.user.repo.Parser.ParseHEntry(resp)
if err == nil {
webmention.Title = entry.Title
return post.PersistIncomingWebmention(webmention)
}
}
return err
}
func (post *GenericPost) ApprovedIncomingWebmentions() []WebmentionIn {
webmentions := post.IncomingWebmentions()
approved := []WebmentionIn{}
for _, webmention := range webmentions {
if webmention.ApprovalStatus == "approved" {
approved = append(approved, webmention)
}
}
// sort by retrieved date
sort.Slice(approved, func(i, j int) bool {
return approved[i].RetrievedAt.After(approved[j].RetrievedAt)
})
return approved
}
// ScanForLinks scans the post content for links and adds them to the
// `status.yml` file for the post. The links are not scanned by this function.
func (post *GenericPost) ScanForLinks() error {
// this could be done in markdown parsing, but I don't want to
// rely on goldmark for this (yet)
postHtml := post.RenderedContent()
links, _ := post.user.repo.Parser.ParseLinksFromString(postHtml)
// add reply url if set
if post.Meta().Reply.Url != "" {
links = append(links, post.Meta().Reply.Url)
}
for _, link := range links {
post.PersistOutgoingWebmention(&WebmentionOut{
Target: link,
})
}
return nil
}
func (post *GenericPost) SendWebmention(webmention WebmentionOut) error {
defer post.PersistOutgoingWebmention(&webmention)
// if last scan is less than 7 days ago, don't send webmention
if webmention.ScannedAt.After(time.Now().Add(-7*24*time.Hour)) && !webmention.Supported {
return errors.New("did not scan. Last scan was less than 7 days ago")
}
webmention.ScannedAt = time.Now()
resp, err := post.user.repo.HttpClient.Get(webmention.Target)
if err != nil {
webmention.Supported = false
return err
}
endpoint, err := post.user.repo.Parser.GetWebmentionEndpoint(resp)
if err != nil {
webmention.Supported = false
return err
}
webmention.Supported = true
// send webmention
payload := url.Values{}
payload.Set("source", post.FullUrl())
payload.Set("target", webmention.Target)
_, err = post.user.repo.HttpClient.PostForm(endpoint, payload)
if err != nil {
return err
}
// update webmention status
webmention.LastSentAt = time.Now()
return nil
}

View File

@ -1,531 +0,0 @@
package owl_test
import (
"h4kor/owl-blogs"
"h4kor/owl-blogs/test/assertions"
"h4kor/owl-blogs/test/mocks"
"os"
"path"
"strconv"
"sync"
"testing"
"time"
)
func TestCanGetPostTitle(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
result := post.Title()
assertions.AssertEqual(t, result, "testpost")
}
func TestMediaDir(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
result := post.MediaDir()
assertions.AssertEqual(t, result, path.Join(post.Dir(), "media"))
}
func TestPostUrlPath(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
expected := "/user/" + user.Name() + "/posts/" + post.Id() + "/"
assertions.AssertEqual(t, post.UrlPath(), expected)
}
func TestPostFullUrl(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
expected := "http://localhost:8080/user/" + user.Name() + "/posts/" + post.Id() + "/"
assertions.AssertEqual(t, post.FullUrl(), expected)
}
func TestPostUrlMediaPath(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
expected := "/user/" + user.Name() + "/posts/" + post.Id() + "/media/data.png"
assertions.AssertEqual(t, post.UrlMediaPath("data.png"), expected)
}
func TestPostUrlMediaPathWithSubDir(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
expected := "/user/" + user.Name() + "/posts/" + post.Id() + "/media/foo/data.png"
assertions.AssertEqual(t, post.UrlMediaPath("foo/data.png"), expected)
}
func TestDraftInMetaData(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
content := "---\n"
content += "title: test\n"
content += "draft: true\n"
content += "---\n"
content += "\n"
content += "Write your post here.\n"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
meta := post.Meta()
assertions.AssertEqual(t, meta.Draft, true)
}
func TestNoRawHTMLIfDisallowedByRepo(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
content := "---\n"
content += "title: test\n"
content += "draft: true\n"
content += "---\n"
content += "\n"
content += "<script>alert('foo')</script>\n"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
html := post.RenderedContent()
assertions.AssertNotContains(t, html, "<script>")
}
func TestRawHTMLIfAllowedByRepo(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{AllowRawHtml: true})
user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
content := "---\n"
content += "title: test\n"
content += "draft: true\n"
content += "---\n"
content += "\n"
content += "<script>alert('foo')</script>\n"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
html := post.RenderedContent()
assertions.AssertContains(t, html, "<script>")
}
func TestMeta(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{AllowRawHtml: true})
user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
content := "---\n"
content += "title: test\n"
content += "draft: true\n"
content += "date: Wed, 17 Aug 2022 10:50:02 +0000\n"
content += "aliases:\n"
content += " - foo/bar/\n"
content += "---\n"
content += "\n"
content += "<script>alert('foo')</script>\n"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
assertions.AssertEqual(t, post.Meta().Title, "test")
assertions.AssertLen(t, post.Meta().Aliases, 1)
assertions.AssertEqual(t, post.Meta().Draft, true)
assertions.AssertEqual(t, post.Meta().Date.Format(time.RFC1123Z), "Wed, 17 Aug 2022 10:50:02 +0000")
assertions.AssertEqual(t, post.Meta().Draft, true)
}
///
/// Webmention
///
func TestPersistIncomingWebmention(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
webmention := owl.WebmentionIn{
Source: "http://example.com/source",
}
err := post.PersistIncomingWebmention(webmention)
assertions.AssertNoError(t, err, "Error persisting webmention")
mentions := post.IncomingWebmentions()
assertions.AssertLen(t, mentions, 1)
assertions.AssertEqual(t, mentions[0].Source, webmention.Source)
}
func TestAddIncomingWebmentionCreatesFile(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &mocks.MockHtmlParser{}
user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
err := post.AddIncomingWebmention("https://example.com")
assertions.AssertNoError(t, err, "Error adding webmention")
mentions := post.IncomingWebmentions()
assertions.AssertLen(t, mentions, 1)
}
func TestAddIncomingWebmentionNotOverwritingWebmention(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &mocks.MockHtmlParser{}
user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
post.PersistIncomingWebmention(owl.WebmentionIn{
Source: "https://example.com",
ApprovalStatus: "approved",
})
post.AddIncomingWebmention("https://example.com")
mentions := post.IncomingWebmentions()
assertions.AssertLen(t, mentions, 1)
assertions.AssertEqual(t, mentions[0].ApprovalStatus, "approved")
}
func TestEnrichAddsTitle(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &mocks.MockHtmlParser{}
user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
post.AddIncomingWebmention("https://example.com")
post.EnrichWebmention(owl.WebmentionIn{Source: "https://example.com"})
mentions := post.IncomingWebmentions()
assertions.AssertLen(t, mentions, 1)
assertions.AssertEqual(t, mentions[0].Title, "Mock Title")
}
func TestApprovedIncomingWebmentions(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
webmention := owl.WebmentionIn{
Source: "http://example.com/source",
ApprovalStatus: "approved",
RetrievedAt: time.Now(),
}
post.PersistIncomingWebmention(webmention)
webmention = owl.WebmentionIn{
Source: "http://example.com/source2",
ApprovalStatus: "",
RetrievedAt: time.Now().Add(time.Hour * -1),
}
post.PersistIncomingWebmention(webmention)
webmention = owl.WebmentionIn{
Source: "http://example.com/source3",
ApprovalStatus: "approved",
RetrievedAt: time.Now().Add(time.Hour * -2),
}
post.PersistIncomingWebmention(webmention)
webmention = owl.WebmentionIn{
Source: "http://example.com/source4",
ApprovalStatus: "rejected",
RetrievedAt: time.Now().Add(time.Hour * -3),
}
post.PersistIncomingWebmention(webmention)
webmentions := post.ApprovedIncomingWebmentions()
assertions.AssertLen(t, webmentions, 2)
assertions.AssertEqual(t, webmentions[0].Source, "http://example.com/source")
assertions.AssertEqual(t, webmentions[1].Source, "http://example.com/source3")
}
func TestScanningForLinks(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
content := "---\n"
content += "title: test\n"
content += "date: Wed, 17 Aug 2022 10:50:02 +0000\n"
content += "---\n"
content += "\n"
content += "[Hello](https://example.com/hello)\n"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
post.ScanForLinks()
webmentions := post.OutgoingWebmentions()
assertions.AssertLen(t, webmentions, 1)
assertions.AssertEqual(t, webmentions[0].Target, "https://example.com/hello")
}
func TestScanningForLinksDoesNotAddDuplicates(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
content := "---\n"
content += "title: test\n"
content += "date: Wed, 17 Aug 2022 10:50:02 +0000\n"
content += "---\n"
content += "\n"
content += "[Hello](https://example.com/hello)\n"
content += "[Hello](https://example.com/hello)\n"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
post.ScanForLinks()
post.ScanForLinks()
post.ScanForLinks()
webmentions := post.OutgoingWebmentions()
assertions.AssertLen(t, webmentions, 1)
assertions.AssertEqual(t, webmentions[0].Target, "https://example.com/hello")
}
func TestScanningForLinksDoesAddReplyUrl(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
content := "---\n"
content += "title: test\n"
content += "date: Wed, 17 Aug 2022 10:50:02 +0000\n"
content += "reply:\n"
content += " url: https://example.com/reply\n"
content += "---\n"
content += "\n"
content += "Hi\n"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
post.ScanForLinks()
webmentions := post.OutgoingWebmentions()
assertions.AssertLen(t, webmentions, 1)
assertions.AssertEqual(t, webmentions[0].Target, "https://example.com/reply")
}
func TestCanSendWebmention(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &mocks.MockHtmlParser{}
user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
webmention := owl.WebmentionOut{
Target: "http://example.com",
}
err := post.SendWebmention(webmention)
assertions.AssertNoError(t, err, "Error sending webmention")
webmentions := post.OutgoingWebmentions()
assertions.AssertLen(t, webmentions, 1)
assertions.AssertEqual(t, webmentions[0].Target, "http://example.com")
assertions.AssertEqual(t, webmentions[0].LastSentAt.IsZero(), false)
}
func TestSendWebmentionOnlyScansOncePerWeek(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &mocks.MockHtmlParser{}
user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
webmention := owl.WebmentionOut{
Target: "http://example.com",
ScannedAt: time.Now().Add(time.Hour * -24 * 6),
}
post.PersistOutgoingWebmention(&webmention)
webmentions := post.OutgoingWebmentions()
webmention = webmentions[0]
err := post.SendWebmention(webmention)
assertions.AssertError(t, err, "Expected error, got nil")
webmentions = post.OutgoingWebmentions()
assertions.AssertLen(t, webmentions, 1)
assertions.AssertEqual(t, webmentions[0].ScannedAt, webmention.ScannedAt)
}
func TestSendingMultipleWebmentions(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &mocks.MockHtmlParser{}
user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
wg := sync.WaitGroup{}
wg.Add(20)
for i := 0; i < 20; i++ {
go func(k int) {
webmention := owl.WebmentionOut{
Target: "http://example.com" + strconv.Itoa(k),
}
post.SendWebmention(webmention)
wg.Done()
}(i)
}
wg.Wait()
webmentions := post.OutgoingWebmentions()
assertions.AssertLen(t, webmentions, 20)
}
func TestReceivingMultipleWebmentions(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &mocks.MockHtmlParser{}
user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
wg := sync.WaitGroup{}
wg.Add(20)
for i := 0; i < 20; i++ {
go func(k int) {
post.AddIncomingWebmention("http://example.com" + strconv.Itoa(k))
wg.Done()
}(i)
}
wg.Wait()
webmentions := post.IncomingWebmentions()
assertions.AssertLen(t, webmentions, 20)
}
func TestSendingAndReceivingMultipleWebmentions(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &mocks.MockHtmlParser{}
user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
wg := sync.WaitGroup{}
wg.Add(40)
for i := 0; i < 20; i++ {
go func(k int) {
post.AddIncomingWebmention("http://example.com" + strconv.Itoa(k))
wg.Done()
}(i)
go func(k int) {
webmention := owl.WebmentionOut{
Target: "http://example.com" + strconv.Itoa(k),
}
post.SendWebmention(webmention)
wg.Done()
}(i)
}
wg.Wait()
ins := post.IncomingWebmentions()
outs := post.OutgoingWebmentions()
assertions.AssertLen(t, ins, 20)
assertions.AssertLen(t, outs, 20)
}
func TestComplexParallelWebmentions(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &mocks.MockParseLinksHtmlParser{
Links: []string{
"http://example.com/1",
"http://example.com/2",
"http://example.com/3",
},
}
user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
wg := sync.WaitGroup{}
wg.Add(60)
for i := 0; i < 20; i++ {
go func(k int) {
post.AddIncomingWebmention("http://example.com/" + strconv.Itoa(k))
wg.Done()
}(i)
go func(k int) {
webmention := owl.WebmentionOut{
Target: "http://example.com/" + strconv.Itoa(k),
}
post.SendWebmention(webmention)
wg.Done()
}(i)
go func() {
post.ScanForLinks()
wg.Done()
}()
}
wg.Wait()
ins := post.IncomingWebmentions()
outs := post.OutgoingWebmentions()
assertions.AssertLen(t, ins, 20)
assertions.AssertLen(t, outs, 20)
}
func TestPostWithoutContent(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost(owl.PostMeta{}, "")
result := post.RenderedContent()
assertions.AssertEqual(t, result, "")
}
// func TestComplexParallelSimulatedProcessesWebmentions(t *testing.T) {
// repoName := testRepoName()
// repo, _ := owl.CreateRepository(repoName, owl.RepoConfig{})
// repo.HttpClient = &mocks.MockHttpClient{}
// repo.Parser = &MockParseLinksHtmlParser{
// Links: []string{
// "http://example.com/1",
// "http://example.com/2",
// "http://example.com/3",
// },
// }
// user, _ := repo.CreateUser("testuser")
// post, _ := user.CreateNewPostFull(owl.PostMeta{Type: "article", Title: "testpost"}, "")
// wg := sync.WaitGroup{}
// wg.Add(40)
// for i := 0; i < 20; i++ {
// go func(k int) {
// defer wg.Done()
// fRepo, _ := owl.OpenRepository(repoName)
// fUser, _ := fRepo.GetUser("testuser")
// fPost, err := fUser.GetPost(post.Id())
// if err != nil {
// t.Error(err)
// return
// }
// fPost.AddIncomingWebmention("http://example.com/" + strconv.Itoa(k))
// }(i)
// go func(k int) {
// defer wg.Done()
// fRepo, _ := owl.OpenRepository(repoName)
// fUser, _ := fRepo.GetUser("testuser")
// fPost, err := fUser.GetPost(post.Id())
// if err != nil {
// t.Error(err)
// return
// }
// webmention := owl.WebmentionOut{
// Target: "http://example.com/" + strconv.Itoa(k),
// }
// fPost.SendWebmention(webmention)
// }(i)
// }
// wg.Wait()
// ins := post.IncomingWebmentions()
// if len(ins) != 20 {
// t.Errorf("Expected 20 webmentions, got %d", len(ins))
// }
// outs := post.OutgoingWebmentions()
// if len(outs) != 20 {
// t.Errorf("Expected 20 webmentions, got %d", len(outs))
// }
// }

View File

@ -1,2 +0,0 @@
docker build . -t git.libove.org/h4kor/owl-blogs:$1
docker push git.libove.org/h4kor/owl-blogs:$1

View File

@ -1,254 +0,0 @@
package owl
import (
"bytes"
_ "embed"
"fmt"
"html/template"
"strings"
)
type PageContent struct {
Title string
Description string
Content template.HTML
Type string
SelfUrl string
}
type PostRenderData struct {
Title string
Post Post
Content template.HTML
}
type AuthRequestData struct {
Me string
ClientId string
RedirectUri string
State string
Scope string
Scopes []string // Split version of scope. filled by rendering function.
ResponseType string
CodeChallenge string
CodeChallengeMethod string
User User
CsrfToken string
}
type EditorViewData struct {
User User
Error string
CsrfToken string
}
type ErrorMessage struct {
Error string
Message string
}
func noescape(str string) template.HTML {
return template.HTML(str)
}
func listUrl(user User, id string) string {
return user.ListUrl(PostList{
Id: id,
})
}
func postUrl(user User, id string) string {
post, _ := user.GetPost(id)
return post.UrlPath()
}
func renderEmbedTemplate(templateFile string, data interface{}) (string, error) {
templateStr, err := embed_files.ReadFile(templateFile)
if err != nil {
return "", err
}
return renderTemplateStr(templateStr, data)
}
func renderTemplateStr(templateStr []byte, data interface{}) (string, error) {
t, err := template.New("_").Funcs(template.FuncMap{
"noescape": noescape,
"listUrl": listUrl,
"postUrl": postUrl,
}).Parse(string(templateStr))
if err != nil {
return "", err
}
var html bytes.Buffer
err = t.Execute(&html, data)
if err != nil {
return "", err
}
return html.String(), nil
}
func renderIntoBaseTemplate(user User, data PageContent) (string, error) {
baseTemplate, _ := user.Template()
t, err := template.New("index").Funcs(template.FuncMap{
"noescape": noescape,
"listUrl": listUrl,
"postUrl": postUrl,
}).Parse(baseTemplate)
if err != nil {
return "", err
}
full_data := struct {
Title string
Description string
Content template.HTML
Type string
SelfUrl string
User User
}{
Title: data.Title,
Description: data.Description,
Content: data.Content,
Type: data.Type,
SelfUrl: data.SelfUrl,
User: user,
}
var html bytes.Buffer
err = t.Execute(&html, full_data)
return html.String(), err
}
func renderPostContent(post Post) (string, error) {
buf := post.RenderedContent()
postHtml, err := renderEmbedTemplate(
fmt.Sprintf("embed/%s/detail.html", post.TemplateDir()),
PostRenderData{
Title: post.Title(),
Post: post,
Content: template.HTML(buf),
},
)
return postHtml, err
}
func RenderPost(post Post) (string, error) {
postHtml, err := renderPostContent(post)
if err != nil {
return "", err
}
return renderIntoBaseTemplate(*post.User(), PageContent{
Title: post.Title(),
Description: post.Meta().Description,
Content: template.HTML(postHtml),
Type: "article",
SelfUrl: post.FullUrl(),
})
}
func RenderIndexPage(user User) (string, error) {
posts, _ := user.PrimaryFeedPosts()
postHtml, err := renderEmbedTemplate("embed/post-list.html", posts)
if err != nil {
return "", err
}
return renderIntoBaseTemplate(user, PageContent{
Title: "Index",
Content: template.HTML(postHtml),
})
}
func RenderPostList(user User, list *PostList) (string, error) {
posts, _ := user.GetPostsOfList(*list)
var postHtml string
var err error
if list.ListType == "photo" {
postHtml, err = renderEmbedTemplate("embed/post-list-photo.html", posts)
} else {
postHtml, err = renderEmbedTemplate("embed/post-list.html", posts)
}
if err != nil {
return "", err
}
return renderIntoBaseTemplate(user, PageContent{
Title: "Index",
Content: template.HTML(postHtml),
})
}
func RenderUserAuthPage(reqData AuthRequestData) (string, error) {
reqData.Scopes = strings.Split(reqData.Scope, " ")
authHtml, err := renderEmbedTemplate("embed/auth.html", reqData)
if err != nil {
return "", err
}
return renderIntoBaseTemplate(reqData.User, PageContent{
Title: "Auth",
Content: template.HTML(authHtml),
})
}
func RenderUserError(user User, error ErrorMessage) (string, error) {
errHtml, err := renderEmbedTemplate("embed/error.html", error)
if err != nil {
return "", err
}
return renderIntoBaseTemplate(user, PageContent{
Title: "Error",
Content: template.HTML(errHtml),
})
}
func RenderUserList(repo Repository) (string, error) {
baseTemplate, _ := repo.Template()
users, _ := repo.Users()
userHtml, err := renderEmbedTemplate("embed/user-list.html", users)
if err != nil {
return "", err
}
data := PageContent{
Title: "Index",
Content: template.HTML(userHtml),
}
return renderTemplateStr([]byte(baseTemplate), data)
}
func RenderLoginPage(user User, error_type string, csrfToken string) (string, error) {
loginHtml, err := renderEmbedTemplate("embed/editor/login.html", EditorViewData{
User: user,
Error: error_type,
CsrfToken: csrfToken,
})
if err != nil {
return "", err
}
return renderIntoBaseTemplate(user, PageContent{
Title: "Login",
Content: template.HTML(loginHtml),
})
}
func RenderEditorPage(user User, csrfToken string) (string, error) {
editorHtml, err := renderEmbedTemplate("embed/editor/editor.html", EditorViewData{
User: user,
CsrfToken: csrfToken,
})
if err != nil {
return "", err
}
return renderIntoBaseTemplate(user, PageContent{
Title: "Editor",
Content: template.HTML(editorHtml),
})
}

View File

@ -1,505 +0,0 @@
package owl_test
import (
"h4kor/owl-blogs"
"h4kor/owl-blogs/test/assertions"
"os"
"path"
"testing"
"time"
)
func TestCanRenderPost(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
result, err := owl.RenderPost(post)
assertions.AssertNoError(t, err, "Error rendering post")
assertions.AssertContains(t, result, "testpost")
}
func TestRenderOneMe(t *testing.T) {
user := getTestUser()
config := user.Config()
config.Me = append(config.Me, owl.UserMe{
Name: "Twitter",
Url: "https://twitter.com/testhandle",
})
user.SetConfig(config)
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
result, err := owl.RenderPost(post)
assertions.AssertNoError(t, err, "Error rendering post")
assertions.AssertContains(t, result, "href=\"https://twitter.com/testhandle\" rel=\"me\"")
}
func TestRenderTwoMe(t *testing.T) {
user := getTestUser()
config := user.Config()
config.Me = append(config.Me, owl.UserMe{
Name: "Twitter",
Url: "https://twitter.com/testhandle",
})
config.Me = append(config.Me, owl.UserMe{
Name: "Github",
Url: "https://github.com/testhandle",
})
user.SetConfig(config)
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
result, err := owl.RenderPost(post)
assertions.AssertNoError(t, err, "Error rendering post")
assertions.AssertContains(t, result, "href=\"https://twitter.com/testhandle\" rel=\"me\"")
assertions.AssertContains(t, result, "href=\"https://github.com/testhandle\" rel=\"me\"")
}
func TestRenderPostHEntry(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
result, _ := owl.RenderPost(post)
assertions.AssertContains(t, result, "class=\"h-entry\"")
assertions.AssertContains(t, result, "class=\"p-name\"")
assertions.AssertContains(t, result, "class=\"e-content\"")
}
func TestRendererUsesBaseTemplate(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
result, err := owl.RenderPost(post)
assertions.AssertNoError(t, err, "Error rendering post")
assertions.AssertContains(t, result, "<html")
}
func TestCanRenderPostList(t *testing.T) {
user := getTestUser()
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost1"}, "")
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost2"}, "")
result, err := owl.RenderPostList(user, &owl.PostList{
Id: "testlist",
Title: "Test List",
Include: []string{
"article",
},
})
assertions.AssertNoError(t, err, "Error rendering post list")
assertions.AssertContains(t, result, "testpost1")
assertions.AssertContains(t, result, "testpost2")
}
func TestCanRenderPostListNotIncludeOther(t *testing.T) {
user := getTestUser()
user.CreateNewPost(owl.PostMeta{Title: "testpost1", Type: "article"}, "testpost1")
user.CreateNewPost(owl.PostMeta{Title: "testpost2", Type: "note"}, "testpost2")
result, _ := owl.RenderPostList(user, &owl.PostList{
Id: "testlist",
Title: "Test List",
Include: []string{
"article",
},
})
assertions.AssertContains(t, result, "testpost1")
assertions.AssertNotContains(t, result, "testpost2")
}
func TestCanRenderPostListNotIncludeMultiple(t *testing.T) {
user := getTestUser()
user.CreateNewPost(owl.PostMeta{Title: "testpost1", Type: "article"}, "testpost1")
user.CreateNewPost(owl.PostMeta{Title: "testpost2", Type: "note"}, "testpost2")
user.CreateNewPost(owl.PostMeta{Title: "testpost3", Type: "recipe"}, "testpost3")
result, _ := owl.RenderPostList(user, &owl.PostList{
Id: "testlist",
Title: "Test List",
Include: []string{
"article", "recipe",
},
})
assertions.AssertContains(t, result, "testpost1")
assertions.AssertNotContains(t, result, "testpost2")
assertions.AssertContains(t, result, "testpost3")
}
func TestCanRenderIndexPage(t *testing.T) {
user := getTestUser()
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost1"}, "")
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost2"}, "")
result, _ := owl.RenderIndexPage(user)
assertions.AssertContains(t, result, "testpost1")
assertions.AssertContains(t, result, "testpost2")
}
func TestCanRenderIndexPageNoTitle(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{}, "hi")
result, _ := owl.RenderIndexPage(user)
assertions.AssertContains(t, result, post.Id())
}
func TestRenderNoteAsFullContent(t *testing.T) {
user := getTestUser()
user.CreateNewPost(owl.PostMeta{Type: "note"}, "This is a note")
result, _ := owl.RenderPostList(user, &owl.PostList{
Include: []string{"note"},
})
assertions.AssertContains(t, result, "This is a note")
assertions.AssertNotContains(t, result, "&lt;p&gt;This is a note")
}
func TestIndexPageContainsHFeedContainer(t *testing.T) {
user := getTestUser()
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost1"}, "")
result, _ := owl.RenderIndexPage(user)
assertions.AssertContains(t, result, "<div class=\"h-feed\">")
}
func TestIndexPageContainsHEntryAndUUrl(t *testing.T) {
user := getTestUser()
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost1"}, "")
result, _ := owl.RenderIndexPage(user)
assertions.AssertContains(t, result, "class=\"h-entry\"")
assertions.AssertContains(t, result, "class=\"u-url\"")
}
func TestIndexPageDoesNotContainsArticle(t *testing.T) {
user := getTestUser()
user.CreateNewPost(owl.PostMeta{Type: "article"}, "hi")
result, _ := owl.RenderIndexPage(user)
assertions.AssertContains(t, result, "class=\"h-entry\"")
assertions.AssertContains(t, result, "class=\"u-url\"")
}
func TestIndexPageDoesNotContainsReply(t *testing.T) {
user := getTestUser()
user.CreateNewPost(owl.PostMeta{Type: "reply", Reply: owl.ReplyData{Url: "https://example.com/post"}}, "hi")
result, _ := owl.RenderIndexPage(user)
assertions.AssertContains(t, result, "class=\"h-entry\"")
assertions.AssertContains(t, result, "class=\"u-url\"")
}
func TestRenderIndexPageWithBrokenBaseTemplate(t *testing.T) {
user := getTestUser()
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost1"}, "")
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost2"}, "")
os.WriteFile(path.Join(user.Dir(), "meta/base.html"), []byte("{{content}}"), 0644)
_, err := owl.RenderIndexPage(user)
assertions.AssertError(t, err, "Expected error rendering index page")
}
func TestRenderUserList(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
repo.CreateUser("user1")
repo.CreateUser("user2")
result, err := owl.RenderUserList(repo)
assertions.AssertNoError(t, err, "Error rendering user list")
assertions.AssertContains(t, result, "user1")
assertions.AssertContains(t, result, "user2")
}
func TestRendersHeaderTitle(t *testing.T) {
user := getTestUser()
user.SetConfig(owl.UserConfig{
Title: "Test Title",
SubTitle: "Test SubTitle",
HeaderColor: "#ff1337",
})
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
result, _ := owl.RenderPost(post)
assertions.AssertContains(t, result, "Test Title")
assertions.AssertContains(t, result, "Test SubTitle")
assertions.AssertContains(t, result, "#ff1337")
}
func TestRenderPostIncludesRelToWebMention(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
result, _ := owl.RenderPost(post)
assertions.AssertContains(t, result, "rel=\"webmention\"")
assertions.AssertContains(t, result, "href=\""+user.WebmentionUrl()+"\"")
}
func TestRenderPostAddsLinksToApprovedWebmention(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
webmention := owl.WebmentionIn{
Source: "http://example.com/source3",
Title: "Test Title",
ApprovalStatus: "approved",
RetrievedAt: time.Now().Add(time.Hour * -2),
}
post.PersistIncomingWebmention(webmention)
webmention = owl.WebmentionIn{
Source: "http://example.com/source4",
ApprovalStatus: "rejected",
RetrievedAt: time.Now().Add(time.Hour * -3),
}
post.PersistIncomingWebmention(webmention)
result, _ := owl.RenderPost(post)
assertions.AssertContains(t, result, "http://example.com/source3")
assertions.AssertContains(t, result, "Test Title")
assertions.AssertNotContains(t, result, "http://example.com/source4")
}
func TestRenderPostNotMentioningWebmentionsIfNoAvail(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
result, _ := owl.RenderPost(post)
assertions.AssertNotContains(t, result, "Webmention")
}
func TestRenderIncludesFullUrl(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
result, _ := owl.RenderPost(post)
assertions.AssertContains(t, result, "class=\"u-url\"")
assertions.AssertContains(t, result, post.FullUrl())
}
func TestAddAvatarIfExist(t *testing.T) {
user := getTestUser()
os.WriteFile(path.Join(user.MediaDir(), "avatar.png"), []byte("test"), 0644)
result, _ := owl.RenderIndexPage(user)
assertions.AssertContains(t, result, "avatar.png")
}
func TestAuthorNameInPost(t *testing.T) {
user := getTestUser()
user.SetConfig(owl.UserConfig{
Title: "Test Title",
SubTitle: "Test SubTitle",
HeaderColor: "#ff1337",
AuthorName: "Test Author",
})
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
result, _ := owl.RenderPost(post)
assertions.AssertContains(t, result, "Test Author")
}
func TestRenderReplyWithoutText(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{
Type: "reply",
Reply: owl.ReplyData{
Url: "https://example.com/post",
},
}, "Hi ")
result, _ := owl.RenderPost(post)
assertions.AssertContains(t, result, "https://example.com/post")
}
func TestRenderReplyWithText(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{
Type: "reply",
Reply: owl.ReplyData{
Url: "https://example.com/post",
Text: "This is a reply",
},
}, "Hi ")
result, _ := owl.RenderPost(post)
assertions.AssertContains(t, result, "https://example.com/post")
assertions.AssertContains(t, result, "This is a reply")
}
func TestRengerPostContainsBookmark(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "bookmark", Bookmark: owl.BookmarkData{Url: "https://example.com/post"}}, "hi")
result, _ := owl.RenderPost(post)
assertions.AssertContains(t, result, "https://example.com/post")
}
func TestOpenGraphTags(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
content := "---\n"
content += "title: The Rock\n"
content += "description: Dwayne Johnson\n"
content += "date: Wed, 17 Aug 2022 10:50:02 +0000\n"
content += "---\n"
content += "\n"
content += "Hi \n"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
post, _ = user.GetPost(post.Id())
result, _ := owl.RenderPost(post)
assertions.AssertContains(t, result, "<meta property=\"og:title\" content=\"The Rock\" />")
assertions.AssertContains(t, result, "<meta property=\"og:description\" content=\"Dwayne Johnson\" />")
assertions.AssertContains(t, result, "<meta property=\"og:type\" content=\"article\" />")
assertions.AssertContains(t, result, "<meta property=\"og:url\" content=\""+post.FullUrl()+"\" />")
}
func TestAddFaviconIfExist(t *testing.T) {
user := getTestUser()
os.WriteFile(path.Join(user.MediaDir(), "favicon.png"), []byte("test"), 0644)
result, _ := owl.RenderIndexPage(user)
assertions.AssertContains(t, result, "favicon.png")
}
func TestRenderUserAuth(t *testing.T) {
user := getTestUser()
user.ResetPassword("test")
result, err := owl.RenderUserAuthPage(owl.AuthRequestData{
User: user,
})
assertions.AssertNoError(t, err, "Error rendering user auth page")
assertions.AssertContains(t, result, "<form")
}
func TestRenderUserAuthIncludesClientId(t *testing.T) {
user := getTestUser()
user.ResetPassword("test")
result, err := owl.RenderUserAuthPage(owl.AuthRequestData{
User: user,
ClientId: "https://example.com/",
})
assertions.AssertNoError(t, err, "Error rendering user auth page")
assertions.AssertContains(t, result, "https://example.com/")
}
func TestRenderUserAuthHiddenFields(t *testing.T) {
user := getTestUser()
user.ResetPassword("test")
result, err := owl.RenderUserAuthPage(owl.AuthRequestData{
User: user,
ClientId: "https://example.com/",
RedirectUri: "https://example.com/redirect",
ResponseType: "code",
State: "teststate",
})
assertions.AssertNoError(t, err, "Error rendering user auth page")
assertions.AssertContains(t, result, "name=\"client_id\" value=\"https://example.com/\"")
assertions.AssertContains(t, result, "name=\"redirect_uri\" value=\"https://example.com/redirect\"")
assertions.AssertContains(t, result, "name=\"response_type\" value=\"code\"")
assertions.AssertContains(t, result, "name=\"state\" value=\"teststate\"")
}
func TestRenderHeaderMenuListItem(t *testing.T) {
user := getTestUser()
user.AddHeaderMenuItem(owl.MenuItem{
Title: "Test Entry",
List: "test",
})
result, _ := owl.RenderIndexPage(user)
assertions.AssertContains(t, result, "Test Entry")
assertions.AssertContains(t, result, "/lists/test")
}
func TestRenderHeaderMenuUrlItem(t *testing.T) {
user := getTestUser()
user.AddHeaderMenuItem(owl.MenuItem{
Title: "Test Entry",
Url: "https://example.com",
})
result, _ := owl.RenderIndexPage(user)
assertions.AssertContains(t, result, "Test Entry")
assertions.AssertContains(t, result, "https://example.com")
}
func TestRenderHeaderMenuPost(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
user.AddHeaderMenuItem(owl.MenuItem{
Title: "Test Entry",
Post: post.Id(),
})
result, _ := owl.RenderIndexPage(user)
assertions.AssertContains(t, result, "Test Entry")
assertions.AssertContains(t, result, post.UrlPath())
}
func TestRenderFooterMenuListItem(t *testing.T) {
user := getTestUser()
user.AddFooterMenuItem(owl.MenuItem{
Title: "Test Entry",
List: "test",
})
result, _ := owl.RenderIndexPage(user)
assertions.AssertContains(t, result, "Test Entry")
assertions.AssertContains(t, result, "/lists/test")
}
func TestRenderFooterMenuUrlItem(t *testing.T) {
user := getTestUser()
user.AddFooterMenuItem(owl.MenuItem{
Title: "Test Entry",
Url: "https://example.com",
})
result, _ := owl.RenderIndexPage(user)
assertions.AssertContains(t, result, "Test Entry")
assertions.AssertContains(t, result, "https://example.com")
}
func TestRenderFooterMenuPost(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{
Type: "private",
}, "")
result, _ := owl.RenderIndexPage(user)
assertions.AssertNotContains(t, result, "Test Entry")
assertions.AssertNotContains(t, result, post.UrlPath())
user.AddFooterMenuItem(owl.MenuItem{
Title: "Test Entry",
Post: post.Id(),
})
result, _ = owl.RenderIndexPage(user)
assertions.AssertContains(t, result, "Test Entry")
assertions.AssertContains(t, result, post.UrlPath())
}
func TestRecipePost(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("testuser")
post, err := user.CreateNewPost(owl.PostMeta{
Type: "recipe",
Title: "test recipe",
Recipe: owl.RecipeData{
Yield: "1 loaf",
Ingredients: []string{
"1 cup flour",
"1 cup water",
},
Duration: "1 hour",
},
}, "")
assertions.AssertNoError(t, err, "Error creating post")
result, err := owl.RenderPost(post)
assertions.AssertNoError(t, err, "Error rendering post")
assertions.AssertContains(t, result, "1 loaf")
assertions.AssertContains(t, result, "1 cup flour")
assertions.AssertContains(t, result, "1 cup water")
assertions.AssertContains(t, result, "1 hour")
}

View File

@ -1,184 +0,0 @@
package owl
import (
_ "embed"
"fmt"
"os"
"path"
)
//go:embed embed/initial/base.html
var base_template string
var VERSION = "0.0.1"
type Repository struct {
name string
HttpClient HttpClient
Parser HtmlParser
}
type RepoConfig struct {
Domain string `yaml:"domain"`
SingleUser string `yaml:"single_user"`
AllowRawHtml bool `yaml:"allow_raw_html"`
}
func CreateRepository(name string, config RepoConfig) (Repository, error) {
newRepo := Repository{name: name, Parser: OwlHtmlParser{}, HttpClient: &OwlHttpClient{}}
// check if repository already exists
if dirExists(newRepo.Dir()) {
return Repository{}, fmt.Errorf("Repository already exists")
}
os.Mkdir(newRepo.Dir(), 0755)
os.Mkdir(newRepo.UsersDir(), 0755)
os.Mkdir(newRepo.StaticDir(), 0755)
// create config file
if config.Domain == "" {
config.Domain = "http://localhost:8080"
}
saveToYaml(path.Join(newRepo.Dir(), "config.yml"), config)
// copy all files from static_files embed.FS to StaticDir
staticFiles, _ := embed_files.ReadDir("embed/initial/static")
for _, file := range staticFiles {
if file.IsDir() {
continue
}
src_data, _ := embed_files.ReadFile("embed/initial/static/" + file.Name())
os.WriteFile(newRepo.StaticDir()+"/"+file.Name(), src_data, 0644)
}
// copy repo/ to newRepo.Dir()
init_files, _ := embed_files.ReadDir("embed/initial/repo")
for _, file := range init_files {
if file.IsDir() {
continue
}
src_data, _ := embed_files.ReadFile("embed/initial/repo/" + file.Name())
os.WriteFile(newRepo.Dir()+"/"+file.Name(), src_data, 0644)
}
return newRepo, nil
}
func OpenRepository(name string) (Repository, error) {
repo := Repository{name: name, Parser: OwlHtmlParser{}, HttpClient: &OwlHttpClient{}}
if !dirExists(repo.Dir()) {
return Repository{}, fmt.Errorf("Repository does not exist: " + repo.Dir())
}
return repo, nil
}
func (repo Repository) Dir() string {
return repo.name
}
func (repo Repository) StaticDir() string {
return path.Join(repo.Dir(), "static")
}
func (repo Repository) UsersDir() string {
return path.Join(repo.Dir(), "users")
}
func (repo Repository) UserUrlPath(user User) string {
config, _ := repo.Config()
if config.SingleUser != "" {
return "/"
}
return "/user/" + user.name + "/"
}
func (repo Repository) FullUrl() string {
config, _ := repo.Config()
return config.Domain
}
func (repo Repository) Template() (string, error) {
// load base.html
path := path.Join(repo.Dir(), "base.html")
base_html, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(base_html), nil
}
func (repo Repository) Users() ([]User, error) {
config, _ := repo.Config()
if config.SingleUser != "" {
return []User{{repo: &repo, name: config.SingleUser}}, nil
}
userNames := listDir(repo.UsersDir())
users := make([]User, len(userNames))
for i, name := range userNames {
users[i] = User{repo: &repo, name: name}
}
return users, nil
}
func (repo *Repository) CreateUser(name string) (User, error) {
new_user := User{repo: repo, name: name}
// check if user already exists
if dirExists(new_user.Dir()) {
return User{}, fmt.Errorf("User already exists")
}
// creates repo/name folder if it doesn't exist
user_dir := new_user.Dir()
os.Mkdir(user_dir, 0755)
// create folders
os.Mkdir(path.Join(user_dir, "meta"), 0755)
os.Mkdir(path.Join(user_dir, "public"), 0755)
os.Mkdir(path.Join(user_dir, "media"), 0755)
// create Meta files
os.WriteFile(path.Join(user_dir, "meta", "VERSION"), []byte(VERSION), 0644)
os.WriteFile(path.Join(user_dir, "meta", "base.html"), []byte(base_template), 0644)
saveToYaml(new_user.ConfigFile(), UserConfig{
Title: name,
SubTitle: "",
HeaderColor: "#bdd6be",
})
return new_user, nil
}
func (repo Repository) GetUser(name string) (User, error) {
user := User{repo: &repo, name: name}
if !dirExists(user.Dir()) {
return User{}, fmt.Errorf("User does not exist")
}
return user, nil
}
func (repo Repository) PostAliases() (map[string]Post, error) {
users, err := repo.Users()
if err != nil {
return nil, err
}
aliases := make(map[string]Post)
for _, user := range users {
user_aliases, err := user.PostAliases()
if err != nil {
return nil, err
}
for alias, post := range user_aliases {
aliases[alias] = post
}
}
return aliases, nil
}
func (repo Repository) Config() (RepoConfig, error) {
meta := RepoConfig{}
err := loadFromYaml(path.Join(repo.Dir(), "config.yml"), &meta)
return meta, err
}

View File

@ -1,257 +0,0 @@
package owl_test
import (
"h4kor/owl-blogs"
"h4kor/owl-blogs/test/assertions"
"os"
"path"
"testing"
)
func TestCanCreateRepository(t *testing.T) {
repoName := testRepoName()
_, err := owl.CreateRepository(repoName, owl.RepoConfig{})
assertions.AssertNoError(t, err, "Error creating repository: ")
}
func TestCannotCreateExistingRepository(t *testing.T) {
repoName := testRepoName()
owl.CreateRepository(repoName, owl.RepoConfig{})
_, err := owl.CreateRepository(repoName, owl.RepoConfig{})
assertions.AssertError(t, err, "No error returned when creating existing repository")
}
func TestCanCreateANewUser(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser(randomUserName())
_, err := os.Stat(path.Join(user.Dir(), ""))
assertions.AssertNoError(t, err, "Error creating user: ")
}
func TestCannotRecreateExisitingUser(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
userName := randomUserName()
repo.CreateUser(userName)
_, err := repo.CreateUser(userName)
assertions.AssertError(t, err, "No error returned when creating existing user")
}
func TestCreateUserAddsVersionFile(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser(randomUserName())
_, err := os.Stat(path.Join(user.Dir(), "/meta/VERSION"))
assertions.AssertNoError(t, err, "Version file not created")
}
func TestCreateUserAddsBaseHtmlFile(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser(randomUserName())
_, err := os.Stat(path.Join(user.Dir(), "/meta/base.html"))
assertions.AssertNoError(t, err, "Base html file not created")
}
func TestCreateUserAddConfigYml(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser(randomUserName())
_, err := os.Stat(path.Join(user.Dir(), "/meta/config.yml"))
assertions.AssertNoError(t, err, "Config file not created")
}
func TestCreateUserAddsPublicFolder(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser(randomUserName())
_, err := os.Stat(path.Join(user.Dir(), "/public"))
assertions.AssertNoError(t, err, "Public folder not created")
}
func TestCanListRepoUsers(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
user1, _ := repo.CreateUser(randomUserName())
user2, _ := repo.CreateUser(randomUserName())
// Create a new post
users, _ := repo.Users()
assertions.AssertLen(t, users, 2)
for _, user := range users {
assertions.AssertNot(
t,
user.Name() != user1.Name() && user.Name() != user2.Name(),
"User found: "+user.Name(),
)
}
}
func TestCanOpenRepository(t *testing.T) {
// Create a new user
repoName := testRepoName()
repo, _ := owl.CreateRepository(repoName, owl.RepoConfig{})
// Open the repository
repo2, err := owl.OpenRepository(repoName)
assertions.AssertNoError(t, err, "Error opening repository: ")
assertions.Assert(t, repo2.Dir() == repo.Dir(), "Repository directories do not match")
}
func TestCannotOpenNonExisitingRepo(t *testing.T) {
_, err := owl.OpenRepository(testRepoName())
assertions.AssertError(t, err, "No error returned when opening non-existing repository")
}
func TestGetUser(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser(randomUserName())
// Get the user
user2, err := repo.GetUser(user.Name())
assertions.AssertNoError(t, err, "Error getting user: ")
assertions.Assert(t, user2.Name() == user.Name(), "User names do not match")
}
func TestCannotGetNonexistingUser(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
_, err := repo.GetUser(randomUserName())
assertions.AssertError(t, err, "No error returned when getting non-existing user")
}
func TestGetStaticDirOfRepo(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
// Get the user
staticDir := repo.StaticDir()
assertions.Assert(t, staticDir != "", "Static dir is empty")
}
func TestNewRepoGetsStaticFiles(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
_, err := os.Stat(repo.StaticDir())
assertions.AssertNoError(t, err, "Static dir not created")
dir, _ := os.Open(repo.StaticDir())
defer dir.Close()
files, _ := dir.Readdirnames(-1)
assertions.AssertLen(t, files, 1)
}
func TestNewRepoGetsStaticFilesPicoCSSWithContent(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
file, err := os.Open(path.Join(repo.StaticDir(), "pico.min.css"))
assertions.AssertNoError(t, err, "Error opening pico.min.css")
// check that the file has content
stat, _ := file.Stat()
assertions.Assert(t, stat.Size() > 0, "pico.min.css is empty")
}
func TestNewRepoGetsBaseHtml(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
_, err := os.Stat(path.Join(repo.Dir(), "/base.html"))
assertions.AssertNoError(t, err, "Base html file not found")
}
func TestCanGetRepoTemplate(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
// Get the user
template, err := repo.Template()
assertions.AssertNoError(t, err, "Error getting template: ")
assertions.Assert(t, template != "", "Template is empty")
}
func TestCanOpenRepositoryInSingleUserMode(t *testing.T) {
// Create a new user
repoName := testRepoName()
userName := randomUserName()
created_repo, _ := owl.CreateRepository(repoName, owl.RepoConfig{SingleUser: userName})
created_repo.CreateUser(userName)
created_repo.CreateUser(randomUserName())
created_repo.CreateUser(randomUserName())
// Open the repository
repo, _ := owl.OpenRepository(repoName)
users, _ := repo.Users()
assertions.AssertLen(t, users, 1)
assertions.Assert(t, users[0].Name() == userName, "User name does not match")
}
func TestSingleUserRepoUserUrlPathIsSimple(t *testing.T) {
// Create a new user
repoName := testRepoName()
userName := randomUserName()
created_repo, _ := owl.CreateRepository(repoName, owl.RepoConfig{SingleUser: userName})
created_repo.CreateUser(userName)
// Open the repository
repo, _ := owl.OpenRepository(repoName)
user, _ := repo.GetUser(userName)
assertions.Assert(t, user.UrlPath() == "/", "User url path is not /")
}
func TestCanGetMapWithAllPostAliases(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser(randomUserName())
post, _ := user.CreateNewPost(owl.PostMeta{Title: "test-1"}, "")
content := "---\n"
content += "title: Test\n"
content += "aliases: \n"
content += " - /foo/bar\n"
content += " - /foo/baz\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
posts, _ := user.PublishedPosts()
assertions.AssertLen(t, posts, 1)
var aliases map[string]owl.Post
aliases, err := repo.PostAliases()
assertions.AssertNoError(t, err, "Error getting post aliases: ")
assertions.AssertMapLen(t, aliases, 2)
assertions.Assert(t, aliases["/foo/bar"] != nil, "Alias '/foo/bar' not found")
assertions.Assert(t, aliases["/foo/baz"] != nil, "Alias '/foo/baz' not found")
}
func TestAliasesHaveCorrectPost(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser(randomUserName())
post1, _ := user.CreateNewPost(owl.PostMeta{Title: "test-1"}, "")
post2, _ := user.CreateNewPost(owl.PostMeta{Title: "test-2"}, "")
content := "---\n"
content += "title: Test\n"
content += "aliases: \n"
content += " - /foo/1\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post1.ContentFile(), []byte(content), 0644)
content = "---\n"
content += "title: Test\n"
content += "aliases: \n"
content += " - /foo/2\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post2.ContentFile(), []byte(content), 0644)
posts, _ := user.PublishedPosts()
assertions.AssertLen(t, posts, 2)
var aliases map[string]owl.Post
aliases, err := repo.PostAliases()
assertions.AssertNoError(t, err, "Error getting post aliases: ")
assertions.AssertMapLen(t, aliases, 2)
assertions.Assert(t, aliases["/foo/1"].Id() == post1.Id(), "Alias '/foo/1' does not point to post 1")
assertions.Assert(t, aliases["/foo/2"].Id() == post2.Id(), "Alias '/foo/2' does not point to post 2")
}

65
rss.go
View File

@ -1,65 +0,0 @@
package owl
import (
"bytes"
"encoding/xml"
"time"
)
type RSS struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`
Channel RSSChannel `xml:"channel"`
}
type RSSChannel struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
Items []RSSItem `xml:"item"`
}
type RSSItem struct {
Guid string `xml:"guid"`
Title string `xml:"title"`
Link string `xml:"link"`
PubDate string `xml:"pubDate"`
Description string `xml:"description"`
}
func RenderRSSFeed(user User) (string, error) {
config := user.Config()
rss := RSS{
Version: "2.0",
Channel: RSSChannel{
Title: config.Title,
Link: user.FullUrl(),
Description: config.SubTitle,
Items: make([]RSSItem, 0),
},
}
posts, _ := user.PrimaryFeedPosts()
for _, post := range posts {
meta := post.Meta()
content, _ := renderPostContent(post)
rss.Channel.Items = append(rss.Channel.Items, RSSItem{
Guid: post.FullUrl(),
Title: post.Title(),
Link: post.FullUrl(),
PubDate: meta.Date.Format(time.RFC1123Z),
Description: content,
})
}
buf := new(bytes.Buffer)
err := xml.NewEncoder(buf).Encode(rss)
if err != nil {
return "", err
}
return xml.Header + buf.String(), nil
}

View File

@ -1,68 +0,0 @@
package owl_test
import (
"h4kor/owl-blogs"
"h4kor/owl-blogs/test/assertions"
"os"
"testing"
)
func TestRenderRSSFeedMeta(t *testing.T) {
user := getTestUser()
user.SetConfig(owl.UserConfig{
Title: "Test Title",
SubTitle: "Test SubTitle",
})
res, err := owl.RenderRSSFeed(user)
assertions.AssertNoError(t, err, "Error rendering RSS feed")
assertions.AssertContains(t, res, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
assertions.AssertContains(t, res, "<rss version=\"2.0\">")
}
func TestRenderRSSFeedUserData(t *testing.T) {
user := getTestUser()
user.SetConfig(owl.UserConfig{
Title: "Test Title",
SubTitle: "Test SubTitle",
})
res, err := owl.RenderRSSFeed(user)
assertions.AssertNoError(t, err, "Error rendering RSS feed")
assertions.AssertContains(t, res, "Test Title")
assertions.AssertContains(t, res, "Test SubTitle")
assertions.AssertContains(t, res, "http://localhost:8080/user/")
}
func TestRenderRSSFeedPostData(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{Title: "testpost"}, "")
content := "---\n"
content += "title: Test Post\n"
content += "date: 2015-01-01\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
res, err := owl.RenderRSSFeed(user)
assertions.AssertNoError(t, err, "Error rendering RSS feed")
assertions.AssertContains(t, res, "Test Post")
assertions.AssertContains(t, res, post.FullUrl())
assertions.AssertContains(t, res, "Thu, 01 Jan 2015 00:00:00 +0000")
}
func TestRenderRSSFeedPostDataWithoutDate(t *testing.T) {
user := getTestUser()
post, _ := user.CreateNewPost(owl.PostMeta{Title: "testpost"}, "")
content := "---\n"
content += "title: Test Post\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
res, err := owl.RenderRSSFeed(user)
assertions.AssertNoError(t, err, "Error rendering RSS feed")
assertions.AssertContains(t, res, "Test Post")
assertions.AssertContains(t, res, post.FullUrl())
}

View File

@ -1,109 +0,0 @@
package assertions
import (
"net/http/httptest"
"strings"
"testing"
)
func Assert(t *testing.T, condition bool, message string) {
t.Helper()
if !condition {
t.Errorf(message)
}
}
func AssertNot(t *testing.T, condition bool, message string) {
t.Helper()
if condition {
t.Errorf(message)
}
}
func AssertContains(t *testing.T, containing string, search string) {
t.Helper()
if !strings.Contains(containing, search) {
t.Errorf("Expected '%s' to contain '%s'", containing, search)
}
}
func AssertArrayContains[T comparable](t *testing.T, list []T, search T) {
t.Helper()
for _, item := range list {
if item == search {
return
}
}
t.Errorf("Expected '%v' to be in '%v'", search, list)
}
func AssertNotContains(t *testing.T, containing string, search string) {
t.Helper()
if strings.Contains(containing, search) {
t.Errorf("Expected '%s' to not contain '%s'", containing, search)
}
}
func AssertNoError(t *testing.T, err error, message string) {
t.Helper()
if err != nil {
t.Errorf(message+": %s", err.Error())
}
}
func AssertError(t *testing.T, err error, message string) {
t.Helper()
if err == nil {
t.Errorf(message)
}
}
func AssertLen[T any](t *testing.T, list []T, expected int) {
t.Helper()
if len(list) != expected {
t.Errorf("Expected list to have length %d, got %d", expected, len(list))
}
}
func AssertMapLen[T any, S comparable](t *testing.T, list map[S]T, expected int) {
t.Helper()
if len(list) != expected {
t.Errorf("Expected list to have length %d, got %d", expected, len(list))
}
}
func AssertEqual[T comparable](t *testing.T, actual T, expected T) {
t.Helper()
if actual != expected {
t.Errorf("Expected '%v', got '%v'", expected, actual)
}
}
func AssertNotEqual[T comparable](t *testing.T, actual T, expected T) {
t.Helper()
if actual == expected {
t.Errorf("Expected '%v' to not be '%v'", expected, actual)
}
}
func AssertStatus(t *testing.T, rr *httptest.ResponseRecorder, expStatus int) {
if status := rr.Code; status != expStatus {
t.Errorf("handler returned wrong status code: got %v want %v",
status, expStatus)
return
}
}
func AssertLessThan(t *testing.T, actual int, expected int) {
t.Helper()
if actual >= expected {
t.Errorf("Expected '%d' to be less than '%d'", actual, expected)
}
}
func AssertGreaterThan(t *testing.T, actual int, expected int) {
t.Helper()
if actual <= expected {
t.Errorf("Expected '%d' to be greater than '%d'", actual, expected)
}
}

View File

@ -1,64 +0,0 @@
package mocks
import (
"h4kor/owl-blogs"
"io"
"net/http"
"net/url"
)
type MockHtmlParser struct{}
func (*MockHtmlParser) ParseHEntry(resp *http.Response) (owl.ParsedHEntry, error) {
return owl.ParsedHEntry{Title: "Mock Title"}, nil
}
func (*MockHtmlParser) ParseLinks(resp *http.Response) ([]string, error) {
return []string{"http://example.com"}, nil
}
func (*MockHtmlParser) ParseLinksFromString(string) ([]string, error) {
return []string{"http://example.com"}, nil
}
func (*MockHtmlParser) GetWebmentionEndpoint(resp *http.Response) (string, error) {
return "http://example.com/webmention", nil
}
func (*MockHtmlParser) GetRedirctUris(resp *http.Response) ([]string, error) {
return []string{"http://example.com/redirect"}, nil
}
type MockParseLinksHtmlParser struct {
Links []string
}
func (*MockParseLinksHtmlParser) ParseHEntry(resp *http.Response) (owl.ParsedHEntry, error) {
return owl.ParsedHEntry{Title: "Mock Title"}, nil
}
func (parser *MockParseLinksHtmlParser) ParseLinks(resp *http.Response) ([]string, error) {
return parser.Links, nil
}
func (parser *MockParseLinksHtmlParser) ParseLinksFromString(string) ([]string, error) {
return parser.Links, nil
}
func (*MockParseLinksHtmlParser) GetWebmentionEndpoint(resp *http.Response) (string, error) {
return "http://example.com/webmention", nil
}
func (parser *MockParseLinksHtmlParser) GetRedirctUris(resp *http.Response) ([]string, error) {
return parser.Links, nil
}
type MockHttpClient struct{}
func (*MockHttpClient) Get(url string) (resp *http.Response, err error) {
return &http.Response{}, nil
}
func (*MockHttpClient) Post(url, contentType string, body io.Reader) (resp *http.Response, err error) {
return &http.Response{}, nil
}
func (*MockHttpClient) PostForm(url string, data url.Values) (resp *http.Response, err error) {
return &http.Response{}, nil
}

547
user.go
View File

@ -1,547 +0,0 @@
package owl
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"net/url"
"os"
"path"
"sort"
"time"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v2"
)
type User struct {
repo *Repository
name string
}
type UserConfig struct {
Title string `yaml:"title"`
SubTitle string `yaml:"subtitle"`
HeaderColor string `yaml:"header_color"`
AuthorName string `yaml:"author_name"`
Me []UserMe `yaml:"me"`
PassworHash string `yaml:"password_hash"`
Lists []PostList `yaml:"lists"`
PrimaryListInclude []string `yaml:"primary_list_include"`
HeaderMenu []MenuItem `yaml:"header_menu"`
FooterMenu []MenuItem `yaml:"footer_menu"`
}
type PostList struct {
Id string `yaml:"id"`
Title string `yaml:"title"`
Include []string `yaml:"include"`
ListType string `yaml:"list_type"`
}
type MenuItem struct {
Title string `yaml:"title"`
List string `yaml:"list"`
Url string `yaml:"url"`
Post string `yaml:"post"`
}
func (l *PostList) ContainsType(t string) bool {
for _, t2 := range l.Include {
if t2 == t {
return true
}
}
return false
}
type UserMe struct {
Name string `yaml:"name"`
Url string `yaml:"url"`
}
type AuthCode struct {
Code string `yaml:"code"`
ClientId string `yaml:"client_id"`
RedirectUri string `yaml:"redirect_uri"`
CodeChallenge string `yaml:"code_challenge"`
CodeChallengeMethod string `yaml:"code_challenge_method"`
Scope string `yaml:"scope"`
Created time.Time `yaml:"created"`
}
type AccessToken struct {
Token string `yaml:"token"`
Scope string `yaml:"scope"`
ClientId string `yaml:"client_id"`
RedirectUri string `yaml:"redirect_uri"`
Created time.Time `yaml:"created"`
ExpiresIn int `yaml:"expires_in"`
}
type Session struct {
Id string `yaml:"id"`
Created time.Time `yaml:"created"`
ExpiresIn int `yaml:"expires_in"`
}
func (user User) Dir() string {
return path.Join(user.repo.UsersDir(), user.name)
}
func (user User) UrlPath() string {
return user.repo.UserUrlPath(user)
}
func (user User) ListUrl(list PostList) string {
url, _ := url.JoinPath(user.UrlPath(), "lists/"+list.Id+"/")
return url
}
func (user User) FullUrl() string {
url, _ := url.JoinPath(user.repo.FullUrl(), user.UrlPath())
return url
}
func (user User) AuthUrl() string {
if user.Config().PassworHash == "" {
return ""
}
url, _ := url.JoinPath(user.FullUrl(), "auth/")
return url
}
func (user User) TokenUrl() string {
url, _ := url.JoinPath(user.AuthUrl(), "token/")
return url
}
func (user User) IndieauthMetadataUrl() string {
url, _ := url.JoinPath(user.FullUrl(), ".well-known/oauth-authorization-server")
return url
}
func (user User) WebmentionUrl() string {
url, _ := url.JoinPath(user.FullUrl(), "webmention/")
return url
}
func (user User) MicropubUrl() string {
url, _ := url.JoinPath(user.FullUrl(), "micropub/")
return url
}
func (user User) MediaUrl() string {
url, _ := url.JoinPath(user.UrlPath(), "media")
return url
}
func (user User) EditorUrl() string {
url, _ := url.JoinPath(user.UrlPath(), "editor/")
return url
}
func (user User) EditorLoginUrl() string {
url, _ := url.JoinPath(user.UrlPath(), "editor/auth/")
return url
}
func (user User) PostDir() string {
return path.Join(user.Dir(), "public")
}
func (user User) MetaDir() string {
return path.Join(user.Dir(), "meta")
}
func (user User) MediaDir() string {
return path.Join(user.Dir(), "media")
}
func (user User) ConfigFile() string {
return path.Join(user.MetaDir(), "config.yml")
}
func (user User) AuthCodesFile() string {
return path.Join(user.MetaDir(), "auth_codes.yml")
}
func (user User) AccessTokensFile() string {
return path.Join(user.MetaDir(), "access_tokens.yml")
}
func (user User) SessionsFile() string {
return path.Join(user.MetaDir(), "sessions.yml")
}
func (user User) Name() string {
return user.name
}
func (user User) AvatarUrl() string {
for _, ext := range []string{".jpg", ".jpeg", ".png", ".gif"} {
if fileExists(path.Join(user.MediaDir(), "avatar"+ext)) {
url, _ := url.JoinPath(user.MediaUrl(), "avatar"+ext)
return url
}
}
return ""
}
func (user User) FaviconUrl() string {
for _, ext := range []string{".jpg", ".jpeg", ".png", ".gif", ".ico"} {
if fileExists(path.Join(user.MediaDir(), "favicon"+ext)) {
url, _ := url.JoinPath(user.MediaUrl(), "favicon"+ext)
return url
}
}
return ""
}
func (user User) AllPosts() ([]Post, error) {
postFiles := listDir(path.Join(user.Dir(), "public"))
posts := make([]Post, 0)
for _, id := range postFiles {
// if is a directory and has index.md, add to posts
if dirExists(path.Join(user.Dir(), "public", id)) {
if fileExists(path.Join(user.Dir(), "public", id, "index.md")) {
post, _ := user.GetPost(id)
posts = append(posts, post)
}
}
}
type PostWithDate struct {
post Post
date time.Time
}
postDates := make([]PostWithDate, len(posts))
for i, post := range posts {
meta := post.Meta()
postDates[i] = PostWithDate{post: post, date: meta.Date}
}
// sort posts by date
sort.Slice(postDates, func(i, j int) bool {
return postDates[i].date.After(postDates[j].date)
})
for i, post := range postDates {
posts[i] = post.post
}
return posts, nil
}
func (user User) PublishedPosts() ([]Post, error) {
posts, _ := user.AllPosts()
// remove drafts
n := 0
for _, post := range posts {
meta := post.Meta()
if !meta.Draft {
posts[n] = post
n++
}
}
posts = posts[:n]
return posts, nil
}
func (user User) PrimaryFeedPosts() ([]Post, error) {
config := user.Config()
include := config.PrimaryListInclude
if len(include) == 0 {
include = []string{"article", "reply"} // default before addition of this option
}
return user.GetPostsOfList(PostList{
Id: "",
Title: "",
Include: include,
})
}
func (user User) GetPostsOfList(list PostList) ([]Post, error) {
posts, _ := user.PublishedPosts()
// remove posts not included
n := 0
for _, post := range posts {
meta := post.Meta()
if list.ContainsType(meta.Type) {
posts[n] = post
n++
}
}
posts = posts[:n]
return posts, nil
}
func (user User) GetPost(id string) (Post, error) {
// check if posts index.md exists
if !fileExists(path.Join(user.Dir(), "public", id, "index.md")) {
return &GenericPost{}, fmt.Errorf("post %s does not exist", id)
}
post := GenericPost{user: &user, id: id}
return &post, nil
}
func (user User) CreateNewPost(meta PostMeta, content string) (Post, error) {
slugHint := meta.Title
if slugHint == "" {
slugHint = "note"
}
folder_name := toDirectoryName(slugHint)
post_dir := path.Join(user.Dir(), "public", folder_name)
// if post already exists, add -n to the end of the name
i := 0
for {
if dirExists(post_dir) {
i++
folder_name = toDirectoryName(fmt.Sprintf("%s-%d", slugHint, i))
post_dir = path.Join(user.Dir(), "public", folder_name)
} else {
break
}
}
post := GenericPost{user: &user, id: folder_name}
// if date is not set, set it to now
if meta.Date.IsZero() {
meta.Date = time.Now()
}
initial_content := ""
initial_content += "---\n"
// write meta
meta_bytes, err := yaml.Marshal(meta) // TODO: this should be down by the Post
if err != nil {
return &GenericPost{}, err
}
initial_content += string(meta_bytes)
initial_content += "---\n"
initial_content += "\n"
initial_content += content
// create post file
os.Mkdir(post_dir, 0755)
os.WriteFile(post.ContentFile(), []byte(initial_content), 0644)
// create media dir
os.Mkdir(post.MediaDir(), 0755)
return user.GetPost(post.Id())
}
func (user User) Template() (string, error) {
// load base.html
path := path.Join(user.Dir(), "meta", "base.html")
base_html, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(base_html), nil
}
func (user User) Config() UserConfig {
meta := UserConfig{}
loadFromYaml(user.ConfigFile(), &meta)
return meta
}
func (user User) SetConfig(new_config UserConfig) error {
return saveToYaml(user.ConfigFile(), new_config)
}
func (user User) PostAliases() (map[string]Post, error) {
post_aliases := make(map[string]Post)
posts, err := user.PublishedPosts()
if err != nil {
return post_aliases, err
}
for _, post := range posts {
if err != nil {
return post_aliases, err
}
for _, alias := range post.Aliases() {
post_aliases[alias] = post
}
}
return post_aliases, nil
}
func (user User) GetPostList(id string) (*PostList, error) {
lists := user.Config().Lists
for _, list := range lists {
if list.Id == id {
return &list, nil
}
}
return &PostList{}, fmt.Errorf("list %s does not exist", id)
}
func (user User) AddPostList(list PostList) error {
config := user.Config()
config.Lists = append(config.Lists, list)
return user.SetConfig(config)
}
func (user User) AddHeaderMenuItem(link MenuItem) error {
config := user.Config()
config.HeaderMenu = append(config.HeaderMenu, link)
return user.SetConfig(config)
}
func (user User) AddFooterMenuItem(link MenuItem) error {
config := user.Config()
config.FooterMenu = append(config.FooterMenu, link)
return user.SetConfig(config)
}
func (user User) ResetPassword(password string) error {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 10)
if err != nil {
return err
}
config := user.Config()
config.PassworHash = string(bytes)
return user.SetConfig(config)
}
func (user User) VerifyPassword(password string) bool {
err := bcrypt.CompareHashAndPassword(
[]byte(user.Config().PassworHash), []byte(password),
)
return err == nil
}
func (user User) getAuthCodes() []AuthCode {
codes := make([]AuthCode, 0)
loadFromYaml(user.AuthCodesFile(), &codes)
return codes
}
func (user User) addAuthCode(code AuthCode) error {
codes := user.getAuthCodes()
codes = append(codes, code)
return saveToYaml(user.AuthCodesFile(), codes)
}
func (user User) GenerateAuthCode(
client_id string, redirect_uri string,
code_challenge string, code_challenge_method string,
scope string,
) (string, error) {
// generate code
code := GenerateRandomString(32)
return code, user.addAuthCode(AuthCode{
Code: code,
ClientId: client_id,
RedirectUri: redirect_uri,
CodeChallenge: code_challenge,
CodeChallengeMethod: code_challenge_method,
Scope: scope,
Created: time.Now(),
})
}
func (user User) VerifyAuthCode(
code string, client_id string, redirect_uri string, code_verifier string,
) (bool, AuthCode) {
codes := user.getAuthCodes()
for _, c := range codes {
if c.Code == code && c.ClientId == client_id && c.RedirectUri == redirect_uri {
if c.CodeChallengeMethod == "plain" {
return c.CodeChallenge == code_verifier, c
} else if c.CodeChallengeMethod == "S256" {
// hash code_verifier
hash := sha256.Sum256([]byte(code_verifier))
return c.CodeChallenge == base64.RawURLEncoding.EncodeToString(hash[:]), c
} else if c.CodeChallengeMethod == "" {
// Check age of code
// A maximum lifetime of 10 minutes is recommended ( https://indieauth.spec.indieweb.org/#authorization-response)
if time.Since(c.Created) < 10*time.Minute {
return true, c
}
}
}
}
return false, AuthCode{}
}
func (user User) getAccessTokens() []AccessToken {
codes := make([]AccessToken, 0)
loadFromYaml(user.AccessTokensFile(), &codes)
return codes
}
func (user User) addAccessToken(code AccessToken) error {
codes := user.getAccessTokens()
codes = append(codes, code)
return saveToYaml(user.AccessTokensFile(), codes)
}
func (user User) GenerateAccessToken(authCode AuthCode) (string, int, error) {
// generate code
token := GenerateRandomString(32)
duration := 24 * 60 * 60
return token, duration, user.addAccessToken(AccessToken{
Token: token,
ClientId: authCode.ClientId,
RedirectUri: authCode.RedirectUri,
Scope: authCode.Scope,
ExpiresIn: duration,
Created: time.Now(),
})
}
func (user User) ValidateAccessToken(token string) (bool, AccessToken) {
tokens := user.getAccessTokens()
for _, t := range tokens {
if t.Token == token {
if time.Since(t.Created) < time.Duration(t.ExpiresIn)*time.Second {
return true, t
}
}
}
return false, AccessToken{}
}
func (user User) getSessions() []Session {
sessions := make([]Session, 0)
loadFromYaml(user.SessionsFile(), &sessions)
return sessions
}
func (user User) addSession(session Session) error {
sessions := user.getSessions()
sessions = append(sessions, session)
return saveToYaml(user.SessionsFile(), sessions)
}
func (user User) CreateNewSession() string {
// generate code
code := GenerateRandomString(32)
user.addSession(Session{
Id: code,
Created: time.Now(),
ExpiresIn: 30 * 24 * 60 * 60,
})
return code
}
func (user User) ValidateSession(session_id string) bool {
sessions := user.getSessions()
for _, session := range sessions {
if session.Id == session_id {
if time.Since(session.Created) < time.Duration(session.ExpiresIn)*time.Second {
return true
}
}
}
return false
}

View File

@ -1,352 +0,0 @@
package owl_test
import (
"fmt"
"h4kor/owl-blogs"
"h4kor/owl-blogs/test/assertions"
"os"
"path"
"testing"
)
func TestCreateNewPostCreatesEntryInPublic(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser(randomUserName())
// Create a new post
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
files, err := os.ReadDir(path.Join(user.Dir(), "public"))
assertions.AssertNoError(t, err, "Error reading directory")
assertions.AssertLen(t, files, 1)
}
func TestCreateNewPostCreatesMediaDir(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser(randomUserName())
// Create a new post
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
_, err := os.Stat(post.MediaDir())
assertions.AssertNot(t, os.IsNotExist(err), "Media directory not created")
}
func TestCreateNewPostAddsDateToMetaBlock(t *testing.T) {
user := getTestUser()
// Create a new post
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
posts, _ := user.PublishedPosts()
post, _ := user.GetPost(posts[0].Id())
meta := post.Meta()
assertions.AssertNot(t, meta.Date.IsZero(), "Date not set")
}
func TestCreateNewPostMultipleCalls(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser(randomUserName())
// Create a new post
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
files, err := os.ReadDir(path.Join(user.Dir(), "public"))
assertions.AssertNoError(t, err, "Error reading directory")
assertions.AssertEqual(t, len(files), 3)
}
func TestCanListUserPosts(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser(randomUserName())
// Create a new post
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
posts, err := user.PublishedPosts()
assertions.AssertNoError(t, err, "Error reading posts")
assertions.AssertLen(t, posts, 3)
}
func TestCannotListUserPostsInSubdirectories(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser(randomUserName())
// Create a new post
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
os.Mkdir(path.Join(user.PostDir(), "foo"), 0755)
os.Mkdir(path.Join(user.PostDir(), "foo/bar"), 0755)
content := ""
content += "---\n"
content += "title: test\n"
content += "---\n"
content += "\n"
content += "Write your post here.\n"
os.WriteFile(path.Join(user.PostDir(), "foo/index.md"), []byte(content), 0644)
os.WriteFile(path.Join(user.PostDir(), "foo/bar/index.md"), []byte(content), 0644)
posts, _ := user.PublishedPosts()
postIds := []string{}
for _, p := range posts {
postIds = append(postIds, p.Id())
}
if !contains(postIds, "foo") {
t.Error("Does not contain post: foo. Found:")
for _, p := range posts {
t.Error("\t" + p.Id())
}
}
if contains(postIds, "foo/bar") {
t.Error("Invalid post found: foo/bar. Found:")
for _, p := range posts {
t.Error("\t" + p.Id())
}
}
}
func TestCannotListUserPostsWithoutIndexMd(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser(randomUserName())
// Create a new post
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
os.Mkdir(path.Join(user.PostDir(), "foo"), 0755)
os.Mkdir(path.Join(user.PostDir(), "foo/bar"), 0755)
content := ""
content += "---\n"
content += "title: test\n"
content += "---\n"
content += "\n"
content += "Write your post here.\n"
os.WriteFile(path.Join(user.PostDir(), "foo/bar/index.md"), []byte(content), 0644)
posts, _ := user.PublishedPosts()
postIds := []string{}
for _, p := range posts {
postIds = append(postIds, p.Id())
}
if contains(postIds, "foo") {
t.Error("Contains invalid post: foo. Found:")
for _, p := range posts {
t.Error("\t" + p.Id())
}
}
}
func TestListUserPostsDoesNotIncludeDrafts(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser(randomUserName())
// Create a new post
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
content := ""
content += "---\n"
content += "title: test\n"
content += "draft: true\n"
content += "---\n"
content += "\n"
content += "Write your post here.\n"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
posts, _ := user.PublishedPosts()
assertions.AssertLen(t, posts, 0)
}
func TestListUsersDraftsExcludedRealWorld(t *testing.T) {
// Create a new user
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser(randomUserName())
// Create a new post
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
content := ""
content += "---\n"
content += "title: Articles September 2019\n"
content += "author: h4kor\n"
content += "type: post\n"
content += "date: -001-11-30T00:00:00+00:00\n"
content += "draft: true\n"
content += "url: /?p=426\n"
content += "categories:\n"
content += " - Uncategorised\n"
content += "\n"
content += "---\n"
content += "<https://nesslabs.com/time-anxiety>\n"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
posts, _ := user.PublishedPosts()
assertions.AssertLen(t, posts, 0)
}
func TestCanLoadPost(t *testing.T) {
user := getTestUser()
// Create a new post
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
posts, _ := user.PublishedPosts()
post, _ := user.GetPost(posts[0].Id())
assertions.Assert(t, post.Title() == "testpost", "Post title is not correct")
}
func TestUserUrlPath(t *testing.T) {
user := getTestUser()
assertions.Assert(t, user.UrlPath() == "/user/"+user.Name()+"/", "Wrong url path")
}
func TestUserFullUrl(t *testing.T) {
user := getTestUser()
assertions.Assert(t, user.FullUrl() == "http://localhost:8080/user/"+user.Name()+"/", "Wrong url path")
}
func TestPostsSortedByPublishingDateLatestFirst(t *testing.T) {
user := getTestUser()
// Create a new post
post1, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
post2, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost2"}, "")
content := "---\n"
content += "title: Test Post\n"
content += "date: Wed, 17 Aug 2022 10:50:02 +0000\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post1.ContentFile(), []byte(content), 0644)
content = "---\n"
content += "title: Test Post 2\n"
content += "date: Wed, 17 Aug 2022 20:50:06 +0000\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post2.ContentFile(), []byte(content), 0644)
posts, _ := user.PublishedPosts()
assertions.Assert(t, posts[0].Id() == post2.Id(), "Wrong Id")
assertions.Assert(t, posts[1].Id() == post1.Id(), "Wrong Id")
}
func TestPostsSortedByPublishingDateLatestFirst2(t *testing.T) {
user := getTestUser()
// Create a new post
posts := []owl.Post{}
for i := 59; i >= 0; i-- {
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
content := "---\n"
content += "title: Test Post\n"
content += fmt.Sprintf("date: Wed, 17 Aug 2022 10:%02d:02 +0000\n", i)
content += "---\n"
content += "This is a test"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
posts = append(posts, post)
}
retPosts, _ := user.PublishedPosts()
for i, p := range retPosts {
assertions.Assert(t, p.Id() == posts[i].Id(), "Wrong Id")
}
}
func TestPostsSortedByPublishingDateBrokenAtBottom(t *testing.T) {
user := getTestUser()
// Create a new post
post1, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost"}, "")
post2, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost2"}, "")
content := "---\n"
content += "title: Test Post\n"
content += "date: Wed, 17 +0000\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post1.ContentFile(), []byte(content), 0644)
content = "---\n"
content += "title: Test Post 2\n"
content += "date: Wed, 17 Aug 2022 20:50:06 +0000\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post2.ContentFile(), []byte(content), 0644)
posts, _ := user.PublishedPosts()
assertions.Assert(t, posts[0].Id() == post2.Id(), "Wrong Id")
assertions.Assert(t, posts[1].Id() == post1.Id(), "Wrong Id")
}
func TestAvatarEmptyIfNotExist(t *testing.T) {
user := getTestUser()
assertions.Assert(t, user.AvatarUrl() == "", "Avatar should be empty")
}
func TestAvatarSetIfFileExist(t *testing.T) {
user := getTestUser()
os.WriteFile(path.Join(user.MediaDir(), "avatar.png"), []byte("test"), 0644)
assertions.Assert(t, user.AvatarUrl() != "", "Avatar should not be empty")
}
func TestPostNameIllegalFileName(t *testing.T) {
user := getTestUser()
_, err := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "testpost?///"}, "")
assertions.AssertNoError(t, err, "Should not have failed")
}
func TestFaviconIfNotExist(t *testing.T) {
user := getTestUser()
assertions.Assert(t, user.FaviconUrl() == "", "Favicon should be empty")
}
func TestFaviconSetIfFileExist(t *testing.T) {
user := getTestUser()
os.WriteFile(path.Join(user.MediaDir(), "favicon.ico"), []byte("test"), 0644)
assertions.Assert(t, user.FaviconUrl() != "", "Favicon should not be empty")
}
func TestResetUserPassword(t *testing.T) {
user := getTestUser()
user.ResetPassword("test")
assertions.Assert(t, user.Config().PassworHash != "", "Password Hash should not be empty")
assertions.Assert(t, user.Config().PassworHash != "test", "Password Hash should not be test")
}
func TestVerifyPassword(t *testing.T) {
user := getTestUser()
user.ResetPassword("test")
assertions.Assert(t, user.VerifyPassword("test"), "Password should be correct")
assertions.Assert(t, !user.VerifyPassword("test2"), "Password should be incorrect")
assertions.Assert(t, !user.VerifyPassword(""), "Password should be incorrect")
assertions.Assert(t, !user.VerifyPassword("Test"), "Password should be incorrect")
assertions.Assert(t, !user.VerifyPassword("TEST"), "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")
}

View File

@ -1,16 +0,0 @@
package owl
import (
"crypto/rand"
"math/big"
)
func GenerateRandomString(length int) string {
chars := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
b := make([]rune, length)
for i := range b {
k, _ := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
b[i] = chars[k.Int64()]
}
return string(b)
}

View File

@ -1,43 +0,0 @@
package owl
import (
"time"
)
type WebmentionIn struct {
Source string `yaml:"source"`
Title string `yaml:"title"`
ApprovalStatus string `yaml:"approval_status"`
RetrievedAt time.Time `yaml:"retrieved_at"`
}
func (webmention *WebmentionIn) UpdateWith(update WebmentionIn) {
if update.Title != "" {
webmention.Title = update.Title
}
if update.ApprovalStatus != "" {
webmention.ApprovalStatus = update.ApprovalStatus
}
if !update.RetrievedAt.IsZero() {
webmention.RetrievedAt = update.RetrievedAt
}
}
type WebmentionOut struct {
Target string `yaml:"target"`
Supported bool `yaml:"supported"`
ScannedAt time.Time `yaml:"scanned_at"`
LastSentAt time.Time `yaml:"last_sent_at"`
}
func (webmention *WebmentionOut) UpdateWith(update WebmentionOut) {
if update.Supported {
webmention.Supported = update.Supported
}
if !update.ScannedAt.IsZero() {
webmention.ScannedAt = update.ScannedAt
}
if !update.LastSentAt.IsZero() {
webmention.LastSentAt = update.LastSentAt
}
}

View File

@ -1,155 +0,0 @@
package owl_test
import (
"bytes"
"h4kor/owl-blogs"
"h4kor/owl-blogs/test/assertions"
"io"
"net/http"
"net/url"
"testing"
)
func constructResponse(html []byte) *http.Response {
url, _ := url.Parse("http://example.com/foo/bar")
return &http.Response{
Request: &http.Request{
URL: url,
},
Body: io.NopCloser(bytes.NewReader([]byte(html))),
}
}
//
// https://www.w3.org/TR/webmention/#h-webmention-verification
//
func TestParseValidHEntry(t *testing.T) {
html := []byte("<div class=\"h-entry\"><div class=\"p-name\">Foo</div></div>")
parser := &owl.OwlHtmlParser{}
entry, err := parser.ParseHEntry(&http.Response{Body: io.NopCloser(bytes.NewReader(html))})
assertions.AssertNoError(t, err, "Unable to parse feed")
assertions.AssertEqual(t, entry.Title, "Foo")
}
func TestParseValidHEntryWithoutTitle(t *testing.T) {
html := []byte("<div class=\"h-entry\"></div><div class=\"p-name\">Foo</div>")
parser := &owl.OwlHtmlParser{}
entry, err := parser.ParseHEntry(&http.Response{Body: io.NopCloser(bytes.NewReader(html))})
assertions.AssertNoError(t, err, "Unable to parse feed")
assertions.AssertEqual(t, entry.Title, "")
}
func TestGetWebmentionEndpointLink(t *testing.T) {
html := []byte("<link rel=\"webmention\" href=\"http://example.com/webmention\" />")
parser := &owl.OwlHtmlParser{}
endpoint, err := parser.GetWebmentionEndpoint(constructResponse(html))
assertions.AssertNoError(t, err, "Unable to parse feed")
assertions.AssertEqual(t, endpoint, "http://example.com/webmention")
}
func TestGetWebmentionEndpointLinkA(t *testing.T) {
html := []byte("<a rel=\"webmention\" href=\"http://example.com/webmention\" />")
parser := &owl.OwlHtmlParser{}
endpoint, err := parser.GetWebmentionEndpoint(constructResponse(html))
assertions.AssertNoError(t, err, "Unable to parse feed")
assertions.AssertEqual(t, endpoint, "http://example.com/webmention")
}
func TestGetWebmentionEndpointLinkAFakeWebmention(t *testing.T) {
html := []byte("<a rel=\"not-webmention\" href=\"http://example.com/foo\" /><a rel=\"webmention\" href=\"http://example.com/webmention\" />")
parser := &owl.OwlHtmlParser{}
endpoint, err := parser.GetWebmentionEndpoint(constructResponse(html))
assertions.AssertNoError(t, err, "Unable to parse feed")
assertions.AssertEqual(t, endpoint, "http://example.com/webmention")
}
func TestGetWebmentionEndpointLinkHeader(t *testing.T) {
html := []byte("")
parser := &owl.OwlHtmlParser{}
resp := constructResponse(html)
resp.Header = http.Header{"Link": []string{"<http://example.com/webmention>; rel=\"webmention\""}}
endpoint, err := parser.GetWebmentionEndpoint(resp)
assertions.AssertNoError(t, err, "Unable to parse feed")
assertions.AssertEqual(t, endpoint, "http://example.com/webmention")
}
func TestGetWebmentionEndpointLinkHeaderCommas(t *testing.T) {
html := []byte("")
parser := &owl.OwlHtmlParser{}
resp := constructResponse(html)
resp.Header = http.Header{
"Link": []string{"<https://webmention.rocks/test/19/webmention/error>; rel=\"other\", <https://webmention.rocks/test/19/webmention>; rel=\"webmention\""},
}
endpoint, err := parser.GetWebmentionEndpoint(resp)
assertions.AssertNoError(t, err, "Unable to parse feed")
assertions.AssertEqual(t, endpoint, "https://webmention.rocks/test/19/webmention")
}
func TestGetWebmentionEndpointRelativeLink(t *testing.T) {
html := []byte("<link rel=\"webmention\" href=\"/webmention\" />")
parser := &owl.OwlHtmlParser{}
endpoint, err := parser.GetWebmentionEndpoint(constructResponse(html))
assertions.AssertNoError(t, err, "Unable to parse feed")
assertions.AssertEqual(t, endpoint, "http://example.com/webmention")
}
func TestGetWebmentionEndpointRelativeLinkInHeader(t *testing.T) {
html := []byte("<link rel=\"webmention\" href=\"/webmention\" />")
parser := &owl.OwlHtmlParser{}
resp := constructResponse(html)
resp.Header = http.Header{"Link": []string{"</webmention>; rel=\"webmention\""}}
endpoint, err := parser.GetWebmentionEndpoint(resp)
assertions.AssertNoError(t, err, "Unable to parse feed")
assertions.AssertEqual(t, endpoint, "http://example.com/webmention")
}
// func TestRealWorldWebmention(t *testing.T) {
// links := []string{
// "https://webmention.rocks/test/1",
// "https://webmention.rocks/test/2",
// "https://webmention.rocks/test/3",
// "https://webmention.rocks/test/4",
// "https://webmention.rocks/test/5",
// "https://webmention.rocks/test/6",
// "https://webmention.rocks/test/7",
// "https://webmention.rocks/test/8",
// "https://webmention.rocks/test/9",
// // "https://webmention.rocks/test/10", // not supported
// "https://webmention.rocks/test/11",
// "https://webmention.rocks/test/12",
// "https://webmention.rocks/test/13",
// "https://webmention.rocks/test/14",
// "https://webmention.rocks/test/15",
// "https://webmention.rocks/test/16",
// "https://webmention.rocks/test/17",
// "https://webmention.rocks/test/18",
// "https://webmention.rocks/test/19",
// "https://webmention.rocks/test/20",
// "https://webmention.rocks/test/21",
// "https://webmention.rocks/test/22",
// "https://webmention.rocks/test/23/page",
// }
// for _, link := range links {
// parser := &owl.OwlHtmlParser{}
// client := &owl.OwlHttpClient{}
// html, _ := client.Get(link)
// _, err := parser.GetWebmentionEndpoint(html)
// if err != nil {
// t.Errorf("Unable to find webmention: %v for link %v", err, link)
// }
// }
// }