Overhaul code org, and improve image uploading
This commit introduces breaking changes. It overhauls how and where services are configured and placed in the codebase, as well as moving the entire source into src/ It also changes how these integrations are configured via environment variables. Old configs will still work for now, but it is strongly suggested that you migrate your config.
This commit is contained in:
BIN
src/assets/favicon.ico
Normal file
BIN
src/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
src/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2
Normal file
BIN
src/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2
Normal file
Binary file not shown.
BIN
src/assets/leaves.webp
Normal file
BIN
src/assets/leaves.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
998
src/main.go
Normal file
998
src/main.go
Normal file
@@ -0,0 +1,998 @@
|
||||
//go:generate tailwindcss -i styles/main.css -o assets/tailwind.css --minify
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/HugoSmits86/nativewebp"
|
||||
"github.com/caarlos0/env/v11"
|
||||
"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/src/middleware"
|
||||
"github.com/juls0730/passport/src/services"
|
||||
"golang.org/x/image/draw"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed assets/** templates/** schema.sql
|
||||
var embeddedAssets embed.FS
|
||||
|
||||
var devContent = `<script>
|
||||
let host = window.location.hostname;
|
||||
const socket = new WebSocket('ws://' + host + ':2067/ws');
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
if (event.data === 'refresh') {
|
||||
async function testPage() {
|
||||
try {
|
||||
let res = await fetch(window.location.href)
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setTimeout(testPage, 300);
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
testPage();
|
||||
}
|
||||
});
|
||||
</script>`
|
||||
|
||||
var (
|
||||
insertCategoryStmt *sql.Stmt
|
||||
insertLinkStmt *sql.Stmt
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DevMode bool `env:"PASSPORT_DEV_MODE" envDefault:"false"`
|
||||
Prefork bool `env:"PASSPORT_ENABLE_PREFORK" envDefault:"false"`
|
||||
|
||||
WeatherAPIKey string `env:"PASSPORT_WEATHER_API_KEY"`
|
||||
Weather *services.WeatherConfig
|
||||
|
||||
UptimeAPIKey string `env:"PASSPORT_UPTIME_API_KEY"`
|
||||
Uptime *services.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"`
|
||||
}
|
||||
|
||||
Depricated struct {
|
||||
WeatherEnabled bool `env:"PASSPORT_ENABLE_WEATHER" envDefault:"false"`
|
||||
UptimeEnabled bool `env:"PASSPORT_ENABLE_UPTIME" envDefault:"false"`
|
||||
}
|
||||
}
|
||||
|
||||
func ParseConfig() (*Config, error) {
|
||||
config := Config{}
|
||||
|
||||
err := env.Parse(&config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if config.WeatherAPIKey != "" {
|
||||
config.Weather = &services.WeatherConfig{
|
||||
APIKey: config.WeatherAPIKey,
|
||||
}
|
||||
if err := env.Parse(config.Weather); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if config.Depricated.WeatherEnabled {
|
||||
slog.Warn("Your configuration file contains depricated Weather settings. Please update your configuration file!")
|
||||
depricatedWeatherConfig := &services.DepricatedWeatherConfig{}
|
||||
if err := env.Parse(depricatedWeatherConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config.Weather = &services.WeatherConfig{}
|
||||
config.Weather.Provider = depricatedWeatherConfig.OpenWeather.Provider
|
||||
config.Weather.APIKey = depricatedWeatherConfig.OpenWeather.APIKey
|
||||
config.Weather.Units = depricatedWeatherConfig.OpenWeather.Units
|
||||
config.Weather.Lat = depricatedWeatherConfig.OpenWeather.Lat
|
||||
config.Weather.Lon = depricatedWeatherConfig.OpenWeather.Lon
|
||||
config.Weather.UpdateInterval = depricatedWeatherConfig.UpdateInterval
|
||||
}
|
||||
|
||||
if config.UptimeAPIKey != "" {
|
||||
config.Uptime = &services.UptimeConfig{
|
||||
APIKey: config.UptimeAPIKey,
|
||||
}
|
||||
if err := env.Parse(config.Uptime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if config.Depricated.UptimeEnabled {
|
||||
slog.Warn("Your configuration file contains depricated Uptime settings. Please update your configuration file!")
|
||||
depricatedUptimeConfig := &services.DepricatedUptimeConfig{}
|
||||
if err := env.Parse(depricatedUptimeConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config.Uptime = &services.UptimeConfig{}
|
||||
config.Uptime.APIKey = depricatedUptimeConfig.APIKey
|
||||
config.Uptime.UpdateInterval = depricatedUptimeConfig.UpdateInterval
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
type App struct {
|
||||
*Config
|
||||
*CategoryManager
|
||||
*services.WeatherManager
|
||||
*services.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("sqlite", 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 *services.WeatherManager
|
||||
if config.WeatherAPIKey != "" {
|
||||
weatherCache = services.NewWeatherManager(config.Weather)
|
||||
}
|
||||
|
||||
var uptimeManager *services.UptimeManager
|
||||
if config.UptimeAPIKey != "" {
|
||||
uptimeManager = services.NewUptimeManager(config.Uptime)
|
||||
}
|
||||
|
||||
return &App{
|
||||
Config: config,
|
||||
WeatherManager: weatherCache,
|
||||
CategoryManager: categoryManager,
|
||||
UptimeManager: uptimeManager,
|
||||
db: db,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func CropToCenter(img image.Image, outputSize int) (image.Image, error) {
|
||||
if img == nil {
|
||||
return nil, fmt.Errorf("input image is nil")
|
||||
}
|
||||
if outputSize <= 0 {
|
||||
return nil, fmt.Errorf("output size must be positive")
|
||||
}
|
||||
|
||||
srcBounds := img.Bounds()
|
||||
srcWidth := srcBounds.Dx()
|
||||
srcHeight := srcBounds.Dy()
|
||||
|
||||
squareSide := min(srcWidth, srcHeight)
|
||||
|
||||
cropX := (srcWidth - squareSide) / 2
|
||||
cropY := (srcHeight - squareSide) / 2
|
||||
|
||||
srcCropRect := image.Rect(cropX, cropY, cropX+squareSide, cropY+squareSide)
|
||||
|
||||
croppedSquareImg := image.NewRGBA(image.Rect(0, 0, squareSide, squareSide))
|
||||
draw.Draw(croppedSquareImg, croppedSquareImg.Rect, img, srcCropRect.Min, draw.Src)
|
||||
|
||||
if squareSide == outputSize {
|
||||
return croppedSquareImg, nil
|
||||
}
|
||||
|
||||
outputImg := image.NewRGBA(image.Rect(0, 0, outputSize, outputSize))
|
||||
|
||||
draw.CatmullRom.Scale(outputImg, outputImg.Rect, croppedSquareImg, croppedSquareImg.Bounds(), draw.Src, nil)
|
||||
|
||||
return outputImg, nil
|
||||
}
|
||||
|
||||
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 = nativewebp.Decode(srcFile)
|
||||
case "image/svg+xml":
|
||||
// does not fall through (my C brain was tripping over this)
|
||||
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()
|
||||
|
||||
// crop slightly larger than 64px to vastly increase the quality of the image, but not increase the file size
|
||||
// *too* much and so that we dont have a ton of extra file data that will never be seen by the user
|
||||
resizedImg, err := CropToCenter(img, 96)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
options := &nativewebp.Options{}
|
||||
if err := nativewebp.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 {
|
||||
row := manager.db.QueryRow(`SELECT id, name, icon FROM categories WHERE id = ?`, id)
|
||||
|
||||
var cat Category
|
||||
if err := row.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 == "" {
|
||||
slog.Debug("blank icon")
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.Remove(filepath.Join("public/", icon)); err != nil {
|
||||
// dont error to the user if the icon doesnt exist, just log it
|
||||
slog.Error("Failed to delete icon", "icon", icon, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *CategoryManager) GetLink(id int64) *Link {
|
||||
row := manager.db.QueryRow(`SELECT id, category_id, name, description, icon, url FROM links WHERE id = ?`, id)
|
||||
|
||||
var link Link
|
||||
if err := row.Scan(&link.ID, &link.CategoryID, &link.Name, &link.Description, &link.Icon, &link.URL); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &link
|
||||
}
|
||||
|
||||
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 {
|
||||
slog.Error("Failed to delete icon", "icon", icon, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var WeatherIcons = map[string]string{
|
||||
"clear-day": `<svg aria-label="Clear day" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M16 12.005a4 4 0 1 1-4 4a4.005 4.005 0 0 1 4-4m0-2a6 6 0 1 0 6 6a6 6 0 0 0-6-6M5.394 6.813L6.81 5.399l3.505 3.506L8.9 10.319zM2 15.005h5v2H2zm3.394 10.193L8.9 21.692l1.414 1.414l-3.505 3.506zM15 25.005h2v5h-2zm6.687-1.9l1.414-1.414l3.506 3.506l-1.414 1.414zm3.313-8.1h5v2h-5zm-3.313-6.101l3.506-3.506l1.414 1.414l-3.506 3.506zM15 2.005h2v5h-2z"/></svg>`,
|
||||
"clear-night": `<svg aria-label="Clear night" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M13.503 5.414a15.076 15.076 0 0 0 11.593 18.194a11.1 11.1 0 0 1-7.975 3.39c-.138 0-.278.005-.418 0a11.094 11.094 0 0 1-3.2-21.584M14.98 3a1 1 0 0 0-.175.016a13.096 13.096 0 0 0 1.825 25.981c.164.006.328 0 .49 0a13.07 13.07 0 0 0 10.703-5.555a1.01 1.01 0 0 0-.783-1.565A13.08 13.08 0 0 1 15.89 4.38A1.015 1.015 0 0 0 14.98 3"/></svg>`,
|
||||
"partly-cloudy-day": `<svg aria-label="Partly cloudy day" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M27 15h4v2h-4zm-4-7.413l3-3L27.415 6l-3 3zM15 1h2v4h-2zM4.586 26l3-3l1.415 1.415l-3 3zM4.585 6L6 4.587l3 3l-1.414 1.415z"/><path fill="currentColor" d="M1 15h4v2H1zm25.794 5.342a6.96 6.96 0 0 0-1.868-3.267A9 9 0 0 0 25 16a9 9 0 1 0-14.585 7.033A4.977 4.977 0 0 0 15 30h10a4.995 4.995 0 0 0 1.794-9.658M9 16a6.996 6.996 0 0 1 13.985-.297A6.9 6.9 0 0 0 20 15a7.04 7.04 0 0 0-6.794 5.342a5 5 0 0 0-1.644 1.048A6.97 6.97 0 0 1 9 16m16 12H15a2.995 2.995 0 0 1-.696-5.908l.658-.157l.099-.67a4.992 4.992 0 0 1 9.878 0l.099.67l.658.156A2.995 2.995 0 0 1 25 28"/></svg>`,
|
||||
"partly-cloudy-night": `<svg aria-label="Partly cloudy night" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M30 19a4.97 4.97 0 0 0-3.206-4.658A6.971 6.971 0 0 0 13.758 12.9a13.14 13.14 0 0 1 .131-8.52A1.015 1.015 0 0 0 12.98 3a1 1 0 0 0-.175.016a13.096 13.096 0 0 0 1.825 25.981c.164.006.328 0 .49 0a13.04 13.04 0 0 0 10.29-5.038A4.99 4.99 0 0 0 30 19m-15.297 7.998a11.095 11.095 0 0 1-3.2-21.584a15.2 15.2 0 0 0 .844 9.367A4.988 4.988 0 0 0 15 24h7.677a11.1 11.1 0 0 1-7.556 2.998c-.138 0-.278.004-.418 0M25 22H15a2.995 2.995 0 0 1-.696-5.908l.658-.157l.099-.67a4.992 4.992 0 0 1 9.878 0l.099.67l.658.157A2.995 2.995 0 0 1 25 22"/></svg>`,
|
||||
"mostly-cloudy-day": `<svg aria-label="Mostly cloudy day" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M21.743 18.692a6 6 0 0 0 1.057-1.086a5.998 5.998 0 1 0-10.733-4.445A7.56 7.56 0 0 0 6.35 18.25A5.993 5.993 0 0 0 8 30.005h11a5.985 5.985 0 0 0 2.743-11.313M18 10.005a4.004 4.004 0 0 1 4 4a3.96 3.96 0 0 1-.8 2.4a4 4 0 0 1-.94.891a7.54 7.54 0 0 0-6.134-4.24A4 4 0 0 1 18 10.006m1 18H8a3.993 3.993 0 0 1-.673-7.93l.663-.112l.146-.656a5.496 5.496 0 0 1 10.729 0l.146.656l.662.112a3.993 3.993 0 0 1-.673 7.93m7-15.001h4v2h-4zM22.95 7.64l2.828-2.827l1.415 1.414l-2.829 2.828zM17 2.005h2v4h-2zM8.808 6.227l1.414-1.414l2.829 2.828l-1.415 1.414z"/></svg>`,
|
||||
"mostly-cloudy-night": `<svg aria-label="Mostly cloudy night" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M29.844 15.035a1.52 1.52 0 0 0-1.23-.866a5.36 5.36 0 0 1-3.41-1.716a6.47 6.47 0 0 1-1.286-6.392a1.6 1.6 0 0 0-.299-1.546a1.45 1.45 0 0 0-1.36-.493l-.019.003a7.93 7.93 0 0 0-6.22 7.431A7.4 7.4 0 0 0 13.5 11a7.55 7.55 0 0 0-7.15 5.244A5.993 5.993 0 0 0 8 28h11a5.977 5.977 0 0 0 5.615-8.088a7.5 7.5 0 0 0 5.132-3.357a1.54 1.54 0 0 0 .097-1.52M19 26H8a3.993 3.993 0 0 1-.673-7.93l.663-.112l.145-.656a5.496 5.496 0 0 1 10.73 0l.145.656l.663.113A3.993 3.993 0 0 1 19 26m4.465-8.001h-.021a5.96 5.96 0 0 0-2.795-1.755a7.5 7.5 0 0 0-2.6-3.677c-.01-.101-.036-.197-.041-.3a6.08 6.08 0 0 1 3.79-6.05a8.46 8.46 0 0 0 1.94 7.596a7.4 7.4 0 0 0 3.902 2.228a5.43 5.43 0 0 1-4.175 1.958"/></svg>`,
|
||||
"light-rain": `<svg aria-label="Light rain" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M11 30a1 1 0 0 1-.894-1.447l2-4a1 1 0 1 1 1.788.894l-2 4A1 1 0 0 1 11 30"/><path fill="currentColor" d="M24.8 9.136a8.994 8.994 0 0 0-17.6 0A6.497 6.497 0 0 0 8.5 22h10.881l-1.276 2.553a1 1 0 0 0 1.789.894L21.618 22H23.5a6.497 6.497 0 0 0 1.3-12.864M23.5 20h-15a4.498 4.498 0 0 1-.356-8.981l.816-.064l.099-.812a6.994 6.994 0 0 1 13.883 0l.099.812l.815.064A4.498 4.498 0 0 1 23.5 20"/></svg>`,
|
||||
"rain": `<svg aria-label="Rain" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M23.5 22h-15A6.5 6.5 0 0 1 7.2 9.14a9 9 0 0 1 17.6 0A6.5 6.5 0 0 1 23.5 22M16 4a7 7 0 0 0-6.94 6.14L9 11h-.86a4.5 4.5 0 0 0 .36 9h15a4.5 4.5 0 0 0 .36-9H23l-.1-.82A7 7 0 0 0 16 4m-2 26a.93.93 0 0 1-.45-.11a1 1 0 0 1-.44-1.34l2-4a1 1 0 1 1 1.78.9l-2 4A1 1 0 0 1 14 30m6 0a.93.93 0 0 1-.45-.11a1 1 0 0 1-.44-1.34l2-4a1 1 0 1 1 1.78.9l-2 4A1 1 0 0 1 20 30M8 30a.93.93 0 0 1-.45-.11a1 1 0 0 1-.44-1.34l2-4a1 1 0 1 1 1.78.9l-2 4A1 1 0 0 1 8 30"/></svg>`,
|
||||
"thunder": `<svg aria-label="Thunder" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M21 30a1 1 0 0 1-.894-1.447l2-4a1 1 0 1 1 1.788.894l-2 4A1 1 0 0 1 21 30M9 32a1 1 0 0 1-.894-1.447l2-4a1 1 0 1 1 1.788.894l-2 4A1 1 0 0 1 9 32m6.901-1.504l-1.736-.992L17.31 24h-6l4.855-8.496l1.736.992L14.756 22h6.001z"/><path fill="currentColor" d="M24.8 9.136a8.994 8.994 0 0 0-17.6 0a6.493 6.493 0 0 0 .23 12.768l-1.324 2.649a1 1 0 1 0 1.789.894l2-4a1 1 0 0 0-.447-1.341A1 1 0 0 0 9 20.01V20h-.5a4.498 4.498 0 0 1-.356-8.981l.816-.064l.099-.812a6.994 6.994 0 0 1 13.883 0l.099.812l.815.064A4.498 4.498 0 0 1 23.5 20H23v2h.5a6.497 6.497 0 0 0 1.3-12.864"/></svg>`,
|
||||
"snow": `<svg aria-label="Snow" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M23.5 22h-15A6.5 6.5 0 0 1 7.2 9.14a9 9 0 0 1 17.6 0A6.5 6.5 0 0 1 23.5 22M16 4a7 7 0 0 0-6.94 6.14L9 11h-.86a4.5 4.5 0 0 0 .36 9h15a4.5 4.5 0 0 0 .36-9H23l-.1-.82A7 7 0 0 0 16 4m-4 21.05L10.95 24L9.5 25.45L8.05 24L7 25.05l1.45 1.45L7 27.95L8.05 29l1.45-1.45L10.95 29L12 27.95l-1.45-1.45zm14 0L24.95 24l-1.45 1.45L22.05 24L21 25.05l1.45 1.45L21 27.95L22.05 29l1.45-1.45L24.95 29L26 27.95l-1.45-1.45zm-7 2L17.95 26l-1.45 1.45L15.05 26L14 27.05l1.45 1.45L14 29.95L15.05 31l1.45-1.45L17.95 31L19 29.95l-1.45-1.45z"/></svg>`,
|
||||
"mist": `<svg aria-label="Mist" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path fill="currentColor" d="M24.8 11.138a8.994 8.994 0 0 0-17.6 0A6.53 6.53 0 0 0 2 17.5V19a1 1 0 0 0 1 1h12a1 1 0 0 0 0-2H4v-.497a4.52 4.52 0 0 1 4.144-4.482l.816-.064l.099-.812a6.994 6.994 0 0 1 13.883 0l.099.813l.815.063A4.496 4.496 0 0 1 23.5 22H7a1 1 0 0 0 0 2h16.5a6.496 6.496 0 0 0 1.3-12.862"/><rect width="18" height="2" x="2" y="26" fill="currentColor" rx="1"/></svg>`,
|
||||
}
|
||||
|
||||
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{
|
||||
"_time_format": "sqlite",
|
||||
"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.WeatherAPIKey != "" {
|
||||
weather := app.WeatherManager.GetWeather()
|
||||
|
||||
renderData["WeatherData"] = fiber.Map{
|
||||
"Temp": weather.Temperature,
|
||||
"Desc": weather.WeatherText,
|
||||
"Icon": getWeatherIcon(weather.Icon),
|
||||
}
|
||||
}
|
||||
|
||||
if app.Config.UptimeAPIKey != "" {
|
||||
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("/category", 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 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 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 c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"message": fmt.Sprintf("Failed to create category: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
"message": "Category created successfully",
|
||||
"category": category,
|
||||
})
|
||||
})
|
||||
|
||||
api.Post("/category/:id/link", func(c fiber.Ctx) error {
|
||||
var req struct {
|
||||
Name string `form:"name"`
|
||||
Description string `form:"description"`
|
||||
URL string `form:"url"`
|
||||
}
|
||||
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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"message": "Name and URL are required",
|
||||
})
|
||||
}
|
||||
|
||||
categoryID, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"message": fmt.Sprintf("Failed to parse category ID: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
if app.CategoryManager.GetCategory(categoryID) == nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"message": "Category not found",
|
||||
})
|
||||
}
|
||||
|
||||
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: 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("/category/:categoryID/link/:linkID", func(c fiber.Ctx) error {
|
||||
linkID, err := strconv.ParseInt(c.Params("linkID"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"message": fmt.Sprintf("Failed to parse link ID: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
categoryID, err := strconv.ParseInt(c.Params("categoryID"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"message": fmt.Sprintf("Failed to parse category ID: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
if app.CategoryManager.GetCategory(categoryID) == nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"message": "Category not found",
|
||||
})
|
||||
}
|
||||
|
||||
link := app.CategoryManager.GetLink(linkID)
|
||||
if link == nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"message": "Link not found",
|
||||
})
|
||||
}
|
||||
|
||||
if link.CategoryID != categoryID {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"message": "Invalid category ID",
|
||||
})
|
||||
}
|
||||
|
||||
err = app.CategoryManager.DeleteLink(linkID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"message": fmt.Sprintf("Failed to delete link: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
api.Delete("/category/:id", func(c fiber.Ctx) error {
|
||||
// id = parseInt(c.Params("id"))
|
||||
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"message": fmt.Sprintf("Failed to parse category ID: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
if app.CategoryManager.GetCategory(id) == nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"message": "Category not found",
|
||||
})
|
||||
}
|
||||
|
||||
err = app.CategoryManager.DeleteCategory(id)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"message": fmt.Sprintf("Failed to delete category: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
router.Listen(":3000", fiber.ListenConfig{
|
||||
EnablePrefork: app.Config.Prefork,
|
||||
})
|
||||
}
|
||||
53
src/middleware/admin.go
Normal file
53
src/middleware/admin.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"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.Next()
|
||||
}
|
||||
|
||||
// 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 {
|
||||
slog.Error("Failed to check session", "error", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"message": fmt.Sprintf("Failed to check session: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
sessionExpiry, err := time.Parse("2006-01-02 15:04:05-07:00", session.ExpiresAt)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse session expiry", "error", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"message": fmt.Sprintf("Failed to parse session expiry: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
if sessionExpiry.Before(time.Now()) {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
c.Locals("IsAdmin", true)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
20
src/schema.sql
Normal file
20
src/schema.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
111
src/services/uptimeService.go
Normal file
111
src/services/uptimeService.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DepricatedUptimeConfig struct {
|
||||
APIKey string `env:"UPTIMEROBOT_API_KEY"`
|
||||
UpdateInterval int `env:"UPTIMEROBOT_UPDATE_INTERVAL" envDefault:"300"`
|
||||
}
|
||||
|
||||
type UptimeConfig struct {
|
||||
APIKey string
|
||||
UpdateInterval int `env:"UPTIME_UPDATE_INTERVAL" envDefault:"300"`
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
158
src/services/weatherService.go
Normal file
158
src/services/weatherService.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type WeatherProvider string
|
||||
|
||||
const (
|
||||
OpenWeatherMap WeatherProvider = "openweathermap"
|
||||
)
|
||||
|
||||
type DepricatedWeatherConfig struct {
|
||||
OpenWeather struct {
|
||||
Provider WeatherProvider `env:"OPENWEATHER_PROVIDER" envDefault:"openweathermap"`
|
||||
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 WeatherConfig struct {
|
||||
Provider WeatherProvider `env:"WEATHER_PROVIDER" envDefault:"openweathermap"`
|
||||
APIKey string `env:"WEATHER_API_KEY"`
|
||||
Units string `env:"WEATHER_TEMP_UNITS" envDefault:"metric"`
|
||||
Lat float64 `env:"WEATHER_LAT"`
|
||||
Lon float64 `env:"WEATHER_LON"`
|
||||
UpdateInterval int `env:"WEATHER_UPDATE_INTERVAL" envDefault:"15"`
|
||||
}
|
||||
|
||||
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 WeatherManager struct {
|
||||
data *WeatherData
|
||||
lastUpdate time.Time
|
||||
mutex sync.RWMutex
|
||||
updateChan chan struct{}
|
||||
config *WeatherConfig
|
||||
}
|
||||
|
||||
func NewWeatherManager(config *WeatherConfig) *WeatherManager {
|
||||
if config.Provider != OpenWeatherMap {
|
||||
log.Fatalln("Only OpenWeatherMap is supported!")
|
||||
return nil
|
||||
}
|
||||
|
||||
if config.APIKey == "" {
|
||||
log.Fatalln("An API Key required for OpenWeather!")
|
||||
return nil
|
||||
}
|
||||
|
||||
updateInterval := config.UpdateInterval
|
||||
if updateInterval < 1 {
|
||||
updateInterval = 15
|
||||
}
|
||||
|
||||
units := config.Units
|
||||
if units == "" {
|
||||
units = "metric"
|
||||
}
|
||||
|
||||
cache := &WeatherManager{
|
||||
data: &WeatherData{},
|
||||
updateChan: make(chan struct{}),
|
||||
config: config,
|
||||
}
|
||||
|
||||
go cache.weatherWorker()
|
||||
|
||||
cache.updateChan <- struct{}{}
|
||||
|
||||
return cache
|
||||
}
|
||||
|
||||
func (c *WeatherManager) GetWeather() WeatherData {
|
||||
c.mutex.RLock()
|
||||
defer c.mutex.RUnlock()
|
||||
return *c.data
|
||||
}
|
||||
|
||||
func (c *WeatherManager) weatherWorker() {
|
||||
ticker := time.NewTicker(time.Duration(c.config.UpdateInterval) * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.updateChan:
|
||||
c.updateWeather()
|
||||
case <-ticker.C:
|
||||
c.updateWeather()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WeatherManager) updateWeather() {
|
||||
url := fmt.Sprintf("https://api.openweathermap.org/data/2.5/weather?lat=%f&lon=%f&appid=%s&units=%s",
|
||||
c.config.Lat, c.config.Lon, c.config.APIKey, c.config.Units)
|
||||
|
||||
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()
|
||||
}
|
||||
127
src/styles/main.css
Normal file
127
src/styles/main.css
Normal file
@@ -0,0 +1,127 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
|
||||
@theme {
|
||||
--color-accent: oklch(57.93% 0.258 294.12);
|
||||
--color-success: oklch(70.19% 0.158 160.44);
|
||||
--color-error: oklch(63.43% 0.251 28.48);
|
||||
|
||||
--color-base: oklch(11% .007 285);
|
||||
--color-surface: oklch(19% 0.007 314.66);
|
||||
--color-overlay: oklch(26% 0.008 314.66);
|
||||
|
||||
--color-muted: oklch(63% 0.015 286);
|
||||
--color-subtle: oklch(72% 0.015 286);
|
||||
--color-text: oklch(87% 0.015 286);
|
||||
|
||||
--color-highlight-sm: oklch(30.67% 0.007 286);
|
||||
--color-highlight: oklch(39.26% 0.010 286);
|
||||
--color-highlight-lg: oklch(47.72% 0.011 286);
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Instrument Sans";
|
||||
src: url("/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2") format("woff2");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--default-font-family: "Instrument Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
}
|
||||
|
||||
html {
|
||||
line-height: normal;
|
||||
color-scheme: dark;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(42px, 10vw, 64px);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: clamp(30px, 6vw, 36px);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input:not(.search) {
|
||||
@apply px-4 py-2 rounded-md w-full bg-surface border border-highlight-sm/70 placeholder:text-highlight text-text focus-visible:outline-none transition-colors duration-300 ease-out overflow-hidden;
|
||||
|
||||
&[type="file"] {
|
||||
@apply p-0 cursor-pointer;
|
||||
|
||||
&::file-selector-button {
|
||||
@apply px-2 py-2 mr-1 bg-highlight text-subtle cursor-pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.link-card {
|
||||
background: var(--color-overlay);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
text-decoration: none;
|
||||
border-radius: 1rem;
|
||||
padding: 0.625rem;
|
||||
align-items: center;
|
||||
transition-property: box-shadow, transform, translate;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
transform: translateY(2px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.link-card {
|
||||
transition: none;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.link-card img {
|
||||
margin-right: 0.5rem;
|
||||
user-select: none;
|
||||
border-radius: 0.375rem;
|
||||
aspect-ratio: 1/1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.link-card div {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.link-card div p {
|
||||
color: var(--color-subtle);
|
||||
}
|
||||
16
src/templates/layouts/main.hbs
Normal file
16
src/templates/layouts/main.hbs
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Passport</title>
|
||||
<link rel="favicon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin="anonymous" href="/assets/fonts/InstrumentSans-VariableFont_wdth,wght.woff2">
|
||||
<style>{{{inlineCSS}}}</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-surface text-text">
|
||||
{{embed}}
|
||||
</body>
|
||||
{{{devContent}}}
|
||||
</html>
|
||||
292
src/templates/views/admin/index.hbs
Normal file
292
src/templates/views/admin/index.hbs
Normal file
@@ -0,0 +1,292 @@
|
||||
<div id="blur-target"
|
||||
class="transition-[filter] motion-reduce:transition-none ease-[cubic-bezier(0.45,0,0.55,1)] duration-300">
|
||||
<header class="flex w-full p-3">
|
||||
<a href="/"
|
||||
class="flex items-center flex-row gap-2 text-white border-b hover:border-transparent justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"
|
||||
viewBox="0 0 24 24"><!-- Icon from Tabler Icons by Paweł Kuna - https://github.com/tabler/tabler-icons/blob/master/LICENSE -->
|
||||
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
||||
<path d="m9 14l-4-4l4-4" />
|
||||
<path d="M5 10h11a4 4 0 1 1 0 8h-1" />
|
||||
</g>
|
||||
</svg>
|
||||
Return to home
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<section class="flex justify-center w-full">
|
||||
<div class="w-full sm:w-4/5 p-2.5">
|
||||
{{#each Categories}}
|
||||
<div class="flex items-center" key="category-{{this.ID}}">
|
||||
<img class="object-contain mr-2 select-none" width="32" height="32" draggable="false"
|
||||
alt="{{this.Name}}" src="{{this.Icon}}" />
|
||||
<h2 class="capitalize break-all">{{this.Name}}</h2>
|
||||
<button onclick="deleteCategory({{this.ID}})"
|
||||
class="w-fit h-fit flex p-0.5 bg-base border border-highlight rounded-md hover:filter hover:brightness-125 cursor-pointer"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="#ff1919" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
|
||||
</svg></button>
|
||||
</div>
|
||||
<div class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2">
|
||||
{{#each this.Links}}
|
||||
<div key="link-{{this.ID}}" class="link-card relative">
|
||||
<img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
|
||||
<div>
|
||||
<h3>{{this.Name}}</h3>
|
||||
<p>{{this.Description}}</p>
|
||||
</div>
|
||||
<button onclick="deleteLink({{this.ID}}, {{this.CategoryID}})"
|
||||
class="w-fit h-fit flex p-0.5 bg-base border border-highlight/70 rounded-md hover:filter hover:brightness-125 cursor-pointer absolute right-1 top-1"><svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="#ff1919" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
|
||||
</svg></button>
|
||||
</div>
|
||||
{{/each}}
|
||||
<div onclick="openModal('link', {{this.ID}})"
|
||||
class="rounded-2xl border border-dashed border-subtle p-2.5 flex flex-row items-center hover:underline transition-[box-shadow,transform] ease-[cubic-bezier(0.45,0,0.55,1)] duration-150 pointer-cursor select-none cursor-pointer">
|
||||
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M12 5v14m-7-7h14" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3>Add a link</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
<div class="flex items-center">
|
||||
<svg class="mr-2" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M12 5v14m-7-7h14" />
|
||||
</svg>
|
||||
<h2 onclick="openModal('category')" class="text-subtle underline decoration-dashed cursor-pointer">
|
||||
Add a new category
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="modal-container"
|
||||
class="flex modal-bg fixed top-0 left-0 bottom-0 right-0 bg-black/45 justify-center items-center">
|
||||
<div class="bg-overlay rounded-xl overflow-hidden w-full p-4 modal max-w-sm">
|
||||
<div id="category-contents" class="hidden">
|
||||
<h3>Create A category</h3>
|
||||
<form id="category-form" action="/api/categories" method="post"
|
||||
class="flex flex-col gap-y-3 my-2 [&>div]:flex [&>div]:flex-col [&>div]:gap-1">
|
||||
<div>
|
||||
<label for="categoryName">Name</label>
|
||||
<input required type="text" name="name" id="categoryName" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="linkIcon">Icon</label>
|
||||
<input type="file" name="icon" id="linkIcon" accept=".svg" required />
|
||||
</div>
|
||||
<button class="px-4 py-2 rounded-md w-full bg-accent text-white border-0" type="submit">Create
|
||||
category</button>
|
||||
</form>
|
||||
<span id="category-message"></span>
|
||||
</div>
|
||||
|
||||
<div id="link-contents" class="hidden">
|
||||
<h3>Add A link</h3>
|
||||
<form id="link-form" action="/api/links" method="post"
|
||||
class="flex flex-col gap-y-3 my-2 [&>div]:flex [&>div]:flex-col [&>div]:gap-1">
|
||||
<div>
|
||||
<label for="linkName">Name</label>
|
||||
<input required type="text" name="name" id="linkName" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="linkDesc">Description (optional)</label>
|
||||
<input type="text" name="description" id="linkDesc" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="linkURL">URL</label>
|
||||
<input required type="url" name="url" id="linkURL" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="linkIcon">Icon</label>
|
||||
<input required type="file" name="icon" id="linkIcon" accept="image/*" />
|
||||
</div>
|
||||
<button class="px-4 py-2 rounded-md w-full bg-accent text-white border-0" type="submit">Add
|
||||
link</button>
|
||||
</form>
|
||||
<span id="link-message"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// idfk what this variable capitalization is, it's a mess
|
||||
let modalContainer = document.getElementById("modal-container");
|
||||
let modal = modalContainer.querySelector("div");
|
||||
let pageElement = document.getElementById("blur-target");
|
||||
let targetCategoryID = null;
|
||||
let activeModal = null;
|
||||
|
||||
function openModal(modalKind, categoryID) {
|
||||
activeModal = modalKind;
|
||||
targetCategoryID = categoryID;
|
||||
|
||||
pageElement.style.filter = "blur(20px)";
|
||||
document.getElementById(modalKind + "-contents").classList.remove("hidden");
|
||||
|
||||
modalContainer.classList.add("is-visible");
|
||||
modal.classList.add("is-visible");
|
||||
document.getElementById(modalKind + "-form").reset();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
pageElement.style.filter = "";
|
||||
|
||||
modalContainer.classList.remove("is-visible");
|
||||
modal.classList.remove("is-visible");
|
||||
|
||||
setTimeout(() => {
|
||||
document.getElementById(activeModal + "-contents").classList.add("hidden");
|
||||
activeModal = null;
|
||||
}, 300)
|
||||
|
||||
document.getElementById(activeModal + "-form").querySelectorAll("[required]").forEach((el) => {
|
||||
el.classList.remove("invalid:border-[#861024]!");
|
||||
});
|
||||
|
||||
targetCategoryID = null;
|
||||
}
|
||||
|
||||
modalContainer.addEventListener("click", (event) => {
|
||||
if (event.target === modalContainer) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
async function deleteLink(linkID, categoryID) {
|
||||
let res = await fetch(`/api/category/${categoryID}/link/${linkID}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
if (res.status === 200) {
|
||||
let linkEl = document.querySelector(`[key="link-${linkID}"]`);
|
||||
linkEl.remove();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCategory(categoryID) {
|
||||
let res = await fetch(`/api/category/${categoryID}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
if (res.status === 200) {
|
||||
let categoryEl = document.querySelector(`[key="category-${categoryID}"]`);
|
||||
// get the next element and remove it (its the link grid)
|
||||
let nextEl = categoryEl.nextElementSibling;
|
||||
nextEl.remove();
|
||||
categoryEl.remove();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("link-form").querySelector("button").addEventListener("click", (event) => {
|
||||
document.getElementById("link-form").querySelectorAll("[required]").forEach((el) => {
|
||||
el.classList.add("invalid:border-[#861024]!");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("link-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
let data = new FormData(event.target);
|
||||
|
||||
let res = await fetch(`/api/category/${targetCategoryID}/link`, {
|
||||
method: "POST",
|
||||
body: data
|
||||
});
|
||||
|
||||
if (res.status === 201) {
|
||||
closeModal('link');
|
||||
document.getElementById("link-form").reset();
|
||||
location.reload();
|
||||
} else {
|
||||
let json = await res.json();
|
||||
document.getElementById("category-message").innerText = json.message;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("category-form").querySelector("button").addEventListener("click", (event) => {
|
||||
document.getElementById("category-form").querySelectorAll("[required]").forEach((el) => {
|
||||
el.classList.add("invalid:border-[#861024]!");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("category-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
let data = new FormData(event.target);
|
||||
|
||||
let res = await fetch(`/api/category`, {
|
||||
method: "POST",
|
||||
body: data
|
||||
});
|
||||
|
||||
if (res.status === 201) {
|
||||
closeModal('category');
|
||||
document.getElementById("category-form").reset();
|
||||
location.reload();
|
||||
} else {
|
||||
let json = await res.json();
|
||||
document.getElementById("link-message").innerText = json.message;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.modal-bg {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-bg.is-visible {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.modal-bg {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
|
||||
transition: opacity 0.3s ease, visibility 0s 0.3s;
|
||||
transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
|
||||
}
|
||||
|
||||
.modal-bg.is-visible {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.modal {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
transition-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
|
||||
}
|
||||
|
||||
.modal.is-visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0) scale(1);
|
||||
transition-delay: 0s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
45
src/templates/views/admin/login.hbs
Normal file
45
src/templates/views/admin/login.hbs
Normal file
@@ -0,0 +1,45 @@
|
||||
<main class="flex justify-center items-center h-screen relative bg-base">
|
||||
<div class="flex bg-surface rounded-xl overflow-hidden">
|
||||
<img src="/assets/leaves.webp" class="h-96 w-64 object-cover" />
|
||||
<div class="flex flex-col p-4 text-center">
|
||||
<h2 class="text-2xl">
|
||||
Login
|
||||
</h2>
|
||||
<form action="/admin/login" method="post" class="flex flex-col gap-y-3 my-2">
|
||||
<input type="text" name="username" placeholder="Username" />
|
||||
<input type="password" name="password" placeholder="Password" />
|
||||
<button class="px-4 py-2 rounded-md w-full bg-accent text-white border-0" type="submit">Login</button>
|
||||
</form>
|
||||
<span id="message"></span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
let message = document.getElementById("message");
|
||||
let form = document.querySelector("form");
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
let data = {
|
||||
"username": form.username.value,
|
||||
"password": form.password.value
|
||||
};
|
||||
|
||||
console.log(data);
|
||||
|
||||
let res = await fetch("/admin/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
if (res.status === 200) {
|
||||
window.location.href = "/admin";
|
||||
return;
|
||||
}
|
||||
|
||||
message.innerText = (await res.json()).message;
|
||||
});
|
||||
</script>
|
||||
98
src/templates/views/index.hbs
Normal file
98
src/templates/views/index.hbs
Normal file
@@ -0,0 +1,98 @@
|
||||
<main class="grid grid-rows-3 grid-cols-[1fr] justify-center items-center h-screen bg-base">
|
||||
<div class="flex h-full p-2.5 justify-between">
|
||||
<div>
|
||||
{{#if WeatherData}}
|
||||
<div class="text-subtle flex items-center">
|
||||
<span class="mr-2 flex items-center">
|
||||
{{{WeatherData.Icon}}}
|
||||
</span>
|
||||
<div class="font-semibold">
|
||||
<p>{{WeatherData.Temp}}°C</p>
|
||||
<p>{{WeatherData.Desc}}</p>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div>
|
||||
{{#if UptimeData}}
|
||||
<div class="text-subtle flex items-end flex-col">
|
||||
{{#each UptimeData}}
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2 flex items-center">
|
||||
{{{this.FriendlyName}}}
|
||||
</span>
|
||||
<div class="relative my-auto size-2">
|
||||
<div class="relative my-auto size-2 flex-shrink-0 flex-grow-0">
|
||||
<svg class="absolute w-full h-full animate-ping" viewBox="0 0 10 10">
|
||||
<circle cx="5" cy="5" r="5"
|
||||
class="fill-current {{#if (eq this.Status 2)}} text-success {{else}} text-error {{/if}}">
|
||||
</circle>
|
||||
</svg>
|
||||
<svg class="relative w-full h-full" viewBox="0 0 10 10">
|
||||
<circle cx="5" cy="5" r="5"
|
||||
class="fill-current {{#if (eq this.Status 2)}} text-success {{else}} text-error {{/if}}">
|
||||
</circle>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-start-2 flex flex-col items-center w-full px-6">
|
||||
<div class="flex items-center pb-2.5">
|
||||
<svg class="mr-3 aspect-square w-[clamp(42px,10vw,60px)]" viewBox="0 0 100 100" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="12.1483" y="24.7693" width="70" height="47" rx="12" transform="rotate(14.63 12.1483 24.7693)"
|
||||
fill="url(#paint0_linear_20_10)" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M52.7386 13.4812C46.8869 10.3698 39.6209 12.5913 36.5096 18.4429L32.7819 25.4537L68.4322 34.7599C77.5166 37.1313 82.9586 46.418 80.5872 55.5025L74.7779 77.7567C74.7752 77.7674 74.7724 77.778 74.7696 77.7886C79.7728 78.7022 85.0029 76.3441 87.518 71.6138L98.3159 51.306C101.427 45.4543 99.2058 38.1883 93.3542 35.0769L52.7386 13.4812Z"
|
||||
fill="url(#paint1_linear_20_10)" />
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_20_10" x1="12.359" y1="44.8681" x2="82.491" y2="48.2607"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F0389B" />
|
||||
<stop offset="1" stop-color="#EEE740" />
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_20_10" x1="33.8935" y1="25.6926" x2="94.2236" y2="61.6131"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#AA38F0" />
|
||||
<stop offset="1" stop-color="#EE406A" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<h1>Passport</h1>
|
||||
</div>
|
||||
<form class="w-full max-w-3xl" action="{{ SearchProviderURL }}" method="GET">
|
||||
<input name="{{ SearchParam }}" aria-label="Search bar"
|
||||
class="w-full bg-surface border border-highlight-sm/70 rounded-full px-3 py-1 text-white h-7 focus-visible:outline-none placeholder:italic placeholder:text-highlight search"
|
||||
placeholder="Search..." />
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
<section class="flex justify-center w-full bg-surface">
|
||||
<div class="w-full sm:w-4/5 p-2.5">
|
||||
{{#each Categories}}
|
||||
<div class="flex items-center mt-2 first:mt-0">
|
||||
<img class="object-contain mr-2 select-none" width="32" height="32" draggable="false" alt="{{this.Name}}"
|
||||
src="{{this.Icon}}" />
|
||||
<h2 class="capitalize break-all">{{this.Name}}</h2>
|
||||
</div>
|
||||
<div class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2">
|
||||
{{#each this.Links}} <a href="{{this.URL}}" class="link-card" draggable="false" target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
<img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
|
||||
<div>
|
||||
<h3>{{this.Name}}</h3>
|
||||
<p>{{this.Description}}</p>
|
||||
</div>
|
||||
</a>
|
||||
{{else}}
|
||||
<p class="text-subtle">No links here, add one!</p>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</section>
|
||||
Reference in New Issue
Block a user