//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"
"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 = ``
var (
insertCategoryStmt *sql.Stmt
insertLinkStmt *sql.Stmt
)
type App struct {
*WeatherCache
*CategoryManager
*UptimeManager
db *sql.DB
}
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))
if err != nil {
return nil, err
}
categoryManager, err := NewCategoryManager(db)
if err != nil {
return nil, err
}
var weatherCache *WeatherCache
if os.Getenv("PASSPORT_ENABLE_WEATHER") != "false" {
weatherCache = NewWeatherCache()
}
var uptimeManager *UptimeManager
if os.Getenv("PASSPORT_ENABLE_UPTIME") != "false" {
uptimeManager = NewUptimeManager()
}
return &App{
WeatherCache: weatherCache,
CategoryManager: categoryManager,
UptimeManager: uptimeManager,
}, 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() *UptimeManager {
if os.Getenv("UPTIMEROBOT_API_KEY") == "" {
log.Fatalln("UptimeRobot API Key is required!")
return nil
}
updateInterval, err := strconv.Atoi(os.Getenv("UPTIMEROBOT_UPDATE_INTERVAL"))
if err != nil || updateInterval < 1 {
updateInterval = 5
}
uptimeManager := &UptimeManager{
updateChan: make(chan struct{}),
updateInterval: updateInterval,
apiKey: os.Getenv("UPTIMEROBOT_API_KEY"),
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.Minute)
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
}
fmt.Printf("%+v", monitors.Monitors)
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 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()
}
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
Categories []Category
}
func NewCategoryManager(db *sql.DB) (*CategoryManager, error) {
rows, err := 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
}
rows, err := db.Query(`
SELECT id, category_id, name, description, icon, url
FROM links
WHERE category_id = ?
ORDER BY id ASC
`, cat.ID)
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)
}
cat.Links = links
categories = append(categories, cat)
}
return &CategoryManager{
db: db,
Categories: categories,
}, nil
}
// Get Category by ID, returns nil if not found
func (manager *CategoryManager) GetCategory(id int64) *Category {
var category *Category
// probably potentially bad
for _, cat := range manager.Categories {
if cat.ID == id {
category = &cat
break
}
}
return category
}
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
manager.Categories = append(manager.Categories, category)
return &category, nil
}
func (manager *CategoryManager) CreateLink(db *sql.DB, link Link) (*Link, error) {
var err error
insertLinkStmt, err = db.Prepare(`
INSERT INTO links (category_id, name, description, icon, url)
VALUES (?, ?, ?, ?, ?) RETURNING id`)
if err != nil {
return nil, err
}
defer insertLinkStmt.Close()
var linkID int64
if err := insertLinkStmt.QueryRow(link.CategoryID, link.Name, link.Description, link.Icon, link.URL).Scan(&linkID); err != nil {
return nil, err
}
link.ID = linkID
var cat *Category
for i, c := range manager.Categories {
if c.ID == link.CategoryID {
cat = &manager.Categories[i]
break
}
}
if cat == nil {
return nil, fmt.Errorf("category not found")
}
cat.Links = append(cat.Links, link)
return &link, nil
}
func (manager *CategoryManager) DeleteLink(id any) error {
var icon string
if err := manager.db.QueryRow("SELECT icon FROM links WHERE id = ?", id).Scan(&icon); err != nil {
return err
}
_, err := manager.db.Exec("DELETE FROM links WHERE id = ?", id)
if err != nil {
return err
}
if icon != "" {
if err := os.Remove(filepath.Join("public/", icon)); err != nil {
return err
}
}
return nil
}
var WeatherIcons = map[string]string{
"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?cache=shared&mode=rwc&_journal_mode=WAL")
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 {
if os.Getenv("PASSPORT_DEV_MODE") == "true" {
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))
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{
"SearchProvider": os.Getenv("PASSPORT_SEARCH_PROVIDER"),
"Categories": app.CategoryManager.Categories,
}
if os.Getenv("PASSPORT_ENABLE_WEATHER") != "false" {
weather := app.WeatherCache.GetWeather()
renderData["WeatherData"] = fiber.Map{
"Temp": weather.Temperature,
"Desc": weather.WeatherText,
"Icon": getWeatherIcon(weather.Icon),
}
}
if os.Getenv("PASSPORT_ENABLE_UPTIME") != "false" {
fmt.Printf("%+v", app.UptimeManager.getUptime())
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
}
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")
}
return c.Render("views/admin/index", fiber.Map{
"Categories": app.CategoryManager.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", 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")
app.CategoryManager.DeleteLink(id)
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", fiber.ListenConfig{
EnablePrefork: os.Getenv("PASSPORT_ENABLE_PREFORK") == "true",
})
}