From 4540797ccee983ae1583ad4faf12cdb56cc7fe0d Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Wed, 19 Jul 2023 20:59:43 +0200 Subject: [PATCH 01/41] cleanup --- .gitignore | 26 -- .vscode/settings.json | 3 - Dockerfile | 33 -- assets/owl.png | Bin 47278 -> 0 bytes assets/owl.svg | 200 ----------- auth_test.go | 48 --- cmd/owl/init.go | 38 --- cmd/owl/main.go | 32 -- cmd/owl/new_post.go | 51 --- cmd/owl/new_user.go | 38 --- cmd/owl/reset_password.go | 44 --- cmd/owl/web.go | 24 -- cmd/owl/web/aliases_test.go | 191 ----------- cmd/owl/web/auth_handler.go | 396 --------------------- cmd/owl/web/auth_test.go | 428 ----------------------- cmd/owl/web/editor_handler.go | 364 -------------------- cmd/owl/web/editor_test.go | 346 ------------------- cmd/owl/web/handler.go | 407 ---------------------- cmd/owl/web/micropub_test.go | 183 ---------- cmd/owl/web/multi_user_test.go | 108 ------ cmd/owl/web/post_test.go | 34 -- cmd/owl/web/rss_test.go | 31 -- cmd/owl/web/server.go | 95 ------ cmd/owl/web/single_user_test.go | 132 ------- cmd/owl/web/webmention_test.go | 162 --------- cmd/owl/webmention.go | 99 ------ directories.go | 45 --- embed.go | 6 - embed/article/detail.html | 60 ---- embed/auth.html | 24 -- embed/bookmark/detail.html | 72 ---- embed/editor/editor.html | 127 ------- embed/editor/login.html | 13 - embed/error.html | 4 - embed/initial/base.html | 146 -------- embed/initial/header.html | 5 - embed/initial/repo/base.html | 16 - embed/initial/static/pico.min.css | 5 - embed/note/detail.html | 47 --- embed/page/detail.html | 34 -- embed/photo/detail.html | 52 --- embed/post-list-photo.html | 9 - embed/post-list.html | 25 -- embed/post.html | 71 ---- embed/recipe/detail.html | 78 ----- embed/reply/detail.html | 60 ---- embed/untyped/detail.html | 72 ---- embed/user-list.html | 9 - files.go | 23 -- fixtures/image.png | Bin 2971 -> 0 bytes go.mod | 17 - go.sum | 23 -- html.go | 269 --------------- http.go | 15 - owl_test.go | 45 --- post.go | 478 -------------------------- post_test.go | 531 ----------------------------- release.sh | 2 - renderer.go | 254 -------------- renderer_test.go | 505 --------------------------- repository.go | 184 ---------- repository_test.go | 257 -------------- rss.go | 65 ---- rss_test.go | 68 ---- test/assertions/asserts.go | 109 ------ test/mocks/mocks.go | 64 ---- user.go | 547 ------------------------------ user_test.go | 352 ------------------- utils.go | 16 - webmention.go | 43 --- webmention_test.go | 155 --------- 71 files changed, 8515 deletions(-) delete mode 100644 .gitignore delete mode 100644 .vscode/settings.json delete mode 100644 Dockerfile delete mode 100644 assets/owl.png delete mode 100644 assets/owl.svg delete mode 100644 auth_test.go delete mode 100644 cmd/owl/init.go delete mode 100644 cmd/owl/main.go delete mode 100644 cmd/owl/new_post.go delete mode 100644 cmd/owl/new_user.go delete mode 100644 cmd/owl/reset_password.go delete mode 100644 cmd/owl/web.go delete mode 100644 cmd/owl/web/aliases_test.go delete mode 100644 cmd/owl/web/auth_handler.go delete mode 100644 cmd/owl/web/auth_test.go delete mode 100644 cmd/owl/web/editor_handler.go delete mode 100644 cmd/owl/web/editor_test.go delete mode 100644 cmd/owl/web/handler.go delete mode 100644 cmd/owl/web/micropub_test.go delete mode 100644 cmd/owl/web/multi_user_test.go delete mode 100644 cmd/owl/web/post_test.go delete mode 100644 cmd/owl/web/rss_test.go delete mode 100644 cmd/owl/web/server.go delete mode 100644 cmd/owl/web/single_user_test.go delete mode 100644 cmd/owl/web/webmention_test.go delete mode 100644 cmd/owl/webmention.go delete mode 100644 directories.go delete mode 100644 embed.go delete mode 100644 embed/article/detail.html delete mode 100644 embed/auth.html delete mode 100644 embed/bookmark/detail.html delete mode 100644 embed/editor/editor.html delete mode 100644 embed/editor/login.html delete mode 100644 embed/error.html delete mode 100644 embed/initial/base.html delete mode 100644 embed/initial/header.html delete mode 100644 embed/initial/repo/base.html delete mode 100644 embed/initial/static/pico.min.css delete mode 100644 embed/note/detail.html delete mode 100644 embed/page/detail.html delete mode 100644 embed/photo/detail.html delete mode 100644 embed/post-list-photo.html delete mode 100644 embed/post-list.html delete mode 100644 embed/post.html delete mode 100644 embed/recipe/detail.html delete mode 100644 embed/reply/detail.html delete mode 100644 embed/untyped/detail.html delete mode 100644 embed/user-list.html delete mode 100644 files.go delete mode 100644 fixtures/image.png delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 html.go delete mode 100644 http.go delete mode 100644 owl_test.go delete mode 100644 post.go delete mode 100644 post_test.go delete mode 100755 release.sh delete mode 100644 renderer.go delete mode 100644 renderer_test.go delete mode 100644 repository.go delete mode 100644 repository_test.go delete mode 100644 rss.go delete mode 100644 rss_test.go delete mode 100644 test/assertions/asserts.go delete mode 100644 test/mocks/mocks.go delete mode 100644 user.go delete mode 100644 user_test.go delete mode 100644 utils.go delete mode 100644 webmention.go delete mode 100644 webmention_test.go diff --git a/.gitignore b/.gitignore deleted file mode 100644 index e610cac..0000000 --- a/.gitignore +++ /dev/null @@ -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 diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index ebfde9a..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "editor.formatOnSave": true, -} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index e75fe32..0000000 --- a/Dockerfile +++ /dev/null @@ -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"] \ No newline at end of file diff --git a/assets/owl.png b/assets/owl.png deleted file mode 100644 index 2b1466ca3078d343640df2d7078be1a834f12677..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 47278 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 N^3uvuwGt-({T~^vP(=U$ diff --git a/assets/owl.svg b/assets/owl.svg deleted file mode 100644 index 996e37f..0000000 --- a/assets/owl.svg +++ /dev/null @@ -1,200 +0,0 @@ - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/auth_test.go b/auth_test.go deleted file mode 100644 index 5efd35f..0000000 --- a/auth_test.go +++ /dev/null @@ -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("") - 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(` - - - - - - `) - 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{"; rel=\"redirect_uri\""}} - uris, err := parser.GetRedirctUris(resp) - - assertions.AssertNoError(t, err, "Unable to parse feed") - assertions.AssertArrayContains(t, uris, "http://example.com/redirect") -} diff --git a/cmd/owl/init.go b/cmd/owl/init.go deleted file mode 100644 index 1eb7ca3..0000000 --- a/cmd/owl/init.go +++ /dev/null @@ -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) - } - - }, -} diff --git a/cmd/owl/main.go b/cmd/owl/main.go deleted file mode 100644 index f9048c1..0000000 --- a/cmd/owl/main.go +++ /dev/null @@ -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() -} diff --git a/cmd/owl/new_post.go b/cmd/owl/new_post.go deleted file mode 100644 index df0d3e7..0000000 --- a/cmd/owl/new_post.go +++ /dev/null @@ -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()) - } - }, -} diff --git a/cmd/owl/new_user.go b/cmd/owl/new_user.go deleted file mode 100644 index d98d8a5..0000000 --- a/cmd/owl/new_user.go +++ /dev/null @@ -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) - } - }, -} diff --git a/cmd/owl/reset_password.go b/cmd/owl/reset_password.go deleted file mode 100644 index 78179cf..0000000 --- a/cmd/owl/reset_password.go +++ /dev/null @@ -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) - - }, -} diff --git a/cmd/owl/web.go b/cmd/owl/web.go deleted file mode 100644 index 6a939e4..0000000 --- a/cmd/owl/web.go +++ /dev/null @@ -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) - }, -} diff --git a/cmd/owl/web/aliases_test.go b/cmd/owl/web/aliases_test.go deleted file mode 100644 index b941e5a..0000000 --- a/cmd/owl/web/aliases_test.go +++ /dev/null @@ -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) - -} diff --git a/cmd/owl/web/auth_handler.go b/cmd/owl/web/auth_handler.go deleted file mode 100644 index 2e08638..0000000 --- a/cmd/owl/web/auth_handler.go +++ /dev/null @@ -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 - } - - } -} diff --git a/cmd/owl/web/auth_test.go b/cmd/owl/web/auth_test.go deleted file mode 100644 index 78ea651..0000000 --- a/cmd/owl/web/auth_test.go +++ /dev/null @@ -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()) -} diff --git a/cmd/owl/web/editor_handler.go b/cmd/owl/web/editor_handler.go deleted file mode 100644 index 8d1e4e2..0000000 --- a/cmd/owl/web/editor_handler.go +++ /dev/null @@ -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) - } - } -} diff --git a/cmd/owl/web/editor_test.go b/cmd/owl/web/editor_test.go deleted file mode 100644 index 46268e6..0000000 --- a/cmd/owl/web/editor_test.go +++ /dev/null @@ -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]) - } - } - -} diff --git a/cmd/owl/web/handler.go b/cmd/owl/web/handler.go deleted file mode 100644 index 37f3791..0000000 --- a/cmd/owl/web/handler.go +++ /dev/null @@ -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)) - } -} diff --git a/cmd/owl/web/micropub_test.go b/cmd/owl/web/micropub_test.go deleted file mode 100644 index d9574ff..0000000 --- a/cmd/owl/web/micropub_test.go +++ /dev/null @@ -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) -} diff --git a/cmd/owl/web/multi_user_test.go b/cmd/owl/web/multi_user_test.go deleted file mode 100644 index 74acc10..0000000 --- a/cmd/owl/web/multi_user_test.go +++ /dev/null @@ -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") -} diff --git a/cmd/owl/web/post_test.go b/cmd/owl/web/post_test.go deleted file mode 100644 index 11c9dc9..0000000 --- a/cmd/owl/web/post_test.go +++ /dev/null @@ -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) -} diff --git a/cmd/owl/web/rss_test.go b/cmd/owl/web/rss_test.go deleted file mode 100644 index 6ac5f83..0000000 --- a/cmd/owl/web/rss_test.go +++ /dev/null @@ -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") -} diff --git a/cmd/owl/web/server.go b/cmd/owl/web/server.go deleted file mode 100644 index b36342e..0000000 --- a/cmd/owl/web/server.go +++ /dev/null @@ -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) - -} diff --git a/cmd/owl/web/single_user_test.go b/cmd/owl/web/single_user_test.go deleted file mode 100644 index 1ab409d..0000000 --- a/cmd/owl/web/single_user_test.go +++ /dev/null @@ -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 += "\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") -} diff --git a/cmd/owl/web/webmention_test.go b/cmd/owl/web/webmention_test.go deleted file mode 100644 index 84faf3e..0000000 --- a/cmd/owl/web/webmention_test.go +++ /dev/null @@ -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) -} diff --git a/cmd/owl/webmention.go b/cmd/owl/webmention.go deleted file mode 100644 index 5c2dba1..0000000 --- a/cmd/owl/webmention.go +++ /dev/null @@ -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) - } - } - }, -} diff --git a/directories.go b/directories.go deleted file mode 100644 index 0e377aa..0000000 --- a/directories.go +++ /dev/null @@ -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 -} diff --git a/embed.go b/embed.go deleted file mode 100644 index b01283e..0000000 --- a/embed.go +++ /dev/null @@ -1,6 +0,0 @@ -package owl - -import "embed" - -//go:embed embed/* -var embed_files embed.FS diff --git a/embed/article/detail.html b/embed/article/detail.html deleted file mode 100644 index 63e8349..0000000 --- a/embed/article/detail.html +++ /dev/null @@ -1,60 +0,0 @@ -
-
-

{{.Title}}

- - # - Published: - - {{ if .Post.User.Config.AuthorName }} - by - - {{ if .Post.User.AvatarUrl }} - - {{ end }} - {{.Post.User.Config.AuthorName}} - - {{ end }} - -
-
-
- - {{ if .Post.Meta.Bookmark.Url }} -

- Bookmark: - {{ if .Post.Meta.Bookmark.Text }} - {{.Post.Meta.Bookmark.Text}} - {{ else }} - {{.Post.Meta.Bookmark.Url}} - {{ end }} - -

- {{ end }} - -
- {{.Content}} -
- -
- {{if .Post.ApprovedIncomingWebmentions}} -

- Webmentions -

- - {{end}} -
\ No newline at end of file diff --git a/embed/auth.html b/embed/auth.html deleted file mode 100644 index 1e4eec1..0000000 --- a/embed/auth.html +++ /dev/null @@ -1,24 +0,0 @@ -

Authorization for {{.ClientId}}

- -
Requesting scope:
-
    - {{range $index, $element := .Scopes}} -
  • {{$element}}
  • - {{end}} -
- -

- -
- - - - - - - - - - - -
\ No newline at end of file diff --git a/embed/bookmark/detail.html b/embed/bookmark/detail.html deleted file mode 100644 index ece34bc..0000000 --- a/embed/bookmark/detail.html +++ /dev/null @@ -1,72 +0,0 @@ -
-
-

{{.Title}}

- - # - Published: - - {{ if .Post.User.Config.AuthorName }} - by - - {{ if .Post.User.AvatarUrl }} - - {{ end }} - {{.Post.User.Config.AuthorName}} - - {{ end }} - -
-
-
- - {{ if .Post.Meta.Reply.Url }} -

- In reply to: - {{ if .Post.Meta.Reply.Text }} - {{.Post.Meta.Reply.Text}} - {{ else }} - {{.Post.Meta.Reply.Url}} - {{ end }} - -

- {{ end }} - - {{ if .Post.Meta.Bookmark.Url }} -

- Bookmark: - {{ if .Post.Meta.Bookmark.Text }} - {{.Post.Meta.Bookmark.Text}} - {{ else }} - {{.Post.Meta.Bookmark.Url}} - {{ end }} - -

- {{ end }} - -
- {{.Content}} -
- -
- {{if .Post.ApprovedIncomingWebmentions}} -

- Webmentions -

- - {{end}} -
\ No newline at end of file diff --git a/embed/editor/editor.html b/embed/editor/editor.html deleted file mode 100644 index 7e23604..0000000 --- a/embed/editor/editor.html +++ /dev/null @@ -1,127 +0,0 @@ -
- Write Article/Page -
-

Create New Article

- - - - - - - - - - -

- -
-
- -
- Upload Photo -
-

Upload Photo

- - - - - - - - - - - - - -

- -
-
- -
- Write Recipe -
-

Create new Recipe

- - - - - - - - - - - - - - - - - - - - -

- -
-
- -
- Write Note -
-

Create New Note

- - - - -

- -
-
- -
- Write Reply -
-

Create New Reply

- - - - - - - - - - - - -

- -
-
- -
- Bookmark -
-

Create Bookmark

- - - - - - - - - - - - -

- -
-
\ No newline at end of file diff --git a/embed/editor/login.html b/embed/editor/login.html deleted file mode 100644 index f3e7dc4..0000000 --- a/embed/editor/login.html +++ /dev/null @@ -1,13 +0,0 @@ -{{ if eq .Error "wrong_password" }} -
- Wrong Password -
-{{ end }} - - -
-

Login to Editor

- - - -
\ No newline at end of file diff --git a/embed/error.html b/embed/error.html deleted file mode 100644 index e3000ab..0000000 --- a/embed/error.html +++ /dev/null @@ -1,4 +0,0 @@ -
-

{{ .Error }}

- {{ .Message }} -
\ No newline at end of file diff --git a/embed/initial/base.html b/embed/initial/base.html deleted file mode 100644 index d9993ad..0000000 --- a/embed/initial/base.html +++ /dev/null @@ -1,146 +0,0 @@ - - - - - - - - {{ .Title }} - {{ .User.Config.Title }} - - {{ if .User.FaviconUrl }} - - {{ else }} - - {{ end }} - - - {{ if .Description }} - - - {{ end }} - {{ if .Type }} - - {{ end }} - {{ if .SelfUrl }} - - {{ end }} - - - - {{ if .User.AuthUrl }} - - - - - {{ end }} - - - - -
-
-
-

{{ .User.Config.Title }}

-

{{ .User.Config.SubTitle }}

-
- -
- {{ if .User.AvatarUrl }} - - {{ end }} -
- {{ range $me := .User.Config.Me }} -
  • {{$me.Name}} -
  • - {{ end }} -
    -
    -
    -
    - -
    -
    - {{ .Content }} - - - - - diff --git a/embed/initial/header.html b/embed/initial/header.html deleted file mode 100644 index dc71910..0000000 --- a/embed/initial/header.html +++ /dev/null @@ -1,5 +0,0 @@ -
      - {{ range .UserLinks }} -
    • {{.Text}}
    • - {{ end }} -
    \ No newline at end of file diff --git a/embed/initial/repo/base.html b/embed/initial/repo/base.html deleted file mode 100644 index 4afaa2b..0000000 --- a/embed/initial/repo/base.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - {{ .Title }} - - - - - {{ .Content }} - - - \ No newline at end of file diff --git a/embed/initial/static/pico.min.css b/embed/initial/static/pico.min.css deleted file mode 100644 index a4fbbd8..0000000 --- a/embed/initial/static/pico.min.css +++ /dev/null @@ -1,5 +0,0 @@ -@charset "UTF-8";/*! - * Pico.css v1.5.3 (https://picocss.com) - * Copyright 2019-2022 - Licensed under MIT - */:root{--font-family:system-ui,-apple-system,"Segoe UI","Roboto","Ubuntu","Cantarell","Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--line-height:1.5;--font-weight:400;--font-size:16px;--border-radius:0.25rem;--border-width:1px;--outline-width:3px;--spacing:1rem;--typography-spacing-vertical:1.5rem;--block-spacing-vertical:calc(var(--spacing) * 2);--block-spacing-horizontal:var(--spacing);--grid-spacing-vertical:0;--grid-spacing-horizontal:var(--spacing);--form-element-spacing-vertical:0.75rem;--form-element-spacing-horizontal:1rem;--nav-element-spacing-vertical:1rem;--nav-element-spacing-horizontal:0.5rem;--nav-link-spacing-vertical:0.5rem;--nav-link-spacing-horizontal:0.5rem;--form-label-font-weight:var(--font-weight);--transition:0.2s ease-in-out}@media (min-width:576px){:root{--font-size:17px}}@media (min-width:768px){:root{--font-size:18px}}@media (min-width:992px){:root{--font-size:19px}}@media (min-width:1200px){:root{--font-size:20px}}@media (min-width:576px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 2.5)}}@media (min-width:768px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 3)}}@media (min-width:992px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 3.5)}}@media (min-width:1200px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 4)}}@media (min-width:576px){article{--block-spacing-horizontal:calc(var(--spacing) * 1.25)}}@media (min-width:768px){article{--block-spacing-horizontal:calc(var(--spacing) * 1.5)}}@media (min-width:992px){article{--block-spacing-horizontal:calc(var(--spacing) * 1.75)}}@media (min-width:1200px){article{--block-spacing-horizontal:calc(var(--spacing) * 2)}}dialog>article{--block-spacing-vertical:calc(var(--spacing) * 2);--block-spacing-horizontal:var(--spacing)}@media (min-width:576px){dialog>article{--block-spacing-vertical:calc(var(--spacing) * 2.5);--block-spacing-horizontal:calc(var(--spacing) * 1.25)}}@media (min-width:768px){dialog>article{--block-spacing-vertical:calc(var(--spacing) * 3);--block-spacing-horizontal:calc(var(--spacing) * 1.5)}}a{--text-decoration:none}a.contrast,a.secondary{--text-decoration:underline}small{--font-size:0.875em}h1,h2,h3,h4,h5,h6{--font-weight:700}h1{--font-size:2rem;--typography-spacing-vertical:3rem}h2{--font-size:1.75rem;--typography-spacing-vertical:2.625rem}h3{--font-size:1.5rem;--typography-spacing-vertical:2.25rem}h4{--font-size:1.25rem;--typography-spacing-vertical:1.874rem}h5{--font-size:1.125rem;--typography-spacing-vertical:1.6875rem}[type=checkbox],[type=radio]{--border-width:2px}[type=checkbox][role=switch]{--border-width:3px}tfoot td,tfoot th,thead td,thead th{--border-width:3px}:not(thead):not(tfoot)>*>td{--font-size:0.875em}code,kbd,pre,samp{--font-family:"Menlo","Consolas","Roboto Mono","Ubuntu Monospace","Noto Mono","Oxygen Mono","Liberation Mono",monospace,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"}kbd{--font-weight:bolder}:root:not([data-theme=dark]),[data-theme=light]{color-scheme:light;--background-color:#fff;--color:hsl(205deg, 20%, 32%);--h1-color:hsl(205deg, 30%, 15%);--h2-color:#24333e;--h3-color:hsl(205deg, 25%, 23%);--h4-color:#374956;--h5-color:hsl(205deg, 20%, 32%);--h6-color:#4d606d;--muted-color:hsl(205deg, 10%, 50%);--muted-border-color:hsl(205deg, 20%, 94%);--primary:hsl(195deg, 85%, 41%);--primary-hover:hsl(195deg, 90%, 32%);--primary-focus:rgba(16, 149, 193, 0.125);--primary-inverse:#fff;--secondary:hsl(205deg, 15%, 41%);--secondary-hover:hsl(205deg, 20%, 32%);--secondary-focus:rgba(89, 107, 120, 0.125);--secondary-inverse:#fff;--contrast:hsl(205deg, 30%, 15%);--contrast-hover:#000;--contrast-focus:rgba(89, 107, 120, 0.125);--contrast-inverse:#fff;--mark-background-color:#fff2ca;--mark-color:#543a26;--ins-color:#388e3c;--del-color:#c62828;--blockquote-border-color:var(--muted-border-color);--blockquote-footer-color:var(--muted-color);--button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--form-element-background-color:transparent;--form-element-border-color:hsl(205deg, 14%, 68%);--form-element-color:var(--color);--form-element-placeholder-color:var(--muted-color);--form-element-active-background-color:transparent;--form-element-active-border-color:var(--primary);--form-element-focus-color:var(--primary-focus);--form-element-disabled-background-color:hsl(205deg, 18%, 86%);--form-element-disabled-border-color:hsl(205deg, 14%, 68%);--form-element-disabled-opacity:0.5;--form-element-invalid-border-color:#c62828;--form-element-invalid-active-border-color:#d32f2f;--form-element-invalid-focus-color:rgba(211, 47, 47, 0.125);--form-element-valid-border-color:#388e3c;--form-element-valid-active-border-color:#43a047;--form-element-valid-focus-color:rgba(67, 160, 71, 0.125);--switch-background-color:hsl(205deg, 16%, 77%);--switch-color:var(--primary-inverse);--switch-checked-background-color:var(--primary);--range-border-color:hsl(205deg, 18%, 86%);--range-active-border-color:hsl(205deg, 16%, 77%);--range-thumb-border-color:var(--background-color);--range-thumb-color:var(--secondary);--range-thumb-hover-color:var(--secondary-hover);--range-thumb-active-color:var(--primary);--table-border-color:var(--muted-border-color);--table-row-stripped-background-color:#f6f8f9;--code-background-color:hsl(205deg, 20%, 94%);--code-color:var(--muted-color);--code-kbd-background-color:var(--contrast);--code-kbd-color:var(--contrast-inverse);--code-tag-color:hsl(330deg, 40%, 50%);--code-property-color:hsl(185deg, 40%, 40%);--code-value-color:hsl(40deg, 20%, 50%);--code-comment-color:hsl(205deg, 14%, 68%);--accordion-border-color:var(--muted-border-color);--accordion-close-summary-color:var(--color);--accordion-open-summary-color:var(--muted-color);--card-background-color:var(--background-color);--card-border-color:var(--muted-border-color);--card-box-shadow:0.0145rem 0.029rem 0.174rem rgba(27, 40, 50, 0.01698),0.0335rem 0.067rem 0.402rem rgba(27, 40, 50, 0.024),0.0625rem 0.125rem 0.75rem rgba(27, 40, 50, 0.03),0.1125rem 0.225rem 1.35rem rgba(27, 40, 50, 0.036),0.2085rem 0.417rem 2.502rem rgba(27, 40, 50, 0.04302),0.5rem 1rem 6rem rgba(27, 40, 50, 0.06),0 0 0 0.0625rem rgba(27, 40, 50, 0.015);--card-sectionning-background-color:#fbfbfc;--dropdown-background-color:#fbfbfc;--dropdown-border-color:#e1e6eb;--dropdown-box-shadow:var(--card-box-shadow);--dropdown-color:var(--color);--dropdown-hover-background-color:hsl(205deg, 20%, 94%);--modal-overlay-background-color:rgba(213, 220, 226, 0.8);--progress-background-color:hsl(205deg, 18%, 86%);--progress-color:var(--primary);--loading-spinner-opacity:0.5;--tooltip-background-color:var(--contrast);--tooltip-color:var(--contrast-inverse);--icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23FFF' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(65, 84, 98, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(255, 255, 255, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button-inverse:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(255, 255, 255, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(115, 130, 140, 0.999)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(65, 84, 98, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(198, 40, 40, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");--icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23FFF' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(65, 84, 98, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(65, 84, 98, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(56, 142, 60, 0.999)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E")}@media only screen and (prefers-color-scheme:dark){:root:not([data-theme=light]){color-scheme:dark;--background-color:#11191f;--color:hsl(205deg, 16%, 77%);--h1-color:hsl(205deg, 20%, 94%);--h2-color:#e1e6eb;--h3-color:hsl(205deg, 18%, 86%);--h4-color:#c8d1d8;--h5-color:hsl(205deg, 16%, 77%);--h6-color:#afbbc4;--muted-color:hsl(205deg, 10%, 50%);--muted-border-color:#1f2d38;--primary:hsl(195deg, 85%, 41%);--primary-hover:hsl(195deg, 80%, 50%);--primary-focus:rgba(16, 149, 193, 0.25);--primary-inverse:#fff;--secondary:hsl(205deg, 15%, 41%);--secondary-hover:hsl(205deg, 10%, 50%);--secondary-focus:rgba(115, 130, 140, 0.25);--secondary-inverse:#fff;--contrast:hsl(205deg, 20%, 94%);--contrast-hover:#fff;--contrast-focus:rgba(115, 130, 140, 0.25);--contrast-inverse:#000;--mark-background-color:#d1c284;--mark-color:#11191f;--ins-color:#388e3c;--del-color:#c62828;--blockquote-border-color:var(--muted-border-color);--blockquote-footer-color:var(--muted-color);--button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--form-element-background-color:#11191f;--form-element-border-color:#374956;--form-element-color:var(--color);--form-element-placeholder-color:var(--muted-color);--form-element-active-background-color:var(--form-element-background-color);--form-element-active-border-color:var(--primary);--form-element-focus-color:var(--primary-focus);--form-element-disabled-background-color:hsl(205deg, 25%, 23%);--form-element-disabled-border-color:hsl(205deg, 20%, 32%);--form-element-disabled-opacity:0.5;--form-element-invalid-border-color:#b71c1c;--form-element-invalid-active-border-color:#c62828;--form-element-invalid-focus-color:rgba(198, 40, 40, 0.25);--form-element-valid-border-color:#2e7d32;--form-element-valid-active-border-color:#388e3c;--form-element-valid-focus-color:rgba(56, 142, 60, 0.25);--switch-background-color:#374956;--switch-color:var(--primary-inverse);--switch-checked-background-color:var(--primary);--range-border-color:#24333e;--range-active-border-color:hsl(205deg, 25%, 23%);--range-thumb-border-color:var(--background-color);--range-thumb-color:var(--secondary);--range-thumb-hover-color:var(--secondary-hover);--range-thumb-active-color:var(--primary);--table-border-color:var(--muted-border-color);--table-row-stripped-background-color:rgba(115, 130, 140, 0.05);--code-background-color:#18232c;--code-color:var(--muted-color);--code-kbd-background-color:var(--contrast);--code-kbd-color:var(--contrast-inverse);--code-tag-color:hsl(330deg, 30%, 50%);--code-property-color:hsl(185deg, 30%, 50%);--code-value-color:hsl(40deg, 10%, 50%);--code-comment-color:#4d606d;--accordion-border-color:var(--muted-border-color);--accordion-active-summary-color:var(--primary);--accordion-close-summary-color:var(--color);--accordion-open-summary-color:var(--muted-color);--card-background-color:#141e26;--card-border-color:var(--card-background-color);--card-box-shadow:0.0145rem 0.029rem 0.174rem rgba(0, 0, 0, 0.01698),0.0335rem 0.067rem 0.402rem rgba(0, 0, 0, 0.024),0.0625rem 0.125rem 0.75rem rgba(0, 0, 0, 0.03),0.1125rem 0.225rem 1.35rem rgba(0, 0, 0, 0.036),0.2085rem 0.417rem 2.502rem rgba(0, 0, 0, 0.04302),0.5rem 1rem 6rem rgba(0, 0, 0, 0.06),0 0 0 0.0625rem rgba(0, 0, 0, 0.015);--card-sectionning-background-color:#18232c;--dropdown-background-color:hsl(205deg, 30%, 15%);--dropdown-border-color:#24333e;--dropdown-box-shadow:var(--card-box-shadow);--dropdown-color:var(--color);--dropdown-hover-background-color:rgba(36, 51, 62, 0.75);--modal-overlay-background-color:rgba(36, 51, 62, 0.9);--progress-background-color:#24333e;--progress-color:var(--primary);--loading-spinner-opacity:0.5;--tooltip-background-color:var(--contrast);--tooltip-color:var(--contrast-inverse);--icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23FFF' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(255, 255, 255, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button-inverse:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(0, 0, 0, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(115, 130, 140, 0.999)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(183, 28, 28, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");--icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23FFF' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(46, 125, 50, 0.999)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E")}}[data-theme=dark]{color-scheme:dark;--background-color:#11191f;--color:hsl(205deg, 16%, 77%);--h1-color:hsl(205deg, 20%, 94%);--h2-color:#e1e6eb;--h3-color:hsl(205deg, 18%, 86%);--h4-color:#c8d1d8;--h5-color:hsl(205deg, 16%, 77%);--h6-color:#afbbc4;--muted-color:hsl(205deg, 10%, 50%);--muted-border-color:#1f2d38;--primary:hsl(195deg, 85%, 41%);--primary-hover:hsl(195deg, 80%, 50%);--primary-focus:rgba(16, 149, 193, 0.25);--primary-inverse:#fff;--secondary:hsl(205deg, 15%, 41%);--secondary-hover:hsl(205deg, 10%, 50%);--secondary-focus:rgba(115, 130, 140, 0.25);--secondary-inverse:#fff;--contrast:hsl(205deg, 20%, 94%);--contrast-hover:#fff;--contrast-focus:rgba(115, 130, 140, 0.25);--contrast-inverse:#000;--mark-background-color:#d1c284;--mark-color:#11191f;--ins-color:#388e3c;--del-color:#c62828;--blockquote-border-color:var(--muted-border-color);--blockquote-footer-color:var(--muted-color);--button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--form-element-background-color:#11191f;--form-element-border-color:#374956;--form-element-color:var(--color);--form-element-placeholder-color:var(--muted-color);--form-element-active-background-color:var(--form-element-background-color);--form-element-active-border-color:var(--primary);--form-element-focus-color:var(--primary-focus);--form-element-disabled-background-color:hsl(205deg, 25%, 23%);--form-element-disabled-border-color:hsl(205deg, 20%, 32%);--form-element-disabled-opacity:0.5;--form-element-invalid-border-color:#b71c1c;--form-element-invalid-active-border-color:#c62828;--form-element-invalid-focus-color:rgba(198, 40, 40, 0.25);--form-element-valid-border-color:#2e7d32;--form-element-valid-active-border-color:#388e3c;--form-element-valid-focus-color:rgba(56, 142, 60, 0.25);--switch-background-color:#374956;--switch-color:var(--primary-inverse);--switch-checked-background-color:var(--primary);--range-border-color:#24333e;--range-active-border-color:hsl(205deg, 25%, 23%);--range-thumb-border-color:var(--background-color);--range-thumb-color:var(--secondary);--range-thumb-hover-color:var(--secondary-hover);--range-thumb-active-color:var(--primary);--table-border-color:var(--muted-border-color);--table-row-stripped-background-color:rgba(115, 130, 140, 0.05);--code-background-color:#18232c;--code-color:var(--muted-color);--code-kbd-background-color:var(--contrast);--code-kbd-color:var(--contrast-inverse);--code-tag-color:hsl(330deg, 30%, 50%);--code-property-color:hsl(185deg, 30%, 50%);--code-value-color:hsl(40deg, 10%, 50%);--code-comment-color:#4d606d;--accordion-border-color:var(--muted-border-color);--accordion-active-summary-color:var(--primary);--accordion-close-summary-color:var(--color);--accordion-open-summary-color:var(--muted-color);--card-background-color:#141e26;--card-border-color:var(--card-background-color);--card-box-shadow:0.0145rem 0.029rem 0.174rem rgba(0, 0, 0, 0.01698),0.0335rem 0.067rem 0.402rem rgba(0, 0, 0, 0.024),0.0625rem 0.125rem 0.75rem rgba(0, 0, 0, 0.03),0.1125rem 0.225rem 1.35rem rgba(0, 0, 0, 0.036),0.2085rem 0.417rem 2.502rem rgba(0, 0, 0, 0.04302),0.5rem 1rem 6rem rgba(0, 0, 0, 0.06),0 0 0 0.0625rem rgba(0, 0, 0, 0.015);--card-sectionning-background-color:#18232c;--dropdown-background-color:hsl(205deg, 30%, 15%);--dropdown-border-color:#24333e;--dropdown-box-shadow:var(--card-box-shadow);--dropdown-color:var(--color);--dropdown-hover-background-color:rgba(36, 51, 62, 0.75);--modal-overlay-background-color:rgba(36, 51, 62, 0.9);--progress-background-color:#24333e;--progress-color:var(--primary);--loading-spinner-opacity:0.5;--tooltip-background-color:var(--contrast);--tooltip-color:var(--contrast-inverse);--icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23FFF' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(255, 255, 255, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button-inverse:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(0, 0, 0, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(115, 130, 140, 0.999)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(183, 28, 28, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");--icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23FFF' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(162, 175, 185, 0.999)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgba(46, 125, 50, 0.999)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E")}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;text-rendering:optimizeLegibility;background-color:var(--background-color);color:var(--color);font-weight:var(--font-weight);font-size:var(--font-size);line-height:var(--line-height);font-family:var(--font-family);overflow-wrap:break-word;cursor:default;-moz-tab-size:4;-o-tab-size:4;tab-size:4}main{display:block}body{width:100%;margin:0}body>footer,body>header,body>main{width:100%;margin-right:auto;margin-left:auto;padding:var(--block-spacing-vertical) 0}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--spacing);padding-left:var(--spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:992px){.container{max-width:920px}}@media (min-width:1200px){.container{max-width:1130px}}section{margin-bottom:var(--block-spacing-vertical)}.grid{grid-column-gap:var(--grid-spacing-horizontal);grid-row-gap:var(--grid-spacing-vertical);display:grid;grid-template-columns:1fr;margin:0}@media (min-width:992px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}figure{display:block;margin:0;padding:0;overflow-x:auto}figure figcaption{padding:calc(var(--spacing) * .5) 0;color:var(--muted-color)}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,figure,form,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--typography-spacing-vertical);color:var(--color);font-style:normal;font-weight:var(--font-weight);font-size:var(--font-size)}[role=link],a{--color:var(--primary);--background-color:transparent;outline:0;background-color:var(--background-color);color:var(--color);-webkit-text-decoration:var(--text-decoration);text-decoration:var(--text-decoration);transition:background-color var(--transition),color var(--transition),box-shadow var(--transition),-webkit-text-decoration var(--transition);transition:background-color var(--transition),color var(--transition),text-decoration var(--transition),box-shadow var(--transition);transition:background-color var(--transition),color var(--transition),text-decoration var(--transition),box-shadow var(--transition),-webkit-text-decoration var(--transition)}[role=link]:is([aria-current],:hover,:active,:focus),a:is([aria-current],:hover,:active,:focus){--color:var(--primary-hover);--text-decoration:underline}[role=link]:focus,a:focus{--background-color:var(--primary-focus)}[role=link].secondary,a.secondary{--color:var(--secondary)}[role=link].secondary:is([aria-current],:hover,:active,:focus),a.secondary:is([aria-current],:hover,:active,:focus){--color:var(--secondary-hover)}[role=link].secondary:focus,a.secondary:focus{--background-color:var(--secondary-focus)}[role=link].contrast,a.contrast{--color:var(--contrast)}[role=link].contrast:is([aria-current],:hover,:active,:focus),a.contrast:is([aria-current],:hover,:active,:focus){--color:var(--contrast-hover)}[role=link].contrast:focus,a.contrast:focus{--background-color:var(--contrast-focus)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--typography-spacing-vertical);color:var(--color);font-weight:var(--font-weight);font-size:var(--font-size);font-family:var(--font-family)}h1{--color:var(--h1-color)}h2{--color:var(--h2-color)}h3{--color:var(--h3-color)}h4{--color:var(--h4-color)}h5{--color:var(--h5-color)}h6{--color:var(--h6-color)}:where(address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--typography-spacing-vertical)}.headings,hgroup{margin-bottom:var(--typography-spacing-vertical)}.headings>*,hgroup>*{margin-bottom:0}.headings>:last-child,hgroup>:last-child{--color:var(--muted-color);--font-weight:unset;font-size:1rem;font-family:unset}p{margin-bottom:var(--typography-spacing-vertical)}small{font-size:var(--font-size)}:where(dl,ol,ul){padding-right:0;padding-left:var(--spacing);-webkit-padding-start:var(--spacing);padding-inline-start:var(--spacing);-webkit-padding-end:0;padding-inline-end:0}:where(dl,ol,ul) li{margin-bottom:calc(var(--typography-spacing-vertical) * .25)}:where(dl,ol,ul) :is(dl,ol,ul){margin:0;margin-top:calc(var(--typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--mark-background-color);color:var(--mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--typography-spacing-vertical) 0;padding:var(--spacing);border-right:none;border-left:.25rem solid var(--blockquote-border-color);-webkit-border-start:0.25rem solid var(--blockquote-border-color);border-inline-start:0.25rem solid var(--blockquote-border-color);-webkit-border-end:none;border-inline-end:none}blockquote footer{margin-top:calc(var(--typography-spacing-vertical) * .5);color:var(--blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--ins-color);text-decoration:none}del{color:var(--del-color)}::-moz-selection{background-color:var(--primary-focus)}::selection{background-color:var(--primary-focus)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:root){overflow:hidden}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}button{display:block;width:100%;margin-bottom:var(--spacing)}[role=button]{display:inline-block;text-decoration:none}[role=button],button,input[type=button],input[type=reset],input[type=submit]{--background-color:var(--primary);--border-color:var(--primary);--color:var(--primary-inverse);--box-shadow:var(--button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--form-element-spacing-vertical) var(--form-element-spacing-horizontal);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[role=button]:is([aria-current],:hover,:active,:focus),button:is([aria-current],:hover,:active,:focus),input[type=button]:is([aria-current],:hover,:active,:focus),input[type=reset]:is([aria-current],:hover,:active,:focus),input[type=submit]:is([aria-current],:hover,:active,:focus){--background-color:var(--primary-hover);--border-color:var(--primary-hover);--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--color:var(--primary-inverse)}[role=button]:focus,button:focus,input[type=button]:focus,input[type=reset]:focus,input[type=submit]:focus{--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--outline-width) var(--primary-focus)}:is(button,input[type=submit],input[type=button],[role=button]).secondary,input[type=reset]{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);cursor:pointer}:is(button,input[type=submit],input[type=button],[role=button]).secondary:is([aria-current],:hover,:active,:focus),input[type=reset]:is([aria-current],:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover);--color:var(--secondary-inverse)}:is(button,input[type=submit],input[type=button],[role=button]).secondary:focus,input[type=reset]:focus{--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--outline-width) var(--secondary-focus)}:is(button,input[type=submit],input[type=button],[role=button]).contrast{--background-color:var(--contrast);--border-color:var(--contrast);--color:var(--contrast-inverse)}:is(button,input[type=submit],input[type=button],[role=button]).contrast:is([aria-current],:hover,:active,:focus){--background-color:var(--contrast-hover);--border-color:var(--contrast-hover);--color:var(--contrast-inverse)}:is(button,input[type=submit],input[type=button],[role=button]).contrast:focus{--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--outline-width) var(--contrast-focus)}:is(button,input[type=submit],input[type=button],[role=button]).outline,input[type=reset].outline{--background-color:transparent;--color:var(--primary)}:is(button,input[type=submit],input[type=button],[role=button]).outline:is([aria-current],:hover,:active,:focus),input[type=reset].outline:is([aria-current],:hover,:active,:focus){--background-color:transparent;--color:var(--primary-hover)}:is(button,input[type=submit],input[type=button],[role=button]).outline.secondary,input[type=reset].outline{--color:var(--secondary)}:is(button,input[type=submit],input[type=button],[role=button]).outline.secondary:is([aria-current],:hover,:active,:focus),input[type=reset].outline:is([aria-current],:hover,:active,:focus){--color:var(--secondary-hover)}:is(button,input[type=submit],input[type=button],[role=button]).outline.contrast{--color:var(--contrast)}:is(button,input[type=submit],input[type=button],[role=button]).outline.contrast:is([aria-current],:hover,:active,:focus){--color:var(--contrast-hover)}:where(button,[type=submit],[type=button],[type=reset],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]),a[role=button]:not([href]){opacity:.5;pointer-events:none}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox]):not([type=radio]):not([type=range]){height:calc(1rem * var(--line-height) + var(--form-element-spacing-vertical) * 2 + var(--border-width) * 2)}fieldset{margin:0;margin-bottom:var(--spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--spacing) * .25);font-weight:var(--form-label-font-weight,var(--font-weight))}input:not([type=checkbox]):not([type=radio]),select,textarea{width:100%}input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--form-element-spacing-vertical) var(--form-element-spacing-horizontal)}input,select,textarea{--background-color:var(--form-element-background-color);--border-color:var(--form-element-border-color);--color:var(--form-element-color);--box-shadow:none;border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}:where(select,textarea):is(:active,:focus),input:not([type=submit]):not([type=button]):not([type=reset]):not([type=checkbox]):not([type=radio]):not([readonly]):is(:active,:focus){--background-color:var(--form-element-active-background-color)}:where(select,textarea):is(:active,:focus),input:not([type=submit]):not([type=button]):not([type=reset]):not([role=switch]):not([readonly]):is(:active,:focus){--border-color:var(--form-element-active-border-color)}input:not([type=submit]):not([type=button]):not([type=reset]):not([type=range]):not([type=file]):not([readonly]):focus,select:focus,textarea:focus{--box-shadow:0 0 0 var(--outline-width) var(--form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit]):not([type=button]):not([type=reset]),select,textarea),input:not([type=submit]):not([type=button]):not([type=reset])[disabled],select[disabled],textarea[disabled]{--background-color:var(--form-element-disabled-background-color);--border-color:var(--form-element-disabled-border-color);opacity:var(--form-element-disabled-opacity);pointer-events:none}:where(input,select,textarea):not([type=checkbox]):not([type=radio])[aria-invalid]{padding-right:calc(var(--form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--form-element-spacing-horizontal);-webkit-padding-start:var(--form-element-spacing-horizontal)!important;padding-inline-start:var(--form-element-spacing-horizontal)!important;-webkit-padding-end:calc(var(--form-element-spacing-horizontal) + 1.5rem)!important;padding-inline-end:calc(var(--form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox]):not([type=radio])[aria-invalid=false]{background-image:var(--icon-valid)}:where(input,select,textarea):not([type=checkbox]):not([type=radio])[aria-invalid=true]{background-image:var(--icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--border-color:var(--form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--border-color:var(--form-element-valid-active-border-color)!important;--box-shadow:0 0 0 var(--outline-width) var(--form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--border-color:var(--form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--border-color:var(--form-element-invalid-active-border-color)!important;--box-shadow:0 0 0 var(--outline-width) var(--form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox]):not([type=radio])[aria-invalid=false],[dir=rtl] :where(input,select,textarea):not([type=checkbox]):not([type=radio])[aria-invalid=true],[dir=rtl] :where(input,select,textarea):not([type=checkbox]):not([type=radio])[aria-invalid]{background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--form-element-placeholder-color);opacity:1}input:not([type=checkbox]):not([type=radio]),select,textarea{margin-bottom:var(--spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple]):not([size]){padding-right:calc(var(--form-element-spacing-horizontal) + 1.5rem);padding-left:var(--form-element-spacing-horizontal);-webkit-padding-start:var(--form-element-spacing-horizontal);padding-inline-start:var(--form-element-spacing-horizontal);-webkit-padding-end:calc(var(--form-element-spacing-horizontal) + 1.5rem);padding-inline-end:calc(var(--form-element-spacing-horizontal) + 1.5rem);background-image:var(--icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}[dir=rtl] select:not([multiple]):not([size]){background-position:center left .75rem}:where(input,select,textarea)+small{display:block;width:100%;margin-top:calc(var(--spacing) * -.75);margin-bottom:var(--spacing);color:var(--muted-color)}label>:where(input,select,textarea){margin-top:calc(var(--spacing) * .25)}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-right:.375em;margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:.375em;margin-inline-end:.375em;border-width:var(--border-width);font-size:inherit;vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--background-color:var(--primary);--border-color:var(--primary);background-image:var(--icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-right:.375em;margin-bottom:0;cursor:pointer}[type=checkbox]:indeterminate{--background-color:var(--primary);--border-color:var(--primary);background-image:var(--icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--background-color:var(--primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--background-color:var(--switch-background-color);--border-color:var(--switch-background-color);--color:var(--switch-color);width:2.25em;height:1.25em;border:var(--border-width) solid var(--border-color);border-radius:1.25em;background-color:var(--background-color);line-height:1.25em}[type=checkbox][role=switch]:focus{--background-color:var(--switch-background-color);--border-color:var(--switch-background-color)}[type=checkbox][role=switch]:checked{--background-color:var(--switch-checked-background-color);--border-color:var(--switch-checked-background-color)}[type=checkbox][role=switch]:before{display:block;width:calc(1.25em - (var(--border-width) * 2));height:100%;border-radius:50%;background-color:var(--color);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:checked{background-image:none}[type=checkbox][role=switch]:checked::before{margin-left:calc(1.125em - var(--border-width));-webkit-margin-start:calc(1.125em - var(--border-width));margin-inline-start:calc(1.125em - var(--border-width))}[type=checkbox]:checked[aria-invalid=false],[type=checkbox][aria-invalid=false],[type=checkbox][role=switch]:checked[aria-invalid=false],[type=checkbox][role=switch][aria-invalid=false],[type=radio]:checked[aria-invalid=false],[type=radio][aria-invalid=false]{--border-color:var(--form-element-valid-border-color)}[type=checkbox]:checked[aria-invalid=true],[type=checkbox][aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=checkbox][role=switch][aria-invalid=true],[type=radio]:checked[aria-invalid=true],[type=radio][aria-invalid=true]{--border-color:var(--form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--border-radius) * .5)}input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=date],input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=datetime-local],input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=month],input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=time],input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=week]{--icon-position:0.75rem;--icon-width:1rem;padding-right:calc(var(--icon-width) + var(--icon-position));background-image:var(--icon-date);background-position:center right var(--icon-position);background-size:var(--icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=time]{background-image:var(--icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--icon-width);margin-right:calc(var(--icon-width) * -1);margin-left:var(--icon-position);opacity:0}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--color:var(--muted-color);padding:calc(var(--form-element-spacing-vertical) * .5) 0;border:0;border-radius:0;background:0 0}[type=file]::-webkit-file-upload-button{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:calc(var(--spacing)/ 2);margin-inline-end:calc(var(--spacing)/ 2);padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;-webkit-transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::file-selector-button{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:calc(var(--spacing)/ 2);margin-inline-end:calc(var(--spacing)/ 2);padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::-webkit-file-upload-button:is(:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=file]::file-selector-button:is(:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=file]::-webkit-file-upload-button{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:calc(var(--spacing)/ 2);margin-inline-end:calc(var(--spacing)/ 2);padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;-webkit-transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::-webkit-file-upload-button:is(:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=file]::-ms-browse{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;margin-inline-start:0;margin-inline-end:calc(var(--spacing)/ 2);padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;-ms-transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::-ms-browse:is(:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.25rem;border-radius:var(--border-radius);background-color:var(--range-border-color);-webkit-transition:background-color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),box-shadow var(--transition)}[type=range]::-moz-range-track{width:100%;height:.25rem;border-radius:var(--border-radius);background-color:var(--range-border-color);-moz-transition:background-color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),box-shadow var(--transition)}[type=range]::-ms-track{width:100%;height:.25rem;border-radius:var(--border-radius);background-color:var(--range-border-color);-ms-transition:background-color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),box-shadow var(--transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.5rem;border:2px solid var(--range-thumb-border-color);border-radius:50%;background-color:var(--range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--transition),transform var(--transition);transition:background-color var(--transition),transform var(--transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.5rem;border:2px solid var(--range-thumb-border-color);border-radius:50%;background-color:var(--range-thumb-color);cursor:pointer;-moz-transition:background-color var(--transition),transform var(--transition);transition:background-color var(--transition),transform var(--transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.5rem;border:2px solid var(--range-thumb-border-color);border-radius:50%;background-color:var(--range-thumb-color);cursor:pointer;-ms-transition:background-color var(--transition),transform var(--transition);transition:background-color var(--transition),transform var(--transition)}[type=range]:focus,[type=range]:hover{--range-border-color:var(--range-active-border-color);--range-thumb-color:var(--range-thumb-hover-color)}[type=range]:active{--range-thumb-color:var(--range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=search]{-webkit-padding-start:calc(var(--form-element-spacing-horizontal) + 1.75rem);padding-inline-start:calc(var(--form-element-spacing-horizontal) + 1.75rem);border-radius:5rem;background-image:var(--icon-search);background-position:center left 1.125rem;background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=search][aria-invalid]{-webkit-padding-start:calc(var(--form-element-spacing-horizontal) + 1.75rem)!important;padding-inline-start:calc(var(--form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=search][aria-invalid=false]{background-image:var(--icon-search),var(--icon-valid)}input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=search][aria-invalid=true]{background-image:var(--icon-search),var(--icon-invalid)}[type=search]::-webkit-search-cancel-button{-webkit-appearance:none;display:none}[dir=rtl] :where(input):not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--spacing)/ 2) var(--spacing);border-bottom:var(--border-width) solid var(--table-border-color);color:var(--color);font-weight:var(--font-weight);font-size:var(--font-size);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--border-width) solid var(--table-border-color);border-bottom:0}table[role=grid] tbody tr:nth-child(odd){background-color:var(--table-row-stripped-background-color)}code,kbd,pre,samp{font-size:.875em;font-family:var(--font-family)}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre{border-radius:var(--border-radius);background:var(--code-background-color);color:var(--code-color);font-weight:var(--font-weight);line-height:initial}code,kbd{display:inline-block;padding:.375rem .5rem}pre{display:block;margin-bottom:var(--spacing);overflow-x:auto}pre>code{display:block;padding:var(--spacing);background:0 0;font-size:14px;line-height:var(--line-height)}code b{color:var(--code-tag-color);font-weight:var(--font-weight)}code i{color:var(--code-property-color);font-style:normal}code u{color:var(--code-value-color);text-decoration:none}code em{color:var(--code-comment-color);font-style:normal}kbd{background-color:var(--code-kbd-background-color);color:var(--code-kbd-color);vertical-align:baseline}hr{height:0;border:0;border-top:1px solid var(--muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}details{display:block;margin-bottom:var(--spacing);padding-bottom:var(--spacing);border-bottom:var(--border-width) solid var(--accordion-border-color)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--transition)}details summary:not([role]){color:var(--accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;-webkit-margin-start:calc(var(--spacing,1rem) * 0.5);margin-inline-start:calc(var(--spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--transition)}details summary:focus{outline:0}details summary:focus:not([role=button]){color:var(--accordion-active-summary-color)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--line-height,1.5));background-image:var(--icon-chevron-button)}details summary[role=button]:not(.outline).contrast::after{background-image:var(--icon-chevron-button-inverse)}details[open]>summary{margin-bottom:calc(var(--spacing))}details[open]>summary:not([role]):not(:focus){color:var(--accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin:var(--block-spacing-vertical) 0;padding:var(--block-spacing-vertical) var(--block-spacing-horizontal);border-radius:var(--border-radius);background:var(--card-background-color);box-shadow:var(--card-box-shadow)}article>footer,article>header{margin-right:calc(var(--block-spacing-horizontal) * -1);margin-left:calc(var(--block-spacing-horizontal) * -1);padding:calc(var(--block-spacing-vertical) * .66) var(--block-spacing-horizontal);background-color:var(--card-sectionning-background-color)}article>header{margin-top:calc(var(--block-spacing-vertical) * -1);margin-bottom:var(--block-spacing-vertical);border-bottom:var(--border-width) solid var(--card-border-color);border-top-right-radius:var(--border-radius);border-top-left-radius:var(--border-radius)}article>footer{margin-top:var(--block-spacing-vertical);margin-bottom:calc(var(--block-spacing-vertical) * -1);border-top:var(--border-width) solid var(--card-border-color);border-bottom-right-radius:var(--border-radius);border-bottom-left-radius:var(--border-radius)}:root{--scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:var(--spacing);border:0;background-color:var(--modal-overlay-background-color);color:var(--color)}dialog article{max-height:calc(100vh - var(--spacing) * 2);overflow:auto}@media (min-width:576px){dialog article{max-width:510px}}@media (min-width:768px){dialog article{max-width:700px}}dialog article>footer,dialog article>header{padding:calc(var(--block-spacing-vertical) * .5) var(--block-spacing-horizontal)}dialog article>header .close{margin:0;margin-left:var(--spacing);float:right}dialog article>footer{text-align:right}dialog article>footer [role=button]{margin-bottom:0}dialog article>footer [role=button]:not(:first-of-type){margin-left:calc(var(--spacing) * .5)}dialog article p:last-of-type{margin:0}dialog article .close{display:block;width:1rem;height:1rem;margin-top:calc(var(--block-spacing-vertical) * -.5);margin-bottom:var(--typography-spacing-vertical);margin-left:auto;background-image:var(--icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;opacity:.5;transition:opacity var(--transition)}dialog article .close:is([aria-current],:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--scrollbar-width,0);overflow:hidden;pointer-events:none}.modal-is-open dialog{pointer-events:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{-webkit-animation-duration:.2s;animation-duration:.2s;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;-webkit-animation-fill-mode:both;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{-webkit-animation-duration:.8s;animation-duration:.8s;-webkit-animation-name:fadeIn;animation-name:fadeIn}:where(.modal-is-opening,.modal-is-closing) dialog>article{-webkit-animation-delay:.2s;animation-delay:.2s;-webkit-animation-name:slideInDown;animation-name:slideInDown}.modal-is-closing dialog,.modal-is-closing dialog>article{-webkit-animation-delay:0s;animation-delay:0s;animation-direction:reverse}@-webkit-keyframes fadeIn{from{background-color:transparent}to{background-color:var(--modal-overlay-background-color)}}@keyframes fadeIn{from{background-color:transparent}to{background-color:var(--modal-overlay-background-color)}}@-webkit-keyframes slideInDown{from{transform:translateY(-100%);opacity:0}to{transform:translateY(0);opacity:1}}@keyframes slideInDown{from{transform:translateY(-100%);opacity:0}to{transform:translateY(0);opacity:1}}:where(nav li)::before{float:left;content:"​"}nav,nav ul{display:flex}nav{justify-content:space-between}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--nav-element-spacing-vertical) var(--nav-element-spacing-horizontal)}nav li>*{--spacing:0}nav :where(a,[role=link]){display:inline-block;margin:calc(var(--nav-link-spacing-vertical) * -1) calc(var(--nav-link-spacing-horizontal) * -1);padding:var(--nav-link-spacing-vertical) var(--nav-link-spacing-horizontal);border-radius:var(--border-radius);text-decoration:none}nav :where(a,[role=link]):is([aria-current],:hover,:active,:focus){text-decoration:none}nav [role=button]{margin-right:inherit;margin-left:inherit;padding:var(--nav-link-spacing-vertical) var(--nav-link-spacing-horizontal)}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--nav-element-spacing-vertical) * .5) var(--nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--spacing) * .5);overflow:hidden;border:0;border-radius:var(--border-radius);background-color:var(--progress-background-color);color:var(--progress-color)}progress::-webkit-progress-bar{border-radius:var(--border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--progress-color)}progress::-moz-progress-bar{background-color:var(--progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--progress-background-color) linear-gradient(to right,var(--progress-color) 30%,var(--progress-background-color) 30%) top left/150% 150% no-repeat;-webkit-animation:progressIndeterminate 1s linear infinite;animation:progressIndeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@-webkit-keyframes progressIndeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}@keyframes progressIndeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}details[role=list],li[role=list]{position:relative}details[role=list] summary+ul,li[role=list]>ul{display:flex;z-index:99;position:absolute;top:auto;right:0;left:0;flex-direction:column;margin:0;padding:0;border:var(--border-width) solid var(--dropdown-border-color);border-radius:var(--border-radius);border-top-right-radius:0;border-top-left-radius:0;background-color:var(--dropdown-background-color);box-shadow:var(--card-box-shadow);color:var(--dropdown-color);white-space:nowrap}details[role=list] summary+ul li,li[role=list]>ul li{width:100%;margin-bottom:0;padding:calc(var(--form-element-spacing-vertical) * .5) var(--form-element-spacing-horizontal);list-style:none}details[role=list] summary+ul li:first-of-type,li[role=list]>ul li:first-of-type{margin-top:calc(var(--form-element-spacing-vertical) * .5)}details[role=list] summary+ul li:last-of-type,li[role=list]>ul li:last-of-type{margin-bottom:calc(var(--form-element-spacing-vertical) * .5)}details[role=list] summary+ul li a,li[role=list]>ul li a{display:block;margin:calc(var(--form-element-spacing-vertical) * -.5) calc(var(--form-element-spacing-horizontal) * -1);padding:calc(var(--form-element-spacing-vertical) * .5) var(--form-element-spacing-horizontal);overflow:hidden;color:var(--dropdown-color);text-decoration:none;text-overflow:ellipsis}details[role=list] summary+ul li a:hover,li[role=list]>ul li a:hover{background-color:var(--dropdown-hover-background-color)}details[role=list] summary::after,li[role=list]>a::after{display:block;width:1rem;height:calc(1rem * var(--line-height,1.5));-webkit-margin-start:0.5rem;margin-inline-start:.5rem;float:right;transform:rotate(0);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}details[role=list]{padding:0;border-bottom:none}details[role=list] summary{margin-bottom:0}details[role=list] summary:not([role]){height:calc(1rem * var(--line-height) + var(--form-element-spacing-vertical) * 2 + var(--border-width) * 2);padding:var(--form-element-spacing-vertical) var(--form-element-spacing-horizontal);border:var(--border-width) solid var(--form-element-border-color);border-radius:var(--border-radius);background-color:var(--form-element-background-color);color:var(--form-element-placeholder-color);line-height:inherit;cursor:pointer;transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}details[role=list] summary:not([role]):active,details[role=list] summary:not([role]):focus{border-color:var(--form-element-active-border-color);background-color:var(--form-element-active-background-color)}details[role=list] summary:not([role]):focus{box-shadow:0 0 0 var(--outline-width) var(--form-element-focus-color)}details[role=list][open] summary{border-bottom-right-radius:0;border-bottom-left-radius:0}details[role=list][open] summary::before{display:block;z-index:1;position:fixed;top:0;right:0;bottom:0;left:0;background:0 0;content:"";cursor:default}nav details[role=list] summary,nav li[role=list] a{display:flex;direction:ltr}nav details[role=list] summary+ul,nav li[role=list]>ul{min-width:-webkit-fit-content;min-width:-moz-fit-content;min-width:fit-content;border-radius:var(--border-radius)}nav details[role=list] summary+ul li a,nav li[role=list]>ul li a{border-radius:0}nav details[role=list] summary,nav details[role=list] summary:not([role]){height:auto;padding:var(--nav-link-spacing-vertical) var(--nav-link-spacing-horizontal)}nav details[role=list][open] summary{border-radius:var(--border-radius)}nav details[role=list] summary+ul{margin-top:var(--outline-width);-webkit-margin-start:0;margin-inline-start:0}nav details[role=list] summary[role=link]{margin-bottom:calc(var(--nav-link-spacing-vertical) * -1);line-height:var(--line-height)}nav details[role=list] summary[role=link]+ul{margin-top:calc(var(--nav-link-spacing-vertical) + var(--outline-width));-webkit-margin-start:calc(var(--nav-link-spacing-horizontal) * -1);margin-inline-start:calc(var(--nav-link-spacing-horizontal) * -1)}li[role=list] a:active~ul,li[role=list] a:focus~ul,li[role=list]:hover>ul{display:flex}li[role=list]>ul{display:none;margin-top:calc(var(--nav-link-spacing-vertical) + var(--outline-width));-webkit-margin-start:calc(var(--nav-element-spacing-horizontal) - var(--nav-link-spacing-horizontal));margin-inline-start:calc(var(--nav-element-spacing-horizontal) - var(--nav-link-spacing-horizontal))}li[role=list]>a::after{background-image:var(--icon-chevron)}[aria-busy=true]{cursor:progress}[aria-busy=true]:not(input):not(select):not(textarea)::before{display:inline-block;width:1em;height:1em;border:.1875em solid currentColor;border-radius:1em;border-right-color:transparent;content:"";vertical-align:text-bottom;vertical-align:-.125em;-webkit-animation:spinner .75s linear infinite;animation:spinner .75s linear infinite;opacity:var(--loading-spinner-opacity)}[aria-busy=true]:not(input):not(select):not(textarea):not(:empty)::before{margin-right:calc(var(--spacing) * .5);margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:calc(var(--spacing) * .5);margin-inline-end:calc(var(--spacing) * .5)}[aria-busy=true]:not(input):not(select):not(textarea):empty{text-align:center}a[aria-busy=true],button[aria-busy=true],input[type=button][aria-busy=true],input[type=reset][aria-busy=true],input[type=submit][aria-busy=true]{pointer-events:none}@-webkit-keyframes spinner{to{transform:rotate(360deg)}}@keyframes spinner{to{transform:rotate(360deg)}}[data-tooltip]{position:relative}[data-tooltip]:not(a):not(button):not(input){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--border-radius);background:var(--tooltip-background-color);content:attr(data-tooltip);color:var(--tooltip-color);font-style:normal;font-weight:var(--font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--tooltip-background-color)}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{-webkit-animation-duration:.2s;animation-duration:.2s;-webkit-animation-name:slide;animation-name:slide}[data-tooltip]:focus::after,[data-tooltip]:hover::after{-webkit-animation-name:slideCaret;animation-name:slideCaret}}@-webkit-keyframes slide{from{transform:translate(-50%,.75rem);opacity:0}to{transform:translate(-50%,-.25rem);opacity:1}}@keyframes slide{from{transform:translate(-50%,.75rem);opacity:0}to{transform:translate(-50%,-.25rem);opacity:1}}@-webkit-keyframes slideCaret{from{opacity:0}50%{transform:translate(-50%,-.25rem);opacity:0}to{transform:translate(-50%,0);opacity:1}}@keyframes slideCaret{from{opacity:0}50%{transform:translate(-50%,-.25rem);opacity:0}to{transform:translate(-50%,0);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;-webkit-animation-duration:1ms!important;animation-duration:1ms!important;-webkit-animation-delay:-1ms!important;animation-delay:-1ms!important;-webkit-animation-iteration-count:1!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}} -/*# sourceMappingURL=pico.min.css.map */ \ No newline at end of file diff --git a/embed/note/detail.html b/embed/note/detail.html deleted file mode 100644 index 0bf38cf..0000000 --- a/embed/note/detail.html +++ /dev/null @@ -1,47 +0,0 @@ -
    -
    - - # - Published: - - {{ if .Post.User.Config.AuthorName }} - by - - {{ if .Post.User.AvatarUrl }} - - {{ end }} - {{.Post.User.Config.AuthorName}} - - {{ end }} - -
    -
    -
    - -
    - {{.Content}} -
    - -
    - {{if .Post.ApprovedIncomingWebmentions}} -

    - Webmentions -

    - - {{end}} -
    \ No newline at end of file diff --git a/embed/page/detail.html b/embed/page/detail.html deleted file mode 100644 index c2f94b7..0000000 --- a/embed/page/detail.html +++ /dev/null @@ -1,34 +0,0 @@ -
    -
    -

    {{.Title}}

    - - # - -
    -
    -
    - -
    - {{.Content}} -
    - -
    - {{if .Post.ApprovedIncomingWebmentions}} -

    - Webmentions -

    - - {{end}} -
    \ No newline at end of file diff --git a/embed/photo/detail.html b/embed/photo/detail.html deleted file mode 100644 index b80e40a..0000000 --- a/embed/photo/detail.html +++ /dev/null @@ -1,52 +0,0 @@ -
    -
    -

    {{.Title}}

    - - # - Published: - - {{ if .Post.User.Config.AuthorName }} - by - - {{ if .Post.User.AvatarUrl }} - - {{ end }} - {{.Post.User.Config.AuthorName}} - - {{ end }} - -
    -
    -
    - - {{ if .Post.Meta.PhotoPath }} - {{.Post.Meta.Description}} - {{ end }} - -
    - {{.Content}} -
    - -
    - {{if .Post.ApprovedIncomingWebmentions}} -

    - Webmentions -

    - - {{end}} -
    \ No newline at end of file diff --git a/embed/post-list-photo.html b/embed/post-list-photo.html deleted file mode 100644 index 61b8f2c..0000000 --- a/embed/post-list-photo.html +++ /dev/null @@ -1,9 +0,0 @@ -
    - {{range .}} -
    - - {{.Meta.Description}} - -
    - {{end}} -
    \ No newline at end of file diff --git a/embed/post-list.html b/embed/post-list.html deleted file mode 100644 index 1d814be..0000000 --- a/embed/post-list.html +++ /dev/null @@ -1,25 +0,0 @@ -
    - {{range .}} -
    -
    - {{ if eq .Meta.Type "note"}} -
    - {{ if .Title }}{{.Title}}{{ else }}#{{.Id}}{{ end }} -
    -

    {{.RenderedContent | noescape}}

    - {{ else }} -

    - {{ if .Title }}{{.Title}}{{ else }}#{{.Id}}{{ end }} -

    - {{ end }} - - Published: - - -
    -
    -
    - {{end}} -
    \ No newline at end of file diff --git a/embed/post.html b/embed/post.html deleted file mode 100644 index b08301d..0000000 --- a/embed/post.html +++ /dev/null @@ -1,71 +0,0 @@ -
    -
    -

    {{.Title}}

    - - # - Published: - - {{ if .Post.User.Config.AuthorName }} - by - - {{ if .Post.User.AvatarUrl }} - - {{ end }} - {{.Post.User.Config.AuthorName}} - - {{ end }} - -
    -
    -
    - - {{ if .Post.Meta.Reply.Url }} -

    - In reply to: - {{ if .Post.Meta.Reply.Text }} - {{.Post.Meta.Reply.Text}} - {{ else }} - {{.Post.Meta.Reply.Url}} - {{ end }} - -

    - {{ end }} - - {{ if .Post.Meta.Bookmark.Url }} -

    - Bookmark: - {{ if .Post.Meta.Bookmark.Text }} - {{.Post.Meta.Bookmark.Text}} - {{ else }} - {{.Post.Meta.Bookmark.Url}} - {{ end }} - -

    - {{ end }} - -
    - {{.Content}} -
    - -
    - {{if .Post.ApprovedIncomingWebmentions}} -

    - Webmentions -

    - - {{end}} -
    \ No newline at end of file diff --git a/embed/recipe/detail.html b/embed/recipe/detail.html deleted file mode 100644 index 2b73808..0000000 --- a/embed/recipe/detail.html +++ /dev/null @@ -1,78 +0,0 @@ -
    -
    -

    {{.Title}}

    - - # - Published: - - {{ if .Post.User.Config.AuthorName }} - by - - {{ if .Post.User.AvatarUrl }} - - {{ end }} - {{.Post.User.Config.AuthorName}} - - {{ end }} - -
    -
    -
    - - -
    - - - {{ if .Post.Meta.Recipe.Yield }} - Servings: {{ .Post.Meta.Recipe.Yield }} - {{ if .Post.Meta.Recipe.Duration }}, {{end}} - - {{ end }} - - {{ if .Post.Meta.Recipe.Duration }} - Prep Time: - {{ end }} - - -

    Ingredients

    - -
      - {{ range $ingredient := .Post.Meta.Recipe.Ingredients }} -
    • - {{ $ingredient }} -
    • - {{ end }} -
    - -

    Instructions

    - -
    - {{.Content}} -
    -
    - -
    - {{if .Post.ApprovedIncomingWebmentions}} -

    - Webmentions -

    - - {{end}} -
    \ No newline at end of file diff --git a/embed/reply/detail.html b/embed/reply/detail.html deleted file mode 100644 index c74f6bd..0000000 --- a/embed/reply/detail.html +++ /dev/null @@ -1,60 +0,0 @@ -
    -
    -

    {{.Title}}

    - - # - Published: - - {{ if .Post.User.Config.AuthorName }} - by - - {{ if .Post.User.AvatarUrl }} - - {{ end }} - {{.Post.User.Config.AuthorName}} - - {{ end }} - -
    -
    -
    - - {{ if .Post.Meta.Reply.Url }} -

    - In reply to: - {{ if .Post.Meta.Reply.Text }} - {{.Post.Meta.Reply.Text}} - {{ else }} - {{.Post.Meta.Reply.Url}} - {{ end }} - -

    - {{ end }} - -
    - {{.Content}} -
    - -
    - {{if .Post.ApprovedIncomingWebmentions}} -

    - Webmentions -

    - - {{end}} -
    \ No newline at end of file diff --git a/embed/untyped/detail.html b/embed/untyped/detail.html deleted file mode 100644 index ece34bc..0000000 --- a/embed/untyped/detail.html +++ /dev/null @@ -1,72 +0,0 @@ -
    -
    -

    {{.Title}}

    - - # - Published: - - {{ if .Post.User.Config.AuthorName }} - by - - {{ if .Post.User.AvatarUrl }} - - {{ end }} - {{.Post.User.Config.AuthorName}} - - {{ end }} - -
    -
    -
    - - {{ if .Post.Meta.Reply.Url }} -

    - In reply to: - {{ if .Post.Meta.Reply.Text }} - {{.Post.Meta.Reply.Text}} - {{ else }} - {{.Post.Meta.Reply.Url}} - {{ end }} - -

    - {{ end }} - - {{ if .Post.Meta.Bookmark.Url }} -

    - Bookmark: - {{ if .Post.Meta.Bookmark.Text }} - {{.Post.Meta.Bookmark.Text}} - {{ else }} - {{.Post.Meta.Bookmark.Url}} - {{ end }} - -

    - {{ end }} - -
    - {{.Content}} -
    - -
    - {{if .Post.ApprovedIncomingWebmentions}} -

    - Webmentions -

    - - {{end}} -
    \ No newline at end of file diff --git a/embed/user-list.html b/embed/user-list.html deleted file mode 100644 index 13ec082..0000000 --- a/embed/user-list.html +++ /dev/null @@ -1,9 +0,0 @@ -{{range .}} - -{{end}} \ No newline at end of file diff --git a/files.go b/files.go deleted file mode 100644 index 9acc304..0000000 --- a/files.go +++ /dev/null @@ -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) -} diff --git a/fixtures/image.png b/fixtures/image.png deleted file mode 100644 index 538dcf9b926adeefbc840f544f550b0c41dfc8ff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2971 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 Bk@)}s diff --git a/go.mod b/go.mod deleted file mode 100644 index b6e1a3e..0000000 --- a/go.mod +++ /dev/null @@ -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 -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 910f1b6..0000000 --- a/go.sum +++ /dev/null @@ -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= diff --git a/html.go b/html.go deleted file mode 100644 index 7a86643..0000000 --- a/html.go +++ /dev/null @@ -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 -} diff --git a/http.go b/http.go deleted file mode 100644 index 7a2f106..0000000 --- a/http.go +++ /dev/null @@ -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 diff --git a/owl_test.go b/owl_test.go deleted file mode 100644 index 01c112a..0000000 --- a/owl_test.go +++ /dev/null @@ -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 -} diff --git a/post.go b/post.go deleted file mode 100644 index c759fd7..0000000 --- a/post.go +++ /dev/null @@ -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 -} diff --git a/post_test.go b/post_test.go deleted file mode 100644 index 4f5c727..0000000 --- a/post_test.go +++ /dev/null @@ -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 += "\n" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - html := post.RenderedContent() - assertions.AssertNotContains(t, html, "\n" - os.WriteFile(post.ContentFile(), []byte(content), 0644) - html := post.RenderedContent() - assertions.AssertContains(t, html, "\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)) -// } -// } diff --git a/release.sh b/release.sh deleted file mode 100755 index 5ffa699..0000000 --- a/release.sh +++ /dev/null @@ -1,2 +0,0 @@ -docker build . -t git.libove.org/h4kor/owl-blogs:$1 -docker push git.libove.org/h4kor/owl-blogs:$1 \ No newline at end of file diff --git a/renderer.go b/renderer.go deleted file mode 100644 index 3a5ece6..0000000 --- a/renderer.go +++ /dev/null @@ -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), - }) -} diff --git a/renderer_test.go b/renderer_test.go deleted file mode 100644 index 225cea7..0000000 --- a/renderer_test.go +++ /dev/null @@ -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, "") -} - -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, "") - assertions.AssertContains(t, result, "") - assertions.AssertContains(t, result, "") - assertions.AssertContains(t, result, "") - -} - -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, " 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") - -} diff --git a/rss.go b/rss.go deleted file mode 100644 index efc29d0..0000000 --- a/rss.go +++ /dev/null @@ -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 - -} diff --git a/rss_test.go b/rss_test.go deleted file mode 100644 index 7135a4a..0000000 --- a/rss_test.go +++ /dev/null @@ -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, "") - assertions.AssertContains(t, res, "") - -} - -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()) -} diff --git a/test/assertions/asserts.go b/test/assertions/asserts.go deleted file mode 100644 index a49d542..0000000 --- a/test/assertions/asserts.go +++ /dev/null @@ -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) - } -} diff --git a/test/mocks/mocks.go b/test/mocks/mocks.go deleted file mode 100644 index 07ac8e6..0000000 --- a/test/mocks/mocks.go +++ /dev/null @@ -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 -} diff --git a/user.go b/user.go deleted file mode 100644 index 1ac5974..0000000 --- a/user.go +++ /dev/null @@ -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 -} diff --git a/user_test.go b/user_test.go deleted file mode 100644 index bdac4c3..0000000 --- a/user_test.go +++ /dev/null @@ -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 += "\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") -} diff --git a/utils.go b/utils.go deleted file mode 100644 index 27083a2..0000000 --- a/utils.go +++ /dev/null @@ -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) -} diff --git a/webmention.go b/webmention.go deleted file mode 100644 index e0e0f94..0000000 --- a/webmention.go +++ /dev/null @@ -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 - } -} diff --git a/webmention_test.go b/webmention_test.go deleted file mode 100644 index 8de1420..0000000 --- a/webmention_test.go +++ /dev/null @@ -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("
    Foo
    ") - 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("
    Foo
    ") - 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("") - 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("") - 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("") - 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{"; 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{"; rel=\"other\", ; 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("") - 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("") - parser := &owl.OwlHtmlParser{} - resp := constructResponse(html) - resp.Header = http.Header{"Link": []string{"; 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) -// } -// } - -// } From 7060c54989f2d7501528ef87002554bb1f0b8a43 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Sun, 25 Jun 2023 20:04:06 +0200 Subject: [PATCH 02/41] init --- .gitignore | 26 +++++ app/repository/interfaces.go | 12 +++ domain/model/entry.go | 16 ++++ domain/model/image_entry.go | 40 ++++++++ go.mod | 13 +++ go.sum | 24 +++++ infra/entry_repository.go | 168 +++++++++++++++++++++++++++++++++ infra/entry_repository_test.go | 144 ++++++++++++++++++++++++++++ infra/interface.go | 7 ++ main.go | 30 ++++++ test/mock_db.go | 19 ++++ test/mock_entry.go | 43 +++++++++ 12 files changed, 542 insertions(+) create mode 100644 .gitignore create mode 100644 app/repository/interfaces.go create mode 100644 domain/model/entry.go create mode 100644 domain/model/image_entry.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 infra/entry_repository.go create mode 100644 infra/entry_repository_test.go create mode 100644 infra/interface.go create mode 100644 main.go create mode 100644 test/mock_db.go create mode 100644 test/mock_entry.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e610cac --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# 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 diff --git a/app/repository/interfaces.go b/app/repository/interfaces.go new file mode 100644 index 0000000..3f97dca --- /dev/null +++ b/app/repository/interfaces.go @@ -0,0 +1,12 @@ +package repository + +import "owl-blogs/domain/model" + +type EntryRepository interface { + RegisterEntryType(entry model.Entry) error + Create(entry model.Entry) error + Update(entry model.Entry) error + Delete(entry model.Entry) error + FindById(id string) (model.Entry, error) + FindAll(types *[]string) ([]model.Entry, error) +} diff --git a/domain/model/entry.go b/domain/model/entry.go new file mode 100644 index 0000000..0b119de --- /dev/null +++ b/domain/model/entry.go @@ -0,0 +1,16 @@ +package model + +import "time" + +type EntryContent string + +type Entry interface { + ID() string + Content() EntryContent + PublishedAt() *time.Time + MetaData() interface{} + Create(id string, content string, publishedAt *time.Time, metaData EntryMetaData) error +} + +type EntryMetaData interface { +} diff --git a/domain/model/image_entry.go b/domain/model/image_entry.go new file mode 100644 index 0000000..a60ea0c --- /dev/null +++ b/domain/model/image_entry.go @@ -0,0 +1,40 @@ +package model + +import "time" + +type ImageEntry struct { + id string + content EntryContent + publishedAt *time.Time + ImagePath string +} + +type ImageEntryMetaData struct { + ImagePath string +} + +func (e *ImageEntry) ID() string { + return e.id +} + +func (e *ImageEntry) Content() EntryContent { + return e.content +} + +func (e *ImageEntry) PublishedAt() *time.Time { + return e.publishedAt +} + +func (e *ImageEntry) MetaData() interface{} { + return &ImageEntryMetaData{ + ImagePath: e.ImagePath, + } +} + +func (e *ImageEntry) Create(id string, content string, publishedAt *time.Time, metaData EntryMetaData) error { + e.id = id + e.content = EntryContent(content) + e.publishedAt = publishedAt + e.ImagePath = metaData.(*ImageEntryMetaData).ImagePath + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..67a03f1 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module owl-blogs + +go 1.20 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/jmoiron/sqlx v1.3.5 // indirect + github.com/mattn/go-sqlite3 v1.14.17 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.0 // indirect + github.com/stretchr/testify v1.8.4 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2c70d96 --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/infra/entry_repository.go b/infra/entry_repository.go new file mode 100644 index 0000000..f347347 --- /dev/null +++ b/infra/entry_repository.go @@ -0,0 +1,168 @@ +package infra + +import ( + "encoding/json" + "errors" + "owl-blogs/app/repository" + "owl-blogs/domain/model" + "reflect" + "strings" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +type sqlEntry struct { + Id string `db:"id"` + Type string `db:"type"` + Content string `db:"content"` + PublishedAt *time.Time `db:"published_at"` + MetaData *string `db:"meta_data"` +} + +type DefaultEntryRepo struct { + types map[string]model.Entry + db *sqlx.DB +} + +// Create implements repository.EntryRepository. +func (r *DefaultEntryRepo) Create(entry model.Entry) error { + exEntry, _ := r.FindById(entry.ID()) + if exEntry != nil { + return errors.New("entry already exists") + } + + t := r.entryType(entry) + if _, ok := r.types[t]; !ok { + return errors.New("entry type not registered") + } + + var metaDataJson []byte + if entry.MetaData() != nil { + metaDataJson, _ = json.Marshal(entry.MetaData()) + } + + _, err := r.db.Exec("INSERT INTO entries (id, type, content, published_at, meta_data) VALUES (?, ?, ?, ?, ?)", entry.ID(), t, entry.Content(), entry.PublishedAt(), metaDataJson) + return err +} + +// Delete implements repository.EntryRepository. +func (r *DefaultEntryRepo) Delete(entry model.Entry) error { + _, err := r.db.Exec("DELETE FROM entries WHERE id = ?", entry.ID()) + return err +} + +// FindAll implements repository.EntryRepository. +func (r *DefaultEntryRepo) FindAll(types *[]string) ([]model.Entry, error) { + filterStr := "" + if types != nil { + filters := []string{} + for _, t := range *types { + filters = append(filters, "type = '"+t+"'") + } + filterStr = strings.Join(filters, " OR ") + } + + var entries []sqlEntry + if filterStr != "" { + err := r.db.Select(&entries, "SELECT * FROM entries WHERE "+filterStr) + if err != nil { + return nil, err + } + } else { + err := r.db.Select(&entries, "SELECT * FROM entries") + if err != nil { + return nil, err + } + } + + result := []model.Entry{} + for _, entry := range entries { + e, err := r.sqlEntryToEntry(entry) + if err != nil { + return nil, err + } + result = append(result, e) + } + return result, nil +} + +// FindById implements repository.EntryRepository. +func (r *DefaultEntryRepo) FindById(id string) (model.Entry, error) { + data := sqlEntry{} + err := r.db.Get(&data, "SELECT * FROM entries WHERE id = ?", id) + if err != nil { + return nil, err + } + if data.Id == "" { + return nil, nil + } + return r.sqlEntryToEntry(data) +} + +// RegisterEntryType implements repository.EntryRepository. +func (r *DefaultEntryRepo) RegisterEntryType(entry model.Entry) error { + t := r.entryType(entry) + if _, ok := r.types[t]; ok { + return errors.New("entry type already registered") + } + r.types[t] = entry + return nil +} + +// Update implements repository.EntryRepository. +func (r *DefaultEntryRepo) Update(entry model.Entry) error { + exEntry, _ := r.FindById(entry.ID()) + if exEntry == nil { + return errors.New("entry not found") + } + + t := r.entryType(entry) + if _, ok := r.types[t]; !ok { + return errors.New("entry type not registered") + } + + var metaDataJson []byte + if entry.MetaData() != nil { + metaDataJson, _ = json.Marshal(entry.MetaData()) + } + + _, err := r.db.Exec("UPDATE entries SET content = ?, published_at = ?, meta_data = ? WHERE id = ?", entry.Content(), entry.PublishedAt(), metaDataJson, entry.ID()) + return err +} + +func NewEntryRepository(db Database) repository.EntryRepository { + sqlxdb := db.Get() + + // Create tables if not exists + sqlxdb.MustExec(` + CREATE TABLE IF NOT EXISTS entries ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + content TEXT NOT NULL, + published_at DATETIME, + meta_data TEXT NOT NULL + ); + `) + + return &DefaultEntryRepo{ + types: map[string]model.Entry{}, + db: sqlxdb, + } +} + +func (r *DefaultEntryRepo) entryType(entry model.Entry) string { + return reflect.TypeOf(entry).Elem().Name() +} + +func (r *DefaultEntryRepo) sqlEntryToEntry(entry sqlEntry) (model.Entry, error) { + e, ok := r.types[entry.Type] + if !ok { + return nil, errors.New("entry type not registered") + } + metaData := reflect.New(reflect.TypeOf(e.MetaData()).Elem()).Interface() + json.Unmarshal([]byte(*entry.MetaData), metaData) + e.Create(entry.Id, entry.Content, entry.PublishedAt, metaData) + return e, nil +} diff --git a/infra/entry_repository_test.go b/infra/entry_repository_test.go new file mode 100644 index 0000000..95a1373 --- /dev/null +++ b/infra/entry_repository_test.go @@ -0,0 +1,144 @@ +package infra_test + +import ( + "owl-blogs/app/repository" + "owl-blogs/infra" + "owl-blogs/test" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func setupRepo() repository.EntryRepository { + db := test.NewMockDb() + repo := infra.NewEntryRepository(db) + repo.RegisterEntryType(&test.MockEntry{}) + return repo +} + +func TestRepoRegister(t *testing.T) { + db := test.NewMockDb() + repo := infra.NewEntryRepository(db) + err := repo.RegisterEntryType(&test.MockEntry{}) + require.NoError(t, err) + + err = repo.RegisterEntryType(&test.MockEntry{}) + require.Error(t, err) +} + +func TestRepoCreate(t *testing.T) { + repo := setupRepo() + + entry := &test.MockEntry{} + now := time.Now() + entry.Create("id", "content", &now, &test.MockEntryMetaData{ + Str: "str", + Number: 1, + Date: now, + }) + err := repo.Create(entry) + require.NoError(t, err) + + entry2, err := repo.FindById("id") + require.NoError(t, err) + require.Equal(t, entry.ID(), entry2.ID()) + require.Equal(t, entry.Content(), entry2.Content()) + require.Equal(t, entry.PublishedAt().Unix(), entry2.PublishedAt().Unix()) + meta := entry.MetaData().(*test.MockEntryMetaData) + meta2 := entry2.MetaData().(*test.MockEntryMetaData) + require.Equal(t, meta.Str, meta2.Str) + require.Equal(t, meta.Number, meta2.Number) + require.Equal(t, meta.Date.Unix(), meta2.Date.Unix()) +} + +func TestRepoDelete(t *testing.T) { + repo := setupRepo() + + entry := &test.MockEntry{} + now := time.Now() + entry.Create("id", "content", &now, &test.MockEntryMetaData{ + Str: "str", + Number: 1, + Date: now, + }) + err := repo.Create(entry) + require.NoError(t, err) + + err = repo.Delete(entry) + require.NoError(t, err) + + _, err = repo.FindById("id") + require.Error(t, err) +} + +func TestRepoFindAll(t *testing.T) { + repo := setupRepo() + + entry := &test.MockEntry{} + now := time.Now() + entry.Create("id", "content", &now, &test.MockEntryMetaData{ + Str: "str", + Number: 1, + Date: now, + }) + err := repo.Create(entry) + require.NoError(t, err) + + entry2 := &test.MockEntry{} + now2 := time.Now() + entry2.Create("id2", "content2", &now2, &test.MockEntryMetaData{ + Str: "str2", + Number: 2, + Date: now2, + }) + err = repo.Create(entry2) + require.NoError(t, err) + + entries, err := repo.FindAll(nil) + require.NoError(t, err) + require.Equal(t, 2, len(entries)) + + entries, err = repo.FindAll(&[]string{"MockEntry"}) + require.NoError(t, err) + require.Equal(t, 2, len(entries)) + + entries, err = repo.FindAll(&[]string{"MockEntry2"}) + require.NoError(t, err) + require.Equal(t, 0, len(entries)) + +} + +func TestRepoUpdate(t *testing.T) { + repo := setupRepo() + + entry := &test.MockEntry{} + now := time.Now() + entry.Create("id", "content", &now, &test.MockEntryMetaData{ + Str: "str", + Number: 1, + Date: now, + }) + err := repo.Create(entry) + require.NoError(t, err) + + entry2 := &test.MockEntry{} + now2 := time.Now() + entry2.Create("id", "content2", &now2, &test.MockEntryMetaData{ + Str: "str2", + Number: 2, + Date: now2, + }) + err = repo.Update(entry2) + require.NoError(t, err) + + entry3, err := repo.FindById("id") + require.NoError(t, err) + require.Equal(t, entry3.Content(), entry2.Content()) + require.Equal(t, entry3.PublishedAt().Unix(), entry2.PublishedAt().Unix()) + meta := entry3.MetaData().(*test.MockEntryMetaData) + meta2 := entry2.MetaData().(*test.MockEntryMetaData) + require.Equal(t, meta.Str, meta2.Str) + require.Equal(t, meta.Number, meta2.Number) + require.Equal(t, meta.Date.Unix(), meta2.Date.Unix()) +} diff --git a/infra/interface.go b/infra/interface.go new file mode 100644 index 0000000..367f929 --- /dev/null +++ b/infra/interface.go @@ -0,0 +1,7 @@ +package infra + +import "github.com/jmoiron/sqlx" + +type Database interface { + Get() *sqlx.DB +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..13c39ec --- /dev/null +++ b/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "fmt" + "owl-blogs/domain/model" + "reflect" +) + +func Persist(entry model.Entry) error { + t := reflect.TypeOf(entry).Elem().Name() + + fmt.Println(t) + return nil +} + +func main() { + // repo := infra.NewEntryRepository() + // repo.RegisterEntryType(&model.ImageEntry{}) + + // var img model.Entry = &model.ImageEntry{} + // img.Create("id", "content", nil, &model.ImageEntryMetaData{ImagePath: "path"}) + + // repo.Save(img) + + // img2, err := repo.FindById("id") + // if err != nil { + // panic(err) + // } + // fmt.Println(img2) +} diff --git a/test/mock_db.go b/test/mock_db.go new file mode 100644 index 0000000..ac1fc74 --- /dev/null +++ b/test/mock_db.go @@ -0,0 +1,19 @@ +package test + +import ( + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +type MockDb struct { + db *sqlx.DB +} + +func (d *MockDb) Get() *sqlx.DB { + return d.db +} + +func NewMockDb() *MockDb { + db := sqlx.MustOpen("sqlite3", ":memory:") + return &MockDb{db: db} +} diff --git a/test/mock_entry.go b/test/mock_entry.go new file mode 100644 index 0000000..c58742b --- /dev/null +++ b/test/mock_entry.go @@ -0,0 +1,43 @@ +package test + +import ( + "owl-blogs/domain/model" + "time" +) + +type MockEntryMetaData struct { + Str string + Number int + Date time.Time +} + +type MockEntry struct { + id string + content model.EntryContent + publishedAt *time.Time + metaData *MockEntryMetaData +} + +func (e *MockEntry) ID() string { + return e.id +} + +func (e *MockEntry) Content() model.EntryContent { + return e.content +} + +func (e *MockEntry) PublishedAt() *time.Time { + return e.publishedAt +} + +func (e *MockEntry) MetaData() interface{} { + return e.metaData +} + +func (e *MockEntry) Create(id string, content string, publishedAt *time.Time, metaData model.EntryMetaData) error { + e.id = id + e.content = model.EntryContent(content) + e.publishedAt = publishedAt + e.metaData = metaData.(*MockEntryMetaData) + return nil +} From 229f5833e0c65e8d36dfd0fb15f48cd1eefce2b0 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Sun, 25 Jun 2023 21:32:36 +0200 Subject: [PATCH 03/41] web app setup --- app/entry_service.go | 38 ++++++++++++++++++++ go.mod | 16 +++++++++ go.sum | 80 ++++++++++++++++++++++++++++++++++++++++++ infra/sqlite_db.go | 18 ++++++++++ main.go | 31 +++++----------- owlblogs.db | Bin 0 -> 12288 bytes web/app.go | 56 +++++++++++++++++++++++++++++ web/editor_handler.go | 23 ++++++++++++ web/entry_handler.go | 19 ++++++++++ web/index_handler.go | 19 ++++++++++ web/list_handler.go | 19 ++++++++++ web/login_handler.go | 23 ++++++++++++ web/media_handler.go | 19 ++++++++++ web/rss_handler.go | 19 ++++++++++ 14 files changed, 357 insertions(+), 23 deletions(-) create mode 100644 app/entry_service.go create mode 100644 infra/sqlite_db.go create mode 100644 owlblogs.db create mode 100644 web/app.go create mode 100644 web/editor_handler.go create mode 100644 web/entry_handler.go create mode 100644 web/index_handler.go create mode 100644 web/list_handler.go create mode 100644 web/login_handler.go create mode 100644 web/media_handler.go create mode 100644 web/rss_handler.go diff --git a/app/entry_service.go b/app/entry_service.go new file mode 100644 index 0000000..ba64baf --- /dev/null +++ b/app/entry_service.go @@ -0,0 +1,38 @@ +package app + +import ( + "owl-blogs/app/repository" + "owl-blogs/domain/model" +) + +type EntryService struct { + EntryRepository repository.EntryRepository +} + +func NewEntryService(entryRepository repository.EntryRepository) *EntryService { + return &EntryService{EntryRepository: entryRepository} +} + +func (s *EntryService) Create(entry model.Entry) error { + return s.EntryRepository.Create(entry) +} + +func (s *EntryService) Update(entry model.Entry) error { + return s.EntryRepository.Update(entry) +} + +func (s *EntryService) Delete(entry model.Entry) error { + return s.EntryRepository.Delete(entry) +} + +func (s *EntryService) FindById(id string) (model.Entry, error) { + return s.EntryRepository.FindById(id) +} + +func (s *EntryService) FindAllByType(types *[]string) ([]model.Entry, error) { + return s.EntryRepository.FindAll(types) +} + +func (s *EntryService) FindAll() ([]model.Entry, error) { + return s.EntryRepository.FindAll(nil) +} diff --git a/go.mod b/go.mod index 67a03f1..b920a87 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,27 @@ module owl-blogs go 1.20 require ( + github.com/andybalholm/brotli v1.0.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gofiber/fiber/v2 v2.47.0 // indirect + github.com/google/uuid v1.3.0 // indirect github.com/jmoiron/sqlx v1.3.5 // indirect + github.com/klauspost/compress v1.16.3 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mattn/go-sqlite3 v1.14.17 // indirect + github.com/philhofer/fwd v1.1.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 // indirect + github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect github.com/stretchr/objx v0.5.0 // indirect github.com/stretchr/testify v1.8.4 // indirect + github.com/tinylib/msgp v1.1.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.47.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sys v0.9.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2c70d96..6bb6d0d 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,40 @@ +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/gofiber/fiber/v2 v2.47.0 h1:EN5lHVCc+Pyqh5OEsk8fzRiifgwpbrP0rulQ4iNf3fs= +github.com/gofiber/fiber/v2 v2.47.0/go.mod h1:mbFMVN1lQuzziTkkakgtKKdjfsXSw9BKR5lmcNksUoU= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY= +github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= +github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= +github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 h1:rmMl4fXJhKMNWl+K+r/fq4FbbKI+Ia2m9hYBLm2h4G4= +github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94/go.mod h1:90zrgN3D/WJsDd1iXHT96alCoN2KJo6/4x1DZC3wZs8= +github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4= +github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk= +github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= @@ -18,6 +43,61 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw= +github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= +github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.47.0 h1:y7moDoxYzMooFpT5aHgNgVOQDrS3qlkfiP9mDtGGK9c= +github.com/valyala/fasthttp v1.47.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= +golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/infra/sqlite_db.go b/infra/sqlite_db.go new file mode 100644 index 0000000..a779cff --- /dev/null +++ b/infra/sqlite_db.go @@ -0,0 +1,18 @@ +package infra + +import ( + "github.com/jmoiron/sqlx" +) + +type SqliteDatabase struct { + db *sqlx.DB +} + +func NewSqliteDB(path string) Database { + db := sqlx.MustOpen("sqlite3", path) + return &SqliteDatabase{db: db} +} + +func (d *SqliteDatabase) Get() *sqlx.DB { + return d.db +} diff --git a/main.go b/main.go index 13c39ec..52dd4cd 100644 --- a/main.go +++ b/main.go @@ -1,30 +1,15 @@ package main import ( - "fmt" - "owl-blogs/domain/model" - "reflect" + "owl-blogs/app" + "owl-blogs/infra" + "owl-blogs/web" ) -func Persist(entry model.Entry) error { - t := reflect.TypeOf(entry).Elem().Name() - - fmt.Println(t) - return nil -} - func main() { - // repo := infra.NewEntryRepository() - // repo.RegisterEntryType(&model.ImageEntry{}) - - // var img model.Entry = &model.ImageEntry{} - // img.Create("id", "content", nil, &model.ImageEntryMetaData{ImagePath: "path"}) - - // repo.Save(img) - - // img2, err := repo.FindById("id") - // if err != nil { - // panic(err) - // } - // fmt.Println(img2) + db := infra.NewSqliteDB("owlblogs.db") + repo := infra.NewEntryRepository(db) + entryService := app.NewEntryService(repo) + webApp := web.NewWebApp(entryService) + webApp.Run() } diff --git a/owlblogs.db b/owlblogs.db new file mode 100644 index 0000000000000000000000000000000000000000..9139de8567b1e1da39d33517ca7f6a733c0077d5 GIT binary patch literal 12288 zcmeI#K}*9h6bJC6irT^GV22%g$x%TC*;%k^4#RG0jo?lpvk@y@JJ$^KsNc?;AIGCf z?GA#ln}_iaBDC9Zm#3ZX!cOfr^>KC=@3fE31fs1YWs%m%jV+EWngC)uWFPW zK7F=>FX9DV5_E%)RSDRK00bZa0SG_<0uX=z1Rwwb2>g@4Tc2+C!jQfn8#T$bF3ddB zRWo^K36BJ4B07yZYZlpV%lG{(WrE)cc9jf Date: Sun, 25 Jun 2023 21:54:04 +0200 Subject: [PATCH 04/41] type registery --- app/entry_register.go | 51 ++++++++++++++++++++++++++++++++++ app/repository/interfaces.go | 1 - infra/entry_repository.go | 41 ++++++++++----------------- infra/entry_repository_test.go | 16 +++-------- main.go | 3 +- 5 files changed, 71 insertions(+), 41 deletions(-) create mode 100644 app/entry_register.go diff --git a/app/entry_register.go b/app/entry_register.go new file mode 100644 index 0000000..a6dd53b --- /dev/null +++ b/app/entry_register.go @@ -0,0 +1,51 @@ +package app + +import ( + "errors" + "owl-blogs/domain/model" + "reflect" +) + +type EntryTypeRegistry struct { + types map[string]model.Entry +} + +func NewEntryTypeRegistry() *EntryTypeRegistry { + return &EntryTypeRegistry{types: map[string]model.Entry{}} +} + +func (r *EntryTypeRegistry) entryType(entry model.Entry) string { + return reflect.TypeOf(entry).Elem().Name() +} + +func (r *EntryTypeRegistry) Register(entry model.Entry) error { + t := r.entryType(entry) + if _, ok := r.types[t]; ok { + return errors.New("entry type already registered") + } + r.types[t] = entry + return nil +} + +func (r *EntryTypeRegistry) Types() []model.Entry { + types := []model.Entry{} + for _, t := range r.types { + types = append(types, t) + } + return types +} + +func (r *EntryTypeRegistry) TypeName(entry model.Entry) (string, error) { + t := r.entryType(entry) + if _, ok := r.types[t]; !ok { + return "", errors.New("entry type not registered") + } + return t, nil +} + +func (r *EntryTypeRegistry) Type(name string) (model.Entry, error) { + if _, ok := r.types[name]; !ok { + return nil, errors.New("entry type not registered") + } + return r.types[name], nil +} diff --git a/app/repository/interfaces.go b/app/repository/interfaces.go index 3f97dca..5fb3954 100644 --- a/app/repository/interfaces.go +++ b/app/repository/interfaces.go @@ -3,7 +3,6 @@ package repository import "owl-blogs/domain/model" type EntryRepository interface { - RegisterEntryType(entry model.Entry) error Create(entry model.Entry) error Update(entry model.Entry) error Delete(entry model.Entry) error diff --git a/infra/entry_repository.go b/infra/entry_repository.go index f347347..3e3fa72 100644 --- a/infra/entry_repository.go +++ b/infra/entry_repository.go @@ -3,6 +3,7 @@ package infra import ( "encoding/json" "errors" + "owl-blogs/app" "owl-blogs/app/repository" "owl-blogs/domain/model" "reflect" @@ -22,8 +23,8 @@ type sqlEntry struct { } type DefaultEntryRepo struct { - types map[string]model.Entry - db *sqlx.DB + typeRegistry *app.EntryTypeRegistry + db *sqlx.DB } // Create implements repository.EntryRepository. @@ -33,8 +34,8 @@ func (r *DefaultEntryRepo) Create(entry model.Entry) error { return errors.New("entry already exists") } - t := r.entryType(entry) - if _, ok := r.types[t]; !ok { + t, err := r.typeRegistry.TypeName(entry) + if err != nil { return errors.New("entry type not registered") } @@ -43,7 +44,7 @@ func (r *DefaultEntryRepo) Create(entry model.Entry) error { metaDataJson, _ = json.Marshal(entry.MetaData()) } - _, err := r.db.Exec("INSERT INTO entries (id, type, content, published_at, meta_data) VALUES (?, ?, ?, ?, ?)", entry.ID(), t, entry.Content(), entry.PublishedAt(), metaDataJson) + _, err = r.db.Exec("INSERT INTO entries (id, type, content, published_at, meta_data) VALUES (?, ?, ?, ?, ?)", entry.ID(), t, entry.Content(), entry.PublishedAt(), metaDataJson) return err } @@ -101,16 +102,6 @@ func (r *DefaultEntryRepo) FindById(id string) (model.Entry, error) { return r.sqlEntryToEntry(data) } -// RegisterEntryType implements repository.EntryRepository. -func (r *DefaultEntryRepo) RegisterEntryType(entry model.Entry) error { - t := r.entryType(entry) - if _, ok := r.types[t]; ok { - return errors.New("entry type already registered") - } - r.types[t] = entry - return nil -} - // Update implements repository.EntryRepository. func (r *DefaultEntryRepo) Update(entry model.Entry) error { exEntry, _ := r.FindById(entry.ID()) @@ -118,8 +109,8 @@ func (r *DefaultEntryRepo) Update(entry model.Entry) error { return errors.New("entry not found") } - t := r.entryType(entry) - if _, ok := r.types[t]; !ok { + _, err := r.typeRegistry.TypeName(entry) + if err != nil { return errors.New("entry type not registered") } @@ -128,11 +119,11 @@ func (r *DefaultEntryRepo) Update(entry model.Entry) error { metaDataJson, _ = json.Marshal(entry.MetaData()) } - _, err := r.db.Exec("UPDATE entries SET content = ?, published_at = ?, meta_data = ? WHERE id = ?", entry.Content(), entry.PublishedAt(), metaDataJson, entry.ID()) + _, err = r.db.Exec("UPDATE entries SET content = ?, published_at = ?, meta_data = ? WHERE id = ?", entry.Content(), entry.PublishedAt(), metaDataJson, entry.ID()) return err } -func NewEntryRepository(db Database) repository.EntryRepository { +func NewEntryRepository(db Database, register *app.EntryTypeRegistry) repository.EntryRepository { sqlxdb := db.Get() // Create tables if not exists @@ -147,18 +138,14 @@ func NewEntryRepository(db Database) repository.EntryRepository { `) return &DefaultEntryRepo{ - types: map[string]model.Entry{}, - db: sqlxdb, + db: sqlxdb, + typeRegistry: register, } } -func (r *DefaultEntryRepo) entryType(entry model.Entry) string { - return reflect.TypeOf(entry).Elem().Name() -} - func (r *DefaultEntryRepo) sqlEntryToEntry(entry sqlEntry) (model.Entry, error) { - e, ok := r.types[entry.Type] - if !ok { + e, err := r.typeRegistry.Type(entry.Type) + if err != nil { return nil, errors.New("entry type not registered") } metaData := reflect.New(reflect.TypeOf(e.MetaData()).Elem()).Interface() diff --git a/infra/entry_repository_test.go b/infra/entry_repository_test.go index 95a1373..be62dfd 100644 --- a/infra/entry_repository_test.go +++ b/infra/entry_repository_test.go @@ -1,6 +1,7 @@ package infra_test import ( + "owl-blogs/app" "owl-blogs/app/repository" "owl-blogs/infra" "owl-blogs/test" @@ -12,21 +13,12 @@ import ( func setupRepo() repository.EntryRepository { db := test.NewMockDb() - repo := infra.NewEntryRepository(db) - repo.RegisterEntryType(&test.MockEntry{}) + register := app.NewEntryTypeRegistry() + register.Register(&test.MockEntry{}) + repo := infra.NewEntryRepository(db, register) return repo } -func TestRepoRegister(t *testing.T) { - db := test.NewMockDb() - repo := infra.NewEntryRepository(db) - err := repo.RegisterEntryType(&test.MockEntry{}) - require.NoError(t, err) - - err = repo.RegisterEntryType(&test.MockEntry{}) - require.Error(t, err) -} - func TestRepoCreate(t *testing.T) { repo := setupRepo() diff --git a/main.go b/main.go index 52dd4cd..413dfb7 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,8 @@ import ( func main() { db := infra.NewSqliteDB("owlblogs.db") - repo := infra.NewEntryRepository(db) + registry := app.NewEntryTypeRegistry() + repo := infra.NewEntryRepository(db, registry) entryService := app.NewEntryService(repo) webApp := web.NewWebApp(entryService) webApp.Run() From 1742728639e252906088972282ff768b4fb9176d Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Sun, 25 Jun 2023 22:09:27 +0200 Subject: [PATCH 05/41] experiments for editor --- domain/model/image_entry.go | 2 +- web/editor/entry_form.go | 49 +++++++++++++++++++++++++++++++++++++ web/editor_handler.go | 5 +++- 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 web/editor/entry_form.go diff --git a/domain/model/image_entry.go b/domain/model/image_entry.go index a60ea0c..2d1f848 100644 --- a/domain/model/image_entry.go +++ b/domain/model/image_entry.go @@ -10,7 +10,7 @@ type ImageEntry struct { } type ImageEntryMetaData struct { - ImagePath string + ImagePath string `owl:"type=upload"` } func (e *ImageEntry) ID() string { diff --git a/web/editor/entry_form.go b/web/editor/entry_form.go new file mode 100644 index 0000000..188693c --- /dev/null +++ b/web/editor/entry_form.go @@ -0,0 +1,49 @@ +package editor + +import ( + "fmt" + "owl-blogs/domain/model" + "reflect" + "strings" +) + +type EditorEntryForm struct { + entry model.Entry +} + +type EntryFormField struct { + Name string + Params map[string]string +} + +func NewEditorFormService(entry model.Entry) *EditorEntryForm { + return &EditorEntryForm{ + entry: entry, + } +} + +func (s *EditorEntryForm) HtmlForm() string { + meta := s.entry.MetaData() + entryType := reflect.TypeOf(meta).Elem() + numFields := entryType.NumField() + + fields := []EntryFormField{} + for i := 0; i < numFields; i++ { + field := EntryFormField{ + Name: entryType.Field(i).Name, + Params: map[string]string{}, + } + tag := entryType.Field(i).Tag.Get("owl") + for _, param := range strings.Split(tag, " ") { + parts := strings.Split(param, "=") + if len(parts) == 2 { + field.Params[parts[0]] = parts[1] + } else { + field.Params[param] = "" + } + } + fields = append(fields, field) + } + + return fmt.Sprintf("%v", fields) +} diff --git a/web/editor_handler.go b/web/editor_handler.go index 25a5e81..357d69b 100644 --- a/web/editor_handler.go +++ b/web/editor_handler.go @@ -2,6 +2,8 @@ package web import ( "owl-blogs/app" + "owl-blogs/domain/model" + "owl-blogs/web/editor" "github.com/gofiber/fiber/v2" ) @@ -15,7 +17,8 @@ func NewEditorHandler(entryService *app.EntryService) *EditorHandler { } func (h *EditorHandler) HandleGet(c *fiber.Ctx) error { - return c.SendString("Hello, Editor!") + form := editor.NewEditorFormService(&model.ImageEntry{}) + return c.SendString(form.HtmlForm()) } func (h *EditorHandler) HandlePost(c *fiber.Ctx) error { From 936a0a0d8033320ba1fd44fb66f7527523480fa7 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Wed, 5 Jul 2023 22:03:00 +0200 Subject: [PATCH 06/41] WIP html forms --- domain/model/image_entry.go | 2 +- web/editor/entity_form_test.go | 77 +++++++++++++++++++++++++++++++++ web/editor/entry_form.go | 78 ++++++++++++++++++++++++++-------- web/editor_handler.go | 7 ++- 4 files changed, 143 insertions(+), 21 deletions(-) create mode 100644 web/editor/entity_form_test.go diff --git a/domain/model/image_entry.go b/domain/model/image_entry.go index 2d1f848..7b909ea 100644 --- a/domain/model/image_entry.go +++ b/domain/model/image_entry.go @@ -10,7 +10,7 @@ type ImageEntry struct { } type ImageEntryMetaData struct { - ImagePath string `owl:"type=upload"` + ImagePath string `owl:"inputType=file"` } func (e *ImageEntry) ID() string { diff --git a/web/editor/entity_form_test.go b/web/editor/entity_form_test.go new file mode 100644 index 0000000..076021c --- /dev/null +++ b/web/editor/entity_form_test.go @@ -0,0 +1,77 @@ +package editor_test + +import ( + "owl-blogs/domain/model" + "owl-blogs/web/editor" + "reflect" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type MockEntryMetaData struct { + Image string `owl:"inputType=file"` + Content string `owl:"inputType=text"` +} + +type MockEntry struct { + id string + content model.EntryContent + publishedAt *time.Time + metaData *MockEntryMetaData +} + +func (e *MockEntry) ID() string { + return e.id +} + +func (e *MockEntry) Content() model.EntryContent { + return e.content +} + +func (e *MockEntry) PublishedAt() *time.Time { + return e.publishedAt +} + +func (e *MockEntry) MetaData() interface{} { + return e.metaData +} + +func (e *MockEntry) Create(id string, content string, publishedAt *time.Time, metaData model.EntryMetaData) error { + e.id = id + e.content = model.EntryContent(content) + e.publishedAt = publishedAt + e.metaData = metaData.(*MockEntryMetaData) + return nil +} + +func TestFieldToFormField(t *testing.T) { + field := reflect.TypeOf(&MockEntryMetaData{}).Elem().Field(0) + formField, err := editor.FieldToFormField(field) + require.NoError(t, err) + require.Equal(t, "Image", formField.Name) + require.Equal(t, "file", formField.Params.InputType) +} + +func TestStructToFields(t *testing.T) { + fields, err := editor.StructToFormFields(&MockEntryMetaData{}) + require.NoError(t, err) + require.Len(t, fields, 2) + require.Equal(t, "Image", fields[0].Name) + require.Equal(t, "file", fields[0].Params.InputType) + require.Equal(t, "Content", fields[1].Name) + require.Equal(t, "text", fields[1].Params.InputType) +} + +func TestEditorEntryForm_HtmlForm(t *testing.T) { + formService := editor.NewEditorFormService(&MockEntry{}) + form, err := formService.HtmlForm() + require.NoError(t, err) + require.Contains(t, form, "") + require.Contains(t, form, "") + require.Contains(t, form, "") + +} diff --git a/web/editor/entry_form.go b/web/editor/entry_form.go index 188693c..a31585a 100644 --- a/web/editor/entry_form.go +++ b/web/editor/entry_form.go @@ -11,9 +11,13 @@ type EditorEntryForm struct { entry model.Entry } +type EntryFormFieldParams struct { + InputType string +} + type EntryFormField struct { Name string - Params map[string]string + Params EntryFormFieldParams } func NewEditorFormService(entry model.Entry) *EditorEntryForm { @@ -22,28 +26,66 @@ func NewEditorFormService(entry model.Entry) *EditorEntryForm { } } -func (s *EditorEntryForm) HtmlForm() string { - meta := s.entry.MetaData() +func (s *EntryFormFieldParams) ApplyTag(tagKey string, tagValue string) error { + switch tagKey { + case "inputType": + s.InputType = tagValue + default: + return fmt.Errorf("unknown tag key: %v", tagKey) + } + return nil +} + +func (s *EntryFormField) Html() string { + return fmt.Sprintf("\n", s.Params.InputType, s.Name) +} + +func FieldToFormField(field reflect.StructField) (EntryFormField, error) { + formField := EntryFormField{ + Name: field.Name, + Params: EntryFormFieldParams{}, + } + tag := field.Tag.Get("owl") + for _, param := range strings.Split(tag, " ") { + parts := strings.Split(param, "=") + if len(parts) != 2 { + continue + } + err := formField.Params.ApplyTag(parts[0], parts[1]) + if err != nil { + return EntryFormField{}, err + } + } + return formField, nil +} + +func StructToFormFields(meta interface{}) ([]EntryFormField, error) { entryType := reflect.TypeOf(meta).Elem() numFields := entryType.NumField() - fields := []EntryFormField{} for i := 0; i < numFields; i++ { - field := EntryFormField{ - Name: entryType.Field(i).Name, - Params: map[string]string{}, - } - tag := entryType.Field(i).Tag.Get("owl") - for _, param := range strings.Split(tag, " ") { - parts := strings.Split(param, "=") - if len(parts) == 2 { - field.Params[parts[0]] = parts[1] - } else { - field.Params[param] = "" - } + field, err := FieldToFormField(entryType.Field(i)) + if err != nil { + return nil, err } fields = append(fields, field) } - - return fmt.Sprintf("%v", fields) + return fields, nil +} + +func (s *EditorEntryForm) HtmlForm() (string, error) { + meta := s.entry.MetaData() + fields, err := StructToFormFields(meta) + if err != nil { + return "", err + } + + html := "
    \n" + for _, field := range fields { + html += field.Html() + } + html += "\n" + html += "
    \n" + + return html, nil } diff --git a/web/editor_handler.go b/web/editor_handler.go index 357d69b..72be670 100644 --- a/web/editor_handler.go +++ b/web/editor_handler.go @@ -17,10 +17,13 @@ func NewEditorHandler(entryService *app.EntryService) *EditorHandler { } func (h *EditorHandler) HandleGet(c *fiber.Ctx) error { - form := editor.NewEditorFormService(&model.ImageEntry{}) - return c.SendString(form.HtmlForm()) + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + formService := editor.NewEditorFormService(&model.ImageEntry{}) + form, _ := formService.HtmlForm() + return c.SendString(form) } func (h *EditorHandler) HandlePost(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) return c.SendString("Hello, Editor!") } From bcacbf1e4dac2f7ebfdbb3a8b5c0c996f5344da7 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Thu, 6 Jul 2023 18:56:43 +0200 Subject: [PATCH 07/41] no more content --- domain/model/entry.go | 2 +- domain/model/image_entry.go | 16 ++++++++-------- infra/entry_repository.go | 8 +++----- infra/entry_repository_test.go | 12 ++++++------ test/mock_entry.go | 6 ++---- web/editor/entity_form_test.go | 6 ++---- web/editor_handler.go | 5 ++++- 7 files changed, 26 insertions(+), 29 deletions(-) diff --git a/domain/model/entry.go b/domain/model/entry.go index 0b119de..95f8b1c 100644 --- a/domain/model/entry.go +++ b/domain/model/entry.go @@ -9,7 +9,7 @@ type Entry interface { Content() EntryContent PublishedAt() *time.Time MetaData() interface{} - Create(id string, content string, publishedAt *time.Time, metaData EntryMetaData) error + Create(id string, publishedAt *time.Time, metaData EntryMetaData) error } type EntryMetaData interface { diff --git a/domain/model/image_entry.go b/domain/model/image_entry.go index 7b909ea..0ac5ee2 100644 --- a/domain/model/image_entry.go +++ b/domain/model/image_entry.go @@ -4,13 +4,13 @@ import "time" type ImageEntry struct { id string - content EntryContent publishedAt *time.Time - ImagePath string + meta ImageEntryMetaData } type ImageEntryMetaData struct { - ImagePath string `owl:"inputType=file"` + ImagePath string `owl:"inputType=file"` + Content EntryContent `owl:"inputType=text widget=textarea"` } func (e *ImageEntry) ID() string { @@ -18,7 +18,7 @@ func (e *ImageEntry) ID() string { } func (e *ImageEntry) Content() EntryContent { - return e.content + return e.meta.Content } func (e *ImageEntry) PublishedAt() *time.Time { @@ -27,14 +27,14 @@ func (e *ImageEntry) PublishedAt() *time.Time { func (e *ImageEntry) MetaData() interface{} { return &ImageEntryMetaData{ - ImagePath: e.ImagePath, + ImagePath: e.meta.ImagePath, + Content: e.meta.Content, } } -func (e *ImageEntry) Create(id string, content string, publishedAt *time.Time, metaData EntryMetaData) error { +func (e *ImageEntry) Create(id string, publishedAt *time.Time, metaData EntryMetaData) error { e.id = id - e.content = EntryContent(content) e.publishedAt = publishedAt - e.ImagePath = metaData.(*ImageEntryMetaData).ImagePath + e.meta = *metaData.(*ImageEntryMetaData) return nil } diff --git a/infra/entry_repository.go b/infra/entry_repository.go index 3e3fa72..4e91cf2 100644 --- a/infra/entry_repository.go +++ b/infra/entry_repository.go @@ -17,7 +17,6 @@ import ( type sqlEntry struct { Id string `db:"id"` Type string `db:"type"` - Content string `db:"content"` PublishedAt *time.Time `db:"published_at"` MetaData *string `db:"meta_data"` } @@ -44,7 +43,7 @@ func (r *DefaultEntryRepo) Create(entry model.Entry) error { metaDataJson, _ = json.Marshal(entry.MetaData()) } - _, err = r.db.Exec("INSERT INTO entries (id, type, content, published_at, meta_data) VALUES (?, ?, ?, ?, ?)", entry.ID(), t, entry.Content(), entry.PublishedAt(), metaDataJson) + _, err = r.db.Exec("INSERT INTO entries (id, type, published_at, meta_data) VALUES (?, ?, ?, ?)", entry.ID(), t, entry.PublishedAt(), metaDataJson) return err } @@ -119,7 +118,7 @@ func (r *DefaultEntryRepo) Update(entry model.Entry) error { metaDataJson, _ = json.Marshal(entry.MetaData()) } - _, err = r.db.Exec("UPDATE entries SET content = ?, published_at = ?, meta_data = ? WHERE id = ?", entry.Content(), entry.PublishedAt(), metaDataJson, entry.ID()) + _, err = r.db.Exec("UPDATE entries SET published_at = ?, meta_data = ? WHERE id = ?", entry.PublishedAt(), metaDataJson, entry.ID()) return err } @@ -131,7 +130,6 @@ func NewEntryRepository(db Database, register *app.EntryTypeRegistry) repository CREATE TABLE IF NOT EXISTS entries ( id TEXT PRIMARY KEY, type TEXT NOT NULL, - content TEXT NOT NULL, published_at DATETIME, meta_data TEXT NOT NULL ); @@ -150,6 +148,6 @@ func (r *DefaultEntryRepo) sqlEntryToEntry(entry sqlEntry) (model.Entry, error) } metaData := reflect.New(reflect.TypeOf(e.MetaData()).Elem()).Interface() json.Unmarshal([]byte(*entry.MetaData), metaData) - e.Create(entry.Id, entry.Content, entry.PublishedAt, metaData) + e.Create(entry.Id, entry.PublishedAt, metaData) return e, nil } diff --git a/infra/entry_repository_test.go b/infra/entry_repository_test.go index be62dfd..a0b9771 100644 --- a/infra/entry_repository_test.go +++ b/infra/entry_repository_test.go @@ -24,7 +24,7 @@ func TestRepoCreate(t *testing.T) { entry := &test.MockEntry{} now := time.Now() - entry.Create("id", "content", &now, &test.MockEntryMetaData{ + entry.Create("id", &now, &test.MockEntryMetaData{ Str: "str", Number: 1, Date: now, @@ -49,7 +49,7 @@ func TestRepoDelete(t *testing.T) { entry := &test.MockEntry{} now := time.Now() - entry.Create("id", "content", &now, &test.MockEntryMetaData{ + entry.Create("id", &now, &test.MockEntryMetaData{ Str: "str", Number: 1, Date: now, @@ -69,7 +69,7 @@ func TestRepoFindAll(t *testing.T) { entry := &test.MockEntry{} now := time.Now() - entry.Create("id", "content", &now, &test.MockEntryMetaData{ + entry.Create("id", &now, &test.MockEntryMetaData{ Str: "str", Number: 1, Date: now, @@ -79,7 +79,7 @@ func TestRepoFindAll(t *testing.T) { entry2 := &test.MockEntry{} now2 := time.Now() - entry2.Create("id2", "content2", &now2, &test.MockEntryMetaData{ + entry2.Create("id2", &now2, &test.MockEntryMetaData{ Str: "str2", Number: 2, Date: now2, @@ -106,7 +106,7 @@ func TestRepoUpdate(t *testing.T) { entry := &test.MockEntry{} now := time.Now() - entry.Create("id", "content", &now, &test.MockEntryMetaData{ + entry.Create("id", &now, &test.MockEntryMetaData{ Str: "str", Number: 1, Date: now, @@ -116,7 +116,7 @@ func TestRepoUpdate(t *testing.T) { entry2 := &test.MockEntry{} now2 := time.Now() - entry2.Create("id", "content2", &now2, &test.MockEntryMetaData{ + entry2.Create("id", &now2, &test.MockEntryMetaData{ Str: "str2", Number: 2, Date: now2, diff --git a/test/mock_entry.go b/test/mock_entry.go index c58742b..aafcc25 100644 --- a/test/mock_entry.go +++ b/test/mock_entry.go @@ -13,7 +13,6 @@ type MockEntryMetaData struct { type MockEntry struct { id string - content model.EntryContent publishedAt *time.Time metaData *MockEntryMetaData } @@ -23,7 +22,7 @@ func (e *MockEntry) ID() string { } func (e *MockEntry) Content() model.EntryContent { - return e.content + return model.EntryContent(e.metaData.Str) } func (e *MockEntry) PublishedAt() *time.Time { @@ -34,9 +33,8 @@ func (e *MockEntry) MetaData() interface{} { return e.metaData } -func (e *MockEntry) Create(id string, content string, publishedAt *time.Time, metaData model.EntryMetaData) error { +func (e *MockEntry) Create(id string, publishedAt *time.Time, metaData model.EntryMetaData) error { e.id = id - e.content = model.EntryContent(content) e.publishedAt = publishedAt e.metaData = metaData.(*MockEntryMetaData) return nil diff --git a/web/editor/entity_form_test.go b/web/editor/entity_form_test.go index 076021c..050e402 100644 --- a/web/editor/entity_form_test.go +++ b/web/editor/entity_form_test.go @@ -17,7 +17,6 @@ type MockEntryMetaData struct { type MockEntry struct { id string - content model.EntryContent publishedAt *time.Time metaData *MockEntryMetaData } @@ -27,7 +26,7 @@ func (e *MockEntry) ID() string { } func (e *MockEntry) Content() model.EntryContent { - return e.content + return model.EntryContent(e.metaData.Content) } func (e *MockEntry) PublishedAt() *time.Time { @@ -38,9 +37,8 @@ func (e *MockEntry) MetaData() interface{} { return e.metaData } -func (e *MockEntry) Create(id string, content string, publishedAt *time.Time, metaData model.EntryMetaData) error { +func (e *MockEntry) Create(id string, publishedAt *time.Time, metaData model.EntryMetaData) error { e.id = id - e.content = model.EntryContent(content) e.publishedAt = publishedAt e.metaData = metaData.(*MockEntryMetaData) return nil diff --git a/web/editor_handler.go b/web/editor_handler.go index 72be670..8fbd363 100644 --- a/web/editor_handler.go +++ b/web/editor_handler.go @@ -19,7 +19,10 @@ func NewEditorHandler(entryService *app.EntryService) *EditorHandler { func (h *EditorHandler) HandleGet(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) formService := editor.NewEditorFormService(&model.ImageEntry{}) - form, _ := formService.HtmlForm() + form, err := formService.HtmlForm() + if err != nil { + return err + } return c.SendString(form) } From ff193f62e98c8b3118df97fae92cb22953476743 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Thu, 6 Jul 2023 19:36:24 +0200 Subject: [PATCH 08/41] create --- app/entry_service.go | 5 ++-- app/repository/interfaces.go | 7 +++-- infra/entry_repository.go | 14 ++++----- infra/entry_repository_test.go | 29 +++++++++---------- web/editor/entity_form_test.go | 46 +++++++++++++++++++++++------- web/editor/entry_form.go | 52 ++++++++++++++++++++++++++++++++-- web/editor_handler.go | 22 ++++++++++++-- 7 files changed, 132 insertions(+), 43 deletions(-) diff --git a/app/entry_service.go b/app/entry_service.go index ba64baf..103acab 100644 --- a/app/entry_service.go +++ b/app/entry_service.go @@ -3,6 +3,7 @@ package app import ( "owl-blogs/app/repository" "owl-blogs/domain/model" + "time" ) type EntryService struct { @@ -13,8 +14,8 @@ func NewEntryService(entryRepository repository.EntryRepository) *EntryService { return &EntryService{EntryRepository: entryRepository} } -func (s *EntryService) Create(entry model.Entry) error { - return s.EntryRepository.Create(entry) +func (s *EntryService) Create(entry model.Entry, publishedAt *time.Time, metaData model.EntryMetaData) error { + return s.EntryRepository.Create(entry, publishedAt, metaData) } func (s *EntryService) Update(entry model.Entry) error { diff --git a/app/repository/interfaces.go b/app/repository/interfaces.go index 5fb3954..7a9cf2b 100644 --- a/app/repository/interfaces.go +++ b/app/repository/interfaces.go @@ -1,9 +1,12 @@ package repository -import "owl-blogs/domain/model" +import ( + "owl-blogs/domain/model" + "time" +) type EntryRepository interface { - Create(entry model.Entry) error + Create(entry model.Entry, publishedAt *time.Time, metaData model.EntryMetaData) error Update(entry model.Entry) error Delete(entry model.Entry) error FindById(id string) (model.Entry, error) diff --git a/infra/entry_repository.go b/infra/entry_repository.go index 4e91cf2..1815079 100644 --- a/infra/entry_repository.go +++ b/infra/entry_repository.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/google/uuid" "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" ) @@ -27,12 +28,7 @@ type DefaultEntryRepo struct { } // Create implements repository.EntryRepository. -func (r *DefaultEntryRepo) Create(entry model.Entry) error { - exEntry, _ := r.FindById(entry.ID()) - if exEntry != nil { - return errors.New("entry already exists") - } - +func (r *DefaultEntryRepo) Create(entry model.Entry, publishedAt *time.Time, metaData model.EntryMetaData) error { t, err := r.typeRegistry.TypeName(entry) if err != nil { return errors.New("entry type not registered") @@ -40,10 +36,12 @@ func (r *DefaultEntryRepo) Create(entry model.Entry) error { var metaDataJson []byte if entry.MetaData() != nil { - metaDataJson, _ = json.Marshal(entry.MetaData()) + metaDataJson, _ = json.Marshal(metaData) } - _, err = r.db.Exec("INSERT INTO entries (id, type, published_at, meta_data) VALUES (?, ?, ?, ?)", entry.ID(), t, entry.PublishedAt(), metaDataJson) + id := uuid.New().String() + _, err = r.db.Exec("INSERT INTO entries (id, type, published_at, meta_data) VALUES (?, ?, ?, ?)", id, t, publishedAt, metaDataJson) + entry.Create(id, publishedAt, metaData) return err } diff --git a/infra/entry_repository_test.go b/infra/entry_repository_test.go index a0b9771..55bd9cf 100644 --- a/infra/entry_repository_test.go +++ b/infra/entry_repository_test.go @@ -24,15 +24,14 @@ func TestRepoCreate(t *testing.T) { entry := &test.MockEntry{} now := time.Now() - entry.Create("id", &now, &test.MockEntryMetaData{ + err := repo.Create(entry, &now, &test.MockEntryMetaData{ Str: "str", Number: 1, Date: now, }) - err := repo.Create(entry) require.NoError(t, err) - entry2, err := repo.FindById("id") + entry2, err := repo.FindById(entry.ID()) require.NoError(t, err) require.Equal(t, entry.ID(), entry2.ID()) require.Equal(t, entry.Content(), entry2.Content()) @@ -49,12 +48,11 @@ func TestRepoDelete(t *testing.T) { entry := &test.MockEntry{} now := time.Now() - entry.Create("id", &now, &test.MockEntryMetaData{ + err := repo.Create(entry, &now, &test.MockEntryMetaData{ Str: "str", Number: 1, Date: now, }) - err := repo.Create(entry) require.NoError(t, err) err = repo.Delete(entry) @@ -69,22 +67,21 @@ func TestRepoFindAll(t *testing.T) { entry := &test.MockEntry{} now := time.Now() - entry.Create("id", &now, &test.MockEntryMetaData{ + err := repo.Create(entry, &now, &test.MockEntryMetaData{ Str: "str", Number: 1, Date: now, }) - err := repo.Create(entry) + require.NoError(t, err) entry2 := &test.MockEntry{} now2 := time.Now() - entry2.Create("id2", &now2, &test.MockEntryMetaData{ - Str: "str2", - Number: 2, - Date: now2, + err = repo.Create(entry2, &now2, &test.MockEntryMetaData{ + Str: "str", + Number: 1, + Date: now, }) - err = repo.Create(entry2) require.NoError(t, err) entries, err := repo.FindAll(nil) @@ -106,25 +103,25 @@ func TestRepoUpdate(t *testing.T) { entry := &test.MockEntry{} now := time.Now() - entry.Create("id", &now, &test.MockEntryMetaData{ + err := repo.Create(entry, &now, &test.MockEntryMetaData{ Str: "str", Number: 1, Date: now, }) - err := repo.Create(entry) require.NoError(t, err) entry2 := &test.MockEntry{} now2 := time.Now() - entry2.Create("id", &now2, &test.MockEntryMetaData{ + err = repo.Create(entry2, &now2, &test.MockEntryMetaData{ Str: "str2", Number: 2, Date: now2, }) + require.NoError(t, err) err = repo.Update(entry2) require.NoError(t, err) - entry3, err := repo.FindById("id") + entry3, err := repo.FindById(entry2.ID()) require.NoError(t, err) require.Equal(t, entry3.Content(), entry2.Content()) require.Equal(t, entry3.PublishedAt().Unix(), entry2.PublishedAt().Unix()) diff --git a/web/editor/entity_form_test.go b/web/editor/entity_form_test.go index 050e402..68fd685 100644 --- a/web/editor/entity_form_test.go +++ b/web/editor/entity_form_test.go @@ -1,6 +1,7 @@ package editor_test import ( + "mime/multipart" "owl-blogs/domain/model" "owl-blogs/web/editor" "reflect" @@ -15,10 +16,21 @@ type MockEntryMetaData struct { Content string `owl:"inputType=text"` } +type MockFormData struct { +} + +func (f *MockFormData) FormFile(key string) (*multipart.FileHeader, error) { + return nil, nil +} + +func (f *MockFormData) FormValue(key string, defaultValue ...string) string { + return key +} + type MockEntry struct { id string publishedAt *time.Time - metaData *MockEntryMetaData + metaData MockEntryMetaData } func (e *MockEntry) ID() string { @@ -34,13 +46,13 @@ func (e *MockEntry) PublishedAt() *time.Time { } func (e *MockEntry) MetaData() interface{} { - return e.metaData + return &e.metaData } func (e *MockEntry) Create(id string, publishedAt *time.Time, metaData model.EntryMetaData) error { e.id = id e.publishedAt = publishedAt - e.metaData = metaData.(*MockEntryMetaData) + e.metaData = *metaData.(*MockEntryMetaData) return nil } @@ -63,13 +75,27 @@ func TestStructToFields(t *testing.T) { } func TestEditorEntryForm_HtmlForm(t *testing.T) { - formService := editor.NewEditorFormService(&MockEntry{}) - form, err := formService.HtmlForm() + form := editor.NewEntryForm(&MockEntry{}) + html, err := form.HtmlForm() require.NoError(t, err) - require.Contains(t, form, "") - require.Contains(t, form, "") - require.Contains(t, form, "") + require.Contains(t, html, "\n", s.Params.InputType, s.Name) + html := "" + html += fmt.Sprintf("\n", s.Name, s.Name) + if s.Params.InputType == "text" && s.Params.Widget == "textarea" { + html += fmt.Sprintf("\n", s.Name, s.Name) + } else { + html += fmt.Sprintf("\n", s.Params.InputType, s.Name, s.Name) + } + return html } func FieldToFormField(field reflect.StructField) (EntryFormField, error) { @@ -89,3 +112,28 @@ func (s *EditorEntryForm) HtmlForm() (string, error) { return html, nil } + +func (s *EditorEntryForm) Parse(ctx HttpFormData) (model.Entry, error) { + if ctx == nil { + return nil, fmt.Errorf("nil context") + } + meta := s.entry.MetaData() + metaVal := reflect.ValueOf(meta) + if metaVal.Kind() != reflect.Ptr { + return nil, fmt.Errorf("meta data is not a pointer") + } + fields, err := StructToFormFields(meta) + if err != nil { + return nil, err + } + for field := range fields { + fieldName := fields[field].Name + fieldValue := ctx.FormValue(fieldName) + metaField := metaVal.Elem().FieldByName(fieldName) + if metaField.IsValid() { + metaField.SetString(fieldValue) + } + } + + return s.entry, nil +} diff --git a/web/editor_handler.go b/web/editor_handler.go index 8fbd363..d9bb198 100644 --- a/web/editor_handler.go +++ b/web/editor_handler.go @@ -4,6 +4,7 @@ import ( "owl-blogs/app" "owl-blogs/domain/model" "owl-blogs/web/editor" + "time" "github.com/gofiber/fiber/v2" ) @@ -18,15 +19,30 @@ func NewEditorHandler(entryService *app.EntryService) *EditorHandler { func (h *EditorHandler) HandleGet(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) - formService := editor.NewEditorFormService(&model.ImageEntry{}) - form, err := formService.HtmlForm() + form := editor.NewEntryForm(&model.ImageEntry{}) + htmlForm, err := form.HtmlForm() if err != nil { return err } - return c.SendString(form) + return c.SendString(htmlForm) } func (h *EditorHandler) HandlePost(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + form := editor.NewEntryForm(&model.ImageEntry{}) + // get form data + metaData, err := form.Parse(c) + if err != nil { + return err + } + + // create entry + now := time.Now() + err = h.entrySvc.Create(&model.ImageEntry{}, &now, metaData) + if err != nil { + return err + } + return c.SendString("Hello, Editor!") } From 8fc82b86a36c0ef0ac1401406a90005cf09753c1 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Thu, 6 Jul 2023 19:42:54 +0200 Subject: [PATCH 09/41] WIP first entry creation --- .gitignore | 3 +++ main.go | 4 ++++ owlblogs.db | Bin 12288 -> 0 bytes web/editor_handler.go | 3 ++- 4 files changed, 9 insertions(+), 1 deletion(-) delete mode 100644 owlblogs.db diff --git a/.gitignore b/.gitignore index e610cac..4db9215 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ users/ .vscode/ *.swp + + +*.db \ No newline at end of file diff --git a/main.go b/main.go index 413dfb7..6cb60bc 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "owl-blogs/app" + "owl-blogs/domain/model" "owl-blogs/infra" "owl-blogs/web" ) @@ -9,6 +10,9 @@ import ( func main() { db := infra.NewSqliteDB("owlblogs.db") registry := app.NewEntryTypeRegistry() + + registry.Register(&model.ImageEntry{}) + repo := infra.NewEntryRepository(db, registry) entryService := app.NewEntryService(repo) webApp := web.NewWebApp(entryService) diff --git a/owlblogs.db b/owlblogs.db deleted file mode 100644 index 9139de8567b1e1da39d33517ca7f6a733c0077d5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeI#K}*9h6bJC6irT^GV22%g$x%TC*;%k^4#RG0jo?lpvk@y@JJ$^KsNc?;AIGCf z?GA#ln}_iaBDC9Zm#3ZX!cOfr^>KC=@3fE31fs1YWs%m%jV+EWngC)uWFPW zK7F=>FX9DV5_E%)RSDRK00bZa0SG_<0uX=z1Rwwb2>g@4Tc2+C!jQfn8#T$bF3ddB zRWo^K36BJ4B07yZYZlpV%lG{(WrE)cc9jf Date: Thu, 6 Jul 2023 22:16:52 +0200 Subject: [PATCH 10/41] editor list --- cmd/owl/editor_test.go | 48 ++++++++++++++++++++++++ main.go => cmd/owl/main.go | 10 +++-- test/fixtures/test.png | Bin 0 -> 3118 bytes web/app.go | 16 ++++---- web/editor_handler.go | 31 +++++++++++++--- web/editor_list_handler.go | 53 +++++++++++++++++++++++++++ web/templates/base.tmpl | 18 +++++++++ web/templates/views/editor_list.tmpl | 12 ++++++ 8 files changed, 173 insertions(+), 15 deletions(-) create mode 100644 cmd/owl/editor_test.go rename main.go => cmd/owl/main.go (79%) create mode 100644 test/fixtures/test.png create mode 100644 web/editor_list_handler.go create mode 100644 web/templates/base.tmpl create mode 100644 web/templates/views/editor_list.tmpl diff --git a/cmd/owl/editor_test.go b/cmd/owl/editor_test.go new file mode 100644 index 0000000..a7735a5 --- /dev/null +++ b/cmd/owl/editor_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "bytes" + "io" + "mime/multipart" + "net/http/httptest" + "os" + "path" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEditorFormGet(t *testing.T) { + app := App().FiberApp + + req := httptest.NewRequest("GET", "/editor/ImageEntry", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) +} + +func TestEditorFormPost(t *testing.T) { + app := App().FiberApp + + fileDir, _ := os.Getwd() + fileName := "../../test/fixtures/test.png" + filePath := path.Join(fileDir, fileName) + + file, err := os.Open(filePath) + require.NoError(t, err) + defer file.Close() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("ImagePath", filepath.Base(file.Name())) + io.Copy(part, file) + part, _ = writer.CreateFormField("Content") + io.WriteString(part, "test content") + writer.Close() + + req := httptest.NewRequest("POST", "/editor/ImageEntry", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) +} diff --git a/main.go b/cmd/owl/main.go similarity index 79% rename from main.go rename to cmd/owl/main.go index 6cb60bc..22ae34d 100644 --- a/main.go +++ b/cmd/owl/main.go @@ -7,7 +7,7 @@ import ( "owl-blogs/web" ) -func main() { +func App() *web.WebApp { db := infra.NewSqliteDB("owlblogs.db") registry := app.NewEntryTypeRegistry() @@ -15,6 +15,10 @@ func main() { repo := infra.NewEntryRepository(db, registry) entryService := app.NewEntryService(repo) - webApp := web.NewWebApp(entryService) - webApp.Run() + return web.NewWebApp(entryService, registry) + +} + +func main() { + App().Run() } diff --git a/test/fixtures/test.png b/test/fixtures/test.png new file mode 100644 index 0000000000000000000000000000000000000000..fb234fd01e2b8d41d180101c5e61ed3575fa7b02 GIT binary patch literal 3118 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 + + + + {{template "title" .}} - Owl Blog + + +
    + Owl Blog +
    +
    + {{template "main" .}} +
    +
    + + +{{end}} \ No newline at end of file diff --git a/web/templates/views/editor_list.tmpl b/web/templates/views/editor_list.tmpl new file mode 100644 index 0000000..4af68b6 --- /dev/null +++ b/web/templates/views/editor_list.tmpl @@ -0,0 +1,12 @@ +{{define "title"}}Editor List{{end}} + +{{define "main"}} + +

    Editor List

    + +
      +{{range .Types}} +
    • {{.}}
    • +{{end}} + +{{end}} \ No newline at end of file From 28fd4f1dc2c5ba5f60a10081448498d5d8f4249e Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Fri, 7 Jul 2023 21:04:25 +0200 Subject: [PATCH 11/41] first entry created and displayed --- app/entry_register_test.go | 23 ++++++++++++ cmd/owl/editor_test.go | 37 +++++++++++++++++-- cmd/owl/main.go | 8 ++-- domain/model/image_entry.go | 11 ++---- web/app.go | 7 ++-- ...entity_form_test.go => entry_form_test.go} | 0 web/editor_handler.go | 7 ++-- web/entry_handler.go | 34 +++++++++++++++-- web/templates/views/entry/ImageEntry.tmpl | 17 +++++++++ 9 files changed, 120 insertions(+), 24 deletions(-) create mode 100644 app/entry_register_test.go rename web/editor/{entity_form_test.go => entry_form_test.go} (100%) create mode 100644 web/templates/views/entry/ImageEntry.tmpl diff --git a/app/entry_register_test.go b/app/entry_register_test.go new file mode 100644 index 0000000..47775d1 --- /dev/null +++ b/app/entry_register_test.go @@ -0,0 +1,23 @@ +package app_test + +import ( + "owl-blogs/app" + "owl-blogs/test" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRegistryTypeNameNotExisting(t *testing.T) { + register := app.NewEntryTypeRegistry() + _, err := register.TypeName(&test.MockEntry{}) + require.Error(t, err) +} + +func TestRegistryTypeName(t *testing.T) { + register := app.NewEntryTypeRegistry() + register.Register(&test.MockEntry{}) + name, err := register.TypeName(&test.MockEntry{}) + require.NoError(t, err) + require.Equal(t, "MockEntry", name) +} diff --git a/cmd/owl/editor_test.go b/cmd/owl/editor_test.go index a7735a5..a2efef6 100644 --- a/cmd/owl/editor_test.go +++ b/cmd/owl/editor_test.go @@ -3,18 +3,34 @@ package main import ( "bytes" "io" + "math/rand" "mime/multipart" "net/http/httptest" "os" + "owl-blogs/domain/model" + "owl-blogs/infra" "path" "path/filepath" + "strings" "testing" + "time" "github.com/stretchr/testify/require" ) +func testDbName() string { + var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + rand.Seed(time.Now().UnixNano()) + b := make([]rune, 6) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return "/tmp/" + string(b) + ".db" +} + func TestEditorFormGet(t *testing.T) { - app := App().FiberApp + db := infra.NewSqliteDB(testDbName()) + app := App(db).FiberApp req := httptest.NewRequest("GET", "/editor/ImageEntry", nil) resp, err := app.Test(req) @@ -23,7 +39,11 @@ func TestEditorFormGet(t *testing.T) { } func TestEditorFormPost(t *testing.T) { - app := App().FiberApp + dbName := testDbName() + db := infra.NewSqliteDB(dbName) + owlApp := App(db) + app := owlApp.FiberApp + repo := infra.NewEntryRepository(db, owlApp.Registry) fileDir, _ := os.Getwd() fileName := "../../test/fixtures/test.png" @@ -41,8 +61,17 @@ func TestEditorFormPost(t *testing.T) { io.WriteString(part, "test content") writer.Close() - req := httptest.NewRequest("POST", "/editor/ImageEntry", nil) + req := httptest.NewRequest("POST", "/editor/ImageEntry", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) resp, err := app.Test(req) require.NoError(t, err) - require.Equal(t, 200, resp.StatusCode) + require.Equal(t, 302, resp.StatusCode) + require.Contains(t, resp.Header.Get("Location"), "/posts/") + + id := strings.Split(resp.Header.Get("Location"), "/")[2] + entry, err := repo.FindById(id) + require.NoError(t, err) + require.Equal(t, "test content", entry.MetaData().(*model.ImageEntryMetaData).Content) + // require.Equal(t, "test.png", entry.MetaData().(*model.ImageEntryMetaData).ImagePath) + } diff --git a/cmd/owl/main.go b/cmd/owl/main.go index 22ae34d..815b8cb 100644 --- a/cmd/owl/main.go +++ b/cmd/owl/main.go @@ -7,8 +7,9 @@ import ( "owl-blogs/web" ) -func App() *web.WebApp { - db := infra.NewSqliteDB("owlblogs.db") +const DbPath = "owlblogs.db" + +func App(db infra.Database) *web.WebApp { registry := app.NewEntryTypeRegistry() registry.Register(&model.ImageEntry{}) @@ -20,5 +21,6 @@ func App() *web.WebApp { } func main() { - App().Run() + db := infra.NewSqliteDB(DbPath) + App(db).Run() } diff --git a/domain/model/image_entry.go b/domain/model/image_entry.go index 0ac5ee2..195be9e 100644 --- a/domain/model/image_entry.go +++ b/domain/model/image_entry.go @@ -9,8 +9,8 @@ type ImageEntry struct { } type ImageEntryMetaData struct { - ImagePath string `owl:"inputType=file"` - Content EntryContent `owl:"inputType=text widget=textarea"` + ImagePath string `owl:"inputType=file"` + Content string `owl:"inputType=text widget=textarea"` } func (e *ImageEntry) ID() string { @@ -18,7 +18,7 @@ func (e *ImageEntry) ID() string { } func (e *ImageEntry) Content() EntryContent { - return e.meta.Content + return EntryContent(e.meta.Content) } func (e *ImageEntry) PublishedAt() *time.Time { @@ -26,10 +26,7 @@ func (e *ImageEntry) PublishedAt() *time.Time { } func (e *ImageEntry) MetaData() interface{} { - return &ImageEntryMetaData{ - ImagePath: e.meta.ImagePath, - Content: e.meta.Content, - } + return &e.meta } func (e *ImageEntry) Create(id string, publishedAt *time.Time, metaData EntryMetaData) error { diff --git a/web/app.go b/web/app.go index dd08d47..7b5eb6a 100644 --- a/web/app.go +++ b/web/app.go @@ -8,7 +8,8 @@ import ( type WebApp struct { FiberApp *fiber.App - entryService *app.EntryService + EntryService *app.EntryService + Registry *app.EntryTypeRegistry } func NewWebApp(entryService *app.EntryService, typeRegistry *app.EntryTypeRegistry) *WebApp { @@ -16,7 +17,7 @@ func NewWebApp(entryService *app.EntryService, typeRegistry *app.EntryTypeRegist indexHandler := NewIndexHandler(entryService) listHandler := NewListHandler(entryService) - entryHandler := NewEntryHandler(entryService) + entryHandler := NewEntryHandler(entryService, typeRegistry) mediaHandler := NewMediaHandler(entryService) rssHandler := NewRSSHandler(entryService) loginHandler := NewLoginHandler(entryService) @@ -50,7 +51,7 @@ func NewWebApp(entryService *app.EntryService, typeRegistry *app.EntryTypeRegist // app.Post("/auth/token/", userAuthTokenHandler(repo)) // app.Get("/.well-known/oauth-authorization-server", userAuthMetadataHandler(repo)) // app.NotFound = http.HandlerFunc(notFoundHandler(repo)) - return &WebApp{FiberApp: app, entryService: entryService} + return &WebApp{FiberApp: app, EntryService: entryService, Registry: typeRegistry} } func (w *WebApp) Run() { diff --git a/web/editor/entity_form_test.go b/web/editor/entry_form_test.go similarity index 100% rename from web/editor/entity_form_test.go rename to web/editor/entry_form_test.go diff --git a/web/editor_handler.go b/web/editor_handler.go index b80675c..b4d4ac3 100644 --- a/web/editor_handler.go +++ b/web/editor_handler.go @@ -53,18 +53,17 @@ func (h *EditorHandler) HandlePost(c *fiber.Ctx) error { form := editor.NewEntryForm(entryType) // get form data - metaData, err := form.Parse(c) + entry, err := form.Parse(c) if err != nil { return err } // create entry now := time.Now() - entry := entryType - err = h.entrySvc.Create(entry, &now, metaData.MetaData()) + err = h.entrySvc.Create(entry, &now, entry.MetaData()) if err != nil { return err } + return c.Redirect("/posts/" + entry.ID() + "/") - return c.SendString("Hello, Editor!") } diff --git a/web/entry_handler.go b/web/entry_handler.go index 5c39911..3b81401 100644 --- a/web/entry_handler.go +++ b/web/entry_handler.go @@ -2,18 +2,46 @@ package web import ( "owl-blogs/app" + "owl-blogs/domain/model" + "text/template" "github.com/gofiber/fiber/v2" ) type EntryHandler struct { entrySvc *app.EntryService + registry *app.EntryTypeRegistry } -func NewEntryHandler(entryService *app.EntryService) *EntryHandler { - return &EntryHandler{entrySvc: entryService} +func NewEntryHandler(entryService *app.EntryService, registry *app.EntryTypeRegistry) *EntryHandler { + return &EntryHandler{entrySvc: entryService, registry: registry} +} + +func (h *EntryHandler) getTemplate(entry model.Entry) (*template.Template, error) { + name, err := h.registry.TypeName(entry) + if err != nil { + return nil, err + } + return template.ParseFS( + templates, + "templates/base.tmpl", + "templates/views/entry/"+name+".tmpl", + ) } func (h *EntryHandler) Handle(c *fiber.Ctx) error { - return c.SendString("Hello, RSS!") + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + entryId := c.Params("post") + entry, err := h.entrySvc.FindById(entryId) + if err != nil { + return err + } + + template, err := h.getTemplate(entry) + if err != nil { + return err + } + + return template.ExecuteTemplate(c, "base", entry) } diff --git a/web/templates/views/entry/ImageEntry.tmpl b/web/templates/views/entry/ImageEntry.tmpl new file mode 100644 index 0000000..05441b9 --- /dev/null +++ b/web/templates/views/entry/ImageEntry.tmpl @@ -0,0 +1,17 @@ +{{define "title"}}Image Entry{{end}} + +{{define "main"}} + +{{.MetaData.ImagePath}} + +

      +{{.Content}} +

      + +

      + Published: {{.PublishedAt}} +

      + + +{{end}} + From 9301790408c5c369fbf215d1a2c2be283a2563a1 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Sat, 8 Jul 2023 11:08:55 +0200 Subject: [PATCH 12/41] binary service --- app/binary_service.go | 22 ++++++++++++ app/entry_register.go | 9 ++++- app/repository/interfaces.go | 5 +++ cmd/owl/main.go | 9 +++-- domain/model/binary_file.go | 7 ++++ domain/model/image_entry.go | 4 +-- infra/binary_file_repository.go | 51 ++++++++++++++++++++++++++++ infra/binary_file_repository_test.go | 47 +++++++++++++++++++++++++ infra/entry_repository_test.go | 36 ++++++++++++++++++++ web/app.go | 16 ++++++--- 10 files changed, 195 insertions(+), 11 deletions(-) create mode 100644 app/binary_service.go create mode 100644 domain/model/binary_file.go create mode 100644 infra/binary_file_repository.go create mode 100644 infra/binary_file_repository_test.go diff --git a/app/binary_service.go b/app/binary_service.go new file mode 100644 index 0000000..4ef64b7 --- /dev/null +++ b/app/binary_service.go @@ -0,0 +1,22 @@ +package app + +import ( + "owl-blogs/app/repository" + "owl-blogs/domain/model" +) + +type BinaryService struct { + repo repository.BinaryRepository +} + +func NewBinaryFileService(repo repository.BinaryRepository) *BinaryService { + return &BinaryService{repo: repo} +} + +func (s *BinaryService) Create(name string, file []byte) (*model.BinaryFile, error) { + return s.repo.Create(name, file) +} + +func (s *BinaryService) FindById(id string) (*model.BinaryFile, error) { + return s.repo.FindById(id) +} diff --git a/app/entry_register.go b/app/entry_register.go index a6dd53b..90c4e7e 100644 --- a/app/entry_register.go +++ b/app/entry_register.go @@ -47,5 +47,12 @@ func (r *EntryTypeRegistry) Type(name string) (model.Entry, error) { if _, ok := r.types[name]; !ok { return nil, errors.New("entry type not registered") } - return r.types[name], nil + + val := reflect.ValueOf(r.types[name]) + if val.Kind() == reflect.Ptr { + val = reflect.Indirect(val) + } + newEntry := reflect.New(val.Type()).Interface().(model.Entry) + + return newEntry, nil } diff --git a/app/repository/interfaces.go b/app/repository/interfaces.go index 7a9cf2b..8eed3dc 100644 --- a/app/repository/interfaces.go +++ b/app/repository/interfaces.go @@ -12,3 +12,8 @@ type EntryRepository interface { FindById(id string) (model.Entry, error) FindAll(types *[]string) ([]model.Entry, error) } + +type BinaryRepository interface { + Create(name string, data []byte) (*model.BinaryFile, error) + FindById(id string) (*model.BinaryFile, error) +} diff --git a/cmd/owl/main.go b/cmd/owl/main.go index 815b8cb..e4c3aeb 100644 --- a/cmd/owl/main.go +++ b/cmd/owl/main.go @@ -14,9 +14,12 @@ func App(db infra.Database) *web.WebApp { registry.Register(&model.ImageEntry{}) - repo := infra.NewEntryRepository(db, registry) - entryService := app.NewEntryService(repo) - return web.NewWebApp(entryService, registry) + entryRepo := infra.NewEntryRepository(db, registry) + binRepo := infra.NewBinaryFileRepo(db) + + entryService := app.NewEntryService(entryRepo) + binaryService := app.NewBinaryFileService(binRepo) + return web.NewWebApp(entryService, registry, binaryService) } diff --git a/domain/model/binary_file.go b/domain/model/binary_file.go new file mode 100644 index 0000000..0c9da91 --- /dev/null +++ b/domain/model/binary_file.go @@ -0,0 +1,7 @@ +package model + +type BinaryFile struct { + Id string + Name string + Data []byte +} diff --git a/domain/model/image_entry.go b/domain/model/image_entry.go index 195be9e..9b92545 100644 --- a/domain/model/image_entry.go +++ b/domain/model/image_entry.go @@ -9,8 +9,8 @@ type ImageEntry struct { } type ImageEntryMetaData struct { - ImagePath string `owl:"inputType=file"` - Content string `owl:"inputType=text widget=textarea"` + ImageId string `owl:"inputType=file"` + Content string `owl:"inputType=text widget=textarea"` } func (e *ImageEntry) ID() string { diff --git a/infra/binary_file_repository.go b/infra/binary_file_repository.go new file mode 100644 index 0000000..d01ce42 --- /dev/null +++ b/infra/binary_file_repository.go @@ -0,0 +1,51 @@ +package infra + +import ( + "owl-blogs/domain/model" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +type sqlBinaryFile struct { + Id string `db:"id"` + Name string `db:"name"` + Data []byte `db:"data"` +} + +type DefaultBinaryFileRepo struct { + db *sqlx.DB +} + +func NewBinaryFileRepo(db Database) *DefaultBinaryFileRepo { + sqlxdb := db.Get() + + // Create table if not exists + sqlxdb.MustExec(` + CREATE TABLE IF NOT EXISTS binary_files ( + id VARCHAR(255) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + data BLOB NOT NULL + ); + `) + + return &DefaultBinaryFileRepo{db: sqlxdb} +} + +func (repo *DefaultBinaryFileRepo) Create(name string, data []byte) (*model.BinaryFile, error) { + id := uuid.New().String() + _, err := repo.db.Exec("INSERT INTO binary_files (id, name, data) VALUES (?, ?, ?)", id, name, data) + if err != nil { + return nil, err + } + return &model.BinaryFile{Id: id, Name: name, Data: data}, nil +} + +func (repo *DefaultBinaryFileRepo) FindById(id string) (*model.BinaryFile, error) { + var sqlFile sqlBinaryFile + err := repo.db.Get(&sqlFile, "SELECT * FROM binary_files WHERE id = ?", id) + if err != nil { + return nil, err + } + return &model.BinaryFile{Id: sqlFile.Id, Name: sqlFile.Name, Data: sqlFile.Data}, nil +} diff --git a/infra/binary_file_repository_test.go b/infra/binary_file_repository_test.go new file mode 100644 index 0000000..d4e7853 --- /dev/null +++ b/infra/binary_file_repository_test.go @@ -0,0 +1,47 @@ +package infra_test + +import ( + "owl-blogs/app/repository" + "owl-blogs/infra" + "owl-blogs/test" + "testing" + + "github.com/stretchr/testify/require" +) + +func setupBinaryRepo() repository.BinaryRepository { + db := test.NewMockDb() + repo := infra.NewBinaryFileRepo(db) + return repo +} + +func TestBinaryRepoCreate(t *testing.T) { + repo := setupBinaryRepo() + + file, err := repo.Create("name", []byte("😀 😃 😄 😁")) + require.NoError(t, err) + + file, err = repo.FindById(file.Id) + require.NoError(t, err) + require.Equal(t, file.Name, "name") + require.Equal(t, file.Data, []byte("😀 😃 😄 😁")) +} + +func TestBinaryRepoNoSideEffect(t *testing.T) { + repo := setupBinaryRepo() + + file, err := repo.Create("name1", []byte("111")) + require.NoError(t, err) + + file2, err := repo.Create("name2", []byte("222")) + require.NoError(t, err) + + file, err = repo.FindById(file.Id) + require.NoError(t, err) + file2, err = repo.FindById(file2.Id) + require.NoError(t, err) + require.Equal(t, file.Name, "name1") + require.Equal(t, file.Data, []byte("111")) + require.Equal(t, file2.Name, "name2") + require.Equal(t, file2.Data, []byte("222")) +} diff --git a/infra/entry_repository_test.go b/infra/entry_repository_test.go index 55bd9cf..949c9b2 100644 --- a/infra/entry_repository_test.go +++ b/infra/entry_repository_test.go @@ -131,3 +131,39 @@ func TestRepoUpdate(t *testing.T) { require.Equal(t, meta.Number, meta2.Number) require.Equal(t, meta.Date.Unix(), meta2.Date.Unix()) } + +func TestRepoNoSideEffect(t *testing.T) { + repo := setupRepo() + + entry1 := &test.MockEntry{} + now1 := time.Now() + err := repo.Create(entry1, &now1, &test.MockEntryMetaData{ + Str: "1", + Number: 1, + Date: now1, + }) + require.NoError(t, err) + + entry2 := &test.MockEntry{} + now2 := time.Now() + err = repo.Create(entry2, &now2, &test.MockEntryMetaData{ + Str: "2", + Number: 2, + Date: now2, + }) + require.NoError(t, err) + + r1, err := repo.FindById(entry1.ID()) + require.NoError(t, err) + r2, err := repo.FindById(entry2.ID()) + require.NoError(t, err) + + require.Equal(t, r1.MetaData().(*test.MockEntryMetaData).Str, "1") + require.Equal(t, r1.MetaData().(*test.MockEntryMetaData).Number, 1) + require.Equal(t, r1.MetaData().(*test.MockEntryMetaData).Date.Unix(), now1.Unix()) + + require.Equal(t, r2.MetaData().(*test.MockEntryMetaData).Str, "2") + require.Equal(t, r2.MetaData().(*test.MockEntryMetaData).Number, 2) + require.Equal(t, r2.MetaData().(*test.MockEntryMetaData).Date.Unix(), now2.Unix()) + +} diff --git a/web/app.go b/web/app.go index 7b5eb6a..1532664 100644 --- a/web/app.go +++ b/web/app.go @@ -7,12 +7,13 @@ import ( ) type WebApp struct { - FiberApp *fiber.App - EntryService *app.EntryService - Registry *app.EntryTypeRegistry + FiberApp *fiber.App + EntryService *app.EntryService + BinaryService *app.BinaryService + Registry *app.EntryTypeRegistry } -func NewWebApp(entryService *app.EntryService, typeRegistry *app.EntryTypeRegistry) *WebApp { +func NewWebApp(entryService *app.EntryService, typeRegistry *app.EntryTypeRegistry, binService *app.BinaryService) *WebApp { app := fiber.New() indexHandler := NewIndexHandler(entryService) @@ -51,7 +52,12 @@ func NewWebApp(entryService *app.EntryService, typeRegistry *app.EntryTypeRegist // app.Post("/auth/token/", userAuthTokenHandler(repo)) // app.Get("/.well-known/oauth-authorization-server", userAuthMetadataHandler(repo)) // app.NotFound = http.HandlerFunc(notFoundHandler(repo)) - return &WebApp{FiberApp: app, EntryService: entryService, Registry: typeRegistry} + return &WebApp{ + FiberApp: app, + EntryService: entryService, + Registry: typeRegistry, + BinaryService: binService, + } } func (w *WebApp) Run() { From bcf8ba4d9bec0fd3bc551da742fa615bcc96fa82 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Sat, 8 Jul 2023 12:03:10 +0200 Subject: [PATCH 13/41] upload files --- cmd/owl/editor_test.go | 13 +++++- infra/binary_file_repository.go | 3 +- web/app.go | 2 +- web/editor/entry_form.go | 52 +++++++++++++++++----- web/editor/entry_form_test.go | 53 ++++++++++++++++++++--- web/editor_handler.go | 9 ++-- web/templates/views/entry/ImageEntry.tmpl | 2 +- 7 files changed, 109 insertions(+), 25 deletions(-) diff --git a/cmd/owl/editor_test.go b/cmd/owl/editor_test.go index a2efef6..462b8ca 100644 --- a/cmd/owl/editor_test.go +++ b/cmd/owl/editor_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "io" + "io/ioutil" "math/rand" "mime/multipart" "net/http/httptest" @@ -44,6 +45,7 @@ func TestEditorFormPost(t *testing.T) { owlApp := App(db) app := owlApp.FiberApp repo := infra.NewEntryRepository(db, owlApp.Registry) + binRepo := infra.NewBinaryFileRepo(db) fileDir, _ := os.Getwd() fileName := "../../test/fixtures/test.png" @@ -51,11 +53,13 @@ func TestEditorFormPost(t *testing.T) { file, err := os.Open(filePath) require.NoError(t, err) + fileBytes, err := ioutil.ReadFile(filePath) + require.NoError(t, err) defer file.Close() body := &bytes.Buffer{} writer := multipart.NewWriter(body) - part, _ := writer.CreateFormFile("ImagePath", filepath.Base(file.Name())) + part, _ := writer.CreateFormFile("ImageId", filepath.Base(file.Name())) io.Copy(part, file) part, _ = writer.CreateFormField("Content") io.WriteString(part, "test content") @@ -72,6 +76,11 @@ func TestEditorFormPost(t *testing.T) { entry, err := repo.FindById(id) require.NoError(t, err) require.Equal(t, "test content", entry.MetaData().(*model.ImageEntryMetaData).Content) - // require.Equal(t, "test.png", entry.MetaData().(*model.ImageEntryMetaData).ImagePath) + imageId := entry.MetaData().(*model.ImageEntryMetaData).ImageId + require.NotZero(t, imageId) + bin, err := binRepo.FindById(imageId) + require.NoError(t, err) + require.Equal(t, bin.Name, "test.png") + require.Equal(t, fileBytes, bin.Data) } diff --git a/infra/binary_file_repository.go b/infra/binary_file_repository.go index d01ce42..361539c 100644 --- a/infra/binary_file_repository.go +++ b/infra/binary_file_repository.go @@ -1,6 +1,7 @@ package infra import ( + "owl-blogs/app/repository" "owl-blogs/domain/model" "github.com/google/uuid" @@ -17,7 +18,7 @@ type DefaultBinaryFileRepo struct { db *sqlx.DB } -func NewBinaryFileRepo(db Database) *DefaultBinaryFileRepo { +func NewBinaryFileRepo(db Database) repository.BinaryRepository { sqlxdb := db.Get() // Create table if not exists diff --git a/web/app.go b/web/app.go index 1532664..3accf1e 100644 --- a/web/app.go +++ b/web/app.go @@ -23,7 +23,7 @@ func NewWebApp(entryService *app.EntryService, typeRegistry *app.EntryTypeRegist rssHandler := NewRSSHandler(entryService) loginHandler := NewLoginHandler(entryService) editorListHandler := NewEditorListHandler(typeRegistry) - editorHandler := NewEditorHandler(entryService, typeRegistry) + editorHandler := NewEditorHandler(entryService, typeRegistry, binService) // app.ServeFiles("/static/*filepath", http.Dir(repo.StaticDir())) app.Get("/", indexHandler.Handle) diff --git a/web/editor/entry_form.go b/web/editor/entry_form.go index 45f5c60..9857f2b 100644 --- a/web/editor/entry_form.go +++ b/web/editor/entry_form.go @@ -3,6 +3,7 @@ package editor import ( "fmt" "mime/multipart" + "owl-blogs/app" "owl-blogs/domain/model" "reflect" "strings" @@ -21,7 +22,8 @@ type HttpFormData interface { } type EditorEntryForm struct { - entry model.Entry + entry model.Entry + binSvc *app.BinaryService } type EntryFormFieldParams struct { @@ -34,9 +36,10 @@ type EntryFormField struct { Params EntryFormFieldParams } -func NewEntryForm(entry model.Entry) *EditorEntryForm { +func NewEntryForm(entry model.Entry, binaryService *app.BinaryService) *EditorEntryForm { return &EditorEntryForm{ - entry: entry, + entry: entry, + binSvc: binaryService, } } @@ -103,7 +106,7 @@ func (s *EditorEntryForm) HtmlForm() (string, error) { return "", err } - html := "
      \n" + html := "\n" for _, field := range fields { html += field.Html() } @@ -126,13 +129,42 @@ func (s *EditorEntryForm) Parse(ctx HttpFormData) (model.Entry, error) { if err != nil { return nil, err } - for field := range fields { - fieldName := fields[field].Name - fieldValue := ctx.FormValue(fieldName) - metaField := metaVal.Elem().FieldByName(fieldName) - if metaField.IsValid() { - metaField.SetString(fieldValue) + for _, field := range fields { + fieldName := field.Name + + if field.Params.InputType == "file" { + file, err := ctx.FormFile(fieldName) + if err != nil { + return nil, err + } + fileData, err := file.Open() + if err != nil { + return nil, err + } + defer fileData.Close() + fileBytes := make([]byte, file.Size) + _, err = fileData.Read(fileBytes) + if err != nil { + return nil, err + } + + binaryFile, err := s.binSvc.Create(file.Filename, fileBytes) + if err != nil { + return nil, err + } + + metaField := metaVal.Elem().FieldByName(fieldName) + if metaField.IsValid() { + metaField.SetString(binaryFile.Id) + } + } else { + formValue := ctx.FormValue(fieldName) + metaField := metaVal.Elem().FieldByName(fieldName) + if metaField.IsValid() { + metaField.SetString(formValue) + } } + } return s.entry, nil diff --git a/web/editor/entry_form_test.go b/web/editor/entry_form_test.go index 68fd685..79396a1 100644 --- a/web/editor/entry_form_test.go +++ b/web/editor/entry_form_test.go @@ -1,9 +1,17 @@ package editor_test import ( + "bytes" + "io" "mime/multipart" + "os" + "owl-blogs/app" "owl-blogs/domain/model" + "owl-blogs/infra" + "owl-blogs/test" "owl-blogs/web/editor" + "path" + "path/filepath" "reflect" "testing" "time" @@ -17,10 +25,41 @@ type MockEntryMetaData struct { } type MockFormData struct { + fileHeader *multipart.FileHeader +} + +func NewMockFormData() *MockFormData { + fileDir, _ := os.Getwd() + fileName := "../../test/fixtures/test.png" + filePath := path.Join(fileDir, fileName) + + file, err := os.Open(filePath) + if err != nil { + panic(err) + } + defer file.Close() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("ImagePath", filepath.Base(file.Name())) + if err != nil { + panic(err) + } + io.Copy(part, file) + writer.Close() + + multipartForm := multipart.NewReader(body, writer.Boundary()) + formData, err := multipartForm.ReadForm(0) + if err != nil { + panic(err) + } + fileHeader := formData.File["ImagePath"][0] + + return &MockFormData{fileHeader: fileHeader} } func (f *MockFormData) FormFile(key string) (*multipart.FileHeader, error) { - return nil, nil + return f.fileHeader, nil } func (f *MockFormData) FormValue(key string, defaultValue ...string) string { @@ -75,7 +114,7 @@ func TestStructToFields(t *testing.T) { } func TestEditorEntryForm_HtmlForm(t *testing.T) { - form := editor.NewEntryForm(&MockEntry{}) + form := editor.NewEntryForm(&MockEntry{}, nil) html, err := form.HtmlForm() require.NoError(t, err) require.Contains(t, html, " {{.Content}} From 197629db9a3143565127d431518a5e6a7250aa51 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Sat, 8 Jul 2023 13:28:06 +0200 Subject: [PATCH 14/41] secured editor --- app/author_service.go | 76 +++++++++++++++++++++++++++++++ app/author_service_test.go | 79 +++++++++++++++++++++++++++++++++ app/repository/interfaces.go | 5 +++ cmd/owl/editor_test.go | 78 ++++++++++++++++++++++++++------ cmd/owl/main.go | 9 +++- config/config.go | 29 ++++++++++++ domain/model/author.go | 6 +++ go.mod | 3 +- go.sum | 4 ++ infra/author_repository.go | 61 +++++++++++++++++++++++++ infra/author_repository_test.go | 47 ++++++++++++++++++++ run_tests.sh | 6 +++ web/app.go | 27 ++++++++--- web/editor_handler.go | 12 ++++- web/middleware/auth.go | 31 +++++++++++++ 15 files changed, 447 insertions(+), 26 deletions(-) create mode 100644 app/author_service.go create mode 100644 app/author_service_test.go create mode 100644 config/config.go create mode 100644 domain/model/author.go create mode 100644 infra/author_repository.go create mode 100644 infra/author_repository_test.go create mode 100755 run_tests.sh create mode 100644 web/middleware/auth.go diff --git a/app/author_service.go b/app/author_service.go new file mode 100644 index 0000000..7b27747 --- /dev/null +++ b/app/author_service.go @@ -0,0 +1,76 @@ +package app + +import ( + "crypto/sha256" + "fmt" + "owl-blogs/app/repository" + "owl-blogs/config" + "owl-blogs/domain/model" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +type AuthorService struct { + repo repository.AuthorRepository + config config.Config +} + +func NewAuthorService(repo repository.AuthorRepository, config config.Config) *AuthorService { + return &AuthorService{repo: repo, config: config} +} + +func hashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), 10) + if err != nil { + return "", err + } + return string(bytes), nil +} + +func (s *AuthorService) Create(name string, password string) (*model.Author, error) { + hash, err := hashPassword(password) + if err != nil { + return nil, err + } + return s.repo.Create(name, hash) +} + +func (s *AuthorService) FindByName(name string) (*model.Author, error) { + return s.repo.FindByName(name) +} + +func (s *AuthorService) Authenticate(name string, password string) bool { + author, err := s.repo.FindByName(name) + if err != nil { + return false + } + err = bcrypt.CompareHashAndPassword([]byte(author.PasswordHash), []byte(password)) + return err == nil +} + +func (s *AuthorService) getSecretKey() string { + return s.config.SECRET_KEY() +} + +func (s *AuthorService) CreateToken(name string) (string, error) { + hash := sha256.New() + _, err := hash.Write([]byte(name + s.getSecretKey())) + if err != nil { + return "", err + } + return fmt.Sprintf("%s.%x", name, hash.Sum(nil)), nil +} + +func (s *AuthorService) ValidateToken(token string) bool { + parts := strings.Split(token, ".") + witness := parts[len(parts)-1] + name := strings.Join(parts[:len(parts)-1], ".") + + hash := sha256.New() + _, err := hash.Write([]byte(name + s.getSecretKey())) + if err != nil { + return false + } + return fmt.Sprintf("%x", hash.Sum(nil)) == witness +} diff --git a/app/author_service_test.go b/app/author_service_test.go new file mode 100644 index 0000000..3c9a03c --- /dev/null +++ b/app/author_service_test.go @@ -0,0 +1,79 @@ +package app_test + +import ( + "owl-blogs/app" + "owl-blogs/infra" + "owl-blogs/test" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type testConfig struct { +} + +func (c *testConfig) SECRET_KEY() string { + return "test" +} + +func getAutherService() *app.AuthorService { + db := test.NewMockDb() + authorRepo := infra.NewDefaultAuthorRepo(db) + authorService := app.NewAuthorService(authorRepo, &testConfig{}) + return authorService + +} + +func TestAuthorCreate(t *testing.T) { + authorService := getAutherService() + author, err := authorService.Create("test", "test") + require.NoError(t, err) + require.Equal(t, "test", author.Name) + require.NotEmpty(t, author.PasswordHash) + require.NotEqual(t, "test", author.PasswordHash) +} + +func TestAuthorFindByName(t *testing.T) { + authorService := getAutherService() + _, err := authorService.Create("test", "test") + require.NoError(t, err) + author, err := authorService.FindByName("test") + require.NoError(t, err) + require.Equal(t, "test", author.Name) + require.NotEmpty(t, author.PasswordHash) + require.NotEqual(t, "test", author.PasswordHash) +} + +func TestAuthorAuthenticate(t *testing.T) { + authorService := getAutherService() + _, err := authorService.Create("test", "test") + require.NoError(t, err) + require.True(t, authorService.Authenticate("test", "test")) + require.False(t, authorService.Authenticate("test", "test1")) + require.False(t, authorService.Authenticate("test1", "test")) +} + +func TestAuthorCreateToken(t *testing.T) { + authorService := getAutherService() + _, err := authorService.Create("test", "test") + require.NoError(t, err) + token, err := authorService.CreateToken("test") + require.NoError(t, err) + require.NotEmpty(t, token) + require.NotEqual(t, "test", token) +} + +func TestAuthorValidateToken(t *testing.T) { + authorService := getAutherService() + _, err := authorService.Create("test", "test") + require.NoError(t, err) + token, err := authorService.CreateToken("test") + require.NoError(t, err) + + require.True(t, authorService.ValidateToken(token)) + require.False(t, authorService.ValidateToken(token[:len(token)-2])) + require.False(t, authorService.ValidateToken("test")) + require.False(t, authorService.ValidateToken("test.test")) + require.False(t, authorService.ValidateToken(strings.Replace(token, "test", "test1", 1))) +} diff --git a/app/repository/interfaces.go b/app/repository/interfaces.go index 8eed3dc..6c34079 100644 --- a/app/repository/interfaces.go +++ b/app/repository/interfaces.go @@ -17,3 +17,8 @@ type BinaryRepository interface { Create(name string, data []byte) (*model.BinaryFile, error) FindById(id string) (*model.BinaryFile, error) } + +type AuthorRepository interface { + Create(name string, passwordHash string) (*model.Author, error) + FindByName(name string) (*model.Author, error) +} diff --git a/cmd/owl/editor_test.go b/cmd/owl/editor_test.go index 462b8ca..cd9346c 100644 --- a/cmd/owl/editor_test.go +++ b/cmd/owl/editor_test.go @@ -4,46 +4,64 @@ import ( "bytes" "io" "io/ioutil" - "math/rand" "mime/multipart" + "net/http" "net/http/httptest" "os" + "owl-blogs/app" "owl-blogs/domain/model" "owl-blogs/infra" + "owl-blogs/test" "path" "path/filepath" "strings" "testing" - "time" "github.com/stretchr/testify/require" ) -func testDbName() string { - var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") - rand.Seed(time.Now().UnixNano()) - b := make([]rune, 6) - for i := range b { - b[i] = letterRunes[rand.Intn(len(letterRunes))] +func getUserToken(service *app.AuthorService) string { + _, err := service.Create("test", "test") + if err != nil { + panic(err) } - return "/tmp/" + string(b) + ".db" + token, err := service.CreateToken("test") + if err != nil { + panic(err) + } + return token } func TestEditorFormGet(t *testing.T) { - db := infra.NewSqliteDB(testDbName()) - app := App(db).FiberApp + db := test.NewMockDb() + owlApp := App(db) + app := owlApp.FiberApp + token := getUserToken(owlApp.AuthorService) req := httptest.NewRequest("GET", "/editor/ImageEntry", nil) + req.AddCookie(&http.Cookie{Name: "token", Value: token}) resp, err := app.Test(req) require.NoError(t, err) require.Equal(t, 200, resp.StatusCode) } -func TestEditorFormPost(t *testing.T) { - dbName := testDbName() - db := infra.NewSqliteDB(dbName) +func TestEditorFormGetNoAuth(t *testing.T) { + db := test.NewMockDb() owlApp := App(db) app := owlApp.FiberApp + + req := httptest.NewRequest("GET", "/editor/ImageEntry", nil) + req.AddCookie(&http.Cookie{Name: "token", Value: "invalid"}) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 302, resp.StatusCode) +} + +func TestEditorFormPost(t *testing.T) { + db := test.NewMockDb() + owlApp := App(db) + app := owlApp.FiberApp + token := getUserToken(owlApp.AuthorService) repo := infra.NewEntryRepository(db, owlApp.Registry) binRepo := infra.NewBinaryFileRepo(db) @@ -67,6 +85,7 @@ func TestEditorFormPost(t *testing.T) { req := httptest.NewRequest("POST", "/editor/ImageEntry", body) req.Header.Set("Content-Type", writer.FormDataContentType()) + req.AddCookie(&http.Cookie{Name: "token", Value: token}) resp, err := app.Test(req) require.NoError(t, err) require.Equal(t, 302, resp.StatusCode) @@ -84,3 +103,34 @@ func TestEditorFormPost(t *testing.T) { require.Equal(t, fileBytes, bin.Data) } + +func TestEditorFormPostNoAuth(t *testing.T) { + db := test.NewMockDb() + owlApp := App(db) + app := owlApp.FiberApp + + fileDir, _ := os.Getwd() + fileName := "../../test/fixtures/test.png" + filePath := path.Join(fileDir, fileName) + + file, err := os.Open(filePath) + require.NoError(t, err) + defer file.Close() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("ImageId", filepath.Base(file.Name())) + io.Copy(part, file) + part, _ = writer.CreateFormField("Content") + io.WriteString(part, "test content") + writer.Close() + + req := httptest.NewRequest("POST", "/editor/ImageEntry", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.AddCookie(&http.Cookie{Name: "token", Value: "invalid"}) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 302, resp.StatusCode) + require.Contains(t, resp.Header.Get("Location"), "/auth/login") + +} diff --git a/cmd/owl/main.go b/cmd/owl/main.go index e4c3aeb..6a557c1 100644 --- a/cmd/owl/main.go +++ b/cmd/owl/main.go @@ -2,6 +2,7 @@ package main import ( "owl-blogs/app" + "owl-blogs/config" "owl-blogs/domain/model" "owl-blogs/infra" "owl-blogs/web" @@ -10,16 +11,20 @@ import ( const DbPath = "owlblogs.db" func App(db infra.Database) *web.WebApp { - registry := app.NewEntryTypeRegistry() + config := config.NewConfig() + registry := app.NewEntryTypeRegistry() registry.Register(&model.ImageEntry{}) entryRepo := infra.NewEntryRepository(db, registry) binRepo := infra.NewBinaryFileRepo(db) + authorRepo := infra.NewDefaultAuthorRepo(db) entryService := app.NewEntryService(entryRepo) binaryService := app.NewBinaryFileService(binRepo) - return web.NewWebApp(entryService, registry, binaryService) + authorService := app.NewAuthorService(authorRepo, config) + + return web.NewWebApp(entryService, registry, binaryService, authorService) } diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..03ae7c8 --- /dev/null +++ b/config/config.go @@ -0,0 +1,29 @@ +package config + +import "os" + +type Config interface { + SECRET_KEY() string +} + +type EnvConfig struct { + secretKey string +} + +func getEnvOrPanic(key string) string { + value, set := os.LookupEnv(key) + if !set { + panic("Environment variable " + key + " is not set") + } + return value +} + +func NewConfig() Config { + return &EnvConfig{ + secretKey: getEnvOrPanic("OWL_SECRET_KEY"), + } +} + +func (c *EnvConfig) SECRET_KEY() string { + return c.secretKey +} diff --git a/domain/model/author.go b/domain/model/author.go new file mode 100644 index 0000000..41b8fbf --- /dev/null +++ b/domain/model/author.go @@ -0,0 +1,6 @@ +package model + +type Author struct { + Name string + PasswordHash string +} diff --git a/go.mod b/go.mod index b920a87..1ccf3ca 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.47.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect - golang.org/x/sys v0.9.0 // indirect + golang.org/x/crypto v0.11.0 // indirect + golang.org/x/sys v0.10.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6bb6d0d..6fc6625 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -83,6 +85,8 @@ golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= diff --git a/infra/author_repository.go b/infra/author_repository.go new file mode 100644 index 0000000..0c7bd98 --- /dev/null +++ b/infra/author_repository.go @@ -0,0 +1,61 @@ +package infra + +import ( + "owl-blogs/domain/model" + + "github.com/jmoiron/sqlx" +) + +type sqlAuthor struct { + Name string `db:"name"` + PasswordHash string `db:"password_hash"` +} + +type DefaultAuthorRepo struct { + db *sqlx.DB +} + +func NewDefaultAuthorRepo(db Database) *DefaultAuthorRepo { + sqlxdb := db.Get() + + // Create table if not exists + sqlxdb.MustExec(` + CREATE TABLE IF NOT EXISTS authors ( + name TEXT PRIMARY KEY, + password_hash TEXT NOT NULL + ); + `) + + return &DefaultAuthorRepo{ + db: sqlxdb, + } +} + +// FindByName implements repository.AuthorRepository. +func (r *DefaultAuthorRepo) FindByName(name string) (*model.Author, error) { + var author sqlAuthor + err := r.db.Get(&author, "SELECT * FROM authors WHERE name = ?", name) + if err != nil { + return nil, err + } + return &model.Author{ + Name: author.Name, + PasswordHash: author.PasswordHash, + }, nil +} + +// Create implements repository.AuthorRepository. +func (r *DefaultAuthorRepo) Create(name string, passwordHash string) (*model.Author, error) { + author := sqlAuthor{ + Name: name, + PasswordHash: passwordHash, + } + _, err := r.db.NamedExec("INSERT INTO authors (name, password_hash) VALUES (:name, :password_hash)", author) + if err != nil { + return nil, err + } + return &model.Author{ + Name: author.Name, + PasswordHash: author.PasswordHash, + }, nil +} diff --git a/infra/author_repository_test.go b/infra/author_repository_test.go new file mode 100644 index 0000000..7283ca8 --- /dev/null +++ b/infra/author_repository_test.go @@ -0,0 +1,47 @@ +package infra_test + +import ( + "owl-blogs/app/repository" + "owl-blogs/infra" + "owl-blogs/test" + "testing" + + "github.com/stretchr/testify/require" +) + +func setupAutherRepo() repository.AuthorRepository { + db := test.NewMockDb() + repo := infra.NewDefaultAuthorRepo(db) + return repo +} + +func TestAuthorRepoCreate(t *testing.T) { + repo := setupAutherRepo() + + author, err := repo.Create("name", "password") + require.NoError(t, err) + + author, err = repo.FindByName(author.Name) + require.NoError(t, err) + require.Equal(t, author.Name, "name") + require.Equal(t, author.PasswordHash, "password") +} + +func TestAuthorRepoNoSideEffect(t *testing.T) { + repo := setupAutherRepo() + + author, err := repo.Create("name1", "password1") + require.NoError(t, err) + + author2, err := repo.Create("name2", "password2") + require.NoError(t, err) + + author, err = repo.FindByName(author.Name) + require.NoError(t, err) + author2, err = repo.FindByName(author2.Name) + require.NoError(t, err) + require.Equal(t, author.Name, "name1") + require.Equal(t, author.PasswordHash, "password1") + require.Equal(t, author2.Name, "name2") + require.Equal(t, author2.PasswordHash, "password2") +} diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..cb823ef --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +set -e + +OWL_SECRET_KEY=test-secret-key \ +go test -v -coverprofile=coverage.out ./... diff --git a/web/app.go b/web/app.go index 3accf1e..c08f763 100644 --- a/web/app.go +++ b/web/app.go @@ -2,6 +2,7 @@ package web import ( "owl-blogs/app" + "owl-blogs/web/middleware" "github.com/gofiber/fiber/v2" ) @@ -11,9 +12,15 @@ type WebApp struct { EntryService *app.EntryService BinaryService *app.BinaryService Registry *app.EntryTypeRegistry + AuthorService *app.AuthorService } -func NewWebApp(entryService *app.EntryService, typeRegistry *app.EntryTypeRegistry, binService *app.BinaryService) *WebApp { +func NewWebApp( + entryService *app.EntryService, + typeRegistry *app.EntryTypeRegistry, + binService *app.BinaryService, + authorService *app.AuthorService, +) *WebApp { app := fiber.New() indexHandler := NewIndexHandler(entryService) @@ -25,15 +32,20 @@ func NewWebApp(entryService *app.EntryService, typeRegistry *app.EntryTypeRegist editorListHandler := NewEditorListHandler(typeRegistry) editorHandler := NewEditorHandler(entryService, typeRegistry, binService) + // Login + app.Get("/auth/login", loginHandler.HandleGet) + app.Post("/auth/login", loginHandler.HandlePost) + + // Editor + editor := app.Group("/editor") + editor.Use(middleware.NewAuthMiddleware(authorService).Handle) + editor.Get("/", editorListHandler.Handle) + editor.Get("/:editor/", editorHandler.HandleGet) + editor.Post("/:editor/", editorHandler.HandlePost) + // app.ServeFiles("/static/*filepath", http.Dir(repo.StaticDir())) app.Get("/", indexHandler.Handle) app.Get("/lists/:list/", listHandler.Handle) - // Editor - app.Get("/editor/auth/", loginHandler.HandleGet) - app.Post("/editor/auth/", loginHandler.HandlePost) - app.Get("/editor/", editorListHandler.Handle) - app.Get("/editor/:editor/", editorHandler.HandleGet) - app.Post("/editor/:editor/", editorHandler.HandlePost) // Media app.Get("/media/*filepath", mediaHandler.Handle) // RSS @@ -57,6 +69,7 @@ func NewWebApp(entryService *app.EntryService, typeRegistry *app.EntryTypeRegist EntryService: entryService, Registry: typeRegistry, BinaryService: binService, + AuthorService: authorService, } } diff --git a/web/editor_handler.go b/web/editor_handler.go index 826bd83..6d87f0e 100644 --- a/web/editor_handler.go +++ b/web/editor_handler.go @@ -15,8 +15,16 @@ type EditorHandler struct { registry *app.EntryTypeRegistry } -func NewEditorHandler(entryService *app.EntryService, registry *app.EntryTypeRegistry, binService *app.BinaryService) *EditorHandler { - return &EditorHandler{entrySvc: entryService, registry: registry, binSvc: binService} +func NewEditorHandler( + entryService *app.EntryService, + registry *app.EntryTypeRegistry, + binService *app.BinaryService, +) *EditorHandler { + return &EditorHandler{ + entrySvc: entryService, + registry: registry, + binSvc: binService, + } } func (h *EditorHandler) paramToEntry(c *fiber.Ctx) (model.Entry, error) { diff --git a/web/middleware/auth.go b/web/middleware/auth.go new file mode 100644 index 0000000..5b644e6 --- /dev/null +++ b/web/middleware/auth.go @@ -0,0 +1,31 @@ +package middleware + +import ( + "owl-blogs/app" + + "github.com/gofiber/fiber/v2" +) + +type AuthMiddleware struct { + authorService *app.AuthorService +} + +func NewAuthMiddleware(authorService *app.AuthorService) *AuthMiddleware { + return &AuthMiddleware{authorService: authorService} +} + +func (m *AuthMiddleware) Handle(c *fiber.Ctx) error { + // get token from cookie + token := c.Cookies("token") + if token == "" { + return c.Redirect("/auth/login") + } + + // check token + valid := m.authorService.ValidateToken(token) + if !valid { + return c.Redirect("/auth/login") + } + + return c.Next() +} From a90bcaaa2d11a04e064effa886fdfc0047d95733 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Sat, 8 Jul 2023 14:28:15 +0200 Subject: [PATCH 15/41] login --- cmd/owl/create_author.go | 29 ++++++++++++++++++++++++++++ cmd/owl/main.go | 19 ++++++++++++++++-- cmd/owl/web.go | 21 ++++++++++++++++++++ go.mod | 3 +++ go.sum | 8 ++++++++ run_dev.sh | 6 ++++++ web/app.go | 2 +- web/editor_list_handler.go | 19 +----------------- web/login_handler.go | 35 +++++++++++++++++++++++++++++----- web/templates.go | 34 +++++++++++++++++++++++++++++++++ web/templates/views/login.tmpl | 16 ++++++++++++++++ 11 files changed, 166 insertions(+), 26 deletions(-) create mode 100644 cmd/owl/create_author.go create mode 100644 cmd/owl/web.go create mode 100755 run_dev.sh create mode 100644 web/templates.go create mode 100644 web/templates/views/login.tmpl diff --git a/cmd/owl/create_author.go b/cmd/owl/create_author.go new file mode 100644 index 0000000..d5f2a88 --- /dev/null +++ b/cmd/owl/create_author.go @@ -0,0 +1,29 @@ +package main + +import ( + "owl-blogs/infra" + + "github.com/spf13/cobra" +) + +var user string +var password string + +func init() { + rootCmd.AddCommand(newAuthorCmd) + + newAuthorCmd.Flags().StringVarP(&user, "user", "u", "", "The user name") + newAuthorCmd.MarkFlagRequired("user") + newAuthorCmd.Flags().StringVarP(&password, "password", "p", "", "The password") + newAuthorCmd.MarkFlagRequired("password") +} + +var newAuthorCmd = &cobra.Command{ + Use: "new-author", + Short: "Creates a new author", + Long: `Creates a new author`, + Run: func(cmd *cobra.Command, args []string) { + db := infra.NewSqliteDB(DbPath) + App(db).AuthorService.Create(user, password) + }, +} diff --git a/cmd/owl/main.go b/cmd/owl/main.go index 6a557c1..a51435d 100644 --- a/cmd/owl/main.go +++ b/cmd/owl/main.go @@ -1,15 +1,31 @@ package main import ( + "fmt" + "os" "owl-blogs/app" "owl-blogs/config" "owl-blogs/domain/model" "owl-blogs/infra" "owl-blogs/web" + + "github.com/spf13/cobra" ) const DbPath = "owlblogs.db" +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 App(db infra.Database) *web.WebApp { config := config.NewConfig() @@ -29,6 +45,5 @@ func App(db infra.Database) *web.WebApp { } func main() { - db := infra.NewSqliteDB(DbPath) - App(db).Run() + Execute() } diff --git a/cmd/owl/web.go b/cmd/owl/web.go new file mode 100644 index 0000000..1eac414 --- /dev/null +++ b/cmd/owl/web.go @@ -0,0 +1,21 @@ +package main + +import ( + "owl-blogs/infra" + + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(webCmd) +} + +var webCmd = &cobra.Command{ + Use: "web", + Short: "Start the web server", + Long: `Start the web server`, + Run: func(cmd *cobra.Command, args []string) { + db := infra.NewSqliteDB(DbPath) + App(db).Run() + }, +} diff --git a/go.mod b/go.mod index 1ccf3ca..35d83e3 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/gofiber/fiber/v2 v2.47.0 // indirect github.com/google/uuid v1.3.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jmoiron/sqlx v1.3.5 // indirect github.com/klauspost/compress v1.16.3 // indirect github.com/mattn/go-colorable v0.1.13 // indirect @@ -18,6 +19,8 @@ require ( github.com/rivo/uniseg v0.2.0 // indirect github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 // indirect github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect + github.com/spf13/cobra v1.7.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/objx v0.5.0 // indirect github.com/stretchr/testify v1.8.4 // indirect github.com/tinylib/msgp v1.1.8 // indirect diff --git a/go.sum b/go.sum index 6fc6625..d8332d0 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -8,6 +9,8 @@ github.com/gofiber/fiber/v2 v2.47.0 h1:EN5lHVCc+Pyqh5OEsk8fzRiifgwpbrP0rulQ4iNf3 github.com/gofiber/fiber/v2 v2.47.0/go.mod h1:mbFMVN1lQuzziTkkakgtKKdjfsXSw9BKR5lmcNksUoU= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY= @@ -30,11 +33,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 h1:rmMl4fXJhKMNWl+K+r/fq4FbbKI+Ia2m9hYBLm2h4G4= github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94/go.mod h1:90zrgN3D/WJsDd1iXHT96alCoN2KJo6/4x1DZC3wZs8= github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4= github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk= github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= diff --git a/run_dev.sh b/run_dev.sh new file mode 100755 index 0000000..a5521cb --- /dev/null +++ b/run_dev.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +set -e + +OWL_SECRET_KEY=test-secret-key \ +go run owl-blogs/cmd/owl web \ No newline at end of file diff --git a/web/app.go b/web/app.go index c08f763..b6d2fa9 100644 --- a/web/app.go +++ b/web/app.go @@ -28,7 +28,7 @@ func NewWebApp( entryHandler := NewEntryHandler(entryService, typeRegistry) mediaHandler := NewMediaHandler(entryService) rssHandler := NewRSSHandler(entryService) - loginHandler := NewLoginHandler(entryService) + loginHandler := NewLoginHandler(authorService) editorListHandler := NewEditorListHandler(typeRegistry) editorHandler := NewEditorHandler(entryService, typeRegistry, binService) diff --git a/web/editor_list_handler.go b/web/editor_list_handler.go index feafe33..ef7c52f 100644 --- a/web/editor_list_handler.go +++ b/web/editor_list_handler.go @@ -1,19 +1,13 @@ package web import ( - "embed" "owl-blogs/app" - "text/template" "github.com/gofiber/fiber/v2" ) -//go:embed templates -var templates embed.FS - type EditorListHandler struct { registry *app.EntryTypeRegistry - ts *template.Template } type EditorListContext struct { @@ -21,19 +15,8 @@ type EditorListContext struct { } func NewEditorListHandler(registry *app.EntryTypeRegistry) *EditorListHandler { - ts, err := template.ParseFS( - templates, - "templates/base.tmpl", - "templates/views/editor_list.tmpl", - ) - - if err != nil { - panic(err) - } - return &EditorListHandler{ registry: registry, - ts: ts, } } @@ -49,5 +32,5 @@ func (h *EditorListHandler) Handle(c *fiber.Ctx) error { typeNames = append(typeNames, name) } - return h.ts.ExecuteTemplate(c, "base", &EditorListContext{Types: typeNames}) + return RenderTemplate(c, "views/editor_list", &EditorListContext{Types: typeNames}) } diff --git a/web/login_handler.go b/web/login_handler.go index 0d6ff8e..99af413 100644 --- a/web/login_handler.go +++ b/web/login_handler.go @@ -2,22 +2,47 @@ package web import ( "owl-blogs/app" + "time" "github.com/gofiber/fiber/v2" ) type LoginHandler struct { - entrySvc *app.EntryService + authorService *app.AuthorService } -func NewLoginHandler(entryService *app.EntryService) *LoginHandler { - return &LoginHandler{entrySvc: entryService} +func NewLoginHandler(authorService *app.AuthorService) *LoginHandler { + return &LoginHandler{authorService: authorService} } func (h *LoginHandler) HandleGet(c *fiber.Ctx) error { - return c.SendString("Hello, Login!") + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + return RenderTemplate(c, "views/login", nil) } func (h *LoginHandler) HandlePost(c *fiber.Ctx) error { - return c.SendString("Hello, Login!") + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + name := c.FormValue("name") + password := c.FormValue("password") + + valid := h.authorService.Authenticate(name, password) + if !valid { + return c.Redirect("/auth/login") + } + + token, err := h.authorService.CreateToken(name) + if err != nil { + return err + } + + cookie := fiber.Cookie{ + Name: "token", + Value: token, + Expires: time.Now().Add(30 * 24 * time.Hour), + HTTPOnly: true, + } + c.Cookie(&cookie) + + return c.Redirect("/editor/") + } diff --git a/web/templates.go b/web/templates.go new file mode 100644 index 0000000..7dee3a1 --- /dev/null +++ b/web/templates.go @@ -0,0 +1,34 @@ +package web + +import ( + "embed" + "io" + "text/template" +) + +//go:embed templates +var templates embed.FS + +func CreateTemplate(templateName string) (*template.Template, error) { + + return template.ParseFS( + templates, + "templates/base.tmpl", + "templates/"+templateName+".tmpl", + ) + +} + +func RenderTemplate(w io.Writer, templateName string, data interface{}) error { + + t, err := CreateTemplate(templateName) + + if err != nil { + return err + } + + err = t.ExecuteTemplate(w, "base", data) + + return err + +} diff --git a/web/templates/views/login.tmpl b/web/templates/views/login.tmpl new file mode 100644 index 0000000..b3bd630 --- /dev/null +++ b/web/templates/views/login.tmpl @@ -0,0 +1,16 @@ +{{define "title"}}Editor List{{end}} + +{{define "main"}} + +

      Login

      + + + + + + + + + + +{{end}} \ No newline at end of file From e9e17ed263699d9dfba7f803f8967395c7370ddd Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Sat, 8 Jul 2023 14:32:20 +0200 Subject: [PATCH 16/41] first image \o/ --- web/app.go | 4 ++-- web/media_handler.go | 13 +++++++++---- web/templates/views/entry/ImageEntry.tmpl | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/web/app.go b/web/app.go index b6d2fa9..583683e 100644 --- a/web/app.go +++ b/web/app.go @@ -26,7 +26,7 @@ func NewWebApp( indexHandler := NewIndexHandler(entryService) listHandler := NewListHandler(entryService) entryHandler := NewEntryHandler(entryService, typeRegistry) - mediaHandler := NewMediaHandler(entryService) + mediaHandler := NewMediaHandler(binService) rssHandler := NewRSSHandler(entryService) loginHandler := NewLoginHandler(authorService) editorListHandler := NewEditorListHandler(typeRegistry) @@ -47,7 +47,7 @@ func NewWebApp( app.Get("/", indexHandler.Handle) app.Get("/lists/:list/", listHandler.Handle) // Media - app.Get("/media/*filepath", mediaHandler.Handle) + app.Get("/media/:id", mediaHandler.Handle) // RSS app.Get("/index.xml", rssHandler.Handle) // Posts diff --git a/web/media_handler.go b/web/media_handler.go index f0a7743..7fb7e5f 100644 --- a/web/media_handler.go +++ b/web/media_handler.go @@ -7,13 +7,18 @@ import ( ) type MediaHandler struct { - entrySvc *app.EntryService + binaryService *app.BinaryService } -func NewMediaHandler(entryService *app.EntryService) *MediaHandler { - return &MediaHandler{entrySvc: entryService} +func NewMediaHandler(binaryService *app.BinaryService) *MediaHandler { + return &MediaHandler{binaryService: binaryService} } func (h *MediaHandler) Handle(c *fiber.Ctx) error { - return c.SendString("Hello, Media!") + id := c.Params("id") + binary, err := h.binaryService.FindById(id) + if err != nil { + return err + } + return c.Send(binary.Data) } diff --git a/web/templates/views/entry/ImageEntry.tmpl b/web/templates/views/entry/ImageEntry.tmpl index aee8544..71521c8 100644 --- a/web/templates/views/entry/ImageEntry.tmpl +++ b/web/templates/views/entry/ImageEntry.tmpl @@ -2,7 +2,7 @@ {{define "main"}} -{{.MetaData.ImageId}} +

      {{.Content}} From 1514e7533c8db926d1c68c27e21b0b63c1808e6c Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Sat, 8 Jul 2023 14:51:56 +0200 Subject: [PATCH 17/41] post entry --- cmd/owl/main.go | 1 + domain/model/post_entry.go | 37 ++++++++++++++++++++++++ web/templates/views/entry/PostEntry.tmpl | 17 +++++++++++ 3 files changed, 55 insertions(+) create mode 100644 domain/model/post_entry.go create mode 100644 web/templates/views/entry/PostEntry.tmpl diff --git a/cmd/owl/main.go b/cmd/owl/main.go index a51435d..00bce55 100644 --- a/cmd/owl/main.go +++ b/cmd/owl/main.go @@ -31,6 +31,7 @@ func App(db infra.Database) *web.WebApp { registry := app.NewEntryTypeRegistry() registry.Register(&model.ImageEntry{}) + registry.Register(&model.PostEntry{}) entryRepo := infra.NewEntryRepository(db, registry) binRepo := infra.NewBinaryFileRepo(db) diff --git a/domain/model/post_entry.go b/domain/model/post_entry.go new file mode 100644 index 0000000..ad4c336 --- /dev/null +++ b/domain/model/post_entry.go @@ -0,0 +1,37 @@ +package model + +import "time" + +type PostEntry struct { + id string + publishedAt *time.Time + meta PostEntryMetaData +} + +type PostEntryMetaData struct { + Title string `owl:"inputType=text"` + Content string `owl:"inputType=text widget=textarea"` +} + +func (e *PostEntry) ID() string { + return e.id +} + +func (e *PostEntry) Content() EntryContent { + return EntryContent(e.meta.Content) +} + +func (e *PostEntry) PublishedAt() *time.Time { + return e.publishedAt +} + +func (e *PostEntry) MetaData() interface{} { + return &e.meta +} + +func (e *PostEntry) Create(id string, publishedAt *time.Time, metaData EntryMetaData) error { + e.id = id + e.publishedAt = publishedAt + e.meta = *metaData.(*PostEntryMetaData) + return nil +} diff --git a/web/templates/views/entry/PostEntry.tmpl b/web/templates/views/entry/PostEntry.tmpl new file mode 100644 index 0000000..2ddd89e --- /dev/null +++ b/web/templates/views/entry/PostEntry.tmpl @@ -0,0 +1,17 @@ +{{define "title"}}{{.MetaData.Title}}{{end}} + +{{define "main"}} + +

      {{.MetaData.Title}}

      + +

      +{{.Content}} +

      + +

      + Published: {{.PublishedAt}} +

      + + +{{end}} + From eaacf70140f8e5d6945b529fcf2f250de672fa11 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Sat, 8 Jul 2023 15:56:42 +0200 Subject: [PATCH 18/41] entry base --- domain/model/entry.go | 27 ++++++++++++++++++++++++++- domain/model/image_entry.go | 20 +++----------------- domain/model/post_entry.go | 20 +++----------------- infra/entry_repository.go | 8 ++++++-- test/mock_entry.go | 18 +++--------------- web/editor/entry_form_test.go | 19 +++---------------- 6 files changed, 44 insertions(+), 68 deletions(-) diff --git a/domain/model/entry.go b/domain/model/entry.go index 95f8b1c..a23d66d 100644 --- a/domain/model/entry.go +++ b/domain/model/entry.go @@ -9,8 +9,33 @@ type Entry interface { Content() EntryContent PublishedAt() *time.Time MetaData() interface{} - Create(id string, publishedAt *time.Time, metaData EntryMetaData) error + // Create(id string, publishedAt *time.Time, metaData EntryMetaData) error + + SetID(id string) + SetPublishedAt(publishedAt *time.Time) + SetMetaData(metaData interface{}) } type EntryMetaData interface { } + +type EntryBase struct { + id string + publishedAt *time.Time +} + +func (e *EntryBase) ID() string { + return e.id +} + +func (e *EntryBase) PublishedAt() *time.Time { + return e.publishedAt +} + +func (e *EntryBase) SetID(id string) { + e.id = id +} + +func (e *EntryBase) SetPublishedAt(publishedAt *time.Time) { + e.publishedAt = publishedAt +} diff --git a/domain/model/image_entry.go b/domain/model/image_entry.go index 9b92545..de15ac4 100644 --- a/domain/model/image_entry.go +++ b/domain/model/image_entry.go @@ -1,11 +1,8 @@ package model -import "time" - type ImageEntry struct { - id string - publishedAt *time.Time - meta ImageEntryMetaData + EntryBase + meta ImageEntryMetaData } type ImageEntryMetaData struct { @@ -13,25 +10,14 @@ type ImageEntryMetaData struct { Content string `owl:"inputType=text widget=textarea"` } -func (e *ImageEntry) ID() string { - return e.id -} - func (e *ImageEntry) Content() EntryContent { return EntryContent(e.meta.Content) } -func (e *ImageEntry) PublishedAt() *time.Time { - return e.publishedAt -} - func (e *ImageEntry) MetaData() interface{} { return &e.meta } -func (e *ImageEntry) Create(id string, publishedAt *time.Time, metaData EntryMetaData) error { - e.id = id - e.publishedAt = publishedAt +func (e *ImageEntry) SetMetaData(metaData interface{}) { e.meta = *metaData.(*ImageEntryMetaData) - return nil } diff --git a/domain/model/post_entry.go b/domain/model/post_entry.go index ad4c336..dababd3 100644 --- a/domain/model/post_entry.go +++ b/domain/model/post_entry.go @@ -1,11 +1,8 @@ package model -import "time" - type PostEntry struct { - id string - publishedAt *time.Time - meta PostEntryMetaData + EntryBase + meta PostEntryMetaData } type PostEntryMetaData struct { @@ -13,25 +10,14 @@ type PostEntryMetaData struct { Content string `owl:"inputType=text widget=textarea"` } -func (e *PostEntry) ID() string { - return e.id -} - func (e *PostEntry) Content() EntryContent { return EntryContent(e.meta.Content) } -func (e *PostEntry) PublishedAt() *time.Time { - return e.publishedAt -} - func (e *PostEntry) MetaData() interface{} { return &e.meta } -func (e *PostEntry) Create(id string, publishedAt *time.Time, metaData EntryMetaData) error { - e.id = id - e.publishedAt = publishedAt +func (e *PostEntry) SetMetaData(metaData interface{}) { e.meta = *metaData.(*PostEntryMetaData) - return nil } diff --git a/infra/entry_repository.go b/infra/entry_repository.go index 1815079..611523f 100644 --- a/infra/entry_repository.go +++ b/infra/entry_repository.go @@ -41,7 +41,9 @@ func (r *DefaultEntryRepo) Create(entry model.Entry, publishedAt *time.Time, met id := uuid.New().String() _, err = r.db.Exec("INSERT INTO entries (id, type, published_at, meta_data) VALUES (?, ?, ?, ?)", id, t, publishedAt, metaDataJson) - entry.Create(id, publishedAt, metaData) + entry.SetID(id) + entry.SetPublishedAt(publishedAt) + entry.SetMetaData(metaData) return err } @@ -146,6 +148,8 @@ func (r *DefaultEntryRepo) sqlEntryToEntry(entry sqlEntry) (model.Entry, error) } metaData := reflect.New(reflect.TypeOf(e.MetaData()).Elem()).Interface() json.Unmarshal([]byte(*entry.MetaData), metaData) - e.Create(entry.Id, entry.PublishedAt, metaData) + e.SetID(entry.Id) + e.SetPublishedAt(entry.PublishedAt) + e.SetMetaData(metaData) return e, nil } diff --git a/test/mock_entry.go b/test/mock_entry.go index aafcc25..69b7acf 100644 --- a/test/mock_entry.go +++ b/test/mock_entry.go @@ -12,30 +12,18 @@ type MockEntryMetaData struct { } type MockEntry struct { - id string - publishedAt *time.Time - metaData *MockEntryMetaData -} - -func (e *MockEntry) ID() string { - return e.id + model.EntryBase + metaData *MockEntryMetaData } func (e *MockEntry) Content() model.EntryContent { return model.EntryContent(e.metaData.Str) } -func (e *MockEntry) PublishedAt() *time.Time { - return e.publishedAt -} - func (e *MockEntry) MetaData() interface{} { return e.metaData } -func (e *MockEntry) Create(id string, publishedAt *time.Time, metaData model.EntryMetaData) error { - e.id = id - e.publishedAt = publishedAt +func (e *MockEntry) SetMetaData(metaData interface{}) { e.metaData = metaData.(*MockEntryMetaData) - return nil } diff --git a/web/editor/entry_form_test.go b/web/editor/entry_form_test.go index 79396a1..4fc20c9 100644 --- a/web/editor/entry_form_test.go +++ b/web/editor/entry_form_test.go @@ -14,7 +14,6 @@ import ( "path/filepath" "reflect" "testing" - "time" "github.com/stretchr/testify/require" ) @@ -67,32 +66,20 @@ func (f *MockFormData) FormValue(key string, defaultValue ...string) string { } type MockEntry struct { - id string - publishedAt *time.Time - metaData MockEntryMetaData -} - -func (e *MockEntry) ID() string { - return e.id + model.EntryBase + metaData MockEntryMetaData } func (e *MockEntry) Content() model.EntryContent { return model.EntryContent(e.metaData.Content) } -func (e *MockEntry) PublishedAt() *time.Time { - return e.publishedAt -} - func (e *MockEntry) MetaData() interface{} { return &e.metaData } -func (e *MockEntry) Create(id string, publishedAt *time.Time, metaData model.EntryMetaData) error { - e.id = id - e.publishedAt = publishedAt +func (e *MockEntry) SetMetaData(metaData interface{}) { e.metaData = *metaData.(*MockEntryMetaData) - return nil } func TestFieldToFormField(t *testing.T) { From 723c94c576e5d9df292686eb1b2df98568995a33 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Sat, 8 Jul 2023 15:59:26 +0200 Subject: [PATCH 19/41] renaming and cleanup --- cmd/owl/editor_test.go | 12 +++++----- cmd/owl/main.go | 4 ++-- domain/model/article.go | 23 +++++++++++++++++++ domain/model/image.go | 23 +++++++++++++++++++ domain/model/image_entry.go | 23 ------------------- domain/model/post_entry.go | 23 ------------------- .../entry/{ImageEntry.tmpl => Image.tmpl} | 0 .../views/entry/{PostEntry.tmpl => Post.tmpl} | 0 8 files changed, 54 insertions(+), 54 deletions(-) create mode 100644 domain/model/article.go create mode 100644 domain/model/image.go delete mode 100644 domain/model/image_entry.go delete mode 100644 domain/model/post_entry.go rename web/templates/views/entry/{ImageEntry.tmpl => Image.tmpl} (100%) rename web/templates/views/entry/{PostEntry.tmpl => Post.tmpl} (100%) diff --git a/cmd/owl/editor_test.go b/cmd/owl/editor_test.go index cd9346c..2639cc2 100644 --- a/cmd/owl/editor_test.go +++ b/cmd/owl/editor_test.go @@ -38,7 +38,7 @@ func TestEditorFormGet(t *testing.T) { app := owlApp.FiberApp token := getUserToken(owlApp.AuthorService) - req := httptest.NewRequest("GET", "/editor/ImageEntry", nil) + req := httptest.NewRequest("GET", "/editor/Image", nil) req.AddCookie(&http.Cookie{Name: "token", Value: token}) resp, err := app.Test(req) require.NoError(t, err) @@ -50,7 +50,7 @@ func TestEditorFormGetNoAuth(t *testing.T) { owlApp := App(db) app := owlApp.FiberApp - req := httptest.NewRequest("GET", "/editor/ImageEntry", nil) + req := httptest.NewRequest("GET", "/editor/Image", nil) req.AddCookie(&http.Cookie{Name: "token", Value: "invalid"}) resp, err := app.Test(req) require.NoError(t, err) @@ -83,7 +83,7 @@ func TestEditorFormPost(t *testing.T) { io.WriteString(part, "test content") writer.Close() - req := httptest.NewRequest("POST", "/editor/ImageEntry", body) + req := httptest.NewRequest("POST", "/editor/Image", body) req.Header.Set("Content-Type", writer.FormDataContentType()) req.AddCookie(&http.Cookie{Name: "token", Value: token}) resp, err := app.Test(req) @@ -94,8 +94,8 @@ func TestEditorFormPost(t *testing.T) { id := strings.Split(resp.Header.Get("Location"), "/")[2] entry, err := repo.FindById(id) require.NoError(t, err) - require.Equal(t, "test content", entry.MetaData().(*model.ImageEntryMetaData).Content) - imageId := entry.MetaData().(*model.ImageEntryMetaData).ImageId + require.Equal(t, "test content", entry.MetaData().(*model.ImageMetaData).Content) + imageId := entry.MetaData().(*model.ImageMetaData).ImageId require.NotZero(t, imageId) bin, err := binRepo.FindById(imageId) require.NoError(t, err) @@ -125,7 +125,7 @@ func TestEditorFormPostNoAuth(t *testing.T) { io.WriteString(part, "test content") writer.Close() - req := httptest.NewRequest("POST", "/editor/ImageEntry", body) + req := httptest.NewRequest("POST", "/editor/Image", body) req.Header.Set("Content-Type", writer.FormDataContentType()) req.AddCookie(&http.Cookie{Name: "token", Value: "invalid"}) resp, err := app.Test(req) diff --git a/cmd/owl/main.go b/cmd/owl/main.go index 00bce55..28519d0 100644 --- a/cmd/owl/main.go +++ b/cmd/owl/main.go @@ -30,8 +30,8 @@ func App(db infra.Database) *web.WebApp { config := config.NewConfig() registry := app.NewEntryTypeRegistry() - registry.Register(&model.ImageEntry{}) - registry.Register(&model.PostEntry{}) + registry.Register(&model.Image{}) + registry.Register(&model.Article{}) entryRepo := infra.NewEntryRepository(db, registry) binRepo := infra.NewBinaryFileRepo(db) diff --git a/domain/model/article.go b/domain/model/article.go new file mode 100644 index 0000000..b783477 --- /dev/null +++ b/domain/model/article.go @@ -0,0 +1,23 @@ +package model + +type Article struct { + EntryBase + meta ArticleMetaData +} + +type ArticleMetaData struct { + Title string `owl:"inputType=text"` + Content string `owl:"inputType=text widget=textarea"` +} + +func (e *Article) Content() EntryContent { + return EntryContent(e.meta.Content) +} + +func (e *Article) MetaData() interface{} { + return &e.meta +} + +func (e *Article) SetMetaData(metaData interface{}) { + e.meta = *metaData.(*ArticleMetaData) +} diff --git a/domain/model/image.go b/domain/model/image.go new file mode 100644 index 0000000..1429941 --- /dev/null +++ b/domain/model/image.go @@ -0,0 +1,23 @@ +package model + +type Image struct { + EntryBase + meta ImageMetaData +} + +type ImageMetaData struct { + ImageId string `owl:"inputType=file"` + Content string `owl:"inputType=text widget=textarea"` +} + +func (e *Image) Content() EntryContent { + return EntryContent(e.meta.Content) +} + +func (e *Image) MetaData() interface{} { + return &e.meta +} + +func (e *Image) SetMetaData(metaData interface{}) { + e.meta = *metaData.(*ImageMetaData) +} diff --git a/domain/model/image_entry.go b/domain/model/image_entry.go deleted file mode 100644 index de15ac4..0000000 --- a/domain/model/image_entry.go +++ /dev/null @@ -1,23 +0,0 @@ -package model - -type ImageEntry struct { - EntryBase - meta ImageEntryMetaData -} - -type ImageEntryMetaData struct { - ImageId string `owl:"inputType=file"` - Content string `owl:"inputType=text widget=textarea"` -} - -func (e *ImageEntry) Content() EntryContent { - return EntryContent(e.meta.Content) -} - -func (e *ImageEntry) MetaData() interface{} { - return &e.meta -} - -func (e *ImageEntry) SetMetaData(metaData interface{}) { - e.meta = *metaData.(*ImageEntryMetaData) -} diff --git a/domain/model/post_entry.go b/domain/model/post_entry.go deleted file mode 100644 index dababd3..0000000 --- a/domain/model/post_entry.go +++ /dev/null @@ -1,23 +0,0 @@ -package model - -type PostEntry struct { - EntryBase - meta PostEntryMetaData -} - -type PostEntryMetaData struct { - Title string `owl:"inputType=text"` - Content string `owl:"inputType=text widget=textarea"` -} - -func (e *PostEntry) Content() EntryContent { - return EntryContent(e.meta.Content) -} - -func (e *PostEntry) MetaData() interface{} { - return &e.meta -} - -func (e *PostEntry) SetMetaData(metaData interface{}) { - e.meta = *metaData.(*PostEntryMetaData) -} diff --git a/web/templates/views/entry/ImageEntry.tmpl b/web/templates/views/entry/Image.tmpl similarity index 100% rename from web/templates/views/entry/ImageEntry.tmpl rename to web/templates/views/entry/Image.tmpl diff --git a/web/templates/views/entry/PostEntry.tmpl b/web/templates/views/entry/Post.tmpl similarity index 100% rename from web/templates/views/entry/PostEntry.tmpl rename to web/templates/views/entry/Post.tmpl From 408bb88cb89a1fe16ebeff98108a8c5d0f8c78c6 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Sun, 9 Jul 2023 19:09:54 +0200 Subject: [PATCH 20/41] renaming and cleanup --- app/repository/interfaces.go | 7 +++++++ domain/model/binary_file.go | 17 +++++++++++++++++ domain/model/binary_file_test.go | 13 +++++++++++++ infra/binary_file_repository.go | 11 +++++++++++ web/app.go | 3 +-- web/media_handler.go | 2 +- 6 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 domain/model/binary_file_test.go diff --git a/app/repository/interfaces.go b/app/repository/interfaces.go index 6c34079..ae51e39 100644 --- a/app/repository/interfaces.go +++ b/app/repository/interfaces.go @@ -14,11 +14,18 @@ type EntryRepository interface { } type BinaryRepository interface { + // Create creates a new binary file + // The name is the original file name, and is not unique + // BinaryFile.Id is a unique identifier Create(name string, data []byte) (*model.BinaryFile, error) FindById(id string) (*model.BinaryFile, error) } type AuthorRepository interface { + // Create creates a new author + // It returns an error if the name is already taken Create(name string, passwordHash string) (*model.Author, error) + // FindByName finds an author by name + // It returns an error if the author is not found FindByName(name string) (*model.Author, error) } diff --git a/domain/model/binary_file.go b/domain/model/binary_file.go index 0c9da91..50450e8 100644 --- a/domain/model/binary_file.go +++ b/domain/model/binary_file.go @@ -1,7 +1,24 @@ package model +import ( + "mime" + "strings" +) + type BinaryFile struct { Id string Name string Data []byte } + +func (b *BinaryFile) Mime() string { + parts := strings.Split(b.Name, ".") + if len(parts) < 2 { + return "application/octet-stream" + } + t := mime.TypeByExtension("." + parts[len(parts)-1]) + if t == "" { + return "application/octet-stream" + } + return t +} diff --git a/domain/model/binary_file_test.go b/domain/model/binary_file_test.go new file mode 100644 index 0000000..1d5afaf --- /dev/null +++ b/domain/model/binary_file_test.go @@ -0,0 +1,13 @@ +package model_test + +import ( + "owl-blogs/domain/model" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMimeType(t *testing.T) { + bin := model.BinaryFile{Name: "test.jpg"} + require.Equal(t, "image/jpeg", bin.Mime()) +} diff --git a/infra/binary_file_repository.go b/infra/binary_file_repository.go index 361539c..0adba28 100644 --- a/infra/binary_file_repository.go +++ b/infra/binary_file_repository.go @@ -3,6 +3,7 @@ package infra import ( "owl-blogs/app/repository" "owl-blogs/domain/model" + "strings" "github.com/google/uuid" "github.com/jmoiron/sqlx" @@ -18,6 +19,8 @@ type DefaultBinaryFileRepo struct { db *sqlx.DB } +// NewBinaryFileRepo creates a new binary file repository +// It creates the table if not exists func NewBinaryFileRepo(db Database) repository.BinaryRepository { sqlxdb := db.Get() @@ -33,8 +36,15 @@ func NewBinaryFileRepo(db Database) repository.BinaryRepository { return &DefaultBinaryFileRepo{db: sqlxdb} } +// Create implements repository.BinaryRepository func (repo *DefaultBinaryFileRepo) Create(name string, data []byte) (*model.BinaryFile, error) { id := uuid.New().String() + parts := strings.Split(name, ".") + if len(parts) > 1 { + ext := parts[len(parts)-1] + id = id + "." + ext + } + _, err := repo.db.Exec("INSERT INTO binary_files (id, name, data) VALUES (?, ?, ?)", id, name, data) if err != nil { return nil, err @@ -42,6 +52,7 @@ func (repo *DefaultBinaryFileRepo) Create(name string, data []byte) (*model.Bina return &model.BinaryFile{Id: id, Name: name, Data: data}, nil } +// FindById implements repository.BinaryRepository func (repo *DefaultBinaryFileRepo) FindById(id string) (*model.BinaryFile, error) { var sqlFile sqlBinaryFile err := repo.db.Get(&sqlFile, "SELECT * FROM binary_files WHERE id = ?", id) diff --git a/web/app.go b/web/app.go index 583683e..9ee3d12 100644 --- a/web/app.go +++ b/web/app.go @@ -47,12 +47,11 @@ func NewWebApp( app.Get("/", indexHandler.Handle) app.Get("/lists/:list/", listHandler.Handle) // Media - app.Get("/media/:id", mediaHandler.Handle) + app.Get("/media/+", mediaHandler.Handle) // RSS app.Get("/index.xml", rssHandler.Handle) // Posts app.Get("/posts/:post/", entryHandler.Handle) - app.Get("/posts/:post/media/*filepath", mediaHandler.Handle) // Webmention // app.Post("/webmention/", userWebmentionHandler(repo)) // Micropub diff --git a/web/media_handler.go b/web/media_handler.go index 7fb7e5f..3ee211e 100644 --- a/web/media_handler.go +++ b/web/media_handler.go @@ -15,7 +15,7 @@ func NewMediaHandler(binaryService *app.BinaryService) *MediaHandler { } func (h *MediaHandler) Handle(c *fiber.Ctx) error { - id := c.Params("id") + id := c.Params("+") binary, err := h.binaryService.FindById(id) if err != nil { return err From f513205cc30e13aa3873eddf42635e39cdb76b20 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Sun, 9 Jul 2023 19:51:49 +0200 Subject: [PATCH 21/41] listing entries --- domain/model/article.go | 15 +++++- domain/model/entry.go | 4 +- domain/model/image.go | 15 +++++- render/templates.go | 52 +++++++++++++++++++ {web => render}/templates/base.tmpl | 0 render/templates/entry/Article.tmpl | 1 + render/templates/entry/Image.tmpl | 8 +++ .../templates/views/editor_list.tmpl | 0 .../templates/views/entry.tmpl | 15 +++--- render/templates/views/index.tmpl | 22 ++++++++ {web => render}/templates/views/login.tmpl | 0 web/editor_list_handler.go | 3 +- web/entry_handler.go | 22 +------- web/index_handler.go | 11 +++- web/login_handler.go | 3 +- web/templates.go | 34 ------------ web/templates/views/entry/Image.tmpl | 17 ------ 17 files changed, 138 insertions(+), 84 deletions(-) create mode 100644 render/templates.go rename {web => render}/templates/base.tmpl (100%) create mode 100644 render/templates/entry/Article.tmpl create mode 100644 render/templates/entry/Image.tmpl rename {web => render}/templates/views/editor_list.tmpl (100%) rename web/templates/views/entry/Post.tmpl => render/templates/views/entry.tmpl (52%) create mode 100644 render/templates/views/index.tmpl rename {web => render}/templates/views/login.tmpl (100%) delete mode 100644 web/templates.go delete mode 100644 web/templates/views/entry/Image.tmpl diff --git a/domain/model/article.go b/domain/model/article.go index b783477..b07120f 100644 --- a/domain/model/article.go +++ b/domain/model/article.go @@ -1,5 +1,10 @@ package model +import ( + "fmt" + "owl-blogs/render" +) + type Article struct { EntryBase meta ArticleMetaData @@ -10,8 +15,16 @@ type ArticleMetaData struct { Content string `owl:"inputType=text widget=textarea"` } +func (e *Article) Title() string { + return e.meta.Title +} + func (e *Article) Content() EntryContent { - return EntryContent(e.meta.Content) + str, err := render.RenderTemplateToString("entry/Article", e) + if err != nil { + fmt.Println(err) + } + return EntryContent(str) } func (e *Article) MetaData() interface{} { diff --git a/domain/model/entry.go b/domain/model/entry.go index a23d66d..4f499f8 100644 --- a/domain/model/entry.go +++ b/domain/model/entry.go @@ -9,7 +9,9 @@ type Entry interface { Content() EntryContent PublishedAt() *time.Time MetaData() interface{} - // Create(id string, publishedAt *time.Time, metaData EntryMetaData) error + + // Optional: can return empty string + Title() string SetID(id string) SetPublishedAt(publishedAt *time.Time) diff --git a/domain/model/image.go b/domain/model/image.go index 1429941..176d8a5 100644 --- a/domain/model/image.go +++ b/domain/model/image.go @@ -1,5 +1,10 @@ package model +import ( + "fmt" + "owl-blogs/render" +) + type Image struct { EntryBase meta ImageMetaData @@ -10,8 +15,16 @@ type ImageMetaData struct { Content string `owl:"inputType=text widget=textarea"` } +func (e *Image) Title() string { + return "" +} + func (e *Image) Content() EntryContent { - return EntryContent(e.meta.Content) + str, err := render.RenderTemplateToString("entry/Image", e) + if err != nil { + fmt.Println(err) + } + return EntryContent(str) } func (e *Image) MetaData() interface{} { diff --git a/render/templates.go b/render/templates.go new file mode 100644 index 0000000..251168b --- /dev/null +++ b/render/templates.go @@ -0,0 +1,52 @@ +package render + +import ( + "bytes" + "embed" + "io" + "text/template" +) + +//go:embed templates +var templates embed.FS + +func CreateTemplateWithBase(templateName string) (*template.Template, error) { + + return template.ParseFS( + templates, + "templates/base.tmpl", + "templates/"+templateName+".tmpl", + ) + +} + +func RenderTemplateWithBase(w io.Writer, templateName string, data interface{}) error { + + t, err := CreateTemplateWithBase(templateName) + + if err != nil { + return err + } + + err = t.ExecuteTemplate(w, "base", data) + + return err + +} + +func RenderTemplateToString(templateName string, data interface{}) (string, error) { + + t, err := template.ParseFS( + templates, + "templates/"+templateName+".tmpl", + ) + + if err != nil { + return "", err + } + + var output bytes.Buffer + + err = t.Execute(&output, data) + return output.String(), err +} diff --git a/web/templates/base.tmpl b/render/templates/base.tmpl similarity index 100% rename from web/templates/base.tmpl rename to render/templates/base.tmpl diff --git a/render/templates/entry/Article.tmpl b/render/templates/entry/Article.tmpl new file mode 100644 index 0000000..bac8848 --- /dev/null +++ b/render/templates/entry/Article.tmpl @@ -0,0 +1 @@ +{{.MetaData.Content}} diff --git a/render/templates/entry/Image.tmpl b/render/templates/entry/Image.tmpl new file mode 100644 index 0000000..c616146 --- /dev/null +++ b/render/templates/entry/Image.tmpl @@ -0,0 +1,8 @@ +

      + {{.MetaData.Title}} +

      + + + +{{.MetaData.Content}} + diff --git a/web/templates/views/editor_list.tmpl b/render/templates/views/editor_list.tmpl similarity index 100% rename from web/templates/views/editor_list.tmpl rename to render/templates/views/editor_list.tmpl diff --git a/web/templates/views/entry/Post.tmpl b/render/templates/views/entry.tmpl similarity index 52% rename from web/templates/views/entry/Post.tmpl rename to render/templates/views/entry.tmpl index 2ddd89e..8af2b7d 100644 --- a/web/templates/views/entry/Post.tmpl +++ b/render/templates/views/entry.tmpl @@ -1,17 +1,18 @@ -{{define "title"}}{{.MetaData.Title}}{{end}} +{{define "title"}}{{.Title}}{{end}} {{define "main"}} -

      {{.MetaData.Title}}

      - -

      -{{.Content}} -

      - +{{if .Title}} +

      {{.Title}}

      +{{end}}

      Published: {{.PublishedAt}}

      + +{{.Content}} + + {{end}} diff --git a/render/templates/views/index.tmpl b/render/templates/views/index.tmpl new file mode 100644 index 0000000..8ec0e75 --- /dev/null +++ b/render/templates/views/index.tmpl @@ -0,0 +1,22 @@ +{{define "title"}}Index{{end}} + +{{define "main"}} + +{{ range . }} +
      +

      + + {{if .Title}} + {{ .Title }} + {{else}} + # + {{end}} + +

      +

      {{ .PublishedAt }}

      + {{ .Content }} +
      +
      +{{ end }} + +{{end}} \ No newline at end of file diff --git a/web/templates/views/login.tmpl b/render/templates/views/login.tmpl similarity index 100% rename from web/templates/views/login.tmpl rename to render/templates/views/login.tmpl diff --git a/web/editor_list_handler.go b/web/editor_list_handler.go index ef7c52f..771e6ad 100644 --- a/web/editor_list_handler.go +++ b/web/editor_list_handler.go @@ -2,6 +2,7 @@ package web import ( "owl-blogs/app" + "owl-blogs/render" "github.com/gofiber/fiber/v2" ) @@ -32,5 +33,5 @@ func (h *EditorListHandler) Handle(c *fiber.Ctx) error { typeNames = append(typeNames, name) } - return RenderTemplate(c, "views/editor_list", &EditorListContext{Types: typeNames}) + return render.RenderTemplateWithBase(c, "views/editor_list", &EditorListContext{Types: typeNames}) } diff --git a/web/entry_handler.go b/web/entry_handler.go index 3b81401..50e3640 100644 --- a/web/entry_handler.go +++ b/web/entry_handler.go @@ -2,8 +2,7 @@ package web import ( "owl-blogs/app" - "owl-blogs/domain/model" - "text/template" + "owl-blogs/render" "github.com/gofiber/fiber/v2" ) @@ -17,18 +16,6 @@ func NewEntryHandler(entryService *app.EntryService, registry *app.EntryTypeRegi return &EntryHandler{entrySvc: entryService, registry: registry} } -func (h *EntryHandler) getTemplate(entry model.Entry) (*template.Template, error) { - name, err := h.registry.TypeName(entry) - if err != nil { - return nil, err - } - return template.ParseFS( - templates, - "templates/base.tmpl", - "templates/views/entry/"+name+".tmpl", - ) -} - func (h *EntryHandler) Handle(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) @@ -38,10 +25,5 @@ func (h *EntryHandler) Handle(c *fiber.Ctx) error { return err } - template, err := h.getTemplate(entry) - if err != nil { - return err - } - - return template.ExecuteTemplate(c, "base", entry) + return render.RenderTemplateWithBase(c, "views/entry", entry) } diff --git a/web/index_handler.go b/web/index_handler.go index e043134..2a0575d 100644 --- a/web/index_handler.go +++ b/web/index_handler.go @@ -2,6 +2,7 @@ package web import ( "owl-blogs/app" + "owl-blogs/render" "github.com/gofiber/fiber/v2" ) @@ -15,5 +16,13 @@ func NewIndexHandler(entryService *app.EntryService) *IndexHandler { } func (h *IndexHandler) Handle(c *fiber.Ctx) error { - return c.SendString("Hello, World 👋!") + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + entries, err := h.entrySvc.FindAll() + + if err != nil { + return err + } + + return render.RenderTemplateWithBase(c, "views/index", entries) + } diff --git a/web/login_handler.go b/web/login_handler.go index 99af413..6ffe7a6 100644 --- a/web/login_handler.go +++ b/web/login_handler.go @@ -2,6 +2,7 @@ package web import ( "owl-blogs/app" + "owl-blogs/render" "time" "github.com/gofiber/fiber/v2" @@ -17,7 +18,7 @@ func NewLoginHandler(authorService *app.AuthorService) *LoginHandler { func (h *LoginHandler) HandleGet(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) - return RenderTemplate(c, "views/login", nil) + return render.RenderTemplateWithBase(c, "views/login", nil) } func (h *LoginHandler) HandlePost(c *fiber.Ctx) error { diff --git a/web/templates.go b/web/templates.go deleted file mode 100644 index 7dee3a1..0000000 --- a/web/templates.go +++ /dev/null @@ -1,34 +0,0 @@ -package web - -import ( - "embed" - "io" - "text/template" -) - -//go:embed templates -var templates embed.FS - -func CreateTemplate(templateName string) (*template.Template, error) { - - return template.ParseFS( - templates, - "templates/base.tmpl", - "templates/"+templateName+".tmpl", - ) - -} - -func RenderTemplate(w io.Writer, templateName string, data interface{}) error { - - t, err := CreateTemplate(templateName) - - if err != nil { - return err - } - - err = t.ExecuteTemplate(w, "base", data) - - return err - -} diff --git a/web/templates/views/entry/Image.tmpl b/web/templates/views/entry/Image.tmpl deleted file mode 100644 index 71521c8..0000000 --- a/web/templates/views/entry/Image.tmpl +++ /dev/null @@ -1,17 +0,0 @@ -{{define "title"}}Image Entry{{end}} - -{{define "main"}} - - - -

      -{{.Content}} -

      - -

      - Published: {{.PublishedAt}} -

      - - -{{end}} - From 34119d564241a72a75aaebe72470ef6c29570d19 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Sun, 9 Jul 2023 20:36:35 +0200 Subject: [PATCH 22/41] fix tests --- test/mock_entry.go | 4 ++++ web/editor/entry_form_test.go | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/test/mock_entry.go b/test/mock_entry.go index 69b7acf..6957205 100644 --- a/test/mock_entry.go +++ b/test/mock_entry.go @@ -27,3 +27,7 @@ func (e *MockEntry) MetaData() interface{} { func (e *MockEntry) SetMetaData(metaData interface{}) { e.metaData = metaData.(*MockEntryMetaData) } + +func (e *MockEntry) Title() string { + return "" +} diff --git a/web/editor/entry_form_test.go b/web/editor/entry_form_test.go index 4fc20c9..4068254 100644 --- a/web/editor/entry_form_test.go +++ b/web/editor/entry_form_test.go @@ -82,6 +82,10 @@ func (e *MockEntry) SetMetaData(metaData interface{}) { e.metaData = *metaData.(*MockEntryMetaData) } +func (e *MockEntry) Title() string { + return "" +} + func TestFieldToFormField(t *testing.T) { field := reflect.TypeOf(&MockEntryMetaData{}).Elem().Field(0) formField, err := editor.FieldToFormField(field) From 9921b1f91e71fd9b0a2122a49b032bb0402496d9 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Sun, 9 Jul 2023 21:04:59 +0200 Subject: [PATCH 23/41] WIP more types --- cmd/owl/main.go | 3 +++ domain/model/note.go | 26 +++++++++++++++++++++ domain/model/page.go | 36 +++++++++++++++++++++++++++++ domain/model/recipe.go | 39 ++++++++++++++++++++++++++++++++ render/templates/entry/Page.tmpl | 1 + 5 files changed, 105 insertions(+) create mode 100644 domain/model/note.go create mode 100644 domain/model/page.go create mode 100644 domain/model/recipe.go create mode 100644 render/templates/entry/Page.tmpl diff --git a/cmd/owl/main.go b/cmd/owl/main.go index 28519d0..4047ea0 100644 --- a/cmd/owl/main.go +++ b/cmd/owl/main.go @@ -32,6 +32,9 @@ func App(db infra.Database) *web.WebApp { registry := app.NewEntryTypeRegistry() registry.Register(&model.Image{}) registry.Register(&model.Article{}) + registry.Register(&model.Page{}) + registry.Register(&model.Recipe{}) + registry.Register(&model.Note{}) entryRepo := infra.NewEntryRepository(db, registry) binRepo := infra.NewBinaryFileRepo(db) diff --git a/domain/model/note.go b/domain/model/note.go new file mode 100644 index 0000000..f2ed50b --- /dev/null +++ b/domain/model/note.go @@ -0,0 +1,26 @@ +package model + +type Note struct { + EntryBase + meta NoteMetaData +} + +type NoteMetaData struct { + Content string `owl:"inputType=text widget=textarea"` +} + +func (e *Note) Title() string { + return "" +} + +func (e *Note) Content() EntryContent { + return EntryContent(e.meta.Content) +} + +func (e *Note) MetaData() interface{} { + return &e.meta +} + +func (e *Note) SetMetaData(metaData interface{}) { + e.meta = *metaData.(*NoteMetaData) +} diff --git a/domain/model/page.go b/domain/model/page.go new file mode 100644 index 0000000..385d864 --- /dev/null +++ b/domain/model/page.go @@ -0,0 +1,36 @@ +package model + +import ( + "fmt" + "owl-blogs/render" +) + +type Page struct { + EntryBase + meta PageMetaData +} + +type PageMetaData struct { + Title string `owl:"inputType=text"` + Content string `owl:"inputType=text widget=textarea"` +} + +func (e *Page) Title() string { + return e.meta.Title +} + +func (e *Page) Content() EntryContent { + str, err := render.RenderTemplateToString("entry/Page", e) + if err != nil { + fmt.Println(err) + } + return EntryContent(str) +} + +func (e *Page) MetaData() interface{} { + return &e.meta +} + +func (e *Page) SetMetaData(metaData interface{}) { + e.meta = *metaData.(*PageMetaData) +} diff --git a/domain/model/recipe.go b/domain/model/recipe.go new file mode 100644 index 0000000..ff83edb --- /dev/null +++ b/domain/model/recipe.go @@ -0,0 +1,39 @@ +package model + +import ( + "fmt" + "owl-blogs/render" +) + +type Recipe struct { + EntryBase + meta RecipeMetaData +} + +type RecipeMetaData struct { + Title string `owl:"inputType=text"` + Yield string `owl:"inputType=text"` + Duration string `owl:"inputType=text"` + Ingredients []string `owl:"inputType=text widget=textarea"` + Content string `owl:"inputType=text widget=textarea"` +} + +func (e *Recipe) Title() string { + return e.meta.Title +} + +func (e *Recipe) Content() EntryContent { + str, err := render.RenderTemplateToString("entry/Recipe", e) + if err != nil { + fmt.Println(err) + } + return EntryContent(str) +} + +func (e *Recipe) MetaData() interface{} { + return &e.meta +} + +func (e *Recipe) SetMetaData(metaData interface{}) { + e.meta = *metaData.(*RecipeMetaData) +} diff --git a/render/templates/entry/Page.tmpl b/render/templates/entry/Page.tmpl new file mode 100644 index 0000000..bac8848 --- /dev/null +++ b/render/templates/entry/Page.tmpl @@ -0,0 +1 @@ +{{.MetaData.Content}} From 088d41e8a20f2f50fa45d5d5ecf15e0d6e46fdfc Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Sun, 9 Jul 2023 21:27:59 +0200 Subject: [PATCH 24/41] option to set entry for binary file --- app/binary_service.go | 2 +- app/repository/interfaces.go | 3 +- cmd/owl/import_v1.go | 28 ++++++++++++++ infra/binary_file_repository.go | 58 +++++++++++++++++++++++----- infra/binary_file_repository_test.go | 6 +-- 5 files changed, 82 insertions(+), 15 deletions(-) create mode 100644 cmd/owl/import_v1.go diff --git a/app/binary_service.go b/app/binary_service.go index 4ef64b7..e2b8949 100644 --- a/app/binary_service.go +++ b/app/binary_service.go @@ -14,7 +14,7 @@ func NewBinaryFileService(repo repository.BinaryRepository) *BinaryService { } func (s *BinaryService) Create(name string, file []byte) (*model.BinaryFile, error) { - return s.repo.Create(name, file) + return s.repo.Create(name, file, nil) } func (s *BinaryService) FindById(id string) (*model.BinaryFile, error) { diff --git a/app/repository/interfaces.go b/app/repository/interfaces.go index ae51e39..15341ae 100644 --- a/app/repository/interfaces.go +++ b/app/repository/interfaces.go @@ -17,8 +17,9 @@ type BinaryRepository interface { // Create creates a new binary file // The name is the original file name, and is not unique // BinaryFile.Id is a unique identifier - Create(name string, data []byte) (*model.BinaryFile, error) + Create(name string, data []byte, entry model.Entry) (*model.BinaryFile, error) FindById(id string) (*model.BinaryFile, error) + FindByNameForEntry(name string, entry model.Entry) (*model.BinaryFile, error) } type AuthorRepository interface { diff --git a/cmd/owl/import_v1.go b/cmd/owl/import_v1.go new file mode 100644 index 0000000..143fd04 --- /dev/null +++ b/cmd/owl/import_v1.go @@ -0,0 +1,28 @@ +package main + +import ( + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(importCmd) + + importCmd.Flags().StringVarP(&user, "path", "p", "", "Path to the user folder") + importCmd.MarkFlagRequired("path") +} + +var importCmd = &cobra.Command{ + Use: "import", + Short: "Import data from v1", + Long: `Import data from v1`, + Run: func(cmd *cobra.Command, args []string) { + // db := infra.NewSqliteDB(DbPath) + // App(db).ImportV1() + + // TODO: Implement this + // For each folder in the user folder + // Map to entry types + // Convert and save + // Import Binary files + }, +} diff --git a/infra/binary_file_repository.go b/infra/binary_file_repository.go index 0adba28..47d29a4 100644 --- a/infra/binary_file_repository.go +++ b/infra/binary_file_repository.go @@ -1,18 +1,19 @@ package infra import ( + "fmt" "owl-blogs/app/repository" "owl-blogs/domain/model" "strings" - "github.com/google/uuid" "github.com/jmoiron/sqlx" ) type sqlBinaryFile struct { - Id string `db:"id"` - Name string `db:"name"` - Data []byte `db:"data"` + Id string `db:"id"` + Name string `db:"name"` + EntryId *string `db:"entry_id"` + Data []byte `db:"data"` } type DefaultBinaryFileRepo struct { @@ -29,6 +30,7 @@ func NewBinaryFileRepo(db Database) repository.BinaryRepository { CREATE TABLE IF NOT EXISTS binary_files ( id VARCHAR(255) PRIMARY KEY, name VARCHAR(255) NOT NULL, + entry_id VARCHAR(255), data BLOB NOT NULL ); `) @@ -37,15 +39,41 @@ func NewBinaryFileRepo(db Database) repository.BinaryRepository { } // Create implements repository.BinaryRepository -func (repo *DefaultBinaryFileRepo) Create(name string, data []byte) (*model.BinaryFile, error) { - id := uuid.New().String() +func (repo *DefaultBinaryFileRepo) Create(name string, data []byte, entry model.Entry) (*model.BinaryFile, error) { parts := strings.Split(name, ".") - if len(parts) > 1 { - ext := parts[len(parts)-1] - id = id + "." + ext + fileName := strings.Join(parts[:len(parts)-1], ".") + fileExt := parts[len(parts)-1] + id := fileName + "." + fileExt + + // check if id exists + var count int + err := repo.db.Get(&count, "SELECT COUNT(*) FROM binary_files WHERE id = ?", id) + if err != nil { + return nil, err } - _, err := repo.db.Exec("INSERT INTO binary_files (id, name, data) VALUES (?, ?, ?)", id, name, data) + if count > 0 { + counter := 1 + for { + id = fmt.Sprintf("%s-%d.%s", fileName, counter, fileExt) + err := repo.db.Get(&count, "SELECT COUNT(*) FROM binary_files WHERE id = ?", id) + if err != nil { + return nil, err + } + if count == 0 { + break + } + counter++ + } + } + + var entryId *string + if entry != nil { + eId := entry.ID() + entryId = &eId + } + + _, err = repo.db.Exec("INSERT INTO binary_files (id, name, entry_id, data) VALUES (?, ?, ?, ?)", id, name, entryId, data) if err != nil { return nil, err } @@ -61,3 +89,13 @@ func (repo *DefaultBinaryFileRepo) FindById(id string) (*model.BinaryFile, error } return &model.BinaryFile{Id: sqlFile.Id, Name: sqlFile.Name, Data: sqlFile.Data}, nil } + +// FindByNameForEntry implements repository.BinaryRepository +func (repo *DefaultBinaryFileRepo) FindByNameForEntry(name string, entry model.Entry) (*model.BinaryFile, error) { + var sqlFile sqlBinaryFile + err := repo.db.Get(&sqlFile, "SELECT * FROM binary_files WHERE name = ? AND entry_id = ?", name, entry.ID()) + if err != nil { + return nil, err + } + return &model.BinaryFile{Id: sqlFile.Id, Name: sqlFile.Name, Data: sqlFile.Data}, nil +} diff --git a/infra/binary_file_repository_test.go b/infra/binary_file_repository_test.go index d4e7853..dcb6a5e 100644 --- a/infra/binary_file_repository_test.go +++ b/infra/binary_file_repository_test.go @@ -18,7 +18,7 @@ func setupBinaryRepo() repository.BinaryRepository { func TestBinaryRepoCreate(t *testing.T) { repo := setupBinaryRepo() - file, err := repo.Create("name", []byte("😀 😃 😄 😁")) + file, err := repo.Create("name", []byte("😀 😃 😄 😁"), nil) require.NoError(t, err) file, err = repo.FindById(file.Id) @@ -30,10 +30,10 @@ func TestBinaryRepoCreate(t *testing.T) { func TestBinaryRepoNoSideEffect(t *testing.T) { repo := setupBinaryRepo() - file, err := repo.Create("name1", []byte("111")) + file, err := repo.Create("name1", []byte("111"), nil) require.NoError(t, err) - file2, err := repo.Create("name2", []byte("222")) + file2, err := repo.Create("name2", []byte("222"), nil) require.NoError(t, err) file, err = repo.FindById(file.Id) From 9c30ff7877ef2ff73366b051d099d8eede74b570 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Sun, 9 Jul 2023 22:12:06 +0200 Subject: [PATCH 25/41] WIP Import + refactor entry creation --- app/entry_service.go | 5 +- app/repository/interfaces.go | 3 +- cmd/owl/import_v1.go | 53 +++++++-- go.mod | 1 + go.sum | 2 + importer/utils.go | 200 +++++++++++++++++++++++++++++++++ infra/entry_repository.go | 14 +-- infra/entry_repository_test.go | 34 ++++-- web/editor_handler.go | 4 +- 9 files changed, 287 insertions(+), 29 deletions(-) create mode 100644 importer/utils.go diff --git a/app/entry_service.go b/app/entry_service.go index 103acab..ba64baf 100644 --- a/app/entry_service.go +++ b/app/entry_service.go @@ -3,7 +3,6 @@ package app import ( "owl-blogs/app/repository" "owl-blogs/domain/model" - "time" ) type EntryService struct { @@ -14,8 +13,8 @@ func NewEntryService(entryRepository repository.EntryRepository) *EntryService { return &EntryService{EntryRepository: entryRepository} } -func (s *EntryService) Create(entry model.Entry, publishedAt *time.Time, metaData model.EntryMetaData) error { - return s.EntryRepository.Create(entry, publishedAt, metaData) +func (s *EntryService) Create(entry model.Entry) error { + return s.EntryRepository.Create(entry) } func (s *EntryService) Update(entry model.Entry) error { diff --git a/app/repository/interfaces.go b/app/repository/interfaces.go index 15341ae..c5c3013 100644 --- a/app/repository/interfaces.go +++ b/app/repository/interfaces.go @@ -2,11 +2,10 @@ package repository import ( "owl-blogs/domain/model" - "time" ) type EntryRepository interface { - Create(entry model.Entry, publishedAt *time.Time, metaData model.EntryMetaData) error + Create(entry model.Entry) error Update(entry model.Entry) error Delete(entry model.Entry) error FindById(id string) (model.Entry, error) diff --git a/cmd/owl/import_v1.go b/cmd/owl/import_v1.go index 143fd04..5a02868 100644 --- a/cmd/owl/import_v1.go +++ b/cmd/owl/import_v1.go @@ -1,13 +1,20 @@ package main import ( + "fmt" + "owl-blogs/domain/model" + "owl-blogs/importer" + "owl-blogs/infra" + "github.com/spf13/cobra" ) +var userPath string + func init() { rootCmd.AddCommand(importCmd) - importCmd.Flags().StringVarP(&user, "path", "p", "", "Path to the user folder") + importCmd.Flags().StringVarP(&userPath, "path", "p", "", "Path to the user folder") importCmd.MarkFlagRequired("path") } @@ -16,13 +23,43 @@ var importCmd = &cobra.Command{ Short: "Import data from v1", Long: `Import data from v1`, Run: func(cmd *cobra.Command, args []string) { - // db := infra.NewSqliteDB(DbPath) - // App(db).ImportV1() + db := infra.NewSqliteDB(DbPath) + app := App(db) - // TODO: Implement this - // For each folder in the user folder - // Map to entry types - // Convert and save - // Import Binary files + posts, err := importer.AllUserPosts(userPath) + if err != nil { + panic(err) + } + + for _, post := range posts { + fmt.Println(post.Meta.Type) + switch post.Meta.Type { + case "article": + article := model.Article{} + article.SetID(post.Id) + article.SetMetaData(model.ArticleMetaData{ + Title: post.Meta.Title, + Content: post.Content, + }) + article.SetPublishedAt(&post.Meta.Date) + app.EntryService.Create(&article) + + case "bookmark": + + case "reply": + + case "photo": + + case "note": + + case "recipe": + + case "page": + + default: + panic("Unknown type") + } + + } }, } diff --git a/go.mod b/go.mod index 35d83e3..54cae65 100644 --- a/go.mod +++ b/go.mod @@ -29,5 +29,6 @@ require ( github.com/valyala/tcplisten v1.0.0 // indirect golang.org/x/crypto v0.11.0 // indirect golang.org/x/sys v0.10.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index d8332d0..a5aaeef 100644 --- a/go.sum +++ b/go.sum @@ -111,6 +111,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/importer/utils.go b/importer/utils.go new file mode 100644 index 0000000..15bf9e2 --- /dev/null +++ b/importer/utils.go @@ -0,0 +1,200 @@ +package importer + +import ( + "bytes" + "os" + "path" + "time" + + "gopkg.in/yaml.v2" +) + +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"` +} + +type Post struct { + Id string + Meta PostMeta + Content string +} + +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 +} + +func LoadContent(data []byte) string { + + // 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:] + } + } + + return string(data) +} + +func LoadMeta(data []byte) (PostMeta, error) { + + // 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 PostMeta{}, err + } + } + } + + return meta, nil +} + +func AllUserPosts(userPath string) ([]Post, error) { + postFiles := listDir(path.Join(userPath, "public")) + posts := make([]Post, 0) + for _, id := range postFiles { + // if is a directory and has index.md, add to posts + if dirExists(path.Join(userPath, "public", id)) { + if fileExists(path.Join(userPath, "public", id, "index.md")) { + postData, err := os.ReadFile(path.Join(userPath, "public", id, "index.md")) + if err != nil { + return nil, err + } + meta, err := LoadMeta(postData) + if err != nil { + return nil, err + } + post := Post{ + Id: id, + Content: LoadContent(postData), + Meta: meta, + } + posts = append(posts, post) + } + } + } + + return posts, nil +} + +func listDir(path string) []string { + dir, _ := os.Open(path) + defer dir.Close() + files, _ := dir.Readdirnames(-1) + return files +} + +func dirExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/infra/entry_repository.go b/infra/entry_repository.go index 611523f..6f9a7fc 100644 --- a/infra/entry_repository.go +++ b/infra/entry_repository.go @@ -28,7 +28,7 @@ type DefaultEntryRepo struct { } // Create implements repository.EntryRepository. -func (r *DefaultEntryRepo) Create(entry model.Entry, publishedAt *time.Time, metaData model.EntryMetaData) error { +func (r *DefaultEntryRepo) Create(entry model.Entry) error { t, err := r.typeRegistry.TypeName(entry) if err != nil { return errors.New("entry type not registered") @@ -36,14 +36,14 @@ func (r *DefaultEntryRepo) Create(entry model.Entry, publishedAt *time.Time, met var metaDataJson []byte if entry.MetaData() != nil { - metaDataJson, _ = json.Marshal(metaData) + metaDataJson, _ = json.Marshal(entry.MetaData()) } - id := uuid.New().String() - _, err = r.db.Exec("INSERT INTO entries (id, type, published_at, meta_data) VALUES (?, ?, ?, ?)", id, t, publishedAt, metaDataJson) - entry.SetID(id) - entry.SetPublishedAt(publishedAt) - entry.SetMetaData(metaData) + if entry.ID() == "" { + entry.SetID(uuid.New().String()) + } + + _, err = r.db.Exec("INSERT INTO entries (id, type, published_at, meta_data) VALUES (?, ?, ?, ?)", entry.ID(), t, entry.PublishedAt(), metaDataJson) return err } diff --git a/infra/entry_repository_test.go b/infra/entry_repository_test.go index 949c9b2..a6178c1 100644 --- a/infra/entry_repository_test.go +++ b/infra/entry_repository_test.go @@ -24,11 +24,13 @@ func TestRepoCreate(t *testing.T) { entry := &test.MockEntry{} now := time.Now() - err := repo.Create(entry, &now, &test.MockEntryMetaData{ + entry.SetPublishedAt(&now) + entry.SetMetaData(&test.MockEntryMetaData{ Str: "str", Number: 1, Date: now, }) + err := repo.Create(entry) require.NoError(t, err) entry2, err := repo.FindById(entry.ID()) @@ -48,11 +50,13 @@ func TestRepoDelete(t *testing.T) { entry := &test.MockEntry{} now := time.Now() - err := repo.Create(entry, &now, &test.MockEntryMetaData{ + entry.SetPublishedAt(&now) + entry.SetMetaData(&test.MockEntryMetaData{ Str: "str", Number: 1, Date: now, }) + err := repo.Create(entry) require.NoError(t, err) err = repo.Delete(entry) @@ -67,21 +71,26 @@ func TestRepoFindAll(t *testing.T) { entry := &test.MockEntry{} now := time.Now() - err := repo.Create(entry, &now, &test.MockEntryMetaData{ + entry.SetPublishedAt(&now) + entry.SetMetaData(&test.MockEntryMetaData{ Str: "str", Number: 1, Date: now, }) + err := repo.Create(entry) require.NoError(t, err) entry2 := &test.MockEntry{} now2 := time.Now() - err = repo.Create(entry2, &now2, &test.MockEntryMetaData{ + entry2.SetPublishedAt(&now2) + entry2.SetMetaData(&test.MockEntryMetaData{ Str: "str", Number: 1, Date: now, }) + + err = repo.Create(entry2) require.NoError(t, err) entries, err := repo.FindAll(nil) @@ -103,20 +112,24 @@ func TestRepoUpdate(t *testing.T) { entry := &test.MockEntry{} now := time.Now() - err := repo.Create(entry, &now, &test.MockEntryMetaData{ + entry.SetPublishedAt(&now) + entry.SetMetaData(&test.MockEntryMetaData{ Str: "str", Number: 1, Date: now, }) + err := repo.Create(entry) require.NoError(t, err) entry2 := &test.MockEntry{} now2 := time.Now() - err = repo.Create(entry2, &now2, &test.MockEntryMetaData{ + entry2.SetPublishedAt(&now2) + entry2.SetMetaData(&test.MockEntryMetaData{ Str: "str2", Number: 2, Date: now2, }) + err = repo.Create(entry2) require.NoError(t, err) err = repo.Update(entry2) require.NoError(t, err) @@ -137,20 +150,25 @@ func TestRepoNoSideEffect(t *testing.T) { entry1 := &test.MockEntry{} now1 := time.Now() - err := repo.Create(entry1, &now1, &test.MockEntryMetaData{ + entry1.SetPublishedAt(&now1) + entry1.SetMetaData(&test.MockEntryMetaData{ Str: "1", Number: 1, Date: now1, }) + + err := repo.Create(entry1) require.NoError(t, err) entry2 := &test.MockEntry{} now2 := time.Now() - err = repo.Create(entry2, &now2, &test.MockEntryMetaData{ + entry2.SetPublishedAt(&now2) + entry2.SetMetaData(&test.MockEntryMetaData{ Str: "2", Number: 2, Date: now2, }) + err = repo.Create(entry2) require.NoError(t, err) r1, err := repo.FindById(entry1.ID()) diff --git a/web/editor_handler.go b/web/editor_handler.go index 6d87f0e..bb75e94 100644 --- a/web/editor_handler.go +++ b/web/editor_handler.go @@ -69,7 +69,9 @@ func (h *EditorHandler) HandlePost(c *fiber.Ctx) error { // create entry now := time.Now() - err = h.entrySvc.Create(entry, &now, entry.MetaData()) + entry.SetPublishedAt(&now) + + err = h.entrySvc.Create(entry) if err != nil { return err } From 7c20b472f65656f7fb2d7ede17c822ca4272ce06 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Thu, 13 Jul 2023 20:39:24 +0200 Subject: [PATCH 26/41] importer + markdown --- app/binary_service.go | 4 ++ cmd/owl/import_v1.go | 67 ++++++++++++++++++++++++++--- domain/model/image.go | 3 +- go.mod | 1 + go.sum | 2 + importer/utils.go | 8 +++- render/templates.go | 42 +++++++++++++++--- render/templates/entry/Article.tmpl | 2 +- web/index_handler.go | 6 +++ 9 files changed, 118 insertions(+), 17 deletions(-) diff --git a/app/binary_service.go b/app/binary_service.go index e2b8949..d459cd8 100644 --- a/app/binary_service.go +++ b/app/binary_service.go @@ -17,6 +17,10 @@ func (s *BinaryService) Create(name string, file []byte) (*model.BinaryFile, err return s.repo.Create(name, file, nil) } +func (s *BinaryService) CreateEntryFile(name string, file []byte, entry model.Entry) (*model.BinaryFile, error) { + return s.repo.Create(name, file, entry) +} + func (s *BinaryService) FindById(id string) (*model.BinaryFile, error) { return s.repo.FindById(id) } diff --git a/cmd/owl/import_v1.go b/cmd/owl/import_v1.go index 5a02868..69f9b42 100644 --- a/cmd/owl/import_v1.go +++ b/cmd/owl/import_v1.go @@ -2,9 +2,11 @@ package main import ( "fmt" + "os" "owl-blogs/domain/model" "owl-blogs/importer" "owl-blogs/infra" + "path" "github.com/spf13/cobra" ) @@ -32,30 +34,81 @@ var importCmd = &cobra.Command{ } for _, post := range posts { + existing, _ := app.EntryService.FindById(post.Id) + if existing != nil { + continue + } fmt.Println(post.Meta.Type) + + // import assets + mediaDir := path.Join(userPath, post.MediaDir()) + println(mediaDir) + files := importer.ListDir(mediaDir) + for _, file := range files { + // mock entry to pass to binary service + entry := &model.Article{} + entry.SetID(post.Id) + + fileData, err := os.ReadFile(path.Join(mediaDir, file)) + if err != nil { + panic(err) + } + app.BinaryService.CreateEntryFile(file, fileData, entry) + } + switch post.Meta.Type { case "article": article := model.Article{} article.SetID(post.Id) - article.SetMetaData(model.ArticleMetaData{ + article.SetPublishedAt(&post.Meta.Date) + article.SetMetaData(&model.ArticleMetaData{ Title: post.Meta.Title, Content: post.Content, }) - article.SetPublishedAt(&post.Meta.Date) app.EntryService.Create(&article) - case "bookmark": case "reply": case "photo": - + photo := model.Image{} + photo.SetID(post.Id) + photo.SetPublishedAt(&post.Meta.Date) + photo.SetMetaData(&model.ImageMetaData{ + Title: post.Meta.Title, + Content: post.Content, + ImageId: post.Meta.PhotoPath, + }) + app.EntryService.Create(&photo) case "note": - + note := model.Note{} + note.SetID(post.Id) + note.SetPublishedAt(&post.Meta.Date) + note.SetMetaData(&model.NoteMetaData{ + Content: post.Content, + }) + app.EntryService.Create(¬e) case "recipe": - + recipe := model.Recipe{} + recipe.SetID(post.Id) + recipe.SetPublishedAt(&post.Meta.Date) + recipe.SetMetaData(&model.RecipeMetaData{ + Title: post.Meta.Title, + Yield: post.Meta.Recipe.Yield, + Duration: post.Meta.Recipe.Duration, + Ingredients: post.Meta.Recipe.Ingredients, + Content: post.Content, + }) + app.EntryService.Create(&recipe) case "page": - + page := model.Page{} + page.SetID(post.Id) + page.SetPublishedAt(&post.Meta.Date) + page.SetMetaData(&model.PageMetaData{ + Title: post.Meta.Title, + Content: post.Content, + }) + app.EntryService.Create(&page) default: panic("Unknown type") } diff --git a/domain/model/image.go b/domain/model/image.go index 176d8a5..cc77ca5 100644 --- a/domain/model/image.go +++ b/domain/model/image.go @@ -12,11 +12,12 @@ type Image struct { type ImageMetaData struct { ImageId string `owl:"inputType=file"` + Title string `owl:"inputType=text"` Content string `owl:"inputType=text widget=textarea"` } func (e *Image) Title() string { - return "" + return e.meta.Title } func (e *Image) Content() EntryContent { diff --git a/go.mod b/go.mod index 54cae65..8496677 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.47.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect + github.com/yuin/goldmark v1.5.4 // indirect golang.org/x/crypto v0.11.0 // indirect golang.org/x/sys v0.10.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index a5aaeef..8656157 100644 --- a/go.sum +++ b/go.sum @@ -62,6 +62,8 @@ github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVS github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= +github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/importer/utils.go b/importer/utils.go index 15bf9e2..e7fe2c1 100644 --- a/importer/utils.go +++ b/importer/utils.go @@ -43,6 +43,10 @@ type Post struct { Content string } +func (post *Post) MediaDir() string { + return path.Join("public", post.Id, "media") +} + func (pm *PostMeta) UnmarshalYAML(unmarshal func(interface{}) error) error { type T struct { Type string `yaml:"type"` @@ -155,7 +159,7 @@ func LoadMeta(data []byte) (PostMeta, error) { } func AllUserPosts(userPath string) ([]Post, error) { - postFiles := listDir(path.Join(userPath, "public")) + postFiles := ListDir(path.Join(userPath, "public")) posts := make([]Post, 0) for _, id := range postFiles { // if is a directory and has index.md, add to posts @@ -182,7 +186,7 @@ func AllUserPosts(userPath string) ([]Post, error) { return posts, nil } -func listDir(path string) []string { +func ListDir(path string) []string { dir, _ := os.Open(path) defer dir.Close() files, _ := dir.Readdirnames(-1) diff --git a/render/templates.go b/render/templates.go index 251168b..a9a88ce 100644 --- a/render/templates.go +++ b/render/templates.go @@ -5,19 +5,33 @@ import ( "embed" "io" "text/template" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer/html" ) //go:embed templates var templates embed.FS +var funcMap = template.FuncMap{ + "markdown": func(text string) string { + html, err := RenderMarkdown(text) + if err != nil { + return ">>>could not render markdown<<<" + } + return html + }, +} + func CreateTemplateWithBase(templateName string) (*template.Template, error) { - return template.ParseFS( + return template.New(templateName).Funcs(funcMap).ParseFS( templates, "templates/base.tmpl", "templates/"+templateName+".tmpl", ) - } func RenderTemplateWithBase(w io.Writer, templateName string, data interface{}) error { @@ -35,11 +49,9 @@ func RenderTemplateWithBase(w io.Writer, templateName string, data interface{}) } func RenderTemplateToString(templateName string, data interface{}) (string, error) { + tmplStr, _ := templates.ReadFile("templates/" + templateName + ".tmpl") - t, err := template.ParseFS( - templates, - "templates/"+templateName+".tmpl", - ) + t, err := template.New("templates/" + templateName + ".tmpl").Funcs(funcMap).Parse(string(tmplStr)) if err != nil { return "", err @@ -50,3 +62,21 @@ func RenderTemplateToString(templateName string, data interface{}) (string, erro err = t.Execute(&output, data) return output.String(), err } + +func RenderMarkdown(mdText string) (string, error) { + markdown := goldmark.New( + goldmark.WithRendererOptions( + html.WithUnsafe(), + ), + goldmark.WithExtensions( + // meta.Meta, + extension.GFM, + ), + ) + var buf bytes.Buffer + context := parser.NewContext() + err := markdown.Convert([]byte(mdText), &buf, parser.WithContext(context)) + + return buf.String(), err + +} diff --git a/render/templates/entry/Article.tmpl b/render/templates/entry/Article.tmpl index bac8848..f9e080a 100644 --- a/render/templates/entry/Article.tmpl +++ b/render/templates/entry/Article.tmpl @@ -1 +1 @@ -{{.MetaData.Content}} +{{.MetaData.Content | markdown }} diff --git a/web/index_handler.go b/web/index_handler.go index 2a0575d..ae4fa64 100644 --- a/web/index_handler.go +++ b/web/index_handler.go @@ -3,6 +3,7 @@ package web import ( "owl-blogs/app" "owl-blogs/render" + "sort" "github.com/gofiber/fiber/v2" ) @@ -19,6 +20,11 @@ func (h *IndexHandler) Handle(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) entries, err := h.entrySvc.FindAll() + // sort entries by date descending + sort.Slice(entries, func(i, j int) bool { + return entries[i].PublishedAt().After(*entries[j].PublishedAt()) + }) + if err != nil { return err } From 687707a3e8e621ad8f854d6d2fc1e25208e9d201 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Thu, 13 Jul 2023 21:20:00 +0200 Subject: [PATCH 27/41] micro formats + html from owl 1 --- app/entry_service.go | 13 +- assets/owl.png | Bin 0 -> 47278 bytes assets/owl.svg | 200 ++++++++++++++++++++++++++++++ domain/model/author.go | 2 + render/templates/base.tmpl | 11 +- render/templates/entry/Image.tmpl | 4 - render/templates/views/entry.tmpl | 34 +++-- render/templates/views/index.tmpl | 46 +++++-- web/app.go | 15 ++- web/entry_handler.go | 22 +++- web/index_handler.go | 45 ++++++- web/static/favicon.ico | Bin 0 -> 38078 bytes web/static/pico.min.css | 5 + 13 files changed, 359 insertions(+), 38 deletions(-) create mode 100644 assets/owl.png create mode 100644 assets/owl.svg create mode 100644 web/static/favicon.ico create mode 100644 web/static/pico.min.css diff --git a/app/entry_service.go b/app/entry_service.go index ba64baf..79b11fe 100644 --- a/app/entry_service.go +++ b/app/entry_service.go @@ -34,5 +34,16 @@ func (s *EntryService) FindAllByType(types *[]string) ([]model.Entry, error) { } func (s *EntryService) FindAll() ([]model.Entry, error) { - return s.EntryRepository.FindAll(nil) + entries, err := s.EntryRepository.FindAll(nil) + if err != nil { + return nil, err + } + // filter unpublished entries + publishedEntries := make([]model.Entry, 0) + for _, entry := range entries { + if entry.PublishedAt() != nil && !entry.PublishedAt().IsZero() { + publishedEntries = append(publishedEntries, entry) + } + } + return publishedEntries, nil } diff --git a/assets/owl.png b/assets/owl.png new file mode 100644 index 0000000000000000000000000000000000000000..2b1466ca3078d343640df2d7078be1a834f12677 GIT binary patch literal 47278 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 N^3uvuwGt-({T~^vP(=U$ literal 0 HcmV?d00001 diff --git a/assets/owl.svg b/assets/owl.svg new file mode 100644 index 0000000..996e37f --- /dev/null +++ b/assets/owl.svg @@ -0,0 +1,200 @@ + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/domain/model/author.go b/domain/model/author.go index 41b8fbf..fdb514b 100644 --- a/domain/model/author.go +++ b/domain/model/author.go @@ -3,4 +3,6 @@ package model type Author struct { Name string PasswordHash string + FullUrl string + AvatarUrl string } diff --git a/render/templates/base.tmpl b/render/templates/base.tmpl index 8e3b572..07f37c2 100644 --- a/render/templates/base.tmpl +++ b/render/templates/base.tmpl @@ -4,15 +4,18 @@ {{template "title" .}} - Owl Blog + -
      - Owl Blog +
      + Owl Blog
      -
      +
      {{template "main" .}}
      -
      Powered by Go
      +
      + Powered by Go +
      {{end}} \ No newline at end of file diff --git a/render/templates/entry/Image.tmpl b/render/templates/entry/Image.tmpl index c616146..05f38f5 100644 --- a/render/templates/entry/Image.tmpl +++ b/render/templates/entry/Image.tmpl @@ -1,7 +1,3 @@ -

      - {{.MetaData.Title}} -

      - {{.MetaData.Content}} diff --git a/render/templates/views/entry.tmpl b/render/templates/views/entry.tmpl index 8af2b7d..5d8ab78 100644 --- a/render/templates/views/entry.tmpl +++ b/render/templates/views/entry.tmpl @@ -1,17 +1,33 @@ -{{define "title"}}{{.Title}}{{end}} +{{define "title"}}{{.Entry.Title}}{{end}} {{define "main"}} -{{if .Title}} -

      {{.Title}}

      -{{end}} -

      - Published: {{.PublishedAt}} -

      +
      +
      + {{if .Entry.Title}} +

      {{.Entry.Title}}

      + {{end}} + + # + Published: + + {{ if .Author.Name }} + by + + {{ if .Author.AvatarUrl }} + + {{ end }} + {{.Author.Name}} + + {{ end }} + +
      + {{.Entry.Content}} -{{.Content}} - +
      {{end}} diff --git a/render/templates/views/index.tmpl b/render/templates/views/index.tmpl index 8ec0e75..dc43f01 100644 --- a/render/templates/views/index.tmpl +++ b/render/templates/views/index.tmpl @@ -2,21 +2,43 @@ {{define "main"}} -{{ range . }} -
      -

      - - {{if .Title}} - {{ .Title }} - {{else}} - # - {{end}} - -

      -

      {{ .PublishedAt }}

      +
      +{{ range .Entries }} +
      +
      +

      + + {{if .Title}} + {{ .Title }} + {{else}} + # + {{end}} + +

      + + + +
      {{ .Content }}

      {{ end }} +
      +
      + {{end}} \ No newline at end of file diff --git a/web/app.go b/web/app.go index 9ee3d12..4eb3ef9 100644 --- a/web/app.go +++ b/web/app.go @@ -1,12 +1,18 @@ package web import ( + "embed" + "net/http" "owl-blogs/app" "owl-blogs/web/middleware" "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/filesystem" ) +//go:embed static/* +var embedDirStatic embed.FS + type WebApp struct { FiberApp *fiber.App EntryService *app.EntryService @@ -25,7 +31,7 @@ func NewWebApp( indexHandler := NewIndexHandler(entryService) listHandler := NewListHandler(entryService) - entryHandler := NewEntryHandler(entryService, typeRegistry) + entryHandler := NewEntryHandler(entryService, typeRegistry, authorService) mediaHandler := NewMediaHandler(binService) rssHandler := NewRSSHandler(entryService) loginHandler := NewLoginHandler(authorService) @@ -43,7 +49,12 @@ func NewWebApp( editor.Get("/:editor/", editorHandler.HandleGet) editor.Post("/:editor/", editorHandler.HandlePost) - // app.ServeFiles("/static/*filepath", http.Dir(repo.StaticDir())) + // app.Static("/static/*filepath", http.Dir(repo.StaticDir())) + app.Use("/static", filesystem.New(filesystem.Config{ + Root: http.FS(embedDirStatic), + PathPrefix: "static", + Browse: false, + })) app.Get("/", indexHandler.Handle) app.Get("/lists/:list/", listHandler.Handle) // Media diff --git a/web/entry_handler.go b/web/entry_handler.go index 50e3640..a7bfe1a 100644 --- a/web/entry_handler.go +++ b/web/entry_handler.go @@ -2,18 +2,25 @@ package web import ( "owl-blogs/app" + "owl-blogs/domain/model" "owl-blogs/render" "github.com/gofiber/fiber/v2" ) type EntryHandler struct { - entrySvc *app.EntryService - registry *app.EntryTypeRegistry + entrySvc *app.EntryService + authorSvc *app.AuthorService + registry *app.EntryTypeRegistry } -func NewEntryHandler(entryService *app.EntryService, registry *app.EntryTypeRegistry) *EntryHandler { - return &EntryHandler{entrySvc: entryService, registry: registry} +type entryData struct { + Entry model.Entry + Author *model.Author +} + +func NewEntryHandler(entryService *app.EntryService, registry *app.EntryTypeRegistry, authorService *app.AuthorService) *EntryHandler { + return &EntryHandler{entrySvc: entryService, authorSvc: authorService, registry: registry} } func (h *EntryHandler) Handle(c *fiber.Ctx) error { @@ -25,5 +32,10 @@ func (h *EntryHandler) Handle(c *fiber.Ctx) error { return err } - return render.RenderTemplateWithBase(c, "views/entry", entry) + author, err := h.authorSvc.FindByName("h4kor") + if err != nil { + return err + } + + return render.RenderTemplateWithBase(c, "views/entry", entryData{Entry: entry, Author: author}) } diff --git a/web/index_handler.go b/web/index_handler.go index ae4fa64..e7325ec 100644 --- a/web/index_handler.go +++ b/web/index_handler.go @@ -2,8 +2,10 @@ package web import ( "owl-blogs/app" + "owl-blogs/domain/model" "owl-blogs/render" "sort" + "strconv" "github.com/gofiber/fiber/v2" ) @@ -16,19 +18,60 @@ func NewIndexHandler(entryService *app.EntryService) *IndexHandler { return &IndexHandler{entrySvc: entryService} } +type indexRenderData struct { + Entries []model.Entry + Page int + NextPage int + PrevPage int + FirstPage bool + LastPage bool +} + func (h *IndexHandler) Handle(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) entries, err := h.entrySvc.FindAll() + if err != nil { + return err + } // sort entries by date descending sort.Slice(entries, func(i, j int) bool { return entries[i].PublishedAt().After(*entries[j].PublishedAt()) }) + // pagination + page := c.Query("page") + if page == "" { + page = "1" + } + pageNum, err := strconv.Atoi(page) + if err != nil { + pageNum = 1 + } + limit := 10 + offset := (pageNum - 1) * limit + lastPage := false + if offset > len(entries) { + offset = len(entries) + lastPage = true + } + if offset+limit > len(entries) { + limit = len(entries) - offset + lastPage = true + } + entries = entries[offset : offset+limit] + if err != nil { return err } - return render.RenderTemplateWithBase(c, "views/index", entries) + return render.RenderTemplateWithBase(c, "views/index", indexRenderData{ + Entries: entries, + Page: pageNum, + NextPage: pageNum + 1, + PrevPage: pageNum - 1, + FirstPage: pageNum == 1, + LastPage: lastPage, + }) } diff --git a/web/static/favicon.ico b/web/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c2f7f354fa03de14bf302283d632c2f14c143334 GIT binary patch literal 38078 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 a77Q}GoF*qG4(y*wfooter,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 2.5)}}@media (min-width:768px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 3)}}@media (min-width:992px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 3.5)}}@media (min-width:1200px){body>footer,body>header,body>main,section{--block-spacing-vertical:calc(var(--spacing) * 4)}}@media (min-width:576px){article{--block-spacing-horizontal:calc(var(--spacing) * 1.25)}}@media (min-width:768px){article{--block-spacing-horizontal:calc(var(--spacing) * 1.5)}}@media (min-width:992px){article{--block-spacing-horizontal:calc(var(--spacing) * 1.75)}}@media (min-width:1200px){article{--block-spacing-horizontal:calc(var(--spacing) * 2)}}dialog>article{--block-spacing-vertical:calc(var(--spacing) * 2);--block-spacing-horizontal:var(--spacing)}@media (min-width:576px){dialog>article{--block-spacing-vertical:calc(var(--spacing) * 2.5);--block-spacing-horizontal:calc(var(--spacing) * 1.25)}}@media (min-width:768px){dialog>article{--block-spacing-vertical:calc(var(--spacing) * 3);--block-spacing-horizontal:calc(var(--spacing) * 1.5)}}a{--text-decoration:none}a.contrast,a.secondary{--text-decoration:underline}small{--font-size:0.875em}h1,h2,h3,h4,h5,h6{--font-weight:700}h1{--font-size:2rem;--typography-spacing-vertical:3rem}h2{--font-size:1.75rem;--typography-spacing-vertical:2.625rem}h3{--font-size:1.5rem;--typography-spacing-vertical:2.25rem}h4{--font-size:1.25rem;--typography-spacing-vertical:1.874rem}h5{--font-size:1.125rem;--typography-spacing-vertical:1.6875rem}[type=checkbox],[type=radio]{--border-width:2px}[type=checkbox][role=switch]{--border-width:3px}tfoot td,tfoot th,thead td,thead th{--border-width:3px}:not(thead,tfoot)>*>td{--font-size:0.875em}code,kbd,pre,samp{--font-family:"Menlo","Consolas","Roboto Mono","Ubuntu Monospace","Noto Mono","Oxygen Mono","Liberation Mono",monospace,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"}kbd{--font-weight:bolder}:root:not([data-theme=dark]),[data-theme=light]{--background-color:#fff;--color:hsl(205, 20%, 32%);--h1-color:hsl(205, 30%, 15%);--h2-color:#24333e;--h3-color:hsl(205, 25%, 23%);--h4-color:#374956;--h5-color:hsl(205, 20%, 32%);--h6-color:#4d606d;--muted-color:hsl(205, 10%, 50%);--muted-border-color:hsl(205, 20%, 94%);--primary:hsl(195, 85%, 41%);--primary-hover:hsl(195, 90%, 32%);--primary-focus:rgba(16, 149, 193, 0.125);--primary-inverse:#fff;--secondary:hsl(205, 15%, 41%);--secondary-hover:hsl(205, 20%, 32%);--secondary-focus:rgba(89, 107, 120, 0.125);--secondary-inverse:#fff;--contrast:hsl(205, 30%, 15%);--contrast-hover:#000;--contrast-focus:rgba(89, 107, 120, 0.125);--contrast-inverse:#fff;--mark-background-color:#fff2ca;--mark-color:#543a26;--ins-color:#388e3c;--del-color:#c62828;--blockquote-border-color:var(--muted-border-color);--blockquote-footer-color:var(--muted-color);--button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--form-element-background-color:transparent;--form-element-border-color:hsl(205, 14%, 68%);--form-element-color:var(--color);--form-element-placeholder-color:var(--muted-color);--form-element-active-background-color:transparent;--form-element-active-border-color:var(--primary);--form-element-focus-color:var(--primary-focus);--form-element-disabled-background-color:hsl(205, 18%, 86%);--form-element-disabled-border-color:hsl(205, 14%, 68%);--form-element-disabled-opacity:0.5;--form-element-invalid-border-color:#c62828;--form-element-invalid-active-border-color:#d32f2f;--form-element-invalid-focus-color:rgba(211, 47, 47, 0.125);--form-element-valid-border-color:#388e3c;--form-element-valid-active-border-color:#43a047;--form-element-valid-focus-color:rgba(67, 160, 71, 0.125);--switch-background-color:hsl(205, 16%, 77%);--switch-color:var(--primary-inverse);--switch-checked-background-color:var(--primary);--range-border-color:hsl(205, 18%, 86%);--range-active-border-color:hsl(205, 16%, 77%);--range-thumb-border-color:var(--background-color);--range-thumb-color:var(--secondary);--range-thumb-hover-color:var(--secondary-hover);--range-thumb-active-color:var(--primary);--table-border-color:var(--muted-border-color);--table-row-stripped-background-color:#f6f8f9;--code-background-color:hsl(205, 20%, 94%);--code-color:var(--muted-color);--code-kbd-background-color:var(--contrast);--code-kbd-color:var(--contrast-inverse);--code-tag-color:hsl(330, 40%, 50%);--code-property-color:hsl(185, 40%, 40%);--code-value-color:hsl(40, 20%, 50%);--code-comment-color:hsl(205, 14%, 68%);--accordion-border-color:var(--muted-border-color);--accordion-close-summary-color:var(--color);--accordion-open-summary-color:var(--muted-color);--card-background-color:var(--background-color);--card-border-color:var(--muted-border-color);--card-box-shadow:0.0145rem 0.029rem 0.174rem rgba(27, 40, 50, 0.01698),0.0335rem 0.067rem 0.402rem rgba(27, 40, 50, 0.024),0.0625rem 0.125rem 0.75rem rgba(27, 40, 50, 0.03),0.1125rem 0.225rem 1.35rem rgba(27, 40, 50, 0.036),0.2085rem 0.417rem 2.502rem rgba(27, 40, 50, 0.04302),0.5rem 1rem 6rem rgba(27, 40, 50, 0.06),0 0 0 0.0625rem rgba(27, 40, 50, 0.015);--card-sectionning-background-color:#fbfbfc;--dropdown-background-color:#fbfbfc;--dropdown-border-color:#e1e6eb;--dropdown-box-shadow:var(--card-box-shadow);--dropdown-color:var(--color);--dropdown-hover-background-color:hsl(205, 20%, 94%);--modal-overlay-background-color:rgba(213, 220, 226, 0.7);--progress-background-color:hsl(205, 18%, 86%);--progress-color:var(--primary);--loading-spinner-opacity:0.5;--tooltip-background-color:var(--contrast);--tooltip-color:var(--contrast-inverse);--icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(65, 84, 98)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button-inverse:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(115, 130, 140)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(65, 84, 98)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(198, 40, 40)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");--icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(65, 84, 98)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(65, 84, 98)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(56, 142, 60)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");color-scheme:light}@media only screen and (prefers-color-scheme:dark){:root:not([data-theme]){--background-color:#11191f;--color:hsl(205, 16%, 77%);--h1-color:hsl(205, 20%, 94%);--h2-color:#e1e6eb;--h3-color:hsl(205, 18%, 86%);--h4-color:#c8d1d8;--h5-color:hsl(205, 16%, 77%);--h6-color:#afbbc4;--muted-color:hsl(205, 10%, 50%);--muted-border-color:#1f2d38;--primary:hsl(195, 85%, 41%);--primary-hover:hsl(195, 80%, 50%);--primary-focus:rgba(16, 149, 193, 0.25);--primary-inverse:#fff;--secondary:hsl(205, 15%, 41%);--secondary-hover:hsl(205, 10%, 50%);--secondary-focus:rgba(115, 130, 140, 0.25);--secondary-inverse:#fff;--contrast:hsl(205, 20%, 94%);--contrast-hover:#fff;--contrast-focus:rgba(115, 130, 140, 0.25);--contrast-inverse:#000;--mark-background-color:#d1c284;--mark-color:#11191f;--ins-color:#388e3c;--del-color:#c62828;--blockquote-border-color:var(--muted-border-color);--blockquote-footer-color:var(--muted-color);--button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--form-element-background-color:#11191f;--form-element-border-color:#374956;--form-element-color:var(--color);--form-element-placeholder-color:var(--muted-color);--form-element-active-background-color:var(--form-element-background-color);--form-element-active-border-color:var(--primary);--form-element-focus-color:var(--primary-focus);--form-element-disabled-background-color:hsl(205, 25%, 23%);--form-element-disabled-border-color:hsl(205, 20%, 32%);--form-element-disabled-opacity:0.5;--form-element-invalid-border-color:#b71c1c;--form-element-invalid-active-border-color:#c62828;--form-element-invalid-focus-color:rgba(198, 40, 40, 0.25);--form-element-valid-border-color:#2e7d32;--form-element-valid-active-border-color:#388e3c;--form-element-valid-focus-color:rgba(56, 142, 60, 0.25);--switch-background-color:#374956;--switch-color:var(--primary-inverse);--switch-checked-background-color:var(--primary);--range-border-color:#24333e;--range-active-border-color:hsl(205, 25%, 23%);--range-thumb-border-color:var(--background-color);--range-thumb-color:var(--secondary);--range-thumb-hover-color:var(--secondary-hover);--range-thumb-active-color:var(--primary);--table-border-color:var(--muted-border-color);--table-row-stripped-background-color:rgba(115, 130, 140, 0.05);--code-background-color:#18232c;--code-color:var(--muted-color);--code-kbd-background-color:var(--contrast);--code-kbd-color:var(--contrast-inverse);--code-tag-color:hsl(330, 30%, 50%);--code-property-color:hsl(185, 30%, 50%);--code-value-color:hsl(40, 10%, 50%);--code-comment-color:#4d606d;--accordion-border-color:var(--muted-border-color);--accordion-active-summary-color:var(--primary);--accordion-close-summary-color:var(--color);--accordion-open-summary-color:var(--muted-color);--card-background-color:#141e26;--card-border-color:var(--card-background-color);--card-box-shadow:0.0145rem 0.029rem 0.174rem rgba(0, 0, 0, 0.01698),0.0335rem 0.067rem 0.402rem rgba(0, 0, 0, 0.024),0.0625rem 0.125rem 0.75rem rgba(0, 0, 0, 0.03),0.1125rem 0.225rem 1.35rem rgba(0, 0, 0, 0.036),0.2085rem 0.417rem 2.502rem rgba(0, 0, 0, 0.04302),0.5rem 1rem 6rem rgba(0, 0, 0, 0.06),0 0 0 0.0625rem rgba(0, 0, 0, 0.015);--card-sectionning-background-color:#18232c;--dropdown-background-color:hsl(205, 30%, 15%);--dropdown-border-color:#24333e;--dropdown-box-shadow:var(--card-box-shadow);--dropdown-color:var(--color);--dropdown-hover-background-color:rgba(36, 51, 62, 0.75);--modal-overlay-background-color:rgba(36, 51, 62, 0.8);--progress-background-color:#24333e;--progress-color:var(--primary);--loading-spinner-opacity:0.5;--tooltip-background-color:var(--contrast);--tooltip-color:var(--contrast-inverse);--icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button-inverse:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(0, 0, 0)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(115, 130, 140)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(183, 28, 28)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");--icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(46, 125, 50)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");color-scheme:dark}}[data-theme=dark]{--background-color:#11191f;--color:hsl(205, 16%, 77%);--h1-color:hsl(205, 20%, 94%);--h2-color:#e1e6eb;--h3-color:hsl(205, 18%, 86%);--h4-color:#c8d1d8;--h5-color:hsl(205, 16%, 77%);--h6-color:#afbbc4;--muted-color:hsl(205, 10%, 50%);--muted-border-color:#1f2d38;--primary:hsl(195, 85%, 41%);--primary-hover:hsl(195, 80%, 50%);--primary-focus:rgba(16, 149, 193, 0.25);--primary-inverse:#fff;--secondary:hsl(205, 15%, 41%);--secondary-hover:hsl(205, 10%, 50%);--secondary-focus:rgba(115, 130, 140, 0.25);--secondary-inverse:#fff;--contrast:hsl(205, 20%, 94%);--contrast-hover:#fff;--contrast-focus:rgba(115, 130, 140, 0.25);--contrast-inverse:#000;--mark-background-color:#d1c284;--mark-color:#11191f;--ins-color:#388e3c;--del-color:#c62828;--blockquote-border-color:var(--muted-border-color);--blockquote-footer-color:var(--muted-color);--button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--form-element-background-color:#11191f;--form-element-border-color:#374956;--form-element-color:var(--color);--form-element-placeholder-color:var(--muted-color);--form-element-active-background-color:var(--form-element-background-color);--form-element-active-border-color:var(--primary);--form-element-focus-color:var(--primary-focus);--form-element-disabled-background-color:hsl(205, 25%, 23%);--form-element-disabled-border-color:hsl(205, 20%, 32%);--form-element-disabled-opacity:0.5;--form-element-invalid-border-color:#b71c1c;--form-element-invalid-active-border-color:#c62828;--form-element-invalid-focus-color:rgba(198, 40, 40, 0.25);--form-element-valid-border-color:#2e7d32;--form-element-valid-active-border-color:#388e3c;--form-element-valid-focus-color:rgba(56, 142, 60, 0.25);--switch-background-color:#374956;--switch-color:var(--primary-inverse);--switch-checked-background-color:var(--primary);--range-border-color:#24333e;--range-active-border-color:hsl(205, 25%, 23%);--range-thumb-border-color:var(--background-color);--range-thumb-color:var(--secondary);--range-thumb-hover-color:var(--secondary-hover);--range-thumb-active-color:var(--primary);--table-border-color:var(--muted-border-color);--table-row-stripped-background-color:rgba(115, 130, 140, 0.05);--code-background-color:#18232c;--code-color:var(--muted-color);--code-kbd-background-color:var(--contrast);--code-kbd-color:var(--contrast-inverse);--code-tag-color:hsl(330, 30%, 50%);--code-property-color:hsl(185, 30%, 50%);--code-value-color:hsl(40, 10%, 50%);--code-comment-color:#4d606d;--accordion-border-color:var(--muted-border-color);--accordion-active-summary-color:var(--primary);--accordion-close-summary-color:var(--color);--accordion-open-summary-color:var(--muted-color);--card-background-color:#141e26;--card-border-color:var(--card-background-color);--card-box-shadow:0.0145rem 0.029rem 0.174rem rgba(0, 0, 0, 0.01698),0.0335rem 0.067rem 0.402rem rgba(0, 0, 0, 0.024),0.0625rem 0.125rem 0.75rem rgba(0, 0, 0, 0.03),0.1125rem 0.225rem 1.35rem rgba(0, 0, 0, 0.036),0.2085rem 0.417rem 2.502rem rgba(0, 0, 0, 0.04302),0.5rem 1rem 6rem rgba(0, 0, 0, 0.06),0 0 0 0.0625rem rgba(0, 0, 0, 0.015);--card-sectionning-background-color:#18232c;--dropdown-background-color:hsl(205, 30%, 15%);--dropdown-border-color:#24333e;--dropdown-box-shadow:var(--card-box-shadow);--dropdown-color:var(--color);--dropdown-hover-background-color:rgba(36, 51, 62, 0.75);--modal-overlay-background-color:rgba(36, 51, 62, 0.8);--progress-background-color:#24333e;--progress-color:var(--primary);--loading-spinner-opacity:0.5;--tooltip-background-color:var(--contrast);--tooltip-color:var(--contrast-inverse);--icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-chevron-button-inverse:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(0, 0, 0)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(115, 130, 140)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(183, 28, 28)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");--icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(162, 175, 185)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(46, 125, 50)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");color-scheme:dark}[type=checkbox],[type=radio],[type=range],progress{accent-color:var(--primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--background-color);color:var(--color);font-weight:var(--font-weight);font-size:var(--font-size);line-height:var(--line-height);font-family:var(--font-family);text-rendering:optimizeLegibility;overflow-wrap:break-word;cursor:default;-moz-tab-size:4;-o-tab-size:4;tab-size:4}main{display:block}body{width:100%;margin:0}body>footer,body>header,body>main{width:100%;margin-right:auto;margin-left:auto;padding:var(--block-spacing-vertical) 0}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--spacing);padding-left:var(--spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:992px){.container{max-width:920px}}@media (min-width:1200px){.container{max-width:1130px}}section{margin-bottom:var(--block-spacing-vertical)}.grid{grid-column-gap:var(--grid-spacing-horizontal);grid-row-gap:var(--grid-spacing-vertical);display:grid;grid-template-columns:1fr;margin:0}@media (min-width:992px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}figure{display:block;margin:0;padding:0;overflow-x:auto}figure figcaption{padding:calc(var(--spacing) * .5) 0;color:var(--muted-color)}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,figure,form,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--typography-spacing-vertical);color:var(--color);font-style:normal;font-weight:var(--font-weight);font-size:var(--font-size)}[role=link],a{--color:var(--primary);--background-color:transparent;outline:0;background-color:var(--background-color);color:var(--color);-webkit-text-decoration:var(--text-decoration);text-decoration:var(--text-decoration);transition:background-color var(--transition),color var(--transition),box-shadow var(--transition),-webkit-text-decoration var(--transition);transition:background-color var(--transition),color var(--transition),text-decoration var(--transition),box-shadow var(--transition);transition:background-color var(--transition),color var(--transition),text-decoration var(--transition),box-shadow var(--transition),-webkit-text-decoration var(--transition)}[role=link]:is([aria-current],:hover,:active,:focus),a:is([aria-current],:hover,:active,:focus){--color:var(--primary-hover);--text-decoration:underline}[role=link]:focus,a:focus{--background-color:var(--primary-focus)}[role=link].secondary,a.secondary{--color:var(--secondary)}[role=link].secondary:is([aria-current],:hover,:active,:focus),a.secondary:is([aria-current],:hover,:active,:focus){--color:var(--secondary-hover)}[role=link].secondary:focus,a.secondary:focus{--background-color:var(--secondary-focus)}[role=link].contrast,a.contrast{--color:var(--contrast)}[role=link].contrast:is([aria-current],:hover,:active,:focus),a.contrast:is([aria-current],:hover,:active,:focus){--color:var(--contrast-hover)}[role=link].contrast:focus,a.contrast:focus{--background-color:var(--contrast-focus)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--typography-spacing-vertical);color:var(--color);font-weight:var(--font-weight);font-size:var(--font-size);font-family:var(--font-family)}h1{--color:var(--h1-color)}h2{--color:var(--h2-color)}h3{--color:var(--h3-color)}h4{--color:var(--h4-color)}h5{--color:var(--h5-color)}h6{--color:var(--h6-color)}:where(address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--typography-spacing-vertical)}.headings,hgroup{margin-bottom:var(--typography-spacing-vertical)}.headings>*,hgroup>*{margin-bottom:0}.headings>:last-child,hgroup>:last-child{--color:var(--muted-color);--font-weight:unset;font-size:1rem;font-family:unset}p{margin-bottom:var(--typography-spacing-vertical)}small{font-size:var(--font-size)}:where(dl,ol,ul){padding-right:0;padding-left:var(--spacing);-webkit-padding-start:var(--spacing);padding-inline-start:var(--spacing);-webkit-padding-end:0;padding-inline-end:0}:where(dl,ol,ul) li{margin-bottom:calc(var(--typography-spacing-vertical) * .25)}:where(dl,ol,ul) :is(dl,ol,ul){margin:0;margin-top:calc(var(--typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--mark-background-color);color:var(--mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--typography-spacing-vertical) 0;padding:var(--spacing);border-right:none;border-left:.25rem solid var(--blockquote-border-color);-webkit-border-start:0.25rem solid var(--blockquote-border-color);border-inline-start:0.25rem solid var(--blockquote-border-color);-webkit-border-end:none;border-inline-end:none}blockquote footer{margin-top:calc(var(--typography-spacing-vertical) * .5);color:var(--blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--ins-color);text-decoration:none}del{color:var(--del-color)}::-moz-selection{background-color:var(--primary-focus)}::selection{background-color:var(--primary-focus)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:root){overflow:hidden}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}button{display:block;width:100%;margin-bottom:var(--spacing)}[role=button]{display:inline-block;text-decoration:none}[role=button],button,input[type=button],input[type=reset],input[type=submit]{--background-color:var(--primary);--border-color:var(--primary);--color:var(--primary-inverse);--box-shadow:var(--button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--form-element-spacing-vertical) var(--form-element-spacing-horizontal);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[role=button]:is([aria-current],:hover,:active,:focus),button:is([aria-current],:hover,:active,:focus),input[type=button]:is([aria-current],:hover,:active,:focus),input[type=reset]:is([aria-current],:hover,:active,:focus),input[type=submit]:is([aria-current],:hover,:active,:focus){--background-color:var(--primary-hover);--border-color:var(--primary-hover);--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--color:var(--primary-inverse)}[role=button]:focus,button:focus,input[type=button]:focus,input[type=reset]:focus,input[type=submit]:focus{--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--outline-width) var(--primary-focus)}:is(button,input[type=submit],input[type=button],[role=button]).secondary,input[type=reset]{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);cursor:pointer}:is(button,input[type=submit],input[type=button],[role=button]).secondary:is([aria-current],:hover,:active,:focus),input[type=reset]:is([aria-current],:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover);--color:var(--secondary-inverse)}:is(button,input[type=submit],input[type=button],[role=button]).secondary:focus,input[type=reset]:focus{--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--outline-width) var(--secondary-focus)}:is(button,input[type=submit],input[type=button],[role=button]).contrast{--background-color:var(--contrast);--border-color:var(--contrast);--color:var(--contrast-inverse)}:is(button,input[type=submit],input[type=button],[role=button]).contrast:is([aria-current],:hover,:active,:focus){--background-color:var(--contrast-hover);--border-color:var(--contrast-hover);--color:var(--contrast-inverse)}:is(button,input[type=submit],input[type=button],[role=button]).contrast:focus{--box-shadow:var(--button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--outline-width) var(--contrast-focus)}:is(button,input[type=submit],input[type=button],[role=button]).outline,input[type=reset].outline{--background-color:transparent;--color:var(--primary)}:is(button,input[type=submit],input[type=button],[role=button]).outline:is([aria-current],:hover,:active,:focus),input[type=reset].outline:is([aria-current],:hover,:active,:focus){--background-color:transparent;--color:var(--primary-hover)}:is(button,input[type=submit],input[type=button],[role=button]).outline.secondary,input[type=reset].outline{--color:var(--secondary)}:is(button,input[type=submit],input[type=button],[role=button]).outline.secondary:is([aria-current],:hover,:active,:focus),input[type=reset].outline:is([aria-current],:hover,:active,:focus){--color:var(--secondary-hover)}:is(button,input[type=submit],input[type=button],[role=button]).outline.contrast{--color:var(--contrast)}:is(button,input[type=submit],input[type=button],[role=button]).outline.contrast:is([aria-current],:hover,:active,:focus){--color:var(--contrast-hover)}:where(button,[type=submit],[type=button],[type=reset],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]),a[role=button]:not([href]){opacity:.5;pointer-events:none}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--line-height) + var(--form-element-spacing-vertical) * 2 + var(--border-width) * 2)}fieldset{margin:0;margin-bottom:var(--spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--spacing) * .25);font-weight:var(--form-label-font-weight,var(--font-weight))}input:not([type=checkbox],[type=radio]),select,textarea{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--form-element-spacing-vertical) var(--form-element-spacing-horizontal)}input,select,textarea{--background-color:var(--form-element-background-color);--border-color:var(--form-element-border-color);--color:var(--form-element-color);--box-shadow:none;border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}:where(select,textarea):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[readonly]):is(:active,:focus){--background-color:var(--form-element-active-background-color)}:where(select,textarea):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[role=switch],[readonly]):is(:active,:focus){--border-color:var(--form-element-active-border-color)}input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus,select:focus,textarea:focus{--box-shadow:0 0 0 var(--outline-width) var(--form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit],[type=button],[type=reset]),select,textarea),input:not([type=submit],[type=button],[type=reset])[disabled],select[disabled],textarea[disabled]{--background-color:var(--form-element-disabled-background-color);--border-color:var(--form-element-disabled-border-color);opacity:var(--form-element-disabled-opacity);pointer-events:none}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week])[aria-invalid]{padding-right:calc(var(--form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--form-element-spacing-horizontal);-webkit-padding-start:var(--form-element-spacing-horizontal)!important;padding-inline-start:var(--form-element-spacing-horizontal)!important;-webkit-padding-end:calc(var(--form-element-spacing-horizontal) + 1.5rem)!important;padding-inline-end:calc(var(--form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week])[aria-invalid=false]{background-image:var(--icon-valid)}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week])[aria-invalid=true]{background-image:var(--icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--border-color:var(--form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--border-color:var(--form-element-valid-active-border-color)!important;--box-shadow:0 0 0 var(--outline-width) var(--form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--border-color:var(--form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--border-color:var(--form-element-invalid-active-border-color)!important;--box-shadow:0 0 0 var(--outline-width) var(--form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox],[type=radio]):is([aria-invalid],[aria-invalid=true],[aria-invalid=false]){background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select,textarea{margin-bottom:var(--spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--form-element-spacing-horizontal) + 1.5rem);padding-left:var(--form-element-spacing-horizontal);-webkit-padding-start:var(--form-element-spacing-horizontal);padding-inline-start:var(--form-element-spacing-horizontal);-webkit-padding-end:calc(var(--form-element-spacing-horizontal) + 1.5rem);padding-inline-end:calc(var(--form-element-spacing-horizontal) + 1.5rem);background-image:var(--icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}[dir=rtl] select:not([multiple],[size]){background-position:center left .75rem}:where(input,select,textarea,.grid)+small{display:block;width:100%;margin-top:calc(var(--spacing) * -.75);margin-bottom:var(--spacing);color:var(--muted-color)}label>:where(input,select,textarea){margin-top:calc(var(--spacing) * .25)}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-right:.375em;margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:.375em;margin-inline-end:.375em;border-width:var(--border-width);font-size:inherit;vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--background-color:var(--primary);--border-color:var(--primary);background-image:var(--icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-right:.375em;margin-bottom:0;cursor:pointer}[type=checkbox]:indeterminate{--background-color:var(--primary);--border-color:var(--primary);background-image:var(--icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--background-color:var(--primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--background-color:var(--switch-background-color);--border-color:var(--switch-background-color);--color:var(--switch-color);width:2.25em;height:1.25em;border:var(--border-width) solid var(--border-color);border-radius:1.25em;background-color:var(--background-color);line-height:1.25em}[type=checkbox][role=switch]:focus{--background-color:var(--switch-background-color);--border-color:var(--switch-background-color)}[type=checkbox][role=switch]:checked{--background-color:var(--switch-checked-background-color);--border-color:var(--switch-checked-background-color)}[type=checkbox][role=switch]:before{display:block;width:calc(1.25em - (var(--border-width) * 2));height:100%;border-radius:50%;background-color:var(--color);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:checked{background-image:none}[type=checkbox][role=switch]:checked::before{margin-left:calc(1.125em - var(--border-width));-webkit-margin-start:calc(1.125em - var(--border-width));margin-inline-start:calc(1.125em - var(--border-width))}[type=checkbox]:checked[aria-invalid=false],[type=checkbox][aria-invalid=false],[type=checkbox][role=switch]:checked[aria-invalid=false],[type=checkbox][role=switch][aria-invalid=false],[type=radio]:checked[aria-invalid=false],[type=radio][aria-invalid=false]{--border-color:var(--form-element-valid-border-color)}[type=checkbox]:checked[aria-invalid=true],[type=checkbox][aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=checkbox][role=switch][aria-invalid=true],[type=radio]:checked[aria-invalid=true],[type=radio][aria-invalid=true]{--border-color:var(--form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--border-radius) * .5)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){--icon-position:0.75rem;--icon-width:1rem;padding-right:calc(var(--icon-width) + var(--icon-position));background-image:var(--icon-date);background-position:center right var(--icon-position);background-size:var(--icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--icon-width);margin-right:calc(var(--icon-width) * -1);margin-left:var(--icon-position);opacity:0}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--color:var(--muted-color);padding:calc(var(--form-element-spacing-vertical) * .5) 0;border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:calc(var(--spacing)/ 2);margin-inline-end:calc(var(--spacing)/ 2);padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::file-selector-button:is(:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=file]::-webkit-file-upload-button{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:calc(var(--spacing)/ 2);margin-inline-end:calc(var(--spacing)/ 2);padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;-webkit-transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::-webkit-file-upload-button:is(:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=file]::-ms-browse{--background-color:var(--secondary);--border-color:var(--secondary);--color:var(--secondary-inverse);margin-right:calc(var(--spacing)/ 2);margin-left:0;margin-inline-start:0;margin-inline-end:calc(var(--spacing)/ 2);padding:calc(var(--form-element-spacing-vertical) * .5) calc(var(--form-element-spacing-horizontal) * .5);border:var(--border-width) solid var(--border-color);border-radius:var(--border-radius);outline:0;background-color:var(--background-color);box-shadow:var(--box-shadow);color:var(--color);font-weight:var(--font-weight);font-size:1rem;line-height:var(--line-height);text-align:center;cursor:pointer;-ms-transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}[type=file]::-ms-browse:is(:hover,:active,:focus){--background-color:var(--secondary-hover);--border-color:var(--secondary-hover)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.25rem;border-radius:var(--border-radius);background-color:var(--range-border-color);-webkit-transition:background-color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),box-shadow var(--transition)}[type=range]::-moz-range-track{width:100%;height:.25rem;border-radius:var(--border-radius);background-color:var(--range-border-color);-moz-transition:background-color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),box-shadow var(--transition)}[type=range]::-ms-track{width:100%;height:.25rem;border-radius:var(--border-radius);background-color:var(--range-border-color);-ms-transition:background-color var(--transition),box-shadow var(--transition);transition:background-color var(--transition),box-shadow var(--transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.5rem;border:2px solid var(--range-thumb-border-color);border-radius:50%;background-color:var(--range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--transition),transform var(--transition);transition:background-color var(--transition),transform var(--transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.5rem;border:2px solid var(--range-thumb-border-color);border-radius:50%;background-color:var(--range-thumb-color);cursor:pointer;-moz-transition:background-color var(--transition),transform var(--transition);transition:background-color var(--transition),transform var(--transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.5rem;border:2px solid var(--range-thumb-border-color);border-radius:50%;background-color:var(--range-thumb-color);cursor:pointer;-ms-transition:background-color var(--transition),transform var(--transition);transition:background-color var(--transition),transform var(--transition)}[type=range]:focus,[type=range]:hover{--range-border-color:var(--range-active-border-color);--range-thumb-color:var(--range-thumb-hover-color)}[type=range]:active{--range-thumb-color:var(--range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{-webkit-padding-start:calc(var(--form-element-spacing-horizontal) + 1.75rem);padding-inline-start:calc(var(--form-element-spacing-horizontal) + 1.75rem);border-radius:5rem;background-image:var(--icon-search);background-position:center left 1.125rem;background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{-webkit-padding-start:calc(var(--form-element-spacing-horizontal) + 1.75rem)!important;padding-inline-start:calc(var(--form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=false]{background-image:var(--icon-search),var(--icon-valid)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=true]{background-image:var(--icon-search),var(--icon-invalid)}[type=search]::-webkit-search-cancel-button{-webkit-appearance:none;display:none}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--spacing)/ 2) var(--spacing);border-bottom:var(--border-width) solid var(--table-border-color);color:var(--color);font-weight:var(--font-weight);font-size:var(--font-size);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--border-width) solid var(--table-border-color);border-bottom:0}table[role=grid] tbody tr:nth-child(odd){background-color:var(--table-row-stripped-background-color)}code,kbd,pre,samp{font-size:.875em;font-family:var(--font-family)}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre{border-radius:var(--border-radius);background:var(--code-background-color);color:var(--code-color);font-weight:var(--font-weight);line-height:initial}code,kbd{display:inline-block;padding:.375rem .5rem}pre{display:block;margin-bottom:var(--spacing);overflow-x:auto}pre>code{display:block;padding:var(--spacing);background:0 0;font-size:14px;line-height:var(--line-height)}code b{color:var(--code-tag-color);font-weight:var(--font-weight)}code i{color:var(--code-property-color);font-style:normal}code u{color:var(--code-value-color);text-decoration:none}code em{color:var(--code-comment-color);font-style:normal}kbd{background-color:var(--code-kbd-background-color);color:var(--code-kbd-color);vertical-align:baseline}hr{height:0;border:0;border-top:1px solid var(--muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}details{display:block;margin-bottom:var(--spacing);padding-bottom:var(--spacing);border-bottom:var(--border-width) solid var(--accordion-border-color)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--transition)}details summary:not([role]){color:var(--accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;-webkit-margin-start:calc(var(--spacing,1rem) * 0.5);margin-inline-start:calc(var(--spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--transition)}details summary:focus{outline:0}details summary:focus:not([role=button]){color:var(--accordion-active-summary-color)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--line-height,1.5));background-image:var(--icon-chevron-button)}details summary[role=button]:not(.outline).contrast::after{background-image:var(--icon-chevron-button-inverse)}details[open]>summary{margin-bottom:calc(var(--spacing))}details[open]>summary:not([role]):not(:focus){color:var(--accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin:var(--block-spacing-vertical) 0;padding:var(--block-spacing-vertical) var(--block-spacing-horizontal);border-radius:var(--border-radius);background:var(--card-background-color);box-shadow:var(--card-box-shadow)}article>footer,article>header{margin-right:calc(var(--block-spacing-horizontal) * -1);margin-left:calc(var(--block-spacing-horizontal) * -1);padding:calc(var(--block-spacing-vertical) * .66) var(--block-spacing-horizontal);background-color:var(--card-sectionning-background-color)}article>header{margin-top:calc(var(--block-spacing-vertical) * -1);margin-bottom:var(--block-spacing-vertical);border-bottom:var(--border-width) solid var(--card-border-color);border-top-right-radius:var(--border-radius);border-top-left-radius:var(--border-radius)}article>footer{margin-top:var(--block-spacing-vertical);margin-bottom:calc(var(--block-spacing-vertical) * -1);border-top:var(--border-width) solid var(--card-border-color);border-bottom-right-radius:var(--border-radius);border-bottom-left-radius:var(--border-radius)}:root{--scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:var(--spacing);border:0;-webkit-backdrop-filter:var(--modal-overlay-backdrop-filter);backdrop-filter:var(--modal-overlay-backdrop-filter);background-color:var(--modal-overlay-background-color);color:var(--color)}dialog article{max-height:calc(100vh - var(--spacing) * 2);overflow:auto}@media (min-width:576px){dialog article{max-width:510px}}@media (min-width:768px){dialog article{max-width:700px}}dialog article>footer,dialog article>header{padding:calc(var(--block-spacing-vertical) * .5) var(--block-spacing-horizontal)}dialog article>header .close{margin:0;margin-left:var(--spacing);float:right}dialog article>footer{text-align:right}dialog article>footer [role=button]{margin-bottom:0}dialog article>footer [role=button]:not(:first-of-type){margin-left:calc(var(--spacing) * .5)}dialog article p:last-of-type{margin:0}dialog article .close{display:block;width:1rem;height:1rem;margin-top:calc(var(--block-spacing-vertical) * -.5);margin-bottom:var(--typography-spacing-vertical);margin-left:auto;background-image:var(--icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;opacity:.5;transition:opacity var(--transition)}dialog article .close:is([aria-current],:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--scrollbar-width,0);overflow:hidden;pointer-events:none;touch-action:none}.modal-is-open dialog{pointer-events:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-duration:.2s;animation-timing-function:ease-in-out;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{animation-duration:.8s;animation-name:modal-overlay}:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-delay:.2s;animation-name:modal}.modal-is-closing dialog,.modal-is-closing dialog>article{animation-delay:0s;animation-direction:reverse}@keyframes modal-overlay{from{-webkit-backdrop-filter:none;backdrop-filter:none;background-color:transparent}}@keyframes modal{from{transform:translateY(-100%);opacity:0}}:where(nav li)::before{float:left;content:"​"}nav,nav ul{display:flex}nav{justify-content:space-between}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--nav-element-spacing-vertical) var(--nav-element-spacing-horizontal)}nav li>*{--spacing:0}nav :where(a,[role=link]){display:inline-block;margin:calc(var(--nav-link-spacing-vertical) * -1) calc(var(--nav-link-spacing-horizontal) * -1);padding:var(--nav-link-spacing-vertical) var(--nav-link-spacing-horizontal);border-radius:var(--border-radius);text-decoration:none}nav :where(a,[role=link]):is([aria-current],:hover,:active,:focus){text-decoration:none}nav[aria-label=breadcrumb]{align-items:center;justify-content:start}nav[aria-label=breadcrumb] ul li:not(:first-child){-webkit-margin-start:var(--nav-link-spacing-horizontal);margin-inline-start:var(--nav-link-spacing-horizontal)}nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{position:absolute;width:calc(var(--nav-link-spacing-horizontal) * 2);-webkit-margin-start:calc(var(--nav-link-spacing-horizontal)/ 2);margin-inline-start:calc(var(--nav-link-spacing-horizontal)/ 2);content:"/";color:var(--muted-color);text-align:center}nav[aria-label=breadcrumb] a[aria-current]{background-color:transparent;color:inherit;text-decoration:none;pointer-events:none}nav [role=button]{margin-right:inherit;margin-left:inherit;padding:var(--nav-link-spacing-vertical) var(--nav-link-spacing-horizontal)}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--nav-element-spacing-vertical) * .5) var(--nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}[dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{content:"\\"}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--spacing) * .5);overflow:hidden;border:0;border-radius:var(--border-radius);background-color:var(--progress-background-color);color:var(--progress-color)}progress::-webkit-progress-bar{border-radius:var(--border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--progress-color)}progress::-moz-progress-bar{background-color:var(--progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--progress-background-color) linear-gradient(to right,var(--progress-color) 30%,var(--progress-background-color) 30%) top left/150% 150% no-repeat;animation:progress-indeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}details[role=list],li[role=list]{position:relative}details[role=list] summary+ul,li[role=list]>ul{display:flex;z-index:99;position:absolute;top:auto;right:0;left:0;flex-direction:column;margin:0;padding:0;border:var(--border-width) solid var(--dropdown-border-color);border-radius:var(--border-radius);border-top-right-radius:0;border-top-left-radius:0;background-color:var(--dropdown-background-color);box-shadow:var(--card-box-shadow);color:var(--dropdown-color);white-space:nowrap}details[role=list] summary+ul li,li[role=list]>ul li{width:100%;margin-bottom:0;padding:calc(var(--form-element-spacing-vertical) * .5) var(--form-element-spacing-horizontal);list-style:none}details[role=list] summary+ul li:first-of-type,li[role=list]>ul li:first-of-type{margin-top:calc(var(--form-element-spacing-vertical) * .5)}details[role=list] summary+ul li:last-of-type,li[role=list]>ul li:last-of-type{margin-bottom:calc(var(--form-element-spacing-vertical) * .5)}details[role=list] summary+ul li a,li[role=list]>ul li a{display:block;margin:calc(var(--form-element-spacing-vertical) * -.5) calc(var(--form-element-spacing-horizontal) * -1);padding:calc(var(--form-element-spacing-vertical) * .5) var(--form-element-spacing-horizontal);overflow:hidden;color:var(--dropdown-color);text-decoration:none;text-overflow:ellipsis}details[role=list] summary+ul li a:hover,li[role=list]>ul li a:hover{background-color:var(--dropdown-hover-background-color)}details[role=list] summary::after,li[role=list]>a::after{display:block;width:1rem;height:calc(1rem * var(--line-height,1.5));-webkit-margin-start:0.5rem;margin-inline-start:.5rem;float:right;transform:rotate(0);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}details[role=list]{padding:0;border-bottom:none}details[role=list] summary{margin-bottom:0}details[role=list] summary:not([role]){height:calc(1rem * var(--line-height) + var(--form-element-spacing-vertical) * 2 + var(--border-width) * 2);padding:var(--form-element-spacing-vertical) var(--form-element-spacing-horizontal);border:var(--border-width) solid var(--form-element-border-color);border-radius:var(--border-radius);background-color:var(--form-element-background-color);color:var(--form-element-placeholder-color);line-height:inherit;cursor:pointer;transition:background-color var(--transition),border-color var(--transition),color var(--transition),box-shadow var(--transition)}details[role=list] summary:not([role]):active,details[role=list] summary:not([role]):focus{border-color:var(--form-element-active-border-color);background-color:var(--form-element-active-background-color)}details[role=list] summary:not([role]):focus{box-shadow:0 0 0 var(--outline-width) var(--form-element-focus-color)}details[role=list][open] summary{border-bottom-right-radius:0;border-bottom-left-radius:0}details[role=list][open] summary::before{display:block;z-index:1;position:fixed;top:0;right:0;bottom:0;left:0;background:0 0;content:"";cursor:default}nav details[role=list] summary,nav li[role=list] a{display:flex;direction:ltr}nav details[role=list] summary+ul,nav li[role=list]>ul{min-width:-moz-fit-content;min-width:fit-content;border-radius:var(--border-radius)}nav details[role=list] summary+ul li a,nav li[role=list]>ul li a{border-radius:0}nav details[role=list] summary,nav details[role=list] summary:not([role]){height:auto;padding:var(--nav-link-spacing-vertical) var(--nav-link-spacing-horizontal)}nav details[role=list][open] summary{border-radius:var(--border-radius)}nav details[role=list] summary+ul{margin-top:var(--outline-width);-webkit-margin-start:0;margin-inline-start:0}nav details[role=list] summary[role=link]{margin-bottom:calc(var(--nav-link-spacing-vertical) * -1);line-height:var(--line-height)}nav details[role=list] summary[role=link]+ul{margin-top:calc(var(--nav-link-spacing-vertical) + var(--outline-width));-webkit-margin-start:calc(var(--nav-link-spacing-horizontal) * -1);margin-inline-start:calc(var(--nav-link-spacing-horizontal) * -1)}li[role=list] a:active~ul,li[role=list] a:focus~ul,li[role=list]:hover>ul{display:flex}li[role=list]>ul{display:none;margin-top:calc(var(--nav-link-spacing-vertical) + var(--outline-width));-webkit-margin-start:calc(var(--nav-element-spacing-horizontal) - var(--nav-link-spacing-horizontal));margin-inline-start:calc(var(--nav-element-spacing-horizontal) - var(--nav-link-spacing-horizontal))}li[role=list]>a::after{background-image:var(--icon-chevron)}label>details[role=list]{margin-top:calc(var(--spacing) * .25);margin-bottom:var(--spacing)}[aria-busy=true]{cursor:progress}[aria-busy=true]:not(input,select,textarea)::before{display:inline-block;width:1em;height:1em;border:.1875em solid currentColor;border-radius:1em;border-right-color:transparent;content:"";vertical-align:text-bottom;vertical-align:-.125em;animation:spinner .75s linear infinite;opacity:var(--loading-spinner-opacity)}[aria-busy=true]:not(input,select,textarea):not(:empty)::before{margin-right:calc(var(--spacing) * .5);margin-left:0;-webkit-margin-start:0;margin-inline-start:0;-webkit-margin-end:calc(var(--spacing) * .5);margin-inline-end:calc(var(--spacing) * .5)}[aria-busy=true]:not(input,select,textarea):empty{text-align:center}a[aria-busy=true],button[aria-busy=true],input[type=button][aria-busy=true],input[type=reset][aria-busy=true],input[type=submit][aria-busy=true]{pointer-events:none}@keyframes spinner{to{transform:rotate(360deg)}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before,[data-tooltip][data-placement=top]::after,[data-tooltip][data-placement=top]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--border-radius);background:var(--tooltip-background-color);content:attr(data-tooltip);color:var(--tooltip-color);font-style:normal;font-weight:var(--font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after,[data-tooltip][data-placement=top]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip][data-placement=left]::after,[data-tooltip][data-placement=left]::before{top:50%;right:100%;bottom:auto;left:auto;transform:translate(-.25rem,-50%)}[data-tooltip][data-placement=left]:after{transform:translate(.3rem,-50%);border:.3rem solid transparent;border-left:.3rem solid}[data-tooltip][data-placement=right]::after,[data-tooltip][data-placement=right]::before{top:50%;right:auto;bottom:auto;left:100%;transform:translate(.25rem,-50%)}[data-tooltip][data-placement=right]:after{transform:translate(-.3rem,-50%);border:.3rem solid transparent;border-right:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:hover::after,[data-tooltip]:hover::before,[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover [data-tooltip]:focus::after,[data-tooltip][data-placement=bottom]:hover [data-tooltip]:focus::before{animation-duration:.2s;animation-name:tooltip-slide-top}[data-tooltip]:hover::after,[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover [data-tooltip]:focus::after{animation-name:tooltip-caret-slide-top}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{animation-duration:.2s;animation-name:tooltip-slide-bottom}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{animation-name:tooltip-caret-slide-bottom}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:focus::before,[data-tooltip][data-placement=left]:hover::after,[data-tooltip][data-placement=left]:hover::before{animation-duration:.2s;animation-name:tooltip-slide-left}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:hover::after{animation-name:tooltip-caret-slide-left}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:focus::before,[data-tooltip][data-placement=right]:hover::after,[data-tooltip][data-placement=right]:hover::before{animation-duration:.2s;animation-name:tooltip-slide-right}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:hover::after{animation-name:tooltip-caret-slide-right}}@keyframes tooltip-slide-top{from{transform:translate(-50%,.75rem);opacity:0}to{transform:translate(-50%,-.25rem);opacity:1}}@keyframes tooltip-caret-slide-top{from{opacity:0}50%{transform:translate(-50%,-.25rem);opacity:0}to{transform:translate(-50%,0);opacity:1}}@keyframes tooltip-slide-bottom{from{transform:translate(-50%,-.75rem);opacity:0}to{transform:translate(-50%,.25rem);opacity:1}}@keyframes tooltip-caret-slide-bottom{from{opacity:0}50%{transform:translate(-50%,-.5rem);opacity:0}to{transform:translate(-50%,-.3rem);opacity:1}}@keyframes tooltip-slide-left{from{transform:translate(.75rem,-50%);opacity:0}to{transform:translate(-.25rem,-50%);opacity:1}}@keyframes tooltip-caret-slide-left{from{opacity:0}50%{transform:translate(.05rem,-50%);opacity:0}to{transform:translate(.3rem,-50%);opacity:1}}@keyframes tooltip-slide-right{from{transform:translate(-.75rem,-50%);opacity:0}to{transform:translate(.25rem,-50%);opacity:1}}@keyframes tooltip-caret-slide-right{from{opacity:0}50%{transform:translate(-.05rem,-50%);opacity:0}to{transform:translate(-.3rem,-50%);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}} +/*# sourceMappingURL=pico.min.css.map */ \ No newline at end of file From 033a6b4a7fefe4e4896bf18015c3606cdd15f0ec Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Thu, 13 Jul 2023 21:55:08 +0200 Subject: [PATCH 28/41] rework to have worker as part of entry --- app/author_service.go | 6 ++-- app/author_service_test.go | 16 ++++++---- cmd/owl/import_v1.go | 54 ++++++++++++++++++---------------- domain/model/entry.go | 11 +++++++ infra/entry_repository.go | 7 +++-- infra/entry_repository_test.go | 5 ++++ web/editor_handler.go | 1 + web/entry_handler.go | 4 +-- web/middleware/auth.go | 5 +++- 9 files changed, 71 insertions(+), 38 deletions(-) diff --git a/app/author_service.go b/app/author_service.go index 7b27747..64257b0 100644 --- a/app/author_service.go +++ b/app/author_service.go @@ -62,7 +62,7 @@ func (s *AuthorService) CreateToken(name string) (string, error) { return fmt.Sprintf("%s.%x", name, hash.Sum(nil)), nil } -func (s *AuthorService) ValidateToken(token string) bool { +func (s *AuthorService) ValidateToken(token string) (bool, string) { parts := strings.Split(token, ".") witness := parts[len(parts)-1] name := strings.Join(parts[:len(parts)-1], ".") @@ -70,7 +70,7 @@ func (s *AuthorService) ValidateToken(token string) bool { hash := sha256.New() _, err := hash.Write([]byte(name + s.getSecretKey())) if err != nil { - return false + return false, "" } - return fmt.Sprintf("%x", hash.Sum(nil)) == witness + return fmt.Sprintf("%x", hash.Sum(nil)) == witness, name } diff --git a/app/author_service_test.go b/app/author_service_test.go index 3c9a03c..2b419f4 100644 --- a/app/author_service_test.go +++ b/app/author_service_test.go @@ -71,9 +71,15 @@ func TestAuthorValidateToken(t *testing.T) { token, err := authorService.CreateToken("test") require.NoError(t, err) - require.True(t, authorService.ValidateToken(token)) - require.False(t, authorService.ValidateToken(token[:len(token)-2])) - require.False(t, authorService.ValidateToken("test")) - require.False(t, authorService.ValidateToken("test.test")) - require.False(t, authorService.ValidateToken(strings.Replace(token, "test", "test1", 1))) + valid, name := authorService.ValidateToken(token) + require.True(t, valid) + require.Equal(t, "test", name) + valid, _ = authorService.ValidateToken(token[:len(token)-2]) + require.False(t, valid) + valid, _ = authorService.ValidateToken("test") + require.False(t, valid) + valid, _ = authorService.ValidateToken("test.test") + require.False(t, valid) + valid, _ = authorService.ValidateToken(strings.Replace(token, "test", "test1", 1)) + require.False(t, valid) } diff --git a/cmd/owl/import_v1.go b/cmd/owl/import_v1.go index 69f9b42..1eb68a1 100644 --- a/cmd/owl/import_v1.go +++ b/cmd/owl/import_v1.go @@ -12,12 +12,15 @@ import ( ) var userPath string +var author string func init() { rootCmd.AddCommand(importCmd) importCmd.Flags().StringVarP(&userPath, "path", "p", "", "Path to the user folder") importCmd.MarkFlagRequired("path") + importCmd.Flags().StringVarP(&author, "author", "a", "", "The author name") + importCmd.MarkFlagRequired("author") } var importCmd = &cobra.Command{ @@ -56,63 +59,64 @@ var importCmd = &cobra.Command{ app.BinaryService.CreateEntryFile(file, fileData, entry) } + var entry model.Entry + switch post.Meta.Type { case "article": - article := model.Article{} - article.SetID(post.Id) - article.SetPublishedAt(&post.Meta.Date) - article.SetMetaData(&model.ArticleMetaData{ + entry = &model.Article{} + entry.SetID(post.Id) + entry.SetPublishedAt(&post.Meta.Date) + entry.SetMetaData(&model.ArticleMetaData{ Title: post.Meta.Title, Content: post.Content, }) - app.EntryService.Create(&article) case "bookmark": case "reply": case "photo": - photo := model.Image{} - photo.SetID(post.Id) - photo.SetPublishedAt(&post.Meta.Date) - photo.SetMetaData(&model.ImageMetaData{ + entry = &model.Image{} + entry.SetID(post.Id) + entry.SetPublishedAt(&post.Meta.Date) + entry.SetMetaData(&model.ImageMetaData{ Title: post.Meta.Title, Content: post.Content, ImageId: post.Meta.PhotoPath, }) - app.EntryService.Create(&photo) case "note": - note := model.Note{} - note.SetID(post.Id) - note.SetPublishedAt(&post.Meta.Date) - note.SetMetaData(&model.NoteMetaData{ + entry = &model.Note{} + entry.SetID(post.Id) + entry.SetPublishedAt(&post.Meta.Date) + entry.SetMetaData(&model.NoteMetaData{ Content: post.Content, }) - app.EntryService.Create(¬e) case "recipe": - recipe := model.Recipe{} - recipe.SetID(post.Id) - recipe.SetPublishedAt(&post.Meta.Date) - recipe.SetMetaData(&model.RecipeMetaData{ + entry = &model.Recipe{} + entry.SetID(post.Id) + entry.SetPublishedAt(&post.Meta.Date) + entry.SetMetaData(&model.RecipeMetaData{ Title: post.Meta.Title, Yield: post.Meta.Recipe.Yield, Duration: post.Meta.Recipe.Duration, Ingredients: post.Meta.Recipe.Ingredients, Content: post.Content, }) - app.EntryService.Create(&recipe) case "page": - page := model.Page{} - page.SetID(post.Id) - page.SetPublishedAt(&post.Meta.Date) - page.SetMetaData(&model.PageMetaData{ + entry = &model.Page{} + entry.SetID(post.Id) + entry.SetPublishedAt(&post.Meta.Date) + entry.SetMetaData(&model.PageMetaData{ Title: post.Meta.Title, Content: post.Content, }) - app.EntryService.Create(&page) default: panic("Unknown type") } + if entry != nil { + entry.SetAuthorId(author) + app.EntryService.Create(entry) + } } }, } diff --git a/domain/model/entry.go b/domain/model/entry.go index 4f499f8..b9372c2 100644 --- a/domain/model/entry.go +++ b/domain/model/entry.go @@ -8,6 +8,7 @@ type Entry interface { ID() string Content() EntryContent PublishedAt() *time.Time + AuthorId() string MetaData() interface{} // Optional: can return empty string @@ -16,6 +17,7 @@ type Entry interface { SetID(id string) SetPublishedAt(publishedAt *time.Time) SetMetaData(metaData interface{}) + SetAuthorId(authorId string) } type EntryMetaData interface { @@ -24,6 +26,7 @@ type EntryMetaData interface { type EntryBase struct { id string publishedAt *time.Time + authorId string } func (e *EntryBase) ID() string { @@ -41,3 +44,11 @@ func (e *EntryBase) SetID(id string) { func (e *EntryBase) SetPublishedAt(publishedAt *time.Time) { e.publishedAt = publishedAt } + +func (e *EntryBase) AuthorId() string { + return e.authorId +} + +func (e *EntryBase) SetAuthorId(authorId string) { + e.authorId = authorId +} diff --git a/infra/entry_repository.go b/infra/entry_repository.go index 6f9a7fc..3aad5bb 100644 --- a/infra/entry_repository.go +++ b/infra/entry_repository.go @@ -20,6 +20,7 @@ type sqlEntry struct { Type string `db:"type"` PublishedAt *time.Time `db:"published_at"` MetaData *string `db:"meta_data"` + AuthorId string `db:"author_id"` } type DefaultEntryRepo struct { @@ -43,7 +44,7 @@ func (r *DefaultEntryRepo) Create(entry model.Entry) error { entry.SetID(uuid.New().String()) } - _, err = r.db.Exec("INSERT INTO entries (id, type, published_at, meta_data) VALUES (?, ?, ?, ?)", entry.ID(), t, entry.PublishedAt(), metaDataJson) + _, err = r.db.Exec("INSERT INTO entries (id, type, published_at, author_id, meta_data) VALUES (?, ?, ?, ?, ?)", entry.ID(), t, entry.PublishedAt(), entry.AuthorId(), metaDataJson) return err } @@ -118,7 +119,7 @@ func (r *DefaultEntryRepo) Update(entry model.Entry) error { metaDataJson, _ = json.Marshal(entry.MetaData()) } - _, err = r.db.Exec("UPDATE entries SET published_at = ?, meta_data = ? WHERE id = ?", entry.PublishedAt(), metaDataJson, entry.ID()) + _, err = r.db.Exec("UPDATE entries SET published_at = ?, author_id = ?, meta_data = ? WHERE id = ?", entry.PublishedAt(), entry.AuthorId(), metaDataJson, entry.ID()) return err } @@ -131,6 +132,7 @@ func NewEntryRepository(db Database, register *app.EntryTypeRegistry) repository id TEXT PRIMARY KEY, type TEXT NOT NULL, published_at DATETIME, + author_id TEXT NOT NULL, meta_data TEXT NOT NULL ); `) @@ -151,5 +153,6 @@ func (r *DefaultEntryRepo) sqlEntryToEntry(entry sqlEntry) (model.Entry, error) e.SetID(entry.Id) e.SetPublishedAt(entry.PublishedAt) e.SetMetaData(metaData) + e.SetAuthorId(entry.AuthorId) return e, nil } diff --git a/infra/entry_repository_test.go b/infra/entry_repository_test.go index a6178c1..458cd42 100644 --- a/infra/entry_repository_test.go +++ b/infra/entry_repository_test.go @@ -25,6 +25,7 @@ func TestRepoCreate(t *testing.T) { entry := &test.MockEntry{} now := time.Now() entry.SetPublishedAt(&now) + entry.SetAuthorId("authorId") entry.SetMetaData(&test.MockEntryMetaData{ Str: "str", Number: 1, @@ -37,6 +38,7 @@ func TestRepoCreate(t *testing.T) { require.NoError(t, err) require.Equal(t, entry.ID(), entry2.ID()) require.Equal(t, entry.Content(), entry2.Content()) + require.Equal(t, entry.AuthorId(), entry2.AuthorId()) require.Equal(t, entry.PublishedAt().Unix(), entry2.PublishedAt().Unix()) meta := entry.MetaData().(*test.MockEntryMetaData) meta2 := entry2.MetaData().(*test.MockEntryMetaData) @@ -113,6 +115,7 @@ func TestRepoUpdate(t *testing.T) { entry := &test.MockEntry{} now := time.Now() entry.SetPublishedAt(&now) + entry.SetAuthorId("authorId") entry.SetMetaData(&test.MockEntryMetaData{ Str: "str", Number: 1, @@ -124,6 +127,7 @@ func TestRepoUpdate(t *testing.T) { entry2 := &test.MockEntry{} now2 := time.Now() entry2.SetPublishedAt(&now2) + entry.SetAuthorId("authorId2") entry2.SetMetaData(&test.MockEntryMetaData{ Str: "str2", Number: 2, @@ -138,6 +142,7 @@ func TestRepoUpdate(t *testing.T) { require.NoError(t, err) require.Equal(t, entry3.Content(), entry2.Content()) require.Equal(t, entry3.PublishedAt().Unix(), entry2.PublishedAt().Unix()) + require.Equal(t, entry3.AuthorId(), entry2.AuthorId()) meta := entry3.MetaData().(*test.MockEntryMetaData) meta2 := entry2.MetaData().(*test.MockEntryMetaData) require.Equal(t, meta.Str, meta2.Str) diff --git a/web/editor_handler.go b/web/editor_handler.go index bb75e94..1075681 100644 --- a/web/editor_handler.go +++ b/web/editor_handler.go @@ -70,6 +70,7 @@ func (h *EditorHandler) HandlePost(c *fiber.Ctx) error { // create entry now := time.Now() entry.SetPublishedAt(&now) + entry.SetAuthorId(c.Locals("author").(string)) err = h.entrySvc.Create(entry) if err != nil { diff --git a/web/entry_handler.go b/web/entry_handler.go index a7bfe1a..f8bd30c 100644 --- a/web/entry_handler.go +++ b/web/entry_handler.go @@ -32,9 +32,9 @@ func (h *EntryHandler) Handle(c *fiber.Ctx) error { return err } - author, err := h.authorSvc.FindByName("h4kor") + author, err := h.authorSvc.FindByName(entry.AuthorId()) if err != nil { - return err + author = &model.Author{} } return render.RenderTemplateWithBase(c, "views/entry", entryData{Entry: entry, Author: author}) diff --git a/web/middleware/auth.go b/web/middleware/auth.go index 5b644e6..6127489 100644 --- a/web/middleware/auth.go +++ b/web/middleware/auth.go @@ -22,10 +22,13 @@ func (m *AuthMiddleware) Handle(c *fiber.Ctx) error { } // check token - valid := m.authorService.ValidateToken(token) + valid, name := m.authorService.ValidateToken(token) if !valid { return c.Redirect("/auth/login") } + // set author name to context + c.Locals("author", name) + return c.Next() } From 3a1559584c804cb805272b29cc008aaebce0ac39 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Sun, 16 Jul 2023 21:08:25 +0200 Subject: [PATCH 29/41] siteconfig --- app/repository/interfaces.go | 5 ++ domain/model/siteconfig.go | 33 ++++++++++++++ infra/site_config_repository.go | 62 +++++++++++++++++++++++++ infra/site_config_repository_test.go | 68 ++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+) create mode 100644 domain/model/siteconfig.go create mode 100644 infra/site_config_repository.go create mode 100644 infra/site_config_repository_test.go diff --git a/app/repository/interfaces.go b/app/repository/interfaces.go index c5c3013..d0294c4 100644 --- a/app/repository/interfaces.go +++ b/app/repository/interfaces.go @@ -29,3 +29,8 @@ type AuthorRepository interface { // It returns an error if the author is not found FindByName(name string) (*model.Author, error) } + +type SiteConfigRepository interface { + Get() (model.SiteConfig, error) + Update(siteConfig model.SiteConfig) error +} diff --git a/domain/model/siteconfig.go b/domain/model/siteconfig.go new file mode 100644 index 0000000..72fc5b4 --- /dev/null +++ b/domain/model/siteconfig.go @@ -0,0 +1,33 @@ +package model + +type MeLinks struct { + Name string + Url string +} + +type EntryList struct { + Id string + Title string + Include []string + ListType string +} + +type MenuItem struct { + Title string + List string + Url string + Post string +} + +type SiteConfig struct { + Title string + SubTitle string + HeaderColor string + AuthorName string + Me []MeLinks + Lists []EntryList + PrimaryListInclude []string + HeaderMenu []MenuItem + FooterMenu []MenuItem + Secret string +} diff --git a/infra/site_config_repository.go b/infra/site_config_repository.go new file mode 100644 index 0000000..0d302f3 --- /dev/null +++ b/infra/site_config_repository.go @@ -0,0 +1,62 @@ +package infra + +import ( + "encoding/json" + "owl-blogs/app/repository" + "owl-blogs/domain/model" + + "github.com/jmoiron/sqlx" +) + +type DefaultSiteConfigRepo struct { + db *sqlx.DB +} + +func NewSiteConfigRepo(db Database) repository.SiteConfigRepository { + sqlxdb := db.Get() + + sqlxdb.MustExec(` + CREATE TABLE IF NOT EXISTS site_config ( + config TEXT + ); + `) + + return &DefaultSiteConfigRepo{ + db: sqlxdb, + } +} + +// Get implements repository.SiteConfigRepository. +func (r *DefaultSiteConfigRepo) Get() (model.SiteConfig, error) { + data := []byte{} + err := r.db.Get(&data, "SELECT config FROM site_config LIMIT 1") + if err != nil { + if err.Error() == "sql: no rows in result set" { + return model.SiteConfig{}, nil + } + return model.SiteConfig{}, err + } + if len(data) == 0 { + return model.SiteConfig{}, nil + } + config := model.SiteConfig{} + err = json.Unmarshal(data, &config) + return config, err +} + +// Update implements repository.SiteConfigRepository. +func (r *DefaultSiteConfigRepo) Update(siteConfig model.SiteConfig) error { + jsonData, err := json.Marshal(siteConfig) + if err != nil { + return err + } + res, err := r.db.Exec("UPDATE site_config SET config = ?", jsonData) + if err != nil { + return err + } + rows, err := res.RowsAffected() + if rows == 0 { + _, err = r.db.Exec("INSERT INTO site_config (config) VALUES (?)", jsonData) + } + return err +} diff --git a/infra/site_config_repository_test.go b/infra/site_config_repository_test.go new file mode 100644 index 0000000..b16641c --- /dev/null +++ b/infra/site_config_repository_test.go @@ -0,0 +1,68 @@ +package infra_test + +import ( + "owl-blogs/app/repository" + "owl-blogs/infra" + "owl-blogs/test" + "testing" + + "github.com/stretchr/testify/require" +) + +func setupSiteConfigRepo() repository.SiteConfigRepository { + db := test.NewMockDb() + repo := infra.NewSiteConfigRepo(db) + return repo +} + +func TestSiteConfigRepo(t *testing.T) { + repo := setupSiteConfigRepo() + + config, err := repo.Get() + require.NoError(t, err) + require.Equal(t, "", config.Title) + require.Equal(t, "", config.SubTitle) + + config.Title = "title" + config.SubTitle = "SubTitle" + + err = repo.Update(config) + require.NoError(t, err) + + config2, err := repo.Get() + require.NoError(t, err) + require.Equal(t, "title", config2.Title) + require.Equal(t, "SubTitle", config2.SubTitle) +} + +func TestSiteConfigUpdates(t *testing.T) { + repo := setupSiteConfigRepo() + + config, err := repo.Get() + require.NoError(t, err) + require.Equal(t, "", config.Title) + require.Equal(t, "", config.SubTitle) + + config.Title = "title" + config.SubTitle = "SubTitle" + + err = repo.Update(config) + require.NoError(t, err) + + config2, err := repo.Get() + require.NoError(t, err) + require.Equal(t, "title", config2.Title) + require.Equal(t, "SubTitle", config2.SubTitle) + + config2.Title = "title2" + config2.SubTitle = "SubTitle2" + + err = repo.Update(config2) + require.NoError(t, err) + + config3, err := repo.Get() + require.NoError(t, err) + require.Equal(t, "title2", config3.Title) + require.Equal(t, "SubTitle2", config3.SubTitle) + +} From cecd94b296bf0d90ae03b2d98a48892ff77f1e0b Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Sun, 16 Jul 2023 21:48:39 +0200 Subject: [PATCH 30/41] WIP site config + copy existing design --- app/author_service.go | 22 ++++-- app/author_service_test.go | 17 +++-- app/utils.go | 15 ++++ cmd/owl/editor_test.go | 6 +- cmd/owl/import_v1.go | 23 ++++--- cmd/owl/main.go | 18 +++-- config/config.go | 10 +-- domain/model/siteconfig.go | 1 + {domain/model => entry_types}/article.go | 9 +-- {domain/model => entry_types}/image.go | 9 +-- {domain/model => entry_types}/note.go | 10 +-- {domain/model => entry_types}/page.go | 9 +-- {domain/model => entry_types}/recipe.go | 9 +-- render/templates.go | 11 ++- render/templates/base.tmpl | 87 ++++++++++++++++++++++-- render/templates/views/editor.tmpl | 11 +++ web/editor_handler.go | 3 +- 17 files changed, 201 insertions(+), 69 deletions(-) create mode 100644 app/utils.go rename {domain/model => entry_types}/article.go (78%) rename {domain/model => entry_types}/image.go (79%) rename {domain/model => entry_types}/note.go (67%) rename {domain/model => entry_types}/page.go (78%) rename {domain/model => entry_types}/recipe.go (82%) create mode 100644 render/templates/views/editor.tmpl diff --git a/app/author_service.go b/app/author_service.go index 64257b0..835749a 100644 --- a/app/author_service.go +++ b/app/author_service.go @@ -4,7 +4,6 @@ import ( "crypto/sha256" "fmt" "owl-blogs/app/repository" - "owl-blogs/config" "owl-blogs/domain/model" "strings" @@ -12,12 +11,12 @@ import ( ) type AuthorService struct { - repo repository.AuthorRepository - config config.Config + repo repository.AuthorRepository + siteConfigRepo repository.SiteConfigRepository } -func NewAuthorService(repo repository.AuthorRepository, config config.Config) *AuthorService { - return &AuthorService{repo: repo, config: config} +func NewAuthorService(repo repository.AuthorRepository, siteConfigRepo repository.SiteConfigRepository) *AuthorService { + return &AuthorService{repo: repo, siteConfigRepo: siteConfigRepo} } func hashPassword(password string) (string, error) { @@ -50,7 +49,18 @@ func (s *AuthorService) Authenticate(name string, password string) bool { } func (s *AuthorService) getSecretKey() string { - return s.config.SECRET_KEY() + config, err := s.siteConfigRepo.Get() + if err != nil { + panic(err) + } + if config.Secret == "" { + config.Secret = RandStringRunes(64) + err = s.siteConfigRepo.Update(config) + if err != nil { + panic(err) + } + } + return config.Secret } func (s *AuthorService) CreateToken(name string) (string, error) { diff --git a/app/author_service_test.go b/app/author_service_test.go index 2b419f4..409ea49 100644 --- a/app/author_service_test.go +++ b/app/author_service_test.go @@ -2,6 +2,7 @@ package app_test import ( "owl-blogs/app" + "owl-blogs/domain/model" "owl-blogs/infra" "owl-blogs/test" "strings" @@ -10,17 +11,25 @@ import ( "github.com/stretchr/testify/require" ) -type testConfig struct { +type testConfigRepo struct { + config model.SiteConfig } -func (c *testConfig) SECRET_KEY() string { - return "test" +// Get implements repository.SiteConfigRepository. +func (c *testConfigRepo) Get() (model.SiteConfig, error) { + return c.config, nil +} + +// Update implements repository.SiteConfigRepository. +func (c *testConfigRepo) Update(siteConfig model.SiteConfig) error { + c.config = siteConfig + return nil } func getAutherService() *app.AuthorService { db := test.NewMockDb() authorRepo := infra.NewDefaultAuthorRepo(db) - authorService := app.NewAuthorService(authorRepo, &testConfig{}) + authorService := app.NewAuthorService(authorRepo, &testConfigRepo{}) return authorService } diff --git a/app/utils.go b/app/utils.go new file mode 100644 index 0000000..2797526 --- /dev/null +++ b/app/utils.go @@ -0,0 +1,15 @@ +package app + +import ( + "math/rand" +) + +var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + +func RandStringRunes(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return string(b) +} diff --git a/cmd/owl/editor_test.go b/cmd/owl/editor_test.go index 2639cc2..df697fe 100644 --- a/cmd/owl/editor_test.go +++ b/cmd/owl/editor_test.go @@ -9,7 +9,7 @@ import ( "net/http/httptest" "os" "owl-blogs/app" - "owl-blogs/domain/model" + entrytypes "owl-blogs/entry_types" "owl-blogs/infra" "owl-blogs/test" "path" @@ -94,8 +94,8 @@ func TestEditorFormPost(t *testing.T) { id := strings.Split(resp.Header.Get("Location"), "/")[2] entry, err := repo.FindById(id) require.NoError(t, err) - require.Equal(t, "test content", entry.MetaData().(*model.ImageMetaData).Content) - imageId := entry.MetaData().(*model.ImageMetaData).ImageId + require.Equal(t, "test content", entry.MetaData().(*entrytypes.ImageMetaData).Content) + imageId := entry.MetaData().(*entrytypes.ImageMetaData).ImageId require.NotZero(t, imageId) bin, err := binRepo.FindById(imageId) require.NoError(t, err) diff --git a/cmd/owl/import_v1.go b/cmd/owl/import_v1.go index 1eb68a1..f5004b0 100644 --- a/cmd/owl/import_v1.go +++ b/cmd/owl/import_v1.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "owl-blogs/domain/model" + entrytypes "owl-blogs/entry_types" "owl-blogs/importer" "owl-blogs/infra" "path" @@ -49,7 +50,7 @@ var importCmd = &cobra.Command{ files := importer.ListDir(mediaDir) for _, file := range files { // mock entry to pass to binary service - entry := &model.Article{} + entry := &entrytypes.Article{} entry.SetID(post.Id) fileData, err := os.ReadFile(path.Join(mediaDir, file)) @@ -63,10 +64,10 @@ var importCmd = &cobra.Command{ switch post.Meta.Type { case "article": - entry = &model.Article{} + entry = &entrytypes.Article{} entry.SetID(post.Id) entry.SetPublishedAt(&post.Meta.Date) - entry.SetMetaData(&model.ArticleMetaData{ + entry.SetMetaData(&entrytypes.ArticleMetaData{ Title: post.Meta.Title, Content: post.Content, }) @@ -75,26 +76,26 @@ var importCmd = &cobra.Command{ case "reply": case "photo": - entry = &model.Image{} + entry = &entrytypes.Image{} entry.SetID(post.Id) entry.SetPublishedAt(&post.Meta.Date) - entry.SetMetaData(&model.ImageMetaData{ + entry.SetMetaData(&entrytypes.ImageMetaData{ Title: post.Meta.Title, Content: post.Content, ImageId: post.Meta.PhotoPath, }) case "note": - entry = &model.Note{} + entry = &entrytypes.Note{} entry.SetID(post.Id) entry.SetPublishedAt(&post.Meta.Date) - entry.SetMetaData(&model.NoteMetaData{ + entry.SetMetaData(&entrytypes.NoteMetaData{ Content: post.Content, }) case "recipe": - entry = &model.Recipe{} + entry = &entrytypes.Recipe{} entry.SetID(post.Id) entry.SetPublishedAt(&post.Meta.Date) - entry.SetMetaData(&model.RecipeMetaData{ + entry.SetMetaData(&entrytypes.RecipeMetaData{ Title: post.Meta.Title, Yield: post.Meta.Recipe.Yield, Duration: post.Meta.Recipe.Duration, @@ -102,10 +103,10 @@ var importCmd = &cobra.Command{ Content: post.Content, }) case "page": - entry = &model.Page{} + entry = &entrytypes.Page{} entry.SetID(post.Id) entry.SetPublishedAt(&post.Meta.Date) - entry.SetMetaData(&model.PageMetaData{ + entry.SetMetaData(&entrytypes.PageMetaData{ Title: post.Meta.Title, Content: post.Content, }) diff --git a/cmd/owl/main.go b/cmd/owl/main.go index 4047ea0..5d43205 100644 --- a/cmd/owl/main.go +++ b/cmd/owl/main.go @@ -4,8 +4,7 @@ import ( "fmt" "os" "owl-blogs/app" - "owl-blogs/config" - "owl-blogs/domain/model" + entrytypes "owl-blogs/entry_types" "owl-blogs/infra" "owl-blogs/web" @@ -27,22 +26,21 @@ func Execute() { } func App(db infra.Database) *web.WebApp { - config := config.NewConfig() - registry := app.NewEntryTypeRegistry() - registry.Register(&model.Image{}) - registry.Register(&model.Article{}) - registry.Register(&model.Page{}) - registry.Register(&model.Recipe{}) - registry.Register(&model.Note{}) + registry.Register(&entrytypes.Image{}) + registry.Register(&entrytypes.Article{}) + registry.Register(&entrytypes.Page{}) + registry.Register(&entrytypes.Recipe{}) + registry.Register(&entrytypes.Note{}) entryRepo := infra.NewEntryRepository(db, registry) binRepo := infra.NewBinaryFileRepo(db) authorRepo := infra.NewDefaultAuthorRepo(db) + siteConfigRepo := infra.NewSiteConfigRepo(db) entryService := app.NewEntryService(entryRepo) binaryService := app.NewBinaryFileService(binRepo) - authorService := app.NewAuthorService(authorRepo, config) + authorService := app.NewAuthorService(authorRepo, siteConfigRepo) return web.NewWebApp(entryService, registry, binaryService, authorService) diff --git a/config/config.go b/config/config.go index 03ae7c8..1d0eedb 100644 --- a/config/config.go +++ b/config/config.go @@ -3,11 +3,9 @@ package config import "os" type Config interface { - SECRET_KEY() string } type EnvConfig struct { - secretKey string } func getEnvOrPanic(key string) string { @@ -19,11 +17,5 @@ func getEnvOrPanic(key string) string { } func NewConfig() Config { - return &EnvConfig{ - secretKey: getEnvOrPanic("OWL_SECRET_KEY"), - } -} - -func (c *EnvConfig) SECRET_KEY() string { - return c.secretKey + return &EnvConfig{} } diff --git a/domain/model/siteconfig.go b/domain/model/siteconfig.go index 72fc5b4..b705e8b 100644 --- a/domain/model/siteconfig.go +++ b/domain/model/siteconfig.go @@ -30,4 +30,5 @@ type SiteConfig struct { HeaderMenu []MenuItem FooterMenu []MenuItem Secret string + AvatarUrl string } diff --git a/domain/model/article.go b/entry_types/article.go similarity index 78% rename from domain/model/article.go rename to entry_types/article.go index b07120f..9849a24 100644 --- a/domain/model/article.go +++ b/entry_types/article.go @@ -1,12 +1,13 @@ -package model +package entrytypes import ( "fmt" + "owl-blogs/domain/model" "owl-blogs/render" ) type Article struct { - EntryBase + model.EntryBase meta ArticleMetaData } @@ -19,12 +20,12 @@ func (e *Article) Title() string { return e.meta.Title } -func (e *Article) Content() EntryContent { +func (e *Article) Content() model.EntryContent { str, err := render.RenderTemplateToString("entry/Article", e) if err != nil { fmt.Println(err) } - return EntryContent(str) + return model.EntryContent(str) } func (e *Article) MetaData() interface{} { diff --git a/domain/model/image.go b/entry_types/image.go similarity index 79% rename from domain/model/image.go rename to entry_types/image.go index cc77ca5..bd23e37 100644 --- a/domain/model/image.go +++ b/entry_types/image.go @@ -1,12 +1,13 @@ -package model +package entrytypes import ( "fmt" + "owl-blogs/domain/model" "owl-blogs/render" ) type Image struct { - EntryBase + model.EntryBase meta ImageMetaData } @@ -20,12 +21,12 @@ func (e *Image) Title() string { return e.meta.Title } -func (e *Image) Content() EntryContent { +func (e *Image) Content() model.EntryContent { str, err := render.RenderTemplateToString("entry/Image", e) if err != nil { fmt.Println(err) } - return EntryContent(str) + return model.EntryContent(str) } func (e *Image) MetaData() interface{} { diff --git a/domain/model/note.go b/entry_types/note.go similarity index 67% rename from domain/model/note.go rename to entry_types/note.go index f2ed50b..f7fb87d 100644 --- a/domain/model/note.go +++ b/entry_types/note.go @@ -1,7 +1,9 @@ -package model +package entrytypes + +import "owl-blogs/domain/model" type Note struct { - EntryBase + model.EntryBase meta NoteMetaData } @@ -13,8 +15,8 @@ func (e *Note) Title() string { return "" } -func (e *Note) Content() EntryContent { - return EntryContent(e.meta.Content) +func (e *Note) Content() model.EntryContent { + return model.EntryContent(e.meta.Content) } func (e *Note) MetaData() interface{} { diff --git a/domain/model/page.go b/entry_types/page.go similarity index 78% rename from domain/model/page.go rename to entry_types/page.go index 385d864..78faece 100644 --- a/domain/model/page.go +++ b/entry_types/page.go @@ -1,12 +1,13 @@ -package model +package entrytypes import ( "fmt" + "owl-blogs/domain/model" "owl-blogs/render" ) type Page struct { - EntryBase + model.EntryBase meta PageMetaData } @@ -19,12 +20,12 @@ func (e *Page) Title() string { return e.meta.Title } -func (e *Page) Content() EntryContent { +func (e *Page) Content() model.EntryContent { str, err := render.RenderTemplateToString("entry/Page", e) if err != nil { fmt.Println(err) } - return EntryContent(str) + return model.EntryContent(str) } func (e *Page) MetaData() interface{} { diff --git a/domain/model/recipe.go b/entry_types/recipe.go similarity index 82% rename from domain/model/recipe.go rename to entry_types/recipe.go index ff83edb..20b0a87 100644 --- a/domain/model/recipe.go +++ b/entry_types/recipe.go @@ -1,12 +1,13 @@ -package model +package entrytypes import ( "fmt" + "owl-blogs/domain/model" "owl-blogs/render" ) type Recipe struct { - EntryBase + model.EntryBase meta RecipeMetaData } @@ -22,12 +23,12 @@ func (e *Recipe) Title() string { return e.meta.Title } -func (e *Recipe) Content() EntryContent { +func (e *Recipe) Content() model.EntryContent { str, err := render.RenderTemplateToString("entry/Recipe", e) if err != nil { fmt.Println(err) } - return EntryContent(str) + return model.EntryContent(str) } func (e *Recipe) MetaData() interface{} { diff --git a/render/templates.go b/render/templates.go index a9a88ce..b9e5c87 100644 --- a/render/templates.go +++ b/render/templates.go @@ -4,6 +4,7 @@ import ( "bytes" "embed" "io" + "owl-blogs/domain/model" "text/template" "github.com/yuin/goldmark" @@ -12,6 +13,11 @@ import ( "github.com/yuin/goldmark/renderer/html" ) +type TemplateData struct { + Data interface{} + SiteConfig model.SiteConfig +} + //go:embed templates var templates embed.FS @@ -42,7 +48,10 @@ func RenderTemplateWithBase(w io.Writer, templateName string, data interface{}) return err } - err = t.ExecuteTemplate(w, "base", data) + err = t.ExecuteTemplate(w, "base", TemplateData{ + Data: data, + SiteConfig: model.SiteConfig{}, + }) return err diff --git a/render/templates/base.tmpl b/render/templates/base.tmpl index 07f37c2..ba79a5f 100644 --- a/render/templates/base.tmpl +++ b/render/templates/base.tmpl @@ -3,15 +3,94 @@ - {{template "title" .}} - Owl Blog + + {{template "title" .Data}} - {{ .SiteConfig.Title }} + + + -
      - Owl Blog +
      +
      +
      +

      {{ .SiteConfig.Title }}

      +

      {{ .SiteConfig.SubTitle }}

      +
      + +
      + {{ if .SiteConfig.AvatarUrl }} + + {{ end }} +
      + {{ range $me := .SiteConfig.Me }} +
    • {{$me.Name}} +
    • + {{ end }} +
      +
      +
      +
      + +
      - {{template "main" .}} + {{template "main" .Data}}
      Powered by Go diff --git a/render/templates/views/editor.tmpl b/render/templates/views/editor.tmpl new file mode 100644 index 0000000..4a9f3fa --- /dev/null +++ b/render/templates/views/editor.tmpl @@ -0,0 +1,11 @@ +{{define "title"}}Editor{{end}} + +{{define "main"}} + +Back +
      +
      + +{{.}} + +{{end}} \ No newline at end of file diff --git a/web/editor_handler.go b/web/editor_handler.go index 1075681..7694f3b 100644 --- a/web/editor_handler.go +++ b/web/editor_handler.go @@ -3,6 +3,7 @@ package web import ( "owl-blogs/app" "owl-blogs/domain/model" + "owl-blogs/render" "owl-blogs/web/editor" "time" @@ -49,7 +50,7 @@ func (h *EditorHandler) HandleGet(c *fiber.Ctx) error { if err != nil { return err } - return c.SendString(htmlForm) + return render.RenderTemplateWithBase(c, "views/editor", htmlForm) } func (h *EditorHandler) HandlePost(c *fiber.Ctx) error { From e91128fd7e3d7696f932065bbf1257a45257b1c5 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Mon, 17 Jul 2023 20:44:59 +0200 Subject: [PATCH 31/41] basic site config editing --- cmd/owl/main.go | 2 +- render/templates.go | 4 +- render/templates/base.tmpl | 2 +- render/templates/views/site_config.tmpl | 25 ++++++++++++ web/app.go | 40 +++++++++++-------- web/editor_handler.go | 18 +++++---- web/editor_list_handler.go | 12 ++++-- web/entry_handler.go | 24 +++++++++--- web/index_handler.go | 16 ++++++-- web/login_handler.go | 14 +++++-- web/siteconfig_handler.go | 51 +++++++++++++++++++++++++ web/utils.go | 14 +++++++ 12 files changed, 179 insertions(+), 43 deletions(-) create mode 100644 render/templates/views/site_config.tmpl create mode 100644 web/siteconfig_handler.go create mode 100644 web/utils.go diff --git a/cmd/owl/main.go b/cmd/owl/main.go index 5d43205..909cb63 100644 --- a/cmd/owl/main.go +++ b/cmd/owl/main.go @@ -42,7 +42,7 @@ func App(db infra.Database) *web.WebApp { binaryService := app.NewBinaryFileService(binRepo) authorService := app.NewAuthorService(authorRepo, siteConfigRepo) - return web.NewWebApp(entryService, registry, binaryService, authorService) + return web.NewWebApp(entryService, registry, binaryService, authorService, siteConfigRepo) } diff --git a/render/templates.go b/render/templates.go index b9e5c87..9cc9d55 100644 --- a/render/templates.go +++ b/render/templates.go @@ -40,7 +40,7 @@ func CreateTemplateWithBase(templateName string) (*template.Template, error) { ) } -func RenderTemplateWithBase(w io.Writer, templateName string, data interface{}) error { +func RenderTemplateWithBase(w io.Writer, siteConfig model.SiteConfig, templateName string, data interface{}) error { t, err := CreateTemplateWithBase(templateName) @@ -50,7 +50,7 @@ func RenderTemplateWithBase(w io.Writer, templateName string, data interface{}) err = t.ExecuteTemplate(w, "base", TemplateData{ Data: data, - SiteConfig: model.SiteConfig{}, + SiteConfig: siteConfig, }) return err diff --git a/render/templates/base.tmpl b/render/templates/base.tmpl index ba79a5f..321926d 100644 --- a/render/templates/base.tmpl +++ b/render/templates/base.tmpl @@ -22,7 +22,7 @@ .avatar { float: left; margin-right: 1rem; - + border-radius: 50%; } .header { diff --git a/render/templates/views/site_config.tmpl b/render/templates/views/site_config.tmpl new file mode 100644 index 0000000..90806ac --- /dev/null +++ b/render/templates/views/site_config.tmpl @@ -0,0 +1,25 @@ +{{define "title"}}Editor{{end}} + +{{define "main"}} + +
      + + + + + + + + + + + + + + + + +
      + + +{{end}} \ No newline at end of file diff --git a/web/app.go b/web/app.go index 4eb3ef9..e02ef5a 100644 --- a/web/app.go +++ b/web/app.go @@ -4,6 +4,7 @@ import ( "embed" "net/http" "owl-blogs/app" + "owl-blogs/app/repository" "owl-blogs/web/middleware" "github.com/gofiber/fiber/v2" @@ -14,11 +15,12 @@ import ( var embedDirStatic embed.FS type WebApp struct { - FiberApp *fiber.App - EntryService *app.EntryService - BinaryService *app.BinaryService - Registry *app.EntryTypeRegistry - AuthorService *app.AuthorService + FiberApp *fiber.App + EntryService *app.EntryService + BinaryService *app.BinaryService + Registry *app.EntryTypeRegistry + AuthorService *app.AuthorService + SiteConfigRepo repository.SiteConfigRepository } func NewWebApp( @@ -26,17 +28,18 @@ func NewWebApp( typeRegistry *app.EntryTypeRegistry, binService *app.BinaryService, authorService *app.AuthorService, + siteConfigRepo repository.SiteConfigRepository, ) *WebApp { app := fiber.New() - indexHandler := NewIndexHandler(entryService) + indexHandler := NewIndexHandler(entryService, siteConfigRepo) listHandler := NewListHandler(entryService) - entryHandler := NewEntryHandler(entryService, typeRegistry, authorService) + entryHandler := NewEntryHandler(entryService, typeRegistry, authorService, siteConfigRepo) mediaHandler := NewMediaHandler(binService) rssHandler := NewRSSHandler(entryService) - loginHandler := NewLoginHandler(authorService) - editorListHandler := NewEditorListHandler(typeRegistry) - editorHandler := NewEditorHandler(entryService, typeRegistry, binService) + loginHandler := NewLoginHandler(authorService, siteConfigRepo) + editorListHandler := NewEditorListHandler(typeRegistry, siteConfigRepo) + editorHandler := NewEditorHandler(entryService, typeRegistry, binService, siteConfigRepo) // Login app.Get("/auth/login", loginHandler.HandleGet) @@ -49,6 +52,12 @@ func NewWebApp( editor.Get("/:editor/", editorHandler.HandleGet) editor.Post("/:editor/", editorHandler.HandlePost) + // SiteConfig + siteConfig := app.Group("/site-config") + siteConfig.Use(middleware.NewAuthMiddleware(authorService).Handle) + siteConfig.Get("/", NewSiteConfigHandler(siteConfigRepo).HandleGet) + siteConfig.Post("/", NewSiteConfigHandler(siteConfigRepo).HandlePost) + // app.Static("/static/*filepath", http.Dir(repo.StaticDir())) app.Use("/static", filesystem.New(filesystem.Config{ Root: http.FS(embedDirStatic), @@ -75,11 +84,12 @@ func NewWebApp( // app.Get("/.well-known/oauth-authorization-server", userAuthMetadataHandler(repo)) // app.NotFound = http.HandlerFunc(notFoundHandler(repo)) return &WebApp{ - FiberApp: app, - EntryService: entryService, - Registry: typeRegistry, - BinaryService: binService, - AuthorService: authorService, + FiberApp: app, + EntryService: entryService, + Registry: typeRegistry, + BinaryService: binService, + AuthorService: authorService, + SiteConfigRepo: siteConfigRepo, } } diff --git a/web/editor_handler.go b/web/editor_handler.go index 7694f3b..7335713 100644 --- a/web/editor_handler.go +++ b/web/editor_handler.go @@ -2,6 +2,7 @@ package web import ( "owl-blogs/app" + "owl-blogs/app/repository" "owl-blogs/domain/model" "owl-blogs/render" "owl-blogs/web/editor" @@ -11,20 +12,23 @@ import ( ) type EditorHandler struct { - entrySvc *app.EntryService - binSvc *app.BinaryService - registry *app.EntryTypeRegistry + configRepo repository.SiteConfigRepository + entrySvc *app.EntryService + binSvc *app.BinaryService + registry *app.EntryTypeRegistry } func NewEditorHandler( entryService *app.EntryService, registry *app.EntryTypeRegistry, binService *app.BinaryService, + configRepo repository.SiteConfigRepository, ) *EditorHandler { return &EditorHandler{ - entrySvc: entryService, - registry: registry, - binSvc: binService, + entrySvc: entryService, + registry: registry, + binSvc: binService, + configRepo: configRepo, } } @@ -50,7 +54,7 @@ func (h *EditorHandler) HandleGet(c *fiber.Ctx) error { if err != nil { return err } - return render.RenderTemplateWithBase(c, "views/editor", htmlForm) + return render.RenderTemplateWithBase(c, getConfig(h.configRepo), "views/editor", htmlForm) } func (h *EditorHandler) HandlePost(c *fiber.Ctx) error { diff --git a/web/editor_list_handler.go b/web/editor_list_handler.go index 771e6ad..8218b16 100644 --- a/web/editor_list_handler.go +++ b/web/editor_list_handler.go @@ -2,22 +2,26 @@ package web import ( "owl-blogs/app" + "owl-blogs/app/repository" "owl-blogs/render" "github.com/gofiber/fiber/v2" ) type EditorListHandler struct { - registry *app.EntryTypeRegistry + configRepo repository.SiteConfigRepository + registry *app.EntryTypeRegistry } type EditorListContext struct { Types []string } -func NewEditorListHandler(registry *app.EntryTypeRegistry) *EditorListHandler { +func NewEditorListHandler(registry *app.EntryTypeRegistry, + configRepo repository.SiteConfigRepository) *EditorListHandler { return &EditorListHandler{ - registry: registry, + registry: registry, + configRepo: configRepo, } } @@ -33,5 +37,5 @@ func (h *EditorListHandler) Handle(c *fiber.Ctx) error { typeNames = append(typeNames, name) } - return render.RenderTemplateWithBase(c, "views/editor_list", &EditorListContext{Types: typeNames}) + return render.RenderTemplateWithBase(c, getConfig(h.configRepo), "views/editor_list", &EditorListContext{Types: typeNames}) } diff --git a/web/entry_handler.go b/web/entry_handler.go index f8bd30c..4c7dd32 100644 --- a/web/entry_handler.go +++ b/web/entry_handler.go @@ -2,6 +2,7 @@ package web import ( "owl-blogs/app" + "owl-blogs/app/repository" "owl-blogs/domain/model" "owl-blogs/render" @@ -9,9 +10,10 @@ import ( ) type EntryHandler struct { - entrySvc *app.EntryService - authorSvc *app.AuthorService - registry *app.EntryTypeRegistry + configRepo repository.SiteConfigRepository + entrySvc *app.EntryService + authorSvc *app.AuthorService + registry *app.EntryTypeRegistry } type entryData struct { @@ -19,8 +21,18 @@ type entryData struct { Author *model.Author } -func NewEntryHandler(entryService *app.EntryService, registry *app.EntryTypeRegistry, authorService *app.AuthorService) *EntryHandler { - return &EntryHandler{entrySvc: entryService, authorSvc: authorService, registry: registry} +func NewEntryHandler( + entryService *app.EntryService, + registry *app.EntryTypeRegistry, + authorService *app.AuthorService, + configRepo repository.SiteConfigRepository, +) *EntryHandler { + return &EntryHandler{ + entrySvc: entryService, + authorSvc: authorService, + registry: registry, + configRepo: configRepo, + } } func (h *EntryHandler) Handle(c *fiber.Ctx) error { @@ -37,5 +49,5 @@ func (h *EntryHandler) Handle(c *fiber.Ctx) error { author = &model.Author{} } - return render.RenderTemplateWithBase(c, "views/entry", entryData{Entry: entry, Author: author}) + return render.RenderTemplateWithBase(c, getConfig(h.configRepo), "views/entry", entryData{Entry: entry, Author: author}) } diff --git a/web/index_handler.go b/web/index_handler.go index e7325ec..29b6ad8 100644 --- a/web/index_handler.go +++ b/web/index_handler.go @@ -2,6 +2,7 @@ package web import ( "owl-blogs/app" + "owl-blogs/app/repository" "owl-blogs/domain/model" "owl-blogs/render" "sort" @@ -11,11 +12,18 @@ import ( ) type IndexHandler struct { - entrySvc *app.EntryService + configRepo repository.SiteConfigRepository + entrySvc *app.EntryService } -func NewIndexHandler(entryService *app.EntryService) *IndexHandler { - return &IndexHandler{entrySvc: entryService} +func NewIndexHandler( + entryService *app.EntryService, + configRepo repository.SiteConfigRepository, +) *IndexHandler { + return &IndexHandler{ + entrySvc: entryService, + configRepo: configRepo, + } } type indexRenderData struct { @@ -65,7 +73,7 @@ func (h *IndexHandler) Handle(c *fiber.Ctx) error { return err } - return render.RenderTemplateWithBase(c, "views/index", indexRenderData{ + return render.RenderTemplateWithBase(c, getConfig(h.configRepo), "views/index", indexRenderData{ Entries: entries, Page: pageNum, NextPage: pageNum + 1, diff --git a/web/login_handler.go b/web/login_handler.go index 6ffe7a6..43262a3 100644 --- a/web/login_handler.go +++ b/web/login_handler.go @@ -2,6 +2,7 @@ package web import ( "owl-blogs/app" + "owl-blogs/app/repository" "owl-blogs/render" "time" @@ -9,16 +10,23 @@ import ( ) type LoginHandler struct { + configRepo repository.SiteConfigRepository authorService *app.AuthorService } -func NewLoginHandler(authorService *app.AuthorService) *LoginHandler { - return &LoginHandler{authorService: authorService} +func NewLoginHandler( + authorService *app.AuthorService, + configRepo repository.SiteConfigRepository, +) *LoginHandler { + return &LoginHandler{ + authorService: authorService, + configRepo: configRepo, + } } func (h *LoginHandler) HandleGet(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) - return render.RenderTemplateWithBase(c, "views/login", nil) + return render.RenderTemplateWithBase(c, getConfig(h.configRepo), "views/login", nil) } func (h *LoginHandler) HandlePost(c *fiber.Ctx) error { diff --git a/web/siteconfig_handler.go b/web/siteconfig_handler.go new file mode 100644 index 0000000..c3c3cbc --- /dev/null +++ b/web/siteconfig_handler.go @@ -0,0 +1,51 @@ +package web + +import ( + "owl-blogs/app/repository" + "owl-blogs/render" + + "github.com/gofiber/fiber/v2" +) + +type SiteConfigHandler struct { + siteConfigRepo repository.SiteConfigRepository +} + +func NewSiteConfigHandler(siteConfigRepo repository.SiteConfigRepository) *SiteConfigHandler { + return &SiteConfigHandler{ + siteConfigRepo: siteConfigRepo, + } +} + +func (h *SiteConfigHandler) HandleGet(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + config, err := h.siteConfigRepo.Get() + if err != nil { + return err + } + + return render.RenderTemplateWithBase(c, getConfig(h.siteConfigRepo), "views/site_config", config) +} + +func (h *SiteConfigHandler) HandlePost(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + config, err := h.siteConfigRepo.Get() + if err != nil { + return err + } + + config.Title = c.FormValue("Title") + config.SubTitle = c.FormValue("SubTitle") + config.HeaderColor = c.FormValue("HeaderColor") + config.AuthorName = c.FormValue("AuthorName") + config.AvatarUrl = c.FormValue("AvatarUrl") + + err = h.siteConfigRepo.Update(config) + if err != nil { + return err + } + + return c.Redirect("/site-config/") +} diff --git a/web/utils.go b/web/utils.go new file mode 100644 index 0000000..6967195 --- /dev/null +++ b/web/utils.go @@ -0,0 +1,14 @@ +package web + +import ( + "owl-blogs/app/repository" + "owl-blogs/domain/model" +) + +func getConfig(repo repository.SiteConfigRepository) model.SiteConfig { + config, err := repo.Get() + if err != nil { + panic(err) + } + return config +} From 5f897cb6774b3058ed218e7c38c8b0de0f0eefa8 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Mon, 17 Jul 2023 21:43:31 +0200 Subject: [PATCH 32/41] WIP site config forms --- render/templates/views/site_config.tmpl | 10 +- render/templates/views/site_config_list.tmpl | 58 ++++++++++ render/templates/views/site_config_me.tmpl | 43 ++++++++ web/app.go | 16 ++- web/siteconfig_list_handler.go | 106 +++++++++++++++++++ web/siteconfig_me_handler.go | 75 +++++++++++++ 6 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 render/templates/views/site_config_list.tmpl create mode 100644 render/templates/views/site_config_me.tmpl create mode 100644 web/siteconfig_list_handler.go create mode 100644 web/siteconfig_me_handler.go diff --git a/render/templates/views/site_config.tmpl b/render/templates/views/site_config.tmpl index 90806ac..c9a10ee 100644 --- a/render/templates/views/site_config.tmpl +++ b/render/templates/views/site_config.tmpl @@ -2,7 +2,15 @@ {{define "main"}} -
      + + +

      Site Settings

      + + diff --git a/render/templates/views/site_config_list.tmpl b/render/templates/views/site_config_list.tmpl new file mode 100644 index 0000000..3a3da65 --- /dev/null +++ b/render/templates/views/site_config_list.tmpl @@ -0,0 +1,58 @@ +{{define "title"}}Editor{{end}} + +{{define "main"}} + +

      Create a List

      + + + + + + + + + {{ range .Types}} + + + {{end}} + +
      +
      + + + + +
      + +

      Me Links

      + + + + + + + + + + + + + {{range $i, $l := .Lists}} + + + + + + + + {{end}} + +
      IdTitleIncludeListTypeActions
      {{$l.Id}}{{$l.Title}}{{$l.Include}}{{$l.ListType}} +
      + + +
      +
      + + +{{end}} \ No newline at end of file diff --git a/render/templates/views/site_config_me.tmpl b/render/templates/views/site_config_me.tmpl new file mode 100644 index 0000000..21037a7 --- /dev/null +++ b/render/templates/views/site_config_me.tmpl @@ -0,0 +1,43 @@ +{{define "title"}}Editor{{end}} + +{{define "main"}} + +

      Create a Me Link

      +
      + + + + + + + +
      + +

      Me Links

      + + + + + + + + + + + {{range $i, $a := .}} + + + + + + {{end}} + +
      NameURLActions
      {{.Name}}{{.Url}} +
      + + +
      +
      + + +{{end}} \ No newline at end of file diff --git a/web/app.go b/web/app.go index e02ef5a..b420b8a 100644 --- a/web/app.go +++ b/web/app.go @@ -55,8 +55,20 @@ func NewWebApp( // SiteConfig siteConfig := app.Group("/site-config") siteConfig.Use(middleware.NewAuthMiddleware(authorService).Handle) - siteConfig.Get("/", NewSiteConfigHandler(siteConfigRepo).HandleGet) - siteConfig.Post("/", NewSiteConfigHandler(siteConfigRepo).HandlePost) + + siteConfigHandler := NewSiteConfigHandler(siteConfigRepo) + siteConfig.Get("/", siteConfigHandler.HandleGet) + siteConfig.Post("/", siteConfigHandler.HandlePost) + + siteConfigMeHandler := NewSiteConfigMeHandler(siteConfigRepo) + siteConfig.Get("/me", siteConfigMeHandler.HandleGet) + siteConfig.Post("/me/create/", siteConfigMeHandler.HandleCreate) + siteConfig.Post("/me/delete/", siteConfigMeHandler.HandleDelete) + + siteConfigListHandler := NewSiteConfigListHandler(siteConfigRepo, typeRegistry) + siteConfig.Get("/lists", siteConfigListHandler.HandleGet) + siteConfig.Post("/lists/create/", siteConfigListHandler.HandleCreate) + siteConfig.Post("/lists/delete/", siteConfigListHandler.HandleDelete) // app.Static("/static/*filepath", http.Dir(repo.StaticDir())) app.Use("/static", filesystem.New(filesystem.Config{ diff --git a/web/siteconfig_list_handler.go b/web/siteconfig_list_handler.go new file mode 100644 index 0000000..d5f746e --- /dev/null +++ b/web/siteconfig_list_handler.go @@ -0,0 +1,106 @@ +package web + +import ( + "owl-blogs/app" + "owl-blogs/app/repository" + "owl-blogs/domain/model" + "owl-blogs/render" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +type SiteConfigListHandler struct { + siteConfigRepo repository.SiteConfigRepository + typeRegistry *app.EntryTypeRegistry +} + +type siteConfigListTemplateData struct { + Lists []model.EntryList + Types []string +} + +func NewSiteConfigListHandler( + siteConfigRepo repository.SiteConfigRepository, + typeRegistry *app.EntryTypeRegistry, +) *SiteConfigListHandler { + return &SiteConfigListHandler{ + siteConfigRepo: siteConfigRepo, + typeRegistry: typeRegistry, + } +} + +func (h *SiteConfigListHandler) HandleGet(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + config, err := h.siteConfigRepo.Get() + if err != nil { + return err + } + + types := make([]string, 0) + for _, t := range h.typeRegistry.Types() { + typeName, err := h.typeRegistry.TypeName(t) + if err != nil { + continue + } + types = append(types, typeName) + } + + return render.RenderTemplateWithBase( + c, getConfig(h.siteConfigRepo), "views/site_config_list", siteConfigListTemplateData{ + Lists: config.Lists, + Types: types, + }) +} + +func (h *SiteConfigListHandler) HandleCreate(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + config, err := h.siteConfigRepo.Get() + if err != nil { + return err + } + + form, err := c.MultipartForm() + if err != nil { + return err + } + + config.Lists = append(config.Lists, model.EntryList{ + Id: c.FormValue("Id"), + Title: c.FormValue("Title"), + Include: form.Value["Include"], + ListType: c.FormValue("ListType"), + }) + + err = h.siteConfigRepo.Update(config) + if err != nil { + return err + } + + return c.Redirect("/site-config/lists") +} + +func (h *SiteConfigListHandler) HandleDelete(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + config, err := h.siteConfigRepo.Get() + if err != nil { + return err + } + + id, err := strconv.Atoi(c.FormValue("idx")) + if err != nil { + return err + } + + config.Lists = append(config.Lists[:id], config.Lists[id+1:]...) + + err = h.siteConfigRepo.Update(config) + if err != nil { + return err + } + + return c.Redirect("/site-config/lists") +} diff --git a/web/siteconfig_me_handler.go b/web/siteconfig_me_handler.go new file mode 100644 index 0000000..3fdcf26 --- /dev/null +++ b/web/siteconfig_me_handler.go @@ -0,0 +1,75 @@ +package web + +import ( + "owl-blogs/app/repository" + "owl-blogs/domain/model" + "owl-blogs/render" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +type SiteConfigMeHandler struct { + siteConfigRepo repository.SiteConfigRepository +} + +func NewSiteConfigMeHandler(siteConfigRepo repository.SiteConfigRepository) *SiteConfigMeHandler { + return &SiteConfigMeHandler{ + siteConfigRepo: siteConfigRepo, + } +} + +func (h *SiteConfigMeHandler) HandleGet(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + config, err := h.siteConfigRepo.Get() + if err != nil { + return err + } + + return render.RenderTemplateWithBase( + c, getConfig(h.siteConfigRepo), "views/site_config_me", config.Me) +} + +func (h *SiteConfigMeHandler) HandleCreate(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + config, err := h.siteConfigRepo.Get() + if err != nil { + return err + } + + config.Me = append(config.Me, model.MeLinks{ + Name: c.FormValue("Name"), + Url: c.FormValue("Url"), + }) + + err = h.siteConfigRepo.Update(config) + if err != nil { + return err + } + + return c.Redirect("/site-config/me") +} + +func (h *SiteConfigMeHandler) HandleDelete(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + config, err := h.siteConfigRepo.Get() + if err != nil { + return err + } + + idx, err := strconv.Atoi(c.FormValue("idx")) + if err != nil { + return err + } + config.Me = append(config.Me[:idx], config.Me[idx+1:]...) + + err = h.siteConfigRepo.Update(config) + if err != nil { + return err + } + + return c.Redirect("/site-config/me") +} From abbbbb44024fe16c664c5e868e7b5f298df99201 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Tue, 18 Jul 2023 20:09:45 +0200 Subject: [PATCH 33/41] menu --- render/templates/views/site_config_menus.tmpl | 95 ++++++++++++++++++ web/app.go | 5 + web/siteconfig_menus_handler.go | 97 +++++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 render/templates/views/site_config_menus.tmpl create mode 100644 web/siteconfig_menus_handler.go diff --git a/render/templates/views/site_config_menus.tmpl b/render/templates/views/site_config_menus.tmpl new file mode 100644 index 0000000..b1caeb0 --- /dev/null +++ b/render/templates/views/site_config_menus.tmpl @@ -0,0 +1,95 @@ +{{define "title"}}Editor{{end}} + +{{define "main"}} + +

      Create a List

      +
      + + + + + + + + + + + + + + + + + + + +
      + +

      Header Menu

      + + + + + + + + + + + + + {{range $i, $l := .HeaderMenu}} + + + + + + + + {{end}} + +
      TitleListUrlPostActions
      {{$l.Title}}{{$l.List}}{{$l.Url}}{{$l.Post}} +
      + + + +
      +
      + + +

      Footer Menu

      + + + + + + + + + + + + + {{range $i, $l := .FooterMenu}} + + + + + + + + {{end}} + +
      TitleListUrlPostActions
      {{$l.Title}}{{$l.List}}{{$l.Url}}{{$l.Post}} +
      + + + +
      +
      + + +{{end}} \ No newline at end of file diff --git a/web/app.go b/web/app.go index b420b8a..0c12859 100644 --- a/web/app.go +++ b/web/app.go @@ -70,6 +70,11 @@ func NewWebApp( siteConfig.Post("/lists/create/", siteConfigListHandler.HandleCreate) siteConfig.Post("/lists/delete/", siteConfigListHandler.HandleDelete) + siteConfigMenusHandler := NewSiteConfigMenusHandler(siteConfigRepo) + siteConfig.Get("/menus", siteConfigMenusHandler.HandleGet) + siteConfig.Post("/menus/create/", siteConfigMenusHandler.HandleCreate) + siteConfig.Post("/menus/delete/", siteConfigMenusHandler.HandleDelete) + // app.Static("/static/*filepath", http.Dir(repo.StaticDir())) app.Use("/static", filesystem.New(filesystem.Config{ Root: http.FS(embedDirStatic), diff --git a/web/siteconfig_menus_handler.go b/web/siteconfig_menus_handler.go new file mode 100644 index 0000000..0236c9e --- /dev/null +++ b/web/siteconfig_menus_handler.go @@ -0,0 +1,97 @@ +package web + +import ( + "owl-blogs/app/repository" + "owl-blogs/domain/model" + "owl-blogs/render" + "strconv" + + "github.com/gofiber/fiber/v2" +) + +type SiteConfigMenusHandler struct { + siteConfigRepo repository.SiteConfigRepository +} + +type siteConfigMenusTemplateData struct { + HeaderMenu []model.MenuItem + FooterMenu []model.MenuItem +} + +func NewSiteConfigMenusHandler(siteConfigRepo repository.SiteConfigRepository) *SiteConfigMenusHandler { + return &SiteConfigMenusHandler{ + siteConfigRepo: siteConfigRepo, + } +} + +func (h *SiteConfigMenusHandler) HandleGet(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + config, err := h.siteConfigRepo.Get() + if err != nil { + return err + } + + return render.RenderTemplateWithBase( + c, getConfig(h.siteConfigRepo), "views/site_config_menus", siteConfigMenusTemplateData{ + HeaderMenu: config.HeaderMenu, + FooterMenu: config.FooterMenu, + }) +} + +func (h *SiteConfigMenusHandler) HandleCreate(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + config, err := h.siteConfigRepo.Get() + if err != nil { + return err + } + + menuItem := model.MenuItem{ + Title: c.FormValue("Title"), + List: c.FormValue("List"), + Url: c.FormValue("Url"), + Post: c.FormValue("Post"), + } + + if c.FormValue("menu") == "header" { + config.HeaderMenu = append(config.HeaderMenu, menuItem) + } else if c.FormValue("menu") == "footer" { + config.FooterMenu = append(config.FooterMenu, menuItem) + } + + err = h.siteConfigRepo.Update(config) + if err != nil { + return err + } + + return c.Redirect("/site-config/menus") +} + +func (h *SiteConfigMenusHandler) HandleDelete(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + config, err := h.siteConfigRepo.Get() + if err != nil { + return err + } + + menu := c.FormValue("menu") + idx, err := strconv.Atoi(c.FormValue("idx")) + if err != nil { + return err + } + + if menu == "header" { + config.HeaderMenu = append(config.HeaderMenu[:idx], config.HeaderMenu[idx+1:]...) + } else if menu == "footer" { + config.FooterMenu = append(config.FooterMenu[:idx], config.FooterMenu[idx+1:]...) + } + + err = h.siteConfigRepo.Update(config) + if err != nil { + return err + } + + return c.Redirect("/site-config/menus") +} From 485ccb9090e1f87f1f3d50101e4da7ca83e93758 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Tue, 18 Jul 2023 20:12:17 +0200 Subject: [PATCH 34/41] 'finished' admin pages --- render/templates/views/editor_list.tmpl | 12 ++++++++++++ render/templates/views/site_config.tmpl | 7 ++++++- render/templates/views/site_config_list.tmpl | 11 +++++++++++ render/templates/views/site_config_me.tmpl | 11 +++++++++++ render/templates/views/site_config_menus.tmpl | 11 +++++++++++ 5 files changed, 51 insertions(+), 1 deletion(-) diff --git a/render/templates/views/editor_list.tmpl b/render/templates/views/editor_list.tmpl index 4af68b6..2555fdc 100644 --- a/render/templates/views/editor_list.tmpl +++ b/render/templates/views/editor_list.tmpl @@ -2,6 +2,18 @@ {{define "main"}} + + +

      Editor List

        diff --git a/render/templates/views/site_config.tmpl b/render/templates/views/site_config.tmpl index c9a10ee..81da0a9 100644 --- a/render/templates/views/site_config.tmpl +++ b/render/templates/views/site_config.tmpl @@ -4,7 +4,12 @@ diff --git a/render/templates/views/site_config_list.tmpl b/render/templates/views/site_config_list.tmpl index 3a3da65..5d65823 100644 --- a/render/templates/views/site_config_list.tmpl +++ b/render/templates/views/site_config_list.tmpl @@ -2,6 +2,17 @@ {{define "main"}} + +

        Create a List

        diff --git a/render/templates/views/site_config_me.tmpl b/render/templates/views/site_config_me.tmpl index 21037a7..0c5a8d4 100644 --- a/render/templates/views/site_config_me.tmpl +++ b/render/templates/views/site_config_me.tmpl @@ -2,6 +2,17 @@ {{define "main"}} + +

        Create a Me Link

        diff --git a/render/templates/views/site_config_menus.tmpl b/render/templates/views/site_config_menus.tmpl index b1caeb0..1e1ccef 100644 --- a/render/templates/views/site_config_menus.tmpl +++ b/render/templates/views/site_config_menus.tmpl @@ -2,6 +2,17 @@ {{define "main"}} + +

        Create a List

        From cc5ecff2f0a85f91a90932bfaf2f195c58995496 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Tue, 18 Jul 2023 21:03:33 +0200 Subject: [PATCH 35/41] full import --- cmd/owl/import_v1.go | 81 +++++++++++++++++++++++++++- cmd/owl/main.go | 2 + entry_types/bookmark.go | 38 +++++++++++++ entry_types/reply.go | 38 +++++++++++++ importer/config.go | 33 ++++++++++++ importer/utils.go | 34 ++++++++++++ render/templates/base.tmpl | 23 +++++++- render/templates/entry/Bookmark.tmpl | 3 ++ render/templates/entry/Reply.tmpl | 3 ++ 9 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 entry_types/bookmark.go create mode 100644 entry_types/reply.go create mode 100644 importer/config.go create mode 100644 render/templates/entry/Bookmark.tmpl create mode 100644 render/templates/entry/Reply.tmpl diff --git a/cmd/owl/import_v1.go b/cmd/owl/import_v1.go index f5004b0..ee811ba 100644 --- a/cmd/owl/import_v1.go +++ b/cmd/owl/import_v1.go @@ -10,6 +10,7 @@ import ( "path" "github.com/spf13/cobra" + "gopkg.in/yaml.v2" ) var userPath string @@ -37,6 +38,68 @@ var importCmd = &cobra.Command{ panic(err) } + // import config + bytes, err := os.ReadFile(path.Join(userPath, "meta/config.yml")) + if err != nil { + panic(err) + } + v1Config := importer.V1UserConfig{} + yaml.Unmarshal(bytes, &v1Config) + + mes := []model.MeLinks{} + for _, me := range v1Config.Me { + mes = append(mes, model.MeLinks{ + Name: me.Name, + Url: me.Url, + }) + } + + lists := []model.EntryList{} + for _, list := range v1Config.Lists { + lists = append(lists, model.EntryList{ + Id: list.Id, + Title: list.Title, + Include: importer.ConvertTypeList(list.Include, app.Registry), + ListType: list.ListType, + }) + } + + headerMenu := []model.MenuItem{} + for _, item := range v1Config.HeaderMenu { + headerMenu = append(headerMenu, model.MenuItem{ + Title: item.Title, + List: item.List, + Url: item.Url, + Post: item.Post, + }) + } + + footerMenu := []model.MenuItem{} + for _, item := range v1Config.FooterMenu { + footerMenu = append(footerMenu, model.MenuItem{ + Title: item.Title, + List: item.List, + Url: item.Url, + Post: item.Post, + }) + } + + v2Config, _ := app.SiteConfigRepo.Get() + v2Config.Title = v1Config.Title + v2Config.SubTitle = v1Config.SubTitle + v2Config.HeaderColor = v1Config.HeaderColor + v2Config.AuthorName = v1Config.AuthorName + v2Config.Me = mes + v2Config.Lists = lists + v2Config.PrimaryListInclude = v1Config.PrimaryListInclude + v2Config.HeaderMenu = headerMenu + v2Config.FooterMenu = footerMenu + + err = app.SiteConfigRepo.Update(v2Config) + if err != nil { + panic(err) + } + for _, post := range posts { existing, _ := app.EntryService.FindById(post.Id) if existing != nil { @@ -72,9 +135,23 @@ var importCmd = &cobra.Command{ Content: post.Content, }) case "bookmark": - + entry = &entrytypes.Bookmark{} + entry.SetID(post.Id) + entry.SetPublishedAt(&post.Meta.Date) + entry.SetMetaData(&entrytypes.BookmarkMetaData{ + Url: post.Meta.Bookmark.Url, + Title: post.Meta.Bookmark.Text, + Content: post.Content, + }) case "reply": - + entry = &entrytypes.Reply{} + entry.SetID(post.Id) + entry.SetPublishedAt(&post.Meta.Date) + entry.SetMetaData(&entrytypes.ReplyMetaData{ + Url: post.Meta.Reply.Url, + Title: post.Meta.Reply.Text, + Content: post.Content, + }) case "photo": entry = &entrytypes.Image{} entry.SetID(post.Id) diff --git a/cmd/owl/main.go b/cmd/owl/main.go index 909cb63..0d6406b 100644 --- a/cmd/owl/main.go +++ b/cmd/owl/main.go @@ -32,6 +32,8 @@ func App(db infra.Database) *web.WebApp { registry.Register(&entrytypes.Page{}) registry.Register(&entrytypes.Recipe{}) registry.Register(&entrytypes.Note{}) + registry.Register(&entrytypes.Bookmark{}) + registry.Register(&entrytypes.Reply{}) entryRepo := infra.NewEntryRepository(db, registry) binRepo := infra.NewBinaryFileRepo(db) diff --git a/entry_types/bookmark.go b/entry_types/bookmark.go new file mode 100644 index 0000000..2002bd0 --- /dev/null +++ b/entry_types/bookmark.go @@ -0,0 +1,38 @@ +package entrytypes + +import ( + "fmt" + "owl-blogs/domain/model" + "owl-blogs/render" +) + +type Bookmark struct { + model.EntryBase + meta BookmarkMetaData +} + +type BookmarkMetaData struct { + Title string `owl:"inputType=text"` + Url string `owl:"inputType=text"` + Content string `owl:"inputType=text widget=textarea"` +} + +func (e *Bookmark) Title() string { + return e.meta.Title +} + +func (e *Bookmark) Content() model.EntryContent { + str, err := render.RenderTemplateToString("entry/Bookmark", e) + if err != nil { + fmt.Println(err) + } + return model.EntryContent(str) +} + +func (e *Bookmark) MetaData() interface{} { + return &e.meta +} + +func (e *Bookmark) SetMetaData(metaData interface{}) { + e.meta = *metaData.(*BookmarkMetaData) +} diff --git a/entry_types/reply.go b/entry_types/reply.go new file mode 100644 index 0000000..de2f751 --- /dev/null +++ b/entry_types/reply.go @@ -0,0 +1,38 @@ +package entrytypes + +import ( + "fmt" + "owl-blogs/domain/model" + "owl-blogs/render" +) + +type Reply struct { + model.EntryBase + meta ReplyMetaData +} + +type ReplyMetaData struct { + Title string `owl:"inputType=text"` + Url string `owl:"inputType=text"` + Content string `owl:"inputType=text widget=textarea"` +} + +func (e *Reply) Title() string { + return "Re: " + e.meta.Title +} + +func (e *Reply) Content() model.EntryContent { + str, err := render.RenderTemplateToString("entry/Reply", e) + if err != nil { + fmt.Println(err) + } + return model.EntryContent(str) +} + +func (e *Reply) MetaData() interface{} { + return &e.meta +} + +func (e *Reply) SetMetaData(metaData interface{}) { + e.meta = *metaData.(*ReplyMetaData) +} diff --git a/importer/config.go b/importer/config.go new file mode 100644 index 0000000..7fb861a --- /dev/null +++ b/importer/config.go @@ -0,0 +1,33 @@ +package importer + +type V1UserConfig struct { + Title string `yaml:"title"` + SubTitle string `yaml:"subtitle"` + HeaderColor string `yaml:"header_color"` + AuthorName string `yaml:"author_name"` + Me []V1UserMe `yaml:"me"` + PassworHash string `yaml:"password_hash"` + Lists []V1PostList `yaml:"lists"` + PrimaryListInclude []string `yaml:"primary_list_include"` + HeaderMenu []V1MenuItem `yaml:"header_menu"` + FooterMenu []V1MenuItem `yaml:"footer_menu"` +} + +type V1UserMe struct { + Name string `yaml:"name"` + Url string `yaml:"url"` +} + +type V1PostList struct { + Id string `yaml:"id"` + Title string `yaml:"title"` + Include []string `yaml:"include"` + ListType string `yaml:"list_type"` +} + +type V1MenuItem struct { + Title string `yaml:"title"` + List string `yaml:"list"` + Url string `yaml:"url"` + Post string `yaml:"post"` +} diff --git a/importer/utils.go b/importer/utils.go index e7fe2c1..7b77cb3 100644 --- a/importer/utils.go +++ b/importer/utils.go @@ -3,6 +3,8 @@ package importer import ( "bytes" "os" + "owl-blogs/app" + entrytypes "owl-blogs/entry_types" "path" "time" @@ -202,3 +204,35 @@ func fileExists(path string) bool { _, err := os.Stat(path) return err == nil } + +func ConvertTypeList(v1 []string, registry *app.EntryTypeRegistry) []string { + v2 := make([]string, len(v1)) + for i, v1Type := range v1 { + switch v1Type { + case "article": + name, _ := registry.TypeName(&entrytypes.Article{}) + v2[i] = name + case "bookmark": + name, _ := registry.TypeName(&entrytypes.Bookmark{}) + v2[i] = name + case "reply": + name, _ := registry.TypeName(&entrytypes.Reply{}) + v2[i] = name + case "photo": + name, _ := registry.TypeName(&entrytypes.Image{}) + v2[i] = name + case "note": + name, _ := registry.TypeName(&entrytypes.Note{}) + v2[i] = name + case "recipe": + name, _ := registry.TypeName(&entrytypes.Recipe{}) + v2[i] = name + case "page": + name, _ := registry.TypeName(&entrytypes.Page{}) + v2[i] = name + default: + v2[i] = v1Type + } + } + return v2 +} diff --git a/render/templates/base.tmpl b/render/templates/base.tmpl index 321926d..e85f769 100644 --- a/render/templates/base.tmpl +++ b/render/templates/base.tmpl @@ -85,6 +85,15 @@
        @@ -93,7 +102,19 @@ {{template "main" .Data}}
      diff --git a/render/templates/entry/Bookmark.tmpl b/render/templates/entry/Bookmark.tmpl new file mode 100644 index 0000000..9b3a887 --- /dev/null +++ b/render/templates/entry/Bookmark.tmpl @@ -0,0 +1,3 @@ +Bookmark: {{.MetaData.Url}} + +{{.MetaData.Content | markdown }} diff --git a/render/templates/entry/Reply.tmpl b/render/templates/entry/Reply.tmpl new file mode 100644 index 0000000..dfc5323 --- /dev/null +++ b/render/templates/entry/Reply.tmpl @@ -0,0 +1,3 @@ +Reply to: {{.MetaData.Url}} + +{{.MetaData.Content | markdown }} From 1bf817465cb6655bd1349e674ba125802241c70c Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Wed, 19 Jul 2023 19:57:22 +0200 Subject: [PATCH 36/41] WIP release --- Dockerfile | 33 +++++++++++++++++++++++++++++++++ release.sh | 2 ++ run_dev.sh | 6 ------ run_tests.sh | 6 ------ 4 files changed, 35 insertions(+), 12 deletions(-) create mode 100644 Dockerfile create mode 100755 release.sh delete mode 100755 run_dev.sh delete mode 100755 run_tests.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8d9b4c5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +## +## Build Container +## +FROM golang:1.20-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 +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"] \ No newline at end of file diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..5ffa699 --- /dev/null +++ b/release.sh @@ -0,0 +1,2 @@ +docker build . -t git.libove.org/h4kor/owl-blogs:$1 +docker push git.libove.org/h4kor/owl-blogs:$1 \ No newline at end of file diff --git a/run_dev.sh b/run_dev.sh deleted file mode 100755 index a5521cb..0000000 --- a/run_dev.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -set -e - -OWL_SECRET_KEY=test-secret-key \ -go run owl-blogs/cmd/owl web \ No newline at end of file diff --git a/run_tests.sh b/run_tests.sh deleted file mode 100755 index cb823ef..0000000 --- a/run_tests.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -set -e - -OWL_SECRET_KEY=test-secret-key \ -go test -v -coverprofile=coverage.out ./... From b488f9b0325b9d775ada9c7db6231ec6aa7a404b Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Wed, 19 Jul 2023 20:25:42 +0200 Subject: [PATCH 37/41] refactor site config repo to general config repo --- app/author_service.go | 16 ++--- app/author_service_test.go | 9 +-- app/repository/interfaces.go | 6 +- cmd/owl/import_v1.go | 9 ++- cmd/owl/main.go | 2 +- config/config.go | 4 ++ infra/config_repository.go | 60 ++++++++++++++++++ ...tory_test.go => config_repository_test.go} | 29 +++++---- infra/site_config_repository.go | 62 ------------------- web/app.go | 4 +- web/editor_handler.go | 6 +- web/editor_list_handler.go | 6 +- web/entry_handler.go | 6 +- web/index_handler.go | 6 +- web/login_handler.go | 6 +- web/siteconfig_handler.go | 27 ++++---- web/siteconfig_list_handler.go | 29 +++++---- web/siteconfig_me_handler.go | 27 +++++--- web/siteconfig_menus_handler.go | 35 ++++++----- web/utils.go | 8 ++- 20 files changed, 199 insertions(+), 158 deletions(-) create mode 100644 infra/config_repository.go rename infra/{site_config_repository_test.go => config_repository_test.go} (67%) delete mode 100644 infra/site_config_repository.go diff --git a/app/author_service.go b/app/author_service.go index 835749a..93d52c8 100644 --- a/app/author_service.go +++ b/app/author_service.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "fmt" "owl-blogs/app/repository" + "owl-blogs/config" "owl-blogs/domain/model" "strings" @@ -12,10 +13,10 @@ import ( type AuthorService struct { repo repository.AuthorRepository - siteConfigRepo repository.SiteConfigRepository + siteConfigRepo repository.ConfigRepository } -func NewAuthorService(repo repository.AuthorRepository, siteConfigRepo repository.SiteConfigRepository) *AuthorService { +func NewAuthorService(repo repository.AuthorRepository, siteConfigRepo repository.ConfigRepository) *AuthorService { return &AuthorService{repo: repo, siteConfigRepo: siteConfigRepo} } @@ -49,18 +50,19 @@ func (s *AuthorService) Authenticate(name string, password string) bool { } func (s *AuthorService) getSecretKey() string { - config, err := s.siteConfigRepo.Get() + siteConfig := model.SiteConfig{} + err := s.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) if err != nil { panic(err) } - if config.Secret == "" { - config.Secret = RandStringRunes(64) - err = s.siteConfigRepo.Update(config) + if siteConfig.Secret == "" { + siteConfig.Secret = RandStringRunes(64) + err = s.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) if err != nil { panic(err) } } - return config.Secret + return siteConfig.Secret } func (s *AuthorService) CreateToken(name string) (string, error) { diff --git a/app/author_service_test.go b/app/author_service_test.go index 409ea49..856a920 100644 --- a/app/author_service_test.go +++ b/app/author_service_test.go @@ -16,13 +16,14 @@ type testConfigRepo struct { } // Get implements repository.SiteConfigRepository. -func (c *testConfigRepo) Get() (model.SiteConfig, error) { - return c.config, nil +func (c *testConfigRepo) Get(name string, result interface{}) error { + *result.(*model.SiteConfig) = c.config + return nil } // Update implements repository.SiteConfigRepository. -func (c *testConfigRepo) Update(siteConfig model.SiteConfig) error { - c.config = siteConfig +func (c *testConfigRepo) Update(name string, result interface{}) error { + c.config = result.(model.SiteConfig) return nil } diff --git a/app/repository/interfaces.go b/app/repository/interfaces.go index d0294c4..f57c5b7 100644 --- a/app/repository/interfaces.go +++ b/app/repository/interfaces.go @@ -30,7 +30,7 @@ type AuthorRepository interface { FindByName(name string) (*model.Author, error) } -type SiteConfigRepository interface { - Get() (model.SiteConfig, error) - Update(siteConfig model.SiteConfig) error +type ConfigRepository interface { + Get(name string, config interface{}) error + Update(name string, siteConfig interface{}) error } diff --git a/cmd/owl/import_v1.go b/cmd/owl/import_v1.go index ee811ba..8604a49 100644 --- a/cmd/owl/import_v1.go +++ b/cmd/owl/import_v1.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "owl-blogs/config" "owl-blogs/domain/model" entrytypes "owl-blogs/entry_types" "owl-blogs/importer" @@ -84,7 +85,11 @@ var importCmd = &cobra.Command{ }) } - v2Config, _ := app.SiteConfigRepo.Get() + v2Config := &model.SiteConfig{} + err = app.SiteConfigRepo.Get(config.SITE_CONFIG, v2Config) + if err != nil { + panic(err) + } v2Config.Title = v1Config.Title v2Config.SubTitle = v1Config.SubTitle v2Config.HeaderColor = v1Config.HeaderColor @@ -95,7 +100,7 @@ var importCmd = &cobra.Command{ v2Config.HeaderMenu = headerMenu v2Config.FooterMenu = footerMenu - err = app.SiteConfigRepo.Update(v2Config) + err = app.SiteConfigRepo.Update(config.SITE_CONFIG, v2Config) if err != nil { panic(err) } diff --git a/cmd/owl/main.go b/cmd/owl/main.go index 0d6406b..3f244c3 100644 --- a/cmd/owl/main.go +++ b/cmd/owl/main.go @@ -38,7 +38,7 @@ func App(db infra.Database) *web.WebApp { entryRepo := infra.NewEntryRepository(db, registry) binRepo := infra.NewBinaryFileRepo(db) authorRepo := infra.NewDefaultAuthorRepo(db) - siteConfigRepo := infra.NewSiteConfigRepo(db) + siteConfigRepo := infra.NewConfigRepo(db) entryService := app.NewEntryService(entryRepo) binaryService := app.NewBinaryFileService(binRepo) diff --git a/config/config.go b/config/config.go index 1d0eedb..b7ce95d 100644 --- a/config/config.go +++ b/config/config.go @@ -2,6 +2,10 @@ package config import "os" +const ( + SITE_CONFIG = "site_config" +) + type Config interface { } diff --git a/infra/config_repository.go b/infra/config_repository.go new file mode 100644 index 0000000..c9e6e60 --- /dev/null +++ b/infra/config_repository.go @@ -0,0 +1,60 @@ +package infra + +import ( + "encoding/json" + "owl-blogs/app/repository" + + "github.com/jmoiron/sqlx" +) + +type DefaultConfigRepo struct { + db *sqlx.DB +} + +func NewConfigRepo(db Database) repository.ConfigRepository { + sqlxdb := db.Get() + + sqlxdb.MustExec(` + CREATE TABLE IF NOT EXISTS site_config ( + name TEXT PRIMARY KEY, + config TEXT + ); + `) + + return &DefaultConfigRepo{ + db: sqlxdb, + } +} + +// Get implements repository.SiteConfigRepository. +func (r *DefaultConfigRepo) Get(name string, result interface{}) error { + data := []byte{} + err := r.db.Get(&data, "SELECT config FROM site_config WHERE name = ?", name) + if err != nil { + if err.Error() == "sql: no rows in result set" { + return nil + } + return err + } + if len(data) == 0 { + return nil + } + return json.Unmarshal(data, result) +} + +// Update implements repository.SiteConfigRepository. +func (r *DefaultConfigRepo) Update(name string, siteConfig interface{}) error { + jsonData, err := json.Marshal(siteConfig) + if err != nil { + return err + } + res, err := r.db.Exec("UPDATE site_config SET config = ? WHERE name = ?", jsonData, name) + if err != nil { + return err + } + rows, err := res.RowsAffected() + if rows == 0 { + _, err = r.db.Exec("INSERT INTO site_config (name, config) VALUES (?, ?)", name, jsonData) + } + return err +} diff --git a/infra/site_config_repository_test.go b/infra/config_repository_test.go similarity index 67% rename from infra/site_config_repository_test.go rename to infra/config_repository_test.go index b16641c..db4e24e 100644 --- a/infra/site_config_repository_test.go +++ b/infra/config_repository_test.go @@ -2,6 +2,7 @@ package infra_test import ( "owl-blogs/app/repository" + "owl-blogs/domain/model" "owl-blogs/infra" "owl-blogs/test" "testing" @@ -9,16 +10,17 @@ import ( "github.com/stretchr/testify/require" ) -func setupSiteConfigRepo() repository.SiteConfigRepository { +func setupSiteConfigRepo() repository.ConfigRepository { db := test.NewMockDb() - repo := infra.NewSiteConfigRepo(db) + repo := infra.NewConfigRepo(db) return repo } func TestSiteConfigRepo(t *testing.T) { repo := setupSiteConfigRepo() - config, err := repo.Get() + config := model.SiteConfig{} + err := repo.Get("test", &config) require.NoError(t, err) require.Equal(t, "", config.Title) require.Equal(t, "", config.SubTitle) @@ -26,10 +28,11 @@ func TestSiteConfigRepo(t *testing.T) { config.Title = "title" config.SubTitle = "SubTitle" - err = repo.Update(config) + err = repo.Update("test", config) require.NoError(t, err) - config2, err := repo.Get() + config2 := model.SiteConfig{} + err = repo.Get("test", &config2) require.NoError(t, err) require.Equal(t, "title", config2.Title) require.Equal(t, "SubTitle", config2.SubTitle) @@ -37,8 +40,8 @@ func TestSiteConfigRepo(t *testing.T) { func TestSiteConfigUpdates(t *testing.T) { repo := setupSiteConfigRepo() - - config, err := repo.Get() + config := model.SiteConfig{} + err := repo.Get("test", &config) require.NoError(t, err) require.Equal(t, "", config.Title) require.Equal(t, "", config.SubTitle) @@ -46,10 +49,10 @@ func TestSiteConfigUpdates(t *testing.T) { config.Title = "title" config.SubTitle = "SubTitle" - err = repo.Update(config) + err = repo.Update("test", config) require.NoError(t, err) - - config2, err := repo.Get() + config2 := model.SiteConfig{} + err = repo.Get("test", &config2) require.NoError(t, err) require.Equal(t, "title", config2.Title) require.Equal(t, "SubTitle", config2.SubTitle) @@ -57,10 +60,10 @@ func TestSiteConfigUpdates(t *testing.T) { config2.Title = "title2" config2.SubTitle = "SubTitle2" - err = repo.Update(config2) + err = repo.Update("test", config2) require.NoError(t, err) - - config3, err := repo.Get() + config3 := model.SiteConfig{} + err = repo.Get("test", &config3) require.NoError(t, err) require.Equal(t, "title2", config3.Title) require.Equal(t, "SubTitle2", config3.SubTitle) diff --git a/infra/site_config_repository.go b/infra/site_config_repository.go deleted file mode 100644 index 0d302f3..0000000 --- a/infra/site_config_repository.go +++ /dev/null @@ -1,62 +0,0 @@ -package infra - -import ( - "encoding/json" - "owl-blogs/app/repository" - "owl-blogs/domain/model" - - "github.com/jmoiron/sqlx" -) - -type DefaultSiteConfigRepo struct { - db *sqlx.DB -} - -func NewSiteConfigRepo(db Database) repository.SiteConfigRepository { - sqlxdb := db.Get() - - sqlxdb.MustExec(` - CREATE TABLE IF NOT EXISTS site_config ( - config TEXT - ); - `) - - return &DefaultSiteConfigRepo{ - db: sqlxdb, - } -} - -// Get implements repository.SiteConfigRepository. -func (r *DefaultSiteConfigRepo) Get() (model.SiteConfig, error) { - data := []byte{} - err := r.db.Get(&data, "SELECT config FROM site_config LIMIT 1") - if err != nil { - if err.Error() == "sql: no rows in result set" { - return model.SiteConfig{}, nil - } - return model.SiteConfig{}, err - } - if len(data) == 0 { - return model.SiteConfig{}, nil - } - config := model.SiteConfig{} - err = json.Unmarshal(data, &config) - return config, err -} - -// Update implements repository.SiteConfigRepository. -func (r *DefaultSiteConfigRepo) Update(siteConfig model.SiteConfig) error { - jsonData, err := json.Marshal(siteConfig) - if err != nil { - return err - } - res, err := r.db.Exec("UPDATE site_config SET config = ?", jsonData) - if err != nil { - return err - } - rows, err := res.RowsAffected() - if rows == 0 { - _, err = r.db.Exec("INSERT INTO site_config (config) VALUES (?)", jsonData) - } - return err -} diff --git a/web/app.go b/web/app.go index 0c12859..3ec68fc 100644 --- a/web/app.go +++ b/web/app.go @@ -20,7 +20,7 @@ type WebApp struct { BinaryService *app.BinaryService Registry *app.EntryTypeRegistry AuthorService *app.AuthorService - SiteConfigRepo repository.SiteConfigRepository + SiteConfigRepo repository.ConfigRepository } func NewWebApp( @@ -28,7 +28,7 @@ func NewWebApp( typeRegistry *app.EntryTypeRegistry, binService *app.BinaryService, authorService *app.AuthorService, - siteConfigRepo repository.SiteConfigRepository, + siteConfigRepo repository.ConfigRepository, ) *WebApp { app := fiber.New() diff --git a/web/editor_handler.go b/web/editor_handler.go index 7335713..9c83504 100644 --- a/web/editor_handler.go +++ b/web/editor_handler.go @@ -12,7 +12,7 @@ import ( ) type EditorHandler struct { - configRepo repository.SiteConfigRepository + configRepo repository.ConfigRepository entrySvc *app.EntryService binSvc *app.BinaryService registry *app.EntryTypeRegistry @@ -22,7 +22,7 @@ func NewEditorHandler( entryService *app.EntryService, registry *app.EntryTypeRegistry, binService *app.BinaryService, - configRepo repository.SiteConfigRepository, + configRepo repository.ConfigRepository, ) *EditorHandler { return &EditorHandler{ entrySvc: entryService, @@ -54,7 +54,7 @@ func (h *EditorHandler) HandleGet(c *fiber.Ctx) error { if err != nil { return err } - return render.RenderTemplateWithBase(c, getConfig(h.configRepo), "views/editor", htmlForm) + return render.RenderTemplateWithBase(c, getSiteConfig(h.configRepo), "views/editor", htmlForm) } func (h *EditorHandler) HandlePost(c *fiber.Ctx) error { diff --git a/web/editor_list_handler.go b/web/editor_list_handler.go index 8218b16..dc66a0d 100644 --- a/web/editor_list_handler.go +++ b/web/editor_list_handler.go @@ -9,7 +9,7 @@ import ( ) type EditorListHandler struct { - configRepo repository.SiteConfigRepository + configRepo repository.ConfigRepository registry *app.EntryTypeRegistry } @@ -18,7 +18,7 @@ type EditorListContext struct { } func NewEditorListHandler(registry *app.EntryTypeRegistry, - configRepo repository.SiteConfigRepository) *EditorListHandler { + configRepo repository.ConfigRepository) *EditorListHandler { return &EditorListHandler{ registry: registry, configRepo: configRepo, @@ -37,5 +37,5 @@ func (h *EditorListHandler) Handle(c *fiber.Ctx) error { typeNames = append(typeNames, name) } - return render.RenderTemplateWithBase(c, getConfig(h.configRepo), "views/editor_list", &EditorListContext{Types: typeNames}) + return render.RenderTemplateWithBase(c, getSiteConfig(h.configRepo), "views/editor_list", &EditorListContext{Types: typeNames}) } diff --git a/web/entry_handler.go b/web/entry_handler.go index 4c7dd32..2a94070 100644 --- a/web/entry_handler.go +++ b/web/entry_handler.go @@ -10,7 +10,7 @@ import ( ) type EntryHandler struct { - configRepo repository.SiteConfigRepository + configRepo repository.ConfigRepository entrySvc *app.EntryService authorSvc *app.AuthorService registry *app.EntryTypeRegistry @@ -25,7 +25,7 @@ func NewEntryHandler( entryService *app.EntryService, registry *app.EntryTypeRegistry, authorService *app.AuthorService, - configRepo repository.SiteConfigRepository, + configRepo repository.ConfigRepository, ) *EntryHandler { return &EntryHandler{ entrySvc: entryService, @@ -49,5 +49,5 @@ func (h *EntryHandler) Handle(c *fiber.Ctx) error { author = &model.Author{} } - return render.RenderTemplateWithBase(c, getConfig(h.configRepo), "views/entry", entryData{Entry: entry, Author: author}) + return render.RenderTemplateWithBase(c, getSiteConfig(h.configRepo), "views/entry", entryData{Entry: entry, Author: author}) } diff --git a/web/index_handler.go b/web/index_handler.go index 29b6ad8..59551ba 100644 --- a/web/index_handler.go +++ b/web/index_handler.go @@ -12,13 +12,13 @@ import ( ) type IndexHandler struct { - configRepo repository.SiteConfigRepository + configRepo repository.ConfigRepository entrySvc *app.EntryService } func NewIndexHandler( entryService *app.EntryService, - configRepo repository.SiteConfigRepository, + configRepo repository.ConfigRepository, ) *IndexHandler { return &IndexHandler{ entrySvc: entryService, @@ -73,7 +73,7 @@ func (h *IndexHandler) Handle(c *fiber.Ctx) error { return err } - return render.RenderTemplateWithBase(c, getConfig(h.configRepo), "views/index", indexRenderData{ + return render.RenderTemplateWithBase(c, getSiteConfig(h.configRepo), "views/index", indexRenderData{ Entries: entries, Page: pageNum, NextPage: pageNum + 1, diff --git a/web/login_handler.go b/web/login_handler.go index 43262a3..11ae56d 100644 --- a/web/login_handler.go +++ b/web/login_handler.go @@ -10,13 +10,13 @@ import ( ) type LoginHandler struct { - configRepo repository.SiteConfigRepository + configRepo repository.ConfigRepository authorService *app.AuthorService } func NewLoginHandler( authorService *app.AuthorService, - configRepo repository.SiteConfigRepository, + configRepo repository.ConfigRepository, ) *LoginHandler { return &LoginHandler{ authorService: authorService, @@ -26,7 +26,7 @@ func NewLoginHandler( func (h *LoginHandler) HandleGet(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) - return render.RenderTemplateWithBase(c, getConfig(h.configRepo), "views/login", nil) + return render.RenderTemplateWithBase(c, getSiteConfig(h.configRepo), "views/login", nil) } func (h *LoginHandler) HandlePost(c *fiber.Ctx) error { diff --git a/web/siteconfig_handler.go b/web/siteconfig_handler.go index c3c3cbc..0e62e25 100644 --- a/web/siteconfig_handler.go +++ b/web/siteconfig_handler.go @@ -2,16 +2,18 @@ package web import ( "owl-blogs/app/repository" + "owl-blogs/config" + "owl-blogs/domain/model" "owl-blogs/render" "github.com/gofiber/fiber/v2" ) type SiteConfigHandler struct { - siteConfigRepo repository.SiteConfigRepository + siteConfigRepo repository.ConfigRepository } -func NewSiteConfigHandler(siteConfigRepo repository.SiteConfigRepository) *SiteConfigHandler { +func NewSiteConfigHandler(siteConfigRepo repository.ConfigRepository) *SiteConfigHandler { return &SiteConfigHandler{ siteConfigRepo: siteConfigRepo, } @@ -20,29 +22,32 @@ func NewSiteConfigHandler(siteConfigRepo repository.SiteConfigRepository) *SiteC func (h *SiteConfigHandler) HandleGet(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) - config, err := h.siteConfigRepo.Get() + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) if err != nil { return err } - return render.RenderTemplateWithBase(c, getConfig(h.siteConfigRepo), "views/site_config", config) + return render.RenderTemplateWithBase(c, getSiteConfig(h.siteConfigRepo), "views/site_config", siteConfig) } func (h *SiteConfigHandler) HandlePost(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) - config, err := h.siteConfigRepo.Get() + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + if err != nil { return err } - config.Title = c.FormValue("Title") - config.SubTitle = c.FormValue("SubTitle") - config.HeaderColor = c.FormValue("HeaderColor") - config.AuthorName = c.FormValue("AuthorName") - config.AvatarUrl = c.FormValue("AvatarUrl") + siteConfig.Title = c.FormValue("Title") + siteConfig.SubTitle = c.FormValue("SubTitle") + siteConfig.HeaderColor = c.FormValue("HeaderColor") + siteConfig.AuthorName = c.FormValue("AuthorName") + siteConfig.AvatarUrl = c.FormValue("AvatarUrl") - err = h.siteConfigRepo.Update(config) + err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) if err != nil { return err } diff --git a/web/siteconfig_list_handler.go b/web/siteconfig_list_handler.go index d5f746e..963e3e5 100644 --- a/web/siteconfig_list_handler.go +++ b/web/siteconfig_list_handler.go @@ -3,6 +3,7 @@ package web import ( "owl-blogs/app" "owl-blogs/app/repository" + "owl-blogs/config" "owl-blogs/domain/model" "owl-blogs/render" "strconv" @@ -11,7 +12,7 @@ import ( ) type SiteConfigListHandler struct { - siteConfigRepo repository.SiteConfigRepository + siteConfigRepo repository.ConfigRepository typeRegistry *app.EntryTypeRegistry } @@ -21,7 +22,7 @@ type siteConfigListTemplateData struct { } func NewSiteConfigListHandler( - siteConfigRepo repository.SiteConfigRepository, + siteConfigRepo repository.ConfigRepository, typeRegistry *app.EntryTypeRegistry, ) *SiteConfigListHandler { return &SiteConfigListHandler{ @@ -33,7 +34,9 @@ func NewSiteConfigListHandler( func (h *SiteConfigListHandler) HandleGet(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) - config, err := h.siteConfigRepo.Get() + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + if err != nil { return err } @@ -48,8 +51,8 @@ func (h *SiteConfigListHandler) HandleGet(c *fiber.Ctx) error { } return render.RenderTemplateWithBase( - c, getConfig(h.siteConfigRepo), "views/site_config_list", siteConfigListTemplateData{ - Lists: config.Lists, + c, getSiteConfig(h.siteConfigRepo), "views/site_config_list", siteConfigListTemplateData{ + Lists: siteConfig.Lists, Types: types, }) } @@ -57,7 +60,9 @@ func (h *SiteConfigListHandler) HandleGet(c *fiber.Ctx) error { func (h *SiteConfigListHandler) HandleCreate(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) - config, err := h.siteConfigRepo.Get() + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + if err != nil { return err } @@ -67,14 +72,14 @@ func (h *SiteConfigListHandler) HandleCreate(c *fiber.Ctx) error { return err } - config.Lists = append(config.Lists, model.EntryList{ + siteConfig.Lists = append(siteConfig.Lists, model.EntryList{ Id: c.FormValue("Id"), Title: c.FormValue("Title"), Include: form.Value["Include"], ListType: c.FormValue("ListType"), }) - err = h.siteConfigRepo.Update(config) + err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) if err != nil { return err } @@ -85,7 +90,9 @@ func (h *SiteConfigListHandler) HandleCreate(c *fiber.Ctx) error { func (h *SiteConfigListHandler) HandleDelete(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) - config, err := h.siteConfigRepo.Get() + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + if err != nil { return err } @@ -95,9 +102,9 @@ func (h *SiteConfigListHandler) HandleDelete(c *fiber.Ctx) error { return err } - config.Lists = append(config.Lists[:id], config.Lists[id+1:]...) + siteConfig.Lists = append(siteConfig.Lists[:id], siteConfig.Lists[id+1:]...) - err = h.siteConfigRepo.Update(config) + err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) if err != nil { return err } diff --git a/web/siteconfig_me_handler.go b/web/siteconfig_me_handler.go index 3fdcf26..3fdad35 100644 --- a/web/siteconfig_me_handler.go +++ b/web/siteconfig_me_handler.go @@ -2,6 +2,7 @@ package web import ( "owl-blogs/app/repository" + "owl-blogs/config" "owl-blogs/domain/model" "owl-blogs/render" "strconv" @@ -10,10 +11,10 @@ import ( ) type SiteConfigMeHandler struct { - siteConfigRepo repository.SiteConfigRepository + siteConfigRepo repository.ConfigRepository } -func NewSiteConfigMeHandler(siteConfigRepo repository.SiteConfigRepository) *SiteConfigMeHandler { +func NewSiteConfigMeHandler(siteConfigRepo repository.ConfigRepository) *SiteConfigMeHandler { return &SiteConfigMeHandler{ siteConfigRepo: siteConfigRepo, } @@ -22,29 +23,33 @@ func NewSiteConfigMeHandler(siteConfigRepo repository.SiteConfigRepository) *Sit func (h *SiteConfigMeHandler) HandleGet(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) - config, err := h.siteConfigRepo.Get() + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + if err != nil { return err } return render.RenderTemplateWithBase( - c, getConfig(h.siteConfigRepo), "views/site_config_me", config.Me) + c, getSiteConfig(h.siteConfigRepo), "views/site_config_me", siteConfig.Me) } func (h *SiteConfigMeHandler) HandleCreate(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) - config, err := h.siteConfigRepo.Get() + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + if err != nil { return err } - config.Me = append(config.Me, model.MeLinks{ + siteConfig.Me = append(siteConfig.Me, model.MeLinks{ Name: c.FormValue("Name"), Url: c.FormValue("Url"), }) - err = h.siteConfigRepo.Update(config) + err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) if err != nil { return err } @@ -55,7 +60,9 @@ func (h *SiteConfigMeHandler) HandleCreate(c *fiber.Ctx) error { func (h *SiteConfigMeHandler) HandleDelete(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) - config, err := h.siteConfigRepo.Get() + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + if err != nil { return err } @@ -64,9 +71,9 @@ func (h *SiteConfigMeHandler) HandleDelete(c *fiber.Ctx) error { if err != nil { return err } - config.Me = append(config.Me[:idx], config.Me[idx+1:]...) + siteConfig.Me = append(siteConfig.Me[:idx], siteConfig.Me[idx+1:]...) - err = h.siteConfigRepo.Update(config) + err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) if err != nil { return err } diff --git a/web/siteconfig_menus_handler.go b/web/siteconfig_menus_handler.go index 0236c9e..3babe6b 100644 --- a/web/siteconfig_menus_handler.go +++ b/web/siteconfig_menus_handler.go @@ -2,6 +2,7 @@ package web import ( "owl-blogs/app/repository" + "owl-blogs/config" "owl-blogs/domain/model" "owl-blogs/render" "strconv" @@ -10,7 +11,7 @@ import ( ) type SiteConfigMenusHandler struct { - siteConfigRepo repository.SiteConfigRepository + siteConfigRepo repository.ConfigRepository } type siteConfigMenusTemplateData struct { @@ -18,7 +19,7 @@ type siteConfigMenusTemplateData struct { FooterMenu []model.MenuItem } -func NewSiteConfigMenusHandler(siteConfigRepo repository.SiteConfigRepository) *SiteConfigMenusHandler { +func NewSiteConfigMenusHandler(siteConfigRepo repository.ConfigRepository) *SiteConfigMenusHandler { return &SiteConfigMenusHandler{ siteConfigRepo: siteConfigRepo, } @@ -27,22 +28,26 @@ func NewSiteConfigMenusHandler(siteConfigRepo repository.SiteConfigRepository) * func (h *SiteConfigMenusHandler) HandleGet(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) - config, err := h.siteConfigRepo.Get() + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + if err != nil { return err } return render.RenderTemplateWithBase( - c, getConfig(h.siteConfigRepo), "views/site_config_menus", siteConfigMenusTemplateData{ - HeaderMenu: config.HeaderMenu, - FooterMenu: config.FooterMenu, + c, getSiteConfig(h.siteConfigRepo), "views/site_config_menus", siteConfigMenusTemplateData{ + HeaderMenu: siteConfig.HeaderMenu, + FooterMenu: siteConfig.FooterMenu, }) } func (h *SiteConfigMenusHandler) HandleCreate(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) - config, err := h.siteConfigRepo.Get() + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + if err != nil { return err } @@ -55,12 +60,12 @@ func (h *SiteConfigMenusHandler) HandleCreate(c *fiber.Ctx) error { } if c.FormValue("menu") == "header" { - config.HeaderMenu = append(config.HeaderMenu, menuItem) + siteConfig.HeaderMenu = append(siteConfig.HeaderMenu, menuItem) } else if c.FormValue("menu") == "footer" { - config.FooterMenu = append(config.FooterMenu, menuItem) + siteConfig.FooterMenu = append(siteConfig.FooterMenu, menuItem) } - err = h.siteConfigRepo.Update(config) + err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) if err != nil { return err } @@ -71,7 +76,9 @@ func (h *SiteConfigMenusHandler) HandleCreate(c *fiber.Ctx) error { func (h *SiteConfigMenusHandler) HandleDelete(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) - config, err := h.siteConfigRepo.Get() + siteConfig := model.SiteConfig{} + err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig) + if err != nil { return err } @@ -83,12 +90,12 @@ func (h *SiteConfigMenusHandler) HandleDelete(c *fiber.Ctx) error { } if menu == "header" { - config.HeaderMenu = append(config.HeaderMenu[:idx], config.HeaderMenu[idx+1:]...) + siteConfig.HeaderMenu = append(siteConfig.HeaderMenu[:idx], siteConfig.HeaderMenu[idx+1:]...) } else if menu == "footer" { - config.FooterMenu = append(config.FooterMenu[:idx], config.FooterMenu[idx+1:]...) + siteConfig.FooterMenu = append(siteConfig.FooterMenu[:idx], siteConfig.FooterMenu[idx+1:]...) } - err = h.siteConfigRepo.Update(config) + err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) if err != nil { return err } diff --git a/web/utils.go b/web/utils.go index 6967195..818b1f9 100644 --- a/web/utils.go +++ b/web/utils.go @@ -2,13 +2,15 @@ package web import ( "owl-blogs/app/repository" + "owl-blogs/config" "owl-blogs/domain/model" ) -func getConfig(repo repository.SiteConfigRepository) model.SiteConfig { - config, err := repo.Get() +func getSiteConfig(repo repository.ConfigRepository) model.SiteConfig { + siteConfig := model.SiteConfig{} + err := repo.Get(config.SITE_CONFIG, &siteConfig) if err != nil { panic(err) } - return config + return siteConfig } From b60980b368c14613978c35fbd890f391381f305f Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Wed, 19 Jul 2023 20:45:42 +0200 Subject: [PATCH 38/41] lists --- app/entry_service.go | 31 ++++++----- cmd/owl/import_v1.go | 2 +- render/templates/views/list.tmpl | 44 ++++++++++++++++ web/app.go | 24 ++++----- web/index_handler.go | 7 ++- web/list_handler.go | 89 ++++++++++++++++++++++++++++++-- 6 files changed, 165 insertions(+), 32 deletions(-) create mode 100644 render/templates/views/list.tmpl diff --git a/app/entry_service.go b/app/entry_service.go index 79b11fe..3528c4e 100644 --- a/app/entry_service.go +++ b/app/entry_service.go @@ -29,21 +29,26 @@ func (s *EntryService) FindById(id string) (model.Entry, error) { return s.EntryRepository.FindById(id) } -func (s *EntryService) FindAllByType(types *[]string) ([]model.Entry, error) { - return s.EntryRepository.FindAll(types) +func (s *EntryService) filterEntries(entries []model.Entry, published bool, drafts bool) []model.Entry { + filteredEntries := make([]model.Entry, 0) + for _, entry := range entries { + if published && entry.PublishedAt() != nil && !entry.PublishedAt().IsZero() { + filteredEntries = append(filteredEntries, entry) + } + if drafts && (entry.PublishedAt() == nil || entry.PublishedAt().IsZero()) { + filteredEntries = append(filteredEntries, entry) + } + } + return filteredEntries +} + +func (s *EntryService) FindAllByType(types *[]string, published bool, drafts bool) ([]model.Entry, error) { + entries, err := s.EntryRepository.FindAll(types) + return s.filterEntries(entries, published, drafts), err + } func (s *EntryService) FindAll() ([]model.Entry, error) { entries, err := s.EntryRepository.FindAll(nil) - if err != nil { - return nil, err - } - // filter unpublished entries - publishedEntries := make([]model.Entry, 0) - for _, entry := range entries { - if entry.PublishedAt() != nil && !entry.PublishedAt().IsZero() { - publishedEntries = append(publishedEntries, entry) - } - } - return publishedEntries, nil + return s.filterEntries(entries, true, true), err } diff --git a/cmd/owl/import_v1.go b/cmd/owl/import_v1.go index 8604a49..d545d12 100644 --- a/cmd/owl/import_v1.go +++ b/cmd/owl/import_v1.go @@ -96,7 +96,7 @@ var importCmd = &cobra.Command{ v2Config.AuthorName = v1Config.AuthorName v2Config.Me = mes v2Config.Lists = lists - v2Config.PrimaryListInclude = v1Config.PrimaryListInclude + v2Config.PrimaryListInclude = importer.ConvertTypeList(v1Config.PrimaryListInclude, app.Registry) v2Config.HeaderMenu = headerMenu v2Config.FooterMenu = footerMenu diff --git a/render/templates/views/list.tmpl b/render/templates/views/list.tmpl new file mode 100644 index 0000000..6a65d14 --- /dev/null +++ b/render/templates/views/list.tmpl @@ -0,0 +1,44 @@ +{{define "title"}}Index{{end}} + +{{define "main"}} + +
      +{{ range .Entries }} +
      +
      +

      + + {{if .Title}} + {{ .Title }} + {{else}} + # + {{end}} + +

      + + + +
      + {{ .Content }} +
      +
      +{{ end }} +
      + +
      + +{{end}} \ No newline at end of file diff --git a/web/app.go b/web/app.go index 3ec68fc..738f2df 100644 --- a/web/app.go +++ b/web/app.go @@ -28,18 +28,18 @@ func NewWebApp( typeRegistry *app.EntryTypeRegistry, binService *app.BinaryService, authorService *app.AuthorService, - siteConfigRepo repository.ConfigRepository, + configRepo repository.ConfigRepository, ) *WebApp { app := fiber.New() - indexHandler := NewIndexHandler(entryService, siteConfigRepo) - listHandler := NewListHandler(entryService) - entryHandler := NewEntryHandler(entryService, typeRegistry, authorService, siteConfigRepo) + indexHandler := NewIndexHandler(entryService, configRepo) + listHandler := NewListHandler(entryService, configRepo) + entryHandler := NewEntryHandler(entryService, typeRegistry, authorService, configRepo) mediaHandler := NewMediaHandler(binService) rssHandler := NewRSSHandler(entryService) - loginHandler := NewLoginHandler(authorService, siteConfigRepo) - editorListHandler := NewEditorListHandler(typeRegistry, siteConfigRepo) - editorHandler := NewEditorHandler(entryService, typeRegistry, binService, siteConfigRepo) + loginHandler := NewLoginHandler(authorService, configRepo) + editorListHandler := NewEditorListHandler(typeRegistry, configRepo) + editorHandler := NewEditorHandler(entryService, typeRegistry, binService, configRepo) // Login app.Get("/auth/login", loginHandler.HandleGet) @@ -56,21 +56,21 @@ func NewWebApp( siteConfig := app.Group("/site-config") siteConfig.Use(middleware.NewAuthMiddleware(authorService).Handle) - siteConfigHandler := NewSiteConfigHandler(siteConfigRepo) + siteConfigHandler := NewSiteConfigHandler(configRepo) siteConfig.Get("/", siteConfigHandler.HandleGet) siteConfig.Post("/", siteConfigHandler.HandlePost) - siteConfigMeHandler := NewSiteConfigMeHandler(siteConfigRepo) + siteConfigMeHandler := NewSiteConfigMeHandler(configRepo) siteConfig.Get("/me", siteConfigMeHandler.HandleGet) siteConfig.Post("/me/create/", siteConfigMeHandler.HandleCreate) siteConfig.Post("/me/delete/", siteConfigMeHandler.HandleDelete) - siteConfigListHandler := NewSiteConfigListHandler(siteConfigRepo, typeRegistry) + siteConfigListHandler := NewSiteConfigListHandler(configRepo, typeRegistry) siteConfig.Get("/lists", siteConfigListHandler.HandleGet) siteConfig.Post("/lists/create/", siteConfigListHandler.HandleCreate) siteConfig.Post("/lists/delete/", siteConfigListHandler.HandleDelete) - siteConfigMenusHandler := NewSiteConfigMenusHandler(siteConfigRepo) + siteConfigMenusHandler := NewSiteConfigMenusHandler(configRepo) siteConfig.Get("/menus", siteConfigMenusHandler.HandleGet) siteConfig.Post("/menus/create/", siteConfigMenusHandler.HandleCreate) siteConfig.Post("/menus/delete/", siteConfigMenusHandler.HandleDelete) @@ -106,7 +106,7 @@ func NewWebApp( Registry: typeRegistry, BinaryService: binService, AuthorService: authorService, - SiteConfigRepo: siteConfigRepo, + SiteConfigRepo: configRepo, } } diff --git a/web/index_handler.go b/web/index_handler.go index 59551ba..459d183 100644 --- a/web/index_handler.go +++ b/web/index_handler.go @@ -37,7 +37,10 @@ type indexRenderData struct { func (h *IndexHandler) Handle(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) - entries, err := h.entrySvc.FindAll() + + siteConfig := getSiteConfig(h.configRepo) + + entries, err := h.entrySvc.FindAllByType(&siteConfig.PrimaryListInclude, true, false) if err != nil { return err } @@ -73,7 +76,7 @@ func (h *IndexHandler) Handle(c *fiber.Ctx) error { return err } - return render.RenderTemplateWithBase(c, getSiteConfig(h.configRepo), "views/index", indexRenderData{ + return render.RenderTemplateWithBase(c, siteConfig, "views/index", indexRenderData{ Entries: entries, Page: pageNum, NextPage: pageNum + 1, diff --git a/web/list_handler.go b/web/list_handler.go index 8602f80..221b033 100644 --- a/web/list_handler.go +++ b/web/list_handler.go @@ -2,18 +2,99 @@ package web import ( "owl-blogs/app" + "owl-blogs/app/repository" + "owl-blogs/domain/model" + "owl-blogs/render" + "sort" + "strconv" "github.com/gofiber/fiber/v2" ) type ListHandler struct { - entrySvc *app.EntryService + configRepo repository.ConfigRepository + entrySvc *app.EntryService } -func NewListHandler(entryService *app.EntryService) *ListHandler { - return &ListHandler{entrySvc: entryService} +func NewListHandler( + entryService *app.EntryService, + configRepo repository.ConfigRepository, +) *ListHandler { + return &ListHandler{ + entrySvc: entryService, + configRepo: configRepo, + } +} + +type listRenderData struct { + List model.EntryList + Entries []model.Entry + Page int + NextPage int + PrevPage int + FirstPage bool + LastPage bool } func (h *ListHandler) Handle(c *fiber.Ctx) error { - return c.SendString("Hello, List!") + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + + siteConfig := getSiteConfig(h.configRepo) + listId := c.Params("list") + list := model.EntryList{} + for _, l := range siteConfig.Lists { + if l.Id == listId { + list = l + } + } + if list.Id == "" { + return c.SendStatus(404) + } + + entries, err := h.entrySvc.FindAllByType(&list.Include, true, false) + if err != nil { + return err + } + + // sort entries by date descending + sort.Slice(entries, func(i, j int) bool { + return entries[i].PublishedAt().After(*entries[j].PublishedAt()) + }) + + // pagination + page := c.Query("page") + if page == "" { + page = "1" + } + pageNum, err := strconv.Atoi(page) + if err != nil { + pageNum = 1 + } + limit := 10 + offset := (pageNum - 1) * limit + lastPage := false + if offset > len(entries) { + offset = len(entries) + lastPage = true + } + if offset+limit > len(entries) { + limit = len(entries) - offset + lastPage = true + } + entries = entries[offset : offset+limit] + + if err != nil { + return err + } + + return render.RenderTemplateWithBase(c, siteConfig, "views/list", listRenderData{ + List: list, + Entries: entries, + Page: pageNum, + NextPage: pageNum + 1, + PrevPage: pageNum - 1, + FirstPage: pageNum == 1, + LastPage: lastPage, + }) + } From c24f2cb9db136b7559f8b1692ff2772d1a7f6a53 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Wed, 19 Jul 2023 20:52:19 +0200 Subject: [PATCH 39/41] fix error with spaces in media name --- infra/binary_file_repository_test.go | 12 ++++++++++++ web/media_handler.go | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/infra/binary_file_repository_test.go b/infra/binary_file_repository_test.go index dcb6a5e..4bd7a82 100644 --- a/infra/binary_file_repository_test.go +++ b/infra/binary_file_repository_test.go @@ -45,3 +45,15 @@ func TestBinaryRepoNoSideEffect(t *testing.T) { require.Equal(t, file2.Name, "name2") require.Equal(t, file2.Data, []byte("222")) } + +func TestBinaryWithSpaceInName(t *testing.T) { + repo := setupBinaryRepo() + + file, err := repo.Create("name with space", []byte("111"), nil) + require.NoError(t, err) + + file, err = repo.FindById(file.Id) + require.NoError(t, err) + require.Equal(t, file.Name, "name with space") + require.Equal(t, file.Data, []byte("111")) +} diff --git a/web/media_handler.go b/web/media_handler.go index 3ee211e..71271d2 100644 --- a/web/media_handler.go +++ b/web/media_handler.go @@ -1,6 +1,7 @@ package web import ( + "net/url" "owl-blogs/app" "github.com/gofiber/fiber/v2" @@ -16,6 +17,11 @@ func NewMediaHandler(binaryService *app.BinaryService) *MediaHandler { func (h *MediaHandler) Handle(c *fiber.Ctx) error { id := c.Params("+") + // urldecode + id, err := url.PathUnescape(id) + if err != nil { + return err + } binary, err := h.binaryService.FindById(id) if err != nil { return err From 93184589bf5d10cd6216b906788c283e86f55704 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Wed, 19 Jul 2023 20:54:18 +0200 Subject: [PATCH 40/41] markdown on note --- cmd/owl/media_test.go | 24 ++++++++++++++++++++++++ entry_types/note.go | 12 ++++++++++-- render/templates/entry/Note.tmpl | 1 + render/templates/entry/Page.tmpl | 2 +- 4 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 cmd/owl/media_test.go create mode 100644 render/templates/entry/Note.tmpl diff --git a/cmd/owl/media_test.go b/cmd/owl/media_test.go new file mode 100644 index 0000000..abbaaa8 --- /dev/null +++ b/cmd/owl/media_test.go @@ -0,0 +1,24 @@ +package main + +import ( + "net/http/httptest" + "owl-blogs/test" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMediaWithSpace(t *testing.T) { + db := test.NewMockDb() + owlApp := App(db) + app := owlApp.FiberApp + + _, err := owlApp.BinaryService.Create("name with space.jpg", []byte("111")) + require.NoError(t, err) + + req := httptest.NewRequest("GET", "/media/name%20with%20space.jpg", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + +} diff --git a/entry_types/note.go b/entry_types/note.go index f7fb87d..590e9a5 100644 --- a/entry_types/note.go +++ b/entry_types/note.go @@ -1,6 +1,10 @@ package entrytypes -import "owl-blogs/domain/model" +import ( + "fmt" + "owl-blogs/domain/model" + "owl-blogs/render" +) type Note struct { model.EntryBase @@ -16,7 +20,11 @@ func (e *Note) Title() string { } func (e *Note) Content() model.EntryContent { - return model.EntryContent(e.meta.Content) + str, err := render.RenderTemplateToString("entry/Note", e) + if err != nil { + fmt.Println(err) + } + return model.EntryContent(str) } func (e *Note) MetaData() interface{} { diff --git a/render/templates/entry/Note.tmpl b/render/templates/entry/Note.tmpl new file mode 100644 index 0000000..f9e080a --- /dev/null +++ b/render/templates/entry/Note.tmpl @@ -0,0 +1 @@ +{{.MetaData.Content | markdown }} diff --git a/render/templates/entry/Page.tmpl b/render/templates/entry/Page.tmpl index bac8848..f9e080a 100644 --- a/render/templates/entry/Page.tmpl +++ b/render/templates/entry/Page.tmpl @@ -1 +1 @@ -{{.MetaData.Content}} +{{.MetaData.Content | markdown }} From 897f4094bfbb1f15e8c776ee296b645cfd18cb12 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Wed, 19 Jul 2023 21:07:36 +0200 Subject: [PATCH 41/41] clean README --- README.md | 114 ------------------------------------------------------ 1 file changed, 114 deletions(-) diff --git a/README.md b/README.md index d7aa93d..b41d09c 100644 --- a/README.md +++ b/README.md @@ -6,117 +6,3 @@ A simple web server for blogs generated from Markdown files. **_This project is not yet stable. Expect frequent breaking changes! Only use this if you are willing to regularly adjust your project accordingly._** -## Repository - -A repository holds all data for a web server. It contains multiple users. - -## User - -A user has a collection of posts. -Each directory in the `/users/` directory of a repository is considered a user. - -### User Directory structure - -``` -/ - \- public/ - \- - \- index.md - -- This will be rendered as the blog post. - -- Must be present for the blog post to be valid. - -- All other folders will be ignored - \- incoming_webmentions.yml - -- Used to track incoming webmentions - \- outgoing_webmentions.yml - -- Used to track outgoing webmentions - \- media/ - -- Contains all media files used in the blog post. - -- All files in this folder will be publicly available - \- webmention/ - \- .yml - -- Contains data for a received webmention - \- meta/ - \- base.html - -- The template used to render all sites - \- config.yml - -- Holds information about the user - \- VERSION - -- Contains the version string. - -- Used to determine compatibility in the future - \- media/ - -- All this files will be publicly available. To be used for general files - \- avatar.{png|jpg|jpeg|gif} - -- Optional: Avatar to be used in various places - \- favicon.{png|jpg|jpeg|gif|ico} - -- Optional: Favicon for the site -``` - -### User Config - -Stored in `meta/config.yml` - -``` -title: "Title of the Blog" -subtitle: "Subtitle of the Blog" -header_color: "#ff0000" -author_name: "Your Name" -me: - - name: "Connect on Mastodon" - url: "https://chaos.social/@h4kor" - - name: "I'm on Twitter" - url: "https://twitter.com/h4kor" -``` - -### Post - -Posts are Markdown files with a mandatory metadata head. - -- The `title` will be added to the web page and does not have to be reapeated in the body. It will be used in any lists of posts. -- `description` is optional. At the moment this is only used for the HTML head meta data. -- `aliases` are optional. They are used as permanent redirects to the actual blog page. -- `draft` is false by default. If set to `true` the post will not be accessible. -- `reply` optional. Will add the link to the top of the post with `rel="in-reply-to"`. For more infos see: [https://indieweb.org/reply](https://indieweb.org/reply) - -``` ---- -title: My new Post -Description: Short text used in meta data (and lists in the future) -date: 13 Aug 2022 17:07 UTC -aliases: - - /my/new/post - - /old_blog_path/ -draft: false -reply: - url: https://link.to/referred_post - text: Text used for link ---- - -Actual post - -``` - -### Webmentions - -This feature is not yet full supported and needs a lot of manual work. Expect this to change quiet frequently and breaking existing usages. - -To send webmentions use the command `owl webmention` - -Retrieved webmentions have to be approved manually by changing the `approval_status` in the `incoming_webmentions.yml` file. - -#### incoming_webmentions.yml - -``` -- source: https://example.com/post - title: Example Post - approval_status: ["", "approved", "rejected"] - retrieved_at: 2021-08-13T17:07:00Z -``` - -#### outgoing_webmentions.yml - -``` -- target: https://example.com/post - supported: true - scanned_at: 2021-08-13T17:07:00Z - last_sent_at: 2021-08-13T17:07:00Z -```