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