Compare commits
2 Commits
55e132d80b
...
v0.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
01a147d2d3
|
|||
|
462ed6491c
|
@@ -63,6 +63,9 @@ You can then run the binary.
|
||||
| `PASSPORT_SEARCH_PROVIDER` | The search provider to use for the search bar, without any query parameters | true |
|
||||
| `PASSPORT_SEARCH_PROVIDER_QUERY_PARAM` | The query parameter to use for the search provider, e.g. `q` for most providers | false | q |
|
||||
|
||||
> [!NOTE]
|
||||
> Currently passport only supports search using a GET request.
|
||||
|
||||
#### Weather configuration
|
||||
|
||||
The weather integration is optional, and will be enabled automatically if you provide an API key. The following only applies if you are using the OpenWeatherMap integration.
|
||||
|
||||
2
go.mod
2
go.mod
@@ -5,6 +5,8 @@ go 1.25.0
|
||||
require (
|
||||
github.com/HugoSmits86/nativewebp v1.2.0
|
||||
github.com/caarlos0/env/v11 v11.3.1
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
|
||||
golang.org/x/image v0.24.0
|
||||
modernc.org/sqlite v1.39.0
|
||||
)
|
||||
|
||||
6
go.sum
6
go.sum
@@ -7,6 +7,8 @@ github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vaui
|
||||
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/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
@@ -51,6 +53,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||
github.com/shamaton/msgpack/v2 v2.3.0 h1:eawIa7lQmwRv0V6rdmL/5Ev9KdJHk07eQH3ceJi3BUw=
|
||||
github.com/shamaton/msgpack/v2 v2.3.0/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
|
||||
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
||||
@@ -74,6 +78,7 @@ golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
@@ -86,6 +91,7 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
|
||||
371
src/main.go
371
src/main.go
@@ -1,4 +1,4 @@
|
||||
//go:generate tailwindcss -i styles/main.css -o assets/tailwind.css --minify
|
||||
//go:generate tailwindcss -i styles/main.scss -o assets/tailwind.css --minify
|
||||
|
||||
package main
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -27,6 +28,7 @@ import (
|
||||
|
||||
"github.com/HugoSmits86/nativewebp"
|
||||
"github.com/caarlos0/env/v11"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/helmet"
|
||||
"github.com/gofiber/fiber/v3/middleware/static"
|
||||
@@ -35,6 +37,8 @@ import (
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/juls0730/passport/src/middleware"
|
||||
"github.com/juls0730/passport/src/services"
|
||||
"github.com/rwcarlsen/goexif/exif"
|
||||
"github.com/rwcarlsen/goexif/tiff"
|
||||
"golang.org/x/image/draw"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
@@ -44,23 +48,36 @@ var embeddedAssets embed.FS
|
||||
|
||||
var devContent = `<script>
|
||||
let host = window.location.hostname;
|
||||
const socket = new WebSocket('ws://' + host + ':2067/ws');
|
||||
let protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const socket = new WebSocket(protocol + '//' + host + ':2067/ws');
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
if (event.data === 'refresh') {
|
||||
console.log('Got refresh signal');
|
||||
|
||||
let attempts = 0;
|
||||
let delay = 100;
|
||||
|
||||
async function testPage() {
|
||||
try {
|
||||
let res = await fetch(window.location.href)
|
||||
let res = await fetch(window.location.href)
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setTimeout(testPage, 300);
|
||||
if (attempts > 5) {
|
||||
return;
|
||||
}
|
||||
setTimeout(testPage, delay);
|
||||
|
||||
// exponential backoff
|
||||
attempts++;
|
||||
delay = 100 * Math.pow(2, attempts);
|
||||
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
testPage();
|
||||
}
|
||||
setTimeout(testPage, 150);
|
||||
}
|
||||
});
|
||||
</script>`
|
||||
|
||||
@@ -282,8 +299,45 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe
|
||||
return "", errors.New("unsupported file type")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
if contentType != "image/svg+xml" {
|
||||
off, err := srcFile.Seek(0, io.SeekStart)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to seek to start of file: %v", err)
|
||||
}
|
||||
|
||||
if off != 0 {
|
||||
return "", fmt.Errorf("failed to seek to start of file: %v", err)
|
||||
}
|
||||
|
||||
x, err := exif.Decode(srcFile)
|
||||
// if there *is* exif, parse it
|
||||
if err == nil {
|
||||
tag, err := x.Get(exif.Orientation)
|
||||
if err == nil {
|
||||
if tag.Count == 1 && tag.Format() == tiff.IntVal {
|
||||
orientation, err := tag.Int(0)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get orientation: %v", err)
|
||||
}
|
||||
|
||||
slog.Debug("Orientation tag found", "orientation", orientation)
|
||||
|
||||
switch orientation {
|
||||
case 3:
|
||||
img = imaging.Rotate180(img)
|
||||
case 6:
|
||||
img = imaging.Rotate270(img)
|
||||
case 8:
|
||||
img = imaging.Rotate90(img)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
img, err = CropToCenter(img, 96)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
assetsDir := "public/uploads"
|
||||
@@ -291,7 +345,21 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe
|
||||
iconPath := filepath.Join(assetsDir, fileName)
|
||||
|
||||
if contentType == "image/svg+xml" {
|
||||
if err = c.SaveFile(file, iconPath); err != nil {
|
||||
// replace currentColor with a text color
|
||||
outFile, err := os.Create(iconPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
svgText, err := io.ReadAll(srcFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
svgText = bytes.ReplaceAll(svgText, []byte("currentColor"), []byte(`oklch(87% 0.015 286)`))
|
||||
_, err = outFile.Write(svgText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
@@ -301,16 +369,9 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe
|
||||
}
|
||||
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 {
|
||||
if err := nativewebp.Encode(&buf, img, options); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -319,7 +380,7 @@ func UploadFile(file *multipart.FileHeader, fileName, contentType string, c fibe
|
||||
}
|
||||
}
|
||||
|
||||
iconPath = "/uploads/" + fileName
|
||||
iconPath = "/uploads/" + url.PathEscape(fileName)
|
||||
|
||||
return iconPath, nil
|
||||
}
|
||||
@@ -790,6 +851,14 @@ func main() {
|
||||
})
|
||||
}
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
|
||||
if len(req.Name) > 50 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"message": "Name is too long. Maximum length is 50 characters",
|
||||
})
|
||||
}
|
||||
|
||||
file, err := c.FormFile("icon")
|
||||
if err != nil || file == nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
@@ -810,7 +879,7 @@ func main() {
|
||||
})
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%d_%s.svg", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_"))
|
||||
filename := fmt.Sprintf("%d_%s.svg", time.Now().Unix(), strings.ReplaceAll(req.Name[:min(10, len(req.Name))], " ", "_"))
|
||||
|
||||
iconPath, err := UploadFile(file, filename, contentType, c)
|
||||
if err != nil {
|
||||
@@ -819,8 +888,6 @@ func main() {
|
||||
})
|
||||
}
|
||||
|
||||
UploadFile(file, iconPath, contentType, c)
|
||||
|
||||
category, err := app.CategoryManager.CreateCategory(Category{
|
||||
Name: req.Name,
|
||||
Icon: iconPath,
|
||||
@@ -857,6 +924,23 @@ func main() {
|
||||
})
|
||||
}
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Description != "" {
|
||||
req.Description = strings.TrimSpace(req.Description)
|
||||
}
|
||||
|
||||
if len(req.Name) > 50 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"message": "Name is too long. Maximum length is 50 characters",
|
||||
})
|
||||
}
|
||||
|
||||
if len(req.Description) > 150 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"message": "Description is too long. Maximum length is 150 characters",
|
||||
})
|
||||
}
|
||||
|
||||
categoryID, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
@@ -890,7 +974,7 @@ func main() {
|
||||
})
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%d_%s.webp", time.Now().Unix(), strings.ReplaceAll(req.Name, " ", "_"))
|
||||
filename := fmt.Sprintf("%d_%s.webp", time.Now().Unix(), strings.ReplaceAll(req.Name[:min(10, len(req.Name))], " ", "_"))
|
||||
|
||||
iconPath, err := UploadFile(file, filename, contentType, c)
|
||||
if err != nil {
|
||||
@@ -900,8 +984,6 @@ func main() {
|
||||
})
|
||||
}
|
||||
|
||||
UploadFile(file, iconPath, contentType, c)
|
||||
|
||||
link, err := app.CategoryManager.CreateLink(app.CategoryManager.db, Link{
|
||||
CategoryID: categoryID,
|
||||
Name: req.Name,
|
||||
@@ -922,6 +1004,245 @@ func main() {
|
||||
})
|
||||
})
|
||||
|
||||
api.Patch("/category/:id", func(c fiber.Ctx) error {
|
||||
var req struct {
|
||||
Name string `form:"name"`
|
||||
}
|
||||
|
||||
if c.Params("id") == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"message": "ID is required",
|
||||
})
|
||||
}
|
||||
|
||||
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 err := c.Bind().Form(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"message": "Failed to parse request",
|
||||
})
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
if len(req.Name) > 50 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"message": "Name is too long. Maximum length is 50 characters",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
category := app.CategoryManager.GetCategory(id)
|
||||
if category == nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"message": "Category not found",
|
||||
})
|
||||
}
|
||||
|
||||
tx, err := app.db.Begin()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"message": "Failed to start transaction",
|
||||
})
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
file, err := c.FormFile("icon")
|
||||
if err == nil {
|
||||
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 svg files are allowed",
|
||||
})
|
||||
}
|
||||
|
||||
oldIconPath := category.Icon
|
||||
|
||||
filename := fmt.Sprintf("%d_%s.svg", time.Now().Unix(), strings.ReplaceAll(req.Name[:min(10, len(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!",
|
||||
})
|
||||
}
|
||||
|
||||
_, err = tx.Exec("UPDATE categories SET icon = ? WHERE id = ?", iconPath, id)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"message": "Failed to update category",
|
||||
})
|
||||
}
|
||||
|
||||
err = os.Remove(filepath.Join("public/", oldIconPath))
|
||||
if err != nil {
|
||||
slog.Error("Failed to delete icon", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
_, err = tx.Exec("UPDATE categories SET name = ? WHERE id = ?", req.Name, category.ID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"message": "Failed to update category",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"message": "Failed to commit transaction",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"message": "Category updated successfully",
|
||||
})
|
||||
})
|
||||
|
||||
api.Patch("/category/:categoryID/link/:linkID", func(c fiber.Ctx) error {
|
||||
var req struct {
|
||||
Name string `form:"name"`
|
||||
Description string `form:"description"`
|
||||
Icon string `form:"icon"`
|
||||
}
|
||||
if err := c.Bind().Form(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"message": "Failed to parse request",
|
||||
})
|
||||
}
|
||||
|
||||
if len(req.Name) > 50 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"message": "Name is too long. Maximum length is 50 characters",
|
||||
})
|
||||
}
|
||||
|
||||
if len(req.Description) > 150 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"message": "Description is too long. Maximum length is 150 characters",
|
||||
})
|
||||
}
|
||||
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
tx, err := app.db.Begin()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"message": "Failed to start transaction",
|
||||
})
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
file, err := c.FormFile("icon")
|
||||
if err == nil {
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
oldIconPath := link.Icon
|
||||
|
||||
filename := fmt.Sprintf("%d_%s.webp", time.Now().Unix(), strings.ReplaceAll(req.Name[:min(10, len(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!",
|
||||
})
|
||||
}
|
||||
|
||||
_, err = tx.Exec("UPDATE links SET icon = ? WHERE id = ?", iconPath, linkID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"message": "Failed to update link",
|
||||
})
|
||||
}
|
||||
|
||||
err = os.Remove(filepath.Join("public/", oldIconPath))
|
||||
if err != nil {
|
||||
slog.Error("Failed to delete icon", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
_, err = tx.Exec("UPDATE links SET name = ? WHERE id = ?", req.Name, linkID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"message": "Failed to update link",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if req.Description != "" {
|
||||
_, err = tx.Exec("UPDATE links SET description = ? WHERE id = ?", req.Description, linkID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"message": "Failed to update link",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"message": "Failed to commit transaction",
|
||||
})
|
||||
}
|
||||
|
||||
slog.Info("Link updated successfully", "id", linkID, "name", req.Name)
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"message": "Link updated successfully",
|
||||
})
|
||||
})
|
||||
|
||||
api.Delete("/category/:categoryID/link/:linkID", func(c fiber.Ctx) error {
|
||||
linkID, err := strconv.ParseInt(c.Params("linkID"), 10, 64)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
@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-error: oklch(53% 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-surface: oklch(19% 0.007 285.66);
|
||||
--color-overlay: oklch(26% 0.008 285.66);
|
||||
|
||||
--color-muted: oklch(63% 0.015 286);
|
||||
--color-subtle: oklch(72% 0.015 286);
|
||||
@@ -61,7 +61,7 @@ button {
|
||||
}
|
||||
|
||||
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;
|
||||
@apply px-4 py-2 rounded-md w-full bg-surface border border-highlight/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;
|
||||
@@ -84,15 +84,18 @@ input:not(.search) {
|
||||
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);
|
||||
contain: layout style paint;
|
||||
|
||||
&: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);
|
||||
}
|
||||
&:not(.admin) {
|
||||
&: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);
|
||||
&:active {
|
||||
box-shadow: 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
transform: translateY(2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,18 +113,47 @@ input:not(.search) {
|
||||
}
|
||||
}
|
||||
|
||||
.link-card img {
|
||||
/* Div that holds the image */
|
||||
.link-card div[data-img-container] {
|
||||
flex-shrink: 0;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.link-card div[data-img-container] img {
|
||||
user-select: none;
|
||||
border-radius: 0.375rem;
|
||||
aspect-ratio: 1/1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.link-card div {
|
||||
/* Div that holds the text */
|
||||
.link-card div[data-text-container] {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.link-card div p {
|
||||
.link-card div[data-text-container] p {
|
||||
color: var(--color-subtle);
|
||||
}
|
||||
|
||||
|
||||
.categoy-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.category-header div[data-img-container] {
|
||||
@apply shrink-0 relative mr-2 h-full flex items-center justify-center size-8;
|
||||
}
|
||||
|
||||
.categoy-header div[data-img-container] img {
|
||||
user-select: none;
|
||||
object-fit: cover;
|
||||
aspect-ratio: 1/1;
|
||||
}
|
||||
|
||||
.category-header h2 {
|
||||
text-transform: capitalize;
|
||||
word-break: break-all;
|
||||
border-width: 1px;
|
||||
border-color: #0000;
|
||||
}
|
||||
17
src/templates/partials/modals/category-form.hbs
Normal file
17
src/templates/partials/modals/category-form.hbs
Normal file
@@ -0,0 +1,17 @@
|
||||
<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" maxlength="50" />
|
||||
</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>
|
||||
13
src/templates/partials/modals/delete-category.hbs
Normal file
13
src/templates/partials/modals/delete-category.hbs
Normal file
@@ -0,0 +1,13 @@
|
||||
<div id="category-delete-contents" class="hidden text-center">
|
||||
<h3>Are you sure you want to delete this category?</h3>
|
||||
<p class="mb-3">You are about to delete the category <strong id="category-name"></strong>. This action cannot be
|
||||
undone.
|
||||
All links associated with this category will also be deleted. Are you sure you want to continue?</p>
|
||||
<div class="flex justify-end flex-col gap-y-2">
|
||||
<button class="px-4 py-2 rounded-md w-full bg-error text-white border-0"
|
||||
onclick="confirmDeleteCategory()">Delete
|
||||
category</button>
|
||||
<button class="px-4 py-2 rounded-md w-full bg-overlay border border-highlight text-white"
|
||||
onclick="closeModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
12
src/templates/partials/modals/delete-link.hbs
Normal file
12
src/templates/partials/modals/delete-link.hbs
Normal file
@@ -0,0 +1,12 @@
|
||||
<div id="link-delete-contents" class="hidden text-center">
|
||||
<h3>Are you sure you want to delete this link?</h3>
|
||||
<p class="mb-3">You are about to delete the link <strong id="link-name"></strong>. This action cannot be undone. Are
|
||||
you sure you
|
||||
want to continue?</p>
|
||||
<div class="flex justify-end flex-col gap-y-2">
|
||||
<button class="px-4 py-2 rounded-md w-full bg-error text-white border-0" onclick="confirmDeleteLink()">Delete
|
||||
link</button>
|
||||
<button class="px-4 py-2 rounded-md w-full bg-overlay border border-highlight text-white"
|
||||
onclick="closeModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
25
src/templates/partials/modals/link-form.hbs
Normal file
25
src/templates/partials/modals/link-form.hbs
Normal file
@@ -0,0 +1,25 @@
|
||||
<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" maxlength="50" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="linkDesc">Description (optional)</label>
|
||||
<input type="text" name="description" id="linkDesc" maxlength="150" />
|
||||
</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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -76,17 +76,19 @@
|
||||
<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}}" />
|
||||
<img class="object-contain mr-2 select-none size-8" 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>
|
||||
<div data-img-container>
|
||||
<img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
|
||||
</div>
|
||||
<div data-text-container>
|
||||
<h3>{{this.Name}}</h3>
|
||||
<p>{{this.Description}}</p>
|
||||
<p class="min-h-5">{{this.Description}}</p>
|
||||
</div>
|
||||
</a>
|
||||
{{else}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "passport",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Passport is a simple, lightweight, and fast dashboard/new tab page for your browser.",
|
||||
"author": "juls0730",
|
||||
"license": "BSL-1.0",
|
||||
@@ -11,8 +11,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "go generate ./src/; PASSPORT_DEV_MODE=true go run src/main.go",
|
||||
"build": "go generate ./src/ && go build -tags netgo,prod -ldflags=\"-w -s\" -o passport"
|
||||
"build": "go generate ./src/ && go build -tags netgo,prod -ldflags=\"-w -s\" -o passport src/main.go"
|
||||
},
|
||||
"pattern": "src/**/*.{go,hbs,css,svg,png,jpg,jpeg,webp,woff2,ico,webp}",
|
||||
"pattern": "src/**/*.{go,hbs,scss,svg,png,jpg,jpeg,webp,woff2,ico,webp}",
|
||||
"shutdown_signal": "SIGINT"
|
||||
}
|
||||
Reference in New Issue
Block a user