Massively reduce DB calls

This commit is contained in:
Zoe
2025-01-07 11:28:11 +00:00
parent 1dbb689860
commit a779fcb111

505
main.go
View File

@@ -15,6 +15,7 @@ import (
"io" "io"
"io/fs" "io/fs"
"log" "log"
"log/slog"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"os" "os"
@@ -61,25 +62,14 @@ socket.addEventListener('message', (event) => {
}); });
</script>` </script>`
type Category struct { var (
ID int64 `json:"id"` insertCategoryStmt *sql.Stmt
Name string `json:"name"` insertLinkStmt *sql.Stmt
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"`
}
type App struct { type App struct {
db *sql.DB
*WeatherCache *WeatherCache
*CategoryManager
} }
func NewApp(dbPath string) (*App, error) { func NewApp(dbPath string) (*App, error) {
@@ -94,65 +84,21 @@ func NewApp(dbPath string) (*App, error) {
} }
_, err = db.Exec(string(schema)) _, err = db.Exec(string(schema))
if err != nil {
return nil, err
}
categoryManager, err := NewCategoryManager(db)
if err != nil {
return nil, err
}
return &App{ return &App{
db: db,
WeatherCache: NewWeatherCache(), WeatherCache: NewWeatherCache(),
CategoryManager: categoryManager,
}, nil }, 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 { type OpenWeatherResponse struct {
Weather []struct { Weather []struct {
Name string `json:"main"` Name string `json:"main"`
@@ -277,14 +223,6 @@ func (c *WeatherCache) updateWeather() {
c.mutex.Unlock() 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) { func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fiber.Ctx) (string, error) {
srcFile, err := file.Open() srcFile, err := file.Open()
if err != nil { if err != nil {
@@ -342,151 +280,169 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe
return iconPath, nil return iconPath, nil
} }
func CreateLink(db *sql.DB) fiber.Handler { type Category struct {
return func(c fiber.Ctx) error { ID int64 `json:"id"`
var req CreateLinkRequest Name string `json:"name"`
if err := c.Bind().MultipartForm(&req); err != nil { Icon string `json:"icon"`
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ Links []Link `json:"links"`
"message": "Failed to parse request",
})
} }
if req.Name == "" || req.URL == "" { type Link struct {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ ID int64 `json:"id"`
"message": "Name and URL are required", CategoryID int64 `json:"category_id"`
}) Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
URL string `json:"url"`
} }
file, err := c.FormFile("icon") type CategoryManager struct {
if err != nil || file == nil { db *sql.DB
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ Categories []Category
"message": "Icon is required",
})
} }
if file.Size > 5*1024*1024 { func NewCategoryManager(db *sql.DB) (*CategoryManager, error) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ rows, err := db.Query(`
"message": "File size too large. Maximum size is 5MB", SELECT id, name, icon
}) FROM categories
} ORDER BY id ASC
`)
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 { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ return nil, err
"message": "Failed to upload file, please try again!", }
}) 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
} }
link := Link{ rows, err := db.Query(`
Name: req.Name, SELECT id, category_id, name, description, icon, url
Description: req.Description, FROM links
URL: req.URL, WHERE category_id = ?
Icon: iconPath, ORDER BY id ASC
CategoryID: req.CategoryID, `, cat.ID)
}
_, 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 { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ return nil, err
"message": "Failed to create link", }
}) 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 c.Status(fiber.StatusCreated).JSON(fiber.Map{ cat.Links = links
"message": "Link created successfully", categories = append(categories, cat)
"link": link, }
})
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
} }
} }
type CreateCategoryRequest struct { return category
Name string `form:"name"`
Icon *multipart.FileHeader `form:"icon"`
} }
func CreateCategory(db *sql.DB) fiber.Handler { func (manager *CategoryManager) CreateCategory(category Category) (*Category, error) {
return func(c fiber.Ctx) error { var err 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",
})
}
if req.Name == "" { insertCategoryStmt, err = manager.db.Prepare(`
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) INSERT INTO categories (name, icon)
VALUES (?, ?)`, VALUES (?, ?) RETURNING id`)
category.Name, category.Icon)
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ return nil, err
"message": "Failed to create category",
})
} }
return c.Status(fiber.StatusCreated).JSON(fiber.Map{ defer insertCategoryStmt.Close()
"message": "Category created successfully",
"category": category, 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{ var WeatherIcons = map[string]string{
@@ -594,11 +550,6 @@ func main() {
})) }))
router.Get("/", func(c fiber.Ctx) error { router.Get("/", func(c fiber.Ctx) error {
categories, err := app.GetCategories()
if err != nil {
return err
}
weather := app.WeatherCache.GetWeather() weather := app.WeatherCache.GetWeather()
return c.Render("views/index", fiber.Map{ return c.Render("views/index", fiber.Map{
@@ -608,7 +559,7 @@ func main() {
"Desc": weather.WeatherText, "Desc": weather.WeatherText,
"Icon": getWeatherIcon(weather.Icon), "Icon": getWeatherIcon(weather.Icon),
}, },
"Categories": categories, "Categories": app.CategoryManager.Categories,
}, "layouts/main") }, "layouts/main")
}) })
@@ -665,13 +616,8 @@ func main() {
return c.Redirect().To("/admin/login") return c.Redirect().To("/admin/login")
} }
categories, err := app.GetCategories()
if err != nil {
return err
}
return c.Render("views/admin/index", fiber.Map{ return c.Render("views/admin/index", fiber.Map{
"Categories": categories, "Categories": app.CategoryManager.Categories,
}, "layouts/main") }, "layouts/main")
}) })
@@ -684,27 +630,140 @@ func main() {
return c.Next() return c.Next()
}) })
api.Post("/categories", CreateCategory(app.db)) api.Post("/categories", func(c fiber.Ctx) error {
api.Post("/links", CreateLink(app.db)) var req struct {
Name string `form:"name"`
api.Delete("/links/:id", func(c fiber.Ctx) error { }
id := c.Params("id") if err := c.Bind().MultipartForm(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
var icon string "message": "Failed to parse request",
if err := app.db.QueryRow("SELECT icon FROM links WHERE id = ?", id).Scan(&icon); err != nil { })
return err
} }
_, 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 { if err != nil {
return err return err
} }
if icon != "" { return c.Status(fiber.StatusCreated).JSON(fiber.Map{
if err := os.Remove(filepath.Join("public/", icon)); err != nil { "message": "Category created successfully",
return err "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) return c.SendStatus(fiber.StatusOK)
}) })