The codebase that powers boop.cat boop.cat
at main 575 lines 14 kB view raw
1// Copyright 2025 boop.cat 2// Licensed under the Apache License, Version 2.0 3// See LICENSE file for details. 4 5package deploy 6 7import ( 8 "context" 9 "database/sql" 10 "encoding/json" 11 "fmt" 12 "io/ioutil" 13 "log" 14 "net/http" 15 "os" 16 "path/filepath" 17 "strings" 18 "sync" 19 "time" 20 21 "github.com/nrednav/cuid2" 22 23 "boop-cat/db" 24 "boop-cat/lib" 25) 26 27type Engine struct { 28 DB *sql.DB 29 WorkDir string 30 B2KeyID string 31 B2AppKey string 32 B2BucketID string 33 CFToken string 34 CFAccountID string 35 CFNamespaceID string 36 deploymentsMux sync.Mutex 37 deployments map[string]context.CancelFunc 38} 39 40func NewEngine(database *sql.DB, b2KeyID, b2AppKey, b2BucketID, cfToken, cfAccount, cfNamespace string) *Engine { 41 42 workDir := filepath.Join(os.TempDir(), "fsd-builds") 43 os.MkdirAll(workDir, 0755) 44 45 return &Engine{ 46 DB: database, 47 WorkDir: workDir, 48 B2KeyID: b2KeyID, 49 B2AppKey: b2AppKey, 50 B2BucketID: b2BucketID, 51 CFToken: cfToken, 52 CFAccountID: cfAccount, 53 CFNamespaceID: cfNamespace, 54 deployments: make(map[string]context.CancelFunc), 55 } 56} 57 58func (e *Engine) DeploySite(siteID, userID string, logStream chan<- string) (*db.Deployment, error) { 59 60 var commitSha, commitMessage, commitAuthor, commitAvatar *string 61 62 site, err := db.GetSiteByID(e.DB, userID, siteID) 63 if err == nil && site.GitURL.Valid && strings.Contains(site.GitURL.String, "github.com") { 64 65 ghToken, _ := db.GetGitHubToken(e.DB, userID) 66 67 parts := strings.Split(strings.TrimPrefix(site.GitURL.String, "https://github.com/"), "/") 68 if len(parts) >= 2 { 69 owner := parts[0] 70 repo := strings.TrimSuffix(parts[1], ".git") 71 branch := "main" 72 if site.GitBranch.Valid { 73 branch = site.GitBranch.String 74 } 75 76 apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/commits/%s", owner, repo, branch) 77 req, _ := http.NewRequest("GET", apiURL, nil) 78 if ghToken != "" { 79 req.Header.Set("Authorization", "Bearer "+ghToken) 80 } 81 82 client := &http.Client{Timeout: 2 * time.Second} 83 resp, err := client.Do(req) 84 if err == nil { 85 defer resp.Body.Close() 86 if resp.StatusCode == 200 { 87 var res struct { 88 SHA string `json:"sha"` 89 Commit struct { 90 Message string `json:"message"` 91 Author struct { 92 Name string `json:"name"` 93 } `json:"author"` 94 } `json:"commit"` 95 Author struct { 96 AvatarURL string `json:"avatar_url"` 97 } `json:"author"` 98 Committer struct { 99 AvatarURL string `json:"avatar_url"` 100 } `json:"committer"` 101 } 102 103 if err := json.NewDecoder(resp.Body).Decode(&res); err == nil { 104 105 sha := res.SHA 106 commitSha = &sha 107 108 msg := res.Commit.Message 109 commitMessage = &msg 110 111 auth := res.Commit.Author.Name 112 commitAuthor = &auth 113 114 if res.Author.AvatarURL != "" { 115 av := res.Author.AvatarURL 116 commitAvatar = &av 117 } else if res.Committer.AvatarURL != "" { 118 av := res.Committer.AvatarURL 119 commitAvatar = &av 120 } 121 } 122 } 123 } 124 } 125 } 126 127 deployID := cuid2.Generate() 128 err = db.CreateDeployment(e.DB, deployID, userID, siteID, "building", commitSha, commitMessage, commitAuthor, commitAvatar) 129 if err != nil { 130 return nil, fmt.Errorf("failed to create deployment record: %w", err) 131 } 132 133 ctx, cancel := context.WithCancel(context.Background()) 134 e.deploymentsMux.Lock() 135 e.deployments[deployID] = cancel 136 e.deploymentsMux.Unlock() 137 138 go func() { 139 defer func() { 140 e.deploymentsMux.Lock() 141 delete(e.deployments, deployID) 142 e.deploymentsMux.Unlock() 143 cancel() 144 if logStream != nil { 145 close(logStream) 146 } 147 }() 148 149 logsDir := filepath.Join(e.WorkDir, "logs") 150 os.MkdirAll(logsDir, 0755) 151 logsPath := filepath.Join(logsDir, deployID+".log") 152 logFile, _ := os.Create(logsPath) 153 154 db.UpdateDeploymentLogs(e.DB, deployID, logsPath) 155 156 logger := func(msg string) { 157 log.Printf("[Deploy %s] %s", deployID, msg) 158 if logFile != nil { 159 logFile.WriteString(fmt.Sprintf("[%s] %s\n", time.Now().Format(time.RFC3339), msg)) 160 } 161 if logStream != nil { 162 logStream <- msg 163 } 164 } 165 166 err := e.runPipeline(ctx, siteID, userID, deployID, logger) 167 if logFile != nil { 168 logFile.Close() 169 } 170 if err != nil { 171 logger(fmt.Sprintf("Deployment failed: %v", err)) 172 if ctx.Err() == context.Canceled { 173 db.UpdateDeploymentStatus(e.DB, deployID, "canceled", "") 174 } else { 175 db.UpdateDeploymentStatus(e.DB, deployID, "failed", "") 176 } 177 } else { 178 logger("Deployment successful") 179 } 180 }() 181 182 return db.GetDeploymentByID(e.DB, deployID) 183} 184 185func (e *Engine) CancelDeployment(deployID string) error { 186 e.deploymentsMux.Lock() 187 cancel, ok := e.deployments[deployID] 188 e.deploymentsMux.Unlock() 189 190 if !ok { 191 return fmt.Errorf("deployment not found or not running") 192 } 193 194 cancel() 195 return nil 196} 197 198func (e *Engine) runPipeline(ctx context.Context, siteID, userID, deployID string, logger func(string)) error { 199 200 site, err := db.GetSiteByID(e.DB, userID, siteID) 201 if err != nil { 202 return err 203 } 204 205 logger(fmt.Sprintf("Starting deployment for site %s (%s)", site.Name, site.ID)) 206 207 if ctx.Err() != nil { 208 return ctx.Err() 209 } 210 211 buildDir := filepath.Join(e.WorkDir, deployID) 212 213 logger("Cloning repository...") 214 if !site.GitURL.Valid { 215 return fmt.Errorf("site has no git url") 216 } 217 repoURL := site.GitURL.String 218 219 ghToken, err := db.GetGitHubToken(e.DB, userID) 220 if err == nil && ghToken != "" && strings.Contains(repoURL, "github.com") { 221 222 if strings.HasPrefix(repoURL, "https://github.com/") { 223 logger("Injecting GitHub authentication token...") 224 225 repoURL = strings.Replace(repoURL, "https://github.com/", fmt.Sprintf("https://oauth2:%s@github.com/", ghToken), 1) 226 } 227 } else if err != nil { 228 logger(fmt.Sprintf("Warning: Failed to check for GitHub token: %v", err)) 229 } else if ghToken == "" { 230 logger("No GitHub token found for user. Private repos may fail.") 231 } 232 233 branch := "main" 234 if site.GitBranch.Valid { 235 branch = site.GitBranch.String 236 } 237 238 err = GitClone(ctx, repoURL, branch, buildDir, 1, logger) 239 if err != nil { 240 return fmt.Errorf("git clone failed: %w", err) 241 } 242 243 if ctx.Err() != nil { 244 return ctx.Err() 245 } 246 head, err := GitCurrentHead(buildDir) 247 if err == nil { 248 249 avatarURL := "" 250 if strings.Contains(site.GitURL.String, "github.com") { 251 252 parts := strings.Split(strings.TrimPrefix(site.GitURL.String, "https://github.com/"), "/") 253 if len(parts) >= 2 { 254 owner := parts[0] 255 repo := strings.TrimSuffix(parts[1], ".git") 256 257 apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/commits/%s", owner, repo, head.SHA) 258 req, _ := http.NewRequest("GET", apiURL, nil) 259 if ghToken != "" { 260 req.Header.Set("Authorization", "Bearer "+ghToken) 261 } 262 263 client := &http.Client{Timeout: 5 * time.Second} 264 resp, err := client.Do(req) 265 if err == nil { 266 defer resp.Body.Close() 267 var res struct { 268 Author struct { 269 AvatarURL string `json:"avatar_url"` 270 } `json:"author"` 271 Committer struct { 272 AvatarURL string `json:"avatar_url"` 273 } `json:"committer"` 274 } 275 if err := json.NewDecoder(resp.Body).Decode(&res); err == nil { 276 if res.Author.AvatarURL != "" { 277 avatarURL = res.Author.AvatarURL 278 } else if res.Committer.AvatarURL != "" { 279 avatarURL = res.Committer.AvatarURL 280 } 281 } 282 } 283 } 284 } 285 286 e.DB.Exec(`UPDATE deployments SET commitSha=?, commitMessage=?, commitAuthor=?, commitAvatar=? WHERE id=?`, 287 head.SHA, head.Message, head.Author, avatarURL, deployID) 288 } 289 290 logger("Building project...") 291 292 envVars := []string{} 293 if site.EnvText.Valid && site.EnvText.String != "" { 294 decryptedEnv := lib.Decrypt(site.EnvText.String) 295 envVars = parseEnvText(decryptedEnv) 296 } 297 298 bs := &BuildSystem{ 299 RootDir: buildDir, 300 Env: envVars, 301 Logger: logger, 302 } 303 304 customBuildCmd := "" 305 if site.BuildCommand.Valid { 306 customBuildCmd = site.BuildCommand.String 307 } 308 309 outputDirName, err := bs.Build(ctx, customBuildCmd) 310 if err != nil { 311 return fmt.Errorf("build failed: %w", err) 312 } 313 314 fullOutputDir := filepath.Join(buildDir, outputDirName) 315 if !fileExists(fullOutputDir) { 316 return fmt.Errorf("output directory %s not found", outputDirName) 317 } 318 319 logger("Build complete. Starting upload...") 320 db.UpdateDeploymentStatus(e.DB, deployID, "running", "") 321 322 logger("Uploading to storage...") 323 b2 := NewB2Client(e.B2KeyID, e.B2AppKey, e.B2BucketID) 324 325 prefix := fmt.Sprintf("sites/%s/%s", siteID, deployID) 326 327 files, _ := ListFilesRecursive(fullOutputDir) 328 logger(fmt.Sprintf("Found %d files to upload", len(files))) 329 330 const maxConcurrency = 20 331 semaphore := make(chan struct{}, maxConcurrency) 332 var wg sync.WaitGroup 333 var uploadErr error 334 var errMutex sync.Mutex 335 336 for _, fPath := range files { 337 if ctx.Err() != nil { 338 break 339 } 340 341 wg.Add(1) 342 semaphore <- struct{}{} 343 344 go func(path string) { 345 defer wg.Done() 346 defer func() { <-semaphore }() 347 348 errMutex.Lock() 349 if uploadErr != nil { 350 errMutex.Unlock() 351 return 352 } 353 errMutex.Unlock() 354 355 relPath, _ := filepath.Rel(fullOutputDir, path) 356 key := fmt.Sprintf("%s/%s", prefix, relPath) 357 key = filepath.ToSlash(key) 358 359 content, err := ioutil.ReadFile(path) 360 if err != nil { 361 errMutex.Lock() 362 if uploadErr == nil { 363 uploadErr = fmt.Errorf("read failed for %s: %w", relPath, err) 364 } 365 errMutex.Unlock() 366 return 367 } 368 369 contentType := "application/octet-stream" 370 if strings.HasSuffix(key, ".html") { 371 contentType = "text/html" 372 } 373 if strings.HasSuffix(key, ".css") { 374 contentType = "text/css" 375 } 376 if strings.HasSuffix(key, ".js") { 377 contentType = "application/javascript" 378 } 379 if strings.HasSuffix(key, ".json") { 380 contentType = "application/json" 381 } 382 if strings.HasSuffix(key, ".png") { 383 contentType = "image/png" 384 } 385 if strings.HasSuffix(key, ".jpg") { 386 contentType = "image/jpeg" 387 } 388 if strings.HasSuffix(key, ".svg") { 389 contentType = "image/svg+xml" 390 } 391 392 err = b2.UploadFile(key, content, contentType) 393 if err != nil { 394 errMutex.Lock() 395 if uploadErr == nil { 396 uploadErr = fmt.Errorf("upload failed for %s: %w", relPath, err) 397 } 398 errMutex.Unlock() 399 } 400 }(fPath) 401 } 402 403 wg.Wait() 404 405 if uploadErr != nil { 406 return uploadErr 407 } 408 if ctx.Err() != nil { 409 return ctx.Err() 410 } 411 logger("Upload complete") 412 413 logger("Updating routing...") 414 cf := NewCloudflareClient(e.CFAccountID, e.CFNamespaceID, e.CFToken) 415 rootDomain := os.Getenv("FSD_EDGE_ROOT_DOMAIN") 416 417 if site.Domain != "" { 418 419 routingKey := site.Domain 420 421 if rootDomain != "" && strings.HasSuffix(site.Domain, "."+rootDomain) { 422 routingKey = strings.TrimSuffix(site.Domain, "."+rootDomain) 423 } else if rootDomain != "" && site.Domain == rootDomain { 424 425 routingKey = "@" 426 } 427 428 err = cf.EnsureRouting(routingKey, siteID, deployID) 429 if err != nil { 430 return fmt.Errorf("routing update failed: %w", err) 431 } 432 } 433 434 customDomains, _ := db.ListCustomDomains(e.DB, siteID) 435 for _, cd := range customDomains { 436 437 hostname := strings.ToLower(strings.TrimSpace(cd.Hostname)) 438 hostname = strings.TrimPrefix(hostname, "http://") 439 hostname = strings.TrimPrefix(hostname, "https://") 440 441 logger(fmt.Sprintf("Updating routing for custom domain: %s", hostname)) 442 err = cf.EnsureRouting(hostname, siteID, deployID) 443 if err != nil { 444 logger(fmt.Sprintf("Failed to update routing for %s: %v", hostname, err)) 445 } 446 } 447 448 if site.Domain == "" && len(customDomains) == 0 { 449 450 err = cf.EnsureRouting("", siteID, deployID) 451 if err != nil { 452 return fmt.Errorf("routing update failed: %w", err) 453 } 454 } 455 456 if rootDomain == "" { 457 rootDomain = os.Getenv("FSD_EDGE_ROOT_DOMAIN") 458 } 459 if rootDomain == "" { 460 rootDomain = "boop.cat" 461 } 462 463 finalURL := "" 464 465 if len(customDomains) > 0 { 466 for _, cd := range customDomains { 467 468 if cd.Status == "active" || cd.Status == "live" { 469 finalURL = fmt.Sprintf("https://%s", cd.Hostname) 470 break 471 } 472 } 473 474 if finalURL == "" && len(customDomains) > 0 { 475 finalURL = fmt.Sprintf("https://%s", customDomains[0].Hostname) 476 } 477 } 478 479 if finalURL == "" && site.Domain != "" { 480 if strings.HasSuffix(site.Domain, "."+rootDomain) || site.Domain == rootDomain { 481 finalURL = fmt.Sprintf("https://%s", site.Domain) 482 } else { 483 finalURL = fmt.Sprintf("https://%s.%s", site.Domain, rootDomain) 484 } 485 } 486 487 db.UpdateDeploymentStatus(e.DB, deployID, "running", finalURL) 488 db.UpdateSiteCurrentDeployment(e.DB, siteID, deployID) 489 490 if err := db.StopOtherDeployments(e.DB, siteID, deployID); err != nil { 491 logger(fmt.Sprintf("Warning: Failed to stop other deployments: %v", err)) 492 } 493 494 logger("Deployment successful!") 495 return nil 496} 497 498func ListFilesRecursive(root string) ([]string, error) { 499 var files []string 500 err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { 501 if err != nil { 502 return err 503 } 504 505 if info.IsDir() { 506 if info.Name() == ".git" || info.Name() == "node_modules" { 507 return filepath.SkipDir 508 } 509 } 510 if !info.IsDir() { 511 512 if !strings.Contains(path, "/.git/") && !strings.Contains(path, "\\.git\\") { 513 files = append(files, path) 514 } 515 } 516 return nil 517 }) 518 return files, err 519} 520 521func parseEnvText(envText string) []string { 522 var result []string 523 lines := strings.Split(envText, "\n") 524 for _, line := range lines { 525 line = strings.TrimSpace(line) 526 527 if line == "" || strings.HasPrefix(line, "#") { 528 continue 529 } 530 531 if idx := strings.Index(line, "="); idx > 0 { 532 key := strings.TrimSpace(line[:idx]) 533 value := strings.TrimSpace(line[idx+1:]) 534 535 if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) { 536 value = value[1 : len(value)-1] 537 } 538 result = append(result, fmt.Sprintf("%s=%s", key, value)) 539 } 540 } 541 return result 542} 543 544func (e *Engine) CleanupSite(siteID string, userID string) error { 545 546 cf := NewCloudflareClient(e.CFAccountID, e.CFNamespaceID, e.CFToken) 547 548 site, err := db.GetSiteByID(e.DB, userID, siteID) 549 if err == nil && site != nil { 550 551 rootDomain := os.Getenv("FSD_EDGE_ROOT_DOMAIN") 552 routingKey := site.Domain 553 if rootDomain != "" && strings.HasSuffix(site.Domain, "."+rootDomain) { 554 routingKey = strings.TrimSuffix(site.Domain, "."+rootDomain) 555 } 556 cf.RemoveRouting(routingKey, site.ID, site.Domain) 557 } 558 559 customDomains, _ := db.ListCustomDomains(e.DB, siteID) 560 for _, cd := range customDomains { 561 cf.RemoveRouting("", siteID, cd.Hostname) 562 563 } 564 565 b2 := NewB2Client(e.B2KeyID, e.B2AppKey, e.B2BucketID) 566 567 prefix := fmt.Sprintf("sites/%s/", siteID) 568 569 err = b2.DeleteFilesWithPrefix(prefix) 570 if err != nil { 571 return fmt.Errorf("failed to delete files: %w", err) 572 } 573 574 return nil 575}