Browse Source

Minor changes:

- Added recent shorted URLs listing
- Added visitor page of a shorted URL
#43
dependabot/npm_and_yarn/web/prismjs-1.21.0
Max Schmitt 8 years ago
parent
commit
ac05d6f036
  1. 2
      handlers/handlers.go
  2. 46
      handlers/public.go
  3. 4
      main_test.go
  4. 4
      static/public/index.html
  5. 52
      static/src/Recent/Recent.js
  6. 63
      static/src/Visitors/Visitors.js
  7. 6
      static/src/index.js
  8. 122
      store/store.go
  9. 12
      store/util.go

2
handlers/handlers.go

@ -71,6 +71,8 @@ func (h *Handler) setHandlers() error {
protected.Use(h.authMiddleware)
protected.POST("/create", h.handleCreate)
protected.POST("/lookup", h.handleLookup)
protected.POST("/recent", h.handleRecent)
protected.POST("/visitors", h.handleGetVisitors)
h.engine.GET("/api/v1/info", h.handleInfo)
h.engine.GET("/d/:id/:hash", h.handleDelete)

46
handlers/public.go

@ -19,7 +19,7 @@ import (
type urlUtil struct {
URL string `binding:"required"`
ID, DeletionURL string
Expiration *time.Time `json:",omitempty"`
Expiration *time.Time
}
// handleLookup is the http handler for getting the infos
@ -49,14 +49,28 @@ func (h *Handler) handleLookup(c *gin.Context) {
// handleAccess handles the access for incoming requests
func (h *Handler) handleAccess(c *gin.Context) {
url, err := h.store.GetURLAndIncrease(c.Request.URL.Path[1:])
id := c.Request.URL.Path[1:]
url, err := h.store.GetURLAndIncrease(id)
if err == store.ErrNoEntryFound {
return
} else if err != nil {
http.Error(c.Writer, fmt.Sprintf("could not get and crease visitor counter: %v, ", err), http.StatusInternalServerError)
return
}
go h.store.RegisterVisit(id, store.Visitor{
IP: c.ClientIP(),
Timestamp: time.Now(),
Referer: c.GetHeader("Referer"),
UTMSource: c.Query("utm_source"),
UTMMedium: c.Query("utm_medium"),
UTMCampaign: c.Query("utm_campaign"),
UTMContent: c.Query("utm_content"),
UTMTerm: c.Query("utm_term"),
})
c.Redirect(http.StatusTemporaryRedirect, url)
// There is a need to Abort in the current middleware to prevent
// that the status code will be overridden by the default NoRoute handler
c.Abort()
}
// handleCreate handles requests to create an entry
@ -87,6 +101,23 @@ func (h *Handler) handleCreate(c *gin.Context) {
})
}
// handleGetVisitors handles requests to create an entry
func (h *Handler) handleGetVisitors(c *gin.Context) {
var data struct {
ID string `binding:"required"`
}
if err := c.ShouldBind(&data); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
dataSets, err := h.store.GetVisitors(data.ID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, dataSets)
}
func (h *Handler) handleInfo(c *gin.Context) {
info := gin.H{
"providers": h.providers,
@ -97,6 +128,17 @@ func (h *Handler) handleInfo(c *gin.Context) {
}
c.JSON(http.StatusOK, info)
}
func (h *Handler) handleRecent(c *gin.Context) {
user := c.MustGet("user").(*auth.JWTClaims)
entries, err := h.store.GetUserEntries(user.OAuthProvider, user.OAuthID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, entries)
}
func (h *Handler) handleDelete(c *gin.Context) {
givenHmac, err := base64.RawURLEncoding.DecodeString(c.Param("hash"))
if err != nil {

4
main_test.go

@ -2,7 +2,6 @@ package main
import (
"net"
"os"
"testing"
"time"
@ -14,11 +13,10 @@ func TestInitShortener(t *testing.T) {
if err != nil {
t.Fatalf("could not init shortener: %v", err)
}
time.Sleep(time.Second) // Give the http server a second to boot up
time.Sleep(time.Millisecond * 200) // Give the http server a second to boot up
// We expect there a port is in use error
if _, err := net.Listen("tcp", viper.GetString("listen_addr")); err == nil {
t.Fatalf("port is not in use: %v", err)
}
close()
os.Exit(0)
}

4
static/public/index.html

@ -19,7 +19,9 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>URL Shortener</title>
<title>Golang URL Shortener</title>
<meta name="description" content="URL Shortener written in Golang using Bolt DB. Provides features such as Deletion, Expiration, OAuth and is of course Dockerizable">
<meta name="author" content="Max Schmitt">
</head>
<body>

52
static/src/Recent/Recent.js

@ -0,0 +1,52 @@
import React, { Component } from 'react'
import { Container, Table } from 'semantic-ui-react'
import toastr from 'toastr'
import moment from 'moment'
export default class RecentComponent extends Component {
state = {
recent: null
}
componentWillMount() {
fetch('/api/v1/protected/recent', {
method: 'POST',
headers: {
'Authorization': window.localStorage.getItem('token'),
}
})
.then(res => res.ok ? res.json() : Promise.reject(res.json()))
.then(recent => this.setState({ recent: recent }))
.catch(e => e.done(res => toastr.error(`Could not fetch recent shortened URLs: ${res}`)))
}
onRowClick(id) {
this.props.history.push(`/visitors/${id}`)
}
render() {
const { recent } = this.state
return (
<Container>
<Table celled selectable>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Original URL</Table.HeaderCell>
<Table.HeaderCell>Created</Table.HeaderCell>
<Table.HeaderCell>Short URL</Table.HeaderCell>
<Table.HeaderCell>All Clicks</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{recent && Object.keys(recent).map(key => <Table.Row key={key} title="Click to view visitor statistics" onClick={this.onRowClick.bind(this, key)}>
<Table.Cell>{recent[key].Public.URL}</Table.Cell>
<Table.Cell>{moment(recent[key].Public.CreatedOn).format('LLL')}</Table.Cell>
<Table.Cell>{`${window.location.origin}/${key}`}</Table.Cell>
<Table.Cell>{recent[key].Public.VisitCount}</Table.Cell>
</Table.Row>)}
</Table.Body>
</Table>
</Container>
)
}
};

63
static/src/Visitors/Visitors.js

@ -0,0 +1,63 @@
import React, { Component } from 'react'
import { Container, Table } from 'semantic-ui-react'
import moment from 'moment'
import toastr from 'toastr'
export default class VisitorComponent extends Component {
state = {
id: "",
visitors: null
}
componentDidMount() {
this.setState({ id: this.props.match.params.id })
fetch('/api/v1/protected/visitors', {
method: 'POST',
body: JSON.stringify({
ID: this.props.match.params.id
}),
headers: {
'Authorization': window.localStorage.getItem('token'),
'Content-Type': 'application/json'
}
})
.then(res => res.ok ? res.json() : Promise.reject(res.json()))
.then(visitors => this.setState({ visitors }))
.catch(e => e.done(res => toastr.error(`Could not fetch visitors: ${res}`)))
}
// getUTMSource is a function which generates the output for the utm[...] table column
getUTMSource(visit) {
return [visit.UTMSource, visit.UTMMedium, visit.UTMCampaign, visit.UTMContent, visit.UTMTerm]
.map(value => value ? value : null)
.filter(v => v)
.map((value, idx, data) => value + (idx !== data.length - 1 ? ", " : ""))
.join("")
}
render() {
const { visitors } = this.state
return (
<Container >
<Table celled>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Timestamp</Table.HeaderCell>
<Table.HeaderCell>IP</Table.HeaderCell>
<Table.HeaderCell>Referer</Table.HeaderCell>
<Table.HeaderCell>UTM (source, medium, campaign, content, term)</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{visitors && visitors.map((visit, idx) => <Table.Row key={idx}>
<Table.Cell>{moment(visit.Timestamp).format('LLL')}</Table.Cell>
<Table.Cell>{visit.IP}</Table.Cell>
<Table.Cell>{visit.Referer}</Table.Cell>
<Table.Cell>{this.getUTMSource(visit)}</Table.Cell>
</Table.Row>)}
</Table.Body>
</Table>
</Container>
)
}
};

6
static/src/index.js

@ -10,6 +10,8 @@ import About from './About/About'
import Home from './Home/Home'
import ShareX from './ShareX/ShareX'
import Lookup from './Lookup/Lookup'
import Recent from './Recent/Recent'
import Visitors from './Visitors/Visitors'
export default class BaseComponent extends Component {
state = {
@ -22,7 +24,7 @@ export default class BaseComponent extends Component {
handleItemClick = (e, { name }) => this.setState({ activeItem: name })
onOAuthClose() {
onOAuthClose = () => {
this.setState({ oAuthOpen: true })
}
@ -159,6 +161,8 @@ export default class BaseComponent extends Component {
<Route path="/about" component={About} />
<Route path="/ShareX" component={ShareX} />
<Route path="/Lookup" component={Lookup} />
<Route path="/recent" component={Recent} />
<Route path="/visitors/:id" component={Visitors} />
</Container>
</MemoryRouter>
)

122
store/store.go

@ -2,6 +2,7 @@
package store
import (
"bytes"
"crypto/hmac"
"crypto/sha512"
"encoding/json"
@ -9,6 +10,7 @@ import (
"time"
"github.com/maxibanki/golang-url-shortener/util"
"github.com/pborman/uuid"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
@ -19,9 +21,8 @@ import (
// Store holds internal funcs and vars about the store
type Store struct {
db *bolt.DB
bucketName []byte
idLength int
db *bolt.DB
idLength int
}
// Entry is the data set which is stored in the DB as JSON
@ -31,12 +32,19 @@ type Entry struct {
Public EntryPublicData
}
// Visitor is the entry which is stored in the visitors bucket
type Visitor struct {
IP, Referer string
Timestamp time.Time
UTMSource, UTMMedium, UTMCampaign, UTMContent, UTMTerm string `json:",omitempty"`
}
// EntryPublicData is the public part of an entry
type EntryPublicData struct {
CreatedOn, LastVisit time.Time
Expiration *time.Time `json:",omitempty"`
VisitCount int
URL string
CreatedOn time.Time
LastVisit, Expiration *time.Time `json:",omitempty"`
VisitCount int
URL string
}
// ErrNoEntryFound is returned when no entry to a id is found
@ -51,24 +59,27 @@ var ErrGeneratingIDFailed = errors.New("could not generate unique id, all ten tr
// ErrEntryIsExpired is returned when the entry is expired
var ErrEntryIsExpired = errors.New("entry is expired")
var (
shortedURLsBucket = []byte("shorted")
shortedIDsToUserBucket = []byte("shorted2IDs")
)
// New initializes the store with the db
func New() (*Store, error) {
db, err := bolt.Open(filepath.Join(util.GetDataDir(), "main.db"), 0644, &bolt.Options{Timeout: 1 * time.Second})
if err != nil {
return nil, errors.Wrap(err, "could not open bolt DB database")
}
bucketName := []byte("shorted")
err = db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(bucketName)
_, err := tx.CreateBucketIfNotExists(shortedURLsBucket)
return err
})
if err != nil {
return nil, err
}
return &Store{
db: db,
idLength: viper.GetInt("shorted_id_length"),
bucketName: bucketName,
db: db,
idLength: viper.GetInt("shorted_id_length"),
}, nil
}
@ -92,13 +103,14 @@ func (s *Store) IncreaseVisitCounter(id string) error {
return errors.Wrap(err, "could not get entry by ID")
}
entry.Public.VisitCount++
entry.Public.LastVisit = time.Now()
currentTime := time.Now()
entry.Public.LastVisit = &currentTime
raw, err := json.Marshal(entry)
if err != nil {
return err
}
return s.db.Update(func(tx *bolt.Tx) error {
if err := tx.Bucket(s.bucketName).Put([]byte(id), raw); err != nil {
if err := tx.Bucket(shortedURLsBucket).Put([]byte(id), raw); err != nil {
return errors.Wrap(err, "could not put updated visitor count JSON into the bucket")
}
return nil
@ -125,7 +137,7 @@ func (s *Store) GetURLAndIncrease(id string) (string, error) {
func (s *Store) GetEntryByIDRaw(id string) ([]byte, error) {
var raw []byte
return raw, s.db.View(func(tx *bolt.Tx) error {
raw = tx.Bucket(s.bucketName).Get([]byte(id))
raw = tx.Bucket(shortedURLsBucket).Get([]byte(id))
if raw == nil {
return ErrNoEntryFound
}
@ -162,11 +174,87 @@ func (s *Store) DeleteEntry(id string, givenHmac []byte) error {
return errors.New("hmac verification failed")
}
return s.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket(s.bucketName)
bucket := tx.Bucket(shortedURLsBucket)
if bucket.Get([]byte(id)) == nil {
return errors.New("entry already deleted")
}
return bucket.Delete([]byte(id))
if err := bucket.Delete([]byte(id)); err != nil {
return errors.Wrap(err, "could not delete entry")
}
if err := tx.DeleteBucket([]byte(id)); err != nil && err != bolt.ErrBucketNotFound && err != bolt.ErrBucketExists {
return errors.Wrap(err, "could not delte bucket")
}
uTsIDsBucket := tx.Bucket(shortedIDsToUserBucket)
return uTsIDsBucket.ForEach(func(k, v []byte) error {
if bytes.Equal(k, []byte(id)) {
return uTsIDsBucket.Delete(k)
}
return nil
})
})
}
// RegisterVisit registers an new incoming request in the store
func (s *Store) RegisterVisit(id string, visitor Visitor) {
requestID := uuid.New()
logrus.WithFields(logrus.Fields{
"ClientIP": visitor.IP,
"ID": id,
"RequestID": requestID,
}).Info("New redirect was registered...")
err := s.db.Update(func(tx *bolt.Tx) error {
bucket, err := tx.CreateBucketIfNotExists([]byte(id))
if err != nil {
return errors.Wrap(err, "could not create bucket")
}
data, err := json.Marshal(visitor)
if err != nil {
return errors.Wrap(err, "could not create json")
}
return bucket.Put([]byte(requestID), data)
})
if err != nil {
logrus.Warningf("could not register visit: %v", err)
}
}
// GetVisitors returns all the visits of a shorted URL
func (s *Store) GetVisitors(id string) ([]Visitor, error) {
output := []Visitor{}
return output, s.db.Update(func(tx *bolt.Tx) error {
bucket, err := tx.CreateBucketIfNotExists([]byte(id))
if err != nil {
return errors.Wrap(err, "could not create bucket")
}
return bucket.ForEach(func(k, v []byte) error {
var value Visitor
if err := json.Unmarshal(v, &value); err != nil {
return errors.Wrap(err, "could not unmarshal json")
}
output = append(output, value)
return nil
})
})
}
// GetUserEntries returns all the shorted URL entries of an user
func (s *Store) GetUserEntries(oAuthProvider, oAuthID string) (map[string]Entry, error) {
entries := map[string]Entry{}
return entries, s.db.Update(func(tx *bolt.Tx) error {
bucket, err := tx.CreateBucketIfNotExists(shortedIDsToUserBucket)
if err != nil {
return errors.Wrap(err, "could not create bucket")
}
return bucket.ForEach(func(k, v []byte) error {
if bytes.Equal(v, []byte(oAuthProvider+oAuthID)) {
entry, err := s.GetEntryByID(string(k))
if err != nil {
return errors.Wrap(err, "could not get entry")
}
entries[string(k)] = *entry
}
return nil
})
})
}

12
store/util.go

@ -15,16 +15,20 @@ import (
)
// createEntryRaw creates a entry with the given key value pair
func (s *Store) createEntryRaw(key, value []byte) error {
func (s *Store) createEntryRaw(key, value, userIdentifier []byte) error {
return s.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket(s.bucketName)
bucket := tx.Bucket(shortedURLsBucket)
if raw := bucket.Get(key); raw != nil {
return errors.New("entry already exists")
}
if err := bucket.Put(key, value); err != nil {
return errors.Wrap(err, "could not put data into bucket")
}
return nil
uTsURLsBucket, err := tx.CreateBucketIfNotExists(shortedIDsToUserBucket)
if err != nil {
return errors.Wrap(err, "could not create bucket")
}
return uTsURLsBucket.Put(key, userIdentifier)
})
}
@ -46,7 +50,7 @@ func (s *Store) createEntry(entry Entry, entryID string) (string, []byte, error)
if _, err := mac.Write([]byte(entryID)); err != nil {
return "", nil, errors.Wrap(err, "could not write hmac")
}
return entryID, mac.Sum(nil), s.createEntryRaw([]byte(entryID), rawEntry)
return entryID, mac.Sum(nil), s.createEntryRaw([]byte(entryID), rawEntry, []byte(entry.OAuthProvider+entry.OAuthID))
}
// generateRandomString generates a random string with an predefined length

Loading…
Cancel
Save