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 }