Finish lots of things up

Amolith created

Big commit because I want in the zone and getting a lot of crap done.

This finished implementing the dashboard functionality, all of the API,
normalises some logging, and cleans some other stuff up.

The dashboard still needs improvements (#7), the default config values
need to be written to a file if the file doesn't already exist (#8), and
the status messages from the backend need to be displayed in the
dashboard (#10).

Change summary

createHandler.go                  |  2 
helperfuncs.go                    | 55 ++++++++++++++++++
readHandler.go                    |  3 
root.go                           | 99 ++++++++++++++++++++++++++++----
templates/edit.html               | 86 ++++++++++++++++++++++++++++
templates/home_authenticated.html |  3 
updateHandler.go                  | 49 ++++------------
7 files changed, 241 insertions(+), 56 deletions(-)

Detailed changes

createHandler.go 🔗

@@ -37,8 +37,6 @@ func (m *model) createHandler(writer http.ResponseWriter, request *http.Request)
 		return
 	}
 
-	log.Println("Saving \"" + url + "\" mapped to \"" + name + "\"")
-
 	response := m.create(name, url)
 
 	writer.Write([]byte(response))

helperfuncs.go 🔗

@@ -2,6 +2,9 @@ package main
 
 import (
 	"fmt"
+	"log"
+	"strings"
+	"text/template"
 
 	"github.com/dgraph-io/badger/v3"
 )
@@ -17,9 +20,19 @@ func (m model) create(name string, url string) string {
 		return fmt.Sprint(err)
 	}
 
+	log.Println("\"" + url + "\" mapped to \"" + name + "\"")
 	return fmt.Sprint("URL mapped to ", name, "\n")
 }
 
