//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" "log/slog" "mime/multipart" "net/http" "os" "os/signal" "path/filepath" "strconv" "strings" "sync" "syscall" "time" "github.com/caarlos0/env/v11" "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 = `` var ( insertCategoryStmt *sql.Stmt insertLinkStmt *sql.Stmt ) type WeatherProvider string const ( OpenWeatherMap WeatherProvider = "openweathermap" ) type WeatherConfig struct { Provider WeatherProvider `env:"OPENWEATHER_PROVIDER" envDefault:"openweathermap"` OpenWeather struct { APIKey string `env:"OPENWEATHER_API_KEY"` Units string `env:"OPENWEATHER_TEMP_UNITS" envDefault:"metric"` Lat float64 `env:"OPENWEATHER_LAT"` Lon float64 `env:"OPENWEATHER_LON"` } UpdateInterval int `env:"OPENWEATHER_UPDATE_INTERVAL" envDefault:"15"` } type UptimeConfig struct { APIKey string `env:"UPTIMEROBOT_API_KEY"` UpdateInterval int `env:"UPTIMEROBOT_UPDATE_INTERVAL" envDefault:"300"` } type Config struct { DevMode bool `env:"PASSPORT_DEV_MODE" envDefault:"false"` Prefork bool `env:"PASSPORT_ENABLE_PREFORK" envDefault:"false"` WeatherEnabled bool `env:"PASSPORT_ENABLE_WEATHER" envDefault:"false"` Weather *WeatherConfig UptimeEnabled bool `env:"PASSPORT_ENABLE_UPTIME" envDefault:"false"` Uptime *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"` } } func ParseConfig() (*Config, error) { config := Config{} err := env.Parse(&config) if err != nil { return nil, err } if config.WeatherEnabled { config.Weather = &WeatherConfig{} if err := env.Parse(config.Weather); err != nil { return nil, err } } if config.UptimeEnabled { config.Uptime = &UptimeConfig{} if err := env.Parse(config.Uptime); err != nil { return nil, err } } return &config, nil } type App struct { *Config *CategoryManager *WeatherCache *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("sqlite3", 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 *WeatherCache if config.WeatherEnabled { weatherCache = NewWeatherCache(config.Weather) } var uptimeManager *UptimeManager if config.UptimeEnabled { uptimeManager = NewUptimeManager(config.Uptime) } return &App{ Config: config, WeatherCache: weatherCache, CategoryManager: categoryManager, UptimeManager: uptimeManager, db: db, }, nil } type UptimeRobotSite struct { FriendlyName string `json:"friendly_name"` Url string `json:"url"` Status int `json:"status"` } type UptimeManager struct { sites []UptimeRobotSite lastUpdate time.Time mutex sync.RWMutex updateChan chan struct{} updateInterval int apiKey string } func NewUptimeManager(config *UptimeConfig) *UptimeManager { if config.APIKey == "" { log.Fatalln("UptimeRobot API Key is required!") return nil } updateInterval := config.UpdateInterval if updateInterval < 1 { updateInterval = 300 } uptimeManager := &UptimeManager{ updateChan: make(chan struct{}), updateInterval: updateInterval, apiKey: config.APIKey, sites: []UptimeRobotSite{}, } go uptimeManager.updateWorker() uptimeManager.updateChan <- struct{}{} return uptimeManager } func (u *UptimeManager) getUptime() []UptimeRobotSite { u.mutex.RLock() defer u.mutex.RUnlock() return u.sites } func (u *UptimeManager) updateWorker() { ticker := time.NewTicker(time.Duration(u.updateInterval) * time.Second) defer ticker.Stop() for { select { case <-u.updateChan: u.update() case <-ticker.C: u.update() } } } type UptimeRobotResponse struct { Monitors []UptimeRobotSite `json:"monitors"` } func (u *UptimeManager) update() { resp, err := http.Post("https://api.uptimerobot.com/v2/getMonitors?api_key="+u.apiKey, "application/json", nil) if err != nil { fmt.Printf("Error fetching uptime data: %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 monitors UptimeRobotResponse if err := json.Unmarshal(body, &monitors); err != nil { fmt.Printf("Error parsing uptime data: %v\n", err) return } u.mutex.Lock() u.sites = monitors.Monitors u.lastUpdate = time.Now() u.mutex.Unlock() } 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 float64 lon float64 } func NewWeatherCache(config *WeatherConfig) *WeatherCache { if config.Provider != OpenWeatherMap { log.Fatalln("Only OpenWeatherMap is supported!") return nil } if config.OpenWeather.APIKey == "" { log.Fatalln("An API Key required for OpenWeather!") return nil } updateInterval := config.UpdateInterval if updateInterval < 1 { updateInterval = 15 } units := config.OpenWeather.Units if units == "" { units = "metric" } cache := &WeatherCache{ data: &WeatherData{}, updateChan: make(chan struct{}), tempUnits: units, updateInterval: updateInterval, apiKey: config.OpenWeather.APIKey, lat: config.OpenWeather.Lat, lon: config.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=%f&lon=%f&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() } 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 } 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 { rows, err := manager.db.Query(` SELECT id, name, icon FROM categories WHERE id = ? `, id) if err != nil { return nil } defer rows.Close() var cat Category if err := rows.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 == "" { continue } if err := os.Remove(filepath.Join("public/", icon)); err != nil { return err } } return nil } 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 { return 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{ "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) } 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 { if app.Config.DevMode { return devContent } return "" }) engine.AddFunc("eq", func(a, b any) bool { return a == b }) 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("/", 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 { renderData := fiber.Map{ "SearchProviderURL": app.Config.SearchProvider.URL, "SearchParam": app.Config.SearchProvider.Query, "Categories": app.CategoryManager.GetCategories(), } if app.Config.WeatherEnabled { weather := app.WeatherCache.GetWeather() renderData["WeatherData"] = fiber.Map{ "Temp": weather.Temperature, "Desc": weather.WeatherText, "Icon": getWeatherIcon(weather.Icon), } } if app.Config.UptimeEnabled { renderData["UptimeData"] = app.UptimeManager.getUptime() } return c.Render("views/index", renderData, "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 } // 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(), }, "layouts/main") }) 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("/categories", 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 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 } 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().Form(&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") err = app.CategoryManager.DeleteLink(id) if err != nil { return err } return c.SendStatus(fiber.StatusOK) }) api.Delete("/categories/:id", func(c fiber.Ctx) error { // id = parseInt(c.Params("id")) id, err := strconv.ParseInt(c.Params("id"), 10, 64) if err != nil { return err } err = app.CategoryManager.DeleteCategory(id) if err != nil { return err } return c.SendStatus(fiber.StatusOK) }) } router.Listen(":3000", fiber.ListenConfig{ EnablePrefork: app.Config.Prefork, }) }