17 Commits

Author SHA1 Message Date
Zoe
01a147d2d3 v0.3.1: More admin UI improvements and bug fixes
All checks were successful
Build and Push Docker Image to GHCR / build-and-push (push) Successful in 29s
This commit further refines the admin UI, and introduces a very SPA-like
creating process for links and categories. In-place editing has also
been improved, the styling is more correct and better formatted, as well
as having some cleaner code.

This PR also fixes a few bugs:
- Image uploads not being URL encoded, so special characters would break
  images
- If an image has exif, but no orientation tag, the image would be
  wrongfully rejected
- In-place editing forms were not correctly sized, and title inputs
  would not break with line breaks in the titles

This PR also greatly improves performance on the admin UI.
2025-09-30 19:45:58 -05:00
Zoe
462ed6491c Vastly overhaul admin UI
All checks were successful
Build and Push Docker Image to GHCR / build-and-push (push) Successful in 29s
Admin UI now has the ability to edit links that exist. Deleting items is
more accessible and asks for a confirmation before deleting. Link and
Category names as well as link descriptions now have a length limit
(todo: make it configurable?). Small bug fixes related to image saving
are also included in this commit.
2025-09-30 01:14:18 -05:00
Zoe
cd6ac6e771 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.
2025-09-29 16:41:47 -05:00
Zoe
8c9ad40776 consistent and better styling 2025-09-29 16:37:27 +00:00
Zoe
83512c3584 make workflow more generic
All checks were successful
Build and Push Docker Image to GHCR / build-and-push (push) Successful in 31s
2025-09-24 16:39:24 +00:00
Zoe
b75337f450 cleaned up templates a bit, and some style fixes 2025-09-24 15:39:16 +00:00
Zoe
770472c30c Reduce release size
Some checks failed
Build and Push Docker Image to GHCR / build-and-push (push) Failing after 2m42s
Switch to all pure go libraries to no longer depends on libc, allowing
us to use a static container image. Compress binary using UPX for an
addition 7MB
2025-09-23 16:21:05 +00:00
Zoe
8e6753ebea fixup docs and use zqdgr in the build process 2025-09-23 13:45:05 +00:00
Zoe
bc8b9d172b style changes, nicer link management, api reorg
Some checks are pending
Build and Push Docker Image to GHCR / build-and-push (push) Waiting to run
2025-09-22 21:31:35 -05:00
Zoe
a1e5346fdf fix(docker release): install tailwindcss and generate css files
Some checks are pending
Build and Push Docker Image to GHCR / build-and-push (push) Waiting to run
2025-09-22 19:00:12 -05:00
Zoe
5b8177bd12 improve database handling and category management, enhance admin UI with animations 2025-09-22 18:49:53 -05:00
Zoe
8c18e81358 docker releases 2025-09-22 18:49:05 -05:00
Zoe
0558c719d6 bump(deps): fiber 2025-09-22 18:32:58 -05:00
Zoe
d31f24c101 I wish it was impossible to make this mistake 2025-04-19 01:08:38 -05:00
Zoe
d8f3c4fd69 fix styling on cards 2025-04-09 13:08:07 +00:00
b0e891a59b Update README.md 2025-04-09 04:55:12 -05:00
Zoe
bf466b26ac Better docs, bug fixes, and config structure
The project now uses a more sensible config structure to allow for
actual defaults. Passport now has NO reliance on javascript whatsoever,
and can be used identically without javascript enabled.
2025-04-09 04:48:38 -05:00
29 changed files with 3241 additions and 1349 deletions

View File

@@ -1,7 +1,8 @@
PASSPORT_ENABLE_WEATHER=true
OPENWEATHER_API_KEY=1234567890 OPENWEATHER_API_KEY=1234567890
OPENWEATHER_LAT=34.052235 OPENWEATHER_LAT=34.052235
OPENWEATHER_LON=-118.243683 OPENWEATHER_LON=-118.243683
OPENWEATHER_UPDATE_INTERVAL=15 OPENWEATHER_UPDATE_INTERVAL=15
PASSPORT_ADMIN_USERNAME=admin PASSPORT_ADMIN_USERNAME=admin
PASSPORT_ADMIN_PASSWORD=P@ssw0rd PASSPORT_ADMIN_PASSWORD=P@ssw0rd
PASSPORT_SEARCH_PROVIDER=https://google.com/search?q=%s PASSPORT_SEARCH_PROVIDER=https://google.com/search

