diff --git a/.gitignore b/.gitignore index 7cd8025..3c2c62a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ .glide/ debug +*.db +*.lock /config.* /handlers/static.go /handlers/tmpls/tmpls.go diff --git a/README.md b/README.md index dfe71c1..23278a1 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ - Visitor Counting - Expirable Links - URL deletion -- Authorization System via OAuth 2.0 (Google, GitHub and Micrsoft) +- Authorization System via OAuth 2.0 (Google, GitHub and Microsoft) - High performance database with [bolt](https://github.com/boltdb/bolt) - Easy [ShareX](https://github.com/ShareX/ShareX) integration - Dockerizable @@ -36,7 +36,7 @@ ## Why did you built this -Just only because I want to extend my current self hosted URL shorter (which was really messy code) with some more features and learn about new techniques like: +Only because I just want to extend my current self hosted URL shorter (which was really messy code) with some more features and learn about new techniques like: - Golang unit testing - React diff --git a/build/info.sh b/build/info.sh index 161ef3b..d34f007 100644 --- a/build/info.sh +++ b/build/info.sh @@ -1,10 +1,12 @@ cat > util/info.go < 1 { - id = c.Request.URL.Path[1:] - } - entry, err := h.store.GetEntryByID(id) - if err == store.ErrIDIsEmpty || err == store.ErrNoEntryFound { + url, err := h.store.GetURLAndIncrease(c.Request.URL.Path[1:]) + if err == store.ErrNoEntryFound { return } else if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - return - } - if time.Now().After(entry.Public.Expiration) && !entry.Public.Expiration.IsZero() { - return - } - if err := h.store.IncreaseVisitCounter(id); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + http.Error(c.Writer, fmt.Sprintf("could not get and crease visitor counter: %v, ", err), http.StatusInternalServerError) return } - c.Redirect(http.StatusTemporaryRedirect, entry.Public.URL) + c.Redirect(http.StatusTemporaryRedirect, url) } // handleCreate handles requests to create an entry @@ -77,7 +66,7 @@ func (h *Handler) handleCreate(c *gin.Context) { return } user := c.MustGet("user").(*auth.JWTClaims) - id, err := h.store.CreateEntry(store.Entry{ + id, delID, err := h.store.CreateEntry(store.Entry{ Public: store.EntryPublicData{ URL: data.URL, Expiration: data.Expiration, @@ -90,8 +79,11 @@ func (h *Handler) handleCreate(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - data.URL = h.getSchemaAndHost(c) + "/" + id - c.JSON(http.StatusOK, data) + originURL := h.getURLOrigin(c) + c.JSON(http.StatusOK, urlUtil{ + URL: fmt.Sprintf("%s/%s", originURL, id), + DeletionURL: fmt.Sprintf("%s/d/%s/%s", originURL, id, url.QueryEscape(delID)), + }) } func (h *Handler) handleInfo(c *gin.Context) { @@ -104,8 +96,17 @@ func (h *Handler) handleInfo(c *gin.Context) { } c.JSON(http.StatusOK, info) } +func (h *Handler) handleDelete(c *gin.Context) { + if err := h.store.DeleteEntry(c.Param("id"), c.Param("hash")); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + }) +} -func (h *Handler) getSchemaAndHost(c *gin.Context) string { +func (h *Handler) getURLOrigin(c *gin.Context) string { protocol := "http" if c.Request.TLS != nil { protocol = "https" diff --git a/static/package.json b/static/package.json index af8caea..bee0c50 100644 --- a/static/package.json +++ b/static/package.json @@ -16,12 +16,13 @@ "react-dom": "^16.1.1", "react-prism": "^4.3.1", "react-qr-svg": "^2.1.0", - "react-responsive": "^4.0.0", + "react-responsive": "^4.0.2", "react-router": "^4.2.0", "react-router-dom": "^4.2.2", "react-scripts": "1.0.17", "semantic-ui-css": "^2.2.12", - "semantic-ui-react": "^0.76.0" + "semantic-ui-react": "^0.76.0", + "toastr": "^2.1.2" }, "scripts": { "start": "react-scripts start", diff --git a/static/src/About/About.js b/static/src/About/About.js index 4c06be4..a0d4ab3 100644 --- a/static/src/About/About.js +++ b/static/src/About/About.js @@ -1,12 +1,13 @@ import React, { Component } from 'react' import { Container, Table } from 'semantic-ui-react' +import moment from 'moment' export default class AboutComponent extends Component { state = { info: null } - componentWillMount() { - fetch("/api/v1/info").then(res => res.json()).then(d => this.setState({ info: d })) + componentWillReceiveProps = () => { + this.setState({ info: this.props.info }) } render() { const { info } = this.state @@ -31,7 +32,7 @@ export default class AboutComponent extends Component { Compilation Time - {info.compilationTime} + {moment(info.compilationTime).fromNow()} ({info.compilationTime}) Commit Hash diff --git a/static/src/Card/Card.js b/static/src/Card/Card.js index ab6f1ec..4e7e7fb 100644 --- a/static/src/Card/Card.js +++ b/static/src/Card/Card.js @@ -2,6 +2,7 @@ import React, { Component } from 'react' import { Card, Icon, Button, Modal } from 'semantic-ui-react' import { QRCode } from 'react-qr-svg'; import Clipboard from 'react-clipboard.js'; +import toastr from 'toastr' export default class CardComponent extends Component { state = { @@ -15,6 +16,12 @@ export default class CardComponent extends Component { }, 500) } } + onDeletonLinkCopy() { + toastr.info('Copied the deletion URL to the Clipboard') + } + onShortedURLSuccess() { + toastr.info('Copied the shorted URL to the Clipboard') + } render() { const { expireDate } = this.state return ( @@ -30,6 +37,7 @@ export default class CardComponent extends Component { {this.props.description} + {this.props.deletionURL && } @@ -40,7 +48,7 @@ export default class CardComponent extends Component { - +
Copy to Clipboard diff --git a/static/src/Home/Home.js b/static/src/Home/Home.js index 8e20457..158d4f2 100644 --- a/static/src/Home/Home.js +++ b/static/src/Home/Home.js @@ -61,7 +61,8 @@ export default class HomeComponent extends Component { links: [...this.state.links, [ r.URL, this.url, - this.state.setOptions.indexOf("expire") > -1 ? this.state.expiration : undefined + this.state.setOptions.indexOf("expire") > -1 ? this.state.expiration : undefined, + r.DeletionURL ]] })) } @@ -102,7 +103,7 @@ export default class HomeComponent extends Component { - {links.map((link, i) => )} + {links.map((link, i) => )}
) diff --git a/static/src/Lookup/Lookup.js b/static/src/Lookup/Lookup.js index 7e16950..384e436 100644 --- a/static/src/Lookup/Lookup.js +++ b/static/src/Lookup/Lookup.js @@ -28,7 +28,7 @@ export default class LookupComponent extends Component { this.VisitCount, res.CratedOn, res.LastVisit, - moment(res.Expiration) + res.Expiration ? moment(res.Expiration) : null ]] })) } @@ -45,7 +45,7 @@ export default class LookupComponent extends Component { - {links.map((link, i) => )} + {links.map((link, i) => )} ) diff --git a/static/src/ShareX/ShareX.js b/static/src/ShareX/ShareX.js index 93025ba..c084630 100644 --- a/static/src/ShareX/ShareX.js +++ b/static/src/ShareX/ShareX.js @@ -22,7 +22,8 @@ export default class ShareXComponent extends Component { Authorization: window.localStorage.getItem('token') }, ResponseType: "Text", - URL: "$json:URL$" + URL: "$json:URL$", + DeletionURL: "$json:DeletionURL$" }, null, 4), currentStep: 0, availableSteps: [ diff --git a/static/src/index.js b/static/src/index.js index d9e1316..b81c0a0 100644 --- a/static/src/index.js +++ b/static/src/index.js @@ -2,7 +2,9 @@ import React, { Component } from 'react' import ReactDOM from 'react-dom'; import { HashRouter, Route, Link } from 'react-router-dom' import { Menu, Container, Modal, Button, Image, Icon } from 'semantic-ui-react' +import toastr from 'toastr' import 'semantic-ui-css/semantic.min.css'; +import 'toastr/build/toastr.css'; import About from './About/About' import Home from './Home/Home' @@ -15,7 +17,7 @@ export default class BaseComponent extends Component { userData: {}, authorized: false, activeItem: "", - providers: [] + info: null } onOAuthClose() { @@ -30,7 +32,10 @@ export default class BaseComponent extends Component { } loadInfo = () => { - fetch('/api/v1/info').then(d => d.json()).then(d => this.setState({ providers: d.providers })) + fetch('/api/v1/info') + .then(d => d.json()) + .then(info => this.setState({ info })) + .catch(e => toastr.error(e)) } checkAuth = () => { @@ -45,12 +50,14 @@ export default class BaseComponent extends Component { headers: { 'Content-Type': 'application/json' } - }).then(res => res.ok ? res.json() : Promise.reject(res.json())) // Check if the request was StatusOK, otherwise reject Promise + }) + .then(res => res.ok ? res.json() : Promise.reject(`incorrect response status code: ${res.status}; text: ${res.statusText}`)) .then(d => { that.setState({ userData: d }) that.setState({ authorized: true }) }) .catch(e => { + toastr.error(`Could not fetch info: ${e}`) window.localStorage.removeItem('token'); that.setState({ authorized: false }) }) @@ -69,7 +76,7 @@ export default class BaseComponent extends Component { onOAuthClick = provider => { window.addEventListener('message', this.onOAuthCallback, false); var url = `${window.location.origin}/api/v1/auth/${provider}/login`; - if (!this._oAuthPopup) { + if (!this._oAuthPopup || this._oAuthPopup.closed) { // Open the oAuth window that is it centered in the middle of the screen var wwidth = 400, wHeight = 500; @@ -87,7 +94,7 @@ export default class BaseComponent extends Component { } render() { - const { open, authorized, activeItem, userData, providers } = this.state + const { open, authorized, activeItem, userData, info } = this.state if (!authorized) { return ( @@ -96,28 +103,28 @@ export default class BaseComponent extends Component {

The following authentication services are currently available:

-
- {providers.length === 0 &&

There are currently no correct oAuth credentials maintained.

} - {providers.indexOf("google") !== -1 &&
+ {info &&
+ {info.providers.length === 0 &&

There are currently no correct oAuth credentials maintained.

} + {info.providers.indexOf("google") !== -1 &&
- {providers.indexOf("github") !== -1 &&
} + {info.providers.indexOf("github") !== -1 &&
}
} - {providers.indexOf("github") !== -1 &&
+ {info.providers.indexOf("github") !== -1 &&
} - {providers.indexOf("microsoft") !== -1 &&
+ {info.providers.indexOf("microsoft") !== -1 &&
} -
+
} - + ) } return ( @@ -147,7 +154,9 @@ export default class BaseComponent extends Component { - + ( + + )} /> diff --git a/store/store.go b/store/store.go index b55c331..ae3bfe0 100644 --- a/store/store.go +++ b/store/store.go @@ -2,6 +2,9 @@ package store import ( + "crypto/hmac" + "crypto/sha512" + "encoding/base64" "encoding/json" "path/filepath" "time" @@ -31,9 +34,10 @@ type Entry struct { // EntryPublicData is the public part of an entry type EntryPublicData struct { - CreatedOn, LastVisit, Expiration time.Time - VisitCount int - URL string + CreatedOn, LastVisit time.Time + Expiration *time.Time `json:",omitempty"` + VisitCount int + URL string } // ErrNoEntryFound is returned when no entry to a id is found @@ -45,8 +49,8 @@ var ErrNoValidURL = errors.New("the given URL is no valid URL") // ErrGeneratingIDFailed is returned when the 10 tries to generate an id failed var ErrGeneratingIDFailed = errors.New("could not generate unique id, all ten tries failed") -// ErrIDIsEmpty is returned when the given ID is empty -var ErrIDIsEmpty = errors.New("the given ID is empty") +// ErrEntryIsExpired is returned when the entry is expired +var ErrEntryIsExpired = errors.New("entry is expired") // New initializes the store with the db func New() (*Store, error) { @@ -72,7 +76,7 @@ func New() (*Store, error) { // GetEntryByID returns a unmarshalled entry of the db by a given ID func (s *Store) GetEntryByID(id string) (*Entry, error) { if id == "" { - return nil, ErrIDIsEmpty + return nil, ErrNoEntryFound } rawEntry, err := s.GetEntryByIDRaw(id) if err != nil { @@ -102,6 +106,22 @@ func (s *Store) IncreaseVisitCounter(id string) error { }) } +// GetURLAndIncrease Increases the visitor count, checks +// if the URL is expired and returns the origin URL +func (s *Store) GetURLAndIncrease(id string) (string, error) { + entry, err := s.GetEntryByID(id) + if err != nil { + return "", err + } + if entry.Public.Expiration != nil && time.Now().After(*entry.Public.Expiration) { + return "", ErrEntryIsExpired + } + if err := s.IncreaseVisitCounter(id); err != nil { + return "", errors.Wrap(err, "could not increase visitor counter") + } + return entry.Public.URL, nil +} + // GetEntryByIDRaw returns the raw data (JSON) of a data set func (s *Store) GetEntryByIDRaw(id string) ([]byte, error) { var raw []byte @@ -115,22 +135,44 @@ func (s *Store) GetEntryByIDRaw(id string) ([]byte, error) { } // CreateEntry creates a new record and returns his short id -func (s *Store) CreateEntry(entry Entry, givenID string) (string, error) { +func (s *Store) CreateEntry(entry Entry, givenID string) (string, string, error) { if !govalidator.IsURL(entry.Public.URL) { - return "", ErrNoValidURL + return "", "", ErrNoValidURL } // try it 10 times to make a short URL for i := 1; i <= 10; i++ { - id, err := s.createEntry(entry, givenID) + id, delID, err := s.createEntry(entry, givenID) if err != nil && givenID != "" { - return "", err + return "", "", err } else if err != nil { logrus.Debugf("Could not create entry: %v", err) continue } - return id, nil + return id, delID, nil + } + return "", "", ErrGeneratingIDFailed +} + +// DeleteEntry deletes an Entry fully from the DB +func (s *Store) DeleteEntry(id, hash string) error { + mac := hmac.New(sha512.New, util.GetPrivateKey()) + if _, err := mac.Write([]byte(id)); err != nil { + return errors.Wrap(err, "could not write hmac") + } + givenHmac, err := base64.RawURLEncoding.DecodeString(hash) + if err != nil { + return errors.Wrap(err, "could not decode base64") } - return "", ErrGeneratingIDFailed + if !hmac.Equal(mac.Sum(nil), givenHmac) { + return errors.New("hmac verification failed") + } + return s.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket(s.bucketName) + if bucket.Get([]byte(id)) == nil { + return errors.New("entry already deleted") + } + return bucket.Delete([]byte(id)) + }) } // Close closes the bolt db database diff --git a/store/store_test.go b/store/store_test.go index 7554da1..60eba15 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -2,6 +2,7 @@ package store import ( "os" + "strings" "testing" "github.com/spf13/viper" @@ -54,12 +55,12 @@ func TestCreateEntry(t *testing.T) { t.Fatalf("unexpected error: %v", err) } defer cleanup(store) - _, err = store.CreateEntry(Entry{}, "") + _, _, err = store.CreateEntry(Entry{}, "") if err != ErrNoValidURL { t.Fatalf("unexpected error: %v", err) } for i := 1; i <= 100; i++ { - _, err := store.CreateEntry(Entry{ + _, _, err := store.CreateEntry(Entry{ Public: EntryPublicData{ URL: "https://golang.org/", }, @@ -81,8 +82,8 @@ func TestGetEntryByID(t *testing.T) { t.Fatalf("could not get expected '%v' error: %v", ErrNoEntryFound, err) } _, err = store.GetEntryByID("") - if err != ErrIDIsEmpty { - t.Fatalf("could not get expected '%v' error: %v", ErrIDIsEmpty, err) + if err != ErrNoEntryFound { + t.Fatalf("could not get expected '%v' error: %v", ErrNoEntryFound, err) } } @@ -92,7 +93,7 @@ func TestIncreaseVisitCounter(t *testing.T) { t.Fatalf("could not create store: %v", err) } defer cleanup(store) - id, err := store.CreateEntry(Entry{ + id, _, err := store.CreateEntry(Entry{ Public: EntryPublicData{ URL: "https://golang.org/", }, @@ -114,9 +115,8 @@ func TestIncreaseVisitCounter(t *testing.T) { if entryBeforeInc.Public.VisitCount+1 != entryAfterInc.Public.VisitCount { t.Fatalf("the increasement was not successful, the visit count is not correct") } - errIDIsEmpty := "could not get entry by ID: the given ID is empty" - if err = store.IncreaseVisitCounter(""); err.Error() != errIDIsEmpty { - t.Fatalf("could not get expected '%v'; got: %v", errIDIsEmpty, err) + if err = store.IncreaseVisitCounter(""); !strings.Contains(err.Error(), ErrNoEntryFound.Error()) { + t.Fatalf("could not get expected '%v'; got: %v", ErrNoEntryFound, err) } } diff --git a/store/util.go b/store/util.go index 028f773..3e18305 100644 --- a/store/util.go +++ b/store/util.go @@ -1,13 +1,17 @@ package store import ( + "crypto/hmac" "crypto/rand" + "crypto/sha512" + "encoding/base64" "encoding/json" "math/big" "time" "unicode" "github.com/boltdb/bolt" + "github.com/maxibanki/golang-url-shortener/util" "github.com/pkg/errors" ) @@ -27,19 +31,23 @@ func (s *Store) createEntryRaw(key, value []byte) error { // createEntry creates a new entry with a randomly generated id. If on is present // then the given ID is used -func (s *Store) createEntry(entry Entry, entryID string) (string, error) { +func (s *Store) createEntry(entry Entry, entryID string) (string, string, error) { var err error if entryID == "" { if entryID, err = generateRandomString(s.idLength); err != nil { - return "", errors.Wrap(err, "could not generate random string") + return "", "", errors.Wrap(err, "could not generate random string") } } entry.Public.CreatedOn = time.Now() rawEntry, err := json.Marshal(entry) if err != nil { - return "", err + return "", "", err } - return entryID, s.createEntryRaw([]byte(entryID), rawEntry) + mac := hmac.New(sha512.New, util.GetPrivateKey()) + if _, err := mac.Write([]byte(entryID)); err != nil { + return "", "", errors.Wrap(err, "could not write hmac") + } + return entryID, base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), s.createEntryRaw([]byte(entryID), rawEntry) } // generateRandomString generates a random string with an predefined length diff --git a/util/config.go b/util/config.go index 42034e9..75c82b9 100644 --- a/util/config.go +++ b/util/config.go @@ -14,7 +14,7 @@ import ( var ( dataDirPath string // DoNotSetConfigName is used to predefine if the name of the config should be set. - // Used for the unit testing + // Used for unit testing DoNotSetConfigName = false ) diff --git a/util/private.go b/util/private.go index 81b049d..d459498 100644 --- a/util/private.go +++ b/util/private.go @@ -11,7 +11,8 @@ import ( var privateKey []byte -// CheckForPrivateKey checks if already an private key exists, if not it will be randomly generated +// CheckForPrivateKey checks if already an private key exists, if not it will +// be randomly generated and saved as a private.dat file in the data directory func CheckForPrivateKey() error { privateDatPath := filepath.Join(GetDataDir(), "private.dat") privateDatContent, err := ioutil.ReadFile(privateDatPath) @@ -32,7 +33,7 @@ func CheckForPrivateKey() error { return nil } -// GetPrivateKey returns the private key from the loaded private key +// GetPrivateKey returns the private key func GetPrivateKey() []byte { return privateKey } diff --git a/util/util.go b/util/util.go deleted file mode 100644 index 056120e..0000000 --- a/util/util.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package util implements helper functions for the complete Golang URL Shortener app -package util