Goit

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

Goit/src/goit/goit.go (313 lines, 6.8 KiB) -rw-r--r-- blame download

0123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
// Copyright (C) 2023, Jakob Wakeling
// All rights reserved.

package goit

import (
	"archive/zip"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"log"
	"os"
	"path/filepath"
	"slices"
	"strings"
	"time"

	"github.com/Jamozed/Goit/res"
	"github.com/Jamozed/Goit/src/cron"
	"github.com/Jamozed/Goit/src/util"
	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing/transport"
	_ "github.com/mattn/go-sqlite3"
)

var Conf config
var db *sql.DB
var Favicon []byte
var Cron *cron.Cron

var Reserved []string = []string{"admin", "repo", "static", "user"}

var StartTime = time.Now()

func Goit() error {
	if conf, err := loadConfig(); err != nil {
		return err
	} else {
		Conf = conf
	}

	if err := os.MkdirAll(Conf.LogsPath, 0o777); err != nil {
		return fmt.Errorf("[config] %w", err)
	}

	logFile, err := os.Create(filepath.Join(Conf.LogsPath, fmt.Sprint("goit_", time.Now().Unix(), ".log")))
	if err != nil {
		log.Fatalln("[log]", err.Error())
	}

	log.SetOutput(io.MultiWriter(os.Stderr, logFile))
	log.Println("Starting Goit", res.Version)

	log.Println("[Config] using data path:", Conf.DataPath)
	if err := os.MkdirAll(Conf.DataPath, 0o777); err != nil {
		return fmt.Errorf("[config] %w", err)
	}

	if dat, err := os.ReadFile(filepath.Join(Conf.DataPath, "favicon.png")); err != nil {
		log.Println("[favicon]", err.Error())
	} else {
		Favicon = dat
	}

	if db, err = sql.Open("sqlite3", filepath.Join(Conf.DataPath, "goit.db?_timeout=5000")); err != nil {
		return fmt.Errorf("[database] %w", err)
	}

	/* Update the database if necessary */
	if err := updateDatabase(db); err != nil {
		return fmt.Errorf("[database] %w", err)
	}

	/* Create an admin user if one does not exist */
	if exists, err := UserExists("admin"); err != nil {
		log.Println("[admin:exists]", err.Error())
		err = nil /* ignored */
	} else if !exists {
		if salt, err := Salt(); err != nil {
			log.Println("[admin:salt]", err.Error())
			err = nil /* ignored */
		} else if _, err = db.Exec(
			"INSERT INTO users (id, name, name_full, pass, pass_algo, salt, is_admin) VALUES (?, ?, ?, ?, ?, ?, ?)",
			0, "admin", "Administrator", Hash("admin", salt), "argon2", salt, true,
		); err != nil {
			log.Println("[admin:INSERT]", err.Error())
			err = nil /* ignored */
		}
	}

	/* Initialise and start the cron service */
	Cron = cron.New()
	Cron.Start()

	/* Periodically clean up expired sessions */
	Cron.Add(-1, cron.Hourly, CleanupSessions)

	/* Add cron jobs for mirror repositories */
	repos, err := GetRepos()
	if err != nil {
		return err
	}

	for _, r := range repos {
		if r.IsMirror {
			util.Debugln("Adding mirror cron job for", r.Name)
			rid, name := r.Id, r.Name
			Cron.Add(r.Id, cron.Daily, func() {
				if err := Pull(rid); err != nil {
					log.Println("[cron:mirror]", rid, name, err.Error())
				} else {
					log.Println("[cron:mirror] updated", rid, name)
				}
			})
		}
	}

	Cron.Update()

	return nil
}

func RepoPath(name string, abs bool) string {
	return util.If(abs, filepath.Join(Conf.DataPath, "repos", name+".git"), filepath.Join(name+".git"))
}

func IsLegal(s string) bool {
	for i := 0; i < len(s); i += 1 {
		if !slices.Contains([]byte("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~/"), s[i]) {
			return false
		}
	}

	return true
}

