diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b74c85f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +/shortify +/devzone/mongodb-data +/.config.toml \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d649a44 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Shortify + +## Requirements + +- Go 1.14.4 +- MongoDB + +## Building + +```bash +go build ./cmd/shortify +``` + +## Running + +1. Firstly, you need to generate config: + ```bash + ./shortify -genconfig > backend.toml + ``` + +2. Then just edit it, and start executable with specified config: + ```bash + ./shortify -config ./backend.toml + ``` \ No newline at end of file diff --git a/app/app.go b/app/app.go new file mode 100644 index 0000000..428c32a --- /dev/null +++ b/app/app.go @@ -0,0 +1,78 @@ +package app + +import ( + "context" + "fmt" + "github.com/MoonSHRD/logger" + "net/http" + + "github.com/MoonSHRD/shortify/config" + "github.com/gorilla/mux" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type App struct { + Config *config.Config + DBClient *mongo.Client + DB *mongo.Database + + server *http.Server +} + +func NewApp(config *config.Config) (*App, error) { + app := &App{} + app.Config = config + err := app.createMongoDBConnection(&config.MongoDB) + if err != nil { + return nil, err + } + + port := app.Config.HTTP.Port + addr := app.Config.HTTP.Address + server := &http.Server{Addr: fmt.Sprintf("%s:%d", addr, port)} + app.server = server + + return app, nil +} + +func (app *App) createMongoDBConnection(config *config.MongoDB) error { + var mongoURI string + if config.User == "" && config.Password == "" { + mongoURI = fmt.Sprintf("mongodb://%s:%d", config.Host, config.Port) + } else { + mongoURI = fmt.Sprintf("mongodb://%s:%s@%s:%d", config.User, config.Password, config.Host, config.Port) + } + clientOptions := options.Client().ApplyURI(mongoURI) + client, err := mongo.Connect(context.Background(), clientOptions) + if err != nil { + return err + } + err = client.Ping(context.Background(), nil) + if err != nil { + return err + } + + app.DBClient = client + app.DB = client.Database(app.Config.MongoDB.DatabaseName) + return nil +} + +func (app *App) Run(r *mux.Router) { + port := app.Config.HTTP.Port + addr := app.Config.HTTP.Address + app.server.Handler = r + + logger.Infof("HTTP server starts listening at %s:%d", addr, port) + go func() { + if err := app.server.ListenAndServe(); err != nil { + logger.Info(err) + } + }() +} + +func (app *App) Destroy() { + ctx := context.Background() + _ = app.DBClient.Disconnect(ctx) + _ = app.server.Shutdown(ctx) +} diff --git a/cmd/shortify/main.go b/cmd/shortify/main.go new file mode 100644 index 0000000..4e7a54d --- /dev/null +++ b/cmd/shortify/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "flag" + "fmt" + "io/ioutil" + "log" + "os" + "os/signal" + "syscall" + + "github.com/MoonSHRD/logger" + "github.com/MoonSHRD/shortify/app" + "github.com/MoonSHRD/shortify/config" + "github.com/MoonSHRD/shortify/router" +) + +func main() { + var configPath string + var generateConfig bool + var verboseLogging bool + var syslogLogging bool + flag.StringVar(&configPath, "config", "", "Path to server config") + flag.BoolVar(&generateConfig, "genconfig", false, "Generate new config") + flag.BoolVar(&verboseLogging, "verbose", true, "Verbose logging") + flag.BoolVar(&syslogLogging, "syslog", false, "Log to system logging daemon") + flag.Parse() + defer logger.Init("shortify", verboseLogging, syslogLogging, ioutil.Discard).Close() // TODO Make ability to use file for log output + if generateConfig { + confStr, err := config.Generate() + if err != nil { + log.Fatalf("Cannot generate config! %s", err.Error()) + } + fmt.Print(confStr) + os.Exit(0) + } + logger.Info("Starting Shortify...") + if configPath == "" { + logger.Fatal("Path to config isn't specified!") + } + + cfg, err := config.Parse(configPath) + if err != nil { + logger.Fatal(err) + } + app, err := app.NewApp(cfg) + if err != nil { + logger.Fatal(err) + } + router, err := router.NewRouter(app) + if err != nil { + logger.Fatalf("Failed to initialize router: %s", err.Error()) + } + + // CTRL+C handler. + signalHandler := make(chan os.Signal, 1) + shutdownDone := make(chan bool, 1) + signal.Notify(signalHandler, os.Interrupt, syscall.SIGTERM) + go func() { + <-signalHandler + logger.Info("CTRL+C or SIGTERM received, shutting down Shortify...") + app.Destroy() + shutdownDone <- true + }() + + app.Run(router) + + <-shutdownDone + os.Exit(0) +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..3550a8e --- /dev/null +++ b/config/config.go @@ -0,0 +1,51 @@ +package config + +import ( + "github.com/pelletier/go-toml" + "log" + "os" +) + +type Config struct { + HTTP HTTP `toml:"http"` + MongoDB MongoDB `toml:"mongoDB"` + AuthServerURL string `toml:"authServerURL"` +} + +type HTTP struct { + Address string `toml:"address"` + Port int `toml:"port"` +} + +type MongoDB struct { + Host string `toml:"host"` + Port int `toml:"port"` + User string `toml:"user"` + Password string `toml:"password"` + DatabaseName string `toml:"databaseName"` +} + +func Parse(path string) (*Config, error) { + file, err := os.Open(path) + defer file.Close() + if err != nil { + log.Fatal(err) + } + decoder := toml.NewDecoder(file) + cfg := Config{} + err = decoder.Decode(&cfg) + if err != nil { + log.Fatal(err) + } + + return &cfg, nil +} + +func Generate() (string, error) { + config := Config{} + configStr, err := toml.Marshal(config) + if err != nil { + return "", err + } + return string(configStr), nil +} diff --git a/controllers/links.go b/controllers/links.go new file mode 100644 index 0000000..2efc09b --- /dev/null +++ b/controllers/links.go @@ -0,0 +1,71 @@ +package controllers + +import ( + "encoding/json" + "github.com/MoonSHRD/logger" + "net/http" + + "github.com/MoonSHRD/shortify/app" + httpModels "github.com/MoonSHRD/shortify/models/http" + "github.com/MoonSHRD/shortify/services" +) + +type LinksController struct { + app *app.App + linksService *services.LinksService +} + +func NewLinksController(a *app.App, ls *services.LinksService) *LinksController { + return &LinksController{ + app: a, + linksService: ls, + } +} + +func (lc *LinksController) CreateLink(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + decoder := json.NewDecoder(r.Body) + var createLinkRequest httpModels.CreateLinkRequest + err := decoder.Decode(&createLinkRequest) + if err != nil { + logger.Error(err) + ReturnHTTPError(w, err.Error(), http.StatusBadRequest) + return + } + res, err := lc.linksService.Put(&createLinkRequest) + if err != nil { + if err == services.ErrEmptyLinkValue { + ReturnHTTPError(w, err.Error(), http.StatusBadRequest) + return + } + logger.Error(err) + ReturnHTTPError(w, err.Error(), http.StatusInternalServerError) + return + } + json, _ := json.Marshal(res) + w.Write(json) +} + +func (lc *LinksController) GetLink(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + linkIDParam, ok := r.URL.Query()["linkID"] + if !ok || len(linkIDParam[0]) < 1 { + logger.Error("Url Param 'linkID' is missing") + return + } + linkID := linkIDParam[0] + + res, err := lc.linksService.GetByLinkID(linkID) + if err != nil { + if err == services.ErrNoSuchLink { + ReturnHTTPError(w, err.Error(), http.StatusBadRequest) + return + } + logger.Error(err) + ReturnHTTPError(w, err.Error(), http.StatusInternalServerError) + return + } + json, _ := json.Marshal(res) + w.Write(json) +} diff --git a/controllers/utils.go b/controllers/utils.go new file mode 100644 index 0000000..d96ad60 --- /dev/null +++ b/controllers/utils.go @@ -0,0 +1,26 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "net/http" + + httpModels "github.com/MoonSHRD/shortify/models/http" +) + +func ReturnHTTPError(w http.ResponseWriter, err string, code int) { + res := httpModels.HTTPError{ + Error: httpModels.ErrorMessage{ + Message: err, + }, + } + r, _ := json.Marshal(res) + writeHTTPJSONError(w, string(r), code) +} + +func writeHTTPJSONError(w http.ResponseWriter, err string, code int) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(code) + fmt.Fprintln(w, err) +} diff --git a/devzone/devzone.sh b/devzone/devzone.sh new file mode 100755 index 0000000..732f4bc --- /dev/null +++ b/devzone/devzone.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +# Actually this isn't needed as we extract script path later and use it +# for everything, but anyway! +if [ "$0" != "./devzone.sh" ]; then + echo "This script should be launched as './devzone.sh'!" + exit 1 +fi + +# Check for OS first. On macOS greadlink should be used instead of +# readlink. +READLINK="/bin/readlink" +OS=$(uname -s) +if [ "${OS}" == "Darwin" ]; then + READLINK="/usr/local/bin/greadlink" + if [ ! -f "${READLINK}" ]; then + echo "GNU readlink is required for macOS. Please, install coreutils with brew." + exit 1 + fi +fi + +SCRIPT_PATH=$(dirname "`${READLINK} -f "${BASH_SOURCE}"`") +echo "devzone script path: ${SCRIPT_PATH}" + +# This values might or might not be filled by OS. +# And it MUST NOT be filled on macOS. Docker is so convenient... +if [ "${OS}" != "Darwin" ]; then + USER_ID=$(id -u) + GROUP_ID=$(id -g) +else + echo "macOS users have no need in setting user and group ID" +fi + +down() { + echo "Cleaning up development environment..." + docker-compose down --remove-orphans +} + +run() { + echo "Getting development environment up and running with docker-compose..." + # ToDo: checks? + USER_ID=$USER_ID GROUP_ID=$GROUP_ID docker-compose -p devzone_shortify up + if [ $? -ne 0 ]; then + echo "Something went wrong. Read previous messages carefully!" + exit 1 + fi + echo "Development zone shutted down." +} + + +help() { + echo "Developers helper script." + echo "" + echo "Available subcommands:" + echo -e "\tdown\t\t\tClear development environment from data." + echo -e "\trun\t\t\tStart development zone required servers (databases, etc.)." +} + +case $1 in + down) + down + ;; + run) + run + ;; + *) + help + ;; +esac diff --git a/devzone/docker-compose.yaml b/devzone/docker-compose.yaml new file mode 100644 index 0000000..d216494 --- /dev/null +++ b/devzone/docker-compose.yaml @@ -0,0 +1,12 @@ +version: "2.4" + +services: + mongo: + image: "mongo:4.2.3" + ports: + - "127.0.0.1:27017:27017" + environment: + MONGO_INITDB_ROOT_USERNAME: "root" + MONGO_INITDB_ROOT_PASSWORD: "root" + volumes: + - ./mongodb-data:/data/db diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b2cc206 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/MoonSHRD/shortify + +go 1.14 + +require ( + github.com/MoonSHRD/logger v1.0.3 + github.com/dgrijalva/jwt-go v3.2.0+incompatible + github.com/gorilla/mux v1.7.4 + github.com/pelletier/go-toml v1.8.0 + go.mongodb.org/mongo-driver v1.3.4 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fb7e51a --- /dev/null +++ b/go.sum @@ -0,0 +1,122 @@ +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/MoonSHRD/logger v1.0.3 h1:eDEg+BTdge9dlwjBZHyjD5ksUE3qzlA+Aw/Og/KlJpE= +github.com/MoonSHRD/logger v1.0.3/go.mod h1:VGsvbTksOmTK5c3SL7DmL+XPywd4tAUGhyrYvs9kPYA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/dgrijalva/jwt-go v1.0.2 h1:KPldsxuKGsS2FPWsNeg9ZO18aCrGKujPoWXn2yo+KQM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0= +github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY= +github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg= +github.com/gobuffalo/envy v1.6.15/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= +github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= +github.com/gobuffalo/flect v0.1.0/go.mod h1:d2ehjJqGOH/Kjqcoz+F7jHTBbmDb38yXA598Hb50EGs= +github.com/gobuffalo/flect v0.1.1/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= +github.com/gobuffalo/flect v0.1.3/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= +github.com/gobuffalo/genny v0.0.0-20190329151137-27723ad26ef9/go.mod h1:rWs4Z12d1Zbf19rlsn0nurr75KqhYp52EAGGxTbBhNk= +github.com/gobuffalo/genny v0.0.0-20190403191548-3ca520ef0d9e/go.mod h1:80lIj3kVJWwOrXWWMRzzdhW3DsrdjILVil/SFKBzF28= +github.com/gobuffalo/genny v0.1.0/go.mod h1:XidbUqzak3lHdS//TPu2OgiFB+51Ur5f7CSnXZ/JDvo= +github.com/gobuffalo/genny v0.1.1/go.mod h1:5TExbEyY48pfunL4QSXxlDOmdsD44RRq4mVZ0Ex28Xk= +github.com/gobuffalo/gitgen v0.0.0-20190315122116-cc086187d211/go.mod h1:vEHJk/E9DmhejeLeNt7UVvlSGv3ziL+djtTr3yyzcOw= +github.com/gobuffalo/gogen v0.0.0-20190315121717-8f38393713f5/go.mod h1:V9QVDIxsgKNZs6L2IYiGR8datgMhB577vzTDqypH360= +github.com/gobuffalo/gogen v0.1.0/go.mod h1:8NTelM5qd8RZ15VjQTFkAW6qOMx5wBbW4dSCS3BY8gg= +github.com/gobuffalo/gogen v0.1.1/go.mod h1:y8iBtmHmGc4qa3urIyo1shvOD8JftTtfcKi+71xfDNE= +github.com/gobuffalo/logger v0.0.0-20190315122211-86e12af44bc2/go.mod h1:QdxcLw541hSGtBnhUc4gaNIXRjiDppFGaDqzbrBd3v8= +github.com/gobuffalo/mapi v1.0.1/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= +github.com/gobuffalo/mapi v1.0.2/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= +github.com/gobuffalo/packd v0.0.0-20190315124812-a385830c7fc0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= +github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= +github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ= +github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0= +github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= +github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= +github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4= +github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/klauspost/compress v1.9.5 h1:U+CaK85mrNNb4k8BNOfgJtJ/gr6kswUCFj6miSzVC6M= +github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= +github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo= +github.com/pelletier/go-toml v1.8.0 h1:Keo9qb7iRJs2voHvunFtuuYFsbWeOBh8/P9v/kVMFtw= +github.com/pelletier/go-toml v1.8.0/go.mod h1:D6yutnOGMveHEPV7VQOuvI/gXY61bv+9bAOTRnLElKs= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c h1:u40Z8hqBAAQyv+vATcGgV0YCnDjqSL7/q/JyPhhJSPk= +github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= +github.com/xdg/stringprep v0.0.0-20180714160509-73f8eece6fdc h1:n+nNi93yXLkJvKwXNP9d55HC7lGK4H/SRcwB5IaUZLo= +github.com/xdg/stringprep v0.0.0-20180714160509-73f8eece6fdc/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= +go.mongodb.org/mongo-driver v1.3.4 h1:zs/dKNwX0gYUtzwrN9lLiR15hCO0nDwQj5xXx+vjCdE= +go.mongodb.org/mongo-driver v1.3.4/go.mod h1:MSWZXKOynuguX+JSvwP8i+58jYCXxbia8HS3gZBapIE= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5 h1:8dUaAV7K4uHsF56JQWkprecIQKdPHtR9jCHF5nB8uzc= +golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190329151228-23e29df326fe/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190531172133-b3315ee88b7d/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/middlewares/auth.go b/middlewares/auth.go new file mode 100644 index 0000000..5d214aa --- /dev/null +++ b/middlewares/auth.go @@ -0,0 +1,95 @@ +package middlewares + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/MoonSHRD/logger" + "github.com/MoonSHRD/shortify/app" + "github.com/MoonSHRD/shortify/controllers" + "io/ioutil" + "net/http" + "strings" +) + +const ( + ValidateTokenEndpoint = "/api/v1/validateAccessToken" +) + +type AuthMiddleware struct { + app *app.App +} + +func NewAuthMiddleware(a *app.App) *AuthMiddleware { + return &AuthMiddleware{ + app: a, + } +} + +func (am *AuthMiddleware) ProcessRequest(next http.HandlerFunc) http.HandlerFunc { + return func(res http.ResponseWriter, req *http.Request) { + authServerValidationEndpoint := am.app.Config.AuthServerURL + ValidateTokenEndpoint + + // parse access token from http header (Bearer Auth) + accessToken := req.Header.Get("Authorization") + if len(accessToken) == 0 { + err := fmt.Errorf("unauthorized") + logger.Warningf("Unauthorized access on route %s", req.URL.Path) + controllers.ReturnHTTPError(res, err.Error(), http.StatusForbidden) + return + } + splitToken := strings.Split(accessToken, " ") + if len(splitToken) == 0 { + err := fmt.Errorf("unauthorized") + logger.Warningf("Unauthorized access on route %s", req.URL.Path) + controllers.ReturnHTTPError(res, err.Error(), http.StatusForbidden) + return + } + accessToken = splitToken[1] + + validateAccessTokenBodyRequest, err := json.Marshal(map[string]string{ + "accessToken": accessToken, + }) + resp, err := http.Post(authServerValidationEndpoint, "application/json", bytes.NewBuffer(validateAccessTokenBodyRequest)) + if err != nil { + logger.Error(err) + controllers.ReturnHTTPError(res, err.Error(), http.StatusInternalServerError) + return + } + if resp.StatusCode == http.StatusOK { + bodyBytes, err := ioutil.ReadAll(resp.Body) + if err != nil { + logger.Error(err) + controllers.ReturnHTTPError(res, err.Error(), http.StatusInternalServerError) + return + } + resp.Body.Close() + + var authServerResponse struct { + Valid bool `json:"isValid"` + } + + err = json.Unmarshal(bodyBytes, &authServerResponse) + if err != nil { + logger.Error(err) + controllers.ReturnHTTPError(res, err.Error(), http.StatusInternalServerError) + return + } + + if authServerResponse.Valid { + next(res, req) + return + } else { + err := fmt.Errorf("unauthorized") + logger.Warningf("Unauthorized access on route %s", req.URL.Path) + controllers.ReturnHTTPError(res, err.Error(), http.StatusForbidden) + return + } + } else { + err = fmt.Errorf("Incorrect HTTP code from auth server - %d", resp.StatusCode) + logger.Warningf(err.Error()) + controllers.ReturnHTTPError(res, err.Error(), http.StatusInternalServerError) + return + } + } +} diff --git a/middlewares/logger.go b/middlewares/logger.go new file mode 100644 index 0000000..d048b88 --- /dev/null +++ b/middlewares/logger.go @@ -0,0 +1,16 @@ +package middlewares + +import ( + "github.com/MoonSHRD/logger" + "net/http" + "time" +) + +// Logger logs the current request to the console printing the date, HTTP method, path and elapsed time +func Logger(next http.HandlerFunc) http.HandlerFunc { + return func(res http.ResponseWriter, req *http.Request) { + start := time.Now() + next(res, req) + logger.Infof("[%s] %q %v\n", req.Method, req.URL.String(), time.Since(start)) + } +} diff --git a/models/http/create_link_request.go b/models/http/create_link_request.go new file mode 100644 index 0000000..c826c52 --- /dev/null +++ b/models/http/create_link_request.go @@ -0,0 +1,6 @@ +package http + +type CreateLinkRequest struct { + LinkValue string `json:"linkValue"` + TTL int64 `json:"ttl"` +} diff --git a/models/http/http_error.go b/models/http/http_error.go new file mode 100644 index 0000000..b43de3a --- /dev/null +++ b/models/http/http_error.go @@ -0,0 +1,10 @@ +package http + +type HTTPError struct { + //ErrorCode int + Error ErrorMessage `json:"error"` +} + +type ErrorMessage struct { + Message string `json:"message"` +} diff --git a/models/http/http_response.go b/models/http/http_response.go new file mode 100644 index 0000000..e18ec90 --- /dev/null +++ b/models/http/http_response.go @@ -0,0 +1,5 @@ +package http + +type HTTPResponse struct { + Status int `json:"status"` +} diff --git a/models/link.go b/models/link.go new file mode 100644 index 0000000..9c7044c --- /dev/null +++ b/models/link.go @@ -0,0 +1,13 @@ +package models + +import ( + "go.mongodb.org/mongo-driver/bson/primitive" + "time" +) + +type Link struct { + ID primitive.ObjectID `json:"-" bson:"_id"` + LinkID string `json:"linkID" bson:"linkID"` + LinkValue string `json:"linkValue" bson:"linkValue"` + ExpiresAt *time.Time `json:"expiresAt" bson:"expiresAt"` +} diff --git a/repositories/link.go b/repositories/link.go new file mode 100644 index 0000000..6f6b86e --- /dev/null +++ b/repositories/link.go @@ -0,0 +1,82 @@ +package repositories + +import ( + "context" + "github.com/MoonSHRD/shortify/app" + "github.com/MoonSHRD/shortify/models" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + LinksCollectionName = "links" +) + +type LinksRepository struct { + app *app.App + dbCollection *mongo.Collection + ctx context.Context +} + +func NewLinksRepository(a *app.App) (*LinksRepository, error) { + ctx := context.Background() + dbCol := a.DB.Collection(LinksCollectionName) + ur := &LinksRepository{ + app: a, + dbCollection: dbCol, + ctx: ctx, + } + err := ur.initMongo() + if err != nil { + return nil, err + } + return ur, nil +} + +func (lr *LinksRepository) initMongo() error { + linkIDIndex := mongo.IndexModel{ + Keys: bson.M{ + "linkID": 1, + }, Options: options.Index().SetUnique(true), + } + + expiresAtIndex := mongo.IndexModel{ + Keys: bson.M{ + "expiresAt": 1, + }, Options: options.Index().SetExpireAfterSeconds(0), + } + + exists, err := isCollectionExists(lr.ctx, lr.app.DB, lr.dbCollection.Name()) + if err != nil { + return err + } + if exists { + _, err := lr.dbCollection.Indexes().DropAll(lr.ctx) + if err != nil { + return err + } + } + + _, err = lr.dbCollection.Indexes().CreateMany(lr.ctx, []mongo.IndexModel{linkIDIndex, expiresAtIndex}) + return err +} + +func (lr *LinksRepository) Put(link *models.Link) error { + link.ID = primitive.NewObjectID() + _, err := lr.dbCollection.InsertOne(lr.ctx, link) + return err +} + +func (lr *LinksRepository) GetByID(id primitive.ObjectID) (*models.Link, error) { + var link models.Link + err := lr.dbCollection.FindOne(lr.ctx, bson.M{"_id": id}).Decode(&link) + return &link, err +} + +func (lr *LinksRepository) GetByLinkID(linkID string) (*models.Link, error) { + var link models.Link + err := lr.dbCollection.FindOne(lr.ctx, bson.M{"linkID": linkID}).Decode(&link) + return &link, err +} diff --git a/repositories/utils.go b/repositories/utils.go new file mode 100644 index 0000000..a7a24dc --- /dev/null +++ b/repositories/utils.go @@ -0,0 +1,23 @@ +package repositories + +import ( + "context" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" +) + +func isCollectionExists(ctx context.Context, db *mongo.Database, collectionName string) (bool, error) { + isExists := false + names, err := db.ListCollectionNames(ctx, bson.D{{"name", collectionName}}) + if err != nil { + return false, err + } + + for _, name := range names { + if name == collectionName { + isExists = true + break + } + } + return isExists, nil +} diff --git a/router/router.go b/router/router.go new file mode 100644 index 0000000..2b735ea --- /dev/null +++ b/router/router.go @@ -0,0 +1,46 @@ +package router + +import ( + "net/http" + + "github.com/MoonSHRD/shortify/controllers" + "github.com/MoonSHRD/shortify/repositories" + "github.com/MoonSHRD/shortify/services" + + "github.com/MoonSHRD/shortify/app" + "github.com/MoonSHRD/shortify/middlewares" + "github.com/gorilla/mux" +) + +func NewRouter(a *app.App) (*mux.Router, error) { + r := mux.NewRouter() + + // NOTE Create repositories here + ur, err := repositories.NewLinksRepository(a) + if err != nil { + return nil, err + } + + // NOTE Create services here + gs := services.NewLinksService(a, ur) + + // NOTE Create controllers here + gc := controllers.NewLinksController(a, gs) + + // NOTE Create middlewares here + authMiddleware := middlewares.NewAuthMiddleware(a) + + r.HandleFunc("/", middlewares.Logger( + func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Shortify is up and running!")) + }, + )).Methods(http.MethodGet) + + api := r.PathPrefix("/api/v1").Subrouter() + + // NOTE Add routes here + api.HandleFunc("/links", authMiddleware.ProcessRequest(middlewares.Logger(gc.CreateLink))).Methods(http.MethodPost) + api.HandleFunc("/links", authMiddleware.ProcessRequest(middlewares.Logger(gc.GetLink))).Methods(http.MethodGet) + + return r, nil +} diff --git a/services/link.go b/services/link.go new file mode 100644 index 0000000..f9b2e4d --- /dev/null +++ b/services/link.go @@ -0,0 +1,62 @@ +package services + +import ( + "fmt" + "github.com/MoonSHRD/shortify/app" + "github.com/MoonSHRD/shortify/models" + httpModels "github.com/MoonSHRD/shortify/models/http" + "github.com/MoonSHRD/shortify/repositories" + "github.com/MoonSHRD/shortify/utils" + "go.mongodb.org/mongo-driver/mongo" + "time" +) + +var ( + ErrNoSuchLink = fmt.Errorf("no such link") + ErrEmptyLinkValue = fmt.Errorf("empty link value") +) + +type LinksService struct { + app *app.App + linksRepository *repositories.LinksRepository +} + +func NewLinksService(a *app.App, ur *repositories.LinksRepository) *LinksService { + return &LinksService{ + app: a, + linksRepository: ur, + } +} + +func (ls *LinksService) Put(createLinkRequest *httpModels.CreateLinkRequest) (*models.Link, error) { + link := &models.Link{} + if createLinkRequest.TTL == -1 { + link.LinkID = utils.GenerateAlphanumericString(8) + link.ExpiresAt = nil + } else { + link.LinkID = utils.GenerateAlphanumericString(6) + now := time.Now().UTC() + expire := now.Add(time.Duration(createLinkRequest.TTL) * time.Second) + link.ExpiresAt = &expire + } + if createLinkRequest.LinkValue == "" { + return nil, ErrEmptyLinkValue + } + link.LinkValue = createLinkRequest.LinkValue + err := ls.linksRepository.Put(link) + if err != nil { + return nil, err + } + return link, nil +} + +func (ls *LinksService) GetByLinkID(linkID string) (*models.Link, error) { + link, err := ls.linksRepository.GetByLinkID(linkID) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, ErrNoSuchLink + } + return nil, err + } + return link, nil +} diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..274fe66 --- /dev/null +++ b/utils/utils.go @@ -0,0 +1,13 @@ +package utils + +import "math/rand" + +var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890") + +func GenerateAlphanumericString(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +}