//go:generate tailwindcss -i styles/main.css -o assets/tailwind.css --minify package main import ( "bytes" "database/sql" "embed" "encoding/json" "errors" "fmt" "image" "image/jpeg" "image/png" "io" "io/fs" "log" "mime/multipart" "net/http" "os" "path/filepath" "strconv" "strings" "sync" "time" "github.com/chai2010/webp" "github.com/gofiber/fiber/v3" "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/middleware" _ "github.com/mattn/go-sqlite3" "github.com/nfnt/resize" ) //go:embed assets/** templates/** schema.sql var embeddedAssets embed.FS var devContent string = `` 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 App struct { db *sql.DB *WeatherCache } func NewApp(dbPath string) (*App, error) { db, err := sql.Open("sqlite3", dbPath) if err != nil { return nil, err } schema, err := embeddedAssets.ReadFile("schema.sql") if err != nil { return nil, err } _, err = db.Exec(string(schema)) return &App{ db: db, WeatherCache: NewWeatherCache(), }, 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"` IconId string `json:"icon"` } `json:"weather"` Main struct { Temp float64 `json:"temp"` } `json:"main"` Code int `json:"cod"` Message string `json:"message"` } type WeatherData struct { Temperature float64 WeatherText string Icon string } type WeatherCache struct { data *WeatherData lastUpdate time.Time mutex sync.RWMutex updateChan chan struct{} tempUnits string updateInterval int apiKey string lat string lon string } func NewWeatherCache() *WeatherCache { if os.Getenv("OPENWEATHER_API_KEY") == "" || os.Getenv("OPENWEATHER_LAT") == "" || os.Getenv("OPENWEATHER_LON") == "" { log.Fatalln("OpenWeather API Key, and your latitude and longitude are required!") return nil } updateInterval, err := strconv.Atoi(os.Getenv("OPENWEATHER_UPDATE_INTERVAL")) if err != nil || updateInterval < 1 { updateInterval = 15 } units := os.Getenv("OPENWEATHER_TEMP_UNITS") if units == "" { units = "metric" } cache := &WeatherCache{ data: &WeatherData{}, updateChan: make(chan struct{}), tempUnits: units, updateInterval: updateInterval, apiKey: os.Getenv("OPENWEATHER_API_KEY"), lat: os.Getenv("OPENWEATHER_LAT"), lon: os.Getenv("OPENWEATHER_LON"), } go cache.weatherWorker() cache.updateChan <- struct{}{} return cache } func (c *WeatherCache) GetWeather() WeatherData { c.mutex.RLock() defer c.mutex.RUnlock() return *c.data } func (c *WeatherCache) weatherWorker() { ticker := time.NewTicker(time.Duration(c.updateInterval) * time.Minute) defer ticker.Stop() for { select { case <-c.updateChan: c.updateWeather() case <-ticker.C: c.updateWeather() } } } func (c *WeatherCache) updateWeather() { url := fmt.Sprintf("https://api.openweathermap.org/data/2.5/weather?lat=%s&lon=%s&appid=%s&units=%s", c.lat, c.lon, c.apiKey, c.tempUnits) resp, err := http.Get(url) if err != nil { fmt.Printf("Error fetching weather: %v\n", err) return } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { fmt.Printf("Error reading response: %v\n", err) return } var weatherResp OpenWeatherResponse if err := json.Unmarshal(body, &weatherResp); err != nil { fmt.Printf("Error parsing weather data: %v\n", err) return } // if the request failed if weatherResp.Code != 200 { // if there is no pre-existing data in the cache if c.data.WeatherText == "" { log.Fatalf("Fetching the weather data failed!\n%s\n", weatherResp.Message) } else { return } } c.mutex.Lock() c.data.Temperature = weatherResp.Main.Temp c.data.WeatherText = weatherResp.Weather[0].Name c.data.Icon = weatherResp.Weather[0].IconId c.lastUpdate = time.Now() 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 { 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 = webp.Decode(srcFile) case "image/svg+xml": default: return "", errors.New("unsupported file type") } if err != nil { return "", err } assetsDir := "public/uploads" iconPath := filepath.Join(assetsDir, fileName) if contentType == "image/svg+xml" { if err = c.SaveFile(file, iconPath); err != nil { return "", err } } else { outFile, err := os.Create(iconPath) if err != nil { return "", err } defer outFile.Close() resizedImg := resize.Resize(64, 0, img, resize.MitchellNetravali) var buf bytes.Buffer options := &webp.Options{Lossless: true, Quality: 80} if err := webp.Encode(&buf, resizedImg, options); err != nil { return "", err } if _, err := io.Copy(outFile, &buf); err != nil { return "", err } } iconPath = "/uploads/" + fileName 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 CreateCategoryRequest struct { Name string `form:"name"` Icon *multipart.FileHeader `form:"icon"` } 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", }) } 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, }) } } 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() { if err := godotenv.Load(); err != nil { fmt.Println("No .env file found") } } func main() { if err := os.MkdirAll("public/uploads", 0755); err != nil { log.Fatal(err) } app, err := NewApp("passport.db") if err != nil { log.Fatal(err) } templatesDir, err := fs.Sub(embeddedAssets, "templates") if err != nil { log.Fatal(err) } assetsDir, err := fs.Sub(embeddedAssets, "assets") if err != nil { log.Fatal(err) } css, err := fs.ReadFile(embeddedAssets, "assets/tailwind.css") if err != nil { log.Fatal(err) } engine := handlebars.NewFileSystem(http.FS(templatesDir), ".hbs") engine.AddFunc("inlineCSS", func() string { return string(css) }) engine.AddFunc("devContent", func() string { return devContent }) router := fiber.New(fiber.Config{ Views: engine, }) router.Use(helmet.New(helmet.ConfigDefault)) router.Use("/", static.New("./public", static.Config{ Browse: false, MaxAge: 31536000, })) router.Use("/assets", static.New("", static.Config{ FS: assetsDir, MaxAge: 31536000, })) 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{ "SearchProvider": os.Getenv("PASSPORT_SEARCH_PROVIDER"), "WeatherData": fiber.Map{ "Temp": weather.Temperature, "Desc": weather.WeatherText, "Icon": getWeatherIcon(weather.Icon), }, "Categories": categories, }, "layouts/main") }) 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{}, "layouts/main") }) 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 } if loginData.Username != os.Getenv("PASSPORT_ADMIN_USERNAME") || loginData.Password != os.Getenv("PASSPORT_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") } categories, err := app.GetCategories() if err != nil { return err } return c.Render("views/admin/index", fiber.Map{ "Categories": categories, }, "layouts/main") }) api := router.Group("/api") { 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("/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 } _, err := app.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 c.SendStatus(fiber.StatusOK) }) api.Delete("/categories/:id", func(c fiber.Ctx) error { id := c.Params("id") rows, err := app.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 := app.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 == "" { continue } if err := os.Remove(filepath.Join("public/", icon)); err != nil { return err } } return c.SendStatus(fiber.StatusOK) }) } router.Listen(":3000") }