├── config-example.json ├── .gitignore ├── database.sql ├── scripts └── go-url-shortener ├── README.md └── main.go /config-example.json: -------------------------------------------------------------------------------- 1 | { 2 | "mysql_host": "your_mysql_host", 3 | "mysql_database": "your_mysql_database", 4 | "mysql_user": "your_mysql_user", 5 | "mysql_password": "your_mysql_password", 6 | "short_url": "http://short.ext", 7 | "default_url": "http://long-domain-name.ext" 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | -------------------------------------------------------------------------------- /database.sql: -------------------------------------------------------------------------------- 1 | # Copied from Mathias Bynens' PHP URL Shortener: https://github.com/mathiasbynens/php-url-shortener 2 | 3 | # Why you should use `utf8mb4` instead of `utf8`: http://mathiasbynens.be/notes/mysql-utf8mb4 4 | CREATE TABLE `redirect` ( 5 | `slug` varchar(14) collate utf8mb4_unicode_ci NOT NULL, 6 | `url` varchar(620) collate utf8mb4_unicode_ci NOT NULL, 7 | `date` datetime NOT NULL, 8 | `hits` bigint(20) NOT NULL default '0', 9 | PRIMARY KEY (`slug`) 10 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Used for the URL shortener'; 11 | 12 | INSERT INTO `redirect` VALUES ('g', 'https://github.com/samwierema/go-url-shortener', NOW(), 1); 13 | -------------------------------------------------------------------------------- /scripts/go-url-shortener: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | ### BEGIN INIT INFO 4 | # Provides: go-url-shortener 5 | # Required-Start: $remote_fs $syslog 6 | # Required-Stop: $remote_fs $syslog 7 | # Should-Start: $network 8 | # Should-Stop: $network 9 | # Default-Start: 2 3 4 5 10 | # Default-Stop: 0 1 6 11 | # Short-Description: Go URL Shortener 12 | # Description: Starts the Go URL Shortener service in the background 13 | ### END INIT INFO 14 | 15 | PATH=/sbin:/usr/sbin:/bin:/usr/bin 16 | 17 | GOPATH="/path/to/your/go/root" 18 | 19 | GUS="$GOPATH/bin/go-url-shortener" 20 | GUS_NAME="go-url-shortener" 21 | GUS_PID="/var/run/$GUS_NAME.pid" 22 | GUS_LOG_DIR="/var/log/$GUS_NAME" ; mkdir -p $GUS_LOG_DIR 23 | GUS_LOG_FILE="$GUS_LOG_DIR/$GUS_NAME.log" 24 | 25 | SSD_PARAMS="--quiet --oknodo --pidfile $GUS_PID" 26 | 27 | set -e 28 | test -x $GUS || exit 0 29 | 30 | . /lib/lsb/init-functions 31 | 32 | case "$1" in 33 | start) 34 | log_daemon_msg "Starting Go URL Shortener service" "$GUS_NAME" || true 35 | if start-stop-daemon --start $SSD_PARAMS --make-pidfile --background --no-close --exec $GUS >> $GUS_LOG_FILE 2>&1; then 36 | log_end_msg 0 || true 37 | else 38 | log_end_msg 1 || true 39 | fi 40 | ;; 41 | 42 | stop) 43 | log_daemon_msg "Stopping Go URL Shortener service" "$GUS_NAME" || true 44 | if start-stop-daemon --stop --retry 10 $SSD_PARAMS; then 45 | log_end_msg 0 || true 46 | else 47 | log_end_msg 1 || true 48 | fi 49 | ;; 50 | 51 | restart) 52 | $0 stop 53 | $0 start 54 | ;; 55 | 56 | status) 57 | status_of_proc -p $GUS_PID $GUS $GUS_NAME && exit 0 || exit $? 58 | ;; 59 | 60 | *) 61 | log_action_msg "Usage: /etc/init.d/$GUS_NAME {start|stop|restart|status}" || true 62 | exit 1 63 | esac 64 | 65 | exit 0 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # An URL shortener written in Golang 2 | Inspired by Mathias Bynens' [PHP URL Shortener](https://github.com/mathiasbynens/php-url-shortener), and triggered by a wish to learn Go, I wanted to try and see if I could build an URL shortener in Go. 3 | 4 | ## Features 5 | 6 | * Redirect to your main website when no slug, or incorrect slug, is entered, e.g. `http://wiere.ma/` → `http://samwierema.nl/`. 7 | * Generates short URLs using only `[a-z0-9]` characters. 8 | * Doesn’t create multiple short URLs when you try to shorten the same URL. In this case, the script will simply return the existing short URL for that long URL. 9 | 10 | ## Installation 11 | 1. Download the source code and install it using the `go install` command. 12 | 2. Use `database.sql` to create the `redirect` table in a database of choice. 13 | 3. Create a config file in `/path/to/.go-url-shortener/` named `config.(json|yaml|toml)`. Use `config-example.json` as a example. 14 | 4. Run the program as a daemon using one of the many methods: write a script for [upstart](https://launchpad.net/upstart), init, use [daemonize](http://software.clapper.org/daemonize/), [Supervisord](http://supervisord.org/), [Circus](http://circus.readthedocs.org/) or just plain old `nohup`. You can even start (and manage) it in a `screen` session. 15 | 5. Adding the following configuration to Apache (make sure you've got [mod_proxy](http://httpd.apache.org/docs/2.2/mod/mod_proxy.html) enabled): 16 | ``` 17 | 18 | ServerName your-short-domain.ext 19 | 20 | ProxyPreserveHost on 21 | ProxyPass / http://localhost:8080/ 22 | ProxyPassReverse / http://localhost:8080/ 23 | 24 | ``` 25 | 26 | ### Using the example init script 27 | You will find an example init script in the `scripts` folder. To use, you **must** at least change the GOPATH line to point to your Go root path. 28 | 29 | ## To-do 30 | * Add tests 31 | 32 | ## Author 33 | * [Sam Wierema](http://wiere.ma) 34 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | _ "github.com/go-sql-driver/mysql" 6 | "github.com/gorilla/mux" 7 | "github.com/spf13/viper" 8 | "log" 9 | "math/rand" 10 | "net/http" 11 | "time" 12 | ) 13 | 14 | var db *sql.DB 15 | 16 | func main() { 17 | rand.Seed(time.Now().UTC().UnixNano()) 18 | 19 | // Instantiate the configuration 20 | viper.SetConfigName("config") 21 | viper.AddConfigPath("/path/to/.go-url-shortener") 22 | viper.ReadInConfig() 23 | 24 | // Instantiate the database 25 | var err error 26 | dsn := viper.GetString("mysql_user") + ":" + viper.GetString("mysql_password") + "@tcp(" + viper.GetString("mysql_host") + ":3306)/" + viper.GetString("mysql_database") + "?collation=utf8mb4_unicode_ci&parseTime=true" 27 | db, err = sql.Open("mysql", dsn) 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | 32 | defer db.Close() 33 | 34 | // Instantiate the mux router 35 | r := mux.NewRouter() 36 | r.HandleFunc("/s", ShortenHandler).Queries("url", "") 37 | r.HandleFunc("/{slug:[a-zA-Z0-9]+}", ShortenedUrlHandler) 38 | r.HandleFunc("/", CatchAllHandler) 39 | 40 | // Assign mux as the HTTP handler 41 | http.Handle("/", r) 42 | http.ListenAndServe(":8080", nil) 43 | } 44 | 45 | // Shortens a given URL passed through in the request. 46 | // If the URL has already been shortened, returns the existing URL. 47 | // Writes the short URL in plain text to w. 48 | func ShortenHandler(w http.ResponseWriter, r *http.Request) { 49 | // Check if the url parameter has been sent along (and is not empty) 50 | url := r.URL.Query().Get("url") 51 | if url == "" { 52 | http.Error(w, "", http.StatusBadRequest) 53 | return 54 | } 55 | 56 | // Get the short URL out of the config 57 | if !viper.IsSet("short_url") { 58 | http.Error(w, "", http.StatusInternalServerError) 59 | return 60 | } 61 | short_url := viper.GetString("short_url") 62 | 63 | // Check if url already exists in the database 64 | var slug string 65 | err := db.QueryRow("SELECT `slug` FROM `redirect` WHERE `url` = ?", url).Scan(&slug) 66 | if err == nil { 67 | // The URL already exists! Return the shortened URL. 68 | w.Write([]byte(short_url + "/" + slug)) 69 | return 70 | } 71 | 72 | // generate a slug and validate it doesn't 73 | // exist until we find a valid one. 74 | var exists = true 75 | for exists == true { 76 | slug = generateSlug() 77 | err, exists = slugExists(slug) 78 | if err != nil { 79 | http.Error(w, err.Error(), http.StatusInternalServerError) 80 | return 81 | } 82 | } 83 | 84 | // Insert it into the database 85 | stmt, err := db.Prepare("INSERT INTO `redirect` (`slug`, `url`, `date`, `hits`) VALUES (?, ?, NOW(), ?)") 86 | if err != nil { 87 | http.Error(w, err.Error(), http.StatusInternalServerError) 88 | return 89 | } 90 | 91 | _, err = stmt.Exec(slug, url, 0) 92 | if err != nil { 93 | http.Error(w, err.Error(), http.StatusInternalServerError) 94 | return 95 | } 96 | 97 | w.WriteHeader(http.StatusCreated) 98 | w.Write([]byte(short_url + "/" + slug)) 99 | } 100 | 101 | // generateSlug will generate a random slug to be used as shorten link. 102 | func generateSlug() string { 103 | // It doesn't exist! Generate a new slug for it 104 | // From: http://stackoverflow.com/questions/22892120/how-to-generate-a-random-string-of-a-fixed-length-in-golang 105 | var chars = []rune("0123456789abcdefghijklmnopqrstuvwxyz") 106 | s := make([]rune, 6) 107 | for i := range s { 108 | s[i] = chars[rand.Intn(len(chars))] 109 | } 110 | 111 | return string(s) 112 | } 113 | 114 | // slugExists will check whether the slug already exists in the database 115 | func slugExists(slug string) (e error, exists bool) { 116 | err := db.QueryRow("SELECT EXISTS(SELECT * FROM `redirect` WHERE `slug` = ?)", slug).Scan(&exists) 117 | if err != nil { 118 | return err, false 119 | } 120 | 121 | return nil, exists 122 | } 123 | 124 | // Handles a requested short URL. 125 | // Redirects with a 301 header if found. 126 | func ShortenedUrlHandler(w http.ResponseWriter, r *http.Request) { 127 | // 1. Check if a slug exists 128 | vars := mux.Vars(r) 129 | slug, ok := vars["slug"] 130 | if !ok { 131 | http.Error(w, "", http.StatusBadRequest) 132 | return 133 | } 134 | 135 | // 2. Check if the slug exists in the database 136 | var url string 137 | err := db.QueryRow("SELECT `url` FROM `redirect` WHERE `slug` = ?", slug).Scan(&url) 138 | if err != nil { 139 | http.NotFound(w, r) 140 | return 141 | } 142 | 143 | // 3. If the slug (and thus the URL) exist, update the hit counter 144 | stmt, err := db.Prepare("UPDATE `redirect` SET `hits` = `hits` + 1 WHERE `slug` = ?") 145 | if err != nil { 146 | http.Error(w, err.Error(), http.StatusInternalServerError) 147 | return 148 | } 149 | 150 | _, err = stmt.Exec(slug) 151 | if err != nil { 152 | http.Error(w, err.Error(), http.StatusInternalServerError) 153 | return 154 | } 155 | 156 | // 4. Finally, redirect the user to the URL 157 | http.Redirect(w, r, url, http.StatusMovedPermanently) 158 | } 159 | 160 | // Catches all other requests to the short URL domain. 161 | // If a default URL exists in the config redirect to it. 162 | func CatchAllHandler(w http.ResponseWriter, r *http.Request) { 163 | // 1. Get the redirect URL out of the config 164 | if !viper.IsSet("default_url") { 165 | // The reason for using StatusNotFound here instead of StatusInternalServerError 166 | // is because this is a catch-all function. You could come here via various 167 | // ways, so showing a StatusNotFound is friendlier than saying there's an 168 | // error (i.e. the configuration is missing) 169 | http.NotFound(w, r) 170 | return 171 | } 172 | 173 | // 2. If it exists, redirect the user to it 174 | http.Redirect(w, r, viper.GetString("default_url"), http.StatusMovedPermanently) 175 | } 176 | --------------------------------------------------------------------------------