983 lines
28 KiB
Go
983 lines
28 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
//go:embed web
|
|
var assets embed.FS
|
|
|
|
// ConfigFileName defines the name of the configuration file expected in the root directory.
|
|
const configFileName = "portagit.json"
|
|
|
|
func main() {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// Ensure that the repositories are organized according to the expected directory structure.
|
|
migrateRepositories(cwd)
|
|
|
|
os.MkdirAll(filepath.Join(cwd, "uploads"), 0755)
|
|
|
|
webFS, err := fs.Sub(assets, "web")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// Initialize the template function map with helper functions for HTML rendering.
|
|
funcMap := template.FuncMap{
|
|
"safe": func(s string) template.HTML { return template.HTML(s) },
|
|
"iterate": func(count int) []int {
|
|
var i []int
|
|
for j := 0; j < count; j++ {
|
|
i = append(i, j)
|
|
}
|
|
return i
|
|
},
|
|
"humanize": humanize,
|
|
"dict": func(values ...interface{}) (map[string]interface{}, error) {
|
|
if len(values)%2 != 0 {
|
|
return nil, fmt.Errorf("invalid dict call")
|
|
}
|
|
dict := make(map[string]interface{}, len(values)/2)
|
|
for i := 0; i < len(values); i += 2 {
|
|
key, ok := values[i].(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("dict keys must be strings")
|
|
}
|
|
dict[key] = values[i+1]
|
|
}
|
|
return dict, nil
|
|
},
|
|
"languageColor": func(lang string) string {
|
|
colors := map[string]string{
|
|
// Map of programming languages to their respective hex color codes, inspired by GitHub's color palette.
|
|
"Go": "#00ADD8",
|
|
"JavaScript": "#F1E05A",
|
|
"TypeScript": "#3178C6",
|
|
"Python": "#3572A5",
|
|
"HTML": "#E34C26",
|
|
"CSS": "#563D7C",
|
|
"Java": "#B07219",
|
|
"C++": "#F34B7D",
|
|
"C": "#555555",
|
|
"PHP": "#4F5D95",
|
|
"Ruby": "#701516",
|
|
"Rust": "#DEA584",
|
|
"Shell": "#89E051",
|
|
"C#": "#178600",
|
|
"Swift": "#F05138",
|
|
"Kotlin": "#A97BFF",
|
|
"Dart": "#00B4AB",
|
|
"Lua": "#000080",
|
|
}
|
|
if c, ok := colors[lang]; ok {
|
|
return c
|
|
}
|
|
return "#8b949e"
|
|
},
|
|
}
|
|
|
|
tmpl, err := template.New("base").Funcs(funcMap).ParseFS(webFS, "templates/*.html")
|
|
if err != nil {
|
|
log.Fatal("Error parsing templates:", err)
|
|
}
|
|
|
|
// Register the handler for the initial setup wizard.
|
|
http.HandleFunc("/setup", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == "GET" {
|
|
tmpl.ExecuteTemplate(w, "setup.html", nil)
|
|
return
|
|
}
|
|
|
|
if r.Method == "POST" {
|
|
err := r.ParseMultipartForm(10 << 20)
|
|
if err != nil && err != http.ErrNotMultipart {
|
|
http.Error(w, "Error parsing form: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err == http.ErrNotMultipart {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "Error parsing standard form", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
username := r.FormValue("username")
|
|
bio := r.FormValue("bio")
|
|
githubUser := r.FormValue("github_username")
|
|
avatarPath := ""
|
|
|
|
if githubUser != "" {
|
|
isStream := r.URL.Query().Get("stream") == "true"
|
|
var flusher http.Flusher
|
|
if isStream {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
if f, ok := w.(http.Flusher); ok {
|
|
flusher = f
|
|
}
|
|
}
|
|
|
|
// sendEvent is a helper function to stream Server-Sent Events (SSE) to the client.
|
|
sendEvent := func(current, total int, msg string) {
|
|
if isStream && flusher != nil {
|
|
payload := map[string]interface{}{
|
|
"current": current,
|
|
"total": total,
|
|
"message": msg,
|
|
}
|
|
jsonPayload, _ := json.Marshal(payload)
|
|
fmt.Fprintf(w, "data: %s\n\n", jsonPayload)
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
|
|
sendEvent(0, 0, "Fetching Profile...")
|
|
|
|
resp, err := http.Get("https://api.github.com/users/" + githubUser)
|
|
if err == nil && resp.StatusCode == 200 {
|
|
defer resp.Body.Close()
|
|
var ghUser GithubUser
|
|
if err := json.NewDecoder(resp.Body).Decode(&ghUser); err == nil {
|
|
if username == "" {
|
|
username = ghUser.Name
|
|
if username == "" {
|
|
username = ghUser.Login
|
|
}
|
|
}
|
|
if bio == "" {
|
|
bio = ghUser.Bio
|
|
}
|
|
if ghUser.AvatarUrl != "" {
|
|
sendEvent(0, 0, "Downloading Avatar...")
|
|
resp, err := http.Get(ghUser.AvatarUrl)
|
|
if err == nil && resp.StatusCode == 200 {
|
|
defer resp.Body.Close()
|
|
filename := fmt.Sprintf("gh_avatar_%d.png", time.Now().Unix())
|
|
destPath := filepath.Join(cwd, "uploads", filename)
|
|
out, err := os.Create(destPath)
|
|
if err == nil {
|
|
defer out.Close()
|
|
io.Copy(out, resp.Body)
|
|
avatarPath = "/uploads/" + filename
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
sendEvent(0, 0, "Fetching Repositories...")
|
|
|
|
respRepos, err := http.Get("https://api.github.com/users/" + githubUser + "/repos")
|
|
if err == nil && respRepos.StatusCode == 200 {
|
|
defer respRepos.Body.Close()
|
|
var ghRepos []GithubRepo
|
|
if err := json.NewDecoder(respRepos.Body).Decode(&ghRepos); err == nil {
|
|
reposDir := filepath.Join(cwd, "repositories")
|
|
os.MkdirAll(reposDir, 0755)
|
|
|
|
totalRepos := len(ghRepos)
|
|
for i, repo := range ghRepos {
|
|
sendEvent(i+1, totalRepos, fmt.Sprintf("Cloning %s...", repo.Name))
|
|
|
|
target := filepath.Join(reposDir, repo.Name)
|
|
if _, err := os.Stat(target); os.IsNotExist(err) {
|
|
exec.Command("git", "clone", repo.CloneUrl, target).Run()
|
|
}
|
|
|
|
if repo.Description != "" {
|
|
descPath := filepath.Join(target, ".git", "description")
|
|
os.MkdirAll(filepath.Dir(descPath), 0755)
|
|
os.WriteFile(descPath, []byte(repo.Description), 0644)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if isStream {
|
|
newConfig := Config{
|
|
Username: username,
|
|
GithubUser: githubUser,
|
|
Bio: bio,
|
|
Avatar: avatarPath,
|
|
Socials: Socials{
|
|
Github: "https://github.com/" + githubUser,
|
|
},
|
|
CustomColors: CustomColors{
|
|
BgColor: "#0d1117",
|
|
CardBg: "#161b22",
|
|
BorderColor: "#30363d",
|
|
AccentColor: "#58a6ff",
|
|
TextPrimary: "#c9d1d9",
|
|
TextSecondary: "#8b949e",
|
|
TextMuted: "#484f58",
|
|
ButtonText: "#ffffff",
|
|
},
|
|
Repositories: []Repository{},
|
|
}
|
|
|
|
files, _ := json.MarshalIndent(newConfig, "", " ")
|
|
os.WriteFile(filepath.Join(cwd, configFileName), files, 0644)
|
|
|
|
payload := map[string]interface{}{
|
|
"redirect": "/",
|
|
}
|
|
jsonPayload, _ := json.Marshal(payload)
|
|
fmt.Fprintf(w, "data: %s\n\n", jsonPayload)
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
file, handler, err := r.FormFile("avatar")
|
|
if err == nil {
|
|
defer file.Close()
|
|
ext := filepath.Ext(handler.Filename)
|
|
if ext == "" {
|
|
ext = ".png"
|
|
}
|
|
filename := fmt.Sprintf("avatar_%d%s", time.Now().Unix(), ext)
|
|
destPath := filepath.Join(cwd, "uploads", filename)
|
|
dst, err := os.Create(destPath)
|
|
if err == nil {
|
|
defer dst.Close()
|
|
io.Copy(dst, file)
|
|
avatarPath = "/uploads/" + filename
|
|
}
|
|
}
|
|
|
|
newConfig := Config{
|
|
Username: username,
|
|
Bio: bio,
|
|
Avatar: avatarPath,
|
|
}
|
|
saveConfig(filepath.Join(cwd, configFileName), newConfig)
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
})
|
|
|
|
// Register the handler for user settings, including profile and appearance updates.
|
|
http.HandleFunc("/settings", func(w http.ResponseWriter, r *http.Request) {
|
|
currentConfig := loadConfig(cwd)
|
|
currentTab := r.URL.Query().Get("tab")
|
|
if currentTab == "" {
|
|
currentTab = "profile"
|
|
}
|
|
|
|
if r.Method == "GET" {
|
|
data := SettingsData{
|
|
Config: currentConfig,
|
|
CurrentTab: currentTab,
|
|
}
|
|
tmpl.ExecuteTemplate(w, "settings.html", data)
|
|
return
|
|
}
|
|
|
|
if r.Method == "POST" {
|
|
err := r.ParseMultipartForm(10 << 20)
|
|
if err != nil && err != http.ErrNotMultipart {
|
|
http.Error(w, "Error parsing form: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err == http.ErrNotMultipart {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "Error parsing standard form", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
if r.FormValue("action") == "update_profile" {
|
|
currentConfig.Username = r.FormValue("username")
|
|
currentConfig.Bio = r.FormValue("bio")
|
|
currentConfig.Email = r.FormValue("email")
|
|
currentConfig.Socials.Website = r.FormValue("website")
|
|
currentConfig.Socials.Github = r.FormValue("github")
|
|
currentConfig.Socials.Linkedin = r.FormValue("linkedin")
|
|
|
|
file, handler, err := r.FormFile("avatar")
|
|
if err == nil {
|
|
defer file.Close()
|
|
ext := filepath.Ext(handler.Filename)
|
|
if ext == "" {
|
|
ext = ".png"
|
|
}
|
|
filename := fmt.Sprintf("avatar_%d%s", time.Now().Unix(), ext)
|
|
destPath := filepath.Join(cwd, "uploads", filename)
|
|
dst, err := os.Create(destPath)
|
|
if err == nil {
|
|
defer dst.Close()
|
|
io.Copy(dst, file)
|
|
currentConfig.Avatar = "/uploads/" + filename
|
|
}
|
|
}
|
|
} else if r.FormValue("action") == "update_appearance" {
|
|
currentConfig.CustomColors.BgColor = r.FormValue("bg_color")
|
|
currentConfig.CustomColors.CardBg = r.FormValue("card_bg")
|
|
currentConfig.CustomColors.BorderColor = r.FormValue("border_color")
|
|
currentConfig.CustomColors.AccentColor = r.FormValue("accent_color")
|
|
currentConfig.CustomColors.TextPrimary = r.FormValue("text_primary")
|
|
currentConfig.CustomColors.TextSecondary = r.FormValue("text_secondary")
|
|
currentConfig.CustomColors.TextMuted = r.FormValue("text_muted")
|
|
currentConfig.CustomColors.ButtonText = r.FormValue("button_text")
|
|
}
|
|
|
|
saveConfig(filepath.Join(cwd, configFileName), currentConfig)
|
|
|
|
http.Redirect(w, r, "/settings?tab="+currentTab, http.StatusSeeOther)
|
|
}
|
|
})
|
|
|
|
// Register the handler for updating pinned repositories.
|
|
http.HandleFunc("/api/pins", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "Error parsing form", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
currentConfig := loadConfig(cwd)
|
|
currentConfig.PinnedRepos = r.Form["pinned_repos"] // "pinned_repos" will be a list of repo names
|
|
saveConfig(filepath.Join(cwd, configFileName), currentConfig)
|
|
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
})
|
|
|
|
// Register the handler for creating new repositories.
|
|
http.HandleFunc("/new", func(w http.ResponseWriter, r *http.Request) {
|
|
currentConfig := loadConfig(cwd)
|
|
|
|
if r.Method == "GET" {
|
|
data := PageData{
|
|
Config: currentConfig,
|
|
}
|
|
tmpl.ExecuteTemplate(w, "new.html", data)
|
|
return
|
|
}
|
|
|
|
if r.Method == "POST" {
|
|
repoName := r.FormValue("repo_name")
|
|
description := r.FormValue("description")
|
|
initReadme := r.FormValue("init_readme") == "on"
|
|
|
|
if repoName == "" {
|
|
http.Error(w, "Repository name is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
targetPath := filepath.Join(cwd, "repositories", repoName)
|
|
|
|
if err := os.MkdirAll(targetPath, 0755); err != nil {
|
|
http.Error(w, "Failed to create directory: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
cmd := exec.Command("git", "init")
|
|
cmd.Dir = targetPath
|
|
if err := cmd.Run(); err != nil {
|
|
http.Error(w, "Failed to run git init: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if initReadme {
|
|
readmePath := filepath.Join(targetPath, "README.md")
|
|
content := fmt.Sprintf("# %s\n\n%s", repoName, description)
|
|
if err := os.WriteFile(readmePath, []byte(content), 0644); err == nil {
|
|
exec.Command("git", "-C", targetPath, "add", "README.md").Run()
|
|
exec.Command("git", "-C", targetPath, "commit", "-m", "Initial commit").Run()
|
|
}
|
|
}
|
|
|
|
http.Redirect(w, r, "/?tab=repositories", http.StatusSeeOther)
|
|
}
|
|
})
|
|
|
|
// Register the handler for browsing repository contents (files, branches, and commits).
|
|
http.HandleFunc("/repo", func(w http.ResponseWriter, r *http.Request) {
|
|
currentConfig := loadConfig(cwd)
|
|
repoName := r.URL.Query().Get("name")
|
|
subPath := r.URL.Query().Get("path")
|
|
viewType := r.URL.Query().Get("type")
|
|
|
|
if repoName == "" {
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
repoRoot := filepath.Join(cwd, "repositories", repoName)
|
|
|
|
targetPath := filepath.Join(repoRoot, subPath)
|
|
if !strings.HasPrefix(targetPath, repoRoot) {
|
|
http.Error(w, "Invalid path", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if r.Method == "POST" {
|
|
action := r.FormValue("action")
|
|
switch action {
|
|
case "create_branch":
|
|
branchName := r.FormValue("branch_name")
|
|
if branchName != "" {
|
|
err := createBranch(repoRoot, branchName)
|
|
if err != nil {
|
|
log.Println("Error creating branch:", err)
|
|
}
|
|
}
|
|
http.Redirect(w, r, "/repo?name="+repoName, http.StatusFound)
|
|
return
|
|
case "switch_branch":
|
|
branch := r.FormValue("branch")
|
|
if branch != "" {
|
|
exec.Command("git", "-C", repoRoot, "checkout", branch).Run()
|
|
}
|
|
http.Redirect(w, r, fmt.Sprintf("/repo?name=%s", repoName), http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
if action == "upload_file" {
|
|
r.ParseMultipartForm(50 << 20)
|
|
files := r.MultipartForm.File["file"]
|
|
|
|
for _, handler := range files {
|
|
file, err := handler.Open()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
defer file.Close()
|
|
|
|
destPath := filepath.Join(targetPath, handler.Filename)
|
|
|
|
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
|
continue
|
|
}
|
|
|
|
dst, err := os.Create(destPath)
|
|
if err == nil {
|
|
io.Copy(dst, file)
|
|
dst.Close()
|
|
|
|
if strings.ToLower(filepath.Ext(handler.Filename)) == ".zip" {
|
|
archive, err := zip.OpenReader(destPath)
|
|
if err == nil {
|
|
defer archive.Close()
|
|
for _, f := range archive.File {
|
|
filePath := filepath.Join(targetPath, f.Name)
|
|
if !strings.HasPrefix(filePath, filepath.Clean(targetPath)+string(os.PathSeparator)) {
|
|
continue
|
|
}
|
|
if f.FileInfo().IsDir() {
|
|
os.MkdirAll(filePath, os.ModePerm)
|
|
continue
|
|
}
|
|
os.MkdirAll(filepath.Dir(filePath), os.ModePerm)
|
|
dstFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
|
if err == nil {
|
|
fileInArchive, err := f.Open()
|
|
if err == nil {
|
|
io.Copy(dstFile, fileInArchive)
|
|
fileInArchive.Close()
|
|
}
|
|
dstFile.Close()
|
|
}
|
|
}
|
|
}
|
|
os.Remove(destPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
exec.Command("git", "-C", repoRoot, "add", ".").Run()
|
|
exec.Command("git", "-C", repoRoot, "commit", "-m", "Add/Update files via upload").Run()
|
|
|
|
backUrl := fmt.Sprintf("/repo?name=%s", repoName)
|
|
if subPath != "" {
|
|
backUrl += fmt.Sprintf("&path=%s", subPath)
|
|
}
|
|
http.Redirect(w, r, backUrl, http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
if action == "create_path" {
|
|
name := r.FormValue("name")
|
|
itemType := r.FormValue("item_type")
|
|
|
|
if name == "" {
|
|
http.Error(w, "Name is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
newPath := filepath.Join(targetPath, name)
|
|
if !strings.HasPrefix(filepath.Clean(newPath), repoRoot) {
|
|
http.Error(w, "Invalid path", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if itemType == "folder" {
|
|
os.MkdirAll(newPath, 0755)
|
|
} else {
|
|
if err := os.WriteFile(newPath, []byte(""), 0644); err != nil {
|
|
http.Error(w, "Error creating file", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
exec.Command("git", "-C", repoRoot, "add", ".").Run()
|
|
exec.Command("git", "-C", repoRoot, "commit", "-m", "Create "+name).Run()
|
|
|
|
relPath, _ := filepath.Rel(repoRoot, newPath)
|
|
relPath = filepath.ToSlash(relPath)
|
|
|
|
http.Redirect(w, r, fmt.Sprintf("/repo?name=%s&path=%s&type=edit", repoName, relPath), http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
backUrl := fmt.Sprintf("/repo?name=%s", repoName)
|
|
if subPath != "" {
|
|
backUrl += fmt.Sprintf("&path=%s", subPath)
|
|
}
|
|
http.Redirect(w, r, backUrl, http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
if action == "delete_repo" {
|
|
if repoRoot != "" && strings.HasPrefix(filepath.Clean(repoRoot), filepath.Join(cwd, "repositories")) {
|
|
err := os.RemoveAll(repoRoot)
|
|
if err != nil {
|
|
http.Error(w, "Failed to delete repo: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
if action == "save_file" {
|
|
content := r.FormValue("content")
|
|
message := r.FormValue("message")
|
|
if message == "" {
|
|
message = "Update " + filepath.Base(subPath)
|
|
}
|
|
|
|
if err := os.WriteFile(targetPath, []byte(content), 0644); err != nil {
|
|
http.Error(w, "Error writing file: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
exec.Command("git", "-C", repoRoot, "add", ".").Run()
|
|
exec.Command("git", "-C", repoRoot, "commit", "-m", message).Run()
|
|
|
|
backUrl := fmt.Sprintf("/repo?name=%s&path=%s&type=blob", repoName, subPath)
|
|
http.Redirect(w, r, backUrl, http.StatusSeeOther)
|
|
return
|
|
}
|
|
}
|
|
|
|
repoData := Repository{
|
|
Name: repoName,
|
|
Path: repoRoot,
|
|
}
|
|
|
|
branches, currentBranch := getBranches(repoRoot)
|
|
|
|
var files []FileInfo
|
|
var commits []Commit
|
|
var readmeContent string
|
|
var fileContent string
|
|
var commitDiffs []FileDiff
|
|
isHistory := (viewType == "history" || viewType == "commits")
|
|
isCommit := (viewType == "commit")
|
|
isEdit := (viewType == "edit")
|
|
isFile := (viewType == "blob") || isEdit
|
|
|
|
if isCommit {
|
|
hash := r.URL.Query().Get("hash")
|
|
if hash != "" {
|
|
commitDiffs = getCommitDiff(repoRoot, hash)
|
|
}
|
|
} else if isHistory {
|
|
commits = getCommits(repoRoot)
|
|
} else if isFile {
|
|
content, err := os.ReadFile(targetPath)
|
|
if err == nil {
|
|
fileContent = string(content)
|
|
} else {
|
|
fileContent = "Error reading file."
|
|
}
|
|
} else {
|
|
if subPath == "" {
|
|
readmeBytes, err := os.ReadFile(filepath.Join(repoRoot, "README.md"))
|
|
if err == nil {
|
|
readmeContent = string(readmeBytes)
|
|
}
|
|
}
|
|
|
|
info, err := os.Stat(targetPath)
|
|
if err == nil && info.IsDir() {
|
|
entries, _ := os.ReadDir(targetPath)
|
|
if subPath != "" {
|
|
parent := filepath.Dir(subPath)
|
|
if parent == "." {
|
|
parent = ""
|
|
}
|
|
files = append(files, FileInfo{Name: "..", IsDir: true})
|
|
}
|
|
for _, e := range entries {
|
|
if e.Name() == ".git" {
|
|
continue
|
|
}
|
|
info, _ := e.Info()
|
|
|
|
// Retrieve the last modification time from the git log.
|
|
lastMod := ""
|
|
relPath := e.Name()
|
|
if subPath != "" {
|
|
relPath = filepath.Join(subPath, e.Name())
|
|
}
|
|
|
|
gitLogCmd := exec.Command("git", "-C", repoRoot, "log", "-1", "--format=%ar", "--", relPath)
|
|
if out, err := gitLogCmd.Output(); err == nil {
|
|
lastMod = strings.TrimSpace(string(out))
|
|
}
|
|
|
|
files = append(files, FileInfo{
|
|
Name: e.Name(),
|
|
IsDir: e.IsDir(),
|
|
Size: info.Size(),
|
|
LastMod: lastMod,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
var breadcrumbs []Breadcrumb
|
|
if subPath != "" {
|
|
parts := strings.Split(subPath, "/")
|
|
currentAccumulatedPath := ""
|
|
for _, part := range parts {
|
|
if currentAccumulatedPath == "" {
|
|
currentAccumulatedPath = part
|
|
} else {
|
|
currentAccumulatedPath = filepath.Join(currentAccumulatedPath, part)
|
|
}
|
|
breadcrumbs = append(breadcrumbs, Breadcrumb{
|
|
Name: part,
|
|
Path: currentAccumulatedPath,
|
|
})
|
|
}
|
|
}
|
|
|
|
data := RepoPageData{
|
|
Config: currentConfig,
|
|
Repository: repoData,
|
|
Files: files,
|
|
Readme: readmeContent,
|
|
CurrentPath: subPath,
|
|
Branches: branches,
|
|
CurrentBranch: currentBranch,
|
|
Commits: commits,
|
|
IsHistory: isHistory,
|
|
IsCommit: isCommit,
|
|
IsEdit: isEdit,
|
|
CommitDiffs: commitDiffs,
|
|
Breadcrumbs: breadcrumbs,
|
|
}
|
|
|
|
tmpl.ExecuteTemplate(w, "repo.html", struct {
|
|
RepoPageData
|
|
FileContent string
|
|
IsFile bool
|
|
}{
|
|
RepoPageData: data,
|
|
FileContent: fileContent,
|
|
IsFile: isFile,
|
|
})
|
|
})
|
|
|
|
// getUserAuthorPatterns compiles a list of author identifiers (username, GitHub handle, email)
|
|
// associated with the current user. This is used to filter stats in the contribution graph.
|
|
getUserAuthorPatterns := func(c Config) []string {
|
|
var patterns []string
|
|
if c.Username != "" {
|
|
patterns = append(patterns, c.Username)
|
|
}
|
|
if c.GithubUser != "" {
|
|
patterns = append(patterns, c.GithubUser)
|
|
}
|
|
if c.Email != "" {
|
|
patterns = append(patterns, c.Email)
|
|
}
|
|
return patterns
|
|
}
|
|
|
|
// Register the handler for repository-specific sub-views (e.g., commit history).
|
|
http.HandleFunc("/repo/", func(w http.ResponseWriter, r *http.Request) {
|
|
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/repo/"), "/")
|
|
if len(pathParts) == 0 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
repoName := pathParts[0]
|
|
action := ""
|
|
if len(pathParts) > 1 {
|
|
action = pathParts[1]
|
|
}
|
|
|
|
cwd, _ := os.Getwd()
|
|
currentConfig := loadConfig(cwd)
|
|
repoRoot := filepath.Join(cwd, "repositories", repoName)
|
|
|
|
if _, err := os.Stat(repoRoot); os.IsNotExist(err) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
if action == "commits" {
|
|
dateStr := r.URL.Query().Get("date")
|
|
yearStr := r.URL.Query().Get("year")
|
|
monthStr := r.URL.Query().Get("month")
|
|
|
|
var commits []Commit
|
|
|
|
if dateStr != "" {
|
|
commits = getCommitsForDate(repoRoot, dateStr)
|
|
} else if yearStr != "" && monthStr != "" {
|
|
year, _ := strconv.Atoi(yearStr)
|
|
month, _ := strconv.Atoi(monthStr)
|
|
if year > 0 && month > 0 {
|
|
commits = getCommitsForMonth(repoRoot, year, month)
|
|
} else {
|
|
commits = getCommits(repoRoot)
|
|
}
|
|
} else {
|
|
commits = getCommits(repoRoot)
|
|
}
|
|
|
|
branches, currentBranch := getBranches(repoRoot)
|
|
|
|
repoData := Repository{
|
|
Name: repoName,
|
|
Path: repoRoot,
|
|
}
|
|
|
|
data := RepoPageData{
|
|
Config: currentConfig,
|
|
Repository: repoData,
|
|
Branches: branches,
|
|
CurrentBranch: currentBranch,
|
|
Commits: commits,
|
|
IsHistory: true,
|
|
}
|
|
|
|
if err := tmpl.ExecuteTemplate(w, "repo.html", struct {
|
|
RepoPageData
|
|
FileContent string
|
|
IsFile bool
|
|
}{
|
|
RepoPageData: data,
|
|
IsFile: false,
|
|
}); err != nil {
|
|
log.Println("Error executing template:", err)
|
|
http.Error(w, "Template Error: "+err.Error(), http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
if action == "" {
|
|
http.Redirect(w, r, "/repo?name="+repoName, http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
http.NotFound(w, r)
|
|
})
|
|
|
|
// API endpoint to fetch contribution graph data (lazy loaded).
|
|
http.HandleFunc("/api/contribution-graph", func(w http.ResponseWriter, r *http.Request) {
|
|
currentConfig := loadConfig(cwd)
|
|
scanPath := filepath.Join(cwd, "repositories")
|
|
|
|
dateStr := r.URL.Query().Get("date")
|
|
yearStr := r.URL.Query().Get("year")
|
|
|
|
authorPatterns := getUserAuthorPatterns(currentConfig)
|
|
|
|
var graph []ContributionDay
|
|
var activity []ActivityMonth
|
|
var selectedYear int
|
|
|
|
if dateStr != "" {
|
|
activity = scanActivityForDate(scanPath, dateStr, authorPatterns)
|
|
if t, err := time.Parse("2006-01-02", dateStr); err == nil {
|
|
selectedYear = t.Year()
|
|
} else {
|
|
selectedYear = time.Now().Year()
|
|
}
|
|
} else {
|
|
selectedYear = time.Now().Year()
|
|
if yearStr != "" {
|
|
if y, err := strconv.Atoi(yearStr); err == nil {
|
|
selectedYear = y
|
|
}
|
|
}
|
|
fmt.Printf("DEBUG Graph Request: Year=%d, Patterns=%v\n", selectedYear, authorPatterns)
|
|
graph, activity = scanCommitsForYear(scanPath, selectedYear, authorPatterns)
|
|
}
|
|
|
|
data := struct {
|
|
ContributionGraph []ContributionDay
|
|
ActivityFeed []ActivityMonth
|
|
SelectedYear int
|
|
}{
|
|
ContributionGraph: graph,
|
|
ActivityFeed: activity,
|
|
SelectedYear: selectedYear,
|
|
}
|
|
|
|
tmpl.ExecuteTemplate(w, "contribution_graph_inner", data)
|
|
})
|
|
|
|
// API endpoint to initialize the contribution graph on page load.
|
|
http.HandleFunc("/api/contribution-graph-init", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
currentConfig := loadConfig(cwd)
|
|
scanPath := filepath.Join(cwd, "repositories")
|
|
authorPatterns := getUserAuthorPatterns(currentConfig)
|
|
|
|
availableYears := getAvailableYears(scanPath, authorPatterns)
|
|
selectedYear := time.Now().Year()
|
|
if len(availableYears) > 0 {
|
|
selectedYear = availableYears[0]
|
|
}
|
|
|
|
graph, activity := scanCommitsForYear(scanPath, selectedYear, authorPatterns)
|
|
|
|
data := struct {
|
|
ContributionGraph []ContributionDay
|
|
ActivityFeed []ActivityMonth
|
|
SelectedYear int
|
|
}{
|
|
ContributionGraph: graph,
|
|
ActivityFeed: activity,
|
|
SelectedYear: selectedYear,
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, "contribution_graph_inner", data); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
type InitResponse struct {
|
|
AvailableYears []int `json:"availableYears"`
|
|
SelectedYear int `json:"selectedYear"`
|
|
GraphHTML string `json:"graphHTML"`
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(InitResponse{
|
|
AvailableYears: availableYears,
|
|
SelectedYear: selectedYear,
|
|
GraphHTML: buf.String(),
|
|
})
|
|
})
|
|
|
|
var repoCache []Repository
|
|
var repoCacheTime time.Time
|
|
var repoCacheMutex sync.Mutex
|
|
|
|
// Main dashboard handler.
|
|
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
configPath := filepath.Join(cwd, configFileName)
|
|
info, err := os.Stat(configPath)
|
|
if os.IsNotExist(err) || info.Size() == 0 {
|
|
http.Redirect(w, r, "/setup", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
currentConfig := loadConfig(cwd)
|
|
if currentConfig.Username == "" {
|
|
http.Redirect(w, r, "/setup", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
activeTab := r.URL.Query().Get("tab")
|
|
if activeTab == "" {
|
|
activeTab = "overview"
|
|
}
|
|
|
|
scanPath := filepath.Join(cwd, "repositories")
|
|
os.MkdirAll(scanPath, 0755)
|
|
|
|
os.MkdirAll(scanPath, 0755)
|
|
repoCacheMutex.Lock()
|
|
if time.Since(repoCacheTime) > 1*time.Minute || len(repoCache) == 0 {
|
|
currentConfig.Repositories = scanRepositories(scanPath)
|
|
repoCache = currentConfig.Repositories
|
|
repoCacheTime = time.Now()
|
|
} else {
|
|
currentConfig.Repositories = repoCache
|
|
}
|
|
repoCacheMutex.Unlock()
|
|
|
|
var availableYears []int
|
|
var contributionGraph []ContributionDay
|
|
selectedYear := time.Now().Year()
|
|
|
|
var specialReadme string
|
|
if currentConfig.GithubUser != "" {
|
|
readmePath := filepath.Join(scanPath, currentConfig.GithubUser, "README.md")
|
|
if content, err := os.ReadFile(readmePath); err == nil {
|
|
specialReadme = string(content)
|
|
}
|
|
}
|
|
|
|
data := PageData{
|
|
Config: currentConfig,
|
|
ActiveTab: activeTab,
|
|
SpecialReadme: specialReadme,
|
|
ContributionGraph: contributionGraph,
|
|
AvailableYears: availableYears,
|
|
SelectedYear: selectedYear,
|
|
}
|
|
tmpl.ExecuteTemplate(w, "index.html", data)
|
|
})
|
|
|
|
http.Handle("/static/", http.FileServer(http.FS(webFS)))
|
|
|
|
http.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(filepath.Join(cwd, "uploads")))))
|
|
|
|
fmt.Println("🚀 PortaGit est en ligne !")
|
|
fmt.Println("🌍 Ouvrez votre navigateur sur : http://localhost:8080")
|
|
|
|
if err := http.ListenAndServe(":8080", nil); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|