From 2718e3f8f29030c4d7398b39cfc599db346db346 Mon Sep 17 00:00:00 2001 From: MarcUs7i <96580944+MarcUs7i@users.noreply.github.com> Date: Tue, 25 Feb 2025 00:05:06 +0100 Subject: [PATCH 01/81] Refactor Docker Compose configuration for uLinkShortener service and add separate build file --- docker-compose-build.yml | 30 ++++++++++++++++++++++++++++++ docker-compose.yml | 35 +++++------------------------------ 2 files changed, 35 insertions(+), 30 deletions(-) create mode 100644 docker-compose-build.yml diff --git a/docker-compose-build.yml b/docker-compose-build.yml new file mode 100644 index 0000000..2f45655 --- /dev/null +++ b/docker-compose-build.yml @@ -0,0 +1,30 @@ +services: + web: + build: . + network_mode: "host" + environment: + - MONGO_URI=mongodb://mongo:27017/uLinkShortener + depends_on: + - mongo + restart: unless-stopped + networks: + - app-network + + mongo: + image: mongo:latest + volumes: + - mongodb_data:/data/db + ports: + - "27017:27017" + networks: + - app-network + environment: + - MONGO_INITDB_DATABASE=uLinkShortener + restart: unless-stopped + +volumes: + mongodb_data: + +networks: + app-network: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index 6568b0c..39566e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,33 +1,8 @@ -version: '3.8' - services: - web: - build: . - ports: - - "5000:5000" - environment: - - MONGO_URI=mongodb://mongo:27017/uLinkShortener - depends_on: - - mongo + ulinkshortener: + image: ghcr.io/MarcUs7i/ulinkshortener:latest + container_name: ulinkshortener restart: unless-stopped - networks: - - app-network - - mongo: - image: mongo:latest + network_mode: "host" volumes: - - mongodb_data:/data/db - ports: - - "27017:27017" - networks: - - app-network - environment: - - MONGO_INITDB_DATABASE=uLinkShortener - restart: unless-stopped - -volumes: - mongodb_data: - -networks: - app-network: - driver: bridge + - ./.env:/app/.env \ No newline at end of file From f941eb54f15ae5bafc9d7cdff6fe526b9c944fda Mon Sep 17 00:00:00 2001 From: MarcUs7i <96580944+MarcUs7i@users.noreply.github.com> Date: Tue, 25 Feb 2025 00:06:03 +0100 Subject: [PATCH 02/81] Fixed typo in docker-compose.yml --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 39566e4..b4c0489 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: ulinkshortener: - image: ghcr.io/MarcUs7i/ulinkshortener:latest + image: ghcr.io/marcus7i/ulinkshortener:latest container_name: ulinkshortener restart: unless-stopped network_mode: "host" From e86ee245062459b8f18c52c49c3987f1716fc700 Mon Sep 17 00:00:00 2001 From: MarcUs7i <96580944+MarcUs7i@users.noreply.github.com> Date: Tue, 25 Feb 2025 00:08:09 +0100 Subject: [PATCH 03/81] Add support for multiple platforms in release workflow --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a6e558..d05fca1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,4 +31,5 @@ jobs: with: context: . push: true + platforms: linux/amd64, linux/arm64/v8, linux/arm/v7 tags: ghcr.io/marcus7i/ulinkshortener:latest \ No newline at end of file From b131756235d1666c483ed28ff7d07f7498a1846d Mon Sep 17 00:00:00 2001 From: MarcUs7i <96580944+MarcUs7i@users.noreply.github.com> Date: Tue, 25 Feb 2025 00:13:49 +0100 Subject: [PATCH 04/81] Add PORT configuration to environment and update server to use it --- .env.example | 1 + server.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 360dbd6..da6f996 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,2 @@ MONGO_URI=mongodb://localhost:27017/uLinkShortener +PORT=5000 \ No newline at end of file diff --git a/server.py b/server.py index d7b3e58..a38b8a6 100644 --- a/server.py +++ b/server.py @@ -179,4 +179,5 @@ def delete_link(short_id): return jsonify({'success': True}) if __name__ == '__main__': - app.run(debug=True, host="0.0.0.0", port=5000) + port = int(os.getenv("PORT", 5000)) + app.run(debug=True, host="0.0.0.0", port=port) From b17e528180249a21b53e7d67632e943d540556e4 Mon Sep 17 00:00:00 2001 From: MarcUs7i <96580944+MarcUs7i@users.noreply.github.com> Date: Wed, 2 Apr 2025 08:37:01 +0200 Subject: [PATCH 05/81] Rewritten in go --- .env.example | 1 + .github/dependabot.yml | 14 +++ .github/workflows/build.yml | 52 ++++++++++ Dockerfile | 24 +++-- cmd/api/main.go | 92 +++++++++++++++++ go.mod | 23 +++++ go.sum | 56 +++++++++++ internal/api/handlers/analytics.go | 126 +++++++++++++++++++++++ internal/api/handlers/auth.go | 95 ++++++++++++++++++ internal/api/handlers/handlers.go | 38 +++++++ internal/api/handlers/url.go | 132 +++++++++++++++++++++++++ internal/api/router.go | 39 ++++++++ internal/config/config.go | 26 +++++ internal/database/mongodb.go | 73 ++++++++++++++ internal/models/analytics.go | 46 +++++++++ internal/models/link.go | 12 +++ internal/models/user.go | 10 ++ internal/utils/clientinfo.go | 83 ++++++++++++++++ internal/utils/shortid.go | 32 ++++++ internal/utils/url.go | 19 ++++ {static => web/static/css}/style.css | 0 {static => web/static/js}/script.js | 7 +- {template => web/templates}/index.html | 7 +- 23 files changed, 989 insertions(+), 18 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/build.yml create mode 100644 cmd/api/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/handlers/analytics.go create mode 100644 internal/api/handlers/auth.go create mode 100644 internal/api/handlers/handlers.go create mode 100644 internal/api/handlers/url.go create mode 100644 internal/api/router.go create mode 100644 internal/config/config.go create mode 100644 internal/database/mongodb.go create mode 100644 internal/models/analytics.go create mode 100644 internal/models/link.go create mode 100644 internal/models/user.go create mode 100644 internal/utils/clientinfo.go create mode 100644 internal/utils/shortid.go create mode 100644 internal/utils/url.go rename {static => web/static/css}/style.css (100%) rename {static => web/static/js}/script.js (98%) rename {template => web/templates}/index.html (94%) diff --git a/.env.example b/.env.example index da6f996..9ba55a8 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ MONGO_URI=mongodb://localhost:27017/uLinkShortener +MONGO_DB_NAME=uLinkShortener PORT=5000 \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a4dc91f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + + # Enable version updates for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..f3a3bba --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,52 @@ +name: Build Docker Image + +on: + workflow_dispatch: + push: + branches: [main] + pull_request: + branches: [main] + + +jobs: + build: + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/marcus7i/ulinkshortener + tags: | + type=raw,value=latest + type=sha,format=short + type=ref,event=branch + type=ref,event=pr + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: ${{ github.event_name != 'pull_request' }} + platforms: linux/amd64, linux/arm64/v8 + tags: ghcr.io/marcus7i/ulinkshortener:latest + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 86a05c1..12ed69f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,18 @@ -FROM python:3.11-slim +FROM golang:1.21-alpine AS builder WORKDIR /app - -# Copy requirements first to leverage Docker cache -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy application code +COPY go.mod go.sum ./ +RUN go mod download COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ulinkshortener ./cmd/api +FROM alpine:latest -# Set environment variables -ENV FLASK_APP=server.py -ENV FLASK_ENV=production +WORKDIR /app +RUN apk --no-cache add ca-certificates +COPY --from=builder /app/ulinkshortener . +COPY --from=builder /app/web /app/web -# Expose port +ENV PORT=5000 EXPOSE 5000 -# Run the application -CMD ["python", "server.py"] +CMD ["./ulinkshortener"] diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..06bc8f9 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "encoding/json" + "html/template" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/joho/godotenv" + "github.com/marcus7i/ulinkshortener/internal/api" + "github.com/marcus7i/ulinkshortener/internal/config" + "github.com/marcus7i/ulinkshortener/internal/database" + "github.com/marcus7i/ulinkshortener/internal/api/handlers" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Println("No .env file found, using environment variables") + } + + cfg := config.New() + + db, err := database.New(cfg.MongoURI) + if err != nil { + log.Fatalf("Failed to connect to MongoDB: %v", err) + } + defer db.Close() + + r := api.SetupRouter(db) + + funcMap := template.FuncMap{ + "marshal": func(v interface{}) template.JS { + a, _ := json.Marshal(v) + return template.JS(a) + }, + } + + templates := template.Must(template.New("").Funcs(funcMap).ParseGlob("web/templates/*.html")) + + r.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + h := handlers.NewHandler(db) + stats, err := h.GetStatsData(r.Context()) + if err != nil { + http.Error(w, "Error generating stats", http.StatusInternalServerError) + return + } + + cookie, _ := r.Cookie("account_id") + if cookie != nil { + stats.LoggedIn = true + } + + templates.ExecuteTemplate(w, "index.html", map[string]interface{}{ + "stats": stats, + }) + return + } + + http.NotFound(w, r) + }) + + srv := &http.Server{ + Addr: ":" + cfg.Port, + Handler: r, + } + + go func() { + log.Printf("Server running on port %s", cfg.Port) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Listen error: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("Shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Fatal("Server forced to shutdown:", err) + } + + log.Println("Server exited with code 0") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e6217e8 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module github.com/marcus7i/ulinkshortener + +go 1.21 + +require ( + github.com/gorilla/mux v1.8.1 + github.com/joho/godotenv v1.5.1 + github.com/mssola/user_agent v0.6.0 + go.mongodb.org/mongo-driver v1.17.3 +) + +require ( + github.com/golang/snappy v0.0.4 // indirect + github.com/klauspost/compress v1.17.5 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + golang.org/x/crypto v0.26.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/text v0.17.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b427448 --- /dev/null +++ b/go.sum @@ -0,0 +1,56 @@ +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/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.17.5 h1:d4vBd+7CHydUqpFBgUEKkSdtSugf9YFmSkvUYPquI5E= +github.com/klauspost/compress v1.17.5/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/mssola/user_agent v0.6.0 h1:uwPR4rtWlCHRFyyP9u2KOV0u8iQXmS7Z7feTrstQwk4= +github.com/mssola/user_agent v0.6.0/go.mod h1:TTPno8LPY3wAIEKRpAtkdMT0f8SE24pLRGPahjCH4uw= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= +go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/api/handlers/analytics.go b/internal/api/handlers/analytics.go new file mode 100644 index 0000000..22a94bb --- /dev/null +++ b/internal/api/handlers/analytics.go @@ -0,0 +1,126 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/gorilla/mux" + "github.com/marcus7i/ulinkshortener/internal/database" + "github.com/marcus7i/ulinkshortener/internal/models" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +func (h *Handler) GetAnalytics(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + accountID := vars["accountID"] + + ctx := context.Background() + var user models.User + err := h.DB.Collection(database.UsersCollection).FindOne(ctx, bson.M{"account_id": accountID}).Decode(&user) + if err != nil { + respondWithError(w, http.StatusUnauthorized, "Invalid account") + return + } + + cursor, err := h.DB.Collection(database.LinksCollection).Find(ctx, bson.M{"account_id": accountID}) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Failed to retrieve links") + return + } + defer cursor.Close(ctx) + + var links []models.Link + if err = cursor.All(ctx, &links); err != nil { + respondWithError(w, http.StatusInternalServerError, "Failed to process links") + return + } + + cursor, err = h.DB.Collection(database.AnalyticsCollection).Find(ctx, bson.M{"account_id": accountID}) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Failed to retrieve analytics") + return + } + defer cursor.Close(ctx) + + var analytics []map[string]interface{} + if err = cursor.All(ctx, &analytics); err != nil { + respondWithError(w, http.StatusInternalServerError, "Failed to process analytics") + return + } + + respondWithJSON(w, http.StatusOK, map[string]interface{}{ + "links": links, + "analytics": analytics, + }) +} + +func (h *Handler) GetStatsData(ctx context.Context) (models.Stats, error) { + + totalLinks, err := h.DB.Collection(database.LinksCollection).CountDocuments(ctx, bson.M{}) + if err != nil { + totalLinks = 0 + } + + totalClicks, err := h.DB.Collection(database.AnalyticsCollection).CountDocuments(ctx, bson.M{}) + if err != nil { + totalClicks = 0 + } + + ipVersionsPipeline := mongo.Pipeline{ + {{Key: "$group", Value: bson.D{{Key: "_id", Value: "$ip_version"}, {Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}}}}, + {{Key: "$sort", Value: bson.D{{Key: "count", Value: -1}}}}, + } + ipVersionsCursor, err := h.DB.Collection(database.AnalyticsCollection).Aggregate(ctx, ipVersionsPipeline) + var ipVersions []models.StatItem + if err == nil { + ipVersionsCursor.All(ctx, &ipVersions) + } + + osPipeline := mongo.Pipeline{ + {{Key: "$group", Value: bson.D{{Key: "_id", Value: "$platform"}, {Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}}}}, + {{Key: "$sort", Value: bson.D{{Key: "count", Value: -1}}}}, + {{Key: "$limit", Value: 10}}, + } + osCursor, err := h.DB.Collection(database.AnalyticsCollection).Aggregate(ctx, osPipeline) + var osStats []models.StatItem + if err == nil { + osCursor.All(ctx, &osStats) + } + + countryPipeline := mongo.Pipeline{ + {{Key: "$group", Value: bson.D{{Key: "_id", Value: "$country"}, {Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}}}}, + {{Key: "$sort", Value: bson.D{{Key: "count", Value: -1}}}}, + {{Key: "$limit", Value: 10}}, + } + countryCursor, err := h.DB.Collection(database.AnalyticsCollection).Aggregate(ctx, countryPipeline) + var countryStats []models.StatItem + if err == nil { + countryCursor.All(ctx, &countryStats) + } + + ispPipeline := mongo.Pipeline{ + {{Key: "$group", Value: bson.D{{Key: "_id", Value: "$isp"}, {Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}}}}, + {{Key: "$sort", Value: bson.D{{Key: "count", Value: -1}}}}, + {{Key: "$limit", Value: 10}}, + } + ispCursor, err := h.DB.Collection(database.AnalyticsCollection).Aggregate(ctx, ispPipeline) + var ispStats []models.StatItem + if err == nil { + ispCursor.All(ctx, &ispStats) + } + + stats := models.Stats{ + TotalLinks: totalLinks, + TotalClicks: totalClicks, + ChartData: models.ChartData{ + IPVersions: ipVersions, + OSStats: osStats, + CountryStats: countryStats, + ISPStats: ispStats, + }, + LoggedIn: false, + } + + return stats, nil +} diff --git a/internal/api/handlers/auth.go b/internal/api/handlers/auth.go new file mode 100644 index 0000000..e6b0135 --- /dev/null +++ b/internal/api/handlers/auth.go @@ -0,0 +1,95 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/marcus7i/ulinkshortener/internal/database" + "github.com/marcus7i/ulinkshortener/internal/models" + "github.com/marcus7i/ulinkshortener/internal/utils" + "go.mongodb.org/mongo-driver/bson" +) + +type LoginRequest struct { + AccountID string `json:"account_id"` +} + +func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + accountID := utils.GenerateAccountID() + + for { + var user models.User + err := h.DB.Collection(database.UsersCollection).FindOne(ctx, bson.M{"account_id": accountID}).Decode(&user) + if err != nil { + break + } + accountID = utils.GenerateAccountID() + } + + _, err := h.DB.Collection(database.UsersCollection).InsertOne(ctx, models.User{ + AccountID: accountID, + CreatedAt: time.Now(), + }) + + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Failed to create account") + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "account_id", + Value: accountID, + Path: "/", + MaxAge: 31536000, // 1 year + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + }) + + respondWithJSON(w, http.StatusOK, map[string]string{"account_id": accountID}) +} + +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + var req LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid request") + return + } + + ctx := context.Background() + var user models.User + err := h.DB.Collection(database.UsersCollection).FindOne(ctx, bson.M{"account_id": req.AccountID}).Decode(&user) + if err != nil { + respondWithError(w, http.StatusUnauthorized, "Invalid account ID") + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "account_id", + Value: req.AccountID, + Path: "/", + MaxAge: 31536000, // 1 year + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + }) + + respondWithJSON(w, http.StatusOK, map[string]bool{"success": true}) +} + +func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: "account_id", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + }) + + respondWithJSON(w, http.StatusOK, map[string]bool{"success": true}) +} \ No newline at end of file diff --git a/internal/api/handlers/handlers.go b/internal/api/handlers/handlers.go new file mode 100644 index 0000000..caebab4 --- /dev/null +++ b/internal/api/handlers/handlers.go @@ -0,0 +1,38 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/marcus7i/ulinkshortener/internal/database" +) + +type Handler struct { + DB *database.MongoDB +} + +func NewHandler(db *database.MongoDB) *Handler { + return &Handler{ + DB: db, + } +} + +func respondWithError(w http.ResponseWriter, code int, message string) { + respondWithJSON(w, code, map[string]string{"error": message}) +} + +func respondWithJSON(w http.ResponseWriter, code int, payload interface{}) { + response, _ := json.Marshal(payload) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + w.Write(response) +} + +func getAccountIDFromCookie(r *http.Request) string { + cookie, err := r.Cookie("account_id") + if err != nil { + return "" + } + return cookie.Value +} \ No newline at end of file diff --git a/internal/api/handlers/url.go b/internal/api/handlers/url.go new file mode 100644 index 0000000..b5ffb6e --- /dev/null +++ b/internal/api/handlers/url.go @@ -0,0 +1,132 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/gorilla/mux" + "github.com/marcus7i/ulinkshortener/internal/database" + "github.com/marcus7i/ulinkshortener/internal/models" + "github.com/marcus7i/ulinkshortener/internal/utils" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +type CreateLinkRequest struct { + AccountID string `json:"account_id"` + URL string `json:"url"` +} + +type CreateLinkResponse struct { + ShortURL string `json:"short_url"` +} + +func (h *Handler) CreateLink(w http.ResponseWriter, r *http.Request) { + var req CreateLinkRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid request") + return + } + + ctx := context.Background() + user := h.DB.Collection(database.UsersCollection).FindOne(ctx, bson.M{"account_id": req.AccountID}) + if user.Err() != nil { + respondWithError(w, http.StatusUnauthorized, "Invalid account") + return + } + + if !utils.IsValidURL(req.URL) { + respondWithError(w, http.StatusBadRequest, "Invalid URL. Please provide a valid URL with scheme (e.g., http:// or https://)") + return + } + + shortID := utils.GenerateShortID() + + link := models.Link{ + ShortID: shortID, + TargetURL: req.URL, + AccountID: req.AccountID, + CreatedAt: time.Now(), + } + + _, err := h.DB.Collection(database.LinksCollection).InsertOne(ctx, link) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Failed to create link") + return + } + + respondWithJSON(w, http.StatusOK, CreateLinkResponse{ + ShortURL: fmt.Sprintf("/l/%s", shortID), + }) +} + +func (h *Handler) RedirectLink(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + shortID := vars["shortID"] + + ctx := context.Background() + var link models.Link + err := h.DB.Collection(database.LinksCollection).FindOne(ctx, bson.M{"short_id": shortID}).Decode(&link) + if err != nil { + if err == mongo.ErrNoDocuments { + http.Error(w, "Link not found", http.StatusNotFound) + } else { + http.Error(w, "Server error", http.StatusInternalServerError) + } + return + } + + clientInfo := utils.GetClientInfo(r) + clientInfo["link_id"] = shortID + clientInfo["account_id"] = link.AccountID + + _, err = h.DB.Collection(database.AnalyticsCollection).InsertOne(ctx, clientInfo) + if err != nil { + fmt.Printf("Failed to log analytics: %v\n", err) + } + + http.Redirect(w, r, link.TargetURL, http.StatusFound) +} + +func (h *Handler) DeleteLink(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + shortID := vars["shortID"] + accountID := getAccountIDFromCookie(r) + + if accountID == "" { + respondWithError(w, http.StatusUnauthorized, "Not logged in") + return + } + + ctx := context.Background() + var link models.Link + err := h.DB.Collection(database.LinksCollection).FindOne(ctx, bson.M{ + "short_id": shortID, + "account_id": accountID, + }).Decode(&link) + + if err != nil { + if err == mongo.ErrNoDocuments { + respondWithError(w, http.StatusNotFound, "Link not found or unauthorized") + } else { + respondWithError(w, http.StatusInternalServerError, "Server error") + } + return + } + + _, err = h.DB.Collection(database.LinksCollection).DeleteOne(ctx, bson.M{"short_id": shortID}) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Failed to delete link") + return + } + + _, err = h.DB.Collection(database.AnalyticsCollection).DeleteMany(ctx, bson.M{"link_id": shortID}) + if err != nil { + fmt.Printf("Failed to delete analytics: %v\n", err) + } + + respondWithJSON(w, http.StatusOK, map[string]bool{"success": true}) +} \ No newline at end of file diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..542b6fd --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,39 @@ +package api + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/marcus7i/ulinkshortener/internal/api/handlers" + "github.com/marcus7i/ulinkshortener/internal/database" +) + +func SetupRouter(db *database.MongoDB) *mux.Router { + r := mux.NewRouter() + h := handlers.NewHandler(db) + + fs := http.FileServer(http.Dir("./web/static")) + r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", fs)) + + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/static/css/style.css" { + w.Header().Set("Content-Type", "text/css") + } else if r.URL.Path == "/static/js/script.js" { + w.Header().Set("Content-Type", "application/javascript") + } + next.ServeHTTP(w, r) + }) + }) + + r.HandleFunc("/register", h.Register).Methods("POST") + r.HandleFunc("/login", h.Login).Methods("POST") + r.HandleFunc("/logout", h.Logout).Methods("POST") + + r.HandleFunc("/create", h.CreateLink).Methods("POST") + r.HandleFunc("/l/{shortID}", h.RedirectLink).Methods("GET") + r.HandleFunc("/analytics/{accountID}", h.GetAnalytics).Methods("GET") + r.HandleFunc("/delete/{shortID}", h.DeleteLink).Methods("DELETE") + + return r +} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d356cc4 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,26 @@ +package config + +import ( + "os" + + "github.com/joho/godotenv" +) + +type Config struct { + MongoURI string + Port string +} + +func New() *Config { + godotenv.Load() + + port := os.Getenv("PORT") + if port == "" { + port = "5000" + } + + return &Config{ + MongoURI: os.Getenv("MONGO_URI"), + Port: port, + } +} \ No newline at end of file diff --git a/internal/database/mongodb.go b/internal/database/mongodb.go new file mode 100644 index 0000000..711b85f --- /dev/null +++ b/internal/database/mongodb.go @@ -0,0 +1,73 @@ +package database + +import ( + "context" + "time" + "strings" + + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type MongoDB struct { + Client *mongo.Client + DB *mongo.Database +} + +const ( + LinksCollection = "links" + AnalyticsCollection = "analytics" + UsersCollection = "users" + DefaultDBName = "uLinkShortener" +) + +func New(uri string) (*MongoDB, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri)) + if err != nil { + return nil, err + } + + err = client.Ping(ctx, nil) + if err != nil { + return nil, err + } + + dbName := getDatabaseName(uri) + if dbName == "" { + dbName = DefaultDBName + } + db := client.Database(dbName) + + return &MongoDB{ + Client: client, + DB: db, + }, nil +} + +func getDatabaseName(uri string) string { + lastSlashIndex := strings.LastIndex(uri, "/") + if lastSlashIndex == -1 || lastSlashIndex == len(uri)-1 { + return "" + } + + dbName := uri[lastSlashIndex+1:] + + if queryIndex := strings.Index(dbName, "?"); queryIndex != -1 { + dbName = dbName[:queryIndex] + } + + return dbName +} + +func (m *MongoDB) Close() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return m.Client.Disconnect(ctx) +} + +func (m *MongoDB) Collection(name string) *mongo.Collection { + return m.DB.Collection(name) +} \ No newline at end of file diff --git a/internal/models/analytics.go b/internal/models/analytics.go new file mode 100644 index 0000000..bb5b54c --- /dev/null +++ b/internal/models/analytics.go @@ -0,0 +1,46 @@ +package models + +import ( + "time" +) + +type Analytics struct { + LinkID string `json:"link_id" bson:"link_id"` + AccountID string `json:"account_id" bson:"account_id"` + IP string `json:"ip" bson:"ip"` + UserAgent string `json:"user_agent" bson:"user_agent"` + Platform string `json:"platform" bson:"platform"` + Browser string `json:"browser" bson:"browser"` + Version string `json:"version" bson:"version"` + Language string `json:"language" bson:"language"` + Referrer string `json:"referrer" bson:"referrer"` + Timestamp time.Time `json:"timestamp" bson:"timestamp"` + RemotePort string `json:"remote_port" bson:"remote_port"` + Accept string `json:"accept" bson:"accept"` + AcceptLanguage string `json:"accept_language" bson:"accept_language"` + AcceptEncoding string `json:"accept_encoding" bson:"accept_encoding"` + ScreenSize string `json:"screen_size" bson:"screen_size"` + WindowSize string `json:"window_size" bson:"window_size"` + Country string `json:"country" bson:"country"` + ISP string `json:"isp" bson:"isp"` + IPVersion string `json:"ip_version" bson:"ip_version"` +} + +type Stats struct { + TotalLinks int64 `json:"total_links"` + TotalClicks int64 `json:"total_clicks"` + ChartData ChartData `json:"chart_data"` + LoggedIn bool `json:"logged_in"` +} + +type ChartData struct { + IPVersions []StatItem `json:"ip_versions"` + OSStats []StatItem `json:"os_stats"` + CountryStats []StatItem `json:"country_stats"` + ISPStats []StatItem `json:"isp_stats"` +} + +type StatItem struct { + ID string `json:"_id" bson:"_id"` + Count int `json:"count" bson:"count"` +} diff --git a/internal/models/link.go b/internal/models/link.go new file mode 100644 index 0000000..84c6de4 --- /dev/null +++ b/internal/models/link.go @@ -0,0 +1,12 @@ +package models + +import ( + "time" +) + +type Link struct { + ShortID string `json:"short_id" bson:"short_id"` + TargetURL string `json:"target_url" bson:"target_url"` + AccountID string `json:"account_id" bson:"account_id"` + CreatedAt time.Time `json:"created_at" bson:"created_at"` +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..6efb3f2 --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,10 @@ +package models + +import ( + "time" +) + +type User struct { + AccountID string `json:"account_id" bson:"account_id"` + CreatedAt time.Time `json:"created_at" bson:"created_at"` +} diff --git a/internal/utils/clientinfo.go b/internal/utils/clientinfo.go new file mode 100644 index 0000000..0151745 --- /dev/null +++ b/internal/utils/clientinfo.go @@ -0,0 +1,83 @@ +package utils + +import ( + "net/http" + "strings" + "time" + + "github.com/mssola/user_agent" +) + +func GetClientInfo(r *http.Request) map[string]interface{} { + ua := user_agent.New(r.UserAgent()) + + ipAddress := r.Header.Get("CF-Connecting-IP") + if ipAddress == "" { + ipAddress = r.Header.Get("X-Real-IP") + } + if ipAddress == "" { + forwardedFor := r.Header.Get("X-Forwarded-For") + if forwardedFor != "" { + ips := strings.Split(forwardedFor, ",") + ipAddress = strings.TrimSpace(ips[0]) + } + } + if ipAddress == "" { + ipAddress = r.RemoteAddr + if colonIndex := strings.LastIndex(ipAddress, ":"); colonIndex != -1 { + ipAddress = ipAddress[:colonIndex] + } + } + if ipAddress == "" { + ipAddress = "Unknown" + } + + browser, version := ua.Browser() + + platform := r.Header.Get("sec-ch-ua-platform") + if platform == "" { + platform = ua.OS() + if platform == "" { + platform = "Unknown" + } + } + + language := r.Header.Get("Accept-Language") + if language != "" && strings.Contains(language, ",") { + language = strings.Split(language, ",")[0] + } else if language == "" { + language = "Unknown" + } + + ipVersion := "IPv4" + if strings.Contains(ipAddress, ":") { + ipVersion = "IPv6" + } + + return map[string]interface{}{ + "ip": ipAddress, + "user_agent": r.UserAgent(), + "platform": platform, + "browser": browser, + "version": version, + "language": language, + "referrer": valueOrDefault(r.Referer(), "Direct"), + "timestamp": time.Now(), + "remote_port": valueOrDefault(r.Header.Get("X-Forwarded-Port"), "Unknown"), + "accept": valueOrDefault(r.Header.Get("Accept"), "Unknown"), + "accept_language": valueOrDefault(r.Header.Get("Accept-Language"), "Unknown"), + "accept_encoding": valueOrDefault(r.Header.Get("Accept-Encoding"), "Unknown"), + "screen_size": valueOrDefault(r.Header.Get("Sec-CH-UA-Platform-Screen"), "Unknown"), + "window_size": valueOrDefault(r.Header.Get("Viewport-Width"), "Unknown"), + "country": valueOrDefault(r.Header.Get("CF-IPCountry"), "Unknown"), + "isp": valueOrDefault(r.Header.Get("X-ISP"), "Unknown"), + "ip_version": ipVersion, + } +} + +func valueOrDefault(value, defaultValue string) string { + if value == "" { + return defaultValue + } + return value +} \ No newline at end of file diff --git a/internal/utils/shortid.go b/internal/utils/shortid.go new file mode 100644 index 0000000..9d85620 --- /dev/null +++ b/internal/utils/shortid.go @@ -0,0 +1,32 @@ +package utils + +import ( + "math/rand" + "strings" + "time" +) + +const ( + letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + idLength = 8 + accountLength = 8 +) + +var seededRand = rand.New(rand.NewSource(time.Now().UnixNano())) + +func GenerateShortID() string { + return randomString(idLength, letterBytes) +} + +func GenerateAccountID() string { + return randomString(accountLength, "0123456789") +} + +func randomString(length int, charset string) string { + b := strings.Builder{} + b.Grow(length) + for i := 0; i < length; i++ { + b.WriteByte(charset[seededRand.Intn(len(charset))]) + } + return b.String() +} diff --git a/internal/utils/url.go b/internal/utils/url.go new file mode 100644 index 0000000..4dfe01b --- /dev/null +++ b/internal/utils/url.go @@ -0,0 +1,19 @@ +package utils + +import ( + "net/url" + "strings" +) + +func IsValidURL(urlStr string) bool { + if strings.TrimSpace(urlStr) == "" { + return false + } + + parsedURL, err := url.Parse(urlStr) + if err != nil { + return false + } + + return parsedURL.Scheme != "" && parsedURL.Host != "" +} \ No newline at end of file diff --git a/static/style.css b/web/static/css/style.css similarity index 100% rename from static/style.css rename to web/static/css/style.css diff --git a/static/script.js b/web/static/js/script.js similarity index 98% rename from static/script.js rename to web/static/js/script.js index b119474..8781bd7 100644 --- a/static/script.js +++ b/web/static/js/script.js @@ -1,5 +1,6 @@ let currentAccount = ''; let refreshInterval; +const chartInstances = {}; function showError(elementId, message) { const errorElement = document.getElementById(elementId); @@ -275,7 +276,11 @@ function createCharts() { Object.entries(chartConfigs).forEach(([chartId, config]) => { const ctx = document.getElementById(chartId); if (ctx) { - new Chart(ctx, { + if (chartInstances[chartId]) { + chartInstances[chartId].destroy(); + } + + chartInstances[chartId] = new Chart(ctx, { type: 'pie', data: { labels: config.data.map(item => item._id || 'Unknown'), diff --git a/template/index.html b/web/templates/index.html similarity index 94% rename from template/index.html rename to web/templates/index.html index 4ae0aaa..034dfa8 100644 --- a/template/index.html +++ b/web/templates/index.html @@ -2,16 +2,15 @@ URL Shortener - + - +
From bc1371a7c99dc205de4764deea3358a369f6c4bd Mon Sep 17 00:00:00 2001 From: MarcUs7i <96580944+MarcUs7i@users.noreply.github.com> Date: Wed, 2 Apr 2025 08:46:50 +0200 Subject: [PATCH 06/81] Updated docker-compose-build.yml --- docker-compose-build.yml | 34 +++++++--------------------------- 1 file changed, 7 insertions(+), 27 deletions(-) diff --git a/docker-compose-build.yml b/docker-compose-build.yml index 2f45655..9754b28 100644 --- a/docker-compose-build.yml +++ b/docker-compose-build.yml @@ -1,30 +1,10 @@ services: - web: - build: . + ulinkshortener: + build: + context: . + dockerfile: Dockerfile + container_name: ulinkshortener + restart: unless-stopped network_mode: "host" - environment: - - MONGO_URI=mongodb://mongo:27017/uLinkShortener - depends_on: - - mongo - restart: unless-stopped - networks: - - app-network - - mongo: - image: mongo:latest volumes: - - mongodb_data:/data/db - ports: - - "27017:27017" - networks: - - app-network - environment: - - MONGO_INITDB_DATABASE=uLinkShortener - restart: unless-stopped - -volumes: - mongodb_data: - -networks: - app-network: - driver: bridge + - ./.env:/app/.env \ No newline at end of file From 1a173a699a46ecf31bd0c54ca5f85dcf78129c51 Mon Sep 17 00:00:00 2001 From: MarcUs7i <96580944+MarcUs7i@users.noreply.github.com> Date: Wed, 2 Apr 2025 09:00:32 +0200 Subject: [PATCH 07/81] disabled chart refreshes --- web/static/js/script.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/static/js/script.js b/web/static/js/script.js index 8781bd7..8f64f6b 100644 --- a/web/static/js/script.js +++ b/web/static/js/script.js @@ -34,7 +34,7 @@ window.addEventListener('load', () => { if (accountId) { handleLogin(accountId); } - setInterval(refreshPublicStats, 5000); + //setInterval(refreshPublicStats, 5000); }); async function refreshPublicStats() { From 1474edf7fe1572760730197ffab0e7e5ef08a214 Mon Sep 17 00:00:00 2001 From: MarcUs7i <96580944+MarcUs7i@users.noreply.github.com> Date: Wed, 2 Apr 2025 09:23:14 +0200 Subject: [PATCH 08/81] Fixed website analytics update --- Dockerfile | 4 +--- web/static/js/script.js | 28 ++++++++++++++++++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 12ed69f..aed9d38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,5 @@ RUN apk --no-cache add ca-certificates COPY --from=builder /app/ulinkshortener . COPY --from=builder /app/web /app/web -ENV PORT=5000 -EXPOSE 5000 - +EXPOSE ${PORT} CMD ["./ulinkshortener"] diff --git a/web/static/js/script.js b/web/static/js/script.js index 8f64f6b..1ecc736 100644 --- a/web/static/js/script.js +++ b/web/static/js/script.js @@ -173,18 +173,38 @@ function closeDeleteDialog() { } async function loadAnalytics() { + if (!currentAccount) { + console.log("No account ID available"); + return; + } + const response = await fetch(`/analytics/${currentAccount}`); + if (!response.ok) { + console.error(`Failed to fetch analytics: ${response.status}`); + return; + } + const data = await response.json(); + if (!data) { + console.error("Invalid analytics data format"); + return; + } + + const analyticsDiv = document.getElementById('analytics'); + analyticsDiv.innerHTML = '

Your Analytics

'; + if (!data.links || data.links.length === 0) { + analyticsDiv.innerHTML += '

No links created yet.

'; + return; + } + + const analytics = data.analytics || []; const openDetails = Array.from(document.querySelectorAll('details[open]')).map( detail => detail.getAttribute('data-visit-id') ); - const analyticsDiv = document.getElementById('analytics'); - analyticsDiv.innerHTML = '

Your Analytics

'; - data.links.forEach(link => { - const linkAnalytics = data.analytics.filter(a => a.link_id === link.short_id); + const linkAnalytics = analytics.filter(a => a.link_id === link.short_id); const clicks = linkAnalytics.length; const shortUrl = `${window.location.origin}/l/${link.short_id}`; From d88cb5cdd31928d9fb4818824df911a15da9d522 Mon Sep 17 00:00:00 2001 From: MarcUs7i <96580944+MarcUs7i@users.noreply.github.com> Date: Wed, 2 Apr 2025 09:43:34 +0200 Subject: [PATCH 09/81] Updated .env.example --- .env.example | 1 - 1 file changed, 1 deletion(-) diff --git a/.env.example b/.env.example index 9ba55a8..da6f996 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,2 @@ MONGO_URI=mongodb://localhost:27017/uLinkShortener -MONGO_DB_NAME=uLinkShortener PORT=5000 \ No newline at end of file From 30f11d49f67519027c69691ba7f4ce47a546e0c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Apr 2025 07:51:33 +0000 Subject: [PATCH 10/81] Bump actions/checkout from 2 to 4 Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v2...v4) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d05fca1..09c8584 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v1 From 3eca1505ebe98224cf4d13fbe6ab6474c5188659 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Apr 2025 07:51:36 +0000 Subject: [PATCH 11/81] Bump docker/login-action from 1 to 3 Bumps [docker/login-action](https://github.com/docker/login-action) from 1 to 3. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/v1...v3) --- updated-dependencies: - dependency-name: docker/login-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d05fca1..5556d12 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: uses: docker/setup-buildx-action@v1 - name: Login to GitHub Container Registry - uses: docker/login-action@v1 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} From edb583c6b2784e9a0d3ba418fc80c905fb113b35 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Apr 2025 07:51:37 +0000 Subject: [PATCH 12/81] Bump docker/build-push-action from 2 to 6 Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 2 to 6. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v2...v6) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d05fca1..9d0a999 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push Docker image - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v6 with: context: . push: true From 1330ae152bb2fd334769a2bfb565910ee38f2f89 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Apr 2025 07:57:38 +0000 Subject: [PATCH 13/81] Bump docker/setup-buildx-action from 1 to 3 Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 1 to 3. - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/v1...v3) --- updated-dependencies: - dependency-name: docker/setup-buildx-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09c8584..f73dd95 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry uses: docker/login-action@v1 From ddeffc250475feea7bd11dcbf3b09b4d8292b81b Mon Sep 17 00:00:00 2001 From: MarcUs7i <96580944+MarcUs7i@users.noreply.github.com> Date: Wed, 2 Apr 2025 12:03:30 +0200 Subject: [PATCH 14/81] Removed python files --- .github/workflows/build.yml | 2 + .github/workflows/release.yml | 35 ------- requirements.txt | 4 - server.py | 183 ---------------------------------- 4 files changed, 2 insertions(+), 222 deletions(-) delete mode 100644 .github/workflows/release.yml delete mode 100644 requirements.txt delete mode 100644 server.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f3a3bba..eaf2e1a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,8 @@ on: branches: [main] pull_request: branches: [main] + release: + types: [created] jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 09c8584..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Build on Release - -on: - release: - types: [created] - -jobs: - build: - runs-on: ubuntu-latest - - permissions: - contents: read - packages: write - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v1 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push Docker image - uses: docker/build-push-action@v2 - with: - context: . - push: true - platforms: linux/amd64, linux/arm64/v8, linux/arm/v7 - tags: ghcr.io/marcus7i/ulinkshortener:latest \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index ed16465..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -flask -flask-pymongo -python-dotenv -requests \ No newline at end of file diff --git a/server.py b/server.py deleted file mode 100644 index a38b8a6..0000000 --- a/server.py +++ /dev/null @@ -1,183 +0,0 @@ -from flask import Flask, request, redirect, render_template, jsonify, make_response -from flask_pymongo import PyMongo -from datetime import datetime -import random -import string -import os -from dotenv import load_dotenv -import json -from urllib.parse import quote_plus, urlparse - -load_dotenv() - -app = Flask(__name__, template_folder='template', static_folder='static', static_url_path='/static') -app.config["MONGO_URI"] = os.getenv("MONGO_URI") -mongo = PyMongo(app) - -def generate_short_id(): - return ''.join(random.choices(string.ascii_letters + string.digits, k=8)) - -def get_client_info(): - user_agent = request.user_agent - ip_address = (request.headers.get('CF-Connecting-IP') or - request.headers.get('X-Real-IP') or - request.headers.get('X-Forwarded-For', '').split(',')[0].strip() or - request.remote_addr or - 'Unknown') - - return { - 'ip': ip_address, - 'user_agent': user_agent.string, - 'platform': request.headers.get('sec-ch-ua-platform', user_agent.platform or 'Unknown'), - 'browser': user_agent.browser or 'Unknown', - 'version': user_agent.version or '', - 'language': request.accept_languages.best or 'Unknown', - 'referrer': request.referrer or 'Direct', - 'timestamp': datetime.now(), - 'remote_port': request.environ.get('REMOTE_PORT', 'Unknown'), - 'accept': request.headers.get('Accept', 'Unknown'), - 'accept_language': request.headers.get('Accept-Language', 'Unknown'), - 'accept_encoding': request.headers.get('Accept-Encoding', 'Unknown'), - 'screen_size': request.headers.get('Sec-CH-UA-Platform-Screen', 'Unknown'), - 'window_size': request.headers.get('Viewport-Width', 'Unknown'), - 'country': request.headers.get('CF-IPCountry', 'Unknown'), # If using Cloudflare - 'isp': request.headers.get('X-ISP', 'Unknown'), # Requires additional middleware - 'ip_version': 'IPv6' if ':' in ip_address else 'IPv4' - } - -def is_valid_url(url): - if not url or url.isspace(): - return False - try: - result = urlparse(url) - return all([result.scheme, result.netloc]) - except: - return False - -@app.route('/') -def home(): - account_id = request.cookies.get('account_id') - - stats = { - 'total_links': mongo.db.links.count_documents({}), - 'total_clicks': mongo.db.analytics.count_documents({}), - 'chart_data': { - 'ip_versions': list(mongo.db.analytics.aggregate([ - {"$group": {"_id": "$ip_version", "count": {"$sum": 1}}}, - {"$sort": {"count": -1}} - ])), - 'os_stats': list(mongo.db.analytics.aggregate([ - {"$group": {"_id": "$platform", "count": {"$sum": 1}}}, - {"$sort": {"count": -1}}, - {"$limit": 10} - ])), - 'country_stats': list(mongo.db.analytics.aggregate([ - {"$group": {"_id": "$country", "count": {"$sum": 1}}}, - {"$sort": {"count": -1}}, - {"$limit": 10} - ])), - 'isp_stats': list(mongo.db.analytics.aggregate([ - {"$group": {"_id": "$isp", "count": {"$sum": 1}}}, - {"$sort": {"count": -1}}, - {"$limit": 10} - ])) - }, - 'logged_in': bool(account_id) - } - return render_template('index.html', stats=stats) - -@app.route('/register', methods=['POST']) -def register(): - account_id = ''.join(random.choices(string.digits, k=8)) - while mongo.db.users.find_one({'account_id': account_id}): - account_id = ''.join(random.choices(string.digits, k=8)) - - mongo.db.users.insert_one({'account_id': account_id}) - response = make_response(jsonify({'account_id': account_id})) - response.set_cookie('account_id', account_id, max_age=31536000) - return response - -@app.route('/login', methods=['POST']) -def login(): - account_id = request.json.get('account_id') - user = mongo.db.users.find_one({'account_id': account_id}) - if user: - response = make_response(jsonify({'success': True})) - response.set_cookie('account_id', account_id, max_age=31536000) - return response - return jsonify({'success': False}), 401 - -@app.route('/logout', methods=['POST']) -def logout(): - response = make_response(jsonify({'success': True})) - response.delete_cookie('account_id') - return response - -@app.route('/create', methods=['POST']) -def create_link(): - account_id = request.json.get('account_id') - target_url = request.json.get('url') - - if not mongo.db.users.find_one({'account_id': account_id}): - return jsonify({'error': 'Invalid account'}), 401 - - if not is_valid_url(target_url): - return jsonify({'error': 'Invalid URL. Please provide a valid URL with scheme (e.g., http:// or https://)'}), 400 - - short_id = generate_short_id() - mongo.db.links.insert_one({ - 'short_id': short_id, - 'target_url': target_url, - 'account_id': account_id, - 'created_at': datetime.now() - }) - - return jsonify({'short_url': f'/l/{short_id}'}) - -@app.route('/l/') -def redirect_link(short_id): - link = mongo.db.links.find_one({'short_id': short_id}) - if not link: - return 'Link not found', 404 - - client_info = get_client_info() - mongo.db.analytics.insert_one({ - 'link_id': short_id, - 'account_id': link['account_id'], - **client_info - }) - - return redirect(link['target_url']) - -@app.route('/analytics/') -def get_analytics(account_id): - if not mongo.db.users.find_one({'account_id': account_id}): - return jsonify({'error': 'Invalid account'}), 401 - - links = list(mongo.db.links.find({'account_id': account_id}, {'_id': 0})) - analytics = list(mongo.db.analytics.find({'account_id': account_id}, {'_id': 0})) - - return jsonify({ - 'links': links, - 'analytics': analytics - }) - -@app.route('/delete/', methods=['DELETE']) -def delete_link(short_id): - account_id = request.cookies.get('account_id') - if not account_id: - return jsonify({'error': 'Not logged in'}), 401 - - link = mongo.db.links.find_one({'short_id': short_id, 'account_id': account_id}) - if not link: - return jsonify({'error': 'Link not found or unauthorized'}), 404 - - # Delete the link and its analytics - mongo.db.links.delete_one({'short_id': short_id}) - mongo.db.analytics.delete_many({'link_id': short_id}) - - return jsonify({'success': True}) - -if __name__ == '__main__': - port = int(os.getenv("PORT", 5000)) - app.run(debug=True, host="0.0.0.0", port=port) From a4ff5216ef3f806931b68358bedb055f968d6a87 Mon Sep 17 00:00:00 2001 From: MarcUs7i <96580944+MarcUs7i@users.noreply.github.com> Date: Wed, 2 Apr 2025 12:03:41 +0200 Subject: [PATCH 15/81] Updated README.md --- LICENSE | 7 +++++++ README.md | 46 +++++++++++++++++++++++++--------------------- 2 files changed, 32 insertions(+), 21 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b369d7f --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright 2025 MarcUs7i + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 06d5ae8..34002ee 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,43 @@ # uLinkShortener -This project is the code behind [u.marcus7i.net](https://u.marcus7i.net), a custom URL shortener. It uses Flask, MongoDB, and Docker for quick deployment. +This project is the code behind [u.marcus7i.net](https://u.marcus7i.net), a custom URL shortener. It uses Go, MongoDB, and Docker for quick deployment. ## Prerequisites -- Python +- Go - MongoDB database (local or remote) - Docker & Docker Compose (optional, for containerized deployments) ## Setup 1. Clone the repository -2. Create a virtual environment (optional): - ``` - python -m venv env - source env/bin/activate # Linux/Mac - env\Scripts\activate # Windows - ``` -3. Install dependencies: - ``` - pip install -r requirements.txt - ``` 4. Define environment variables in the `.env` file: ``` MONGO_URI=mongodb://:@:/ + PORT= ``` ## Running Locally -1. Start MongoDB -2. Run: + +### Without Docker + +1. Install dependencies: ``` - python server.py + go mod download + ``` +2. Build and run: + ``` + go run cmd/api/main.go + ``` + +### With Docker + +1. Build and run with docker compose: + ``` + docker-compose -f docker-compose-build.yml up --build + ``` +2. Use of pre-built image: + ``` + docker compose up -d ``` -3. Access the app at http://localhost:5000 ## Docker Deployment 1. Build and run containers: @@ -39,9 +46,6 @@ This project is the code behind [u.marcus7i.net](https://u.marcus7i.net), a cust ``` 2. The application will be available at http://localhost:5000 -## Using GHCR +## License -Pull the prebuilt image: - ```bash - docker pull ghcr.io/MarcUs7i/ulinkshortener:latest - ``` \ No newline at end of file +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. \ No newline at end of file From ab484dd0b4d7fd0c06c1ade6b0cd659ee73c0084 Mon Sep 17 00:00:00 2001 From: MarcUs7i <96580944+MarcUs7i@users.noreply.github.com> Date: Wed, 2 Apr 2025 12:03:30 +0200 Subject: [PATCH 16/81] Removed python files --- .github/workflows/build.yml | 2 + .github/workflows/release.yml | 35 ------- requirements.txt | 4 - server.py | 183 ---------------------------------- 4 files changed, 2 insertions(+), 222 deletions(-) delete mode 100644 .github/workflows/release.yml delete mode 100644 requirements.txt delete mode 100644 server.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f3a3bba..eaf2e1a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,8 @@ on: branches: [main] pull_request: branches: [main] + release: + types: [created] jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index e881e60..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Build on Release - -on: - release: - types: [created] - -jobs: - build: - runs-on: ubuntu-latest - - permissions: - contents: read - packages: write - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: . - push: true - platforms: linux/amd64, linux/arm64/v8, linux/arm/v7 - tags: ghcr.io/marcus7i/ulinkshortener:latest \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index ed16465..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -flask -flask-pymongo -python-dotenv -requests \ No newline at end of file diff --git a/server.py b/server.py deleted file mode 100644 index a38b8a6..0000000 --- a/server.py +++ /dev/null @@ -1,183 +0,0 @@ -from flask import Flask, request, redirect, render_template, jsonify, make_response -from flask_pymongo import PyMongo -from datetime import datetime -import random -import string -import os -from dotenv import load_dotenv -import json -from urllib.parse import quote_plus, urlparse - -load_dotenv() - -app = Flask(__name__, template_folder='template', static_folder='static', static_url_path='/static') -app.config["MONGO_URI"] = os.getenv("MONGO_URI") -mongo = PyMongo(app) - -def generate_short_id(): - return ''.join(random.choices(string.ascii_letters + string.digits, k=8)) - -def get_client_info(): - user_agent = request.user_agent - ip_address = (request.headers.get('CF-Connecting-IP') or - request.headers.get('X-Real-IP') or - request.headers.get('X-Forwarded-For', '').split(',')[0].strip() or - request.remote_addr or - 'Unknown') - - return { - 'ip': ip_address, - 'user_agent': user_agent.string, - 'platform': request.headers.get('sec-ch-ua-platform', user_agent.platform or 'Unknown'), - 'browser': user_agent.browser or 'Unknown', - 'version': user_agent.version or '', - 'language': request.accept_languages.best or 'Unknown', - 'referrer': request.referrer or 'Direct', - 'timestamp': datetime.now(), - 'remote_port': request.environ.get('REMOTE_PORT', 'Unknown'), - 'accept': request.headers.get('Accept', 'Unknown'), - 'accept_language': request.headers.get('Accept-Language', 'Unknown'), - 'accept_encoding': request.headers.get('Accept-Encoding', 'Unknown'), - 'screen_size': request.headers.get('Sec-CH-UA-Platform-Screen', 'Unknown'), - 'window_size': request.headers.get('Viewport-Width', 'Unknown'), - 'country': request.headers.get('CF-IPCountry', 'Unknown'), # If using Cloudflare - 'isp': request.headers.get('X-ISP', 'Unknown'), # Requires additional middleware - 'ip_version': 'IPv6' if ':' in ip_address else 'IPv4' - } - -def is_valid_url(url): - if not url or url.isspace(): - return False - try: - result = urlparse(url) - return all([result.scheme, result.netloc]) - except: - return False - -@app.route('/') -def home(): - account_id = request.cookies.get('account_id') - - stats = { - 'total_links': mongo.db.links.count_documents({}), - 'total_clicks': mongo.db.analytics.count_documents({}), - 'chart_data': { - 'ip_versions': list(mongo.db.analytics.aggregate([ - {"$group": {"_id": "$ip_version", "count": {"$sum": 1}}}, - {"$sort": {"count": -1}} - ])), - 'os_stats': list(mongo.db.analytics.aggregate([ - {"$group": {"_id": "$platform", "count": {"$sum": 1}}}, - {"$sort": {"count": -1}}, - {"$limit": 10} - ])), - 'country_stats': list(mongo.db.analytics.aggregate([ - {"$group": {"_id": "$country", "count": {"$sum": 1}}}, - {"$sort": {"count": -1}}, - {"$limit": 10} - ])), - 'isp_stats': list(mongo.db.analytics.aggregate([ - {"$group": {"_id": "$isp", "count": {"$sum": 1}}}, - {"$sort": {"count": -1}}, - {"$limit": 10} - ])) - }, - 'logged_in': bool(account_id) - } - return render_template('index.html', stats=stats) - -@app.route('/register', methods=['POST']) -def register(): - account_id = ''.join(random.choices(string.digits, k=8)) - while mongo.db.users.find_one({'account_id': account_id}): - account_id = ''.join(random.choices(string.digits, k=8)) - - mongo.db.users.insert_one({'account_id': account_id}) - response = make_response(jsonify({'account_id': account_id})) - response.set_cookie('account_id', account_id, max_age=31536000) - return response - -@app.route('/login', methods=['POST']) -def login(): - account_id = request.json.get('account_id') - user = mongo.db.users.find_one({'account_id': account_id}) - if user: - response = make_response(jsonify({'success': True})) - response.set_cookie('account_id', account_id, max_age=31536000) - return response - return jsonify({'success': False}), 401 - -@app.route('/logout', methods=['POST']) -def logout(): - response = make_response(jsonify({'success': True})) - response.delete_cookie('account_id') - return response - -@app.route('/create', methods=['POST']) -def create_link(): - account_id = request.json.get('account_id') - target_url = request.json.get('url') - - if not mongo.db.users.find_one({'account_id': account_id}): - return jsonify({'error': 'Invalid account'}), 401 - - if not is_valid_url(target_url): - return jsonify({'error': 'Invalid URL. Please provide a valid URL with scheme (e.g., http:// or https://)'}), 400 - - short_id = generate_short_id() - mongo.db.links.insert_one({ - 'short_id': short_id, - 'target_url': target_url, - 'account_id': account_id, - 'created_at': datetime.now() - }) - - return jsonify({'short_url': f'/l/{short_id}'}) - -@app.route('/l/') -def redirect_link(short_id): - link = mongo.db.links.find_one({'short_id': short_id}) - if not link: - return 'Link not found', 404 - - client_info = get_client_info() - mongo.db.analytics.insert_one({ - 'link_id': short_id, - 'account_id': link['account_id'], - **client_info - }) - - return redirect(link['target_url']) - -@app.route('/analytics/') -def get_analytics(account_id): - if not mongo.db.users.find_one({'account_id': account_id}): - return jsonify({'error': 'Invalid account'}), 401 - - links = list(mongo.db.links.find({'account_id': account_id}, {'_id': 0})) - analytics = list(mongo.db.analytics.find({'account_id': account_id}, {'_id': 0})) - - return jsonify({ - 'links': links, - 'analytics': analytics - }) - -@app.route('/delete/', methods=['DELETE']) -def delete_link(short_id): - account_id = request.cookies.get('account_id') - if not account_id: - return jsonify({'error': 'Not logged in'}), 401 - - link = mongo.db.links.find_one({'short_id': short_id, 'account_id': account_id}) - if not link: - return jsonify({'error': 'Link not found or unauthorized'}), 404 - - # Delete the link and its analytics - mongo.db.links.delete_one({'short_id': short_id}) - mongo.db.analytics.delete_many({'link_id': short_id}) - - return jsonify({'success': True}) - -if __name__ == '__main__': - port = int(os.getenv("PORT", 5000)) - app.run(debug=True, host="0.0.0.0", port=port) From f8e49d5f6aab423b884a0b21b91774437a1a1b54 Mon Sep 17 00:00:00 2001 From: MarcUs7i <96580944+MarcUs7i@users.noreply.github.com> Date: Wed, 2 Apr 2025 12:03:41 +0200 Subject: [PATCH 17/81] Updated README.md --- LICENSE | 7 +++++++ README.md | 46 +++++++++++++++++++++++++--------------------- 2 files changed, 32 insertions(+), 21 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b369d7f --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright 2025 MarcUs7i + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 06d5ae8..34002ee 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,43 @@ # uLinkShortener -This project is the code behind [u.marcus7i.net](https://u.marcus7i.net), a custom URL shortener. It uses Flask, MongoDB, and Docker for quick deployment. +This project is the code behind [u.marcus7i.net](https://u.marcus7i.net), a custom URL shortener. It uses Go, MongoDB, and Docker for quick deployment. ## Prerequisites -- Python +- Go - MongoDB database (local or remote) - Docker & Docker Compose (optional, for containerized deployments) ## Setup 1. Clone the repository -2. Create a virtual environment (optional): - ``` - python -m venv env - source env/bin/activate # Linux/Mac - env\Scripts\activate # Windows - ``` -3. Install dependencies: - ``` - pip install -r requirements.txt - ``` 4. Define environment variables in the `.env` file: ``` MONGO_URI=mongodb://:@:/ + PORT= ``` ## Running Locally -1. Start MongoDB -2. Run: + +### Without Docker + +1. Install dependencies: ``` - python server.py + go mod download + ``` +2. Build and run: + ``` + go run cmd/api/main.go + ``` + +### With Docker + +1. Build and run with docker compose: + ``` + docker-compose -f docker-compose-build.yml up --build + ``` +2. Use of pre-built image: + ``` + docker compose up -d ``` -3. Access the app at http://localhost:5000 ## Docker Deployment 1. Build and run containers: @@ -39,9 +46,6 @@ This project is the code behind [u.marcus7i.net](https://u.marcus7i.net), a cust ``` 2. The application will be available at http://localhost:5000 -## Using GHCR +## License -Pull the prebuilt image: - ```bash - docker pull ghcr.io/MarcUs7i/ulinkshortener:latest - ``` \ No newline at end of file +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. \ No newline at end of file From ec50bba19b99cf738ea54c83b44561609551fc00 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Apr 2025 10:30:34 +0000 Subject: [PATCH 18/81] Bump golang.org/x/crypto from 0.26.0 to 0.31.0 Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.26.0 to 0.31.0. - [Commits](https://github.com/golang/crypto/compare/v0.26.0...v0.31.0) --- updated-dependencies: - dependency-name: golang.org/x/crypto dependency-type: indirect ... Signed-off-by: dependabot[bot] --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index e6217e8..80d3a84 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - golang.org/x/crypto v0.26.0 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/text v0.17.0 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/text v0.21.0 // indirect ) diff --git a/go.sum b/go.sum index b427448..5f2dc6a 100644 --- a/go.sum +++ b/go.sum @@ -27,16 +27,16 @@ go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeH go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -48,8 +48,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= -golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= From 71bdd08da9833834120a27d88976702438900f22 Mon Sep 17 00:00:00 2001 From: MarcUs7i <96580944+MarcUs7i@users.noreply.github.com> Date: Fri, 11 Jul 2025 00:59:08 +0200 Subject: [PATCH 19/81] =?UTF-8?q?finished=20=C2=B5LinkShortener=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 19 +- .env.example | 6 +- .github/dependabot.yml | 28 +- .github/workflows/build.yml | 7 +- .gitignore | 46 +- Dockerfile | 27 +- LICENSE | 7 - README.md | 17 +- bun.lock | 998 ++++++++++++++++++ cmd/api/main.go | 92 -- docker-compose-build.yml | 13 +- docker-compose.yml | 15 +- eslint.config.mjs | 16 + go.mod | 23 - go.sum | 56 - internal/api/handlers/analytics.go | 126 --- internal/api/handlers/auth.go | 95 -- internal/api/handlers/handlers.go | 38 - internal/api/handlers/url.go | 132 --- internal/api/router.go | 39 - internal/config/config.go | 26 - internal/database/mongodb.go | 73 -- internal/models/analytics.go | 46 - internal/models/link.go | 12 - internal/models/user.go | 10 - internal/utils/clientinfo.go | 83 -- internal/utils/shortid.go | 32 - internal/utils/url.go | 19 - next.config.ts | 7 + package.json | 34 + postcss.config.mjs | 5 + src/app/Home.module.css | 168 +++ src/app/admin/AdminDashboard.module.css | 228 ++++ src/app/admin/page.tsx | 278 +++++ .../user/[accountId]/UserDetail.module.css | 174 +++ .../[shortId]/AdminLinkDetail.module.css | 344 ++++++ .../user/[accountId]/links/[shortId]/page.tsx | 574 ++++++++++ src/app/admin/user/[accountId]/page.tsx | 241 +++++ src/app/api/admin/statistics/rebuild/route.ts | 31 + .../admin/users/[accountId]/admin/route.ts | 67 ++ .../links/[shortId]/analytics/route.ts | 116 ++ .../[accountId]/links/[shortId]/route.ts | 137 +++ .../admin/users/[accountId]/links/route.ts | 45 + src/app/api/admin/users/[accountId]/route.ts | 53 + .../[accountId]/sessions/revoke/route.ts | 39 + .../admin/users/[accountId]/sessions/route.ts | 47 + src/app/api/admin/users/route.ts | 91 ++ src/app/api/analytics/route.ts | 185 ++++ src/app/api/auth/[...nextauth]/route.ts | 102 ++ src/app/api/auth/check-session/route.ts | 43 + src/app/api/auth/register/route.ts | 61 ++ src/app/api/auth/remove/route.ts | 47 + src/app/api/auth/sessions/revoke/route.ts | 46 + src/app/api/auth/sessions/route.ts | 38 + src/app/api/link/route.ts | 252 +++++ src/app/api/links/route.ts | 44 + src/app/api/statistics/route.ts | 33 + src/app/dashboard/Dashboard.module.css | 163 +++ .../link/[shortId]/LinkDetail.module.css | 280 +++++ src/app/dashboard/link/[shortId]/page.tsx | 545 ++++++++++ src/app/dashboard/page.tsx | 164 +++ .../dashboard/security/Security.module.css | 120 +++ src/app/dashboard/security/page.tsx | 96 ++ src/app/favicon.ico | Bin 0 -> 41662 bytes src/app/globals.css | 67 ++ src/app/l/[shortId]/route.ts | 36 + src/app/layout.tsx | 52 + src/app/not-found/NotFound.module.css | 57 + src/app/not-found/page.tsx | 18 + src/app/page.tsx | 212 ++++ src/app/privacy/Privacy.module.css | 117 ++ src/app/privacy/page.tsx | 77 ++ src/app/tos/ToS.module.css | 116 ++ src/app/tos/page.tsx | 77 ++ src/components/Footer.module.css | 53 + src/components/Header.module.css | 222 ++++ src/components/Providers.tsx | 11 + src/components/ResponsiveLayout.tsx | 43 + src/components/SessionMonitor.tsx | 45 + src/components/footer.tsx | 19 + src/components/header.tsx | 197 ++++ src/components/ui/ConfirmModal.module.css | 84 ++ src/components/ui/ConfirmModal.tsx | 90 ++ src/components/ui/Graph.module.css | 46 + src/components/ui/Graph.tsx | 296 ++++++ src/components/ui/LoadingIcon.tsx | 46 + src/components/ui/Toast.module.css | 57 + src/components/ui/Toast.tsx | 24 + .../ui/admin/AdminLinkTable.module.css | 219 ++++ src/components/ui/admin/AdminLinkTable.tsx | 203 ++++ .../ui/dashboard/LinkTable.module.css | 219 ++++ src/components/ui/dashboard/LinkTable.tsx | 196 ++++ .../ui/dashboard/SessionManager.module.css | 157 +++ .../ui/dashboard/SessionManager.tsx | 189 ++++ .../dashboard/link/AnalyticsTable.module.css | 267 +++++ .../ui/dashboard/link/AnalyticsTable.tsx | 262 +++++ src/contexts/ToastContext.tsx | 51 + src/lib/analyticsdb.ts | 146 +++ src/lib/linkdb.ts | 131 +++ src/lib/logger.ts | 22 + src/lib/mongodb.ts | 43 + src/lib/sessiondb.ts | 141 +++ src/lib/statisticsdb.ts | 80 ++ src/lib/userdb.ts | 158 +++ src/lib/utils.ts | 127 +++ src/middleware.ts | 55 + src/types/analytics.d.ts | 30 + src/types/auth.d.ts | 22 + src/types/global.d.ts | 4 + src/types/link.d.ts | 7 + src/types/session.d.ts | 10 + src/types/statistics.d.ts | 18 + src/types/user.d.ts | 5 + tsconfig.json | 27 + web/static/css/style.css | 299 ------ web/static/js/script.js | 344 ------ web/templates/index.html | 84 -- 117 files changed, 10892 insertions(+), 1685 deletions(-) delete mode 100644 LICENSE create mode 100644 bun.lock delete mode 100644 cmd/api/main.go create mode 100644 eslint.config.mjs delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 internal/api/handlers/analytics.go delete mode 100644 internal/api/handlers/auth.go delete mode 100644 internal/api/handlers/handlers.go delete mode 100644 internal/api/handlers/url.go delete mode 100644 internal/api/router.go delete mode 100644 internal/config/config.go delete mode 100644 internal/database/mongodb.go delete mode 100644 internal/models/analytics.go delete mode 100644 internal/models/link.go delete mode 100644 internal/models/user.go delete mode 100644 internal/utils/clientinfo.go delete mode 100644 internal/utils/shortid.go delete mode 100644 internal/utils/url.go create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 src/app/Home.module.css create mode 100644 src/app/admin/AdminDashboard.module.css create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/admin/user/[accountId]/UserDetail.module.css create mode 100644 src/app/admin/user/[accountId]/links/[shortId]/AdminLinkDetail.module.css create mode 100644 src/app/admin/user/[accountId]/links/[shortId]/page.tsx create mode 100644 src/app/admin/user/[accountId]/page.tsx create mode 100644 src/app/api/admin/statistics/rebuild/route.ts create mode 100644 src/app/api/admin/users/[accountId]/admin/route.ts create mode 100644 src/app/api/admin/users/[accountId]/links/[shortId]/analytics/route.ts create mode 100644 src/app/api/admin/users/[accountId]/links/[shortId]/route.ts create mode 100644 src/app/api/admin/users/[accountId]/links/route.ts create mode 100644 src/app/api/admin/users/[accountId]/route.ts create mode 100644 src/app/api/admin/users/[accountId]/sessions/revoke/route.ts create mode 100644 src/app/api/admin/users/[accountId]/sessions/route.ts create mode 100644 src/app/api/admin/users/route.ts create mode 100644 src/app/api/analytics/route.ts create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/app/api/auth/check-session/route.ts create mode 100644 src/app/api/auth/register/route.ts create mode 100644 src/app/api/auth/remove/route.ts create mode 100644 src/app/api/auth/sessions/revoke/route.ts create mode 100644 src/app/api/auth/sessions/route.ts create mode 100644 src/app/api/link/route.ts create mode 100644 src/app/api/links/route.ts create mode 100644 src/app/api/statistics/route.ts create mode 100644 src/app/dashboard/Dashboard.module.css create mode 100644 src/app/dashboard/link/[shortId]/LinkDetail.module.css create mode 100644 src/app/dashboard/link/[shortId]/page.tsx create mode 100644 src/app/dashboard/page.tsx create mode 100644 src/app/dashboard/security/Security.module.css create mode 100644 src/app/dashboard/security/page.tsx create mode 100644 src/app/favicon.ico create mode 100644 src/app/globals.css create mode 100644 src/app/l/[shortId]/route.ts create mode 100644 src/app/layout.tsx create mode 100644 src/app/not-found/NotFound.module.css create mode 100644 src/app/not-found/page.tsx create mode 100644 src/app/page.tsx create mode 100644 src/app/privacy/Privacy.module.css create mode 100644 src/app/privacy/page.tsx create mode 100644 src/app/tos/ToS.module.css create mode 100644 src/app/tos/page.tsx create mode 100644 src/components/Footer.module.css create mode 100644 src/components/Header.module.css create mode 100644 src/components/Providers.tsx create mode 100644 src/components/ResponsiveLayout.tsx create mode 100644 src/components/SessionMonitor.tsx create mode 100644 src/components/footer.tsx create mode 100644 src/components/header.tsx create mode 100644 src/components/ui/ConfirmModal.module.css create mode 100644 src/components/ui/ConfirmModal.tsx create mode 100644 src/components/ui/Graph.module.css create mode 100644 src/components/ui/Graph.tsx create mode 100644 src/components/ui/LoadingIcon.tsx create mode 100644 src/components/ui/Toast.module.css create mode 100644 src/components/ui/Toast.tsx create mode 100644 src/components/ui/admin/AdminLinkTable.module.css create mode 100644 src/components/ui/admin/AdminLinkTable.tsx create mode 100644 src/components/ui/dashboard/LinkTable.module.css create mode 100644 src/components/ui/dashboard/LinkTable.tsx create mode 100644 src/components/ui/dashboard/SessionManager.module.css create mode 100644 src/components/ui/dashboard/SessionManager.tsx create mode 100644 src/components/ui/dashboard/link/AnalyticsTable.module.css create mode 100644 src/components/ui/dashboard/link/AnalyticsTable.tsx create mode 100644 src/contexts/ToastContext.tsx create mode 100644 src/lib/analyticsdb.ts create mode 100644 src/lib/linkdb.ts create mode 100644 src/lib/logger.ts create mode 100644 src/lib/mongodb.ts create mode 100644 src/lib/sessiondb.ts create mode 100644 src/lib/statisticsdb.ts create mode 100644 src/lib/userdb.ts create mode 100644 src/lib/utils.ts create mode 100644 src/middleware.ts create mode 100644 src/types/analytics.d.ts create mode 100644 src/types/auth.d.ts create mode 100644 src/types/global.d.ts create mode 100644 src/types/link.d.ts create mode 100644 src/types/session.d.ts create mode 100644 src/types/statistics.d.ts create mode 100644 src/types/user.d.ts create mode 100644 tsconfig.json delete mode 100644 web/static/css/style.css delete mode 100644 web/static/js/script.js delete mode 100644 web/templates/index.html diff --git a/.dockerignore b/.dockerignore index 37c14e6..ab64301 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,13 +1,10 @@ -__pycache__ -*.pyc -*.pyo -*.pyd -.Python -env/ -venv/ -.env +node_modules +.next .git -.gitignore -.vscode -*.md +.github *.log +.env +.env.local +.env.development.local +.env.test.local +.env.production.local \ No newline at end of file diff --git a/.env.example b/.env.example index da6f996..3d9d7fe 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,4 @@ -MONGO_URI=mongodb://localhost:27017/uLinkShortener -PORT=5000 \ No newline at end of file +MONGO_URI=mongodb://:@:/ +MONGO_DB_NAME= +NEXTAUTH_SECRET=VERY_SECURE_SECRET +NEXTAUTH_URL=http://localhost:3000 \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a4dc91f..89184f4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,14 +1,32 @@ version: 2 updates: - - package-ecosystem: "gomod" + # Monitor npm dependencies + - package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 10 - - # Enable version updates for GitHub Actions - - package-ecosystem: "github-actions" + versioning-strategy: auto + labels: + - "dependencies" + - "npm" + + # Monitor Docker dependencies + - package-ecosystem: "docker" directory: "/" schedule: interval: "weekly" - open-pull-requests-limit: 5 \ No newline at end of file + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "docker" + + # Monitor GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "github-actions" \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eaf2e1a..0449ae4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,9 +36,10 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ghcr.io/marcus7i/ulinkshortener + images: ghcr.io/${{ github.repository }} tags: | - type=raw,value=latest + type=ref,event=tag + type=raw,value=latest,enable={{is_default_branch}} type=sha,format=short type=ref,event=branch type=ref,event=pr @@ -50,5 +51,5 @@ jobs: file: ./Dockerfile push: ${{ github.event_name != 'pull_request' }} platforms: linux/amd64, linux/arm64/v8 - tags: ghcr.io/marcus7i/ulinkshortener:latest + tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index d558a03..3431edd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,44 @@ -venv/ -.env \ No newline at end of file +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# logs +*.log \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index aed9d38..370565d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,23 @@ -FROM golang:1.21-alpine AS builder +FROM oven/bun:1 AS builder WORKDIR /app -COPY go.mod go.sum ./ -RUN go mod download +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ulinkshortener ./cmd/api -FROM alpine:latest + +RUN bun run build + +FROM oven/bun:1-slim AS runner WORKDIR /app -RUN apk --no-cache add ca-certificates -COPY --from=builder /app/ulinkshortener . -COPY --from=builder /app/web /app/web +ENV NODE_ENV=production -EXPOSE ${PORT} -CMD ["./ulinkshortener"] +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/next.config.ts ./next.config.ts + +RUN bun install --production +EXPOSE 3000 + +CMD ["bun", "run", "start"] \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index b369d7f..0000000 --- a/LICENSE +++ /dev/null @@ -1,7 +0,0 @@ -Copyright 2025 MarcUs7i - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 34002ee..e6b075b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# uLinkShortener +# µLinkShortener v2 -This project is the code behind [u.marcus7i.net](https://u.marcus7i.net), a custom URL shortener. It uses Go, MongoDB, and Docker for quick deployment. +This project is the code behind [u.marcus7i.net](https://u.marcus7i.net), a custom URL shortener. It uses Next.JS, MongoDB, and Docker for quick deployment. ## Prerequisites -- Go +- Next.js - MongoDB database (local or remote) - Docker & Docker Compose (optional, for containerized deployments) @@ -12,7 +12,9 @@ This project is the code behind [u.marcus7i.net](https://u.marcus7i.net), a cust 4. Define environment variables in the `.env` file: ``` MONGO_URI=mongodb://:@:/ - PORT= + MONGO_DB_NAME= + NEXTAUTH_SECRET=VERY_SECURE_SECRET + NEXTAUTH_URL=http://localhost:3000 ``` ## Running Locally @@ -21,11 +23,12 @@ This project is the code behind [u.marcus7i.net](https://u.marcus7i.net), a cust 1. Install dependencies: ``` - go mod download + bun install ``` 2. Build and run: ``` - go run cmd/api/main.go + bun run build + bun run start ``` ### With Docker @@ -44,7 +47,7 @@ This project is the code behind [u.marcus7i.net](https://u.marcus7i.net), a cust ``` docker-compose up --build ``` -2. The application will be available at http://localhost:5000 +2. The application will be available at http://localhost:3000 ## License diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..a50a921 --- /dev/null +++ b/bun.lock @@ -0,0 +1,998 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "ulinkshortener", + "dependencies": { + "chart.js": "^4.5.0", + "mongodb": "^6.17.0", + "next": "15.3.3", + "next-auth": "^4.24.11", + "react": "^19.0.0", + "react-chartjs-2": "^5.3.0", + "react-dom": "^19.0.0", + "ua-parser-js": "^2.0.3", + "winston": "^3.17.0", + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/uuid": "^10.0.0", + "eslint": "^9", + "eslint-config-next": "15.3.3", + "tailwindcss": "^4", + "typescript": "^5", + }, + }, + }, + "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + + "@babel/runtime": ["@babel/runtime@7.27.6", "", {}, "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q=="], + + "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], + + "@dabh/diagnostics": ["@dabh/diagnostics@2.0.3", "", { "dependencies": { "colorspace": "1.1.x", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA=="], + + "@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" } }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], + + "@eslint/config-array": ["@eslint/config-array@0.20.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.2.3", "", {}, "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg=="], + + "@eslint/core": ["@eslint/core@0.14.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], + + "@eslint/js": ["@eslint/js@9.28.0", "", {}, "sha512-fnqSjGWd/CoIp4EXIxWVK/sHA6DOHN4+8Ix2cX5ycOY7LG0UY8nHCU5pIp2eaE1Mc7Qd8kHspYNzYXT2ojPLzg=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.2", "", { "dependencies": { "@eslint/core": "^0.15.0", "levn": "^0.4.1" } }, "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.1.0" }, "os": "darwin", "cpu": "arm64" }, "sha512-OfXHZPppddivUJnqyKoi5YVeHRkkNE2zUFT2gbpKxp/JZCFYEYubnMg+gOp6lWfasPrTS+KPosKqdI+ELYVDtg=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.1.0" }, "os": "darwin", "cpu": "x64" }, "sha512-dYvWqmjU9VxqXmjEtjmvHnGqF8GrVjM2Epj9rJ6BUIXvk8slvNDJbhGFvIoXzkDhrJC2jUxNLz/GUjjvSzfw+g=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.1.0", "", { "os": "linux", "cpu": "arm" }, "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.1.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.1.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.1.0" }, "os": "linux", "cpu": "arm" }, "sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.1.0" }, "os": "linux", "cpu": "arm64" }, "sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.1.0" }, "os": "linux", "cpu": "s390x" }, "sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.1.0" }, "os": "linux", "cpu": "x64" }, "sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" }, "os": "linux", "cpu": "arm64" }, "sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.1.0" }, "os": "linux", "cpu": "x64" }, "sha512-DOYMrDm5E6/8bm/yQLCWyuDJwUnlevR8xtF8bs+gjZ7cyUNYXiSf/E8Kp0Ss5xasIaXSHzb888V1BE4i1hFhAA=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.2", "", { "dependencies": { "@emnapi/runtime": "^1.4.3" }, "cpu": "none" }, "sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-cfP/r9FdS63VA5k0xiqaNaEoGxBg9k7uE+RQGzuK9fHt7jib4zAVVseR9LsE4gJcNWgT6APKMNnCcnyOtmSEUQ=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLjGGvAbj0X/FXl8n1WbtQ6iVBpWU7JO94u/P2M4a8CFYsvQi4GW2mRy/JqkRx0qpBzaOdKJKw8uc930EX2AHw=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.2", "", { "os": "win32", "cpu": "x64" }, "sha512-aUdT6zEYtDKCaxkofmmJDJYGCf0+pJg3eU9/oBuqvEeoB9dKI6ZLc/1iLJCTuJQDO4ptntAlkUmHgGjyuobZbw=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], + + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + + "@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.3.0", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.11", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.9.0" } }, "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA=="], + + "@next/env": ["@next/env@15.3.3", "", {}, "sha512-OdiMrzCl2Xi0VTjiQQUK0Xh7bJHnOuET2s+3V+Y40WJBAXrJeGA3f+I8MZJ/YQ3mVGi5XGR1L66oFlgqXhQ4Vw=="], + + "@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.3.3", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-VKZJEiEdpKkfBmcokGjHu0vGDG+8CehGs90tBEy/IDoDDKGngeyIStt2MmE5FYNyU9BhgR7tybNWTAJY/30u+Q=="], + + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WRJERLuH+O3oYB4yZNVahSVFmtxRNjNF1I1c34tYMoJb0Pve+7/RaLAJJizyYiFhjYNGHRAE1Ri2Fd23zgDqhg=="], + + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-XHdzH/yBc55lu78k/XwtuFR/ZXUTcflpRXcsu0nKmF45U96jt1tsOZhVrn5YH+paw66zOANpOnFQ9i6/j+UYvw=="], + + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VZ3sYL2LXB8znNGcjhocikEkag/8xiLgnvQts41tq6i+wql63SMS1Q6N8RVXHw5pEUjiof+II3HkDd7GFcgkzw=="], + + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-h6Y1fLU4RWAp1HPNJWDYBQ+e3G7sLckyBXhmH9ajn8l/RSMnhbuPBV/fXmy3muMcVwoJdHL+UtzRzs0nXOf9SA=="], + + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-jJ8HRiF3N8Zw6hGlytCj5BiHyG/K+fnTKVDEKvUCyiQ/0r5tgwO7OgaRiOjjRoIx2vwLR+Rz8hQoPrnmFbJdfw=="], + + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-HrUcTr4N+RgiiGn3jjeT6Oo208UT/7BuTr7K0mdKRBtTbT4v9zJqCDKO97DUqqoBK1qyzP1RwvrWTvU6EPh/Cw=="], + + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-SxorONgi6K7ZUysMtRF3mIeHC5aA3IQLmKFQzU0OuhuUYwpOBc1ypaLJLP5Bf3M9k53KUUUj4vTPwzGvl/NwlQ=="], + + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4QZG6F8enl9/S2+yIiOiju0iCTFd93d8VC1q9LZS4p/Xuk81W2QDjCFeoogmrWWkAD59z8ZxepBQap2dKS5ruw=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], + + "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], + + "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], + + "@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.11.0", "", {}, "sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ=="], + + "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], + + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.1.10", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.10" } }, "sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.10", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.10", "@tailwindcss/oxide-darwin-arm64": "4.1.10", "@tailwindcss/oxide-darwin-x64": "4.1.10", "@tailwindcss/oxide-freebsd-x64": "4.1.10", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.10", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.10", "@tailwindcss/oxide-linux-arm64-musl": "4.1.10", "@tailwindcss/oxide-linux-x64-gnu": "4.1.10", "@tailwindcss/oxide-linux-x64-musl": "4.1.10", "@tailwindcss/oxide-wasm32-wasi": "4.1.10", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.10", "@tailwindcss/oxide-win32-x64-msvc": "4.1.10" } }, "sha512-v0C43s7Pjw+B9w21htrQwuFObSkio2aV/qPx/mhrRldbqxbWJK6KizM+q7BF1/1CmuLqZqX3CeYF7s7P9fbA8Q=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.10", "", { "os": "android", "cpu": "arm64" }, "sha512-VGLazCoRQ7rtsCzThaI1UyDu/XRYVyH4/EWiaSX6tFglE+xZB5cvtC5Omt0OQ+FfiIVP98su16jDVHDEIuH4iQ=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZIFqvR1irX2yNjWJzKCqTCcHZbgkSkSkZKbRM3BPzhDL/18idA8uWCoopYA2CSDdSGFlDAxYdU2yBHwAwx8euQ=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-eCA4zbIhWUFDXoamNztmS0MjXHSEJYlvATzWnRiTqJkcUteSjO94PoRHJy1Xbwp9bptjeIxxBHh+zBWFhttbrQ=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-8/392Xu12R0cc93DpiJvNpJ4wYVSiciUlkiOHOSOQNH3adq9Gi/dtySK7dVQjXIOzlpSHjeCL89RUUI8/GTI6g=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.10", "", { "os": "linux", "cpu": "arm" }, "sha512-t9rhmLT6EqeuPT+MXhWhlRYIMSfh5LZ6kBrC4FS6/+M1yXwfCtp24UumgCWOAJVyjQwG+lYva6wWZxrfvB+NhQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-3oWrlNlxLRxXejQ8zImzrVLuZ/9Z2SeKoLhtCu0hpo38hTO2iL86eFOu4sVR8cZc6n3z7eRXXqtHJECa6mFOvA=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-saScU0cmWvg/Ez4gUmQWr9pvY9Kssxt+Xenfx1LG7LmqjcrvBnw4r9VjkFcqmbBb7GCBwYNcZi9X3/oMda9sqQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-/G3ao/ybV9YEEgAXeEg28dyH6gs1QG8tvdN9c2MNZdUXYBaIY/Gx0N6RlJzfLy/7Nkdok4kaxKPHKJUlAaoTdA=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-LNr7X8fTiKGRtQGOerSayc2pWJp/9ptRYAa4G+U+cjw9kJZvkopav1AQc5HHD+U364f71tZv6XamaHKgrIoVzA=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.10", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", "@napi-rs/wasm-runtime": "^0.2.10", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-d6ekQpopFQJAcIK2i7ZzWOYGZ+A6NzzvQ3ozBvWFdeyqfOZdYHU66g5yr+/HC4ipP1ZgWsqa80+ISNILk+ae/Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1Iwg9gRbwNVOCYmnigWCCgow8nDWSFmeTUU5nbNx3rqbe4p0kRbEqLwLJbYZKmSSp23g4N6rCDmm7OuPBXhDA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.10", "", { "os": "win32", "cpu": "x64" }, "sha512-sGiJTjcBSfGq2DVRtaSljq5ZgZS2SDHSIfhOylkBvHVjwOsodBhnb3HdmiKkVuUGKD0I7G63abMOVaskj1KpOA=="], + + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.10", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.10", "@tailwindcss/oxide": "4.1.10", "postcss": "^8.4.41", "tailwindcss": "4.1.10" } }, "sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], + + "@types/node": ["@types/node@20.19.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-hfrc+1tud1xcdVTABC2JiomZJEklMcXYNTVtZLAeqTVWD+qL5jkHKT+1lOtqDdGxt+mB53DTtiz673vfjU8D1Q=="], + + "@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="], + + "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], + + "@types/react-dom": ["@types/react-dom@19.1.6", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw=="], + + "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], + + "@types/uuid": ["@types/uuid@10.0.0", "", {}, "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ=="], + + "@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="], + + "@types/whatwg-url": ["@types/whatwg-url@11.0.5", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.34.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.34.0", "@typescript-eslint/type-utils": "8.34.0", "@typescript-eslint/utils": "8.34.0", "@typescript-eslint/visitor-keys": "8.34.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.34.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-QXwAlHlbcAwNlEEMKQS2RCgJsgXrTJdjXT08xEgbPFa2yYQgVjBymxP5DrfrE7X7iodSzd9qBUHUycdyVJTW1w=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.34.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.34.0", "@typescript-eslint/types": "8.34.0", "@typescript-eslint/typescript-estree": "8.34.0", "@typescript-eslint/visitor-keys": "8.34.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.34.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.34.0", "@typescript-eslint/types": "^8.34.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-iEgDALRf970/B2YExmtPMPF54NenZUf4xpL3wsCRx/lgjz6ul/l13R81ozP/ZNuXfnLCS+oPmG7JIxfdNYKELw=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.34.0", "", { "dependencies": { "@typescript-eslint/types": "8.34.0", "@typescript-eslint/visitor-keys": "8.34.0" } }, "sha512-9Ac0X8WiLykl0aj1oYQNcLZjHgBojT6cW68yAgZ19letYu+Hxd0rE0veI1XznSSst1X5lwnxhPbVdwjDRIomRw=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.34.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-+W9VYHKFIzA5cBeooqQxqNriAP0QeQ7xTiDuIOr71hzgffm3EL2hxwWBIIj4GuofIbKxGNarpKqIq6Q6YrShOA=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.34.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.34.0", "@typescript-eslint/utils": "8.34.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-n7zSmOcUVhcRYC75W2pnPpbO1iwhJY3NLoHEtbJwJSNlVAZuwqu05zY3f3s2SDWWDSo9FdN5szqc73DCtDObAg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.34.0", "", {}, "sha512-9V24k/paICYPniajHfJ4cuAWETnt7Ssy+R0Rbcqo5sSFr3QEZ/8TSoUi9XeXVBGXCaLtwTOKSLGcInCAvyZeMA=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.34.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.34.0", "@typescript-eslint/tsconfig-utils": "8.34.0", "@typescript-eslint/types": "8.34.0", "@typescript-eslint/visitor-keys": "8.34.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-rOi4KZxI7E0+BMqG7emPSK1bB4RICCpF7QD3KCLXn9ZvWoESsOMlHyZPAHyG04ujVplPaHbmEvs34m+wjgtVtg=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.34.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.34.0", "@typescript-eslint/types": "8.34.0", "@typescript-eslint/typescript-estree": "8.34.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-8L4tWatGchV9A1cKbjaavS6mwYwp39jql8xUmIIKJdm+qiaeHy5KMKlBrf30akXAWBzn2SqKsNOtSENWUwg7XQ=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.34.0", "", { "dependencies": { "@typescript-eslint/types": "8.34.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-qHV7pW7E85A0x6qyrFn+O+q1k1p3tQCsqIZ1KZ5ESLXY57aTvUd3/a4rdPTeXisvhXn2VQG0VSKUqs8KHF2zcA=="], + + "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.9.0", "", { "os": "android", "cpu": "arm" }, "sha512-h1T2c2Di49ekF2TE8ZCoJkb+jwETKUIPDJ/nO3tJBKlLFPu+fyd93f0rGP/BvArKx2k2HlRM4kqkNarj3dvZlg=="], + + "@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.9.0", "", { "os": "android", "cpu": "arm64" }, "sha512-sG1NHtgXtX8owEkJ11yn34vt0Xqzi3k9TJ8zppDmyG8GZV4kVWw44FHwKwHeEFl07uKPeC4ZoyuQaGh5ruJYPA=="], + + "@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.9.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nJ9z47kfFnCxN1z/oYZS7HSNsFh43y2asePzTEZpEvK7kGyuShSl3RRXnm/1QaqFL+iP+BjMwuB+DYUymOkA5A=="], + + "@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.9.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-TK+UA1TTa0qS53rjWn7cVlEKVGz2B6JYe0C++TdQjvWYIyx83ruwh0wd4LRxYBM5HeuAzXcylA9BH2trARXJTw=="], + + "@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.9.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6uZwzMRFcD7CcCd0vz3Hp+9qIL2jseE/bx3ZjaLwn8t714nYGwiE84WpaMCYjU+IQET8Vu/+BNAGtYD7BG/0yA=="], + + "@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.9.0", "", { "os": "linux", "cpu": "arm" }, "sha512-bPUBksQfrgcfv2+mm+AZinaKq8LCFvt5PThYqRotqSuuZK1TVKkhbVMS/jvSRfYl7jr3AoZLYbDkItxgqMKRkg=="], + + "@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.9.0", "", { "os": "linux", "cpu": "arm" }, "sha512-uT6E7UBIrTdCsFQ+y0tQd3g5oudmrS/hds5pbU3h4s2t/1vsGWbbSKhBSCD9mcqaqkBwoqlECpUrRJCmldl8PA=="], + + "@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.9.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vdqBh911wc5awE2bX2zx3eflbyv8U9xbE/jVKAm425eRoOVv/VseGZsqi3A3SykckSpF4wSROkbQPvbQFn8EsA=="], + + "@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.9.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-/8JFZ/SnuDr1lLEVsxsuVwrsGquTvT51RZGvyDB/dOK3oYK2UqeXzgeyq6Otp8FZXQcEYqJwxb9v+gtdXn03eQ=="], + + "@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.9.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FkJjybtrl+rajTw4loI3L6YqSOpeZfDls4SstL/5lsP2bka9TiHUjgMBjygeZEis1oC8LfJTS8FSgpKPaQx2tQ=="], + + "@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.9.0", "", { "os": "linux", "cpu": "none" }, "sha512-w/NZfHNeDusbqSZ8r/hp8iL4S39h4+vQMc9/vvzuIKMWKppyUGKm3IST0Qv0aOZ1rzIbl9SrDeIqK86ZpUK37w=="], + + "@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.9.0", "", { "os": "linux", "cpu": "none" }, "sha512-bEPBosut8/8KQbUixPry8zg/fOzVOWyvwzOfz0C0Rw6dp+wIBseyiHKjkcSyZKv/98edrbMknBaMNJfA/UEdqw=="], + + "@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.9.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-LDtMT7moE3gK753gG4pc31AAqGUC86j3AplaFusc717EUGF9ZFJ356sdQzzZzkBk1XzMdxFyZ4f/i35NKM/lFA=="], + + "@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.9.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WmFd5KINHIXj8o1mPaT8QRjA9HgSXhN1gl9Da4IZihARihEnOylu4co7i/yeaIpcfsI6sYs33cNZKyHYDh0lrA=="], + + "@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.9.0", "", { "os": "linux", "cpu": "x64" }, "sha512-CYuXbANW+WgzVRIl8/QvZmDaZxrqvOldOwlbUjIM4pQ46FJ0W5cinJ/Ghwa/Ng1ZPMJMk1VFdsD/XwmCGIXBWg=="], + + "@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.9.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" }, "cpu": "none" }, "sha512-6Rp2WH0OoitMYR57Z6VE8Y6corX8C6QEMWLgOV6qXiJIeZ1F9WGXY/yQ8yDC4iTraotyLOeJ2Asea0urWj2fKQ=="], + + "@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.9.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-rknkrTRuvujprrbPmGeHi8wYWxmNVlBoNW8+4XF2hXUnASOjmuC9FNF1tGbDiRQWn264q9U/oGtixyO3BT8adQ=="], + + "@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.9.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-Ceymm+iBl+bgAICtgiHyMLz6hjxmLJKqBim8tDzpX61wpZOx2bPK6Gjuor7I2RiUynVjvvkoRIkrPyMwzBzF3A=="], + + "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.9.0", "", { "os": "win32", "cpu": "x64" }, "sha512-k59o9ZyeyS0hAlcaKFezYSH2agQeRFEB7KoQLXl3Nb3rgkqT1NY9Vwy+SqODiLmYnEjxWJVRE/yq2jFVqdIxZw=="], + + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], + + "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], + + "array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="], + + "array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="], + + "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], + + "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], + + "array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="], + + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + + "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], + + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "axe-core": ["axe-core@4.10.3", "", {}, "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "bson": ["bson@6.10.4", "", {}, "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng=="], + + "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], + + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001723", "", {}, "sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chart.js": ["chart.js@4.5.0", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ=="], + + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + + "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + + "colorspace": ["colorspace@1.1.4", "", { "dependencies": { "color": "^3.1.3", "text-hex": "1.0.x" } }, "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], + + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], + + "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], + + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "detect-europe-js": ["detect-europe-js@0.1.2", "", {}, "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow=="], + + "detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], + + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="], + + "enhanced-resolve": ["enhanced-resolve@5.18.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg=="], + + "es-abstract": ["es-abstract@1.24.0", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-iterator-helpers": ["es-iterator-helpers@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.0.3", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.6", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.4", "safe-array-concat": "^1.1.3" } }, "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], + + "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.28.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.0", "@eslint/config-helpers": "^0.2.1", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.28.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.3.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ=="], + + "eslint-config-next": ["eslint-config-next@15.3.3", "", { "dependencies": { "@next/eslint-plugin-next": "15.3.3", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^5.0.0" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-QJLv/Ouk2vZnxL4b67njJwTLjTf7uZRltI0LL4GERYR4qMF5z08+gxkfODAeaK7TiC6o+cER91bDaEnwrTWV6Q=="], + + "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="], + + "eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@3.10.1", "", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", "get-tsconfig": "^4.10.0", "is-bun-module": "^2.0.0", "stable-hash": "^0.0.5", "tinyglobby": "^0.2.13", "unrs-resolver": "^1.6.2" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import", "eslint-plugin-import-x"] }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="], + + "eslint-module-utils": ["eslint-module-utils@2.12.0", "", { "dependencies": { "debug": "^3.2.7" } }, "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg=="], + + "eslint-plugin-import": ["eslint-plugin-import@2.31.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", "array.prototype.findlastindex": "^1.2.5", "array.prototype.flat": "^1.3.2", "array.prototype.flatmap": "^1.3.2", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.0", "hasown": "^2.0.2", "is-core-module": "^2.15.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.0", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.8", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A=="], + + "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="], + + "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], + + "fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="], + + "fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="], + + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "form-data": ["form-data@4.0.3", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], + + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + + "get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], + + "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], + + "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], + + "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + + "is-bun-module": ["is-bun-module@2.0.0", "", { "dependencies": { "semver": "^7.7.1" } }, "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ=="], + + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + + "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], + + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + + "is-generator-function": ["is-generator-function@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "get-proto": "^1.0.0", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], + + "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], + + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + + "is-standalone-pwa": ["is-standalone-pwa@0.1.1", "", {}, "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g=="], + + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + + "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], + + "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], + + "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + + "jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], + + "jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], + + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="], + + "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], + + "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="], + + "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], + + "mongodb": ["mongodb@6.17.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.1.9", "bson": "^6.10.4", "mongodb-connection-string-url": "^3.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.188.0", "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", "gcp-metadata": "^5.2.0", "kerberos": "^2.0.1", "mongodb-client-encryption": ">=6.0.0 <7", "snappy": "^7.2.2", "socks": "^2.7.1" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-neerUzg/8U26cgruLysKEjJvoNSXhyID3RvzvdcpsIi2COYM3FS3o9nlH7fxFtefTb942dX3W9i37oPfCVj4wA=="], + + "mongodb-connection-string-url": ["mongodb-connection-string-url@3.0.2", "", { "dependencies": { "@types/whatwg-url": "^11.0.2", "whatwg-url": "^14.1.0 || ^13.0.0" } }, "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "napi-postinstall": ["napi-postinstall@0.2.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-ZEzHJwBhZ8qQSbknHqYcdtQVr8zUgGyM/q6h6qAyhtyVMNrSgDhrC4disf03dYW0e+czXyLnZINnCTEkWy0eJg=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "next": ["next@15.3.3", "", { "dependencies": { "@next/env": "15.3.3", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.3", "@next/swc-darwin-x64": "15.3.3", "@next/swc-linux-arm64-gnu": "15.3.3", "@next/swc-linux-arm64-musl": "15.3.3", "@next/swc-linux-x64-gnu": "15.3.3", "@next/swc-linux-x64-musl": "15.3.3", "@next/swc-win32-arm64-msvc": "15.3.3", "@next/swc-win32-x64-msvc": "15.3.3", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-JqNj29hHNmCLtNvd090SyRbXJiivQ+58XjCcrC50Crb5g5u2zi7Y2YivbsEfzk6AtVI80akdOQbaMZwWB1Hthw=="], + + "next-auth": ["next-auth@4.24.11", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@panva/hkdf": "^1.0.2", "cookie": "^0.7.0", "jose": "^4.15.5", "oauth": "^0.9.15", "openid-client": "^5.4.0", "preact": "^10.6.3", "preact-render-to-string": "^5.1.19", "uuid": "^8.3.2" }, "peerDependencies": { "@auth/core": "0.34.2", "next": "^12.2.5 || ^13 || ^14 || ^15", "nodemailer": "^6.6.5", "react": "^17.0.2 || ^18 || ^19", "react-dom": "^17.0.2 || ^18 || ^19" }, "optionalPeers": ["@auth/core", "nodemailer"] }, "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "oauth": ["oauth@0.9.15", "", {}, "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-hash": ["object-hash@2.2.0", "", {}, "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="], + + "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], + + "object.groupby": ["object.groupby@1.0.3", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="], + + "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + + "oidc-token-hash": ["oidc-token-hash@5.1.0", "", {}, "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA=="], + + "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], + + "openid-client": ["openid-client@5.7.1", "", { "dependencies": { "jose": "^4.15.9", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], + + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + + "postcss": ["postcss@8.5.5", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg=="], + + "preact": ["preact@10.26.9", "", {}, "sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA=="], + + "preact-render-to-string": ["preact-render-to-string@5.2.6", "", { "dependencies": { "pretty-format": "^3.8.0" }, "peerDependencies": { "preact": ">=10" } }, "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], + + "react-chartjs-2": ["react-chartjs-2@5.3.0", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw=="], + + "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], + + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + + "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + + "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + + "sharp": ["sharp@0.34.2", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.4", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.2", "@img/sharp-darwin-x64": "0.34.2", "@img/sharp-libvips-darwin-arm64": "1.1.0", "@img/sharp-libvips-darwin-x64": "1.1.0", "@img/sharp-libvips-linux-arm": "1.1.0", "@img/sharp-libvips-linux-arm64": "1.1.0", "@img/sharp-libvips-linux-ppc64": "1.1.0", "@img/sharp-libvips-linux-s390x": "1.1.0", "@img/sharp-libvips-linux-x64": "1.1.0", "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", "@img/sharp-libvips-linuxmusl-x64": "1.1.0", "@img/sharp-linux-arm": "0.34.2", "@img/sharp-linux-arm64": "0.34.2", "@img/sharp-linux-s390x": "0.34.2", "@img/sharp-linux-x64": "0.34.2", "@img/sharp-linuxmusl-arm64": "0.34.2", "@img/sharp-linuxmusl-x64": "0.34.2", "@img/sharp-wasm32": "0.34.2", "@img/sharp-win32-arm64": "0.34.2", "@img/sharp-win32-ia32": "0.34.2", "@img/sharp-win32-x64": "0.34.2" } }, "sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "sparse-bitfield": ["sparse-bitfield@3.0.3", "", { "dependencies": { "memory-pager": "^1.0.2" } }, "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ=="], + + "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], + + "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], + + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + + "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], + + "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], + + "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], + + "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], + + "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], + + "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], + + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "tailwindcss": ["tailwindcss@4.1.10", "", {}, "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA=="], + + "tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="], + + "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], + + "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="], + + "tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], + + "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], + + "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + + "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], + + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], + + "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "ua-is-frozen": ["ua-is-frozen@0.1.2", "", {}, "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw=="], + + "ua-parser-js": ["ua-parser-js@2.0.3", "", { "dependencies": { "@types/node-fetch": "^2.6.12", "detect-europe-js": "^0.1.2", "is-standalone-pwa": "^0.1.1", "node-fetch": "^2.7.0", "ua-is-frozen": "^0.1.2" }, "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LZyXZdNttONW8LjzEH3Z8+6TE7RfrEiJqDKyh0R11p/kxvrV2o9DrT2FGZO+KVNs3k+drcIQ6C3En6wLnzJGpw=="], + + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "unrs-resolver": ["unrs-resolver@1.9.0", "", { "dependencies": { "napi-postinstall": "^0.2.2" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.9.0", "@unrs/resolver-binding-android-arm64": "1.9.0", "@unrs/resolver-binding-darwin-arm64": "1.9.0", "@unrs/resolver-binding-darwin-x64": "1.9.0", "@unrs/resolver-binding-freebsd-x64": "1.9.0", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.9.0", "@unrs/resolver-binding-linux-arm-musleabihf": "1.9.0", "@unrs/resolver-binding-linux-arm64-gnu": "1.9.0", "@unrs/resolver-binding-linux-arm64-musl": "1.9.0", "@unrs/resolver-binding-linux-ppc64-gnu": "1.9.0", "@unrs/resolver-binding-linux-riscv64-gnu": "1.9.0", "@unrs/resolver-binding-linux-riscv64-musl": "1.9.0", "@unrs/resolver-binding-linux-s390x-gnu": "1.9.0", "@unrs/resolver-binding-linux-x64-gnu": "1.9.0", "@unrs/resolver-binding-linux-x64-musl": "1.9.0", "@unrs/resolver-binding-wasm32-wasi": "1.9.0", "@unrs/resolver-binding-win32-arm64-msvc": "1.9.0", "@unrs/resolver-binding-win32-ia32-msvc": "1.9.0", "@unrs/resolver-binding-win32-x64-msvc": "1.9.0" } }, "sha512-wqaRu4UnzBD2ABTC1kLfBjAqIDZ5YUTr/MLGa7By47JV1bJDSW7jq/ZSLigB7enLe7ubNaJhtnBXgrc/50cEhg=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], + + "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], + + "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + + "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], + + "winston": ["winston@3.17.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw=="], + + "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.15.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw=="], + + "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.11", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.9.0" }, "bundled": true }, "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@typescript-eslint/typescript-estree/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "colorspace/color": ["color@3.2.1", "", { "dependencies": { "color-convert": "^1.9.3", "color-string": "^1.6.0" } }, "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA=="], + + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "is-bun-module/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + + "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "sharp/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "colorspace/color/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], + + "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "colorspace/color/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], + } +} diff --git a/cmd/api/main.go b/cmd/api/main.go deleted file mode 100644 index 06bc8f9..0000000 --- a/cmd/api/main.go +++ /dev/null @@ -1,92 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "html/template" - "log" - "net/http" - "os" - "os/signal" - "syscall" - "time" - - "github.com/joho/godotenv" - "github.com/marcus7i/ulinkshortener/internal/api" - "github.com/marcus7i/ulinkshortener/internal/config" - "github.com/marcus7i/ulinkshortener/internal/database" - "github.com/marcus7i/ulinkshortener/internal/api/handlers" -) - -func main() { - if err := godotenv.Load(); err != nil { - log.Println("No .env file found, using environment variables") - } - - cfg := config.New() - - db, err := database.New(cfg.MongoURI) - if err != nil { - log.Fatalf("Failed to connect to MongoDB: %v", err) - } - defer db.Close() - - r := api.SetupRouter(db) - - funcMap := template.FuncMap{ - "marshal": func(v interface{}) template.JS { - a, _ := json.Marshal(v) - return template.JS(a) - }, - } - - templates := template.Must(template.New("").Funcs(funcMap).ParseGlob("web/templates/*.html")) - - r.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/" { - h := handlers.NewHandler(db) - stats, err := h.GetStatsData(r.Context()) - if err != nil { - http.Error(w, "Error generating stats", http.StatusInternalServerError) - return - } - - cookie, _ := r.Cookie("account_id") - if cookie != nil { - stats.LoggedIn = true - } - - templates.ExecuteTemplate(w, "index.html", map[string]interface{}{ - "stats": stats, - }) - return - } - - http.NotFound(w, r) - }) - - srv := &http.Server{ - Addr: ":" + cfg.Port, - Handler: r, - } - - go func() { - log.Printf("Server running on port %s", cfg.Port) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("Listen error: %v", err) - } - }() - - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - <-quit - log.Println("Shutting down server...") - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := srv.Shutdown(ctx); err != nil { - log.Fatal("Server forced to shutdown:", err) - } - - log.Println("Server exited with code 0") -} diff --git a/docker-compose-build.yml b/docker-compose-build.yml index 9754b28..c379cf5 100644 --- a/docker-compose-build.yml +++ b/docker-compose-build.yml @@ -3,8 +3,13 @@ services: build: context: . dockerfile: Dockerfile - container_name: ulinkshortener - restart: unless-stopped - network_mode: "host" + ports: + - "3000:3000" + environment: + - MONGO_URI=${MONGO_URI} + - MONGO_DB_NAME=${MONGO_DB_NAME} + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET} + - NEXTAUTH_URL=${NEXTAUTH_URL} volumes: - - ./.env:/app/.env \ No newline at end of file + - ./.env:/app/.env + restart: unless-stopped \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index b4c0489..8a1fc90 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,13 @@ services: ulinkshortener: - image: ghcr.io/marcus7i/ulinkshortener:latest - container_name: ulinkshortener - restart: unless-stopped - network_mode: "host" + image: ghcr.io/kizuren/ulinkshortener + ports: + - "3000:3000" + environment: + - MONGO_URI=${MONGO_URI} + - MONGO_DB_NAME=${MONGO_DB_NAME} + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET} + - NEXTAUTH_URL=${NEXTAUTH_URL} volumes: - - ./.env:/app/.env \ No newline at end of file + - ./.env:/app/.env + restart: unless-stopped \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..c85fb67 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,16 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), +]; + +export default eslintConfig; diff --git a/go.mod b/go.mod deleted file mode 100644 index 80d3a84..0000000 --- a/go.mod +++ /dev/null @@ -1,23 +0,0 @@ -module github.com/marcus7i/ulinkshortener - -go 1.21 - -require ( - github.com/gorilla/mux v1.8.1 - github.com/joho/godotenv v1.5.1 - github.com/mssola/user_agent v0.6.0 - go.mongodb.org/mongo-driver v1.17.3 -) - -require ( - github.com/golang/snappy v0.0.4 // indirect - github.com/klauspost/compress v1.17.5 // indirect - github.com/montanaflynn/stats v0.7.1 // indirect - github.com/xdg-go/pbkdf2 v1.0.0 // indirect - github.com/xdg-go/scram v1.1.2 // indirect - github.com/xdg-go/stringprep v1.0.4 // indirect - github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/text v0.21.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 5f2dc6a..0000000 --- a/go.sum +++ /dev/null @@ -1,56 +0,0 @@ -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/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= -github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.17.5 h1:d4vBd+7CHydUqpFBgUEKkSdtSugf9YFmSkvUYPquI5E= -github.com/klauspost/compress v1.17.5/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= -github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= -github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= -github.com/mssola/user_agent v0.6.0 h1:uwPR4rtWlCHRFyyP9u2KOV0u8iQXmS7Z7feTrstQwk4= -github.com/mssola/user_agent v0.6.0/go.mod h1:TTPno8LPY3wAIEKRpAtkdMT0f8SE24pLRGPahjCH4uw= -github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= -github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= -github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= -github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= -github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= -go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/api/handlers/analytics.go b/internal/api/handlers/analytics.go deleted file mode 100644 index 22a94bb..0000000 --- a/internal/api/handlers/analytics.go +++ /dev/null @@ -1,126 +0,0 @@ -package handlers - -import ( - "context" - "net/http" - - "github.com/gorilla/mux" - "github.com/marcus7i/ulinkshortener/internal/database" - "github.com/marcus7i/ulinkshortener/internal/models" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/mongo" -) - -func (h *Handler) GetAnalytics(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - accountID := vars["accountID"] - - ctx := context.Background() - var user models.User - err := h.DB.Collection(database.UsersCollection).FindOne(ctx, bson.M{"account_id": accountID}).Decode(&user) - if err != nil { - respondWithError(w, http.StatusUnauthorized, "Invalid account") - return - } - - cursor, err := h.DB.Collection(database.LinksCollection).Find(ctx, bson.M{"account_id": accountID}) - if err != nil { - respondWithError(w, http.StatusInternalServerError, "Failed to retrieve links") - return - } - defer cursor.Close(ctx) - - var links []models.Link - if err = cursor.All(ctx, &links); err != nil { - respondWithError(w, http.StatusInternalServerError, "Failed to process links") - return - } - - cursor, err = h.DB.Collection(database.AnalyticsCollection).Find(ctx, bson.M{"account_id": accountID}) - if err != nil { - respondWithError(w, http.StatusInternalServerError, "Failed to retrieve analytics") - return - } - defer cursor.Close(ctx) - - var analytics []map[string]interface{} - if err = cursor.All(ctx, &analytics); err != nil { - respondWithError(w, http.StatusInternalServerError, "Failed to process analytics") - return - } - - respondWithJSON(w, http.StatusOK, map[string]interface{}{ - "links": links, - "analytics": analytics, - }) -} - -func (h *Handler) GetStatsData(ctx context.Context) (models.Stats, error) { - - totalLinks, err := h.DB.Collection(database.LinksCollection).CountDocuments(ctx, bson.M{}) - if err != nil { - totalLinks = 0 - } - - totalClicks, err := h.DB.Collection(database.AnalyticsCollection).CountDocuments(ctx, bson.M{}) - if err != nil { - totalClicks = 0 - } - - ipVersionsPipeline := mongo.Pipeline{ - {{Key: "$group", Value: bson.D{{Key: "_id", Value: "$ip_version"}, {Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}}}}, - {{Key: "$sort", Value: bson.D{{Key: "count", Value: -1}}}}, - } - ipVersionsCursor, err := h.DB.Collection(database.AnalyticsCollection).Aggregate(ctx, ipVersionsPipeline) - var ipVersions []models.StatItem - if err == nil { - ipVersionsCursor.All(ctx, &ipVersions) - } - - osPipeline := mongo.Pipeline{ - {{Key: "$group", Value: bson.D{{Key: "_id", Value: "$platform"}, {Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}}}}, - {{Key: "$sort", Value: bson.D{{Key: "count", Value: -1}}}}, - {{Key: "$limit", Value: 10}}, - } - osCursor, err := h.DB.Collection(database.AnalyticsCollection).Aggregate(ctx, osPipeline) - var osStats []models.StatItem - if err == nil { - osCursor.All(ctx, &osStats) - } - - countryPipeline := mongo.Pipeline{ - {{Key: "$group", Value: bson.D{{Key: "_id", Value: "$country"}, {Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}}}}, - {{Key: "$sort", Value: bson.D{{Key: "count", Value: -1}}}}, - {{Key: "$limit", Value: 10}}, - } - countryCursor, err := h.DB.Collection(database.AnalyticsCollection).Aggregate(ctx, countryPipeline) - var countryStats []models.StatItem - if err == nil { - countryCursor.All(ctx, &countryStats) - } - - ispPipeline := mongo.Pipeline{ - {{Key: "$group", Value: bson.D{{Key: "_id", Value: "$isp"}, {Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}}}}, - {{Key: "$sort", Value: bson.D{{Key: "count", Value: -1}}}}, - {{Key: "$limit", Value: 10}}, - } - ispCursor, err := h.DB.Collection(database.AnalyticsCollection).Aggregate(ctx, ispPipeline) - var ispStats []models.StatItem - if err == nil { - ispCursor.All(ctx, &ispStats) - } - - stats := models.Stats{ - TotalLinks: totalLinks, - TotalClicks: totalClicks, - ChartData: models.ChartData{ - IPVersions: ipVersions, - OSStats: osStats, - CountryStats: countryStats, - ISPStats: ispStats, - }, - LoggedIn: false, - } - - return stats, nil -} diff --git a/internal/api/handlers/auth.go b/internal/api/handlers/auth.go deleted file mode 100644 index e6b0135..0000000 --- a/internal/api/handlers/auth.go +++ /dev/null @@ -1,95 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "net/http" - "time" - - "github.com/marcus7i/ulinkshortener/internal/database" - "github.com/marcus7i/ulinkshortener/internal/models" - "github.com/marcus7i/ulinkshortener/internal/utils" - "go.mongodb.org/mongo-driver/bson" -) - -type LoginRequest struct { - AccountID string `json:"account_id"` -} - -func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { - ctx := context.Background() - accountID := utils.GenerateAccountID() - - for { - var user models.User - err := h.DB.Collection(database.UsersCollection).FindOne(ctx, bson.M{"account_id": accountID}).Decode(&user) - if err != nil { - break - } - accountID = utils.GenerateAccountID() - } - - _, err := h.DB.Collection(database.UsersCollection).InsertOne(ctx, models.User{ - AccountID: accountID, - CreatedAt: time.Now(), - }) - - if err != nil { - respondWithError(w, http.StatusInternalServerError, "Failed to create account") - return - } - - http.SetCookie(w, &http.Cookie{ - Name: "account_id", - Value: accountID, - Path: "/", - MaxAge: 31536000, // 1 year - HttpOnly: true, - Secure: r.TLS != nil, - SameSite: http.SameSiteLaxMode, - }) - - respondWithJSON(w, http.StatusOK, map[string]string{"account_id": accountID}) -} - -func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { - var req LoginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondWithError(w, http.StatusBadRequest, "Invalid request") - return - } - - ctx := context.Background() - var user models.User - err := h.DB.Collection(database.UsersCollection).FindOne(ctx, bson.M{"account_id": req.AccountID}).Decode(&user) - if err != nil { - respondWithError(w, http.StatusUnauthorized, "Invalid account ID") - return - } - - http.SetCookie(w, &http.Cookie{ - Name: "account_id", - Value: req.AccountID, - Path: "/", - MaxAge: 31536000, // 1 year - HttpOnly: true, - Secure: r.TLS != nil, - SameSite: http.SameSiteLaxMode, - }) - - respondWithJSON(w, http.StatusOK, map[string]bool{"success": true}) -} - -func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { - http.SetCookie(w, &http.Cookie{ - Name: "account_id", - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: true, - Secure: r.TLS != nil, - SameSite: http.SameSiteLaxMode, - }) - - respondWithJSON(w, http.StatusOK, map[string]bool{"success": true}) -} \ No newline at end of file diff --git a/internal/api/handlers/handlers.go b/internal/api/handlers/handlers.go deleted file mode 100644 index caebab4..0000000 --- a/internal/api/handlers/handlers.go +++ /dev/null @@ -1,38 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - - "github.com/marcus7i/ulinkshortener/internal/database" -) - -type Handler struct { - DB *database.MongoDB -} - -func NewHandler(db *database.MongoDB) *Handler { - return &Handler{ - DB: db, - } -} - -func respondWithError(w http.ResponseWriter, code int, message string) { - respondWithJSON(w, code, map[string]string{"error": message}) -} - -func respondWithJSON(w http.ResponseWriter, code int, payload interface{}) { - response, _ := json.Marshal(payload) - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - w.Write(response) -} - -func getAccountIDFromCookie(r *http.Request) string { - cookie, err := r.Cookie("account_id") - if err != nil { - return "" - } - return cookie.Value -} \ No newline at end of file diff --git a/internal/api/handlers/url.go b/internal/api/handlers/url.go deleted file mode 100644 index b5ffb6e..0000000 --- a/internal/api/handlers/url.go +++ /dev/null @@ -1,132 +0,0 @@ -package handlers - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "time" - - "github.com/gorilla/mux" - "github.com/marcus7i/ulinkshortener/internal/database" - "github.com/marcus7i/ulinkshortener/internal/models" - "github.com/marcus7i/ulinkshortener/internal/utils" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/mongo" -) - -type CreateLinkRequest struct { - AccountID string `json:"account_id"` - URL string `json:"url"` -} - -type CreateLinkResponse struct { - ShortURL string `json:"short_url"` -} - -func (h *Handler) CreateLink(w http.ResponseWriter, r *http.Request) { - var req CreateLinkRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondWithError(w, http.StatusBadRequest, "Invalid request") - return - } - - ctx := context.Background() - user := h.DB.Collection(database.UsersCollection).FindOne(ctx, bson.M{"account_id": req.AccountID}) - if user.Err() != nil { - respondWithError(w, http.StatusUnauthorized, "Invalid account") - return - } - - if !utils.IsValidURL(req.URL) { - respondWithError(w, http.StatusBadRequest, "Invalid URL. Please provide a valid URL with scheme (e.g., http:// or https://)") - return - } - - shortID := utils.GenerateShortID() - - link := models.Link{ - ShortID: shortID, - TargetURL: req.URL, - AccountID: req.AccountID, - CreatedAt: time.Now(), - } - - _, err := h.DB.Collection(database.LinksCollection).InsertOne(ctx, link) - if err != nil { - respondWithError(w, http.StatusInternalServerError, "Failed to create link") - return - } - - respondWithJSON(w, http.StatusOK, CreateLinkResponse{ - ShortURL: fmt.Sprintf("/l/%s", shortID), - }) -} - -func (h *Handler) RedirectLink(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - shortID := vars["shortID"] - - ctx := context.Background() - var link models.Link - err := h.DB.Collection(database.LinksCollection).FindOne(ctx, bson.M{"short_id": shortID}).Decode(&link) - if err != nil { - if err == mongo.ErrNoDocuments { - http.Error(w, "Link not found", http.StatusNotFound) - } else { - http.Error(w, "Server error", http.StatusInternalServerError) - } - return - } - - clientInfo := utils.GetClientInfo(r) - clientInfo["link_id"] = shortID - clientInfo["account_id"] = link.AccountID - - _, err = h.DB.Collection(database.AnalyticsCollection).InsertOne(ctx, clientInfo) - if err != nil { - fmt.Printf("Failed to log analytics: %v\n", err) - } - - http.Redirect(w, r, link.TargetURL, http.StatusFound) -} - -func (h *Handler) DeleteLink(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - shortID := vars["shortID"] - accountID := getAccountIDFromCookie(r) - - if accountID == "" { - respondWithError(w, http.StatusUnauthorized, "Not logged in") - return - } - - ctx := context.Background() - var link models.Link - err := h.DB.Collection(database.LinksCollection).FindOne(ctx, bson.M{ - "short_id": shortID, - "account_id": accountID, - }).Decode(&link) - - if err != nil { - if err == mongo.ErrNoDocuments { - respondWithError(w, http.StatusNotFound, "Link not found or unauthorized") - } else { - respondWithError(w, http.StatusInternalServerError, "Server error") - } - return - } - - _, err = h.DB.Collection(database.LinksCollection).DeleteOne(ctx, bson.M{"short_id": shortID}) - if err != nil { - respondWithError(w, http.StatusInternalServerError, "Failed to delete link") - return - } - - _, err = h.DB.Collection(database.AnalyticsCollection).DeleteMany(ctx, bson.M{"link_id": shortID}) - if err != nil { - fmt.Printf("Failed to delete analytics: %v\n", err) - } - - respondWithJSON(w, http.StatusOK, map[string]bool{"success": true}) -} \ No newline at end of file diff --git a/internal/api/router.go b/internal/api/router.go deleted file mode 100644 index 542b6fd..0000000 --- a/internal/api/router.go +++ /dev/null @@ -1,39 +0,0 @@ -package api - -import ( - "net/http" - - "github.com/gorilla/mux" - "github.com/marcus7i/ulinkshortener/internal/api/handlers" - "github.com/marcus7i/ulinkshortener/internal/database" -) - -func SetupRouter(db *database.MongoDB) *mux.Router { - r := mux.NewRouter() - h := handlers.NewHandler(db) - - fs := http.FileServer(http.Dir("./web/static")) - r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", fs)) - - r.Use(func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/static/css/style.css" { - w.Header().Set("Content-Type", "text/css") - } else if r.URL.Path == "/static/js/script.js" { - w.Header().Set("Content-Type", "application/javascript") - } - next.ServeHTTP(w, r) - }) - }) - - r.HandleFunc("/register", h.Register).Methods("POST") - r.HandleFunc("/login", h.Login).Methods("POST") - r.HandleFunc("/logout", h.Logout).Methods("POST") - - r.HandleFunc("/create", h.CreateLink).Methods("POST") - r.HandleFunc("/l/{shortID}", h.RedirectLink).Methods("GET") - r.HandleFunc("/analytics/{accountID}", h.GetAnalytics).Methods("GET") - r.HandleFunc("/delete/{shortID}", h.DeleteLink).Methods("DELETE") - - return r -} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index d356cc4..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,26 +0,0 @@ -package config - -import ( - "os" - - "github.com/joho/godotenv" -) - -type Config struct { - MongoURI string - Port string -} - -func New() *Config { - godotenv.Load() - - port := os.Getenv("PORT") - if port == "" { - port = "5000" - } - - return &Config{ - MongoURI: os.Getenv("MONGO_URI"), - Port: port, - } -} \ No newline at end of file diff --git a/internal/database/mongodb.go b/internal/database/mongodb.go deleted file mode 100644 index 711b85f..0000000 --- a/internal/database/mongodb.go +++ /dev/null @@ -1,73 +0,0 @@ -package database - -import ( - "context" - "time" - "strings" - - "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/mongo/options" -) - -type MongoDB struct { - Client *mongo.Client - DB *mongo.Database -} - -const ( - LinksCollection = "links" - AnalyticsCollection = "analytics" - UsersCollection = "users" - DefaultDBName = "uLinkShortener" -) - -func New(uri string) (*MongoDB, error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri)) - if err != nil { - return nil, err - } - - err = client.Ping(ctx, nil) - if err != nil { - return nil, err - } - - dbName := getDatabaseName(uri) - if dbName == "" { - dbName = DefaultDBName - } - db := client.Database(dbName) - - return &MongoDB{ - Client: client, - DB: db, - }, nil -} - -func getDatabaseName(uri string) string { - lastSlashIndex := strings.LastIndex(uri, "/") - if lastSlashIndex == -1 || lastSlashIndex == len(uri)-1 { - return "" - } - - dbName := uri[lastSlashIndex+1:] - - if queryIndex := strings.Index(dbName, "?"); queryIndex != -1 { - dbName = dbName[:queryIndex] - } - - return dbName -} - -func (m *MongoDB) Close() error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - return m.Client.Disconnect(ctx) -} - -func (m *MongoDB) Collection(name string) *mongo.Collection { - return m.DB.Collection(name) -} \ No newline at end of file diff --git a/internal/models/analytics.go b/internal/models/analytics.go deleted file mode 100644 index bb5b54c..0000000 --- a/internal/models/analytics.go +++ /dev/null @@ -1,46 +0,0 @@ -package models - -import ( - "time" -) - -type Analytics struct { - LinkID string `json:"link_id" bson:"link_id"` - AccountID string `json:"account_id" bson:"account_id"` - IP string `json:"ip" bson:"ip"` - UserAgent string `json:"user_agent" bson:"user_agent"` - Platform string `json:"platform" bson:"platform"` - Browser string `json:"browser" bson:"browser"` - Version string `json:"version" bson:"version"` - Language string `json:"language" bson:"language"` - Referrer string `json:"referrer" bson:"referrer"` - Timestamp time.Time `json:"timestamp" bson:"timestamp"` - RemotePort string `json:"remote_port" bson:"remote_port"` - Accept string `json:"accept" bson:"accept"` - AcceptLanguage string `json:"accept_language" bson:"accept_language"` - AcceptEncoding string `json:"accept_encoding" bson:"accept_encoding"` - ScreenSize string `json:"screen_size" bson:"screen_size"` - WindowSize string `json:"window_size" bson:"window_size"` - Country string `json:"country" bson:"country"` - ISP string `json:"isp" bson:"isp"` - IPVersion string `json:"ip_version" bson:"ip_version"` -} - -type Stats struct { - TotalLinks int64 `json:"total_links"` - TotalClicks int64 `json:"total_clicks"` - ChartData ChartData `json:"chart_data"` - LoggedIn bool `json:"logged_in"` -} - -type ChartData struct { - IPVersions []StatItem `json:"ip_versions"` - OSStats []StatItem `json:"os_stats"` - CountryStats []StatItem `json:"country_stats"` - ISPStats []StatItem `json:"isp_stats"` -} - -type StatItem struct { - ID string `json:"_id" bson:"_id"` - Count int `json:"count" bson:"count"` -} diff --git a/internal/models/link.go b/internal/models/link.go deleted file mode 100644 index 84c6de4..0000000 --- a/internal/models/link.go +++ /dev/null @@ -1,12 +0,0 @@ -package models - -import ( - "time" -) - -type Link struct { - ShortID string `json:"short_id" bson:"short_id"` - TargetURL string `json:"target_url" bson:"target_url"` - AccountID string `json:"account_id" bson:"account_id"` - CreatedAt time.Time `json:"created_at" bson:"created_at"` -} diff --git a/internal/models/user.go b/internal/models/user.go deleted file mode 100644 index 6efb3f2..0000000 --- a/internal/models/user.go +++ /dev/null @@ -1,10 +0,0 @@ -package models - -import ( - "time" -) - -type User struct { - AccountID string `json:"account_id" bson:"account_id"` - CreatedAt time.Time `json:"created_at" bson:"created_at"` -} diff --git a/internal/utils/clientinfo.go b/internal/utils/clientinfo.go deleted file mode 100644 index 0151745..0000000 --- a/internal/utils/clientinfo.go +++ /dev/null @@ -1,83 +0,0 @@ -package utils - -import ( - "net/http" - "strings" - "time" - - "github.com/mssola/user_agent" -) - -func GetClientInfo(r *http.Request) map[string]interface{} { - ua := user_agent.New(r.UserAgent()) - - ipAddress := r.Header.Get("CF-Connecting-IP") - if ipAddress == "" { - ipAddress = r.Header.Get("X-Real-IP") - } - if ipAddress == "" { - forwardedFor := r.Header.Get("X-Forwarded-For") - if forwardedFor != "" { - ips := strings.Split(forwardedFor, ",") - ipAddress = strings.TrimSpace(ips[0]) - } - } - if ipAddress == "" { - ipAddress = r.RemoteAddr - if colonIndex := strings.LastIndex(ipAddress, ":"); colonIndex != -1 { - ipAddress = ipAddress[:colonIndex] - } - } - if ipAddress == "" { - ipAddress = "Unknown" - } - - browser, version := ua.Browser() - - platform := r.Header.Get("sec-ch-ua-platform") - if platform == "" { - platform = ua.OS() - if platform == "" { - platform = "Unknown" - } - } - - language := r.Header.Get("Accept-Language") - if language != "" && strings.Contains(language, ",") { - language = strings.Split(language, ",")[0] - } else if language == "" { - language = "Unknown" - } - - ipVersion := "IPv4" - if strings.Contains(ipAddress, ":") { - ipVersion = "IPv6" - } - - return map[string]interface{}{ - "ip": ipAddress, - "user_agent": r.UserAgent(), - "platform": platform, - "browser": browser, - "version": version, - "language": language, - "referrer": valueOrDefault(r.Referer(), "Direct"), - "timestamp": time.Now(), - "remote_port": valueOrDefault(r.Header.Get("X-Forwarded-Port"), "Unknown"), - "accept": valueOrDefault(r.Header.Get("Accept"), "Unknown"), - "accept_language": valueOrDefault(r.Header.Get("Accept-Language"), "Unknown"), - "accept_encoding": valueOrDefault(r.Header.Get("Accept-Encoding"), "Unknown"), - "screen_size": valueOrDefault(r.Header.Get("Sec-CH-UA-Platform-Screen"), "Unknown"), - "window_size": valueOrDefault(r.Header.Get("Viewport-Width"), "Unknown"), - "country": valueOrDefault(r.Header.Get("CF-IPCountry"), "Unknown"), - "isp": valueOrDefault(r.Header.Get("X-ISP"), "Unknown"), - "ip_version": ipVersion, - } -} - -func valueOrDefault(value, defaultValue string) string { - if value == "" { - return defaultValue - } - return value -} \ No newline at end of file diff --git a/internal/utils/shortid.go b/internal/utils/shortid.go deleted file mode 100644 index 9d85620..0000000 --- a/internal/utils/shortid.go +++ /dev/null @@ -1,32 +0,0 @@ -package utils - -import ( - "math/rand" - "strings" - "time" -) - -const ( - letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - idLength = 8 - accountLength = 8 -) - -var seededRand = rand.New(rand.NewSource(time.Now().UnixNano())) - -func GenerateShortID() string { - return randomString(idLength, letterBytes) -} - -func GenerateAccountID() string { - return randomString(accountLength, "0123456789") -} - -func randomString(length int, charset string) string { - b := strings.Builder{} - b.Grow(length) - for i := 0; i < length; i++ { - b.WriteByte(charset[seededRand.Intn(len(charset))]) - } - return b.String() -} diff --git a/internal/utils/url.go b/internal/utils/url.go deleted file mode 100644 index 4dfe01b..0000000 --- a/internal/utils/url.go +++ /dev/null @@ -1,19 +0,0 @@ -package utils - -import ( - "net/url" - "strings" -) - -func IsValidURL(urlStr string) bool { - if strings.TrimSpace(urlStr) == "" { - return false - } - - parsedURL, err := url.Parse(urlStr) - if err != nil { - return false - } - - return parsedURL.Scheme != "" && parsedURL.Host != "" -} \ No newline at end of file diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..fb5d802 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + allowedDevOrigins: ['localhost', '*.marcus7i.net'], +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..9b0c9b6 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "ulinkshortener", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "chart.js": "^4.5.0", + "mongodb": "^6.17.0", + "next": "15.3.3", + "next-auth": "^4.24.11", + "react": "^19.0.0", + "react-chartjs-2": "^5.3.0", + "react-dom": "^19.0.0", + "ua-parser-js": "^2.0.3", + "winston": "^3.17.0" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/uuid": "^10.0.0", + "eslint": "^9", + "eslint-config-next": "15.3.3", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..c7bcb4b --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/src/app/Home.module.css b/src/app/Home.module.css new file mode 100644 index 0000000..0fa9437 --- /dev/null +++ b/src/app/Home.module.css @@ -0,0 +1,168 @@ +/* Homepage specific styles */ +.default-container { + width: 100%; + max-width: 90%; + margin: 0 auto; + padding: 0 1.5rem; +} +@media (min-width: 768px) { + .default-container { + max-width: 80%; + } +} +@media (min-width: 1200px) { + .default-container { + max-width: 60%; + } +} + +/* Hero section */ +.hero-section { + min-height: 25vh; + display: flex; + flex-direction: column; + justify-content: center; + padding: 3rem 0 2rem; +} + +.hero-title { + font-size: 3.5rem; + font-weight: 700; + margin-bottom: 1.5rem; + background: linear-gradient(90deg, var(--accent), #9089fc); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.hero-description { + font-size: 1.25rem; + line-height: 1.7; + color: var(--text-secondary); + margin-bottom: 2rem; +} + +.hero-cta { + display: inline-block; + background-color: var(--accent); + color: white; + padding: 0.75rem 1.5rem; + border-radius: 0.375rem; + font-weight: 500; + text-decoration: none; + transition: background-color 0.2s; +} + +.hero-cta:hover { + background-color: var(--accent-hover); +} + +/* Stats section */ +.stats-summary { + margin-bottom: 2rem; + padding: 0 1rem; +} + +.stats-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 2rem; + margin: 0 auto; +} + +.stats-card { + background-color: var(--card-bg); + border-radius: 0.5rem; + padding: 1.5rem; + box-shadow: var(--card-shadow); + border: 1px solid var(--border-color); + transition: transform 0.2s, box-shadow 0.2s; + text-align: center; +} + +.stats-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 15px rgba(0, 0, 0, 0.2); +} + +.stats-card h3 { + font-size: 1rem; + margin-top: 0; + margin-bottom: 0.5rem; + color: var(--text-secondary); +} + +.stats-number { + font-size: 2.5rem; + font-weight: 700; + margin: 0; + color: var(--primary-color); +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .stats-number { + font-size: 2rem; + } +} + +/* Graphs section */ +.graphs-section { + padding: 2rem 0 4rem; +} + +.graphs-title { + font-size: 2rem; + margin-bottom: 2rem; + text-align: center; +} + +.graphs-container { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 2rem; +} + +.graph-card { + background-color: var(--card-bg); + border-radius: 0.5rem; + padding: 1.5rem; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + border: 1px solid var(--border-color); + transition: transform 0.2s, box-shadow 0.2s; + height: 20rem; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.graph-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 15px rgba(0, 0, 0, 0.2); +} + +.graph-title { + font-size: 1.25rem; + margin-bottom: 1rem; + color: var(--text-primary); +} + +.graph-content { + flex: 1; + position: relative; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .graphs-container { + grid-template-columns: 1fr; + } + + .hero-title { + font-size: 2.5rem; + } + + .default-container { + max-width: max-content; + } +} \ No newline at end of file diff --git a/src/app/admin/AdminDashboard.module.css b/src/app/admin/AdminDashboard.module.css new file mode 100644 index 0000000..2c57fbd --- /dev/null +++ b/src/app/admin/AdminDashboard.module.css @@ -0,0 +1,228 @@ +.adminContainer { + max-width: 75rem; + width: 100%; + margin: 0 auto; + padding: 2rem 1rem; +} + +.adminHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.adminTitle { + font-size: 2rem; + color: var(--text-primary); + margin: 0; +} + +.actionButtons { + display: flex; + gap: 1rem; +} + +.dashboardButton, .statsButton { + padding: 0.5rem 1rem; + border-radius: 4px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: none; +} + +.dashboardButton { + background-color: var(--accent); + color: white; +} + +.statsButton { + background-color: var(--success); + color: white; +} + +.dashboardButton:hover, .statsButton:hover { + opacity: 0.9; + transform: translateY(-2px); +} + +.statsButton:disabled { + background-color: var(--disabled-bg); + color: var(--disabled-text); + cursor: not-allowed; + transform: none; +} + +.usersSection { + background-color: var(--card-bg); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 2rem; + border: 1px solid var(--border-color); +} + +.sectionHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.searchContainer { + position: relative; + display: flex; + align-items: center; +} + +.searchInput { + padding: 0.5rem 0.75rem; + padding-right: 2rem; + border-radius: 4px; + border: 1px solid var(--border-color); + background-color: var(--input-bg); + color: var(--text-primary); + font-size: 0.9rem; + width: 250px; +} + +.clearSearchButton { + position: absolute; + right: 0.5rem; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 0.8rem; +} + +.tableContainer { + overflow-x: auto; +} + +.usersTable { + width: 100%; + border-collapse: collapse; + margin-top: 0.5rem; +} + +.usersTable th, +.usersTable td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +.usersTable th { + background-color: var(--table-header-bg); + color: var(--text-primary); + font-weight: 500; +} + +.usersTable tr:last-child td { + border-bottom: none; +} + +.usersTable tr:hover td { + background-color: var(--hover-bg); +} + +.actions { + display: flex; + gap: 0.5rem; +} + +.viewButton, .deleteButton { + padding: 0.4rem 0.6rem; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: none; +} + +.viewButton { + background-color: var(--accent); + color: white; +} + +.deleteButton { + background-color: var(--error); + color: white; +} + +.viewButton:hover, .deleteButton:hover { + opacity: 0.9; +} + +.noResults { + text-align: center; + padding: 2rem; + color: var(--text-secondary); + font-style: italic; +} + +.loading { + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + color: var(--text-secondary); + font-size: 1rem; +} + +.makeAdminButton, .removeAdminButton { + padding: 0.4rem 0.6rem; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: none; + margin-right: 0.5rem; +} + +.makeAdminButton { + background-color: var(--success); + color: white; +} + +.removeAdminButton { + background-color: var(--warning); + color: white; +} + +.makeAdminButton:hover, .removeAdminButton:hover { + opacity: 0.9; +} + +@media (max-width: 768px) { + .adminHeader { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .actionButtons { + width: 100%; + } + + .sectionHeader { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .searchContainer { + width: 100%; + } + + .searchInput { + width: 100%; + } + + .actions { + flex-direction: column; + } +} \ No newline at end of file diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..8b48db9 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { useSession } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import ConfirmModal from '@/components/ui/ConfirmModal'; +import { useToast } from '@/contexts/ToastContext'; +import styles from './AdminDashboard.module.css'; +import type { User } from '@/types/user'; + +export default function AdminDashboard() { + const { data: session, status } = useSession(); + const router = useRouter(); + const { showToast } = useToast(); + + const [users, setUsers] = useState([]); + const [filteredUsers, setFilteredUsers] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const [loading, setLoading] = useState(true); + const [userToDelete, setUserToDelete] = useState(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isRecreatingStats, setIsRecreatingStats] = useState(false); + + useEffect(() => { + if (status === "unauthenticated" || (status === "authenticated" && !session?.user?.isAdmin)) { + router.push('/dashboard'); + } + }, [status, session, router]); + + useEffect(() => { + if (session?.user?.isAdmin) { + fetchUsers(); + } + }, [session]); + + useEffect(() => { + if (!searchTerm.trim()) { + setFilteredUsers(users); + return; + } + + const term = searchTerm.toLowerCase(); + const filtered = users.filter(user => + user.account_id.toLowerCase().includes(term) || + user.created_at.toLocaleString().toLowerCase().includes(term) || + (user.is_admin ? "admin" : "user").includes(term) + ); + + setFilteredUsers(filtered); + }, [searchTerm, users]); + + const fetchUsers = async () => { + try { + setLoading(true); + const response = await fetch('/api/admin/users'); + + if (!response.ok) { + throw new Error('Failed to fetch users'); + } + + const data = await response.json(); + + if (data.success) { + const processedUsers = data.users.map((user: User) => ({ + account_id: user.account_id, + is_admin: user.is_admin, + created_at: new Date(user.created_at) + })); + + setUsers(processedUsers); + setFilteredUsers(processedUsers); + } else { + showToast(data.message || 'Failed to load users', 'error'); + } + } catch (error) { + console.error('Error fetching users:', error); + showToast('Failed to load users', 'error'); + } finally { + setLoading(false); + } + }; + + const handleDeleteUser = async () => { + if (!userToDelete) return; + + try { + const response = await fetch('/api/admin/users', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ account_id: userToDelete }), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + showToast('User deleted successfully', 'success'); + setUsers(prevUsers => prevUsers.filter(user => user.account_id !== userToDelete)); + } else { + showToast(data.message || 'Failed to delete user', 'error'); + } + } catch (error) { + console.error('Error deleting user:', error); + showToast('Failed to delete user', 'error'); + } finally { + setUserToDelete(null); + setIsDeleteModalOpen(false); + } + }; + + const confirmDeleteUser = (accountId: string) => { + setUserToDelete(accountId); + setIsDeleteModalOpen(true); + }; + + const handleRecreateStatistics = async () => { + try { + setIsRecreatingStats(true); + const response = await fetch('/api/admin/statistics/rebuild', { + method: 'POST' + }); + + const data = await response.json(); + + if (response.ok && data.success) { + showToast('Statistics recreated successfully', 'success'); + } else { + showToast(data.message || 'Failed to recreate statistics', 'error'); + } + } catch (error) { + console.error('Error recreating statistics:', error); + showToast('Failed to recreate statistics', 'error'); + } finally { + setIsRecreatingStats(false); + } + }; + + const handleToggleAdminStatus = async (accountId: string) => { + try { + const response = await fetch(`/api/admin/users/${accountId}/admin`, { + method: 'POST', + }); + + const data = await response.json(); + + if (response.ok && data.success) { + showToast(data.message, 'success'); + setUsers(prevUsers => + prevUsers.map(user => + user.account_id === accountId + ? { ...user, is_admin: data.is_admin } + : user + ) + ); + } else { + showToast(data.message || 'Failed to toggle admin status', 'error'); + } + } catch (error) { + console.error('Error toggling admin status:', error); + showToast('Failed to toggle admin status', 'error'); + } + }; + + if (status === "loading" || loading) { + return
Loading...
; + } + + if (status === "unauthenticated" || (status === "authenticated" && !session?.user?.isAdmin)) { + return null; + } + + return ( +
+
+

Admin Dashboard

+
+ + + + +
+
+ +
+
+

Manage Users

+
+ setSearchTerm(e.target.value)} + className={styles.searchInput} + /> + {searchTerm && ( + + )} +
+
+ + {filteredUsers.length === 0 ? ( +
+ {searchTerm ? 'No users match your search' : 'No users available'} +
+ ) : ( +
+ + + + + + + + + + + {filteredUsers.map((user) => ( + + + + + + + ))} + +
Account IDCreatedRoleActions
{user.account_id}{user.created_at.toLocaleString()}{user.is_admin ? 'Admin' : 'User'} + + + + + {user.account_id !== session?.user?.accountId && ( + + )} + + {!user.is_admin && ( + + )} +
+
+ )} +
+ + setIsDeleteModalOpen(false)} + /> +
+ ); +} \ No newline at end of file diff --git a/src/app/admin/user/[accountId]/UserDetail.module.css b/src/app/admin/user/[accountId]/UserDetail.module.css new file mode 100644 index 0000000..ae9d355 --- /dev/null +++ b/src/app/admin/user/[accountId]/UserDetail.module.css @@ -0,0 +1,174 @@ +.container { + max-width: 75rem; + width: 100%; + margin: 0 auto; + padding: 2rem 1rem; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.title { + font-size: 2rem; + color: var(--text-primary); + margin: 0; +} + +.actionButtons { + display: flex; + gap: 1rem; +} + +.backButton, .deleteButton { + padding: 0.5rem 1rem; + border-radius: 4px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: none; +} + +.backButton { + background-color: var(--accent); + color: white; +} + +.deleteButton { + background-color: var(--error); + color: white; +} + +.backButton:hover, .deleteButton:hover { + opacity: 0.9; + transform: translateY(-2px); +} + +.userInfo, .linksSection, .sessionsSection { + background-color: var(--card-bg); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 2rem; + border: 1px solid var(--border-color); +} + +.infoCard { + background-color: var(--card-secondary-bg); + border-radius: 6px; + padding: 1rem; + margin-top: 1rem; +} + +.infoRow { + display: flex; + margin-bottom: 0.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-color); +} + +.infoRow:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.infoLabel { + width: 120px; + font-weight: 500; + color: var(--text-secondary); +} + +.infoValue { + flex: 1; + color: var(--text-primary); +} + +.noItems { + text-align: center; + padding: 2rem; + color: var(--text-secondary); + font-style: italic; +} + +.tableContainer { + overflow-x: auto; +} + +.sessionsTable { + width: 100%; + border-collapse: collapse; + margin-top: 0.5rem; +} + +.sessionsTable th, +.sessionsTable td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +.sessionsTable th { + background-color: var(--table-header-bg); + color: var(--text-primary); + font-weight: 500; +} + +.sessionsTable tr:last-child td { + border-bottom: none; +} + +.sessionsTable tr:hover td { + background-color: var(--hover-bg); +} + +.revokeButton { + padding: 0.4rem 0.6rem; + border-radius: 4px; + font-size: 0.8rem; + background-color: var(--error); + color: white; + border: none; + cursor: pointer; + transition: all 0.2s ease; +} + +.revokeButton:hover { + opacity: 0.9; +} + +.revokeButton:disabled { + background-color: var(--disabled-bg); + color: var(--disabled-text); + cursor: not-allowed; +} + +.loading { + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + color: var(--text-secondary); + font-size: 1rem; +} + +.error { + text-align: center; + padding: 2rem; + color: var(--error); + font-size: 1rem; +} + +@media (max-width: 768px) { + .header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .actionButtons { + width: 100%; + } +} \ No newline at end of file diff --git a/src/app/admin/user/[accountId]/links/[shortId]/AdminLinkDetail.module.css b/src/app/admin/user/[accountId]/links/[shortId]/AdminLinkDetail.module.css new file mode 100644 index 0000000..1c3eb34 --- /dev/null +++ b/src/app/admin/user/[accountId]/links/[shortId]/AdminLinkDetail.module.css @@ -0,0 +1,344 @@ +.container { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 2rem 1rem; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.titleSection { + display: flex; + flex-direction: column; +} + +.title { + font-size: 2rem; + margin: 0; + color: var(--text-primary); +} + +.breadcrumbs { + font-size: 0.9rem; + color: var(--text-secondary); + margin-top: 0.5rem; +} + +.breadcrumbLink { + color: var(--accent); + text-decoration: none; + margin: 0 0.25rem; +} + +.breadcrumbLink:hover { + text-decoration: underline; +} + +.breadcrumbCurrent { + color: var(--text-primary); + margin: 0 0.25rem; +} + +.backLink { + padding: 0.5rem 1rem; + border-radius: 4px; + background-color: var(--accent); + color: var(--text-primary); + text-decoration: none; + font-weight: 500; + transition: all 0.2s ease; +} + +.backLink:hover { + opacity: 0.9; + transform: translateY(-2px); +} + +.userInfo { + background-color: var(--card-bg); + border-radius: 8px; + padding: 1rem 1.5rem; + margin-bottom: 1.5rem; + display: flex; + align-items: center; + border-left: 4px solid var(--accent); +} + +.label { + font-weight: 600; + color: var(--text-secondary); + margin-right: 0.5rem; +} + +.value { + color: var(--text-primary); + word-break: break-all; +} + +.linkInfo { + margin-bottom: 2rem; +} + +.linkCard { + background-color: var(--card-bg); + border-radius: 0.5rem; + padding: 1.5rem; + box-shadow: var(--card-shadow); +} + +.linkCard h2 { + margin-top: 0; + margin-bottom: 1.5rem; + color: var(--text-primary); + font-size: 1.5rem; +} + +.linkDetails { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.linkDetailItem { + display: flex; + flex-direction: column; +} + +.shortUrl { + color: var(--primary-color); + word-break: break-all; + text-decoration: none; +} + +.shortUrl:hover { + text-decoration: underline; +} + +.defaultButton { + background-color: transparent; + color: var(--primary-color); + border: 1px solid var(--primary-color); + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + cursor: pointer; +} + +.defaultButton:hover { + background-color: var(--primary-color); + color: white; +} + +.targetUrlSection { + margin-top: 0.5rem; +} + +.targetUrlHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.editButton { + background-color: transparent; + color: var(--primary-color); + border: 1px solid var(--primary-color); + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + cursor: pointer; +} + +.editButton:hover { + background-color: var(--primary-color); + color: white; +} + +.targetUrl { + color: var(--text-primary); + word-break: break-all; + text-decoration: none; +} + +.targetUrl:hover { + text-decoration: underline; + color: var(--primary-color); +} + +.editForm { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.urlInput { + width: 100%; + padding: 0.5rem; + border: 1px solid var(--border-color); + border-radius: 0.25rem; + background-color: var(--input-bg); + color: var(--text-primary); +} + +.editActions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +.cancelButton { + background-color: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: 0.25rem; + padding: 0.5rem 0.75rem; + cursor: pointer; +} + +.cancelButton:hover { + background-color: var(--border-color); +} + +.saveButton { + background-color: var(--primary-color); + color: white; + border: none; + border-radius: 0.25rem; + padding: 0.5rem 0.75rem; + cursor: pointer; +} + +.saveButton:hover { + background-color: var(--primary-dark); +} + +.analyticsSection { + margin-top: 2rem; +} + +.analyticsHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 1rem; +} + +.analyticsHeader h2 { + margin: 0; + color: var(--text-primary); + font-size: 1.5rem; +} + +.totalClicks { + color: var(--text-secondary); + font-weight: 600; +} + +.deleteAllButton { + background-color: transparent; + color: var(--error); + border: 1px solid var(--error); + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: 1rem; + cursor: pointer; +} + +.deleteAllButton:hover { + background-color: var(--error); + color: white; +} + +.graphs { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.graphCard { + background-color: var(--card-bg); + border-radius: 0.5rem; + padding: 1rem; + box-shadow: var(--card-shadow); +} + +.graphCard h3 { + margin-top: 0; + margin-bottom: 1rem; + color: var(--text-primary); + font-size: 1rem; + text-align: center; +} + +.noAnalytics { + display: flex; + justify-content: center; + align-items: center; + background-color: var(--card-bg); + border-radius: 0.5rem; + padding: 2rem; + box-shadow: var(--card-shadow); +} + +.noAnalytics p { + color: var(--text-secondary); + font-size: 1rem; + text-align: center; +} + +.loadingContainer { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 300px; + gap: 1rem; +} + +.loader { + border: 4px solid rgba(0, 0, 0, 0.1); + border-left-color: var(--primary-color); + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; +} + +.error { + text-align: center; + color: var(--error); + font-size: 1.2rem; + margin-top: 2rem; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 768px) { + .header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .analyticsHeader { + flex-direction: column; + align-items: flex-start; + } + + .graphs { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/src/app/admin/user/[accountId]/links/[shortId]/page.tsx b/src/app/admin/user/[accountId]/links/[shortId]/page.tsx new file mode 100644 index 0000000..d70ffe0 --- /dev/null +++ b/src/app/admin/user/[accountId]/links/[shortId]/page.tsx @@ -0,0 +1,574 @@ +"use client"; + +import { useState, useEffect, useRef } from 'react'; +import { useSession } from 'next-auth/react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { useToast } from '@/contexts/ToastContext'; +import ConfirmModal from '@/components/ui/ConfirmModal'; +import AnalyticsTable from '@/components/ui/dashboard/link/AnalyticsTable'; +import Graph from '@/components/ui/Graph'; +import styles from './AdminLinkDetail.module.css'; +import type { Link as LinkType } from '@/types/link'; +import type { Analytics } from '@/types/analytics'; +import type { StatItem } from '@/types/statistics'; + +export default function AdminLinkDetailPage() { + const { data: session, status } = useSession(); + const params = useParams(); + const router = useRouter(); + const { showToast } = useToast(); + const accountId = params.accountId as string; + const shortId = params.shortId as string; + + const [link, setLink] = useState(null); + const [targetUrl, setTargetUrl] = useState(''); + const [isEditing, setIsEditing] = useState(false); + const inputRef = useRef(null); + const [isLoading, setIsLoading] = useState(true); + const [allAnalytics, setAllAnalytics] = useState([]); + const [analytics, setAnalytics] = useState([]); + const [totalAnalytics, setTotalAnalytics] = useState(0); + const [page, setPage] = useState(1); + const [limit] = useState(15); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showDeleteAllModal, setShowDeleteAllModal] = useState(false); + const [analyticsToDelete, setAnalyticsToDelete] = useState(''); + const [isLoadingStats, setIsLoadingStats] = useState(true); + const isRedirecting = useRef(false); + + // Stats data + const [browserStats, setBrowserStats] = useState([]); + const [osStats, setOsStats] = useState([]); + const [countryStats, setCountryStats] = useState([]); + const [ipVersionStats, setIpVersionStats] = useState([]); + + useEffect(() => { + if (status === "unauthenticated" || (status === "authenticated" && !session?.user?.isAdmin)) { + router.push('/dashboard'); + } + }, [status, session, router]); + + function isValidUrl(urlStr: string) : boolean { + if(urlStr.trim() === "") { + return false; + } + + try { + const parsedUrl = new URL(urlStr); + return parsedUrl.protocol !== "" && parsedUrl.hostname !== ""; + } catch { + return false; + } + } + + useEffect(() => { + if (isEditing && inputRef.current) { + setTimeout(() => { + inputRef.current?.focus(); + }, 10); + } + }, [isEditing]); + + useEffect(() => { + if (isRedirecting.current || status !== "authenticated" || !session?.user?.isAdmin) return; + + async function fetchLinkData() { + try { + setIsLoading(true); + const response = await fetch(`/api/admin/users/${accountId}/links/${shortId}`); + if (!response.ok) { + showToast('Failed to load link details', 'error'); + isRedirecting.current = true; + router.push(`/admin/user/${accountId}`); + return; + } + + const data = await response.json(); + if (data.success && data.link) { + setLink(data.link); + setTargetUrl(data.link.target_url); + } else { + showToast(data.message || 'Link not found', 'error'); + isRedirecting.current = true; + router.push(`/admin/user/${accountId}`); + } + } catch { + showToast('An error occurred while loading link details', 'error'); + isRedirecting.current = true; + router.push(`/admin/user/${accountId}`); + } finally { + setIsLoading(false); + } + } + + fetchLinkData(); + }, [shortId, accountId, router, showToast, session, status]); + + useEffect(() => { + if (!link || status !== "authenticated" || !session?.user?.isAdmin) return; + + async function fetchAllAnalytics() { + try { + const response = await fetch(`/api/admin/users/${accountId}/links/${shortId}/analytics?all=true`); + if (!response.ok) { + showToast('Failed to load complete analytics data', 'error'); + return; + } + + const data = await response.json(); + if (data.success) { + setAllAnalytics(data.analytics); + setTotalAnalytics(data.pagination.total); + } + } catch { + showToast('An error occurred while loading complete analytics data', 'error'); + } + } + + fetchAllAnalytics(); + }, [link, shortId, accountId, showToast, session, status]); + + useEffect(() => { + if (!link || status !== "authenticated" || !session?.user?.isAdmin) return; + + async function fetchPaginatedAnalytics() { + try { + const response = await fetch(`/api/admin/users/${accountId}/links/${shortId}/analytics?page=${page}&limit=${limit}`); + if (!response.ok) { + showToast('Failed to load analytics page', 'error'); + return; + } + + const data = await response.json(); + if (data.success) { + setAnalytics(data.analytics); + } + } catch { + showToast('An error occurred while loading analytics page', 'error'); + } + } + + fetchPaginatedAnalytics(); + }, [link, shortId, accountId, page, limit, showToast, session, status]); + + useEffect(() => { + if (!link || allAnalytics.length === 0) return; + + async function generateStats() { + setIsLoadingStats(true); + try { + // Browser stats + const browsers = allAnalytics.reduce((acc: Record, item) => { + const browser = item.browser || 'Unknown'; + acc[browser] = (acc[browser] || 0) + 1; + return acc; + }, {}); + + // OS stats + const oses = allAnalytics.reduce((acc: Record, item) => { + const os = item.platform || 'Unknown'; + acc[os] = (acc[os] || 0) + 1; + return acc; + }, {}); + + // Country stats + const countries = allAnalytics.reduce((acc: Record, item) => { + const country = item.country || 'Unknown'; + acc[country] = (acc[country] || 0) + 1; + return acc; + }, {}); + + // IP version stats + const ipVersions = allAnalytics.reduce((acc: Record, item) => { + const ipVersion = item.ip_version || 'Unknown'; + acc[ipVersion] = (acc[ipVersion] || 0) + 1; + return acc; + }, {}); + + // Convert to StatItem[] and sort by count + setBrowserStats(Object.entries(browsers) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count)); + + setOsStats(Object.entries(oses) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count)); + + setCountryStats(Object.entries(countries) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count)); + + setIpVersionStats(Object.entries(ipVersions) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count)); + } catch { + showToast('An error occurred while processing analytics data', 'error'); + } finally { + setIsLoadingStats(false); + } + } + + generateStats(); + }, [allAnalytics, link, showToast]); + + const handlePageChange = (newPage: number) => { + setPage(newPage); + }; + + const handleEditLink = async () => { + if (!isValidUrl(targetUrl)) { + showToast('Please enter a valid URL', 'error'); + return; + } + + try { + const response = await fetch(`/api/admin/users/${accountId}/links/${shortId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + target_url: targetUrl + }), + }); + + const data = await response.json(); + + if (data.success) { + showToast('Link updated successfully', 'success'); + setIsEditing(false); + if (link) { + setLink({ + ...link, + target_url: targetUrl, + last_modified: new Date() + }); + } + } else { + showToast(data.message || 'Failed to update link', 'error'); + } + } catch { + showToast('An error occurred while updating the link', 'error'); + } + }; + + const handleDeleteAnalytics = async () => { + try { + const response = await fetch(`/api/admin/users/${accountId}/links/${shortId}/analytics`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + analytics_id: analyticsToDelete + }), + }); + + const data = await response.json(); + + if (data.success) { + showToast('Analytics entry deleted successfully', 'success'); + + setAnalytics(analytics.filter(item => item._id?.toString() !== analyticsToDelete)); + + setTotalAnalytics(prev => prev - 1); + } else { + showToast(data.message || 'Failed to delete analytics entry', 'error'); + } + } catch { + showToast('An error occurred while deleting the analytics entry', 'error'); + } finally { + setShowDeleteModal(false); + setAnalyticsToDelete(''); + } + }; + + const handleDeleteAllAnalytics = async () => { + try { + const response = await fetch(`/api/admin/users/${accountId}/links/${shortId}/analytics`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + delete_all: true + }), + }); + + const data = await response.json(); + + if (data.success) { + showToast('All analytics entries deleted successfully', 'success'); + + setAnalytics([]); + setTotalAnalytics(0); + setBrowserStats([]); + setOsStats([]); + setCountryStats([]); + setIpVersionStats([]); + } else { + showToast(data.message || 'Failed to delete all analytics entries', 'error'); + } + } catch { + showToast('An error occurred while deleting all analytics entries', 'error'); + } finally { + setShowDeleteAllModal(false); + } + }; + + if (status === "loading" || isLoading) { + return ( +
+
+

Loading link details...

+
+ ); + } + + if (status === "unauthenticated" || (status === "authenticated" && !session?.user?.isAdmin)) { + return null; + } + + if (!link) { + return ( +
Link not found or you don't have permission to view it.
+ ); + } + + return ( +
+
+
+

Admin Link Management

+
+ Admin > + User > + Link {shortId} +
+
+ + Back to User + +
+ +
+ Managing link for User ID: + {accountId} +
+ +
+
+

Link Information

+
+
+ Short ID: + {shortId} +
+ +
+ + +
+ +
+ Created: + + {link.created_at instanceof Date + ? link.created_at.toLocaleString() + : new Date(link.created_at).toLocaleString()} + +
+ +
+ Last Modified: + + {link.last_modified instanceof Date + ? link.last_modified.toLocaleString() + : new Date(link.last_modified).toLocaleString()} + +
+ +
+
+ Target URL: + {!isEditing && ( + + )} +
+ + {isEditing ? ( +
{ + e.preventDefault(); + handleEditLink(); + }} + className={styles.editForm} + > + setTargetUrl(e.target.value)} + className={styles.urlInput} + placeholder="https://example.com" + /> +
+ + +
+
+ ) : ( + + {link.target_url} + + )} +
+
+
+
+ +
+
+

Analytics

+ + Total Clicks: {totalAnalytics} + + {totalAnalytics > 0 && ( + + )} +
+ + {totalAnalytics > 0 ? ( + <> +
+
+

Browsers

+ +
+ +
+

Operating Systems

+ +
+ +
+

Countries

+ +
+ +
+

IP Versions

+ +
+
+ + { + setAnalyticsToDelete(id); + setShowDeleteModal(true); + }} + /> + + ) : ( +
+

No clicks recorded yet for this link.

+
+ )} +
+ + {/* Confirm Delete Modal */} + { + setShowDeleteModal(false); + setAnalyticsToDelete(''); + }} + /> + + {/* Confirm Delete All Modal */} + setShowDeleteAllModal(false)} + /> +
+ ); +} \ No newline at end of file diff --git a/src/app/admin/user/[accountId]/page.tsx b/src/app/admin/user/[accountId]/page.tsx new file mode 100644 index 0000000..cb5b876 --- /dev/null +++ b/src/app/admin/user/[accountId]/page.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useSession } from 'next-auth/react'; +import Link from 'next/link'; +import AdminLinkTable from '@/components/ui/admin/AdminLinkTable'; +import ConfirmModal from '@/components/ui/ConfirmModal'; +import { useToast } from '@/contexts/ToastContext'; +import styles from './UserDetail.module.css'; +import type { User } from '@/types/user'; +import type { SessionInfo } from '@/types/session'; + +export default function UserDetailPage() { + const params = useParams(); + const router = useRouter(); + const { data: session, status } = useSession(); + const { showToast } = useToast(); + const accountId = params.accountId as string; + + const [user, setUser] = useState(null); + const [links, setLinks] = useState([]); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [revoking, setRevoking] = useState(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + useEffect(() => { + if (status === "unauthenticated" || (status === "authenticated" && !session?.user?.isAdmin)) { + router.push('/dashboard'); + } + }, [status, session, router]); + + useEffect(() => { + if (status === "authenticated" && session?.user?.isAdmin) { + fetchUserData(); + } + }, [status, session, accountId]); + + const fetchUserData = async () => { + try { + setLoading(true); + const userResponse = await fetch(`/api/admin/users/${accountId}`); + + if (!userResponse.ok) { + throw new Error('Failed to fetch user details'); + } + + const userData = await userResponse.json(); + if (userData.success) { + setUser(userData.user); + + const linksResponse = await fetch(`/api/admin/users/${accountId}/links`); + const linksData = await linksResponse.json(); + + if (linksResponse.ok && linksData.success) { + setLinks(linksData.links); + } + + const sessionsResponse = await fetch(`/api/admin/users/${accountId}/sessions`); + const sessionsData = await sessionsResponse.json(); + + if (sessionsResponse.ok && sessionsData.success) { + setSessions(sessionsData.sessions); + } + } else { + showToast(userData.message || 'Failed to load user details', 'error'); + router.push('/admin'); + } + } catch (error) { + console.error('Error fetching user data:', error); + showToast('Failed to load user details', 'error'); + router.push('/admin'); + } finally { + setLoading(false); + } + }; + + const handleRevokeSession = async (sessionId: string) => { + try { + setRevoking(sessionId); + + const response = await fetch(`/api/admin/users/${accountId}/sessions/revoke`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, accountId }) + }); + + const data = await response.json(); + + if (response.ok && data.success) { + showToast('Session revoked successfully', 'success'); + setSessions(prevSessions => + prevSessions.filter(s => s.id !== sessionId) + ); + } else { + showToast(data.message || 'Failed to revoke session', 'error'); + } + } catch (error) { + console.error('Error revoking session:', error); + showToast('Failed to revoke session', 'error'); + } finally { + setRevoking(null); + } + }; + + const handleDeleteUser = async () => { + try { + const response = await fetch('/api/admin/users', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ account_id: accountId }), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + showToast('User deleted successfully', 'success'); + router.push('/admin'); + } else { + showToast(data.message || 'Failed to delete user', 'error'); + } + } catch (error) { + console.error('Error deleting user:', error); + showToast('Failed to delete user', 'error'); + } finally { + setIsDeleteModalOpen(false); + } + }; + + const formatDate = (dateString: Date) => { + const date = new Date(dateString); + return date.toLocaleString(); + }; + + if (status === "loading" || loading) { + return
Loading user details...
; + } + + if (!user) { + return
User not found
; + } + + return ( +
+
+

User Details

+
+ + + + {!user.is_admin && ( + + )} +
+
+ +
+

Account Information

+
+
+ Account ID: + {user.account_id} +
+
+ Created: + {formatDate(user.created_at)} +
+
+ Role: + {user.is_admin ? 'Admin' : 'User'} +
+
+
+ +
+

User Links

+ {links.length === 0 ? ( +
This user has not created any links yet.
+ ) : ( + + )} +
+ +
+

Active Sessions

+ {sessions.length === 0 ? ( +
This user has no active sessions.
+ ) : ( +
+ + + + + + + + + + + + {sessions.map((s) => ( + + + + + + + + ))} + +
Device & BrowserIP AddressLast ActiveCreatedActions
{s.userAgent.split(' ').slice(0, 3).join(' ')}{s.ipAddress}{formatDate(s.lastActive)}{formatDate(s.createdAt)} + +
+
+ )} +
+ + setIsDeleteModalOpen(false)} + /> +
+ ); +} \ No newline at end of file diff --git a/src/app/api/admin/statistics/rebuild/route.ts b/src/app/api/admin/statistics/rebuild/route.ts new file mode 100644 index 0000000..aba1e8c --- /dev/null +++ b/src/app/api/admin/statistics/rebuild/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { updateStats } from '@/lib/statisticsdb'; +import logger from '@/lib/logger'; + +export async function POST() { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json({ + message: "Unauthorized", + success: false, + }, { status: 401 }); + } + + const result = await updateStats(); + + return NextResponse.json({ + message: result.status, + success: result.success, + }); + } catch (error) { + logger.error('Error rebuilding statistics:', { error }); + return NextResponse.json({ + message: "Failed to rebuild statistics", + success: false, + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/admin/users/[accountId]/admin/route.ts b/src/app/api/admin/users/[accountId]/admin/route.ts new file mode 100644 index 0000000..af3760d --- /dev/null +++ b/src/app/api/admin/users/[accountId]/admin/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { isUserAdmin, makeUserAdmin } from '@/lib/userdb'; +import { removeAllSessionsByAccountId } from '@/lib/sessiondb'; +import logger from '@/lib/logger'; + +export async function POST( + req: NextRequest, + { params }: { params: { accountId: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json({ + message: "Unauthorized", + success: false, + }, { status: 401 }); + } + + const { accountId } = params; + + if (!accountId) { + return NextResponse.json({ + message: "Account ID is required", + success: false, + }, { status: 400 }); + } + + if (accountId === session.user.accountId) { + return NextResponse.json({ + message: "You cannot change your own admin status", + success: false, + }, { status: 400 }); + } + + const isAdmin = await isUserAdmin(accountId); + const result = await makeUserAdmin(accountId, !isAdmin); + + if (!result.success) { + return NextResponse.json({ + message: result.status, + success: false, + }, { status: result.status === "User not found" ? 404 : 500 }); + } + + await removeAllSessionsByAccountId(accountId); + + logger.info(`Admin status toggled for user ${accountId}, all sessions revoked`, { + accountId, + newStatus: !isAdmin + }); + + return NextResponse.json({ + message: `${result.status} User will need to log in again.`, + is_admin: !isAdmin, + success: true, + }); + } catch (error) { + logger.error('Error toggling admin status:', { error }); + return NextResponse.json({ + message: "Failed to toggle admin status", + success: false, + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/admin/users/[accountId]/links/[shortId]/analytics/route.ts b/src/app/api/admin/users/[accountId]/links/[shortId]/analytics/route.ts new file mode 100644 index 0000000..c8f7820 --- /dev/null +++ b/src/app/api/admin/users/[accountId]/links/[shortId]/analytics/route.ts @@ -0,0 +1,116 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { getAllAnalytics, removeAnalytics, removeAllAnalytics } from '@/lib/analyticsdb'; +import { sanitizeMongoDocument } from '@/lib/utils'; +import logger from '@/lib/logger'; + +export async function GET( + req: NextRequest, + { params }: { params: { accountId: string, shortId: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json({ + message: "Unauthorized", + success: false, + }, { status: 401 }); + } + + const { accountId, shortId } = await params; + + if (!accountId || !shortId) { + return NextResponse.json({ + message: "Account ID and Short ID are required", + success: false, + }, { status: 400 }); + } + + const url = new URL(req.url); + const page = parseInt(url.searchParams.get('page') || '1'); + const limit = parseInt(url.searchParams.get('limit') || '50'); + + const { analytics, total } = await getAllAnalytics( + accountId, + shortId, + { page, limit } + ); + + const sanitizedAnalytics = analytics.map(item => sanitizeMongoDocument(item)); + + return NextResponse.json({ + analytics: sanitizedAnalytics, + pagination: { + total, + page, + limit, + pages: Math.ceil(total / limit) + }, + success: true, + }); + } catch (error) { + logger.error('Error getting analytics:', { error, accountId: params.accountId, shortId: params.shortId }); + return NextResponse.json({ + message: "Failed to retrieve analytics", + success: false, + }, { status: 500 }); + } +} + +export async function DELETE( + req: NextRequest, + { params }: { params: { accountId: string, shortId: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json({ + message: "Unauthorized", + success: false, + }, { status: 401 }); + } + + const { accountId, shortId } = await params; + + if (!accountId || !shortId) { + return NextResponse.json({ + message: "Account ID and Short ID are required", + success: false, + }, { status: 400 }); + } + + const body = await req.json(); + + if (body.delete_all) { + const result = await removeAllAnalytics(accountId, shortId); + + return NextResponse.json({ + message: result.status, + success: result.success, + }, { status: result.success ? 200 : 400 }); + } + + if (body.analytics_id) { + const result = await removeAnalytics(accountId, shortId, body.analytics_id); + + return NextResponse.json({ + message: result.status, + success: result.success, + }, { status: result.success ? 200 : 400 }); + } + + return NextResponse.json({ + message: "Either delete_all or analytics_id must be provided", + success: false, + }, { status: 400 }); + } catch (error) { + logger.error('Error deleting analytics:', { error, accountId: params.accountId, shortId: params.shortId }); + return NextResponse.json({ + message: "Failed to delete analytics", + success: false, + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/admin/users/[accountId]/links/[shortId]/route.ts b/src/app/api/admin/users/[accountId]/links/[shortId]/route.ts new file mode 100644 index 0000000..6fdb5a8 --- /dev/null +++ b/src/app/api/admin/users/[accountId]/links/[shortId]/route.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { getLinkById, editLink, removeLink } from '@/lib/linkdb'; +import { sanitizeMongoDocument } from '@/lib/utils'; +import logger from '@/lib/logger'; + +export async function GET( + req: NextRequest, + { params }: { params: { accountId: string, shortId: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json({ + message: "Unauthorized", + success: false, + }, { status: 401 }); + } + + const { accountId, shortId } = await params; + + if (!accountId || !shortId) { + return NextResponse.json({ + message: "Account ID and Short ID are required", + success: false, + }, { status: 400 }); + } + + const { link, return: linkReturn } = await getLinkById(accountId, shortId); + + if (!linkReturn.success || !link) { + return NextResponse.json({ + message: linkReturn.status, + success: false, + }, { status: 404 }); + } + + return NextResponse.json({ + link: sanitizeMongoDocument(link), + success: true, + }); + } catch (error) { + logger.error('Error getting link details:', { error, accountId: params.accountId, shortId: params.shortId }); + return NextResponse.json({ + message: "Failed to retrieve link details", + success: false, + }, { status: 500 }); + } +} + +export async function PATCH( + req: NextRequest, + { params }: { params: { accountId: string, shortId: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json({ + message: "Unauthorized", + success: false, + }, { status: 401 }); + } + + const { accountId, shortId } = await params; + + if (!accountId || !shortId) { + return NextResponse.json({ + message: "Account ID and Short ID are required", + success: false, + }, { status: 400 }); + } + + const body = await req.json(); + const { target_url } = body; + + if (!target_url) { + return NextResponse.json({ + message: "Target URL is required", + success: false, + }, { status: 400 }); + } + + const result = await editLink(accountId, shortId, target_url); + + return NextResponse.json({ + message: result.status, + success: result.success, + }, { status: result.success ? 200 : 400 }); + } catch (error) { + logger.error('Error updating link:', { error, accountId: params.accountId, shortId: params.shortId }); + return NextResponse.json({ + message: "Failed to update link", + success: false, + }, { status: 500 }); + } +} + +export async function DELETE( + req: NextRequest, + { params }: { params: { accountId: string, shortId: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json({ + message: "Unauthorized", + success: false, + }, { status: 401 }); + } + + const { accountId, shortId } = await params; + + if (!accountId || !shortId) { + return NextResponse.json({ + message: "Account ID and Short ID are required", + success: false, + }, { status: 400 }); + } + + const result = await removeLink(accountId, shortId); + + return NextResponse.json({ + message: result.status, + success: result.success, + }, { status: result.success ? 200 : 400 }); + } catch (error) { + logger.error('Error deleting link:', { error, accountId: params.accountId, shortId: params.shortId }); + return NextResponse.json({ + message: "Failed to delete link", + success: false, + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/admin/users/[accountId]/links/route.ts b/src/app/api/admin/users/[accountId]/links/route.ts new file mode 100644 index 0000000..550c9f3 --- /dev/null +++ b/src/app/api/admin/users/[accountId]/links/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { getLinks } from '@/lib/linkdb'; +import { sanitizeMongoDocument } from '@/lib/utils'; +import logger from '@/lib/logger'; + +export async function GET( + req: NextRequest, + { params }: { params: { accountId: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json({ + message: "Unauthorized", + success: false, + }, { status: 401 }); + } + + const { accountId } = await params; + + if (!accountId) { + return NextResponse.json({ + message: "Account ID is required", + success: false, + }, { status: 400 }); + } + + const { links } = await getLinks(accountId); + const sanitizedLinks = links.map(link => (sanitizeMongoDocument(link))); + + return NextResponse.json({ + links: sanitizedLinks, + success: true, + }); + } catch (error) { + logger.error('Error getting user links:', { error }); + return NextResponse.json({ + message: "Failed to retrieve user links", + success: false, + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/admin/users/[accountId]/route.ts b/src/app/api/admin/users/[accountId]/route.ts new file mode 100644 index 0000000..87f2b37 --- /dev/null +++ b/src/app/api/admin/users/[accountId]/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { getUserById } from '@/lib/userdb'; +import logger from '@/lib/logger'; +import { sanitizeMongoDocument } from '@/lib/utils'; + +export async function GET( + req: NextRequest, + { params }: { params: { accountId: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json({ + message: "Unauthorized", + success: false, + }, { status: 401 }); + } + + const { accountId } = await params; + + if (!accountId) { + return NextResponse.json({ + message: "Account ID is required", + success: false, + }, { status: 400 }); + } + + const user = await getUserById(accountId); + + if (!user) { + return NextResponse.json({ + message: "User not found", + success: false, + }, { status: 404 }); + } + + const sanitizedUser = sanitizeMongoDocument(user); + + return NextResponse.json({ + user: sanitizedUser, + success: true, + }); + } catch (error) { + logger.error('Error getting user:', error); + return NextResponse.json({ + message: "Failed to retrieve user", + success: false, + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/admin/users/[accountId]/sessions/revoke/route.ts b/src/app/api/admin/users/[accountId]/sessions/revoke/route.ts new file mode 100644 index 0000000..d682462 --- /dev/null +++ b/src/app/api/admin/users/[accountId]/sessions/revoke/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { revokeSession } from '@/lib/sessiondb'; +import logger from '@/lib/logger'; + +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json({ + message: "Unauthorized", + success: false, + }, { status: 401 }); + } + + const { sessionId, accountId } = await req.json(); + + if (!sessionId || !accountId) { + return NextResponse.json({ + message: "Session ID and Account ID are required", + success: false, + }, { status: 400 }); + } + + const result = await revokeSession(sessionId, accountId); + + return NextResponse.json({ + ...result, + }); + } catch (error) { + logger.error('Error revoking session:', { error }); + return NextResponse.json({ + message: "Failed to revoke session", + success: false, + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/admin/users/[accountId]/sessions/route.ts b/src/app/api/admin/users/[accountId]/sessions/route.ts new file mode 100644 index 0000000..c771bda --- /dev/null +++ b/src/app/api/admin/users/[accountId]/sessions/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { getSessions } from '@/lib/sessiondb'; +import { sanitizeMongoDocument } from '@/lib/utils'; +import logger from '@/lib/logger'; + +export async function GET( + req: NextRequest, + { params }: { params: { accountId: string } } +) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json({ + message: "Unauthorized", + success: false, + }, { status: 401 }); + } + + const { accountId } = await params; + + if (!accountId) { + return NextResponse.json({ + message: "Account ID is required", + success: false, + }, { status: 400 }); + } + + const sessions = await getSessions(accountId); + const sanitizedSessions = Array.isArray(sessions) + ? sessions.map(session => sanitizeMongoDocument(session)) + : []; + + return NextResponse.json({ + sessions: sanitizedSessions, + success: true, + }); + } catch (error) { + logger.error('Error getting user sessions:', { error }); + return NextResponse.json({ + message: "Failed to retrieve user sessions", + success: false, + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..de4194b --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { listUsers, removeUser, isUserAdmin } from '@/lib/userdb'; +import { sanitizeMongoDocument } from '@/lib/utils'; +import logger from '@/lib/logger'; + +export async function GET() { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json({ + message: "Unauthorized", + success: false, + }, { status: 401 }); + } + + const result = await listUsers(); + + if (result.users) { + const sanitizedUsers = result.users.map(user => (sanitizeMongoDocument(user))); + + return NextResponse.json({ + users: sanitizedUsers, + total: result.total, + success: true, + }); + } + + return NextResponse.json({ + message: result.return.status, + success: result.return.success, + }, { status: result.return.success ? 200 : 500 }); + } catch (error) { + logger.error('Error getting users:', { error }); + return NextResponse.json({ + message: "Failed to retrieve users", + success: false, + }, { status: 500 }); + } +} + +export async function DELETE(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.isAdmin) { + return NextResponse.json({ + message: "Unauthorized", + success: false, + }, { status: 401 }); + } + + const { account_id } = await req.json(); + + if (!account_id) { + return NextResponse.json({ + message: "Account ID is required", + success: false, + }, { status: 400 }); + } + + if (account_id === session.user.accountId) { + return NextResponse.json({ + message: "You cannot delete your own account from admin panel", + success: false, + }, { status: 400 }); + } + + if (await isUserAdmin(account_id)) { + return NextResponse.json({ + message: "Cannot delete admin accounts", + success: false, + }, { status: 400 }); + } + + const result = await removeUser(account_id); + + return NextResponse.json({ + message: result.status, + success: result.success, + }); + } catch (error) { + logger.error('Error deleting user:', { error }); + return NextResponse.json({ + message: "Failed to delete user", + success: false, + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts new file mode 100644 index 0000000..7fcfbe5 --- /dev/null +++ b/src/app/api/analytics/route.ts @@ -0,0 +1,185 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAllAnalytics, removeAnalytics, removeAllAnalytics } from '@/lib/analyticsdb'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import logger from '@/lib/logger'; + +// View analytics for a specific link +// Example: /api/analytics?link_id=SHORT_ID&page=1&limit=50&startDate=2025-01-01&endDate=2025-12-31 +export async function GET(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.accountId) { + logger.info('Analytics request failed due to unauthorized access', { url: req.url }); + return NextResponse.json({ + message: "Unauthorized. Please sign in first.", + success: false, + }, { status: 401 }); + } + + const account_id = session.user.accountId; + const url = new URL(req.url); + + const link_id = url.searchParams.get('link_id'); + if (!link_id) { + logger.info('Analytics request failed due to missing link_id', { account_id, url: req.url }); + return NextResponse.json({ + message: "Missing link_id parameter", + success: false, + }, { status: 400 }); + } + const all = url.searchParams.get('all') === 'true'; + + let page = 1; + let limit = 50; + + if (!all) { + page = parseInt(url.searchParams.get('page') || '1'); + limit = parseInt(url.searchParams.get('limit') || '50'); + } + + let startDate: Date | undefined; + let endDate: Date | undefined; + + if (url.searchParams.get('startDate')) { + startDate = new Date(url.searchParams.get('startDate')!); + } + + if (url.searchParams.get('endDate')) { + endDate = new Date(url.searchParams.get('endDate')!); + } + + const queryOptions = all + ? {} + : { + page, + limit, + startDate, + endDate + }; + + const { analytics, total } = await getAllAnalytics(account_id, link_id, queryOptions); + + return NextResponse.json({ + message: "Analytics retrieved successfully", + success: true, + analytics, + pagination: { + total, + page, + limit, + totalPages: Math.ceil(total / limit) + } + }, { status: 200 }); + } catch (error) { + logger.error('Analytics retrieval error:', { error, url: req.url }); + return NextResponse.json({ + message: "Failed to retrieve analytics", + success: false, + }, { status: 500 }); + } +} + +// Delete analytics record +export async function DELETE(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.accountId) { + logger.info('Analytics deletion request failed due to unauthorized access', { url: req.url }); + return NextResponse.json({ + message: "Unauthorized. Please sign in first.", + success: false, + }, { status: 401 }); + } + + const account_id = session.user.accountId; + let link_id, analytics_id, delete_all; + + try { + const contentType = req.headers.get('content-type'); + if (contentType && contentType.includes('application/json') && req.body) { + const body = await req.json(); + link_id = body.link_id; + analytics_id = body.analytics_id; + delete_all = body.delete_all; + } + } catch { + logger.info('Analytics deletion request failed due to missing parameters', { url: req.url }); + return NextResponse.json({ + message: "Missing required parameters", + success: false, + }, { status: 400 }); + } + + if (!link_id) { + logger.info('Analytics deletion request failed due to missing link_id', { account_id, url: req.url }); + return NextResponse.json({ + message: "Missing link_id parameter", + success: false, + }, { status: 400 }); + } + + // Delete all analytics for a link + if (delete_all) { + const result = await removeAllAnalytics(account_id, link_id); + + if (result.success) { + logger.info('All analytics deletion request succeeded', { account_id, link_id, url: req.url }); + return NextResponse.json({ + message: "All analytics records deleted successfully", + success: true, + }, { status: 200 }); + } else { + logger.info('All analytics deletion request failed', { error: result.status, account_id, link_id, url: req.url }); + return NextResponse.json({ + message: result.status, + success: false, + }, { status: 404 }); + } + } + + // Delete single analytics record + if (!analytics_id) { + logger.info('Analytics deletion request failed due to missing analytics_id', { account_id, link_id, url: req.url }); + return NextResponse.json({ + message: "Missing analytics_id parameter for single record deletion", + success: false, + }, { status: 400 }); + } + + const result = await removeAnalytics(account_id, link_id, analytics_id); + + if (result.success) { + logger.info('Single analytics record deletion request succeeded', { + account_id, + link_id, + analytics_id, + url: req.url + }); + return NextResponse.json({ + message: "Analytics record deleted successfully", + success: true, + }, { status: 200 }); + } else { + logger.info('Single analytics record deletion request failed', { + error: result.status, + account_id, + link_id, + analytics_id, + url: req.url + }); + return NextResponse.json({ + message: result.status, + success: false, + }, { status: 404 }); + } + } catch (error) { + logger.error('Analytics deletion error:', { error, url: req.url }); + return NextResponse.json({ + message: "Failed to delete analytics", + success: false, + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..0401a1e --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,102 @@ +import NextAuth, { NextAuthOptions, User } from "next-auth"; +import CredentialsProvider from "next-auth/providers/credentials"; +import logger from '@/lib/logger'; +import { existsUser, isUserAdmin } from "@/lib/userdb"; +import { JWT } from "next-auth/jwt"; +import { Session } from "next-auth"; +import { createSession, updateSessionActivity, revokeSession } from '@/lib/sessiondb'; +import { headers } from 'next/headers'; + +export const authOptions: NextAuthOptions = { + providers: [ + CredentialsProvider({ + name: "Account ID", + credentials: { + accountId: { label: "Account ID", type: "text", placeholder: "Enter your Account ID" } + }, + async authorize(credentials) { + const { accountId } = credentials as { accountId: string }; + + const exists = await existsUser(accountId); + + if (exists) { + const isAdmin = await isUserAdmin(accountId); + + return { + id: accountId, + accountId, + isAdmin, + }; + } + + return null; + } + }), + ], + callbacks: { + async jwt({ token, user, trigger }: { token: JWT; user: User, trigger?: string }) { + if (user) { + token.accountId = user.accountId; + token.isAdmin = user.isAdmin; + + const headersList = await headers(); + const userAgent = headersList.get('user-agent') || 'Unknown'; + const ip = headersList.get('x-forwarded-for') || + headersList.get('x-real-ip') || + 'Unknown'; + + const { sessionId } = await createSession( + user.accountId, + userAgent, + ip + ); + + token.sessionId = sessionId; + } + + if (trigger === 'update' && token.sessionId) { + await updateSessionActivity(token.sessionId as string); + } + + return token; + }, + async session({ session, token }: { session: Session; token: JWT }) { + if (token) { + session.user = { + ...session.user, + accountId: token.accountId as string, + isAdmin: token.isAdmin as boolean, + sessionId: token.sessionId as string + }; + } + return session; + } + }, + events: { + async signOut({ token }) { + if (token?.sessionId) { + try { + await revokeSession(token.sessionId as string, token.accountId as string); + } catch (error) { + logger.error('Error terminating session on signOut:', error); + } + } + } + }, + pages: { + signIn: '/', + error: '/', + }, + session: { + strategy: "jwt" as const, + maxAge: 30 * 24 * 60 * 60, // 30 days (session lifetime) + }, + jwt: { + maxAge: 5 * 60, // 5 min (token lifetime) + }, + secret: process.env.NEXTAUTH_SECRET || "/JZ9N+lqRtvspbAfs0HK41RkthPYuUdqxb+cuimYOXw=", +}; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; \ No newline at end of file diff --git a/src/app/api/auth/check-session/route.ts b/src/app/api/auth/check-session/route.ts new file mode 100644 index 0000000..f01f762 --- /dev/null +++ b/src/app/api/auth/check-session/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { isSessionValid } from '@/lib/sessiondb'; +import logger from '@/lib/logger'; + +export async function GET() { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.accountId || !session?.user?.sessionId) { + return NextResponse.json({ + valid: false, + message: "No active session" + }, { status: 401 }); + } + + const isValid = await isSessionValid( + session.user.sessionId, + session.user.accountId + ); + + if (!isValid) { + logger.info('Session check failed - revoked or expired session', { + sessionId: session.user.sessionId, + accountId: session.user.accountId + }); + + return NextResponse.json({ + valid: false, + message: "Session has been revoked" + }, { status: 401 }); + } + + return NextResponse.json({ valid: true }); + } catch (error) { + logger.error('Error checking session:', error); + return NextResponse.json({ + valid: false, + message: "Error checking session" + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..21a04c9 --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createUser, isUserAdmin } from '@/lib/userdb'; +import logger from '@/lib/logger'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; + +export async function POST(req: NextRequest) { + try { + let is_admin; + logger.info('Registration request', { url: req.url }); + const session = await getServerSession(authOptions); + + try { + const contentType = req.headers.get('content-type'); + if (contentType && contentType.includes('application/json') && req.body) { + const body = await req.json(); + is_admin = body.is_admin; + } + } catch { + logger.info('Registration request failed due to missing parameters', { url: req.url }); + return NextResponse.json({ + message: "Missing required parameters", + success: false, + }, { status: 400 }); + } + + if (is_admin) { + const isAuthorized = await isUserAdmin(session?.user?.accountId); + + if (isAuthorized) { + const account = await createUser(is_admin); + logger.info('Account creation request succeeded (admin)', { is_admin, url: req.url }); + return NextResponse.json({ + message: "Admin registration successful", + success: true, + account_id: account.account_id, + }, { status: 200 }); + } else { + logger.info('Registration request failed due to missing rights', { is_admin, url: req.url }); + return NextResponse.json({ + message: "Unauthorized admin registration attempt", + success: false, + }, { status: 401 }); + } + } + + const account = await createUser(false); + + return NextResponse.json({ + message: "Registration successful", + success: true, + account_id: account.account_id, + }); + } catch (error) { + logger.error('Registration error:', { error, url: req.url }); + return NextResponse.json({ + message: "Registration failed", + success: false, + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/auth/remove/route.ts b/src/app/api/auth/remove/route.ts new file mode 100644 index 0000000..81ec850 --- /dev/null +++ b/src/app/api/auth/remove/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { removeUser, isUserAdmin } from '@/lib/userdb'; +import logger from '@/lib/logger'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; + +export async function DELETE(req: NextRequest) { + try { + let account_id; + logger.info('Account removal request', { url: req.url }); + const session = await getServerSession(authOptions); + + try { + const contentType = req.headers.get('content-type'); + if (contentType && contentType.includes('application/json') && req.body) { + const body = await req.json(); + account_id = body.account_id; + } + } catch { + logger.info('Account removal request failed due to missing parameters', { url: req.url }); + return NextResponse.json({ + message: "Missing required parameters", + success: false, + }, { status: 400 }); + } + + if (session?.user?.accountId === account_id || await isUserAdmin(session?.user?.accountId)) { + const accountRemovalResponse = await removeUser(account_id); + return NextResponse.json({ + message: accountRemovalResponse.status, + success: accountRemovalResponse.success, + }, { status: accountRemovalResponse.success ? 200 : 500 }); + } + + logger.info('Account removal request failed due to missing rights', { url: req.url }); + return NextResponse.json({ + message: "Unauthorized account removal attempt", + success: false, + }, { status: 401 }); + } catch (error) { + logger.error('Account removal error:', { error, url: req.url }); + return NextResponse.json({ + message: "Account removal failed", + success: false, + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/auth/sessions/revoke/route.ts b/src/app/api/auth/sessions/revoke/route.ts new file mode 100644 index 0000000..25e9f47 --- /dev/null +++ b/src/app/api/auth/sessions/revoke/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { revokeSession } from '@/lib/sessiondb'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { signOut } from 'next-auth/react'; +import logger from '@/lib/logger'; + +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.accountId) { + return NextResponse.json({ + message: "Unauthorized", + success: false, + }, { status: 401 }); + } + + const { sessionId } = await req.json(); + + if (!sessionId) { + return NextResponse.json({ + message: "Session ID is required", + success: false, + }, { status: 400 }); + } + + const result = await revokeSession(sessionId, session.user.accountId); + + const isCurrentSession = sessionId === session.user.sessionId; + if (isCurrentSession) { + signOut({ redirect: false }); + } + + return NextResponse.json({ + ...result, + isCurrentSession, + }); + } catch (error) { + logger.error('Error revoking session:', { error }); + return NextResponse.json({ + message: "Failed to revoking session", + success: false, + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/auth/sessions/route.ts b/src/app/api/auth/sessions/route.ts new file mode 100644 index 0000000..f66ade2 --- /dev/null +++ b/src/app/api/auth/sessions/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { getSessions } from '@/lib/sessiondb'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import logger from '@/lib/logger'; + +export async function GET() { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.accountId) { + return NextResponse.json({ + message: "Unauthorized", + success: false, + }, { status: 401 }); + } + + const sessions = await getSessions(session.user.accountId); + + // Mark the current session + const currentSessionId = session.user.sessionId; + const sessionsWithCurrent = sessions.map(s => ({ + ...s, + isCurrentSession: s.id === currentSessionId + })); + + return NextResponse.json({ + sessions: sessionsWithCurrent, + success: true, + }); + } catch (error) { + logger.error('Error getting sessions:', { error }); + return NextResponse.json({ + message: "Failed to retrieve sessions", + success: false, + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/link/route.ts b/src/app/api/link/route.ts new file mode 100644 index 0000000..d65be89 --- /dev/null +++ b/src/app/api/link/route.ts @@ -0,0 +1,252 @@ +import { NextRequest, NextResponse } from 'next/server'; +import logger from '@/lib/logger'; +import { isValidUrl, sanitizeMongoDocument } from '@/lib/utils'; +import { getLinkById, createLink, editLink, removeLink } from '@/lib/linkdb'; +import { removeAllAnalytics } from '@/lib/analyticsdb'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; + +// View link +// Example: /api/link?shortId=SHORT_ID +export async function GET(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.accountId) { + logger.info('Link retrieval request failed due to unauthorized access', { url: req.url }); + return NextResponse.json({ + message: "Unauthorized. Please sign in first.", + success: false, + }, { status: 401 }); + } + + const account_id = session.user.accountId; + + const url = new URL(req.url); + const shortId = url.searchParams.get('shortId'); + + if (!shortId) { + logger.info('Link retrieval request failed due to missing shortId', { account_id, url: req.url }); + return NextResponse.json({ + message: "Missing shortId parameter", + success: false, + }, { status: 400 }); + } + + const { link, return: returnValue } = await getLinkById(account_id, shortId); + + if (returnValue.success && link) { + return NextResponse.json({ + message: "Link retrieved successfully", + success: true, + link: sanitizeMongoDocument(link), + }, { status: 200 }); + } + + logger.info('Link retrieval request failed', { error: returnValue.status, account_id, shortId, url: req.url }); + return NextResponse.json({ + message: returnValue.status || "Link not found", + success: false, + }, { status: 404 }); + } catch (error) { + logger.error('Link retrieval error:', { error, url: req.url }); + return NextResponse.json({ + message: "Failed to retrieve link", + success: false, + }, { status: 500 }); + } +} + +// Create link +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.accountId) { + logger.info('Link creation request failed due to unauthorized access', { url: req.url }); + return NextResponse.json({ + message: "Unauthorized. Please sign in first.", + success: false, + }, { status: 401 }); + } + + const account_id = session.user.accountId; + let target_url; + + try { + const contentType = req.headers.get('content-type'); + if (contentType && contentType.includes('application/json') && req.body) { + const body = await req.json(); + target_url = body.target_url; + } + } catch { + logger.info('Link creation request failed due to missing parameters', { url: req.url }); + return NextResponse.json({ + message: "Missing required parameters", + success: false, + }, { status: 400 }); + } + + if (!target_url || !isValidUrl(target_url)) { + logger.info('Link creation request failed due to invalid URL', { account_id, url: req.url }); + return NextResponse.json({ + message: "Invalid URL. Please provide a valid URL with http:// or https://", + success: false, + }, { status: 400 }); + } + + const { shortId, return: returnValue } = await createLink(account_id, target_url); + + if(returnValue.success) { + return NextResponse.json({ + message: "Link Creation succeeded", + success: true, + shortId, + }, { status: 200 }); + } + + logger.error('Link creation request failed', { error: returnValue.status, account_id, url: req.url }); + return NextResponse.json({ + message: returnValue.status || "Link creation failed", + success: false, + }, { status: 422 }); + } catch (error) { + logger.error('Link creation error:', { error, url: req.url }); + return NextResponse.json({ + message: "Link creation failed", + success: false, + }, { status: 500 }); + } +} + +// Edit link +export async function PATCH(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.accountId) { + logger.info('Link edit request failed due to unauthorized access', { url: req.url }); + return NextResponse.json({ + message: "Unauthorized. Please sign in first.", + success: false, + }, { status: 401 }); + } + + const account_id = session.user.accountId; + let shortId, target_url; + + try { + const contentType = req.headers.get('content-type'); + if (contentType && contentType.includes('application/json') && req.body) { + const body = await req.json(); + shortId = body.shortId; + target_url = body.target_url; + } + } catch { + logger.info('Link edit request failed due to missing parameters', { url: req.url }); + return NextResponse.json({ + message: "Missing required parameters", + success: false, + }, { status: 400 }); + } + + if (!shortId) { + logger.info('Link edit request failed due to missing shortId', { account_id, url: req.url }); + return NextResponse.json({ + message: "Missing shortId parameter", + success: false, + }, { status: 400 }); + } + + if (!target_url || !isValidUrl(target_url)) { + logger.info('Link edit request failed due to invalid URL', { account_id, url: req.url }); + return NextResponse.json({ + message: "Invalid URL. Please provide a valid URL with http:// or https://", + success: false, + }, { status: 400 }); + } + + const returnValue = await editLink(account_id, shortId, target_url); + + if(returnValue.success) { + return NextResponse.json({ + message: "Link updated successfully", + success: true, + }, { status: 200 }); + } + + logger.error('Link edit request failed', { error: returnValue.status, account_id, shortId, url: req.url }); + return NextResponse.json({ + message: returnValue.status || "Link update failed", + success: false, + }, { status: 422 }); + } catch (error) { + logger.error('Link edit error:', { error, url: req.url }); + return NextResponse.json({ + message: "Link update failed", + success: false, + }, { status: 500 }); + } +} + +// Delete link +export async function DELETE(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.accountId) { + logger.info('Link removal request failed due to unauthorized access', { url: req.url }); + return NextResponse.json({ + message: "Unauthorized. Please sign in first.", + success: false, + }, { status: 401 }); + } + + const account_id = session.user.accountId; + let shortId; + + try { + const contentType = req.headers.get('content-type'); + if (contentType && contentType.includes('application/json') && req.body) { + const body = await req.json(); + shortId = body.shortId; + } + } catch { + logger.info('Link removal request failed due to missing parameters', { url: req.url }); + return NextResponse.json({ + message: "Missing required parameters", + success: false, + }, { status: 400 }); + } + + if (!shortId) { + logger.info('Link removal request failed due to missing shortId', { account_id, url: req.url }); + return NextResponse.json({ + message: "Missing shortId parameter", + success: false, + }, { status: 400 }); + } + + await removeAllAnalytics(account_id, shortId); + const returnValue = await removeLink(account_id, shortId); + + if(returnValue.success) { + return NextResponse.json({ + message: "Link removal succeeded", + success: true, + }, { status: 200 }); + } + + logger.error('Link removal request failed', { error: returnValue.status, account_id, url: req.url }); + return NextResponse.json({ + message: returnValue.status || "Link removal failed", + success: false, + }, { status: 422 }); + } catch (error) { + logger.error('Link removal error:', { error, url: req.url }); + return NextResponse.json({ + message: "Link removal failed", + success: false, + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/links/route.ts b/src/app/api/links/route.ts new file mode 100644 index 0000000..da8cda1 --- /dev/null +++ b/src/app/api/links/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getLinks } from '@/lib/linkdb'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { sanitizeMongoDocument } from '@/lib/utils'; +import logger from '@/lib/logger'; + +// Get all links +export async function GET(req: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.accountId) { + logger.info('Links retrieval request failed due to unauthorized access', { url: req.url }); + return NextResponse.json({ + message: "Unauthorized. Please sign in first.", + success: false, + }, { status: 401 }); + } + + const account_id = session.user.accountId; + const { links, return: returnValue } = await getLinks(account_id); + + if (returnValue.success) { + return NextResponse.json({ + message: "Links retrieved successfully", + success: true, + links: links.map(link => sanitizeMongoDocument(link)), + }, { status: 200 }); + } + + logger.info('Links retrieval request failed', { error: returnValue.status, account_id, url: req.url }); + return NextResponse.json({ + message: returnValue.status || "Failed to retrieve links", + success: false, + }, { status: 404 }); + } catch (error) { + logger.error('Links retrieval error:', { error, url: req.url }); + return NextResponse.json({ + message: "Failed to retrieve links", + success: false, + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/statistics/route.ts b/src/app/api/statistics/route.ts new file mode 100644 index 0000000..042d931 --- /dev/null +++ b/src/app/api/statistics/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; +import { getAllStats, updateStats } from '@/lib/statisticsdb'; + +export async function GET() { + try { + let stats = await getAllStats(); + + if (!stats || !stats.last_updated || + (new Date().getTime() - new Date(stats.last_updated).getTime() > 5 * 60 * 1000)) { // 5min + await updateStats(); + stats = await getAllStats(); + } + + if (!stats) { + return NextResponse.json({ + message: "Failed to retrieve statistics", + success: false, + }, { status: 500 }); + } + + return NextResponse.json({ + message: "Statistics retrieved successfully", + success: true, + stats, + }); + } catch (error) { + console.error('Statistics retrieval error:', error); + return NextResponse.json({ + message: "An error occurred while retrieving statistics", + success: false, + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/dashboard/Dashboard.module.css b/src/app/dashboard/Dashboard.module.css new file mode 100644 index 0000000..a56b687 --- /dev/null +++ b/src/app/dashboard/Dashboard.module.css @@ -0,0 +1,163 @@ +.dashboardContainer { + max-width: 75rem; + width: 100%; + margin: 0 auto; + padding: 2rem 1rem; +} + +.dashboardHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.dashboardTitle { + font-size: 2rem; + color: var(--text-primary); + margin: 0; +} + +.actionButtons { + display: flex; + gap: 1rem; +} + +.securityButton, .adminButton { + padding: 0.5rem 1rem; + border-radius: 4px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: none; +} + +.securityButton { + background-color: var(--accent); + color: white; +} + +.adminButton { + background-color: #8b5cf6; + color: white; +} + +.securityButton:hover, .adminButton:hover { + opacity: 0.9; + transform: translateY(-2px); +} + +.urlShortener { + background-color: var(--card-bg); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 2rem; + border: 1px solid var(--border-color); +} + +.createForm { + margin-top: 1rem; +} + +.inputGroup { + display: flex; + gap: 1rem; +} + +.urlInput { + flex: 1; + padding: 0.75rem 1rem; + border-radius: 4px; + border: 1px solid var(--border-color); + background-color: var(--input-bg); + color: var(--text-primary); + font-size: 1rem; +} + +.createButton { + padding: 0 1.5rem; + border-radius: 4px; + border: none; + background-color: var(--accent); + color: white; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.createButton:hover { + background-color: var(--accent-hover); +} + +.createButton:disabled { + background-color: var(--accent-disabled); + cursor: not-allowed; +} + +.linksSection { + background-color: var(--card-bg); + border-radius: 8px; + padding: 1.5rem; + border: 1px solid var(--border-color); +} + +.loading { + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + color: var(--text-secondary); + font-size: 1rem; +} + +.noLinks { + text-align: center; + padding: 2rem; + color: var(--text-secondary); + font-style: italic; +} + +@media (max-width: 1200px) { + .dashboardContainer { + max-width: 100%; + padding: 1.5rem; + } +} + +@media (max-width: 768px) { + .inputGroup { + flex-direction: column; + } + + .dashboardHeader { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .actionButtons { + width: 100%; + justify-content: flex-start; + } + + .urlShortener, + .linksSection { + padding: 1rem; + } +} + +@media (max-width: 480px) { + .dashboardContainer { + padding: 1rem 0.5rem; + } + + .dashboardTitle { + font-size: 1.5rem; + } + + .securityButton, + .adminButton { + padding: 0.4rem 0.8rem; + font-size: 0.9rem; + } +} \ No newline at end of file diff --git a/src/app/dashboard/link/[shortId]/LinkDetail.module.css b/src/app/dashboard/link/[shortId]/LinkDetail.module.css new file mode 100644 index 0000000..b15018b --- /dev/null +++ b/src/app/dashboard/link/[shortId]/LinkDetail.module.css @@ -0,0 +1,280 @@ +.container { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 2rem 1rem; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.backLink { + padding: 0.5rem 1rem; + border-radius: 4px; + background-color: var(--accent); + color: var(--text-primary); + text-decoration: none; + font-weight: 500; + transition: all 0.2s ease; +} + +.backLink:hover { + opacity: 0.9; + transform: translateY(-2px); +} + +.title { + font-size: 2rem; + margin: 0; + color: var(--text-primary); +} + +.linkInfo { + margin-bottom: 2rem; +} + +.linkCard { + background-color: var(--card-bg); + border-radius: 0.5rem; + padding: 1.5rem; + box-shadow: var(--card-shadow); +} + +.linkCard h2 { + margin-top: 0; + margin-bottom: 1.5rem; + color: var(--text-primary); + font-size: 1.5rem; +} + +.linkDetails { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.linkDetailItem { + display: flex; + flex-direction: column; +} + +.label { + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 0.25rem; +} + +.value { + color: var(--text-primary); + word-break: break-all; +} + +.shortUrl { + color: var(--primary-color); + word-break: break-all; + text-decoration: none; +} + +.shortUrl:hover { + text-decoration: underline; +} + +.defaultButton { + background-color: transparent; + color: var(--primary-color); + border: 1px solid var(--primary-color); + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + cursor: pointer; +} + +.defaultButton:hover { + background-color: var(--primary-color); + color: white; +} + +.targetUrlSection { + margin-top: 0.5rem; +} + +.targetUrlHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.targetUrl { + color: var(--text-primary); + word-break: break-all; + text-decoration: none; +} + +.targetUrl:hover { + text-decoration: underline; + color: var(--primary-color); +} + +.editForm { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.urlInput { + width: 100%; + padding: 0.5rem; + border: 1px solid var(--border-color); + border-radius: 0.25rem; + background-color: var(--input-bg); + color: var(--text-primary); +} + +.editActions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +.cancelButton { + background-color: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-color); + border-radius: 0.25rem; + padding: 0.5rem 0.75rem; + cursor: pointer; +} + +.cancelButton:hover { + background-color: var(--border-color); +} + +.saveButton { + background-color: var(--primary-color); + color: white; + border: none; + border-radius: 0.25rem; + padding: 0.5rem 0.75rem; + cursor: pointer; +} + +.saveButton:hover { + background-color: var(--primary-dark); +} + +.analyticsSection { + margin-top: 2rem; +} + +.analyticsHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 1rem; +} + +.analyticsHeader h2 { + margin: 0; + color: var(--text-primary); + font-size: 1.5rem; +} + +.totalClicks { + color: var(--text-secondary); + font-weight: 600; +} + +.deleteAllButton { + background-color: transparent; + color: var(--primary-color); + border: 1px solid var(--primary-color); + border-radius: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: 1rem; + cursor: pointer; +} + +.deleteAllButton:hover { + background-color: var(--primary-color); + color: white; +} + +.graphs { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.graphCard { + background-color: var(--card-bg); + border-radius: 0.5rem; + padding: 1rem; + box-shadow: var(--card-shadow); +} + +.graphCard h3 { + margin-top: 0; + margin-bottom: 1rem; + color: var(--text-primary); + font-size: 1rem; + text-align: center; +} + +.noAnalytics { + display: flex; + justify-content: center; + align-items: center; + background-color: var(--card-bg); + border-radius: 0.5rem; + padding: 2rem; + box-shadow: var(--card-shadow); +} + +.noAnalytics p { + color: var(--text-secondary); + font-size: 1rem; + text-align: center; +} + +.loadingContainer { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 300px; + gap: 1rem; +} + +.loader { + border: 4px solid rgba(0, 0, 0, 0.1); + border-left-color: var(--primary-color); + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 768px) { + .analyticsHeader { + flex-direction: column; + align-items: flex-start; + } + + .graphs { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/src/app/dashboard/link/[shortId]/page.tsx b/src/app/dashboard/link/[shortId]/page.tsx new file mode 100644 index 0000000..d695e88 --- /dev/null +++ b/src/app/dashboard/link/[shortId]/page.tsx @@ -0,0 +1,545 @@ +"use client"; + +import { useState, useEffect, useRef } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { useToast } from '@/contexts/ToastContext'; +import Graph, { StatItem } from '@/components/ui/Graph'; +import ConfirmModal from '@/components/ui/ConfirmModal'; +import AnalyticsTable from '@/components/ui/dashboard/link/AnalyticsTable'; +import styles from './LinkDetail.module.css'; +import { Analytics } from '@/types/analytics'; +import { Link as LinkType } from '@/types/link'; + +export default function LinkDetailPage() { + const params = useParams(); + const router = useRouter(); + const { showToast } = useToast(); + const shortId = params.shortId as string; + + const [link, setLink] = useState(null); + const [targetUrl, setTargetUrl] = useState(''); + const [isEditing, setIsEditing] = useState(false); + const inputRef = useRef(null); + const [isLoading, setIsLoading] = useState(true); + const [allAnalytics, setAllAnalytics] = useState([]); + const [analytics, setAnalytics] = useState([]); + const [totalAnalytics, setTotalAnalytics] = useState(0); + const [page, setPage] = useState(1); + const [limit] = useState(15); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showDeleteAllModal, setShowDeleteAllModal] = useState(false); + const [analyticsToDelete, setAnalyticsToDelete] = useState(''); + const [isLoadingStats, setIsLoadingStats] = useState(true); + const isRedirecting = useRef(false); + + // Stats data + const [browserStats, setBrowserStats] = useState([]); + const [osStats, setOsStats] = useState([]); + const [countryStats, setCountryStats] = useState([]); + const [ipVersionStats, setIpVersionStats] = useState([]); + + function isValidUrl(urlStr: string) : boolean { + if(urlStr.trim() === "") { + return false; + } + + try { + const parsedUrl = new URL(urlStr); + return parsedUrl.protocol !== "" && parsedUrl.hostname !== ""; + } catch { + return false; + } + } + + useEffect(() => { + if (isEditing && inputRef.current) { + setTimeout(() => { + inputRef.current?.focus(); + }, 10); + } + }, [isEditing]); + + useEffect(() => { + if (isRedirecting.current) return; + + async function fetchLinkData() { + try { + const response = await fetch(`/api/link?shortId=${shortId}`); + if (!response.ok) { + showToast('Failed to load link details', 'error'); + isRedirecting.current = true; + router.push('/dashboard'); + return; + } + + const data = await response.json(); + if (data.success && data.link) { + setLink(data.link); + setTargetUrl(data.link.target_url); + } else { + showToast(data.message || 'Link not found', 'error'); + isRedirecting.current = true; + router.push('/dashboard'); + } + } catch { + showToast('An error occurred while loading link details', 'error'); + isRedirecting.current = true; + router.push('/dashboard'); + } finally { + setIsLoading(false); + } + } + + fetchLinkData(); + }, [shortId, router, showToast]); + + useEffect(() => { + if (!link) return; + + async function fetchAllAnalytics() { + try { + const response = await fetch(`/api/analytics?link_id=${shortId}&all=true`); + if (!response.ok) { + showToast('Failed to load complete analytics data', 'error'); + return; + } + + const data = await response.json(); + if (data.success) { + setAllAnalytics(data.analytics); + setTotalAnalytics(data.pagination.total); + } + } catch { + showToast('An error occurred while loading complete analytics data', 'error'); + } + } + + fetchAllAnalytics(); + }, [link, shortId, showToast]); + + useEffect(() => { + if (!link) return; + + async function fetchPaginatedAnalytics() { + try { + const response = await fetch(`/api/analytics?link_id=${shortId}&page=${page}&limit=${limit}`); + if (!response.ok) { + showToast('Failed to load analytics page', 'error'); + return; + } + + const data = await response.json(); + if (data.success) { + setAnalytics(data.analytics); + } + } catch { + showToast('An error occurred while loading analytics page', 'error'); + } + } + + fetchPaginatedAnalytics(); + }, [link, shortId, page, limit, showToast]); + + useEffect(() => { + if (!link || allAnalytics.length === 0) return; + + async function generateStats() { + setIsLoadingStats(true); + try { + // Browser stats + const browsers = allAnalytics.reduce((acc: Record, item) => { + const browser = item.browser || 'Unknown'; + acc[browser] = (acc[browser] || 0) + 1; + return acc; + }, {}); + + // OS stats + const oses = allAnalytics.reduce((acc: Record, item) => { + const os = item.platform || 'Unknown'; + acc[os] = (acc[os] || 0) + 1; + return acc; + }, {}); + + // Country stats + const countries = allAnalytics.reduce((acc: Record, item) => { + const country = item.country || 'Unknown'; + acc[country] = (acc[country] || 0) + 1; + return acc; + }, {}); + + // IP version stats + const ipVersions = allAnalytics.reduce((acc: Record, item) => { + const ipVersion = item.ip_version || 'Unknown'; + acc[ipVersion] = (acc[ipVersion] || 0) + 1; + return acc; + }, {}); + + // Convert to StatItem[] and sort by count + setBrowserStats(Object.entries(browsers) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count)); + + setOsStats(Object.entries(oses) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count)); + + setCountryStats(Object.entries(countries) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count)); + + setIpVersionStats(Object.entries(ipVersions) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count)); + } catch { + showToast('An error occurred while processing analytics data', 'error'); + } finally { + setIsLoadingStats(false); + } + } + + generateStats(); + }, [allAnalytics, link, showToast]); + + const handlePageChange = (newPage: number) => { + setPage(newPage); + }; + + const handleEditLink = async () => { + if (!isValidUrl(targetUrl)) { + showToast('Please enter a valid URL', 'error'); + return; + } + + try { + const response = await fetch(`/api/link`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + shortId: shortId, + target_url: targetUrl + }), + }); + + const data = await response.json(); + + if (data.success) { + showToast('Link updated successfully', 'success'); + setIsEditing(false); + if (link) { + setLink({ + ...link, + target_url: targetUrl, + last_modified: new Date() + }); + } + } else { + showToast(data.message || 'Failed to update link', 'error'); + } + } catch { + showToast('An error occurred while updating the link', 'error'); + } + }; + + const handleDeleteAnalytics = async () => { + try { + const response = await fetch('/api/analytics', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + link_id: shortId, + analytics_id: analyticsToDelete + }), + }); + + const data = await response.json(); + + if (data.success) { + showToast('Analytics entry deleted successfully', 'success'); + + setAnalytics(analytics.filter(item => item._id?.toString() !== analyticsToDelete)); + + setTotalAnalytics(prev => prev - 1); + } else { + showToast(data.message || 'Failed to delete analytics entry', 'error'); + } + } catch { + showToast('An error occurred while deleting the analytics entry', 'error'); + } finally { + setShowDeleteModal(false); + setAnalyticsToDelete(''); + } + }; + + const handleDeleteAllAnalytics = async () => { + try { + const response = await fetch('/api/analytics', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + link_id: shortId, + delete_all: true + }), + }); + + const data = await response.json(); + + if (data.success) { + showToast('All analytics entries deleted successfully', 'success'); + + setAnalytics([]); + setTotalAnalytics(0); + setBrowserStats([]); + setOsStats([]); + setCountryStats([]); + setIpVersionStats([]); + } else { + showToast(data.message || 'Failed to delete all analytics entries', 'error'); + } + } catch { + showToast('An error occurred while deleting all analytics entries', 'error'); + } finally { + setShowDeleteAllModal(false); + } + }; + + if (isLoading) { + return ( +
+
+

Loading link details...

+
+ ); + } + + return ( +
+
+

Link Details

+ + Back to Dashboard + +
+ +
+
+

Link Information

+
+
+ Short ID: + {shortId} +
+ +
+ + +
+ +
+ Created: + + {link ? new Date(link.created_at).toLocaleString() : ''} + +
+ +
+ Last Modified: + + {link ? new Date(link.last_modified).toLocaleString() : ''} + +
+ +
+
+ Target URL: + {!isEditing && ( + + )} +
+ + {isEditing ? ( +
{ + e.preventDefault(); + handleEditLink(); + }} + className={styles.editForm} + > + setTargetUrl(e.target.value)} + className={styles.urlInput} + placeholder="https://example.com" + /> +
+ + +
+
+ ) : ( + + {link?.target_url} + + )} +
+
+
+
+ +
+
+

Analytics

+ + Total Clicks: {totalAnalytics} + + {totalAnalytics > 0 && ( + + )} +
+ + {totalAnalytics > 0 ? ( + <> +
+
+

Browsers

+ +
+ +
+

Operating Systems

+ +
+ +
+

Countries

+ +
+ +
+

IP Versions

+ +
+
+ + { + setAnalyticsToDelete(id); + setShowDeleteModal(true); + }} + /> + + ) : ( +
+

No clicks recorded yet for this link.

+
+ )} +
+ + {/* Confirm Delete Modal */} + { + setShowDeleteModal(false); + setAnalyticsToDelete(''); + }} + /> + + {/* Confirm Delete All Modal */} + setShowDeleteAllModal(false)} + /> +
+ ); +} \ No newline at end of file diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..d70f118 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { useSession } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import LinkTable from '@/components/ui/dashboard/LinkTable'; +import styles from './Dashboard.module.css'; +import { useToast } from '@/contexts/ToastContext'; + +export default function Dashboard() { + const { data: session, status } = useSession(); + const router = useRouter(); + const { showToast } = useToast(); + const [links, setLinks] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (status === "unauthenticated") { + router.push('/'); + } + }, [status, router]); + + useEffect(() => { + if (session?.user?.accountId) { + fetchLinks(); + } + }, [session]); + + const fetchLinks = async () => { + try { + setLoading(true); + const response = await fetch('/api/links'); + + if (!response.ok) { + throw new Error('Failed to fetch links'); + } + + const data = await response.json(); + if (data.success) { + setLinks(data.links); + } else { + showToast(data.message || 'Failed to load links', 'error'); + } + } catch (error) { + console.error('Error fetching links:', error); + showToast('Failed to load links', 'error'); + } finally { + setLoading(false); + } + }; + + if (status === "loading") { + return
Loading...
; + } + + if (status === "unauthenticated") { + return null; + } + + return ( +
+
+

Dashboard

+
+ + + + + {session?.user?.isAdmin && ( + + + + )} +
+
+ +
+

Create New Short Link

+ +
+ +
+

Your Shortened Links

+ {loading ? ( +
Loading your links...
+ ) : links.length === 0 ? ( +
+ You haven't created any links yet. Create your first short link above! +
+ ) : ( + + )} +
+
+ ); +} + +interface CreateLinkFormProps { + onLinkCreated: () => void; +} + +function CreateLinkForm({ onLinkCreated }: CreateLinkFormProps) { + const [url, setUrl] = useState(''); + const [creating, setCreating] = useState(false); + const { showToast } = useToast(); + + const handleSubmit = async (e: { preventDefault: () => void; }) => { + e.preventDefault(); + + if (!url.trim()) { + showToast('Please enter a URL', 'error'); + return; + } + + try { + setCreating(true); + const response = await fetch('/api/link', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ target_url: url }), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + showToast('Link created successfully!', 'success'); + setUrl(''); + if (onLinkCreated) onLinkCreated(); + } else { + showToast(data.message || 'Failed to create link', 'error'); + } + } catch (error) { + console.error('Error creating link:', error); + showToast('Failed to create link', 'error'); + } finally { + setCreating(false); + } + }; + + return ( +
+
+ setUrl(e.target.value)} + placeholder="Enter URL to shorten" + className={styles.urlInput} + required + /> + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/dashboard/security/Security.module.css b/src/app/dashboard/security/Security.module.css new file mode 100644 index 0000000..2c95f57 --- /dev/null +++ b/src/app/dashboard/security/Security.module.css @@ -0,0 +1,120 @@ +.container { + max-width: 75rem; + width: 100%; + margin: 0 auto; + padding: 2rem 1rem; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.header h1 { + font-size: 2rem; + color: var(--text-primary); + margin: 0; +} + +.backLink { + padding: 0.5rem 1rem; + border-radius: 4px; + background-color: var(--accent); + color: var(--text-primary); + text-decoration: none; + font-weight: 500; + transition: all 0.2s ease; +} + +.backLink:hover { + opacity: 0.9; + transform: translateY(-2px); +} + +.content { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.sessionSection, +.dangerSection { + background-color: var(--card-bg); + border-radius: 8px; + padding: 1.5rem; + border: 1px solid var(--border-color); +} + +.sessionSection h2, +.dangerSection h2 { + font-size: 1.6rem; + margin: 0 0 1.5rem 0; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border-color); + font-weight: 600; +} + +.dangerSection h2 { + color: #dc3545; + border-bottom-color: rgba(220, 53, 69, 0.3); +} + +.sessionSection { + margin-bottom: 1rem; +} + +.dangerCard { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + background-color: rgba(220, 53, 69, 0.05); + border: 1px solid rgba(220, 53, 69, 0.2); + border-radius: 4px; +} + +.dangerInfo h3 { + margin-top: 0; + color: #dc3545; +} + +.dangerInfo p { + color: var(--text-secondary); + margin-bottom: 0; +} + +.deleteAccountBtn { + padding: 0.5rem 1rem; + background-color: #dc3545; + color: white; + border: none; + border-radius: 4px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.deleteAccountBtn:hover { + background-color: #c82333; +} + +/* Responsive styles */ +@media (max-width: 768px) { + .header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .dangerCard { + flex-direction: column; + gap: 1.5rem; + align-items: flex-start; + } + + .deleteAccountBtn { + width: 100%; + } +} \ No newline at end of file diff --git a/src/app/dashboard/security/page.tsx b/src/app/dashboard/security/page.tsx new file mode 100644 index 0000000..fb34cc8 --- /dev/null +++ b/src/app/dashboard/security/page.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useState } from 'react'; +import { useSession, signOut } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { useToast } from '@/contexts/ToastContext'; +import SessionManager from '@/components/ui/dashboard/SessionManager'; +import ConfirmModal from '@/components/ui/ConfirmModal'; +import styles from './Security.module.css'; + +export default function SecurityPage() { + const router = useRouter(); + const { data: session } = useSession(); + const { showToast } = useToast(); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const handleAccountDeletion = async () => { + try { + setIsDeleting(true); + + const response = await fetch('/api/auth/remove', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ account_id: session?.user?.accountId }), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + showToast('Account deleted successfully', 'success'); + + await signOut({ redirect: false }); + router.push('/'); + } else { + showToast(data.message || 'Failed to delete account', 'error'); + setIsDeleteModalOpen(false); + } + } catch (error) { + console.error('Error deleting account:', error); + showToast('Failed to delete account', 'error'); + setIsDeleteModalOpen(false); + } finally { + setIsDeleting(false); + } + }; + + return ( +
+
+

Security Settings

+ + Back to Dashboard + +
+ +
+
+ +
+ +
+

Danger Zone

+
+
+

Delete Account

+

+ This will permanently delete your account and all associated data. + This action cannot be undone. +

+
+ +
+
+
+ + setIsDeleteModalOpen(false)} + /> +
+ ); +} \ No newline at end of file diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ff8fc09740fe852a7bee90f95fcb96db1c4ac24f GIT binary patch literal 41662 zcmeI5OOF&)6vyu{gdu7e9^#VbnYbXXhz1t+1UEYedQTPP33ltwHF&bAU6QT(*nhc7`Bu=LMom*Aicbe|1d%LS{Kk6Plbe+1V&g1{D z>Y}P{mrBj>=j2Jn-#gW(pD6X5QtEk>Rq8G(>qQnmRBF!)vFa7!uYf>6ARrJB2nYlO z0s;YnfIvVXAP^7;2$Y0C6h*V>OXw-IfnG(gqnFWh=uvc2N$Eq_1OYHUg5E%v&^-AM zhdbz5^r--1aR`8?@Nf?uRQ_PY3+RU8s;XWj@LP|*ixwq65NLPxkQ}~aaNCB;_f66A z7XcTBudq51z)QHeUo;U$ynk6fTpdIQ?-ZQM<|3sfaFRolM=k;<<#F*l;C_XEf&PN7 zpe_=QlP+!mZv*Ob?*1G9OX%LNP>OY1&=aU|V=I9Z14ux7-xfIhenR&doA+GAXCwMC zYAb;gkOVBbw_z*tZ?0`U3FxyD|0}k^@vA&1VDOzKUc$w*ePGXA&35lt<`6gGjxp^vJ%5hoiJUK-zN7l#MjRJTZuW~ajqAZi?oB;H zvqZ*o&)qKg5YLvAM=o(E-4|RAW)YE$T`Z$dOHQiB#~BMgc9p@0#I)d~YTs9H&4=$wWW<1(O9-da#wDI3dYi+eAb7O*$us>D>z_~Qkw6*%#Uwq35ki5*Tpy#gmb(YDJ4C#7C9 z5`PYrYeczLlxs%0cHCoEUB`=Us&vih%E?FIlY0%0&puS{opg&_pC0OAt->1mE$l2~~^K7;WoB%?G$UI2l<~AzNZXckwaxf47UgHE0 zoY*4l=6Ado-Ztw3CvHGndOz_#0vj$;p8+^oNVlD|PkMDd$6Hd+cDq#sCXSrQoR6JF zUoEqK;@ff}?}L20%vyZJrU#iXEOWT!Iy>*T4!A@zr{ObH z-Xk&NsYY8)&ibgwR}S#qj2=VJqw=g(-rsu-ZK7vTnd`9LS3PDP^2+^#Jh@|T6PC+E z03zi3HShgh;rD#yQSd}$_&9;)$O+)b1A`Mm0*j&aEW2K&)| zb0dfP{fSMFM>59#WLO`K`*q3N4x<9$9$zSu=a2J%F~*1DggC;*eIs7h;*n9|GRnLF;^nsv~b>KPw(3ro-k24_8hE&tc}=$@U_0o-gFR>BC|$Zrg*)Ae;!MgG%5cXD0wy zUz`Lka=1u`J#Z4ZNWr%Jgp)Go+=cUnB@d<>oa{htujO#f6`AW2{vBn4?6?em6EWw` zv8{=BhnKORb2srI57+?QY(~d&&fS15h0Ov20fB%(Kp-Fx5C{ka1Ofs9fq+0jV1f|f znzSuT)ZI2%7?G{&NfB}W6(uk25sB?3H4~eO-D(#dk2jTS#164dQ+2~$#jY9l3iiBV zFJmVO_4Z3@hmyB3BZ=K;CIx7FQAPaM8DE^iuhFqvv(a)=fc9_AMJuM=oR3!5*tIC? zwwv`%JXvma*^7;}}IX)PyH0E5sCX@5zN}-^ba5! z0X;6@v|+&6omhuP}s7mUw&>cCaO(9*-Dm&g*?3wwtry(UJmUyEPLn zHZ%pXt=Z8O#I_!iEQ!A!k}Qd>ha^j4>!FA&iLHksvZS^Q>3txMuZI|Gq~jE@*pMvA z_>!u(>XId~m({$gNtVQxJ+dZQ5K6)({xmxf(21wPX>{ymRWtD8r(9~T*}+r5QL2MC&0u% logger.error('Failed to save analytics', { error: err, shortId })); + + return NextResponse.redirect(new URL(link.target_url)); + } catch (error) { + logger.error('Link redirection error', { error }); + return NextResponse.redirect(new URL('/error', req.url)); + } +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..7e6de4e --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,52 @@ +import Providers from '@/components/Providers'; +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import Header from "@/components/header"; +import Footer from "@/components/footer"; +import { ToastProvider } from '@/contexts/ToastContext'; +import Toast from '@/components/ui/Toast'; +import ResponsiveLayout from '@/components/ResponsiveLayout'; +import SessionMonitor from '@/components/SessionMonitor'; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "µLinkShortener", + description: "Create short links and see who accessed them!", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + +
+
+ {children} +
+
- + {/* Confirm Delete Modal */} { setShowDeleteModal(false); setAnalyticsToDelete(''); }} /> - + {/* Confirm Delete All Modal */} setShowDeleteAllModal(false)} /> ); -} \ No newline at end of file +} diff --git a/src/app/admin/user/[accountId]/page.tsx b/src/app/admin/user/[accountId]/page.tsx index cb5b876..7f65384 100644 --- a/src/app/admin/user/[accountId]/page.tsx +++ b/src/app/admin/user/[accountId]/page.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import { useState, useEffect } from 'react'; import { useParams, useRouter } from 'next/navigation'; @@ -17,49 +17,49 @@ export default function UserDetailPage() { const { data: session, status } = useSession(); const { showToast } = useToast(); const accountId = params.accountId as string; - + const [user, setUser] = useState(null); const [links, setLinks] = useState([]); const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(true); const [revoking, setRevoking] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - + useEffect(() => { - if (status === "unauthenticated" || (status === "authenticated" && !session?.user?.isAdmin)) { + if (status === 'unauthenticated' || (status === 'authenticated' && !session?.user?.isAdmin)) { router.push('/dashboard'); } }, [status, session, router]); - + useEffect(() => { - if (status === "authenticated" && session?.user?.isAdmin) { + if (status === 'authenticated' && session?.user?.isAdmin) { fetchUserData(); } }, [status, session, accountId]); - + const fetchUserData = async () => { try { setLoading(true); const userResponse = await fetch(`/api/admin/users/${accountId}`); - + if (!userResponse.ok) { throw new Error('Failed to fetch user details'); } - + const userData = await userResponse.json(); if (userData.success) { setUser(userData.user); - + const linksResponse = await fetch(`/api/admin/users/${accountId}/links`); const linksData = await linksResponse.json(); - + if (linksResponse.ok && linksData.success) { setLinks(linksData.links); } - + const sessionsResponse = await fetch(`/api/admin/users/${accountId}/sessions`); const sessionsData = await sessionsResponse.json(); - + if (sessionsResponse.ok && sessionsData.success) { setSessions(sessionsData.sessions); } @@ -75,24 +75,22 @@ export default function UserDetailPage() { setLoading(false); } }; - + const handleRevokeSession = async (sessionId: string) => { try { setRevoking(sessionId); - + const response = await fetch(`/api/admin/users/${accountId}/sessions/revoke`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId, accountId }) + body: JSON.stringify({ sessionId, accountId }), }); - + const data = await response.json(); - + if (response.ok && data.success) { showToast('Session revoked successfully', 'success'); - setSessions(prevSessions => - prevSessions.filter(s => s.id !== sessionId) - ); + setSessions(prevSessions => prevSessions.filter(s => s.id !== sessionId)); } else { showToast(data.message || 'Failed to revoke session', 'error'); } @@ -103,7 +101,7 @@ export default function UserDetailPage() { setRevoking(null); } }; - + const handleDeleteUser = async () => { try { const response = await fetch('/api/admin/users', { @@ -113,9 +111,9 @@ export default function UserDetailPage() { }, body: JSON.stringify({ account_id: accountId }), }); - + const data = await response.json(); - + if (response.ok && data.success) { showToast('User deleted successfully', 'success'); router.push('/admin'); @@ -134,34 +132,31 @@ export default function UserDetailPage() { const date = new Date(dateString); return date.toLocaleString(); }; - - if (status === "loading" || loading) { + + if (status === 'loading' || loading) { return
Loading user details...
; } - + if (!user) { return
User not found
; } - + return (

User Details

- + {!user.is_admin && ( - )}
- +

Account Information

@@ -179,7 +174,7 @@ export default function UserDetailPage() {
- +

User Links

{links.length === 0 ? ( @@ -188,7 +183,7 @@ export default function UserDetailPage() { )}
- +

Active Sessions

{sessions.length === 0 ? ( @@ -206,7 +201,7 @@ export default function UserDetailPage() { - {sessions.map((s) => ( + {sessions.map(s => ( {s.userAgent.split(' ').slice(0, 3).join(' ')} {s.ipAddress} @@ -228,14 +223,14 @@ export default function UserDetailPage() { )}
- - setIsDeleteModalOpen(false)} /> ); -} \ No newline at end of file +} diff --git a/src/app/api/admin/statistics/rebuild/route.ts b/src/app/api/admin/statistics/rebuild/route.ts index aba1e8c..34950e6 100644 --- a/src/app/api/admin/statistics/rebuild/route.ts +++ b/src/app/api/admin/statistics/rebuild/route.ts @@ -7,25 +7,31 @@ import logger from '@/lib/logger'; export async function POST() { try { const session = await getServerSession(authOptions); - + if (!session?.user?.isAdmin) { - return NextResponse.json({ - message: "Unauthorized", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); } - + const result = await updateStats(); - + return NextResponse.json({ message: result.status, success: result.success, }); } catch (error) { logger.error('Error rebuilding statistics:', { error }); - return NextResponse.json({ - message: "Failed to rebuild statistics", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'Failed to rebuild statistics', + success: false, + }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/src/app/api/admin/users/[accountId]/admin/route.ts b/src/app/api/admin/users/[accountId]/admin/route.ts index 3be4129..d3bd012 100644 --- a/src/app/api/admin/users/[accountId]/admin/route.ts +++ b/src/app/api/admin/users/[accountId]/admin/route.ts @@ -11,47 +11,59 @@ export async function POST( ) { try { const session = await getServerSession(authOptions); - + if (!session?.user?.isAdmin) { - return NextResponse.json({ - message: "Unauthorized", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); } - + const { accountId } = await params; - + if (!accountId) { - return NextResponse.json({ - message: "Account ID is required", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Account ID is required', + success: false, + }, + { status: 400 } + ); } - + if (accountId === session.user.accountId) { - return NextResponse.json({ - message: "You cannot change your own admin status", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'You cannot change your own admin status', + success: false, + }, + { status: 400 } + ); } - + const isAdmin = await isUserAdmin(accountId); const result = await makeUserAdmin(accountId, !isAdmin); - + if (!result.success) { - return NextResponse.json({ - message: result.status, - success: false, - }, { status: result.status === "User not found" ? 404 : 500 }); + return NextResponse.json( + { + message: result.status, + success: false, + }, + { status: result.status === 'User not found' ? 404 : 500 } + ); } - + await removeAllSessionsByAccountId(accountId); - - logger.info(`Admin status toggled for user ${accountId}, all sessions revoked`, { - accountId, - newStatus: !isAdmin + + logger.info(`Admin status toggled for user ${accountId}, all sessions revoked`, { + accountId, + newStatus: !isAdmin, }); - + return NextResponse.json({ message: `${result.status} User will need to log in again.`, is_admin: !isAdmin, @@ -59,9 +71,12 @@ export async function POST( }); } catch (error) { logger.error('Error toggling admin status:', error); - return NextResponse.json({ - message: "Failed to toggle admin status", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'Failed to toggle admin status', + success: false, + }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/src/app/api/admin/users/[accountId]/links/[shortId]/analytics/route.ts b/src/app/api/admin/users/[accountId]/links/[shortId]/analytics/route.ts index 402cf13..bc38cf2 100644 --- a/src/app/api/admin/users/[accountId]/links/[shortId]/analytics/route.ts +++ b/src/app/api/admin/users/[accountId]/links/[shortId]/analytics/route.ts @@ -7,110 +7,141 @@ import logger from '@/lib/logger'; export async function GET( req: NextRequest, - { params }: { params: Promise<{ accountId: string, shortId: string }> } + { params }: { params: Promise<{ accountId: string; shortId: string }> } ) { try { const session = await getServerSession(authOptions); - + if (!session?.user?.isAdmin) { - return NextResponse.json({ - message: "Unauthorized", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); } - + const { accountId, shortId } = await params; - + if (!accountId || !shortId) { - return NextResponse.json({ - message: "Account ID and Short ID are required", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Account ID and Short ID are required', + success: false, + }, + { status: 400 } + ); } - + const url = new URL(req.url); const page = parseInt(url.searchParams.get('page') || '1'); const limit = parseInt(url.searchParams.get('limit') || '50'); - - const { analytics, total } = await getAllAnalytics( - accountId, - shortId, - { page, limit } - ); - + + const { analytics, total } = await getAllAnalytics(accountId, shortId, { page, limit }); + const sanitizedAnalytics = analytics.map(item => sanitizeMongoDocument(item)); - + return NextResponse.json({ analytics: sanitizedAnalytics, pagination: { total, page, limit, - pages: Math.ceil(total / limit) + pages: Math.ceil(total / limit), }, success: true, }); } catch (error) { - logger.error('Error getting analytics:', { error, accountId: (await params).accountId, shortId: (await params).shortId }); - return NextResponse.json({ - message: "Failed to retrieve analytics", - success: false, - }, { status: 500 }); + logger.error('Error getting analytics:', { + error, + accountId: (await params).accountId, + shortId: (await params).shortId, + }); + return NextResponse.json( + { + message: 'Failed to retrieve analytics', + success: false, + }, + { status: 500 } + ); } } export async function DELETE( req: NextRequest, - { params }: { params: Promise<{ accountId: string, shortId: string }> } + { params }: { params: Promise<{ accountId: string; shortId: string }> } ) { try { const session = await getServerSession(authOptions); - + if (!session?.user?.isAdmin) { - return NextResponse.json({ - message: "Unauthorized", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); } - + const { accountId, shortId } = await params; - + if (!accountId || !shortId) { - return NextResponse.json({ - message: "Account ID and Short ID are required", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Account ID and Short ID are required', + success: false, + }, + { status: 400 } + ); } - + const body = await req.json(); - + if (body.delete_all) { const result = await removeAllAnalytics(accountId, shortId); - - return NextResponse.json({ - message: result.status, - success: result.success, - }, { status: result.success ? 200 : 400 }); + + return NextResponse.json( + { + message: result.status, + success: result.success, + }, + { status: result.success ? 200 : 400 } + ); } - + if (body.analytics_id) { const result = await removeAnalytics(accountId, shortId, body.analytics_id); - - return NextResponse.json({ - message: result.status, - success: result.success, - }, { status: result.success ? 200 : 400 }); + + return NextResponse.json( + { + message: result.status, + success: result.success, + }, + { status: result.success ? 200 : 400 } + ); } - - return NextResponse.json({ - message: "Either delete_all or analytics_id must be provided", - success: false, - }, { status: 400 }); + + return NextResponse.json( + { + message: 'Either delete_all or analytics_id must be provided', + success: false, + }, + { status: 400 } + ); } catch (error) { - logger.error('Error deleting analytics:', { error, accountId: (await params).accountId, shortId: (await params).shortId }); - return NextResponse.json({ - message: "Failed to delete analytics", - success: false, - }, { status: 500 }); + logger.error('Error deleting analytics:', { + error, + accountId: (await params).accountId, + shortId: (await params).shortId, + }); + return NextResponse.json( + { + message: 'Failed to delete analytics', + success: false, + }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/src/app/api/admin/users/[accountId]/links/[shortId]/route.ts b/src/app/api/admin/users/[accountId]/links/[shortId]/route.ts index 16c971a..a144c5d 100644 --- a/src/app/api/admin/users/[accountId]/links/[shortId]/route.ts +++ b/src/app/api/admin/users/[accountId]/links/[shortId]/route.ts @@ -7,131 +7,182 @@ import logger from '@/lib/logger'; export async function GET( req: NextRequest, - { params }: { params: Promise<{ accountId: string, shortId: string }> } + { params }: { params: Promise<{ accountId: string; shortId: string }> } ) { try { const session = await getServerSession(authOptions); - + if (!session?.user?.isAdmin) { - return NextResponse.json({ - message: "Unauthorized", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); } - + const { accountId, shortId } = await params; - + if (!accountId || !shortId) { - return NextResponse.json({ - message: "Account ID and Short ID are required", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Account ID and Short ID are required', + success: false, + }, + { status: 400 } + ); } - + const { link, return: linkReturn } = await getLinkById(accountId, shortId); - + if (!linkReturn.success || !link) { - return NextResponse.json({ - message: linkReturn.status, - success: false, - }, { status: 404 }); + return NextResponse.json( + { + message: linkReturn.status, + success: false, + }, + { status: 404 } + ); } - + return NextResponse.json({ link: sanitizeMongoDocument(link), success: true, }); } catch (error) { - logger.error('Error getting link details:', { error, accountId: (await params).accountId, shortId: (await params).shortId }); - return NextResponse.json({ - message: "Failed to retrieve link details", - success: false, - }, { status: 500 }); + logger.error('Error getting link details:', { + error, + accountId: (await params).accountId, + shortId: (await params).shortId, + }); + return NextResponse.json( + { + message: 'Failed to retrieve link details', + success: false, + }, + { status: 500 } + ); } } export async function PATCH( req: NextRequest, - { params }: { params: Promise<{ accountId: string, shortId: string }> } + { params }: { params: Promise<{ accountId: string; shortId: string }> } ) { try { const session = await getServerSession(authOptions); - + if (!session?.user?.isAdmin) { - return NextResponse.json({ - message: "Unauthorized", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); } - + const { accountId, shortId } = await params; - + if (!accountId || !shortId) { - return NextResponse.json({ - message: "Account ID and Short ID are required", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Account ID and Short ID are required', + success: false, + }, + { status: 400 } + ); } - + const body = await req.json(); const { target_url } = body; - + if (!target_url) { - return NextResponse.json({ - message: "Target URL is required", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Target URL is required', + success: false, + }, + { status: 400 } + ); } - + const result = await editLink(accountId, shortId, target_url); - - return NextResponse.json({ - message: result.status, - success: result.success, - }, { status: result.success ? 200 : 400 }); + + return NextResponse.json( + { + message: result.status, + success: result.success, + }, + { status: result.success ? 200 : 400 } + ); } catch (error) { - logger.error('Error updating link:', { error, accountId: (await params).accountId, shortId: (await params).shortId }); - return NextResponse.json({ - message: "Failed to update link", - success: false, - }, { status: 500 }); + logger.error('Error updating link:', { + error, + accountId: (await params).accountId, + shortId: (await params).shortId, + }); + return NextResponse.json( + { + message: 'Failed to update link', + success: false, + }, + { status: 500 } + ); } } export async function DELETE( req: NextRequest, - { params }: { params: Promise<{ accountId: string, shortId: string }> } + { params }: { params: Promise<{ accountId: string; shortId: string }> } ) { try { const session = await getServerSession(authOptions); - + if (!session?.user?.isAdmin) { - return NextResponse.json({ - message: "Unauthorized", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); } - + const { accountId, shortId } = await params; - + if (!accountId || !shortId) { - return NextResponse.json({ - message: "Account ID and Short ID are required", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Account ID and Short ID are required', + success: false, + }, + { status: 400 } + ); } - + const result = await removeLink(accountId, shortId); - - return NextResponse.json({ - message: result.status, - success: result.success, - }, { status: result.success ? 200 : 400 }); + + return NextResponse.json( + { + message: result.status, + success: result.success, + }, + { status: result.success ? 200 : 400 } + ); } catch (error) { - logger.error('Error deleting link:', { error, accountId: (await params).accountId, shortId: (await params).shortId }); - return NextResponse.json({ - message: "Failed to delete link", - success: false, - }, { status: 500 }); + logger.error('Error deleting link:', { + error, + accountId: (await params).accountId, + shortId: (await params).shortId, + }); + return NextResponse.json( + { + message: 'Failed to delete link', + success: false, + }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/src/app/api/admin/users/[accountId]/links/route.ts b/src/app/api/admin/users/[accountId]/links/route.ts index 1a4e0e5..7b9f924 100644 --- a/src/app/api/admin/users/[accountId]/links/route.ts +++ b/src/app/api/admin/users/[accountId]/links/route.ts @@ -11,35 +11,44 @@ export async function GET( ) { try { const session = await getServerSession(authOptions); - + if (!session?.user?.isAdmin) { - return NextResponse.json({ - message: "Unauthorized", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); } - + const { accountId } = await params; - + if (!accountId) { - return NextResponse.json({ - message: "Account ID is required", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Account ID is required', + success: false, + }, + { status: 400 } + ); } - + const { links } = await getLinks(accountId); - const sanitizedLinks = links.map(link => (sanitizeMongoDocument(link))); - + const sanitizedLinks = links.map(link => sanitizeMongoDocument(link)); + return NextResponse.json({ links: sanitizedLinks, success: true, }); } catch (error) { logger.error('Error getting user links:', error); - return NextResponse.json({ - message: "Failed to retrieve user links", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'Failed to retrieve user links', + success: false, + }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/src/app/api/admin/users/[accountId]/route.ts b/src/app/api/admin/users/[accountId]/route.ts index 7f8644c..6c0d072 100644 --- a/src/app/api/admin/users/[accountId]/route.ts +++ b/src/app/api/admin/users/[accountId]/route.ts @@ -11,43 +11,55 @@ export async function GET( ) { try { const session = await getServerSession(authOptions); - + if (!session?.user?.isAdmin) { - return NextResponse.json({ - message: "Unauthorized", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); } - + const { accountId } = await params; - + if (!accountId) { - return NextResponse.json({ - message: "Account ID is required", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Account ID is required', + success: false, + }, + { status: 400 } + ); } - + const user = await getUserById(accountId); - + if (!user) { - return NextResponse.json({ - message: "User not found", - success: false, - }, { status: 404 }); + return NextResponse.json( + { + message: 'User not found', + success: false, + }, + { status: 404 } + ); } - + const sanitizedUser = sanitizeMongoDocument(user); - + return NextResponse.json({ user: sanitizedUser, success: true, }); } catch (error) { logger.error('Error getting user:', error); - return NextResponse.json({ - message: "Failed to retrieve user", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'Failed to retrieve user', + success: false, + }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/src/app/api/admin/users/[accountId]/sessions/revoke/route.ts b/src/app/api/admin/users/[accountId]/sessions/revoke/route.ts index d682462..5d4480f 100644 --- a/src/app/api/admin/users/[accountId]/sessions/revoke/route.ts +++ b/src/app/api/admin/users/[accountId]/sessions/revoke/route.ts @@ -7,33 +7,42 @@ import logger from '@/lib/logger'; export async function POST(req: NextRequest) { try { const session = await getServerSession(authOptions); - + if (!session?.user?.isAdmin) { - return NextResponse.json({ - message: "Unauthorized", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); } - + const { sessionId, accountId } = await req.json(); - + if (!sessionId || !accountId) { - return NextResponse.json({ - message: "Session ID and Account ID are required", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Session ID and Account ID are required', + success: false, + }, + { status: 400 } + ); } - + const result = await revokeSession(sessionId, accountId); - + return NextResponse.json({ ...result, }); } catch (error) { logger.error('Error revoking session:', { error }); - return NextResponse.json({ - message: "Failed to revoke session", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'Failed to revoke session', + success: false, + }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/src/app/api/admin/users/[accountId]/sessions/route.ts b/src/app/api/admin/users/[accountId]/sessions/route.ts index 1a866ed..afc003c 100644 --- a/src/app/api/admin/users/[accountId]/sessions/route.ts +++ b/src/app/api/admin/users/[accountId]/sessions/route.ts @@ -11,37 +11,46 @@ export async function GET( ) { try { const session = await getServerSession(authOptions); - + if (!session?.user?.isAdmin) { - return NextResponse.json({ - message: "Unauthorized", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); } - + const { accountId } = await params; - + if (!accountId) { - return NextResponse.json({ - message: "Account ID is required", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Account ID is required', + success: false, + }, + { status: 400 } + ); } - + const sessions = await getSessions(accountId); - const sanitizedSessions = Array.isArray(sessions) + const sanitizedSessions = Array.isArray(sessions) ? sessions.map(session => sanitizeMongoDocument(session)) : []; - + return NextResponse.json({ sessions: sanitizedSessions, success: true, }); } catch (error) { logger.error('Error getting user sessions:', error); - return NextResponse.json({ - message: "Failed to retrieve user sessions", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'Failed to retrieve user sessions', + success: false, + }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index de4194b..e449ab3 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -8,84 +8,108 @@ import logger from '@/lib/logger'; export async function GET() { try { const session = await getServerSession(authOptions); - + if (!session?.user?.isAdmin) { - return NextResponse.json({ - message: "Unauthorized", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); } - + const result = await listUsers(); - + if (result.users) { - const sanitizedUsers = result.users.map(user => (sanitizeMongoDocument(user))); - + const sanitizedUsers = result.users.map(user => sanitizeMongoDocument(user)); + return NextResponse.json({ users: sanitizedUsers, total: result.total, success: true, }); } - - return NextResponse.json({ - message: result.return.status, - success: result.return.success, - }, { status: result.return.success ? 200 : 500 }); + + return NextResponse.json( + { + message: result.return.status, + success: result.return.success, + }, + { status: result.return.success ? 200 : 500 } + ); } catch (error) { logger.error('Error getting users:', { error }); - return NextResponse.json({ - message: "Failed to retrieve users", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'Failed to retrieve users', + success: false, + }, + { status: 500 } + ); } } export async function DELETE(req: NextRequest) { try { const session = await getServerSession(authOptions); - + if (!session?.user?.isAdmin) { - return NextResponse.json({ - message: "Unauthorized", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); } - + const { account_id } = await req.json(); - + if (!account_id) { - return NextResponse.json({ - message: "Account ID is required", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Account ID is required', + success: false, + }, + { status: 400 } + ); } - + if (account_id === session.user.accountId) { - return NextResponse.json({ - message: "You cannot delete your own account from admin panel", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'You cannot delete your own account from admin panel', + success: false, + }, + { status: 400 } + ); } - + if (await isUserAdmin(account_id)) { - return NextResponse.json({ - message: "Cannot delete admin accounts", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Cannot delete admin accounts', + success: false, + }, + { status: 400 } + ); } - + const result = await removeUser(account_id); - + return NextResponse.json({ message: result.status, success: result.success, }); } catch (error) { logger.error('Error deleting user:', { error }); - return NextResponse.json({ - message: "Failed to delete user", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'Failed to delete user', + success: false, + }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 7fcfbe5..ceac02b 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -9,75 +9,87 @@ import logger from '@/lib/logger'; export async function GET(req: NextRequest) { try { const session = await getServerSession(authOptions); - + if (!session?.user?.accountId) { logger.info('Analytics request failed due to unauthorized access', { url: req.url }); - return NextResponse.json({ - message: "Unauthorized. Please sign in first.", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized. Please sign in first.', + success: false, + }, + { status: 401 } + ); } - + const account_id = session.user.accountId; const url = new URL(req.url); - + const link_id = url.searchParams.get('link_id'); if (!link_id) { logger.info('Analytics request failed due to missing link_id', { account_id, url: req.url }); - return NextResponse.json({ - message: "Missing link_id parameter", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Missing link_id parameter', + success: false, + }, + { status: 400 } + ); } const all = url.searchParams.get('all') === 'true'; - + let page = 1; let limit = 50; - + if (!all) { page = parseInt(url.searchParams.get('page') || '1'); limit = parseInt(url.searchParams.get('limit') || '50'); } - + let startDate: Date | undefined; let endDate: Date | undefined; - + if (url.searchParams.get('startDate')) { startDate = new Date(url.searchParams.get('startDate')!); } - + if (url.searchParams.get('endDate')) { endDate = new Date(url.searchParams.get('endDate')!); } - - const queryOptions = all + + const queryOptions = all ? {} - : { - page, - limit, - startDate, - endDate + : { + page, + limit, + startDate, + endDate, }; - + const { analytics, total } = await getAllAnalytics(account_id, link_id, queryOptions); - - return NextResponse.json({ - message: "Analytics retrieved successfully", - success: true, - analytics, - pagination: { - total, - page, - limit, - totalPages: Math.ceil(total / limit) - } - }, { status: 200 }); + + return NextResponse.json( + { + message: 'Analytics retrieved successfully', + success: true, + analytics, + pagination: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }, + { status: 200 } + ); } catch (error) { logger.error('Analytics retrieval error:', { error, url: req.url }); - return NextResponse.json({ - message: "Failed to retrieve analytics", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'Failed to retrieve analytics', + success: false, + }, + { status: 500 } + ); } } @@ -85,18 +97,21 @@ export async function GET(req: NextRequest) { export async function DELETE(req: NextRequest) { try { const session = await getServerSession(authOptions); - + if (!session?.user?.accountId) { logger.info('Analytics deletion request failed due to unauthorized access', { url: req.url }); - return NextResponse.json({ - message: "Unauthorized. Please sign in first.", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized. Please sign in first.', + success: false, + }, + { status: 401 } + ); } - + const account_id = session.user.accountId; let link_id, analytics_id, delete_all; - + try { const contentType = req.headers.get('content-type'); if (contentType && contentType.includes('application/json') && req.body) { @@ -107,79 +122,119 @@ export async function DELETE(req: NextRequest) { } } catch { logger.info('Analytics deletion request failed due to missing parameters', { url: req.url }); - return NextResponse.json({ - message: "Missing required parameters", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Missing required parameters', + success: false, + }, + { status: 400 } + ); } - + if (!link_id) { - logger.info('Analytics deletion request failed due to missing link_id', { account_id, url: req.url }); - return NextResponse.json({ - message: "Missing link_id parameter", - success: false, - }, { status: 400 }); + logger.info('Analytics deletion request failed due to missing link_id', { + account_id, + url: req.url, + }); + return NextResponse.json( + { + message: 'Missing link_id parameter', + success: false, + }, + { status: 400 } + ); } - + // Delete all analytics for a link if (delete_all) { const result = await removeAllAnalytics(account_id, link_id); - + if (result.success) { - logger.info('All analytics deletion request succeeded', { account_id, link_id, url: req.url }); - return NextResponse.json({ - message: "All analytics records deleted successfully", - success: true, - }, { status: 200 }); + logger.info('All analytics deletion request succeeded', { + account_id, + link_id, + url: req.url, + }); + return NextResponse.json( + { + message: 'All analytics records deleted successfully', + success: true, + }, + { status: 200 } + ); } else { - logger.info('All analytics deletion request failed', { error: result.status, account_id, link_id, url: req.url }); - return NextResponse.json({ - message: result.status, - success: false, - }, { status: 404 }); + logger.info('All analytics deletion request failed', { + error: result.status, + account_id, + link_id, + url: req.url, + }); + return NextResponse.json( + { + message: result.status, + success: false, + }, + { status: 404 } + ); } } - + // Delete single analytics record if (!analytics_id) { - logger.info('Analytics deletion request failed due to missing analytics_id', { account_id, link_id, url: req.url }); - return NextResponse.json({ - message: "Missing analytics_id parameter for single record deletion", - success: false, - }, { status: 400 }); + logger.info('Analytics deletion request failed due to missing analytics_id', { + account_id, + link_id, + url: req.url, + }); + return NextResponse.json( + { + message: 'Missing analytics_id parameter for single record deletion', + success: false, + }, + { status: 400 } + ); } - + const result = await removeAnalytics(account_id, link_id, analytics_id); - + if (result.success) { - logger.info('Single analytics record deletion request succeeded', { - account_id, - link_id, - analytics_id, - url: req.url + logger.info('Single analytics record deletion request succeeded', { + account_id, + link_id, + analytics_id, + url: req.url, }); - return NextResponse.json({ - message: "Analytics record deleted successfully", - success: true, - }, { status: 200 }); + return NextResponse.json( + { + message: 'Analytics record deleted successfully', + success: true, + }, + { status: 200 } + ); } else { - logger.info('Single analytics record deletion request failed', { - error: result.status, - account_id, - link_id, - analytics_id, - url: req.url + logger.info('Single analytics record deletion request failed', { + error: result.status, + account_id, + link_id, + analytics_id, + url: req.url, }); - return NextResponse.json({ - message: result.status, - success: false, - }, { status: 404 }); + return NextResponse.json( + { + message: result.status, + success: false, + }, + { status: 404 } + ); } } catch (error) { logger.error('Analytics deletion error:', { error, url: req.url }); - return NextResponse.json({ - message: "Failed to delete analytics", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'Failed to delete analytics', + success: false, + }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index 0401a1e..118989e 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -1,63 +1,57 @@ -import NextAuth, { NextAuthOptions, User } from "next-auth"; -import CredentialsProvider from "next-auth/providers/credentials"; +import NextAuth, { NextAuthOptions, User } from 'next-auth'; +import CredentialsProvider from 'next-auth/providers/credentials'; import logger from '@/lib/logger'; -import { existsUser, isUserAdmin } from "@/lib/userdb"; -import { JWT } from "next-auth/jwt"; -import { Session } from "next-auth"; +import { existsUser, isUserAdmin } from '@/lib/userdb'; +import { JWT } from 'next-auth/jwt'; +import { Session } from 'next-auth'; import { createSession, updateSessionActivity, revokeSession } from '@/lib/sessiondb'; import { headers } from 'next/headers'; export const authOptions: NextAuthOptions = { providers: [ CredentialsProvider({ - name: "Account ID", + name: 'Account ID', credentials: { - accountId: { label: "Account ID", type: "text", placeholder: "Enter your Account ID" } + accountId: { label: 'Account ID', type: 'text', placeholder: 'Enter your Account ID' }, }, async authorize(credentials) { const { accountId } = credentials as { accountId: string }; - + const exists = await existsUser(accountId); - + if (exists) { const isAdmin = await isUserAdmin(accountId); - + return { id: accountId, accountId, isAdmin, }; } - + return null; - } + }, }), ], callbacks: { - async jwt({ token, user, trigger }: { token: JWT; user: User, trigger?: string }) { + async jwt({ token, user, trigger }: { token: JWT; user: User; trigger?: string }) { if (user) { token.accountId = user.accountId; token.isAdmin = user.isAdmin; - + const headersList = await headers(); const userAgent = headersList.get('user-agent') || 'Unknown'; - const ip = headersList.get('x-forwarded-for') || - headersList.get('x-real-ip') || - 'Unknown'; - - const { sessionId } = await createSession( - user.accountId, - userAgent, - ip - ); - + const ip = headersList.get('x-forwarded-for') || headersList.get('x-real-ip') || 'Unknown'; + + const { sessionId } = await createSession(user.accountId, userAgent, ip); + token.sessionId = sessionId; } - + if (trigger === 'update' && token.sessionId) { await updateSessionActivity(token.sessionId as string); } - + return token; }, async session({ session, token }: { session: Session; token: JWT }) { @@ -66,11 +60,11 @@ export const authOptions: NextAuthOptions = { ...session.user, accountId: token.accountId as string, isAdmin: token.isAdmin as boolean, - sessionId: token.sessionId as string + sessionId: token.sessionId as string, }; } return session; - } + }, }, events: { async signOut({ token }) { @@ -81,22 +75,22 @@ export const authOptions: NextAuthOptions = { logger.error('Error terminating session on signOut:', error); } } - } + }, }, pages: { signIn: '/', error: '/', }, session: { - strategy: "jwt" as const, + strategy: 'jwt' as const, maxAge: 30 * 24 * 60 * 60, // 30 days (session lifetime) }, jwt: { maxAge: 5 * 60, // 5 min (token lifetime) }, - secret: process.env.NEXTAUTH_SECRET || "/JZ9N+lqRtvspbAfs0HK41RkthPYuUdqxb+cuimYOXw=", + secret: process.env.NEXTAUTH_SECRET || '/JZ9N+lqRtvspbAfs0HK41RkthPYuUdqxb+cuimYOXw=', }; const handler = NextAuth(authOptions); -export { handler as GET, handler as POST }; \ No newline at end of file +export { handler as GET, handler as POST }; diff --git a/src/app/api/auth/check-session/route.ts b/src/app/api/auth/check-session/route.ts index f01f762..9ad5410 100644 --- a/src/app/api/auth/check-session/route.ts +++ b/src/app/api/auth/check-session/route.ts @@ -7,37 +7,43 @@ import logger from '@/lib/logger'; export async function GET() { try { const session = await getServerSession(authOptions); - + if (!session?.user?.accountId || !session?.user?.sessionId) { - return NextResponse.json({ - valid: false, - message: "No active session" - }, { status: 401 }); + return NextResponse.json( + { + valid: false, + message: 'No active session', + }, + { status: 401 } + ); } - - const isValid = await isSessionValid( - session.user.sessionId, - session.user.accountId - ); - + + const isValid = await isSessionValid(session.user.sessionId, session.user.accountId); + if (!isValid) { - logger.info('Session check failed - revoked or expired session', { + logger.info('Session check failed - revoked or expired session', { sessionId: session.user.sessionId, - accountId: session.user.accountId + accountId: session.user.accountId, }); - - return NextResponse.json({ - valid: false, - message: "Session has been revoked" - }, { status: 401 }); + + return NextResponse.json( + { + valid: false, + message: 'Session has been revoked', + }, + { status: 401 } + ); } - + return NextResponse.json({ valid: true }); } catch (error) { logger.error('Error checking session:', error); - return NextResponse.json({ - valid: false, - message: "Error checking session" - }, { status: 500 }); + return NextResponse.json( + { + valid: false, + message: 'Error checking session', + }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 21a04c9..3984f8f 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -9,7 +9,7 @@ export async function POST(req: NextRequest) { let is_admin; logger.info('Registration request', { url: req.url }); const session = await getServerSession(authOptions); - + try { const contentType = req.headers.get('content-type'); if (contentType && contentType.includes('application/json') && req.body) { @@ -18,44 +18,59 @@ export async function POST(req: NextRequest) { } } catch { logger.info('Registration request failed due to missing parameters', { url: req.url }); - return NextResponse.json({ - message: "Missing required parameters", - success: false, - }, { status: 400 }); - } + return NextResponse.json( + { + message: 'Missing required parameters', + success: false, + }, + { status: 400 } + ); + } if (is_admin) { const isAuthorized = await isUserAdmin(session?.user?.accountId); - + if (isAuthorized) { const account = await createUser(is_admin); logger.info('Account creation request succeeded (admin)', { is_admin, url: req.url }); - return NextResponse.json({ - message: "Admin registration successful", - success: true, - account_id: account.account_id, - }, { status: 200 }); + return NextResponse.json( + { + message: 'Admin registration successful', + success: true, + account_id: account.account_id, + }, + { status: 200 } + ); } else { - logger.info('Registration request failed due to missing rights', { is_admin, url: req.url }); - return NextResponse.json({ - message: "Unauthorized admin registration attempt", - success: false, - }, { status: 401 }); + logger.info('Registration request failed due to missing rights', { + is_admin, + url: req.url, + }); + return NextResponse.json( + { + message: 'Unauthorized admin registration attempt', + success: false, + }, + { status: 401 } + ); } } - + const account = await createUser(false); - + return NextResponse.json({ - message: "Registration successful", + message: 'Registration successful', success: true, account_id: account.account_id, }); } catch (error) { logger.error('Registration error:', { error, url: req.url }); - return NextResponse.json({ - message: "Registration failed", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'Registration failed', + success: false, + }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/src/app/api/auth/remove/route.ts b/src/app/api/auth/remove/route.ts index 81ec850..a33adf3 100644 --- a/src/app/api/auth/remove/route.ts +++ b/src/app/api/auth/remove/route.ts @@ -9,7 +9,7 @@ export async function DELETE(req: NextRequest) { let account_id; logger.info('Account removal request', { url: req.url }); const session = await getServerSession(authOptions); - + try { const contentType = req.headers.get('content-type'); if (contentType && contentType.includes('application/json') && req.body) { @@ -18,30 +18,42 @@ export async function DELETE(req: NextRequest) { } } catch { logger.info('Account removal request failed due to missing parameters', { url: req.url }); - return NextResponse.json({ - message: "Missing required parameters", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Missing required parameters', + success: false, + }, + { status: 400 } + ); } - - if (session?.user?.accountId === account_id || await isUserAdmin(session?.user?.accountId)) { + + if (session?.user?.accountId === account_id || (await isUserAdmin(session?.user?.accountId))) { const accountRemovalResponse = await removeUser(account_id); - return NextResponse.json({ - message: accountRemovalResponse.status, - success: accountRemovalResponse.success, - }, { status: accountRemovalResponse.success ? 200 : 500 }); + return NextResponse.json( + { + message: accountRemovalResponse.status, + success: accountRemovalResponse.success, + }, + { status: accountRemovalResponse.success ? 200 : 500 } + ); } - + logger.info('Account removal request failed due to missing rights', { url: req.url }); - return NextResponse.json({ - message: "Unauthorized account removal attempt", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized account removal attempt', + success: false, + }, + { status: 401 } + ); } catch (error) { logger.error('Account removal error:', { error, url: req.url }); - return NextResponse.json({ - message: "Account removal failed", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'Account removal failed', + success: false, + }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/src/app/api/auth/sessions/revoke/route.ts b/src/app/api/auth/sessions/revoke/route.ts index 25e9f47..5654924 100644 --- a/src/app/api/auth/sessions/revoke/route.ts +++ b/src/app/api/auth/sessions/revoke/route.ts @@ -8,39 +8,48 @@ import logger from '@/lib/logger'; export async function POST(req: NextRequest) { try { const session = await getServerSession(authOptions); - + if (!session?.user?.accountId) { - return NextResponse.json({ - message: "Unauthorized", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); } - + const { sessionId } = await req.json(); - + if (!sessionId) { - return NextResponse.json({ - message: "Session ID is required", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Session ID is required', + success: false, + }, + { status: 400 } + ); } - + const result = await revokeSession(sessionId, session.user.accountId); - + const isCurrentSession = sessionId === session.user.sessionId; if (isCurrentSession) { - signOut({ redirect: false }); + signOut({ redirect: false }); } - + return NextResponse.json({ ...result, isCurrentSession, }); } catch (error) { logger.error('Error revoking session:', { error }); - return NextResponse.json({ - message: "Failed to revoking session", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'Failed to revoking session', + success: false, + }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/src/app/api/auth/sessions/route.ts b/src/app/api/auth/sessions/route.ts index f66ade2..2e96e06 100644 --- a/src/app/api/auth/sessions/route.ts +++ b/src/app/api/auth/sessions/route.ts @@ -7,32 +7,38 @@ import logger from '@/lib/logger'; export async function GET() { try { const session = await getServerSession(authOptions); - + if (!session?.user?.accountId) { - return NextResponse.json({ - message: "Unauthorized", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized', + success: false, + }, + { status: 401 } + ); } - + const sessions = await getSessions(session.user.accountId); - + // Mark the current session const currentSessionId = session.user.sessionId; const sessionsWithCurrent = sessions.map(s => ({ ...s, - isCurrentSession: s.id === currentSessionId + isCurrentSession: s.id === currentSessionId, })); - + return NextResponse.json({ sessions: sessionsWithCurrent, success: true, }); } catch (error) { logger.error('Error getting sessions:', { error }); - return NextResponse.json({ - message: "Failed to retrieve sessions", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'Failed to retrieve sessions', + success: false, + }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/src/app/api/link/route.ts b/src/app/api/link/route.ts index d65be89..35b3eb7 100644 --- a/src/app/api/link/route.ts +++ b/src/app/api/link/route.ts @@ -11,49 +11,72 @@ import { authOptions } from '@/app/api/auth/[...nextauth]/route'; export async function GET(req: NextRequest) { try { const session = await getServerSession(authOptions); - + if (!session?.user?.accountId) { logger.info('Link retrieval request failed due to unauthorized access', { url: req.url }); - return NextResponse.json({ - message: "Unauthorized. Please sign in first.", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized. Please sign in first.', + success: false, + }, + { status: 401 } + ); } - + const account_id = session.user.accountId; - + const url = new URL(req.url); const shortId = url.searchParams.get('shortId'); - + if (!shortId) { - logger.info('Link retrieval request failed due to missing shortId', { account_id, url: req.url }); - return NextResponse.json({ - message: "Missing shortId parameter", - success: false, - }, { status: 400 }); + logger.info('Link retrieval request failed due to missing shortId', { + account_id, + url: req.url, + }); + return NextResponse.json( + { + message: 'Missing shortId parameter', + success: false, + }, + { status: 400 } + ); } - + const { link, return: returnValue } = await getLinkById(account_id, shortId); - + if (returnValue.success && link) { - return NextResponse.json({ - message: "Link retrieved successfully", - success: true, - link: sanitizeMongoDocument(link), - }, { status: 200 }); + return NextResponse.json( + { + message: 'Link retrieved successfully', + success: true, + link: sanitizeMongoDocument(link), + }, + { status: 200 } + ); } - - logger.info('Link retrieval request failed', { error: returnValue.status, account_id, shortId, url: req.url }); - return NextResponse.json({ - message: returnValue.status || "Link not found", - success: false, - }, { status: 404 }); + + logger.info('Link retrieval request failed', { + error: returnValue.status, + account_id, + shortId, + url: req.url, + }); + return NextResponse.json( + { + message: returnValue.status || 'Link not found', + success: false, + }, + { status: 404 } + ); } catch (error) { logger.error('Link retrieval error:', { error, url: req.url }); - return NextResponse.json({ - message: "Failed to retrieve link", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'Failed to retrieve link', + success: false, + }, + { status: 500 } + ); } } @@ -61,18 +84,21 @@ export async function GET(req: NextRequest) { export async function POST(req: NextRequest) { try { const session = await getServerSession(authOptions); - + if (!session?.user?.accountId) { logger.info('Link creation request failed due to unauthorized access', { url: req.url }); - return NextResponse.json({ - message: "Unauthorized. Please sign in first.", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized. Please sign in first.', + success: false, + }, + { status: 401 } + ); } - + const account_id = session.user.accountId; let target_url; - + try { const contentType = req.headers.get('content-type'); if (contentType && contentType.includes('application/json') && req.body) { @@ -81,41 +107,60 @@ export async function POST(req: NextRequest) { } } catch { logger.info('Link creation request failed due to missing parameters', { url: req.url }); - return NextResponse.json({ - message: "Missing required parameters", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Missing required parameters', + success: false, + }, + { status: 400 } + ); } - + if (!target_url || !isValidUrl(target_url)) { logger.info('Link creation request failed due to invalid URL', { account_id, url: req.url }); - return NextResponse.json({ - message: "Invalid URL. Please provide a valid URL with http:// or https://", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Invalid URL. Please provide a valid URL with http:// or https://', + success: false, + }, + { status: 400 } + ); } - + const { shortId, return: returnValue } = await createLink(account_id, target_url); - - if(returnValue.success) { - return NextResponse.json({ - message: "Link Creation succeeded", - success: true, - shortId, - }, { status: 200 }); + + if (returnValue.success) { + return NextResponse.json( + { + message: 'Link Creation succeeded', + success: true, + shortId, + }, + { status: 200 } + ); } - - logger.error('Link creation request failed', { error: returnValue.status, account_id, url: req.url }); - return NextResponse.json({ - message: returnValue.status || "Link creation failed", - success: false, - }, { status: 422 }); + + logger.error('Link creation request failed', { + error: returnValue.status, + account_id, + url: req.url, + }); + return NextResponse.json( + { + message: returnValue.status || 'Link creation failed', + success: false, + }, + { status: 422 } + ); } catch (error) { logger.error('Link creation error:', { error, url: req.url }); - return NextResponse.json({ - message: "Link creation failed", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'Link creation failed', + success: false, + }, + { status: 500 } + ); } } @@ -123,18 +168,21 @@ export async function POST(req: NextRequest) { export async function PATCH(req: NextRequest) { try { const session = await getServerSession(authOptions); - + if (!session?.user?.accountId) { logger.info('Link edit request failed due to unauthorized access', { url: req.url }); - return NextResponse.json({ - message: "Unauthorized. Please sign in first.", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized. Please sign in first.', + success: false, + }, + { status: 401 } + ); } - + const account_id = session.user.accountId; let shortId, target_url; - + try { const contentType = req.headers.get('content-type'); if (contentType && contentType.includes('application/json') && req.body) { @@ -144,48 +192,71 @@ export async function PATCH(req: NextRequest) { } } catch { logger.info('Link edit request failed due to missing parameters', { url: req.url }); - return NextResponse.json({ - message: "Missing required parameters", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Missing required parameters', + success: false, + }, + { status: 400 } + ); } - + if (!shortId) { logger.info('Link edit request failed due to missing shortId', { account_id, url: req.url }); - return NextResponse.json({ - message: "Missing shortId parameter", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Missing shortId parameter', + success: false, + }, + { status: 400 } + ); } - + if (!target_url || !isValidUrl(target_url)) { logger.info('Link edit request failed due to invalid URL', { account_id, url: req.url }); - return NextResponse.json({ - message: "Invalid URL. Please provide a valid URL with http:// or https://", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Invalid URL. Please provide a valid URL with http:// or https://', + success: false, + }, + { status: 400 } + ); } - + const returnValue = await editLink(account_id, shortId, target_url); - - if(returnValue.success) { - return NextResponse.json({ - message: "Link updated successfully", - success: true, - }, { status: 200 }); + + if (returnValue.success) { + return NextResponse.json( + { + message: 'Link updated successfully', + success: true, + }, + { status: 200 } + ); } - - logger.error('Link edit request failed', { error: returnValue.status, account_id, shortId, url: req.url }); - return NextResponse.json({ - message: returnValue.status || "Link update failed", - success: false, - }, { status: 422 }); + + logger.error('Link edit request failed', { + error: returnValue.status, + account_id, + shortId, + url: req.url, + }); + return NextResponse.json( + { + message: returnValue.status || 'Link update failed', + success: false, + }, + { status: 422 } + ); } catch (error) { logger.error('Link edit error:', { error, url: req.url }); - return NextResponse.json({ - message: "Link update failed", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'Link update failed', + success: false, + }, + { status: 500 } + ); } } @@ -193,18 +264,21 @@ export async function PATCH(req: NextRequest) { export async function DELETE(req: NextRequest) { try { const session = await getServerSession(authOptions); - + if (!session?.user?.accountId) { logger.info('Link removal request failed due to unauthorized access', { url: req.url }); - return NextResponse.json({ - message: "Unauthorized. Please sign in first.", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized. Please sign in first.', + success: false, + }, + { status: 401 } + ); } - + const account_id = session.user.accountId; let shortId; - + try { const contentType = req.headers.get('content-type'); if (contentType && contentType.includes('application/json') && req.body) { @@ -213,40 +287,62 @@ export async function DELETE(req: NextRequest) { } } catch { logger.info('Link removal request failed due to missing parameters', { url: req.url }); - return NextResponse.json({ - message: "Missing required parameters", - success: false, - }, { status: 400 }); + return NextResponse.json( + { + message: 'Missing required parameters', + success: false, + }, + { status: 400 } + ); } - + if (!shortId) { - logger.info('Link removal request failed due to missing shortId', { account_id, url: req.url }); - return NextResponse.json({ - message: "Missing shortId parameter", - success: false, - }, { status: 400 }); + logger.info('Link removal request failed due to missing shortId', { + account_id, + url: req.url, + }); + return NextResponse.json( + { + message: 'Missing shortId parameter', + success: false, + }, + { status: 400 } + ); } - + await removeAllAnalytics(account_id, shortId); const returnValue = await removeLink(account_id, shortId); - - if(returnValue.success) { - return NextResponse.json({ - message: "Link removal succeeded", - success: true, - }, { status: 200 }); + + if (returnValue.success) { + return NextResponse.json( + { + message: 'Link removal succeeded', + success: true, + }, + { status: 200 } + ); } - - logger.error('Link removal request failed', { error: returnValue.status, account_id, url: req.url }); - return NextResponse.json({ - message: returnValue.status || "Link removal failed", - success: false, - }, { status: 422 }); + + logger.error('Link removal request failed', { + error: returnValue.status, + account_id, + url: req.url, + }); + return NextResponse.json( + { + message: returnValue.status || 'Link removal failed', + success: false, + }, + { status: 422 } + ); } catch (error) { logger.error('Link removal error:', { error, url: req.url }); - return NextResponse.json({ - message: "Link removal failed", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'Link removal failed', + success: false, + }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/src/app/api/links/route.ts b/src/app/api/links/route.ts index da8cda1..bd3216b 100644 --- a/src/app/api/links/route.ts +++ b/src/app/api/links/route.ts @@ -9,36 +9,52 @@ import logger from '@/lib/logger'; export async function GET(req: NextRequest) { try { const session = await getServerSession(authOptions); - + if (!session?.user?.accountId) { logger.info('Links retrieval request failed due to unauthorized access', { url: req.url }); - return NextResponse.json({ - message: "Unauthorized. Please sign in first.", - success: false, - }, { status: 401 }); + return NextResponse.json( + { + message: 'Unauthorized. Please sign in first.', + success: false, + }, + { status: 401 } + ); } - + const account_id = session.user.accountId; const { links, return: returnValue } = await getLinks(account_id); - + if (returnValue.success) { - return NextResponse.json({ - message: "Links retrieved successfully", - success: true, - links: links.map(link => sanitizeMongoDocument(link)), - }, { status: 200 }); + return NextResponse.json( + { + message: 'Links retrieved successfully', + success: true, + links: links.map(link => sanitizeMongoDocument(link)), + }, + { status: 200 } + ); } - - logger.info('Links retrieval request failed', { error: returnValue.status, account_id, url: req.url }); - return NextResponse.json({ - message: returnValue.status || "Failed to retrieve links", - success: false, - }, { status: 404 }); + + logger.info('Links retrieval request failed', { + error: returnValue.status, + account_id, + url: req.url, + }); + return NextResponse.json( + { + message: returnValue.status || 'Failed to retrieve links', + success: false, + }, + { status: 404 } + ); } catch (error) { logger.error('Links retrieval error:', { error, url: req.url }); - return NextResponse.json({ - message: "Failed to retrieve links", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'Failed to retrieve links', + success: false, + }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/src/app/api/statistics/route.ts b/src/app/api/statistics/route.ts index 042d931..1a03658 100644 --- a/src/app/api/statistics/route.ts +++ b/src/app/api/statistics/route.ts @@ -4,30 +4,40 @@ import { getAllStats, updateStats } from '@/lib/statisticsdb'; export async function GET() { try { let stats = await getAllStats(); - - if (!stats || !stats.last_updated || - (new Date().getTime() - new Date(stats.last_updated).getTime() > 5 * 60 * 1000)) { // 5min + + if ( + !stats || + !stats.last_updated || + new Date().getTime() - new Date(stats.last_updated).getTime() > 5 * 60 * 1000 + ) { + // 5min await updateStats(); stats = await getAllStats(); } - + if (!stats) { - return NextResponse.json({ - message: "Failed to retrieve statistics", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'Failed to retrieve statistics', + success: false, + }, + { status: 500 } + ); } - + return NextResponse.json({ - message: "Statistics retrieved successfully", + message: 'Statistics retrieved successfully', success: true, stats, }); } catch (error) { console.error('Statistics retrieval error:', error); - return NextResponse.json({ - message: "An error occurred while retrieving statistics", - success: false, - }, { status: 500 }); + return NextResponse.json( + { + message: 'An error occurred while retrieving statistics', + success: false, + }, + { status: 500 } + ); } -} \ No newline at end of file +} diff --git a/src/app/dashboard/Dashboard.module.css b/src/app/dashboard/Dashboard.module.css index a56b687..0c8d881 100644 --- a/src/app/dashboard/Dashboard.module.css +++ b/src/app/dashboard/Dashboard.module.css @@ -23,7 +23,8 @@ gap: 1rem; } -.securityButton, .adminButton { +.securityButton, +.adminButton { padding: 0.5rem 1rem; border-radius: 4px; font-weight: 500; @@ -42,7 +43,8 @@ color: white; } -.securityButton:hover, .adminButton:hover { +.securityButton:hover, +.adminButton:hover { opacity: 0.9; transform: translateY(-2px); } @@ -128,18 +130,18 @@ .inputGroup { flex-direction: column; } - + .dashboardHeader { flex-direction: column; align-items: flex-start; gap: 1rem; } - + .actionButtons { width: 100%; justify-content: flex-start; } - + .urlShortener, .linksSection { padding: 1rem; @@ -150,14 +152,14 @@ .dashboardContainer { padding: 1rem 0.5rem; } - + .dashboardTitle { font-size: 1.5rem; } - - .securityButton, + + .securityButton, .adminButton { padding: 0.4rem 0.8rem; font-size: 0.9rem; } -} \ No newline at end of file +} diff --git a/src/app/dashboard/link/[shortId]/LinkDetail.module.css b/src/app/dashboard/link/[shortId]/LinkDetail.module.css index b15018b..b25ef1f 100644 --- a/src/app/dashboard/link/[shortId]/LinkDetail.module.css +++ b/src/app/dashboard/link/[shortId]/LinkDetail.module.css @@ -273,8 +273,8 @@ flex-direction: column; align-items: flex-start; } - + .graphs { grid-template-columns: 1fr; } -} \ No newline at end of file +} diff --git a/src/app/dashboard/link/[shortId]/page.tsx b/src/app/dashboard/link/[shortId]/page.tsx index 7a32abf..5d3c548 100644 --- a/src/app/dashboard/link/[shortId]/page.tsx +++ b/src/app/dashboard/link/[shortId]/page.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import { useState, useEffect, useRef } from 'react'; import { useParams, useRouter } from 'next/navigation'; @@ -17,7 +17,7 @@ export default function LinkDetailPage() { const router = useRouter(); const { showToast } = useToast(); const shortId = params.shortId as string; - + const [link, setLink] = useState(null); const [targetUrl, setTargetUrl] = useState(''); const [isEditing, setIsEditing] = useState(false); @@ -33,21 +33,21 @@ export default function LinkDetailPage() { const [analyticsToDelete, setAnalyticsToDelete] = useState(''); const [isLoadingStats, setIsLoadingStats] = useState(true); const isRedirecting = useRef(false); - + // Stats data const [browserStats, setBrowserStats] = useState([]); const [osStats, setOsStats] = useState([]); const [countryStats, setCountryStats] = useState([]); const [ipVersionStats, setIpVersionStats] = useState([]); - function isValidUrl(urlStr: string) : boolean { - if(urlStr.trim() === "") { + function isValidUrl(urlStr: string): boolean { + if (urlStr.trim() === '') { return false; } - + try { const parsedUrl = new URL(urlStr); - return parsedUrl.protocol !== "" && parsedUrl.hostname !== ""; + return parsedUrl.protocol !== '' && parsedUrl.hostname !== ''; } catch { return false; } @@ -60,10 +60,10 @@ export default function LinkDetailPage() { }, 10); } }, [isEditing]); - + useEffect(() => { if (isRedirecting.current) return; - + async function fetchLinkData() { try { const response = await fetch(`/api/link?shortId=${shortId}`); @@ -73,7 +73,7 @@ export default function LinkDetailPage() { router.push('/dashboard'); return; } - + const data = await response.json(); if (data.success && data.link) { setLink(data.link); @@ -91,13 +91,13 @@ export default function LinkDetailPage() { setIsLoading(false); } } - + fetchLinkData(); }, [shortId, router, showToast]); - + useEffect(() => { if (!link) return; - + async function fetchAllAnalytics() { try { const response = await fetch(`/api/analytics?link_id=${shortId}&all=true`); @@ -105,7 +105,7 @@ export default function LinkDetailPage() { showToast('Failed to load complete analytics data', 'error'); return; } - + const data = await response.json(); if (data.success) { setAllAnalytics(data.analytics); @@ -115,21 +115,23 @@ export default function LinkDetailPage() { showToast('An error occurred while loading complete analytics data', 'error'); } } - + fetchAllAnalytics(); }, [link, shortId, showToast]); - + useEffect(() => { if (!link) return; - + async function fetchPaginatedAnalytics() { try { - const response = await fetch(`/api/analytics?link_id=${shortId}&page=${page}&limit=${limit}`); + const response = await fetch( + `/api/analytics?link_id=${shortId}&page=${page}&limit=${limit}` + ); if (!response.ok) { showToast('Failed to load analytics page', 'error'); return; } - + const data = await response.json(); if (data.success) { setAnalytics(data.analytics); @@ -138,13 +140,13 @@ export default function LinkDetailPage() { showToast('An error occurred while loading analytics page', 'error'); } } - + fetchPaginatedAnalytics(); }, [link, shortId, page, limit, showToast]); - + useEffect(() => { if (!link || allAnalytics.length === 0) return; - + async function generateStats() { setIsLoadingStats(true); try { @@ -154,78 +156,86 @@ export default function LinkDetailPage() { acc[browser] = (acc[browser] || 0) + 1; return acc; }, {}); - + // OS stats const oses = allAnalytics.reduce((acc: Record, item) => { const os = item.platform || 'Unknown'; acc[os] = (acc[os] || 0) + 1; return acc; }, {}); - + // Country stats const countries = allAnalytics.reduce((acc: Record, item) => { const country = item.country || 'Unknown'; acc[country] = (acc[country] || 0) + 1; return acc; }, {}); - + // IP version stats const ipVersions = allAnalytics.reduce((acc: Record, item) => { const ipVersion = item.ip_version || 'Unknown'; acc[ipVersion] = (acc[ipVersion] || 0) + 1; return acc; }, {}); - + // Convert to StatItem[] and sort by count - setBrowserStats(Object.entries(browsers) - .map(([id, count]) => ({ id, count })) - .sort((a, b) => b.count - a.count)); - - setOsStats(Object.entries(oses) - .map(([id, count]) => ({ id, count })) - .sort((a, b) => b.count - a.count)); - - setCountryStats(Object.entries(countries) - .map(([id, count]) => ({ id, count })) - .sort((a, b) => b.count - a.count)); - - setIpVersionStats(Object.entries(ipVersions) - .map(([id, count]) => ({ id, count })) - .sort((a, b) => b.count - a.count)); + setBrowserStats( + Object.entries(browsers) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count) + ); + + setOsStats( + Object.entries(oses) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count) + ); + + setCountryStats( + Object.entries(countries) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count) + ); + + setIpVersionStats( + Object.entries(ipVersions) + .map(([id, count]) => ({ id, count })) + .sort((a, b) => b.count - a.count) + ); } catch { showToast('An error occurred while processing analytics data', 'error'); } finally { setIsLoadingStats(false); } } - + generateStats(); }, [allAnalytics, link, showToast]); - + const handlePageChange = (newPage: number) => { setPage(newPage); }; - + const handleEditLink = async () => { if (!isValidUrl(targetUrl)) { showToast('Please enter a valid URL', 'error'); return; } - + try { const response = await fetch(`/api/link`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ + body: JSON.stringify({ shortId: shortId, - target_url: targetUrl + target_url: targetUrl, }), }); - + const data = await response.json(); - + if (data.success) { showToast('Link updated successfully', 'success'); setIsEditing(false); @@ -233,7 +243,7 @@ export default function LinkDetailPage() { setLink({ ...link, target_url: targetUrl, - last_modified: new Date() + last_modified: new Date(), }); } } else { @@ -243,7 +253,7 @@ export default function LinkDetailPage() { showToast('An error occurred while updating the link', 'error'); } }; - + const handleDeleteAnalytics = async () => { try { const response = await fetch('/api/analytics', { @@ -251,19 +261,19 @@ export default function LinkDetailPage() { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ + body: JSON.stringify({ link_id: shortId, - analytics_id: analyticsToDelete + analytics_id: analyticsToDelete, }), }); - + const data = await response.json(); - + if (data.success) { showToast('Analytics entry deleted successfully', 'success'); - + setAnalytics(analytics.filter(item => item._id?.toString() !== analyticsToDelete)); - + setTotalAnalytics(prev => prev - 1); } else { showToast(data.message || 'Failed to delete analytics entry', 'error'); @@ -275,7 +285,7 @@ export default function LinkDetailPage() { setAnalyticsToDelete(''); } }; - + const handleDeleteAllAnalytics = async () => { try { const response = await fetch('/api/analytics', { @@ -283,17 +293,17 @@ export default function LinkDetailPage() { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ + body: JSON.stringify({ link_id: shortId, - delete_all: true + delete_all: true, }), }); - + const data = await response.json(); - + if (data.success) { showToast('All analytics entries deleted successfully', 'success'); - + setAnalytics([]); setTotalAnalytics(0); setBrowserStats([]); @@ -309,7 +319,7 @@ export default function LinkDetailPage() { setShowDeleteAllModal(false); } }; - + if (isLoading) { return (
@@ -318,16 +328,16 @@ export default function LinkDetailPage() {
); } - + return (

Link Details

- + Back to Dashboard
- +

Link Information

@@ -336,20 +346,20 @@ export default function LinkDetailPage() { Short ID: {shortId}
- +
-
- +
Created: {link ? new Date(link.created_at).toLocaleString() : ''}
- +
Last Modified: {link ? new Date(link.last_modified).toLocaleString() : ''}
- +
Target URL: {!isEditing && ( - )}
- + {isEditing ? ( -
{ + { e.preventDefault(); handleEditLink(); }} @@ -397,15 +404,15 @@ export default function LinkDetailPage() { > setTargetUrl(e.target.value)} + onChange={e => setTargetUrl(e.target.value)} className={styles.urlInput} - placeholder="https://example.com" + placeholder='https://example.com' />
- -
) : ( - {link?.target_url} @@ -436,75 +440,55 @@ export default function LinkDetailPage() {
- +

Analytics

- - Total Clicks: {totalAnalytics} - + Total Clicks: {totalAnalytics} {totalAnalytics > 0 && ( - )}
- + {totalAnalytics > 0 ? ( <>

Browsers

- +
- +

Operating Systems

- +
- +

Countries

- +
- +

IP Versions

-
- - { + onDeleteClick={id => { setAnalyticsToDelete(id); setShowDeleteModal(true); }} @@ -516,31 +500,31 @@ export default function LinkDetailPage() {
)} - + {/* Confirm Delete Modal */} { setShowDeleteModal(false); setAnalyticsToDelete(''); }} /> - + {/* Confirm Delete All Modal */} setShowDeleteAllModal(false)} /> ); -} \ No newline at end of file +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index d70f118..937a79d 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import { useState, useEffect } from 'react'; import { useSession } from 'next-auth/react'; @@ -16,7 +16,7 @@ export default function Dashboard() { const [loading, setLoading] = useState(true); useEffect(() => { - if (status === "unauthenticated") { + if (status === 'unauthenticated') { router.push('/'); } }, [status, router]); @@ -31,11 +31,11 @@ export default function Dashboard() { try { setLoading(true); const response = await fetch('/api/links'); - + if (!response.ok) { throw new Error('Failed to fetch links'); } - + const data = await response.json(); if (data.success) { setLinks(data.links); @@ -50,11 +50,11 @@ export default function Dashboard() { } }; - if (status === "loading") { + if (status === 'loading') { return
Loading...
; } - if (status === "unauthenticated") { + if (status === 'unauthenticated') { return null; } @@ -63,12 +63,12 @@ export default function Dashboard() {

Dashboard

- + - + {session?.user?.isAdmin && ( - + )} @@ -79,7 +79,7 @@ export default function Dashboard() {

Create New Short Link

- +

Your Shortened Links

{loading ? ( @@ -104,15 +104,15 @@ function CreateLinkForm({ onLinkCreated }: CreateLinkFormProps) { const [url, setUrl] = useState(''); const [creating, setCreating] = useState(false); const { showToast } = useToast(); - - const handleSubmit = async (e: { preventDefault: () => void; }) => { + + const handleSubmit = async (e: { preventDefault: () => void }) => { e.preventDefault(); - + if (!url.trim()) { showToast('Please enter a URL', 'error'); return; } - + try { setCreating(true); const response = await fetch('/api/link', { @@ -122,9 +122,9 @@ function CreateLinkForm({ onLinkCreated }: CreateLinkFormProps) { }, body: JSON.stringify({ target_url: url }), }); - + const data = await response.json(); - + if (response.ok && data.success) { showToast('Link created successfully!', 'success'); setUrl(''); @@ -139,26 +139,22 @@ function CreateLinkForm({ onLinkCreated }: CreateLinkFormProps) { setCreating(false); } }; - + return (
setUrl(e.target.value)} - placeholder="Enter URL to shorten" + onChange={e => setUrl(e.target.value)} + placeholder='Enter URL to shorten' className={styles.urlInput} required /> -
); -} \ No newline at end of file +} diff --git a/src/app/dashboard/security/Security.module.css b/src/app/dashboard/security/Security.module.css index 2c95f57..4388976 100644 --- a/src/app/dashboard/security/Security.module.css +++ b/src/app/dashboard/security/Security.module.css @@ -107,14 +107,14 @@ align-items: flex-start; gap: 1rem; } - + .dangerCard { flex-direction: column; gap: 1.5rem; align-items: flex-start; } - + .deleteAccountBtn { width: 100%; } -} \ No newline at end of file +} diff --git a/src/app/dashboard/security/page.tsx b/src/app/dashboard/security/page.tsx index fb34cc8..b77c1a8 100644 --- a/src/app/dashboard/security/page.tsx +++ b/src/app/dashboard/security/page.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import { useState } from 'react'; import { useSession, signOut } from 'next-auth/react'; @@ -19,20 +19,20 @@ export default function SecurityPage() { const handleAccountDeletion = async () => { try { setIsDeleting(true); - + const response = await fetch('/api/auth/remove', { method: 'DELETE', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }, body: JSON.stringify({ account_id: session?.user?.accountId }), }); - + const data = await response.json(); - + if (response.ok && data.success) { showToast('Account deleted successfully', 'success'); - + await signOut({ redirect: false }); router.push('/'); } else { @@ -52,7 +52,7 @@ export default function SecurityPage() {

Security Settings

- + Back to Dashboard
@@ -68,11 +68,11 @@ export default function SecurityPage() {

Delete Account

- This will permanently delete your account and all associated data. - This action cannot be undone. + This will permanently delete your account and all associated data. This action + cannot be undone.

-
); -} \ No newline at end of file +} diff --git a/src/app/globals.css b/src/app/globals.css index 95e13e2..15422cc 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -40,7 +40,7 @@ body { background-color: var(--bg-primary); color: var(--text-primary); - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 0; min-height: 100vh; @@ -64,4 +64,4 @@ body { padding-top: calc(var(--header-min-height) + 2rem); padding-bottom: calc(var(--footer-min-height) + 2rem); } -} \ No newline at end of file +} diff --git a/src/app/l/[shortId]/route.ts b/src/app/l/[shortId]/route.ts index 5a47355..d2e4bcd 100644 --- a/src/app/l/[shortId]/route.ts +++ b/src/app/l/[shortId]/route.ts @@ -4,33 +4,31 @@ import { getClientInfo } from '@/lib/utils'; import { saveAnalytics } from '@/lib/analyticsdb'; import logger from '@/lib/logger'; -export async function GET( - req: NextRequest, - { params }: { params: Promise<{ shortId: string }> } -) { +export async function GET(req: NextRequest, { params }: { params: Promise<{ shortId: string }> }) { try { const { shortId } = await params; - + const link = await getTargetUrl(shortId); - + if (!link || !link.target_url) { return NextResponse.redirect(new URL('/not-found', req.url)); } - + const clientInfo = await getClientInfo(req); - + const analyticsData = { link_id: shortId, account_id: link.account_id, - ...clientInfo + ...clientInfo, }; - - saveAnalytics(analyticsData) - .catch(err => logger.error('Failed to save analytics', { error: err, shortId })); - + + saveAnalytics(analyticsData).catch(err => + logger.error('Failed to save analytics', { error: err, shortId }) + ); + return NextResponse.redirect(new URL(link.target_url)); } catch (error) { logger.error('Link redirection error', error); return NextResponse.redirect(new URL('/error', req.url)); } -} \ No newline at end of file +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7e6de4e..560a782 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,27 +1,27 @@ import Providers from '@/components/Providers'; -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import Header from "@/components/header"; -import Footer from "@/components/footer"; +import type { Metadata } from 'next'; +import { Geist, Geist_Mono } from 'next/font/google'; +import Header from '@/components/header'; +import Footer from '@/components/footer'; import { ToastProvider } from '@/contexts/ToastContext'; import Toast from '@/components/ui/Toast'; import ResponsiveLayout from '@/components/ResponsiveLayout'; import SessionMonitor from '@/components/SessionMonitor'; -import "./globals.css"; +import './globals.css'; const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], + variable: '--font-geist-sans', + subsets: ['latin'], }); const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], + variable: '--font-geist-mono', + subsets: ['latin'], }); export const metadata: Metadata = { - title: "µLinkShortener", - description: "Create short links and see who accessed them!", + title: 'µLinkShortener', + description: 'Create short links and see who accessed them!', }; export default function RootLayout({ @@ -30,16 +30,12 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - + +
-
- {children} -
+
{children}
diff --git a/src/app/not-found/NotFound.module.css b/src/app/not-found/NotFound.module.css index 1deb072..40f77cd 100644 --- a/src/app/not-found/NotFound.module.css +++ b/src/app/not-found/NotFound.module.css @@ -54,4 +54,4 @@ .hero-cta:hover { background-color: var(--accent-hover); -} \ No newline at end of file +} diff --git a/src/app/not-found/page.tsx b/src/app/not-found/page.tsx index 3c5b56f..1718db2 100644 --- a/src/app/not-found/page.tsx +++ b/src/app/not-found/page.tsx @@ -3,16 +3,16 @@ import styles from './NotFound.module.css'; export default function NotFound() { return ( -
-
-

Link Not Found

-

+

+
+

Link Not Found

+

The shortened link you're looking for doesn't exist or has been removed.

- + Go Home
); -} \ No newline at end of file +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 84f7640..cc0ec07 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; @@ -17,20 +17,20 @@ const sampleData = { { id: 'Thu', count: 35 }, { id: 'Fri', count: 50 }, { id: 'Sat', count: 20 }, - { id: 'Sun', count: 15 } + { id: 'Sun', count: 15 }, ], geoData: [ { id: 'United States', count: 120 }, { id: 'Germany', count: 80 }, { id: 'United Kingdom', count: 65 }, { id: 'Canada', count: 45 }, - { id: 'France', count: 40 } + { id: 'France', count: 40 }, ], deviceData: [ { id: 'Desktop', count: 210 }, { id: 'Mobile', count: 180 }, - { id: 'Tablet', count: 50 } - ] + { id: 'Tablet', count: 50 }, + ], }; export default function Home() { @@ -39,7 +39,7 @@ export default function Home() { const { showToast } = useToast(); const [isLoading, setIsLoading] = useState(false); const [statsLoading, setStatsLoading] = useState(true); - + // State for real statistics data const [ipVersionStats, setIpVersionStats] = useState([]); const [osStats, setOsStats] = useState([]); @@ -47,24 +47,24 @@ export default function Home() { const [ispStats, setIspStats] = useState([]); const [totalClicks, setTotalClicks] = useState(0); const [totalLinks, setTotalLinks] = useState(0); - + useEffect(() => { async function fetchStats() { try { setStatsLoading(true); const response = await fetch('/api/statistics'); - + if (!response.ok) { throw new Error('Failed to fetch statistics'); } - + const data = await response.json(); - + if (data.success && data.stats) { const { stats } = data; setTotalLinks(stats.total_links || 0); setTotalClicks(stats.total_clicks || 0); - + if (stats.chart_data) { setIpVersionStats(stats.chart_data.ip_versions || []); setOsStats(stats.chart_data.os_stats || []); @@ -78,38 +78,38 @@ export default function Home() { setStatsLoading(false); } } - + fetchStats(); }, []); - + const handleGetStarted = async () => { if (status === 'authenticated') { router.push('/dashboard'); return; } - + setIsLoading(true); try { const response = await fetch('/api/auth/register', { method: 'POST', }); - + if (!response.ok) { throw new Error('Registration failed'); } - + const data = await response.json(); - + if (data.success && data.account_id) { const signInResult = await signIn('credentials', { accountId: data.account_id, - redirect: false + redirect: false, }); - + if (signInResult?.error) { throw new Error(signInResult.error); } - + showToast('Account created successfully!', 'success'); router.push('/dashboard'); } else { @@ -122,83 +122,83 @@ export default function Home() { setIsLoading(false); } }; - + return ( -
+
{/* Hero Section */} -
-

µLinkShortener

-

+

+

µLinkShortener

+

An analytics-driven URL shortening service to track and manage your links.

-
{/* Stats Summary */} -
-
-
+
+
+

Total Links

-

{statsLoading ? '...' : totalLinks.toLocaleString()}

+

+ {statsLoading ? '...' : totalLinks.toLocaleString()} +

-
+

Total Clicks

-

{statsLoading ? '...' : totalClicks.toLocaleString()}

+

+ {statsLoading ? '...' : totalClicks.toLocaleString()} +

{/* Graphs Section */} -
-

Analytics Dashboard

-
-
-

IP Versions

-
- 0 ? ipVersionStats : sampleData.deviceData} +
+

Analytics Dashboard

+
+
+

IP Versions

+
+ 0 ? ipVersionStats : sampleData.deviceData} loading={statsLoading} height={250} />
- -
-

Operating Systems

-
- 0 ? osStats : sampleData.deviceData} + +
+

Operating Systems

+
+ 0 ? osStats : sampleData.deviceData} loading={statsLoading} height={250} />
- -
-

Countries

-
- +

Countries

+
+ 0 ? countryStats : sampleData.geoData} loading={statsLoading} height={250} />
- -
-

Internet Service Providers

-
- +

Internet Service Providers

+
+ 0 ? ispStats : sampleData.geoData} loading={statsLoading} height={250} @@ -209,4 +209,4 @@ export default function Home() {
); -} \ No newline at end of file +} diff --git a/src/app/privacy/Privacy.module.css b/src/app/privacy/Privacy.module.css index b5432a0..8a06827 100644 --- a/src/app/privacy/Privacy.module.css +++ b/src/app/privacy/Privacy.module.css @@ -114,4 +114,4 @@ .link:hover { color: var(--primary-color); text-decoration: underline; -} \ No newline at end of file +} diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index 6f6cae8..a5c8e26 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -3,14 +3,16 @@ import styles from './Privacy.module.css'; export default function PrivacyPage() { return ( -
+
{/* Title Section */} -
-

Privacy Policy

-

- We are committed to respecting user privacy while maintaining the integrity and security of our service.

This policy outlines what data we collect, why we collect it, and how it is used. +

+

Privacy Policy

+

+ We are committed to respecting user privacy while maintaining the integrity and security + of our service.

This policy outlines what data we collect, why we collect it, and + how it is used.

- + Back to Home
@@ -29,9 +31,12 @@ export default function PrivacyPage() {
  • ISP information
  • Geographic location based on IP address
  • -

    We also use cookies to store your account session and preferences for your convenience.

    +

    + We also use cookies to store your account session and preferences for your + convenience. +

    - +

    How We Use Your Information

    We use the collected information to:

    @@ -42,36 +47,62 @@ export default function PrivacyPage() {
  • Detect and prevent abusive usage
  • Provide analytics to link creators
  • -

    We do not sell or share your personal data with third parties, except where required by law.

    +

    + We do not sell or share your personal data with third parties, except + where required by law. +

    Third-Party Services

    -

    We use Cloudflare as a content delivery network (CDN) and security provider. Cloudflare may process technical data such as IP addresses, request headers, and browser metadata to deliver and protect the service. This data is handled in accordance with Cloudflare's Privacy Policy.

    +

    + We use Cloudflare as a content delivery network (CDN) and security provider. + Cloudflare may process technical data such as IP addresses, request headers, and + browser metadata to deliver and protect the service. This data is handled in + accordance with{' '} + + Cloudflare's Privacy Policy + + . +

    We do not share user data with any other third-party services.

    - +

    Data Retention

      -
    • Analytics and usage data are retained until explicitly deleted by the link creator.
    • -
    • User accounts and associated data are retained until a deletion request is received.
    • -
    • Shortened URLs remain active until deleted by their creator or by us in accordance with our Terms of Service.
    • +
    • + Analytics and usage data are retained until explicitly deleted by + the link creator. +
    • +
    • + User accounts and associated data are retained until a deletion + request is received. +
    • +
    • + Shortened URLs remain active until deleted by their creator or by us in accordance + with our Terms of Service. +

    Your Rights

    -

    You may request deletion of your account and associated data at any time by contacting us. Deletion is permanent and cannot be reversed.

    +

    + You may request deletion of your account and associated data at any time by contacting + us. Deletion is permanent and cannot be reversed. +

    - +

    Contact Us

    If you have any questions about this Privacy Policy, please contact us at:

    - privacy.uLink@kizuren.dev + + privacy.uLink@kizuren.dev +
    ); -} \ No newline at end of file +} diff --git a/src/app/tos/ToS.module.css b/src/app/tos/ToS.module.css index bfe8006..a9f954f 100644 --- a/src/app/tos/ToS.module.css +++ b/src/app/tos/ToS.module.css @@ -113,4 +113,4 @@ .link:hover { color: var(--primary-color); text-decoration: underline; -} \ No newline at end of file +} diff --git a/src/app/tos/page.tsx b/src/app/tos/page.tsx index 5efe343..22d8e23 100644 --- a/src/app/tos/page.tsx +++ b/src/app/tos/page.tsx @@ -3,14 +3,15 @@ import styles from './ToS.module.css'; export default function TermsOfServicePage() { return ( -
    +
    {/* Title Section */} -
    -

    Terms of Service

    -

    - By using our URL shortening service, you agree to comply with these Terms of Service. Please read them carefully before using the platform. +

    +

    Terms of Service

    +

    + By using our URL shortening service, you agree to comply with these Terms of Service. + Please read them carefully before using the platform.

    - + Back to Home
    @@ -20,29 +21,45 @@ export default function TermsOfServicePage() {

    Acceptance of Terms

    -

    By accessing or using our URL shortening service, you agree to be bound by these Terms of Service. If you do not agree to these terms, do not use the service.

    +

    + By accessing or using our URL shortening service, you agree to be bound by these Terms + of Service. If you do not agree to these terms, do not use the service. +

    - +

    Description of Service

    -

    We provide a URL shortening service with analytics and tracking functionality. The service is provided “as is,” without guarantees or warranties of any kind.

    +

    + We provide a URL shortening service with analytics and tracking functionality. The + service is provided “as is,” without guarantees or warranties of any kind. +

    - +

    User Responsibilities

    -

    By using this service, you agree that you will not:

    +

    + By using this service, you agree that you will not: +

    • Use the service for any unlawful or unauthorized purpose
    • Distribute malware, phishing links, or any malicious code
    • -
    • Infringe on any third party's intellectual property or proprietary rights
    • +
    • + Infringe on any third party's intellectual property or proprietary rights +
    • Harass, spam, or abuse individuals or systems
    • -
    • Attempt to probe, scan, or compromise our infrastructure or interfere with service operation
    • +
    • + Attempt to probe, scan, or compromise our infrastructure or interfere with service + operation +
    - +

    Content Restrictions

    -

    You may not use the service to create or distribute links that direct to content which:

    +

    + You may not use the service to create or distribute links that direct + to content which: +

    • Contains malware, viruses, or other harmful code
    • Facilitates or promotes illegal activity
    • @@ -51,27 +68,45 @@ export default function TermsOfServicePage() {
    • Includes adult or explicit content without compliant age verification
    • Encourages self-harm, suicide, or criminal activity
    -

    We reserve the right to remove or disable any links at any time without explanation.

    +

    + We reserve the right to remove or disable any links at any time without explanation. +

    - +

    Service Modifications

    -

    We may modify, suspend, or discontinue any part of the service at any time, with or without notice. We are not liable for any loss, data deletion, or disruption caused by such changes.

    +

    + We may modify, suspend, or discontinue any part of the service at any time, with or + without notice. We are not liable for any loss, data deletion, or disruption caused by + such changes. +

    - +

    Termination

    -

    We may suspend or terminate your access to the service at any time, with or without notice, for any reason we deem appropriate. This includes, but is not limited to, violations of these Terms, behavior we consider abusive, disruptive, unlawful, or harmful to the service, to us, to other users, or to third parties. Termination is at our sole discretion and may be irreversible. We are under no obligation to preserve, return, or provide access to any data following termination.

    -

    Attempts to bypass suspension or re-register after termination may result in permanent blocking.

    +

    + We may suspend or terminate your access to the service at any time, with or without + notice, for any reason we deem appropriate. This includes, but is not limited to, + violations of these Terms, behavior we consider abusive, disruptive, unlawful, or + harmful to the service, to us, to other users, or to third parties. Termination is at + our sole discretion and may be irreversible. We are under no obligation to preserve, + return, or provide access to any data following termination. +

    +

    + Attempts to bypass suspension or re-register after termination may result in permanent + blocking. +

    - +

    Contact Us

    If you have any questions about these Terms of Service, please contact us at:

    - terms.uLink@kizuren.dev + + terms.uLink@kizuren.dev +
    ); -} \ No newline at end of file +} diff --git a/src/components/Footer.module.css b/src/components/Footer.module.css index 725cd88..e0891ea 100644 --- a/src/components/Footer.module.css +++ b/src/components/Footer.module.css @@ -50,4 +50,4 @@ .footer { height: auto; } -} \ No newline at end of file +} diff --git a/src/components/Header.module.css b/src/components/Header.module.css index 5ba5a5a..785a5b3 100644 --- a/src/components/Header.module.css +++ b/src/components/Header.module.css @@ -40,7 +40,11 @@ } /* Base button styles for all buttons */ -.loginBtn, .registerBtn, .loginSubmitBtn, .logoutBtn, .dashboardBtn { +.loginBtn, +.registerBtn, +.loginSubmitBtn, +.logoutBtn, +.dashboardBtn { background-color: var(--accent); color: var(--text-primary); border: none; @@ -51,7 +55,10 @@ padding: clamp(4px, 1vw, 8px) clamp(8px, 2vw, 16px); } -.loginBtn:hover, .registerBtn:hover, .loginSubmitBtn:hover, .dashboardBtn:hover { +.loginBtn:hover, +.registerBtn:hover, +.loginSubmitBtn:hover, +.dashboardBtn:hover { background-color: var(--accent-hover); filter: brightness(1.2); } @@ -85,7 +92,7 @@ .container { padding: 0 1rem; } - + .auth { gap: 5px; } @@ -100,14 +107,15 @@ .header { height: auto; } - + .auth { width: 100%; justify-content: center; gap: 10px; } - - .loginBtn, .registerBtn { + + .loginBtn, + .registerBtn { padding: 8px 16px; font-size: 0.9rem; } @@ -166,7 +174,7 @@ flex-direction: column; align-items: stretch; } - + .auth { min-width: auto; } @@ -219,4 +227,4 @@ .idLabel { color: var(--text-secondary); -} \ No newline at end of file +} diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx index 84bb2d3..db649c3 100644 --- a/src/components/Providers.tsx +++ b/src/components/Providers.tsx @@ -1,11 +1,7 @@ -"use client"; +'use client'; -import { SessionProvider } from "next-auth/react"; +import { SessionProvider } from 'next-auth/react'; export default function Providers({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} \ No newline at end of file + return {children}; +} diff --git a/src/components/ResponsiveLayout.tsx b/src/components/ResponsiveLayout.tsx index b5969d1..09f7d9e 100644 --- a/src/components/ResponsiveLayout.tsx +++ b/src/components/ResponsiveLayout.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import { useEffect } from 'react'; @@ -7,11 +7,11 @@ export default function ResponsiveLayout() { const header = document.querySelector('header'); const footer = document.querySelector('footer'); const content = document.querySelector('.page-content'); - + if (header && footer && content) { const headerHeight = header.getBoundingClientRect().height; const footerHeight = footer.getBoundingClientRect().height; - + (content as HTMLElement).style.paddingTop = `${headerHeight}px`; (content as HTMLElement).style.paddingBottom = `${footerHeight}px`; } @@ -20,24 +20,24 @@ export default function ResponsiveLayout() { useEffect(() => { adjustLayout(); window.addEventListener('resize', adjustLayout); - + const observer = new MutationObserver(() => { setTimeout(adjustLayout, 100); }); - + const header = document.querySelector('header'); const footer = document.querySelector('footer'); - + if (header && footer) { observer.observe(header, { subtree: true, childList: true, attributes: true }); observer.observe(footer, { subtree: true, childList: true, attributes: true }); } - + return () => { window.removeEventListener('resize', adjustLayout); observer.disconnect(); }; }, []); - + return null; -} \ No newline at end of file +} diff --git a/src/components/SessionMonitor.tsx b/src/components/SessionMonitor.tsx index 8e9bb76..623fd67 100644 --- a/src/components/SessionMonitor.tsx +++ b/src/components/SessionMonitor.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import { useEffect, useRef } from 'react'; import { signOut, useSession } from 'next-auth/react'; @@ -8,22 +8,22 @@ export default function SessionMonitor() { const { data: session } = useSession(); const { showToast } = useToast(); const intervalRef = useRef(null); - + useEffect(() => { if (intervalRef.current) { clearInterval(intervalRef.current); } - + if (!session?.user) return; - + const checkSession = async () => { try { const res = await fetch('/api/auth/check-session'); - + if (!res.ok) { const data = await res.json(); console.log('Session check failed:', data.message); - + showToast('Your session has expired or been revoked from another device', 'error'); await signOut({ redirect: true, callbackUrl: '/' }); } @@ -31,15 +31,15 @@ export default function SessionMonitor() { console.error('Error checking session:', error); } }; - + intervalRef.current = setInterval(checkSession, 10000); - + return () => { if (intervalRef.current) { clearInterval(intervalRef.current); } }; }, [session, showToast]); - + return null; -} \ No newline at end of file +} diff --git a/src/components/footer.tsx b/src/components/footer.tsx index 09af93c..2b09f0f 100644 --- a/src/components/footer.tsx +++ b/src/components/footer.tsx @@ -5,15 +5,13 @@ export default function Footer() { return (
    -
    - © {new Date().getFullYear()} µLinkShortener -
    +
    © {new Date().getFullYear()} µLinkShortener
    - Privacy Policy - Terms of Service - GitHub + Privacy Policy + Terms of Service + GitHub
    ); -} \ No newline at end of file +} diff --git a/src/components/header.tsx b/src/components/header.tsx index e6e7e73..6e2ee49 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -1,16 +1,17 @@ -"use client"; +'use client'; import Link from 'next/link'; import { useState, useRef, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { signIn, signOut, useSession } from "next-auth/react"; +import { signIn, signOut, useSession } from 'next-auth/react'; import styles from './Header.module.css'; import LoadingIcon from '@/components/ui/LoadingIcon'; import { useToast } from '@/contexts/ToastContext'; const copyAccountIdToClipboard = (accountId: string) => { if (navigator.clipboard && accountId) { - navigator.clipboard.writeText(accountId) + navigator.clipboard + .writeText(accountId) .then(() => { const displayElement = document.querySelector(`.${styles.accountIdDisplay}`); if (displayElement) { @@ -33,9 +34,9 @@ export default function Header() { const router = useRouter(); const { data: session, status } = useSession(); const { showToast } = useToast(); - - const isLoggedIn = status === "authenticated"; - const accountId = session?.user?.accountId as string || ''; + + const isLoggedIn = status === 'authenticated'; + const accountId = (session?.user?.accountId as string) || ''; useEffect(() => { if (showLoginForm && inputRef.current) { @@ -44,19 +45,19 @@ export default function Header() { }, 10); } }, [showLoginForm]); - + const handleLoginSubmit = async (e: React.FormEvent) => { e.preventDefault(); const enteredAccountId = inputRef.current?.value; - + if (enteredAccountId) { setIsLoading(true); try { - const result = await signIn("credentials", { + const result = await signIn('credentials', { accountId: enteredAccountId, - redirect: false + redirect: false, }); - + if (result?.error) { showToast('Account not found. Please check your Account ID.', 'error'); } else { @@ -78,19 +79,19 @@ export default function Header() { const response = await fetch('/api/auth/register', { method: 'POST', }); - + if (!response.ok) { throw new Error('Registration failed'); } - + const data = await response.json(); - + if (data.success && data.account_id) { - await signIn("credentials", { + await signIn('credentials', { accountId: data.account_id, - redirect: false + redirect: false, }); - + router.push('/dashboard'); } else { throw new Error(data.message || 'Failed to create account'); @@ -102,7 +103,7 @@ export default function Header() { setIsLoading(false); } }; - + const handleLogout = async () => { setIsLoading(true); try { @@ -120,69 +121,60 @@ export default function Header() {
    - +

    µLinkShortener

    - +
    - {status === "loading" || isLoading ? ( - + {status === 'loading' || isLoading ? ( + ) : isLoggedIn ? (
    - copyAccountIdToClipboard(accountId)} - title="Click to copy account ID" + title='Click to copy account ID' > Account ID: {accountId} Copied! - - +
    ) : !showLoginForm ? ( <> - - ) : ( -
    - -
    ); -} \ No newline at end of file +} diff --git a/src/components/ui/ConfirmModal.module.css b/src/components/ui/ConfirmModal.module.css index be7bfdf..06cc7c0 100644 --- a/src/components/ui/ConfirmModal.module.css +++ b/src/components/ui/ConfirmModal.module.css @@ -74,11 +74,21 @@ } @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } @keyframes slideUp { - from { transform: translateY(30px); opacity: 0; } - to { transform: translateY(0); opacity: 1; } -} \ No newline at end of file + from { + transform: translateY(30px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} diff --git a/src/components/ui/ConfirmModal.tsx b/src/components/ui/ConfirmModal.tsx index 2da253f..be5095d 100644 --- a/src/components/ui/ConfirmModal.tsx +++ b/src/components/ui/ConfirmModal.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import { useEffect, useRef } from 'react'; import styles from './ConfirmModal.module.css'; @@ -17,10 +17,10 @@ export default function ConfirmModal({ isOpen, title, message, - confirmLabel = "Delete", - cancelLabel = "Cancel", + confirmLabel = 'Delete', + cancelLabel = 'Cancel', onConfirm, - onCancel + onCancel, }: ConfirmModalProps) { const modalRef = useRef(null); @@ -29,12 +29,12 @@ export default function ConfirmModal({ if (isOpen) { // Prevent scrolling of background content document.body.style.overflow = 'hidden'; - + // Auto focus the first button const focusableElements = modalRef.current?.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); - + if (focusableElements && focusableElements.length > 0) { (focusableElements[0] as HTMLElement).focus(); } @@ -45,7 +45,7 @@ export default function ConfirmModal({ onCancel(); } }; - + document.addEventListener('keydown', handleKeyDown); return () => { document.body.style.overflow = ''; @@ -58,33 +58,29 @@ export default function ConfirmModal({ return (
    -
    e.stopPropagation()} ref={modalRef} - role="dialog" - aria-labelledby="modal-title" - aria-modal="true" + role='dialog' + aria-labelledby='modal-title' + aria-modal='true' > - +

    {message}

    - -
    ); -} \ No newline at end of file +} diff --git a/src/components/ui/Graph.module.css b/src/components/ui/Graph.module.css index 1f63377..7f311c9 100644 --- a/src/components/ui/Graph.module.css +++ b/src/components/ui/Graph.module.css @@ -43,4 +43,4 @@ .graphContainer { min-height: 12.5rem; } -} \ No newline at end of file +} diff --git a/src/components/ui/Graph.tsx b/src/components/ui/Graph.tsx index b284097..27e626c 100644 --- a/src/components/ui/Graph.tsx +++ b/src/components/ui/Graph.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import { useEffect, useState, useRef } from 'react'; import { @@ -62,7 +62,7 @@ export default function Graph({ loading = false, height = 200, maxItems = 8, - colors = defaultColors + colors = defaultColors, }: GraphProps) { const [chartLabels, setChartLabels] = useState([]); const [chartValues, setChartValues] = useState([]); @@ -78,14 +78,14 @@ export default function Graph({ const processedData = [...data]; let labels: string[] = []; let values: number[] = []; - + if (processedData.length > maxItems) { const topItems = processedData.slice(0, maxItems - 1); const others = processedData.slice(maxItems - 1); - + labels = topItems.map(item => item.id); values = topItems.map(item => item.count); - + const othersSum = others.reduce((sum, item) => sum + item.count, 0); labels.push('Others'); values.push(othersSum); @@ -106,22 +106,22 @@ export default function Graph({ chartRef.current.update(); } }); - + const currentContainer = containerRef.current; - + if (currentContainer) { resizeObserver.observe(currentContainer); } - + // Also handle window resize const handleResize = () => { if (chartRef.current) { chartRef.current.update(); } }; - + window.addEventListener('resize', handleResize); - + return () => { if (currentContainer) { resizeObserver.unobserve(currentContainer); @@ -141,11 +141,11 @@ export default function Graph({ labels: { color: 'white', font: { - size: 10 + size: 10, }, boxWidth: 12, - padding: 10 - } + padding: 10, + }, }, tooltip: { backgroundColor: 'var(--card-bg)', @@ -155,10 +155,10 @@ export default function Graph({ borderWidth: 1, padding: 8, boxWidth: 10, - boxHeight: 10 - } + boxHeight: 10, + }, }, - color: 'white' + color: 'white', }; // Only add scales for bar and line charts @@ -170,40 +170,40 @@ export default function Graph({ ticks: { color: 'white', font: { - size: 10 + size: 10, }, maxRotation: 45, minRotation: 45, autoSkip: true, - maxTicksLimit: 10 + maxTicksLimit: 10, }, grid: { color: 'rgba(255, 255, 255, 0.1)', border: { - display: false - } - } + display: false, + }, + }, }, y: { beginAtZero: true, ticks: { color: 'white', font: { - size: 10 + size: 10, }, - precision: 0 + precision: 0, }, grid: { color: 'rgba(255, 255, 255, 0.1)', border: { - display: false - } - } - } - } + display: false, + }, + }, + }, + }, }; } - + return baseOptions; }; @@ -217,7 +217,7 @@ export default function Graph({ backgroundColor: chartColors, borderColor: chartColors, borderWidth: 1, - borderRadius: 4 + borderRadius: 4, }, ], }); @@ -233,7 +233,7 @@ export default function Graph({ borderWidth: 2, tension: 0.1, pointRadius: 3, - pointHoverRadius: 5 + pointHoverRadius: 5, }, ], }); @@ -247,7 +247,7 @@ export default function Graph({ backgroundColor: chartColors, borderColor: chartColors, borderWidth: 1, - hoverOffset: 5 + hoverOffset: 5, }, ], }); @@ -261,28 +261,28 @@ export default function Graph({ ) : data && data.length > 0 ? ( <> {type === 'bar' && ( - { + { if (ref) chartRef.current = ref; }} /> )} {type === 'line' && ( - { + { if (ref) chartRef.current = ref; }} /> )} {type === 'doughnut' && ( - { + { if (ref) chartRef.current = ref; }} /> @@ -293,4 +293,4 @@ export default function Graph({ )} ); -} \ No newline at end of file +} diff --git a/src/components/ui/LoadingIcon.tsx b/src/components/ui/LoadingIcon.tsx index ac0052c..d02eb2a 100644 --- a/src/components/ui/LoadingIcon.tsx +++ b/src/components/ui/LoadingIcon.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; interface LoadingIconProps { size?: number; @@ -7,14 +7,14 @@ interface LoadingIconProps { className?: string; } -export default function LoadingIcon({ - size = 24, +export default function LoadingIcon({ + size = 24, color = 'var(--accent)', thickness = 2, - className = '' + className = '', }: LoadingIconProps) { return ( -
    ); -} \ No newline at end of file +} diff --git a/src/components/ui/Toast.module.css b/src/components/ui/Toast.module.css index 37bada8..97afadd 100644 --- a/src/components/ui/Toast.module.css +++ b/src/components/ui/Toast.module.css @@ -17,7 +17,9 @@ cursor: pointer; opacity: 0; transform: translateY(-20px); - transition: opacity 0.3s, transform 0.3s; + transition: + opacity 0.3s, + transform 0.3s; } .toast.error { @@ -52,6 +54,10 @@ } @keyframes fadeOut { - from { opacity: 1; } - to { opacity: 0; } -} \ No newline at end of file + from { + opacity: 1; + } + to { + opacity: 0; + } +} diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx index e5bf0eb..74cc0c1 100644 --- a/src/components/ui/Toast.tsx +++ b/src/components/ui/Toast.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import { useToast } from '@/contexts/ToastContext'; import styles from './Toast.module.css'; @@ -10,8 +10,8 @@ export default function Toast() { return (
    - {toasts.map((toast) => ( -
    ( +
    hideToast(toast.id)} @@ -21,4 +21,4 @@ export default function Toast() { ))}
    ); -} \ No newline at end of file +} diff --git a/src/components/ui/admin/AdminLinkTable.module.css b/src/components/ui/admin/AdminLinkTable.module.css index 5ffb49f..8b20de8 100644 --- a/src/components/ui/admin/AdminLinkTable.module.css +++ b/src/components/ui/admin/AdminLinkTable.module.css @@ -161,25 +161,25 @@ .hideOnMobile { display: none; } - + .shortLinkCell { width: 25%; } - + .targetUrl { width: 45%; } - + .targetUrl a { max-width: 120px; } - + .actions { width: 60%; flex-direction: column; } - - .copyButton, + + .copyButton, .deleteButton { padding: 0.3rem 0.5rem; font-size: 0.75rem; @@ -191,29 +191,29 @@ .tableContainer { border: none; } - + .linkTable { font-size: 0.8rem; } - - .linkTable th, + + .linkTable th, .linkTable td { padding: 0.5rem; } - + .shortLinkCell { width: 30%; } - + .targetUrl { width: 40%; } - + .targetUrl a { max-width: 80px; } - + .actions { width: 70%; } -} \ No newline at end of file +} diff --git a/src/components/ui/admin/AdminLinkTable.tsx b/src/components/ui/admin/AdminLinkTable.tsx index 9cf189d..03c34db 100644 --- a/src/components/ui/admin/AdminLinkTable.tsx +++ b/src/components/ui/admin/AdminLinkTable.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import { useState, useEffect } from 'react'; import Link from 'next/link'; @@ -15,7 +15,7 @@ interface LinkData { interface LinkTableProps { links: LinkData[]; - accountId: string + accountId: string; onLinkDeleted: () => void; } @@ -38,19 +38,21 @@ export default function AdminLinkTable({ links, accountId, onLinkDeleted }: Link } const term = searchTerm.toLowerCase(); - const filtered = links.filter(link => - link.short_id.toLowerCase().includes(term) || - link.target_url.toLowerCase().includes(term) || - new Date(link.created_at).toLocaleString().toLowerCase().includes(term) || - new Date(link.last_modified).toLocaleString().toLowerCase().includes(term) + const filtered = links.filter( + link => + link.short_id.toLowerCase().includes(term) || + link.target_url.toLowerCase().includes(term) || + new Date(link.created_at).toLocaleString().toLowerCase().includes(term) || + new Date(link.last_modified).toLocaleString().toLowerCase().includes(term) ); - + setFilteredLinks(filtered); }, [searchTerm, links]); const copyToClipboard = (shortId: string) => { const fullUrl = `${window.location.origin}/l/${shortId}`; - navigator.clipboard.writeText(fullUrl) + navigator.clipboard + .writeText(fullUrl) .then(() => { showToast('Link copied to clipboard!', 'success'); }) @@ -72,7 +74,7 @@ export default function AdminLinkTable({ links, accountId, onLinkDeleted }: Link const handleDeleteLink = async () => { if (!linkToDelete) return; - + try { setDeletingId(linkToDelete); const response = await fetch(`/api/admin/users/${accountId}/links/${linkToDelete}`, { @@ -82,9 +84,9 @@ export default function AdminLinkTable({ links, accountId, onLinkDeleted }: Link }, body: JSON.stringify({ shortId: linkToDelete }), }); - + const data = await response.json(); - + if (response.ok && data.success) { showToast('Link deleted successfully!', 'success'); if (onLinkDeleted) onLinkDeleted(); @@ -114,17 +116,17 @@ export default function AdminLinkTable({ links, accountId, onLinkDeleted }: Link
    setSearchTerm(e.target.value)} + onChange={e => setSearchTerm(e.target.value)} className={styles.searchInput} /> {searchTerm && ( - @@ -150,10 +152,10 @@ export default function AdminLinkTable({ links, accountId, onLinkDeleted }: Link - {filteredLinks.map((link) => ( + {filteredLinks.map(link => ( - @@ -161,25 +163,25 @@ export default function AdminLinkTable({ links, accountId, onLinkDeleted }: Link - + {truncateUrl(link.target_url)} {formatDate(link.created_at)} {formatDate(link.last_modified)} - - @@ -191,13 +193,13 @@ export default function AdminLinkTable({ links, accountId, onLinkDeleted }: Link
    )} -
    ); -} \ No newline at end of file +} diff --git a/src/components/ui/dashboard/LinkTable.module.css b/src/components/ui/dashboard/LinkTable.module.css index 5ffb49f..8b20de8 100644 --- a/src/components/ui/dashboard/LinkTable.module.css +++ b/src/components/ui/dashboard/LinkTable.module.css @@ -161,25 +161,25 @@ .hideOnMobile { display: none; } - + .shortLinkCell { width: 25%; } - + .targetUrl { width: 45%; } - + .targetUrl a { max-width: 120px; } - + .actions { width: 60%; flex-direction: column; } - - .copyButton, + + .copyButton, .deleteButton { padding: 0.3rem 0.5rem; font-size: 0.75rem; @@ -191,29 +191,29 @@ .tableContainer { border: none; } - + .linkTable { font-size: 0.8rem; } - - .linkTable th, + + .linkTable th, .linkTable td { padding: 0.5rem; } - + .shortLinkCell { width: 30%; } - + .targetUrl { width: 40%; } - + .targetUrl a { max-width: 80px; } - + .actions { width: 70%; } -} \ No newline at end of file +} diff --git a/src/components/ui/dashboard/LinkTable.tsx b/src/components/ui/dashboard/LinkTable.tsx index c586b3f..3bfb715 100644 --- a/src/components/ui/dashboard/LinkTable.tsx +++ b/src/components/ui/dashboard/LinkTable.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import { useState, useEffect } from 'react'; import Link from 'next/link'; @@ -31,19 +31,21 @@ export default function LinkTable({ links, onLinkDeleted }: LinkTableProps) { } const term = searchTerm.toLowerCase(); - const filtered = links.filter(link => - link.short_id.toLowerCase().includes(term) || - link.target_url.toLowerCase().includes(term) || - new Date(link.created_at).toLocaleString().toLowerCase().includes(term) || - new Date(link.last_modified).toLocaleString().toLowerCase().includes(term) + const filtered = links.filter( + link => + link.short_id.toLowerCase().includes(term) || + link.target_url.toLowerCase().includes(term) || + new Date(link.created_at).toLocaleString().toLowerCase().includes(term) || + new Date(link.last_modified).toLocaleString().toLowerCase().includes(term) ); - + setFilteredLinks(filtered); }, [searchTerm, links]); const copyToClipboard = (shortId: string) => { const fullUrl = `${window.location.origin}/l/${shortId}`; - navigator.clipboard.writeText(fullUrl) + navigator.clipboard + .writeText(fullUrl) .then(() => { showToast('Link copied to clipboard!', 'success'); }) @@ -65,7 +67,7 @@ export default function LinkTable({ links, onLinkDeleted }: LinkTableProps) { const handleDeleteLink = async () => { if (!linkToDelete) return; - + try { setDeletingId(linkToDelete); const response = await fetch('/api/link', { @@ -75,9 +77,9 @@ export default function LinkTable({ links, onLinkDeleted }: LinkTableProps) { }, body: JSON.stringify({ shortId: linkToDelete }), }); - + const data = await response.json(); - + if (response.ok && data.success) { showToast('Link deleted successfully!', 'success'); if (onLinkDeleted) onLinkDeleted(); @@ -107,17 +109,17 @@ export default function LinkTable({ links, onLinkDeleted }: LinkTableProps) {
    setSearchTerm(e.target.value)} + onChange={e => setSearchTerm(e.target.value)} className={styles.searchInput} /> {searchTerm && ( - @@ -143,36 +145,33 @@ export default function LinkTable({ links, onLinkDeleted }: LinkTableProps) { - {filteredLinks.map((link) => ( + {filteredLinks.map(link => ( - + {link.short_id} - + {truncateUrl(link.target_url)} {formatDate(link.created_at)} {formatDate(link.last_modified)} - - @@ -184,13 +183,13 @@ export default function LinkTable({ links, onLinkDeleted }: LinkTableProps) {
    )} -
    ); -} \ No newline at end of file +} diff --git a/src/components/ui/dashboard/SessionManager.module.css b/src/components/ui/dashboard/SessionManager.module.css index fd6eb62..1458649 100644 --- a/src/components/ui/dashboard/SessionManager.module.css +++ b/src/components/ui/dashboard/SessionManager.module.css @@ -41,7 +41,9 @@ color: var(--text-primary); } -.loading, .error, .noSessions { +.loading, +.error, +.noSessions { padding: 2rem; text-align: center; border-radius: 4px; @@ -133,7 +135,7 @@ .sessionsTable td:nth-child(3) { display: none; } - + .sessionsTable th:nth-child(4), .sessionsTable td:nth-child(4) { display: none; @@ -144,14 +146,14 @@ .sessionsTable { font-size: 0.8rem; } - - .sessionsTable th, + + .sessionsTable th, .sessionsTable td { padding: 0.5rem; } - + .revokeButton { padding: 0.3rem 0.5rem; font-size: 0.75rem; } -} \ No newline at end of file +} diff --git a/src/components/ui/dashboard/SessionManager.tsx b/src/components/ui/dashboard/SessionManager.tsx index ab8b5ca..1de139b 100644 --- a/src/components/ui/dashboard/SessionManager.tsx +++ b/src/components/ui/dashboard/SessionManager.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import { useState, useEffect, useRef } from 'react'; import { useSession } from 'next-auth/react'; @@ -15,7 +15,7 @@ export default function SessionManager() { const [error, setError] = useState(null); const [revoking, setRevoking] = useState(null); const { showToast } = useToast(); - + const isFetchingSessions = useRef(false); useEffect(() => { @@ -23,17 +23,17 @@ export default function SessionManager() { async function fetchSessions() { if (!session?.user?.accountId) return; - + try { isFetchingSessions.current = true; setLoading(true); - + const response = await fetch('/api/auth/sessions'); - + if (!response.ok) { throw new Error('Failed to fetch sessions'); } - + const data = await response.json(); if (data.success) { setSessions(data.sessions); @@ -60,13 +60,14 @@ export default function SessionManager() { } const term = searchTerm.toLowerCase(); - const filtered = sessions.filter(s => - s.userAgent.toLowerCase().includes(term) || - s.ipAddress.toLowerCase().includes(term) || - new Date(s.lastActive).toLocaleString().toLowerCase().includes(term) || - new Date(s.createdAt).toLocaleString().toLowerCase().includes(term) + const filtered = sessions.filter( + s => + s.userAgent.toLowerCase().includes(term) || + s.ipAddress.toLowerCase().includes(term) || + new Date(s.lastActive).toLocaleString().toLowerCase().includes(term) || + new Date(s.createdAt).toLocaleString().toLowerCase().includes(term) ); - + setFilteredSessions(filtered); }, [searchTerm, sessions]); @@ -74,25 +75,23 @@ export default function SessionManager() { try { const sessionToRevoke = sessions.find(s => s.id === sessionId); if (sessionToRevoke?.isCurrentSession) { - showToast("You cannot revoke your current session", "error"); + showToast('You cannot revoke your current session', 'error'); return; } setRevoking(sessionId); - + const response = await fetch('/api/auth/sessions/revoke', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId }) + body: JSON.stringify({ sessionId }), }); - + const data = await response.json(); - + if (response.ok && data.success) { showToast('Session revoked successfully', 'success'); - setSessions(prevSessions => - prevSessions.filter(s => s.id !== sessionId) - ); + setSessions(prevSessions => prevSessions.filter(s => s.id !== sessionId)); } else { showToast(data.message || 'Failed to revoke session', 'error'); } @@ -120,26 +119,26 @@ export default function SessionManager() { return (

    Active Sessions

    - +
    setSearchTerm(e.target.value)} + onChange={e => setSearchTerm(e.target.value)} className={styles.searchInput} /> {searchTerm && ( - )}
    - + {sessions.length === 0 ? (

    No active sessions found.

    ) : filteredSessions.length === 0 ? ( @@ -157,7 +156,7 @@ export default function SessionManager() { - {filteredSessions.map((s) => ( + {filteredSessions.map(s => ( {s.userAgent.split(' ').slice(0, 3).join(' ')} @@ -186,4 +185,4 @@ export default function SessionManager() { )}
    ); -} \ No newline at end of file +} diff --git a/src/components/ui/dashboard/link/AnalyticsTable.module.css b/src/components/ui/dashboard/link/AnalyticsTable.module.css index 79d25b0..ed8a3fa 100644 --- a/src/components/ui/dashboard/link/AnalyticsTable.module.css +++ b/src/components/ui/dashboard/link/AnalyticsTable.module.css @@ -177,12 +177,12 @@ .tableContainer { padding: 1rem; } - + .analyticsTable th, .analyticsTable td { padding: 0.5rem; } - + .detailsGrid { grid-template-columns: 1fr; } @@ -259,9 +259,9 @@ flex-direction: column; align-items: flex-start; } - + .searchContainer { max-width: 100%; width: 100%; } -} \ No newline at end of file +} diff --git a/src/components/ui/dashboard/link/AnalyticsTable.tsx b/src/components/ui/dashboard/link/AnalyticsTable.tsx index 29e0b68..be595bf 100644 --- a/src/components/ui/dashboard/link/AnalyticsTable.tsx +++ b/src/components/ui/dashboard/link/AnalyticsTable.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import { useState, useEffect } from 'react'; import { Analytics } from '@/types/analytics'; @@ -21,22 +21,22 @@ export default function AnalyticsTable({ currentPage, itemsPerPage, onPageChange, - onDeleteClick + onDeleteClick, }: AnalyticsTableProps) { const [expandedRows, setExpandedRows] = useState>(new Set()); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); - + const totalPages = Math.ceil(totalItems / itemsPerPage); - + useEffect(() => { if (!searchQuery.trim()) { setSearchResults([]); setIsSearching(false); return; } - + setIsSearching(true); const query = searchQuery.toLowerCase(); const filtered = allAnalytics.filter(item => { @@ -46,15 +46,17 @@ export default function AnalyticsTable({ item.ip_version.toLowerCase().includes(query) || item.country.toLowerCase().includes(query) || (item.ip_data?.isp && item.ip_data.isp.toLowerCase().includes(query)) || - // Device and browser info - item.platform.toLowerCase().includes(query) || + item.platform + .toLowerCase() + .includes(query) || item.browser.toLowerCase().includes(query) || item.version.toLowerCase().includes(query) || item.language.toLowerCase().includes(query) || - // Additional details - item.user_agent.toLowerCase().includes(query) || + item.user_agent + .toLowerCase() + .includes(query) || item.referrer.toLowerCase().includes(query) || item.remote_port.toLowerCase().includes(query) || item.accept?.toLowerCase().includes(query) || @@ -62,10 +64,10 @@ export default function AnalyticsTable({ item.accept_encoding?.toLowerCase().includes(query) ); }); - + setSearchResults(filtered); }, [searchQuery, allAnalytics]); - + const toggleRowExpansion = (id: string) => { const newExpandedRows = new Set(expandedRows); if (expandedRows.has(id)) { @@ -75,56 +77,56 @@ export default function AnalyticsTable({ } setExpandedRows(newExpandedRows); }; - + const handleSearchChange = (e: React.ChangeEvent) => { setSearchQuery(e.target.value); - + if (currentPage !== 1) { onPageChange(1); } }; - + const clearSearch = () => { setSearchQuery(''); - + if (currentPage !== 1) { onPageChange(1); } }; - + const displayedAnalytics = isSearching ? searchResults : analytics; - + return (

    Click Details

    {searchQuery && ( - )}
    - + {isSearching && (
    Found {searchResults.length} result{searchResults.length !== 1 ? 's' : ''}
    )} - +
    @@ -141,7 +143,7 @@ export default function AnalyticsTable({ {displayedAnalytics.map(item => { const id = item._id?.toString() || ''; const isExpanded = expandedRows.has(id); - + return ( @@ -151,28 +153,28 @@ export default function AnalyticsTable({ - +
    {new Date(item.timestamp).toLocaleString()} {item.country} -
    ISP: {item.ip_data?.isp || 'Unknown'}
    -
    - {item.platform} +
    + ISP: {item.ip_data?.isp || 'Unknown'} +
    {item.platform} {item.browser} {item.version}
    Lang: {item.language}
    - - @@ -184,21 +186,19 @@ export default function AnalyticsTable({
    - + {displayedAnalytics.length === 0 && (
    - {isSearching - ? `No results found for "${searchQuery}"` - : "No analytics data available"} + {isSearching ? `No results found for "${searchQuery}"` : 'No analytics data available'}
    )} - + {expandedRows.size > 0 && (
    {displayedAnalytics.map(item => { const id = item._id?.toString() || ''; if (!expandedRows.has(id)) return null; - + return (

    Additional Details

    @@ -233,22 +233,22 @@ export default function AnalyticsTable({ })}
    )} - + {totalPages > 1 && !isSearching && (
    - - + Page {currentPage} of {totalPages} - -
    ); -} \ No newline at end of file +} diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx index 9e32bb6..3795a3a 100644 --- a/src/contexts/ToastContext.tsx +++ b/src/contexts/ToastContext.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import React, { createContext, useState, useContext, ReactNode } from 'react'; @@ -24,15 +24,15 @@ export function ToastProvider({ children }: { children: ReactNode }) { const showToast = (message: string, type: ToastType = 'info') => { const id = Math.random().toString(36).substring(2, 9); const newToast = { id, message, type }; - setToasts((prev) => [...prev, newToast]); - + setToasts(prev => [...prev, newToast]); + setTimeout(() => { hideToast(id); }, 2000); }; const hideToast = (id: string) => { - setToasts((prev) => prev.filter((toast) => toast.id !== id)); + setToasts(prev => prev.filter(toast => toast.id !== id)); }; return ( @@ -48,4 +48,4 @@ export function useToast() { throw new Error('useToast must be used within a ToastProvider'); } return context; -} \ No newline at end of file +} diff --git a/src/lib/analyticsdb.ts b/src/lib/analyticsdb.ts index 9e28871..fdc9681 100644 --- a/src/lib/analyticsdb.ts +++ b/src/lib/analyticsdb.ts @@ -28,11 +28,7 @@ export async function getIPData(ip: string): Promise { timestamp: new Date(), }; - await collection.updateOne( - { ip_address: ip }, - { $set: ipData }, - { upsert: true } - ); + await collection.updateOne({ ip_address: ip }, { $set: ipData }, { upsert: true }); return ipData; } catch { @@ -42,7 +38,7 @@ export async function getIPData(ip: string): Promise { isp: 'Unknown', country: 'Unknown', timestamp: new Date(), - } + }; } } @@ -54,18 +50,19 @@ export interface AnalyticsQueryOptions { } export async function getAllAnalytics( - account_id: string, - link_id: string, - query_options: AnalyticsQueryOptions = {}): Promise<{analytics: Analytics[]; total: number}> { + account_id: string, + link_id: string, + query_options: AnalyticsQueryOptions = {} +): Promise<{ analytics: Analytics[]; total: number }> { try { const { db } = await getMongo(); const collection = db.collection(Collection.analytics_collection); - + const { startDate, endDate, page = 1, limit = 50 } = query_options; const timestamp: Record = {}; - if (startDate) timestamp["$gte"] = startDate; - if (endDate) timestamp["$lte"] = endDate; - + if (startDate) timestamp['$gte'] = startDate; + if (endDate) timestamp['$lte'] = endDate; + // Overcomplicated shit const query: Omit, 'timestamp'> & { timestamp?: Record } = { account_id, @@ -74,19 +71,19 @@ export async function getAllAnalytics( if (Object.keys(timestamp).length > 0) { query.timestamp = timestamp; } - + const cursor = collection - .find(query) - .sort({ timestamp: -1 }) // Most recent first - .skip((page - 1) * limit) - .limit(limit); - + .find(query) + .sort({ timestamp: -1 }) // Most recent first + .skip((page - 1) * limit) + .limit(limit); + const analytics = await cursor.toArray(); const total = await collection.countDocuments(query); return { analytics, total }; } catch { - return {analytics: [], total: 0}; + return { analytics: [], total: 0 }; } } @@ -95,23 +92,29 @@ export async function saveAnalytics(analytics: Analytics): Promise(Collection.analytics_collection); await collection.insertOne(analytics); - - return { success: true, status: "Analytics successfully saved" }; + + return { success: true, status: 'Analytics successfully saved' }; } catch { - return { success: false, status: "An exception occured" }; + return { success: false, status: 'An exception occured' }; } } -export async function removeAllAnalytics(account_id: string, link_id: string): Promise { +export async function removeAllAnalytics( + account_id: string, + link_id: string +): Promise { try { const { db } = await getMongo(); const collection = db.collection(Collection.analytics_collection); - const result = await collection.deleteMany({account_id: account_id, link_id: link_id}); + const result = await collection.deleteMany({ account_id: account_id, link_id: link_id }); const success = result.deletedCount > 0; - return { success, status: success ? "Analytics were successfully deleted" : "No analytics found" }; + return { + success, + status: success ? 'Analytics were successfully deleted' : 'No analytics found', + }; } catch { - return { success: false, status: "An exception occured" }; + return { success: false, status: 'An exception occured' }; } } @@ -119,28 +122,35 @@ export async function removeAllAnalyticsFromUser(account_id: string): Promise(Collection.analytics_collection); - const result = await collection.deleteMany({account_id: account_id}); + const result = await collection.deleteMany({ account_id: account_id }); const hasRemovedAnalytics = result.deletedCount > 0; - - return { success: true, status: hasRemovedAnalytics ? "All analytics were successfully removed" : "No analytics found" }; + + return { + success: true, + status: hasRemovedAnalytics + ? 'All analytics were successfully removed' + : 'No analytics found', + }; } catch { - return { success: false, status: "An exception occured" }; + return { success: false, status: 'An exception occured' }; } } -export async function removeAnalytics(account_id: string, link_id: string, _id: string): Promise { +export async function removeAnalytics( + account_id: string, + link_id: string, + _id: string +): Promise { const objectId = safeObjectId(_id); - if(!objectId) return { success: false, status: "Invalid object ID" }; + if (!objectId) return { success: false, status: 'Invalid object ID' }; try { const { db } = await getMongo(); const collection = db.collection(Collection.analytics_collection); - await collection.deleteOne( - {_id: objectId, account_id: account_id, link_id: link_id} - ); - - return { success: true, status: "Analytics successfully removed" }; + await collection.deleteOne({ _id: objectId, account_id: account_id, link_id: link_id }); + + return { success: true, status: 'Analytics successfully removed' }; } catch { - return { success: false, status: "An exception occured" }; + return { success: false, status: 'An exception occured' }; } -} \ No newline at end of file +} diff --git a/src/lib/linkdb.ts b/src/lib/linkdb.ts index 6f134f3..b8f7c5d 100644 --- a/src/lib/linkdb.ts +++ b/src/lib/linkdb.ts @@ -3,58 +3,73 @@ import { generateLinkID, isValidUrl } from './utils'; import type { Link } from '@/types/link'; import type { DetailedReturn } from '@/types/global'; -export async function getLinks(account_id: string): Promise<{links: Link[], return: DetailedReturn}> { +export async function getLinks( + account_id: string +): Promise<{ links: Link[]; return: DetailedReturn }> { try { const { db } = await getMongo(); const collection = db.collection(Collection.links_collection); - - const links = await collection.find({ account_id }) - .sort({ created_at: -1 }) - .toArray(); - - return { links, return: { success: true, status: "Links retrieved successfully" } }; + + const links = await collection.find({ account_id }).sort({ created_at: -1 }).toArray(); + + return { links, return: { success: true, status: 'Links retrieved successfully' } }; } catch { - return { links: [], return: { success: false, status: "An exception occurred" } }; + return { links: [], return: { success: false, status: 'An exception occurred' } }; } } -export async function getTargetUrl(short_id: string): Promise<{ target_url: string; account_id: string }> { +export async function getTargetUrl( + short_id: string +): Promise<{ target_url: string; account_id: string }> { try { const { db } = await getMongo(); const collection = db.collection(Collection.links_collection); - const found_link = await collection.findOne({short_id: short_id}); - - if (!found_link) return { target_url: "", account_id: "" }; + const found_link = await collection.findOne({ short_id: short_id }); - return { + if (!found_link) return { target_url: '', account_id: '' }; + + return { target_url: found_link.target_url, - account_id: found_link.account_id + account_id: found_link.account_id, }; } catch { - return { target_url: "", account_id: "" }; + return { target_url: '', account_id: '' }; } } -export async function getLinkById(account_id: string, short_id: string): Promise<{link: Link | null, return: DetailedReturn}> { +export async function getLinkById( + account_id: string, + short_id: string +): Promise<{ link: Link | null; return: DetailedReturn }> { try { const { db } = await getMongo(); const collection = db.collection(Collection.links_collection); - + const link = await collection.findOne({ short_id, account_id }); - + if (!link) { - return { link: null, return: { success: false, status: "Link not found or you don't have permission to view it" } }; + return { + link: null, + return: { + success: false, + status: "Link not found or you don't have permission to view it", + }, + }; } - - return { link, return: { success: true, status: "Link retrieved successfully" } }; + + return { link, return: { success: true, status: 'Link retrieved successfully' } }; } catch { - return { link: null, return: { success: false, status: "An exception occurred" } }; + return { link: null, return: { success: false, status: 'An exception occurred' } }; } } -export async function createLink(account_id: string, target_url: string): Promise<{shortId: string | null, return: DetailedReturn}> { +export async function createLink( + account_id: string, + target_url: string +): Promise<{ shortId: string | null; return: DetailedReturn }> { try { - if(!isValidUrl(target_url)) return { shortId: null, return: { success: false, status: "Invalid target URL" } }; + if (!isValidUrl(target_url)) + return { shortId: null, return: { success: false, status: 'Invalid target URL' } }; const { db } = await getMongo(); const collection = db.collection(Collection.links_collection); @@ -68,38 +83,44 @@ export async function createLink(account_id: string, target_url: string): Promis } while (duplicate); const newLink: Link = { - short_id: shortId, - target_url: target_url, - account_id: account_id, - created_at: new Date(), - last_modified: new Date() - } + short_id: shortId, + target_url: target_url, + account_id: account_id, + created_at: new Date(), + last_modified: new Date(), + }; await collection.insertOne(newLink); - return { shortId, return: { success: true, status: "Link was successfully created" } }; + return { shortId, return: { success: true, status: 'Link was successfully created' } }; } catch { - return { shortId: null, return: { success: false, status: "An exception occured" } }; + return { shortId: null, return: { success: false, status: 'An exception occured' } }; } } -export async function editLink(account_id: string, short_id: string, target_url: string): Promise { +export async function editLink( + account_id: string, + short_id: string, + target_url: string +): Promise { try { const { db } = await getMongo(); const collection = db.collection(Collection.links_collection); - + const result = await collection.updateOne( - { account_id: account_id, short_id: short_id }, - { $set: { - target_url: target_url, - last_modified: new Date() - }} + { account_id: account_id, short_id: short_id }, + { + $set: { + target_url: target_url, + last_modified: new Date(), + }, + } ); const success = result.modifiedCount > 0; - return { success, status: success ? "Link was successfully updated" : "Link not found" }; + return { success, status: success ? 'Link was successfully updated' : 'Link not found' }; } catch { - return { success: false, status: "An exception occured" }; + return { success: false, status: 'An exception occured' }; } } @@ -107,12 +128,12 @@ export async function removeLink(account_id: string, short_id: string): Promise< try { const { db } = await getMongo(); const collection = db.collection(Collection.links_collection); - const result = await collection.deleteOne({account_id: account_id, short_id: short_id}); + const result = await collection.deleteOne({ account_id: account_id, short_id: short_id }); const success = result.deletedCount > 0; - return { success, status: success ? "Link was successfully removed" : "Link not found" }; + return { success, status: success ? 'Link was successfully removed' : 'Link not found' }; } catch { - return { success: false, status: "An exception occured" }; + return { success: false, status: 'An exception occured' }; } } @@ -120,12 +141,15 @@ export async function removeAllLinksFromUser(account_id: string): Promise(Collection.links_collection); - const result = await collection.deleteMany({account_id: account_id}); + const result = await collection.deleteMany({ account_id: account_id }); const hasRemovedLinks = result.deletedCount > 0; // Here it doesn't matter if no links were removed - return { success: true, status: hasRemovedLinks ? "Links were successfully removed" : "No Links found" }; + return { + success: true, + status: hasRemovedLinks ? 'Links were successfully removed' : 'No Links found', + }; } catch { - return { success: false, status: "An exception occured" }; + return { success: false, status: 'An exception occured' }; } -} \ No newline at end of file +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 733888b..7be4688 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -2,21 +2,15 @@ import winston from 'winston'; const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', - format: winston.format.combine( - winston.format.timestamp(), - winston.format.json() - ), + format: winston.format.combine(winston.format.timestamp(), winston.format.json()), transports: [ new winston.transports.Console({ - format: winston.format.combine( - winston.format.colorize(), - winston.format.simple() - ) + format: winston.format.combine(winston.format.colorize(), winston.format.simple()), }), - + new winston.transports.File({ filename: 'error.log', level: 'error' }), - new winston.transports.File({ filename: 'combined.log' }) - ] + new winston.transports.File({ filename: 'combined.log' }), + ], }); -export default logger; \ No newline at end of file +export default logger; diff --git a/src/lib/mongodb.ts b/src/lib/mongodb.ts index be30113..2471bf3 100644 --- a/src/lib/mongodb.ts +++ b/src/lib/mongodb.ts @@ -1,22 +1,22 @@ -import { Db, MongoClient, ObjectId } from "mongodb"; +import { Db, MongoClient, ObjectId } from 'mongodb'; let client: MongoClient; let db: Db; export const Collection = { - links_collection: "links", - analytics_collection: "analytics", - statistics_collection: "statistics", - user_collection: "users", - sessions_collection: "sessions", - ip_addresses_collection: "ip_addresses" + links_collection: 'links', + analytics_collection: 'analytics', + statistics_collection: 'statistics', + user_collection: 'users', + sessions_collection: 'sessions', + ip_addresses_collection: 'ip_addresses', }; export async function getMongo(): Promise<{ client: MongoClient; db: Db }> { if (client && db) { return { client, db }; } - + if (!process.env.MONGO_URI) { throw new Error('Please add your MongoDB URI to .env'); } @@ -37,4 +37,4 @@ export function safeObjectId(id: string): ObjectId | null { } catch { return null; } -} \ No newline at end of file +} diff --git a/src/lib/sessiondb.ts b/src/lib/sessiondb.ts index dfc7a82..a1eb90e 100644 --- a/src/lib/sessiondb.ts +++ b/src/lib/sessiondb.ts @@ -5,18 +5,18 @@ import { v4 as uuidv4 } from 'uuid'; import logger from '@/lib/logger'; export async function createSession( - accountId: string, - userAgent: string, + accountId: string, + userAgent: string, ipAddress: string -): Promise<{sessionId: string; return: DetailedReturn}> { +): Promise<{ sessionId: string; return: DetailedReturn }> { try { const { db } = await getMongo(); const collection = db.collection(Collection.sessions_collection); - + const now = new Date(); const expiresAt = new Date(); expiresAt.setDate(now.getDate() + 30); - + const sessionId = uuidv4(); const session: SessionInfo = { id: sessionId, @@ -25,20 +25,20 @@ export async function createSession( ipAddress, lastActive: now, createdAt: now, - expiresAt + expiresAt, }; - + await collection.insertOne(session); - + return { sessionId, - return: { success: true, status: "Session created" } + return: { success: true, status: 'Session created' }, }; } catch (error) { logger.error('Error creating session:', error); return { sessionId: '', - return: { success: false, status: "Failed to create session" } + return: { success: false, status: 'Failed to create session' }, }; } } @@ -47,13 +47,13 @@ export async function isSessionValid(sessionId: string, accountId: string): Prom try { const { db } = await getMongo(); const collection = db.collection(Collection.sessions_collection); - + const session = await collection.findOne({ id: sessionId, accountId: accountId, - revoked: { $ne: true } + revoked: { $ne: true }, }); - + return !!session; } catch (error) { logger.error('Error checking session validity:', { error, sessionId, accountId }); @@ -65,15 +65,15 @@ export async function getSessions(accountId: string): Promise { try { const { db } = await getMongo(); const collection = db.collection(Collection.sessions_collection); - + const sessions = await collection - .find({ - accountId, - expiresAt: { $gt: new Date() } + .find({ + accountId, + expiresAt: { $gt: new Date() }, }) .sort({ lastActive: -1 }) .toArray(); - + return sessions; } catch (error) { logger.error('Error getting sessions:', error); @@ -85,21 +85,21 @@ export async function revokeSession(sessionId: string, accountId: string): Promi try { const { db } = await getMongo(); const collection = db.collection(Collection.sessions_collection); - - const result = await collection.deleteOne({ + + const result = await collection.deleteOne({ id: sessionId, - accountId + accountId, }); - + return { success: result.deletedCount > 0, - status: result.deletedCount > 0 ? "Session revoked" : "Session not found" + status: result.deletedCount > 0 ? 'Session revoked' : 'Session not found', }; } catch (error) { logger.error('Error revoking session:', error); return { success: false, - status: "Failed to revoke session" + status: 'Failed to revoke session', }; } } @@ -108,20 +108,20 @@ export async function removeAllSessionsByAccountId(accountId: string): Promise(Collection.sessions_collection); - - const result = await collection.deleteMany({ - accountId + + const result = await collection.deleteMany({ + accountId, }); - + return { success: result.deletedCount > 0, - status: result.deletedCount > 0 ? "Sessions terminated" : "No Sessions found" + status: result.deletedCount > 0 ? 'Sessions terminated' : 'No Sessions found', }; } catch (error) { logger.error('Error terminating session:', error); return { success: false, - status: "Failed to terminate session" + status: 'Failed to terminate session', }; } } @@ -130,12 +130,9 @@ export async function updateSessionActivity(sessionId: string): Promise { try { const { db } = await getMongo(); const collection = db.collection(Collection.sessions_collection); - - await collection.updateOne( - { id: sessionId }, - { $set: { lastActive: new Date() } } - ); + + await collection.updateOne({ id: sessionId }, { $set: { lastActive: new Date() } }); } catch (error) { logger.error('Error updating session activity:', error); } -} \ No newline at end of file +} diff --git a/src/lib/statisticsdb.ts b/src/lib/statisticsdb.ts index 2d42b59..e6ab80b 100644 --- a/src/lib/statisticsdb.ts +++ b/src/lib/statisticsdb.ts @@ -23,40 +23,46 @@ export async function updateStats(): Promise { try { const { db } = await getMongo(); const statisticsCollection = db.collection(Collection.statistics_collection); - + // Get and update the data const analyticsCollection = db.collection(Collection.analytics_collection); const total_links = await db.collection(Collection.links_collection).countDocuments(); const total_clicks = await analyticsCollection.countDocuments(); - const ipv6_count = await analyticsCollection.countDocuments({ip_version: 'IPv6'}); + const ipv6_count = await analyticsCollection.countDocuments({ ip_version: 'IPv6' }); const ip_versions: StatItem[] = [ { id: 'IPv4', count: total_clicks - ipv6_count }, { id: 'IPv6', count: ipv6_count }, ]; - const os_stats_raw = await analyticsCollection.aggregate([ - { $group: { _id: "$platform", count: { $sum: 1 } } }, - { $sort: { count: -1 } }, - { $project: { id: "$_id", count: 1, _id: 0 } } - ]).toArray(); + const os_stats_raw = await analyticsCollection + .aggregate([ + { $group: { _id: '$platform', count: { $sum: 1 } } }, + { $sort: { count: -1 } }, + { $project: { id: '$_id', count: 1, _id: 0 } }, + ]) + .toArray(); const os_stats = os_stats_raw.map(item => ({ id: formatOSStrings(item.id), - count: item.count + count: item.count, })); - const country_stats = await analyticsCollection.aggregate([ - { $group: { _id: "$country", count: { $sum: 1 } } }, - { $sort: { count: -1 } }, - { $project: { id: "$_id", count: 1, _id: 0 } } - ]).toArray(); + const country_stats = await analyticsCollection + .aggregate([ + { $group: { _id: '$country', count: { $sum: 1 } } }, + { $sort: { count: -1 } }, + { $project: { id: '$_id', count: 1, _id: 0 } }, + ]) + .toArray(); - const isp_stats = await analyticsCollection.aggregate([ - { $group: { _id: "$ip_data.isp", count: { $sum: 1 } } }, - { $sort: { count: -1 } }, - { $project: { id: "$_id", count: 1, _id: 0 } } - ]).toArray(); + const isp_stats = await analyticsCollection + .aggregate([ + { $group: { _id: '$ip_data.isp', count: { $sum: 1 } } }, + { $sort: { count: -1 } }, + { $project: { id: '$_id', count: 1, _id: 0 } }, + ]) + .toArray(); const newStats: Stats = { total_links, @@ -65,7 +71,7 @@ export async function updateStats(): Promise { ip_versions, os_stats, country_stats, - isp_stats + isp_stats, }, last_updated: new Date(), }; @@ -73,8 +79,8 @@ export async function updateStats(): Promise { const result = await statisticsCollection.replaceOne({}, newStats, { upsert: true }); const success = result.modifiedCount > 0; - return { success, status: success ? "Stats successfully updated" : "Failed to update stats" }; + return { success, status: success ? 'Stats successfully updated' : 'Failed to update stats' }; } catch { - return { success: false, status: "An exception occurred" }; + return { success: false, status: 'An exception occurred' }; } -} \ No newline at end of file +} diff --git a/src/lib/userdb.ts b/src/lib/userdb.ts index ae1f04d..48b9ee5 100644 --- a/src/lib/userdb.ts +++ b/src/lib/userdb.ts @@ -20,7 +20,7 @@ export async function isUserAdmin(account_id: string): Promise { const { db } = await getMongo(); const collection = db.collection(Collection.user_collection); - const user = await collection.findOne({account_id: account_id}); + const user = await collection.findOne({ account_id: account_id }); return user?.is_admin ?? false; } catch { @@ -39,27 +39,28 @@ export async function makeUserAdmin(account_id: string, admin: boolean): Promise ); if (result.matchedCount === 0) { - return { success: false, status: "User not found" }; + return { success: false, status: 'User not found' }; } - return { - success: result.modifiedCount > 0, - status: result.modifiedCount > 0 - ? `User is now ${admin ? 'an admin' : 'no longer an admin'}` - : "No changes were made" + return { + success: result.modifiedCount > 0, + status: + result.modifiedCount > 0 + ? `User is now ${admin ? 'an admin' : 'no longer an admin'}` + : 'No changes were made', }; } catch (error) { console.error('Error toggling admin status:', error); - return { success: false, status: "An exception occurred" }; + return { success: false, status: 'An exception occurred' }; } } -export async function existsUser(account_id: string) : Promise { +export async function existsUser(account_id: string): Promise { try { const { db } = await getMongo(); const collection = db.collection(Collection.user_collection); - const user = await collection.findOne({account_id: account_id}); + const user = await collection.findOne({ account_id: account_id }); return user !== null; } catch { @@ -67,7 +68,9 @@ export async function existsUser(account_id: string) : Promise { } } -export async function createUser(is_admin: boolean): Promise<{account_id: string, return: DetailedReturn}> { +export async function createUser( + is_admin: boolean +): Promise<{ account_id: string; return: DetailedReturn }> { try { const { db } = await getMongo(); const collection = db.collection(Collection.user_collection); @@ -81,17 +84,18 @@ export async function createUser(is_admin: boolean): Promise<{account_id: string } while (duplicate); const newUser: User = { - account_id: account_id, - is_admin: is_admin, - created_at: new Date() + account_id: account_id, + is_admin: is_admin, + created_at: new Date(), }; const result = await collection.insertOne(newUser); - if(!result.acknowledged) return { account_id: "", return: {success: false, status: "An error occured"} }; + if (!result.acknowledged) + return { account_id: '', return: { success: false, status: 'An error occured' } }; - return { account_id, return: {success: true, status: "User was successfully created"} }; + return { account_id, return: { success: true, status: 'User was successfully created' } }; } catch { - return { account_id: "", return: {success: false, status: "An exception occured"} }; + return { account_id: '', return: { success: false, status: 'An exception occured' } }; } } @@ -100,15 +104,16 @@ export async function removeUser(account_id: string): Promise { const { db } = await getMongo(); const collection = db.collection(Collection.user_collection); - const result = await collection.deleteOne({account_id: account_id}); + const result = await collection.deleteOne({ account_id: account_id }); const removeAnalyticsResult = await removeAllAnalyticsFromUser(account_id); const removeLinksResult = await removeAllLinksFromUser(account_id); await removeAllSessionsByAccountId(account_id); - const success = result.deletedCount > 0 && removeAnalyticsResult.success && removeLinksResult.success; + const success = + result.deletedCount > 0 && removeAnalyticsResult.success && removeLinksResult.success; - return { success: success, status: success ? "User successfully deleted" : "An error occured" }; + return { success: success, status: success ? 'User successfully deleted' : 'An error occured' }; } catch { - return { success: false, status: "An exception occured" }; + return { success: false, status: 'An exception occured' }; } } @@ -116,7 +121,7 @@ export async function getUserById(account_id: string): Promise { try { const { db } = await getMongo(); const collection = db.collection(Collection.user_collection); - + const user = await collection.findOne({ account_id }); return user; } catch { @@ -124,7 +129,9 @@ export async function getUserById(account_id: string): Promise { } } -export async function listUsers(query_options: UserQueryOptions = {}): Promise<{users: User[] | null, total: number, return: DetailedReturn}> { +export async function listUsers( + query_options: UserQueryOptions = {} +): Promise<{ users: User[] | null; total: number; return: DetailedReturn }> { try { const { db } = await getMongo(); const collection = db.collection(Collection.user_collection); @@ -138,21 +145,25 @@ export async function listUsers(query_options: UserQueryOptions = {}): Promise<{ if (endDate) query.created_at.$lte = endDate; } - if (search && search.trim() !== "") { - query.account_id = { $regex: new RegExp(search, "i") }; + if (search && search.trim() !== '') { + query.account_id = { $regex: new RegExp(search, 'i') }; } const cursor = collection - .find(query) - .sort({ created_at: -1 }) // Most recent first - .skip((page - 1) * limit) - .limit(limit); + .find(query) + .sort({ created_at: -1 }) // Most recent first + .skip((page - 1) * limit) + .limit(limit); const users = await cursor.toArray(); const total = await collection.countDocuments(query); - return { users: users, total: total, return: {success: true, status: "Users successfully fetched"} }; + return { + users: users, + total: total, + return: { success: true, status: 'Users successfully fetched' }, + }; } catch { - return { users: null, total: 0, return: {success: false, status: "An exception occured"} }; + return { users: null, total: 0, return: { success: false, status: 'An exception occured' } }; } -} \ No newline at end of file +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index be09bf0..76521af 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,6 @@ -import { NextRequest } from "next/server"; +import { NextRequest } from 'next/server'; import { UAParser } from 'ua-parser-js'; -import { getIPData } from "./analyticsdb"; +import { getIPData } from './analyticsdb'; // For accounts const letterBytes = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; @@ -33,23 +33,23 @@ export function generateAuthToken(): string { } // For Links -export function isValidUrl(urlStr: string) : boolean { - if(urlStr.trim() === "") { +export function isValidUrl(urlStr: string): boolean { + if (urlStr.trim() === '') { return false; } try { const parsedUrl = new URL(urlStr); - return parsedUrl.protocol !== "" && parsedUrl.hostname !== ""; + return parsedUrl.protocol !== '' && parsedUrl.hostname !== ''; } catch { return false; } } // For Clients -const defaultValue = "Unknown" -function valueOrDefault(value: string | null) : string { - return !value || value?.trim() === "" ? defaultValue : value; +const defaultValue = 'Unknown'; +function valueOrDefault(value: string | null): string { + return !value || value?.trim() === '' ? defaultValue : value; } export async function getClientInfo(req: NextRequest) { @@ -109,9 +109,9 @@ export async function getClientInfo(req: NextRequest) { // For stats export function formatOSStrings(os_string: string): string { os_string = os_string.trim(); - os_string = os_string.replaceAll("\"", ""); // Windows usually reports ""Windows""" - os_string = os_string.replaceAll("CPU ", ""); // iOS usually reports "CPU ....." - os_string = os_string.replaceAll(" like Mac OS X", ""); // iOS usually reports at its end " like Mac OS X" + os_string = os_string.replaceAll('"', ''); // Windows usually reports ""Windows""" + os_string = os_string.replaceAll('CPU ', ''); // iOS usually reports "CPU ....." + os_string = os_string.replaceAll(' like Mac OS X', ''); // iOS usually reports at its end " like Mac OS X" return os_string; } @@ -119,9 +119,9 @@ export function formatOSStrings(os_string: string): string { // For MongoDB export function sanitizeMongoDocument(doc: T & { _id?: unknown }): T { if (!doc) return doc; - + const sanitized = { ...doc }; delete sanitized._id; - + return sanitized; -} \ No newline at end of file +} diff --git a/src/proxy.ts b/src/proxy.ts index 97f7fa6..17a50c3 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -5,36 +5,37 @@ import type { NextRequest } from 'next/server'; export async function proxy(request: NextRequest) { const path = request.nextUrl.pathname; const response = NextResponse.next(); - + try { - if (path === '/dashboard' || - path === '/admin' || - path.startsWith('/api/link/') || - path.startsWith('/dashboard/') || - path.startsWith('/admin/')) { - - const token = await getToken({ + if ( + path === '/dashboard' || + path === '/admin' || + path.startsWith('/api/link/') || + path.startsWith('/dashboard/') || + path.startsWith('/admin/') + ) { + const token = await getToken({ req: request, - secret: process.env.NEXTAUTH_SECRET || 'fallback-secret-for-testing' + secret: process.env.NEXTAUTH_SECRET || 'fallback-secret-for-testing', }); - + // Not authenticated if (!token) { return NextResponse.redirect(new URL('/', request.url)); } - + // Check token expiration const now = Math.floor(Date.now() / 1000); if (token.exp && (token.exp as number) < now) { return NextResponse.redirect(new URL('/api/auth/signout?callbackUrl=/', request.url)); } - + // Check admin access if ((path === '/admin' || path.startsWith('/admin/')) && !token.isAdmin) { return NextResponse.redirect(new URL('/dashboard', request.url)); } } - + return response; } catch (error) { console.error('Middleware error:', error); @@ -46,10 +47,10 @@ export async function proxy(request: NextRequest) { export const config = { matcher: [ '/dashboard', - '/dashboard/:path*', + '/dashboard/:path*', '/admin', '/admin/:path*', '/api/link/:path*', - '/api/auth/sessions/:path*' - ] -}; \ No newline at end of file + '/api/auth/sessions/:path*', + ], +}; diff --git a/src/types/analytics.d.ts b/src/types/analytics.d.ts index 674f7c2..4504548 100644 --- a/src/types/analytics.d.ts +++ b/src/types/analytics.d.ts @@ -1,30 +1,30 @@ -import { ObjectId } from "mongodb"; +import { ObjectId } from 'mongodb'; export interface Analytics { - _id?: ObjectId - link_id: string; - account_id: string; - ip_address: string; - user_agent: string; - platform: string; - browser: string; - version: string; - language: string; - referrer: string; - timestamp: Date; - remote_port: string; - accept: string; - accept_language: string; - accept_encoding: string; - country: string; - ip_data: IPAddress; - ip_version: string; + _id?: ObjectId; + link_id: string; + account_id: string; + ip_address: string; + user_agent: string; + platform: string; + browser: string; + version: string; + language: string; + referrer: string; + timestamp: Date; + remote_port: string; + accept: string; + accept_language: string; + accept_encoding: string; + country: string; + ip_data: IPAddress; + ip_version: string; } export interface IPAddress { - ip_address: string; - ip_version: string; - isp: string; - country: string; - timestamp: Date; -} \ No newline at end of file + ip_address: string; + ip_version: string; + isp: string; + country: string; + timestamp: Date; +} diff --git a/src/types/auth.d.ts b/src/types/auth.d.ts index 203ca28..2554c56 100644 --- a/src/types/auth.d.ts +++ b/src/types/auth.d.ts @@ -1,11 +1,11 @@ -import "next-auth"; +import 'next-auth'; -declare module "next-auth" { +declare module 'next-auth' { interface Session { user: { accountId: string; isAdmin: boolean; - } & DefaultSession["user"]; + } & DefaultSession['user']; } interface User { @@ -14,9 +14,9 @@ declare module "next-auth" { } } -declare module "next-auth/jwt" { +declare module 'next-auth/jwt' { interface JWT { accountId: string; isAdmin: boolean; } -} \ No newline at end of file +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 9c6cda2..95d40f2 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1,4 +1,4 @@ export interface DetailedReturn { success: boolean; status: string; -} \ No newline at end of file +} diff --git a/src/types/link.d.ts b/src/types/link.d.ts index 8b63bd5..11a4362 100644 --- a/src/types/link.d.ts +++ b/src/types/link.d.ts @@ -1,7 +1,7 @@ export interface Link { - short_id: string; - target_url: string; - account_id: string; - created_at: Date; - last_modified: Date; -} \ No newline at end of file + short_id: string; + target_url: string; + account_id: string; + created_at: Date; + last_modified: Date; +} diff --git a/src/types/session.d.ts b/src/types/session.d.ts index 5365102..f3c2fb0 100644 --- a/src/types/session.d.ts +++ b/src/types/session.d.ts @@ -7,4 +7,4 @@ export interface SessionInfo { createdAt: Date; expiresAt: Date; isCurrentSession?: boolean; -} \ No newline at end of file +} diff --git a/src/types/user.d.ts b/src/types/user.d.ts index 76bfd14..a1aeed8 100644 --- a/src/types/user.d.ts +++ b/src/types/user.d.ts @@ -2,4 +2,4 @@ export interface User { account_id: string; is_admin: boolean; created_at: Date; -} \ No newline at end of file +} diff --git a/tsconfig.json b/tsconfig.json index b575f7d..19c51c8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "ES2017", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -23,9 +19,7 @@ } ], "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] } }, "include": [ @@ -35,7 +29,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": [ - "node_modules" - ] + "exclude": ["node_modules"] } From 6e9e30f82c370846e135ad5a9a04d20f69fd2c20 Mon Sep 17 00:00:00 2001 From: Kizuren <96580944+Kizuren@users.noreply.github.com> Date: Tue, 9 Dec 2025 02:08:38 +0100 Subject: [PATCH 78/81] Fixed docker --- Dockerfile | 1 + docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 370565d..b9e1399 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/next.config.ts ./next.config.ts RUN bun install --production +RUN bun add typescript EXPOSE 3000 CMD ["bun", "run", "start"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index ce7a23f..b2d6782 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: ports: - "3000:3000" environment: - - MONGO_URI=mongodb://justasuser:veryimportantpasswd@mongo:27017/ulinkshortener?authSource=admin + - MONGO_URI=mongodb://justauser:veryimportantpasswd@mongo:27017/ulinkshortener?authSource=admin - MONGO_DB_NAME=ulinkshortener - NEXTAUTH_SECRET=${NEXTAUTH_SECRET} - NEXTAUTH_URL=${NEXTAUTH_URL} From cb3b9eacffd65f7d49c494048ba1fdd7cbe7804f Mon Sep 17 00:00:00 2001 From: Kizuren <96580944+Kizuren@users.noreply.github.com> Date: Tue, 9 Dec 2025 02:33:14 +0100 Subject: [PATCH 79/81] Fix mongo user in compose --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index b2d6782..c8e673d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,7 @@ services: container_name: ulinkshortener-mongo restart: always environment: - MONGO_INITDB_ROOT_USERNAME: justasuser + MONGO_INITDB_ROOT_USERNAME: justauser MONGO_INITDB_ROOT_PASSWORD: veryimportantpasswd MONGO_INITDB_DATABASE: ulinkshortener command: mongod --bind_ip_all From 960db5f6d7ed25a4175499593e271305723fc378 Mon Sep 17 00:00:00 2001 From: Kizuren <96580944+Kizuren@users.noreply.github.com> Date: Tue, 9 Dec 2025 20:41:35 +0100 Subject: [PATCH 80/81] Update Dockerfile --- Dockerfile | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index b9e1399..23edfb4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,13 +12,11 @@ FROM oven/bun:1-slim AS runner WORKDIR /app ENV NODE_ENV=production +COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/.next ./.next COPY --from=builder /app/public ./public COPY --from=builder /app/package.json ./package.json -COPY --from=builder /app/next.config.ts ./next.config.ts -RUN bun install --production -RUN bun add typescript EXPOSE 3000 -CMD ["bun", "run", "start"] \ No newline at end of file +CMD ["bunx", "next", "start"] From f460ea3096ec1c3e1ac2e2bab33442f009e4c66d Mon Sep 17 00:00:00 2001 From: Kizuren <96580944+Kizuren@users.noreply.github.com> Date: Fri, 12 Dec 2025 12:41:09 +0100 Subject: [PATCH 81/81] Again updating react --- bun.lock | 44 +++++++++++++++++++++----------------------- package.json | 12 ++++++------ 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/bun.lock b/bun.lock index f140f51..68130d7 100644 --- a/bun.lock +++ b/bun.lock @@ -142,37 +142,37 @@ "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], - "@tailwindcss/node": ["@tailwindcss/node@4.1.17", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.17" } }, "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.17", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.17", "@tailwindcss/oxide-darwin-arm64": "4.1.17", "@tailwindcss/oxide-darwin-x64": "4.1.17", "@tailwindcss/oxide-freebsd-x64": "4.1.17", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", "@tailwindcss/oxide-linux-x64-musl": "4.1.17", "@tailwindcss/oxide-wasm32-wasi": "4.1.17", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" } }, "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.17", "", { "os": "android", "cpu": "arm64" }, "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17", "", { "os": "linux", "cpu": "arm" }, "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.17", "", { "dependencies": { "@emnapi/core": "^1.6.0", "@emnapi/runtime": "^1.6.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.7", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.17", "", { "os": "win32", "cpu": "x64" }, "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="], - "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.17", "@tailwindcss/oxide": "4.1.17", "postcss": "^8.4.41", "tailwindcss": "4.1.17" } }, "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw=="], + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="], - "@types/node": ["@types/node@24.10.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA=="], + "@types/node": ["@types/node@24.10.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-gqkrWUsS8hcm0r44yn7/xZeV1ERva/nLgrLxFRUGb7aoNMIJfZJ3AC261zDQuOAKC7MiXai1WCpYc48jAHoShQ=="], "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], @@ -300,11 +300,11 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "react": ["react@19.2.1", "", {}, "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw=="], + "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], "react-chartjs-2": ["react-chartjs-2@5.3.1", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A=="], - "react-dom": ["react-dom@19.2.1", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.1" } }, "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg=="], + "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -328,7 +328,7 @@ "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], - "tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="], + "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="], @@ -344,7 +344,7 @@ "ua-is-frozen": ["ua-is-frozen@0.1.2", "", {}, "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw=="], - "ua-parser-js": ["ua-parser-js@2.0.6", "", { "dependencies": { "detect-europe-js": "^0.1.2", "is-standalone-pwa": "^0.1.1", "ua-is-frozen": "^0.1.2" }, "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-EmaxXfltJaDW75SokrY4/lXMrVyXomE/0FpIIqP2Ctic93gK7rlme55Cwkz8l3YZ6gqf94fCU7AnIkidd/KXPg=="], + "ua-parser-js": ["ua-parser-js@2.0.7", "", { "dependencies": { "detect-europe-js": "^0.1.2", "is-standalone-pwa": "^0.1.1", "ua-is-frozen": "^0.1.2" }, "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-CFdHVHr+6YfbktNZegH3qbYvYgC7nRNEUm2tk7nSFXSODUu4tDBpaFpP1jdXBUOKKwapVlWRfTtS8bCPzsQ47w=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], @@ -368,7 +368,7 @@ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.6.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], @@ -383,7 +383,5 @@ "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next-auth/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], - - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], } } diff --git a/package.json b/package.json index 57387e5..44fd732 100644 --- a/package.json +++ b/package.json @@ -15,21 +15,21 @@ "mongodb": "^7.0.0", "next": "16.0.8", "next-auth": "^4.24.13", - "react": "^19.2.1", + "react": "^19.2.3", "react-chartjs-2": "^5.3.1", - "react-dom": "^19.2.1", - "ua-parser-js": "^2.0.6", + "react-dom": "^19.2.3", + "ua-parser-js": "^2.0.7", "uuid": "^13.0.0", "winston": "^3.19.0" }, "devDependencies": { "@biomejs/biome": "^2.3.8", - "@tailwindcss/postcss": "^4.1.17", - "@types/node": "^24.10.2", + "@tailwindcss/postcss": "^4.1.18", + "@types/node": "^24.10.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@types/uuid": "^11.0.0", - "tailwindcss": "^4.1.17", + "tailwindcss": "^4.1.18", "typescript": "^5.9.3" } }