A deployable markdown editor that connects with your self hosted files and lets you edit in a beautiful interface
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add GitHub repository and file listing API endpoints

- Create connector interface for future multi-backend support
- Implement GitHubConnector using go-github library
- Add repository listing with sorting support (updated, created, name)
- Implement recursive file tree traversal with extension filtering
- Add file content retrieval endpoint
- Create RepoHandler with three endpoints:
* GET /api/repos - list user repositories
* GET /api/repos/:owner/:repo/files - list files in repo
* GET /api/repos/:owner/:repo/files/* - get file content
- Wire up handlers in router with authentication middleware
- Build test successful

+439 -3
+2
backend/go.mod
··· 13 13 14 14 require ( 15 15 github.com/dustin/go-humanize v1.0.1 // indirect 16 + github.com/google/go-github/v58 v58.0.0 // indirect 17 + github.com/google/go-querystring v1.1.0 // indirect 16 18 github.com/google/uuid v1.6.0 // indirect 17 19 github.com/gorilla/mux v1.8.1 // indirect 18 20 github.com/gorilla/securecookie v1.1.2 // indirect
+6
backend/go.sum
··· 6 6 github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= 7 7 github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= 8 8 github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= 9 + github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 9 10 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 10 11 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 12 + github.com/google/go-github/v58 v58.0.0 h1:Una7GGERlF/37XfkPwpzYJe0Vp4dt2k1kCjlxwjIvzw= 13 + github.com/google/go-github/v58 v58.0.0/go.mod h1:k4hxDKEfoWpSqFlc8LTpGd9fu2KrV1YAa6Hi6FmDNY4= 14 + github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 15 + github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 11 16 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 12 17 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 13 18 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= ··· 49 54 golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 50 55 golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= 51 56 golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 57 + golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 52 58 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 53 59 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 54 60 modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
+182
backend/internal/api/handlers/repos.go
··· 1 + package handlers 2 + 3 + import ( 4 + "encoding/json" 5 + "log" 6 + "net/http" 7 + "strings" 8 + 9 + "github.com/go-chi/chi/v5" 10 + "github.com/yourusername/markedit/internal/auth" 11 + "github.com/yourusername/markedit/internal/connectors" 12 + "github.com/yourusername/markedit/internal/database" 13 + ) 14 + 15 + // RepoHandler handles repository-related endpoints 16 + type RepoHandler struct { 17 + db *database.DB 18 + } 19 + 20 + // NewRepoHandler creates a new repo handler 21 + func NewRepoHandler(db *database.DB) *RepoHandler { 22 + return &RepoHandler{db: db} 23 + } 24 + 25 + // ListRepositories lists all repositories for the authenticated user 26 + func (h *RepoHandler) ListRepositories(w http.ResponseWriter, r *http.Request) { 27 + // Get user from session 28 + session, err := auth.GetSession(r) 29 + if err != nil { 30 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 31 + return 32 + } 33 + 34 + userID, ok := auth.GetUserID(session) 35 + if !ok { 36 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 37 + return 38 + } 39 + 40 + // Get user's GitHub access token 41 + token, err := h.db.GetAuthToken(userID, "github") 42 + if err != nil { 43 + log.Printf("Failed to get auth token: %v", err) 44 + http.Error(w, "Failed to get auth token", http.StatusInternalServerError) 45 + return 46 + } 47 + 48 + // Create GitHub connector 49 + connector := connectors.NewGitHubConnector(token.AccessToken) 50 + 51 + // Get sort parameter 52 + sortBy := r.URL.Query().Get("sort") 53 + if sortBy == "" { 54 + sortBy = "updated" 55 + } 56 + 57 + // List repositories 58 + repos, err := connector.ListRepositories(r.Context(), sortBy) 59 + if err != nil { 60 + log.Printf("Failed to list repositories: %v", err) 61 + http.Error(w, "Failed to list repositories", http.StatusInternalServerError) 62 + return 63 + } 64 + 65 + response := map[string]interface{}{ 66 + "repositories": repos, 67 + } 68 + 69 + w.Header().Set("Content-Type", "application/json") 70 + json.NewEncoder(w).Encode(response) 71 + } 72 + 73 + // ListFiles lists files in a repository 74 + func (h *RepoHandler) ListFiles(w http.ResponseWriter, r *http.Request) { 75 + owner := chi.URLParam(r, "owner") 76 + repo := chi.URLParam(r, "repo") 77 + 78 + if owner == "" || repo == "" { 79 + http.Error(w, "Missing owner or repo parameter", http.StatusBadRequest) 80 + return 81 + } 82 + 83 + // Get user from session 84 + session, err := auth.GetSession(r) 85 + if err != nil { 86 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 87 + return 88 + } 89 + 90 + userID, ok := auth.GetUserID(session) 91 + if !ok { 92 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 93 + return 94 + } 95 + 96 + // Get user's GitHub access token 97 + token, err := h.db.GetAuthToken(userID, "github") 98 + if err != nil { 99 + log.Printf("Failed to get auth token: %v", err) 100 + http.Error(w, "Failed to get auth token", http.StatusInternalServerError) 101 + return 102 + } 103 + 104 + // Create GitHub connector 105 + connector := connectors.NewGitHubConnector(token.AccessToken) 106 + 107 + // Get query parameters 108 + path := r.URL.Query().Get("path") 109 + branch := r.URL.Query().Get("branch") 110 + extensionsParam := r.URL.Query().Get("extensions") 111 + 112 + var extensions []string 113 + if extensionsParam != "" { 114 + extensions = strings.Split(extensionsParam, ",") 115 + } 116 + 117 + // List files 118 + files, err := connector.ListFiles(r.Context(), owner, repo, path, branch, extensions) 119 + if err != nil { 120 + log.Printf("Failed to list files: %v", err) 121 + http.Error(w, "Failed to list files", http.StatusInternalServerError) 122 + return 123 + } 124 + 125 + response := map[string]interface{}{ 126 + "files": files.Children, 127 + "current_branch": branch, 128 + } 129 + 130 + w.Header().Set("Content-Type", "application/json") 131 + json.NewEncoder(w).Encode(response) 132 + } 133 + 134 + // GetFileContent gets the content of a file 135 + func (h *RepoHandler) GetFileContent(w http.ResponseWriter, r *http.Request) { 136 + owner := chi.URLParam(r, "owner") 137 + repo := chi.URLParam(r, "repo") 138 + path := chi.URLParam(r, "*") 139 + 140 + if owner == "" || repo == "" || path == "" { 141 + http.Error(w, "Missing required parameters", http.StatusBadRequest) 142 + return 143 + } 144 + 145 + // Get user from session 146 + session, err := auth.GetSession(r) 147 + if err != nil { 148 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 149 + return 150 + } 151 + 152 + userID, ok := auth.GetUserID(session) 153 + if !ok { 154 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 155 + return 156 + } 157 + 158 + // Get user's GitHub access token 159 + token, err := h.db.GetAuthToken(userID, "github") 160 + if err != nil { 161 + log.Printf("Failed to get auth token: %v", err) 162 + http.Error(w, "Failed to get auth token", http.StatusInternalServerError) 163 + return 164 + } 165 + 166 + // Create GitHub connector 167 + connector := connectors.NewGitHubConnector(token.AccessToken) 168 + 169 + // Get branch parameter 170 + branch := r.URL.Query().Get("branch") 171 + 172 + // Get file content 173 + content, err := connector.GetFileContent(r.Context(), owner, repo, path, branch) 174 + if err != nil { 175 + log.Printf("Failed to get file content: %v", err) 176 + http.Error(w, "Failed to get file content", http.StatusInternalServerError) 177 + return 178 + } 179 + 180 + w.Header().Set("Content-Type", "application/json") 181 + json.NewEncoder(w).Encode(content) 182 + }
+5 -3
backend/internal/api/router.go
··· 40 40 41 41 // Create handlers 42 42 authHandler := handlers.NewAuthHandler(db) 43 + repoHandler := handlers.NewRepoHandler(db) 43 44 44 45 // Public routes 45 46 r.Get("/api/health", handlers.HealthCheck) ··· 53 54 r.Get("/api/auth/user", authHandler.GetCurrentUser) 54 55 r.Post("/api/auth/logout", authHandler.Logout) 55 56 56 - // Repository routes (to be implemented) 57 - // r.Get("/api/repos", repoHandler.ListRepos) 58 - // r.Get("/api/repos/{owner}/{repo}/files", repoHandler.ListFiles) 57 + // Repository routes 58 + r.Get("/api/repos", repoHandler.ListRepositories) 59 + r.Get("/api/repos/{owner}/{repo}/files", repoHandler.ListFiles) 60 + r.Get("/api/repos/{owner}/{repo}/files/*", repoHandler.GetFileContent) 59 61 }) 60 62 61 63 return r
+51
backend/internal/connectors/connector.go
··· 1 + package connectors 2 + 3 + import ( 4 + "context" 5 + "time" 6 + ) 7 + 8 + // Repository represents a repository from any connector 9 + type Repository struct { 10 + ID int64 `json:"id"` 11 + FullName string `json:"full_name"` 12 + Name string `json:"name"` 13 + Owner string `json:"owner"` 14 + Private bool `json:"private"` 15 + DefaultBranch string `json:"default_branch"` 16 + UpdatedAt time.Time `json:"updated_at"` 17 + } 18 + 19 + // FileNode represents a file or directory 20 + type FileNode struct { 21 + Path string `json:"path"` 22 + Name string `json:"name"` 23 + Type string `json:"type"` // "file" or "dir" 24 + Size int64 `json:"size,omitempty"` 25 + SHA string `json:"sha,omitempty"` 26 + Children []FileNode `json:"children,omitempty"` 27 + } 28 + 29 + // FileContent represents the content of a file 30 + type FileContent struct { 31 + Content string `json:"content"` 32 + Frontmatter map[string]interface{} `json:"frontmatter,omitempty"` 33 + Path string `json:"path"` 34 + SHA string `json:"sha"` 35 + Branch string `json:"branch"` 36 + } 37 + 38 + // Connector defines the interface for storage backends 39 + type Connector interface { 40 + // ListRepositories lists all repositories for the authenticated user 41 + ListRepositories(ctx context.Context, sortBy string) ([]Repository, error) 42 + 43 + // ListFiles lists files in a repository path 44 + ListFiles(ctx context.Context, owner, repo, path, branch string, extensions []string) (*FileNode, error) 45 + 46 + // GetFileContent retrieves the content of a file 47 + GetFileContent(ctx context.Context, owner, repo, path, branch string) (*FileContent, error) 48 + 49 + // GetType returns the connector type 50 + GetType() string 51 + }
+193
backend/internal/connectors/github.go
··· 1 + package connectors 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "path/filepath" 7 + "strings" 8 + 9 + "github.com/google/go-github/v58/github" 10 + "golang.org/x/oauth2" 11 + ) 12 + 13 + // GitHubConnector implements the Connector interface for GitHub 14 + type GitHubConnector struct { 15 + client *github.Client 16 + } 17 + 18 + // NewGitHubConnector creates a new GitHub connector with an access token 19 + func NewGitHubConnector(accessToken string) *GitHubConnector { 20 + ctx := context.Background() 21 + ts := oauth2.StaticTokenSource( 22 + &oauth2.Token{AccessToken: accessToken}, 23 + ) 24 + tc := oauth2.NewClient(ctx, ts) 25 + client := github.NewClient(tc) 26 + 27 + return &GitHubConnector{ 28 + client: client, 29 + } 30 + } 31 + 32 + // GetType returns the connector type 33 + func (g *GitHubConnector) GetType() string { 34 + return "github" 35 + } 36 + 37 + // ListRepositories lists all repositories for the authenticated user 38 + func (g *GitHubConnector) ListRepositories(ctx context.Context, sortBy string) ([]Repository, error) { 39 + // Map sort parameter 40 + sort := "updated" 41 + switch sortBy { 42 + case "created": 43 + sort = "created" 44 + case "name": 45 + sort = "full_name" 46 + default: 47 + sort = "updated" 48 + } 49 + 50 + opts := &github.RepositoryListOptions{ 51 + Sort: sort, 52 + Direction: "desc", 53 + ListOptions: github.ListOptions{ 54 + PerPage: 100, 55 + }, 56 + } 57 + 58 + var allRepos []Repository 59 + 60 + for { 61 + repos, resp, err := g.client.Repositories.List(ctx, "", opts) 62 + if err != nil { 63 + return nil, fmt.Errorf("failed to list repositories: %w", err) 64 + } 65 + 66 + for _, repo := range repos { 67 + allRepos = append(allRepos, Repository{ 68 + ID: repo.GetID(), 69 + FullName: repo.GetFullName(), 70 + Name: repo.GetName(), 71 + Owner: repo.GetOwner().GetLogin(), 72 + Private: repo.GetPrivate(), 73 + DefaultBranch: repo.GetDefaultBranch(), 74 + UpdatedAt: repo.GetUpdatedAt().Time, 75 + }) 76 + } 77 + 78 + if resp.NextPage == 0 { 79 + break 80 + } 81 + opts.Page = resp.NextPage 82 + } 83 + 84 + return allRepos, nil 85 + } 86 + 87 + // ListFiles lists files in a repository path 88 + func (g *GitHubConnector) ListFiles(ctx context.Context, owner, repo, path, branch string, extensions []string) (*FileNode, error) { 89 + if branch == "" { 90 + // Get default branch 91 + repository, _, err := g.client.Repositories.Get(ctx, owner, repo) 92 + if err != nil { 93 + return nil, fmt.Errorf("failed to get repository: %w", err) 94 + } 95 + branch = repository.GetDefaultBranch() 96 + } 97 + 98 + opts := &github.RepositoryContentGetOptions{ 99 + Ref: branch, 100 + } 101 + 102 + _, dirContent, _, err := g.client.Repositories.GetContents(ctx, owner, repo, path, opts) 103 + if err != nil { 104 + return nil, fmt.Errorf("failed to get contents: %w", err) 105 + } 106 + 107 + root := &FileNode{ 108 + Path: path, 109 + Name: filepath.Base(path), 110 + Type: "dir", 111 + Children: []FileNode{}, 112 + } 113 + 114 + if path == "" { 115 + root.Name = repo 116 + } 117 + 118 + for _, item := range dirContent { 119 + node := FileNode{ 120 + Path: item.GetPath(), 121 + Name: item.GetName(), 122 + Type: item.GetType(), 123 + Size: int64(item.GetSize()), 124 + SHA: item.GetSHA(), 125 + } 126 + 127 + // Filter by extension if specified 128 + if len(extensions) > 0 && node.Type == "file" { 129 + ext := strings.TrimPrefix(filepath.Ext(node.Name), ".") 130 + found := false 131 + for _, allowedExt := range extensions { 132 + if ext == allowedExt { 133 + found = true 134 + break 135 + } 136 + } 137 + if !found { 138 + continue 139 + } 140 + } 141 + 142 + // Recursively get directory contents 143 + if node.Type == "dir" { 144 + subNode, err := g.ListFiles(ctx, owner, repo, item.GetPath(), branch, extensions) 145 + if err != nil { 146 + // Log error but continue 147 + continue 148 + } 149 + node.Children = subNode.Children 150 + } 151 + 152 + root.Children = append(root.Children, node) 153 + } 154 + 155 + return root, nil 156 + } 157 + 158 + // GetFileContent retrieves the content of a file 159 + func (g *GitHubConnector) GetFileContent(ctx context.Context, owner, repo, path, branch string) (*FileContent, error) { 160 + if branch == "" { 161 + // Get default branch 162 + repository, _, err := g.client.Repositories.Get(ctx, owner, repo) 163 + if err != nil { 164 + return nil, fmt.Errorf("failed to get repository: %w", err) 165 + } 166 + branch = repository.GetDefaultBranch() 167 + } 168 + 169 + opts := &github.RepositoryContentGetOptions{ 170 + Ref: branch, 171 + } 172 + 173 + fileContent, _, _, err := g.client.Repositories.GetContents(ctx, owner, repo, path, opts) 174 + if err != nil { 175 + return nil, fmt.Errorf("failed to get file content: %w", err) 176 + } 177 + 178 + if fileContent == nil { 179 + return nil, fmt.Errorf("file not found") 180 + } 181 + 182 + content, err := fileContent.GetContent() 183 + if err != nil { 184 + return nil, fmt.Errorf("failed to decode content: %w", err) 185 + } 186 + 187 + return &FileContent{ 188 + Content: content, 189 + Path: path, 190 + SHA: fileContent.GetSHA(), 191 + Branch: branch, 192 + }, nil 193 + }