//go:generate npm run build package main import ( "bytes" "database/sql" "embed" "errors" "fmt" "image" "image/jpeg" "image/png" "io" "io/fs" "log" "log/slog" "mime/multipart" "net/http" "os" "os/signal" "path/filepath" "strconv" "strings" "syscall" "time" "github.com/HugoSmits86/nativewebp" "github.com/NarmadaWeb/gonify/v3" "github.com/caarlos0/env/v11" "github.com/disintegration/imaging" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/compress" "github.com/gofiber/fiber/v3/middleware/helmet" "github.com/gofiber/fiber/v3/middleware/static" "github.com/gofiber/template/handlebars/v2" "github.com/google/uuid" "github.com/joho/godotenv" "github.com/juls0730/passport/src/middleware" "github.com/juls0730/passport/src/services" "github.com/rwcarlsen/goexif/exif" "github.com/rwcarlsen/goexif/tiff" "golang.org/x/image/draw" _ "modernc.org/sqlite" ) //go:embed assets/** templates/** schema.sql scripts/**.js var embeddedAssets embed.FS var devContent = `` var ( insertCategoryStmt *sql.Stmt insertLinkStmt *sql.Stmt ) type Config struct { DevMode bool `env:"PASSPORT_DEV_MODE" envDefault:"false"` Prefork bool `env:"PASSPORT_ENABLE_PREFORK" envDefault:"false"` WeatherAPIKey string `env:"PASSPORT_WEATHER_API_KEY"` Weather *services.WeatherConfig UptimeAPIKey string `env:"PASSPORT_UPTIME_API_KEY"` Uptime *services.UptimeConfig Admin struct { Username string `env:"PASSPORT_ADMIN_USERNAME"` Password string `env:"PASSPORT_ADMIN_PASSWORD"` } SearchProvider struct { URL string `env:"PASSPORT_SEARCH_PROVIDER"` Query string `env:"PASSPORT_SEARCH_PROVIDER_QUERY_PARAM" envDefault:"q"` } Depricated struct { WeatherEnabled bool `env:"PASSPORT_ENABLE_WEATHER" envDefault:"false"` UptimeEnabled bool `env:"PASSPORT_ENABLE_UPTIME" envDefault:"false"` } } func ParseConfig() (*Config, error) { config := Config{} err := env.Parse(&config) if err != nil { return nil, err } if config.WeatherAPIKey != "" { config.Weather = &services.WeatherConfig{ APIKey: config.WeatherAPIKey, } if err := env.Parse(config.Weather); err != nil { return nil, err } } else if config.Depricated.WeatherEnabled { slog.Warn("Your configuration file contains depricated Weather settings. Please update your configuration file!") depricatedWeatherConfig := &services.DepricatedWeatherConfig{} if err := env.Parse(depricatedWeatherConfig); err != nil { return nil, err } config.Weather = &services.WeatherConfig{} config.Weather.Provider = depricatedWeatherConfig.OpenWeather.Provider config.Weather.APIKey = depricatedWeatherConfig.OpenWeather.APIKey config.Weather.Units = depricatedWeatherConfig.OpenWeather.Units config.Weather.Lat = depricatedWeatherConfig.OpenWeather.Lat config.Weather.Lon = depricatedWeatherConfig.OpenWeather.Lon config.Weather.UpdateInterval = depricatedWeatherConfig.UpdateInterval } if config.UptimeAPIKey != "" { config.Uptime = &services.UptimeConfig{ APIKey: config.UptimeAPIKey, } if err := env.Parse(config.Uptime); err != nil { return nil, err } } else if config.Depricated.UptimeEnabled { slog.Warn("Your configuration file contains depricated Uptime settings. Please update your configuration file!") depricatedUptimeConfig := &services.DepricatedUptimeConfig{} if err := env.Parse(depricatedUptimeConfig); err != nil { return nil, err } config.Uptime = &services.UptimeConfig{} config.Uptime.APIKey = depricatedUptimeConfig.APIKey config.Uptime.UpdateInterval = depricatedUptimeConfig.UpdateInterval } return &config, nil } type App struct { *Config *CategoryManager *services.WeatherManager *services.UptimeManager db *sql.DB } func (app *App) Close() error { return app.db.Close() } func NewApp(dbPath string, options map[string]any) (*App, error) { config, err := ParseConfig() if err != nil { return nil, err } file, err := os.OpenFile(dbPath, os.O_RDWR|os.O_CREATE, 0644) if err != nil { if os.IsPermission(err) { return nil, fmt.Errorf("file %s is not readable and writable: %v", dbPath, err) } return nil, fmt.Errorf("failed to open file %s for read/write: %v", dbPath, err) } defer file.Close() var connectionOpts string for k, v := range options { if connectionOpts != "" { connectionOpts += "&" } connectionOpts += fmt.Sprintf("%s=%v", k, v) } db, err := sql.Open("sqlite", fmt.Sprintf("%s?%s", dbPath, connectionOpts)) if err != nil { return nil, err } err = db.Ping() if err != nil { return nil, err } schema, err := embeddedAssets.ReadFile("schema.sql") if err != nil { return nil, err } _, err = db.Exec(string(schema)) if err != nil { return nil, err } categoryManager, err := NewCategoryManager(db) if err != nil { return nil, err } var weatherCache *services.WeatherManager if config.WeatherAPIKey != "" { weatherCache = services.NewWeatherManager(config.Weather) } var uptimeManager *services.UptimeManager if config.UptimeAPIKey != "" { uptimeManager = services.NewUptimeManager(config.Uptime) } return &App{ Config: config, WeatherManager: weatherCache, CategoryManager: categoryManager, UptimeManager: uptimeManager, db: db, }, nil } func CropToCenter(img image.Image, outputSize int) (image.Image, error) { if img == nil { return nil, fmt.Errorf("input image is nil") } if outputSize <= 0 { return nil, fmt.Errorf("output size must be positive") } srcBounds := img.Bounds() srcWidth := srcBounds.Dx() srcHeight := srcBounds.Dy() squareSide := min(srcWidth, srcHeight) cropX := (srcWidth - squareSide) / 2 cropY := (srcHeight - squareSide) / 2 srcCropRect := image.Rect(cropX, cropY, cropX+squareSide, cropY+squareSide) croppedSquareImg := image.NewRGBA(image.Rect(0, 0, squareSide, squareSide)) draw.Draw(croppedSquareImg, croppedSquareImg.Rect, img, srcCropRect.Min, draw.Src) if squareSide == outputSize { return croppedSquareImg, nil } outputImg := image.NewRGBA(image.Rect(0, 0, outputSize, outputSize)) draw.CatmullRom.Scale(outputImg, outputImg.Rect, croppedSquareImg, croppedSquareImg.Bounds(), draw.Src, nil) return outputImg, nil } func UploadFile(file *multipart.FileHeader, contentType string, c fiber.Ctx) (string, error) { fileId, err := uuid.NewV7() if err != nil { return "", err } var fileName string if filepath.Ext(file.Filename) != ".svg" { fileName = fmt.Sprintf("%s.webp", fileId.String()) } else { fileName = fmt.Sprintf("%s.svg", fileId.String()) } srcFile, err := file.Open() if err != nil { return "", err } defer srcFile.Close() var img image.Image switch contentType { case "image/jpeg": img, err = jpeg.Decode(srcFile) case "image/png": img, err = png.Decode(srcFile) case "image/webp": img, err = nativewebp.Decode(srcFile) case "image/svg+xml": // does not fall through (my C brain was tripping over this) default: return "", errors.New("unsupported file type") } if err != nil { return "", err } if contentType != "image/svg+xml" { off, err := srcFile.Seek(0, io.SeekStart) if err != nil { return "", fmt.Errorf("failed to seek to start of file: %v", err) } if off != 0 { return "", fmt.Errorf("failed to seek to start of file: %v", err) } x, err := exif.Decode(srcFile) // if there *is* exif, parse it if err == nil { tag, err := x.Get(exif.Orientation) if err == nil { if tag.Count == 1 && tag.Format() == tiff.IntVal { orientation, err := tag.Int(0) if err != nil { return "", fmt.Errorf("failed to get orientation: %v", err) } slog.Debug("Orientation tag found", "orientation", orientation) switch orientation { case 3: img = imaging.Rotate180(img) case 6: img = imaging.Rotate270(img) case 8: img = imaging.Rotate90(img) } } } } img, err = CropToCenter(img, 96) if err != nil { return "", err } } assetsDir := "public/uploads" iconPath := filepath.Join(assetsDir, fileName) if contentType == "image/svg+xml" { // replace currentColor with a text color outFile, err := os.Create(iconPath) if err != nil { return "", err } defer outFile.Close() svgText, err := io.ReadAll(srcFile) if err != nil { return "", err } svgText = bytes.ReplaceAll(svgText, []byte("currentColor"), []byte(`oklch(87% 0.015 286)`)) _, err = outFile.Write(svgText) if err != nil { return "", err } } else { outFile, err := os.Create(iconPath) if err != nil { return "", err } defer outFile.Close() var buf bytes.Buffer options := &nativewebp.Options{} if err := nativewebp.Encode(&buf, img, options); err != nil { return "", err } if _, err := io.Copy(outFile, &buf); err != nil { return "", err } } iconPath = "/uploads/" + fileName return iconPath, nil } 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"` } type CategoryManager struct { db *sql.DB } func NewCategoryManager(db *sql.DB) (*CategoryManager, error) { return &CategoryManager{ db: db, }, nil } func (manager *CategoryManager) GetCategories() []Category { rows, err := manager.db.Query(` SELECT id, name, icon FROM categories ORDER BY id ASC `) if err != nil { return nil } 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 } categories = append(categories, cat) } for i, cat := range categories { categories[i].Links = manager.GetLinks(cat.ID) } return categories } // Get Category by ID, returns nil if not found func (manager *CategoryManager) GetCategory(id int64) *Category { row := manager.db.QueryRow(`SELECT id, name, icon FROM categories WHERE id = ?`, id) var cat Category if err := row.Scan(&cat.ID, &cat.Name, &cat.Icon); err != nil { return nil } return &cat } 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 return &category, nil } func (manager *CategoryManager) DeleteCategory(id int64) error { rows, err := manager.db.Query(` SELECT icon FROM categories WHERE id = ? UNION SELECT icon FROM links WHERE category_id = ? `, id, id) if err != nil { return err } defer rows.Close() var icons []string for rows.Next() { var icon string if err := rows.Scan(&icon); err != nil { return err } icons = append(icons, icon) } tx, err := manager.db.Begin() if err != nil { return err } defer tx.Rollback() _, err = tx.Exec("DELETE FROM categories WHERE id = ?", id) if err != nil { return err } _, err = tx.Exec("DELETE FROM links WHERE category_id = ?", id) if err != nil { return err } if err := tx.Commit(); err != nil { return err } for _, icon := range icons { if icon == "" { slog.Debug("blank icon") continue } if err := os.Remove(filepath.Join("public/", icon)); err != nil { // dont error to the user if the icon doesnt exist, just log it slog.Error("Failed to delete icon", "icon", icon, "error", err) } } return nil } func (manager *CategoryManager) GetLink(id int64) *Link { row := manager.db.QueryRow(`SELECT id, category_id, name, description, icon, url FROM links WHERE id = ?`, id) var link Link if err := row.Scan(&link.ID, &link.CategoryID, &link.Name, &link.Description, &link.Icon, &link.URL); err != nil { return nil } return &link } func (manager *CategoryManager) GetLinks(categoryID int64) []Link { rows, err := manager.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 } 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 } links = append(links, link) } return links } 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 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 { slog.Error("Failed to delete icon", "icon", icon, "error", err) } } return nil } var WeatherIcons = map[string]string{ "clear-day": ``, "clear-night": ``, "partly-cloudy-day": ``, "partly-cloudy-night": ``, "mostly-cloudy-day": ``, "mostly-cloudy-night": ``, "light-rain": ``, "rain": ``, "thunder": ``, "snow": ``, "mist": ``, } func getWeatherIcon(iconId string) string { switch iconId { case "01d": return WeatherIcons["clear-day"] case "01n": return WeatherIcons["clear-night"] case "02d", "03d": return WeatherIcons["partly-cloudy-day"] case "02n", "03n": return WeatherIcons["partly-cloudy-night"] case "04d": return WeatherIcons["mostly-cloudy-day"] case "04n": return WeatherIcons["mostly-cloudy-night"] case "09d", "09n": return WeatherIcons["light-rain"] case "10d", "10n": return WeatherIcons["rain"] case "11d", "11n": return WeatherIcons["thunder"] case "13d", "13n": return WeatherIcons["snow"] case "50d", "50n": return WeatherIcons["mist"] default: return "" } } func init() { godotenv.Load() } func main() { if err := os.MkdirAll("public/uploads", 0755); err != nil { log.Fatal(err) } dbPath, err := filepath.Abs("passport.db") if err != nil { log.Fatal(err) } app, err := NewApp(dbPath, map[string]any{ "_time_format": "sqlite", "cache": "shared", "mode": "rwc", "_journal_mode": "WAL", }) if err != nil { log.Fatal(err) } defer app.Close() go func() { c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) <-c app.Close() os.Exit(0) }() templatesDir, err := fs.Sub(embeddedAssets, "templates") if err != nil { log.Fatal(err) } assetsDir, err := fs.Sub(embeddedAssets, "assets") if err != nil { log.Fatal(err) } engine := handlebars.NewFileSystem(http.FS(templatesDir), ".hbs") engine.AddFunc("eq", func(a, b any) bool { return a == b }) engine.AddFunc("embedFile", func(fileToEmbed string) string { content, err := fs.ReadFile(embeddedAssets, fileToEmbed) if err != nil { return "" } fileExtension := filepath.Ext(fileToEmbed) switch fileExtension { case ".js": return fmt.Sprintf("", content) case ".css": return fmt.Sprintf("", content) default: return string(content) } }) engine.AddFunc("devContent", func() string { if app.Config.DevMode { return devContent } return "" }) router := fiber.New(fiber.Config{ Views: engine, }) router.Use(helmet.New(helmet.ConfigDefault)) // redirect /favicon.ico to /assets/favicon.ico router.Get("/favicon.ico", func(c fiber.Ctx) error { return c.Redirect().To("/assets/favicon.ico") }) router.Use(compress.New(compress.Config{ Level: compress.LevelBestSpeed, })) router.Use(gonify.New(gonify.Config{ MinifySVG: !app.DevMode, MinifyCSS: !app.DevMode, MinifyJS: !app.DevMode, MinifyHTML: !app.DevMode, })) router.Use("/", static.New("./public", static.Config{ Browse: false, MaxAge: 31536000, })) router.Use("/assets", static.New("", static.Config{ FS: assetsDir, Browse: false, MaxAge: 31536000, })) router.Get("/", func(c fiber.Ctx) error { c.Response().Header.Set("Link", "; rel=preload; as=font; type=font/woff2; crossorigin") renderData := fiber.Map{ "SearchProviderURL": app.Config.SearchProvider.URL, "SearchParam": app.Config.SearchProvider.Query, "Categories": app.CategoryManager.GetCategories(), } if app.Config.WeatherAPIKey != "" { weather := app.WeatherManager.GetWeather() renderData["WeatherData"] = fiber.Map{ "Temp": weather.Temperature, "Desc": weather.WeatherText, "Icon": getWeatherIcon(weather.Icon), } } if app.Config.UptimeAPIKey != "" { renderData["UptimeData"] = app.UptimeManager.GetUptime() } return c.Render("views/index", renderData) }) router.Use(middleware.AdminMiddleware(app.db)) router.Get("/admin/login", func(c fiber.Ctx) error { if c.Locals("IsAdmin") != nil { return c.Redirect().To("/admin") } return c.Render("views/admin/login", fiber.Map{}) }) router.Post("/admin/login", func(c fiber.Ctx) error { if c.Locals("IsAdmin") != nil { return c.Redirect().To("/admin") } var loginData struct { Username string `json:"username"` Password string `json:"password"` } if err := c.Bind().JSON(&loginData); err != nil { return err } // possible vulnerable to timing attacks if loginData.Username != app.Config.Admin.Username || loginData.Password != app.Config.Admin.Password { return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"message": "Invalid username or password"}) } // Create new session sessionID := uuid.NewString() expiresAt := time.Now().Add(time.Hour * 24 * 7) _, err := app.db.Exec(` INSERT INTO sessions (session_id, expires_at) VALUES (?, ?) `, sessionID, expiresAt) if err != nil { return err } // Set cookie c.Cookie(&fiber.Cookie{ Name: "SessionToken", Value: sessionID, Expires: expiresAt, }) return c.Status(http.StatusOK).JSON(fiber.Map{"message": "Logged in successfully"}) }) router.Get("/admin", func(c fiber.Ctx) error { if c.Locals("IsAdmin") == nil { return c.Redirect().To("/admin/login") } return c.Render("views/admin/index", fiber.Map{ "Categories": app.CategoryManager.GetCategories(), "IsAdmin": true, }) }) api := router.Group("/api") { // all API routes require admin auth. No user needs to make api requests since the site is SSR api.Use(func(c fiber.Ctx) error { if c.Locals("IsAdmin") == nil { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"message": "Unauthorized"}) } return c.Next() }) api.Post("/category", func(c fiber.Ctx) error { var req struct { Name string `form:"name"` } if err := c.Bind().Form(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": "Failed to parse request", }) } if req.Name == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": "Name is required", }) } req.Name = strings.TrimSpace(req.Name) if len(req.Name) > 50 { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": "Name is too long. Maximum length is 50 characters", }) } 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!", }) } iconPath, err := UploadFile(file, contentType, c) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "message": "Failed to upload file, please try again!", }) } category, err := app.CategoryManager.CreateCategory(Category{ Name: req.Name, Icon: iconPath, Links: []Link{}, }) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "message": fmt.Sprintf("Failed to create category: %v", err), }) } return c.Status(fiber.StatusCreated).JSON(fiber.Map{ "message": "Category created successfully", "category": category, }) }) api.Post("/category/:id/link", func(c fiber.Ctx) error { var req struct { Name string `form:"name"` Description string `form:"description"` URL string `form:"url"` } if err := c.Bind().Form(&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", }) } req.Name = strings.TrimSpace(req.Name) if req.Description != "" { req.Description = strings.TrimSpace(req.Description) } if len(req.Name) > 50 { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": "Name is too long. Maximum length is 50 characters", }) } if len(req.Description) > 150 { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": "Description is too long. Maximum length is 150 characters", }) } categoryID, err := strconv.ParseInt(c.Params("id"), 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": fmt.Sprintf("Failed to parse category ID: %v", err), }) } if app.CategoryManager.GetCategory(categoryID) == nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": "Category not found", }) } 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", }) } iconPath, err := UploadFile(file, 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!", }) } link, err := app.CategoryManager.CreateLink(app.CategoryManager.db, Link{ CategoryID: 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.Patch("/category/:id", func(c fiber.Ctx) error { var req struct { Name string `form:"name"` } if c.Params("id") == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": "ID is required", }) } id, err := strconv.ParseInt(c.Params("id"), 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": fmt.Sprintf("Failed to parse category ID: %v", err), }) } if err := c.Bind().Form(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": "Failed to parse request", }) } if req.Name != "" { if len(req.Name) > 50 { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": "Name is too long. Maximum length is 50 characters", }) } } category := app.CategoryManager.GetCategory(id) if category == nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": "Category not found", }) } tx, err := app.db.Begin() if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "message": "Failed to start transaction", }) } defer tx.Rollback() file, err := c.FormFile("icon") if err == nil { 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 svg files are allowed", }) } oldIconPath := category.Icon iconPath, err := UploadFile(file, 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!", }) } _, err = tx.Exec("UPDATE categories SET icon = ? WHERE id = ?", iconPath, id) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "message": "Failed to update category", }) } err = os.Remove(filepath.Join("public/", oldIconPath)) if err != nil { slog.Error("Failed to delete icon", "error", err) } } if req.Name != "" { _, err = tx.Exec("UPDATE categories SET name = ? WHERE id = ?", req.Name, category.ID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "message": "Failed to update category", }) } } err = tx.Commit() if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "message": "Failed to commit transaction", }) } return c.Status(fiber.StatusOK).JSON(fiber.Map{ "message": "Category updated successfully", }) }) api.Patch("/category/:categoryID/link/:linkID", func(c fiber.Ctx) error { var req struct { Name string `form:"name"` Description string `form:"description"` Icon string `form:"icon"` } if err := c.Bind().Form(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": "Failed to parse request", }) } if len(req.Name) > 50 { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": "Name is too long. Maximum length is 50 characters", }) } if len(req.Description) > 150 { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": "Description is too long. Maximum length is 150 characters", }) } linkID, err := strconv.ParseInt(c.Params("linkID"), 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": fmt.Sprintf("Failed to parse link ID: %v", err), }) } categoryID, err := strconv.ParseInt(c.Params("categoryID"), 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": fmt.Sprintf("Failed to parse category ID: %v", err), }) } if app.CategoryManager.GetCategory(categoryID) == nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": "Category not found", }) } link := app.CategoryManager.GetLink(linkID) if link == nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": "Link not found", }) } tx, err := app.db.Begin() if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "message": "Failed to start transaction", }) } defer tx.Rollback() file, err := c.FormFile("icon") if err == nil { 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", }) } oldIconPath := link.Icon iconPath, err := UploadFile(file, 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!", }) } _, err = tx.Exec("UPDATE links SET icon = ? WHERE id = ?", iconPath, linkID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "message": "Failed to update link", }) } err = os.Remove(filepath.Join("public/", oldIconPath)) if err != nil { slog.Error("Failed to delete icon", "error", err) } } if req.Name != "" { _, err = tx.Exec("UPDATE links SET name = ? WHERE id = ?", req.Name, linkID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "message": "Failed to update link", }) } } if req.Description != "" { _, err = tx.Exec("UPDATE links SET description = ? WHERE id = ?", req.Description, linkID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "message": "Failed to update link", }) } } err = tx.Commit() if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "message": "Failed to commit transaction", }) } slog.Info("Link updated successfully", "id", linkID, "name", req.Name) return c.Status(fiber.StatusOK).JSON(fiber.Map{ "message": "Link updated successfully", }) }) api.Delete("/category/:categoryID/link/:linkID", func(c fiber.Ctx) error { linkID, err := strconv.ParseInt(c.Params("linkID"), 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": fmt.Sprintf("Failed to parse link ID: %v", err), }) } categoryID, err := strconv.ParseInt(c.Params("categoryID"), 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": fmt.Sprintf("Failed to parse category ID: %v", err), }) } if app.CategoryManager.GetCategory(categoryID) == nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": "Category not found", }) } link := app.CategoryManager.GetLink(linkID) if link == nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": "Link not found", }) } if link.CategoryID != categoryID { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": "Invalid category ID", }) } err = app.CategoryManager.DeleteLink(linkID) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "message": fmt.Sprintf("Failed to delete link: %v", err), }) } return c.SendStatus(fiber.StatusOK) }) api.Delete("/category/:id", func(c fiber.Ctx) error { // id = parseInt(c.Params("id")) id, err := strconv.ParseInt(c.Params("id"), 10, 64) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": fmt.Sprintf("Failed to parse category ID: %v", err), }) } if app.CategoryManager.GetCategory(id) == nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "message": "Category not found", }) } err = app.CategoryManager.DeleteCategory(id) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "message": fmt.Sprintf("Failed to delete category: %v", err), }) } return c.SendStatus(fiber.StatusOK) }) } router.Listen(":3000", fiber.ListenConfig{ EnablePrefork: app.Config.Prefork, }) }