package main
import (
"database/sql"
"embed"
"encoding/json"
"fmt"
"io"
"io/fs"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gofiber/fiber/v3"
"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"
)
//go:embed views/**
var viewsFS embed.FS
//go:embed fonts/**
var fontsFS embed.FS
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
}
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"`
}
}
type WeatherData struct {
Temperature float64
WeatherText string
Icon string
}
type WeatherCache struct {
data *WeatherData
lastUpdate time.Time
mutex sync.RWMutex
updateChan chan struct{}
apiKey string
lat string
lon string
}
func NewWeatherCache() *WeatherCache {
cache := &WeatherCache{
data: &WeatherData{},
updateChan: make(chan struct{}),
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(30 * 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=metric",
c.lat, c.lon, c.apiKey)
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
}
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 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",
})
}
assetsDir := "public/uploads"
ext := filepath.Ext(file.Filename)
filename := fmt.Sprintf("%d_%s%s", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_"), ext)
iconPath := filepath.Join(assetsDir, filename)
if err := c.SaveFile(file, iconPath); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Failed to save file",
})
}
iconPath = "/uploads/" + filename
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 !strings.HasPrefix(contentType, "image/") {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "Only image files are allowed",
})
}
assetsDir := "public/uploads"
ext := filepath.Ext(file.Filename)
filename := fmt.Sprintf("%d_%s%s", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_"), ext)
iconPath := filepath.Join(assetsDir, filename)
if err := c.SaveFile(file, iconPath); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"message": "Failed to save file",
})
}
iconPath = "/uploads/" + filename
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{
"01d": ``,
"01n": ``,
"02d": ``,
"02n": ``,
"03d": ``,
"03n": ``,
"04d": ``,
"04n": ``,
"09d": ``,
"09n": ``,
"10d": ``,
"10n": ``,
"11d": ``,
"11n": ``,
"13d": ``,
"13n": ``,
"50d": ``,
"50n": ``,
}
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)
}
viewsDir, err := fs.Sub(viewsFS, "views")
if err != nil {
log.Fatal(err)
}
engine := handlebars.NewFileSystem(http.FS(viewsDir), ".hbs")
router := fiber.New(fiber.Config{
Views: engine,
})
router.Use("/", static.New("./public"))
router.Use("/fonts", static.New("", static.Config{
FS: fontsFS,
}))
router.Get("/", func(c fiber.Ctx) error {
categories, err := app.GetCategories()
if err != nil {
return err
}
weather := app.WeatherCache.GetWeather()
return c.Render("index", fiber.Map{
"SearchProvider": os.Getenv("PASSPORT_SEARCH_PROVIDER"),
"WeatherData": fiber.Map{
"Temp": weather.Temperature,
"Desc": weather.WeatherText,
"Icon": WeatherIcons[weather.Icon],
},
"Categories": categories,
}, "layouts/main")
})
router.Get("/admin/login", func(c fiber.Ctx) error {
return c.Render("admin/login", fiber.Map{}, "layouts/main")
})
router.Post("/admin/login", func(c fiber.Ctx) error {
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.Use(middleware.AdminMiddleware(app.db))
router.Get("/admin", func(c fiber.Ctx) error {
categories, err := app.GetCategories()
if err != nil {
return err
}
return c.Render("admin/index", fiber.Map{
"Categories": categories,
}, "layouts/main")
})
api := router.Group("/api")
{
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")
_, err := app.db.Exec("DELETE FROM links WHERE id = ?", id)
if err != nil {
return err
}
return c.SendStatus(fiber.StatusOK)
})
api.Delete("/categories/:id", func(c fiber.Ctx) error {
id := c.Params("id")
_, err := app.db.Exec("DELETE FROM categories WHERE id = ?", id)
if err != nil {
return err
}
_, err = app.db.Exec("DELETE FROM links WHERE category_id = ?", id)
if err != nil {
return err
}
return c.SendStatus(fiber.StatusOK)
})
}
router.Listen(":3000")
}