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 2b1466c..0000000 Binary files a/assets/owl.png and /dev/null differ 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:
- - -

- -
- - - - - - - - - - - -
\ 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 @@ - \ 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 538dcf9..0000000 Binary files a/fixtures/image.png and /dev/null differ 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) -// } -// } - -// }