42
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: Build and Push Docker Image to GHCR
env:
OCI_TOKEN: ${{ secrets.OCI_TOKEN || secrets.GITHUB_TOKEN }}
on:
push:
branches: ["main"]
tags: ["v*.*.*"]
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ vars.OCI_REGISTRY || 'ghcr.io' }}
username: ${{ github.actor }}
password: ${{ env.OCI_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ vars.OCI_REGISTRY || 'ghcr.io' }}/${{ github.repository }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

6
.gitignore vendored
View File

@@ -1,6 +1,8 @@
passport passport
.env .env
passport.db* passport.db*
public/uploads/ public
zqdgr zqdgr
assets/tailwind.css
# compiled via go prepare
src/assets/tailwind.css

31
Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
FROM golang:1.25 AS builder
# build dependencies
RUN apt update && apt install -y upx
RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.13/tailwindcss-linux-x64
RUN chmod +x tailwindcss-linux-x64
RUN mv tailwindcss-linux-x64 /usr/local/bin/tailwindcss
RUN go install github.com/juls0730/zqdgr@latest
WORKDIR /app
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN zqdgr build
RUN upx passport
# ---- Runtime Stage ----
FROM gcr.io/distroless/static-debian12 AS runner
WORKDIR /data
COPY --from=builder /app/passport /usr/local/bin/passport
EXPOSE 3000
CMD ["/usr/local/bin/passport"]

View File

@@ -6,7 +6,7 @@ Passport is a simple, fast, and lightweight web dashboard/new tab replacement.
## Getting Started ## Getting Started
![Screenshot 2025-03-28 at 07-44-22 Passport](https://github.com/user-attachments/assets/d31b0694-3445-46f8-af01-158703e44b4c) ![Screenshot of Passport](/screenshot.png)
### Prerequisites ### Prerequisites
@@ -15,33 +15,83 @@ Passport is a simple, fast, and lightweight web dashboard/new tab replacement.
- [sqlite3](https://www.sqlite.org/download.html) - [sqlite3](https://www.sqlite.org/download.html)
- [TailwdinCSS CLI](https://github.com/tailwindlabs/tailwindcss/releases/latest) - [TailwdinCSS CLI](https://github.com/tailwindlabs/tailwindcss/releases/latest)
### Usage ## Usage
1. Clone the repository ### Docker
2. Configure the `.env` file, an example is provided in the `.env.example` file, see below for every available environment variable
4. Deploy `passport` to your web server
5. profit
#### Configuration Passport is available as a Docker image via this repository. This is the recommended way to run Passport.
```bash
docker run -d --name passport -p 3000:3000 -v passport_data:/data -e PASSPORT_ADMIN_USERNAME=admin -e PASSPORT_ADMIN_PASSWORD=password ghcr.io/juls0730/passport:latest
```
Make sure to change the admin password to something secure. At `/data` is where all of passport's persistent data will be stored, such as image uploads and the sqlite database.
### Building from source
If you want to build from source, you will need to install the dependencies first.
```bash
# note entirely necessary, but strongly recommended
go install github.com/juls0730/zqdgr@latest
curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.13/tailwindcss-linux-x64
chmod +x tailwindcss-linux-x64
mv tailwindcss-linux-x64 /usr/local/bin/tailwindcss
# you may also have to install sqlite3...
```
Then you can build the binary.
```bash
zqdgr build
```
You can then run the binary.
### Configuration
#### Passport configuration
| Environment Variable | Description | Required | Default | | Environment Variable | Description | Required | Default |
| --- | --- | --- | --- | | -------------------------------------- | ------------------------------------------------------------------------------- | -------- | ------- |
| `PASSPORT_DEV_MODE` | Enables dev mode | false | false | | `PASSPORT_DEV_MODE` | Enables dev mode | false | false |
| `PASSPORT_ENABLE_WEATHER` | Enables weather data, requires an OpenWeather API key | false | true | | `PASSPORT_ENABLE_PREFORK` | Enables preforking | false | false |
| `PASSPORT_ENABLE_UPTIME` | Enables uptime data, requires an UptimeRobot API key | false | true |
| `PASSPORT_ADMIN_USERNAME` | The username for the admin dashboard | true | | `PASSPORT_ADMIN_USERNAME` | The username for the admin dashboard | true |
| `PASSPORT_ADMIN_PASSWORD` | The password for the admin dashboard | true | | `PASSPORT_ADMIN_PASSWORD` | The password for the admin dashboard | true |
| `PASSPORT_SEARCH_PROVIDER` | The search provider to use for the search bar | true | | `PASSPORT_SEARCH_PROVIDER` | The search provider to use for the search bar, without any query parameters | true |
| `OPENWEATHER_API_KEY` | The OpenWeather API key | if enabled | | `PASSPORT_SEARCH_PROVIDER_QUERY_PARAM` | The query parameter to use for the search provider, e.g. `q` for most providers | false | q |
| `OPENWEATHER_LAT` | The latitude of your location | if enabled |
| `OPENWEATHER_LON` | The longitude of your location | if enabled | > [!NOTE]
| `OPENWEATHER_UPDATE_INTERVAL` | The interval in minutes to update the weather data | false | 15 | > Currently passport only supports search using a GET request.
| `UPTIMEROBOT_API_KEY` | The UptimeRobot API key | if enabled |
| `UPTIMEROBOT_UPDATE_INTERVAL` | The interval in seconds to update the uptime data | false | 300 | #### 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.
| Environment Variable | Description | Required | Default |
| ------------------------- | ------------------------------------------------------------------------- | -------- | -------------- |
| `WEATHER_PROVIDER` | The weather provider to use, currently only `openweathermap` is supported | false | openweathermap |
| `WEATHER_API_KEY` | The OpenWeather API key | true | |
| `WEATHER_TEMP_UNITS` | The temperature units to use, either `metric` or `imperial` | false | metric |
| `WEATHER_LAT` | The latitude of your location | true | |
| `WEATHER_LON` | The longitude of your location | true | |
| `WEATHER_UPDATE_INTERVAL` | The interval in minutes to update the weather data | false | 15 |
#### Uptime configuration
The uptime integration is optional, and will be enabled automatically if you provide an API key. The following only applies if you are using the UptimeRobot integration.
| Environment Variable | Description | Required | Default |
| ------------------------ | ------------------------------------------------- | -------- | ------- |
| `UPTIME_API_KEY` | The UptimeRobot API key | true | |
| `UPTIME_UPDATE_INTERVAL` | The interval in seconds to update the uptime data | false | 300 |
### Adding links and categories ### Adding links and categories
The admin dashboard can be accessed at `/admin`, you will be redirected to the login page if you are not logged in, use the credentials you configured in the `.env` file to login. Once logged in you can add links and categories. The admin dashboard can be accessed at `/admin`, you will be redirected to the login page if you are not logged in, use
the credentials you configured via the environment variables to login. Once logged in you can add links and categories.
## License ## License

54
go.mod
View File

@@ -1,40 +1,50 @@
module github.com/juls0730/passport module github.com/juls0730/passport
go 1.23.2 go 1.25.0
require ( require (
github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/HugoSmits86/nativewebp v1.2.0
github.com/gofiber/schema v1.2.0 // indirect github.com/caarlos0/env/v11 v11.3.1
github.com/mailgun/raymond/v2 v2.0.48 // indirect github.com/disintegration/imaging v1.6.2
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
github.com/sirupsen/logrus v1.8.1 // indirect golang.org/x/image v0.24.0
github.com/tinylib/msgp v1.2.5 // indirect modernc.org/sqlite v1.39.0
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/net v0.31.0 // indirect
golang.org/x/text v0.21.0 // indirect
) )
require ( require (
github.com/andybalholm/brotli v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/chai2010/webp v1.1.1 github.com/gofiber/schema v1.6.0 // indirect
github.com/mailgun/raymond/v2 v2.0.48 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/tinylib/msgp v1.4.0 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/text v0.29.0 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/gofiber/fiber/v2 v2.52.5 // indirect github.com/gofiber/fiber/v2 v2.52.5 // indirect
github.com/gofiber/fiber/v3 v3.0.0-beta.4 github.com/gofiber/fiber/v3 v3.0.0-rc.1
github.com/gofiber/template v1.8.3 // indirect github.com/gofiber/template v1.8.3 // indirect
github.com/gofiber/template/handlebars/v2 v2.1.10 github.com/gofiber/template/handlebars/v2 v2.1.10
github.com/gofiber/utils v1.1.0 // indirect github.com/gofiber/utils v1.1.0 // indirect
github.com/gofiber/utils/v2 v2.0.0-beta.7 // indirect github.com/gofiber/utils/v2 v2.0.0-rc.1 // indirect
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mattn/go-sqlite3 v1.14.24
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/rivo/uniseg v0.2.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.58.0 // indirect github.com/valyala/fasthttp v1.66.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect golang.org/x/sys v0.36.0 // indirect
golang.org/x/sys v0.28.0 // indirect
) )

140
go.sum
View File

@@ -1,96 +1,130 @@
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/HugoSmits86/nativewebp v1.2.0 h1:XJtXeTg7FsOi9VB1elQYZy3n6VjYLqofSr3gGRLUOp4=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/HugoSmits86/nativewebp v1.2.0/go.mod h1:YNQuWenlVmSUUASVNhTDwf4d7FwYQGbGhklC8p72Vr8=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/chai2010/webp v1.1.1 h1:jTRmEccAJ4MGrhFOrPMpNGIJ/eybIgwKpcACsrTEapk= github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
github.com/chai2010/webp v1.1.1/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU= github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 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=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo=
github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
github.com/gofiber/fiber/v3 v3.0.0-beta.3 h1:7Q2I+HsIqnIEEDB+9oe7Gadpakh6ZLhXpTYz/L20vrg= github.com/gofiber/fiber/v3 v3.0.0-rc.1 h1:034MxesK6bqGkidP+QR+Ysc1ukOacBWOHCarCKC1xfg=
github.com/gofiber/fiber/v3 v3.0.0-beta.3/go.mod h1:kcMur0Dxqk91R7p4vxEpJfDWZ9u5IfvrtQc8Bvv/JmY= github.com/gofiber/fiber/v3 v3.0.0-rc.1/go.mod h1:hFdT00oT0XVuQH1/z2i5n1pl/msExHDUie1SsLOkCuM=
github.com/gofiber/fiber/v3 v3.0.0-beta.4 h1:KzDSavvhG7m81NIsmnu5l3ZDbVS4feCidl4xlIfu6V0= github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
github.com/gofiber/fiber/v3 v3.0.0-beta.4/go.mod h1:/WFUoHRkZEsGHyy2+fYcdqi109IVOFbVwxv1n1RU+kk= github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
github.com/gofiber/schema v1.2.0 h1:j+ZRrNnUa/0ZuWrn/6kAtAufEr4jCJ+JuTURAMxNSZg=
github.com/gofiber/schema v1.2.0/go.mod h1:YYwj01w3hVfaNjhtJzaqetymL56VW642YS3qZPhuE6c=
github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc= github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8= github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
github.com/gofiber/template/handlebars/v2 v2.1.10 h1:Qc+uUMULCqW60LF4VKO1REpiyDAUy3vqW7xq66FPJGM= github.com/gofiber/template/handlebars/v2 v2.1.10 h1:Qc+uUMULCqW60LF4VKO1REpiyDAUy3vqW7xq66FPJGM=
github.com/gofiber/template/handlebars/v2 v2.1.10/go.mod h1:84WH9st5OJi255EGjuMAOqUVQ+Q2jUNhUKYbS5DgAcI= github.com/gofiber/template/handlebars/v2 v2.1.10/go.mod h1:84WH9st5OJi255EGjuMAOqUVQ+Q2jUNhUKYbS5DgAcI=
github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM= github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0= github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
github.com/gofiber/utils/v2 v2.0.0-beta.4 h1:1gjbVFFwVwUb9arPcqiB6iEjHBwo7cHsyS41NeIW3co= github.com/gofiber/utils/v2 v2.0.0-rc.1 h1:b77K5Rk9+Pjdxz4HlwEBnS7u5nikhx7armQB8xPds4s=
github.com/gofiber/utils/v2 v2.0.0-beta.4/go.mod h1:sdRsPU1FXX6YiDGGxd+q2aPJRMzpsxdzCXo9dz+xtOY= github.com/gofiber/utils/v2 v2.0.0-rc.1/go.mod h1:Y1g08g7gvST49bbjHJ1AVqcsmg93912R/tbKWhn6V3E=
github.com/gofiber/utils/v2 v2.0.0-beta.7 h1:NnHFrRHvhrufPABdWajcKZejz9HnCWmT/asoxRsiEbQ= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/gofiber/utils/v2 v2.0.0-beta.7/go.mod h1:J/M03s+HMdZdvhAeyh76xT72IfVqBzuz/OJkrMa7cwU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/mailgun/raymond/v2 v2.0.48 h1:5dmlB680ZkFG2RN/0lvTAghrSxIESeu9/2aeDqACtjw= github.com/mailgun/raymond/v2 v2.0.48 h1:5dmlB680ZkFG2RN/0lvTAghrSxIESeu9/2aeDqACtjw=
github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18= github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
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 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 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= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8=
github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po= github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8= github.com/valyala/fasthttp v1.66.0 h1:M87A0Z7EayeyNaV6pfO3tUTUiYO0dZfEJnRGXTVNuyU=
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM= github.com/valyala/fasthttp v1.66.0/go.mod h1:Y4eC+zwoocmXSVCB1JmhNbYtS7tZPRI2ztPB72EVObs=
github.com/valyala/fasthttp v1.58.0 h1:GGB2dWxSbEprU9j0iMJHgdKYJVDyjrOwF9RE59PbRuE=
github.com/valyala/fasthttp v1.58.0/go.mod h1:SYXvHHaFp7QZHGKSHmoMipInhrI5StHrhDTYVEjK/Kw=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= 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=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

947
main.go
View File

@@ -1,947 +0,0 @@
//go:generate tailwindcss -i styles/main.css -o assets/tailwind.css --minify
package main
import (
"bytes"
"database/sql"
"embed"
"encoding/json"
"errors"
"fmt"
"image"
"image/jpeg"
"image/png"
"io"
"io/fs"
"log"
"log/slog"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/chai2010/webp"
"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/middleware"
_ "github.com/mattn/go-sqlite3"
"github.com/nfnt/resize"
)
//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 App struct {
*WeatherCache
*CategoryManager
*UptimeManager
db *sql.DB
}
func NewApp(dbPath string) (*App, error) {
db, err := sql.Open("sqlite3", dbPath)
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 *WeatherCache
if os.Getenv("PASSPORT_ENABLE_WEATHER") != "false" {
weatherCache = NewWeatherCache()
}
var uptimeManager *UptimeManager
if os.Getenv("PASSPORT_ENABLE_UPTIME") != "false" {
uptimeManager = NewUptimeManager()
}
return &App{
WeatherCache: weatherCache,
CategoryManager: categoryManager,
UptimeManager: uptimeManager,
}, nil
}
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() *UptimeManager {
if os.Getenv("UPTIMEROBOT_API_KEY") == "" {
log.Fatalln("UptimeRobot API Key is required!")
return nil
}
updateInterval, err := strconv.Atoi(os.Getenv("UPTIMEROBOT_UPDATE_INTERVAL"))
if err != nil || updateInterval < 1 {
updateInterval = 300
}
uptimeManager := &UptimeManager{
updateChan: make(chan struct{}),
updateInterval: updateInterval,
apiKey: os.Getenv("UPTIMEROBOT_API_KEY"),
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()
}
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 WeatherCache struct {
data *WeatherData
lastUpdate time.Time
mutex sync.RWMutex
updateChan chan struct{}
tempUnits string
updateInterval int
apiKey string
lat string
lon string
}
func NewWeatherCache() *WeatherCache {
if os.Getenv("OPENWEATHER_API_KEY") == "" || os.Getenv("OPENWEATHER_LAT") == "" || os.Getenv("OPENWEATHER_LON") == "" {
log.Fatalln("OpenWeather API Key, and your latitude and longitude are required!")
return nil
}
updateInterval, err := strconv.Atoi(os.Getenv("OPENWEATHER_UPDATE_INTERVAL"))
if err != nil || updateInterval < 1 {
updateInterval = 15
}
units := os.Getenv("OPENWEATHER_TEMP_UNITS")
if units == "" {
units = "metric"
}
cache := &WeatherCache{
data: &WeatherData{},
updateChan: make(chan struct{}),
tempUnits: units,
updateInterval: updateInterval,
apiKey: os.Getenv("OPENWEATHER_API_KEY"),
lat: os.Getenv("OPENWEATHER_LAT"),
lon: os.Getenv("OPENWEATHER_LON"),
}
go cache.weatherWorker()
cache.updateChan <- struct{}{}
return cache
}
func (c *WeatherCache) GetWeather() WeatherData {
c.mutex.RLock()
defer c.mutex.RUnlock()
return *c.data
}
func (c *WeatherCache) weatherWorker() {
ticker := time.NewTicker(time.Duration(c.updateInterval) * time.Minute)
defer ticker.Stop()
for {
select {
case <-c.updateChan:
c.updateWeather()
case <-ticker.C:
c.updateWeather()
}
}
}
func (c *WeatherCache) updateWeather() {
url := fmt.Sprintf("https://api.openweathermap.org/data/2.5/weather?lat=%s&lon=%s&appid=%s&units=%s",
c.lat, c.lon, c.apiKey, c.tempUnits)
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()
}
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 = webp.Decode(srcFile)
case "image/svg+xml":
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()
resizedImg := resize.Resize(64, 0, img, resize.MitchellNetravali)
var buf bytes.Buffer
options := &webp.Options{Lossless: true, Quality: 80}
if err := webp.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
Categories []Category
}
func NewCategoryManager(db *sql.DB) (*CategoryManager, error) {
rows, err := db.Query(`
SELECT id, name, icon
FROM categories
ORDER BY id ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var categories []Category
for rows.Next() {
var cat Category
if err := rows.Scan(&cat.ID, &cat.Name, &cat.Icon); err != nil {
return nil, err
}
rows, err := db.Query(`
SELECT id, category_id, name, description, icon, url
FROM links
WHERE category_id = ?
ORDER BY id ASC
`, cat.ID)
if err != nil {
return nil, err
}
defer rows.Close()
var links []Link
for rows.Next() {
var link Link
if err := rows.Scan(&link.ID, &link.CategoryID, &link.Name, &link.Description,
&link.Icon, &link.URL); err != nil {
return nil, err
}
links = append(links, link)
}
cat.Links = links
categories = append(categories, cat)
}
return &CategoryManager{
db: db,
Categories: categories,
}, nil
}
// Get Category by ID, returns nil if not found
func (manager *CategoryManager) GetCategory(id int64) *Category {
var category *Category
// probably potentially bad
for _, cat := range manager.Categories {
if cat.ID == id {
category = &cat
break
}
}
return category
}
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
manager.Categories = append(manager.Categories, category)
return &category, nil
}
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
var cat *Category
for i, c := range manager.Categories {
if c.ID == link.CategoryID {
cat = &manager.Categories[i]
break
}
}
if cat == nil {
return nil, fmt.Errorf("category not found")
}
cat.Links = append(cat.Links, link)
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 {
return err
}
}
return nil
}
var WeatherIcons = map[string]string{
"clear-day": `<svg 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 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 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 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 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 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 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 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 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 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 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() {
if err := godotenv.Load(); err != nil {
fmt.Println("No .env file found")
}
}
func main() {
if err := os.MkdirAll("public/uploads", 0755); err != nil {
log.Fatal(err)
}
app, err := NewApp("passport.db?cache=shared&mode=rwc&_journal_mode=WAL")
if err != nil {
log.Fatal(err)
}
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 os.Getenv("PASSPORT_DEV_MODE") == "true" {
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))
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{
"SearchProvider": os.Getenv("PASSPORT_SEARCH_PROVIDER"),
"Categories": app.CategoryManager.Categories,
}
if os.Getenv("PASSPORT_ENABLE_WEATHER") != "false" {
weather := app.WeatherCache.GetWeather()
renderData["WeatherData"] = fiber.Map{
"Temp": weather.Temperature,
"Desc": weather.WeatherText,
"Icon": getWeatherIcon(weather.Icon),
}
}
if os.Getenv("PASSPORT_ENABLE_UPTIME") != "false" {
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
}
if loginData.Username != os.Getenv("PASSPORT_ADMIN_USERNAME") || loginData.Password != os.Getenv("PASSPORT_ADMIN_PASSWORD") {
return c.Status(http.StatusUnauthorized).JSON(fiber.Map{"message": "Invalid username or password"})
}
// Create new session
sessionID := uuid.NewString()
expiresAt := time.Now().Add(time.Hour * 24 * 7)
_, err := app.db.Exec(`
INSERT INTO sessions (session_id, expires_at)
VALUES (?, ?)
`, sessionID, expiresAt)
if err != nil {
return err
}
// Set cookie
c.Cookie(&fiber.Cookie{
Name: "SessionToken",
Value: sessionID,
Expires: expiresAt,
})
return c.Status(http.StatusOK).JSON(fiber.Map{"message": "Logged in successfully"})
})
router.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.Categories,
}, "layouts/main")
})
api := router.Group("/api")
{
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("/categories", 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 fmt.Errorf("name and icon are required")
}
file, err := c.FormFile("icon")
if err != nil || file == nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "Icon is required",
})
}
if file.Size > 5*1024*1024 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "File size too large. Maximum size is 5MB",
})
}
contentType := file.Header.Get("Content-Type")
if 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 err
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"message": "Category created successfully",
"category": category,
})
})
api.Post("/links", func(c fiber.Ctx) error {
var req struct {
Name string `form:"name"`
Description string `form:"description"`
URL string `form:"url"`
CategoryID int64 `form:"category_id"`
}
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 fmt.Errorf("name and url are required")
}
file, err := c.FormFile("icon")
if err != nil || file == nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "Icon is required",
})
}
if file.Size > 5*1024*1024 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "File size too large. Maximum size is 5MB",
})
}
contentType := file.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"message": "Only image files are allowed",
})
}
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: req.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("/links/:id", func(c fiber.Ctx) error {
id := c.Params("id")
app.CategoryManager.DeleteLink(id)
return c.SendStatus(fiber.StatusOK)
})
api.Delete("/categories/:id", func(c fiber.Ctx) error {
id := c.Params("id")
rows, err := app.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 := app.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 == "" {
continue
}
if err := os.Remove(filepath.Join("public/", icon)); err != nil {
return err
}
}
return c.SendStatus(fiber.StatusOK)
})
}
router.Listen(":3000", fiber.ListenConfig{
EnablePrefork: os.Getenv("PASSPORT_ENABLE_PREFORK") == "true",
})
}

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

