mirror of
https://github.com/thomiceli/opengist.git
synced 2025-06-19 00:27:11 +02:00
Initial commit
This commit is contained in:
83
internal/config/config.go
Normal file
83
internal/config/config.go
Normal file
@ -0,0 +1,83 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gopkg.in/yaml.v3"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
var OpengistVersion = "0.0.1"
|
||||
|
||||
var C *config
|
||||
|
||||
type config struct {
|
||||
OpengistHome string `yaml:"opengist-home"`
|
||||
DBFilename string `yaml:"db-filename"`
|
||||
DisableSignup bool `yaml:"disable-signup"`
|
||||
LogLevel string `yaml:"log-level"`
|
||||
|
||||
HTTP struct {
|
||||
Host string `yaml:"host"`
|
||||
Port string `yaml:"port"`
|
||||
Domain string `yaml:"domain"`
|
||||
Git bool `yaml:"git-enabled"`
|
||||
} `yaml:"http"`
|
||||
|
||||
SSH struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Host string `yaml:"host"`
|
||||
Port string `yaml:"port"`
|
||||
Domain string `yaml:"domain"`
|
||||
Keygen string `yaml:"keygen-executable"`
|
||||
} `yaml:"ssh"`
|
||||
}
|
||||
|
||||
func InitConfig(configPath string) error {
|
||||
c := &config{}
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.OpengistHome = filepath.Join(homeDir, ".opengist")
|
||||
c.LogLevel = "warn"
|
||||
file, err := os.Open(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
d := yaml.NewDecoder(file)
|
||||
if err = d.Decode(&c); err != nil {
|
||||
return err
|
||||
}
|
||||
C = c
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitLog() {
|
||||
if err := os.MkdirAll(filepath.Join(GetHomeDir(), "log"), 0755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
file, err := os.OpenFile(filepath.Join(GetHomeDir(), "log", "opengist.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
multi := zerolog.MultiLevelWriter(zerolog.NewConsoleWriter(), file)
|
||||
|
||||
var level zerolog.Level
|
||||
level, err = zerolog.ParseLevel(C.LogLevel)
|
||||
if err != nil {
|
||||
level = zerolog.InfoLevel
|
||||
}
|
||||
|
||||
log.Logger = zerolog.New(multi).Level(level).With().Timestamp().Logger()
|
||||
}
|
||||
|
||||
func GetHomeDir() string {
|
||||
absolutePath, _ := filepath.Abs(C.OpengistHome)
|
||||
return filepath.Clean(absolutePath)
|
||||
}
|
293
internal/git/commands.go
Normal file
293
internal/git/commands.go
Normal file
@ -0,0 +1,293 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"io"
|
||||
"opengist/internal/config"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func GetRepositoryPath(user string, gist string) (string, error) {
|
||||
return filepath.Join(config.GetHomeDir(), "repos", strings.ToLower(user), gist), nil
|
||||
}
|
||||
|
||||
func getTmpRepositoryPath(gistId string) (string, error) {
|
||||
dirname, err := getTmpRepositoriesPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dirname, gistId), nil
|
||||
}
|
||||
|
||||
func getTmpRepositoriesPath() (string, error) {
|
||||
return filepath.Join(config.GetHomeDir(), "tmp", "repos"), nil
|
||||
}
|
||||
|
||||
func InitRepository(user string, gist string) error {
|
||||
repositoryPath, err := GetRepositoryPath(user, gist)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command(
|
||||
"git",
|
||||
"init",
|
||||
"--bare",
|
||||
repositoryPath,
|
||||
)
|
||||
|
||||
_, err = cmd.Output()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f1, err := os.OpenFile(filepath.Join(repositoryPath, "git-daemon-export-ok"), os.O_RDONLY|os.O_CREATE, 0644)
|
||||
defer f1.Close()
|
||||
|
||||
preReceiveDst, err := os.OpenFile(filepath.Join(repositoryPath, "hooks", "pre-receive"), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0744)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
preReceiveSrc, err := os.OpenFile(filepath.Join("internal", "resources", "pre-receive"), os.O_RDONLY, os.ModeAppend)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(preReceiveDst, preReceiveSrc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer preReceiveDst.Close()
|
||||
defer preReceiveSrc.Close()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func GetNumberOfCommitsOfRepository(user string, gist string) (string, error) {
|
||||
repositoryPath, err := GetRepositoryPath(user, gist)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cmd := exec.Command(
|
||||
"git",
|
||||
"rev-list",
|
||||
"--all",
|
||||
"--count",
|
||||
)
|
||||
cmd.Dir = repositoryPath
|
||||
|
||||
stdout, err := cmd.Output()
|
||||
return strings.TrimSuffix(string(stdout), "\n"), err
|
||||
}
|
||||
|
||||
func GetFilesOfRepository(user string, gist string, commit string) ([]string, error) {
|
||||
repositoryPath, err := GetRepositoryPath(user, gist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd := exec.Command(
|
||||
"git",
|
||||
"ls-tree",
|
||||
commit,
|
||||
"--name-only",
|
||||
)
|
||||
cmd.Dir = repositoryPath
|
||||
|
||||
stdout, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slice := strings.Split(string(stdout), "\n")
|
||||
return slice[:len(slice)-1], nil
|
||||
}
|
||||
|
||||
func GetFileContent(user string, gist string, commit string, filename string) (string, error) {
|
||||
repositoryPath, err := GetRepositoryPath(user, gist)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cmd := exec.Command(
|
||||
"git",
|
||||
"--no-pager",
|
||||
"show",
|
||||
commit+":"+filename,
|
||||
)
|
||||
cmd.Dir = repositoryPath
|
||||
|
||||
stdout, err := cmd.Output()
|
||||
return string(stdout), err
|
||||
}
|
||||
|
||||
func GetLog(user string, gist string, skip string) (string, error) {
|
||||
repositoryPath, err := GetRepositoryPath(user, gist)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
cmd := exec.Command(
|
||||
"git",
|
||||
"--no-pager",
|
||||
"log",
|
||||
"-n",
|
||||
"11",
|
||||
"--no-prefix",
|
||||
"--no-color",
|
||||
"-p",
|
||||
"--skip",
|
||||
skip,
|
||||
"--format=format:%n=commit %H:%aN:%at",
|
||||
"--shortstat",
|
||||
"--ignore-missing", // avoid errors if a wrong hash is given
|
||||
"HEAD",
|
||||
)
|
||||
cmd.Dir = repositoryPath
|
||||
|
||||
stdout, err := cmd.Output()
|
||||
return string(stdout), err
|
||||
}
|
||||
|
||||
func CloneTmp(user string, gist string, gistTmpId string) error {
|
||||
repositoryPath, err := GetRepositoryPath(user, gist)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpPath, err := getTmpRepositoriesPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpRepositoryPath := path.Join(tmpPath, gistTmpId)
|
||||
|
||||
err = os.RemoveAll(tmpRepositoryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", "clone", repositoryPath, gistTmpId)
|
||||
cmd.Dir = tmpPath
|
||||
if err = cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd = exec.Command("git", "config", "user.name", user)
|
||||
cmd.Dir = tmpRepositoryPath
|
||||
if err = cmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remove every file (and not the .git directory!)
|
||||
cmd = exec.Command("find", ".", "-maxdepth", "1", "-type", "f", "-delete")
|
||||
cmd.Dir = tmpRepositoryPath
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func SetFileContent(gistTmpId string, filename string, content string) error {
|
||||
repositoryPath, err := getTmpRepositoryPath(gistTmpId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(filepath.Join(repositoryPath, filename), []byte(content), 0644)
|
||||
}
|
||||
|
||||
func AddAll(gistTmpId string) error {
|
||||
tmpPath, err := getTmpRepositoryPath(gistTmpId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// in case of a change where only a file name has its case changed
|
||||
cmd := exec.Command("git", "rm", "-r", "--cached", "--ignore-unmatch", ".")
|
||||
cmd.Dir = tmpPath
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd = exec.Command("git", "add", "-A")
|
||||
cmd.Dir = tmpPath
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func Commit(gistTmpId string) error {
|
||||
cmd := exec.Command("git", "commit", "--allow-empty", "-m", `"Opengist commit"`)
|
||||
tmpPath, err := getTmpRepositoryPath(gistTmpId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Dir = tmpPath
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func Push(gistTmpId string) error {
|
||||
tmpRepositoryPath, err := getTmpRepositoryPath(gistTmpId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command(
|
||||
"git",
|
||||
"push",
|
||||
)
|
||||
cmd.Dir = tmpRepositoryPath
|
||||
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.RemoveAll(tmpRepositoryPath)
|
||||
}
|
||||
|
||||
func DeleteRepository(user string, gist string) error {
|
||||
return os.RemoveAll(filepath.Join(config.GetHomeDir(), "repos", strings.ToLower(user), gist))
|
||||
}
|
||||
|
||||
func UpdateServerInfo(user string, gist string) error {
|
||||
repositoryPath, err := GetRepositoryPath(user, gist)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", "update-server-info")
|
||||
cmd.Dir = repositoryPath
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func RPCRefs(user string, gist string, service string) ([]byte, error) {
|
||||
repositoryPath, err := GetRepositoryPath(user, gist)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", service, "--stateless-rpc", "--advertise-refs", ".")
|
||||
cmd.Dir = repositoryPath
|
||||
stdout, err := cmd.Output()
|
||||
return stdout, err
|
||||
}
|
||||
|
||||
func GetGitVersion() (string, error) {
|
||||
cmd := exec.Command("git", "--version")
|
||||
stdout, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
versionFields := strings.Fields(string(stdout))
|
||||
if len(versionFields) < 3 {
|
||||
return string(stdout), nil
|
||||
}
|
||||
|
||||
return versionFields[2], nil
|
||||
}
|
31
internal/models/db.go
Normal file
31
internal/models/db.go
Normal file
@ -0,0 +1,31 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var db *gorm.DB
|
||||
|
||||
func Setup(dbpath string) error {
|
||||
var err error
|
||||
|
||||
if db, err = gorm.Open(sqlite.Open(dbpath+"?_fk=true"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = db.AutoMigrate(&User{}, &SSHKey{}, &Gist{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CountAll(table interface{}) (int64, error) {
|
||||
var count int64
|
||||
err := db.Model(table).Count(&count).Error
|
||||
return count, err
|
||||
}
|
136
internal/models/gist.go
Normal file
136
internal/models/gist.go
Normal file
@ -0,0 +1,136 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Gist struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Uuid string
|
||||
Title string `validate:"max=50" form:"title"`
|
||||
Preview string
|
||||
PreviewFilename string
|
||||
Description string `validate:"max=150" form:"description"`
|
||||
Private bool `form:"private"`
|
||||
UserID uint
|
||||
User User `validate:"-"`
|
||||
NbFiles int
|
||||
NbLikes int
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
|
||||
Likes []User `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
|
||||
Files []File `gorm:"-" validate:"min=1,dive"`
|
||||
}
|
||||
|
||||
type File struct {
|
||||
Filename string `validate:"excludes=\x2f,excludes=\x5c,max=50"`
|
||||
OldFilename string `validate:"excludes=\x2f,excludes=\x5c,max=50"`
|
||||
Content string `validate:"required"`
|
||||
}
|
||||
|
||||
type Commit struct {
|
||||
Hash string
|
||||
Author string
|
||||
Timestamp string
|
||||
Changed string
|
||||
Files []File
|
||||
}
|
||||
|
||||
func GetGist(user string, gistUuid string) (*Gist, error) {
|
||||
gist := new(Gist)
|
||||
err := db.Preload("User").
|
||||
Where("gists.uuid = ? AND users.username like ?", gistUuid, user).
|
||||
Joins("join users on gists.user_id = users.id").
|
||||
First(&gist).Error
|
||||
|
||||
return gist, err
|
||||
}
|
||||
|
||||
func GetGistByID(gistId string) (*Gist, error) {
|
||||
gist := new(Gist)
|
||||
err := db.Preload("User").
|
||||
Where("gists.id = ?", gistId).
|
||||
First(&gist).Error
|
||||
|
||||
return gist, err
|
||||
}
|
||||
|
||||
func GetAllGistsForCurrentUser(currentUserId uint, offset int, sort string, order string) ([]*Gist, error) {
|
||||
var gists []*Gist
|
||||
err := db.Preload("User").
|
||||
Where("gists.private = 0 or gists.user_id = ?", currentUserId).
|
||||
Limit(11).
|
||||
Offset(offset * 10).
|
||||
Order(sort + "_at " + order).
|
||||
Find(&gists).Error
|
||||
|
||||
return gists, err
|
||||
}
|
||||
|
||||
func GetAllGists(offset int) ([]*Gist, error) {
|
||||
var all []*Gist
|
||||
err := db.Preload("User").
|
||||
Limit(11).
|
||||
Offset(offset * 10).
|
||||
Order("id asc").
|
||||
Find(&all).Error
|
||||
|
||||
return all, err
|
||||
}
|
||||
|
||||
func GetAllGistsFromUser(fromUser string, currentUserId uint, offset int, sort string, order string) ([]*Gist, error) {
|
||||
var gists []*Gist
|
||||
err := db.Preload("User").
|
||||
Where("users.username = ? and ((gists.private = 0) or (gists.private = 1 and gists.user_id = ?))", fromUser, currentUserId).
|
||||
Joins("join users on gists.user_id = users.id").
|
||||
Limit(11).
|
||||
Offset(offset * 10).
|
||||
Order("gists." + sort + "_at " + order).
|
||||
Find(&gists).Error
|
||||
|
||||
return gists, err
|
||||
}
|
||||
|
||||
func CreateGist(gist *Gist) error {
|
||||
return db.Create(&gist).Error
|
||||
}
|
||||
|
||||
func UpdateGist(gist *Gist) error {
|
||||
return db.Save(&gist).Error
|
||||
}
|
||||
|
||||
func DeleteGist(gist *Gist) error {
|
||||
return db.Delete(&gist).Error
|
||||
}
|
||||
|
||||
func GistLastActiveNow(gistID uint) error {
|
||||
return db.Model(&Gist{}).
|
||||
Where("id = ?", gistID).
|
||||
Update("updated_at", time.Now().Unix()).Error
|
||||
}
|
||||
|
||||
func AppendUserLike(gist *Gist, user *User) error {
|
||||
db.Model(&gist).Omit("updated_at").Update("nb_likes", gist.NbLikes+1)
|
||||
return db.Model(&gist).Omit("updated_at").Association("Likes").Append(user)
|
||||
}
|
||||
|
||||
func RemoveUserLike(gist *Gist, user *User) error {
|
||||
db.Model(&gist).Omit("updated_at").Update("nb_likes", gist.NbLikes-1)
|
||||
return db.Model(&gist).Omit("updated_at").Association("Likes").Delete(user)
|
||||
}
|
||||
|
||||
func GetUsersLikesForGists(gist *Gist, offset int) ([]*User, error) {
|
||||
var users []*User
|
||||
err := db.Model(&gist).
|
||||
Where("gist_id = ?", gist.ID).
|
||||
Limit(31).
|
||||
Offset(offset * 30).
|
||||
Association("Likes").Find(&users)
|
||||
return users, err
|
||||
}
|
||||
|
||||
func UserCanWrite(user *User, gist *Gist) bool {
|
||||
return !(user == nil) && (gist.UserID == user.ID)
|
||||
}
|
56
internal/models/sshkey.go
Normal file
56
internal/models/sshkey.go
Normal file
@ -0,0 +1,56 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type SSHKey struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Title string `form:"title" validate:"required,max=50"`
|
||||
Content string `form:"content" validate:"required"`
|
||||
SHA string
|
||||
CreatedAt int64
|
||||
LastUsedAt int64
|
||||
UserID uint
|
||||
User User `validate:"-" `
|
||||
}
|
||||
|
||||
func GetSSHKeysByUserID(userId uint) ([]*SSHKey, error) {
|
||||
var sshKeys []*SSHKey
|
||||
err := db.
|
||||
Where("user_id = ?", userId).
|
||||
Order("created_at asc").
|
||||
Find(&sshKeys).Error
|
||||
|
||||
return sshKeys, err
|
||||
}
|
||||
|
||||
func GetSSHKeyByID(sshKeyId uint) (*SSHKey, error) {
|
||||
sshKey := new(SSHKey)
|
||||
err := db.
|
||||
Where("id = ?", sshKeyId).
|
||||
First(&sshKey).Error
|
||||
|
||||
return sshKey, err
|
||||
}
|
||||
|
||||
func GetSSHKeyByContent(sshKeyContent string) (*SSHKey, error) {
|
||||
sshKey := new(SSHKey)
|
||||
err := db.
|
||||
Where("content like ?", sshKeyContent+"%").
|
||||
First(&sshKey).Error
|
||||
|
||||
return sshKey, err
|
||||
}
|
||||
|
||||
func AddSSHKey(sshKey *SSHKey) error {
|
||||
return db.Create(&sshKey).Error
|
||||
}
|
||||
|
||||
func RemoveSSHKey(sshKey *SSHKey) error {
|
||||
return db.Delete(&sshKey).Error
|
||||
}
|
||||
|
||||
func SSHKeyLastUsedNow(sshKeyID uint) error {
|
||||
return db.Model(&SSHKey{}).
|
||||
Where("id = ?", sshKeyID).
|
||||
Update("last_used_at", time.Now().Unix()).Error
|
||||
}
|
77
internal/models/user.go
Normal file
77
internal/models/user.go
Normal file
@ -0,0 +1,77 @@
|
||||
package models
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Username string `form:"username" gorm:"uniqueIndex" validate:"required,max=24,alphanum,notreserved"`
|
||||
Password string `form:"password" validate:"required"`
|
||||
IsAdmin bool
|
||||
CreatedAt int64
|
||||
|
||||
Gists []Gist `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
|
||||
SSHKeys []SSHKey `gorm:"foreignKey:UserID"`
|
||||
Liked []Gist `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
func DoesUserExists(userName string, count *int64) error {
|
||||
return db.Table("users").
|
||||
Where("username like ?", userName).
|
||||
Count(count).Error
|
||||
}
|
||||
|
||||
func GetAllUsers(offset int) ([]*User, error) {
|
||||
var all []*User
|
||||
err := db.
|
||||
Limit(11).
|
||||
Offset(offset * 10).
|
||||
Order("id asc").
|
||||
Find(&all).Error
|
||||
|
||||
return all, err
|
||||
}
|
||||
|
||||
func GetLoginUser(user *User) error {
|
||||
return db.
|
||||
Where("username like ?", user.Username).
|
||||
First(&user).Error
|
||||
}
|
||||
|
||||
func GetLoginUserById(user *User) error {
|
||||
return db.
|
||||
Where("id = ?", user.ID).
|
||||
First(&user).Error
|
||||
}
|
||||
|
||||
func CreateUser(user *User) error {
|
||||
return db.Create(&user).Error
|
||||
}
|
||||
|
||||
func DeleteUserByID(userid string) error {
|
||||
return db.Delete(&User{}, "id = ?", userid).Error
|
||||
}
|
||||
|
||||
func SetAdminUser(user *User) error {
|
||||
return db.Model(&user).Update("is_admin", true).Error
|
||||
}
|
||||
|
||||
func GetUserBySSHKeyID(sshKeyId uint) (*User, error) {
|
||||
user := new(User)
|
||||
err := db.
|
||||
Preload("SSHKeys").
|
||||
Joins("join ssh_keys on users.id = ssh_keys.user_id").
|
||||
Where("ssh_keys.id = ?", sshKeyId).
|
||||
First(&user).Error
|
||||
|
||||
return user, err
|
||||
}
|
||||
|
||||
func UserHasLikedGist(user *User, gist *Gist) (bool, error) {
|
||||
association := db.Model(&gist).Where("user_id = ?", user.ID).Association("Likes")
|
||||
if association.Error != nil {
|
||||
return false, association.Error
|
||||
}
|
||||
|
||||
if association.Count() == 0 {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
21
internal/resources/pre-receive
Normal file
21
internal/resources/pre-receive
Normal file
@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
|
||||
disallowed_files=()
|
||||
|
||||
while read old_rev new_rev ref
|
||||
do
|
||||
for file in $(git diff --name-only $old_rev $new_rev)
|
||||
do
|
||||
if [[ $file =~ / ]]; then
|
||||
disallowed_files+=($file)
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [ ${#disallowed_files[@]} -gt 0 ]; then
|
||||
echo "Pushing files in folders is not allowed:"
|
||||
for file in "${disallowed_files[@]}"; do
|
||||
echo " $file"
|
||||
done
|
||||
exit 1
|
||||
fi
|
103
internal/ssh/git-ssh.go
Normal file
103
internal/ssh/git-ssh.go
Normal file
@ -0,0 +1,103 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"gorm.io/gorm"
|
||||
"io"
|
||||
"opengist/internal/git"
|
||||
"opengist/internal/models"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func runGitCommand(ch ssh.Channel, gitCmd string, keyID uint) error {
|
||||
verb, args := parseCommand(gitCmd)
|
||||
if !strings.HasPrefix(verb, "git-") {
|
||||
verb = ""
|
||||
}
|
||||
verb = strings.TrimPrefix(verb, "git-")
|
||||
|
||||
if verb != "upload-pack" && verb != "receive-pack" {
|
||||
return errors.New("invalid command")
|
||||
}
|
||||
|
||||
repoFullName := strings.ToLower(strings.Trim(args, "'"))
|
||||
repoFields := strings.SplitN(repoFullName, "/", 2)
|
||||
if len(repoFields) != 2 {
|
||||
return errors.New("invalid gist path")
|
||||
}
|
||||
|
||||
userName := strings.ToLower(repoFields[0])
|
||||
gistName := strings.TrimSuffix(strings.ToLower(repoFields[1]), ".git")
|
||||
|
||||
gist, err := models.GetGist(userName, gistName)
|
||||
if err != nil {
|
||||
return errors.New("gist not found")
|
||||
}
|
||||
|
||||
if verb == "receive-pack" {
|
||||
user, err := models.GetUserBySSHKeyID(keyID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("unauthorized")
|
||||
}
|
||||
errorSsh("Failed to get user by SSH key id", err)
|
||||
return errors.New("internal server error")
|
||||
}
|
||||
|
||||
if user.ID != gist.UserID {
|
||||
return errors.New("unauthorized")
|
||||
}
|
||||
}
|
||||
|
||||
_ = models.SSHKeyLastUsedNow(keyID)
|
||||
|
||||
repositoryPath, err := git.GetRepositoryPath(gist.User.Username, gist.Uuid)
|
||||
if err != nil {
|
||||
errorSsh("Failed to get repository path", err)
|
||||
return errors.New("internal server error")
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", verb, repositoryPath)
|
||||
cmd.Dir = repositoryPath
|
||||
|
||||
stdin, _ := cmd.StdinPipe()
|
||||
stdout, _ := cmd.StdoutPipe()
|
||||
stderr, _ := cmd.StderrPipe()
|
||||
|
||||
if err = cmd.Start(); err != nil {
|
||||
errorSsh("Failed to start git command", err)
|
||||
return errors.New("internal server error")
|
||||
}
|
||||
|
||||
// avoid blocking
|
||||
go func() {
|
||||
_, _ = io.Copy(stdin, ch)
|
||||
}()
|
||||
_, _ = io.Copy(ch, stdout)
|
||||
_, _ = io.Copy(ch, stderr)
|
||||
|
||||
err = cmd.Wait()
|
||||
if err != nil {
|
||||
errorSsh("Failed to wait for git command", err)
|
||||
return errors.New("internal server error")
|
||||
}
|
||||
|
||||
// updatedAt is updated only if serviceType is receive-pack
|
||||
if verb == "receive-pack" {
|
||||
_ = models.GistLastActiveNow(gist.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCommand(cmd string) (string, string) {
|
||||
split := strings.SplitN(cmd, " ", 2)
|
||||
|
||||
if len(split) != 2 {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return split[0], strings.Replace(split[1], "'/", "'", 1)
|
||||
}
|
153
internal/ssh/run.go
Normal file
153
internal/ssh/run.go
Normal file
@ -0,0 +1,153 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"gorm.io/gorm"
|
||||
"io"
|
||||
"net"
|
||||
"opengist/internal/config"
|
||||
"opengist/internal/models"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func Start() {
|
||||
if !config.C.SSH.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
sshConfig := &ssh.ServerConfig{
|
||||
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
pkey, err := models.GetSSHKeyByContent(strings.TrimSpace(string(ssh.MarshalAuthorizedKey(key))))
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return &ssh.Permissions{Extensions: map[string]string{"key-id": strconv.Itoa(int(pkey.ID))}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
key, err := setupHostKey()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("SSH: Could not setup host key")
|
||||
}
|
||||
|
||||
sshConfig.AddHostKey(key)
|
||||
go listen(sshConfig)
|
||||
}
|
||||
|
||||
func listen(serverConfig *ssh.ServerConfig) {
|
||||
log.Info().Msg("Starting SSH server on ssh://" + config.C.SSH.Host + ":" + config.C.SSH.Port)
|
||||
listener, err := net.Listen("tcp", config.C.SSH.Host+":"+config.C.SSH.Port)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("SSH: Failed to start SSH server")
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
for {
|
||||
nConn, err := listener.Accept()
|
||||
if err != nil {
|
||||
errorSsh("Failed to accept incoming connection", err)
|
||||
continue
|
||||
}
|
||||
|
||||
go func() {
|
||||
sConn, channels, reqs, err := ssh.NewServerConn(nConn, serverConfig)
|
||||
if err != nil {
|
||||
if !(err != io.EOF && !errors.Is(err, syscall.ECONNRESET)) {
|
||||
errorSsh("Failed to handshake", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
go ssh.DiscardRequests(reqs)
|
||||
keyID, _ := strconv.Atoi(sConn.Permissions.Extensions["key-id"])
|
||||
go handleConnexion(channels, uint(keyID))
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func handleConnexion(channels <-chan ssh.NewChannel, keyID uint) {
|
||||
for channel := range channels {
|
||||
if channel.ChannelType() != "session" {
|
||||
_ = channel.Reject(ssh.UnknownChannelType, "Unknown channel type")
|
||||
continue
|
||||
}
|
||||
|
||||
ch, reqs, err := channel.Accept()
|
||||
if err != nil {
|
||||
errorSsh("Could not accept channel", err)
|
||||
continue
|
||||
}
|
||||
|
||||
go func(in <-chan *ssh.Request) {
|
||||
defer func() {
|
||||
_ = ch.Close()
|
||||
}()
|
||||
for req := range in {
|
||||
switch req.Type {
|
||||
case "env":
|
||||
|
||||
case "shell":
|
||||
_, _ = ch.Write([]byte("Successfully connected to Opengist SSH server.\r\n"))
|
||||
_, _ = ch.SendRequest("exit-status", false, []byte{0, 0, 0, 0})
|
||||
return
|
||||
case "exec":
|
||||
payloadCmd := string(req.Payload)
|
||||
i := strings.Index(payloadCmd, "git")
|
||||
if i != -1 {
|
||||
payloadCmd = payloadCmd[i:]
|
||||
}
|
||||
|
||||
if err = runGitCommand(ch, payloadCmd, keyID); err != nil {
|
||||
_, _ = ch.Stderr().Write([]byte("Opengist: " + err.Error() + "\r\n"))
|
||||
}
|
||||
_, _ = ch.SendRequest("exit-status", false, []byte{0, 0, 0, 0})
|
||||
return
|
||||
}
|
||||
}
|
||||
}(reqs)
|
||||
}
|
||||
}
|
||||
|
||||
func setupHostKey() (ssh.Signer, error) {
|
||||
dir := filepath.Join(config.GetHomeDir(), "ssh")
|
||||
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keyPath := filepath.Join(dir, "opengist-ed25519")
|
||||
if _, err := os.Stat(keyPath); err != nil && !os.IsExist(err) {
|
||||
cmd := exec.Command(config.C.SSH.Keygen,
|
||||
"-t", "ssh-ed25519",
|
||||
"-f", keyPath,
|
||||
"-m", "PEM",
|
||||
"-N", "")
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
keyData, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
signer, err := ssh.ParsePrivateKey(keyData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
func errorSsh(message string, err error) {
|
||||
log.Error().Err(err).Msg("SSH: " + message)
|
||||
}
|
106
internal/web/admin.go
Normal file
106
internal/web/admin.go
Normal file
@ -0,0 +1,106 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"opengist/internal/config"
|
||||
"opengist/internal/git"
|
||||
"opengist/internal/models"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func adminIndex(ctx echo.Context) error {
|
||||
setData(ctx, "title", "Admin panel")
|
||||
setData(ctx, "adminHeaderPage", "index")
|
||||
|
||||
setData(ctx, "opengistVersion", config.OpengistVersion)
|
||||
setData(ctx, "goVersion", runtime.Version())
|
||||
gitVersion, err := git.GetGitVersion()
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot get git version", err)
|
||||
}
|
||||
setData(ctx, "gitVersion", gitVersion)
|
||||
|
||||
countUsers, err := models.CountAll(&models.User{})
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot count users", err)
|
||||
}
|
||||
setData(ctx, "countUsers", countUsers)
|
||||
|
||||
countGists, err := models.CountAll(&models.Gist{})
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot count gists", err)
|
||||
}
|
||||
setData(ctx, "countGists", countGists)
|
||||
|
||||
countKeys, err := models.CountAll(&models.SSHKey{})
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot count SSH keys", err)
|
||||
}
|
||||
setData(ctx, "countKeys", countKeys)
|
||||
|
||||
return html(ctx, "admin_index.html")
|
||||
}
|
||||
|
||||
func adminUsers(ctx echo.Context) error {
|
||||
setData(ctx, "title", "Users")
|
||||
setData(ctx, "adminHeaderPage", "users")
|
||||
pageInt := getPage(ctx)
|
||||
|
||||
var data []*models.User
|
||||
var err error
|
||||
if data, err = models.GetAllUsers(pageInt - 1); err != nil {
|
||||
return errorRes(500, "Cannot get users", err)
|
||||
}
|
||||
|
||||
if err = paginate(ctx, data, pageInt, 10, "data", "admin/users"); err != nil {
|
||||
return errorRes(404, "Page not found", nil)
|
||||
}
|
||||
|
||||
return html(ctx, "admin_users.html")
|
||||
}
|
||||
|
||||
func adminGists(ctx echo.Context) error {
|
||||
setData(ctx, "title", "Users")
|
||||
setData(ctx, "adminHeaderPage", "gists")
|
||||
pageInt := getPage(ctx)
|
||||
|
||||
var data []*models.Gist
|
||||
var err error
|
||||
if data, err = models.GetAllGists(pageInt - 1); err != nil {
|
||||
return errorRes(500, "Cannot get gists", err)
|
||||
}
|
||||
|
||||
if err = paginate(ctx, data, pageInt, 10, "data", "admin/gists"); err != nil {
|
||||
return errorRes(404, "Page not found", nil)
|
||||
}
|
||||
|
||||
return html(ctx, "admin_gists.html")
|
||||
}
|
||||
|
||||
func adminUserDelete(ctx echo.Context) error {
|
||||
if err := models.DeleteUserByID(ctx.Param("user")); err != nil {
|
||||
return errorRes(500, "Cannot delete this user", err)
|
||||
}
|
||||
|
||||
addFlash(ctx, "User has been deleted", "success")
|
||||
return redirect(ctx, "/admin/users")
|
||||
}
|
||||
|
||||
func adminGistDelete(ctx echo.Context) error {
|
||||
gist, err := models.GetGistByID(ctx.Param("gist"))
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot retrieve gist", err)
|
||||
}
|
||||
|
||||
if err = git.DeleteRepository(gist.User.Username, gist.Uuid); err != nil {
|
||||
return errorRes(500, "Cannot delete the repository", err)
|
||||
}
|
||||
|
||||
if err = models.DeleteGist(gist); err != nil {
|
||||
return errorRes(500, "Cannot delete this gist", err)
|
||||
}
|
||||
|
||||
addFlash(ctx, "Gist has been deleted", "success")
|
||||
return redirect(ctx, "/admin/gists")
|
||||
|
||||
}
|
103
internal/web/auth.go
Normal file
103
internal/web/auth.go
Normal file
@ -0,0 +1,103 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"opengist/internal/config"
|
||||
"opengist/internal/models"
|
||||
)
|
||||
|
||||
func register(ctx echo.Context) error {
|
||||
setData(ctx, "title", "New account")
|
||||
setData(ctx, "htmlTitle", "New account")
|
||||
return html(ctx, "auth_form.html")
|
||||
}
|
||||
|
||||
func processRegister(ctx echo.Context) error {
|
||||
if config.C.DisableSignup {
|
||||
return errorRes(403, "Signing up is disabled", nil)
|
||||
}
|
||||
|
||||
setData(ctx, "title", "New account")
|
||||
setData(ctx, "htmlTitle", "New account")
|
||||
|
||||
sess := getSession(ctx)
|
||||
|
||||
var user = new(models.User)
|
||||
if err := ctx.Bind(user); err != nil {
|
||||
return errorRes(400, "Cannot bind data", err)
|
||||
}
|
||||
|
||||
if err := ctx.Validate(user); err != nil {
|
||||
addFlash(ctx, validationMessages(&err), "error")
|
||||
return html(ctx, "auth_form.html")
|
||||
}
|
||||
|
||||
password, err := argon2id.hash(user.Password)
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot hash password", err)
|
||||
}
|
||||
user.Password = password
|
||||
|
||||
var count int64
|
||||
if err = models.DoesUserExists(user.Username, &count); err != nil || count >= 1 {
|
||||
addFlash(ctx, "Username already exists", "error")
|
||||
return html(ctx, "auth_form.html")
|
||||
}
|
||||
|
||||
if err = models.CreateUser(user); err != nil {
|
||||
return errorRes(500, "Cannot create user", err)
|
||||
}
|
||||
|
||||
if user.ID == 1 {
|
||||
user.IsAdmin = true
|
||||
if err = models.SetAdminUser(user); err != nil {
|
||||
return errorRes(500, "Cannot set user admin", err)
|
||||
}
|
||||
}
|
||||
|
||||
sess.Values["user"] = user.ID
|
||||
saveSession(sess, ctx)
|
||||
|
||||
return redirect(ctx, "/")
|
||||
}
|
||||
|
||||
func login(ctx echo.Context) error {
|
||||
setData(ctx, "title", "Login")
|
||||
setData(ctx, "htmlTitle", "Login")
|
||||
return html(ctx, "auth_form.html")
|
||||
}
|
||||
|
||||
func processLogin(ctx echo.Context) error {
|
||||
sess := getSession(ctx)
|
||||
|
||||
user := &models.User{}
|
||||
if err := ctx.Bind(user); err != nil {
|
||||
return errorRes(400, "Cannot bind data", err)
|
||||
}
|
||||
password := user.Password
|
||||
|
||||
if err := models.GetLoginUser(user); err != nil {
|
||||
addFlash(ctx, "Invalid credentials", "error")
|
||||
return redirect(ctx, "/login")
|
||||
}
|
||||
|
||||
if ok, err := argon2id.verify(password, user.Password); !ok {
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot check for password", err)
|
||||
}
|
||||
addFlash(ctx, "Invalid credentials", "error")
|
||||
return redirect(ctx, "/login")
|
||||
}
|
||||
|
||||
sess.Values["user"] = user.ID
|
||||
saveSession(sess, ctx)
|
||||
deleteCsrfCookie(ctx)
|
||||
|
||||
return redirect(ctx, "/")
|
||||
}
|
||||
|
||||
func logout(ctx echo.Context) error {
|
||||
deleteSession(ctx)
|
||||
deleteCsrfCookie(ctx)
|
||||
return redirect(ctx, "/all")
|
||||
}
|
520
internal/web/gist.go
Normal file
520
internal/web/gist.go
Normal file
@ -0,0 +1,520 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"opengist/internal/config"
|
||||
"opengist/internal/git"
|
||||
"opengist/internal/models"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func gistInit(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
userName := ctx.Param("user")
|
||||
gistName := ctx.Param("gistname")
|
||||
|
||||
if strings.HasSuffix(gistName, ".git") {
|
||||
gistName = strings.TrimSuffix(gistName, ".git")
|
||||
}
|
||||
|
||||
gist, err := models.GetGist(userName, gistName)
|
||||
if err != nil {
|
||||
return notFound("Gist not found")
|
||||
}
|
||||
setData(ctx, "gist", gist)
|
||||
|
||||
if config.C.SSH.Port == "22" {
|
||||
setData(ctx, "ssh_clone_url", config.C.SSH.Domain+":"+userName+"/"+gistName+".git")
|
||||
} else {
|
||||
setData(ctx, "ssh_clone_url", "ssh://"+config.C.SSH.Domain+":"+config.C.SSH.Port+"/"+userName+"/"+gistName+".git")
|
||||
}
|
||||
|
||||
setData(ctx, "httpCloneUrl", "http://"+ctx.Request().Host+"/"+userName+"/"+gistName+".git")
|
||||
setData(ctx, "httpCopyUrl", "http://"+ctx.Request().Host+"/"+userName+"/"+gistName)
|
||||
|
||||
setData(ctx, "currentUrl", template.URL(ctx.Request().URL.Path))
|
||||
|
||||
nbCommits, err := git.GetNumberOfCommitsOfRepository(userName, gistName)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching number of commits", err)
|
||||
}
|
||||
setData(ctx, "nbCommits", nbCommits)
|
||||
|
||||
if currUser := getUserLogged(ctx); currUser != nil {
|
||||
hasLiked, err := models.UserHasLikedGist(currUser, gist)
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot get user like status", err)
|
||||
}
|
||||
setData(ctx, "hasLiked", hasLiked)
|
||||
}
|
||||
|
||||
return next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func allGists(ctx echo.Context) error {
|
||||
var err error
|
||||
fromUser := ctx.Param("user")
|
||||
userLogged := getUserLogged(ctx)
|
||||
|
||||
pageInt := getPage(ctx)
|
||||
|
||||
sort := "created"
|
||||
order := "desc"
|
||||
orderText := "Recently"
|
||||
|
||||
if ctx.QueryParam("sort") == "updated" {
|
||||
sort = "updated"
|
||||
}
|
||||
|
||||
if ctx.QueryParam("order") == "asc" {
|
||||
order = "asc"
|
||||
orderText = "Least recently"
|
||||
}
|
||||
|
||||
setData(ctx, "sort", sort)
|
||||
setData(ctx, "order", orderText)
|
||||
|
||||
var gists []*models.Gist
|
||||
var currentUserId uint
|
||||
if userLogged != nil {
|
||||
currentUserId = userLogged.ID
|
||||
} else {
|
||||
currentUserId = 0
|
||||
}
|
||||
if fromUser == "" {
|
||||
setData(ctx, "htmlTitle", "All gists")
|
||||
fromUser = "all"
|
||||
gists, err = models.GetAllGistsForCurrentUser(currentUserId, pageInt-1, sort, order)
|
||||
} else {
|
||||
setData(ctx, "htmlTitle", "All gists from "+fromUser)
|
||||
setData(ctx, "fromUser", fromUser)
|
||||
|
||||
var count int64
|
||||
if err = models.DoesUserExists(fromUser, &count); err != nil {
|
||||
return errorRes(500, "Error fetching user", err)
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return notFound("User not found")
|
||||
}
|
||||
|
||||
gists, err = models.GetAllGistsFromUser(fromUser, currentUserId, pageInt-1, sort, order)
|
||||
}
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching gists", err)
|
||||
}
|
||||
|
||||
if err = paginate(ctx, gists, pageInt, 10, "gists", fromUser, "&sort="+sort+"&order="+order); err != nil {
|
||||
return errorRes(404, "Page not found", nil)
|
||||
}
|
||||
|
||||
return html(ctx, "all.html")
|
||||
}
|
||||
|
||||
func gist(ctx echo.Context) error {
|
||||
gist := getData(ctx, "gist").(*models.Gist)
|
||||
userName := gist.User.Username
|
||||
gistName := gist.Uuid
|
||||
revision := ctx.Param("revision")
|
||||
|
||||
if revision == "" {
|
||||
revision = "HEAD"
|
||||
}
|
||||
|
||||
nbCommits := getData(ctx, "nbCommits")
|
||||
files := make(map[string]string)
|
||||
if nbCommits != "0" {
|
||||
filesStr, err := git.GetFilesOfRepository(userName, gistName, revision)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching files from repository", err)
|
||||
}
|
||||
for _, file := range filesStr {
|
||||
files[file], err = git.GetFileContent(userName, gistName, revision, file)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching file content from file "+file, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setData(ctx, "page", "code")
|
||||
setData(ctx, "commit", revision)
|
||||
setData(ctx, "files", files)
|
||||
setData(ctx, "revision", revision)
|
||||
setData(ctx, "htmlTitle", gist.Title)
|
||||
|
||||
return html(ctx, "gist.html")
|
||||
}
|
||||
|
||||
func revisions(ctx echo.Context) error {
|
||||
gist := getData(ctx, "gist").(*models.Gist)
|
||||
userName := gist.User.Username
|
||||
gistName := gist.Uuid
|
||||
|
||||
pageInt := getPage(ctx)
|
||||
|
||||
nbCommits := getData(ctx, "nbCommits")
|
||||
commits := make([]*models.Commit, 0)
|
||||
if nbCommits != "0" {
|
||||
gitlogStr, err := git.GetLog(userName, gistName, strconv.Itoa((pageInt-1)*10))
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching commits log", err)
|
||||
}
|
||||
|
||||
gitlog := strings.Split(gitlogStr, "\n=commit ")
|
||||
for _, commitStr := range gitlog[1:] {
|
||||
logContent := strings.SplitN(commitStr, "\n", 3)
|
||||
|
||||
header := strings.Split(logContent[0], ":")
|
||||
commitStruct := models.Commit{
|
||||
Hash: header[0],
|
||||
Author: header[1],
|
||||
Timestamp: header[2],
|
||||
Files: make([]models.File, 0),
|
||||
}
|
||||
|
||||
if len(logContent) > 2 {
|
||||
changed := strings.ReplaceAll(logContent[1], "(+)", "")
|
||||
changed = strings.ReplaceAll(changed, "(-)", "")
|
||||
commitStruct.Changed = changed
|
||||
}
|
||||
|
||||
files := strings.Split(logContent[len(logContent)-1], "diff --git ")
|
||||
if len(files) > 1 {
|
||||
for _, fileStr := range files {
|
||||
content := strings.SplitN(fileStr, "\n@@", 2)
|
||||
if len(content) > 1 {
|
||||
header := strings.Split(content[0], "\n")
|
||||
commitStruct.Files = append(commitStruct.Files, models.File{Content: "@@" + content[1], Filename: header[len(header)-1][4:], OldFilename: header[len(header)-2][4:]})
|
||||
} else {
|
||||
// in case there is no content but a file renamed
|
||||
header := strings.Split(content[0], "\n")
|
||||
if len(header) > 3 {
|
||||
commitStruct.Files = append(commitStruct.Files, models.File{Content: "", Filename: header[3][10:], OldFilename: header[2][12:]})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
commits = append(commits, &commitStruct)
|
||||
}
|
||||
}
|
||||
|
||||
if err := paginate(ctx, commits, pageInt, 10, "commits", userName+"/"+gistName+"/revisions"); err != nil {
|
||||
return errorRes(404, "Page not found", nil)
|
||||
}
|
||||
|
||||
setData(ctx, "page", "revisions")
|
||||
setData(ctx, "revision", "HEAD")
|
||||
setData(ctx, "htmlTitle", "Revision of "+gist.Title)
|
||||
|
||||
return html(ctx, "revisions.html")
|
||||
}
|
||||
|
||||
func create(ctx echo.Context) error {
|
||||
setData(ctx, "htmlTitle", "Create a new gist")
|
||||
return html(ctx, "create.html")
|
||||
}
|
||||
|
||||
func processCreate(ctx echo.Context) error {
|
||||
isCreate := false
|
||||
if ctx.Request().URL.Path == "/" {
|
||||
isCreate = true
|
||||
}
|
||||
|
||||
err := ctx.Request().ParseForm()
|
||||
if err != nil {
|
||||
return errorRes(400, "Bad request", err)
|
||||
}
|
||||
|
||||
var gist *models.Gist
|
||||
|
||||
if isCreate {
|
||||
gist = new(models.Gist)
|
||||
setData(ctx, "htmlTitle", "Create a new gist")
|
||||
} else {
|
||||
gist = getData(ctx, "gist").(*models.Gist)
|
||||
setData(ctx, "htmlTitle", "Edit "+gist.Title)
|
||||
}
|
||||
|
||||
if err := ctx.Bind(gist); err != nil {
|
||||
return errorRes(400, "Cannot bind data", err)
|
||||
}
|
||||
|
||||
gist.Files = make([]models.File, 0)
|
||||
for i := 0; i < len(ctx.Request().PostForm["content"]); i++ {
|
||||
name := ctx.Request().PostForm["name"][i]
|
||||
content := ctx.Request().PostForm["content"][i]
|
||||
|
||||
if name == "" {
|
||||
name = "gistfile" + strconv.Itoa(i+1) + ".txt"
|
||||
}
|
||||
|
||||
escapedValue, err := url.QueryUnescape(content)
|
||||
if err != nil {
|
||||
return errorRes(400, "Invalid character unescaped", err)
|
||||
}
|
||||
|
||||
gist.Files = append(gist.Files, models.File{
|
||||
Filename: name,
|
||||
Content: escapedValue,
|
||||
})
|
||||
}
|
||||
user := getUserLogged(ctx)
|
||||
gist.NbFiles = len(gist.Files)
|
||||
|
||||
if isCreate {
|
||||
uuidGist, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return errorRes(500, "Error creating an UUID", err)
|
||||
}
|
||||
gist.Uuid = strings.Replace(uuidGist.String(), "-", "", -1)
|
||||
|
||||
gist.UserID = user.ID
|
||||
}
|
||||
|
||||
if gist.Title == "" {
|
||||
if ctx.Request().PostForm["name"][0] == "" {
|
||||
gist.Title = "gist:" + gist.Uuid
|
||||
} else {
|
||||
gist.Title = ctx.Request().PostForm["name"][0]
|
||||
}
|
||||
}
|
||||
|
||||
err = ctx.Validate(gist)
|
||||
if err != nil {
|
||||
addFlash(ctx, validationMessages(&err), "error")
|
||||
if isCreate {
|
||||
return html(ctx, "create.html")
|
||||
} else {
|
||||
files := make(map[string]string)
|
||||
filesStr, err := git.GetFilesOfRepository(gist.User.Username, gist.Uuid, "HEAD")
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching files from repository", err)
|
||||
}
|
||||
for _, file := range filesStr {
|
||||
files[file], err = git.GetFileContent(gist.User.Username, gist.Uuid, "HEAD", file)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching file content from file "+file, err)
|
||||
}
|
||||
}
|
||||
|
||||
setData(ctx, "files", files)
|
||||
return html(ctx, "edit.html")
|
||||
}
|
||||
}
|
||||
|
||||
if len(gist.Files) > 0 {
|
||||
split := strings.Split(gist.Files[0].Content, "\n")
|
||||
if len(split) > 10 {
|
||||
gist.Preview = strings.Join(split[:10], "\n")
|
||||
} else {
|
||||
gist.Preview = gist.Files[0].Content
|
||||
}
|
||||
|
||||
gist.PreviewFilename = gist.Files[0].Filename
|
||||
}
|
||||
|
||||
if err = git.InitRepository(user.Username, gist.Uuid); err != nil {
|
||||
return errorRes(500, "Error creating the repository", err)
|
||||
}
|
||||
|
||||
if err = git.CloneTmp(user.Username, gist.Uuid, gist.Uuid); err != nil {
|
||||
return errorRes(500, "Error cloning the repository", err)
|
||||
}
|
||||
|
||||
for _, file := range gist.Files {
|
||||
if err = git.SetFileContent(gist.Uuid, file.Filename, file.Content); err != nil {
|
||||
return errorRes(500, "Error setting file content for file "+file.Filename, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = git.AddAll(gist.Uuid); err != nil {
|
||||
return errorRes(500, "Error adding files to the repository", err)
|
||||
}
|
||||
|
||||
if err = git.Commit(gist.Uuid); err != nil {
|
||||
return errorRes(500, "Error committing files to the local repository", err)
|
||||
}
|
||||
|
||||
if err = git.Push(gist.Uuid); err != nil {
|
||||
return errorRes(500, "Error pushing the local repository", err)
|
||||
}
|
||||
|
||||
if isCreate {
|
||||
if err = models.CreateGist(gist); err != nil {
|
||||
return errorRes(500, "Error creating the gist", err)
|
||||
}
|
||||
} else {
|
||||
if err = models.UpdateGist(gist); err != nil {
|
||||
return errorRes(500, "Error updating the gist", err)
|
||||
}
|
||||
}
|
||||
|
||||
return redirect(ctx, "/"+user.Username+"/"+gist.Uuid)
|
||||
}
|
||||
|
||||
func toggleVisibility(ctx echo.Context) error {
|
||||
var gist = getData(ctx, "gist").(*models.Gist)
|
||||
|
||||
gist.Private = !gist.Private
|
||||
if err := models.UpdateGist(gist); err != nil {
|
||||
return errorRes(500, "Error updating this gist", err)
|
||||
}
|
||||
|
||||
addFlash(ctx, "Gist visibility has been changed", "success")
|
||||
return redirect(ctx, "/"+gist.User.Username+"/"+gist.Uuid)
|
||||
}
|
||||
|
||||
func deleteGist(ctx echo.Context) error {
|
||||
var gist = getData(ctx, "gist").(*models.Gist)
|
||||
|
||||
err := git.DeleteRepository(gist.User.Username, gist.Uuid)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error deleting the repository", err)
|
||||
}
|
||||
|
||||
if err := models.DeleteGist(gist); err != nil {
|
||||
return errorRes(500, "Error deleting this gist", err)
|
||||
}
|
||||
|
||||
addFlash(ctx, "Gist has been deleted", "success")
|
||||
return redirect(ctx, "/")
|
||||
}
|
||||
|
||||
func like(ctx echo.Context) error {
|
||||
var gist = getData(ctx, "gist").(*models.Gist)
|
||||
currentUser := getUserLogged(ctx)
|
||||
|
||||
hasLiked, err := models.UserHasLikedGist(currentUser, gist)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error checking if user has liked a gist", err)
|
||||
}
|
||||
|
||||
if hasLiked {
|
||||
err = models.RemoveUserLike(gist, getUserLogged(ctx))
|
||||
} else {
|
||||
err = models.AppendUserLike(gist, getUserLogged(ctx))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return errorRes(500, "Error liking/dislking this gist", err)
|
||||
}
|
||||
|
||||
redirectTo := "/" + gist.User.Username + "/" + gist.Uuid
|
||||
if r := ctx.QueryParam("redirecturl"); r != "" {
|
||||
redirectTo = r
|
||||
}
|
||||
return redirect(ctx, redirectTo)
|
||||
}
|
||||
|
||||
func rawFile(ctx echo.Context) error {
|
||||
gist := getData(ctx, "gist").(*models.Gist)
|
||||
fileContent, err := git.GetFileContent(
|
||||
gist.User.Username,
|
||||
gist.Uuid,
|
||||
ctx.Param("revision"),
|
||||
ctx.Param("file"))
|
||||
if err != nil {
|
||||
return errorRes(500, "Error getting file content", err)
|
||||
}
|
||||
|
||||
filebytes := []byte(fileContent)
|
||||
|
||||
if len(filebytes) == 0 {
|
||||
return notFound("File not found")
|
||||
}
|
||||
|
||||
return plainText(ctx, 200, string(filebytes))
|
||||
}
|
||||
|
||||
func edit(ctx echo.Context) error {
|
||||
var gist = getData(ctx, "gist").(*models.Gist)
|
||||
|
||||
files := make(map[string]string)
|
||||
filesStr, err := git.GetFilesOfRepository(gist.User.Username, gist.Uuid, "HEAD")
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching files from repository", err)
|
||||
}
|
||||
for _, file := range filesStr {
|
||||
files[file], err = git.GetFileContent(gist.User.Username, gist.Uuid, "HEAD", file)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching file content from file "+file, err)
|
||||
}
|
||||
}
|
||||
|
||||
setData(ctx, "files", files)
|
||||
setData(ctx, "htmlTitle", "Edit "+gist.Title)
|
||||
|
||||
return html(ctx, "edit.html")
|
||||
}
|
||||
|
||||
func downloadZip(ctx echo.Context) error {
|
||||
var gist = getData(ctx, "gist").(*models.Gist)
|
||||
var revision = ctx.Param("revision")
|
||||
|
||||
files := make(map[string]string)
|
||||
filesStr, err := git.GetFilesOfRepository(gist.User.Username, gist.Uuid, revision)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching files from repository", err)
|
||||
}
|
||||
|
||||
for _, file := range filesStr {
|
||||
files[file], err = git.GetFileContent(gist.User.Username, gist.Uuid, revision, file)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error fetching file content from file "+file, err)
|
||||
}
|
||||
}
|
||||
|
||||
zipFile := new(bytes.Buffer)
|
||||
|
||||
zipWriter := zip.NewWriter(zipFile)
|
||||
|
||||
for fileName, fileContent := range files {
|
||||
f, err := zipWriter.Create(fileName)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error adding a file the to the zip archive", err)
|
||||
}
|
||||
_, err = f.Write([]byte(fileContent))
|
||||
if err != nil {
|
||||
return errorRes(500, "Error adding file content the to the zip archive", err)
|
||||
}
|
||||
}
|
||||
err = zipWriter.Close()
|
||||
if err != nil {
|
||||
return errorRes(500, "Error closing the zip archive", err)
|
||||
}
|
||||
|
||||
ctx.Response().Header().Set("Content-Type", "application/zip")
|
||||
ctx.Response().Header().Set("Content-Disposition", "attachment; filename="+gist.Uuid+".zip")
|
||||
ctx.Response().Header().Set("Content-Length", strconv.Itoa(len(zipFile.Bytes())))
|
||||
_, err = ctx.Response().Write(zipFile.Bytes())
|
||||
if err != nil {
|
||||
return errorRes(500, "Error writing the zip archive", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func likes(ctx echo.Context) error {
|
||||
var gist = getData(ctx, "gist").(*models.Gist)
|
||||
|
||||
pageInt := getPage(ctx)
|
||||
|
||||
likers, err := models.GetUsersLikesForGists(gist, pageInt-1)
|
||||
if err != nil {
|
||||
return errorRes(500, "Error getting users who liked this gist", err)
|
||||
}
|
||||
|
||||
if err = paginate(ctx, likers, pageInt, 30, "likers", gist.User.Username+"/"+gist.Uuid+"/likes"); err != nil {
|
||||
return errorRes(404, "Page not found", nil)
|
||||
}
|
||||
|
||||
setData(ctx, "htmlTitle", "Likes for "+gist.Title)
|
||||
setData(ctx, "revision", "HEAD")
|
||||
return html(ctx, "likes.html")
|
||||
}
|
253
internal/web/git-http.go
Normal file
253
internal/web/git-http.go
Normal file
@ -0,0 +1,253 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"net/http"
|
||||
"opengist/internal/git"
|
||||
"opengist/internal/models"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var routes = []struct {
|
||||
gitUrl string
|
||||
method string
|
||||
handler func(ctx echo.Context) error
|
||||
}{
|
||||
{"(.*?)/git-upload-pack$", "POST", uploadPack},
|
||||
{"(.*?)/git-receive-pack$", "POST", receivePack},
|
||||
{"(.*?)/info/refs$", "GET", infoRefs},
|
||||
{"(.*?)/HEAD$", "GET", textFile},
|
||||
{"(.*?)/objects/info/alternates$", "GET", textFile},
|
||||
{"(.*?)/objects/info/http-alternates$", "GET", textFile},
|
||||
{"(.*?)/objects/info/packs$", "GET", infoPacks},
|
||||
{"(.*?)/objects/info/[^/]*$", "GET", textFile},
|
||||
{"(.*?)/objects/[0-9a-f]{2}/[0-9a-f]{38}$", "GET", looseObject},
|
||||
{"(.*?)/objects/pack/pack-[0-9a-f]{40}\\.pack$", "GET", packFile},
|
||||
{"(.*?)/objects/pack/pack-[0-9a-f]{40}\\.idx$", "GET", idxFile},
|
||||
}
|
||||
|
||||
func gitHttp(ctx echo.Context) error {
|
||||
for _, route := range routes {
|
||||
matched, _ := regexp.MatchString(route.gitUrl, ctx.Request().URL.Path)
|
||||
if ctx.Request().Method == route.method && matched {
|
||||
if !strings.HasPrefix(ctx.Request().Header.Get("User-Agent"), "git/") {
|
||||
continue
|
||||
}
|
||||
|
||||
gist := getData(ctx, "gist").(*models.Gist)
|
||||
|
||||
noAuth := ctx.QueryParam("service") == "git-upload-pack" ||
|
||||
strings.HasSuffix(ctx.Request().URL.Path, "git-upload-pack") ||
|
||||
ctx.Request().Method == "GET"
|
||||
|
||||
repositoryPath, err := git.GetRepositoryPath(gist.User.Username, gist.Uuid)
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot get repository path", err)
|
||||
}
|
||||
|
||||
if _, err = os.Stat(repositoryPath); os.IsNotExist(err) {
|
||||
if err != nil {
|
||||
return errorRes(500, "Repository does not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Set("repositoryPath", repositoryPath)
|
||||
|
||||
// Requires Basic Auth if we push the repository
|
||||
if noAuth {
|
||||
return route.handler(ctx)
|
||||
}
|
||||
|
||||
authHeader := ctx.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return basicAuth(ctx)
|
||||
}
|
||||
|
||||
authFields := strings.Fields(authHeader)
|
||||
if len(authFields) != 2 || authFields[0] != "Basic" {
|
||||
return basicAuth(ctx)
|
||||
}
|
||||
|
||||
authUsername, authPassword, err := basicAuthDecode(authFields[1])
|
||||
if err != nil {
|
||||
return basicAuth(ctx)
|
||||
}
|
||||
|
||||
if ok, err := argon2id.verify(authPassword, gist.User.Password); !ok || gist.User.Username != authUsername {
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot verify password", err)
|
||||
}
|
||||
return errorRes(403, "Unauthorized", nil)
|
||||
}
|
||||
|
||||
return route.handler(ctx)
|
||||
}
|
||||
}
|
||||
return notFound("Gist not found")
|
||||
}
|
||||
|
||||
func uploadPack(ctx echo.Context) error {
|
||||
return pack(ctx, "upload-pack")
|
||||
}
|
||||
|
||||
func receivePack(ctx echo.Context) error {
|
||||
return pack(ctx, "receive-pack")
|
||||
}
|
||||
|
||||
func pack(ctx echo.Context, serviceType string) error {
|
||||
noCacheHeaders(ctx)
|
||||
defer ctx.Request().Body.Close()
|
||||
|
||||
if ctx.Request().Header.Get("Content-Type") != "application/x-git-"+serviceType+"-request" {
|
||||
return errorRes(401, "Git client unsupported", nil)
|
||||
}
|
||||
ctx.Response().Header().Set("Content-Type", "application/x-git-"+serviceType+"-result")
|
||||
|
||||
var err error
|
||||
reqBody := ctx.Request().Body
|
||||
|
||||
if ctx.Request().Header.Get("Content-Encoding") == "gzip" {
|
||||
reqBody, err = gzip.NewReader(reqBody)
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot create gzip reader", err)
|
||||
}
|
||||
}
|
||||
|
||||
repositoryPath := ctx.Get("repositoryPath").(string)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.Command("git", serviceType, "--stateless-rpc", repositoryPath)
|
||||
cmd.Dir = repositoryPath
|
||||
cmd.Stdin = reqBody
|
||||
cmd.Stdout = ctx.Response().Writer
|
||||
cmd.Stderr = &stderr
|
||||
if err = cmd.Run(); err != nil {
|
||||
return errorRes(500, "Cannot run git "+serviceType+" ; "+stderr.String(), err)
|
||||
}
|
||||
|
||||
// updatedAt is updated only if serviceType is receive-pack
|
||||
if serviceType == "receive-pack" {
|
||||
_ = models.GistLastActiveNow(getData(ctx, "gist").(*models.Gist).ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func infoRefs(ctx echo.Context) error {
|
||||
noCacheHeaders(ctx)
|
||||
var service string
|
||||
|
||||
gist := getData(ctx, "gist").(*models.Gist)
|
||||
|
||||
serviceType := ctx.QueryParam("service")
|
||||
if !strings.HasPrefix(serviceType, "git-") {
|
||||
service = ""
|
||||
}
|
||||
service = strings.TrimPrefix(serviceType, "git-")
|
||||
|
||||
if service != "upload-pack" && service != "receive-pack" {
|
||||
if err := git.UpdateServerInfo(gist.User.Username, gist.Uuid); err != nil {
|
||||
return errorRes(500, "Cannot update server info", err)
|
||||
}
|
||||
return sendFile(ctx, "text/plain; charset=utf-8")
|
||||
}
|
||||
|
||||
refs, err := git.RPCRefs(gist.User.Username, gist.Uuid, service)
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot run git "+service, err)
|
||||
}
|
||||
|
||||
ctx.Response().Header().Set("Content-Type", "application/x-git-"+service+"-advertisement")
|
||||
ctx.Response().WriteHeader(200)
|
||||
_, _ = ctx.Response().Write(packetWrite("# service=git-" + service + "\n"))
|
||||
_, _ = ctx.Response().Write([]byte("0000"))
|
||||
_, _ = ctx.Response().Write(refs)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func textFile(ctx echo.Context) error {
|
||||
noCacheHeaders(ctx)
|
||||
return sendFile(ctx, "text/plain")
|
||||
}
|
||||
|
||||
func infoPacks(ctx echo.Context) error {
|
||||
cacheHeadersForever(ctx)
|
||||
return sendFile(ctx, "text/plain; charset=utf-8")
|
||||
}
|
||||
|
||||
func looseObject(ctx echo.Context) error {
|
||||
cacheHeadersForever(ctx)
|
||||
return sendFile(ctx, "application/x-git-loose-object")
|
||||
}
|
||||
|
||||
func packFile(ctx echo.Context) error {
|
||||
cacheHeadersForever(ctx)
|
||||
return sendFile(ctx, "application/x-git-packed-objects")
|
||||
}
|
||||
|
||||
func idxFile(ctx echo.Context) error {
|
||||
cacheHeadersForever(ctx)
|
||||
return sendFile(ctx, "application/x-git-packed-objects-toc")
|
||||
}
|
||||
|
||||
func noCacheHeaders(ctx echo.Context) {
|
||||
ctx.Response().Header().Set("Expires", "Thu, 01 Jan 1970 00:00:00 UTC")
|
||||
ctx.Response().Header().Set("Pragma", "no-cache")
|
||||
ctx.Response().Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
|
||||
}
|
||||
|
||||
func cacheHeadersForever(ctx echo.Context) {
|
||||
now := time.Now().Unix()
|
||||
expires := now + 31536000
|
||||
ctx.Response().Header().Set("Date", fmt.Sprintf("%d", now))
|
||||
ctx.Response().Header().Set("Expires", fmt.Sprintf("%d", expires))
|
||||
ctx.Response().Header().Set("Cache-Control", "public, max-age=31536000")
|
||||
}
|
||||
|
||||
func basicAuth(ctx echo.Context) error {
|
||||
ctx.Response().Header().Set("WWW-Authenticate", `Basic realm="."`)
|
||||
return plainText(ctx, 401, "Requires authentication")
|
||||
}
|
||||
|
||||
func basicAuthDecode(encoded string) (string, string, error) {
|
||||
s, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
auth := strings.SplitN(string(s), ":", 2)
|
||||
return auth[0], auth[1], nil
|
||||
}
|
||||
|
||||
func sendFile(ctx echo.Context, contentType string) error {
|
||||
gitFile := "/" + strings.Join(strings.Split(ctx.Request().URL.Path, "/")[3:], "/")
|
||||
gitFile = path.Join(ctx.Get("repositoryPath").(string), gitFile)
|
||||
fi, err := os.Stat(gitFile)
|
||||
if os.IsNotExist(err) {
|
||||
return errorRes(404, "File not found", nil)
|
||||
}
|
||||
ctx.Response().Header().Set("Content-Type", contentType)
|
||||
ctx.Response().Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size()))
|
||||
ctx.Response().Header().Set("Last-Modified", fi.ModTime().Format(http.TimeFormat))
|
||||
return ctx.File(gitFile)
|
||||
}
|
||||
|
||||
func packetWrite(str string) []byte {
|
||||
s := strconv.FormatInt(int64(len(str)+4), 16)
|
||||
|
||||
if len(s)%4 != 0 {
|
||||
s = strings.Repeat("0", 4-len(s)%4) + s
|
||||
}
|
||||
|
||||
return []byte(s + str)
|
||||
}
|
255
internal/web/run.go
Normal file
255
internal/web/run.go
Normal file
@ -0,0 +1,255 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"github.com/rs/zerolog/log"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"opengist/internal/config"
|
||||
"opengist/internal/models"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var store *sessions.CookieStore
|
||||
var re = regexp.MustCompile("[^a-z0-9]+")
|
||||
|
||||
type Template struct {
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
func (t *Template) Render(w io.Writer, name string, data interface{}, _ echo.Context) error {
|
||||
return t.templates.ExecuteTemplate(w, name, data)
|
||||
}
|
||||
|
||||
func Start() {
|
||||
store = sessions.NewCookieStore([]byte("opengist"))
|
||||
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
e.HidePort = true
|
||||
|
||||
e.Use(dataInit)
|
||||
e.Pre(middleware.MethodOverrideWithConfig(middleware.MethodOverrideConfig{
|
||||
Getter: middleware.MethodFromForm("_method"),
|
||||
}))
|
||||
e.Pre(middleware.RemoveTrailingSlash())
|
||||
e.Use(middleware.CORS())
|
||||
e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
|
||||
LogURI: true, LogStatus: true, LogMethod: true,
|
||||
LogValuesFunc: func(ctx echo.Context, v middleware.RequestLoggerValues) error {
|
||||
log.Info().Str("URI", v.URI).Int("status", v.Status).Str("method", v.Method).
|
||||
Msg("HTTP")
|
||||
return nil
|
||||
},
|
||||
}))
|
||||
e.Use(middleware.Recover())
|
||||
e.Use(middleware.Secure())
|
||||
|
||||
e.Renderer = &Template{
|
||||
templates: template.Must(template.New("t").Funcs(
|
||||
template.FuncMap{
|
||||
"split": strings.Split,
|
||||
"indexByte": strings.IndexByte,
|
||||
"toInt": func(i string) int64 {
|
||||
val, _ := strconv.ParseInt(i, 10, 64)
|
||||
return val
|
||||
},
|
||||
"inc": func(i int64) int64 {
|
||||
return i + 1
|
||||
},
|
||||
"splitGit": func(i string) []string {
|
||||
return strings.FieldsFunc(i, func(r rune) bool {
|
||||
return r == ',' || r == ' '
|
||||
})
|
||||
},
|
||||
"lines": func(i string) []string {
|
||||
return strings.Split(i, "\n")
|
||||
},
|
||||
"isMarkdown": func(i string) bool {
|
||||
return ".md" == strings.ToLower(filepath.Ext(i))
|
||||
},
|
||||
"httpStatusText": http.StatusText,
|
||||
"loadedTime": func(startTime time.Time) string {
|
||||
return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms"
|
||||
},
|
||||
"slug": func(s string) string {
|
||||
return strings.Trim(re.ReplaceAllString(strings.ToLower(s), "-"), "-")
|
||||
},
|
||||
}).ParseGlob("templates/*/*.html")),
|
||||
}
|
||||
|
||||
e.HTTPErrorHandler = func(er error, ctx echo.Context) {
|
||||
if err, ok := er.(*echo.HTTPError); ok {
|
||||
if err.Code >= 500 {
|
||||
log.Error().Int("code", err.Code).Err(err.Internal).Msg("HTTP: " + err.Message.(string))
|
||||
}
|
||||
|
||||
setData(ctx, "error", err)
|
||||
if errHtml := htmlWithCode(ctx, err.Code, "error.html"); errHtml != nil {
|
||||
log.Fatal().Err(errHtml).Send()
|
||||
}
|
||||
} else {
|
||||
log.Fatal().Err(er).Send()
|
||||
}
|
||||
}
|
||||
|
||||
e.Use(basicInit)
|
||||
|
||||
e.Validator = NewValidator()
|
||||
|
||||
e.Static("/assets", "./public/assets")
|
||||
|
||||
// Web based routes
|
||||
g1 := e.Group("")
|
||||
{
|
||||
g1.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
|
||||
TokenLookup: "form:_csrf",
|
||||
CookiePath: "/",
|
||||
CookieHTTPOnly: true,
|
||||
CookieSameSite: http.SameSiteStrictMode,
|
||||
}))
|
||||
g1.Use(csrfInit)
|
||||
|
||||
g1.GET("/", create, logged)
|
||||
g1.POST("/", processCreate, logged)
|
||||
|
||||
g1.GET("/register", register)
|
||||
g1.POST("/register", processRegister)
|
||||
g1.GET("/login", login)
|
||||
g1.POST("/login", processLogin)
|
||||
g1.GET("/logout", logout)
|
||||
|
||||
g1.GET("/ssh-keys", sshKeys, logged)
|
||||
g1.POST("/ssh-keys", sshKeysProcess, logged)
|
||||
g1.DELETE("/ssh-keys/:id", sshKeysDelete, logged)
|
||||
|
||||
g2 := g1.Group("/admin")
|
||||
{
|
||||
g2.Use(adminPermission)
|
||||
g2.GET("", adminIndex)
|
||||
g2.GET("/users", adminUsers)
|
||||
g2.POST("/users/:user/delete", adminUserDelete)
|
||||
g2.GET("/gists", adminGists)
|
||||
g2.POST("/gists/:gist/delete", adminGistDelete)
|
||||
}
|
||||
|
||||
g1.GET("/all", allGists)
|
||||
g1.GET("/:user", allGists)
|
||||
|
||||
g3 := g1.Group("/:user/:gistname")
|
||||
{
|
||||
g3.Use(gistInit)
|
||||
g3.GET("", gist)
|
||||
g3.GET("/rev/:revision", gist)
|
||||
g3.GET("/revisions", revisions)
|
||||
g3.GET("/archive/:revision", downloadZip)
|
||||
g3.POST("/visibility", toggleVisibility, logged, writePermission)
|
||||
g3.POST("/delete", deleteGist, logged, writePermission)
|
||||
g3.GET("/raw/:revision/:file", rawFile)
|
||||
g3.GET("/edit", edit, logged, writePermission)
|
||||
g3.POST("/edit", processCreate, logged, writePermission)
|
||||
g3.POST("/like", like, logged)
|
||||
g3.GET("/likes", likes)
|
||||
}
|
||||
}
|
||||
|
||||
debugStr := ""
|
||||
// Git HTTP routes
|
||||
if config.C.HTTP.Git {
|
||||
e.Any("/:user/:gistname/*", gitHttp, gistInit)
|
||||
debugStr = " (with Git HTTP support)"
|
||||
}
|
||||
|
||||
e.Any("/*", noRouteFound)
|
||||
|
||||
addr := config.C.HTTP.Host + ":" + config.C.HTTP.Port
|
||||
log.Info().Msg("Starting HTTP server on http://" + addr + debugStr)
|
||||
|
||||
if err := e.Start(addr); err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to start HTTP server")
|
||||
}
|
||||
}
|
||||
|
||||
func dataInit(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
ctxValue := context.WithValue(ctx.Request().Context(), "data", echo.Map{})
|
||||
ctx.SetRequest(ctx.Request().WithContext(ctxValue))
|
||||
setData(ctx, "loadStartTime", time.Now())
|
||||
return next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func basicInit(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
setData(ctx, "signupDisabled", config.C.DisableSignup)
|
||||
|
||||
sess := getSession(ctx)
|
||||
if sess.Values["user"] != nil {
|
||||
user := &models.User{ID: sess.Values["user"].(uint)}
|
||||
if err := models.GetLoginUserById(user); err != nil {
|
||||
sess.Values["user"] = nil
|
||||
saveSession(sess, ctx)
|
||||
setData(ctx, "userLogged", nil)
|
||||
return redirect(ctx, "/all")
|
||||
}
|
||||
if user != nil {
|
||||
setData(ctx, "userLogged", user)
|
||||
}
|
||||
return next(ctx)
|
||||
}
|
||||
|
||||
setData(ctx, "userLogged", nil)
|
||||
return next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func csrfInit(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
setCsrfHtmlForm(ctx)
|
||||
return next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func writePermission(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
gist := getData(ctx, "gist")
|
||||
user := getUserLogged(ctx)
|
||||
if !models.UserCanWrite(user, gist.(*models.Gist)) {
|
||||
return redirect(ctx, "/"+gist.(*models.Gist).User.Username+"/"+gist.(*models.Gist).Uuid)
|
||||
}
|
||||
return next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func adminPermission(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
user := getUserLogged(ctx)
|
||||
if user == nil || !user.IsAdmin {
|
||||
return notFound("User not found")
|
||||
}
|
||||
return next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func logged(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
user := getUserLogged(ctx)
|
||||
if user != nil {
|
||||
return next(ctx)
|
||||
}
|
||||
return redirect(ctx, "/all")
|
||||
}
|
||||
}
|
||||
|
||||
func noRouteFound(echo.Context) error {
|
||||
return notFound("Page not found")
|
||||
}
|
79
internal/web/ssh.go
Normal file
79
internal/web/ssh.go
Normal file
@ -0,0 +1,79 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"opengist/internal/models"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func sshKeys(ctx echo.Context) error {
|
||||
user := getUserLogged(ctx)
|
||||
|
||||
keys, err := models.GetSSHKeysByUserID(user.ID)
|
||||
if err != nil {
|
||||
return errorRes(500, "Cannot get SSH keys", err)
|
||||
}
|
||||
|
||||
setData(ctx, "sshKeys", keys)
|
||||
setData(ctx, "htmlTitle", "Manage SSH keys")
|
||||
return html(ctx, "ssh_keys.html")
|
||||
}
|
||||
|
||||
func sshKeysProcess(ctx echo.Context) error {
|
||||
setData(ctx, "htmlTitle", "Manage SSH keys")
|
||||
|
||||
user := getUserLogged(ctx)
|
||||
|
||||
var key = new(models.SSHKey)
|
||||
if err := ctx.Bind(key); err != nil {
|
||||
return errorRes(400, "Cannot bind data", err)
|
||||
}
|
||||
|
||||
if err := ctx.Validate(key); err != nil {
|
||||
addFlash(ctx, validationMessages(&err), "error")
|
||||
return redirect(ctx, "/ssh-keys")
|
||||
}
|
||||
|
||||
key.UserID = user.ID
|
||||
|
||||
pubKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key.Content))
|
||||
if err != nil {
|
||||
addFlash(ctx, "Invalid SSH key", "error")
|
||||
return redirect(ctx, "/ssh-keys")
|
||||
}
|
||||
|
||||
sha := sha256.Sum256(pubKey.Marshal())
|
||||
key.SHA = base64.StdEncoding.EncodeToString(sha[:])
|
||||
|
||||
if err := models.AddSSHKey(key); err != nil {
|
||||
return errorRes(500, "Cannot add SSH key", err)
|
||||
}
|
||||
|
||||
addFlash(ctx, "SSH key added", "success")
|
||||
return redirect(ctx, "/ssh-keys")
|
||||
}
|
||||
|
||||
func sshKeysDelete(ctx echo.Context) error {
|
||||
user := getUserLogged(ctx)
|
||||
keyId, err := strconv.Atoi(ctx.Param("id"))
|
||||
|
||||
if err != nil {
|
||||
return redirect(ctx, "/ssh-keys")
|
||||
}
|
||||
|
||||
key, err := models.GetSSHKeyByID(uint(keyId))
|
||||
|
||||
if err != nil || key.UserID != user.ID {
|
||||
return redirect(ctx, "/ssh-keys")
|
||||
}
|
||||
|
||||
if err := models.RemoveSSHKey(key); err != nil {
|
||||
return errorRes(500, "Cannot delete SSH key", err)
|
||||
}
|
||||
|
||||
addFlash(ctx, "SSH key deleted", "success")
|
||||
return redirect(ctx, "/ssh-keys")
|
||||
}
|
254
internal/web/util.go
Normal file
254
internal/web/util.go
Normal file
@ -0,0 +1,254 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"opengist/internal/models"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func setData(ctx echo.Context, key string, value any) {
|
||||
data := ctx.Request().Context().Value("data").(echo.Map)
|
||||
data[key] = value
|
||||
ctxValue := context.WithValue(ctx.Request().Context(), "data", data)
|
||||
ctx.SetRequest(ctx.Request().WithContext(ctxValue))
|
||||
}
|
||||
|
||||
func getData(ctx echo.Context, key string) any {
|
||||
data := ctx.Request().Context().Value("data").(echo.Map)
|
||||
return data[key]
|
||||
}
|
||||
|
||||
func html(ctx echo.Context, template string) error {
|
||||
return htmlWithCode(ctx, 200, template)
|
||||
}
|
||||
|
||||
func htmlWithCode(ctx echo.Context, code int, template string) error {
|
||||
setErrorFlashes(ctx)
|
||||
return ctx.Render(code, template, ctx.Request().Context().Value("data"))
|
||||
}
|
||||
|
||||
func redirect(ctx echo.Context, location string) error {
|
||||
return ctx.Redirect(302, location)
|
||||
}
|
||||
|
||||
func plainText(ctx echo.Context, code int, message string) error {
|
||||
return ctx.String(code, message)
|
||||
}
|
||||
|
||||
func notFound(message string) error {
|
||||
return errorRes(404, message, nil)
|
||||
}
|
||||
|
||||
func errorRes(code int, message string, err error) error {
|
||||
return &echo.HTTPError{Code: code, Message: message, Internal: err}
|
||||
}
|
||||
|
||||
func getUserLogged(ctx echo.Context) *models.User {
|
||||
user := getData(ctx, "userLogged")
|
||||
if user != nil {
|
||||
return user.(*models.User)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setErrorFlashes(ctx echo.Context) {
|
||||
sess, _ := store.Get(ctx.Request(), "flash")
|
||||
|
||||
setData(ctx, "flashErrors", sess.Flashes("error"))
|
||||
setData(ctx, "flashSuccess", sess.Flashes("success"))
|
||||
|
||||
_ = sess.Save(ctx.Request(), ctx.Response())
|
||||
}
|
||||
|
||||
func addFlash(ctx echo.Context, flashMessage string, flashType string) {
|
||||
sess, _ := store.Get(ctx.Request(), "flash")
|
||||
sess.AddFlash(flashMessage, flashType)
|
||||
_ = sess.Save(ctx.Request(), ctx.Response())
|
||||
}
|
||||
|
||||
func getSession(ctx echo.Context) *sessions.Session {
|
||||
sess, _ := store.Get(ctx.Request(), "session")
|
||||
return sess
|
||||
}
|
||||
|
||||
func saveSession(sess *sessions.Session, ctx echo.Context) {
|
||||
_ = sess.Save(ctx.Request(), ctx.Response())
|
||||
}
|
||||
|
||||
func deleteSession(ctx echo.Context) {
|
||||
sess := getSession(ctx)
|
||||
sess.Options.MaxAge = -1
|
||||
sess.Values["user"] = nil
|
||||
saveSession(sess, ctx)
|
||||
}
|
||||
|
||||
func setCsrfHtmlForm(ctx echo.Context) {
|
||||
if csrfToken, ok := ctx.Get("csrf").(string); ok {
|
||||
setData(ctx, "csrfHtml", template.HTML(`<input type="hidden" name="_csrf" value="`+csrfToken+`">`))
|
||||
}
|
||||
}
|
||||
|
||||
func deleteCsrfCookie(ctx echo.Context) {
|
||||
ctx.SetCookie(&http.Cookie{Name: "_csrf", Path: "/", MaxAge: -1})
|
||||
}
|
||||
|
||||
type OpengistValidator struct {
|
||||
v *validator.Validate
|
||||
}
|
||||
|
||||
func NewValidator() *OpengistValidator {
|
||||
v := validator.New()
|
||||
_ = v.RegisterValidation("notreserved", validateReservedKeywords)
|
||||
return &OpengistValidator{v}
|
||||
}
|
||||
|
||||
func (cv *OpengistValidator) Validate(i interface{}) error {
|
||||
if err := cv.v.Struct(i); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validationMessages(err *error) string {
|
||||
errs := (*err).(validator.ValidationErrors)
|
||||
messages := make([]string, len(errs))
|
||||
for i, e := range errs {
|
||||
switch e.Tag() {
|
||||
case "max":
|
||||
messages[i] = e.Field() + " is too long"
|
||||
case "required":
|
||||
messages[i] = e.Field() + " should not be empty"
|
||||
case "excludes":
|
||||
messages[i] = e.Field() + " should not include a sub directory"
|
||||
case "alphanum":
|
||||
messages[i] = e.Field() + " should only contain alphanumeric characters"
|
||||
case "min":
|
||||
messages[i] = "Not enough " + e.Field()
|
||||
case "notreserved":
|
||||
messages[i] = "Invalid " + e.Field()
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(messages, " ; ")
|
||||
}
|
||||
|
||||
func validateReservedKeywords(fl validator.FieldLevel) bool {
|
||||
name := fl.Field().String()
|
||||
|
||||
restrictedNames := map[string]struct{}{}
|
||||
for _, restrictedName := range []string{"register", "login", "logout", "ssh-keys", "admin", "all"} {
|
||||
restrictedNames[restrictedName] = struct{}{}
|
||||
}
|
||||
|
||||
// if the name is not in the restricted names, it is valid
|
||||
_, ok := restrictedNames[name]
|
||||
return !ok
|
||||
}
|
||||
|
||||
func getPage(ctx echo.Context) int {
|
||||
page := ctx.QueryParam("page")
|
||||
if page == "" {
|
||||
page = "1"
|
||||
}
|
||||
pageInt, err := strconv.Atoi(page)
|
||||
if err != nil {
|
||||
pageInt = 1
|
||||
}
|
||||
setData(ctx, "currPage", pageInt)
|
||||
|
||||
return pageInt
|
||||
}
|
||||
|
||||
func paginate[T any](ctx echo.Context, data []*T, pageInt int, perPage int, templateDataName string, urlPage string, urlParams ...string) error {
|
||||
lenData := len(data)
|
||||
if lenData == 0 && pageInt != 1 {
|
||||
return errors.New("page not found")
|
||||
}
|
||||
|
||||
if lenData > perPage {
|
||||
if lenData > 1 {
|
||||
data = data[:lenData-1]
|
||||
}
|
||||
setData(ctx, "nextPage", pageInt+1)
|
||||
}
|
||||
if pageInt > 1 {
|
||||
setData(ctx, "prevPage", pageInt-1)
|
||||
}
|
||||
|
||||
if len(urlParams) > 0 {
|
||||
setData(ctx, "urlParams", template.URL(urlParams[0]))
|
||||
}
|
||||
|
||||
setData(ctx, "urlPage", urlPage)
|
||||
setData(ctx, templateDataName, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
type Argon2ID struct {
|
||||
format string
|
||||
version int
|
||||
time uint32
|
||||
memory uint32
|
||||
keyLen uint32
|
||||
saltLen uint32
|
||||
threads uint8
|
||||
}
|
||||
|
||||
var argon2id = Argon2ID{
|
||||
format: "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
version: argon2.Version,
|
||||
time: 1,
|
||||
memory: 64 * 1024,
|
||||
keyLen: 32,
|
||||
saltLen: 16,
|
||||
threads: 4,
|
||||
}
|
||||
|
||||
func (a Argon2ID) hash(plain string) (string, error) {
|
||||
salt := make([]byte, a.saltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hash := argon2.IDKey([]byte(plain), salt, a.time, a.memory, a.threads, a.keyLen)
|
||||
|
||||
return fmt.Sprintf(a.format, a.version, a.memory, a.time, a.threads,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(hash),
|
||||
), nil
|
||||
}
|
||||
|
||||
func (a Argon2ID) verify(plain, hash string) (bool, error) {
|
||||
hashParts := strings.Split(hash, "$")
|
||||
|
||||
_, err := fmt.Sscanf(hashParts[3], "m=%d,t=%d,p=%d", &a.memory, &a.time, &a.threads)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(hashParts[4])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
decodedHash, err := base64.RawStdEncoding.DecodeString(hashParts[5])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
hashToCompare := argon2.IDKey([]byte(plain), salt, a.time, a.memory, a.threads, uint32(len(decodedHash)))
|
||||
|
||||
return subtle.ConstantTimeCompare(decodedHash, hashToCompare) == 1, nil
|
||||
}
|
Reference in New Issue
Block a user