implement reaper
Jes Olson j3s@c3f.net
Sun, 19 Mar 2023 02:38:56 -0700
8 files changed,
345 insertions(+),
93 deletions(-)
M
go.mod
→
go.mod
@@ -3,11 +3,13 @@
go 1.20 require ( + github.com/SlyMarbo/rss v1.0.5 github.com/glebarez/go-sqlite v1.21.0 golang.org/x/crypto v0.7.0 ) require ( + github.com/axgle/mahonia v0.0.0-20180208002826-3358181d7394 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.3.0 // indirect github.com/mattn/go-isatty v0.0.17 // indirect
M
go.sum
→
go.sum
@@ -1,3 +1,7 @@
+github.com/SlyMarbo/rss v1.0.5 h1:DPcZ4aOXXHJ5yNLXY1q/57frIixMmAvTtLxDE3fsMEI= +github.com/SlyMarbo/rss v1.0.5/go.mod h1:w6Bhn1BZs91q4OlEnJVZEUNRJmlbFmV7BkAlgCN8ofM= +github.com/axgle/mahonia v0.0.0-20180208002826-3358181d7394 h1:OYA+5W64v3OgClL+IrOD63t4i/RW7RqrAVl9LTZ9UqQ= +github.com/axgle/mahonia v0.0.0-20180208002826-3358181d7394/go.mod h1:Q8n74mJTIgjX4RBBcHnJ05h//6/k6foqmgE45jTQtxg= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/glebarez/go-sqlite v1.21.0 h1:b8MHPtBagkSD2gntImZPsG3o3QEXgMDxguW/GLUonHQ=
M
main.go
→
main.go
@@ -1,7 +1,7 @@
package main import ( - "log" + "fmt" "net/http" "strings" )@@ -25,10 +25,11 @@ return
} http.NotFound(w, r) }) + mux.HandleFunc("/feeds", s.feedsHandler) mux.HandleFunc("/login", s.loginHandler) mux.HandleFunc("/logout", s.logoutHandler) mux.HandleFunc("/register", s.registerHandler) - log.Println("listening on http://localhost:5544") - log.Fatal(http.ListenAndServe(":5544", mux)) + fmt.Printf("vore: listening on http://localhost:5544") + panic(http.ListenAndServe(":5544", mux)) }
M
readme
→
readme
@@ -1,5 +1,7 @@
todo + [ ] GET /discover + (similar to sourcehut's "featured projects") [ ] GET / logged out: describe what vore is & why it's cool, tell ppl to sign up@@ -30,21 +32,40 @@ update
add new user & hashed password to database register failure: redirect to /login TODO: validate user input - [ ] GET /{username} - display a users feed items by date, maybe in a table? - -> there is a button to view the feed an article came from + [ ] GET /feeds + > if no feeds, /discover for ideas pretty-print your feeds - <text box with pre-populated list of feeds from {username}> - display a text box pre-populated with the list of feeds that make up {username} - if you don't know what feeds to use, check out /discover for ideas + <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. - if validated, display locked list of feeds with button: submit - [ ] POST /feeds - range over submitted feeds & validate them - success: redir to /feeds/validate - fail: user input err or whatever it's called + [ ] POST /feeds/validate + make sure each url is resolvable + check if feed exists in db already + if it does, do nothing + if it doesn't, attempt fetching it + TODO: validate that title exists + redir -> /feeds/validate + logged out: 401 unauthorized + + [ ] GET /feeds/validate + shows feed diff + "if this all looks correct, hit submit:" + button: submit + POST /feeds/submit + logged out: 401 unauthorized + + [ ] POST /feeds/submit + writes desired feeds to database + subscribes user to feeds + redir -> /feeds + logged out: 401 unauthorized + + [ ] GET /{username} + display a users feed items by date, maybe in a table? + -> there is a button to view the feed an article came from extra: tool for looking up feed from website@@ -60,3 +81,18 @@ user (id, username, password, session_token, created_at)
feed (id, url, fetch_error, created_at, created_by) subscribe (id, user, feed, created_at, created_by) + + + + + + + + + + + + + + +
A
reaper/reaper.go
@@ -0,0 +1,121 @@
+package reaper + +import ( + "fmt" + "sort" + "sync" + "time" + + "git.j3s.sh/vore/sqlite" + "github.com/SlyMarbo/rss" +) + +type Reaper struct { + // internal list of all rss feeds where the map + // key represents the primary id of the key in the db + feeds []rss.Feed + + // this mutex is used for locking writes to Feeds + mu sync.Mutex + + db *sqlite.DB +} + +func Summon(db *sqlite.DB) *Reaper { + var feeds []rss.Feed + + reaper := Reaper{ + feeds: feeds, + mu: sync.Mutex{}, + db: db, + } + return &reaper +} + +func (r *Reaper) Start() { + fmt.Println("reaper: starting") + + // Make initial url list + urls := r.db.GetAllFeedURLs() + + for _, url := range urls { + // Setting UpdateURL lets us defer the actual fetching + feed := rss.Feed{ + UpdateURL: url, + } + r.feeds = append(r.feeds, feed) + } + + for { + r.UpdateAll() + time.Sleep(12 * time.Hour) + } +} + +// Add fetches the given feed url, appends it to r.Feeds, and +// flushes it to the database +func (r *Reaper) Add(url string) error { + feed, err := rss.Fetch(url) + if err != nil { + return err + } + + r.mu.Lock() + r.feeds = append(r.feeds, *feed) + r.mu.Unlock() + + r.db.WriteFeed(feed) + return nil +} + +// FlushAll flushes all feeds to the database +func (r *Reaper) FlushAll() { + // TODO: do we need this? + for _, feed := range r.feeds { + r.db.WriteFeed(&feed) + } +} + +// UpdateAll fetches every feed & attempts updating them +func (r *Reaper) UpdateAll() { + start := time.Now() + fmt.Printf("reaper: fetching %d feeds\n", len(r.feeds)) + for i := range r.feeds { + err := r.feeds[i].Update() + if err != nil { + fmt.Println(err) + // TODO: write err to db? + } + } + fmt.Printf("reaper: fetched %d feeds in %s\n", + len(r.feeds), time.Since(start)) +} + +// GetUserFeeds returns a list of feeds +func (r *Reaper) GetUserFeeds(username string) []rss.Feed { + urls := r.db.GetUserFeedURLs(username) + var result []rss.Feed + for i := range r.feeds { + for _, url := range urls { + if r.feeds[i].UpdateURL == url { + result = append(result, r.feeds[i]) + } + } + } + return result +} + +func (r *Reaper) SortFeedItems(f []rss.Feed) []rss.Item { + var posts []rss.Item + for _, f := range f { + for _, i := range f.Items { + 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 +}
M
site.go
→
site.go
@@ -5,7 +5,8 @@ "fmt"
"net/http" "strings" - "git.j3s.sh/vore/auth" + "git.j3s.sh/vore/lib" + "git.j3s.sh/vore/reaper" "git.j3s.sh/vore/sqlite" "golang.org/x/crypto/bcrypt" )@@ -14,6 +15,9 @@ type Site struct {
// title of the website title string + // contains every single feed + reaper *reaper.Reaper + // site database handle db *sqlite.DB }@@ -21,10 +25,13 @@
// New returns a fully populated & ready for action Site func New() *Site { title := "vore" + db := sqlite.New(title + ".db") s := Site{ - title: title, - db: sqlite.New(title + ".db"), + title: title, + reaper: reaper.Summon(db), + db: db, } + go s.reaper.Start() return &s }@@ -33,9 +40,12 @@ if !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> }`, s.title, s.username(r)) + <p> { %s <a href=/logout>logout</a> } + <p> <a href="/%s">view your feed</a> + <p> <a href="/feeds">edit your feed</a>`, s.title, username, username) } else { fmt.Fprintf(w, `<!DOCTYPE html> <title>%s</title>@@ -52,16 +62,22 @@ if s.loggedIn(r) {
fmt.Fprintf(w, "you are already logged in :3\n") } else { fmt.Fprintf(w, `<!DOCTYPE html> - <pre>login</pre> + <h3>login</h3> <form method="POST" action="/login"> <label for="username">username:</label> <input type="text" name="username" required><br> <label for="password">password:</label> <input type="password" name="password" required><br> <input type="submit" value="login"> - </form> - <p>if you want to register a new account, click the tree: - <a href="/register">π³</a>`) + </form>`) + fmt.Fprintf(w, `<h3>register</h3> + <form method="POST" action="/register"> + <label for="username">username:</label> + <input type="text" name="username" required><br> + <label for="password">password:</label> + <input type="password" name="password" required><br> + <input type="submit" value="register"> + </form>`) } } if r.Method == "POST" {@@ -71,9 +87,7 @@
err := s.login(w, username, password) if err != nil { fmt.Fprintf(w, `<!DOCTYPE html> - <p>β οΈ incorrect username/password β οΈ - <p>to register a new account, click the skull: - <a href="/register">π</a>`) + <p>π unauthorized: %s`, err) return } http.Redirect(w, r, "/", http.StatusSeeOther)@@ -93,68 +107,75 @@ http.Redirect(w, r, "/", http.StatusSeeOther)
} func (s *Site) registerHandler(w http.ResponseWriter, r *http.Request) { - if !methodAllowed(w, r, "GET", "POST") { + if !methodAllowed(w, r, "POST") { return } - - if r.Method == "GET" { - fmt.Fprintf(w, `<!DOCTYPE html> - <pre>register</pre> - <form method="POST" action="/register"> - <label for="username">username:</label> - <input type="text" name="username" required><br> - <label for="password">password:</label> - <input type="password" name="password" required><br> - <input type="submit" value="login"> - </form>`) + username := r.FormValue("username") + password := r.FormValue("password") + err := s.register(username, password) + if err != nil { + internalServerError(w, "failed to register user") + return } - - if r.Method == "POST" { - username := r.FormValue("username") - password := r.FormValue("password") - err := s.register(username, password) - if err != nil { - internalServerError(w, "failed to register user") - return - } - err = s.login(w, username, password) - if err != nil { - internalServerError(w, "extremely weird login error") - return - } - http.Redirect(w, r, "/", http.StatusSeeOther) + err = s.login(w, username, password) + if err != nil { + internalServerError(w, "extremely weird login error") + return } + http.Redirect(w, r, "/", http.StatusSeeOther) } func (s *Site) userHandler(w http.ResponseWriter, r *http.Request) { if !methodAllowed(w, r, "GET") { return } + fmt.Fprintf(w, "<!DOCTYPE html>") + username := strings.TrimPrefix(r.URL.Path, "/") + feeds := s.reaper.GetUserFeeds(username) + if len(feeds) == 0 { + fmt.Fprintf(w, "%s has no feeds π", username) + return + } - fmt.Fprintf(w, `<!DOCTYPE html> - <p>%s's feeds:`, username) - // feeds := s.db.GetFeeds(username) - // for _, feed := range feeds { - // fmt.Fprintf(w, "<p>%s", feed) - // } + sortedItems := s.reaper.SortFeedItems(feeds) + for i := range sortedItems { + fmt.Fprintf(w, `<p><a href="%s">%s</a>`, + sortedItems[i].Link, sortedItems[i].Title) + } +} - // print the list of feeds +// [ ] 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") { + return + } - // if s.loggedIn(r) { - // // TODO: render user template - // } else { - // // TODO: render user template - // } - // fmt.Fprintf(w, `<!DOCTYPE html> - // - // <title>%s</title> - // <p> { %s <a href=/logout>logout</a> }`, s.title, s.username(r)) - // - // fmt.Fprintf(w, `<!DOCTYPE html> - // - // <title>%s</title> - // <a href="/login">login</a>`, s.title) + if !s.loggedIn(r) { + fmt.Fprintf(w, `<!DOCTYPE html> + <p>β οΈ you are not logged inβ οΈ + <p>please click the skull: <a href="/login">π</a>`) + return + } + + username := s.username(r) + fmt.Fprintf(w, `<!DOCTYPE html> + <title>%s</title> + <p>%s's feeds:`, s.title, username) + feeds := s.reaper.GetUserFeeds(username) + for _, feed := range feeds { + fmt.Fprintf(w, "<p>%s", feed.Title) + fmt.Fprintf(w, "<p>%s", feed.Link) + } + // TODO: textbox with feed.URL + // TODO: validate button } // username fetches a client's username based@@ -179,21 +200,21 @@
// login compares the sqlite password field against the user supplied password and // sets a session token against the supplied writer. func (s *Site) login(w http.ResponseWriter, username string, password string) error { - storedPassword := s.db.GetPassword(username) - if storedPassword == "" { - return fmt.Errorf("blank stored password") - } if username == "" { return fmt.Errorf("username cannot be nil") } if password == "" { return fmt.Errorf("password cannot be nil") } + if !s.db.UserExists(username) { + return fmt.Errorf("user does not exist") + } + storedPassword := s.db.GetPassword(username) err := bcrypt.CompareHashAndPassword([]byte(storedPassword), []byte(password)) if err != nil { return fmt.Errorf("invalid password") } - sessionToken := auth.GenerateSessionToken() + sessionToken := lib.GenerateSessionToken() s.db.SetSessionToken(username, sessionToken) http.SetCookie(w, &http.Cookie{ Name: "session_token",
M
sqlite/sql.go
→
sqlite/sql.go
@@ -4,6 +4,7 @@ import (
"database/sql" "log" + "github.com/SlyMarbo/rss" _ "github.com/glebarez/go-sqlite" )@@ -33,9 +34,8 @@ }
// feed _, err = db.Exec(`CREATE TABLE IF NOT EXISTS feed ( id INTEGER PRIMARY KEY AUTOINCREMENT, - url TEXT NOT NULL, + url TEXT UNIQUE NOT NULL, fetch_error TEXT, - created_by TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP )`) if err != nil {@@ -44,20 +44,33 @@ }
// subscribe _, err = db.Exec(`CREATE TABLE IF NOT EXISTS subscribe ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - feed_id TEXT NOT NULL, - created_by TEXT NOT NULL, + user_id INTEGER NOT NULL, + feed_id INTEGER NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP )`) if err != nil { panic(err) } - wrapper := DB{ - sql: db, + // TODO: remove these, they're for testing + _, err = db.Exec("INSERT INTO feed (url) VALUES (?)", "https://j3s.sh/feed.atom") + if err != nil { + panic(err) + } + _, err = db.Exec("INSERT INTO feed (url) VALUES (?)", "https://sequentialread.com/rss/") + if err != nil { + panic(err) + } + _, err = db.Exec("INSERT INTO subscribe (user_id, feed_id) VALUES (1, 1)") + if err != nil { + panic(err) + } + _, err = db.Exec("INSERT INTO subscribe (user_id, feed_id) VALUES (1, 2)") + if err != nil { + panic(err) } - return &wrapper + return &DB{sql: db} } // TODO: think more about errors@@ -112,14 +125,68 @@ }
return true } -func (s *DB) GetFeeds(username string) bool { - var result string - err := s.sql.QueryRow("SELECT username FROM user WHERE username=?", username).Scan(&result) - if err == sql.ErrNoRows { - return false +func (s *DB) GetAllFeedURLs() []string { + // TODO: BAD SELECT STATEMENT!! SORRY :( --wesley + rows, err := s.sql.Query("SELECT * FROM feed") + if err != nil { + panic(err) + } + defer rows.Close() + + var urls []string + for rows.Next() { + var url string + err = rows.Scan(&sql.RawBytes{}, &url, &sql.RawBytes{}, &sql.RawBytes{}) + if err != nil { + panic(err) + } + urls = append(urls, url) + } + return urls +} + +func (s *DB) GetUserFeedURLs(username string) []string { + uid := s.GetUserID(username) + + // this query returns sql rows representing the list of + // rss feed urls the user is subscribed to + rows, err := s.sql.Query(` + SELECT f.url + 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 != nil { + panic(err) + } + defer rows.Close() + + var urls []string + for rows.Next() { + var url string + err = rows.Scan(&url) + if err != nil { + panic(err) + } + urls = append(urls, url) + } + return urls +} + +func (s *DB) GetUserID(username string) int { + var uid int + err := s.sql.QueryRow("SELECT id FROM user WHERE username=?", username).Scan(&uid) + if err != nil { + panic(err) } + return uid +} + +// WriteFeed writes an rss feed to the database for permanent storage +func (s *DB) WriteFeed(f *rss.Feed) { + _, err := s.sql.Exec(`INSERT INTO feed(url) VALUES(?) + ON CONFLICT(url) DO UPDATE SET url=?`, f.Link, f.Link) if err != nil { panic(err) } - return true }