+ commit 796e889809ed7bd3a38fe1a3fc4a9e7eb51d6308
Author: juls0730 <62722391+juls0730@users.noreply.github.com>
Date: Mon Nov 11 00:18:02 2024 -0600
initial commit
diff --git a/.env example b/.env example
new file mode 100644
index 0000000..75f36a7
--- /dev/null
+++ b/.env example
@@ -0,0 +1,6 @@
+OPENWEATHER_API_KEY=1234567890
+OPENWEATHER_LAT=34.052235
+OPENWEATHER_LON=-118.243683
+PASSPORT_ADMIN_USERNAME=admin
+PASSPORT_ADMIN_PASSWORD=P@ssw0rd
+PASSPORT_SEARCH_PROVIDER=https://google.com/search?q=%s
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1031d0a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+passport
+.env
+passport.db
+public/uploads/
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..127a5bc
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,23 @@
+Boost Software License - Version 1.0 - August 17th, 2003
+
+Permission is hereby granted, free of charge, to any person or organization
+obtaining a copy of the software and accompanying documentation covered by
+this license (the "Software") to use, reproduce, display, distribute,
+execute, and transmit the Software, and to prepare derivative works of the
+Software, and to permit third-parties to whom the Software is furnished to
+do so, all subject to the following:
+
+The copyright notices in the Software and this entire statement, including
+the above license grant, this restriction and the following disclaimer,
+must be included in all copies of the Software, in whole or in part, and
+all derivative works of the Software, unless such copies or derivative
+works are solely in the form of machine-executable object code generated by
+a source language processor.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
+SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
+FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0e7bfec
--- /dev/null
+++ b/README.md
@@ -0,0 +1,31 @@
+# Passport
+
+Passport is a simple, fast, and lightweight web dashboard/new tab replacement.
+
+## Getting Started
+
+### Prerequisites
+
+- [Go](https://go.dev/doc/install)
+- [sqlite3](https://www.sqlite.org/download.html)
+
+### Usage
+
+1. Clone the repository
+2. Configure the `.env` file, an example is provided in the `.env example` file
+ - The `OPENWEATHER_API_KEY` is required for the weather data to be displayed
+ - The `OPENWEATHER_LAT` and `OPENWEATHER_LON` are required for the weather data to be displayed
+ - The `PASSPORT_ADMIN_USERNAME` and `PASSPORT_ADMIN_PASSWORD` are required for the admin dashboard
+ - The `PASSPORT_SEARCH_PROVIDER` is the search provider used for the search bar, %s is replaced with the search query
+3. Run `sqlite3 passport.db < passport.sql` to create the database
+4. Run `go build` to build the project
+5. Deploy passport, passport.db and .env, and preferably the public folder (but you dont have to) to your web server
+6. profit
+
+### Adding links and categories
+
+The admin dashboard can be accessed at `/admin`, you will be redirected to the login page if you are not logged in, use the credentials you configured in the `.env` file to login. Once logged in you can add links and categories.
+
+## License
+
+This project is licensed under the BSL-1.0 License - see the [LICENSE](LICENSE) file for details
diff --git a/fonts/InstrumentSans-Italic.ttf b/fonts/InstrumentSans-Italic.ttf
new file mode 100644
index 0000000..059865d
Binary files /dev/null and b/fonts/InstrumentSans-Italic.ttf differ
diff --git a/fonts/InstrumentSans-Regular.ttf b/fonts/InstrumentSans-Regular.ttf
new file mode 100644
index 0000000..64e1dd0
Binary files /dev/null and b/fonts/InstrumentSans-Regular.ttf differ
diff --git a/fonts/InstrumentSans-SemiBold.ttf b/fonts/InstrumentSans-SemiBold.ttf
new file mode 100644
index 0000000..b7e4d3c
Binary files /dev/null and b/fonts/InstrumentSans-SemiBold.ttf differ
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..d604292
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,30 @@
+module github.com/juls0730/passport
+
+go 1.23.2
+
+require (
+ github.com/mailgun/raymond/v2 v2.0.48 // indirect
+ github.com/sirupsen/logrus v1.8.1 // indirect
+)
+
+require (
+ github.com/andybalholm/brotli v1.1.0 // indirect
+ github.com/gofiber/fiber/v2 v2.52.5 // indirect
+ github.com/gofiber/fiber/v3 v3.0.0-beta.3
+ github.com/gofiber/template v1.8.3 // indirect
+ github.com/gofiber/template/handlebars/v2 v2.1.10
+ github.com/gofiber/utils v1.1.0 // indirect
+ github.com/gofiber/utils/v2 v2.0.0-beta.4 // indirect
+ github.com/google/uuid v1.6.0
+ github.com/joho/godotenv v1.5.1
+ github.com/klauspost/compress v1.17.9 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-runewidth v0.0.15 // indirect
+ github.com/mattn/go-sqlite3 v1.14.24
+ github.com/rivo/uniseg v0.2.0 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasthttp v1.55.0 // indirect
+ github.com/valyala/tcplisten v1.0.0 // indirect
+ golang.org/x/sys v0.21.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..20728c7
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,62 @@
+github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
+github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo=
+github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
+github.com/gofiber/fiber/v3 v3.0.0-beta.3 h1:7Q2I+HsIqnIEEDB+9oe7Gadpakh6ZLhXpTYz/L20vrg=
+github.com/gofiber/fiber/v3 v3.0.0-beta.3/go.mod h1:kcMur0Dxqk91R7p4vxEpJfDWZ9u5IfvrtQc8Bvv/JmY=
+github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
+github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
+github.com/gofiber/template/handlebars/v2 v2.1.10 h1:Qc+uUMULCqW60LF4VKO1REpiyDAUy3vqW7xq66FPJGM=
+github.com/gofiber/template/handlebars/v2 v2.1.10/go.mod h1:84WH9st5OJi255EGjuMAOqUVQ+Q2jUNhUKYbS5DgAcI=
+github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
+github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
+github.com/gofiber/utils/v2 v2.0.0-beta.4 h1:1gjbVFFwVwUb9arPcqiB6iEjHBwo7cHsyS41NeIW3co=
+github.com/gofiber/utils/v2 v2.0.0-beta.4/go.mod h1:sdRsPU1FXX6YiDGGxd+q2aPJRMzpsxdzCXo9dz+xtOY=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/mailgun/raymond/v2 v2.0.48 h1:5dmlB680ZkFG2RN/0lvTAghrSxIESeu9/2aeDqACtjw=
+github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
+github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
+github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
+github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
+github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
+github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
+github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
+golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..d31219f
--- /dev/null
+++ b/main.go
@@ -0,0 +1,527 @@
+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")
+}
diff --git a/middleware/admin.go b/middleware/admin.go
new file mode 100644
index 0000000..2e287e5
--- /dev/null
+++ b/middleware/admin.go
@@ -0,0 +1,44 @@
+package middleware
+
+import (
+ "database/sql"
+ "time"
+
+ "github.com/gofiber/fiber/v3"
+)
+
+type Session struct {
+ SessionID string `json:"session_id"`
+ ExpiresAt string `json:"expires_at"`
+}
+
+func AdminMiddleware(db *sql.DB) func(c fiber.Ctx) error {
+ return func(c fiber.Ctx) error {
+ sessionToken := c.Cookies("SessionToken")
+ if sessionToken == "" {
+ return c.Redirect().To("/admin/login")
+ }
+
+ // Check if session exists
+ var session Session
+ err := db.QueryRow(`
+ SELECT session_id, expires_at
+ FROM sessions
+ WHERE session_id = ?
+ `, sessionToken).Scan(&session.SessionID, &session.ExpiresAt)
+ if err != nil {
+ return c.Redirect().To("/admin/login")
+ }
+
+ sessionExpiry, err := time.Parse("2006-01-02 15:04:05-07:00", session.ExpiresAt)
+ if err != nil {
+ return c.Redirect().To("/admin/login")
+ }
+
+ if sessionExpiry.Before(time.Now()) {
+ return c.Redirect().To("/admin/login")
+ }
+
+ return c.Next()
+ }
+}
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..f4b35ec
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/leaves.jpg b/public/leaves.jpg
new file mode 100644
index 0000000..d9489e9
Binary files /dev/null and b/public/leaves.jpg differ
diff --git a/schema.sql b/schema.sql
new file mode 100644
index 0000000..81c3b69
--- /dev/null
+++ b/schema.sql
@@ -0,0 +1,20 @@
+CREATE TABLE categories (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ icon TEXT NOT NULL
+);
+
+CREATE TABLE links (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ category_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ description TEXT NOT NULL,
+ icon TEXT NOT NULL,
+ url TEXT NOT NULL
+);
+
+CREATE TABLE sessions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_id TEXT NOT NULL,
+ expires_at TEXT NOT NULL
+);
\ No newline at end of file
diff --git a/views/admin/index.hbs b/views/admin/index.hbs
new file mode 100644
index 0000000..eee2d30
--- /dev/null
+++ b/views/admin/index.hbs
@@ -0,0 +1,175 @@
+ {{this.Description}}
+
{{this.Name}}
+
+
+
{{this.Name}}
+ Add a link
+ Add a new category
+
+ {{WeatherData.Temp}}°C
+{{WeatherData.Desc}}
+{{this.Description}}
+