holy shit its almost done
Jes Olson j3s@c3f.net
Mon, 20 Mar 2023 04:15:14 -0700
12 files changed,
390 insertions(+),
153 deletions(-)
A
files/err.tmpl.html
@@ -0,0 +1,8 @@
+{{ define "err" }} +{{ template "head" . }} +{{ template "nav" . }} +<p> +{{ if .Data }} +<pre>{{ .Data }}</pre> +{{ end }} +{{ end }}
A
files/feeds.tmpl.html
@@ -0,0 +1,47 @@
+{{ define "feeds" }} +{{ template "head" . }} +{{ template "nav" . }} +{{ if .LoggedIn }} +{{ $length := len .Data }} +{{ if eq $length 1 }} +<p>you're subscribed to 1 feed +{{ else if eq $length 0 }} +<p>you haven't subscribed to any feeds yet ⁉️ +<p>here's some urls to play with. copypasta them into the text box and click update. +<pre> +https://100r.co/links/rss.xml +https://begriffs.com/atom.xml +https://cyberia.club/blog/blog.xml +https://davebucklin.com/feed.xml +https://herman.bearblog.dev/feed/ +https://j3s.sh/feed.atom +https://katherine.cox-buday.com/rss.xml +https://sequentialread.com/rss +</pre> +{{ else }} +<p>you're subscribed to {{ len .Data }} feeds 🗿 +{{ end }} +<form method="POST" action="/feeds/submit"> +<textarea name="submit" rows="10" cols="50"> +{{ range .Data -}} +{{ .UpdateURL }} +{{ end -}} +</textarea> +<br> +<input type="submit" value="update feeds"> +</form> +{{ range .Data }} +<pre> +==> {{ .Title }} + + title: {{ .Title }} + url: {{ .UpdateURL }} + items: {{ len .Items }} + +</pre> +{{ end }} +{{ else }} + <p>⚠️ unauthorized: you are not logged in +{{ end }} +{{ template "tail" . }} +{{ end }}
A
files/head.tmpl.html
@@ -0,0 +1,12 @@
+{{ define "head" }} +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0"> + <title>{{ .Title }}</title> +<style> +{{ .StyleSheet }} +</style> +</head> +{{end}}
A
files/index.tmpl.html
@@ -0,0 +1,40 @@
+{{ define "index" }} +{{ template "head" . }} +{{ template "nav" . }} +<pre> + hey! hi. you. + yes, you. + + you might be wondering what's going on. + i'm here to help clear things up. + + first of all: where are we? what sort + of new-york-times-ass looking pile of + shit has just rendered on your liquid + crystal display? + + don't worry, it's all quite simple. + + that piece of shit has a name. + and that name is vore. + + + vore - a feed reader + + features: + - minimal & reliable + - handles rss & atom feeds + - updates all feeds periodically + - shows you a date-ordered list of posts + - open source & free of charge forever + (and not in the shitty open core kind of way) + - <a href="https://j3s.sh">jes</a> built it + + anti-features: + - no tags + - no options + - no unread indicators or push notifs + - no comments, upvotes, or ranks +</pre> +{{ template "tail" . }} +{{ end }}
A
files/style.css
@@ -0,0 +1,31 @@
+html { + font-family: monospace; + max-width: 500px; + margin: 10px auto; + /* make sure the scrollbar is always shown so + the width doesnt change between pages */ + overflow-y: scroll; + font-size: 120%; +} + +h2 { + text-align: center; +} + +.float-right { + float: right; +} + +nav { + font-weight: bold; +} + +nav a { + text-decoration: underline; + color: #000; +} + +nav a:hover { + background-color: #000; + color: #fff; +}
A
files/user.tmpl.html
@@ -0,0 +1,16 @@
+{{ define "user" }} +{{ template "head" . }} +{{ template "nav" . }} + +<!-- <h3> {{ .Data.User }}'s feed</h3> --> +{{ $length := len .Data.Items }} {{ if eq $length 0 }} +<p> no feeds found 😭</p> +<p> consider <a href="/feeds">adding some</a></p> +{{ end }} + +{{ range .Data.Items }} +<p> <a href="{{ .Link }}">{{ .Title }}</a> +{{ end }} + +{{ template "tail" . }} +{{ end }}
D
http.go
@@ -1,31 +0,0 @@
-package main - -import ( - "net/http" - "strings" -) - -func internalServerError(w http.ResponseWriter, details string) { - status := "oopsie woopsie, uwu\n" - status += "we made a fucky wucky!!\n\n" - status += "500 internal server error: " + details - http.Error(w, status, http.StatusInternalServerError) -} - -// methodAllowed takes an http w/r, and returns true if the -// http requests method is in teh allowedMethods list. -// if methodNotAllowed returns false, it has already -// written a request & it's on the caller to close it. -func methodAllowed(w http.ResponseWriter, r *http.Request, allowedMethods ...string) bool { - allowed := false - for _, m := range allowedMethods { - if m == r.Method { - allowed = true - } - } - if allowed == false { - w.Header().Set("Allow", strings.Join(allowedMethods, ", ")) - http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed) - } - return allowed -}
M
reaper/reaper.go
→
reaper/reaper.go
@@ -39,7 +39,7 @@ // Make initial url list
urls := r.db.GetAllFeedURLs() for _, url := range urls { - // Setting UpdateURL lets us defer the actual fetching + // Setting UpdateURL lets us defer fetching feed := rss.Feed{ UpdateURL: url, }@@ -47,45 +47,57 @@ r.feeds = append(r.feeds, feed)
} for { - r.UpdateAll() + r.updateAll() time.Sleep(2 * time.Hour) } } -// Add fetches the given feed url and appends it to r.Feeds -// If the given URL is already in reaper.Feeds, Add will do nothing -func (r *Reaper) Add(url string) error { - for i := range r.feeds { - if r.feeds[i].UpdateURL == url { - return nil - } +// Add the given rss feed to Reaper for maintenance. +// If the given feed is already in reaper.Feeds, Add does nothing. +func (r *Reaper) addFeed(f rss.Feed) { + if !r.HasFeed(f.UpdateURL) { + r.mu.Lock() + r.feeds = append(r.feeds, f) + r.mu.Unlock() } - - feed, err := rss.Fetch(url) - if err != nil { - return err - } - - r.mu.Lock() - r.feeds = append(r.feeds, *feed) - r.mu.Unlock() - - return nil } // UpdateAll fetches every feed & attempts updating them -func (r *Reaper) UpdateAll() { +// asynchronously, then prints the duration of the sync +func (r *Reaper) updateAll() { start := time.Now() fmt.Printf("reaper: fetching %d feeds\n", len(r.feeds)) + + var wg sync.WaitGroup + wg.Add(len(r.feeds)) for i := range r.feeds { - err := r.feeds[i].Update() - if err != nil { - fmt.Println(err) - // TODO: write err to db? + go func(i int) { + defer wg.Done() + r.updateFeed(&r.feeds[i]) + }(i) + } + go func() { + wg.Wait() + fmt.Printf("reaper: fetched %d feeds in %s\n", len(r.feeds), time.Since(start)) + }() +} + +// updateFeed triggers a fetch on the given feed, +// and sets a fetch error in the db if there is one. +func (r *Reaper) updateFeed(f *rss.Feed) { + err := f.Update() + r.db.SetFeedFetchError(f.UpdateURL, err) +} + +// Have checks whether a given url is represented +// in the reaper cache. +func (r *Reaper) HasFeed(url string) bool { + for i := range r.feeds { + if r.feeds[i].UpdateURL == url { + return true } } - fmt.Printf("reaper: fetched %d feeds in %s\n", - len(r.feeds), time.Since(start)) + return false } // GetUserFeeds returns a list of feeds@@ -110,7 +122,7 @@ return f[i].Title < f[j].Title
}) } -func (r *Reaper) SortFeedItems(f []rss.Feed) []rss.Item { +func (r *Reaper) SortFeedItemsByDate(f []rss.Feed) []rss.Item { var posts []rss.Item for _, f := range f { for _, i := range f.Items {@@ -118,9 +130,21 @@ posts = append(posts, *i)
} } - // magick slice sorter by date sort.Slice(posts, func(i, j int) bool { return posts[i].Date.After(posts[j].Date) }) return posts } + +// FetchFeed attempts to fetch a feed from a given url, marshal +// it into a feed object, and add it to Reaper. +func (r *Reaper) Fetch(url string) error { + feed, err := rss.Fetch(url) + if err != nil { + return err + } + + r.addFeed(*feed) + + return nil +}
M
site.go
→
site.go
@@ -2,13 +2,17 @@ package main
import ( "fmt" - "io" + "io/ioutil" "net/http" + "net/url" + "path/filepath" "strings" + "text/template" "git.j3s.sh/vore/lib" "git.j3s.sh/vore/reaper" "git.j3s.sh/vore/sqlite" + "github.com/SlyMarbo/rss" "golang.org/x/crypto/bcrypt" )@@ -37,25 +41,18 @@ return &s
} func (s *Site) indexHandler(w http.ResponseWriter, r *http.Request) { - if !methodAllowed(w, r, "GET") { + if !s.methodAllowed(w, r, "GET") { return } if s.loggedIn(r) { - username := s.username(r) - fmt.Fprintf(w, `<!DOCTYPE html> - <title>%s</title> - <p> { %s <a href=/logout>logout</a> } - <p> <a href="/%s">view feeds</a> - <p> <a href="/feeds">edit feeds</a>`, s.title, username, username) + http.Redirect(w, r, "/"+s.username(r), http.StatusSeeOther) } else { - fmt.Fprintf(w, `<!DOCTYPE html> - <title>%s</title> - <a href="/login">login</a>`, s.title) + s.renderPage(w, r, "index", nil) } } func (s *Site) loginHandler(w http.ResponseWriter, r *http.Request) { - if !methodAllowed(w, r, "GET", "POST") { + if !s.methodAllowed(w, r, "GET", "POST") { return } if r.Method == "GET" {@@ -97,7 +94,7 @@ }
// TODO: make this take a POST only in accordance w/ some spec func (s *Site) logoutHandler(w http.ResponseWriter, r *http.Request) { - if !methodAllowed(w, r, "GET", "POST") { + if !s.methodAllowed(w, r, "GET", "POST") { return } http.SetCookie(w, &http.Cookie{@@ -108,136 +105,114 @@ http.Redirect(w, r, "/", http.StatusSeeOther)
} func (s *Site) registerHandler(w http.ResponseWriter, r *http.Request) { - if !methodAllowed(w, r, "POST") { + if !s.methodAllowed(w, r, "POST") { return } username := r.FormValue("username") password := r.FormValue("password") err := s.register(username, password) if err != nil { - internalServerError(w, "failed to register user") + s.renderErr(w, err.Error(), http.StatusInternalServerError) return } err = s.login(w, username, password) if err != nil { - internalServerError(w, "extremely weird login error") + s.renderErr(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/", http.StatusSeeOther) } func (s *Site) userHandler(w http.ResponseWriter, r *http.Request) { - if !methodAllowed(w, r, "GET") { + if !s.methodAllowed(w, r, "GET") { return } - fmt.Fprintf(w, `<!DOCTYPE html> - <title>%s</title>`, s.title) username := strings.TrimPrefix(r.URL.Path, "/") - feeds := s.reaper.GetUserFeeds(username) - if len(feeds) == 0 { - fmt.Fprintf(w, "%s has no feeds 😭", username) - return + items := s.reaper.SortFeedItemsByDate(s.reaper.GetUserFeeds(username)) + data := struct { + User string + Items []rss.Item + }{ + User: username, + Items: items, } - sortedItems := s.reaper.SortFeedItems(feeds) - for i := range sortedItems { - fmt.Fprintf(w, `<p><a href="%s">%s</a>`, - sortedItems[i].Link, sortedItems[i].Title) - } + s.renderPage(w, r, "user", data) + } -// [ ] GET /feeds -// -// > if no feeds, /discover for ideas -// pretty-print your feeds -// <text box with pre-populated list of your feed urls, one per line> -// button: validate -// POST /feeds/validate -// logged out: unauthorized. click here to login. func (s *Site) feedsHandler(w http.ResponseWriter, r *http.Request) { - if !methodAllowed(w, r, "GET") { + if !s.methodAllowed(w, r, "GET") { return } - fmt.Fprintf(w, `<!DOCTYPE html> - <title>%s</title>`, s.title) - if !s.loggedIn(r) { - fmt.Fprintf(w, - `<p>⚠️ you are not logged in⚠️ - <p>please click the skull: <a href="/login">💀</a>`) - return + var feeds []rss.Feed + if s.loggedIn(r) { + feeds = s.reaper.GetUserFeeds(s.username(r)) } + s.renderPage(w, r, "feeds", feeds) - feeds := s.reaper.GetUserFeeds(s.username(r)) - fmt.Fprintf(w, `<pre>you are subscribed to %d feeds</pre>`, len(feeds)) - for _, feed := range feeds { - fmt.Fprintf(w, ` -<details> -<summary>%s</summary> -<pre> -title: %s -url: %s -posts: %d -</pre> -</details>`, feed.Title, feed.Title, feed.UpdateURL, len(feed.Items)) - } - fmt.Fprintf(w, `<pre>add/remove feed URLs to this box to change your subscriptions</pre> - <form method="POST" action="/feeds/submit"> - <textarea name="submit" rows="10" cols="72">`) - for _, feed := range feeds { - fmt.Fprintf(w, "%s\n", feed.UpdateURL) - } - fmt.Fprintf(w, `</textarea> - <br> - <input type="submit" value="update feeds"> - </form>`) // TODO: textbox with feed.URL // TODO: validate button } +// TODO: +// +// show diff before submission (like tf plan) +// check if feed exists in db already? +// validate that title exists func (s *Site) feedsSubmitHandler(w http.ResponseWriter, r *http.Request) { - if !methodAllowed(w, r, "POST") { + if !s.methodAllowed(w, r, "POST") { return } if !s.loggedIn(r) { - http.Error(w, "401 unauthorized", 401) + s.renderErr(w, "", http.StatusUnauthorized) return } - inputData := r.FormValue("submit") - if inputData == "" { - http.Error(w, "400 bad request: you must submit data", 400) - return - } + + // validate user input + var validatedURLs []string + for _, inputURL := range strings.Split(r.FormValue("submit"), "\r\n") { + inputURL = strings.TrimSpace(inputURL) + inputURL = strings.ToLower(inputURL) + if inputURL == "" { + continue + } - // TODO: validate user input moar - feeds := strings.Split(inputData, "\r\n") - for _, feed := range feeds { - // TODO: show diff before submission (like tf plan) - // TODO: check if feed exists in db already? - // TODO: validate that title exists - if feed == "" { + // if the entry is already in reaper, don't validate + if s.reaper.HasFeed(inputURL) { + validatedURLs = append(validatedURLs, inputURL) continue } - err := s.reaper.Add(feed) - if err == io.EOF { - http.Error(w, "400 bad request: could not fetch "+feed, 400) - fmt.Println(err) + if _, err := url.ParseRequestURI(inputURL); err != nil { + e := fmt.Sprintf("can't parse url '%s': %s", inputURL, err) + s.renderErr(w, e, http.StatusBadRequest) return } + validatedURLs = append(validatedURLs, inputURL) + } + + // write to reaper + db + for _, u := range validatedURLs { + // if it's in reaper, it's in the db, safe to skip + if s.reaper.HasFeed(u) { + continue + } + err := s.reaper.Fetch(u) if err != nil { - http.Error(w, "400 bad request: "+err.Error(), 400) - fmt.Println(err) + e := fmt.Sprintf("reaper: can't fetch '%s' %s", u, err) + s.renderErr(w, e, http.StatusBadRequest) return } + s.db.WriteFeed(u) } + // subscribe to all listed feeds exclusively s.db.UnsubscribeAll(s.username(r)) - for _, feed := range feeds { - s.db.WriteFeed(feed) - s.db.Subscribe(s.username(r), feed) + for _, url := range validatedURLs { + s.db.Subscribe(s.username(r), url) } - http.Redirect(w, r, "/feeds", http.StatusSeeOther) }@@ -295,3 +270,79 @@
s.db.AddUser(username, string(hashedPassword)) return nil } + +// renderPage renders the given page and passes data to the +// template execution engine. it's normally the last thing a +// handler should do tbh. +func (s *Site) renderPage(w http.ResponseWriter, r *http.Request, page string, data any) { + tmplFiles := filepath.Join("files", "*.tmpl.html") + tmpl := template.Must(template.ParseGlob(tmplFiles)) + + // we read the stylesheet in order to render it inline + cssFile := filepath.Join("files", "style.css") + stylesheet, err := ioutil.ReadFile(cssFile) + if err != nil { + panic(err) + } + + // fields on this anon struct are generally + // pulled out of Data when they're globally required + // callers should jam anything they want into Data + pageData := struct { + Title string + Username string + LoggedIn bool + StyleSheet string + Data any + }{ + Title: page + " | " + s.title, + Username: s.username(r), + LoggedIn: s.loggedIn(r), + StyleSheet: string(stylesheet), + Data: data, + } + + err = tmpl.ExecuteTemplate(w, page, pageData) + if err != nil { + s.renderErr(w, err.Error(), http.StatusInternalServerError) + return + } +} + +// renderErr sets the correct http status in the header, +// optionally decorates certain errors, then renders the err page +func (s *Site) renderErr(w http.ResponseWriter, error string, code int) { + var prefix string + switch code { + case http.StatusBadRequest: + prefix = "400 bad request\n" + case http.StatusUnauthorized: + prefix = "401 unauthorized\n" + case http.StatusMethodNotAllowed: + prefix = "405 method not allowed\n" + prefix += "request method: " + case http.StatusInternalServerError: + prefix = "(╥﹏╥) oopsie woopsie, uwu\n" + prefix += "we made a fucky wucky (╥﹏╥)\n\n" + prefix += "500 internal server error\n" + } + http.Error(w, prefix+error, code) +} + +// methodAllowed takes an http w/r, and returns true if the +// http requests method is in teh allowedMethods list. +// if methodNotAllowed returns false, it has already +// written a request & it's on the caller to close it. +func (s *Site) methodAllowed(w http.ResponseWriter, r *http.Request, allowedMethods ...string) bool { + allowed := false + for _, m := range allowedMethods { + if m == r.Method { + allowed = true + } + } + if allowed == false { + w.Header().Set("Allow", strings.Join(allowedMethods, ", ")) + s.renderErr(w, r.Method, http.StatusMethodNotAllowed) + } + return allowed +}
M
sqlite/sql.go
→
sqlite/sql.go
@@ -132,7 +132,7 @@ }
func (s *DB) GetAllFeedURLs() []string { // TODO: BAD SELECT STATEMENT!! SORRY :( --wesley - rows, err := s.sql.Query("SELECT * FROM feed") + rows, err := s.sql.Query("SELECT url FROM feed") if err != nil { panic(err) }@@ -141,7 +141,7 @@
var urls []string for rows.Next() { var url string - err = rows.Scan(&sql.RawBytes{}, &url, &sql.RawBytes{}, &sql.RawBytes{}) + err = rows.Scan(&url) if err != nil { panic(err) }@@ -161,6 +161,9 @@ FROM feed f
JOIN subscribe s ON f.id = s.feed_id JOIN user u ON s.user_id = u.id WHERE u.id = ?`, uid) + if err == sql.ErrNoRows { + return []string{} + } if err != nil { panic(err) }@@ -205,3 +208,16 @@ if err != nil {
panic(err) } } + +// WriteFeed writes an rss feed to the database for permanent storage +// if the given feed already exists, WriteFeed does nothing. +func (s *DB) SetFeedFetchError(url string, fetchErr error) { + var errStr string + if fetchErr != nil { + errStr = fetchErr.Error() + } + _, err := s.sql.Exec(`UPDATE feed SET fetch_error=? WHERE url=?`, errStr, url) + if err != nil { + panic(err) + } +}