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 @@ +
+
+ {{#each Categories}} +
+ +

{{this.Name}}

+ +
+
+ {{#each this.Links}} +
+ {{this.Name}} +
+

{{this.Name}}

+

{{this.Description}}

+
+ +
+ {{/each}} + +
+ {{/each}} +
+ +

Add a new category

+
+
+
+ + + + \ No newline at end of file diff --git a/views/admin/login.hbs b/views/admin/login.hbs new file mode 100644 index 0000000..c0e84d5 --- /dev/null +++ b/views/admin/login.hbs @@ -0,0 +1,45 @@ +
+
+ +
+

+ Login +

+
+ + + +
+ +
+
+
+ + \ No newline at end of file diff --git a/views/index.hbs b/views/index.hbs new file mode 100644 index 0000000..a1e41f5 --- /dev/null +++ b/views/index.hbs @@ -0,0 +1,54 @@ +
+
+
+ + {{{WeatherData.Icon}}} + +
+

{{WeatherData.Temp}}°C

+

{{WeatherData.Desc}}

+
+
+
+
+
+ +

Passport

+
+ +
+
+
+
+ {{#each Categories}} +
+ +

{{this.Name}}

+
+
+ {{#each this.Links}} + +
+ {{this.Name}} +
+

{{this.Name}}

+

{{this.Description}}

+
+
+
+ {{/each}} +
+ {{/each}} +
+
+ + \ No newline at end of file diff --git a/views/layouts/main.hbs b/views/layouts/main.hbs new file mode 100644 index 0000000..e863f3d --- /dev/null +++ b/views/layouts/main.hbs @@ -0,0 +1,452 @@ + + + + + Passport + + + + + + + {{embed}} + + + \ No newline at end of file diff --git a/views/styles/main.css b/views/styles/main.css new file mode 100644 index 0000000..7e86320 --- /dev/null +++ b/views/styles/main.css @@ -0,0 +1,425 @@ +@font-face { + font-family: "Instrument Sans"; + src: url("/fonts/InstrumentSans-Regular.ttf"); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: "Instrument Sans"; + src: url("/fonts/InstrumentSans-SemiBold.ttf"); + font-weight: 600; + font-style: normal; +} + +@font-face { + font-family: "Instrument Sans"; + src: url("/fonts/InstrumentSans-Italic.ttf"); + font-weight: normal; + font-style: italic; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + background-color: #151316; + color: white; + font-family: "Instrument Sans", sans-serif; + font-optical-sizing: auto; + font-weight: 400; + font-style: normal; + font-variation-settings: + "wdth" 100; +} + +p { + margin: 0; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + margin: 0; + font-weight: 600; +} + +h1 { + font-size: 64px; +} + +h2 { + font-size: 36px; +} + +.font-semibold { + font-weight: 600; +} + +.bg-transparent { + background-color: transparent; +} + +.bg-\[\#211F23\] { + background-color: #211F23; +} + +.bg-\[\#0E0A0E\] { + background-color: #0E0A0E; +} + +.bg-\[\#1C1C21\] { + background-color: #1C1C21; +} + +.hover\:bg-\[\#29292e\]:hover { + background-color: #29292e; +} + +.bg-\[\#151316\] { + background-color: #151316; +} + +.bg-\[\#8A42FF\] { + background-color: #8A42FF; +} + +.bg-\[\#00000070\] { + background-color: rgba(0, 0, 0, 0.7); +} + +.border-0 { + border-width: 0px; +} + +.border { + border-style: solid; + border-width: 1px; +} + +.border-solid { + border-style: solid; +} + +.border-dashed { + border-style: dashed; +} + +.border-\[rgb\(86\,86\,91\)\/30\] { + border-color: rgba(86, 86, 91, 0.30); +} + +.border-\[\#656565\] { + border-color: #656565; +} + +.border-\[\#211F23\] { + border-color: #211F23; +} + +.rounded-full { + border-radius: 9999px; +} + +.relative { + position: relative; +} + +.absolute { + position: absolute; +} + +.hidden { + display: none; +} + +.flex { + display: flex; +} + +.grid { + display: grid; +} + +.grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.gap-2 { + gap: 0.5rem; +} + +.gap-y-3 { + row-gap: 0.75rem; +} + +.justify-center { + justify-content: center; +} + +.items-center { + align-items: center; +} + +.h-100vh { + height: 100vh; +} + +.h-7 { + height: 1.752rem; +} + +.h-96 { + height: 24rem; +} + +.h-fit { + height: fit-content; +} + +.w-64 { + width: 16rem; +} + +.w-full { + width: 100%; +} + +.w-fit { + width: fit-content; +} + +.w-\[700px\] { + width: 700px; +} + +.min-w-\[50vw\] { + min-width: 50vw; +} + +.max-w-\[80vw\] { + max-width: 80vw; +} + +.flex-col { + flex-direction: column; +} + +.mr-3 { + margin-right: 0.75rem; +} + +.mr-2 { + margin-right: 0.5rem; +} + +.my-2 { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + +.p-0\.5 { + padding: 0.125rem; +} + +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.px-3 { + padding-left: 0.75rem; + padding-right: 0.75rem; +} + +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.pb-2\.5 { + padding-bottom: 0.625rem; +} + +.p-2\.5 { + padding: 0.625rem; +} + +.p-4 { + padding: 1rem; +} + +.h-2 { + height: 2rem; +} + +.w-8\/10 { + width: 80%; +} + +.top-0 { + top: 0px; +} + +.left-0 { + left: 0px; +} + +.bottom-0 { + bottom: 0px; +} + +.right-0 { + right: 0px; +} + +.top-1 { + top: 0.25rem; +} + +.right-1 { + right: 0.25rem; +} + +.top-2\.5 { + top: 0.625rem; +} + +.left-2\.5 { + left: 0.625rem; +} + +.text-\[\#BABABA\] { + color: #BABABA; +} + +.text-\[\#D7D7D7\] { + color: #D7D7D7; +} + +.text-\[\#656565\] { + color: #656565; +} + +.text-white { + color: white; +} + +.text-4xl { + font-size: 2.25rem; +} + +.underline-none { + text-decoration: none; +} + +.underline { + text-decoration: underline; +} + +.decoration-dashed { + text-decoration-style: dashed; +} + +.text-unset { + color: unset; +} + +.text-sm { + font-size: 0.875rem; +} + +.text-center { + text-align: center; +} + +.rounded-md { + border-radius: 0.375rem; +} + +.rounded-xl { + border-radius: 0.75rem; +} + +.rounded-2xl { + border-radius: 1rem; +} + +.overflow-hidden { + overflow: hidden; +} + +.shadow-md { + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); +} + +.hover\:shadow-xl:hover { + box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); +} + +.transition-\[shadow\,transform\] { + transition-property: box-shadow,transform; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.ease-\[cubic-bezier\(0\.16\,1\,0\.3\,1\)\] { + transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1); +} + +.capitalize { + text-transform: capitalize; +} + +.hover\:-translate-y-1:hover { + transform: translateY(-0.25rem); +} + +.object-contain { + object-fit: contain; +} + +.object-cover { + object-fit: cover; +} + +.select-none { + user-select: none; +} + +.cursor-pointer { + cursor: pointer; +} + +input, +textarea, +select { + color: inherit; + font: inherit; +} + +input:focus-visible { + outline: none; +} + +input::placeholder { + color: #656565; + font-style: italic; +} \ No newline at end of file