A deployable markdown editor that connects with your self hosted files and lets you edit in a beautiful interface
at main 606 lines 17 kB view raw
1package connectors 2 3import ( 4 "context" 5 "encoding/base64" 6 "fmt" 7 "os" 8 "path/filepath" 9 "sort" 10 "strconv" 11 "strings" 12 "sync" 13 "time" 14 15 "github.com/google/go-github/v58/github" 16 "github.com/yourusername/markedit/internal/database" 17 "github.com/yourusername/markedit/internal/markdown" 18 "golang.org/x/oauth2" 19 "golang.org/x/sync/singleflight" 20) 21 22// fileTreeCache is an in-memory LRU cache for file tree data 23type fileTreeCache struct { 24 mu sync.RWMutex 25 entries map[string]*cacheEntry 26 maxSize int 27 ttl time.Duration 28 sfGroup singleflight.Group // Request deduplication 29} 30 31type cacheEntry struct { 32 data *FileNode 33 expiresAt time.Time 34 etag string // For future ETag support 35 fetchedAt time.Time 36} 37 38var globalCache = &fileTreeCache{ 39 entries: make(map[string]*cacheEntry), 40 maxSize: 100, // Configurable via env 41 ttl: 5 * time.Minute, 42} 43 44func init() { 45 // Configure cache from environment 46 if ttlStr := os.Getenv("FILE_TREE_CACHE_TTL"); ttlStr != "" { 47 if ttl, err := time.ParseDuration(ttlStr); err == nil { 48 globalCache.ttl = ttl 49 } 50 } 51 52 if sizeStr := os.Getenv("FILE_TREE_CACHE_SIZE"); sizeStr != "" { 53 if size, err := strconv.Atoi(sizeStr); err == nil { 54 globalCache.maxSize = size 55 } 56 } 57} 58 59// Get retrieves from cache if not expired 60func (c *fileTreeCache) Get(key string) (*FileNode, bool) { 61 c.mu.RLock() 62 defer c.mu.RUnlock() 63 64 entry, exists := c.entries[key] 65 if !exists || time.Now().After(entry.expiresAt) { 66 return nil, false 67 } 68 return entry.data, true 69} 70 71// Set stores in cache with TTL 72func (c *fileTreeCache) Set(key string, data *FileNode) { 73 c.mu.Lock() 74 defer c.mu.Unlock() 75 76 // LRU eviction if at capacity 77 if len(c.entries) >= c.maxSize { 78 c.evictOldest() 79 } 80 81 c.entries[key] = &cacheEntry{ 82 data: data, 83 expiresAt: time.Now().Add(c.ttl), 84 fetchedAt: time.Now(), 85 } 86} 87 88// Invalidate removes specific cache entry 89func (c *fileTreeCache) Invalidate(key string) { 90 c.mu.Lock() 91 defer c.mu.Unlock() 92 delete(c.entries, key) 93} 94 95// InvalidatePattern removes all matching keys (e.g., "owner/repo/*") 96func (c *fileTreeCache) InvalidatePattern(pattern string) int { 97 c.mu.Lock() 98 defer c.mu.Unlock() 99 100 count := 0 101 for key := range c.entries { 102 if matchesPattern(key, pattern) { 103 delete(c.entries, key) 104 count++ 105 } 106 } 107 return count 108} 109 110// evictOldest removes the oldest entry (LRU) 111func (c *fileTreeCache) evictOldest() { 112 var oldestKey string 113 var oldestTime time.Time 114 115 for key, entry := range c.entries { 116 if oldestKey == "" || entry.fetchedAt.Before(oldestTime) { 117 oldestKey = key 118 oldestTime = entry.fetchedAt 119 } 120 } 121 122 if oldestKey != "" { 123 delete(c.entries, oldestKey) 124 } 125} 126 127// generateCacheKey creates consistent cache key 128func generateCacheKey(owner, repo, branch, path string, extensions []string) string { 129 extStr := strings.Join(extensions, ",") 130 return fmt.Sprintf("%s/%s/%s/%s/%s", owner, repo, branch, path, extStr) 131} 132 133// matchesPattern checks if key matches pattern (simple glob) 134func matchesPattern(key, pattern string) bool { 135 // Simple implementation: pattern can end with /* for prefix match 136 if strings.HasSuffix(pattern, "/*") { 137 prefix := strings.TrimSuffix(pattern, "/*") 138 return strings.HasPrefix(key, prefix) 139 } 140 return key == pattern 141} 142 143// GitHubConnector implements the Connector interface for GitHub 144type GitHubConnector struct { 145 client *github.Client 146 db *database.DB // For logging 147 userID *int // For logging 148} 149 150// NewGitHubConnector creates a new GitHub connector with an access token 151func NewGitHubConnector(accessToken string, db *database.DB, userID *int) *GitHubConnector { 152 ctx := context.Background() 153 ts := oauth2.StaticTokenSource( 154 &oauth2.Token{AccessToken: accessToken}, 155 ) 156 tc := oauth2.NewClient(ctx, ts) 157 client := github.NewClient(tc) 158 159 return &GitHubConnector{ 160 client: client, 161 db: db, 162 userID: userID, 163 } 164} 165 166// GetType returns the connector type 167func (g *GitHubConnector) GetType() string { 168 return "github" 169} 170 171// logCacheEvent logs cache events to the database 172func (g *GitHubConnector) logCacheEvent(cacheKey, eventType string, responseTimeMs int) { 173 if g.db != nil { 174 _ = g.db.LogCacheEvent(cacheKey, "file_tree", eventType, g.userID, responseTimeMs) 175 } 176} 177 178// ListRepositories lists all repositories for the authenticated user 179func (g *GitHubConnector) ListRepositories(ctx context.Context, sortBy string) ([]Repository, error) { 180 // Map sort parameter 181 sort := "updated" 182 switch sortBy { 183 case "created": 184 sort = "created" 185 case "name": 186 sort = "full_name" 187 default: 188 sort = "updated" 189 } 190 191 opts := &github.RepositoryListOptions{ 192 Sort: sort, 193 Direction: "desc", 194 Affiliation: "owner,collaborator,organization_member", // Include all repos user has access to 195 ListOptions: github.ListOptions{ 196 PerPage: 100, 197 }, 198 } 199 200 var allRepos []Repository 201 202 for { 203 repos, resp, err := g.client.Repositories.List(ctx, "", opts) 204 if err != nil { 205 return nil, fmt.Errorf("failed to list repositories (status: %v): %w", resp.StatusCode, err) 206 } 207 208 for _, repo := range repos { 209 allRepos = append(allRepos, Repository{ 210 ID: repo.GetID(), 211 FullName: repo.GetFullName(), 212 Name: repo.GetName(), 213 Owner: repo.GetOwner().GetLogin(), 214 Private: repo.GetPrivate(), 215 DefaultBranch: repo.GetDefaultBranch(), 216 UpdatedAt: repo.GetUpdatedAt().Time, 217 }) 218 } 219 220 if resp.NextPage == 0 { 221 break 222 } 223 opts.Page = resp.NextPage 224 } 225 226 return allRepos, nil 227} 228 229// ListFiles lists files in a repository path using GitHub Tree API with caching 230func (g *GitHubConnector) ListFiles(ctx context.Context, owner, repo, path, branch string, extensions []string) (*FileNode, error) { 231 // Resolve default branch first to ensure consistent cache keys 232 if branch == "" { 233 repository, _, err := g.client.Repositories.Get(ctx, owner, repo) 234 if err != nil { 235 return nil, fmt.Errorf("failed to get repository: %w", err) 236 } 237 branch = repository.GetDefaultBranch() 238 } 239 240 cacheKey := generateCacheKey(owner, repo, branch, path, extensions) 241 242 // Try cache first 243 if cached, found := globalCache.Get(cacheKey); found { 244 g.logCacheEvent(cacheKey, "hit", 0) 245 return cached, nil 246 } 247 248 // Use singleflight to deduplicate concurrent requests 249 result, err, _ := globalCache.sfGroup.Do(cacheKey, func() (interface{}, error) { 250 startTime := time.Now() 251 252 // Fetch from GitHub Tree API 253 data, err := g.fetchTreeFromGitHub(ctx, owner, repo, branch, path, extensions) 254 if err != nil { 255 return nil, err 256 } 257 258 responseTime := int(time.Since(startTime).Milliseconds()) 259 260 // Store in cache 261 globalCache.Set(cacheKey, data) 262 g.logCacheEvent(cacheKey, "miss", responseTime) 263 264 return data, nil 265 }) 266 267 if err != nil { 268 return nil, err 269 } 270 return result.(*FileNode), nil 271} 272 273// fetchTreeFromGitHub fetches file tree from GitHub using Tree API 274func (g *GitHubConnector) fetchTreeFromGitHub(ctx context.Context, owner, repo, branch, path string, extensions []string) (*FileNode, error) { 275 // Step 1: Get branch to get commit SHA 276 branchRef, _, err := g.client.Repositories.GetBranch(ctx, owner, repo, branch, 0) 277 if err != nil { 278 return nil, fmt.Errorf("failed to get branch: %w", err) 279 } 280 commitSHA := branchRef.GetCommit().GetSHA() 281 282 // Step 2: Fetch tree recursively (single API call) 283 tree, _, err := g.client.Git.GetTree(ctx, owner, repo, commitSHA, true) 284 if err != nil { 285 return nil, fmt.Errorf("failed to get tree: %w", err) 286 } 287 288 // Check for truncated response 289 if tree.Truncated != nil && *tree.Truncated { 290 return nil, fmt.Errorf("repository tree is truncated (>100k entries) - repository too large") 291 } 292 293 // Step 3: Filter tree entries by path and extensions 294 var filteredEntries []*github.TreeEntry 295 for _, entry := range tree.Entries { 296 // Skip if not in requested path 297 if path != "" && !strings.HasPrefix(*entry.Path, path) { 298 continue 299 } 300 301 // Filter by extensions (only for blobs/files) 302 if len(extensions) > 0 && *entry.Type == "blob" { 303 if !matchesExtensions(*entry.Path, extensions) { 304 continue 305 } 306 } 307 308 filteredEntries = append(filteredEntries, entry) 309 } 310 311 // Step 4: Build hierarchical FileNode tree from flat structure 312 root := buildFileTree(filteredEntries, path, extensions, repo) 313 314 return root, nil 315} 316 317// matchesExtensions checks if file matches extension filter 318func matchesExtensions(filename string, extensions []string) bool { 319 if len(extensions) == 0 { 320 return true 321 } 322 323 ext := strings.TrimPrefix(filepath.Ext(filename), ".") 324 for _, e := range extensions { 325 if strings.EqualFold(ext, e) { 326 return true 327 } 328 } 329 return false 330} 331 332// buildFileTree converts flat GitHub tree entries to hierarchical FileNode structure 333func buildFileTree(entries []*github.TreeEntry, rootPath string, extensions []string, repoName string) *FileNode { 334 root := &FileNode{ 335 Name: repoName, 336 Path: rootPath, 337 Type: "directory", 338 IsDir: true, 339 Children: []*FileNode{}, 340 } 341 342 if rootPath != "" { 343 root.Name = filepath.Base(rootPath) 344 } 345 346 // Group entries by their first path component 347 pathGroups := make(map[string][]*github.TreeEntry) 348 349 for _, entry := range entries { 350 relativePath := *entry.Path 351 if rootPath != "" { 352 relativePath = strings.TrimPrefix(relativePath, rootPath) 353 relativePath = strings.TrimPrefix(relativePath, "/") 354 } 355 356 if relativePath == "" { 357 continue 358 } 359 360 // Get first component 361 parts := strings.SplitN(relativePath, "/", 2) 362 firstComponent := parts[0] 363 364 pathGroups[firstComponent] = append(pathGroups[firstComponent], entry) 365 } 366 367 // Build tree recursively 368 for name, groupEntries := range pathGroups { 369 // Check if this is a file or directory 370 isFile := len(groupEntries) == 1 && *groupEntries[0].Type == "blob" && !strings.Contains(strings.TrimPrefix(*groupEntries[0].Path, rootPath+"/"), "/") 371 372 if isFile { 373 // It's a file 374 filePath := *groupEntries[0].Path 375 root.Children = append(root.Children, &FileNode{ 376 Name: name, 377 Path: filePath, 378 Type: "file", 379 IsDir: false, 380 SHA: *groupEntries[0].SHA, 381 Size: int64(*groupEntries[0].Size), 382 }) 383 } else { 384 // It's a directory - collect all direct children 385 dirNode := &FileNode{ 386 Name: name, 387 Path: filepath.Join(rootPath, name), 388 Type: "directory", 389 IsDir: true, 390 Children: []*FileNode{}, 391 } 392 393 // Get direct children of this directory 394 directChildren := make(map[string][]*github.TreeEntry) 395 dirPrefix := dirNode.Path + "/" 396 397 for _, entry := range groupEntries { 398 relativeToDir := strings.TrimPrefix(*entry.Path, dirPrefix) 399 parts := strings.SplitN(relativeToDir, "/", 2) 400 directChildren[parts[0]] = append(directChildren[parts[0]], entry) 401 } 402 403 // Recursively build subtree 404 subTree := buildFileTree(groupEntries, dirNode.Path, extensions, name) 405 dirNode.Children = subTree.Children 406 407 // Only add directory if it has children (matching files) 408 if len(dirNode.Children) > 0 { 409 root.Children = append(root.Children, dirNode) 410 } 411 } 412 } 413 414 // Sort: directories first, then alphabetically 415 sort.Slice(root.Children, func(i, j int) bool { 416 if root.Children[i].IsDir != root.Children[j].IsDir { 417 return root.Children[i].IsDir 418 } 419 return root.Children[i].Name < root.Children[j].Name 420 }) 421 422 return root 423} 424 425// GetFileContent retrieves the content of a file 426func (g *GitHubConnector) GetFileContent(ctx context.Context, owner, repo, path, branch string) (*FileContent, error) { 427 if branch == "" { 428 // Get default branch 429 repository, _, err := g.client.Repositories.Get(ctx, owner, repo) 430 if err != nil { 431 return nil, fmt.Errorf("failed to get repository: %w", err) 432 } 433 branch = repository.GetDefaultBranch() 434 } 435 436 opts := &github.RepositoryContentGetOptions{ 437 Ref: branch, 438 } 439 440 fileContent, _, _, err := g.client.Repositories.GetContents(ctx, owner, repo, path, opts) 441 if err != nil { 442 return nil, fmt.Errorf("failed to get file content: %w", err) 443 } 444 445 if fileContent == nil { 446 return nil, fmt.Errorf("file not found") 447 } 448 449 content, err := fileContent.GetContent() 450 if err != nil { 451 return nil, fmt.Errorf("failed to decode content: %w", err) 452 } 453 454 // Parse markdown with frontmatter 455 parsed, err := markdown.Parse(content) 456 if err != nil { 457 return nil, fmt.Errorf("failed to parse markdown: %w", err) 458 } 459 460 return &FileContent{ 461 Content: parsed.Content, 462 Frontmatter: parsed.Frontmatter, 463 Path: path, 464 SHA: fileContent.GetSHA(), 465 Branch: branch, 466 }, nil 467} 468 469// InvalidateCacheForRepo invalidates all cache entries for a repository 470func InvalidateCacheForRepo(owner, repo string) int { 471 pattern := fmt.Sprintf("%s/%s/*", owner, repo) 472 return globalCache.InvalidatePattern(pattern) 473} 474 475// CreateFile creates a new file in the repository 476func (g *GitHubConnector) CreateFile(ctx context.Context, owner, repo, path, content, message string) error { 477 if message == "" { 478 message = fmt.Sprintf("Create %s", path) 479 } 480 481 // Get default branch 482 repository, _, err := g.client.Repositories.Get(ctx, owner, repo) 483 if err != nil { 484 return fmt.Errorf("failed to get repository: %w", err) 485 } 486 branch := repository.GetDefaultBranch() 487 488 // Encode content to base64 489 encodedContent := github.String(base64.StdEncoding.EncodeToString([]byte(content))) 490 491 // Create file options 492 opts := &github.RepositoryContentFileOptions{ 493 Message: github.String(message), 494 Content: []byte(*encodedContent), 495 Branch: github.String(branch), 496 } 497 498 // Create the file 499 _, _, err = g.client.Repositories.CreateFile(ctx, owner, repo, path, opts) 500 if err != nil { 501 return fmt.Errorf("failed to create file: %w", err) 502 } 503 504 return nil 505} 506 507// RenameItem renames a file or folder by deleting the old path and creating at the new path 508func (g *GitHubConnector) RenameItem(ctx context.Context, owner, repo, oldPath, newPath, message string) error { 509 if message == "" { 510 message = fmt.Sprintf("Rename %s to %s", oldPath, newPath) 511 } 512 513 // Get default branch 514 repository, _, err := g.client.Repositories.Get(ctx, owner, repo) 515 if err != nil { 516 return fmt.Errorf("failed to get repository: %w", err) 517 } 518 branch := repository.GetDefaultBranch() 519 520 // Get the content of the old file/path 521 fileContent, _, _, err := g.client.Repositories.GetContents(ctx, owner, repo, oldPath, &github.RepositoryContentGetOptions{ 522 Ref: branch, 523 }) 524 if err != nil { 525 return fmt.Errorf("failed to get file content: %w", err) 526 } 527 528 // If it's a directory, we need to handle all files in it 529 if fileContent == nil { 530 // It's a directory - get all files in it 531 _, directoryContent, _, err := g.client.Repositories.GetContents(ctx, owner, repo, oldPath, &github.RepositoryContentGetOptions{ 532 Ref: branch, 533 }) 534 if err != nil { 535 return fmt.Errorf("failed to get directory contents: %w", err) 536 } 537 538 // Rename each file in the directory 539 for _, item := range directoryContent { 540 if item.GetType() == "file" { 541 oldFilePath := item.GetPath() 542 newFilePath := strings.Replace(oldFilePath, oldPath, newPath, 1) 543 544 // Get file content 545 content, err := item.GetContent() 546 if err != nil { 547 return fmt.Errorf("failed to get content for %s: %w", oldFilePath, err) 548 } 549 550 // Create new file 551 encodedContent := base64.StdEncoding.EncodeToString([]byte(content)) 552 opts := &github.RepositoryContentFileOptions{ 553 Message: github.String(message), 554 Content: []byte(encodedContent), 555 Branch: github.String(branch), 556 } 557 _, _, err = g.client.Repositories.CreateFile(ctx, owner, repo, newFilePath, opts) 558 if err != nil { 559 return fmt.Errorf("failed to create file %s: %w", newFilePath, err) 560 } 561 562 // Delete old file 563 deleteOpts := &github.RepositoryContentFileOptions{ 564 Message: github.String(message), 565 SHA: github.String(item.GetSHA()), 566 Branch: github.String(branch), 567 } 568 _, _, err = g.client.Repositories.DeleteFile(ctx, owner, repo, oldFilePath, deleteOpts) 569 if err != nil { 570 return fmt.Errorf("failed to delete file %s: %w", oldFilePath, err) 571 } 572 } 573 } 574 } else { 575 // It's a single file 576 content, err := fileContent.GetContent() 577 if err != nil { 578 return fmt.Errorf("failed to get file content: %w", err) 579 } 580 581 // Create new file at new path 582 encodedContent := base64.StdEncoding.EncodeToString([]byte(content)) 583 opts := &github.RepositoryContentFileOptions{ 584 Message: github.String(message), 585 Content: []byte(encodedContent), 586 Branch: github.String(branch), 587 } 588 _, _, err = g.client.Repositories.CreateFile(ctx, owner, repo, newPath, opts) 589 if err != nil { 590 return fmt.Errorf("failed to create file at new path: %w", err) 591 } 592 593 // Delete old file 594 deleteOpts := &github.RepositoryContentFileOptions{ 595 Message: github.String(message), 596 SHA: github.String(fileContent.GetSHA()), 597 Branch: github.String(branch), 598 } 599 _, _, err = g.client.Repositories.DeleteFile(ctx, owner, repo, oldPath, deleteOpts) 600 if err != nil { 601 return fmt.Errorf("failed to delete old file: %w", err) 602 } 603 } 604 605 return nil 606}