Beeg refactor for database and users and auth

Amolith created

Change summary

.gitignore                            |   2 
cmd/cli.go                            | 109 +++++++++++++
cmd/willow.go                         | 181 +++++++++++++++++++++++
db/db.go                              |  51 ++++++
db/project.go                         |  83 ++++++++++
db/release.go                         |  62 +++++++
db/sql/schema.sql                     |  46 +++++
db/users.go                           |  84 ++++++++++
git/git.go                            |  75 ++++++--
go.mod                                |  39 +++-
go.sum                                |  98 ++++++++---
justfile                              |  35 +++-
main.go                               | 228 -----------------------------
project/project.go                    | 201 +++++++++++++++++++++++++
releases.go                           |  85 ----------
rss/rss.go                            |  29 ++
users/users.go                        |  92 +++++++++++
ws/static/home.html                   |   0 
ws/static/home.html.license           |   0 
ws/static/login.html                  |  30 +++
ws/static/login.html.license          |   0 
ws/static/new.html                    |   0 
ws/static/new.html.license            |   0 
ws/static/select-release.html         |   0 
ws/static/select-release.html.license |   3 
ws/ws.go                              |  61 +++++--
26 files changed, 1,186 insertions(+), 408 deletions(-)

Detailed changes

.gitignore 🔗