1319
src/main.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@ package middleware
import ( import (
"database/sql" "database/sql"
"fmt"
"log/slog"
"time" "time"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
@@ -27,12 +29,18 @@ func AdminMiddleware(db *sql.DB) func(c fiber.Ctx) error {
WHERE session_id = ? WHERE session_id = ?
`, sessionToken).Scan(&session.SessionID, &session.ExpiresAt) `, sessionToken).Scan(&session.SessionID, &session.ExpiresAt)
if err != nil { if err != nil {
return c.Next() 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) sessionExpiry, err := time.Parse("2006-01-02 15:04:05-07:00", session.ExpiresAt)
if err != nil { if err != nil {
return c.Next() 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()) { if sessionExpiry.Before(time.Now()) {

View 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()
}

View 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()
}

159
src/styles/main.scss Normal file
View File

@@ -0,0 +1,159 @@
@import "tailwindcss";
@theme {
--color-accent: oklch(57.93% 0.258 294.12);
--color-success: oklch(70.19% 0.158 160.44);
--color-error: oklch(53% 0.251 28.48);
--color-base: oklch(11% .007 285);
--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);
--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/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);
contain: layout style paint;
&: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);
}
}
}
@media (prefers-reduced-motion: reduce) {
.link-card {
transition: none;
&:hover {
transform: none;
}
&:active {
transform: none;
}
}
}
/* 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;
}
/* Div that holds the text */
.link-card div[data-text-container] {
word-break: break-all;
}
.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;
}

