diff --git a/.gitignore b/.gitignore index 87fa001..57a1644 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # ignore binaries -chameleon config.toml -.storage +/.storage/ .mypy_cache/ chameleon.tar.gz +/.repo/ +/.database.bin +/.task/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..34d10b9 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,27 @@ +linters: + disable-all: true + # https://golangci-lint.run/usage/linters/ + enable: + - gosimple + - deadcode + - ineffassign + - asciicheck + - dogsled + - exhaustive + - exportloopref + - gocritic + - gofmt + - golint + - prealloc + - lll + - sqlclosecheck + - tparallel + - unconvert + +linters-settings: + gocritic: + disabled-checks: + - exitAfterDefer + - ifElseChain + lll: + line-length: 100 diff --git a/README.md b/README.md index 7e70996..8a96afa 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,37 @@ # Chameleon -Chameleon is web application that reflects content from markdown files from Github. Powers [articles.orsinium.dev](https://articles.orsinium.dev/). +Chameleon is web application (blog engine) that reflects content from markdown files from a git repository. Powers [articles.orsinium.dev](https://articles.orsinium.dev/). -## Run from release +Features: -1. [Download release](https://github.com/orsinium/chameleon/releases). -2. Extract: `tar -xzf chameleon.tar.gz` -3. Edit config: `nano config.toml` -4. Run binary release for your platform: `./linux-amd64.bin` ++ Markdown ++ Minimalistic UI ++ Easy to use, no CI or a special repo structure required ++ Zero configuration ++ Single binary ++ Automatically pull the repo by schedule ++ Built-in prose linter ([Vale](https://github.com/errata-ai/vale)) ++ Syntax highlighting ([Prism](https://prismjs.com/)) ++ Formulas ([MathJax](https://www.mathjax.org/)) ++ Emoji ([enescakir/emoji](https://github.com/enescakir/emoji)) ++ Views count ++ Great performance and server-side caching ++ Optional password protection ++ Search ++ Minification ([minify](https://github.com/tdewolff/minify#examples)) -## Run from source +## Usage + +Build: ```bash -git clone https://github.com/orsinium/chameleon +git clone https://github.com/life4/chameleon.git cd chameleon -cp config{_example,}.toml -go get . -go run *.go +go build -o chameleon.bin . ``` -## Run from build +Run: ```bash -go build . -./chameleon +./chameleon.bin --path ./path/to/repo/ ``` diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..1c683b0 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,10 @@ +# https://taskfile.dev/ +version: "3" + +tasks: + go:run: + sources: + - chameleon/ + - main.go + cmds: + - go run . diff --git a/article.go b/article.go deleted file mode 100644 index a8e1cf9..0000000 --- a/article.go +++ /dev/null @@ -1,174 +0,0 @@ -package main - -import ( - "encoding/binary" - "fmt" - "io/ioutil" - "net/http" - "path" - "regexp" - "strings" - "time" - - "github.com/errata-ai/vale/check" - "github.com/errata-ai/vale/core" - "github.com/errata-ai/vale/lint" - "github.com/recoilme/pudge" - "github.com/recoilme/slowpoke" - "github.com/tidwall/gjson" - blackfriday "gopkg.in/russross/blackfriday.v2" -) - -// Author is a struct with contributor info -type Author struct { - Login, Avatar string -} - -// Article is a struct with article title and content -type Article struct { - Category *Category - Config *Config - - File string - Title string - Raw string - HTML string - Slug string - - Authors []Author - CreatedAt time.Time - UpdatedAt time.Time - Alerts []core.Alert - - Views uint32 -} - -func (article *Article) updateRaw() error { - link, err := article.Category.makeLink(rawLinkT) - if err != nil { - return err - } - link += "/" + article.File - res, err := http.Get(link) - if err != nil { - return err - } - content, err := ioutil.ReadAll(res.Body) - res.Body.Close() - if err != nil { - return err - } - article.Raw = string(content) - return nil -} - -func (article *Article) updateMetaInfo() error { - link, err := article.Category.makeLink(commitsLinkT) - if err != nil { - return err - } - link += "/" + article.File - res, err := http.Get(link) - if err != nil { - return err - } - if res.StatusCode != 200 { - return fmt.Errorf("invalid status code: %d (%s)", res.StatusCode, link) - } - content, err := ioutil.ReadAll(res.Body) - res.Body.Close() - if err != nil { - return err - } - - authorsMap := make(map[string]Author) - var login string - for _, subtree := range gjson.Get(string(content), "#.author").Array() { - login = subtree.Get("login").String() - if login != "" { - authorsMap[login] = Author{Login: login, Avatar: subtree.Get("avatar_url").String()} - } - } - var authors []Author - for _, author := range authorsMap { - authors = append(authors, author) - } - article.Authors = authors - - times := gjson.Get(string(content), "#.commit.author.date").Array() - t, err := time.Parse(time.RFC3339, times[0].String()) - if err != nil { - return err - } - article.UpdatedAt = t - - t, err = time.Parse(time.RFC3339, times[len(times)-1].String()) - if err != nil { - return err - } - article.CreatedAt = t - return nil -} - -func (article *Article) getTitle() string { - title := strings.Split(article.Raw, "\n")[0] - if strings.Index(title, "# ") != 0 { - return article.File - } - article.Raw = strings.TrimPrefix(article.Raw, title) - return title[2:] -} - -var rexImg = regexp.MustCompile("(<img src=\"(.*?)\".*?/>)") - -func (article *Article) getHTML() (html string) { - // convert markdown to html - html = string(blackfriday.Run([]byte(article.Raw))) - // fix relative paths - html = strings.Replace(html, "src=\"./", "src=\"../", -1) - html = strings.Replace(html, "href=\"./", "href=\"../", -1) - // wrap images into link - html = rexImg.ReplaceAllString(html, "<a href=\"$2\" target=\"_blank\">$1</a>") - return -} - -func (article *Article) updateAlerts() error { - config := core.NewConfig() - config.GBaseStyles = []string{"proselint", "write-good", "Joblint", "Spelling"} - config.MinAlertLevel = 1 - config.InExt = ".html" - linter := lint.Linter{Config: config, CheckManager: check.NewManager(config)} - files, _ := linter.LintString(article.HTML) - article.Alerts = files[0].SortedAlerts() - return nil -} - -func (article *Article) getFilename() string { - return path.Join(article.Config.Project, ".storage", article.Category.Slug+".db") -} - -func (article *Article) updateViews() error { - if article.Views != 0 { - return nil - } - bs, err := slowpoke.Get(article.getFilename(), []byte(article.Slug)) - if err == pudge.ErrKeyNotFound { - return nil - } - if err != nil { - return err - } - article.Views = binary.LittleEndian.Uint32(bs) - return nil -} - -func (article *Article) incrementViews() error { - err := article.updateViews() - if err != pudge.ErrKeyNotFound && err != nil { - return err - } - article.Views++ - bs := make([]byte, 4) - binary.LittleEndian.PutUint32(bs, article.Views) - return slowpoke.Set(article.getFilename(), []byte(article.Slug), bs) -} diff --git a/build.py b/build.py deleted file mode 100644 index 3724145..0000000 --- a/build.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python3 - -import os -import sys - -builds = ( - ('darwin', 'amd64'), - ('linux', '386'), - ('linux', 'amd64'), - ('linux', 'arm'), - ('windows', '386'), - ('windows', 'amd64'), -) - -arches = ('arm', 'amd64', '386') -platforms = ('darwin', 'linux', 'windows') - -cmd = "GOOS={platform} GOARCH={arch} go build -o builds/{platform}-{arch}.{ext} {package}" # noQA - -for platform, arch in builds: - os.system(cmd.format( - platform=platform, - arch=arch, - ext='bin' if platform != 'windows' else 'exe', - package=sys.argv[1], - )) diff --git a/category.go b/category.go deleted file mode 100644 index 8e692b5..0000000 --- a/category.go +++ /dev/null @@ -1,88 +0,0 @@ -package main - -import ( - "bytes" - "io/ioutil" - "log" - "net/http" - "sort" - "strings" - "sync" - "text/template" - - "github.com/tidwall/gjson" -) - -// Category is a struct with all information about content in given category -type Category struct { - Repo, Branch, Dir, Name, Ext, Slug string -} - -func (category *Category) makeLink(linkTemplate string) (string, error) { - t, err := template.New("linkTemplate").Parse(linkTemplate) - if err != nil { - return "", err - } - buffer := &bytes.Buffer{} - err = t.Execute(buffer, *category) - if err != nil { - return "", err - } - return buffer.String(), nil -} - -func (category *Category) getArticles() (articles []Article, err error) { - link, err := category.makeLink(dirAPILinkT) - if err != nil { - return - } - - // get filenames - res, err := http.Get(link) - if err != nil { - return - } - content, err := ioutil.ReadAll(res.Body) - res.Body.Close() - if err != nil { - return - } - names := gjson.Get(string(content), "#.name") - - // get content - var wg sync.WaitGroup - var article Article - articlesChan := make(chan Article, len(names.Array())) - for _, name := range names.Array() { - if strings.HasSuffix(name.String(), category.Ext) { - wg.Add(1) - article = Article{ - Category: category, - File: name.String(), - Slug: strings.TrimSuffix(name.String(), category.Ext), - } - go func(article Article) { - defer wg.Done() - err := article.updateRaw() - if err != nil { - log.Fatal(err) - return - } - article.Title = article.getTitle() - articlesChan <- article - }(article) - } - } - wg.Wait() - - close(articlesChan) - for article := range articlesChan { - articles = append(articles, article) - } - - sort.Slice(articles, func(i, j int) bool { - return articles[i].Title < articles[j].Title - }) - - return articles, nil -} diff --git a/chameleon.service b/chameleon.service index fc1f335..91b34c1 100644 --- a/chameleon.service +++ b/chameleon.service @@ -7,7 +7,7 @@ Description=chameleon Type=simple Restart=always RestartSec=5s -ExecStart=/path/to/bin/chameleon --config /path/to/config.toml --project /path/to/project/root/ +ExecStart=/path/to/bin/chameleon --path /path/to/repo/ [Install] WantedBy=multi-user.target diff --git a/chameleon/article.go b/chameleon/article.go new file mode 100644 index 0000000..ba30370 --- /dev/null +++ b/chameleon/article.go @@ -0,0 +1,147 @@ +package chameleon + +import ( + "bytes" + "fmt" + "os" + "regexp" + "strings" + + "github.com/enescakir/emoji" + "gopkg.in/russross/blackfriday.v2" +) + +const ISO8601 = "2006-01-02T15:04:05-07:00" + +var rexImg = regexp.MustCompile("(<img src=\"(.*?)\".*?/>)") +var rexLang = regexp.MustCompile("<code class=\"language-([a-zA-Z]+)\">") + +type Article struct { + Repository Repository + Path Path + + // cache + raw []byte + title string + commits Commits +} + +func (a Article) Valid() (bool, error) { + if !strings.HasSuffix(a.Path.String(), Extension) { + return false, nil + } + return a.Path.IsFile() +} + +func (a Article) IsReadme() bool { + return a.Path.Name() == ReadMe +} + +func (a *Article) Linter() Linter { + return Linter{Article: a} +} + +func (a *Article) Raw() ([]byte, error) { + var err error + if a.raw != nil { + return a.raw, nil + } + a.raw, err = os.ReadFile(a.Path.String()) + if err != nil { + return nil, fmt.Errorf("cannot read file: %v", err) + } + a.trimTitle() + return a.raw, nil +} + +func (a *Article) HTML() (string, error) { + raw, err := a.Raw() + if err != nil { + return "", err + } + html := string(blackfriday.Run(raw)) + html = emoji.Parse(html) + // fix relative paths + html = strings.ReplaceAll(html, "src=\"./", "src=\"../") + html = strings.ReplaceAll(html, "href=\"./", "href=\"../") + // wrap images into link + html = rexImg.ReplaceAllString(html, "<a href=\"$2\" target=\"_blank\">$1</a>") + return html, nil +} + +func (a *Article) Languages() ([]string, error) { + html, err := a.HTML() + if err != nil { + return nil, err + } + matches := rexLang.FindAllStringSubmatch(html, -1) + result := make([]string, 0) + set := make(map[string]struct{}, len(matches)) + for _, m := range matches { + lang := m[1] + _, ok := set[lang] + if !ok { + set[lang] = struct{}{} + result = append(result, lang) + } + } + return result, nil +} + +// trimTitle extracts title from raw content +func (a *Article) trimTitle() { + title := bytes.SplitN(a.raw, []byte{'\n'}, 2)[0] + if bytes.Index(title, []byte{'#', ' '}) != 0 { + if a.IsReadme() { + a.title = a.Path.Parent().Name() + return + } + a.title = a.Path.Name() + return + } + a.raw = bytes.TrimPrefix(a.raw, title) + a.title = strings.TrimSuffix(string(title[2:]), "\n") +} + +func (a *Article) Title() (string, error) { + if a.title == "" { + _, err := a.Raw() + if err != nil { + return "", err + } + } + return a.title, nil +} + +func (a Article) Slug() string { + return strings.TrimSuffix(a.Path.Name(), Extension) +} + +func (a *Article) Commits() (Commits, error) { + if a.commits != nil { + return a.commits, nil + } + + p := a.Path.Relative(a.Repository.Path) + cmd := a.Repository.Command("log", "--pretty=%H|%cI|%an|%ae|%s", "--follow", p.String()) + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("%v: %s", err, out) + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + a.commits = make(Commits, len(lines)) + for i, line := range lines { + a.commits[i], err = ParseCommit(line) + if err != nil { + return nil, err + } + } + return a.commits, nil +} + +func (a Article) URLs() URLs { + return URLs{ + Repository: a.Repository, + Path: a.Path, + } +} diff --git a/chameleon/articles.go b/chameleon/articles.go new file mode 100644 index 0000000..a59d9ed --- /dev/null +++ b/chameleon/articles.go @@ -0,0 +1,23 @@ +package chameleon + +import "sort" + +type Articles []*Article + +func (a Articles) Len() int { + return len(a) +} + +func (a Articles) Less(i, j int) bool { + c1, _ := a[i].Commits() + c2, _ := a[j].Commits() + return c1.Created().Time.Unix() < c2.Created().Time.Unix() +} + +func (a Articles) Swap(i, j int) { + a[i], a[j] = a[j], a[i] +} + +func (a *Articles) Sort() { + sort.Sort(sort.Reverse(a)) +} diff --git a/assets/android-chrome-192x192.png b/chameleon/assets/android-chrome-192x192.png similarity index 100% rename from assets/android-chrome-192x192.png rename to chameleon/assets/android-chrome-192x192.png diff --git a/assets/android-chrome-512x512.png b/chameleon/assets/android-chrome-512x512.png similarity index 100% rename from assets/android-chrome-512x512.png rename to chameleon/assets/android-chrome-512x512.png diff --git a/assets/apple-touch-icon.png b/chameleon/assets/apple-touch-icon.png similarity index 100% rename from assets/apple-touch-icon.png rename to chameleon/assets/apple-touch-icon.png diff --git a/assets/browserconfig.xml b/chameleon/assets/browserconfig.xml similarity index 100% rename from assets/browserconfig.xml rename to chameleon/assets/browserconfig.xml diff --git a/assets/favicon-16x16.png b/chameleon/assets/favicon-16x16.png similarity index 100% rename from assets/favicon-16x16.png rename to chameleon/assets/favicon-16x16.png diff --git a/assets/favicon-32x32.png b/chameleon/assets/favicon-32x32.png similarity index 100% rename from assets/favicon-32x32.png rename to chameleon/assets/favicon-32x32.png diff --git a/assets/favicon.ico b/chameleon/assets/favicon.ico similarity index 100% rename from assets/favicon.ico rename to chameleon/assets/favicon.ico diff --git a/assets/icon.svg b/chameleon/assets/icon.svg similarity index 100% rename from assets/icon.svg rename to chameleon/assets/icon.svg diff --git a/assets/index.html b/chameleon/assets/index.html similarity index 100% rename from assets/index.html rename to chameleon/assets/index.html diff --git a/assets/mstile-144x144.png b/chameleon/assets/mstile-144x144.png similarity index 100% rename from assets/mstile-144x144.png rename to chameleon/assets/mstile-144x144.png diff --git a/assets/mstile-150x150.png b/chameleon/assets/mstile-150x150.png similarity index 100% rename from assets/mstile-150x150.png rename to chameleon/assets/mstile-150x150.png diff --git a/assets/mstile-310x150.png b/chameleon/assets/mstile-310x150.png similarity index 100% rename from assets/mstile-310x150.png rename to chameleon/assets/mstile-310x150.png diff --git a/assets/mstile-310x310.png b/chameleon/assets/mstile-310x310.png similarity index 100% rename from assets/mstile-310x310.png rename to chameleon/assets/mstile-310x310.png diff --git a/assets/mstile-70x70.png b/chameleon/assets/mstile-70x70.png similarity index 100% rename from assets/mstile-70x70.png rename to chameleon/assets/mstile-70x70.png diff --git a/assets/safari-pinned-tab.svg b/chameleon/assets/safari-pinned-tab.svg similarity index 100% rename from assets/safari-pinned-tab.svg rename to chameleon/assets/safari-pinned-tab.svg diff --git a/assets/site.webmanifest b/chameleon/assets/site.webmanifest similarity index 100% rename from assets/site.webmanifest rename to chameleon/assets/site.webmanifest diff --git a/assets/style.css b/chameleon/assets/style.css similarity index 68% rename from assets/style.css rename to chameleon/assets/style.css index 6706b75..63680ce 100644 --- a/assets/style.css +++ b/chameleon/assets/style.css @@ -19,37 +19,23 @@ text-align: center; } -.statblock { - display: inline-block; - margin: 2px 10px; -} - .avatar { - opacity: .9; -} - -.avatar:hover { - text-decoration: none; - opacity: 1; -} - -.avatar img { - border-radius: 25px; - width: 50px; - height: 50px; + border-radius: .5em; + width: 1em; + height: 1em; box-shadow: 0 0 1px; } .level-error { - color: red; + color: #EA2027; } .level-warning { - color: yellow; + color: #EE5A24; } .level-suggestion { - color: green; + color: #009432; } .content img { @@ -66,4 +52,4 @@ blockquote { border-left: solid 6px #bdc3c7; padding-left: 12px; -} \ No newline at end of file +} diff --git a/chameleon/auth.go b/chameleon/auth.go new file mode 100644 index 0000000..2b01d5b --- /dev/null +++ b/chameleon/auth.go @@ -0,0 +1,111 @@ +package chameleon + +import ( + "crypto/sha512" + "crypto/subtle" + "encoding/base64" + "errors" + "net/http" + "strings" + "time" + + "github.com/julienschmidt/httprouter" + "go.uber.org/zap" +) + +type Auth struct { + Password string + Logger *zap.Logger + TTL time.Duration +} + +func (a Auth) generate(date string) string { + h := sha512.New().Sum([]byte(date + "|" + a.Password)) + return base64.StdEncoding.EncodeToString(h) +} + +func (a Auth) valid(r *http.Request) (bool, error) { + cookie, err := r.Cookie("auth") + if err == http.ErrNoCookie { + return false, nil + } + parts := strings.SplitN(cookie.Value, "|", 2) + if len(parts) != 2 { + return false, errors.New("auth date not found") + } + + t, err := time.Parse(time.RFC3339, parts[1]) + if err != nil { + return false, err + } + if t.Add(a.TTL).Unix() < time.Now().Unix() { + return false, nil + } + + given := []byte(parts[0]) + expected := []byte(a.generate(parts[1])) + eq := subtle.ConstantTimeCompare(given, expected) == 1 + return eq, nil +} + +func (a Auth) make(r *http.Request) *http.Cookie { + date := time.Now().Format(time.RFC3339) + token := a.generate(date) + "|" + date + return &http.Cookie{ + Name: "auth", + Value: token, + Path: "/", + MaxAge: 3600 * 24 * 7, + } +} + +func (a Auth) render(w http.ResponseWriter, ok bool) { + w.WriteHeader(http.StatusForbidden) + _ = TemplateAuth.Execute(w, ok) +} + +func (a Auth) Wrap(h httprouter.Handle) httprouter.Handle { + if a.Password == "" { + return h + } + return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + valid, err := a.valid(r) + if err != nil { + a.Logger.Debug("auth error", zap.Error(err), zap.String("ip", r.RemoteAddr)) + http.Redirect(w, r, AuthPrefix, http.StatusTemporaryRedirect) + return + } + if !valid { + http.Redirect(w, r, AuthPrefix, http.StatusTemporaryRedirect) + return + } + h(w, r, ps) + } +} + +func (a Auth) HandleGET(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + valid, _ := a.valid(r) + if valid { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + a.render(w, true) +} + +func (a Auth) HandlePOST(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + err := r.ParseForm() + if err != nil { + a.Logger.Debug("cannot parse form", zap.Error(err)) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + given := []byte(r.PostForm.Get("password")) + expected := []byte(a.Password) + ok := subtle.ConstantTimeCompare(given, expected) == 1 + if !ok { + a.render(w, false) + return + } + http.SetCookie(w, a.make(r)) + http.Redirect(w, r, MainPrefix, http.StatusSeeOther) +} diff --git a/chameleon/auth_test.go b/chameleon/auth_test.go new file mode 100644 index 0000000..e356eea --- /dev/null +++ b/chameleon/auth_test.go @@ -0,0 +1,99 @@ +package chameleon + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAuthNoRedirect(t *testing.T) { + is := require.New(t) + request, err := http.NewRequest(http.MethodGet, "/p/", nil) + is.Nil(err) + response := httptest.NewRecorder() + + s, err := NewServer(newTestConfig(), nil) + is.Nil(err) + s.ServeHTTP(response, request) + + is.Equal(response.Code, 200) +} + +func TestAuthRedirect(t *testing.T) { + is := require.New(t) + request, err := http.NewRequest(http.MethodGet, "/p/", nil) + is.Nil(err) + response := httptest.NewRecorder() + + c := newTestConfig() + c.Password = "123" + s, err := NewServer(c, nil) + is.Nil(err) + s.ServeHTTP(response, request) + + is.Equal(response.Code, 307) + is.Equal(response.Header()["Location"], []string{"/auth/"}) +} + +func TestAuthForm(t *testing.T) { + is := require.New(t) + request, err := http.NewRequest(http.MethodGet, "/auth/", nil) + is.Nil(err) + response := httptest.NewRecorder() + + c := newTestConfig() + c.Password = "123" + s, err := NewServer(c, nil) + is.Nil(err) + s.ServeHTTP(response, request) + + b := response.Body.String() + is.Equal(response.Code, 403, b) + is.Contains(b, "403: Password Required") +} + +func TestAuthWrongPass(t *testing.T) { + is := require.New(t) + data := url.Values{} + data.Set("password", "1") + request, err := http.NewRequest( + http.MethodPost, "/auth/", strings.NewReader(data.Encode()), + ) + is.Nil(err) + response := httptest.NewRecorder() + + c := newTestConfig() + c.Password = "123" + s, err := NewServer(c, nil) + is.Nil(err) + s.ServeHTTP(response, request) + + b := response.Body.String() + is.Equal(response.Code, 403, b) + is.Contains(b, "403: Password Required") +} + +func TestAuthCorrectPass(t *testing.T) { + is := require.New(t) + data := url.Values{} + data.Set("password", "123") + request, err := http.NewRequest( + http.MethodPost, "/auth/", strings.NewReader(data.Encode()), + ) + is.Nil(err) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value") + response := httptest.NewRecorder() + + c := newTestConfig() + c.Password = "123" + s, err := NewServer(c, nil) + is.Nil(err) + s.ServeHTTP(response, request) + + is.Equal(response.Code, 303) + is.Equal(response.Header()["Location"], []string{"/p/"}) +} diff --git a/chameleon/cache.go b/chameleon/cache.go new file mode 100644 index 0000000..53b478e --- /dev/null +++ b/chameleon/cache.go @@ -0,0 +1,49 @@ +package chameleon + +import ( + "net/http" + "time" + + "github.com/julienschmidt/httprouter" + cache "github.com/victorspringer/http-cache" + "github.com/victorspringer/http-cache/adapter/memory" +) + +type Cache struct { + client *cache.Client +} + +func NewCache(capacity int) (*Cache, error) { + if capacity == 0 { + return nil, nil + } + memcached, err := memory.NewAdapter( + memory.AdapterWithAlgorithm(memory.LRU), + memory.AdapterWithCapacity(capacity), + ) + if err != nil { + return nil, err + } + + client, err := cache.NewClient( + cache.ClientWithAdapter(memcached), + cache.ClientWithTTL(10*time.Minute), + ) + if err != nil { + return nil, err + } + return &Cache{client: client}, nil +} + +func (c *Cache) Wrap(f httprouter.Handle) httprouter.Handle { + if c == nil { + return f + } + return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + h1 := func(w http.ResponseWriter, r *http.Request) { + f(w, r, ps) + } + h2 := c.client.Middleware(http.HandlerFunc(h1)) + h2.ServeHTTP(w, r) + } +} diff --git a/chameleon/category.go b/chameleon/category.go new file mode 100644 index 0000000..36638eb --- /dev/null +++ b/chameleon/category.go @@ -0,0 +1,80 @@ +package chameleon + +import "fmt" + +type Category struct { + Repository Repository + Path Path +} + +func (c Category) Valid() (bool, error) { + isDir, err := c.Path.IsDir() + if err != nil { + return false, err + } + if !isDir { + return false, nil + } + return c.Path.Join(ReadMe).IsFile() +} + +func (c Category) Article() *Article { + return &Article{ + Repository: c.Repository, + Path: c.Path.Join(ReadMe), + } +} + +func (c Category) Categories() ([]Category, error) { + cats := make([]Category, 0) + paths, err := c.Path.SubPaths() + if err != nil { + return nil, fmt.Errorf("cannot get subpaths: %v", err) + } + for _, p := range paths { + cat := Category{ + Repository: c.Repository, + Path: p, + } + valid, err := cat.Valid() + if err != nil { + return nil, fmt.Errorf("cannot validate category: %v", err) + } + if !valid { + continue + } + cats = append(cats, cat) + } + return cats, nil +} + +func (c Category) Articles() (Articles, error) { + arts := make(Articles, 0) + paths, err := c.Path.SubPaths() + if err != nil { + return nil, fmt.Errorf("cannot get subpaths: %v", err) + } + for _, p := range paths { + art := &Article{ + Repository: c.Repository, + Path: p, + } + valid, err := art.Valid() + if err != nil { + return nil, fmt.Errorf("cannot validate article: %v", err) + } + if !valid { + continue + } + if art.IsReadme() { + continue + } + arts = append(arts, art) + } + arts.Sort() + return arts, nil +} + +func (c Category) URLs() URLs { + return URLs(c) +} diff --git a/chameleon/commit.go b/chameleon/commit.go new file mode 100644 index 0000000..b23accb --- /dev/null +++ b/chameleon/commit.go @@ -0,0 +1,46 @@ +package chameleon + +import ( + "strings" + "time" +) + +type Commits []Commit + +func (c Commits) Len() int { + return len(c) +} + +func (c Commits) Edited() Commit { + return c[0] +} + +func (c Commits) Created() Commit { + return c[len(c)-1] +} + +type Commit struct { + Hash string + Time time.Time + Name string + Mail string + Msg string + Diff string +} + +func ParseCommit(line string) (Commit, error) { + line = strings.TrimSpace(line) + parts := strings.Split(line, "|") + t, err := time.Parse(ISO8601, parts[1]) + if err != nil { + return Commit{}, err + } + c := Commit{ + Hash: parts[0], + Time: t, + Name: parts[2], + Mail: parts[3], + Msg: parts[4], + } + return c, err +} diff --git a/chameleon/config.go b/chameleon/config.go new file mode 100644 index 0000000..01f7f68 --- /dev/null +++ b/chameleon/config.go @@ -0,0 +1,51 @@ +package chameleon + +import ( + "time" + + "github.com/spf13/pflag" +) + +type Config struct { + // repo + RepoPath string + RepoURL string + Pull time.Duration + Cache int + + // auth + Password string + AuthTTL time.Duration + + // other + Address string + DBPath string + PProf bool +} + +func NewConfig() Config { + return Config{ + Address: "127.0.0.1:1337", + RepoPath: ".repo", + RepoURL: "https://github.com/orsinium/notes.git", + Pull: 5 * time.Minute, + Cache: 1000, + Password: "", + AuthTTL: time.Hour * 24 * 7, + DBPath: ".database.bin", + } +} + +func (c Config) Parse() Config { + pflag.StringVar(&c.Address, "addr", c.Address, "address to serve") + pflag.StringVar(&c.RepoPath, "path", c.RepoPath, "path to repository") + pflag.StringVar(&c.RepoURL, "url", c.RepoURL, "clone URL for repo if not exist") + pflag.DurationVar(&c.Pull, "pull", c.Pull, "how often pull repository, 0 to disable") + pflag.IntVar(&c.Cache, "cache", c.Cache, "how many records to cache, 0 to disable") + pflag.StringVar(&c.Password, "pass", c.Password, "require password") + pflag.DurationVar(&c.AuthTTL, "auth-ttl", c.AuthTTL, "time to keep users logged in") + pflag.StringVar(&c.DBPath, "db", c.DBPath, "path to database file") + pflag.BoolVar(&c.PProf, "pprof", c.PProf, "serve pprof endpoints") + pflag.Parse() + return c +} diff --git a/chameleon/const.go b/chameleon/const.go new file mode 100644 index 0000000..87b8245 --- /dev/null +++ b/chameleon/const.go @@ -0,0 +1,14 @@ +package chameleon + +const ( + Extension = ".md" + ReadMe = "README.md" + + MainPrefix = "/p/" + LinterPrefix = "/linter/" + CommitsPrefix = "/commits/" + DiffPrefix = "/diff/" + AuthPrefix = "/auth/" + StatPrefix = "/stat/" + SearchPrefix = "/search/" +) diff --git a/chameleon/database.go b/chameleon/database.go new file mode 100644 index 0000000..af4a253 --- /dev/null +++ b/chameleon/database.go @@ -0,0 +1,51 @@ +package chameleon + +import ( + "fmt" + "time" + + "go.etcd.io/bbolt" +) + +type Database struct { + db *bbolt.DB +} + +func (db *Database) Open(path string) error { + var err error + opts := &bbolt.Options{Timeout: 5 * time.Second} + db.db, err = bbolt.Open(path, 0600, opts) + if err != nil { + return fmt.Errorf("cannot open db: %v", err) + } + + err = db.db.Update(func(tx *bbolt.Tx) error { + _, err = tx.CreateBucketIfNotExists([]byte("views")) + if err != nil { + return fmt.Errorf("cannot create bucket: %v", err) + } + return nil + }) + if err != nil { + return fmt.Errorf("cannot execute transaction: %v", err) + } + + return nil +} + +func (db Database) Close() error { + if db.db == nil { + return nil + } + return db.db.Close() +} + +func (db Database) Views(path Path) *Views { + if db.db == nil { + return nil + } + return &Views{ + db: db.db, + path: path, + } +} diff --git a/chameleon/handler_diff.go b/chameleon/handler_diff.go new file mode 100644 index 0000000..db6a350 --- /dev/null +++ b/chameleon/handler_diff.go @@ -0,0 +1,45 @@ +package chameleon + +import ( + "errors" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/julienschmidt/httprouter" +) + +var rexHash = regexp.MustCompile(`^[a-f0-9]{40}$`) + +type HandlerDiff struct { + Server *Server +} + +func (h HandlerDiff) Handle(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + err := h.Render(w, ps.ByName("hash")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + +} + +func (h HandlerDiff) Render(w http.ResponseWriter, hash string) error { + if !rexHash.MatchString(hash) { + return errors.New("invalid commit hash") + } + cmd := h.Server.Repository.Command("show", "--pretty=%H|%cI|%an|%ae|%s", hash) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%v: %s", err, out) + } + + lines := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2) + commit, err := ParseCommit(lines[0]) + if err != nil { + return err + } + commit.Diff = lines[1] + return TemplateDiff.Execute(w, &commit) +} diff --git a/chameleon/handler_main.go b/chameleon/handler_main.go new file mode 100644 index 0000000..9b368e5 --- /dev/null +++ b/chameleon/handler_main.go @@ -0,0 +1,137 @@ +package chameleon + +import ( + "fmt" + "net/http" + "regexp" + "strings" + "text/template" + + "github.com/julienschmidt/httprouter" + "go.uber.org/zap" +) + +var rexNotLetter = regexp.MustCompile(`[^a-zA-Z0-9]`) + +type HandlerMain struct { + Template *template.Template + Server *Server +} + +func (h HandlerMain) Handle(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + path := safeText(ps.ByName("filepath")) + + // get page + page, err := h.Page(path) + if err != nil { + h.Server.Logger.Error("cannot get page", zap.Error(err), zap.String("path", path)) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // increment views + if path != "" { + key := "viewed_" + rexNotLetter.ReplaceAllString(path, "_") + _, err = r.Cookie(key) + if err != nil { + cookie := http.Cookie{ + Name: key, + Value: "1", + Path: r.URL.Path, + MaxAge: 3600 * 24, + } + http.SetCookie(w, &cookie) + page.Inc() + } + } + + // render the page + w.WriteHeader(page.Status()) + err = page.Render(w) + if err != nil { + h.Server.Logger.Error("cannot render page", zap.Error(err), zap.String("path", path)) + _, err = fmt.Fprint(w, err.Error()) + if err != nil { + h.Server.Logger.Warn("cannot write response", zap.Error(err), zap.String("path", path)) + } + return + } +} + +func (h HandlerMain) Page(urlPath string) (Page, error) { + if strings.Contains(urlPath, "/.") { + return Page403{}, nil + } + + p := h.Server.Repository.Path.Join(urlPath) + + // category page + isdir, err := p.IsDir() + if err != nil { + return nil, err + } + if isdir { + isfile, err := p.Join(ReadMe).IsFile() + if err != nil { + return nil, err + } + if !isfile { + return nil, fmt.Errorf("README.md not found") + } + page := PageArticle{ + Category: &Category{ + Repository: h.Server.Repository, + Path: p, + }, + Article: Article{ + Repository: h.Server.Repository, + Path: p.Join(ReadMe), + }, + Views: h.Server.Database.Views(p), + Template: h.Template, + Logger: h.Server.Logger, + } + if urlPath != "" && urlPath != "/" { + page.Parent = &Category{ + Repository: h.Server.Repository, + Path: p.Parent(), + } + } + return page, nil + } + + // raw file + isfile, err := p.IsFile() + if err != nil { + return nil, err + } + if isfile { + page := PageAsset{Path: p} + return page, nil + } + + // article page + p = h.Server.Repository.Path.Join(urlPath + Extension) + isfile, err = p.IsFile() + if err != nil { + return nil, err + } + if isfile { + page := PageArticle{ + Parent: &Category{ + Repository: h.Server.Repository, + Path: p.Parent(), + }, + Article: Article{ + Repository: h.Server.Repository, + Path: p, + }, + Views: h.Server.Database.Views(p), + Template: h.Template, + Logger: h.Server.Logger, + } + return page, nil + } + + return Page404{}, nil +} diff --git a/chameleon/handler_search.go b/chameleon/handler_search.go new file mode 100644 index 0000000..5ed03b3 --- /dev/null +++ b/chameleon/handler_search.go @@ -0,0 +1,87 @@ +package chameleon + +import ( + "fmt" + "net/http" + "strings" + + "github.com/julienschmidt/httprouter" + "go.uber.org/zap" +) + +type HandlerSearch struct { + Server *Server +} + +func (h HandlerSearch) Handle(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + page, err := h.Page(r) + if err != nil { + h.Server.Logger.Error("cannot get page", zap.Error(err), zap.String("url", r.URL.RawPath)) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(page.Status()) + err = page.Render(w) + if err != nil { + h.Server.Logger.Error("cannot render page", zap.Error(err), zap.String("url", r.URL.RawPath)) + _, err = fmt.Fprint(w, err.Error()) + if err != nil { + h.Server.Logger.Warn("cannot write response", zap.Error(err), zap.String("url", r.URL.RawPath)) + } + return + } +} + +func (h HandlerSearch) Page(r *http.Request) (Page, error) { + queries, ok := r.URL.Query()["q"] + if !ok || len(queries) == 0 { + return PageSearch{}, nil + } + + query := safeText(queries[0]) + if query == "" { + return PageSearch{}, nil + } + + cmd := h.Server.Repository.Command( + "grep", + "--full-name", + "--ignore-case", + "--recursive", + "--word-regexp", + "--name-only", + "--fixed-strings", + query, + ) + out, err := cmd.CombinedOutput() + if cmd.ProcessState.ExitCode() == 1 { + return PageSearch{Query: query}, nil + } + if err != nil { + return nil, fmt.Errorf("%v: %s", err, out) + } + + paths := strings.Split(strings.TrimSpace(string(out)), "\n") + page := PageSearch{ + Query: query, + Results: make([]*Article, 0), + } + for _, path := range paths { + if path == "" { + continue + } + art := &Article{ + Repository: h.Server.Repository, + Path: h.Server.Repository.Join(path), + } + valid, err := art.Valid() + if err != nil { + return nil, err + } + if !valid { + continue + } + page.Results = append(page.Results, art) + } + return page, nil +} diff --git a/chameleon/handler_stat.go b/chameleon/handler_stat.go new file mode 100644 index 0000000..c68a669 --- /dev/null +++ b/chameleon/handler_stat.go @@ -0,0 +1,30 @@ +package chameleon + +import ( + "net/http" + + "github.com/julienschmidt/httprouter" +) + +type HandlerStat struct { + Server *Server +} + +func (h HandlerStat) Handle(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + err := h.Render(w) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + +} + +func (h HandlerStat) Render(w http.ResponseWriter) error { + stat, err := h.Server.Database.Views(h.Server.Repository.Path).All() + if err != nil { + return err + } + stat.Sort() + stat.SetRepo(h.Server.Repository) + return TemplateStat.Execute(w, stat) +} diff --git a/chameleon/linter.go b/chameleon/linter.go new file mode 100644 index 0000000..acdc3b8 --- /dev/null +++ b/chameleon/linter.go @@ -0,0 +1,30 @@ +package chameleon + +import ( + "fmt" + + "github.com/errata-ai/vale/check" + "github.com/errata-ai/vale/core" + "github.com/errata-ai/vale/lint" +) + +type Linter struct { + Article *Article +} + +func (linter Linter) Alerts() ([]core.Alert, error) { + config := core.NewConfig() + config.GBaseStyles = []string{"proselint", "write-good", "Joblint", "Spelling"} + config.MinAlertLevel = 1 + config.InExt = ".html" + vale := lint.Linter{Config: config, CheckManager: check.NewManager(config)} + html, err := linter.Article.HTML() + if err != nil { + return nil, fmt.Errorf("cannot read html: %v", err) + } + files, err := vale.LintString(html) + if err != nil { + return nil, fmt.Errorf("cannot lint html: %v", err) + } + return files[0].SortedAlerts(), nil +} diff --git a/chameleon/page.go b/chameleon/page.go new file mode 100644 index 0000000..1470814 --- /dev/null +++ b/chameleon/page.go @@ -0,0 +1,11 @@ +package chameleon + +import ( + "io" +) + +type Page interface { + Render(io.Writer) error + Inc() + Status() int +} diff --git a/chameleon/page_403.go b/chameleon/page_403.go new file mode 100644 index 0000000..08210fa --- /dev/null +++ b/chameleon/page_403.go @@ -0,0 +1,19 @@ +package chameleon + +import ( + "io" + "net/http" +) + +type Page403 struct { +} + +func (p Page403) Render(w io.Writer) error { + return Template403.Execute(w, &p) +} + +func (p Page403) Inc() {} + +func (p Page403) Status() int { + return http.StatusForbidden +} diff --git a/chameleon/page_404.go b/chameleon/page_404.go new file mode 100644 index 0000000..016c2ea --- /dev/null +++ b/chameleon/page_404.go @@ -0,0 +1,19 @@ +package chameleon + +import ( + "io" + "net/http" +) + +type Page404 struct { +} + +func (p Page404) Render(w io.Writer) error { + return Template404.Execute(w, &p) +} + +func (p Page404) Inc() {} + +func (p Page404) Status() int { + return http.StatusNotFound +} diff --git a/chameleon/page_article.go b/chameleon/page_article.go new file mode 100644 index 0000000..907f52a --- /dev/null +++ b/chameleon/page_article.go @@ -0,0 +1,53 @@ +package chameleon + +import ( + "io" + "net/http" + "text/template" + + "github.com/tdewolff/minify" + "github.com/tdewolff/minify/html" + "go.uber.org/zap" +) + +type PageArticle struct { + Article Article + Parent *Category + Category *Category + Views *Views + Template *template.Template + Logger *zap.Logger +} + +func (p PageArticle) Render(w io.Writer) error { + m := minify.New() + m.AddFunc("text/html", html.Minify) + + r, interw := io.Pipe() + var errt error + go func() { + errt = p.Template.Execute(interw, &p) + interw.Close() + }() + errm := m.Minify("text/html", w, r) + if errt != nil { + return errt + } + return errm +} + +func (p PageArticle) Inc() { + if p.Views == nil { + return + } + go func() { + err := p.Views.Inc() + if err != nil { + p.Logger.Error("cannot increment views", zap.Error(err)) + } + }() +} + +func (p PageArticle) Status() int { + return http.StatusOK +} diff --git a/chameleon/page_asset.go b/chameleon/page_asset.go new file mode 100644 index 0000000..82f81fb --- /dev/null +++ b/chameleon/page_asset.go @@ -0,0 +1,29 @@ +package chameleon + +import ( + "io" + "net/http" +) + +type PageAsset struct { + Path Path +} + +func (page PageAsset) Render(w io.Writer) error { + f, err := page.Path.Open() + if err != nil { + return err + } + _, err = io.Copy(w, f) + err2 := f.Close() + if err2 != nil { + return err2 + } + return err +} + +func (page PageAsset) Inc() {} + +func (page PageAsset) Status() int { + return http.StatusOK +} diff --git a/chameleon/page_search.go b/chameleon/page_search.go new file mode 100644 index 0000000..5ec60de --- /dev/null +++ b/chameleon/page_search.go @@ -0,0 +1,21 @@ +package chameleon + +import ( + "io" + "net/http" +) + +type PageSearch struct { + Query string + Results []*Article +} + +func (p PageSearch) Render(w io.Writer) error { + return TemplateSearch.Execute(w, &p) +} + +func (p PageSearch) Inc() {} + +func (p PageSearch) Status() int { + return http.StatusOK +} diff --git a/chameleon/path.go b/chameleon/path.go new file mode 100644 index 0000000..51cc094 --- /dev/null +++ b/chameleon/path.go @@ -0,0 +1,91 @@ +package chameleon + +import ( + "fmt" + "io/ioutil" + "os" + "path" + "strings" +) + +type Path string + +func (p Path) String() string { + return string(p) +} + +func (p Path) Name() string { + return path.Base(p.String()) +} + +func (p Path) Relative(to Path) Path { + if p == to { + return "" + } + return Path(strings.TrimPrefix(p.String(), to.String()+"/")) +} + +func (p Path) Parent() Path { + return Path(path.Dir(p.String())) +} + +func (p Path) Join(fname string) Path { + if fname == "" { + return p + } + return Path(path.Join(p.String(), fname)) +} + +func (p Path) IsDir() (bool, error) { + stat, err := os.Stat(p.String()) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return stat.IsDir(), nil +} + +func (p Path) IsFile() (bool, error) { + stat, err := os.Stat(p.String()) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return !stat.IsDir(), nil +} + +func (p Path) EnsureDir() error { + exists, err := p.IsDir() + if err != nil { + return fmt.Errorf("cannot get dir stat: %v", err) + } + if exists { + return nil + } + err = os.MkdirAll(p.String(), 0777) + if err != nil { + return fmt.Errorf("cannot create dir: %v", err) + } + return nil +} + +func (p Path) SubPaths() ([]Path, error) { + infos, err := ioutil.ReadDir(p.String()) + if err != nil { + return nil, err + } + + result := make([]Path, len(infos)) + for i, info := range infos { + result[i] = p.Join(info.Name()) + } + return result, nil +} + +func (p Path) Open() (*os.File, error) { + return os.Open(p.String()) +} diff --git a/chameleon/repository.go b/chameleon/repository.go new file mode 100644 index 0000000..41c0278 --- /dev/null +++ b/chameleon/repository.go @@ -0,0 +1,71 @@ +package chameleon + +import ( + "fmt" + "net/url" + "os/exec" + "strings" + + giturls "github.com/whilp/git-urls" +) + +const CloneURL = "https://github.com/%s.git" + +type Repository struct { + Path +} + +func (r Repository) Command(args ...string) *exec.Cmd { + c := exec.Command("git", args...) + c.Dir = r.Path.String() + return c +} + +func (r Repository) Pull() error { + c := r.Command("pull") + err := c.Run() + if err != nil { + return fmt.Errorf("cannot pull repo: %v", err) + } + return nil +} + +func (r Repository) Remote() (*url.URL, error) { + c := r.Command("remote", "get-url", "origin") + out, err := c.CombinedOutput() + if err != nil { + return nil, err + } + rawURL := strings.TrimSpace(string(out)) + return giturls.Parse(rawURL) +} + +func (r Repository) Branch() (string, error) { + c := r.Command("rev-parse", "--abbrev-ref", "HEAD") + out, err := c.CombinedOutput() + if err != nil { + return "", err + } + branch := strings.TrimSpace(string(out)) + return branch, nil +} + +func (r Repository) Clone(url string) error { + isdir, err := r.Path.IsDir() + if err != nil { + return fmt.Errorf("cannot access local repo: %v", err) + } + if isdir { + return nil + } + if url == "" { + return fmt.Errorf("repo not found and no clone URL specified") + } + + c := exec.Command("git", "clone", url, r.Path.String()) + out, err := c.CombinedOutput() + if err != nil { + return fmt.Errorf("cannot clone repo: %v: %s", err, out) + } + return nil +} diff --git a/chameleon/server.go b/chameleon/server.go new file mode 100644 index 0000000..d7b67e0 --- /dev/null +++ b/chameleon/server.go @@ -0,0 +1,154 @@ +package chameleon + +import ( + "embed" + "fmt" + "net/http" + "net/http/pprof" + "time" + + "github.com/go-co-op/gocron" + "github.com/julienschmidt/httprouter" + "go.uber.org/zap" +) + +//go:embed assets/* +var assets embed.FS + +type Server struct { + Repository Repository + Database *Database + Logger *zap.Logger + Config Config + + cache *Cache + auth Auth + router *httprouter.Router +} + +func NewServer(config Config, logger *zap.Logger) (*Server, error) { + if logger == nil { + logger = zap.NewNop() + } + repo := Repository{Path: Path(config.RepoPath)} + + cache, err := NewCache(config.Cache) + if err != nil { + return nil, fmt.Errorf("cannot create cache: %v", err) + } + + server := Server{ + Repository: repo, + Database: &Database{}, + Logger: logger, + cache: cache, + auth: Auth{ + Password: config.Password, + TTL: config.AuthTTL, + Logger: logger, + }, + Config: config, + } + + if config.DBPath != "" { + err = server.Database.Open(config.DBPath) + if err != nil { + return nil, fmt.Errorf("cannot open database: %v", err) + } + } + + err = repo.Clone(config.RepoURL) + if err != nil { + return nil, fmt.Errorf("cannot clone repo: %v", err) + } + + server.Init() + + if config.Pull != 0 { + sch := gocron.NewScheduler(time.UTC) + job, err := sch.Every(config.Pull).Do(func() { + logger.Debug("pulling the repo") + err := repo.Pull() + if err != nil { + logger.Error("cannot pull the repo", zap.Error(err)) + } + }) + if err != nil { + return nil, fmt.Errorf("cannot schedule the task: %v", err) + } + job.SingletonMode() + sch.StartAsync() + } + + return &server, nil +} + +func (s *Server) Init() { + s.router = httprouter.New() + s.router.Handler( + http.MethodGet, + "/", + http.RedirectHandler(MainPrefix, http.StatusTemporaryRedirect), + ) + s.router.Handler( + http.MethodGet, + "/assets/*filepath", + http.FileServer(http.FS(assets)), + ) + s.router.GET( + MainPrefix+"*filepath", + s.auth.Wrap( + s.cache.Wrap( + HandlerMain{Server: s, Template: TemplateArticle}.Handle, + ), + ), + ) + s.router.GET( + LinterPrefix+"*filepath", + s.auth.Wrap( + s.cache.Wrap( + HandlerMain{Server: s, Template: TemplateLinter}.Handle, + ), + ), + ) + s.router.GET( + CommitsPrefix+"*filepath", + s.auth.Wrap( + HandlerMain{Server: s, Template: TemplateCommits}.Handle, + ), + ) + s.router.GET( + DiffPrefix+":hash", + s.auth.Wrap( + HandlerDiff{Server: s}.Handle, + ), + ) + s.router.GET( + StatPrefix, + s.auth.Wrap( + HandlerStat{Server: s}.Handle, + ), + ) + s.router.GET( + SearchPrefix, + s.auth.Wrap( + HandlerSearch{Server: s}.Handle, + ), + ) + + s.router.GET(AuthPrefix, s.auth.HandleGET) + s.router.POST(AuthPrefix, s.auth.HandlePOST) + + if s.Config.PProf { + s.Logger.Debug("debugging enabled", zap.String("endpoint", "/debug/pprof/")) + s.router.HandlerFunc("GET", "/debug/pprof/", pprof.Index) + s.router.HandlerFunc("GET", "/debug/pprof/cmdline", pprof.Cmdline) + s.router.HandlerFunc("GET", "/debug/pprof/profile", pprof.Profile) + s.router.HandlerFunc("GET", "/debug/pprof/symbol", pprof.Symbol) + s.router.HandlerFunc("GET", "/debug/pprof/trace", pprof.Trace) + } +} + +func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.router.ServeHTTP(w, r) +} diff --git a/chameleon/server_test.go b/chameleon/server_test.go new file mode 100644 index 0000000..dec76a2 --- /dev/null +++ b/chameleon/server_test.go @@ -0,0 +1,31 @@ +package chameleon + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func newTestConfig() Config { + config := NewConfig() + config.Pull = 0 + config.DBPath = "" + config.RepoPath = "../.repo" + return config +} + +func TestRootRedirect(t *testing.T) { + is := require.New(t) + request, err := http.NewRequest(http.MethodGet, "/", nil) + is.Nil(err) + response := httptest.NewRecorder() + + s, err := NewServer(newTestConfig(), nil) + is.Nil(err) + s.ServeHTTP(response, request) + + is.Equal(response.Code, 307) + is.Equal(response.Header()["Location"], []string{"/p/"}) +} diff --git a/chameleon/stat.go b/chameleon/stat.go new file mode 100644 index 0000000..a2d6c4b --- /dev/null +++ b/chameleon/stat.go @@ -0,0 +1,87 @@ +package chameleon + +import "sort" + +type Stat struct { + Path string + Count uint32 + Repo Repository + + article *Article + category *Category +} + +func (s Stat) URLs() URLs { + return URLs{ + Repository: s.Repo, + Path: Path(s.Path), + } +} + +func (s Stat) Article() *Article { + if s.article == nil { + s.article = &Article{ + Repository: s.Repo, + Path: Path(s.Path), + } + ok, _ := s.article.Valid() + if !ok { + s.article = s.Category().Article() + } + } + return s.article +} + +func (s Stat) Category() *Category { + if s.category == nil { + s.category = &Category{ + Repository: s.Repo, + Path: Path(s.Path), + } + } + return s.category +} + +func (s Stat) Title() string { + art := s.Article() + title, err := art.Title() + if err != nil { + return s.Path + } + return title +} + +type ViewStat struct { + Stats []*Stat + Max uint32 +} + +func (s ViewStat) Len() int { + return len(s.Stats) +} + +func (s ViewStat) Less(i, j int) bool { + return s.Stats[i].Count < s.Stats[j].Count +} + +func (s ViewStat) Swap(i, j int) { + s.Stats[i], s.Stats[j] = s.Stats[j], s.Stats[i] +} + +func (s *ViewStat) Sort() { + sort.Sort(sort.Reverse(s)) +} + +func (s *ViewStat) Add(path string, count uint32) { + s.Stats = append(s.Stats, &Stat{Path: path, Count: count}) + if count > s.Max { + s.Max = count + } +} + +func (s ViewStat) SetRepo(repo Repository) { + for i, stat := range s.Stats { + stat.Repo = repo + s.Stats[i] = stat + } +} diff --git a/chameleon/templates.go b/chameleon/templates.go new file mode 100644 index 0000000..2b8bbc6 --- /dev/null +++ b/chameleon/templates.go @@ -0,0 +1,46 @@ +package chameleon + +import ( + "crypto/md5" + "embed" + "fmt" + "text/template" + "time" +) + +//go:embed templates/*.html.j2 +var templates embed.FS + +var ( + TemplateArticle = parseTemplate("templates/category.html.j2") + TemplateLinter = parseTemplate("templates/linter.html.j2") + TemplateCommits = parseTemplate("templates/commits.html.j2") + TemplateAuth = parseTemplate("templates/auth.html.j2") + TemplateDiff = parseTemplate("templates/diff.html.j2") + TemplateStat = parseTemplate("templates/stat.html.j2") + TemplateSearch = parseTemplate("templates/search.html.j2") + + Template403 = parseTemplate("templates/403.html.j2") + Template404 = parseTemplate("templates/404.html.j2") +) + +func parseTemplate(tname string) *template.Template { + return template.Must(template.New("base.html.j2").Funcs(funcs).ParseFS( + templates, + "templates/base.html.j2", + tname, + )) +} + +var funcs = template.FuncMap{ + "date": func(item time.Time) string { + return item.Format("2006-01-02") + }, + "gravatar": func(mail string) string { + hash := md5.Sum([]byte(mail)) + return fmt.Sprintf("https://www.gravatar.com/avatar/%x", hash) + }, + "percent": func(a, b uint32) uint32 { + return a * 100 / b + }, +} diff --git a/chameleon/templates/403.html.j2 b/chameleon/templates/403.html.j2 new file mode 100644 index 0000000..0ae0c93 --- /dev/null +++ b/chameleon/templates/403.html.j2 @@ -0,0 +1,15 @@ +{{define "title"}} + 403: Access Denied +{{end}} + +{{define "header"}} + <h1> + 403: Access Denied + </h1> +{{end}} + +{{define "content"}} + <h3 class="text-center"> + <a href="/">Restart</a> + </h3> +{{end}} diff --git a/chameleon/templates/404.html.j2 b/chameleon/templates/404.html.j2 new file mode 100644 index 0000000..e16ee5b --- /dev/null +++ b/chameleon/templates/404.html.j2 @@ -0,0 +1,15 @@ +{{define "title"}} + 404: Game Over +{{end}} + +{{define "header"}} + <h1> + 404: Game Over + </h1> +{{end}} + +{{define "content"}} + <h3 class="text-center"> + <a href="/">Restart</a> + </h3> +{{end}} diff --git a/chameleon/templates/auth.html.j2 b/chameleon/templates/auth.html.j2 new file mode 100644 index 0000000..ff85601 --- /dev/null +++ b/chameleon/templates/auth.html.j2 @@ -0,0 +1,27 @@ +{{define "title"}} + 403: Password Required +{{end}} + +{{define "header"}} + <h1> + 403: Password Required + </h1> +{{end}} + +{{define "content"}} + <div class="card container-xs" style="width: 18rem; margin: auto"> + <form method="post" action="/auth/" class="card-body row"> + {{ if not . }} + <div class="alert alert-danger" role="alert"> + Invalid password + </div> + {{ end }} + <div class="col-8"> + <input name="password" type="password" class="form-control" placeholder="password"> + </div> + <div class="col-4"> + <button type="submit" class="btn btn-primary">enter</button> + </div> + </form> + </div> +{{end}} diff --git a/chameleon/templates/base.html.j2 b/chameleon/templates/base.html.j2 new file mode 100644 index 0000000..0fc51e6 --- /dev/null +++ b/chameleon/templates/base.html.j2 @@ -0,0 +1,91 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + <meta http-equiv="x-ua-compatible" content="ie=edge"> + <title>{{block "title" .}}Home{{end}} - Chameleon</title> + <link rel="shortcut icon" href="favicon.ico"> + + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/css/bootstrap.min.css" integrity="sha384-BmbxuPwQa2lc/FVzBcNJ7UAyJxM6wuqIj61tLrc4wSX0szH/Ev+nYRRuWlolflfl" crossorigin="anonymous"> + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css" integrity="sha512-iBBXm8fW90+nuLcSKlbmrPcLa0OT92xO1BIsZ+ywDWZCvqsWgccV3gFoRBv0z+8dLJgyAHIhR35VZc2oM/gI1w==" crossorigin="anonymous" /> + + <link href="/assets/style.css" rel="stylesheet" /> + + <!-- https://realfavicongenerator.net/ --> + <link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png"> + <link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png"> + <link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png"> + <link rel="manifest" href="/assets/site.webmanifest"> + <link rel="mask-icon" href="/assets/safari-pinned-tab.svg" color="#5bbad5"> + <link rel="shortcut icon" href="/assets/favicon.ico"> + <meta name="msapplication-TileColor" content="#2c3e50"> + <meta name="msapplication-config" content="/assets/browserconfig.xml"> + <meta name="theme-color" content="#ffffff"> + </head> + + <body class="bg-white text-body"> + + <div class="container-lg content"> + <div class="row header"> + {{block "header" .}} + {{end}} + </div> + <div class="row"> + <div class="col-md-2 sidebar"> + {{ block "sidebar" . }}{{ end }} + <div class="d-grid gap-2 py-1"> + <a class="btn btn-outline-dark btn-sm text-start" href="/"> + <i class="fas fa-home fa-fw"></i> + home + </a> + <a class="btn btn-outline-dark btn-sm text-start" href="/search/"> + <i class="fas fa-search fa-fw"></i> + search + </a> + <a class="btn btn-outline-dark btn-sm text-start" href="/stat/"> + <i class="fas fa-chart-bar fa-fw"></i> + stat + </a> + </div> + </div> + <div class="col"> + {{ block "content" . }}{{ end }} + </div> + </div> + </div> + {{block "toc" .}}{{end}} + + <div class="footer container-fluid" style="margin-top: 10px"> + <h1 class="text-center"> + <a href="/" class="link-dark"> + <i class="far fa-heart"></i> + </a> + </h1> + </div> + + <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/js/bootstrap.bundle.min.js" integrity="sha384-b5kHyXgcpbZJO/tY9Ul7kGkf1S0CWuKcCD38l8YkeH8z8QjE0GmW1gYU5S9FOnJ0" crossorigin="anonymous"></script> + <script> + var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')) + var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { + return new bootstrap.Tooltip(tooltipTriggerEl) + }) + </script> + + + <!-- https://www.mathjax.org/ --> + <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/MathJax.js?config=TeX-MML-AM_CHTML"></script> + + <!-- https://www.bryanbraun.com/anchorjs/ --> + <script src="https://cdnjs.cloudflare.com/ajax/libs/anchor-js/4.3.0/anchor.min.js"></script> + <script> + anchors.options = { + placement: 'left', + icon: '#', + }; + anchors.add('h2'); + </script> + + </body> + +</html> diff --git a/chameleon/templates/category.html.j2 b/chameleon/templates/category.html.j2 new file mode 100644 index 0000000..1f00dec --- /dev/null +++ b/chameleon/templates/category.html.j2 @@ -0,0 +1,95 @@ +{{define "title"}} + {{ .Article.Title }} +{{end}} + +{{define "header"}} + <h1> + {{if .Parent}} + <a href="{{ .Parent.URLs.Main }}">{{ .Parent.Article.Title }}</a> / + {{end}} + {{ .Article.Title }} + </h1> +{{end}} + +{{define "toc"}} + {{if .Category}} + <div class="container-fluid text-light bg-dark text-center lead"> + <ul class="text-start list-unstyled" style="display: inline-block"> + {{range .Category.Categories}} + <li> + <i class="far fa-folder fa-fw"></i> + <a href="{{ .URLs.Main }}" class="link-light">{{ .Article.Title }}</a> + </li> + {{end}} + {{range .Category.Articles}} + <li> + <i class="far fa-file fa-fw"></i> + <span class="text-secondary"> + {{ .Commits.Created.Time | date }} + </span> + <a href="{{ .URLs.Main }}" class="link-light">{{ .Title }}</a> + </li> + {{end}} + </ul> + </div> + {{end}} +{{end}} + +{{define "content"}} + {{ .Article.HTML }} + + <!-- https://prismjs.com/ --> + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.23.0/themes/prism-tomorrow.min.css" /> + <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.23.0/prism.min.js"></script> + + <!-- https://cdnjs.com/libraries/prism/ --> + {{ range .Article.Languages }} + <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.23.0/components/prism-{{ . }}.min.js"></script> + {{ end }} +{{end}} + +{{define "sidebar"}} + {{if not .Category}} + <div> + <i class="far fa-eye fa-fw" title="views" data-bs-toggle="tooltip" data-bs-placement="right"></i> + {{ .Views.Get }} + </div> + + <div> + <i class="far fa-save fa-fw" title="created" data-bs-toggle="tooltip" data-bs-placement="right"></i> + {{ .Article.Commits.Created.Time | date }} + </div> + + <div> + <i class="far fa-edit fa-fw" title="edited" data-bs-toggle="tooltip" data-bs-placement="right"></i> + {{ .Article.Commits.Edited.Time | date }} + </div> + + <div> + <i class="far fa-user fa-fw" title="author" data-bs-toggle="tooltip" data-bs-placement="right"></i> + {{ .Article.Commits.Created.Name }} + </div> + + <div class="d-grid gap-2"> + <a class="btn btn-outline-dark btn-sm text-start" href="{{ .Article.URLs.Linter }}"> + <i class="fas fa-bug fa-fw"></i> + linter + </a> + <a class="btn btn-outline-dark btn-sm text-start" href="{{ .Article.URLs.Commits }}"> + <i class="fas fa-code-branch fa-fw"></i> + commits + ({{ .Article.Commits.Len }}) + </a> + <a class="btn btn-outline-dark btn-sm text-start" href="{{ .Article.URLs.Raw }}"> + <i class="fas fa-code fa-fw"></i> + raw + </a> + {{ if .Article.URLs.Edit }} + <a class="btn btn-outline-dark btn-sm text-start" href="{{ .Article.URLs.Edit }}" target="_blank"> + <i class="far fa-edit fa-fw"></i> + edit + </a> + {{ end }} + </div> + {{end}} +{{end}} diff --git a/chameleon/templates/commits.html.j2 b/chameleon/templates/commits.html.j2 new file mode 100644 index 0000000..420189f --- /dev/null +++ b/chameleon/templates/commits.html.j2 @@ -0,0 +1,28 @@ +{{define "title"}} + {{ .Article.Title }} +{{end}} + +{{define "header"}} + <h1> + {{if .Parent}} + <a href="../">{{ .Parent.Article.Title }}</a> / + {{end}} + {{ .Article.Title }} + </h1> +{{end}} + +{{define "content"}} + <ul> + {{ range .Article.Commits }} + <li> + <a href="/diff/{{ .Hash }}"> + {{ .Time | date }} + </a> + by + <img src="{{ .Mail | gravatar }}" class="avatar" /> + <a href="mailto:{{.Mail}}">{{ .Name }}</a>: + {{ .Msg }} + </li> + {{end}} + <ul> +{{end}} diff --git a/chameleon/templates/diff.html.j2 b/chameleon/templates/diff.html.j2 new file mode 100644 index 0000000..4555b9e --- /dev/null +++ b/chameleon/templates/diff.html.j2 @@ -0,0 +1,21 @@ +{{define "title"}} + {{ .Msg }} +{{end}} +{{define "header"}} + <h1> + {{ .Msg }} + </h1> +{{end}} + +{{define "content"}} + <pre> + <code class="language-diff" style="white-space: pre-wrap"> + {{ .Diff }} + </code> + </pre> + + <!-- https://prismjs.com/ --> + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.23.0/themes/prism-tomorrow.min.css" /> + <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.23.0/prism.min.js"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.23.0/components/prism-diff.min.js"></script> +{{end}} diff --git a/chameleon/templates/linter.html.j2 b/chameleon/templates/linter.html.j2 new file mode 100644 index 0000000..597770f --- /dev/null +++ b/chameleon/templates/linter.html.j2 @@ -0,0 +1,40 @@ +{{define "title"}} + {{ .Article.Title }} +{{end}} + +{{define "header"}} + <h1> + {{if .Parent}} + {{ .Parent.Article.Title }} / + {{end}} + {{ .Article.Title }} + </h1> +{{end}} + +{{define "sidebar"}} + <div class="d-grid gap-2"> + <a class="btn btn-outline-dark btn-sm text-start" href="{{ .Article.URLs.Main }}"> + <i class="fas fa-file fa-fw"></i> + article + </a> + </div> +{{end}} + +{{define "content"}} + <table class="table table-sm" style="text-align: left"> + <tr> + <th>line</th> + <th>level</th> + <th>message</th> + <th>check</th> + </tr> + {{ range .Article.Linter.Alerts }} + <tr> + <td>{{ .Line }}:{{ .Span }}</td> + <td class="level-{{ .Severity }}">{{ .Severity }}</td> + <td>{{ .Message }}</td> + <td>{{ .Check }}</td> + </tr> + {{end}} + </table> +{{end}} diff --git a/chameleon/templates/search.html.j2 b/chameleon/templates/search.html.j2 new file mode 100644 index 0000000..2fcd6b9 --- /dev/null +++ b/chameleon/templates/search.html.j2 @@ -0,0 +1,39 @@ +{{define "title"}} + {{ if .Query }} + Search: {{ .Query }} + {{ else }} + Search + {{ end }} +{{end}} + +{{define "header"}} + <h1> + Search + </h1> +{{end}} + +{{define "content"}} + <form class="text-center g-1"> + <input name="q" class="form-control" placeholder="search" value="{{.Query}}"> + </form> +{{end}} + +{{define "toc"}} + {{ if .Query }} + <div class="container-fluid text-light bg-dark text-center lead"> + {{ if not .Results }} + No results + {{ else }} + <ul class="text-start list-unstyled" style="display: inline-block"> + {{ range .Results }} + <li> + <a href="{{ .URLs.Main }}" class="link-light"> + {{ .Title }} + </a> + </li> + {{ end }} + </ul> + {{ end }} + </div> + {{ end }} +{{ end }} diff --git a/chameleon/templates/stat.html.j2 b/chameleon/templates/stat.html.j2 new file mode 100644 index 0000000..1034162 --- /dev/null +++ b/chameleon/templates/stat.html.j2 @@ -0,0 +1,37 @@ +{{define "title"}} + Statistics +{{end}} + +{{define "header"}} + <h1> + Statistics + </h1> +{{end}} + +{{define "content"}} + <h2>Views per page</h2> + <div class="container"> + {{ $max := .Max }} + {{ range .Stats }} + <div class="row"> + <div class="col-4"> + {{ if .Category.Valid }} + <i class="far fa-folder fa-fw"></i> + {{ else }} + <i class="far fa-file fa-fw"></i> + {{ end }} + <a href="{{ .URLs.Main }}"> + {{ .Article.Title }} + </a> + </div> + <div class="col-1"> + {{ .Count }} + </div> + <div class="col"> + <div style="width: {{ percent .Count $max }}%; height: 1em" class="bg-dark"> + </div> + </div> + </div> + {{ end }} + </table> +{{end}} diff --git a/chameleon/text.go b/chameleon/text.go new file mode 100644 index 0000000..acae133 --- /dev/null +++ b/chameleon/text.go @@ -0,0 +1,22 @@ +package chameleon + +import ( + "regexp" + "strings" +) + +var rexSafeChars = regexp.MustCompile(`[^a-zA-Z\-\.\_\/ ]`) + +// safeText sanitizes the input to be safe to pass into system calls +func safeText(s string) string { + s = rexSafeChars.ReplaceAllString(s, " ") + for strings.Contains(s, " ") { + s = strings.ReplaceAll(s, " ", " ") + } + s = strings.Trim(s, " ") + s = strings.Trim(s, "/") + for strings.Contains(s, "..") { + s = strings.ReplaceAll(s, "..", ".") + } + return s +} diff --git a/chameleon/urls.go b/chameleon/urls.go new file mode 100644 index 0000000..f8b5277 --- /dev/null +++ b/chameleon/urls.go @@ -0,0 +1,59 @@ +package chameleon + +import ( + "fmt" + "strings" +) + +const ( + githubEditURL = "https://github.com/%s/edit/%s/%s" +) + +type URLs struct { + Repository Repository + Path Path +} + +func (urls URLs) suffix() string { + s := urls.Path.Relative(urls.Repository.Path).String() + s = strings.TrimSuffix(s, Extension) + if s == "" { + return s + } + return s + "/" +} + +func (urls URLs) Main() string { + return MainPrefix + urls.suffix() +} + +func (urls URLs) Linter() string { + return LinterPrefix + urls.suffix() +} + +func (urls URLs) Commits() string { + return CommitsPrefix + urls.suffix() +} + +func (urls URLs) Raw() string { + return MainPrefix + strings.TrimSuffix(urls.suffix(), "/") + Extension +} + +func (urls URLs) Edit() (string, error) { + remote, err := urls.Repository.Remote() + if err != nil { + return "", nil + } + + if remote.Hostname() == "github.com" { + branch, err := urls.Repository.Branch() + if err != nil { + return "", err + } + repo := strings.TrimSuffix(remote.Path, ".git") + url := fmt.Sprintf(githubEditURL, repo, branch, urls.suffix()) + return url, nil + } + + return "", nil +} diff --git a/chameleon/views.go b/chameleon/views.go new file mode 100644 index 0000000..a8668c1 --- /dev/null +++ b/chameleon/views.go @@ -0,0 +1,52 @@ +package chameleon + +import ( + "encoding/binary" + + "go.etcd.io/bbolt" +) + +type Views struct { + db *bbolt.DB + path Path +} + +func (views Views) Inc() error { + err := views.db.Update(func(tx *bbolt.Tx) error { + bucket := tx.Bucket([]byte("views")) + b := bucket.Get([]byte(views.path)) + var count uint32 + if len(b) > 1 { + count = binary.BigEndian.Uint32(b) + } + b = make([]byte, 4) + binary.BigEndian.PutUint32(b, count+1) + return bucket.Put([]byte(views.path), b) + }) + return err +} + +func (views Views) Get() (uint32, error) { + var count uint32 + err := views.db.View(func(tx *bbolt.Tx) error { + bucket := tx.Bucket([]byte("views")) + b := bucket.Get([]byte(views.path)) + if len(b) > 1 { + count = binary.BigEndian.Uint32(b) + } + return nil + }) + return count, err +} + +func (views Views) All() (ViewStat, error) { + result := ViewStat{Stats: make([]*Stat, 0)} + err := views.db.View(func(tx *bbolt.Tx) error { + bucket := tx.Bucket([]byte("views")) + return bucket.ForEach(func(k, v []byte) error { + result.Add(string(k), binary.BigEndian.Uint32(v)) + return nil + }) + }) + return result, err +} diff --git a/config_example.toml b/config_example.toml deleted file mode 100644 index 72ca508..0000000 --- a/config_example.toml +++ /dev/null @@ -1,21 +0,0 @@ -listen = "127.0.0.1:1337" -root = "/" - -cache = true -contributors = true -lint = true -views = true - -[categories.rus] -repo = "orsinium/notes" -branch = "master" -dir = "notes-ru" -name = "Russian Articles" -ext = ".md" - -[categories.eng] -repo = "orsinium/notes" -branch = "master" -dir = "notes-en" -name = "English Articles" -ext = ".md" diff --git a/go.mod b/go.mod index d9b6d1a..31441be 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,26 @@ module github.com/orsinium/chameleon -go 1.13 +go 1.16 require ( github.com/BurntSushi/toml v0.3.1 + github.com/enescakir/emoji v1.0.0 // indirect github.com/errata-ai/vale v1.7.1 github.com/go-chi/chi v4.0.2+incompatible + github.com/go-co-op/gocron v0.7.1 // indirect + github.com/julienschmidt/httprouter v1.3.0 // indirect github.com/recoilme/pudge v1.0.3 github.com/recoilme/slowpoke v2.0.1+incompatible github.com/spf13/pflag v1.0.5 + github.com/stretchr/testify v1.7.0 // indirect + github.com/tdewolff/minify v2.3.6+incompatible + github.com/tdewolff/minify/v2 v2.9.15 // indirect + github.com/tdewolff/parse v2.3.4+incompatible // indirect github.com/tidwall/gjson v1.3.5 github.com/victorspringer/http-cache v0.0.0-20190721184638-fe78e97af707 + github.com/whilp/git-urls v1.0.0 // indirect + go.etcd.io/bbolt v1.3.5 // indirect + go.uber.org/multierr v1.6.0 // indirect + go.uber.org/zap v1.16.0 // indirect gopkg.in/russross/blackfriday.v2 v2.0.0 ) diff --git a/go.sum b/go.sum index 243a53f..02aac52 100644 --- a/go.sum +++ b/go.sum @@ -4,22 +4,32 @@ github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8 github.com/ValeLint/gospell v0.0.0-20160306015952-90dfc71015df h1:eaw3Zd3eAS3vnCKYwCk9o4ZcDJbI84tOL44vC9swz9o= github.com/ValeLint/gospell v0.0.0-20160306015952-90dfc71015df/go.mod h1:YyyUOeo+JX2NINtuB09ft2lvoMr4jbi07frpAv/jOaQ= github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgkuj+NQRlZcDbAbM1ORAbXjXX77sX7T289U= github.com/client9/gospell v0.0.0-20160306015952-90dfc71015df h1:XXCjxndsxMyNjoZtyuyDnzSck+h681QN7vKkK0EIVq0= github.com/client9/gospell v0.0.0-20160306015952-90dfc71015df/go.mod h1:X4IDm8zK6KavjWkfKQCet43DKeLii9nJhUK/seHoSbA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/enescakir/emoji v1.0.0 h1:W+HsNql8swfCQFtioDGDHCHri8nudlK1n5p2rHCJoog= +github.com/enescakir/emoji v1.0.0/go.mod h1:Bt1EKuLnKDTYpLALApstIkAjdDrS/8IAgTkKp+WKFD0= github.com/errata-ai/vale v1.7.1 h1:ZEKnF7dc1nnOCFM670lFsAfmZ/FThQeF4ybTpB6SsmU= github.com/errata-ai/vale v1.7.1/go.mod h1:kKk8YYvErOC9wToOqKy/XcmQjLMrcU26/l+6wEp7NxY= github.com/fatih/color v1.5.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/frankban/quicktest v1.4.1/go.mod h1:36zfPVQyHxymz4cH7wlDmVwDrJuljRB60qkgn7rorfQ= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAUcHlgXVRs= github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-co-op/gocron v0.7.1 h1:olyF7+ZKMM7bVk8oWcrAQ75Ewm1O+1U8WWvagrIQ89U= +github.com/go-co-op/gocron v0.7.1/go.mod h1:Hyge6OdrinfqhNgi1kNLnA/O7GtFsr004+Rbrgx5Ylc= github.com/gobwas/glob v0.2.2 h1:czsC5u90AkrSujyGY0l7ST7QVLEPrdoMoXxRx/hXgq0= github.com/gobwas/glob v0.2.2/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/jdkato/prose v0.0.0-20170725235243-4d68d1b77f66 h1:uJCQ0UmypG5jU5iZqojaxTy8N+PNdDkUS/Qy5dUwNjQ= github.com/jdkato/prose v0.0.0-20170725235243-4d68d1b77f66/go.mod h1:jkF0lkxaX5PFSlk9l4Gh9Y+T57TqUZziWT7uZbW5ADg= @@ -27,12 +37,16 @@ github.com/jdkato/regexp v0.0.0-20170725234532-38ab2f7842bf h1:0dDP+WVB6SiB8MUwA github.com/jdkato/regexp v0.0.0-20170725234532-38ab2f7842bf/go.mod h1:o9fY0c1vIooBxHLENPUeocgjhdBFuERi5VMDH+two4s= github.com/jdkato/syllables v0.1.0/go.mod h1:f1KhTKBE2PJvmbDxNAoXYDQABKMoPbtxszWqsf6opjs= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/levigross/grequests v0.0.0-20190130132859-37c80f76a0da/go.mod h1:uCZIhROSrVmuF/BPYFPwDeiiQ6juSLp0kikFoEcNcEs= +github.com/matryer/try v0.0.0-20161228173917-9ac251b645a2/go.mod h1:0KeJpeMD6o+O4hW7qJOT7vyQPKrWmj26uf5wMc/IiIs= github.com/mattn/go-colorable v0.0.8/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-isatty v0.0.2/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= @@ -49,6 +63,8 @@ github.com/neurosnap/sentences v1.0.6/go.mod h1:pg1IapvYpWCJJm/Etxeh0+gtMf1rI1ST github.com/nwaples/rardecode v1.0.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/olekukonko/tablewriter v0.0.0-20170719101040-be5337e7b39e/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= github.com/pierrec/lz4 v2.2.6+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/recoilme/pudge v1.0.3 h1:h/9dEv5fRqtzM4lnO69kUoN+k7ukxxrW9NGb9ug0grM= github.com/recoilme/pudge v1.0.3/go.mod h1:VMvxBLVkrSStldckzCsETBXox3pfovfrnEchafXk8qA= @@ -56,6 +72,7 @@ github.com/recoilme/slowpoke v2.0.1+incompatible h1:55UUAkmU9zOIG2Ra7I/6PJJcg11v github.com/recoilme/slowpoke v2.0.1+incompatible/go.mod h1:bAQc5fISCFURi0Gp488wpYKbsu5iLLYwofS0mg9l6xI= github.com/remeh/sizedwaitgroup v0.0.0-20161123101944-4b44541c9359 h1:Qevki/5jkGrihNHC/G/x515V0DzNqIreUS2QKTcu5lM= github.com/remeh/sizedwaitgroup v0.0.0-20161123101944-4b44541c9359/go.mod h1:3j2R4OIe/SeS6YDhICBy22RWjJC5eNCJ1V+9+NVNYlo= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday v0.0.0-20151110051855-0b647d0506a6 h1:NCdCpzq6sADll3AUyACrweBbe2AqzpU3dVepB3mEaRE= github.com/russross/blackfriday v0.0.0-20151110051855-0b647d0506a6/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/shogo82148/go-shuffle v0.0.0-20170226075450-4789c7c401f2 h1:8+n3ByPah5vdCpEZHjOQ/ZPB0d4fCQOo8c57hJSbboo= @@ -68,7 +85,21 @@ github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.1.4/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/tdewolff/minify v2.3.6+incompatible h1:2hw5/9ZvxhWLvBUnHE06gElGYz+Jv9R4Eys0XUzItYo= +github.com/tdewolff/minify v2.3.6+incompatible/go.mod h1:9Ov578KJUmAWpS6NeZwRZyT56Uf6o3Mcz9CEsg8USYs= +github.com/tdewolff/minify/v2 v2.9.15 h1:gZzGuFHvmxDjsAM6Eu53xo8A5NiaVu3gzamvHAxDpAI= +github.com/tdewolff/minify/v2 v2.9.15/go.mod h1:tK4qPnHUZgANtEGVMwTBxrF1eNIBkigHFYo7F3Y98GQ= +github.com/tdewolff/parse v2.3.4+incompatible h1:x05/cnGwIMf4ceLuDMBOdQ1qGniMoxpP46ghf0Qzh38= +github.com/tdewolff/parse v2.3.4+incompatible/go.mod h1:8oBwCsVmUkgHO8M5iCzSIDtpzXOT0WXX9cWhz+bIzJQ= +github.com/tdewolff/parse/v2 v2.5.14 h1:ftdD54vkOeLZ7VkEZxp+wZrYZyyPi43GGon5GwBTRUI= +github.com/tdewolff/parse/v2 v2.5.14/go.mod h1:WzaJpRSbwq++EIQHYIRTpbYKNA3gn9it1Ik++q4zyho= +github.com/tdewolff/test v1.0.6/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= github.com/tidwall/gjson v1.3.5 h1:2oW9FBNu8qt9jy5URgrzsVx/T/KSn3qn/smJQ0crlDQ= github.com/tidwall/gjson v1.3.5/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls= github.com/tidwall/match v1.0.1 h1:PnKP62LPNxHKTwvHHZZzdOAOCtsJTjo6dZLCwpKm5xc= @@ -79,19 +110,53 @@ github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4A github.com/urfave/cli v1.19.1/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/victorspringer/http-cache v0.0.0-20190721184638-fe78e97af707 h1:Pg/LJmFZnr+hlP9sohJKDaxi1nTSOPvGNo8dBBgRIkM= github.com/victorspringer/http-cache v0.0.0-20190721184638-fe78e97af707/go.mod h1:V7CEaXWuLs0tH3DNWqJO+GVr8YgiAwRgBh76T4LNSPU= +github.com/whilp/git-urls v1.0.0 h1:95f6UMWN5FKW71ECsXRUd3FVYiXdrE7aX4NZKcPmIjU= +github.com/whilp/git-urls v1.0.0/go.mod h1:J16SAmobsqc3Qcy98brfl5f5+e0clUvg1krgwk/qCfE= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/xrash/smetrics v0.0.0-20170218160415-a3153f7040e9 h1:w8V9v0qVympSF6GjdjIyeqR7+EVhAF9CBQmkmW7Zw0w= github.com/xrash/smetrics v0.0.0-20170218160415-a3153f7040e9/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0= +go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.16.0 h1:uFRZXykJGK9lLY4HtgSw44DnIcAM+kRBP7x5m+NpAOM= +go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a h1:DcqTD9SDLc+1P/r1EmRBwnVsrOwW+kk2vWf9n+1sGhs= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 h1:LfCXLvNmTYH9kEmVgqbnsWfruoXZIrh4YBgqVHtDvw0= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.28.0 h1:Bp9VlwLuKFwZwag5eRqc0KdIfk3I/iJfkxh4qb/rsS4= gopkg.in/ini.v1 v1.28.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/neurosnap/sentences.v1 v1.0.6 h1:v7ElyP020iEZQONyLld3fHILHWOPs+ntzuQTNPkul8E= @@ -100,3 +165,9 @@ gopkg.in/russross/blackfriday.v2 v2.0.0 h1:+FlnIV8DSQnT7NZ43hcVKcdJdzZoeCmJj4Ql8 gopkg.in/russross/blackfriday.v2 v2.0.0/go.mod h1:6sSBNz/GtOm/pJTuh5UmBK2ZHfmnxGbl2NZg1UliSOI= gopkg.in/yaml.v2 v2.0.0-20170721122051-25c4ec802a7d h1:2DX7x6HUDGZUyuEDAhUsQQNqkb1zvDyKTjVoTdzaEzo= gopkg.in/yaml.v2 v2.0.0-20170721122051-25c4ec802a7d/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= diff --git a/main.go b/main.go index 8228372..f35992c 100644 --- a/main.go +++ b/main.go @@ -2,289 +2,43 @@ package main import ( "fmt" - "log" "net/http" - "path" - "sort" - "strings" - "text/template" - "time" + "os" - "github.com/BurntSushi/toml" - "github.com/go-chi/chi" - "github.com/spf13/pflag" - cache "github.com/victorspringer/http-cache" - "github.com/victorspringer/http-cache/adapter/memory" + "github.com/orsinium/chameleon/chameleon" + "go.uber.org/zap" ) -const ( - commitsLinkT = "https://api.github.com/repos/{{.Repo}}/commits?sha={{.Branch}}&path={{.Dir}}" - rawLinkT = "https://raw.githubusercontent.com/{{.Repo}}/{{.Branch}}/{{.Dir}}" - dirAPILinkT = "https://api.github.com/repos/{{.Repo}}/contents/{{.Dir}}?ref={{.Branch}}" -) - -// Config is the TOML config with attached handlers -type Config struct { - Listen string // hostname and port to listen - Root string // prefix for all URLs - Project string // path to the project dir - Templates string // path to the templates dir - - Cache bool - Contributors bool - Lint bool - Views bool - - Categories map[string]Category -} - -func (config *Config) handleCategories(w http.ResponseWriter, r *http.Request) { - keys := make([]string, 0, len(config.Categories)) - for k := range config.Categories { - keys = append(keys, k) - } - sort.Strings(keys) - var categories []Category - var category Category - for _, key := range keys { - category = config.Categories[key] - category.Slug = key - categories = append(categories, category) - } - - t, err := template.ParseFiles( - path.Join(config.Templates, "base.html"), - path.Join(config.Templates, "categories.html"), - ) - if err != nil { - log.Fatal(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - err = t.Execute(w, categories) - if err != nil { - log.Fatal(err) - return - } -} - -func (config *Config) handleCategory(w http.ResponseWriter, r *http.Request) { - categorySlug := chi.URLParam(r, "category") - category, ok := config.Categories[categorySlug] - if !ok { - w.WriteHeader(http.StatusNotFound) - return - } - - articles, err := category.getArticles() - if err != nil { - log.Fatal(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - - t, err := template.ParseFiles( - path.Join(config.Templates, "base.html"), - path.Join(config.Templates, "category.html"), - ) - if err != nil { - log.Fatal(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - err = t.Execute(w, struct { - Articles []Article - Category Category - }{ - Articles: articles, - Category: category, - }) - if err != nil { - log.Fatal(err) - return - } -} - -func (config *Config) handleArticleRedirect(w http.ResponseWriter, r *http.Request) { - categorySlug := chi.URLParam(r, "category") - articleSlug := strings.TrimPrefix(chi.URLParam(r, "*"), "/"+categorySlug+"/") - - category, ok := config.Categories[categorySlug] - if !ok { - w.WriteHeader(http.StatusNotFound) - return - } - - // if there is no extension then it is probably article without trailing slash (/lol -> /lol/) - if !strings.ContainsRune(articleSlug, '.') { - http.Redirect(w, r, articleSlug+"/", http.StatusTemporaryRedirect) - return - } - - // if it has extension of article then it is definetly article (/lol.md -> /lol/) - if strings.HasSuffix(articleSlug, category.Ext) { - http.Redirect(w, r, strings.TrimSuffix(articleSlug, category.Ext)+"/", http.StatusTemporaryRedirect) - return - } - - // if it has different extension then it is file (/lol.jpg -> githubusercontent.com/.../lol.jpg) - link, err := category.makeLink(rawLinkT) +func run(logger *zap.Logger) error { + config := chameleon.NewConfig().Parse() + server, err := chameleon.NewServer(config, logger) if err != nil { - w.WriteHeader(http.StatusInternalServerError) - log.Fatal(err) - return + return err } - link += "/" + articleSlug - http.Redirect(w, r, link, http.StatusPermanentRedirect) - -} -func (config *Config) handleArticle(w http.ResponseWriter, r *http.Request) { - categorySlug := chi.URLParam(r, "category") - articleSlug := chi.URLParam(r, "article") - - category, ok := config.Categories[categorySlug] - if !ok { - w.WriteHeader(http.StatusNotFound) - return - } - - if strings.HasSuffix(articleSlug, category.Ext) { - newURL := fmt.Sprintf("/%s/%s/", categorySlug, strings.TrimSuffix(articleSlug, category.Ext)) - http.Redirect(w, r, newURL, http.StatusPermanentRedirect) - return - } - - category.Slug = categorySlug - file := articleSlug + category.Ext - article := Article{ - Config: config, - Category: &category, - File: file, - Slug: articleSlug, - } - err := article.updateRaw() - if err != nil { - log.Fatal(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - article.Title = article.getTitle() - article.HTML = article.getHTML() - - if config.Contributors { - err := article.updateMetaInfo() - if err != nil { - fmt.Println(err) - } - } - - if config.Lint { - err := article.updateAlerts() + defer func() { + err := server.Database.Close() if err != nil { - log.Fatal(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - } - - if config.Views { - _, err := r.Cookie("viewed") - if err != http.ErrNoCookie { - // article already viewed - err := article.updateViews() - if err != nil { - log.Fatal(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - } else { - // article hasn't been viewed yet - err := article.incrementViews() - if err != nil { - log.Fatal(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - cookie := http.Cookie{ - Name: "viewed", - Value: "1", - Domain: r.Host, - Path: fmt.Sprintf("/%s/%s/", category.Slug, article.Slug), - MaxAge: 3600 * 24, - } - http.SetCookie(w, &cookie) + logger.Error("cannot close connection", zap.Error(err)) } - } + }() - t, err := template.ParseFiles( - path.Join(config.Templates, "base.html"), - path.Join(config.Templates, "article.html"), - ) - if err != nil { - log.Fatal(err) - w.WriteHeader(http.StatusInternalServerError) - return - } - err = t.Execute(w, article) - if err != nil { - log.Fatal(err) - return - } + logger.Info("listening", zap.String("addr", config.Address)) + return http.ListenAndServe(config.Address, server) } func main() { - configPath := pflag.StringP("config", "c", "config.toml", "path to config file") - projectPath := pflag.StringP("project", "p", "./", "path to the project directory") - listen := pflag.StringP("listen", "l", "", "server and port to listen (value from config by default)") - pflag.Parse() - - var conf Config - if _, err := toml.DecodeFile(*configPath, &conf); err != nil { - log.Fatal(err) + logger, err := zap.NewDevelopment() + if err != nil { + fmt.Println(err) return } - if *listen != "" { - conf.Listen = *listen - } - conf.Project = *projectPath - conf.Templates = path.Join(conf.Project, "templates") + defer logger.Sync() // nolint - // serve static files - fileServer := http.FileServer(http.Dir(path.Join(conf.Project, "assets"))) - http.Handle(conf.Root+"assets/", http.StripPrefix(conf.Root+"assets/", fileServer)) - - // serve dynamic pages - r := chi.NewRouter() - r.Get("/", conf.handleCategories) - r.Get("/{category}/", conf.handleCategory) - r.Get("/{category}/{article}/", conf.handleArticle) - r.Get("/{category}/*", conf.handleArticleRedirect) - - if conf.Cache { - memcached, err := memory.NewAdapter( - memory.AdapterWithAlgorithm(memory.LRU), - memory.AdapterWithCapacity(20), - ) - if err != nil { - log.Fatal(err) - return - } - - cacheClient, err := cache.NewClient( - cache.ClientWithAdapter(memcached), - cache.ClientWithTTL(10*time.Minute), - ) - if err != nil { - log.Fatal(err) - return - } - - http.Handle(conf.Root, cacheClient.Middleware(r)) - } else { - http.Handle(conf.Root, r) + logger.Info("starting...") + err = run(logger) + if err != nil { + logger.Error("fatal error", zap.Error(err)) + os.Exit(1) } - - fmt.Printf("Ready to get connections on %s%s\n", conf.Listen, conf.Root) - log.Fatal(http.ListenAndServe(conf.Listen, nil)) } diff --git a/release.sh b/release.sh deleted file mode 100755 index 7f488dc..0000000 --- a/release.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash - -echo "make binary releases" -python3 build.py . - -echo "make archive" -tar --create --file chameleon.tar \ - --transform='flags=r;s|config_example|config|' \ - --transform='flags=r;s|builds/||' \ - builds templates config_example.toml chameleon.service - -echo "delete empty directory from archive" -tar --delete --file chameleon.tar builds - -echo "compress" -gzip chameleon.tar - -echo "remove binary releases" -rm -r builds diff --git a/templates/article.html b/templates/article.html deleted file mode 100644 index c738328..0000000 --- a/templates/article.html +++ /dev/null @@ -1,116 +0,0 @@ -{{define "title"}} - {{ .Title }} -{{end}} - -{{define "header"}} - <a href="/{{ .Category.Slug }}/" class="btn btn-dark"> - <i class="fas fa-chevron-left"></i> - {{ .Category.Name }} - <a> - <h1> - {{ .Title }} - </h1> -{{end}} - -{{define "content"}} - {{ .HTML }} -{{end}} - -{{define "footer"}} - <a href="https://github.com/{{.Category.Repo}}/blob/{{.Category.Branch}}/{{.Category.Dir}}/{{.File}}" class="btn btn-dark" target="_blank"> - <i class="fab fa-github"></i> - Read on Github - <a> - - <a href="https://github.com/{{.Category.Repo}}/edit/{{.Category.Branch}}/{{.Category.Dir}}/{{.File}}" class="btn btn-dark" target="_blank"> - <i class="far fa-edit"></i> - Edit - <a> - - <a href="https://github.com/{{.Category.Repo}}/issues/new" class="btn btn-dark" target="_blank"> - <i class="far fa-comment"></i> - Comment - <a> - - <a href="https://raw.githubusercontent.com/{{.Category.Repo}}/{{.Category.Branch}}/{{.Category.Dir}}/{{.File}}" class="btn btn-dark" - target="_blank"> - <i class="far fa-file-code"></i> - View source - <a> - - <div> - <a href="/{{ .Category.Slug }}/" class="btn btn-dark"> - <i class="fas fa-chevron-left"></i> - {{ .Category.Name }} - <a> - </div> -{{end}} - - -{{block "underground" .}} - {{ if .Authors }} - <div class="bg-white text-body floor"> - <div class="text-muted" style="padding-bottom: 20px"> - <span class="statblock"> - <i class="fas fa-plus"></i> - created: - {{ .CreatedAt.Format "2006-01-02 (Mon)" }} - </span> - <span class="statblock"> - <i class="far fa-edit"></i> - updated: - {{ .UpdatedAt.Format "2006-01-02 (Mon)" }} - </span> - <span class="statblock"> - <i class="far fa-eye"></i> - views: - {{ .Views }} - </span> - </div> - - <h2>Contributors</h2> - {{ range .Authors }} - <a href="https://github.com/{{ .Login }}" target="_blank" class="avatar"> - <img src="{{ .Avatar }}" alt="{{ .Login }}"> - </a> - {{end}} - </div> - {{end}} - - {{ if .Alerts }} - <div class="container-fluid bg-dark text-white footer"> - <h2>How can I contribute?</h2> - <p> - There is an output of - <a href="https://github.com/errata-ai/vale" target="_blank">vale linter</a>. - Please, help me to fix these issues to make the article better. - You can change anything, not only issues from the table. - </p> - - <div style="margin-bottom: 20px"> - <a href="https://github.com/{{.Category.Repo}}/edit/{{.Category.Branch}}/{{.Category.Dir}}/{{.File}}" class="btn btn-success" target="_blank"> - <i class="far fa-heart"></i> - Help - <a> - </div> - - <table class="table table-sm" style="text-align: left"> - <tr> - <th>line</th> - <th>level</th> - <th>message</th> - <th>check</th> - </tr> - {{ range .Alerts }} - <tr> - <td>{{ .Line }}:{{ .Span }}</td> - <td class="level-{{ .Severity }}">{{ .Severity }}</td> - <td>{{ .Message }}</td> - <td>{{ .Check }}</td> - </tr> - {{end}} - </table> - </div> - {{end}} - -{{end}} diff --git a/templates/base.html b/templates/base.html deleted file mode 100644 index ea00093..0000000 --- a/templates/base.html +++ /dev/null @@ -1,84 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> - <head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> - <meta http-equiv="x-ua-compatible" content="ie=edge"> - <meta name="description" content="Articles about Python and Go"> - <meta name="keywords" content="articles, python, go, golang, orsinium"> - <meta name="author" content="Gram (@orsinium)"> - <title>{{block "title" .}}Gram's articles{{end}} - Chameleon</title> - <link rel="shortcut icon" href="favicon.ico"> - - <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.2/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-Smlep5jCw/wG7hdkwQ/Z5nLIefveQRIY9nfy6xoR1uRYBtpZgI6339F5dgvm/e9B" crossorigin="anonymous" /> - <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.2/js/bootstrap.min.js" integrity="sha384-o+RDsa0aLu++PJvFqy8fFScvbHFLtbvScb8AjopnFD+iEQ7wo/CG0xlczd+2O/em" crossorigin="anonymous"></script> - <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.1.1/css/all.css" integrity="sha384-O8whS3fhG2OnA5Kas0Y9l3cfpmYjapjI0E4theH4iuMD+pLhbf6JI0jIMfYcK3yZ" crossorigin="anonymous" /> - - <link href="/assets/style.css" rel="stylesheet" /> - - <!-- https://realfavicongenerator.net/ --> - <link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png"> - <link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png"> - <link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png"> - <link rel="manifest" href="/assets/site.webmanifest"> - <link rel="mask-icon" href="/assets/safari-pinned-tab.svg" color="#5bbad5"> - <link rel="shortcut icon" href="/assets/favicon.ico"> - <meta name="msapplication-TileColor" content="#2c3e50"> - <meta name="msapplication-config" content="/assets/browserconfig.xml"> - <meta name="theme-color" content="#ffffff"> - </head> - - <body class="bg-dark text-white"> - - <div class="container-fluid bg-dark text-white header"> - <div class="container"> - {{block "header" .}} - {{end}} - </div> - </div> - - <div class="container-fluid bg-white text-body"> - <div class="container content"> - {{block "content" .}} - No content - {{end}} - </div> - </div> - - <div class="container-fluid bg-dark text-white footer"> - <div class="container"> - {{block "footer" .}} - {{end}} - </div> - </div> - - {{block "underground" .}} - {{end}} - - <!-- https://www.mathjax.org/ --> - <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/MathJax.js?config=TeX-MML-AM_CHTML"></script> - - <!-- https://www.bryanbraun.com/anchorjs/ --> - <script src="https://cdnjs.cloudflare.com/ajax/libs/anchor-js/4.1.1/anchor.min.js"></script> - <script> - anchors.options = { - placement: 'left', - icon: '#', - }; - anchors.add('h2'); - </script> - - <!-- https://prismjs.com/ --> - <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/themes/prism-tomorrow.min.css" /> - <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/prism.min.js"></script> - - <!-- https://cdnjs.com/libraries/prism/ --> - <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/components/prism-bash.min.js"></script> - <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/components/prism-go.min.js"></script> - <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/components/prism-json.min.js"></script> - <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/components/prism-python.min.js"></script> - <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/components/prism-r.min.js"></script> - - </body> - -</html> diff --git a/templates/categories.html b/templates/categories.html deleted file mode 100644 index 8d7de83..0000000 --- a/templates/categories.html +++ /dev/null @@ -1,23 +0,0 @@ -{{define "title"}} - Categories -{{end}} - -{{define "header"}} - <h1> - Categories - </h1> -{{end}} - -{{define "content"}} - <div class="row"> - {{range .}} - <div class="col-sm"> - <a href="/{{ .Slug }}/" class="card btn btn-secondary bg-secondary text-white"> - <div class="card-body"> - {{ .Name }} - </div> - </a> - </div> - {{end}} - </div> -{{end}} diff --git a/templates/category.html b/templates/category.html deleted file mode 100644 index 65d2836..0000000 --- a/templates/category.html +++ /dev/null @@ -1,23 +0,0 @@ -{{define "title"}} - {{ .Category.Name }} -{{end}} - -{{define "header"}} - <a href="/" class="btn btn-dark"> - <i class="fas fa-chevron-left"></i> - Categories - <a> - <h1> - {{ .Category.Name }} - </h1> -{{end}} - -{{define "content"}} - <ul> - {{range .Articles}} - <li> - <a href="{{ .Slug }}/">{{ .Title }}</a> - </li> - {{end}} - </ul> -{{end}} diff --git a/tmp.py b/tmp.py new file mode 100644 index 0000000..b2d0e5a --- /dev/null +++ b/tmp.py @@ -0,0 +1,66 @@ +import re +import sys +import tokenize +import typing +from pathlib import Path + +REX_WORD = re.compile(r'[A-Za-z]+') +# https://stackoverflow.com/a/1176023/8704691 +REX1 = re.compile(r'(.)([A-Z][a-z]+)') +REX2 = re.compile(r'([a-z0-9])([A-Z])') + + +def get_words(text: str) -> typing.Set[str]: + text = REX1.sub(r'\1 \2', text) + text = REX2.sub(r'\1 \2', text).lower() + text = text.replace('_', ' ') + return set(text.split()) + + +def get_redundant_comments(tokens: typing.Iterable[tokenize.TokenInfo]): + comment = None + code_words: typing.Set[str] = set() + for token in tokens: + if token.type == tokenize.NL: + continue + + if token.type == tokenize.COMMENT: + if comment is None: + comment = token + else: + comment = None + continue + if comment is None: + continue + + if token.start[0] == comment.start[0] + 1: + code_words.update(get_words(token.string)) + continue + + comment_words = set(REX_WORD.findall(comment.string)) + if comment_words and not comment_words - code_words: + yield comment + code_words = set() + comment = None + + +def process_file(path: Path): + with path.open('rb') as stream: + tokens = tokenize.tokenize(stream.readline) + for comment in get_redundant_comments(tokens): + print(f'{path}:{comment.start[0]} {comment.string.strip()}') + + +def get_paths(path: Path): + if path.is_dir(): + for p in path.iterdir(): + yield from get_paths(p) + elif path.suffix == '.py': + yield path + + +if __name__ == '__main__': + for path in sys.argv[1:]: + for p in get_paths(Path(path)): + # process file + process_file(p)