func Backup() error {
	if conf, err := loadConfig(); err != nil {
		return err
	} else {
		Conf = conf
	}

	data := struct {
		Users []User `json:"users"`
		Repos []Repo `json:"repos"`
	}{}

	bdir := filepath.Join(Conf.DataPath, "backup")
	if err := os.MkdirAll(bdir, 0o777); err != nil {
		return err
	}

	db, err := sql.Open("sqlite3", filepath.Join(Conf.DataPath, "goit.db?_timeout=5000&_txlock=immediate"))
	if err != nil {
		return err
	}

	tx, err := db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()

	/* Dump users */
	rows, err := tx.Query("SELECT id, name, name_full, pass, pass_algo, salt, is_admin FROM users")
	if err != nil {
		return err
	}
	defer rows.Close()

	for rows.Next() {
		u := User{}
		if err := rows.Scan(&u.Id, &u.Name, &u.FullName, &u.Pass, &u.PassAlgo, &u.Salt, &u.IsAdmin); err != nil {
			return err
		}

		data.Users = append(data.Users, u)
	}

	/* Dump repositories */
	rows, err = tx.Query(
		"SELECT id, owner_id, name, description, default_branch, upstream, visibility, is_mirror FROM repos",
	)
	if err != nil {
		return err
	}
	defer rows.Close()

	for rows.Next() {
		r := Repo{}
		if err := rows.Scan(
			&r.Id, &r.OwnerId, &r.Name, &r.Description, &r.DefaultBranch, &r.Upstream, &r.Visibility, &r.IsMirror,
		); err != nil {
			return err
		}

		data.Repos = append(data.Repos, r)
	}

	/* Open an output ZIP file */
	ts := "goit_" + time.Now().UTC().Format("20060102T150405Z")

	log.Println("Backing up to", filepath.Join(bdir, ts+".zip"))
	zf, err := os.Create(filepath.Join(bdir, ts+".zip"))
	if err != nil {
		return err
	}
	defer zf.Close()

	zw := zip.NewWriter(zf)
	defer zw.Close()

	/* Copy repositories to ZIP */
	tempdir, err := os.MkdirTemp(os.TempDir(), "goit-")
	if err != nil {
		return err
	}
	defer os.RemoveAll(tempdir)

	for _, r := range data.Repos {
		t0 := time.Now()
		cd := filepath.Join(tempdir, RepoPath(r.Name, false))

		gr, err := git.PlainClone(cd, true, &git.CloneOptions{
			URL: RepoPath(r.Name, true), Mirror: true,
		})
		if err != nil {
			if errors.Is(err, transport.ErrRepositoryNotFound) {
				log.Println("Skipping", r.Name, "as it does not exist")
				continue
			}

			if errors.Is(err, transport.ErrEmptyRemoteRepository) {
				log.Println("Skipping", r.Name, "as it is empty")
				continue
			}

			return err
		}

		if err := gr.DeleteRemote("origin"); err != nil {
			return fmt.Errorf("%s %w", cd, err)
		}

		/* Walk duplicated repository and add it to the ZIP */
		if err = filepath.WalkDir(cd, func(path string, d fs.DirEntry, err error) error {
			if err != nil {
				return err
			}

			info, err := d.Info()
			if err != nil {
				return err
			}

			head, err := zip.FileInfoHeader(info)
			if err != nil {
				return err
			}

			head.Name = filepath.Join(ts, "repos", strings.TrimPrefix(path, tempdir))

			if d.IsDir() {
				head.Name += "/"
			} else {
				head.Method = zip.Store
			}

			w, err := zw.CreateHeader(head)
			if err != nil {
				return err
			}

			if !d.IsDir() {
				fi, err := os.Open(path)
				if err != nil {
					return err
				}

				if _, err := io.Copy(w, fi); err != nil {
					return err
				}
			}

			return nil
		}); err != nil {
			return err
		}

		os.RemoveAll(cd)
		log.Println("Backed up", r.Name, "in", time.Since(t0))
	}

	/* Write database as JSON to ZIP */
	if b, err := json.MarshalIndent(data, "", "\t"); err != nil {
		return err
	} else if w, err := zw.Create(filepath.Join(ts, "goit.json")); err != nil {
		return err
	} else if _, err := w.Write(b); err != nil {
		return err
	}

	if err := tx.Commit(); err != nil {
		return err
	}

	return nil
}