Add subscription manager, fix corrupt read/write & short URI panic

More info on corrupt reads and writes:
https://github.com/makeworld-the-better-one/amfora/issues/61#issuecomment-735042805

The panic was occuring due to lines like:

    if u[:6] == "about:"

These would cause panics for URIs that were shorter than 6, like "docs/".
strings.HasPrefix was used instead to fix this.
This commit is contained in:
makeworld 2020-12-05 20:35:15 -05:00
parent 62102d4f98
commit 1a2fba92c2
10 changed files with 145 additions and 41 deletions

View File

@ -20,4 +20,4 @@
- Bookmark keys aren't deleted, just set to `""`
- Waiting on [this viper PR](https://github.com/spf13/viper/pull/519) to be merged
- Help table cells aren't dynamically wrapped
- Filed [issue 29](https://gitlab.com/tslocum/cview/-/issues/29)
- Filed [issue 29](https://gitlab.com/tslocum/cview/-/issues/29)

View File

@ -165,8 +165,9 @@ Amfora ❤️ open source!
- It uses [tcell](https://github.com/gdamore/tcell) for low level terminal operations
- [Viper](https://github.com/spf13/viper) for configuration and TOFU storing
- [go-gemini](https://github.com/makeworld-the-better-one/go-gemini), my forked and updated Gemini client/server library
- My [progressbar fork](https://github.com/makeworld-the-better-one/progressbar)
- My [progressbar fork](https://github.com/makeworld-the-better-one/progressbar) - pull request [here](https://github.com/schollz/progressbar/pull/69)
- [go-humanize](https://github.com/dustin/go-humanize)
- My [gofeed fork](https://github.com/makeworld-the-better-one/gofeed) - pull request [here](https://github.com/mmcdole/gofeed/pull/164)
## License
This project is licensed under the GPL v3.0. See the [LICENSE](./LICENSE) file for details.

View File

@ -576,8 +576,11 @@ func Reload() {
// URL loads and handles the provided URL for the current tab.
// It should be an absolute URL.
func URL(u string) {
if u[:6] == "about:" {
handleAbout(tabs[curTab], u)
t := tabs[curTab]
if strings.HasPrefix(u, "about:") {
if ok := handleAbout(t, u); ok {
t.addToHistory(u)
}
return
}
@ -585,7 +588,7 @@ func URL(u string) {
// Assume it's a Gemini URL
u = "gemini://" + u
}
go goURL(tabs[curTab], u)
go goURL(t, u)
}
func NumTabs() int {

View File

@ -167,27 +167,34 @@ func handleFavicon(t *tab, host, old string) {
// 'about:'. It will display errors if the URL is not recognized,
// but not display anything if an 'about:' URL is not passed.
//
// It does not add the displayed page to history.
//
// It returns a bool indicating if the provided URL could be handled.
func handleAbout(t *tab, u string) bool {
if u[:6] != "about:" {
if !strings.HasPrefix(u, "about:") {
return false
}
switch u {
case "about:bookmarks":
Bookmarks(t)
t.addToHistory("about:bookmarks")
return true
case "about:subscriptions":
Subscriptions(t)
t.addToHistory("about:subscriptions")
return true
case "about:newtab":
temp := newTabPage // Copy
setPage(t, &temp)
t.applyBottomBar()
return true
}
if u == "about:manage-subscriptions" || (len(u) > 27 && u[:27] == "about:manage-subscriptions?") {
ManageSubscriptions(t, u)
// Don't count remove command in history
return u == "about:manage-subscriptions"
}
Error("Error", "Not a valid 'about:' URL.")
return false
}
@ -244,7 +251,7 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) {
App.SetFocus(t.view)
if u[:6] == "about:" {
if strings.HasPrefix(u, "about:") {
return ret(u, handleAbout(t, u))
}

View File

@ -18,7 +18,7 @@ You can customize this page by creating a gemtext file called newtab.gmi, in Amf
Happy browsing!
=> about:bookmarks Bookmarks
=> about:subscriptions Feed and Page Tracking
=> about:subscriptions Subscriptions
=> //gemini.circumlunar.space Project Gemini
=> https://github.com/makeworld-the-better-one/amfora Amfora homepage [HTTPS]

View File

@ -19,8 +19,10 @@ import (
// Not when a URL is opened on a new tab for the first time.
// It will handle setting the bottomBar.
func followLink(t *tab, prev, next string) {
if next[:6] == "about:" {
handleAbout(t, next)
if strings.HasPrefix(next, "about:") {
if ok := handleAbout(t, next); ok {
t.addToHistory(next)
}
return
}

View File

@ -15,6 +15,7 @@ import (
"github.com/makeworld-the-better-one/amfora/renderer"
"github.com/makeworld-the-better-one/amfora/structs"
"github.com/makeworld-the-better-one/amfora/subscriptions"
"github.com/makeworld-the-better-one/go-gemini"
"github.com/mmcdole/gofeed"
"github.com/spf13/viper"
)
@ -28,7 +29,7 @@ func toLocalDay(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
// Feeds displays the feeds page on the current tab.
// Subscriptions displays the subscriptions page on the current tab.
func Subscriptions(t *tab) {
logger.Log.Println("display.Subscriptions called")
@ -43,9 +44,10 @@ func Subscriptions(t *tab) {
logger.Log.Println("started rendering subscriptions page")
subscriptionPageRaw := "# Subscriptions\n\n" +
rawPage := "# Subscriptions\n\n" +
"See the help (by pressing ?) for details on how to use this page.\n\n" +
"If you just opened Amfora then updates will appear incrementally. Reload the page to see them.\n"
"If you just opened Amfora then updates will appear incrementally. Reload the page to see them.\n\n" +
"=> about:manage-subscriptions Manage subscriptions\n"
// curDay represents what day of posts the loop is on.
// It only goes backwards in time.
@ -67,21 +69,21 @@ func Subscriptions(t *tab) {
if pub.Before(curDay) {
// This post is on a new day, add a day header
curDay = pub
subscriptionPageRaw += fmt.Sprintf("\n## %s\n\n", curDay.Format("Jan 02, 2006"))
rawPage += fmt.Sprintf("\n## %s\n\n", curDay.Format("Jan 02, 2006"))
}
if entry.Title == "" || entry.Title == "/" {
// Just put author/title
// Mainly used for when you're tracking the root domain of a site
subscriptionPageRaw += fmt.Sprintf("=>%s %s\n", entry.URL, entry.Prefix)
rawPage += fmt.Sprintf("=>%s %s\n", entry.URL, entry.Prefix)
} else {
// Include title and dash
subscriptionPageRaw += fmt.Sprintf("=>%s %s - %s\n", entry.URL, entry.Prefix, entry.Title)
rawPage += fmt.Sprintf("=>%s %s - %s\n", entry.URL, entry.Prefix, entry.Title)
}
}
content, links := renderer.RenderGemini(subscriptionPageRaw, textWidth(), leftMargin(), false)
content, links := renderer.RenderGemini(rawPage, textWidth(), leftMargin(), false)
page := structs.Page{
Raw: subscriptionPageRaw,
Raw: rawPage,
Content: content,
Links: links,
URL: "about:subscriptions",
@ -97,6 +99,57 @@ func Subscriptions(t *tab) {
logger.Log.Println("done rendering subscriptions page")
}
// ManageSubscriptions displays the subscription managing page in
// the current tab. `u` is the URL entered by the user.
func ManageSubscriptions(t *tab, u string) {
if len(u) > 27 && u[:27] == "about:manage-subscriptions?" {
// There's a query string, aka a URL to unsubscribe from
manageSubscriptionQuery(t, u)
return
}
rawPage := "# Manage Subscriptions\n\n" +
"Below is list of URLs, both feeds and pages. Navigate to the link to unsubscribe from that feed or page.\n\n"
for _, u2 := range subscriptions.AllURLS() {
rawPage += fmt.Sprintf(
"=>%s %s\n",
"about:manage-subscriptions?"+gemini.QueryEscape(u2),
u2,
)
}
content, links := renderer.RenderGemini(rawPage, textWidth(), leftMargin(), false)
page := structs.Page{
Raw: rawPage,
Content: content,
Links: links,
URL: "about:manage-subscriptions",
Width: termW,
Mediatype: structs.TextGemini,
}
go cache.AddPage(&page)
setPage(t, &page)
t.applyBottomBar()
}
func manageSubscriptionQuery(t *tab, u string) {
sub, err := gemini.QueryUnescape(u[27:])
if err != nil {
Error("URL Error", "Invalid query string: "+err.Error())
return
}
err = subscriptions.Remove(sub)
if err != nil {
ManageSubscriptions(t, "about:manage-subscriptions") // Reload
Error("Save Error", "Error saving the unsubscription to disk: "+err.Error())
return
}
ManageSubscriptions(t, "about:manage-subscriptions") // Reload
Info("Unsubscribed from " + sub)
}
// openSubscriptionModal displays the "Add subscription" modal
// It returns whether the user wanted to subscribe to feed/page.
// The subscribed arg specifies whether this feed/page is already

View File

@ -146,9 +146,8 @@ func (t *tab) pageDown() {
t.view.ScrollTo(row+(termH/4)*3, col)
}
// hasContent returns true when the tab has a page that could be displayed.
// The most likely situation where false would be returned is when the default
// new tab content is being displayed.
// hasContent returns false when the tab's page is malformed,
// has no content or URL, or if it's an 'about:' page.
func (t *tab) hasContent() bool {
if t.page == nil || t.view == nil {
return false

View File

@ -73,8 +73,7 @@ type pageJSON struct {
var data = jsonData{
feedMu: &sync.RWMutex{},
pageMu: &sync.RWMutex{},
Feeds: make(map[string]*gofeed.Feed),
Pages: make(map[string]*pageJSON),
// Maps are created in Init()
}
// PageEntry is a single item on a subscriptions page.

View File

@ -6,10 +6,12 @@ import (
"errors"
"fmt"
"io"
"io/ioutil"
"mime"
"os"
"path"
"reflect"
"sort"
"strings"
"sync"
"time"
@ -42,20 +44,29 @@ func Init() error {
f, err := os.Open(config.SubscriptionPath)
if err == nil {
// File exists and could be opened
defer f.Close()
fi, err := f.Stat()
if err == nil && fi.Size() > 0 {
// File is not empty
dec := json.NewDecoder(f)
err = dec.Decode(&data)
if err != nil && err != io.EOF {
jsonBytes, err := ioutil.ReadAll(f)
f.Close()
if err != nil {
return fmt.Errorf("read subscriptions.json error: %w", err)
}
err = json.Unmarshal(jsonBytes, &data)
if err != nil {
return fmt.Errorf("subscriptions.json is corrupted: %w", err) //nolint:goerr113
}
}
f.Close()
} else if !os.IsNotExist(err) {
// There's an error opening the file, but it's not bc is doesn't exist
return fmt.Errorf("open subscriptions.json error: %w", err) //nolint:goerr113
} else {
// File does not exist, initialize maps
data.Feeds = make(map[string]*gofeed.Feed)
data.Pages = make(map[string]*pageJSON)
}
LastUpdated = time.Now()
@ -130,26 +141,21 @@ func writeJSON() error {
writeMu.Lock()
defer writeMu.Unlock()
f, err := os.OpenFile(config.SubscriptionPath, os.O_WRONLY|os.O_CREATE, 0666)
data.Lock()
logger.Log.Println("subscriptions.writeJSON acquired data lock")
jsonBytes, err := json.MarshalIndent(&data, "", " ")
data.Unlock()
if err != nil {
logger.Log.Println("subscriptions.writeJSON error", err)
return err
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
data.Lock()
logger.Log.Println("subscriptions.writeJSON acquired data lock")
err = enc.Encode(&data)
data.Unlock()
err = ioutil.WriteFile(config.SubscriptionPath, jsonBytes, 0666)
if err != nil {
logger.Log.Println("subscriptions.writeJSON error", err)
return err
}
return err
return nil
}
// AddFeed stores a feed.
@ -363,3 +369,37 @@ func updateAll() {
wg.Wait()
}
// AllURLs returns all the subscribed-to URLS, sorted alphabetically.
func AllURLS() []string {
data.RLock()
defer data.RUnlock()
urls := make([]string, len(data.Feeds)+len(data.Pages))
i := 0
for k := range data.Feeds {
urls[i] = k
i++
}
for k := range data.Pages {
urls[i] = k
i++
}
sort.Strings(urls)
return urls
}
// Remove removes a subscription from memory and from the disk.
// The URL must be provided. It will do nothing if the URL is
// not an actual subscription.
//
// It returns any errors that occured when saving to disk.
func Remove(u string) error {
data.Lock()
// Just delete from both instead of using a loop to find it
delete(data.Feeds, u)
delete(data.Pages, u)
data.Unlock()
return writeJSON()
}