Initial commit

This commit is contained in:
Thomas Miceli
2023-03-14 16:22:52 +01:00
commit bee5d045c3
52 changed files with 8560 additions and 0 deletions

106
internal/web/admin.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}