+// Update modifies a shortened link
+func (m model) update(name string, url string, oldName string) string {
+	m.create(name, url)
+	if name != oldName {
+		m.delete(oldName)
+	}
+	return fmt.Sprint("\"" + url + "\" mapped to \"" + name + "\"")
+}
+
 // Delete removes a shortened URL from the database given its name.
 func (m model) delete(name string) string {
 	err := m.database.Update(func(txn *badger.Txn) error {
@@ -29,7 +42,8 @@ func (m model) delete(name string) string {
 		return fmt.Sprint(err)
 	}
 
-	return fmt.Sprint("\"", name, "\" has been deleted")
+	log.Println("\"" + name + "\" has been deleted")
+	return fmt.Sprint("\"" + name + "\" has been deleted\n")
 }
 
 // nameExists returns true if the provided name is already stored in the
@@ -47,3 +61,42 @@ func (m model) nameExists(name string) bool {
 	}
 	return false
 }
+
+// unauthenticated serves the unauthenticated home page to the visitor
+func unauthenticated() string {
+	home, err := templates.ReadFile("templates/home_unauthenticated.html")
+	if err != nil {
+		log.Fatalln(err)
+	}
+	return string(home)
+}
+
+// authenticated serves the authenticated dashboard to the user
+func (m model) authenticated() string {
+	dash, err := templates.ReadFile("templates/home_authenticated.html")
+	if err != nil {
+		log.Fatalln(err)
+	}
+	tmpl, err := template.New("authenticated").Parse(string(dash))
+	page := new(strings.Builder)
+	tmpl.Execute(page, m.genTable())
+	return page.String()
+}
+
+type EditFields struct {
+	URL  string
+	Name string
+}
+
+// update serves the editing interface to the user
+func (m model) edit(name string, url string) string {
+	fields := EditFields{url, name}
+	editPage, err := templates.ReadFile("templates/edit.html")
+	if err != nil {
+		log.Fatalln(err)
+	}
+	tmpl, err := template.New("editPage").Parse(string(editPage))
+	page := new(strings.Builder)
+	tmpl.Execute(page, fields)
+	return page.String()
+}

readHandler.go 🔗

@@ -12,9 +12,8 @@ import (
 func (m model) readHandler(writer http.ResponseWriter, request *http.Request) {
 	token := request.Header.Get("Authorization")
 	token = strings.TrimPrefix(token, "Bearer ")
-	cookie, _ := request.Cookie("access_token")
 
-	if token != m.AccessToken && cookie.Value != m.AccessToken {
+	if token != m.AccessToken {
 		http.Error(writer, "401 Unauthorized: You do not have permission to view shortlinks", 403)
 		return
 	}

root.go 🔗

@@ -5,9 +5,10 @@ import (
 	"io"
 	"log"
 	"net/http"
+	"net/url"
 	"strings"
-	"text/template"
 
+	"github.com/dchest/uniuri"
 	"github.com/dgraph-io/badger/v3"
 )
 
@@ -34,22 +35,92 @@ func (m model) root(writer http.ResponseWriter, request *http.Request) {
 	}
 
 	cookie, err := request.Cookie("access_token")
-	if err != nil {
-		home, err := templates.ReadFile("templates/home_unauthenticated.html")
-		if err != nil {
-			log.Fatalln(err)
+	if err != nil || cookie.Value != m.AccessToken {
+		io.WriteString(writer, unauthenticated())
+		return
+	}
+
+	query := request.URL.Query()
+
+	action := query.Get("action")
+	if len(action) == 0 {
+		io.WriteString(writer, m.authenticated())
+	}
+
+	destination := query.Get("url")
+	name := query.Get("name")
+	oldName := query.Get("oldName")
+
+	var message string
+
+	if action == "create" {
+		if len(destination) == 0 {
+			message = "URL field is required"
+			http.Redirect(writer, request, "/?message="+message, 302)
+			return
 		}
-		io.WriteString(writer, string(home))
+
+		if len(name) == 0 {
+			name = uniuri.NewLen(4)
+			for m.nameExists(name) {
+				name = uniuri.NewLen(4)
+				log.Println("Generated new name:", name)
+			}
+		} else if m.nameExists(name) {
+			http.Error(writer, "406 Not Acceptable: A shortened URL with this name already exists", 406)
+			message = "A shortened URL with this name already exists"
+			http.Redirect(writer, request, "/?message="+message, 302)
+			return
+		}
+
+		message = url.QueryEscape(m.create(name, destination))
+		http.Redirect(writer, request, "/?message="+message, 302)
 		return
 	}
 
-	if cookie.Value == m.AccessToken {
-		dash, err := templates.ReadFile("templates/home_authenticated.html")
-		if err != nil {
-			log.Fatalln(err)
+	if action == "edit" {
+		io.WriteString(writer, m.edit(name, destination))
+	}
+
+	if action == "update" {
+		if len(destination) == 0 {
+			message = "URL field is required"
+			http.Redirect(writer, request, "/?message="+message, 302)
+			return
+		}
+
+		if len(name) == 0 {
+			message = "Name field is required"
+			http.Redirect(writer, request, "/?message="+message, 302)
+			return
+		}
+
+		if len(oldName) == 0 {
+			message = "oldName field is required"
+			http.Redirect(writer, request, "/?message="+message, 302)
+			return
+		}
+
+		if len(name) != 0 && oldName != name {
+			if m.nameExists(name) {
+				message = "A shortened URL with this name already exists"
+				http.Redirect(writer, request, "/?message="+message, 302)
+				return
+			}
+		}
+
+		message := url.QueryEscape(m.update(name, destination, oldName))
+		http.Redirect(writer, request, "/?message="+message, 302)
+	}
+
+	if action == "delete" {
+		if len(name) == 0 {
+			message = "Name field is required"
+			http.Redirect(writer, request, "/?message="+message, 302)
 		}
-		tmpl, err := template.New("authenticated").Parse(string(dash))
-		tmpl.Execute(writer, m.genTable())
+		message := url.QueryEscape(m.delete(name))
+		log.Println(message)
+		http.Redirect(writer, request, "/?message="+message, 302)
 	}
 }
 
@@ -67,8 +138,8 @@ func (m model) genTable() string {
 				table = table + fmt.Sprintf(`<tr>
 	<td><p>%s</p></td>
 	<td><p>%s</p></td>
-	<td><button>Edit</button><button>Delete</button></td>
-</tr>`, k, v)
+	<td><a href="/?action=edit&name=%s&url=%s">Edit</a><a href="/?action=delete&name=%s">Delete</a></td>
+</tr>`, k, v, k, url.QueryEscape(string(v)), k)
 				return nil
 			})
 			if err != nil {

templates/edit.html 🔗

@@ -0,0 +1,86 @@
+<!DOCTYPE html>
+<html lang="en-GB">
+    <head>
+        <title>umu</title>
+        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <meta name="title" content="umu">
+        <meta name="description" content="umu">
+        <style>
+         html {
+             background: #1C1C1C;
+             color: #DCDCDC;
+             font-family: sans-serif;
+             text-align: center;
+             height: 100%;
+             margin: 0 auto;
+         }
+         body {
+             min-height: 100%;
+             padding: 0;
+             margin: 0 auto;
+             display: flex;
+             flex-direction: column;
+             justify-content: center;
+             max-width: 450px;
+         }
+         .title {
+             font-size: 50px;
+         }
+         form {
+             margin: 0 auto;
+             width: min(300px, calc(70% + 100px));
+             text-align: left;
+             display: flex;
+             justify-content: center;
+             flex-direction: column;
+         }
+         form > * {
+             margin-bottom: 0.3em;
+         }
+         label {
+             flex: 0 0 auto;
+         }
+         input {
+             font-family: inherit;
+         }
+         .input {
+             width: 100%;
+             display: flex;
+             justify-content: space-between;
+         }
+         details {
+             margin: 20px 0;
+         }
+         .link_info {
+             text-align: left;
+         }
+         table {
+             text-align: center;
+         }
+         tr:nth-child(even) {
+             background-color: #2B2B2B;
+         }         td > p {
+             font-family: monospace;
+         }
+         td {
+             padding: 10px;
+         }
+        </style>
+    </head>
+    <body>
+        <p class="title">edit</p>
+        <form>
+            <div class="input">
+                <label for="url">URL:</label>
+                <input type="text" id="url" name="url" value="{{.URL}}">
+            </div>
+            <div class="input">
+                <label for="name">Name:</label>
+                <input type="text" id="name" name="name" value="{{.Name}}">
+            </div>
+            <input type="text" id="oldName" name="oldName" value="{{.Name}}" hidden="">
+            <input type="text" id="action" name="action" value="update" hidden="">
+            <input type="submit" formaction="/" value="Update shortened URL">
+        </form>
+    </body>
+</html>

templates/home_authenticated.html 🔗

@@ -78,7 +78,8 @@
                 <label for="name">Name (optional):</label>
                 <input type="text" id="name" name="name">
             </div>
-            <input type="submit" formaction="/create" value="Shorten URL">
+            <input type="text" id="action" name="action" value="create" hidden="">
+            <input type="submit" formaction="/?action=create" value="Shorten URL">
         </form>
         <details>
             <summary>Shortened URLs</summary>

updateHandler.go 🔗

@@ -2,11 +2,8 @@ package main
 
 import (
 	"fmt"
-	"log"
 	"net/http"
 	"strings"
-
-	"github.com/dgraph-io/badger/v3"
 )
 
 func (m *model) updateHandler(writer http.ResponseWriter, request *http.Request) {
@@ -20,53 +17,33 @@ func (m *model) updateHandler(writer http.ResponseWriter, request *http.Request)
 		return
 	}
 
+	oldName := query.Get("oldName")
 	name := query.Get("name")
-	new_name := query.Get("new_name")
-	new_url := query.Get("new_url")
+	destination := query.Get("url")
+
+	if len(oldName) == 0 {
+		http.Error(writer, "400 Bad Request: oldName parameter is required", 400)
+		return
+	}
 
 	if len(name) == 0 {
 		http.Error(writer, "400 Bad Request: name parameter is required", 400)
 		return
 	}
 
-	if len(new_name) == 0 && len(new_url) == 0 {
-		http.Error(writer, "400 Bad Request: You may update either the entry's URL or name but not both", 400)
+	if len(destination) == 0 {
+		http.Error(writer, "400 Bad Request: url parameter is required", 400)
 		return
 	}
 
-	if len(new_name) != 0 {
-		if m.nameExists(new_name) {
+	if len(name) != 0 && oldName != name {
+		if m.nameExists(name) {
 			http.Error(writer, "406 Not Acceptable: A shortened URL with this name already exists", 406)
 			return
 		}
-		name = new_name
 	}
 
-	var url string
-	err := m.database.View(func(txn *badger.Txn) error {
-		value, err := txn.Get([]byte(name))
-		if err != nil {
-			return err
-		}
-		url = value.String()
-		return nil
-	})
-	if err != nil {
-		log.Println("Error", err)
-	}
-
-	if len(new_url) != 0 {
-		url = new_url
-	}
-
-	log.Println("Mapping \"" + name + "\" to \"" + url + "\"")
-
-	err = m.database.Update(func(txn *badger.Txn) error {
-		return txn.Set([]byte(name), []byte(url))
-	})
-	if err != nil {
-		log.Println(err)
-	}
+	m.update(name, destination, oldName)
 
-	http.Error(writer, fmt.Sprint("\"", name, "\" mapped to \"", url, "\""), 200)
+	http.Error(writer, fmt.Sprint("\"", name, "\" mapped to \"", destination, "\""), 200)
 }