From f380f94043f44a4cb89ed6a05f4ab50a46e689f7 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Tue, 29 Nov 2022 20:36:50 +0100 Subject: [PATCH] WIP editor --- cmd/owl/web/editor_handler.go | 237 ++++++++++++++++++++++++++++++++++ cmd/owl/web/server.go | 46 +++++-- embed/editor/editor.html | 23 ++++ embed/editor/login.html | 6 + post.go | 6 + renderer.go | 35 +++++ user.go | 59 ++++++++- 7 files changed, 399 insertions(+), 13 deletions(-) create mode 100644 cmd/owl/web/editor_handler.go create mode 100644 embed/editor/editor.html create mode 100644 embed/editor/login.html diff --git a/cmd/owl/web/editor_handler.go b/cmd/owl/web/editor_handler.go new file mode 100644 index 0000000..77b6f1e --- /dev/null +++ b/cmd/owl/web/editor_handler.go @@ -0,0 +1,237 @@ +package web + +import ( + "h4kor/owl-blogs" + "net/http" + "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) + html, err := owl.RenderLoginPage(user, 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 == "" { + userLoginGetHandler(repo)(w, r, ps) + return + } + if !user.VerifyPassword(password) { + userLoginGetHandler(repo)(w, r, ps) + 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 + } + + 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 := r.Form.Get("content") + draft := r.Form.Get("draft") + + // validate form values + if post_type == "article" && title == "" { + userEditorGetHandler(repo)(w, r, ps) + return + } + + // create post + post, err := user.CreateNewPostFull(owl.PostMeta{ + Type: post_type, + Title: title, + Description: description, + Draft: draft == "on", + Date: time.Now(), + }, content) + + 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 { + http.Redirect(w, r, post.FullUrl(), http.StatusFound) + } else { + http.Redirect(w, r, user.EditorUrl(), http.StatusFound) + } + } +} diff --git a/cmd/owl/web/server.go b/cmd/owl/web/server.go index d828508..b14f3a7 100644 --- a/cmd/owl/web/server.go +++ b/cmd/owl/web/server.go @@ -14,16 +14,27 @@ func Router(repo *owl.Repository) http.Handler { router.ServeFiles("/static/*filepath", http.Dir(repo.StaticDir())) router.GET("/", repoIndexHandler(repo)) router.GET("/user/:user/", userIndexHandler(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/media/*filepath", userMediaHandler(repo)) - router.GET("/user/:user/index.xml", userRSSHandler(repo)) - router.GET("/user/:user/posts/:post/", postHandler(repo)) - router.GET("/user/:user/posts/:post/media/*filepath", postMediaHandler(repo)) - router.POST("/user/:user/webmention/", userWebmentionHandler(repo)) - router.POST("/user/:user/micropub/", userMicropubHandler(repo)) router.GET("/user/:user/.well-known/oauth-authorization-server", userAuthMetadataHandler(repo)) router.NotFound = http.HandlerFunc(notFoundHandler(repo)) return router @@ -33,16 +44,27 @@ func SingleUserRouter(repo *owl.Repository) http.Handler { router := httprouter.New() router.ServeFiles("/static/*filepath", http.Dir(repo.StaticDir())) router.GET("/", userIndexHandler(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("/media/*filepath", userMediaHandler(repo)) - router.GET("/index.xml", userRSSHandler(repo)) - router.GET("/posts/:post/", postHandler(repo)) - router.GET("/posts/:post/media/*filepath", postMediaHandler(repo)) - router.POST("/webmention/", userWebmentionHandler(repo)) - router.POST("/micropub/", userMicropubHandler(repo)) router.GET("/.well-known/oauth-authorization-server", userAuthMetadataHandler(repo)) router.NotFound = http.HandlerFunc(notFoundHandler(repo)) return router diff --git a/embed/editor/editor.html b/embed/editor/editor.html new file mode 100644 index 0000000..237a463 --- /dev/null +++ b/embed/editor/editor.html @@ -0,0 +1,23 @@ +
+ Write Article +
+

Create New Article

+ + + + + + + + + + +

+ +
+
+ +
+ Write Note + TODO +
\ No newline at end of file diff --git a/embed/editor/login.html b/embed/editor/login.html new file mode 100644 index 0000000..f42eb23 --- /dev/null +++ b/embed/editor/login.html @@ -0,0 +1,6 @@ +
+

Login to Editor

+ + + +
\ No newline at end of file diff --git a/post.go b/post.go index f88b9cd..3c51153 100644 --- a/post.go +++ b/post.go @@ -32,6 +32,7 @@ type Reply struct { } type PostMeta struct { + Type string `yaml:"type"` Title string `yaml:"title"` Description string `yaml:"description"` Aliases []string `yaml:"aliases"` @@ -46,6 +47,7 @@ func (pm PostMeta) FormattedDate() string { func (pm *PostMeta) UnmarshalYAML(unmarshal func(interface{}) error) error { type T struct { + Type string `yaml:"type"` Title string `yaml:"title"` Description string `yaml:"description"` Aliases []string `yaml:"aliases"` @@ -65,6 +67,10 @@ func (pm *PostMeta) UnmarshalYAML(unmarshal func(interface{}) error) error { return err } + pm.Type = t.Type + if pm.Type == "" { + pm.Type = "article" + } pm.Title = t.Title pm.Description = t.Description pm.Aliases = t.Aliases diff --git a/renderer.go b/renderer.go index 1ef0ad2..f8a14a7 100644 --- a/renderer.go +++ b/renderer.go @@ -35,6 +35,11 @@ type AuthRequestData struct { CsrfToken string } +type EditorViewData struct { + User User + CsrfToken string +} + type ErrorMessage struct { Error string Message string @@ -168,3 +173,33 @@ func RenderUserList(repo Repository) (string, error) { return renderTemplateStr([]byte(baseTemplate), data) } + +func RenderLoginPage(user User, csrfToken string) (string, error) { + loginHtml, err := renderEmbedTemplate("embed/editor/login.html", EditorViewData{ + User: user, + 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/user.go b/user.go index 874743a..f5e9437 100644 --- a/user.go +++ b/user.go @@ -52,6 +52,12 @@ type AccessToken struct { 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) } @@ -98,6 +104,16 @@ func (user User) MediaUrl() string { 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") } @@ -122,6 +138,10 @@ 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 } @@ -397,8 +417,45 @@ func (user User) ValidateAccessToken(token string) (bool, AccessToken) { tokens := user.getAccessTokens() for _, t := range tokens { if t.Token == token { - return true, t + 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 +}