Skip to content

Commit

Permalink
Implemented cookie handling to store some of the state
Browse files Browse the repository at this point in the history
  • Loading branch information
fingon committed Apr 26, 2024
1 parent dc06226 commit 25b90c8
Show file tree
Hide file tree
Showing 18 changed files with 588 additions and 118 deletions.
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,12 @@ local Lixie instance which probably does not exist.

- Properly vendor static resources (bootstrap CSS, htmx JS)

- convert viewing preferences to be tracked via cookie
- FTS filter: global
+ convert viewing preferences to be tracked via cookie
- FTS filter: global (partially implemented)
- others: per-view

- identify better logging pattern

## Prettiness

- favicon
Expand Down
26 changes: 26 additions & 0 deletions cm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Cookie Monster #

This sublibrary (someday perhaps available separately, but for now, not) is
convenience wrapper for handling client state in client (fully in the
cookie). For now, the state is not encrypted at all (although we could), as
I do not really see the point. The idea is not to store secrets using this,
just e.g. state in various views so that the client resumes same state
implicitly when they go back to a view.

## Short form

```
err = monster.Run(r, w, &state)
```

Where
```
r = http.Request
w = http.ResponseWriter
state = arbitrary struct (ideally empty)
```

The struct is examined through reflection and `cm:"field"` entries are
CRUDed based on `"field"` in the `http.Request`. Note that json definitions
must be also supplied for the fields, or otherwise they will not be
imported/exported.
55 changes: 55 additions & 0 deletions cm/cm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Author: Markus Stenberg <[email protected]>
*
* Copyright (c) 2024 Markus Stenberg
*
* Created: Fri Apr 26 10:35:46 2024 mstenber
* Last modified: Fri Apr 26 14:38:05 2024 mstenber
* Edit time: 93 min
*
*/

package cm

import (
"errors"
"fmt"
"net/http"
"reflect"
)

type CookieSource interface {
Cookie(string) (*http.Cookie, error)
}

var ErrNoSource = errors.New("specified cookie source is nil")

func cookieName(state any) (string, error) {
v := reflect.ValueOf(state)
if v.Kind() != reflect.Pointer {
return "", fmt.Errorf("Invalid kind: %s", v.Kind())
}

s := v.Elem()
if s.Kind() != reflect.Struct {
return "", fmt.Errorf("Invalid kind pointer: %s", s.Kind())
}
return fmt.Sprintf("cm-%s", s.Type()), nil
}

func Run(r *http.Request, w http.ResponseWriter, state any) error {
err := r.ParseForm()
if err != nil {
return err
}

changed, err := Parse(r, URLWrapper(r.Form), state)
if err != nil {
return err
}
if changed {
fmt.Printf("XXX changed\n")
return Write(w, state)
}
return nil
}
124 changes: 124 additions & 0 deletions cm/cm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* Author: Markus Stenberg <[email protected]>
*
* Copyright (c) 2024 Markus Stenberg
*
* Created: Fri Apr 26 10:44:18 2024 mstenber
* Last modified: Fri Apr 26 14:39:52 2024 mstenber
* Edit time: 40 min
*
*/

package cm

import (
"fmt"
"net/http"
"net/url"
"testing"

"gotest.tools/v3/assert"
)

type empty struct {
}

type tt struct {
B bool `json:"b" cm:"bf"`
I int64 `json:"i" cm:"if"`
II int `json:"ii" cm:"iif"`
S string `json:"s" cm:"sf"`
U uint64 `json:"u" cm:"uf"`
}

type staticCookie struct {
// FormValued interface provider
URLWrapper

// Data for rest of CookieSource
name string
cookie *http.Cookie
err error
}

func (self *staticCookie) Cookie(name string) (*http.Cookie, error) {
if name != self.name {
return nil, fmt.Errorf("Invalid name: %s <> %s", name, self.name)
}
if self.cookie == nil {
return nil, http.ErrNoCookie
}
return self.cookie, self.err
}