@@ -5,5 +5,7 @@
 /willow
 /*.csv
 /data/
+/*.sqlite
+/config.toml
 
 /.idea/

cmd/cli.go 🔗

@@ -0,0 +1,109 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+	"database/sql"
+	"fmt"
+	"os"
+	"syscall"
+
+	"git.sr.ht/~amolith/willow/users"
+	"golang.org/x/term"
+)
+
+// createUser is a CLI that creates a new user with the specified username
+func createUser(dbConn *sql.DB, username string) {
+	fmt.Println("Creating user", username)
+
+	fmt.Print("Enter password: ")
+	password, err := term.ReadPassword(int(syscall.Stdin))
+	if err != nil {
+		fmt.Println("Error reading password:", err)
+		os.Exit(1)
+	}
+	fmt.Println()
+
+	fmt.Print("Confirm password: ")
+	passwordConfirmation, err := term.ReadPassword(int(syscall.Stdin))
+	if err != nil {
+		fmt.Println("Error reading password confirmation:", err)
+		os.Exit(1)
+	}
+	fmt.Println()
+
+	if string(password) != string(passwordConfirmation) {
+		fmt.Println("Passwords do not match")
+		os.Exit(1)
+	}
+	err = users.Register(dbConn, username, string(password))
+	if err != nil {
+		fmt.Println("Error creating user:", err)
+		os.Exit(1)
+	}
+
+	fmt.Println("\nUser", username, "created successfully")
+	os.Exit(0)
+}
+
+// deleteUser is a CLI that deletes a user with the specified username
+func deleteUser(dbConn *sql.DB, username string) {
+	fmt.Println("Deleting user", username)
+	err := users.Delete(dbConn, username)
+	if err != nil {
+		fmt.Println("Error deleting user:", err)
+		os.Exit(1)
+	}
+
+	fmt.Printf("User %s deleted successfully\n", username)
+	os.Exit(0)
+}
+
+// listUsers is a CLI that lists all users in the database
+func listUsers(dbConn *sql.DB) {
+	fmt.Println("Listing all users")
+
+	dbUsers, err := users.GetUsers(dbConn)
+	if err != nil {
+		fmt.Println("Error retrieving users from the database:", err)
+		os.Exit(1)
+	}
+
+	if len(dbUsers) == 0 {
+		fmt.Println("- No users found")
+	} else {
+		for _, u := range dbUsers {
+			fmt.Println("-", u)
+		}
+	}
+	os.Exit(0)
+}
+
+// checkAuthorised is a CLI that checks whether the provided user/password
+// combo is authorised.
+func checkAuthorised(dbConn *sql.DB, username string) {
+	fmt.Printf("Checking whether password for user %s is correct\n", username)
+
+	fmt.Print("Enter password: ")
+	password, err := term.ReadPassword(int(syscall.Stdin))
+	if err != nil {
+		fmt.Println("Error reading password:", err)
+		os.Exit(1)
+	}
+	fmt.Println()
+
+	authorised, err := users.Authorised(dbConn, username, string(password))
+	if err != nil {
+		fmt.Println("Error checking authorisation:", err)
+		os.Exit(1)
+	}
+
+	if authorised {
+		fmt.Println("User is authorised")
+	} else {
+		fmt.Println("User is not authorised")
+	}
+	os.Exit(0)
+}

cmd/willow.go 🔗

@@ -0,0 +1,181 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package main
+
+import (
+	"errors"
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+	"sync"
+
+	"git.sr.ht/~amolith/willow/db"
+	"git.sr.ht/~amolith/willow/project"
+	"git.sr.ht/~amolith/willow/ws"
+
+	"github.com/BurntSushi/toml"
+	flag "github.com/spf13/pflag"
+)
+
+type (
+	Config struct {
+		Server      server
+		CSVLocation string
+		DBConn      string
+		// TODO: Make cache location configurable
+		// CacheLocation string
+		FetchInterval int
+	}
+
+	server struct {
+		Listen string
+	}
+)
+
+var (
+	flagConfig          = flag.StringP("config", "c", "config.toml", "Path to config file")
+	flagAddUser         = flag.StringP("add", "a", "", "Username of account to add")
+	flagDeleteUser      = flag.StringP("deleteuser", "d", "", "Username of account to delete")
+	flagCheckAuthorised = flag.StringP("validatecredentials", "v", "", "Username of account to check")
+	flagListUsers       = flag.BoolP("listusers", "l", false, "List all users")
+	config              Config
+	req                 = make(chan struct{})
+	res                 = make(chan []project.Project)
+	manualRefresh       = make(chan struct{})
+)
+
+//goland:noinspection GoUnusedFunction
+func main() {
+	flag.Parse()
+
+	err := checkConfig()
+	if err != nil {
+		log.Fatalln(err)
+	}
+
+	fmt.Println("Opening database at", config.DBConn)
+
+	dbConn, err := db.Open(config.DBConn)
+	if err != nil {
+		fmt.Println("Error opening database:", err)
+		os.Exit(1)
+	}
+
+	fmt.Println("Verifying database schema")
+	err = db.VerifySchema(dbConn)
+	if err != nil {
+		fmt.Println("Error verifying database schema:", err)
+		fmt.Println("Attempting to load schema")
+		err = db.LoadSchema(dbConn)
+		if err != nil {
+			fmt.Println("Error loading schema:", err)
+			os.Exit(1)
+		}
+	}
+	fmt.Println("Database schema verified")
+
+	if len(*flagAddUser) > 0 && len(*flagDeleteUser) == 0 && !*flagListUsers && len(*flagCheckAuthorised) == 0 {
+		createUser(dbConn, *flagAddUser)
+		os.Exit(0)
+	} else if len(*flagAddUser) == 0 && len(*flagDeleteUser) > 0 && !*flagListUsers && len(*flagCheckAuthorised) == 0 {
+		deleteUser(dbConn, *flagDeleteUser)
+		os.Exit(0)
+	} else if len(*flagAddUser) == 0 && len(*flagDeleteUser) == 0 && *flagListUsers && len(*flagCheckAuthorised) == 0 {
+		listUsers(dbConn)
+		os.Exit(0)
+	} else if len(*flagAddUser) == 0 && len(*flagDeleteUser) == 0 && !*flagListUsers && len(*flagCheckAuthorised) > 0 {
+		checkAuthorised(dbConn, *flagCheckAuthorised)
+		os.Exit(0)
+	}
+
+	fmt.Println("Starting refresh loop")
+	go project.RefreshLoop(dbConn, config.FetchInterval, &manualRefresh, &req, &res)
+
+	var mutex sync.Mutex
+
+	wsHandler := ws.Handler{
+		DbConn:        dbConn,
+		Mutex:         &mutex,
+		Req:           &req,
+		Res:           &res,
+		ManualRefresh: &manualRefresh,
+	}
+
+	mux := http.NewServeMux()
+	mux.HandleFunc("/", wsHandler.RootHandler)
+	mux.HandleFunc("/static", ws.StaticHandler)
+	mux.HandleFunc("/new", wsHandler.NewHandler)
+	mux.HandleFunc("/login", wsHandler.LoginHandler)
+
+	httpServer := &http.Server{
+		Addr:    config.Server.Listen,
+		Handler: mux,
+	}
+
+	fmt.Println("Starting web server on", config.Server.Listen)
+	if err := httpServer.ListenAndServe(); errors.Is(err, http.ErrServerClosed) {
+		fmt.Println("Web server closed")
+		os.Exit(0)
+	} else {
+		fmt.Println(err)
+		os.Exit(1)
+	}
+}
+
+func checkConfig() error {
+	file, err := os.Open(*flagConfig)
+	if err != nil {
+		if os.IsNotExist(err) {
+			file, err = os.Create(*flagConfig)
+			if err != nil {
+				return err
+			}
+			defer file.Close()
+
+			_, err = file.WriteString(`# Path to SQLite database
+DBConn = "willow.sqlite"
+# How often to fetch new releases in seconds
+FetchInterval = 3600
+
+[Server]
+# Address to listen on
+Listen = "127.0.0.1:1313"
+				`)
+			if err != nil {
+				return err
+			}
+
+			fmt.Println("Config file created at", *flagConfig)
+			fmt.Println("Please edit it and restart the server")
+			os.Exit(0)
+		} else {
+			return err
+		}
+	}
+	defer file.Close()
+
+	_, err = toml.DecodeFile(*flagConfig, &config)
+	if err != nil {
+		return err
+	}
+
+	if config.FetchInterval < 10 {
+		fmt.Println("Fetch interval is set to", config.FetchInterval, "seconds, but the minimum is 10, using 10")
+		config.FetchInterval = 10
+	}
+
+	if config.Server.Listen == "" {
+		fmt.Println("No listen address specified, using 127.0.0.1:1313")
+		config.Server.Listen = "127.0.0.1:1313"
+	}
+
+	if config.DBConn == "" {
+		fmt.Println("No SQLite path specified, using \"willow.sqlite\"")
+		config.DBConn = "willow.sqlite"
+	}
+
+	return nil
+}

db/db.go 🔗

@@ -0,0 +1,51 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package db
+
+import (
+	"database/sql"
+	"embed"
+
+	_ "modernc.org/sqlite"
+)
+
+// Embed the schema into the binary
+//
+//go:embed sql
+var embeddedSQL embed.FS
+
+// Open opens a connection to the SQLite database
+func Open(dbPath string) (*sql.DB, error) {
+	return sql.Open("sqlite", dbPath)
+}
+
+func VerifySchema(dbConn *sql.DB) error {
+	tables := []string{
+		"users",
+		"sessions",
+		"projects",
+	}
+
+	for _, table := range tables {
+		name := ""
+		err := dbConn.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&name)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// LoadSchema loads the schema into the database
+func LoadSchema(dbConn *sql.DB) error {
+	schema, err := embeddedSQL.ReadFile("sql/schema.sql")
+	if err != nil {
+		return err
+	}
+
+	_, err = dbConn.Exec(string(schema))
+
+	return err
+}

db/project.go 🔗

@@ -0,0 +1,83 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package db
+
+import "database/sql"
+
+// CreateProject adds a project to the database
+func CreateProject(db *sql.DB, url, name, forge, running string) error {
+	_, err := db.Exec("INSERT INTO projects (url, name, forge, version) VALUES (?, ?, ?, ?)", url, name, forge, running)
+	return err
+}
+
+// DeleteProject deletes a project from the database
+func DeleteProject(db *sql.DB, url string) error {
+	_, err := db.Exec("DELETE FROM projects WHERE url = ?", url)
+	if err != nil {
+		return err
+	}
+	_, err = db.Exec("DELETE FROM releases WHERE project_url = ?", url)
+	return err
+}
+
+// GetProject returns a project from the database
+func GetProject(db *sql.DB, url string) (map[string]string, error) {
+	var name, forge, version string
+	err := db.QueryRow("SELECT name, forge, version FROM projects WHERE url = ?", url).Scan(&name, &forge, &version)
+	if err != nil {
+		return nil, err
+	}
+	project := map[string]string{
+		"name":    name,
+		"url":     url,
+		"forge":   forge,
+		"version": version,
+	}
+	return project, nil
+}
+
+// UpdateProject updates an existing project in the database
+func UpdateProject(db *sql.DB, url, name, forge, running string) error {
+	_, err := db.Exec("UPDATE projects SET name=?, forge=?, version=? WHERE url=?", name, forge, running, url)
+	return err
+}
+
+// UpsertProject adds or updates a project in the database
+func UpsertProject(db *sql.DB, url, name, forge, running string) error {
+	_, err := db.Exec(`INSERT INTO projects (url, name, forge, version)
+		VALUES (?, ?, ?, ?)
+		ON CONFLICT(url) DO 
+			UPDATE SET
+				name = excluded.name,
+				forge = excluded.forge,
+				version = excluded.version;`, url, name, forge, running)
+	return err
+}
+
+// GetProjects returns a list of all projects in the database
+func GetProjects(db *sql.DB) ([]map[string]string, error) {
+	rows, err := db.Query("SELECT name, url, forge, version FROM projects")
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var projects []map[string]string
+	for rows.Next() {
+		var name, url, forge, version string
+		err = rows.Scan(&name, &url, &forge, &version)
+		if err != nil {
+			return nil, err
+		}
+		project := map[string]string{
+			"name":    name,
+			"url":     url,
+			"forge":   forge,
+			"version": version,
+		}
+		projects = append(projects, project)
+	}
+	return projects, nil
+}

db/release.go 🔗

@@ -0,0 +1,62 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package db
+
+import "database/sql"
+
+// AddRelease adds a release for a project with a given URL to the database
+
+// DeleteRelease deletes a release for a project with a given URL from the database
+
+// UpdateRelease updates a release for a project with a given URL in the database
+
+// UpsertRelease adds or updates a release for a project with a given URL in the
+// database
+func UpsertRelease(db *sql.DB, projectURL, releaseURL, tag, content, date string) error {
+	_, err := db.Exec(`INSERT INTO releases (project_url, release_url, tag, content, date)
+		VALUES (?, ?, ?, ?, ?)
+		ON CONFLICT(release_url) DO 
+			UPDATE SET
+				release_url = excluded.release_url,
+				content = excluded.content,
+				tag = excluded.tag,
+				content = excluded.content,
+				date = excluded.date;`, projectURL, releaseURL, tag, content, date)
+	return err
+}
+
+// GetRelease returns a release for a project with a given URL from the database
+
+// GetReleases returns all releases for a project with a given URL from the database
+func GetReleases(db *sql.DB, projectURL string) ([]map[string]string, error) {
+	rows, err := db.Query(`SELECT project_url, release_url, tag, content, date FROM releases WHERE project_url = ?`, projectURL)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	releases := make([]map[string]string, 0)
+	for rows.Next() {
+		var (
+			projectURL string
+			releaseURL string
+			tag        string
+			content    string
+			date       string
+		)
+		err := rows.Scan(&projectURL, &releaseURL, &tag, &content, &date)
+		if err != nil {
+			return nil, err
+		}
+		releases = append(releases, map[string]string{
+			"projectURL": projectURL,
+			"releaseURL": releaseURL,
+			"tag":        tag,
+			"content":    content,
+			"date":       date,
+		})
+	}
+	return releases, nil
+}

db/sql/schema.sql 🔗

@@ -0,0 +1,46 @@
+-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+--
+-- SPDX-License-Identifier: CC0-1.0
+
+-- Create table of users with username, password hash, salt, and creation
+-- timestamp
+CREATE TABLE users (
+    username       VARCHAR(255)  NOT         NULL,
+    hash           VARCHAR(255)  NOT         NULL,
+    salt           VARCHAR(255)  NOT         NULL,
+    created_at     TIMESTAMP     NOT         NULL   DEFAULT  CURRENT_TIMESTAMP,
+    PRIMARY        KEY           (username)
+);
+
+-- Create table of sessions with session GUID, username, and timestamp of when
+-- the session was created
+CREATE TABLE sessions (
+    token        VARCHAR(255)  NOT          NULL,
+    username     VARCHAR(255)  NOT          NULL,
+    expires      TIMESTAMP     NOT          NULL,
+    created_at   TIMESTAMP     NOT          NULL   DEFAULT  CURRENT_TIMESTAMP,
+    PRIMARY      KEY           (token)
+);
+
+-- Create table of tracked projects with URL, name, forge, running version, and
+-- timestamp of when the project was added
+CREATE TABLE projects (
+    url         VARCHAR(255)  NOT    NULL,
+    name        VARCHAR(255)  NOT    NULL,
+    forge       VARCHAR(255)  NOT    NULL,
+    version     VARCHAR(255)  NOT    NULL,
+    created_at  TIMESTAMP     NOT    NULL   DEFAULT  CURRENT_TIMESTAMP,
+    PRIMARY     KEY           (url)
+);
+
+-- Create table of project releases with the project URL and the release tags,
+-- contents, URLs, and dates
+CREATE TABLE releases (
+    project_url  VARCHAR(255)  NOT    NULL,
+    release_url  VARCHAR(255)  NOT    NULL,
+    tag          VARCHAR(255)  NOT    NULL,
+    content      TEXT          NOT    NULL,
+    date         TIMESTAMP     NOT    NULL,
+    created_at   TIMESTAMP     NOT    NULL   DEFAULT  CURRENT_TIMESTAMP,
+    PRIMARY      KEY           (release_url)
+);

db/users.go 🔗

@@ -0,0 +1,84 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package db
+
+import (
+	"database/sql"
+	"time"
+)
+
+// DeleteUser deletes specific user from the database and returns an error if it
+// fails
+func DeleteUser(db *sql.DB, user string) error {
+	_, err := db.Exec("DELETE FROM users WHERE username = ?", user)
+	return err
+}
+
+// CreateUser creates a new user in the database and returns an error if it fails
+func CreateUser(db *sql.DB, username, hash, salt string) error {
+	_, err := db.Exec("INSERT INTO users (username, hash, salt) VALUES (?, ?, ?)", username, hash, salt)
+	return err
+}
+
+// GetUser returns a user's hash and salt from the database as strings and
+// returns an error if it fails
+func GetUser(db *sql.DB, username string) (string, string, error) {
+	var hash, salt string
+	err := db.QueryRow("SELECT hash, salt FROM users WHERE username = ?", username).Scan(&hash, &salt)
+	return hash, salt, err
+}
+
+// GetUsers returns a list of all users in the database as a slice of strings
+// and returns an error if it fails
+func GetUsers(db *sql.DB) ([]string, error) {
+	rows, err := db.Query("SELECT username FROM users")
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var users []string
+	for rows.Next() {
+		var user string
+		err = rows.Scan(&user)
+		if err != nil {
+			return nil, err
+		}
+		users = append(users, user)
+	}
+
+	return users, nil
+}
+
+// GetSession accepts a session ID and returns the username associated with it
+// and an error
+func GetSession(db *sql.DB, session string) (string, time.Time, error) {
+	var username string
+	var expiresString string
+	err := db.QueryRow("SELECT username, expires FROM sessions WHERE token = ?", session).Scan(&username, &expiresString)
+	if err != nil {
+		return "", time.Time{}, err
+	}
+
+	expires, err := time.Parse(time.RFC3339, expiresString)
+	if err != nil {
+		return "", time.Time{}, err
+	}
+	return username, expires, nil
+}
+
+// InvalidateSession invalidates a session by setting the expiration date to the
+// provided time.
+func InvalidateSession(db *sql.DB, session string, expiry time.Time) error {
+	_, err := db.Exec("UPDATE sessions SET expires = ? WHERE token = ?", expiry.Format(time.RFC3339), session)
+	return err
+}
+
+// CreateSession creates a new session in the database and returns an error if
+// it fails
+func CreateSession(db *sql.DB, username, token string, expiry time.Time) error {
+	_, err := db.Exec("INSERT INTO sessions (token, username, expires) VALUES (?, ?, ?)", token, username, expiry.Format(time.RFC3339))
+	return err
+}

git.go → git/git.go 🔗

@@ -2,19 +2,36 @@
 //
 // SPDX-License-Identifier: Apache-2.0
 
-package main
+package git
 
 import (
 	"errors"
+	"fmt"
+	"net/url"
 	"os"
 	"sort"
 	"strings"
+	"time"
+
+	"github.com/microcosm-cc/bluemonday"
 
 	"github.com/go-git/go-git/v5"
 	"github.com/go-git/go-git/v5/plumbing"
 	"github.com/go-git/go-git/v5/plumbing/transport"
 )
 
+type Release struct {
+	Tag     string
+	Content string
+	URL     string
+	Date    time.Time
+}
+
+var (
+	bmUGC    = bluemonday.UGCPolicy()
+	bmStrict = bluemonday.StrictPolicy()
+)
+
 // listRemoteTags lists all tags in a remote repository, whether HTTP(S) or SSH.
 // func listRemoteTags(url string) (tags []string, err error) {
 // 	// TODO: Implement listRemoteTags
@@ -22,37 +39,51 @@ import (
 // 	return nil, nil
 // }
 
-// fetchReleases fetches all releases in a remote repository, whether HTTP(S) or SSH.
-func getGitReleases(p project) (project, error) {
-	r, err := minimalClone(p.URL)
+// GetReleases fetches all releases in a remote repository, whether HTTP(S) or
+// SSH.
+func GetReleases(gitURI, forge string) ([]Release, error) {
+	r, err := minimalClone(gitURI)
 	if err != nil {
-		return p, err
+		return nil, err
 	}
 	tagRefs, err := r.Tags()
 	if err != nil {
-		return p, err
+		return nil, err
 	}
+
+	parsedURI, err := url.Parse(gitURI)
+	if err != nil {
+		fmt.Println("Error parsing URI: " + err.Error())
+	}
+
+	var httpURI string
+	if parsedURI.Scheme != "" {
+		httpURI = parsedURI.Host + parsedURI.Path
+	}
+
+	releases := make([]Release, 0)
+
 	err = tagRefs.ForEach(func(tagRef *plumbing.Reference) error {
 		obj, err := r.TagObject(tagRef.Hash())
-		switch err {
-		case plumbing.ErrObjectNotFound:
+		switch {
+		case errors.Is(err, plumbing.ErrObjectNotFound):
 			// This is a lightweight tag, not an annotated tag, skip it
 			return nil
-		case nil:
-			url := ""
+		case err == nil:
+			tagURL := ""
 			tagName := bmStrict.Sanitize(tagRef.Name().Short())
-			switch p.Forge {
+			switch forge {
 			case "sourcehut":
-				url = p.URL + "/refs/" + tagName
+				tagURL = "https://" + httpURI + "/refs/" + tagName
 			case "gitlab":
-				url = p.URL + "/-/releases/" + tagName
+				tagURL = "https://" + httpURI + "/-/releases/" + tagName
 			default:
-				url = ""
+				tagURL = ""
 			}
-			p.Releases = append(p.Releases, release{
+			releases = append(releases, Release{
 				Tag:     tagName,
 				Content: bmUGC.Sanitize(obj.Message),
-				URL:     url,
+				URL:     tagURL,
 				Date:    obj.Tagger.When,
 			})
 		default:
@@ -61,12 +92,12 @@ func getGitReleases(p project) (project, error) {
 		return nil
 	})
 	if err != nil {
-		return p, err
+		return nil, err
 	}
 
-	sort.Slice(p.Releases, func(i, j int) bool { return p.Releases[i].Date.After(p.Releases[j].Date) })
+	sort.Slice(releases, func(i, j int) bool { return releases[i].Date.After(releases[j].Date) })
 
-	return p, nil
+	return releases, nil
 }
 
 // minimalClone clones a repository with a depth of 1 and no checkout.
@@ -86,7 +117,7 @@ func minimalClone(url string) (r *git.Repository, err error) {
 			Depth:      1,
 			Tags:       git.AllTags,
 		})
-		if err == git.NoErrAlreadyUpToDate {
+		if errors.Is(err, git.NoErrAlreadyUpToDate) {
 			return r, nil
 		}
 		return r, err
@@ -101,8 +132,8 @@ func minimalClone(url string) (r *git.Repository, err error) {
 	return r, err
 }
 
-// removeRepo removes a repository from the local filesystem.
-func removeRepo(url string) (err error) {
+// RemoveRepo removes a repository from the local filesystem.
+func RemoveRepo(url string) (err error) {
 	path, err := stringifyRepo(url)
 	if err != nil {
 		return err

go.mod 🔗

@@ -8,41 +8,58 @@ go 1.20
 
 require (
 	github.com/BurntSushi/toml v1.3.2
-	github.com/go-git/go-git/v5 v5.8.0
+	github.com/go-git/go-git/v5 v5.9.0
 	github.com/microcosm-cc/bluemonday v1.0.25
 	github.com/mmcdole/gofeed v1.2.1
 	github.com/spf13/pflag v1.0.5
+	golang.org/x/crypto v0.14.0
+	golang.org/x/term v0.13.0
+	modernc.org/sqlite v1.26.0
 )
 
 require (
+	dario.cat/mergo v1.0.0 // indirect
 	github.com/Microsoft/go-winio v0.6.1 // indirect
-	github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect
+	github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c // indirect
 	github.com/PuerkitoBio/goquery v1.8.1 // indirect
 	github.com/acomagu/bufpipe v1.0.4 // indirect
 	github.com/andybalholm/cascadia v1.3.2 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/cloudflare/circl v1.3.3 // indirect
+	github.com/cyphar/filepath-securejoin v0.2.4 // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/emirpasic/gods v1.18.1 // indirect
 	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
-	github.com/go-git/go-billy/v5 v5.4.1 // indirect
+	github.com/go-git/go-billy/v5 v5.5.0 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
+	github.com/google/uuid v1.3.0 // indirect
 	github.com/gorilla/css v1.0.0 // indirect
-	github.com/imdario/mergo v0.3.16 // indirect
 	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
+	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
 	github.com/kevinburke/ssh_config v1.2.0 // indirect
+	github.com/mattn/go-isatty v0.0.16 // indirect
 	github.com/mmcdole/goxpp v1.1.0 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/pjbgf/sha1cd v0.3.0 // indirect
+	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	github.com/sergi/go-diff v1.3.1 // indirect
-	github.com/skeema/knownhosts v1.2.0 // indirect
+	github.com/skeema/knownhosts v1.2.1 // indirect
 	github.com/xanzy/ssh-agent v0.3.3 // indirect
-	golang.org/x/crypto v0.11.0 // indirect
-	golang.org/x/mod v0.12.0 // indirect
-	golang.org/x/net v0.12.0 // indirect
-	golang.org/x/sys v0.10.0 // indirect
-	golang.org/x/text v0.11.0 // indirect
-	golang.org/x/tools v0.11.0 // indirect
+	golang.org/x/mod v0.13.0 // indirect
+	golang.org/x/net v0.16.0 // indirect
+	golang.org/x/sys v0.13.0 // indirect
+	golang.org/x/text v0.13.0 // indirect
+	golang.org/x/tools v0.13.0 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
+	lukechampine.com/uint128 v1.2.0 // indirect
+	modernc.org/cc/v3 v3.40.0 // indirect
+	modernc.org/ccgo/v3 v3.16.13 // indirect
+	modernc.org/libc v1.24.1 // indirect
+	modernc.org/mathutil v1.5.0 // indirect
+	modernc.org/memory v1.6.0 // indirect
+	modernc.org/opt v0.1.3 // indirect
+	modernc.org/strutil v1.1.3 // indirect
+	modernc.org/token v1.0.1 // indirect
 )

go.sum 🔗

@@ -1,10 +1,12 @@
+dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
 github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
 github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
 github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
 github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
 github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
-github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 h1:KLq8BE0KwCL+mmXnjLWEAOYO+2l2AE4YMmqG1ZpZHBs=
-github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
+github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c h1:kMFnB0vCcX7IL/m9Y5LO+KQYv+t1CQOiFe6+SV2J7bE=
+github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
 github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
 github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
 github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
@@ -19,44 +21,51 @@ github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd3
 github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
 github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs=
 github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
+github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
 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/elazarl/goproxy v0.0.0-20221015165544-a0805db90819 h1:RIB4cRk+lBqKK3Oy0r2gRX4ui7tuhiZq2SuTtTCi0/0=
+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/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
 github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
-github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4=
-github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=
+github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
+github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f h1:Pz0DHeFij3XFhoBRGUDPzSJ+w2UcK5/0JvF8DRI58r8=
-github.com/go-git/go-git/v5 v5.8.0 h1:Rc543s6Tyq+YcyPwZRvU4jzZGM8rB/wWu94TnTIYALQ=
-github.com/go-git/go-git/v5 v5.8.0/go.mod h1:coJHKEOk5kUClpsNlXrUvPrDxY3w3gjHvhcZd8Fodw8=
+github.com/go-git/go-git/v5 v5.9.0 h1:cD9SFA7sHVRdJ7AYck1ZaAa/yeuBvGPxwXDL8cxrObY=
+github.com/go-git/go-git/v5 v5.9.0/go.mod h1:RKIqga24sWdMGZF+1Ekv9kylsDz6LzdTSI2s/OsZWE0=
 github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
 github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
+github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
 github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
-github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
-github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
-github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
-github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 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/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
-github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A=
 github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
+github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
 github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
 github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE=
 github.com/mmcdole/gofeed v1.2.1 h1:tPbFN+mfOLcM1kDF1x2c/N68ChbdBatkppdzf/vDe1s=
@@ -68,18 +77,22 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
 github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
 github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.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/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
 github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
 github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
 github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
-github.com/skeema/knownhosts v1.2.0 h1:h9r9cf0+u7wSE+M183ZtMGgOJKiL96brpaz5ekfJCpM=
-github.com/skeema/knownhosts v1.2.0/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2IqpAoNkjxbMo=
+github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ=
+github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
 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=
@@ -95,12 +108,12 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
 golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
-golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
-golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
+golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
+golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
-golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY=
+golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@@ -111,8 +124,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
 golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
-golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
-golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
+golang.org/x/net v0.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos=
+golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -126,20 +139,22 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
-golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
+golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
 golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
-golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c=
+golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
+golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -148,22 +163,45 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
-golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.11.0 h1:EMCa6U9S2LtZXLAMoWiR/R8dAQFRqbAitmbJ2UKhoi8=
-golang.org/x/tools v0.11.0/go.mod h1:anzJrxPjNtfgiYQYirP2CPGzGLxrH2u2QBhn6Bf3qY8=
+golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
 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-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
 gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
+lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
+modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
+modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
+modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
+modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
+modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
+modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
+modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
+modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
+modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
+modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
+modernc.org/memory v1.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o=
+modernc.org/memory v1.6.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
+modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
+modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
+modernc.org/sqlite v1.26.0 h1:SocQdLRSYlA8W99V8YH0NES75thx19d9sB/aFc4R8Lw=
+modernc.org/sqlite v1.26.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
+modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
+modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
+modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
+modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
+modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=

justfile 🔗

@@ -2,25 +2,40 @@
 #
 # SPDX-License-Identifier: CC0-1.0
 
-default: reuse lint test staticcheck
+default: fmt lint staticcheck test vuln reuse
 
-reuse:
-    reuse lint
+fmt:
+    # Formatting all Go source code
+    go install mvdan.cc/gofumpt@latest
+    gofumpt -l -w .
 
 lint:
-    # Linting Go code
-    go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
+    # Linting Go source code
     golangci-lint run
 
-test:
-    # Running tests
-    go test -v ./...
-
 staticcheck:
     # Performing static analysis
     go install honnef.co/go/tools/cmd/staticcheck@latest
     staticcheck ./...
 
+test:
+    # Running tests
+    go test -v ./...
+
+vuln:
+    # Checking for vulnerabilities
+    go install golang.org/x/vuln/cmd/govulncheck@latest
+    govulncheck ./...
+
+reuse:
+    # Linting licenses and copyright headers
+    reuse lint
+
 clean:
     # Cleaning up
-    rm -rf willow
+    rm -rf willow out/
+
+clean-all:
+    # Removing build artifacts, willow.sqlite, config.toml, and data/ directory
+
+    rm -rf willow out willow.sqlite config.toml data

main.go 🔗

@@ -1,228 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// SPDX-License-Identifier: Apache-2.0
-
-package main
-
-import (
-	"encoding/csv"
-	"errors"
-	"fmt"
-	"github.com/BurntSushi/toml"
-	"log"
-	"net/http"
-	"os"
-	"sort"
-	"strings"
-	"time"
-
-	"github.com/microcosm-cc/bluemonday"
-	flag "github.com/spf13/pflag"
-)
-
-type (
-	Model struct {
-		Projects []project
-	}
-
-	project struct {
-		URL      string
-		Name     string
-		Forge    string
-		Running  string
-		Releases []release
-	}
-
-	release struct {
-		Tag     string
-		Content string
-		URL     string
-		Date    time.Time
-	}
-
-	Config struct {
-		Server      server
-		CSVLocation string
-		// TODO: Make cache location configurable
-		// CacheLocation string
-		FetchInterval int
-	}
-
-	server struct {
-		Listen string
-	}
-)
-
-var (
-	flagConfig    *string = flag.StringP("config", "c", "config.toml", "Path to config file")
-	config        Config
-	req           = make(chan struct{})
-	manualRefresh = make(chan struct{})
-	res           = make(chan []project)
-	m             = Model{
-		Projects: []project{},
-	}
-	bmUGC    = bluemonday.UGCPolicy()
-	bmStrict = bluemonday.StrictPolicy()
-)
-
-func main() {
-
-	flag.Parse()
-
-	err := checkConfig()
-	if err != nil {
-		log.Fatalln(err)
-	}
-
-	err = checkCSV()
-	if err != nil {
-		log.Fatalln(err)
-	}
-
-	reader := csv.NewReader(strings.NewReader(config.CSVLocation))
-
-	records, err := reader.ReadAll()
-	if err != nil {
-		log.Fatalln(err)
-	}
-
-	m.Projects = []project{}
-	if len(records) > 0 {
-		for i, record := range records {
-			if i == 0 {
-				continue
-			}
-			m.Projects = append(m.Projects, project{
-				URL:      record[0],
-				Name:     record[1],
-				Forge:    record[2],
-				Running:  record[3],
-				Releases: []release{},
-			})
-		}
-	}
-
-	go refreshLoop(manualRefresh, req, res)
-
-	mux := http.NewServeMux()
-
-	httpServer := &http.Server{
-		Addr:    config.Server.Listen,
-		Handler: mux,
-	}
-
-	mux.HandleFunc("/", rootHandler)
-	mux.HandleFunc("/static", staticHandler)
-	mux.HandleFunc("/new", newHandler)
-
-	if err := httpServer.ListenAndServe(); errors.Is(err, http.ErrServerClosed) {
-		log.Println("Web server closed")
-	} else {
-		log.Fatalln(err)
-	}
-}
-
-func refreshLoop(manualRefresh, req chan struct{}, res chan []project) {
-	ticker := time.NewTicker(time.Second * 3600)
-
-	fetch := func() []project {
-		projects := make([]project, len(m.Projects))
-		copy(projects, m.Projects)
-		for i, project := range projects {
-			project, err := getReleases(project)
-			if err != nil {
-				fmt.Println(err)
-				continue
-			}
-			projects[i] = project
-		}
-		sort.Slice(projects, func(i, j int) bool { return strings.ToLower(projects[i].Name) < strings.ToLower(projects[j].Name) })
-		return projects
-	}
-
-	projects := fetch()
-
-	for {
-		select {
-		case <-ticker.C:
-			projects = fetch()
-		case <-manualRefresh:
-			ticker.Reset(time.Second * 3600)
-			projects = fetch()
-		case <-req:
-			projectsCopy := make([]project, len(projects))
-			copy(projectsCopy, projects)
-			res <- projectsCopy
-		}
-	}
-}
-
-func checkConfig() error {
-	file, err := os.Open(*flagConfig)
-	if err != nil {
-		if os.IsNotExist(err) {
-			file, err = os.Create(*flagConfig)
-			if err != nil {
-				return err
-			}
-			defer file.Close()
-
-			_, err = file.WriteString("# Location of the CSV file containing the projects\nCSVLocation = \"projects.csv\"\n# How often to fetch new releases in seconds\nFetchInterval = 3600\n\n[Server]\n# Address to listen on\nListen = \"127.0.0.1:1313\"\n")
-			if err != nil {
-				return err
-			}
-
-			fmt.Println("Config file created at", *flagConfig)
-			fmt.Println("Please edit it and restart the server")
-			os.Exit(0)
-		} else {
-			return err
-		}
-	}
-	defer file.Close()
-
-	_, err = toml.DecodeFile(*flagConfig, &config)
-	if err != nil {
-		return err
-	}
-
-	if config.CSVLocation == "" {
-		fmt.Println("No CSV location specified, using projects.csv")
-		config.CSVLocation = "projects.csv"
-	}
-
-	if config.FetchInterval < 10 {
-		fmt.Println("Fetch interval is set to", config.FetchInterval, "seconds, but the minimum is 10, using 10")
-		config.FetchInterval = 10
-	}
-
-	if config.Server.Listen == "" {
-		fmt.Println("No listen address specified, using 127.0.0.1:1313")
-		config.Server.Listen = "127.0.0.1:1313"
-	}
-
-	return nil
-}
-
-func checkCSV() error {
-	file, err := os.Open(config.CSVLocation)
-	if err != nil {
-		if os.IsNotExist(err) {
-			file, err = os.Create(config.CSVLocation)
-			if err != nil {
-				return err
-			}
-			defer file.Close()
-
-			_, err = file.WriteString("url,name,forge,running\nhttps://git.sr.ht/~amolith/earl,earl,sourcehut,v0.0.1-rc0\n")
-			if err != nil {
-				return err
-			}
-		} else {
-			return err
-		}
-	}
-	defer file.Close()
-	return nil
-}

project/project.go 🔗

@@ -0,0 +1,201 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package project
+
+import (
+	"database/sql"
+	"fmt"
+	"log"
+	"sort"
+	"strings"
+	"time"
+
+	"git.sr.ht/~amolith/willow/db"
+	"git.sr.ht/~amolith/willow/git"
+	"git.sr.ht/~amolith/willow/rss"
+)
+
+type Project struct {
+	URL      string
+	Name     string
+	Forge    string
+	Running  string
+	Releases []Release
+}
+
+type Release struct {
+	URL     string
+	Tag     string
+	Content string
+	Date    time.Time
+}
+
+// GetReleases returns a list of all releases for a project from the database
+func GetReleases(dbConn *sql.DB, proj Project) (Project, error) {
+	ret, err := db.GetReleases(dbConn, proj.URL)
+	if err != nil {
+		return proj, err
+	}
+
+	if len(ret) == 0 {
+		return fetchReleases(proj)
+	}
+
+	for _, row := range ret {
+		proj.Releases = append(proj.Releases, Release{
+			Tag:     row["tag"],
+			Content: row["content"],
+			URL:     row["release_url"],
+			Date:    time.Time{},
+		})
+	}
+	sort.Slice(proj.Releases, func(i, j int) bool {
+		return proj.Releases[i].Date.After(proj.Releases[j].Date)
+	})
+	return proj, nil
+}
+
+// fetchReleases fetches releases from a project's forge given its URI
+func fetchReleases(p Project) (Project, error) {
+	var err error
+	switch p.Forge {
+	case "github", "gitea", "forgejo":
+		rssReleases, err := rss.GetReleases(p.URL)
+		if err != nil {
+			return p, err
+		}
+		for _, release := range rssReleases {
+			p.Releases = append(p.Releases, Release{
+				Tag:     release.Tag,
+				Content: release.Content,
+				URL:     release.URL,
+				Date:    release.Date,
+			})
+		}
+	default:
+		gitReleases, err := git.GetReleases(p.URL, p.Forge)
+		if err != nil {
+			return p, err
+		}
+		for _, release := range gitReleases {
+			p.Releases = append(p.Releases, Release{
+				Tag:     release.Tag,
+				Content: release.Content,
+				URL:     release.URL,
+				Date:    release.Date,
+			})
+		}
+	}
+	sort.Slice(p.Releases, func(i, j int) bool {
+		return p.Releases[i].Date.After(p.Releases[j].Date)
+	})
+	return p, err
+}
+
+func Track(dbConn *sql.DB, manualRefresh *chan struct{}, name, url, forge, release string) {
+	err := db.UpsertProject(dbConn, url, name, forge, release)
+	if err != nil {
+		fmt.Println("Error upserting project:", err)
+	}
+	*manualRefresh <- struct{}{}
+}
+
+func Untrack(dbConn *sql.DB, manualRefresh *chan struct{}, url string) {
+	err := db.DeleteProject(dbConn, url)
+	if err != nil {
+		fmt.Println("Error deleting project:", err)
+	}
+
+	*manualRefresh <- struct{}{}
+
+	err = git.RemoveRepo(url)
+	if err != nil {
+		log.Println(err)
+	}
+}
+
+func RefreshLoop(dbConn *sql.DB, interval int, manualRefresh, req *chan struct{}, res *chan []Project) {
+	ticker := time.NewTicker(time.Second * time.Duration(interval))
+
+	fetch := func() []Project {
+		projectsList, err := GetProjects(dbConn)
+		if err != nil {
+			fmt.Println("Error getting projects:", err)
+		}
+		for i, p := range projectsList {
+			p, err := fetchReleases(p)
+			if err != nil {
+				fmt.Println(err)
+				continue
+			}
+			projectsList[i] = p
+		}
+		sort.Slice(projectsList, func(i, j int) bool {
+			return strings.ToLower(projectsList[i].Name) < strings.ToLower(projectsList[j].Name)
+		})
+		for i := range projectsList {
+			for j := range projectsList[i].Releases {
+				err = db.UpsertRelease(dbConn, projectsList[i].URL, projectsList[i].Releases[j].URL, projectsList[i].Releases[j].Tag, projectsList[i].Releases[j].Content, projectsList[i].Releases[j].Date.Format("2006-01-02 15:04:05"))
+				if err != nil {
+					fmt.Println("Error upserting release:", err)
+					continue
+				}
+			}
+		}
+		return projectsList
+	}
+
+	projects := fetch()
+
+	for {
+		select {
+		case <-ticker.C:
+			projects = fetch()
+		case <-*manualRefresh:
+			ticker.Reset(time.Second * 3600)
+			projects = fetch()
+		case <-*req:
+			projectsCopy := make([]Project, len(projects))
+			copy(projectsCopy, projects)
+			*res <- projectsCopy
+		}
+	}
+}
+
+// GetProject returns a project from the database
+func GetProject(dbConn *sql.DB, url string) (Project, error) {
+	var p Project
+	projectDB, err := db.GetProject(dbConn, url)
+	if err != nil {
+		return p, err
+	}
+	p = Project{
+		URL:     projectDB["url"],
+		Name:    projectDB["name"],
+		Forge:   projectDB["forge"],
+		Running: projectDB["version"],
+	}
+	return p, err
+}
+
+// GetProjects returns a list of all projects from the database
+func GetProjects(dbConn *sql.DB) ([]Project, error) {
+	projectsDB, err := db.GetProjects(dbConn)
+	if err != nil {
+		return nil, err
+	}
+
+	projects := make([]Project, len(projectsDB))
+	for i, p := range projectsDB {
+		projects[i] = Project{
+			URL:     p["url"],
+			Name:    p["name"],
+			Forge:   p["forge"],
+			Running: p["version"],
+		}
+	}
+
+	return projects, nil
+}

releases.go 🔗

@@ -1,85 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// SPDX-License-Identifier: Apache-2.0
-
-package main
-
-import (
-	"encoding/csv"
-	"log"
-	"os"
-)
-
-func getReleases(p project) (project, error) {
-	var err error
-	switch p.Forge {
-	case "github", "gitea", "forgejo":
-		p, err = getRSSReleases(p)
-	// case "gitlab":
-	// 	// TODO: maybe use GitLab's API?
-	default:
-		p, err = getGitReleases(p)
-	}
-	return p, err
-}
-
-func track(name, url, forge, release string) {
-	projectExists := false
-	for i := range m.Projects {
-		if m.Projects[i].URL == url {
-			projectExists = true
-			m.Projects[i].Running = release
-		}
-	}
-
-	if !projectExists {
-		m.Projects = append(m.Projects, project{
-			URL:     url,
-			Name:    name,
-			Forge:   forge,
-			Running: release,
-		})
-	}
-
-	manualRefresh <- struct{}{}
-
-	writeCSV()
-}
-
-func untrack(url string) {
-	for i := range m.Projects {
-		if m.Projects[i].URL == url {
-			m.Projects = append(m.Projects[:i], m.Projects[i+1:]...)
-			break
-		}
-	}
-
-	manualRefresh <- struct{}{}
-
-	writeCSV()
-	err := removeRepo(url)
-	if err != nil {
-		log.Println(err)
-	}
-}
-
-func writeCSV() {
-	file, err := os.OpenFile(config.CSVLocation, os.O_RDWR|os.O_CREATE, 0o600)
-	if err != nil {
-		log.Fatalln(err)
-	}
-	defer file.Close()
-
-	writer := csv.NewWriter(file)
-
-	if err := writer.Write([]string{"url", "name", "forge", "running"}); err != nil {
-		log.Fatalln(err)
-	}
-	for _, project := range m.Projects {
-		if err := writer.Write([]string{project.URL, project.Name, project.Forge, project.Running}); err != nil {
-			log.Fatalln(err)
-		}
-	}
-
-	writer.Flush()
-}

rss.go → rss/rss.go 🔗

@@ -2,24 +2,41 @@
 //
 // SPDX-License-Identifier: Apache-2.0
 
-package main
+package rss
 
 import (
 	"fmt"
+	"time"
+
+	"github.com/microcosm-cc/bluemonday"
 
 	"github.com/mmcdole/gofeed"
 )
 
-func getRSSReleases(p project) (project, error) {
+type Release struct {
+	Tag     string
+	Content string
+	URL     string
+	Date    time.Time
+}
+
+var (
+	bmUGC    = bluemonday.UGCPolicy()
+	bmStrict = bluemonday.StrictPolicy()
+)
+
+func GetReleases(feedURL string) ([]Release, error) {
 	fp := gofeed.NewParser()
-	feed, err := fp.ParseURL(p.URL + "/releases.atom")
+	feed, err := fp.ParseURL(feedURL + "/releases.atom")
 	if err != nil {
 		fmt.Println(err)
-		return p, err
+		return nil, err
 	}
 
+	releases := make([]Release, 0)
+
 	for _, item := range feed.Items {
-		p.Releases = append(p.Releases, release{
+		releases = append(releases, Release{
 			Tag:     bmStrict.Sanitize(item.Title),
 			Content: bmUGC.Sanitize(item.Content),
 			URL:     bmStrict.Sanitize(item.Link),
@@ -30,5 +47,5 @@ func getRSSReleases(p project) (project, error) {
 	// TODO: Doesn't seem to work?
 	// sort.Slice(p.Releases, func(i, j int) bool { return p.Releases[i].Date.After(p.Releases[j].Date) })
 
-	return p, nil
+	return releases, nil
 }

users/users.go 🔗

@@ -0,0 +1,92 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package users
+
+import (
+	"crypto/rand"
+	"database/sql"
+	"encoding/base64"
+	"time"
+
+	"git.sr.ht/~amolith/willow/db"
+	"golang.org/x/crypto/argon2"
+)
+
+// argonHash accepts two strings for the user's password and a random salt,
+// hashes the password using the salt, and returns the hash as a base64-encoded
+// string.
+func argonHash(password, salt string) (string, error) {
+	decodedSalt, err := base64.StdEncoding.DecodeString(salt)
+	if err != nil {
+		return "", err
+	}
+	return base64.StdEncoding.EncodeToString(argon2.IDKey([]byte(password), decodedSalt, 2, 64*1024, 4, 64)), nil
+}
+
+// generateSalt generates a random salt and returns it as a base64-encoded
+// string.
+func generateSalt() (string, error) {
+	salt := make([]byte, 16)
+	_, err := rand.Read(salt)
+	if err != nil {
+		return "", err
+	}
+	return base64.StdEncoding.EncodeToString(salt), nil
+}
+
+// Register accepts a username and password, hashes the password and stores the
+// hash and salt in the database.
+func Register(dbConn *sql.DB, username, password string) error {
+	salt, err := generateSalt()
+	if err != nil {
+		return err
+	}
+
+	hash, err := argonHash(password, salt)
+	if err != nil {
+		return err
+	}
+
+	return db.CreateUser(dbConn, username, hash, salt)
+}
+
+// Delete removes a user from the database.
+func Delete(dbConn *sql.DB, username string) error { return db.DeleteUser(dbConn, username) }
+
+// Authorised accepts a username string, a token string, and returns true if the
+// user is authorised, false if not, and an error if one is encountered.
+func Authorised(dbConn *sql.DB, username, token string) (bool, error) {
+	dbHash, dbSalt, err := db.GetUser(dbConn, username)
+	if err != nil {
+		return false, err
+	}
+
+	providedHash, err := argonHash(token, dbSalt)
+	if err != nil {
+		return false, err
+	}
+
+	return dbHash == providedHash, nil
+}
+
+// GetSession accepts a session cookie string and returns the username
+func GetSession(dbConn *sql.DB, session string) (string, time.Time, error) {
+	return db.GetSession(dbConn, session)
+}
+
+// InvalidateSession invalidates a session by setting the expiration date to the
+// current time.
+func InvalidateSession(dbConn *sql.DB, session string) error {
+	return db.InvalidateSession(dbConn, session, time.Now())
+}
+
+// CreateSession accepts a username and a token and creates a session in the
+// database.
+func CreateSession(dbConn *sql.DB, username, token string, expiry time.Time) error {
+	return db.CreateSession(dbConn, username, token, expiry)
+}
+
+// GetUsers returns a list of all users in the database as a slice of strings.
+func GetUsers(dbConn *sql.DB) ([]string, error) { return db.GetUsers(dbConn) }

ws/static/login.html 🔗

@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html lang="en-GB">
+    <head>
+        <title>Willow</title>
+        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <meta name="title" content="Willow">
+        <meta name="description" content="Willow">
+        <style>
+html {
+  max-width: 500px;
+  margin: auto auto;
+}
+        </style>
+    </head>
+    <body>
+        <h1>Willow</h1>
+        <form method="POST">
+            <div class="input">
+                <label for="username">Username:</label>
+                <input type="text" id="username" name="username">
+            </div>
+            <div class="input">
+                <label for="password">Password:</label>
+                <input type="password" id="password" name="password">
+            </div>
+            <input class="button" type="submit" formaction="/login" value="Login">
+        </form>
+        <p><a href="https://sr.ht/~amolith/willow">Source code</a></p>
+    </body>
+</html>

ws.go → ws/ws.go 🔗

@@ -2,31 +2,55 @@
 //
 // SPDX-License-Identifier: Apache-2.0
 
-package main
+package ws
 
 import (
+	"database/sql"
 	"embed"
 	"fmt"
 	"io"
 	"net/http"
 	"net/url"
 	"strings"
+	"sync"
 	"text/template"
+
+	"git.sr.ht/~amolith/willow/project"
+	"github.com/microcosm-cc/bluemonday"
 )
 
+type Handler struct {
+	DbConn        *sql.DB
+	Mutex         *sync.Mutex
+	Req           *chan struct{}
+	ManualRefresh *chan struct{}
+	Res           *chan []project.Project
+}
+
 //go:embed static
 var fs embed.FS
 
-func rootHandler(w http.ResponseWriter, r *http.Request) {
-	req <- struct{}{}
-	data := <-res
+// bmUGC    = bluemonday.UGCPolicy()
+var bmStrict = bluemonday.StrictPolicy()
+
+func (h Handler) RootHandler(w http.ResponseWriter, r *http.Request) {
+	if !h.isAuthorised(r) {
+		http.Redirect(w, r, "/login", http.StatusSeeOther)
+		return
+	}
+	*h.Req <- struct{}{}
+	data := <-*h.Res
 	tmpl := template.Must(template.ParseFS(fs, "static/home.html"))
 	if err := tmpl.Execute(w, data); err != nil {
 		fmt.Println(err)
 	}
 }
 
-func newHandler(w http.ResponseWriter, r *http.Request) {
+func (h Handler) NewHandler(w http.ResponseWriter, r *http.Request) {
+	if !h.isAuthorised(r) {
+		http.Redirect(w, r, "/login", http.StatusSeeOther)
+		return
+	}
 	params := r.URL.Query()
 	action := bmStrict.Sanitize(params.Get("action"))
 	if r.Method == http.MethodGet {
@@ -36,7 +60,7 @@ func newHandler(w http.ResponseWriter, r *http.Request) {
 				fmt.Println(err)
 			}
 		} else if action != "delete" {
-			submittedURL := bmStrict.Sanitize(params.Get("submittedURL"))
+			submittedURL := bmStrict.Sanitize(params.Get("url"))
 			if submittedURL == "" {
 				w.WriteHeader(http.StatusBadRequest)
 				_, err := w.Write([]byte("No URL provided"))
@@ -62,22 +86,20 @@ func newHandler(w http.ResponseWriter, r *http.Request) {
 				_, err := w.Write([]byte("No name provided"))
 				if err != nil {
 					fmt.Println(err)
-
 				}
 			}
 
-			proj := project{
+			proj := project.Project{
 				URL:   submittedURL,
 				Name:  name,
 				Forge: forge,
 			}
-			proj, err := getReleases(proj)
+			proj, err := project.GetReleases(h.DbConn, proj)
 			if err != nil {
 				w.WriteHeader(http.StatusBadRequest)
 				_, err := w.Write([]byte(fmt.Sprintf("Error getting releases: %s", err)))
 				if err != nil {
 					fmt.Println(err)
-
 				}
 			}
 			tmpl := template.Must(template.ParseFS(fs, "static/select-release.html"))
@@ -85,17 +107,16 @@ func newHandler(w http.ResponseWriter, r *http.Request) {
 				fmt.Println(err)
 			}
 		} else if action == "delete" {
-			submittedURL := params.Get("submittedURL")
+			submittedURL := params.Get("url")
 			if submittedURL == "" {
 				w.WriteHeader(http.StatusBadRequest)
 				_, err := w.Write([]byte("No URL provided"))
 				if err != nil {
 					fmt.Println(err)
-
 				}
 			}
 
-			untrack(submittedURL)
+			project.Untrack(h.DbConn, h.ManualRefresh, submittedURL)
 			http.Redirect(w, r, "/", http.StatusSeeOther)
 		}
 	}
@@ -111,7 +132,7 @@ func newHandler(w http.ResponseWriter, r *http.Request) {
 		releaseValue := bmStrict.Sanitize(r.FormValue("release"))
 
 		if nameValue != "" && urlValue != "" && forgeValue != "" && releaseValue != "" {
-			track(nameValue, urlValue, forgeValue, releaseValue)
+			project.Track(h.DbConn, h.ManualRefresh, nameValue, urlValue, forgeValue, releaseValue)
 			http.Redirect(w, r, "/", http.StatusSeeOther)
 			return
 		}
@@ -126,13 +147,21 @@ func newHandler(w http.ResponseWriter, r *http.Request) {
 			_, err := w.Write([]byte("No data provided"))
 			if err != nil {
 				fmt.Println(err)
-
 			}
 		}
 	}
 }
 
-func staticHandler(writer http.ResponseWriter, request *http.Request) {
+func (h Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
+	// TODO: do this
+}
+
+func (h Handler) isAuthorised(r *http.Request) bool {
+	// TODO: do this
+	return false
+}
+
+func StaticHandler(writer http.ResponseWriter, request *http.Request) {
 	resource := strings.TrimPrefix(request.URL.Path, "/")
 	// if path ends in .css, set content type to text/css
 	if strings.HasSuffix(resource, ".css") {