Explorar el Código

Refactored the frontend and added ability to list routes.

Kelly Norton hace 8 años
padre
commit
c6b794d8b8
Se han modificado 18 ficheros con 1004 adiciones y 194 borrados
  1. 9 3
      Makefile
  2. 31 9
      context/iter.go
  3. 1 1
      web/admin.go
  4. 124 31
      web/api.go
  5. 425 72
      web/api_test.go
  6. 4 5
      web/assets/edit.html
  7. 11 30
      web/assets/edit.scss
  8. 171 0
      web/assets/edit.ts
  9. 17 0
      web/assets/lib/dom.ts
  10. 6 0
      web/assets/lib/global.scss
  11. 14 0
      web/assets/lib/types.ts
  12. 61 0
      web/assets/lib/xhr.ts
  13. 18 24
      web/assets/links.html
  14. 34 0
      web/assets/links.scss
  15. 5 3
      web/bindata.go
  16. 21 7
      web/json.go
  17. 22 1
      web/name.go
  18. 30 8
      web/web.go

+ 9 - 3
Makefile

@@ -1,7 +1,7 @@
 CPP = /usr/bin/cpp -P -undef -Wundef -std=c99 -nostdinc -Wtrigraphs -fdollars-in-identifiers -C -Wno-invalid-pp-token
 
 SRC = $(shell find web/assets -maxdepth 1 -type f)
-DST = $(subst web/assets,.build/assets,$(SRC))
+DST = $(patsubst %.scss,%.css,$(patsubst %.ts,%.js,$(subst web/assets,.build/assets,$(SRC))))
 
 ALL: web/bindata.go
 
@@ -11,8 +11,14 @@ ALL: web/bindata.go
 .build/assets:
 	mkdir -p $@
 
-.build/assets/%.js: web/assets/%.js
-	$(CPP) $< | closure-compiler --js_output_file $@
+.build/assets/%.css: web/assets/%.scss
+	sass --no-cache --sourcemap=none --style=compressed $< $@
+
+.build/assets/%.js: web/assets/%.ts
+	$(eval TMP := $(shell mktemp))
+	tsc --out $(TMP) $< 
+	closure-compiler --js $(TMP) --js_output_file $@
+	rm -f $(TMP)
 
 .build/assets/%: web/assets/%
 	cp $< $@

+ 31 - 9
context/iter.go

@@ -14,6 +14,17 @@ type Iter struct {
 	err  error
 }
 