func TestParse(t *testing.T) {
// first off, ensure the type checking is correct. anything
// else than pointer (to a struct) shouldn't work
_, err := Parse(nil, nil, nil)
assert.Assert(t, err != nil)

es := empty{}
_, err = Parse(nil, nil, es)
assert.Assert(t, err != nil)

i := 42
_, err = Parse(nil, nil, &i)
assert.Assert(t, err != nil)

// nil pointer is still error
_, err = Parse(nil, nil, &es)
assert.Equal(t, err, ErrNoSource)

// empty is fine but doesn't do anything yet
sc1 := staticCookie{name: "cm-cm.empty"}
changed, err := Parse(&sc1, sc1.URLWrapper, &es)
assert.Equal(t, err, nil)
assert.Equal(t, changed, false)

q1, err := ToQueryString("x", &sc1)
assert.Equal(t, err, nil)
assert.Equal(t, q1, "x")

ts := tt{}
sc2 := staticCookie{name: "cm-cm.tt"}
changed, err = Parse(&sc2, sc2.URLWrapper, &ts)
assert.Equal(t, err, nil)
assert.Equal(t, changed, false)

sc3 := staticCookie{name: "cm-cm.tt", URLWrapper: URLWrapper(url.Values{
"bf": []string{"true"},
"if": []string{"-1"},
"sf": []string{"str"},
"iif": []string{"7"},
"uf": []string{"42"},
})}
assert.Equal(t, sc3.FormValue("bf"), "true")
changed, err = Parse(&sc3, sc3.URLWrapper, &ts)
assert.Equal(t, err, nil)
assert.Equal(t, changed, true)
assert.Equal(t, ts.B, true)
assert.Equal(t, ts.I, int64(-1))
assert.Equal(t, ts.U, uint64(42))

q3, err := ToQueryString("x", &ts)
assert.Equal(t, err, nil)
assert.Equal(t, q3, "x?bf=true&if=-1&iif=7&sf=str&uf=42")

// Export the cookie
cookie, err := ToCookie(&ts)
assert.Equal(t, err, nil)
assert.Assert(t, cookie != nil)

// Pretend we're new request: take in the cookie
sc4 := staticCookie{name: "cm-cm.tt", cookie: cookie, err: nil}
ts4 := tt{}
fmt.Printf("!!!\n")
changed, err = Parse(&sc4, sc4.URLWrapper, &ts4)
assert.Equal(t, changed, false)
assert.Equal(t, err, nil)

// The resulting query string (=~ content) should be same
q4, err := ToQueryString("x", &ts4)
assert.Equal(t, err, nil)
assert.Equal(t, q3, q4)
}
20 changes: 16 additions & 4 deletions form.go → cm/form.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,45 @@
*
*/

package main
package cm

import "strconv"

type FormValued interface {
FormValue(string) string
}

func intFromForm(r FormValued, key string, value *int) (found bool, err error) {
func IntFromForm(r FormValued, key string, value *int) (found bool, err error) {
raw := r.FormValue(key)
if raw == "" {
return
}
found = true
*value, err = strconv.Atoi(raw)
return
}

func uint64FromForm(r FormValued, key string, value *uint64) (found bool, err error) {
func Int64FromForm(r FormValued, key string, value *int64) (found bool, err error) {
raw := r.FormValue(key)
if raw == "" {
return
}
found = true
*value, err = strconv.ParseInt(raw, 10, 64)
return
}

func Uint64FromForm(r FormValued, key string, value *uint64) (found bool, err error) {
raw := r.FormValue(key)
if raw == "" {
return
}
found = true
*value, err = strconv.ParseUint(raw, 10, 64)
return
}

func boolFromForm(r FormValued, key string, value *bool) {
func BoolFromForm(r FormValued, key string, value *bool) {
raw := r.FormValue(key)
if raw == "" {
return
Expand Down
122 changes: 122 additions & 0 deletions cm/parse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* Author: Markus Stenberg <[email protected]>
*
* Copyright (c) 2024 Markus Stenberg
*
* Created: Fri Apr 26 14:09:03 2024 mstenber
* Last modified: Fri Apr 26 14:40:37 2024 mstenber
* Edit time: 5 min
*
*/

package cm

import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"reflect"
)

func Parse(r CookieSource, u URLWrapper, state any) (changed bool, err error) {
if r == nil {
err = ErrNoSource
return
}
name, err := cookieName(state)
if err != nil {
return
}

// Handle the cookie, if any
cookie, err := r.Cookie(name)
switch {
case err == http.ErrNoCookie:
// fmt.Printf("Cookie not found\n")
err = nil
case err != nil:
// fmt.Printf("Failed to get cookie: %s", err)
return
case cookie != nil:
err = cookie.Valid()
if err != nil {
return
}
data, err := base64.StdEncoding.DecodeString(cookie.Value)
if err != nil {
return false, err
}
err = json.Unmarshal(data, state)
if err != nil {
return false, err
}
}

// Struct we're filling from the form
s := reflect.ValueOf(state).Elem()

// Go through the fields and fill in the values IF they differ from what is in the struct
for _, field := range reflect.VisibleFields(s.Type()) {
formKey := field.Tag.Get("cm")
if formKey == "" {
continue
}
if !u.HasValue(formKey) {
continue
}

value := u.FormValue(formKey)

f := s.FieldByName(field.Name)
if !(f.IsValid() && f.CanSet()) {
err = fmt.Errorf("invalid field %s", field.Name)
return
}

kind := field.Type.Kind()
switch {
case kind == reflect.String:
if f.String() != value {
f.SetString(value)
changed = true
}
case kind == reflect.Bool:
var b bool
BoolFromForm(u, formKey, &b)
if f.Bool() != b {
f.SetBool(b)
changed = true
}

case f.CanInt():
var i int64
_, err = Int64FromForm(u, formKey, &i)
if err != nil {
return
}
if f.Int() != i {
f.SetInt(i)
changed = true
}

case f.CanUint():
var uv uint64
_, err = Uint64FromForm(u, formKey, &uv)
if err != nil {
return
}
if f.Uint() != uv {
f.SetUint(uv)
changed = true
}
default:
err = fmt.Errorf("insupported field %s kind: %s", field.Name, kind)
}
if err != nil {
return
}
}

return
}
Loading

0 comments on commit 25b90c8

Please sign in to comment.