View File

@@ -9,7 +9,7 @@
<style>{{{inlineCSS}}}</style> <style>{{{inlineCSS}}}</style>
</head> </head>
<body class="bg-[#151316] text-white"> <body class="bg-surface text-text">
{{embed}} {{embed}}
</body> </body>
{{{devContent}}} {{{devContent}}}

View 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>

View 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>

View 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>

View 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

View File

@@ -1,19 +1,14 @@
<main class="flex justify-center items-center h-screen relative bg-[#0E0A0E]"> <main class="flex justify-center items-center h-screen relative bg-base">
<div class="flex bg-[#151316] rounded-xl overflow-hidden"> <div class="flex bg-surface rounded-xl overflow-hidden">
<img src="/assets/leaves.webp" class="h-96 w-64 object-cover" /> <img src="/assets/leaves.webp" class="h-96 w-64 object-cover" />
<div class="flex flex-col p-4 text-center"> <div class="flex flex-col p-4 text-center">
<h2 class="text-2xl"> <h2 class="text-2xl">
Login Login
</h2> </h2>
<form action="/admin/login" method="post" class="flex flex-col gap-y-3 my-2"> <form action="/admin/login" method="post" class="flex flex-col gap-y-3 my-2">
<input <input type="text" name="username" placeholder="Username" />
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none" <input type="password" name="password" placeholder="Password" />
type="text" name="username" placeholder="Username" /> <button class="px-4 py-2 rounded-md w-full bg-accent text-white border-0" type="submit">Login</button>
<input
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none"
type="password" name="password" placeholder="Password" />
<button class="px-4 py-2 rounded-md w-full bg-[#8A42FF] text-white border-0"
type="submit">Login</button>
</form> </form>
<span id="message"></span> <span id="message"></span>
</div> </div>

