mirror of
https://github.com/Kizuren/uLinkShortener.git
synced 2025-12-21 21:16:17 +01:00
Rewritten in go
This commit is contained in:
parent
b131756235
commit
b17e528180
23 changed files with 989 additions and 18 deletions
|
|
@ -1,2 +1,3 @@
|
||||||
MONGO_URI=mongodb://localhost:27017/uLinkShortener
|
MONGO_URI=mongodb://localhost:27017/uLinkShortener
|
||||||
|
MONGO_DB_NAME=uLinkShortener
|
||||||
PORT=5000
|
PORT=5000
|
||||||
14
.github/dependabot.yml
vendored
Normal file
14
.github/dependabot.yml
vendored
Normal file
|
|
@ -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
|
||||||
52
.github/workflows/build.yml
vendored
Normal file
52
.github/workflows/build.yml
vendored
Normal file
|
|
@ -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 }}
|
||||||
24
Dockerfile
24
Dockerfile
|
|
@ -1,20 +1,18 @@
|
||||||
FROM python:3.11-slim
|
FROM golang:1.21-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
# Copy requirements first to leverage Docker cache
|
RUN go mod download
|
||||||
COPY requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy application code
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ulinkshortener ./cmd/api
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
# Set environment variables
|
WORKDIR /app
|
||||||
ENV FLASK_APP=server.py
|
RUN apk --no-cache add ca-certificates
|
||||||
ENV FLASK_ENV=production
|
COPY --from=builder /app/ulinkshortener .
|
||||||
|
COPY --from=builder /app/web /app/web
|
||||||
|
|
||||||
# Expose port
|
ENV PORT=5000
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
# Run the application
|
CMD ["./ulinkshortener"]
|
||||||
CMD ["python", "server.py"]
|
|
||||||
|
|
|
||||||
92
cmd/api/main.go
Normal file
92
cmd/api/main.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
23
go.mod
Normal file
23
go.mod
Normal file
|
|
@ -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
|
||||||
|
)
|
||||||
56
go.sum
Normal file
56
go.sum
Normal file
|
|
@ -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=
|
||||||
126
internal/api/handlers/analytics.go
Normal file
126
internal/api/handlers/analytics.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
95
internal/api/handlers/auth.go
Normal file
95
internal/api/handlers/auth.go
Normal file
|
|
@ -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})
|
||||||
|
}
|
||||||
38
internal/api/handlers/handlers.go
Normal file
38
internal/api/handlers/handlers.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
132
internal/api/handlers/url.go
Normal file
132
internal/api/handlers/url.go
Normal file
|
|
@ -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})
|
||||||
|
}
|
||||||
39
internal/api/router.go
Normal file
39
internal/api/router.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
26
internal/config/config.go
Normal file
26
internal/config/config.go
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
73
internal/database/mongodb.go
Normal file
73
internal/database/mongodb.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
46
internal/models/analytics.go
Normal file
46
internal/models/analytics.go
Normal file
|
|
@ -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"`
|
||||||
|
}
|
||||||
12
internal/models/link.go
Normal file
12
internal/models/link.go
Normal file
|
|
@ -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"`
|
||||||
|
}
|
||||||
10
internal/models/user.go
Normal file
10
internal/models/user.go
Normal file
|
|
@ -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"`
|
||||||
|
}
|
||||||
83
internal/utils/clientinfo.go
Normal file
83
internal/utils/clientinfo.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
32
internal/utils/shortid.go
Normal file
32
internal/utils/shortid.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
19
internal/utils/url.go
Normal file
19
internal/utils/url.go
Normal file
|
|
@ -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 != ""
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
let currentAccount = '';
|
let currentAccount = '';
|
||||||
let refreshInterval;
|
let refreshInterval;
|
||||||
|
const chartInstances = {};
|
||||||
|
|
||||||
function showError(elementId, message) {
|
function showError(elementId, message) {
|
||||||
const errorElement = document.getElementById(elementId);
|
const errorElement = document.getElementById(elementId);
|
||||||
|
|
@ -275,7 +276,11 @@ function createCharts() {
|
||||||
Object.entries(chartConfigs).forEach(([chartId, config]) => {
|
Object.entries(chartConfigs).forEach(([chartId, config]) => {
|
||||||
const ctx = document.getElementById(chartId);
|
const ctx = document.getElementById(chartId);
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
new Chart(ctx, {
|
if (chartInstances[chartId]) {
|
||||||
|
chartInstances[chartId].destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
chartInstances[chartId] = new Chart(ctx, {
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
data: {
|
data: {
|
||||||
labels: config.data.map(item => item._id || 'Unknown'),
|
labels: config.data.map(item => item._id || 'Unknown'),
|
||||||
|
|
@ -2,16 +2,15 @@
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>URL Shortener</title>
|
<title>URL Shortener</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Pass server-side stats to frontend
|
window.stats = {{marshal .stats}};
|
||||||
window.stats = {{ stats|tojson|safe }};
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
createCharts();
|
createCharts();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/script.js"></script>
|
<script src="/static/js/script.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
Loading…
Add table
Add a link
Reference in a new issue