PortaGit/main.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)
}
}