//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,
})
}