Goit

Simple and lightweight Git web server
git clone http://git.omkov.net/Goit
Log | Tree | Refs | README | Download

AuthorJakob Wakeling <[email protected]>
Date2023-12-15 10:28:06
Commit570144e80c9e9b0f73733fce92c0af0253f4a88e
Parentc2c52504fe1fabd5637de9db791e4cde73a02aad

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"