+func (i *Iter) decode() error {
+	rt := &Route{}
+	if err := rt.read(bytes.NewBuffer(i.it.Value())); err != nil {
+		return err
+	}
+
+	i.name = string(i.it.Key())
+	i.rt = rt
+	return nil
+}
+
 // Valid indicates whether the current values of the iterator are valid.
 func (i *Iter) Valid() bool {
 	return i.it.Valid() && i.err == nil
@@ -21,28 +32,39 @@ func (i *Iter) Valid() bool {
 
 // Next advances the iterator to the next value.
 func (i *Iter) Next() bool {
-	it := i.it
-
 	i.name = ""
 	i.rt = nil
 
-	if !it.Next() {
+	if !i.it.Next() {
 		return false
 	}
 
-	rt := &Route{}
-
-	if err := rt.read(bytes.NewBuffer(it.Value())); err != nil {
+	if err := i.decode(); err != nil {
 		i.err = err
 		return false
 	}
 
-	i.name = string(i.it.Key())
-	i.rt = rt
-
 	return true
 }
 
+// Seek ...
+func (i *Iter) Seek(cur []byte) bool {
+	i.name = ""
+	i.rt = nil
+
+	v := i.it.Seek(cur)
+
+	if !i.it.Valid() {
+		return v
+	}
+
+	if err := i.decode(); err != nil {
+		i.err = err
+	}
+
+	return v
+}
+
 // Error returns any active error that has stopped the iterator.
 func (i *Iter) Error() error {
 	if err := i.it.Error(); err != nil {

+ 1 - 1
web/admin.go

@@ -34,6 +34,6 @@ func (h *adminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	case "GET":
 		adminGet(h.ctx, w, r)
 	default:
-		writeJSONError(w, http.StatusText(http.StatusMethodNotAllowed))
+		writeJSONError(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusOK) // fix
 	}
 }

+ 124 - 31
web/api.go

@@ -1,11 +1,13 @@
 package web
 
 import (
+	"encoding/base64"
 	"encoding/json"
 	"errors"
-	"fmt"
 	"net/http"
 	"net/url"
+	"strconv"
+	"strings"
 	"time"
 
 	"github.com/kellegous/go/context"
@@ -17,8 +19,10 @@ const (
 )
 
 var (
-	errInvalidURL   = errors.New("Invalid URL")
-	errRedirectLoop = errors.New(" I'm sorry, Dave. I'm afraid I can't do that")
+	errInvalidURL        = errors.New("Invalid URL")
+	errRedirectLoop      = errors.New(" I'm sorry, Dave. I'm afraid I can't do that")
+	genURLPrefix    byte = ':'
+	postGenCursor        = []byte{genURLPrefix + 1}
 )
 
 // A very simple encoding of numeric ids. This is simply a base62 encoding
@@ -30,7 +34,7 @@ func encodeID(id uint64) string {
 		return "0"
 	}
 
-	b = append(b, ':')
+	b = append(b, genURLPrefix)
 
 	for id > 0 {
 		b = append(b, alpha[id%n])
@@ -40,6 +44,15 @@ func encodeID(id uint64) string {
 	return string(b)
 }
 
+// Advance to the next contetxt id and encode it as an ID.
+func nextEncodedID(ctx *context.Context) (string, error) {
+	id, err := ctx.NextID()
+	if err != nil {
+		return "", err
+	}
+	return encodeID(id), nil
+}
+
 // Check that the given URL is suitable as a shortcut link.
 func validateURL(r *http.Request, s string) error {
 	u, err := url.Parse(s)
@@ -69,39 +82,33 @@ func apiURLPost(ctx *context.Context, w http.ResponseWriter, r *http.Request) {
 	}
 
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeJSONError(w, "invalid json")
+		writeJSONError(w, "invalid json", http.StatusBadRequest)
 		return
 	}
 
-	// Handle delete requests
 	if req.URL == "" {
-		if p == "" {
-			writeJSONError(w, "url required")
-			return
-		}
-
-		if err := ctx.Del(p); err != nil {
-			writeJSONBackendError(w, err)
-			return
-		}
+		writeJSONError(w, "url required", http.StatusBadRequest)
+		return
+	}
 
-		writeJSONOk(w)
+	if isBannedName(p) {
+		writeJSONError(w, "name cannot be used", http.StatusBadRequest)
 		return
 	}
 
 	if err := validateURL(r, req.URL); err != nil {
-		writeJSONError(w, err.Error())
+		writeJSONError(w, err.Error(), http.StatusBadRequest)
 		return
 	}
 
 	// If no name is specified, an ID must be generate.
 	if p == "" {
-		id, err := ctx.NextID()
+		var err error
+		p, err = nextEncodedID(ctx)
 		if err != nil {
 			writeJSONBackendError(w, err)
 			return
 		}
-		p = encodeID(id)
 	}
 
 	rt := context.Route{
@@ -115,20 +122,19 @@ func apiURLPost(ctx *context.Context, w http.ResponseWriter, r *http.Request) {
 	}
 
 	writeJSONRoute(w, p, &rt)
-
 }
 
 func apiURLGet(ctx *context.Context, w http.ResponseWriter, r *http.Request) {
 	p := parseName("/api/url/", r.URL.Path)
 
 	if p == "" {
-		writeJSONOk(w)
+		writeJSONError(w, "no name given", http.StatusBadRequest)
 		return
 	}
 
 	rt, err := ctx.Get(p)
 	if err == leveldb.ErrNotFound {
-		writeJSONOk(w)
+		writeJSONError(w, "Not Found", http.StatusNotFound)
 		return
 	} else if err != nil {
 		writeJSONBackendError(w, err)
@@ -142,14 +148,11 @@ func apiURLDelete(ctx *context.Context, w http.ResponseWriter, r *http.Request)
 	p := parseName("/api/url/", r.URL.Path)
 
 	if p == "" {
-		writeJSONError(w, "name required")
+		writeJSONError(w, "name required", http.StatusBadRequest)
 		return
 	}
 
-	if err := ctx.Del(p); err == leveldb.ErrNotFound {
-		writeJSONError(w, fmt.Sprintf("%s not found", p))
-		return
-	} else if err != nil {
+	if err := ctx.Del(p); err != nil {
 		writeJSONBackendError(w, err)
 		return
 	}
@@ -157,9 +160,99 @@ func apiURLDelete(ctx *context.Context, w http.ResponseWriter, r *http.Request)
 	writeJSONOk(w)
 }
 
+func parseCursor(v string) ([]byte, error) {
+	if v == "" {
+		return nil, nil
+	}
+
+	return base64.URLEncoding.DecodeString(v)
+}
+
+func parseInt(v string, def int) (int, error) {
+	if v == "" {
+		return def, nil
+	}
+
+	i, err := strconv.ParseInt(v, 10, 64)
+	if err != nil {
+		return 0, err
+	}
+
+	return int(i), nil
+}
+
+func parseBool(v string, def bool) (bool, error) {
+	if v == "" {
+		return def, nil
+	}
+
+	v = strings.ToLower(v)
+	if v == "true" || v == "t" || v == "1" {
+		return true, nil
+	}
+
+	if v == "false" || v == "f" || v == "0" {
+		return false, nil
+	}
+
+	return false, errors.New("invalid boolean value")
+}
+
 func apiURLsGet(ctx *context.Context, w http.ResponseWriter, r *http.Request) {
-	// TODO(knorton): This will allow enumeration of the routes.
-	writeJSONError(w, http.StatusText(http.StatusNotImplemented))
+	c, err := parseCursor(r.FormValue("cursor"))
+	if err != nil {
+		writeJSONError(w, "invalid cursor value", http.StatusBadRequest)
+		return
+	}
+
+	lim, err := parseInt(r.FormValue("limit"), 100)
+	if err != nil || lim <= 0 || lim > 10000 {
+		writeJSONError(w, "invalid limit value", http.StatusBadRequest)
+		return
+	}
+
+	ig, err := parseBool(r.FormValue("include-generated-names"), false)
+	if err != nil {
+		writeJSONError(w, "invalid include-generated-names value", http.StatusBadRequest)
+		return
+	}
+
+	res := msgRoutes{
+		Ok: true,
+	}
+
+	iter := ctx.List(c)
+	defer iter.Release()
+
+	for iter.Next() {
+		// if we should be ignoring generated links, skip over that range.
+		if !ig && isGenerated(iter.Name()) {
+			iter.Seek(postGenCursor)
+			if !iter.Valid() {
+				break
+			}
+		}
+
+		res.Routes = append(res.Routes, &routeWithName{
+			Name:  iter.Name(),
+			Route: iter.Route(),
+		})
+
+		if len(res.Routes) == lim {
+			break
+		}
+	}
+
+	if iter.Next() {
+		res.Next = base64.URLEncoding.EncodeToString([]byte(iter.Name()))
+	}
+
+	if err := iter.Error(); err != nil {
+		writeJSONBackendError(w, err)
+		return
+	}
+
+	writeJSON(w, &res, http.StatusOK)
 }
 
 func apiURL(ctx *context.Context, w http.ResponseWriter, r *http.Request) {
@@ -171,7 +264,7 @@ func apiURL(ctx *context.Context, w http.ResponseWriter, r *http.Request) {
 	case "DELETE":
 		apiURLDelete(ctx, w, r)
 	default:
-		writeJSONError(w, http.StatusText(http.StatusMethodNotAllowed))
+		writeJSONError(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusOK) // fix
 	}
 }
 
@@ -180,7 +273,7 @@ func apiURLs(ctx *context.Context, w http.ResponseWriter, r *http.Request) {
 	case "GET":
 		apiURLsGet(ctx, w, r)
 	default:
-		writeJSONError(w, http.StatusText(http.StatusMethodNotAllowed))
+		writeJSONError(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusOK) // fix
 	}
 }
 

+ 425 - 72
web/api_test.go

@@ -2,18 +2,27 @@ package web
 
 import (
 	"bytes"
+	"encoding/base64"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
 	"io/ioutil"
 	"net/http"
+	"net/url"
 	"os"
 	"path/filepath"
 	"testing"
+	"time"
 
 	"github.com/kellegous/go/context"
+	"github.com/syndtr/goleveldb/leveldb"
 )
 
+type urlReq struct {
+	URL string `json:"url"`
+}
+
 type env struct {
 	mux *http.ServeMux
 	dir string
@@ -24,42 +33,41 @@ func (e *env) destroy() {
 	os.RemoveAll(e.dir)
 }
 
-func (e *env) getAPI(m *msg, name string) error {
-	return e.callAPI(m, "GET", name, nil)
+func (e *env) get(path string) (*mockResponse, error) {
+	return e.call("GET", path, nil)
 }
 
-func (e *env) postAPI(m *msg, name, url string) error {
-	r := struct {
-		URL string `json:"url"`
-	}{
-		url,
-	}
+func (e *env) post(path string, body interface{}) (*mockResponse, error) {
+	return e.callWithJSON("POST", path, body)
+}
+
+func (e *env) callWithJSON(method, path string, body interface{}) (*mockResponse, error) {
+	var r io.Reader
 
-	var buf bytes.Buffer
-	if err := json.NewEncoder(&buf).Encode(&r); err != nil {
-		return err
+	if body != nil {
+		var buf bytes.Buffer
+		if err := json.NewEncoder(&buf).Encode(body); err != nil {
+			return nil, err
+		}
+		r = &buf
 	}
 
-	return e.callAPI(m, "POST", name, &buf)
+	return e.call(method, path, r)
 }
 
-func (e *env) callAPI(m *msg, method, name string, body io.Reader) error {
-	req, err := http.NewRequest(method, fmt.Sprintf("/api/url/%s", name), body)
+func (e *env) call(method, path string, body io.Reader) (*mockResponse, error) {
+	req, err := http.NewRequest(method, path, body)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
-	res := mockResponse{
+	res := &mockResponse{
 		header: map[string][]string{},
 	}
 
-	e.mux.ServeHTTP(&res, req)
-
-	if err := json.NewDecoder(&res).Decode(&m); err != nil {
-		return err
-	}
+	e.mux.ServeHTTP(res, req)
 
-	return nil
+	return res, nil
 }
 
 func newEnv() (*env, error) {
@@ -107,42 +115,52 @@ func (r *mockResponse) WriteHeader(status int) {
 	r.status = status
 }
 
-func assertJustOk(t *testing.T, m *msg) {
-	if !m.Ok {
-		t.Fatal("expected OK message, but it's not OK")
+func mustBeSameNamedRoute(t *testing.T, a, b *routeWithName) {
+	if a.Name != b.Name || a.URL != b.URL || a.Time.UnixNano() != b.Time.UnixNano() {
+		t.Fatalf("routes are not same: %v vs %v", a, b)
+	}
+}
+
+func mustBeRouteOf(t *testing.T, rt *context.Route, url string) {
+	if rt == nil {
+		t.Fatal("route is nil")
 	}
 
-	if m.Error != "" {
-		t.Fatalf("expected no error, but got %s", m.Error)
+	if rt.URL != url {
+		t.Fatalf("expected url of %s, got %s", url, rt.URL)
 	}
 
-	if m.Route != nil {
-		t.Fatalf("expected no route, got %v", m.Route)
+	if rt.Time.IsZero() {
+		t.Fatal("route time is empty")
 	}
 }
 
-func assertOkWithRoute(t *testing.T, m *msg, url string) {
-	if !m.Ok {
-		t.Fatal("expected OK message, but it's not OK")
+func mustBeNamedRouteOf(t *testing.T, rt *routeWithName, name, url string) {
+	mustBeRouteOf(t, rt.Route, url)
+	if rt.Name != name {
+		t.Fatalf("expected name of %s, got %s", name, rt.Name)
 	}
+}
 
-	if m.Error != "" {
-		t.Fatalf("expected no error, but got %s", m.Error)
+func mustBeOk(t *testing.T, ok bool) {
+	if !ok {
+		t.Fatal("response is not ok")
 	}
+}
 
-	if m.Route == nil {
-		t.Fatalf("Route is nil, expected one with url of %s", url)
+func mustBeErr(t *testing.T, m *msgErr) {
+	if m.Ok {
+		t.Fatal("response is ok, should be err")
 	}
 
-	if m.Route.URL != url {
-		t.Fatalf("Expected url of %s, got %s", url, m.Route.URL)
+	if m.Error == "" {
+		t.Fatal("expected an Error, but it is empty")
 	}
 }
 
-func assertOkWithNamedRoute(t *testing.T, m *msg, name, url string) {
-	assertOkWithRoute(t, m, url)
-	if m.Route.Name != name {
-		t.Fatalf("expected name %s, got %s", name, m.Route.Name)
+func mustHaveStatus(t *testing.T, res *mockResponse, status int) {
+	if res.status != status {
+		t.Fatalf("expected response status %d, got %d", status, res.status)
 	}
 }
 
@@ -150,13 +168,26 @@ func TestAPIGetNotFound(t *testing.T) {
 	e := needEnv(t)
 	defer e.destroy()
 
-	var m msg
-	names := []string{"", "nothing", "nothing/there"}
-	for _, name := range names {
-		if err := e.getAPI(&m, name); err != nil {
+	names := map[string]int{
+		"":              http.StatusBadRequest,
+		"nothing":       http.StatusNotFound,
+		"nothing/there": http.StatusNotFound,
+	}
+
+	for name, status := range names {
+		res, err := e.get(fmt.Sprintf("/api/url/%s", name))
+		if err != nil {
 			t.Fatal(err)
 		}
-		assertJustOk(t, &m)
+
+		mustHaveStatus(t, res, status)
+
+		var m msgErr
+		if err := json.NewDecoder(res).Decode(&m); err != nil {
+			t.Fatal(err)
+		}
+
+		mustBeErr(t, &m)
 	}
 }
 
@@ -164,61 +195,383 @@ func TestAPIPutThenGet(t *testing.T) {
 	e := needEnv(t)
 	defer e.destroy()
 
-	var pm msg
-	if err := e.postAPI(&pm, "xxx", "http://ex.com/"); err != nil {
+	res, err := e.post("/api/url/xxx", &urlReq{
+		URL: "http://ex.com/",
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	mustHaveStatus(t, res, http.StatusOK)
+
+	var pm msgRoute
+	if err := json.NewDecoder(res).Decode(&pm); err != nil {
+		t.Fatal(err)
+	}
+
+	mustBeOk(t, pm.Ok)
+	mustBeNamedRouteOf(t, pm.Route, "xxx", "http://ex.com/")
+
+	res, err = e.get("/api/url/xxx")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	mustHaveStatus(t, res, http.StatusOK)
+
+	var gm msgRoute
+	if err := json.NewDecoder(res).Decode(&gm); err != nil {
+		t.Fatal(err)
+	}
+
+	mustBeOk(t, gm.Ok)
+	mustBeNamedRouteOf(t, pm.Route, "xxx", "http://ex.com/")
+}
+
+func TestBadPuts(t *testing.T) {
+	e := needEnv(t)
+	defer e.destroy()
+
+	var m msgErr
+
+	res, err := e.call("POST", "/api/url/yyy", bytes.NewBufferString("not json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	mustHaveStatus(t, res, http.StatusBadRequest)
+
+	if err := json.NewDecoder(res).Decode(&m); err != nil {
 		t.Fatal(err)
 	}
-	assertOkWithRoute(t, &pm, "http://ex.com/")
+	mustBeErr(t, &m)
 
-	var gm msg
-	if err := e.getAPI(&gm, "xxx"); err != nil {
+	res, err = e.post("/api/url/yyy", &urlReq{})
+	if err != nil {
 		t.Fatal(err)
 	}
-	assertOkWithNamedRoute(t, &gm, "xxx", "http://ex.com/")
+	mustHaveStatus(t, res, http.StatusBadRequest)
+
+	if err := json.NewDecoder(res).Decode(&m); err != nil {
+		t.Fatal(err)
+	}
+	mustBeErr(t, &m)
+
+	res, err = e.post("/api/url/yyy", &urlReq{"not a URL"})
+	if err != nil {
+		t.Fatal(err)
+	}
+	mustHaveStatus(t, res, http.StatusBadRequest)
+
+	if err := json.NewDecoder(res).Decode(&m); err != nil {
+		t.Fatal(err)
+	}
+	mustBeErr(t, &m)
 }
 
 func TestAPIDel(t *testing.T) {
 	e := needEnv(t)
 	defer e.destroy()
 
-	var am msg
-	if err := e.postAPI(&am, "yyy", ""); err != nil {
+	if err := e.ctx.Put("xxx", &context.Route{
+		URL:  "http://ex.com/",
+		Time: time.Now(),
+	}); err != nil {
 		t.Fatal(err)
 	}
-	assertJustOk(t, &am)
 
-	var bm msg
-	if err := e.postAPI(&bm, "yyy", "https://a.com/"); err != nil {
+	res, err := e.call("DELETE", "/api/url/xxx", nil)
+	if err != nil {
 		t.Fatal(err)
 	}
-	assertOkWithNamedRoute(t, &bm, "yyy", "https://a.com/")
 
-	var cm msg
-	if err := e.postAPI(&cm, "yyy", ""); err != nil {
+	mustHaveStatus(t, res, http.StatusOK)
+
+	var m msg
+	if err := json.NewDecoder(res).Decode(&m); err != nil {
 		t.Fatal(err)
 	}
-	assertJustOk(t, &cm)
+	mustBeOk(t, m.Ok)
 
-	var dm msg
-	if err := e.getAPI(&dm, "yyy"); err != nil {
-		t.Fatal(err)
+	if _, err := e.ctx.Get("xxx"); err != leveldb.ErrNotFound {
+		t.Fatal("expected xxx to be deleted")
 	}
-	assertJustOk(t, &dm)
 }
 
 func TestAPIPutThenGetAuto(t *testing.T) {
 	e := needEnv(t)
 	defer e.destroy()
 
-	var am msg
-	if err := e.postAPI(&am, "", "http://b.com/"); err != nil {
+	res, err := e.post("/api/url/", &urlReq{URL: "http://b.com/"})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	mustHaveStatus(t, res, http.StatusOK)
+
+	var am msgRoute
+	if err := json.NewDecoder(res).Decode(&am); err != nil {
 		t.Fatal(err)
 	}
-	assertOkWithRoute(t, &am, "http://b.com/")
+	mustBeOk(t, am.Ok)
+	mustBeRouteOf(t, am.Route.Route, "http://b.com/")
 
-	var bm msg
-	if err := e.getAPI(&bm, am.Route.Name); err != nil {
+	res, err = e.get(fmt.Sprintf("/api/url/%s", am.Route.Name))
+	if err != nil {
 		t.Fatal(err)
 	}
-	assertOkWithNamedRoute(t, &bm, am.Route.Name, "http://b.com/")
+
+	mustHaveStatus(t, res, http.StatusOK)
+
+	var bm msgRoute
+	if err := json.NewDecoder(res).Decode(&bm); err != nil {
+		t.Fatal(err)
+	}
+	mustBeOk(t, bm.Ok)
+	mustBeNamedRouteOf(t, bm.Route, am.Route.Name, "http://b.com/")
+}
+
+func getInPages(e *env, params url.Values) ([][]*routeWithName, error) {
+	var pages [][]*routeWithName
+
+	for {
+		res, err := e.get("/api/urls/?" + params.Encode())
+		if err != nil {
+			return nil, err
+		}
+
+		if res.status != http.StatusOK {
+			return nil, fmt.Errorf("HTTP status: %d", res.status)
+		}
+
+		var m msgRoutes
+		if err := json.NewDecoder(res).Decode(&m); err != nil {
+			return nil, err
+		}
+
+		if !m.Ok {
+			return nil, errors.New("response is not ok")
+		}
+
+		pages = append(pages, m.Routes)
+
+		if m.Next == "" {
+			return pages, nil
+		}
+
+		params.Set("cursor", m.Next)
+	}
+}
+
+type listTest struct {
+	Params url.Values
+	Pages  [][]*routeWithName
+}
+
+func TestAPIList(t *testing.T) {
+	e := needEnv(t)
+	defer e.destroy()
+
+	rts := []*routeWithName{
+		&routeWithName{
+			Name: "0",
+			Route: &context.Route{
+				URL:  "http://0.com/",
+				Time: time.Now(),
+			},
+		},
+
+		&routeWithName{
+			Name: "1",
+			Route: &context.Route{
+				URL:  "http://1.com/",
+				Time: time.Now(),
+			},
+		},
+
+		&routeWithName{
+			Name: ":a",
+			Route: &context.Route{
+				URL:  "http://ga.com/",
+				Time: time.Now(),
+			},
+		},
+
+		&routeWithName{
+			Name: ":b",
+			Route: &context.Route{
+				URL:  "http://gb.com/",
+				Time: time.Now(),
+			},
+		},
+
+		&routeWithName{
+			Name: "a",
+			Route: &context.Route{
+				URL:  "http://a.com/",
+				Time: time.Now(),
+			},
+		},
+
+		&routeWithName{
+			Name: "b",
+			Route: &context.Route{
+				URL:  "http://b.com/",
+				Time: time.Now(),
+			},
+		},
+	}
+
+	for _, rt := range rts {
+		if err := e.ctx.Put(rt.Name, rt.Route); err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	tests := []*listTest{
+		&listTest{
+			Params: url.Values(map[string][]string{}),
+			Pages: [][]*routeWithName{
+				[]*routeWithName{rts[0], rts[1], rts[4], rts[5]},
+			},
+		},
+		&listTest{
+			Params: url.Values(map[string][]string{
+				"include-generated-names": {"true"},
+			}),
+			Pages: [][]*routeWithName{rts},
+		},
+		&listTest{
+			Params: url.Values(map[string][]string{
+				"include-generated-names": {"false"},
+			}),
+			Pages: [][]*routeWithName{
+				[]*routeWithName{rts[0], rts[1], rts[4], rts[5]},
+			},
+		},
+		&listTest{
+			Params: url.Values(map[string][]string{
+				"limit": {"2"},
+			}),
+			Pages: [][]*routeWithName{
+				[]*routeWithName{rts[0], rts[1]},
+				[]*routeWithName{rts[4], rts[5]},
+			},
+		},
+		&listTest{
+			Params: url.Values(map[string][]string{
+				"limit":                   {"2"},
+				"include-generated-names": {"true"},
+			}),
+			Pages: [][]*routeWithName{
+				[]*routeWithName{rts[0], rts[1]},
+				[]*routeWithName{rts[2], rts[3]},
+				[]*routeWithName{rts[4], rts[5]},
+			},
+		},
+		&listTest{
+			Params: url.Values(map[string][]string{
+				"limit":  {"2"},
+				"cursor": {base64.URLEncoding.EncodeToString([]byte{':'})},
+			}),
+			Pages: [][]*routeWithName{
+				[]*routeWithName{rts[4], rts[5]},
+			},
+		},
+		&listTest{
+			Params: url.Values(map[string][]string{
+				"limit":                   {"3"},
+				"include-generated-names": {"true"},
+				"cursor":                  {base64.URLEncoding.EncodeToString([]byte{':'})},
+			}),
+			Pages: [][]*routeWithName{
+				[]*routeWithName{rts[2], rts[3], rts[4]},
+				[]*routeWithName{rts[5]},
+			},
+		},
+		&listTest{
+			Params: url.Values(map[string][]string{
+				"limit": {"1"},
+			}),
+			Pages: [][]*routeWithName{
+				[]*routeWithName{rts[0]},
+				[]*routeWithName{rts[1]},
+				[]*routeWithName{rts[4]},
+				[]*routeWithName{rts[5]},
+			},
+		},
+		&listTest{
+			Params: url.Values(map[string][]string{
+				"cursor": {base64.URLEncoding.EncodeToString([]byte{'z'})},
+			}),
+			Pages: [][]*routeWithName{nil},
+		},
+	}
+
+	for _, test := range tests {
+		t.Logf("running tests for ?%s", test.Params.Encode())
+		pages, err := getInPages(e, test.Params)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if len(pages) != len(test.Pages) {
+			t.Fatalf("number of pages mismatch %d vs %d", len(pages), len(test.Pages))
+		}
+
+		for i, n := 0, len(pages); i < n; i++ {
+			page := pages[i]
+			expected := test.Pages[i]
+
+			if len(page) != len(expected) {
+				t.Fatalf("page %d, length mismatch expected %d got %d", i, len(expected), len(page))
+			}
+
+			for j, m := 0, len(page); j < m; j++ {
+				mustBeSameNamedRoute(t, page[j], expected[j])
+			}
+		}
+	}
+}
+
+func TestBadList(t *testing.T) {
+	e := needEnv(t)
+	defer e.destroy()
+
+	tests := map[string]int{
+		url.Values{
+			"cursor": {"not a cursor"},
+		}.Encode(): http.StatusBadRequest,
+
+		url.Values{
+			"limit": {"0"},
+		}.Encode(): http.StatusBadRequest,
+
+		url.Values{
+			"limit": {"not a limit"},
+		}.Encode(): http.StatusBadRequest,
+
+		url.Values{
+			"limit": {"100000"},
+		}.Encode(): http.StatusBadRequest,
+
+		url.Values{
+			"include-generated-names": {"butter"},
+		}.Encode(): http.StatusBadRequest,
+	}
+
+	for params, status := range tests {
+		res, err := e.get("/api/urls/?" + params)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		mustHaveStatus(t, res, status)
+
+		var m msgErr
+		if err := json.NewDecoder(res).Decode(&m); err != nil {
+			t.Fatal(err)
+		}
+
+		mustBeErr(t, &m)
+	}
 }

+ 4 - 5
web/assets/index.html → web/assets/edit.html

@@ -1,9 +1,9 @@
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
-<html xmlns="http://www.w3.org/1999/xhtml">
+<!DOCTYPE html>
+<html>
   <head>
     <title>Go</title>
     <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
-    <link href="/s/index.css"
+    <link href="/s/edit.css"
         rel="stylesheet"
         type="text/css">
     <link href="http://fonts.googleapis.com/css?family=Raleway:400,300"
@@ -19,7 +19,6 @@
       <div id="cmp"></div>
     </form>
 
-    <script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js"></script>
-    <script src="/s/index.js"></script>
+    <script src="/s/edit.js"></script>
   </body>
 </html>

+ 11 - 30
web/assets/index.css → web/assets/edit.scss

@@ -1,9 +1,4 @@
-body {
-  background: #fff;
-  font-family: 'Raleway', sans-serif;
-  font-size: 42px;
-  font-weight: 300;
-}
+@import "lib/global";
 
 form {
   text-align: center;
@@ -63,12 +58,12 @@ form {
   color: #999;
 }
 
-#cmp > a, .links a {
+#cmp > a {
   color: #09f;
   text-decoration: none;
 }
 
-#cmp > .hnt, .links .full-url {
+#cmp > .hnt {
   color: #ddd;
   text-shadow: 1px 1px 0 #fff;
 }
@@ -88,29 +83,15 @@ form {
   background-repeat: no-repeat;
   background-size: 48px 48px;
   cursor: pointer;
+  transition: opacity 200ms cubic-bezier(0.77, 0, 0.175, 1);
+  opacity: 0;
+  pointer-events: none;
+}
+#cls.vis {
   opacity: 0.3;
-  display: none;
+  pointer-events: inherit;
 }
 
-#cls:hover, .links a:hover {
+#cls:hover {
   opacity: 0.6;
-}
-
-.links {
-  width: 800px;
-  margin: 0 auto;
-}
-
-.links h1 {
-  color: #333;
-  margin-bottom: 40px;
-}
-
-.links ul {
-  padding: 0;
-  list-style-type: none;
-}
-
-.links ul li {
-  margin-bottom: 20px;
-}
+}

+ 171 - 0
web/assets/edit.ts

@@ -0,0 +1,171 @@
+/// <reference path="lib/dom.ts" />
+/// <reference path="lib/types.ts" />
+/// <reference path="lib/xhr.ts" />
+
+namespace go {
+    // Get the OS-specific shortcut key for copying.
+    var copyKey = () => navigator.userAgent.indexOf('Macintosh') >= 0
+        ? '⌘-C'
+        : 'Ctrl-C';
+
+    // Extract the name from the page location.
+    var nameFrom = (uri: string) => {
+        var parts = uri.substring(1).split('/');
+        return parts[1];
+    };
+
+    // Called with the window resizes.
+    var windowDidResize = () => {
+        var rect = $frm.getBoundingClientRect();
+        dom.css($frm, 'margin-top', (window.innerHeight/3 - rect.height/2) + 'px');
+    };
+
+    // Called when the URL changes.
+    var urlDidChange = () => {
+        var url = ($url.value || '').trim();
+        if (url == lastUrl) {
+            return;
+        }
+
+        lastUrl = url;
+
+        hideDrawer();
+        if (url) {
+            $cls.classList.add('vis');
+        } else {
+            $cls.classList.remove('vis');
+        }
+    };
+
+    var formDidSubmit = (e: Event) => {
+        e.preventDefault();
+
+        var name = nameFrom(location.pathname),
+            url = ($url.value || '').trim();
+
+        xhr.post('/api/url/' + name)
+            .sendJSON({url: url})
+            .onDone((data: string, status: number) => {
+                var msg = <MsgRoute>JSON.parse(data);
+                if (!msg.ok) {
+                    showError(msg.error);
+                    return;
+                }
+
+                var route = msg.route;
+                if (!route) {
+                    hideDrawer();
+                    return;
+                }
+
+                var url = route.url || '',
+                    name = route.name || '';
+                if (url) {
+                    history.replaceState({}, null, '/edit/' + name);
+                    showLink(name);
+                }
+            });
+    };
+
+    var formDidClear = () => {
+        var name = nameFrom(location.pathname),
+            url = ($url.value || '').trim();
+
+        $url.value = '';
+        urlDidChange();
+
+        if (!name) {
+            return;
+        }
+
+        xhr.create('DELETE', '/api/url/' + name)
+            .send()
+            .onDone((data: string, status: number) => {
+                var msg = <Msg>JSON.parse(data);
+                if (!msg.ok) {
+                    showError(msg.error);
+                }
+            });
+    };
+
+    var hideDrawer = () => {
+        dom.css($cmp, 'transform', 'scaleY(0)');
+    };
+
+    var showError = (msg: string) => {
+        $cmp.textContent = '';
+        $cmp.classList.remove('link');
+        $cmp.classList.add('fuck');
+
+        var $s = dom.c('span');
+        $s.textContent = 'ERROR: ' + msg;
+        $cmp.appendChild($s);
+
+        dom.css($cmp, 'transform', 'scaleY(1)');
+    };
+
+    var showLink = (name: string) => {
+        var lnk = location.origin + '/' + name;
+
+        $cmp.textContent = '';
+        $cmp.classList.remove('fuck');
+        $cmp.classList.add('link');
+
+        var $a = dom.c('a');
+        $a.setAttribute('href', lnk);
+        $a.textContent = lnk;
+        $cmp.appendChild($a);
+
+        var $h = dom.c('span');
+        $h.classList.add('hnt');
+        $h.textContent = copyKey();
+        $cmp.appendChild($h);
+
+        dom.css($cmp, 'transform', 'scaleY(1)');
+
+        getSelection().setBaseAndExtent($a, 0, $a, 1);
+    };
+
+    // Called when the app loads initially.
+    var appDidLoad = () => {
+        windowDidResize();
+        window.addEventListener('resize', windowDidResize, false);
+        $frm.addEventListener('submit', formDidSubmit, false);
+
+        $url.addEventListener('keyup', urlDidChange, false);
+        $url.addEventListener('paste', urlDidChange, false);
+        $url.addEventListener('change', urlDidChange, false);
+
+        $cls.addEventListener('click', formDidClear, false);
+
+        var name = nameFrom(location.pathname);
+        if (!name) {
+            $url.focus();
+            return;
+        }
+
+        xhr.get('/api/url/' + name)
+            .send()
+            .onDone((data: string, status: number) => {
+                var msg = <MsgRoute>JSON.parse(data);
+
+                if (status != 200) {
+                    return;
+                }
+
+                // TODO(knorton): Hanlde things.
+                var url = msg.route.url || '';
+                $url.value = url;
+                $url.focus();
+                urlDidChange();
+            });
+    };
+
+    var $frm = <HTMLFormElement>dom.q('form'),
+        $cmp = dom.q('#cmp'),
+        $cls = dom.q('#cls'),
+        $url = <HTMLInputElement>dom.q('#url'),
+        lastUrl: string;
+
+    appDidLoad();
+}

+ 17 - 0
web/assets/lib/dom.ts

@@ -0,0 +1,17 @@
+namespace dom {
+	export var q = (s: string) => {
+		return <HTMLElement>document.querySelector(s);
+	};
+
+	export var qa = (s: string) => {
+		return document.querySelectorAll(s);
+	};
+
+	export var c = (n: string) => {
+		return document.createElement(n);
+	};
+
+	export var css = (el: HTMLElement, p: string, v: any) => {
+		el.style.setProperty(p, v, '');
+	};
+}

+ 6 - 0
web/assets/lib/global.scss

@@ -0,0 +1,6 @@
+body {
+  background: #fff;
+  font-family: 'Raleway', sans-serif;
+  font-size: 42px;
+  font-weight: 300;
+}

+ 14 - 0
web/assets/lib/types.ts

@@ -0,0 +1,14 @@
+interface Route {
+	name: string;
+	url: string;
+	time: string;
+}
+
+interface Msg {
+	ok: boolean;
+	error?: string;
+}
+
+interface MsgRoute extends Msg {
+	route: Route;
+}

+ 61 - 0
web/assets/lib/xhr.ts

@@ -0,0 +1,61 @@
+namespace xhr {
+	export class Req {
+		private doneFns = [];
+
+		private errorFns = [];
+
+		public constructor(private xhr: XMLHttpRequest) {
+			xhr.onload = () => {
+				var text = xhr.responseText,
+					status = xhr.status;
+				this.doneFns.forEach((fn) => {
+					fn(text, status);
+				});
+			};
+
+			xhr.onerror = () => {
+				this.errorFns.forEach((fn) => fn());
+			};
+		}
+
+		public onDone(fn: (data: string, status: number) => void) {
+			this.doneFns.push(fn);
+			return this;
+		}
+
+		public onError(fn: () => void) {
+			this.errorFns.push(fn);
+			return this;
+		}
+
+		public withHeader(k: string, v: string) {
+			this.xhr.setRequestHeader(k, v);
+			return this;
+		}
+
+		public sendJSON(data: any) {
+			this.withHeader('Content-Type', 'application/json;charset=utf8');
+			this.xhr.send(JSON.stringify(data));
+			return this;
+		}
+
+		public send(data?: string) {
+			this.xhr.send(data);
+			return this;
+		}
+	}
+
+	export var create = (method: string, url: string) => {
+		var xhr = new XMLHttpRequest();
+		xhr.open(method, url, true);
+		return new Req(xhr);
+	};
+
+	export var get = (url: string) => {
+		return create('GET', url);
+	}
+
+	export var post = (url: string) => {
+		return create('POST', url);
+	}
+}

+ 18 - 24
web/assets/links.html

@@ -1,30 +1,24 @@
-<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
-<html xmlns="http://www.w3.org/1999/xhtml">
-  <head>
+<!DOCTYPE html>
+<html>
+<head>
     <title>Go :: Active Links</title>
     <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
-    <link href="/s/index.css"
-        rel="stylesheet"
-        type="text/css">
+    <link href="/s/links.css"
+        rel="stylesheet">
     <link href="http://fonts.googleapis.com/css?family=Raleway:400,300"
-        rel="stylesheet"
-        type="text/css">
-  </head>
-  <body>
-
+        rel="stylesheet">
+</head>
+<body>
     <div class="links">
-      <h1>Active links</h1>
-      <ul>
-        {{ range $key, $route := . }}
-        <li>
-          <a href="{{ $route.URL }}">go/{{ $key }}</a><br />
-          <a href="{{ $route.URL }}" class="full-url">{{ $route.URL }}</a>
-        </li>
-        {{ end }}
-      </ul>
+        <h1>Active links</h1>
+        <ul>
+            {{ range $key, $route := . }}
+            <li>
+                <a href="{{ $route.URL }}">go/{{ $key }}</a><br />
+                <a href="{{ $route.URL }}" class="full-url">{{ $route.URL }}</a>
+            </li>
+            {{ end }}
+        </ul>
     </div>
-
-    <script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js"></script>
-    <script src="/s/index.js"></script>
-  </body>
+</body>
 </html>

+ 34 - 0
web/assets/links.scss

@@ -0,0 +1,34 @@
+@import "lib/global";
+
+.links {
+    width: 800px;
+    margin: 0 auto;
+
+    a {
+        color: #09f;
+        text-decoration: none;
+
+        &:hover {
+            opacity: 0.6;
+        }
+    }
+
+    .full-url {
+        color: #ddd;
+        text-shadow: 1px 1px 0 #fff;
+    }
+}
+
+.links h1 {
+    color: #333;
+    margin-bottom: 40px;
+}
+
+.links ul {
+    padding: 0;
+    list-style-type: none;
+}
+
+.links ul li {
+    margin-bottom: 20px;
+}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 5 - 3
web/bindata.go


+ 21 - 7
web/json.go

@@ -16,9 +16,23 @@ type routeWithName struct {
 
 // The response type for all API responses.
 type msg struct {
+	Ok bool `json:"ok"`
+}
+
+type msgErr struct {
+	Ok    bool   `json:"ok"`
+	Error string `json:"error"`
+}
+
+type msgRoute struct {
 	Ok    bool           `json:"ok"`
-	Error string         `json:"error,omitempty"`
-	Route *routeWithName `json:"route,omitempty"`
+	Route *routeWithName `json:"route"`
+}
+
+type msgRoutes struct {
+	Ok     bool             `json:"ok"`
+	Routes []*routeWithName `json:"routes"`
+	Next   string           `json:"next"`
 }
 
 // Encode the given data to JSON and send it to the client.
@@ -38,22 +52,22 @@ func writeJSONOk(w http.ResponseWriter) {
 }
 
 // Encode an error response and send it to the client.
-func writeJSONError(w http.ResponseWriter, err string) {
-	writeJSON(w, &msg{
+func writeJSONError(w http.ResponseWriter, err string, status int) {
+	writeJSON(w, &msgErr{
 		Ok:    false,
 		Error: err,
-	}, http.StatusOK)
+	}, status)
 }
 
 // Encode a generic backend error and send it to the client.
 func writeJSONBackendError(w http.ResponseWriter, err error) {
 	log.Printf("[error] %s", err)
-	writeJSONError(w, "backend error")
+	writeJSONError(w, "backend error", http.StatusInternalServerError)
 }
 
 // Encode the given named route as a msg and send it to the client.
 func writeJSONRoute(w http.ResponseWriter, name string, rt *context.Route) {
-	writeJSON(w, &msg{
+	writeJSON(w, &msgRoute{
 		Ok: true,
 		Route: &routeWithName{
 			Name:  name,

+ 22 - 1
web/name.go

@@ -1,9 +1,20 @@
 package web
 
-import "strings"
+import (
+	"strings"
+)
 
 const encodedIDPrefix = ":"
 
+var bannedNames = map[string]bool{
+	"api":     true,
+	"edit":    true,
+	"healthz": true,
+	"links":   true,
+	"s":       true,
+	"version": true,
+}
+
 // Parse the shortcut name from the given URL path, given the base URL that is
 // handling the request.
 func parseName(base, path string) string {
@@ -23,3 +34,13 @@ func cleanName(name string) string {
 	}
 	return name
 }
+
+// Is this name one that was generated from the incrementing id.
+func isGenerated(name string) bool {
+	return strings.HasPrefix(name, string(genURLPrefix))
+}
+
+// isBannedName indicates if the name is one that is reserved by the server?
+func isBannedName(name string) bool {
+	return bannedNames[name]
+}

+ 30 - 8
web/web.go

@@ -28,6 +28,16 @@ func serveAsset(w http.ResponseWriter, r *http.Request, name string) {
 	http.ServeContent(w, r, n.Name(), n.ModTime(), bytes.NewReader(a))
 }
 
+func templateFromAssetFn(fn func() (*asset, error)) (*template.Template, error) {
+	a, err := fn()
+	if err != nil {
+		return nil, err
+	}
+
+	t := template.New(a.info.Name())
+	return t.Parse(string(a.bytes))
+}
+
 // The default handler responds to most requests. It is responsible for the
 // shortcut redirects and for sending unmapped shortcuts to the edit page.
 func getDefault(ctx *context.Context, w http.ResponseWriter, r *http.Request) {
@@ -54,15 +64,19 @@ func getDefault(ctx *context.Context, w http.ResponseWriter, r *http.Request) {
 }
 
 func getLinks(ctx *context.Context, w http.ResponseWriter, r *http.Request) {
-	t := template.New("links")
-	contents, _ := linksHtmlBytes()
-	t, err := t.Parse(string(contents))
+	t, err := templateFromAssetFn(linksHtml)
 	if err != nil {
 		log.Panic(err)
 	}
 
-	routes, _ := ctx.GetAll()
-	t.Execute(w, routes)
+	rts, err := ctx.GetAll()
+	if err != nil {
+		log.Panic(err)
+	}
+
+	if err := t.Execute(w, rts); err != nil {
+		log.Panic(err)
+	}
 }
 
 // ListenAndServe sets up all web routes, binds the port and handles incoming
@@ -80,7 +94,15 @@ func ListenAndServe(addr string, admin bool, version string, ctx *context.Contex
 		getDefault(ctx, w, r)
 	})
 	mux.HandleFunc("/edit/", func(w http.ResponseWriter, r *http.Request) {
-		serveAsset(w, r, "index.html")
+		p := parseName("/edit/", r.URL.Path)
+
+		// if this is a banned name, just redirect to the local URI. That'll show em.
+		if isBannedName(p) {
+			http.Redirect(w, r, fmt.Sprintf("/%s", p), http.StatusTemporaryRedirect)
+			return
+		}
+
+		serveAsset(w, r, "edit.html")
 	})
 	mux.HandleFunc("/links/", func(w http.ResponseWriter, r *http.Request) {
 		getLinks(ctx, w, r)
@@ -88,11 +110,11 @@ func ListenAndServe(addr string, admin bool, version string, ctx *context.Contex
 	mux.HandleFunc("/s/", func(w http.ResponseWriter, r *http.Request) {
 		serveAsset(w, r, r.URL.Path[len("/s/"):])
 	})
-	mux.HandleFunc("/:version", func(w http.ResponseWriter, r *http.Request) {
+	mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
 		fmt.Fprintln(w, version)
 	})
 	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
-		fmt.Fprintln(w, "OK")
+		fmt.Fprintln(w, "👍")
 	})
 
 	// TODO(knorton): Remove the admin handler.

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio