From d5c7bf70ce9fafe3077e30397505c8abb27daea8 Mon Sep 17 00:00:00 2001 From: Amolith Date: Fri, 23 Feb 2024 18:06:16 -0500 Subject: [PATCH] Implement new UI, fix DB use - Implement dual-column UI - Swap project table index from URL to ID - Enable WAL for concurrent reads - Use a Mutex to protect writes --- cmd/willow.go | 14 +-- db/db.go | 45 +++------- db/migrations.go | 11 +++ db/posthooks.go | 32 +++++++ db/project.go | 49 +++++------ db/release.go | 47 +++++----- db/sql/2_swap_project_url_for_id.down.sql | 28 ++++++ db/sql/2_swap_project_url_for_id.up.sql | 29 +++++++ db/users.go | 8 ++ project/project.go | 101 +++++++++++++++------- ws/static/home.html | 76 ++++++++++------ ws/static/login.html | 2 +- ws/static/new.html | 2 +- ws/static/select-release.html | 7 +- ws/static/styles.css | 98 +++++++++++++++------ ws/ws.go | 45 ++++++---- 16 files changed, 398 insertions(+), 196 deletions(-) create mode 100644 db/sql/2_swap_project_url_for_id.down.sql create mode 100644 db/sql/2_swap_project_url_for_id.up.sql diff --git a/cmd/willow.go b/cmd/willow.go index 838b9e2..bb12fc1 100644 --- a/cmd/willow.go +++ b/cmd/willow.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "strconv" + "sync" "git.sr.ht/~amolith/willow/db" "git.sr.ht/~amolith/willow/project" @@ -22,9 +23,8 @@ import ( type ( Config struct { - Server server - CSVLocation string - DBConn string + Server server + DBConn string // TODO: Make cache location configurable // CacheLocation string FetchInterval int @@ -90,17 +90,17 @@ func main() { os.Exit(0) } - fmt.Println("Starting refresh loop") - go project.RefreshLoop(dbConn, config.FetchInterval, &manualRefresh, &req, &res) + mu := sync.Mutex{} - var mutex sync.Mutex + fmt.Println("Starting refresh loop") + go project.RefreshLoop(dbConn, &mu, config.FetchInterval, &manualRefresh, &req, &res) wsHandler := ws.Handler{ DbConn: dbConn, - Mutex: &mutex, Req: &req, Res: &res, ManualRefresh: &manualRefresh, + Mu: &mu, } mux := http.NewServeMux() diff --git a/db/db.go b/db/db.go index e5c9d60..e5cb44b 100644 --- a/db/db.go +++ b/db/db.go @@ -7,6 +7,8 @@ package db import ( "database/sql" _ "embed" + "errors" + "sync" _ "modernc.org/sqlite" ) @@ -14,46 +16,25 @@ import ( //go:embed sql/schema.sql var schema string +var mutex = &sync.Mutex{} + // Open opens a connection to the SQLite database func Open(dbPath string) (*sql.DB, error) { - return sql.Open("sqlite", dbPath) + return sql.Open("sqlite", "file:"+dbPath+"?_pragma=journal_mode%3DWAL") } // VerifySchema checks whether the schema has been initalised and initialises it // if not func InitialiseDatabase(dbConn *sql.DB) error { var name string - err := dbConn.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'").Scan(&name) - if err == nil { + err := dbConn.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='users'").Scan(&name) + if err != nil && errors.Is(err, sql.ErrNoRows) { + mutex.Lock() + defer mutex.Unlock() + if _, err := dbConn.Exec(schema); err != nil { + return err + } return nil } - - tables := []string{ - "users", - "sessions", - "projects", - "releases", - } - - for _, table := range tables { - name := "" - err := dbConn.QueryRow( - "SELECT name FROM sqlite_master WHERE type='table' AND name=@table", - sql.Named("table", table), - ).Scan(&name) - if err != nil { - if err = loadSchema(dbConn); err != nil { - return err - } - } - } - return nil -} - -// loadSchema loads the initial schema into the database -func loadSchema(dbConn *sql.DB) error { - if _, err := dbConn.Exec(schema); err != nil { - return err - } - return nil + return err } diff --git a/db/migrations.go b/db/migrations.go index 9b3c369..70d45c1 100644 --- a/db/migrations.go +++ b/db/migrations.go @@ -22,6 +22,10 @@ var ( migration1Up string //go:embed sql/1_add_project_ids.down.sql migration1Down string + //go:embed sql/2_swap_project_url_for_id.up.sql + migration2Up string + //go:embed sql/2_swap_project_url_for_id.down.sql + migration2Down string ) var migrations = [...]migration{ @@ -35,6 +39,13 @@ var migrations = [...]migration{ downQuery: migration1Down, postHook: generateAndInsertProjectIDs, }, + 2: { + upQuery: migration2Up, + downQuery: migration2Down, + }, + 3: { + postHook: correctProjectIDs, + }, } // Migrate runs all pending migrations diff --git a/db/posthooks.go b/db/posthooks.go index c5be805..a25497f 100644 --- a/db/posthooks.go +++ b/db/posthooks.go @@ -55,3 +55,35 @@ func generateAndInsertProjectIDs(tx *sql.Tx) error { return nil } + +// Basing the project's ID on when it was created (L37) was a bad idea. +func correctProjectIDs(tx *sql.Tx) error { + rows, err := tx.Query("SELECT id, url, name, forge FROM projects") + if err != nil { + return fmt.Errorf("failed to list projects in projects_tmp: %w", err) + } + defer rows.Close() + + for rows.Next() { + var ( + old_id string + url string + name string + forge string + ) + if err := rows.Scan(&old_id, &url, &name, &forge); err != nil { + return fmt.Errorf("failed to scan row from projects_tmp: %w", err) + } + id := fmt.Sprintf("%x", sha256.Sum256([]byte(url+name+forge))) + _, err = tx.Exec( + "UPDATE projects SET id = @id WHERE id = @old_id", + sql.Named("id", id), + sql.Named("old_id", old_id), + ) + if err != nil { + return fmt.Errorf("failed to insert project into projects: %w", err) + } + } + + return nil +} diff --git a/db/project.go b/db/project.go index dbac019..b3857bf 100644 --- a/db/project.go +++ b/db/project.go @@ -4,32 +4,32 @@ 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 -} +import ( + "database/sql" + "sync" +) // DeleteProject deletes a project from the database -func DeleteProject(db *sql.DB, url string) error { - _, err := db.Exec("DELETE FROM projects WHERE url = ?", url) +func DeleteProject(db *sql.DB, mu *sync.Mutex, id string) error { + mu.Lock() + defer mu.Unlock() + _, err := db.Exec("DELETE FROM projects WHERE id = ?", id) if err != nil { return err } - _, err = db.Exec("DELETE FROM releases WHERE project_url = ?", url) + _, err = db.Exec("DELETE FROM releases WHERE project_id = ?", id) 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) + var id, name, forge, version string + err := db.QueryRow("SELECT id, name, forge, version FROM projects WHERE url = ?", url).Scan(&id, &name, &forge, &version) if err != nil { return nil, err } project := map[string]string{ + "id": id, "name": name, "url": url, "forge": forge, @@ -38,27 +38,23 @@ func GetProject(db *sql.DB, url string) (map[string]string, error) { 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 +func UpsertProject(db *sql.DB, mu *sync.Mutex, id, url, name, forge, running string) error { + mu.Lock() + defer mu.Unlock() + _, err := db.Exec(`INSERT INTO projects (id, url, name, forge, version) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET name = excluded.name, forge = excluded.forge, - version = excluded.version;`, url, name, forge, running) + version = excluded.version;`, id, 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") + rows, err := db.Query("SELECT id, name, url, forge, version FROM projects") if err != nil { return nil, err } @@ -66,12 +62,13 @@ func GetProjects(db *sql.DB) ([]map[string]string, error) { var projects []map[string]string for rows.Next() { - var name, url, forge, version string - err = rows.Scan(&name, &url, &forge, &version) + var id, name, url, forge, version string + err = rows.Scan(&id, &name, &url, &forge, &version) if err != nil { return nil, err } project := map[string]string{ + "id": id, "name": name, "url": url, "forge": forge, diff --git a/db/release.go b/db/release.go index 3166195..a7bbb51 100644 --- a/db/release.go +++ b/db/release.go @@ -6,34 +6,29 @@ package db import ( "database/sql" + "sync" ) -// 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, id, projectURL, releaseURL, tag, content, date string) error { - _, err := db.Exec(`INSERT INTO releases (id, project_url, release_url, tag, content, date) +func UpsertRelease(db *sql.DB, mu *sync.Mutex, id, projectID, url, tag, content, date string) error { + mu.Lock() + defer mu.Unlock() + _, err := db.Exec(`INSERT INTO releases (id, project_id, url, tag, content, date) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET - release_url = excluded.release_url, + url = excluded.url, content = excluded.content, tag = excluded.tag, content = excluded.content, - date = excluded.date;`, id, projectURL, releaseURL, tag, content, date) + date = excluded.date;`, id, projectID, url, 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) +// GetReleases returns all releases for a project with a given id from the database +func GetReleases(db *sql.DB, projectID string) ([]map[string]string, error) { + rows, err := db.Query(`SELECT id, url, tag, content, date FROM releases WHERE project_id = ?`, projectID) if err != nil { return nil, err } @@ -42,22 +37,22 @@ func GetReleases(db *sql.DB, projectURL string) ([]map[string]string, error) { releases := make([]map[string]string, 0) for rows.Next() { var ( - projectURL string - releaseURL string - tag string - content string - date string + id string + url string + tag string + content string + date string ) - err := rows.Scan(&projectURL, &releaseURL, &tag, &content, &date) + err := rows.Scan(&id, &url, &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, + "id": id, + "url": url, + "tag": tag, + "content": content, + "date": date, }) } return releases, nil diff --git a/db/sql/2_swap_project_url_for_id.down.sql b/db/sql/2_swap_project_url_for_id.down.sql new file mode 100644 index 0000000..035d7be --- /dev/null +++ b/db/sql/2_swap_project_url_for_id.down.sql @@ -0,0 +1,28 @@ +-- SPDX-FileCopyrightText: Amolith +-- +-- SPDX-License-Identifier: CC0-1.0 + +ALTER TABLE releases RENAME TO releases_tmp; + +CREATE TABLE IF NOT EXISTS releases ( + id TEXT NOT NULL PRIMARY KEY, + project_url TEXT NOT NULL, + release_url TEXT NOT NULL, + tag TEXT NOT NULL, + content TEXT NOT NULL, + date TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO releases (id, project_url, release_url, tag, content, date) +SELECT + r.id, + p.url, + r.url, + r.tag, + r.content, + r.date +FROM releases_tmp r +JOIN projects p ON r.project_url = p.url; + +DROP TABLE releases_tmp; diff --git a/db/sql/2_swap_project_url_for_id.up.sql b/db/sql/2_swap_project_url_for_id.up.sql new file mode 100644 index 0000000..a923aea --- /dev/null +++ b/db/sql/2_swap_project_url_for_id.up.sql @@ -0,0 +1,29 @@ +-- SPDX-FileCopyrightText: Amolith +-- +-- SPDX-License-Identifier: CC0-1.0 + +ALTER TABLE releases RENAME TO releases_tmp; + +CREATE TABLE IF NOT EXISTS releases +( + id TEXT NOT NULL PRIMARY KEY, + project_id TEXT NOT NULL, + url TEXT NOT NULL, + tag TEXT NOT NULL, + content TEXT NOT NULL, + date TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO releases (id, project_id, url, tag, content, date) +SELECT + r.id, + p.id, + r.release_url, + r.tag, + r.content, + r.date +FROM releases_tmp r +JOIN projects p ON r.project_url = p.url; + +DROP TABLE releases_tmp; \ No newline at end of file diff --git a/db/users.go b/db/users.go index e687a31..5abaf4a 100644 --- a/db/users.go +++ b/db/users.go @@ -12,12 +12,16 @@ import ( // DeleteUser deletes specific user from the database and returns an error if it // fails func DeleteUser(db *sql.DB, user string) error { + mutex.Lock() + defer mutex.Unlock() _, 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 { + mutex.Lock() + defer mutex.Unlock() _, err := db.Exec("INSERT INTO users (username, hash, salt) VALUES (?, ?, ?)", username, hash, salt) return err } @@ -72,6 +76,8 @@ func GetSession(db *sql.DB, session string) (string, time.Time, error) { // InvalidateSession invalidates a session by setting the expiration date to the // provided time. func InvalidateSession(db *sql.DB, session string, expiry time.Time) error { + mutex.Lock() + defer mutex.Unlock() _, err := db.Exec("UPDATE sessions SET expires = ? WHERE token = ?", expiry.Format(time.RFC3339), session) return err } @@ -79,6 +85,8 @@ func InvalidateSession(db *sql.DB, session string, expiry time.Time) error { // 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 { + mutex.Lock() + defer mutex.Unlock() _, err := db.Exec("INSERT INTO sessions (token, username, expires) VALUES (?, ?, ?)", token, username, expiry.Format(time.RFC3339)) return err } diff --git a/project/project.go b/project/project.go index a5078de..3d654a2 100644 --- a/project/project.go +++ b/project/project.go @@ -11,6 +11,7 @@ import ( "log" "sort" "strings" + "sync" "time" "github.com/unascribed/FlexVer/go/flexver" @@ -21,6 +22,7 @@ import ( ) type Project struct { + ID string URL string Name string Forge string @@ -29,26 +31,28 @@ type Project struct { } type Release struct { - ID string - URL string - Tag string - Content string - Date time.Time + ID string + ProjectID string + 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) +func GetReleases(dbConn *sql.DB, mu *sync.Mutex, proj Project) (Project, error) { + ret, err := db.GetReleases(dbConn, proj.ID) if err != nil { return proj, err } if len(ret) == 0 { - return fetchReleases(dbConn, proj) + return fetchReleases(dbConn, mu, proj) } for _, row := range ret { proj.Releases = append(proj.Releases, Release{ + ID: row["id"], Tag: row["tag"], Content: row["content"], URL: row["release_url"], @@ -60,7 +64,7 @@ func GetReleases(dbConn *sql.DB, proj Project) (Project, error) { } // fetchReleases fetches releases from a project's forge given its URI -func fetchReleases(dbConn *sql.DB, p Project) (Project, error) { +func fetchReleases(dbConn *sql.DB, mu *sync.Mutex, p Project) (Project, error) { var err error switch p.Forge { case "github", "gitea", "forgejo": @@ -71,13 +75,13 @@ func fetchReleases(dbConn *sql.DB, p Project) (Project, error) { } for _, release := range rssReleases { p.Releases = append(p.Releases, Release{ - ID: genReleaseID(p.URL, release.URL, release.Tag), + ID: GenReleaseID(p.URL, release.URL, release.Tag), Tag: release.Tag, Content: release.Content, URL: release.URL, Date: release.Date, }) - err = upsert(dbConn, p.URL, p.Releases) + err = upsertRelease(dbConn, mu, p.URL, p.Releases) if err != nil { log.Printf("Error upserting release: %v", err) return p, err @@ -90,13 +94,13 @@ func fetchReleases(dbConn *sql.DB, p Project) (Project, error) { } for _, release := range gitReleases { p.Releases = append(p.Releases, Release{ - ID: genReleaseID(p.URL, release.URL, release.Tag), + ID: GenReleaseID(p.URL, release.URL, release.Tag), Tag: release.Tag, Content: release.Content, URL: release.URL, Date: release.Date, }) - err = upsert(dbConn, p.URL, p.Releases) + err = upsertRelease(dbConn, mu, p.URL, p.Releases) if err != nil { log.Printf("Error upserting release: %v", err) return p, err @@ -114,12 +118,12 @@ func SortReleases(releases []Release) []Release { return releases } -// upsert updates or inserts a project release into the database -func upsert(dbConn *sql.DB, url string, releases []Release) error { +// upsertRelease updates or inserts a release in the database +func upsertRelease(dbConn *sql.DB, mu *sync.Mutex, url string, releases []Release) error { for _, release := range releases { date := release.Date.Format("2006-01-02 15:04:05") - id := genReleaseID(url, release.URL, release.Tag) - err := db.UpsertRelease(dbConn, id, url, release.URL, release.Tag, release.Content, date) + id := GenReleaseID(url, release.URL, release.Tag) + err := db.UpsertRelease(dbConn, mu, id, url, release.URL, release.Tag, release.Content, date) if err != nil { log.Printf("Error upserting release: %v", err) return err @@ -128,34 +132,40 @@ func upsert(dbConn *sql.DB, url string, releases []Release) error { return nil } -func genReleaseID(projectURL, releaseURL, tag string) string { +// GenReleaseID generates a likely-unique ID from its project's URL, its release's URL, and its tag +func GenReleaseID(projectURL, releaseURL, tag string) string { idByte := sha256.Sum256([]byte(projectURL + releaseURL + tag)) return fmt.Sprintf("%x", idByte) } -func Track(dbConn *sql.DB, manualRefresh *chan struct{}, name, url, forge, release string) { - err := db.UpsertProject(dbConn, url, name, forge, release) +// GenProjectID generates a likely-unique ID from a project's URI, name, and forge +func GenProjectID(url, name, forge string) string { + idByte := sha256.Sum256([]byte(url + name + forge)) + return fmt.Sprintf("%x", idByte) +} + +func Track(dbConn *sql.DB, mu *sync.Mutex, manualRefresh *chan struct{}, name, url, forge, release string) { + id := GenProjectID(url, name, forge) + err := db.UpsertProject(dbConn, mu, id, 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) +func Untrack(dbConn *sql.DB, mu *sync.Mutex, id string) { + err := db.DeleteProject(dbConn, mu, id) if err != nil { fmt.Println("Error deleting project:", err) } - *manualRefresh <- struct{}{} - - err = git.RemoveRepo(url) + err = git.RemoveRepo(id) if err != nil { log.Println(err) } } -func RefreshLoop(dbConn *sql.DB, interval int, manualRefresh, req *chan struct{}, res *chan []Project) { +func RefreshLoop(dbConn *sql.DB, mu *sync.Mutex, interval int, manualRefresh, req *chan struct{}, res *chan []Project) { ticker := time.NewTicker(time.Second * time.Duration(interval)) fetch := func() []Project { @@ -164,7 +174,7 @@ func RefreshLoop(dbConn *sql.DB, interval int, manualRefresh, req *chan struct{} fmt.Println("Error getting projects:", err) } for i, p := range projectsList { - p, err := fetchReleases(dbConn, p) + p, err := fetchReleases(dbConn, mu, p) if err != nil { fmt.Println(err) continue @@ -175,7 +185,7 @@ func RefreshLoop(dbConn *sql.DB, interval int, manualRefresh, req *chan struct{} return strings.ToLower(projectsList[i].Name) < strings.ToLower(projectsList[j].Name) }) for i := range projectsList { - err = upsert(dbConn, projectsList[i].URL, projectsList[i].Releases) + err = upsertRelease(dbConn, mu, projectsList[i].URL, projectsList[i].Releases) if err != nil { fmt.Println("Error upserting release:", err) continue @@ -202,12 +212,13 @@ func RefreshLoop(dbConn *sql.DB, interval int, manualRefresh, req *chan struct{} } // GetProject returns a project from the database -func GetProject(dbConn *sql.DB, url string) (Project, error) { - projectDB, err := db.GetProject(dbConn, url) +func GetProject(dbConn *sql.DB, id string) (Project, error) { + projectDB, err := db.GetProject(dbConn, id) if err != nil { return Project{}, err } p := Project{ + ID: projectDB["id"], URL: projectDB["url"], Name: projectDB["name"], Forge: projectDB["forge"], @@ -216,6 +227,16 @@ func GetProject(dbConn *sql.DB, url string) (Project, error) { return p, err } +// GetProjectWithReleases returns a single project from the database along with its releases +func GetProjectWithReleases(dbConn *sql.DB, mu *sync.Mutex, id string) (Project, error) { + project, err := GetProject(dbConn, id) + if err != nil { + return Project{}, err + } + + return GetReleases(dbConn, mu, project) +} + // GetProjects returns a list of all projects from the database func GetProjects(dbConn *sql.DB) ([]Project, error) { projectsDB, err := db.GetProjects(dbConn) @@ -226,6 +247,7 @@ func GetProjects(dbConn *sql.DB) ([]Project, error) { projects := make([]Project, len(projectsDB)) for i, p := range projectsDB { projects[i] = Project{ + ID: p["id"], URL: p["url"], Name: p["name"], Forge: p["forge"], @@ -235,3 +257,22 @@ func GetProjects(dbConn *sql.DB) ([]Project, error) { return projects, nil } + +// GetProjectsWithReleases returns a list of all projects and all their releases +// from the database +func GetProjectsWithReleases(dbConn *sql.DB, mu *sync.Mutex) ([]Project, error) { + projects, err := GetProjects(dbConn) + if err != nil { + return nil, err + } + + for i := range projects { + projects[i], err = GetReleases(dbConn, mu, projects[i]) + if err != nil { + return nil, err + } + projects[i].Releases = SortReleases(projects[i].Releases) + } + + return projects, nil +} diff --git a/ws/static/home.html b/ws/static/home.html index cf3ca2c..6a48bd3 100644 --- a/ws/static/home.html +++ b/ws/static/home.html @@ -18,19 +18,51 @@ -

Willow    Log out

-

Track a new project

-
- - {{- range . -}} - {{- if ne .Running (index .Releases 0).Tag -}} -
-

{{ .Name }}   Delete?

-

You've selected {{ .Running }}. Modify?

-

Latest: {{ (index .Releases 0).Tag }}

-

-

- Expand release notes +
+

Willow    Log out

+

Track a new project

+
+
+
+ + {{- range . -}} + {{- if ne .Running (index .Releases 0).Tag -}} +

Outdated projects

+ {{- break -}} + {{- end -}} + {{- end -}} + {{- range . -}} + {{- if ne .Running (index .Releases 0).Tag -}} +
+

{{ .Name }}   Delete?

+

You've selected {{ .Running }}. Modify?

+

Latest: {{ (index .Releases 0).Tag }}

+

View release notes

+
+ {{- end -}} + {{- end -}} + + + {{- range . -}} + {{- if eq .Running (index .Releases 0).Tag -}} +

Up-to-date projects

+ {{- break -}} + {{- end -}} + {{- end -}} + {{- range . -}} + {{- if eq .Running (index .Releases 0).Tag -}} +
+

{{ .Name }}   Delete?

+

You've selected {{ .Running }}. Modify?

+
+ {{- end -}} + {{- end -}} +
+
+

Release notes

+ {{- range . -}} +
+

{{ .Name }}: release notes for {{ (index .Releases 0).Tag }}

{{- if eq .Forge "github" "gitea" "forgejo" -}} {{- (index .Releases 0).Content -}} {{- else -}} @@ -38,20 +70,10 @@ {{- (index .Releases 0).Content -}} {{- end -}} -
-

+

Back to project

+
+ {{- end -}} +
- {{- end -}} - {{- end -}} - - - {{- range . -}} - {{- if eq .Running (index .Releases 0).Tag -}} -
-

{{ .Name }}   Delete?

-

You've selected {{ .Running }}. Modify?

-
- {{- end -}} - {{- end -}} diff --git a/ws/static/login.html b/ws/static/login.html index 4f7bdf1..06d4a2d 100644 --- a/ws/static/login.html +++ b/ws/static/login.html @@ -17,7 +17,7 @@ - +

Willow

diff --git a/ws/static/new.html b/ws/static/new.html index 4d10525..b92e0a9 100644 --- a/ws/static/new.html +++ b/ws/static/new.html @@ -17,7 +17,7 @@ - +

Willow

diff --git a/ws/static/select-release.html b/ws/static/select-release.html index 7c962a6..a5fb1c7 100644 --- a/ws/static/select-release.html +++ b/ws/static/select-release.html @@ -17,7 +17,7 @@ - +

Willow

@@ -31,9 +31,9 @@
{{- else -}} {{- if eq $forge "sourcehut" -}} -
+
{{ .Tag }}
{{- else if eq $forge "gitlab" -}} -
+
{{ .Tag }}
{{- else -}}
{{- end -}} @@ -43,6 +43,7 @@ + diff --git a/ws/static/styles.css b/ws/static/styles.css index 580dbf8..bd9d9d7 100644 --- a/ws/static/styles.css +++ b/ws/static/styles.css @@ -37,12 +37,11 @@ } html { - max-width: 500px; margin: auto auto; color: #2f2f2f; background: white; font-family: 'Atkinson Hyperlegible', sans-serif; - padding-bottom: 20px; + scroll-behavior: smooth; } a { @@ -53,8 +52,28 @@ a:visited { color: #0640e0; } -.project { - max-width: 500px; +.two_column { + display: flex; + gap: 30px; + flex-direction: row; + margin: auto auto; + max-width: 1000px; + height: 92vh; +} + +.projects, .release_notes { + overflow: scroll; + flex: 0 0 500px; +} + +.release_note.card:not(:target) { display: none; } +.release_note.card:target { display: block; } + +.return_to_project { + display: none; +} + +.card { border: 2px solid #2f2f2f; background: #f8f8f8; border-radius: 5px; @@ -63,36 +82,37 @@ a:visited { box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19); } -.project > h2 { +.card > h3 { margin-top: 0; } -.project > p:first-of-type { +.card > p:first-of-type { margin-bottom: 16px; } -.project > h2 > span { +.card > p:last-of-type { + margin-bottom: 16px; +} + +.close, .delete { float: right; } +.delete { font-size: 12px; } +.close > a { + text-decoration: none; + color: #2f2f2f; +} + +.card > pre, .card > div > pre { overflow: scroll; } + +.wrapper { + max-width: 500px; + margin: auto auto; +} + +header > h1 > span { font-size: 12px; float: right; } -body > h1 > span { - font-size: 12px; - float: right; -} - -.project > details > pre { - overflow: scroll; -} - -summary { - cursor: pointer; -} - -details summary > * { - display: inline; -} - @media (prefers-color-scheme: dark) { html { background: #171717; @@ -107,8 +127,36 @@ details summary > * { color: #5582ff; } - .project { + .card { border: 2px solid #ccc; background: #1c1c1c; } + + .close > a { + color: #ccc; + } } + +@media only screen and (max-width: 1000px) { + div[id] { + display: block; + } + + .two_column { + flex-direction: column; + } + + .projects, .release_notes { + overflow: visible; + flex: 0 0 100%; + } + + .return_to_project { + display: block; + } + + .close { + display: none; + } +} + diff --git a/ws/ws.go b/ws/ws.go index 437b75d..8ca5050 100644 --- a/ws/ws.go +++ b/ws/ws.go @@ -16,18 +16,17 @@ import ( "text/template" "time" - "git.sr.ht/~amolith/willow/users" - "git.sr.ht/~amolith/willow/project" + "git.sr.ht/~amolith/willow/users" "github.com/microcosm-cc/bluemonday" ) type Handler struct { DbConn *sql.DB - Mutex *sync.Mutex Req *chan struct{} ManualRefresh *chan struct{} Res *chan []project.Project + Mu *sync.Mutex } //go:embed static @@ -41,8 +40,16 @@ func (h Handler) RootHandler(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/login", http.StatusSeeOther) return } - *h.Req <- struct{}{} - data := <-*h.Res + data, err := project.GetProjectsWithReleases(h.DbConn, h.Mu) + if err != nil { + fmt.Println(err) + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write([]byte("Internal Server Error")) + if err != nil { + fmt.Println(err) + } + return + } tmpl := template.Must(template.ParseFS(fs, "static/home.html")) if err := tmpl.Execute(w, data); err != nil { fmt.Println(err) @@ -114,7 +121,8 @@ func (h Handler) NewHandler(w http.ResponseWriter, r *http.Request) { } - proj, err = project.GetReleases(h.DbConn, proj) + proj.ID = project.GenProjectID(proj.URL, proj.Name, proj.Forge) + proj, err = project.GetReleases(h.DbConn, h.Mu, proj) if err != nil { w.WriteHeader(http.StatusBadRequest) _, err := w.Write([]byte(fmt.Sprintf("Error getting releases: %s", err))) @@ -129,8 +137,8 @@ func (h Handler) NewHandler(w http.ResponseWriter, r *http.Request) { fmt.Println(err) } } else if action == "delete" { - submittedURL := params.Get("url") - if submittedURL == "" { + submittedID := params.Get("id") + if submittedID == "" { w.WriteHeader(http.StatusBadRequest) _, err := w.Write([]byte("No URL provided")) if err != nil { @@ -139,7 +147,7 @@ func (h Handler) NewHandler(w http.ResponseWriter, r *http.Request) { return } - project.Untrack(h.DbConn, h.ManualRefresh, submittedURL) + project.Untrack(h.DbConn, h.Mu, submittedID) http.Redirect(w, r, "/", http.StatusSeeOther) } } @@ -149,28 +157,29 @@ func (h Handler) NewHandler(w http.ResponseWriter, r *http.Request) { if err != nil { fmt.Println(err) } + idValue := bmStrict.Sanitize(r.FormValue("id")) nameValue := bmStrict.Sanitize(r.FormValue("name")) urlValue := bmStrict.Sanitize(r.FormValue("url")) forgeValue := bmStrict.Sanitize(r.FormValue("forge")) releaseValue := bmStrict.Sanitize(r.FormValue("release")) - if nameValue != "" && urlValue != "" && forgeValue != "" && releaseValue != "" { - project.Track(h.DbConn, h.ManualRefresh, nameValue, urlValue, forgeValue, releaseValue) + // If releaseValue is not empty, we're updating an existing project + if idValue != "" && nameValue != "" && urlValue != "" && forgeValue != "" && releaseValue != "" { + project.Track(h.DbConn, h.Mu, h.ManualRefresh, nameValue, urlValue, forgeValue, releaseValue) http.Redirect(w, r, "/", http.StatusSeeOther) return } - if nameValue != "" && urlValue != "" && forgeValue != "" && releaseValue == "" { + // If releaseValue is empty, we're creating a new project + if idValue == "" && nameValue != "" && urlValue != "" && forgeValue != "" && releaseValue == "" { http.Redirect(w, r, "/new?action=yoink&name="+url.QueryEscape(nameValue)+"&url="+url.QueryEscape(urlValue)+"&forge="+url.QueryEscape(forgeValue), http.StatusSeeOther) return } - if nameValue == "" && urlValue == "" && forgeValue == "" && releaseValue == "" { - w.WriteHeader(http.StatusBadRequest) - _, err := w.Write([]byte("No data provided")) - if err != nil { - fmt.Println(err) - } + w.WriteHeader(http.StatusBadRequest) + _, err = w.Write([]byte("No data provided")) + if err != nil { + fmt.Println(err) } } }