View File

@@ -1,8 +1,8 @@
<main class="grid grid-rows-3 grid-cols-[1fr] justify-center items-center h-screen bg-[#0E0A0E]"> <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 class="flex h-full p-2.5 justify-between">
<div> <div>
{{#if WeatherData}} {{#if WeatherData}}
<div class="text-[#BABABA] flex items-center"> <div class="text-subtle flex items-center">
<span class="mr-2 flex items-center"> <span class="mr-2 flex items-center">
{{{WeatherData.Icon}}} {{{WeatherData.Icon}}}
</span> </span>
@@ -15,20 +15,25 @@
</div> </div>
<div> <div>
{{#if UptimeData}} {{#if UptimeData}}
<div class="text-[#BABABA] flex items-end flex-col"> <div class="text-subtle flex items-end flex-col">
{{#each UptimeData}} {{#each UptimeData}}
<div class="flex items-center"> <div class="flex items-center">
<span class="mr-2 flex items-center"> <span class="mr-2 flex items-center">
{{{this.FriendlyName}}} {{{this.FriendlyName}}}
</span> </span>
<div class="relative my-auto"> <div class="relative my-auto size-2">
{{#if (eq this.Status 2)}} <div class="relative my-auto size-2 flex-shrink-0 flex-grow-0">
<span class="absolute w-2 h-2 rounded-full bg-emerald-400 animate-ping block"></span> <svg class="absolute w-full h-full animate-ping" viewBox="0 0 10 10">
<span class="relative w-2 h-2 rounded-full bg-emerald-500 block"></span> <circle cx="5" cy="5" r="5"
{{else}} class="fill-current {{#if (eq this.Status 2)}} text-success {{else}} text-error {{/if}}">
<span class="absolute w-2 h-2 rounded-full bg-rose-400 animate-ping block"></span> </circle>
<span class="relative w-2 h-2 rounded-full bg-rose-500 block"></span> </svg>
{{/if}} <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>
</div> </div>
{{/each}} {{/each}}
@@ -38,7 +43,7 @@
</div> </div>
<div class="row-start-2 flex flex-col items-center w-full px-6"> <div class="row-start-2 flex flex-col items-center w-full px-6">
<div class="flex items-center pb-2.5"> <div class="flex items-center pb-2.5">
<svg class="mr-3 aspect-square w-[clamp(48px,10vw,60px)]" viewBox="0 0 100 100" fill="none" <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"> 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)" <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)" /> fill="url(#paint0_linear_20_10)" />
@@ -60,44 +65,36 @@
</svg> </svg>
<h1>Passport</h1> <h1>Passport</h1>
</div> </div>
<input id="search-input" <form class="w-full max-w-3xl" action="{{ SearchProviderURL }}" method="GET">
class="w-full max-w-3xl bg-[#1C1C21] border border-[#56565b]/30 rounded-full px-3 py-1 text-white h-7 focus-visible:outline-none placeholder:italic placeholder:text-[#434343]" <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..." /> placeholder="Search..." />
</form>
</div> </div>
</main> </main>
<section class="flex justify-center w-full"> <section class="flex justify-center w-full bg-surface">
<div class="w-full sm:w-4/5 p-2.5"> <div class="w-full sm:w-4/5 p-2.5">
{{#each Categories}} {{#each Categories}}
<div class="flex items-center w-fit"> <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}}" <img class="object-contain mr-2 select-none size-8" width="32" height="32" draggable="false"
src="{{this.Icon}}" /> alt="{{this.Name}}" src="{{this.Icon}}" />
<h2 class="capitalize w-fit">{{this.Name}}</h2> <h2 class="capitalize break-all">{{this.Name}}</h2>
</div> </div>
<div class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2"> <div class="p-2.5 grid grid-cols-[repeat(auto-fill,_minmax(min(330px,_100%),_1fr))] gap-2">
{{#each this.Links}} {{#each this.Links}} <a href="{{this.URL}}" class="link-card" draggable="false" target="_blank"
<a href="{{this.URL}}" rel="noopener noreferrer">
class="underline-none text-unset rounded-2xl bg-[#211F23] p-2.5 flex flex-row items-center shadow-md hover:shadow-xl transition-[shadow,transform] ease-[cubic-bezier(0.16,1,0.3,1)] hover:-translate-y-1" <div data-img-container>
draggable="false" target="_blank" rel="noopener noreferrer"> <img width="64" height="64" draggable="false" src="{{this.Icon}}" alt="{{this.Name}}" />
<img class="object-contain mr-2 select-none rounded-md" width="64" height="64" draggable="false" </div>
src="{{this.Icon}}" alt="{{this.Name}}" /> <div data-text-container>
<div>
<h3>{{this.Name}}</h3> <h3>{{this.Name}}</h3>
<p class="text-[#D7D7D7]">{{this.Description}}</p> <p class="min-h-5">{{this.Description}}</p>
</div> </div>
</a> </a>
{{else}}
<p class="text-subtle">No links here, add one!</p>
{{/each}} {{/each}}
</div> </div>
{{/each}} {{/each}}
</div> </div>
</section> </section>
<script>
let search_input = document.getElementById("search-input");
let search_provider = "{{ SearchProvider }}";
// on enter key press
search_input.addEventListener("keyup", function (event) {
if (event.key === "Enter") {
window.location.href = search_provider.replace("%s", search_input.value);
}
});
</script>

View File

@@ -1,37 +0,0 @@
@import "tailwindcss";
@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;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-semibold;
}
h1 {
font-size: clamp(48px, 10vw, 64px);
}
h2 {
font-size: clamp(30px, 6vw, 36px);
}
h3 {
font-size: 1.25rem;
}

View File

@@ -1,203 +0,0 @@
<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">
<img class="object-contain mr-2 select-none" width="32" height="32" draggable="false" alt="{{this.Name}}"
src="{{this.Icon}}" />
<h2 class="capitalize">{{this.Name}}</h2>
<button onclick="deleteCategory({{this.ID}})"
class="w-fit h-fit flex p-0.5 bg-[#1C1C21] border-solid border-[#211F23] rounded-md hover:bg-[#29292e] 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
class="rounded-2xl bg-[#211F23] p-2.5 flex flex-row items-center shadow-md hover:shadow-xl transition-[shadow,transform] ease-[cubic-bezier(0.16,1,0.3,1)] relative">
<img class="object-contain mr-2 select-none rounded-md" width="64" height="64" draggable="false"
src="{{this.Icon}}" alt="{{this.Name}}" />
<div>
<h3>{{this.Name}}</h3>
<p class="text-[#D7D7D7]">{{this.Description}}</p>
</div>
<button onclick="deleteLink({{this.ID}})"
class="w-fit h-fit flex p-0.5 bg-[#1C1C21] border-solid border-[#211F23] rounded-md hover:bg-[#29292e] 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="addLink({{this.ID}})"
class="rounded-2xl border border-dashed border-[#656565] p-2.5 flex flex-row items-center shadow-md hover:shadow-xl transition-[shadow,transform] ease-[cubic-bezier(0.16,1,0.3,1)] 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="addCategory()" class="text-[#656565] underline decoration-dashed cursor-pointer">
Add a new category
</h2>
</div>
</div>
</section>
<div id="linkModal" class="hidden absolute top-0 left-0 bottom-0 right-0 bg-[#00000070] justify-center items-center">
<div class="bg-[#151316] rounded-xl overflow-hidden w-fit p-4">
<h3>Add A link</h3>
<form id="link-form" action="/api/links" method="post" class="flex flex-col gap-y-3 my-2">
<div>
<label for="linkName">Name</label>
<input
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none"
type="text" name="name" placeholder="Name" id="linkName" />
</div>
<div>
<label for="linkDesc">Description</label>
<input
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none"
type="text" name="description" placeholder="Description" id="linkDesc" />
</div>
<div>
<label for="linkURL">URL</label>
<input
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none"
type="text" name="url" placeholder="URL" id="linkURL" />
</div>
<div>
<label for="linkIcon">Icon</label>
<input class="w-full text-white" type="file" name="icon" id="linkIcon" accept="image/*" />
</div>
<button class="px-4 py-2 rounded-md w-full bg-[#8A42FF] text-white border-0" type="submit">Add</button>
</form>
<span id="link-message"></span>
</div>
</div>
<div id="categoryModal"
class="hidden absolute top-0 left-0 bottom-0 right-0 bg-[#00000070] justify-center items-center">
<div class="bg-[#151316] rounded-xl overflow-hidden w-fit p-4">
<h3>Create A category</h3>
<form id="category-form" action="/api/categories" method="post" class="flex flex-col gap-y-3 my-2">
<div>
<label for="categoryName">Name</label>
<input
class="px-4 py-2 rounded-md w-full bg-[#1C1C21] border border-[#56565b]/30 text-white focus-visible:outline-none"
type="text" name="name" placeholder="Name" id="categoryName" />
</div>
<div>
<label for="linkIcon">Icon</label>
<input class="w-full text-white" type="file" name="icon" id="linkIcon" accept=".svg" />
</div>
<button class="px-4 py-2 rounded-md w-full bg-[#8A42FF] text-white border-0" type="submit">Create</button>
</form>
<span id="category-message"></span>
</div>
</div>
<script>
// idfk what this variable capitalization is, it's a mess
let linkModal = document.getElementById("linkModal");
let categoryModal = document.getElementById("categoryModal");
let targetCategoryID = null;
function addCategory() {
categoryModal.classList.remove("hidden");
categoryModal.classList.add("flex");
}
function addLink(categoryID) {
targetCategoryID = categoryID;
linkModal.classList.remove("hidden");
linkModal.classList.add("flex");
}
async function deleteLink(linkID) {
let res = await fetch(`/api/links/${linkID}`, {
method: "DELETE"
});
if (res.status === 200) {
location.reload();
}
}
async function deleteCategory(categoryID) {
let res = await fetch(`/api/categories/${categoryID}`, {
method: "DELETE"
});
if (res.status === 200) {
location.reload();
}
}
document.getElementById("link-form").addEventListener("submit", async (event) => {
event.preventDefault();
let data = new FormData(event.target);
data.append("category_id", targetCategoryID);
let res = await fetch(`/api/links`, {
method: "POST",
body: data
});
if (res.status === 201) {
linkModal.classList.add("hidden");
linkModal.classList.remove("flex");
document.getElementById("link-form").reset();
location.reload();
} else {
let json = await res.json();
document.getElementById("category-message").innerText = json.message;
}
});
document.getElementById("category-form").addEventListener("submit", async (event) => {
event.preventDefault();
let data = new FormData(event.target);
let res = await fetch(`/api/categories`, {
method: "POST",
body: data
});
if (res.status === 201) {
categoryModal.classList.add("hidden");
categoryModal.classList.remove("flex");
document.getElementById("category-form").reset();
location.reload();
} else {
let json = await res.json();
document.getElementById("link-message").innerText = json.message;
}
});
linkModal.addEventListener("click", (event) => {
if (event.target === linkModal) {
targetCategoryID = null;
linkModal.classList.add("hidden");
linkModal.classList.remove("flex");
}
});
categoryModal.addEventListener("click", (event) => {
if (event.target === categoryModal) {
targetCategoryID = null;
categoryModal.classList.add("hidden");
categoryModal.classList.remove("flex");
}
});
</script>

View File

@@ -1,6 +1,6 @@
{ {
"name": "passport", "name": "passport",
"version": "0.0.1", "version": "0.3.1",
"description": "Passport is a simple, lightweight, and fast dashboard/new tab page for your browser.", "description": "Passport is a simple, lightweight, and fast dashboard/new tab page for your browser.",
"author": "juls0730", "author": "juls0730",
"license": "BSL-1.0", "license": "BSL-1.0",
@@ -10,8 +10,9 @@
"url": "https://github.com/juls0730/passport.git" "url": "https://github.com/juls0730/passport.git"
}, },
"scripts": { "scripts": {
"dev": "go generate; PASSPORT_DEV_MODE=true go run main.go", "dev": "go generate ./src/; PASSPORT_DEV_MODE=true go run src/main.go",
"build": "go generate && go build -tags netgo,prod -o passport" "build": "go generate ./src/ && go build -tags netgo,prod -ldflags=\"-w -s\" -o passport src/main.go"
}, },
"pattern": "**/*.go,templates/views/**/*.hbs,styles/**/*.css,assets/**/*.{svg,png,jpg,jpeg,webp,woff2,ttf,otf,eot,ico,gif,webp}" "pattern": "src/**/*.{go,hbs,scss,svg,png,jpg,jpeg,webp,woff2,ico,webp}",
"shutdown_signal": "SIGINT"
} }