Author | Jakob Wakeling <[email protected]> |
Date | 2023-12-15 10:28:06 |
Commit | 570144e80c9e9b0f73733fce92c0af0253f4a88e |
Parent | c2c52504fe1fabd5637de9db791e4cde73a02aad |
Implement repository importing
Diffstat
M | README.md | | | 2 | +- |
M | res/index.html | | | 1 | + |
M | res/repo/create.html | | | 2 | +- |
A | res/repo/import.html | | | 64 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
M | res/res.go | | | 3 | +++ |
A | src/cron/cron.go | | | 179 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
A | src/cron/schedule.go | | | 88 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
A | src/cron/schedule_test.go | | | 134 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
M | src/goit/auth.go | | | 16 | ++++++++-------- |
A | src/goit/db.go | | | 90 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
M | src/goit/goit.go | | | 34 | +++++++++------------------------- |
M | src/goit/http.go | | | 1 | + |
R | src/goit/log.go -> src/util/log.go | | | 2 | +- |
M | src/goit/repo.go | | | 80 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------- |
M | src/main.go | | | 7 | +++++-- |
M | src/repo/create.go | | | 2 | +- |
A | src/repo/import.go | | | 85 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
M | src/user/login.go | | | 2 | ++ |
M | src/user/sessions.go | | | 6 | +++--- |
19 files changed, 736 insertions, 62 deletions
diff --git a/README.md b/README.md index d205e4c..1b75d1d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A simple and lightweight Git web server. - File log, blame (planned), and raw views - Public and private repositories - Read and write permissions for non owners (planned) -- Repository mirroring (pull and/or push) (planned) +- Repository importing and mirroring ## Usage diff --git a/res/index.html b/res/index.html index df35113..68bd5da 100644 --- a/res/index.html +++ b/res/index.html @@ -14,6 +14,7 @@ <a href="/">Repositories</a> {{if .Auth}} | <a href="/repo/create">Create</a> + | <a href="/repo/import">Import</a> | <a href="/user/sessions">User</a> {{end}} {{if .Admin}} diff --git a/res/repo/create.html b/res/repo/create.html index 20c31b1..2ecbae7 100644 --- a/res/repo/create.html +++ b/res/repo/create.html @@ -27,7 +27,7 @@ <td> <select name="visibility"> <option value="public">Public</option> - <option value="private">Private</option> + <option value="private" {{if .IsPrivate}}selected{{end}}>Private</option> </select> </td> </tr> diff --git a/res/repo/import.html b/res/repo/import.html new file mode 100644 index 0000000..0a9f1ca --- /dev/null +++ b/res/repo/import.html @@ -0,0 +1,64 @@ +<!DOCTYPE html> +<head lang="en-GB">{{template "base/head" .}}</head> +<body> + <header> + <table> + <tr> + <td rowspan="2"><a href="/"><img src="/static/favicon.png" style="max-height: 24px"></a></td> + <td><h1>{{.Title}}</h1></td> + </tr> + <tr><td></td></tr> + </table> + </header> + <main> + <form action="/repo/import" method="post"> + {{.CsrfField}} + <table> + <tr> + <td style="text-align: right;"><label for="url">URL</label></td> + <td><input type="text" name="url"></td> + </tr> + <tr> + <td style="text-align: right;"><label for="username">Username</label></td> + <td><input type="text" name="username"></td> + </tr> + <tr> + <td style="text-align: right;"><label for="password">Password</label></td> + <td><input type="password" name="password"></td> + </tr> + <tr> + <td style="text-align: right;"><label for="mirror">Mirror</label></td> + <td><input type="checkbox" name="mirror" value="mirror" {{if .IsMirror}}checked{{end}}></td> + </tr> + <tr> + <td style="text-align: right;"><label for="reponame">Name</label></td> + <td><input type="text" name="reponame"></td> + </tr> + <tr> + <td style="text-align: right; vertical-align: top;"><label for="description">Description</label></td> + <td><textarea name="description"></textarea></td> + </tr> + <tr> + <td style="text-align: right;"><label for="visibility">Visibility</label></td> + <td> + <select name="visibility"> + <option value="public">Public</option> + <option value="private" {{if .IsPrivate}}selected{{end}}>Private</option> + </select> + </td> + </tr> + <tr> + <td></td> + <td> + <input type="submit" value="Import"> + <a href="/" style="color: inherit;">Cancel</a> + </td> + </tr> + <tr> + <td></td> + <td style="color: #AA0000">{{.Message}}</td> + </tr> + </table> + </form> + </main> +</body> diff --git a/res/res.go b/res/res.go index d257f88..489ab59 100644 --- a/res/res.go +++ b/res/res.go @@ -52,6 +52,9 @@ var RepoHeader string //go:embed repo/create.html var RepoCreate string +//go:embed repo/import.html +var RepoImport string + //go:embed repo/edit.html var RepoEdit string diff --git a/src/cron/cron.go b/src/cron/cron.go new file mode 100644 index 0000000..06c558b --- /dev/null +++ b/src/cron/cron.go @@ -0,0 +1,179 @@ +// Copyright (C) 2023, Jakob Wakeling +// All rights reserved. + +package cron + +import ( + "log" + "slices" + "sync" + "time" + + "github.com/Jamozed/Goit/src/util" +) + +type Cron struct { + jobs []Job + stop chan struct{} + update chan struct{} + running bool + mutex sync.Mutex + lastId uint64 + waiter sync.WaitGroup +} + +type Job struct { + id uint64 + schedule Schedule + next time.Time + fn func() +} + +const maxDuration time.Duration = 1<<63 - 1 + +func New() *Cron { + return &Cron{ + jobs: []Job{}, + stop: make(chan struct{}), + update: make(chan struct{}), + } +} + +func (c *Cron) Start() { + c.mutex.Lock() + util.Debugln("[cron.Start] Cron mutex lock") + defer c.mutex.Unlock() + defer util.Debugln("[cron.Start] Cron mutex unlock") + + if c.running { + return + } + + c.running = true + + for _, job := range c.jobs { + job.next = job.schedule.Next(time.Now().UTC()) + } + + go func() { + for { + c.mutex.Lock() + util.Debugln("[cron.run] Cron mutex lock") + + var timer *time.Timer + + if len(c.jobs) == 0 { + timer = time.NewTimer(maxDuration) + } else { + timer = time.NewTimer(c.jobs[0].next.Sub(time.Now().UTC())) + } + + c.mutex.Unlock() + util.Debugln("[cron.run] Cron mutex unlock") + + select { + case now := <-timer.C: + now = now.UTC() + log.Println("[cron] timer expired") + + c.mutex.Lock() + util.Debugln("[cron.now] Cron mutex lock") + + tmp := c.jobs[:0] + for _, job := range c.jobs { + if job.next.After(now) || job.next.IsZero() { + break + } + + log.Println("[cron] running job", job.id) + + c.waiter.Add(1) + go func() { + defer c.waiter.Done() + job.fn() + }() + + if !job.schedule.IsImmediate() { + job.next = job.schedule.Next(now) + tmp = append(tmp, job) + } + } + + c.jobs = tmp + + c.mutex.Unlock() + util.Debugln("[cron.now] Cron mutex unlock") + + c._update() + + case <-c.stop: + timer.Stop() + + c.mutex.Lock() + util.Debugln("[cron.stop] Cron mutex lock") + c.waiter.Wait() + c.running = false + c.mutex.Unlock() + util.Debugln("[cron.stop] Cron mutex unlock") + + return + + case <-c.update: + c._update() + } + } + }() +} + +func (c *Cron) Stop() { + c.mutex.Lock() + util.Debugln("[cron.Stop] Cron mutex lock") + defer c.mutex.Unlock() + defer util.Debugln("[cron.Stop] Cron mutex unlock") + + if !c.running { + return + } + + close(c.stop) +} + +func (c *Cron) Update() { + c.mutex.Lock() + util.Debugln("[cron.Update] Cron mutex lock") + defer c.mutex.Unlock() + defer util.Debugln("[cron.Update] Cron mutex unlock") + + if !c.running { + return + } + + c.update <- struct{}{} +} + +func (c *Cron) _update() { + c.mutex.Lock() + util.Debugln("[cron.Update] Cron mutex lock") + defer c.mutex.Unlock() + defer util.Debugln("[cron.Update] Cron mutex unlock") + + now := time.Now().UTC() + slices.SortFunc(c.jobs, func(a, b Job) int { + return a.schedule.Next(now).Compare(b.schedule.Next(now)) + }) +} + +func (c *Cron) Add(schedule Schedule, fn func()) uint64 { + c.mutex.Lock() + util.Debugln("[cron.Add] Cron mutex lock") + defer c.mutex.Unlock() + defer util.Debugln("[cron.Add] Cron mutex unlock") + + c.lastId += 1 + + job := Job{id: c.lastId, schedule: schedule, fn: fn} + job.next = job.schedule.Next(time.Now().UTC()) + c.jobs = append(c.jobs, job) + + return job.id +} diff --git a/src/cron/schedule.go b/src/cron/schedule.go new file mode 100644 index 0000000..7690775 --- /dev/null +++ b/src/cron/schedule.go @@ -0,0 +1,88 @@ +// Copyright (C) 2023, Jakob Wakeling +// All rights reserved. + +package cron + +import ( + "time" +) + +type Schedule struct{ Month, Day, Weekday, Hour, Minute, Second int64 } + +var Immediate = Schedule{-1, -1, -1, -1, -1, -1} + +func (s Schedule) Next(t time.Time) time.Time { + added := false + +wrap: + for s.Month != -1 && int64(t.Month()) != s.Month { + if !added { + t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()) + added = true + } + + t = t.AddDate(0, 1, 0) + + if t.Month() == time.January { + goto wrap + } + } + + for !((s.Day == -1 || int64(t.Day()) == s.Day) && (s.Weekday == -1 || int64(t.Weekday()) == s.Weekday)) { + if !added { + t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) + added = true + } + + t = t.AddDate(0, 0, 1) + + if t.Day() == 1 { + goto wrap + } + } + + for s.Hour != -1 && int64(t.Hour()) != s.Hour { + if !added { + t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location()) + added = true + } + + t = t.Add(1 * time.Hour) + + if t.Hour() == 0 { + goto wrap + } + } + + for s.Minute != -1 && int64(t.Minute()) != s.Minute { + if !added { + t = t.Truncate(time.Minute) + added = true + } + + t = t.Add(1 * time.Minute) + + if t.Minute() == 0 { + goto wrap + } + } + + for s.Second != -1 && int64(t.Second()) != s.Second { + if !added { + t = t.Truncate(time.Second) + added = true + } + + t = t.Add(1 * time.Second) + + if t.Second() == 0 { + goto wrap + } + } + + return t +} + +func (s Schedule) IsImmediate() bool { + return s == Immediate +} diff --git a/src/cron/schedule_test.go b/src/cron/schedule_test.go new file mode 100644 index 0000000..dde2256 --- /dev/null +++ b/src/cron/schedule_test.go @@ -0,0 +1,134 @@ +// Copyright (C) 2023, Jakob Wakeling +// All rights reserved. + +package cron_test + +import ( + "testing" + "time" + + "github.com/Jamozed/Goit/src/cron" +) + +func TestNext(t *testing.T) { + t.Run("Month", func(t *testing.T) { + schedule := cron.Schedule{6, -1, -1, -1, -1, -1} + baseTime := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + expected := time.Date(1970, 6, 1, 0, 0, 0, 0, time.UTC) + + r := schedule.Next(baseTime) + if r != expected { + t.Error("Expected", expected, "got", r) + } + }) + + t.Run("Month with Wrap", func(t *testing.T) { + schedule := cron.Schedule{6, -1, -1, -1, -1, -1} + baseTime := time.Date(1970, 8, 1, 0, 0, 0, 0, time.UTC) + expected := time.Date(1971, 6, 1, 0, 0, 0, 0, time.UTC) + + r := schedule.Next(baseTime) + if r != expected { + t.Error("Expected", expected, "got", r) + } + }) + + t.Run("Day", func(t *testing.T) { + schedule := cron.Schedule{-1, 12, -1, -1, -1, -1} + baseTime := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + expected := time.Date(1970, 1, 12, 0, 0, 0, 0, time.UTC) + + r := schedule.Next(baseTime) + if r != expected { + t.Error("Expected", expected, "got", r) + } + }) + + t.Run("Day with Wrap", func(t *testing.T) { + schedule := cron.Schedule{-1, 12, -1, -1, -1, -1} + baseTime := time.Date(1970, 1, 24, 0, 0, 0, 0, time.UTC) + expected := time.Date(1970, 2, 12, 0, 0, 0, 0, time.UTC) + + r := schedule.Next(baseTime) + if r != expected { + t.Error("Expected", expected, "got", r) + } + }) + + t.Run("Weekday", func(t *testing.T) { + schedule := cron.Schedule{-1, -1, 3, -1, -1, -1} + baseTime := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + expected := time.Date(1970, 1, 7, 0, 0, 0, 0, time.UTC) + + r := schedule.Next(baseTime) + if r != expected { + t.Error("Expected", expected, "got", r) + } + }) + + t.Run("Day and weekday", func(t *testing.T) { + schedule := cron.Schedule{-1, 12, 3, -1, -1, -1} + baseTime := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + expected := time.Date(1970, 8, 12, 0, 0, 0, 0, time.UTC) + + r := schedule.Next(baseTime) + if r != expected { + t.Error("Expected", expected, "got", r) + } + }) + + t.Run("Hour", func(t *testing.T) { + schedule := cron.Schedule{-1, -1, -1, 18, -1, -1} + baseTime := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + expected := time.Date(1970, 1, 1, 18, 0, 0, 0, time.UTC) + + r := schedule.Next(baseTime) + if r != expected { + t.Error("Expected", expected, "got", r) + } + }) + + t.Run("Minute", func(t *testing.T) { + schedule := cron.Schedule{-1, -1, -1, -1, 30, -1} + baseTime := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + expected := time.Date(1970, 1, 1, 0, 30, 0, 0, time.UTC) + + r := schedule.Next(baseTime) + if r != expected { + t.Error("Expected", expected, "got", r) + } + }) + + t.Run("Second", func(t *testing.T) { + schedule := cron.Schedule{-1, -1, -1, -1, -1, 30} + baseTime := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + expected := time.Date(1970, 1, 1, 0, 0, 30, 0, time.UTC) + + r := schedule.Next(baseTime) + if r != expected { + t.Error("Expected", expected, "got", r) + } + }) + + t.Run("All", func(t *testing.T) { + schedule := cron.Schedule{3, 6, 2, 6, 45, 15} + baseTime := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + expected := time.Date(1973, 3, 6, 6, 45, 15, 0, time.UTC) + + r := schedule.Next(baseTime) + if r != expected { + t.Error("Expected", expected, "got", r) + } + }) + + t.Run("Immediate", func(t *testing.T) { + schedule := cron.Schedule{-1, -1, -1, -1, -1, -1} + baseTime := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + expected := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC) + + r := schedule.Next(baseTime) + if r != expected { + t.Error("Expected", expected, "got", r) + } + }) +} diff --git a/src/goit/auth.go b/src/goit/auth.go index 564e23a..b0a9c31 100644 --- a/src/goit/auth.go +++ b/src/goit/auth.go @@ -38,7 +38,7 @@ func NewSession(uid int64, ip string, expiry time.Time) (Session, error) { var s = Session{Token: t, Ip: util.If(Conf.IpSessions, ip, ""), Seen: time.Now(), Expiry: expiry} SessionsMutex.Lock() - Debugln("[goit.NewSession] SessionsMutex lock") + util.Debugln("[goit.NewSession] SessionsMutex lock") if Sessions[uid] == nil { Sessions[uid] = []Session{} @@ -47,7 +47,7 @@ func NewSession(uid int64, ip string, expiry time.Time) (Session, error) { Sessions[uid] = append(Sessions[uid], s) SessionsMutex.Unlock() - Debugln("[goit.EndSession] SessionsMutex unlock") + util.Debugln("[goit.EndSession] SessionsMutex unlock") return s, nil } @@ -55,9 +55,9 @@ func NewSession(uid int64, ip string, expiry time.Time) (Session, error) { /* End a user session. */ func EndSession(uid int64, token string) { SessionsMutex.Lock() - Debugln("[goit.EndSession] SessionsMutex lock") + util.Debugln("[goit.EndSession] SessionsMutex lock") defer SessionsMutex.Unlock() - defer Debugln("[goit.EndSession] SessionsMutex unlock") + defer util.Debugln("[goit.EndSession] SessionsMutex unlock") if Sessions[uid] == nil { return @@ -80,7 +80,7 @@ func CleanupSessions() { var n int = 0 SessionsMutex.Lock() - Debugln("[goit.CleanupSessions] SessionsMutex lock") + util.Debugln("[goit.CleanupSessions] SessionsMutex lock") for uid, v := range Sessions { var i = 0 @@ -101,7 +101,7 @@ func CleanupSessions() { } SessionsMutex.Unlock() - Debugln("[goit.CleanupSessions] SessionsMutex unlock") + util.Debugln("[goit.CleanupSessions] SessionsMutex unlock") if n > 0 { log.Println("[Cleanup] cleaned up", n, "expired sessions") @@ -136,9 +136,9 @@ func GetSessionCookie(r *http.Request) (int64, Session) { } SessionsMutex.Lock() - Debugln("[goit.GetSessionCookie] SessionsMutex lock") + util.Debugln("[goit.GetSessionCookie] SessionsMutex lock") defer SessionsMutex.Unlock() - defer Debugln("[goit.GetSessionCookie] SessionsMutex unlock") + defer util.Debugln("[goit.GetSessionCookie] SessionsMutex unlock") for i, s := range Sessions[uid] { if ss[1] == s.Token { diff --git a/src/goit/db.go b/src/goit/db.go new file mode 100644 index 0000000..546e84e --- /dev/null +++ b/src/goit/db.go @@ -0,0 +1,90 @@ +package goit + +import ( + "database/sql" + "fmt" + "log" +) + +/* + Version 1 Table Schemas + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + name_full TEXT NOT NULL, + pass BLOB NOT NULL, + pass_algo TEXT NOT NULL, + salt BLOB NOT NULL, + is_admin BOOLEAN NOT NULL + ) + + CREATE TABLE IF NOT EXISTS repos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL, + name TEXT UNIQUE NOT NULL, + name_lower TEXT UNIQUE NOT NULL, + description TEXT NOT NULL, + upstream TEXT NOT NULL, + is_private BOOLEAN NOT NULL, + is_mirror BOOLEAN NOT NULL + ) +*/ + +func dbUpdate(db *sql.DB) error { + latestVersion := 1 + + var version int + if err := db.QueryRow("PRAGMA user_version").Scan(&version); err != nil { + return err + } + + if version > latestVersion { + return fmt.Errorf("database version is newer than supported (%d > %d)", version, latestVersion) + } + + if version == 0 { + /* Database is empty or new, initialise the newest version */ + log.Println("Initialising database at version", latestVersion) + + if _, err := db.Exec( + `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + name_full TEXT NOT NULL, + pass BLOB NOT NULL, + pass_algo TEXT NOT NULL, + salt BLOB NOT NULL, + is_admin BOOLEAN NOT NULL + )`, + ); err != nil { + return err + } + + if _, err := db.Exec( + `CREATE TABLE IF NOT EXISTS repos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL, + name TEXT UNIQUE NOT NULL, + name_lower TEXT UNIQUE NOT NULL, + description TEXT NOT NULL, + upstream TEXT NOT NULL, + is_private BOOLEAN NOT NULL, + is_mirror BOOLEAN NOT NULL + )`, + ); err != nil { + return err + } + + if _, err := db.Exec(fmt.Sprint("PRAGMA user_version = ", latestVersion)); err != nil { + return err + } + } + + for { + switch version { + default: /* No required migrations */ + return nil + } + } +} diff --git a/src/goit/goit.go b/src/goit/goit.go index 35bd116..df0480a 100644 --- a/src/goit/goit.go +++ b/src/goit/goit.go @@ -19,6 +19,7 @@ import ( "time" "github.com/Jamozed/Goit/res" + "github.com/Jamozed/Goit/src/cron" "github.com/Jamozed/Goit/src/util" "github.com/adrg/xdg" "github.com/go-git/go-git/v5" @@ -48,6 +49,7 @@ var Conf = Config{ var db *sql.DB var Favicon []byte +var Cron *cron.Cron var Reserved []string = []string{"admin", "repo", "static", "user"} @@ -90,31 +92,9 @@ func Goit(conf string) (err error) { return fmt.Errorf("[Database] %w", err) } - if _, err = db.Exec( - `CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT UNIQUE NOT NULL, - name_full TEXT NOT NULL, - pass BLOB NOT NULL, - pass_algo TEXT NOT NULL, - salt BLOB NOT NULL, - is_admin BOOLEAN NOT NULL - )`, - ); err != nil { - return fmt.Errorf("[CREATE:users] %w", err) - } - - if _, err = db.Exec( - `CREATE TABLE IF NOT EXISTS repos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - owner_id INTEGER NOT NULL, - name TEXT UNIQUE NOT NULL, - name_lower TEXT UNIQUE NOT NULL, - description TEXT NOT NULL, - is_private BOOLEAN NOT NULL - )`, - ); err != nil { - return fmt.Errorf("[CREATE repos] %w", err) + /* Update the database if necessary */ + if err := dbUpdate(db); err != nil { + return fmt.Errorf("[Database] %w", err) } /* Create an admin user if one does not exist */ @@ -134,6 +114,10 @@ func Goit(conf string) (err error) { } } + /* Initialise and start the cron service */ + Cron = cron.New() + Cron.Start() + return nil } diff --git a/src/goit/http.go b/src/goit/http.go index 8bf43d0..e9d6e9e 100644 --- a/src/goit/http.go +++ b/src/goit/http.go @@ -32,6 +32,7 @@ func init() { template.Must(Tmpl.New("repo/header").Parse(res.RepoHeader)) template.Must(Tmpl.New("repo/create").Parse(res.RepoCreate)) + template.Must(Tmpl.New("repo/import").Parse(res.RepoImport)) template.Must(Tmpl.New("repo/edit").Parse(res.RepoEdit)) template.Must(Tmpl.New("repo/log").Parse(res.RepoLog)) diff --git a/src/goit/repo.go b/src/goit/repo.go index 4e5e2ca..2fe48aa 100644 --- a/src/goit/repo.go +++ b/src/goit/repo.go @@ -10,7 +10,9 @@ import ( "os" "strings" + "github.com/Jamozed/Goit/src/util" "github.com/go-git/go-git/v5" + gitconfig "github.com/go-git/go-git/v5/config" ) type Repo struct { @@ -18,13 +20,15 @@ type Repo struct { OwnerId int64 `json:"owner_id"` Name string `json:"name"` Description string `json:"description"` + Upstream string `json:"upstream"` IsPrivate bool `json:"is_private"` + IsMirror bool `json:"is_mirror"` } func GetRepos() ([]Repo, error) { repos := []Repo{} - rows, err := db.Query("SELECT id, owner_id, name, description, is_private FROM repos") + rows, err := db.Query("SELECT id, owner_id, name, description, upstream, is_private, is_mirror FROM repos") if err != nil { return nil, err } @@ -33,7 +37,9 @@ func GetRepos() ([]Repo, error) { for rows.Next() { r := Repo{} - if err := rows.Scan(&r.Id, &r.OwnerId, &r.Name, &r.Description, &r.IsPrivate); err != nil { + if err := rows.Scan( + &r.Id, &r.OwnerId, &r.Name, &r.Description, &r.Upstream, &r.IsPrivate, &r.IsMirror, + ); err != nil { return nil, err } @@ -51,8 +57,8 @@ func GetRepo(rid int64) (*Repo, error) { r := &Repo{} if err := db.QueryRow( - "SELECT id, owner_id, name, description, is_private FROM repos WHERE id = ?", rid, - ).Scan(&r.Id, &r.OwnerId, &r.Name, &r.Description, &r.IsPrivate); err != nil { + "SELECT id, owner_id, name, description, upstream, is_private, is_mirror FROM repos WHERE id = ?", rid, + ).Scan(&r.Id, &r.OwnerId, &r.Name, &r.Description, &r.Upstream, &r.IsPrivate, &r.IsMirror); err != nil { if !errors.Is(err, sql.ErrNoRows) { return nil, err } @@ -67,8 +73,8 @@ func GetRepoByName(name string) (*Repo, error) { r := &Repo{} if err := db.QueryRow( - "SELECT id, owner_id, name, description, is_private FROM repos WHERE name = ?", name, - ).Scan(&r.Id, &r.OwnerId, &r.Name, &r.Description, &r.IsPrivate); err != nil { + "SELECT id, owner_id, name, description, upstream, is_private, is_mirror FROM repos WHERE name = ?", name, + ).Scan(&r.Id, &r.OwnerId, &r.Name, &r.Description, &r.Upstream, &r.IsPrivate, &r.IsMirror); err != nil { if !errors.Is(err, sql.ErrNoRows) { return nil, err } @@ -79,32 +85,47 @@ func GetRepoByName(name string) (*Repo, error) { return r, nil } -func CreateRepo(repo Repo) error { +func CreateRepo(repo Repo) (int64, error) { tx, err := db.Begin() if err != nil { - return err + return -1, err } - if _, err := tx.Exec( - `INSERT INTO repos (owner_id, name, name_lower, description, is_private) - VALUES (?, ?, ?, ?, ?)`, - repo.OwnerId, repo.Name, strings.ToLower(repo.Name), repo.Description, repo.IsPrivate, - ); err != nil { + res, err := tx.Exec( + `INSERT INTO repos (owner_id, name, name_lower, description, upstream, is_private, is_mirror) + VALUES (?, ?, ?, ?, ?, ?, ?)`, repo.OwnerId, repo.Name, strings.ToLower(repo.Name), repo.Description, + repo.Upstream, repo.IsPrivate, repo.IsMirror, + ) + if err != nil { tx.Rollback() - return err + return -1, err } - if _, err := git.PlainInit(RepoPath(repo.Name, true), true); err != nil { + r, err := git.PlainInit(RepoPath(repo.Name, true), true) + if err != nil { tx.Rollback() - return err + return -1, err } if err := tx.Commit(); err != nil { os.RemoveAll(RepoPath(repo.Name, true)) - return err + return -1, err } - return nil + if repo.Upstream != "" { + if _, err := r.CreateRemote(&gitconfig.RemoteConfig{ + Name: "origin", + URLs: []string{repo.Upstream}, + Mirror: util.If(repo.IsMirror, true, false), + Fetch: []gitconfig.RefSpec{gitconfig.RefSpec("+refs/heads/*:refs/heads/*")}, + }); err != nil { + log.Println("[repo/upstream]", err.Error()) + } + } + + rid, _ := res.LastInsertId() + + return rid, nil } func DelRepo(rid int64) error { @@ -150,8 +171,9 @@ func UpdateRepo(rid int64, repo Repo) error { } if _, err := tx.Exec( - "UPDATE repos SET name = ?, name_lower = ?, description = ?, is_private = ? WHERE id = ?", - repo.Name, strings.ToLower(repo.Name), repo.Description, repo.IsPrivate, rid, + `UPDATE repos SET name = ?, name_lower = ?, description = ?, upstream = ?, is_private = ?, is_mirror = ? + WHERE id = ?`, repo.Name, strings.ToLower(repo.Name), repo.Description, repo.Upstream, repo.IsPrivate, + repo.IsMirror, rid, ); err != nil { tx.Rollback() return err @@ -180,3 +202,21 @@ func ChownRepo(rid int64, uid int64) error { return nil } + +func Pull(rid int64) error { + repo, err := GetRepo(rid) + if err != nil { + return err + } + + r, err := git.PlainOpen(RepoPath(repo.Name, true)) + if err != nil { + return err + } + + if err := r.Fetch(&git.FetchOptions{}); err != nil { + return err + } + + return nil +} diff --git a/src/main.go b/src/main.go index 33297ff..9ebbbe8 100644 --- a/src/main.go +++ b/src/main.go @@ -36,7 +36,7 @@ func main() { var backup bool flag.BoolVar(&backup, "backup", false, "Perform a backup") - flag.BoolVar(&goit.Debug, "debug", false, "Enable debug logging") + flag.BoolVar(&util.Debug, "debug", false, "Enable debug logging") flag.Parse() if backup /* IPC client */ { @@ -71,6 +71,7 @@ func main() { go func() { <-c close(stop) + goit.Cron.Stop() wait.Wait() os.Exit(0) }() @@ -84,7 +85,7 @@ func main() { h.NotFound(goit.HttpNotFound) h.Use(middleware.RedirectSlashes) - if goit.Debug { + if util.Debug { h.Use(middleware.Logger) } else { h.Use(logHttp) @@ -109,6 +110,8 @@ func main() { r.Post("/user/edit", user.HandleEdit) r.Get("/repo/create", repo.HandleCreate) r.Post("/repo/create", repo.HandleCreate) + r.Get("/repo/import", repo.HandleImport) + r.Post("/repo/import", repo.HandleImport) r.Get("/admin", admin.HandleIndex) r.Get("/admin/users", admin.HandleUsers) r.Get("/admin/user/create", admin.HandleUserCreate) diff --git a/src/repo/create.go b/src/repo/create.go index 37aad0b..7564260 100644 --- a/src/repo/create.go +++ b/src/repo/create.go @@ -53,7 +53,7 @@ func HandleCreate(w http.ResponseWriter, r *http.Request) { return } else if exists { data.Message = "Name \"" + data.Name + "\" is taken" - } else if err := goit.CreateRepo(goit.Repo{ + } else if _, err := goit.CreateRepo(goit.Repo{ OwnerId: user.Id, Name: data.Name, Description: data.Description, IsPrivate: data.IsPrivate, }); err != nil { log.Println("[/repo/create]", err.Error()) diff --git a/src/repo/import.go b/src/repo/import.go new file mode 100644 index 0000000..e70b4d6 --- /dev/null +++ b/src/repo/import.go @@ -0,0 +1,85 @@ +// Copyright (C) 2023, Jakob Wakeling +// All rights reserved. + +package repo + +import ( + "html/template" + "log" + "net/http" + "slices" + "strings" + + "github.com/Jamozed/Goit/src/cron" + "github.com/Jamozed/Goit/src/goit" + "github.com/gorilla/csrf" +) + +func HandleImport(w http.ResponseWriter, r *http.Request) { + auth, user, err := goit.Auth(w, r, true) + if err != nil { + log.Println("[/repo/import]", err.Error()) + goit.HttpError(w, http.StatusInternalServerError) + } + + if !auth { + goit.HttpError(w, http.StatusUnauthorized) + return + } + + data := struct { + Title, Message string + Name, Description, Url string + IsPrivate, IsMirror bool + + CsrfField template.HTML + }{ + Title: "Repository - Create", + + CsrfField: csrf.TemplateField(r), + } + + if r.Method == http.MethodPost { + data.Name = r.FormValue("reponame") + data.Description = r.FormValue("description") + data.Url = r.FormValue("url") + data.IsPrivate = r.FormValue("visibility") == "private" + data.IsMirror = r.FormValue("mirror") == "mirror" + + if data.Url == "" { + data.Message = "URL cannot be empty" + } else if data.Name == "" { + data.Message = "Name cannot be empty" + } else if slices.Contains(goit.Reserved, strings.SplitN(data.Name, "/", 2)[0]) || !goit.IsLegal(data.Name) { + data.Message = "Name \"" + data.Name + "\" is illegal" + } else if exists, err := goit.RepoExists(data.Name); err != nil { + log.Println("[/repo/import]", err.Error()) + goit.HttpError(w, http.StatusInternalServerError) + return + } else if exists { + data.Message = "Name \"" + data.Name + "\" is taken" + } else if rid, err := goit.CreateRepo(goit.Repo{ + OwnerId: user.Id, Name: data.Name, Description: data.Description, Upstream: data.Url, + IsPrivate: data.IsPrivate, IsMirror: data.IsMirror, + }); err != nil { + log.Println("[/repo/import]", err.Error()) + goit.HttpError(w, http.StatusInternalServerError) + return + } else { + goit.Cron.Add(cron.Immediate, func() { + if err := goit.Pull(rid); err != nil { + log.Println("[/repo/import:cron]", err.Error()) + } + }) + + goit.Cron.Update() + + http.Redirect(w, r, "/"+data.Name, http.StatusFound) + return + } + } + + if err := goit.Tmpl.ExecuteTemplate(w, "repo/import", data); err != nil { + log.Println("[/repo/import]", err.Error()) + } +} diff --git a/src/user/login.go b/src/user/login.go index 8db8fd4..39e0101 100644 --- a/src/user/login.go +++ b/src/user/login.go @@ -65,6 +65,8 @@ func HandleLogin(w http.ResponseWriter, r *http.Request) { return } + log.Println("[login]", user.Name, "logged in from", ip) + goit.SetSessionCookie(w, user.Id, sess) http.Redirect(w, r, "/", http.StatusFound) return diff --git a/src/user/sessions.go b/src/user/sessions.go index e7c3840..6526cc9 100644 --- a/src/user/sessions.go +++ b/src/user/sessions.go @@ -40,14 +40,14 @@ func HandleSessions(w http.ResponseWriter, r *http.Request) { }{Title: "User - Sessions"} goit.SessionsMutex.RLock() - goit.Debugln("[goit.HandleSessions] SessionsMutex rlock") + util.Debugln("[goit.HandleSessions] SessionsMutex rlock") if revoke >= 0 && revoke < int64(len(goit.Sessions[user.Id])) { var token = goit.Sessions[user.Id][revoke].Token var current = token == ss.Token goit.SessionsMutex.RUnlock() - goit.Debugln("[goit.HandleSessions] SessionsMutex runlock") + util.Debugln("[goit.HandleSessions] SessionsMutex runlock") goit.EndSession(user.Id, token) @@ -69,7 +69,7 @@ func HandleSessions(w http.ResponseWriter, r *http.Request) { } goit.SessionsMutex.RUnlock() - goit.Debugln("[goit.HandleSessions] SessionsMutex runlock") + util.Debugln("[goit.HandleSessions] SessionsMutex runlock") if err := goit.Tmpl.ExecuteTemplate(w, "user/sessions", data); err != nil { log.Println("[/user/login]", err.Error()) diff --git a/src/goit/log.go b/src/util/log.go similarity index 94% rename from src/goit/log.go rename to src/util/log.go index efa35f7..d22fcb6 100644 --- a/src/goit/log.go +++ b/src/util/log.go @@ -1,7 +1,7 @@ // Copyright (C) 2023, Jakob Wakeling // All rights reserved. -package goit +package util import "log"