Beeg refactor for database and users and auth

This commit is contained in:
Amolith 2023-10-25 00:14:32 -04:00
parent c5f2fa0699
commit ef9544ff7d
26 changed files with 1186 additions and 408 deletions

2
.gitignore vendored
View File

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

109
cmd/cli.go Normal file
View File

@ -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)
}

181
cmd/willow.go Normal file
View File

@ -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
}

51
db/db.go Normal file
View File

@ -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
}

83
db/project.go Normal file
View File

@ -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
}

62
db/release.go Normal file
View File

@ -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
}

46
db/sql/schema.sql Normal file
View File

@ -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)
);

84
db/users.go Normal file
View File

@ -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
}

View File

@ -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

39
go.mod
View File

@ -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
)

98
go.sum
View File

@ -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=

View File

@ -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

228
main.go
View File

@ -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
}

201
project/project.go Normal file
View File

@ -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
}

View File

@ -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()
}

View File

@ -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
}

92
users/users.go Normal file
View File

@ -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) }

30
ws/static/login.html Normal file
View File

@ -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>

View File

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
SPDX-License-Identifier: Apache-2.0

View File

@ -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") {