package main import ( "database/sql" "embed" "encoding/json" "fmt" "io/fs" "log" "net/http" "os" "strconv" "strings" "sync" "time" "github.com/google/uuid" _ "github.com/mattn/go-sqlite3" ) //go:embed frontend/dist var staticFiles embed.FS // ==== Models ==== type Checklist struct { UUID string `json:"uuid"` Name string `json:"name"` } type ChecklistItem struct { ID int `json:"id"` Content string `json:"content"` Checked bool `json:"checked"` ParentID *int `json:"parent_id"` LockedBy *string `json:"locked_by,omitempty"` LockUntil *time.Time `json:"lock_until,omitempty"` Checklist string `json:"checklist_uuid"` Dependencies []int `json:"dependencies,omitempty"` NotBefore *time.Time `json:"not_before,omitempty"` NotAfter *time.Time `json:"not_after,omitempty"` } type ItemLock struct { LockedBy string Expires time.Time ChecklistUUID string } // ==== Globals ==== var ( sseClients = make(map[string]map[chan string]bool) // checklist uuid → set of client channels sseClientsMutex sync.Mutex itemLocks = make(map[int]*ItemLock) // item ID → lock itemLocksMutex sync.Mutex ) // ==== Database ==== func getChecklistDB(uuid string) (*sql.DB, error) { // Ensure data directory exists if err := os.MkdirAll("data", 0755); err != nil { return nil, err } dbPath := fmt.Sprintf("data/%s.db", uuid) db, err := sql.Open("sqlite3", dbPath) if err != nil { return nil, err } // Setup schema for this checklist queries := []string{ `CREATE TABLE IF NOT EXISTS checklist_info ( uuid TEXT PRIMARY KEY, name TEXT NOT NULL );`, `CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, checked INTEGER NOT NULL, parent_id INTEGER, not_before TEXT, not_after TEXT, FOREIGN KEY(parent_id) REFERENCES items(id) );`, `CREATE TABLE IF NOT EXISTS dependencies ( item_id INTEGER NOT NULL, dependency_id INTEGER NOT NULL, PRIMARY KEY (item_id, dependency_id), FOREIGN KEY(item_id) REFERENCES items(id) ON DELETE CASCADE, FOREIGN KEY(dependency_id) REFERENCES items(id) ON DELETE CASCADE, CHECK(item_id != dependency_id) );`, } for _, q := range queries { if _, err := db.Exec(q); err != nil { db.Close() return nil, err } } return db, nil } func loadItemDependencies(db *sql.DB, itemID int) ([]int, error) { rows, err := db.Query(`SELECT dependency_id FROM dependencies WHERE item_id = ?`, itemID) if err != nil { return nil, err } defer rows.Close() var deps []int for rows.Next() { var depID int err = rows.Scan(&depID) if err != nil { return nil, err } deps = append(deps, depID) } return deps, nil } // ensureChecklistExists creates a checklist if it doesn't exist func ensureChecklistExists(uuid string) error { db, err := getChecklistDB(uuid) if err != nil { return err } defer db.Close() // Check if checklist_info table has any data var count int err = db.QueryRow(`SELECT COUNT(*) FROM checklist_info`).Scan(&count) if err != nil { return err } // If no checklist exists, create one with a default name if count == 0 { _, err = db.Exec(`INSERT INTO checklist_info (uuid, name) VALUES (?, ?)`, uuid, "Untitled Checklist") if err != nil { return err } } return nil } func loadChecklistName(uuid string) (string, error) { db, err := getChecklistDB(uuid) if err != nil { return "", err } defer db.Close() rows, err := db.Query(`SELECT name FROM checklist_info WHERE uuid = ?`, uuid) if err != nil { return "", err } defer rows.Close() if rows.Next() { var name string err = rows.Scan(&name) if err != nil { return "", err } return name, nil } return "", fmt.Errorf("not found") } func loadChecklistItems(uuid string) ([]ChecklistItem, error) { db, err := getChecklistDB(uuid) if err != nil { return nil, err } defer db.Close() rows, err := db.Query(`SELECT id, content, checked, parent_id, not_before, not_after FROM items`) if err != nil { return nil, err } defer rows.Close() var items []ChecklistItem for rows.Next() { var it ChecklistItem var checked int var parentID sql.NullInt64 var notBefore sql.NullString var notAfter sql.NullString err = rows.Scan(&it.ID, &it.Content, &checked, &parentID, ¬Before, ¬After) if err != nil { return nil, err } it.Checked = checked != 0 if parentID.Valid { v := int(parentID.Int64) it.ParentID = &v } if notBefore.Valid { if t, err := time.Parse(time.RFC3339, notBefore.String); err == nil { it.NotBefore = &t } } if notAfter.Valid { if t, err := time.Parse(time.RFC3339, notAfter.String); err == nil { it.NotAfter = &t } } it.Checklist = uuid // Load dependencies for this item deps, err := loadItemDependencies(db, it.ID) if err != nil { return nil, err } it.Dependencies = deps // Attach lock info if present itemLocksMutex.Lock() if lock, ok := itemLocks[it.ID]; ok && lock.Expires.After(time.Now()) { it.LockedBy = &lock.LockedBy t := lock.Expires it.LockUntil = &t } itemLocksMutex.Unlock() items = append(items, it) } return items, nil } func addChecklist(name string) (string, error) { uuidStr := uuid.New().String() db, err := getChecklistDB(uuidStr) if err != nil { return "", err } defer db.Close() _, err = db.Exec(`INSERT INTO checklist_info (uuid, name) VALUES (?, ?)`, uuidStr, name) return uuidStr, err } func updateChecklistName(uuid string, name string) error { db, err := getChecklistDB(uuid) if err != nil { return err } defer db.Close() _, err = db.Exec(`UPDATE checklist_info SET name = ? WHERE uuid = ?`, name, uuid) return err } func addItem(uuid, content string, parentID *int, notBefore *time.Time, notAfter *time.Time) (ChecklistItem, error) { db, err := getChecklistDB(uuid) if err != nil { return ChecklistItem{}, err } defer db.Close() var notBeforeStr, notAfterStr *string if notBefore != nil { s := notBefore.Format(time.RFC3339) notBeforeStr = &s } if notAfter != nil { s := notAfter.Format(time.RFC3339) notAfterStr = &s } res, err := db.Exec(`INSERT INTO items (content, checked, parent_id, not_before, not_after) VALUES (?, 0, ?, ?, ?)`, content, parentID, notBeforeStr, notAfterStr) if err != nil { return ChecklistItem{}, err } id, _ := res.LastInsertId() return ChecklistItem{ ID: int(id), Content: content, Checked: false, ParentID: parentID, NotBefore: notBefore, NotAfter: notAfter, Checklist: uuid, }, nil } func updateItem(uuid string, id int, content *string, checked *bool, parentID *int, dependencies *[]int, notBefore *time.Time, notAfter *time.Time) (ChecklistItem, error) { log.Printf("updateItem called with uuid: %s, id: %d", uuid, id) log.Printf("Parameters: content=%v, checked=%v, parentID=%v, dependencies=%v", content, checked, parentID, dependencies) db, err := getChecklistDB(uuid) if err != nil { log.Printf("Failed to get database: %v", err) return ChecklistItem{}, err } defer db.Close() log.Printf("Database connection successful for uuid: %s", uuid) // If trying to check an item, validate dependencies and date constraints first if checked != nil && *checked { log.Printf("Validating dependencies for item %d", id) deps, err := loadItemDependencies(db, id) if err != nil { log.Printf("Failed to load dependencies: %v", err) return ChecklistItem{}, err } // Check if all dependencies are completed for _, depID := range deps { var depChecked int err = db.QueryRow(`SELECT checked FROM items WHERE id = ?`, depID).Scan(&depChecked) if err != nil { log.Printf("Failed to check dependency %d: %v", depID, err) return ChecklistItem{}, err } if depChecked == 0 { return ChecklistItem{}, fmt.Errorf("cannot complete item: dependency %d is not completed", depID) } } // Validate date constraints now := time.Now() var notBeforeStr, notAfterStr sql.NullString err = db.QueryRow(`SELECT not_before, not_after FROM items WHERE id = ?`, id).Scan(¬BeforeStr, ¬AfterStr) if err != nil { log.Printf("Failed to get date constraints: %v", err) return ChecklistItem{}, err } if notBeforeStr.Valid { if notBefore, err := time.Parse(time.RFC3339, notBeforeStr.String); err == nil { if now.Before(notBefore) { return ChecklistItem{}, fmt.Errorf("cannot complete item: not before %s", notBefore.Format("2006-01-02 15:04:05")) } } } if notAfterStr.Valid { if notAfter, err := time.Parse(time.RFC3339, notAfterStr.String); err == nil { if now.After(notAfter) { return ChecklistItem{}, fmt.Errorf("cannot complete item: not after %s", notAfter.Format("2006-01-02 15:04:05")) } } } } else { log.Printf("Skipping dependency and date validation - checked is %v", checked) } log.Printf("About to check dependencies parameter") // Update dependencies if provided if dependencies != nil { log.Printf("Updating dependencies for item %d: %v", id, *dependencies) // Delete existing dependencies _, err = db.Exec(`DELETE FROM dependencies WHERE item_id = ?`, id) if err != nil { log.Printf("Failed to delete existing dependencies: %v", err) return ChecklistItem{}, err } // Add new dependencies for _, depID := range *dependencies { log.Printf("Adding dependency %d for item %d", depID, id) _, err = db.Exec(`INSERT INTO dependencies (item_id, dependency_id) VALUES (?, ?)`, id, depID) if err != nil { log.Printf("Failed to add dependency %d: %v", depID, err) return ChecklistItem{}, err } } log.Printf("Dependencies updated successfully") } else { log.Printf("No dependencies to update") } log.Printf("About to build SQL update query") set := []string{} args := []interface{}{} if content != nil { set = append(set, "content = ?") args = append(args, *content) } if checked != nil { set = append(set, "checked = ?") if *checked { args = append(args, 1) } else { args = append(args, 0) } } if parentID != nil { set = append(set, "parent_id = ?") args = append(args, *parentID) } if notBefore != nil { set = append(set, "not_before = ?") args = append(args, notBefore.Format(time.RFC3339)) } if notAfter != nil { set = append(set, "not_after = ?") args = append(args, notAfter.Format(time.RFC3339)) } if len(set) > 0 { q := "UPDATE items SET " + strings.Join(set, ", ") + " WHERE id = ?" args = append(args, id) log.Printf("SQL query: %s with args: %v", q, args) _, err = db.Exec(q, args...) if err != nil { log.Printf("Failed to execute SQL update: %v", err) return ChecklistItem{}, err } log.Printf("SQL update executed successfully") } else { log.Printf("No fields to update in SQL, skipping update query") } // Return updated item log.Printf("Querying updated item %d", id) rows, err := db.Query(`SELECT id, content, checked, parent_id, not_before, not_after FROM items WHERE id = ?`, id) if err != nil { log.Printf("Failed to query updated item: %v", err) return ChecklistItem{}, err } defer rows.Close() if rows.Next() { log.Printf("Found item %d in database", id) var it ChecklistItem var checkedInt int var parentID sql.NullInt64 var notBefore sql.NullString var notAfter sql.NullString err = rows.Scan(&it.ID, &it.Content, &checkedInt, &parentID, ¬Before, ¬After) if err != nil { log.Printf("Failed to scan updated item: %v", err) return ChecklistItem{}, err } it.Checked = checkedInt != 0 if parentID.Valid { v := int(parentID.Int64) it.ParentID = &v } if notBefore.Valid { if t, err := time.Parse(time.RFC3339, notBefore.String); err == nil { it.NotBefore = &t } } if notAfter.Valid { if t, err := time.Parse(time.RFC3339, notAfter.String); err == nil { it.NotAfter = &t } } it.Checklist = uuid // Load dependencies deps, err := loadItemDependencies(db, it.ID) if err != nil { log.Printf("Failed to load dependencies for return: %v", err) return ChecklistItem{}, err } it.Dependencies = deps log.Printf("Successfully updated item %d", id) return it, nil } log.Printf("Item %d not found in database", id) return ChecklistItem{}, fmt.Errorf("not found") } func deleteItem(uuid string, id int) error { db, err := getChecklistDB(uuid) if err != nil { return err } defer db.Close() // Delete dependencies that reference this item _, err = db.Exec(`DELETE FROM dependencies WHERE dependency_id = ?`, id) if err != nil { return err } // Delete the item itself _, err = db.Exec(`DELETE FROM items WHERE id = ?`, id) return err } // ==== SSE Broadcast Logic ==== func broadcast(uuid string, msg interface{}) { js, _ := json.Marshal(msg) log.Printf("Broadcasting to %s: %s", uuid, string(js)) sseClientsMutex.Lock() clientCount := len(sseClients[uuid]) log.Printf("Number of SSE clients for %s: %d", uuid, clientCount) for ch := range sseClients[uuid] { select { case ch <- string(js): log.Printf("Message sent to client") default: log.Printf("Channel full, skipping message") // skip if channel is full (consider logging in prod!) } } sseClientsMutex.Unlock() } // ==== HTTP Handlers ==== func handleGetItems(w http.ResponseWriter, r *http.Request) { uuid := strings.TrimPrefix(r.URL.Path, "/api/checklists/") uuid = uuid[:36] // Ensure checklist exists if err := ensureChecklistExists(uuid); err != nil { http.Error(w, "Failed to ensure checklist exists", 500) return } items, err := loadChecklistItems(uuid) if err != nil { http.Error(w, "Failed to load items", 500) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Items loaded successfully", "items": items, }) } func handleCreateChecklist(w http.ResponseWriter, r *http.Request) { type Req struct { Name string `json:"name"` } var req Req if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" { http.Error(w, "Missing name", 400) return } uuid, err := addChecklist(req.Name) if err != nil { http.Error(w, "Failed to create checklist", 500) return } w.Header().Set("Content-Type", "application/json") resp := map[string]interface{}{ "success": true, "message": "Checklist created successfully", "uuid": uuid, } json.NewEncoder(w).Encode(resp) } func handleAddItem(w http.ResponseWriter, r *http.Request) { uuid := strings.TrimPrefix(r.URL.Path, "/api/checklists/") uuid = uuid[:36] // Ensure checklist exists if err := ensureChecklistExists(uuid); err != nil { http.Error(w, "Failed to ensure checklist exists", 500) return } type Req struct { Content string `json:"content"` ParentID *int `json:"parent_id"` NotBefore *time.Time `json:"not_before"` NotAfter *time.Time `json:"not_after"` } var req Req if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Content) == "" { http.Error(w, "Missing content", 400) return } item, err := addItem(uuid, req.Content, req.ParentID, req.NotBefore, req.NotAfter) if err != nil { http.Error(w, "Failed to add item", 500) return } // broadcast broadcast(uuid, map[string]interface{}{"type": "item_added", "item": item}) w.Header().Set("Content-Type", "application/json") w.WriteHeader(201) json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Item added successfully", "item": item, }) } func handleUpdateItem(w http.ResponseWriter, r *http.Request) { log.Printf("handleUpdateItem called with path: %s", r.URL.Path) parts := strings.Split(r.URL.Path, "/") if len(parts) < 6 { http.Error(w, "Invalid path", 400) return } uuid := parts[3] id := 0 fmt.Sscanf(parts[5], "%d", &id) // Ensure checklist exists if err := ensureChecklistExists(uuid); err != nil { http.Error(w, "Failed to ensure checklist exists", 500) return } type Req struct { Content *string `json:"content"` Checked *bool `json:"checked"` ParentID *int `json:"parent_id"` Dependencies *[]int `json:"dependencies"` NotBefore *time.Time `json:"not_before"` NotAfter *time.Time `json:"not_after"` } var req Req if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Bad body", 400) return } item, err := updateItem(uuid, id, req.Content, req.Checked, req.ParentID, req.Dependencies, req.NotBefore, req.NotAfter) if err != nil { if strings.Contains(err.Error(), "cannot complete item:") { http.Error(w, err.Error(), 400) } else { http.Error(w, "Not found", 404) } return } broadcast(uuid, map[string]interface{}{"type": "item_updated", "item": item}) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Item updated successfully", "item": item, }) } func handleDeleteItem(w http.ResponseWriter, r *http.Request) { parts := strings.Split(r.URL.Path, "/") uuid := parts[3] id := 0 fmt.Sscanf(parts[5], "%d", &id) // Ensure checklist exists if err := ensureChecklistExists(uuid); err != nil { http.Error(w, "Failed to ensure checklist exists", 500) return } if err := deleteItem(uuid, id); err != nil { http.Error(w, "Delete failed", 500) return } broadcast(uuid, map[string]interface{}{"type": "item_deleted", "id": id}) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Item deleted successfully", "id": id, }) } func handleLockItem(w http.ResponseWriter, r *http.Request) { parts := strings.Split(r.URL.Path, "/") uuid := parts[3] id := 0 fmt.Sscanf(parts[5], "%d", &id) // Ensure checklist exists if err := ensureChecklistExists(uuid); err != nil { http.Error(w, "Failed to ensure checklist exists", 500) return } type Req struct { User string `json:"user"` } var req Req if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.User) == "" { http.Error(w, "Missing user", 400) return } expiry := time.Now().Add(15 * time.Second) // e.g. 30 sec lock itemLocksMutex.Lock() itemLocks[id] = &ItemLock{LockedBy: req.User, Expires: expiry, ChecklistUUID: uuid} itemLocksMutex.Unlock() // Broadcast lock broadcast(uuid, map[string]interface{}{"type": "item_locked", "id": id, "locked_by": req.User, "expires": expiry}) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Item locked successfully", "id": id, "locked_by": req.User, "expires": expiry, }) } func handleUpdateChecklistName(w http.ResponseWriter, r *http.Request) { parts := strings.Split(r.URL.Path, "/") uuid := parts[3] log.Printf("handleUpdateChecklistName called for uuid: %s", uuid) // Ensure checklist exists if err := ensureChecklistExists(uuid); err != nil { log.Printf("Failed to ensure checklist exists: %v", err) http.Error(w, "Failed to ensure checklist exists", 500) return } type Req struct { Name string `json:"name"` } var req Req if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" { log.Printf("Invalid request body: %v", err) http.Error(w, "Missing or empty name", 400) return } log.Printf("Updating checklist name to: %s", req.Name) if err := updateChecklistName(uuid, req.Name); err != nil { log.Printf("Failed to update checklist name: %v", err) http.Error(w, "Failed to update checklist name", 500) return } log.Printf("Checklist name updated successfully, broadcasting...") // Broadcast name update broadcast(uuid, map[string]interface{}{"type": "checklist_name_updated", "name": req.Name}) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Checklist name updated successfully", "name": req.Name, }) } func lockExpiryDaemon() { for { time.Sleep(1 * time.Second) now := time.Now() // Collect expired locks first var expiredLocks []struct { id int uuid string } itemLocksMutex.Lock() for id, lock := range itemLocks { if lock.Expires.Before(now) { expiredLocks = append(expiredLocks, struct { id int uuid string }{id: id, uuid: lock.ChecklistUUID}) delete(itemLocks, id) } } itemLocksMutex.Unlock() // Broadcast unlock events after releasing the mutex for _, expired := range expiredLocks { broadcast(expired.uuid, map[string]interface{}{ "type": "item_unlocked", "id": expired.id, }) } } } func handleSSE(w http.ResponseWriter, r *http.Request) { uuid := strings.TrimPrefix(r.URL.Path, "/api/checklists/") uuid = uuid[:36] // Ensure checklist exists if err := ensureChecklistExists(uuid); err != nil { http.Error(w, "Failed to ensure checklist exists", 500) return } flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "Streaming unsupported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") ch := make(chan string, 20) // Register client sseClientsMutex.Lock() if sseClients[uuid] == nil { sseClients[uuid] = make(map[chan string]bool) } sseClients[uuid][ch] = true sseClientsMutex.Unlock() defer func() { sseClientsMutex.Lock() delete(sseClients[uuid], ch) sseClientsMutex.Unlock() close(ch) }() // Send full state on connect items, err := loadChecklistItems(uuid) name, err2 := loadChecklistName(uuid) if err == nil && err2 == nil { msg, _ := json.Marshal(map[string]interface{}{ "type": "full_state", "items": items, }) fmt.Fprintf(w, "data: %s\n\n", msg) flusher.Flush() msg, _ = json.Marshal(map[string]interface{}{ "type": "checklist_name", "name": name, }) fmt.Fprintf(w, "data: %s\n\n", msg) flusher.Flush() } // Forward events for { select { case ev := <-ch: fmt.Fprintf(w, "data: %s\n\n", ev) flusher.Flush() case <-r.Context().Done(): return } } } // ==== Main + Routing ==== func main() { // Ensure data directory exists if err := os.MkdirAll("data", 0755); err != nil { log.Fatalf("Failed to create data directory: %v", err) } go lockExpiryDaemon() // Register API handlers first http.HandleFunc("/api/checklists", func(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" { handleCreateChecklist(w, r) } else { http.NotFound(w, r) } }) http.HandleFunc("/api/checklists/", func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path log.Printf("API request: %s %s", r.Method, path) switch { case strings.HasSuffix(path, "/name") && r.Method == "PATCH": log.Printf("Handling PATCH checklist name") handleUpdateChecklistName(w, r) case strings.HasSuffix(path, "/items") && r.Method == "GET": log.Printf("Handling GET items") handleGetItems(w, r) case strings.HasSuffix(path, "/items") && r.Method == "POST": log.Printf("Handling POST items") handleAddItem(w, r) case strings.Contains(path, "/items/") && strings.HasSuffix(path, "/lock") && r.Method == "POST": log.Printf("Handling lock item") handleLockItem(w, r) case strings.Contains(path, "/items/") && r.Method == "PATCH": log.Printf("Handling PATCH item") handleUpdateItem(w, r) case strings.Contains(path, "/items/") && r.Method == "DELETE": log.Printf("Handling DELETE item") handleDeleteItem(w, r) case strings.HasSuffix(path, "/sse") && r.Method == "GET": log.Printf("Handling SSE") handleSSE(w, r) default: log.Printf("No handler found for %s %s", r.Method, path) http.NotFound(w, r) } }) // Serve static files from embedded filesystem (register last) http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { // Check if this is a UUID path (starts with / followed by 36 characters) isUUIDPath := len(r.URL.Path) == 37 && r.URL.Path[0] == '/' && !strings.Contains(r.URL.Path[1:], "/") if r.URL.Path == "/" || isUUIDPath { // Serve index.html at root or for routes with /{uuid} content, err := staticFiles.ReadFile("frontend/dist/index.html") if err != nil { http.Error(w, "Not found", 404) return } w.Header().Set("Content-Type", "text/html") w.Write(content) } else { // Serve other static files from the static/ subdirectory subFS, err := fs.Sub(staticFiles, "frontend/dist") if err != nil { http.Error(w, "Not found", 404) return } http.StripPrefix("/", http.FileServer(http.FS(subFS))).ServeHTTP(w, r) } }) port := strings.TrimSpace(os.Getenv("PORT")) if port == "" { port = "8080" } parsedPort, err := strconv.Atoi(port) if err != nil { log.Fatalf("Invalid PORT environment variable: %v", err) } log.Printf("Listening on :%d", parsedPort) log.Printf("Frontend available at: http://localhost:%d", parsedPort) log.Fatal(http.ListenAndServe(":"+port, nil)) }