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) } }