The codebase that powers boop.cat
boop.cat
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}