diff --git a/main.go b/main.go index 21ba402..78d7d60 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "io" "io/fs" "log" + "log/slog" "mime/multipart" "net/http" "os" @@ -61,25 +62,14 @@ socket.addEventListener('message', (event) => { }); ` -type Category struct { - ID int64 `json:"id"` - Name string `json:"name"` - Icon string `json:"icon"` - Links []Link `json:"links"` -} - -type Link struct { - ID int64 `json:"id"` - CategoryID int64 `json:"category_id"` - Name string `json:"name"` - Description string `json:"description"` - Icon string `json:"icon"` - URL string `json:"url"` -} +var ( + insertCategoryStmt *sql.Stmt + insertLinkStmt *sql.Stmt +) type App struct { - db *sql.DB *WeatherCache + *CategoryManager } func NewApp(dbPath string) (*App, error) { @@ -94,65 +84,21 @@ func NewApp(dbPath string) (*App, error) { } _, err = db.Exec(string(schema)) + if err != nil { + return nil, err + } + + categoryManager, err := NewCategoryManager(db) + if err != nil { + return nil, err + } return &App{ - db: db, - WeatherCache: NewWeatherCache(), + WeatherCache: NewWeatherCache(), + CategoryManager: categoryManager, }, nil } -func (app *App) GetCategories() ([]Category, error) { - rows, err := app.db.Query(` - SELECT id, name, icon - FROM categories - ORDER BY id ASC - `) - if err != nil { - return nil, err - } - defer rows.Close() - - var categories []Category - for rows.Next() { - var cat Category - if err := rows.Scan(&cat.ID, &cat.Name, &cat.Icon); err != nil { - return nil, err - } - - links, err := app.GetLinksByCategory(cat.ID) - if err != nil { - return nil, err - } - cat.Links = links - categories = append(categories, cat) - } - return categories, nil -} - -func (app *App) GetLinksByCategory(categoryID int64) ([]Link, error) { - rows, err := app.db.Query(` - SELECT id, category_id, name, description, icon, url - FROM links - WHERE category_id = ? - ORDER BY id ASC - `, categoryID) - if err != nil { - return nil, err - } - defer rows.Close() - - var links []Link - for rows.Next() { - var link Link - if err := rows.Scan(&link.ID, &link.CategoryID, &link.Name, &link.Description, - &link.Icon, &link.URL); err != nil { - return nil, err - } - links = append(links, link) - } - return links, nil -} - type OpenWeatherResponse struct { Weather []struct { Name string `json:"main"` @@ -277,14 +223,6 @@ func (c *WeatherCache) updateWeather() { c.mutex.Unlock() } -type CreateLinkRequest struct { - Name string `form:"name"` - Description string `form:"description"` - URL string `form:"url"` - Icon *multipart.FileHeader `form:"icon"` - CategoryID int64 `form:"category_id"` -} - func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fiber.Ctx) (string, error) { srcFile, err := file.Open() if err != nil { @@ -342,151 +280,169 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe return iconPath, nil } -func CreateLink(db *sql.DB) fiber.Handler { - return func(c fiber.Ctx) error { - var req CreateLinkRequest - if err := c.Bind().MultipartForm(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "message": "Failed to parse request", - }) - } - - if req.Name == "" || req.URL == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "message": "Name and URL are required", - }) - } - - file, err := c.FormFile("icon") - if err != nil || file == nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "message": "Icon is required", - }) - } - - if file.Size > 5*1024*1024 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "message": "File size too large. Maximum size is 5MB", - }) - } - - contentType := file.Header.Get("Content-Type") - if !strings.HasPrefix(contentType, "image/") { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "message": "Only image files are allowed", - }) - } - - filename := fmt.Sprintf("%d_%s.webp", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_")) - - if contentType == "image/svg+xml" { - filename = fmt.Sprintf("%d_%s.svg", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_")) - } - - iconPath, err := UploadFile(file, filename, contentType, c) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "message": "Failed to upload file, please try again!", - }) - } - - link := Link{ - Name: req.Name, - Description: req.Description, - URL: req.URL, - Icon: iconPath, - CategoryID: req.CategoryID, - } - - _, err = db.Exec(` - INSERT INTO links (category_id, name, description, icon, url) - VALUES (?, ?, ?, ?, ?)`, - link.CategoryID, link.Name, link.Description, link.Icon, link.URL) - - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "message": "Failed to create link", - }) - } - - return c.Status(fiber.StatusCreated).JSON(fiber.Map{ - "message": "Link created successfully", - "link": link, - }) - } +type Category struct { + ID int64 `json:"id"` + Name string `json:"name"` + Icon string `json:"icon"` + Links []Link `json:"links"` } -type CreateCategoryRequest struct { - Name string `form:"name"` - Icon *multipart.FileHeader `form:"icon"` +type Link struct { + ID int64 `json:"id"` + CategoryID int64 `json:"category_id"` + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon"` + URL string `json:"url"` } -func CreateCategory(db *sql.DB) fiber.Handler { - return func(c fiber.Ctx) error { - var req CreateCategoryRequest - if err := c.Bind().MultipartForm(&req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "message": "Failed to parse request", - }) - } +type CategoryManager struct { + db *sql.DB + Categories []Category +} - if req.Name == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "message": "Name is required", - }) - } - - file, err := c.FormFile("icon") - if err != nil || file == nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "message": "Icon is required", - }) - } - - if file.Size > 5*1024*1024 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "message": "File size too large. Maximum size is 5MB", - }) - } - - contentType := file.Header.Get("Content-Type") - if contentType != "image/svg+xml" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "message": "Only SVGs are supported for category icons!", - }) - } - - filename := fmt.Sprintf("%d_%s.svg", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_")) - - iconPath, err := UploadFile(file, filename, contentType, c) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "message": "Failed to upload file, please try again!", - }) - } - - category := Category{ - Name: req.Name, - Icon: iconPath, - Links: []Link{}, - } - - _, err = db.Exec(` - INSERT INTO categories (name, icon) - VALUES (?, ?)`, - category.Name, category.Icon) - - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "message": "Failed to create category", - }) - } - - return c.Status(fiber.StatusCreated).JSON(fiber.Map{ - "message": "Category created successfully", - "category": category, - }) +func NewCategoryManager(db *sql.DB) (*CategoryManager, error) { + rows, err := db.Query(` + SELECT id, name, icon + FROM categories + ORDER BY id ASC + `) + if err != nil { + return nil, err } + defer rows.Close() + + var categories []Category + for rows.Next() { + var cat Category + if err := rows.Scan(&cat.ID, &cat.Name, &cat.Icon); err != nil { + return nil, err + } + + rows, err := db.Query(` + SELECT id, category_id, name, description, icon, url + FROM links + WHERE category_id = ? + ORDER BY id ASC + `, cat.ID) + if err != nil { + return nil, err + } + defer rows.Close() + + var links []Link + for rows.Next() { + var link Link + if err := rows.Scan(&link.ID, &link.CategoryID, &link.Name, &link.Description, + &link.Icon, &link.URL); err != nil { + return nil, err + } + links = append(links, link) + } + + cat.Links = links + categories = append(categories, cat) + } + + return &CategoryManager{ + db: db, + Categories: categories, + }, nil +} + +// Get Category by ID, returns nil if not found +func (manager *CategoryManager) GetCategory(id int64) *Category { + var category *Category + + // probably potentially bad + for _, cat := range manager.Categories { + if cat.ID == id { + category = &cat + break + } + } + + return category +} + +func (manager *CategoryManager) CreateCategory(category Category) (*Category, error) { + var err error + + insertCategoryStmt, err = manager.db.Prepare(` + INSERT INTO categories (name, icon) + VALUES (?, ?) RETURNING id`) + + if err != nil { + return nil, err + } + + defer insertCategoryStmt.Close() + + var categoryID int64 + + if err := insertCategoryStmt.QueryRow(category.Name, category.Icon).Scan(&categoryID); err != nil { + return nil, err + } + + category.ID = categoryID + manager.Categories = append(manager.Categories, category) + + return &category, nil +} + +func (manager *CategoryManager) CreateLink(db *sql.DB, link Link) (*Link, error) { + var err error + insertLinkStmt, err = db.Prepare(` + INSERT INTO links (category_id, name, description, icon, url) + VALUES (?, ?, ?, ?, ?) RETURNING id`) + if err != nil { + return nil, err + } + + defer insertLinkStmt.Close() + + var linkID int64 + if err := insertLinkStmt.QueryRow(link.CategoryID, link.Name, link.Description, link.Icon, link.URL).Scan(&linkID); err != nil { + return nil, err + } + + link.ID = linkID + + var cat *Category + for i, c := range manager.Categories { + if c.ID == link.CategoryID { + cat = &manager.Categories[i] + break + } + } + + if cat == nil { + return nil, fmt.Errorf("category not found") + } + + cat.Links = append(cat.Links, link) + + return &link, nil +} + +func (manager *CategoryManager) DeleteLink(id any) error { + var icon string + if err := manager.db.QueryRow("SELECT icon FROM links WHERE id = ?", id).Scan(&icon); err != nil { + return err + } + + _, err := manager.db.Exec("DELETE FROM links WHERE id = ?", id) + if err != nil { + return err + } + + if icon != "" { + if err := os.Remove(filepath.Join("public/", icon)); err != nil { + return err + } + } + + return nil } var WeatherIcons = map[string]string{ @@ -594,11 +550,6 @@ func main() { })) router.Get("/", func(c fiber.Ctx) error { - categories, err := app.GetCategories() - if err != nil { - return err - } - weather := app.WeatherCache.GetWeather() return c.Render("views/index", fiber.Map{ @@ -608,7 +559,7 @@ func main() { "Desc": weather.WeatherText, "Icon": getWeatherIcon(weather.Icon), }, - "Categories": categories, + "Categories": app.CategoryManager.Categories, }, "layouts/main") }) @@ -665,13 +616,8 @@ func main() { return c.Redirect().To("/admin/login") } - categories, err := app.GetCategories() - if err != nil { - return err - } - return c.Render("views/admin/index", fiber.Map{ - "Categories": categories, + "Categories": app.CategoryManager.Categories, }, "layouts/main") }) @@ -684,27 +630,140 @@ func main() { return c.Next() }) - api.Post("/categories", CreateCategory(app.db)) - api.Post("/links", CreateLink(app.db)) - - api.Delete("/links/:id", func(c fiber.Ctx) error { - id := c.Params("id") - - var icon string - if err := app.db.QueryRow("SELECT icon FROM links WHERE id = ?", id).Scan(&icon); err != nil { - return err + api.Post("/categories", func(c fiber.Ctx) error { + var req struct { + Name string `form:"name"` + } + if err := c.Bind().MultipartForm(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Failed to parse request", + }) } - _, err := app.db.Exec("DELETE FROM links WHERE id = ?", id) + if req.Name == "" { + return fmt.Errorf("name and icon are required") + } + + file, err := c.FormFile("icon") + if err != nil || file == nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Icon is required", + }) + } + + if file.Size > 5*1024*1024 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "File size too large. Maximum size is 5MB", + }) + } + + contentType := file.Header.Get("Content-Type") + if contentType != "image/svg+xml" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Only SVGs are supported for category icons!", + }) + } + + filename := fmt.Sprintf("%d_%s.svg", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_")) + + iconPath, err := UploadFile(file, filename, contentType, c) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "message": "Failed to upload file, please try again!", + }) + } + + UploadFile(file, iconPath, contentType, c) + + category, err := app.CategoryManager.CreateCategory(Category{ + Name: req.Name, + Icon: iconPath, + Links: []Link{}, + }) + if err != nil { return err } - if icon != "" { - if err := os.Remove(filepath.Join("public/", icon)); err != nil { - return err - } + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "message": "Category created successfully", + "category": category, + }) + }) + + api.Post("/links", func(c fiber.Ctx) error { + var req struct { + Name string `form:"name"` + Description string `form:"description"` + URL string `form:"url"` + CategoryID int64 `form:"category_id"` } + if err := c.Bind().MultipartForm(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Failed to parse request", + }) + } + + if req.Name == "" || req.URL == "" { + return fmt.Errorf("name and url are required") + } + + file, err := c.FormFile("icon") + if err != nil || file == nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Icon is required", + }) + } + + if file.Size > 5*1024*1024 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "File size too large. Maximum size is 5MB", + }) + } + + contentType := file.Header.Get("Content-Type") + if !strings.HasPrefix(contentType, "image/") { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "message": "Only image files are allowed", + }) + } + + filename := fmt.Sprintf("%d_%s.webp", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_")) + + iconPath, err := UploadFile(file, filename, contentType, c) + if err != nil { + slog.Error("Failed to upload file", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "message": "Failed to upload file, please try again!", + }) + } + + UploadFile(file, iconPath, contentType, c) + + link, err := app.CategoryManager.CreateLink(app.CategoryManager.db, Link{ + CategoryID: req.CategoryID, + Name: req.Name, + Description: req.Description, + Icon: iconPath, + URL: req.URL, + }) + if err != nil { + slog.Error("Failed to create link", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "message": "Failed to create link", + }) + } + + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "message": "Link created successfully", + "link": link, + }) + }) + + api.Delete("/links/:id", func(c fiber.Ctx) error { + id := c.Params("id") + + app.CategoryManager.DeleteLink(id) return c.SendStatus(fiber.StatusOK) })