17 changed files with 1205 additions and 0 deletions
-
10auth/authenticator.go
-
138auth/authenticator_test.go
-
108auth/handler.go
-
32auth/handler_test.go
-
43auth/list.go
-
98auth/session.go
-
44auth/session_test.go
-
19auth/user.go
-
28generate/id.go
-
72resource.go
-
396resource_test.go
-
12response/empty.go
-
12response/html.go
-
26response/json.go
-
12response/text.go
-
73router.go
-
82router_test.go
@ -0,0 +1,10 @@ |
|||||
|
package auth |
||||
|
|
||||
|
type Authenticator interface { |
||||
|
ID() string |
||||
|
Name() string |
||||
|
Exists(username string) bool |
||||
|
Find(userid string) *User |
||||
|
Login(username, password string) (*User, error) |
||||
|
Register(username, password string, data map[string]string) (*User, error) |
||||
|
} |
@ -0,0 +1,138 @@ |
|||||
|
package auth |
||||
|
|
||||
|
import ( |
||||
|
"errors" |
||||
|
"strings" |
||||
|
"testing" |
||||
|
|
||||
|
"git.aiterp.net/gisle/wrouter/generate" |
||||
|
) |
||||
|
|
||||
|
var ErrExists = errors.New("auth: user exists") |
||||
|
var ErrLogin = errors.New("auth: login failed") |
||||
|
|
||||
|
type testAuther struct { |
||||
|
FullName string |
||||
|
users []*User |
||||
|
passwords map[string]string |
||||
|
} |
||||
|
|
||||
|
func (ta *testAuther) ID() string { |
||||
|
return strings.ToLower(ta.FullName) |
||||
|
} |
||||
|
|
||||
|
func (ta *testAuther) Name() string { |
||||
|
return ta.FullName |
||||
|
} |
||||
|
|
||||
|
func (ta *testAuther) Exists(username string) bool { |
||||
|
for _, user := range ta.users { |
||||
|
if user.Name == username { |
||||
|
return true |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
func (ta *testAuther) Find(userid string) *User { |
||||
|
for _, user := range ta.users { |
||||
|
if user.ID == userid { |
||||
|
return user |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (ta *testAuther) Login(username, password string) (*User, error) { |
||||
|
for _, user := range ta.users { |
||||
|
if user.Name == username && password == ta.passwords[user.ID] { |
||||
|
return user, nil |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return nil, ErrLogin |
||||
|
} |
||||
|
|
||||
|
func (ta *testAuther) Register(username, password string, data map[string]string) (*User, error) { |
||||
|
if ta.Exists(username) { |
||||
|
return nil, ErrExists |
||||
|
} |
||||
|
|
||||
|
if ta.passwords == nil { |
||||
|
ta.passwords = make(map[string]string) |
||||
|
} |
||||
|
|
||||
|
id := generate.ID() |
||||
|
ta.passwords[id] = password |
||||
|
|
||||
|
user := NewUser(ta, id, username, "member", data) |
||||
|
ta.users = append(ta.users, user) |
||||
|
return user, nil |
||||
|
} |
||||
|
|
||||
|
func TestList(t *testing.T) { |
||||
|
ta1 := testAuther{FullName: "Auth1"} |
||||
|
ta2 := testAuther{FullName: "Auth2"} |
||||
|
Register(&ta1) |
||||
|
Register(&ta2) |
||||
|
|
||||
|
if ta1.ID() != "auth1" { |
||||
|
t.Errorf("ta1.ID() = %s", ta1.ID()) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if ta2.ID() != "auth2" { |
||||
|
t.Errorf("ta2.ID() = %s", ta2.ID()) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
t.Run("Find", func(t *testing.T) { |
||||
|
fa1 := FindAuthenticator("auth1") |
||||
|
fa2 := FindAuthenticator("auth2") |
||||
|
|
||||
|
if &ta1 != fa1 { |
||||
|
t.Errorf("%s != %s", ta1.ID(), fa1.ID()) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if &ta2 != fa2 { |
||||
|
t.Errorf("%s != %s", ta2.ID(), fa2.ID()) |
||||
|
t.Fail() |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("Register", func(t *testing.T) { |
||||
|
user, err := ta1.Register("Test", "CakesAndStuff", nil) |
||||
|
if err != nil || user.Name != "Test" { |
||||
|
t.Logf("err = %v; name = \"%s\"", err, user.Name) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if !ta1.Exists("Test") { |
||||
|
t.Log("Registered user does not exist") |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
user2, err := ta1.Register("Test", "CakesAndStuff", nil) |
||||
|
if err == nil || user2 != nil { |
||||
|
t.Logf("err = %s; name = \"%s\"", err, user2.Name) |
||||
|
t.Fail() |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("Login", func(t *testing.T) { |
||||
|
user, err := ta1.Login("Test", "CakesAndStuff") |
||||
|
if err != nil || user.Name != "Test" { |
||||
|
t.Logf("err = %v; name = \"%s\"", err, user.Name) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
user2, err := ta1.Login("Test", "WrongPassword") |
||||
|
if err == nil || user2 != nil { |
||||
|
t.Logf("err = %v; name = \"%s\"", err, user.Name) |
||||
|
t.Fail() |
||||
|
} |
||||
|
}) |
||||
|
} |
@ -0,0 +1,108 @@ |
|||||
|
package auth |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
"strings" |
||||
|
|
||||
|
"git.aiterp.net/gisle/wrouter/response" |
||||
|
) |
||||
|
|
||||
|
type handler struct { |
||||
|
} |
||||
|
|
||||
|
func (h *handler) Handle(path string, w http.ResponseWriter, req *http.Request, user *User) bool { |
||||
|
// Get the subpath out of the path
|
||||
|
subpath := req.URL.Path[len(path):] |
||||
|
if subpath[0] == '/' { |
||||
|
subpath = subpath[1:] |
||||
|
} |
||||
|
|
||||
|
method := FindAuthenticator(req.Form.Get("method")) |
||||
|
|
||||
|
switch strings.ToLower(subpath) { |
||||
|
case "login": |
||||
|
{ |
||||
|
if req.Method != "POST" { |
||||
|
response.Text(w, 405, req.Method+" not allowed") |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
username := req.Form.Get("username") |
||||
|
password := req.Form.Get("password") |
||||
|
|
||||
|
w.Header().Set("X-Auth-Method", method.Name()) |
||||
|
|
||||
|
user, err := method.Login(username, password) |
||||
|
if err != nil && user != nil { |
||||
|
sess := OpenSession(user) |
||||
|
http.SetCookie(w, &http.Cookie{Name: SessionCookieName, Value: sess.ID, Expires: sess.Time.Add(SessionMaxTime)}) |
||||
|
|
||||
|
response.JSON(w, 200, sess) |
||||
|
} else { |
||||
|
response.Text(w, 401, "Login failed") |
||||
|
} |
||||
|
} |
||||
|
case "register": |
||||
|
{ |
||||
|
if req.Method != "POST" { |
||||
|
response.Text(w, 405, req.Method+" not allowed") |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
data := make(map[string]string) |
||||
|
for key, value := range req.Form { |
||||
|
if key != "username" && key != "password" && key != "method" { |
||||
|
data[key] = value[0] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
username := req.Form.Get("username") |
||||
|
password := req.Form.Get("password") |
||||
|
|
||||
|
user, err := method.Register(username, password, data) |
||||
|
if err != nil && user != nil { |
||||
|
sess := OpenSession(user) |
||||
|
http.SetCookie(w, &http.Cookie{Name: SessionCookieName, Value: sess.ID, Expires: sess.Time.Add(SessionMaxTime)}) |
||||
|
|
||||
|
response.JSON(w, 200, sess) |
||||
|
} else { |
||||
|
response.Text(w, 401, "Register failed") |
||||
|
} |
||||
|
} |
||||
|
case "logout-all": |
||||
|
{ |
||||
|
if req.Method != "POST" { |
||||
|
response.Text(w, 405, req.Method+" not allowed") |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
if user != nil { |
||||
|
ClearSessions(user) |
||||
|
response.Empty(w) |
||||
|
} else { |
||||
|
response.Text(w, 401, "Not logged in") |
||||
|
} |
||||
|
} |
||||
|
case "status": |
||||
|
{ |
||||
|
if req.Method != "GET" { |
||||
|
response.Text(w, 405, req.Method+" not allowed") |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
if user != nil { |
||||
|
response.JSON(w, 200, user) |
||||
|
} else { |
||||
|
response.Text(w, 401, "Not logged in") |
||||
|
} |
||||
|
} |
||||
|
default: |
||||
|
{ |
||||
|
response.Text(w, 404, "Operation not found: "+subpath) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
var Handler = &handler{} |
@ -0,0 +1,32 @@ |
|||||
|
package auth |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
"net/url" |
||||
|
"strings" |
||||
|
"testing" |
||||
|
) |
||||
|
|
||||
|
type handlerStruct struct{} |
||||
|
|
||||
|
func (hs *handlerStruct) ServeHTTP(w http.ResponseWriter, req *http.Request) { |
||||
|
req.ParseForm() // Router does this in non-tests
|
||||
|
|
||||
|
if strings.HasPrefix(req.URL.Path, "/auth") { |
||||
|
Handler.Handle("/auth", w, req, nil) |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestHandler(t *testing.T) { |
||||
|
auther := testAuther{FullName: "Test"} |
||||
|
Register(&auther) |
||||
|
|
||||
|
form := url.Values{} |
||||
|
form.Set("username", "Test") |
||||
|
form.Set("password", "stuff'nthings") |
||||
|
|
||||
|
t.Run("Register", func(t *testing.T) { |
||||
|
|
||||
|
}) |
||||
|
} |
@ -0,0 +1,43 @@ |
|||||
|
package auth |
||||
|
|
||||
|
import "strings" |
||||
|
|
||||
|
var methods []Authenticator |
||||
|
|
||||
|
// Register a method
|
||||
|
func Register(method Authenticator) { |
||||
|
methods = append(methods, method) |
||||
|
} |
||||
|
|
||||
|
// FindAuthenticator finds the first Method that answers with
|
||||
|
// the ID().
|
||||
|
func FindAuthenticator(id string) Authenticator { |
||||
|
for _, method := range methods { |
||||
|
if method.ID() == id { |
||||
|
return method |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
// ListAuthenticators gets a copy of the method list
|
||||
|
func ListAuthenticators() []Authenticator { |
||||
|
dst := make([]Authenticator, len(methods)) |
||||
|
copy(dst, methods) |
||||
|
|
||||
|
return dst |
||||
|
} |
||||
|
|
||||
|
func FindUser(fullid string) *User { |
||||
|
split := strings.SplitN(fullid, ":", 2) |
||||
|
autherID := split[0] |
||||
|
userID := split[1] |
||||
|
|
||||
|
auther := FindAuthenticator(autherID) |
||||
|
if auther == nil { |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
return auther.Find(userID) |
||||
|
} |
@ -0,0 +1,98 @@ |
|||||
|
package auth |
||||
|
|
||||
|
import ( |
||||
|
"log" |
||||
|
"sync" |
||||
|
"time" |
||||
|
|
||||
|
"git.aiterp.net/gisle/wrouter/generate" |
||||
|
) |
||||
|
|
||||
|
const SessionMaxTime = time.Hour * 72 |
||||
|
|
||||
|
var sessionMutex sync.RWMutex |
||||
|
var sessions = make(map[string]*Session, 512) |
||||
|
var lastCheck = time.Now() |
||||
|
|
||||
|
// SessionCookieName for the session cookie
|
||||
|
var SessionCookieName = "sessid" |
||||
|
|
||||
|
// Session is a simple in-memory structure describing a suer session
|
||||
|
type Session struct { |
||||
|
ID string `json:"id"` |
||||
|
UserID string `json:"user"` |
||||
|
Time time.Time `json:"time"` |
||||
|
} |
||||
|
|
||||
|
// OpenSession creates a new session from the supplied user's ID
|
||||
|
func OpenSession(user *User) *Session { |
||||
|
session := &Session{generate.SessionID(), user.FullID(), time.Now()} |
||||
|
|
||||
|
sessionMutex.Lock() |
||||
|
sessions[session.ID] = session |
||||
|
sessionMutex.Unlock() |
||||
|
|
||||
|
// No need to do these checks when there's no activity.
|
||||
|
if time.Since(lastCheck) > time.Hour { |
||||
|
lastCheck = time.Now() |
||||
|
go cleanup() |
||||
|
} |
||||
|
|
||||
|
return session |
||||
|
} |
||||
|
|
||||
|
// FindSession returns a session if the id maps to a still valid session
|
||||
|
func FindSession(id string) *Session { |
||||
|
sessionMutex.RLock() |
||||
|
defer sessionMutex.RUnlock() |
||||
|
|
||||
|
session := sessions[id] |
||||
|
|
||||
|
// Check expiry and update
|
||||
|
if session != nil { |
||||
|
if time.Since(session.Time) > SessionMaxTime { |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
if time.Since(session.Time) > time.Hour { |
||||
|
session.Time = time.Now() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return session |
||||
|
} |
||||
|
|
||||
|
// CloseSession deletes a session by the id
|
||||
|
func CloseSession(id string) { |
||||
|
sessionMutex.Lock() |
||||
|
delete(sessions, id) |
||||
|
sessionMutex.Unlock() |
||||
|
} |
||||
|
|
||||
|
// ClearSessions removes all sessions with the given user ID
|
||||
|
func ClearSessions(user *User) { |
||||
|
sessionMutex.Lock() |
||||
|
for _, sess := range sessions { |
||||
|
if sess.UserID == user.FullID() { |
||||
|
delete(sessions, sess.ID) |
||||
|
} |
||||
|
} |
||||
|
sessionMutex.Unlock() |
||||
|
} |
||||
|
|
||||
|
func cleanup() { |
||||
|
count := 0 |
||||
|
|
||||
|
sessionMutex.Lock() |
||||
|
for key, session := range sessions { |
||||
|
if time.Since(session.Time) > SessionMaxTime { |
||||
|
delete(sessions, key) |
||||
|
count++ |
||||
|
} |
||||
|
} |
||||
|
sessionMutex.Unlock() |
||||
|
|
||||
|
if count > 0 { |
||||
|
log.Println("Removed", count, "sessions.") |
||||
|
} |
||||
|
} |
@ -0,0 +1,44 @@ |
|||||
|
package auth |
||||
|
|
||||
|
import "testing" |
||||
|
|
||||
|
func TestSession(t *testing.T) { |
||||
|
auther := testAuther{FullName: "Test"} |
||||
|
|
||||
|
user := NewUser(&auther, "Tester", "Tester", "member", nil) |
||||
|
sessions := []*Session{OpenSession(user), OpenSession(user), OpenSession(user)} |
||||
|
ids := []string{sessions[0].ID, sessions[1].ID, sessions[2].ID} |
||||
|
|
||||
|
t.Run("Find", func(t *testing.T) { |
||||
|
for i, id := range ids { |
||||
|
found := FindSession(id) |
||||
|
|
||||
|
if found != sessions[i] { |
||||
|
t.Errorf("Find(\"%s\") == %+v", id, found) |
||||
|
t.Fail() |
||||
|
} |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("Close", func(t *testing.T) { |
||||
|
CloseSession(ids[2]) |
||||
|
|
||||
|
if FindSession(ids[0]) == nil || FindSession(ids[1]) == nil || FindSession(ids[2]) != nil { |
||||
|
t.Errorf("Find(\"%s\") == %+v", ids[0], FindSession(ids[0])) |
||||
|
t.Errorf("Find(\"%s\") == %+v", ids[1], FindSession(ids[1])) |
||||
|
t.Errorf("Find(\"%s\") == %+v", ids[2], FindSession(ids[2])) |
||||
|
t.Fail() |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("Clear", func(t *testing.T) { |
||||
|
ClearSessions(user) |
||||
|
|
||||
|
if FindSession(ids[0]) != nil || FindSession(ids[1]) != nil || FindSession(ids[2]) != nil { |
||||
|
t.Errorf("Find(\"%s\") == %+v", ids[0], FindSession(ids[0])) |
||||
|
t.Errorf("Find(\"%s\") == %+v", ids[1], FindSession(ids[1])) |
||||
|
t.Errorf("Find(\"%s\") == %+v", ids[2], FindSession(ids[2])) |
||||
|
t.Fail() |
||||
|
} |
||||
|
}) |
||||
|
} |
@ -0,0 +1,19 @@ |
|||||
|
package auth |
||||
|
|
||||
|
type User struct { |
||||
|
ID string |
||||
|
Name string |
||||
|
Level string |
||||
|
Data map[string]string |
||||
|
|
||||
|
method Authenticator |
||||
|
} |
||||
|
|
||||
|
// FullID is the userid prefixed with the method ID
|
||||
|
func (user *User) FullID() string { |
||||
|
return user.method.ID() + ":" + user.ID |
||||
|
} |
||||
|
|
||||
|
func NewUser(method Authenticator, id, name, level string, data map[string]string) *User { |
||||
|
return &User{id, name, level, data, method} |
||||
|
} |
@ -0,0 +1,28 @@ |
|||||
|
package generate |
||||
|
|
||||
|
import ( |
||||
|
"crypto/rand" |
||||
|
"encoding/binary" |
||||
|
"encoding/hex" |
||||
|
"strings" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
// ID generates a 24 character hex string from 8 bytes of current time
|
||||
|
// in ns and 4 bytes of crypto-random. In practical terms, that makes
|
||||
|
// them orderable.
|
||||
|
func ID() string { |
||||
|
bytes := make([]byte, 12) |
||||
|
binary.BigEndian.PutUint64(bytes, uint64(time.Now().UnixNano())) |
||||
|
rand.Read(bytes[8:]) |
||||
|
|
||||
|
return strings.ToLower(hex.EncodeToString(bytes)) |
||||
|
} |
||||
|
|
||||
|
// SessionID generates a 48 character hex string with crypto/rand
|
||||
|
func SessionID() string { |
||||
|
bytes := make([]byte, 24) |
||||
|
rand.Read(bytes) |
||||
|
|
||||
|
return strings.ToLower(hex.EncodeToString(bytes)) |
||||
|
} |
@ -0,0 +1,72 @@ |
|||||
|
package wrouter |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
"strings" |
||||
|
|
||||
|
"git.aiterp.net/gisle/wrouter/auth" |
||||
|
) |
||||
|
|
||||
|
type Func func(http.ResponseWriter, *http.Request, *auth.User) |
||||
|
type IDFunc func(http.ResponseWriter, *http.Request, string, *auth.User) |
||||
|
|
||||
|
type Resource struct { |
||||
|
list Func |
||||
|
create Func |
||||
|
get IDFunc |
||||
|
update IDFunc |
||||
|
delete IDFunc |
||||
|
} |
||||
|
|
||||
|
func NewResource(list, create Func, get, update, delete IDFunc) Resource { |
||||
|
return Resource{list, create, get, update, delete} |
||||
|
} |
||||
|
|
||||
|
func (resource *Resource) Handle(path string, w http.ResponseWriter, req *http.Request, user *auth.User) bool { |
||||
|
// Get the subpath out of the path
|
||||
|
subpath := req.URL.Path[len(path):] |
||||
|
if subpath[0] == '/' { |
||||
|
subpath = subpath[1:] |
||||
|
} |
||||
|
|
||||
|
// Error out on bad IDs which contains /es
|
||||
|
if x := strings.Index(subpath, "/"); x != -1 { |
||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
||||
|
w.WriteHeader(400) |
||||
|
w.Write([]byte("Invalid ID: " + subpath)) |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
// Route it to the resource
|
||||
|
switch req.Method { |
||||
|
case "GET": |
||||
|
{ |
||||
|
if subpath != "" { |
||||
|
resource.get(w, req, subpath, user) |
||||
|
} else { |
||||
|
resource.list(w, req, user) |
||||
|
} |
||||
|
} |
||||
|
case "POST": |
||||
|
{ |
||||
|
if subpath != "" { |
||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
||||
|
w.WriteHeader(400) |
||||
|
w.Write([]byte("ID not allowed in POST")) |
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
resource.create(w, req, user) |
||||
|
} |
||||
|
case "PATCH", "PUT": |
||||
|
{ |
||||
|
resource.update(w, req, subpath, user) |
||||
|
} |
||||
|
case "DELETE": |
||||
|
{ |
||||
|
resource.delete(w, req, subpath, user) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return true |
||||
|
} |
@ -0,0 +1,396 @@ |
|||||
|
package wrouter |
||||
|
|
||||
|
import ( |
||||
|
"encoding/json" |
||||
|
"net/http" |
||||
|
"net/http/httptest" |
||||
|
"net/url" |
||||
|
"strings" |
||||
|
"testing" |
||||
|
|
||||
|
"git.aiterp.net/gisle/wrouter/generate" |
||||
|
"git.aiterp.net/gisle/wrouter/response" |
||||
|
|
||||
|
"git.aiterp.net/gisle/wrouter/auth" |
||||
|
) |
||||
|
|
||||
|
var pages = []Page{ |
||||
|
Page{generate.ID(), "Test Page", "Blurg blurg"}, |
||||
|
Page{generate.ID(), "Test Page 2", "Blurg blurg 2"}, |
||||
|
Page{generate.ID(), "Stuff", "And things"}, |
||||
|
} |
||||
|
|
||||
|
type Page struct { |
||||
|
ID string `json:"id"` |
||||
|
Title string `json:"title"` |
||||
|
Text string `json:"text"` |
||||
|
} |
||||
|
|
||||
|
type Header struct { |
||||
|
ID string `json:"id"` |
||||
|
Title string `json:"title"` |
||||
|
} |
||||
|
|
||||
|
type PageForm struct { |
||||
|
Title string `json:"title"` |
||||
|
Text string `json:"text"` |
||||
|
} |
||||
|
|
||||
|
func listPage(w http.ResponseWriter, req *http.Request, user *auth.User) { |
||||
|
headers := make([]Header, len(pages)) |
||||
|
for i, page := range pages { |
||||
|
headers[i] = Header{page.ID, page.Title} |
||||
|
} |
||||
|
|
||||
|
response.JSON(w, 200, headers) |
||||
|
} |
||||
|
|
||||
|
func createPage(w http.ResponseWriter, req *http.Request, user *auth.User) { |
||||
|
title := req.Form.Get("title") |
||||
|
text := req.Form.Get("text") |
||||
|
|
||||
|
if title == "" { |
||||
|
response.Text(w, 400, "No title") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
for _, page := range pages { |
||||
|
if page.Title == title { |
||||
|
response.Text(w, 400, "Title already exists") |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
page := Page{generate.ID(), title, text} |
||||
|
pages = append(pages, page) |
||||
|
|
||||
|
response.JSON(w, 200, page) |
||||
|
} |
||||
|
|
||||
|
func getPage(w http.ResponseWriter, req *http.Request, id string, user *auth.User) { |
||||
|
for _, page := range pages { |
||||
|
if page.ID == id { |
||||
|
response.JSON(w, 200, page) |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
response.Text(w, 404, "Page not found") |
||||
|
} |
||||
|
|
||||
|
func updatePage(w http.ResponseWriter, req *http.Request, id string, user *auth.User) { |
||||
|
for _, page := range pages { |
||||
|
if page.ID == id { |
||||
|
title := req.Form.Get("title") |
||||
|
text := req.Form.Get("text") |
||||
|
|
||||
|
if title != "" { |
||||
|
page.Title = title |
||||
|
} |
||||
|
page.Text = text |
||||
|
|
||||
|
response.JSON(w, 200, page) |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
response.Text(w, 404, "Page not found") |
||||
|
} |
||||
|
|
||||
|
func deletePage(w http.ResponseWriter, req *http.Request, id string, user *auth.User) { |
||||
|
for i, page := range pages { |
||||
|
if page.ID == id { |
||||
|
pages = append(pages[:i], pages[i+1:]...) |
||||
|
response.Empty(w) |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
response.Text(w, 404, "Page not found") |
||||
|
} |
||||
|
|
||||
|
var resource = Resource{listPage, createPage, getPage, updatePage, deletePage} |
||||
|
|
||||
|
type handlerStruct struct{} |
||||
|
|
||||
|
func (hs *handlerStruct) ServeHTTP(w http.ResponseWriter, req *http.Request) { |
||||
|
req.ParseForm() // Router does this in non-tests
|
||||
|
|
||||
|
if strings.HasPrefix(req.URL.Path, "/page") { |
||||
|
resource.Handle("/page", w, req, nil) |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func runForm(method, url string, data url.Values) (*http.Response, error) { |
||||
|
body := strings.NewReader(data.Encode()) |
||||
|
req, err := http.NewRequest(method, url, body) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
||||
|
|
||||
|
return http.DefaultClient.Do(req) |
||||
|
} |
||||
|
|
||||
|
func TestResource(t *testing.T) { |
||||
|
server := httptest.NewServer(&handlerStruct{}) |
||||
|
|
||||
|
t.Run("List", func(t *testing.T) { |
||||
|
resp, err := http.Get(server.URL + "/page/") |
||||
|
if err != nil { |
||||
|
t.Error(err) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if resp.StatusCode != 200 { |
||||
|
t.Error("Expected status 200, got", resp.StatusCode, resp.Status) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
headers := []Header{} |
||||
|
err = json.NewDecoder(resp.Body).Decode(&headers) |
||||
|
if err != nil { |
||||
|
t.Error(err) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if len(headers) < 3 { |
||||
|
t.Error("Expected 3 headers, got", len(headers)) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
for i, header := range headers { |
||||
|
page := pages[i] |
||||
|
|
||||
|
if header.ID != page.ID { |
||||
|
t.Error(header.ID, "!=", page.ID) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if header.Title != page.Title { |
||||
|
t.Error(header.Title, "!=", page.Title) |
||||
|
t.Fail() |
||||
|
} |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("Get", func(t *testing.T) { |
||||
|
page := pages[1] |
||||
|
resp, err := http.Get(server.URL + "/page/" + page.ID) |
||||
|
if err != nil { |
||||
|
t.Error(err) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if resp.StatusCode != 200 { |
||||
|
t.Error("Expected status 200, got", resp.StatusCode, resp.Status) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
respPage := Page{} |
||||
|
err = json.NewDecoder(resp.Body).Decode(&respPage) |
||||
|
if err != nil { |
||||
|
t.Error(err) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if respPage.ID == "" { |
||||
|
t.Error("No ID in response page") |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if respPage.ID != page.ID { |
||||
|
t.Errorf("ID %s != %s", respPage.ID, page.ID) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if respPage.Title != page.Title { |
||||
|
t.Errorf("Title %s != %s", respPage.Title, page.Title) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if respPage.Text != page.Text { |
||||
|
t.Errorf("Text %s != %s", respPage.Text, page.Text) |
||||
|
t.Fail() |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("Get_Fail", func(t *testing.T) { |
||||
|
resp, err := http.Get(server.URL + "/page/" + generate.ID()) |
||||
|
if err != nil { |
||||
|
t.Error(err) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if resp.StatusCode != 404 { |
||||
|
t.Error("Expected status 404, got", resp.StatusCode, resp.Status) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
respPage := Page{} |
||||
|
err = json.NewDecoder(resp.Body).Decode(&respPage) |
||||
|
if err == nil { |
||||
|
t.Error("Expected encoder error, got:", respPage) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if respPage.ID != "" { |
||||
|
t.Error("Ad ID in response page", respPage.ID) |
||||
|
t.Fail() |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("Create", func(t *testing.T) { |
||||
|
form := url.Values{} |
||||
|
form.Set("title", "Hello World") |
||||
|
form.Set("text", "Sei Gegrüßt, Erde") |
||||
|
|
||||
|
resp, err := http.PostForm(server.URL+"/page/", form) |
||||
|
|
||||
|
if err != nil { |
||||
|
t.Error(err) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if resp.StatusCode != 200 { |
||||
|
t.Error("Expected status 200, got", resp.StatusCode, resp.Status) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
respPage := Page{} |
||||
|
err = json.NewDecoder(resp.Body).Decode(&respPage) |
||||
|
if err != nil { |
||||
|
t.Error(err) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if respPage.ID == "" { |
||||
|
t.Error("No ID in response page") |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if respPage.Title != form.Get("title") { |
||||
|
t.Errorf("Title %s != %s", respPage.Title, form.Get("title")) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if respPage.Text != form.Get("text") { |
||||
|
t.Errorf("Text %s != %s", respPage.Text, form.Get("text")) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if len(pages) != 4 { |
||||
|
t.Errorf("Page was not added") |
||||
|
t.Fail() |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("Update", func(t *testing.T) { |
||||
|
page := pages[0] |
||||
|
form := url.Values{} |
||||
|
form.Set("text", "Edits and stuff") |
||||
|
|
||||
|
resp, err := runForm("PUT", server.URL+"/page/"+page.ID, form) |
||||
|
|
||||
|
if err != nil { |
||||
|
t.Error("Request:", err) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if resp.StatusCode != 200 { |
||||
|
t.Error("Expected status 200, got", resp.StatusCode, resp.Status) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
respPage := Page{} |
||||
|
err = json.NewDecoder(resp.Body).Decode(&respPage) |
||||
|
if err != nil { |
||||
|
t.Error("Decode:", err) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if respPage.ID == "" { |
||||
|
t.Error("No ID in response page") |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if respPage.Title != page.Title { |
||||
|
t.Errorf("Title %s != %s", respPage.Title, form.Get("title")) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if respPage.Text != form.Get("text") { |
||||
|
t.Errorf("Text %s != %s", respPage.Text, form.Get("text")) |
||||
|
t.Fail() |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("Update_Fail", func(t *testing.T) { |
||||
|
form := url.Values{} |
||||
|
form.Set("text", "Edits and stuff") |
||||
|
|
||||
|
resp, err := runForm("PUT", server.URL+"/page/NONEXISTENT-ID-GOES-HERE", form) |
||||
|
|
||||
|
if err != nil { |
||||
|
t.Error("Request:", err) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if resp.StatusCode != 404 { |
||||
|
t.Error("Expected status 404, got", resp.StatusCode, resp.Status) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
respPage := Page{} |
||||
|
err = json.NewDecoder(resp.Body).Decode(&respPage) |
||||
|
if err == nil { |
||||
|
t.Error("A page was returned:", respPage) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if respPage.ID != "" { |
||||
|
t.Error("ID in response page", respPage.ID) |
||||
|
t.Fail() |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("Delete", func(t *testing.T) { |
||||
|
page := pages[3] |
||||
|
resp, err := runForm("DELETE", server.URL+"/page/"+page.ID, url.Values{}) |
||||
|
|
||||
|
if err != nil { |
||||
|
t.Error("Request:", err) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if resp.StatusCode != 204 { |
||||
|
t.Error("Expected status 204, got", resp.Status) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if len(pages) != 3 { |
||||
|
t.Error("Page was not deleted") |
||||
|
t.Fail() |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("Delete_Fail", func(t *testing.T) { |
||||
|
resp, err := runForm("DELETE", server.URL+"/page/NONEXISTENT-ID-GOES-HERE", url.Values{}) |
||||
|
|
||||
|
if err != nil { |
||||
|
t.Error("Request:", err) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if resp.StatusCode != 404 { |
||||
|
t.Error("Expected status 404, got", resp.Status) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if len(pages) != 3 { |
||||
|
t.Error("A page was deleted despite the non-existent ID") |
||||
|
t.Fail() |
||||
|
} |
||||
|
}) |
||||
|
} |
@ -0,0 +1,12 @@ |
|||||
|
package response |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
) |
||||
|
|
||||
|
// Empty makes a 204 response without a body
|
||||
|
func Empty(writer http.ResponseWriter) { |
||||
|
writer.Header().Set("Content-Type", "text/html; charset=utf-8") |
||||
|
writer.WriteHeader(204) |
||||
|
writer.Write([]byte{}) |
||||
|
} |
@ -0,0 +1,12 @@ |
|||||
|
package response |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
) |
||||
|
|
||||
|
// HTML makes an HTML response
|
||||
|
func HTML(writer http.ResponseWriter, status int, data string) { |
||||
|
writer.Header().Set("Content-Type", "text/html; charset=utf-8") |
||||
|
writer.WriteHeader(status) |
||||
|
writer.Write([]byte(data)) |
||||
|
} |
@ -0,0 +1,26 @@ |
|||||
|
package response |
||||
|
|
||||
|
import ( |
||||
|
"encoding/json" |
||||
|
"fmt" |
||||
|
"log" |
||||
|
"net/http" |
||||
|
) |
||||
|
|
||||
|
// JSON makes a JSON response
|
||||
|
func JSON(writer http.ResponseWriter, status int, data interface{}) { |
||||
|
jsonData, err := json.Marshal(data) |
||||
|
|
||||
|
if err != nil { |
||||
|
log.Println("JSON Marshal failed: ", err.Error()) |
||||
|
|
||||
|
writer.Header().Set("Content-Type", "text/plain; charset=utf-8") |
||||
|
writer.WriteHeader(503) |
||||
|
fmt.Fprint(writer, "JSON marshalling failed:", err.Error()) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
writer.Header().Set("Content-Type", "application/json") |
||||
|
writer.WriteHeader(status) |
||||
|
writer.Write(jsonData) |
||||
|
} |
@ -0,0 +1,12 @@ |
|||||
|
package response |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
) |
||||
|
|
||||
|
// Text makes a textual response
|
||||
|
func Text(writer http.ResponseWriter, status int, data string) { |
||||
|
writer.Header().Set("Content-Type", "text/plain; charset=utf-8") |
||||
|
writer.WriteHeader(status) |
||||
|
writer.Write([]byte(data)) |
||||
|
} |
@ -0,0 +1,73 @@ |
|||||
|
package wrouter |
||||
|
|
||||
|
import ( |
||||
|
"fmt" |
||||
|
"net/http" |
||||
|
"strings" |
||||
|
|
||||
|
"git.aiterp.net/gisle/notebook3/session" |
||||
|
|
||||
|
"git.aiterp.net/gisle/wrouter/auth" |
||||
|
) |
||||
|
|
||||
|
type Route interface { |
||||
|
Handle(path string, w http.ResponseWriter, req *http.Request, user *auth.User) bool |
||||
|
} |
||||
|
|
||||
|
type Router struct { |
||||
|
paths map[Route]string |
||||
|
routes []Route |
||||
|
} |
||||
|
|
||||
|
func (router *Router) Route(path string, route Route) { |
||||
|
if router.paths == nil { |
||||
|
router.paths = make(map[Route]string, 16) |
||||
|
} |
||||
|
|
||||
|
router.paths[route] = path |
||||
|
router.routes = append(router.routes, route) |
||||
|
} |
||||
|
|
||||
|
func (router *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { |
||||
|
defer req.Body.Close() |
||||
|
|
||||
|
// Allow REST for clients of yore
|
||||
|
if req.Header.Get("X-Method") != "" { |
||||
|
req.Method = strings.ToUpper(req.Header.Get("X-Method")) |
||||
|
} |
||||
|
|
||||
|
// Resolve session cookies
|
||||
|
var user *auth.User |
||||
|
cookie, err := req.Cookie(session.CookieName) |
||||
|
if cookie != nil && err == nil { |
||||
|
sess := auth.FindSession(cookie.Value) |
||||
|
if sess != nil { |
||||
|
user = auth.FindUser(sess.UserID) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
for index, route := range router.routes { |
||||
|
path := router.paths[route] |
||||
|
|
||||
|
if strings.HasPrefix(strings.ToLower(req.URL.Path), path) { |
||||
|
// Just so the handler can replace the path properly in case of case
|
||||
|
// insensitive clients getting fancy on it.
|
||||
|
path = req.URL.Path[:len(path)] |
||||
|
|
||||
|
// Attach a little something for testing
|
||||
|
w.Header().Set("X-Route-Path", path) |
||||
|
w.Header().Set("X-Route-Index", fmt.Sprint(index)) |
||||
|
|
||||
|
if route.Handle(path, w, req, user) { |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
||||
|
w.WriteHeader(404) |
||||
|
} |
||||
|
|
||||
|
func (router *Router) Listen(host string, port int) error { |
||||
|
return http.ListenAndServe(fmt.Sprintf("%s:%d", host, port), router) |
||||
|
} |
@ -0,0 +1,82 @@ |
|||||
|
package wrouter |
||||
|
|
||||
|
import ( |
||||
|
"io/ioutil" |
||||
|
"net/http" |
||||
|
"net/http/httptest" |
||||
|
"testing" |
||||
|
|
||||
|
"git.aiterp.net/gisle/wrouter/auth" |
||||
|
) |
||||
|
|
||||
|
type testRoute struct { |
||||
|
Name string |
||||
|
} |
||||
|
|
||||
|
func (tr *testRoute) Handle(path string, w http.ResponseWriter, req *http.Request, user *auth.User) bool { |
||||
|
w.WriteHeader(200) |
||||
|
w.Write([]byte(tr.Name)) |
||||
|
|
||||
|
return true |
||||
|
} |
||||
|
|
||||
|
func TestPaths(t *testing.T) { |
||||
|
tr1 := testRoute{"Test Route 1"} |
||||
|
tr2 := testRoute{"Test Route 2"} |
||||
|
|
||||
|
router := Router{} |
||||
|
router.Route("/test1", &tr1) |
||||
|
router.Route("/test2", &tr2) |
||||
|
|
||||
|
t.Run("It finds /test1", func(t *testing.T) { |
||||
|
req := httptest.NewRequest("GET", "http://test.aiterp.net/test1", nil) |
||||
|
w := httptest.NewRecorder() |
||||
|
router.ServeHTTP(w, req) |
||||
|
|
||||
|
data, _ := ioutil.ReadAll(w.Body) |
||||
|
|
||||
|
if string(data) != "Test Route 1" { |
||||
|
t.Error("Wrong content:", string(data)) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if w.Code != 200 { |
||||
|
t.Error("Wrong code:", w.Code) |
||||
|
t.Fail() |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("It finds /test2", func(t *testing.T) { |
||||
|
req := httptest.NewRequest("GET", "http://test.aiterp.net/test2", nil) |
||||
|
w := httptest.NewRecorder() |
||||
|
router.ServeHTTP(w, req) |
||||
|
|
||||
|
data, _ := ioutil.ReadAll(w.Body) |
||||
|
|
||||
|
if string(data) != "Test Route 2" { |
||||
|
t.Error("Wrong content:", string(data)) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if w.Header().Get("X-Route-Index") != "1" { |
||||
|
t.Error("Wrong index:", w.Code) |
||||
|
t.Fail() |
||||
|
} |
||||
|
|
||||
|
if w.Code != 200 { |
||||
|
t.Error("Wrong code:", w.Code) |
||||
|
t.Fail() |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("It does not find /test3", func(t *testing.T) { |
||||
|
req := httptest.NewRequest("GET", "http://test.aiterp.net/test3", nil) |
||||
|
w := httptest.NewRecorder() |
||||
|
router.ServeHTTP(w, req) |
||||
|
|
||||
|
if w.Code != 404 { |
||||
|
t.Error("Wrong code:", w.Code) |
||||
|
t.Fail() |
||||
|
} |
||||
|
}) |
||||
|
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue