From 7420c88f8449cbc4a03b40e771995476f551b727 Mon Sep 17 00:00:00 2001 From: Ted Unangst Date: Tue, 9 Apr 2019 07:59:33 -0400 Subject: [PATCH] maybe 0.1 --- Makefile | 8 + README | 38 ++ activity.go | 629 ++++++++++++++++++++++ avatar.go | 46 ++ honk.go | 1217 +++++++++++++++++++++++++++++++++++++++++++ html.go | 191 +++++++ image.go | 148 ++++++ login.go | 331 ++++++++++++ rss.go | 65 +++ schema.sql | 20 + template.go | 81 +++ util.go | 227 ++++++++ views/header.html | 21 + views/homepage.html | 27 + views/honk.html | 14 + views/honkers.html | 25 + views/honkform.html | 30 ++ views/honkpage.html | 24 + views/login.html | 11 + views/style.css | 146 ++++++ zig.go | 203 ++++++++ 21 files changed, 3502 insertions(+) create mode 100644 Makefile create mode 100644 README create mode 100644 activity.go create mode 100644 avatar.go create mode 100644 honk.go create mode 100644 html.go create mode 100644 image.go create mode 100644 login.go create mode 100644 rss.go create mode 100644 schema.sql create mode 100644 template.go create mode 100644 util.go create mode 100644 views/header.html create mode 100644 views/homepage.html create mode 100644 views/honk.html create mode 100644 views/honkers.html create mode 100644 views/honkform.html create mode 100644 views/honkpage.html create mode 100644 views/login.html create mode 100644 views/style.css create mode 100644 zig.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6f137d8 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ + +all: honk + +honk: *.go + go build -o honk + +clean: + rm -f honk diff --git a/README b/README new file mode 100644 index 0000000..ab1fae2 --- /dev/null +++ b/README @@ -0,0 +1,38 @@ +honk honk + +-- features + +Take control of your honks and join the federation in the fight against the +evil empire. + +Send honks. Receive honks. And not just honks. +Bonk, donk, tonk, all your favorite activities are here. + +Purple color scheme. + +The button to submit a new honk says "it's gonna be honked". + +Ein Honk is a stupid person auf deutsch. + +-- requirements + +github.com/gorilla/mux +golang.org/x/crypto +golang.org/x/net +golang.org/x/text +humungus.tedunangst.com/r/go-sqlite3 + +It should be sufficient to type make after unpacking a release under go/src. +Mind your gopath. + +Even on a fast machine, building from source can take several seconds. + +Busy honk instances may require megabytes of memory. + +-- setup + +./honk init + +./honk + +honk expects to be fronted by a TLS terminating reverse proxy. diff --git a/activity.go b/activity.go new file mode 100644 index 0000000..5118144 --- /dev/null +++ b/activity.go @@ -0,0 +1,629 @@ +// +// Copyright (c) 2019 Ted Unangst +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +package main + +import ( + "bytes" + "compress/gzip" + "crypto/rsa" + "crypto/sha256" + "database/sql" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync" + "time" +) + +func NewJunk() map[string]interface{} { + return make(map[string]interface{}) +} + +func WriteJunk(w io.Writer, j map[string]interface{}) error { + e := json.NewEncoder(w) + e.SetEscapeHTML(false) + e.SetIndent("", " ") + err := e.Encode(j) + return err +} + +func ReadJunk(r io.Reader) (map[string]interface{}, error) { + decoder := json.NewDecoder(r) + var j map[string]interface{} + err := decoder.Decode(&j) + if err != nil { + return nil, err + } + return j, nil +} + +var theonetruename = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"` +var falsenames = []string{ + `application/ld+json`, + `application/activity+json`, +} +var itiswhatitis = "https://www.w3.org/ns/activitystreams" +var thewholeworld = "https://www.w3.org/ns/activitystreams#Public" + +func friendorfoe(ct string) bool { + ct = strings.ToLower(ct) + for _, at := range falsenames { + if strings.HasPrefix(ct, at) { + return true + } + } + return false +} + +func PostJunk(keyname string, key *rsa.PrivateKey, url string, j map[string]interface{}) error { + client := http.DefaultClient + var buf bytes.Buffer + WriteJunk(&buf, j) + req, err := http.NewRequest("POST", url, &buf) + if err != nil { + return err + } + zig(keyname, key, req, buf.Bytes()) + req.Header.Set("Content-Type", theonetruename) + resp, err := client.Do(req) + if err != nil { + return err + } + if resp.StatusCode != 200 && resp.StatusCode != 202 { + resp.Body.Close() + return fmt.Errorf("http post status: %d", resp.StatusCode) + } + log.Printf("successful post: %s %d", url, resp.StatusCode) + return nil +} + +type gzCloser struct { + r *gzip.Reader + under io.ReadCloser +} + +func (gz *gzCloser) Read(p []byte) (int, error) { + return gz.r.Read(p) +} + +func (gz *gzCloser) Close() error { + defer gz.under.Close() + return gz.r.Close() +} + +func GetJunk(url string) (map[string]interface{}, error) { + client := http.DefaultClient + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", theonetruename) + req.Header.Set("Accept-Encoding", "gzip") + resp, err := client.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + resp.Body.Close() + return nil, fmt.Errorf("http get status: %d", resp.StatusCode) + } + if strings.EqualFold(resp.Header.Get("Content-Encoding"), "gzip") { + gz, err := gzip.NewReader(resp.Body) + if err != nil { + resp.Body.Close() + return nil, err + } + resp.Body = &gzCloser{r: gz, under: resp.Body} + } + defer resp.Body.Close() + j, err := ReadJunk(resp.Body) + return j, err +} + +func jsonfindinterface(ii interface{}, keys []string) interface{} { + for _, key := range keys { + idx, err := strconv.Atoi(key) + if err == nil { + m := ii.([]interface{}) + if idx >= len(m) { + return nil + } + ii = m[idx] + } else { + m := ii.(map[string]interface{}) + ii = m[key] + if ii == nil { + return nil + } + } + } + return ii +} +func jsonfindstring(j interface{}, keys []string) (string, bool) { + s, ok := jsonfindinterface(j, keys).(string) + return s, ok +} +func jsonfindarray(j interface{}, keys []string) ([]interface{}, bool) { + a, ok := jsonfindinterface(j, keys).([]interface{}) + return a, ok +} +func jsonfindmap(j interface{}, keys []string) (map[string]interface{}, bool) { + m, ok := jsonfindinterface(j, keys).(map[string]interface{}) + return m, ok +} +func jsongetstring(j interface{}, key string) (string, bool) { + return jsonfindstring(j, []string{key}) +} +func jsongetarray(j interface{}, key string) ([]interface{}, bool) { + return jsonfindarray(j, []string{key}) +} +func jsongetmap(j interface{}, key string) (map[string]interface{}, bool) { + return jsonfindmap(j, []string{key}) +} + +func sha256string(s string) string { + hasher := sha256.New() + io.WriteString(hasher, s) + sum := hasher.Sum(nil) + return fmt.Sprintf("%x", sum) +} + +func savedonk(url string, name, media string) *Donk { + log.Printf("saving donk: %s", url) + var donk Donk + row := stmtFindFile.QueryRow(url) + err := row.Scan(&donk.FileID) + if err == nil { + return &donk + } + if err != nil && err != sql.ErrNoRows { + log.Printf("err querying: %s", err) + } + resp, err := http.Get(url) + if err != nil { + log.Printf("errer fetching %s: %s", url, err) + return nil + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil + } + var buf bytes.Buffer + io.Copy(&buf, resp.Body) + + xid := xfiltrate() + + res, err := stmtSaveFile.Exec(xid, name, url, media, buf.Bytes()) + if err != nil { + log.Printf("error saving file %s: %s", url, err) + return nil + } + donk.FileID, _ = res.LastInsertId() + return &donk +} + +func needxonk(userid int64, x *Honk) bool { + row := stmtFindXonk.QueryRow(userid, x.XID, x.What) + err := row.Scan(&x.ID) + if err == nil { + return false + } + if err != sql.ErrNoRows { + log.Printf("err querying xonk: %s", err) + } + return true +} + +func savexonk(x *Honk) { + if x.What == "eradicate" { + log.Printf("eradicating %s by %s", x.RID, x.Honker) + _, err := stmtDeleteHonk.Exec(x.RID, x.Honker) + if err != nil { + log.Printf("error eradicating: %s", err) + } + return + } + dt := x.Date.UTC().Format(dbtimeformat) + aud := strings.Join(x.Audience, " ") + res, err := stmtSaveHonk.Exec(x.UserID, x.What, x.Honker, x.XID, x.RID, dt, x.URL, aud, x.Noise) + if err != nil { + log.Printf("err saving xonk: %s", err) + return + } + x.ID, _ = res.LastInsertId() + for _, d := range x.Donks { + _, err = stmtSaveDonk.Exec(x.ID, d.FileID) + if err != nil { + log.Printf("err saving donk: %s", err) + return + } + } +} + +var boxofboxes = make(map[string]string) +var boxlock sync.Mutex + +func getboxes(ident string) (string, string, error) { + boxlock.Lock() + defer boxlock.Unlock() + b, ok := boxofboxes[ident] + if ok { + if b == "" { + return "", "", fmt.Errorf("error?") + } + m := strings.Split(b, "\n") + return m[0], m[1], nil + } + j, err := GetJunk(ident) + if err != nil { + boxofboxes[ident] = "" + return "", "", err + } + inbox, _ := jsongetstring(j, "inbox") + outbox, _ := jsongetstring(j, "outbox") + boxofboxes[ident] = inbox + "\n" + outbox + return inbox, outbox, err +} + +func peeppeep() { + user, _ := butwhatabout("") + honkers := gethonkers(user.ID) + for _, f := range honkers { + if f.Flavor != "peep" { + continue + } + log.Printf("getting updates: %s", f.XID) + _, outbox, err := getboxes(f.XID) + if err != nil { + log.Printf("error getting outbox: %s", err) + continue + } + log.Printf("getting outbox") + j, err := GetJunk(outbox) + if err != nil { + log.Printf("err: %s", err) + continue + } + t, _ := jsongetstring(j, "type") + if t == "OrderedCollection" { + items, _ := jsongetarray(j, "orderedItems") + if items == nil { + page1, _ := jsongetstring(j, "first") + j, err = GetJunk(page1) + if err != nil { + log.Printf("err: %s", err) + continue + } + items, _ = jsongetarray(j, "orderedItems") + } + + for _, item := range items { + xonk := xonkxonk(item) + if xonk != nil && needxonk(user.ID, xonk) { + xonk.UserID = user.ID + savexonk(xonk) + } + } + } + } +} + +func newphone(a []string, obj map[string]interface{}) []string { + for _, addr := range []string{"to", "cc", "attributedTo"} { + who, _ := jsongetstring(obj, addr) + if who != "" { + a = append(a, who) + } + whos, _ := jsongetarray(obj, addr) + for _, w := range whos { + who, _ := w.(string) + if who != "" { + a = append(a, who) + } + } + } + return a +} + +func oneofakind(a []string) []string { + var x []string + for n, s := range a { + for i := n + 1; i < len(a); i++ { + if a[i] == s { + a[i] = "" + } + } + } + for _, s := range a { + if s != "" { + x = append(x, s) + } + } + return x +} + +func xonkxonk(item interface{}) *Honk { + + // id, _ := jsongetstring(item, "id") + what, _ := jsongetstring(item, "type") + dt, _ := jsongetstring(item, "published") + + var audience []string + var err error + var xid, rid, url, content string + var obj map[string]interface{} + switch what { + case "Announce": + xid, _ = jsongetstring(item, "object") + log.Printf("getting bonk: %s", xid) + obj, err = GetJunk(xid) + if err != nil { + log.Printf("error regetting: %s", err) + } + what = "bonk" + case "Create": + obj, _ = jsongetmap(item, "object") + what = "honk" + case "Delete": + obj, _ = jsongetmap(item, "object") + what = "eradicate" + default: + log.Printf("unknown activity: %s", what) + return nil + } + who, _ := jsongetstring(item, "actor") + + var xonk Honk + if obj != nil { + ot, _ := jsongetstring(obj, "type") + url, _ = jsongetstring(obj, "url") + if ot == "Note" { + audience = newphone(audience, obj) + xid, _ = jsongetstring(obj, "id") + content, _ = jsongetstring(obj, "content") + rid, _ = jsongetstring(obj, "inReplyTo") + if what == "honk" && rid != "" { + what = "tonk" + } + } + if ot == "Tombstone" { + rid, _ = jsongetstring(obj, "id") + } + atts, _ := jsongetarray(obj, "attachment") + for _, att := range atts { + at, _ := jsongetstring(att, "type") + mt, _ := jsongetstring(att, "mediaType") + u, _ := jsongetstring(att, "url") + name, _ := jsongetstring(att, "name") + if at == "Document" { + mt = strings.ToLower(mt) + log.Printf("attachment: %s %s", mt, u) + if mt == "image/jpeg" || mt == "image/png" || + mt == "image/gif" { + donk := savedonk(u, name, mt) + if donk != nil { + xonk.Donks = append(xonk.Donks, donk) + } + } + } + } + } + audience = append(audience, who) + + audience = oneofakind(audience) + + xonk.What = what + xonk.Honker = who + xonk.XID = xid + xonk.RID = rid + xonk.Date, _ = time.Parse(time.RFC3339, dt) + xonk.URL = url + xonk.Noise = content + xonk.Audience = audience + + return &xonk +} + +func rubadubdub(user *WhatAbout, req map[string]interface{}) { + xid, _ := jsongetstring(req, "id") + reqactor, _ := jsongetstring(req, "actor") + j := NewJunk() + j["@context"] = itiswhatitis + j["id"] = user.URL + "/dub/" + xid + j["type"] = "Accept" + j["actor"] = user.URL + j["to"] = reqactor + j["published"] = time.Now().UTC().Format(time.RFC3339) + j["object"] = req + + WriteJunk(os.Stdout, j) + + actor, _ := jsongetstring(req, "actor") + inbox, _, err := getboxes(actor) + if err != nil { + log.Printf("can't get dub box: %s", err) + return + } + keyname, key := ziggy(user) + err = PostJunk(keyname, key, inbox, j) + if err != nil { + log.Printf("can't rub a dub: %s", err) + return + } + stmtSaveDub.Exec(user.ID, actor, actor, "dub") +} + +func subsub(user *WhatAbout, xid string) { + + j := NewJunk() + j["@context"] = itiswhatitis + j["id"] = user.URL + "/sub/" + xid + j["type"] = "Follow" + j["actor"] = user.URL + j["to"] = xid + j["object"] = xid + j["published"] = time.Now().UTC().Format(time.RFC3339) + + inbox, _, err := getboxes(xid) + if err != nil { + log.Printf("can't send follow: %s", err) + return + } + WriteJunk(os.Stdout, j) + keyname, key := ziggy(user) + err = PostJunk(keyname, key, inbox, j) + if err != nil { + log.Printf("failed to subsub: %s", err) + } +} + +func jonkjonk(user *WhatAbout, h *Honk) (map[string]interface{}, map[string]interface{}) { + dt := h.Date.Format(time.RFC3339) + var jo map[string]interface{} + j := NewJunk() + j["id"] = user.URL + "/" + h.What + "/" + h.XID + j["actor"] = user.URL + j["published"] = dt + j["to"] = h.Audience[0] + if len(h.Audience) > 1 { + j["cc"] = h.Audience[1:] + } + + switch h.What { + case "tonk": + fallthrough + case "honk": + j["type"] = "Create" + jo = NewJunk() + jo["id"] = user.URL + "/h/" + h.XID + jo["type"] = "Note" + jo["published"] = dt + jo["url"] = user.URL + "/h/" + h.XID + jo["attributedTo"] = user.URL + if h.RID != "" { + jo["inReplyTo"] = h.RID + } + jo["to"] = h.Audience[0] + if len(h.Audience) > 1 { + jo["cc"] = h.Audience[1:] + } + jo["content"] = h.Noise + g := bunchofgrapes(h.Noise) + if len(g) > 0 { + var tags []interface{} + for _, m := range g { + t := NewJunk() + t["type"] = "Mention" + t["name"] = m.who + t["href"] = m.where + tags = append(tags, t) + } + jo["tag"] = tags + } + var atts []interface{} + for _, d := range h.Donks { + jd := NewJunk() + jd["mediaType"] = d.Media + jd["name"] = d.Name + jd["type"] = "Document" + jd["url"] = d.URL + atts = append(atts, jd) + } + if len(atts) > 0 { + jo["attachment"] = atts + } + j["object"] = jo + case "bonk": + j["type"] = "Announce" + j["object"] = h.XID + } + + return j, jo +} + +func honkworldwide(user *WhatAbout, honk *Honk) { + aud := append([]string{}, honk.Audience...) + for i, a := range aud { + if a == thewholeworld || a == user.URL { + aud[i] = "" + } + } + keyname, key := ziggy(user) + jonk, _ := jonkjonk(user, honk) + jonk["@context"] = itiswhatitis + for _, f := range getdubs(user.ID) { + inbox, _, err := getboxes(f.XID) + if err != nil { + log.Printf("error getting inbox %s: %s", f.XID, err) + continue + } + err = PostJunk(keyname, key, inbox, jonk) + if err != nil { + log.Printf("failed to post json to %s: %s", inbox, err) + } + for i, a := range aud { + if a == f.XID { + aud[i] = "" + } + } + } + for _, a := range aud { + if a != "" && !strings.HasSuffix(a, "/followers") { + inbox, _, err := getboxes(a) + if err != nil { + log.Printf("error getting inbox %s: %s", a, err) + continue + } + err = PostJunk(keyname, key, inbox, jonk) + if err != nil { + log.Printf("failed to post json to %s: %s", inbox, err) + } + } + } +} + +func asjonker(user *WhatAbout) map[string]interface{} { + whatabout := obfusbreak(user.About) + + j := NewJunk() + j["@context"] = itiswhatitis + j["id"] = user.URL + j["type"] = "Person" + j["inbox"] = user.URL + "/inbox" + j["outbox"] = user.URL + "/outbox" + j["name"] = user.Display + j["preferredUsername"] = user.Name + j["summary"] = whatabout + j["url"] = user.URL + a := NewJunk() + a["type"] = "icon" + a["mediaType"] = "image/png" + a["url"] = fmt.Sprintf("https://%s/a?a=%s", serverName, url.QueryEscape(user.URL)) + j["icon"] = a + k := NewJunk() + k["id"] = user.URL + "#key" + k["owner"] = user.URL + k["publicKeyPem"] = user.Key + j["publicKey"] = k + + return j +} diff --git a/avatar.go b/avatar.go new file mode 100644 index 0000000..b4728a6 --- /dev/null +++ b/avatar.go @@ -0,0 +1,46 @@ +package main + +import ( + "bytes" + "crypto/sha512" + "image" + "image/png" +) + +func avatar(name string) []byte { + h := sha512.New() + h.Write([]byte(name)) + s := h.Sum(nil) + img := image.NewNRGBA(image.Rect(0, 0, 64, 64)) + for i := 0; i < 64; i++ { + for j := 0; j < 64; j++ { + p := i*img.Stride + j*4 + xx := i/16*16 + j/16 + x := s[xx] + if x < 64 { + img.Pix[p+0] = 32 + img.Pix[p+1] = 0 + img.Pix[p+2] = 64 + img.Pix[p+3] = 255 + } else if x < 128 { + img.Pix[p+0] = 32 + img.Pix[p+1] = 0 + img.Pix[p+2] = 92 + img.Pix[p+3] = 255 + } else if x < 192 { + img.Pix[p+0] = 64 + img.Pix[p+1] = 0 + img.Pix[p+2] = 128 + img.Pix[p+3] = 255 + } else { + img.Pix[p+0] = 96 + img.Pix[p+1] = 0 + img.Pix[p+2] = 160 + img.Pix[p+3] = 255 + } + } + } + var buf bytes.Buffer + png.Encode(&buf, img) + return buf.Bytes() +} diff --git a/honk.go b/honk.go new file mode 100644 index 0000000..3fefe41 --- /dev/null +++ b/honk.go @@ -0,0 +1,1217 @@ +// +// Copyright (c) 2019 Ted Unangst +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +package main + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "database/sql" + "fmt" + "html" + "html/template" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "log" + "net/http" + "os" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/gorilla/mux" +) + +type UserInfo struct { + UserID int64 + Username string +} + +type WhatAbout struct { + ID int64 + Name string + Display string + About string + Key string + URL string +} + +var serverName string +var iconName = "icon.png" + +var readviews *Template + +func ziggy(user *WhatAbout) (keyname string, key *rsa.PrivateKey) { + db := opendatabase() + row := db.QueryRow("select seckey from users where userid = ?", user.ID) + var data string + row.Scan(&data) + var err error + key, _, err = pez(data) + if err != nil { + log.Printf("error loading %s seckey: %s", user.Name, err) + } + keyname = user.URL + "#key" + return +} + +func zaggy(keyname string) (key *rsa.PublicKey) { + db := opendatabase() + row := db.QueryRow("select pubkey from honkers where xid = ?", keyname) + var data string + err := row.Scan(&data) + savekey := false + if err != nil { + savekey = true + j, err := GetJunk(keyname) + if err != nil { + log.Printf("error getting %s pubkey: %s", keyname, err) + return + } + var ok bool + data, ok = jsonfindstring(j, []string{"publicKey", "publicKeyPem"}) + if !ok { + log.Printf("error getting %s pubkey", keyname) + return + } + _, ok = jsonfindstring(j, []string{"publicKey", "owner"}) + if !ok { + log.Printf("error getting %s pubkey owner", keyname) + return + } + } + _, key, err = pez(data) + if err != nil { + log.Printf("error getting %s pubkey: %s", keyname, err) + return + } + if savekey { + db.Exec("insert into honkers (name, xid, flavor, pubkey) values (?, ?, ?, ?)", + "", keyname, "key", data) + } + return +} + +func keymatch(keyname string, actor string) bool { + return strings.HasPrefix(keyname, actor) +} + +func getInfo(r *http.Request) map[string]interface{} { + templinfo := make(map[string]interface{}) + templinfo["StyleParam"] = getstyleparam() + templinfo["ServerName"] = serverName + templinfo["IconName"] = iconName + templinfo["UserInfo"] = GetUserInfo(r) + templinfo["LogoutCSRF"] = GetCSRF("logout", r) + return templinfo +} + +var re_unurl = regexp.MustCompile("https://([^/]+).*/([^/]+)") + +func honkerhandle(h string) string { + m := re_unurl.FindStringSubmatch(h) + if len(m) > 2 { + return fmt.Sprintf("%s@%s", m[2], m[1]) + } + return "" +} + +func reverbolate(honks []*Honk) { + for _, h := range honks { + h.What += "ed" + if h.Honker == "" { + h.Honker = "https://" + serverName + "/u/" + h.Username + if strings.IndexByte(h.XID, '/') == -1 { + h.URL = h.Honker + "/h/" + h.XID + } else { + h.URL = h.XID + } + } else { + idx := strings.LastIndexByte(h.Honker, '/') + if idx != -1 { + h.Username = honkerhandle(h.Honker) + } else { + h.Username = h.Honker + } + if h.URL == "" { + h.URL = h.XID + } + } + h.HTML = cleanstring(h.Noise) + } +} + +func homepage(w http.ResponseWriter, r *http.Request) { + templinfo := getInfo(r) + honks := gethonks("") + u := GetUserInfo(r) + if u != nil { + morehonks := gethonksforuser(u.UserID) + honks = append(honks, morehonks...) + templinfo["HonkCSRF"] = GetCSRF("honkhonk", r) + } + sort.Slice(honks, func(i, j int) bool { + return honks[i].Date.After(honks[j].Date) + }) + reverbolate(honks) + msg := "Things happen." + getconfig("servermsg", &msg) + templinfo["Honks"] = honks + templinfo["ShowRSS"] = true + templinfo["ServerMessage"] = msg + err := readviews.ExecuteTemplate(w, "homepage.html", templinfo) + if err != nil { + log.Print(err) + } +} + +func showrss(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + + honks := gethonks(name) + sort.Slice(honks, func(i, j int) bool { + return honks[i].Date.After(honks[j].Date) + }) + reverbolate(honks) + + home := fmt.Sprintf("https://%s/", serverName) + base := home + if name != "" { + home += "u/" + name + name += " " + } + feed := RssFeed{ + Title: name + "honk", + Link: home, + Description: name + "honk rss", + FeedImage: &RssFeedImage{ + URL: base + "icon.png", + Title: name + "honk rss", + Link: home, + }, + } + var modtime time.Time + past := time.Now().UTC().Add(-3 * 24 * time.Hour) + for _, honk := range honks { + if honk.Date.Before(past) { + break + } + if honk.URL[0] == '/' { + honk.URL = "https://" + serverName + honk.URL + } + feed.Items = append(feed.Items, &RssItem{ + Title: fmt.Sprintf("%s %s %s", honk.Username, honk.What, honk.XID), + Description: RssCData{string(honk.HTML)}, + Link: honk.URL, + PubDate: honk.Date.Format(time.RFC1123), + }) + if honk.Date.After(modtime) { + modtime = honk.Date + } + } + w.Header().Set("Cache-Control", "max-age=300") + w.Header().Set("Last-Modified", modtime.Format(http.TimeFormat)) + + err := feed.Write(w) + if err != nil { + log.Printf("error writing rss: %s", err) + } +} + +func butwhatabout(name string) (*WhatAbout, error) { + row := stmtWhatAbout.QueryRow(name) + var user WhatAbout + err := row.Scan(&user.ID, &user.Name, &user.Display, &user.About, &user.Key) + user.URL = fmt.Sprintf("https://%s/u/%s", serverName, user.Name) + return &user, err +} + +func crappola(j map[string]interface{}) bool { + t, _ := jsongetstring(j, "type") + a, _ := jsongetstring(j, "actor") + o, _ := jsongetstring(j, "object") + if t == "Delete" && a == o { + log.Printf("crappola from %s", a) + return true + } + return false +} + +func inbox(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + user, err := butwhatabout(name) + if err != nil { + http.NotFound(w, r) + return + } + var buf bytes.Buffer + io.Copy(&buf, r.Body) + payload := buf.Bytes() + j, err := ReadJunk(bytes.NewReader(payload)) + if err != nil { + log.Printf("bad payload: %s", err) + io.WriteString(os.Stdout, "bad payload\n") + os.Stdout.Write(payload) + io.WriteString(os.Stdout, "\n") + return + } + if crappola(j) { + return + } + keyname, err := zag(r, payload) + if err != nil { + log.Printf("inbox message failed signature: %s", err) + fd, _ := os.OpenFile("savedinbox.json", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + io.WriteString(fd, "bad signature:\n") + WriteJunk(fd, j) + io.WriteString(fd, "\n") + fd.Close() + return + } + fd, _ := os.OpenFile("savedinbox.json", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + WriteJunk(fd, j) + io.WriteString(fd, "\n") + fd.Close() + who, _ := jsongetstring(j, "actor") + if !keymatch(keyname, who) { + log.Printf("keyname actor mismatch: %s <> %s", keyname, who) + return + } + what, _ := jsongetstring(j, "type") + switch what { + case "Follow": + log.Printf("updating honker follow: %s", who) + rubadubdub(user, j) + case "Accept": + db := opendatabase() + log.Printf("updating honker accept: %s", who) + db.Exec("update honkers set flavor = 'sub' where xid = ? and flavor = 'presub'", who) + case "Undo": + obj, ok := jsongetmap(j, "object") + if !ok { + log.Printf("unknown undo no object") + } else { + what, _ := jsongetstring(obj, "type") + if what != "Follow" { + log.Printf("unknown undo: %s", what) + } else { + log.Printf("updating honker undo: %s", who) + db := opendatabase() + db.Exec("update honkers set flavor = 'undub' where xid = ? and flavor = 'dub'", who) + } + } + default: + xonk := xonkxonk(j) + if xonk != nil && needxonk(user.ID, xonk) { + xonk.UserID = user.ID + savexonk(xonk) + } + } +} + +func outbox(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + user, err := butwhatabout(name) + if err != nil { + http.NotFound(w, r) + return + } + honks := gethonks(name) + + var jonks []map[string]interface{} + for _, h := range honks { + j, _ := jonkjonk(user, h) + jonks = append(jonks, j) + } + + j := NewJunk() + j["@context"] = itiswhatitis + j["id"] = user.URL + "/outbox" + j["type"] = "OrderedCollection" + j["totalItems"] = len(jonks) + j["orderedItems"] = jonks + + w.Header().Set("Content-Type", theonetruename) + WriteJunk(w, j) +} + +func viewuser(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + user, err := butwhatabout(name) + if err != nil { + http.NotFound(w, r) + return + } + if friendorfoe(r.Header.Get("Accept")) { + j := asjonker(user) + w.Header().Set("Content-Type", theonetruename) + WriteJunk(w, j) + return + } + honks := gethonks(name) + u := GetUserInfo(r) + honkpage(w, r, u, user, honks) +} + +func viewhonker(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + u := GetUserInfo(r) + honks := gethonksbyhonker(u.UserID, name) + honkpage(w, r, nil, nil, honks) +} + +func fingerlicker(w http.ResponseWriter, r *http.Request) { + orig := r.FormValue("resource") + + log.Printf("finger lick: %s", orig) + + if strings.HasPrefix(orig, "acct:") { + orig = orig[5:] + } + + name := orig + idx := strings.LastIndexByte(name, '/') + if idx != -1 { + name = name[idx+1:] + if "https://"+serverName+"/u/"+name != orig { + log.Printf("foreign request rejected") + name = "" + } + } else { + idx = strings.IndexByte(name, '@') + if idx != -1 { + name = name[:idx] + if name+"@"+serverName != orig { + log.Printf("foreign request rejected") + name = "" + } + } + } + user, err := butwhatabout(name) + if err != nil { + http.NotFound(w, r) + return + } + + j := NewJunk() + j["subject"] = fmt.Sprintf("acct:%s@%s", user.Name, serverName) + j["aliases"] = []string{user.URL} + var links []map[string]interface{} + l := NewJunk() + l["rel"] = "self" + l["type"] = `application/activity+json` + l["href"] = user.URL + links = append(links, l) + j["links"] = links + + w.Header().Set("Content-Type", "application/jrd+json") + WriteJunk(w, j) +} + +func viewhonk(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + xid := mux.Vars(r)["xid"] + user, err := butwhatabout(name) + if err != nil { + http.NotFound(w, r) + return + } + h := getxonk(name, xid) + if h == nil { + http.NotFound(w, r) + return + } + if friendorfoe(r.Header.Get("Accept")) { + _, j := jonkjonk(user, h) + j["@context"] = itiswhatitis + w.Header().Set("Content-Type", theonetruename) + WriteJunk(w, j) + return + } + honkpage(w, r, nil, nil, []*Honk{h}) +} + +func honkpage(w http.ResponseWriter, r *http.Request, u *UserInfo, user *WhatAbout, honks []*Honk) { + reverbolate(honks) + templinfo := getInfo(r) + if u != nil && u.Username == user.Name { + templinfo["UserCSRF"] = GetCSRF("saveuser", r) + templinfo["HonkCSRF"] = GetCSRF("honkhonk", r) + } + if user != nil { + templinfo["Name"] = user.Name + whatabout := user.About + templinfo["RawWhatAbout"] = whatabout + whatabout = obfusbreak(whatabout) + templinfo["WhatAbout"] = cleanstring(whatabout) + } + templinfo["Honks"] = honks + err := readviews.ExecuteTemplate(w, "honkpage.html", templinfo) + if err != nil { + log.Print(err) + } +} + +func saveuser(w http.ResponseWriter, r *http.Request) { + whatabout := r.FormValue("whatabout") + u := GetUserInfo(r) + db := opendatabase() + _, err := db.Exec("update users set about = ? where username = ?", whatabout, u.Username) + if err != nil { + log.Printf("error bouting what: %s", err) + } + + http.Redirect(w, r, "/u/"+u.Username, http.StatusSeeOther) +} + +type Donk struct { + FileID int64 + XID string + Name string + URL string + Media string + Content []byte +} + +type Honk struct { + ID int64 + UserID int64 + Username string + What string + Honker string + XID string + RID string + Date time.Time + URL string + Noise string + Audience []string + HTML template.HTML + Donks []*Donk +} + +type Honker struct { + ID int64 + UserID int64 + Name string + XID string + Flavor string +} + +func gethonkers(userid int64) []*Honker { + rows, err := stmtHonkers.Query(userid) + if err != nil { + log.Printf("error querying honkers: %s", err) + return nil + } + defer rows.Close() + var honkers []*Honker + for rows.Next() { + var f Honker + err = rows.Scan(&f.ID, &f.UserID, &f.Name, &f.XID, &f.Flavor) + if err != nil { + log.Printf("error scanning honker: %s", err) + return nil + } + honkers = append(honkers, &f) + } + return honkers +} + +func getdubs(userid int64) []*Honker { + rows, err := stmtDubbers.Query(userid) + if err != nil { + log.Printf("error querying dubs: %s", err) + return nil + } + defer rows.Close() + var honkers []*Honker + for rows.Next() { + var f Honker + err = rows.Scan(&f.ID, &f.UserID, &f.Name, &f.XID, &f.Flavor) + if err != nil { + log.Printf("error scanning honker: %s", err) + return nil + } + honkers = append(honkers, &f) + } + return honkers +} + +func gethonk(honkid int64) *Honk { + var h Honk + var dt, aud string + row := stmtOneHonk.QueryRow(honkid) + err := row.Scan(&h.ID, &h.UserID, &h.Username, &h.What, &h.Honker, &h.XID, &h.RID, + &dt, &h.URL, &aud, &h.Noise) + if err != nil { + log.Printf("error scanning honk: %s", err) + return nil + } + h.Date, _ = time.Parse(dbtimeformat, dt) + h.Audience = strings.Split(aud, " ") + return &h +} + +func getxonk(name, xid string) *Honk { + var h Honk + var dt, aud string + row := stmtOneXonk.QueryRow(xid) + err := row.Scan(&h.ID, &h.UserID, &h.Username, &h.What, &h.Honker, &h.XID, &h.RID, + &dt, &h.URL, &aud, &h.Noise) + if err != nil { + log.Printf("error scanning xonk: %s", err) + return nil + } + if name != "" && h.Username != name { + log.Printf("user xonk mismatch") + return nil + } + h.Date, _ = time.Parse(dbtimeformat, dt) + h.Audience = strings.Split(aud, " ") + donksforhonks([]*Honk{&h}) + return &h +} + +func gethonks(username string) []*Honk { + return getsomehonks(username, 0, "") +} + +func gethonksforuser(userid int64) []*Honk { + return getsomehonks("", userid, "") +} +func gethonksbyhonker(userid int64, honker string) []*Honk { + return getsomehonks("", userid, honker) +} + +func getsomehonks(username string, userid int64, honkername string) []*Honk { + var rows *sql.Rows + var err error + if username != "" { + rows, err = stmtUserHonks.Query(username) + } else if honkername != "" { + rows, err = stmtHonksByHonker.Query(userid, honkername) + } else if userid > 0 { + rows, err = stmtHonksForUser.Query(userid) + } else { + rows, err = stmtHonks.Query() + } + if err != nil { + log.Printf("error querying honks: %s", err) + return nil + } + defer rows.Close() + var honks []*Honk + for rows.Next() { + var h Honk + var dt, aud string + err = rows.Scan(&h.ID, &h.UserID, &h.Username, &h.What, &h.Honker, &h.XID, &h.RID, + &dt, &h.URL, &aud, &h.Noise) + if err != nil { + log.Printf("error scanning honks: %s", err) + return nil + } + h.Date, _ = time.Parse(dbtimeformat, dt) + h.Audience = strings.Split(aud, " ") + honks = append(honks, &h) + } + rows.Close() + donksforhonks(honks) + return honks +} + +func donksforhonks(honks []*Honk) { + db := opendatabase() + var ids []string + for _, h := range honks { + ids = append(ids, fmt.Sprintf("%d", h.ID)) + } + q := fmt.Sprintf("select honkid, donks.fileid, xid, name, url, media from donks join files on donks.fileid = files.fileid where honkid in (%s)", strings.Join(ids, ",")) + rows, err := db.Query(q) + if err != nil { + log.Printf("error querying donks: %s", err) + return + } + defer rows.Close() + for rows.Next() { + var hid int64 + var d Donk + err = rows.Scan(&hid, &d.FileID, &d.XID, &d.Name, &d.URL, &d.Media) + if err != nil { + log.Printf("error scanning donk: %s", err) + continue + } + for _, h := range honks { + if h.ID == hid { + h.Donks = append(h.Donks, &d) + } + } + } +} + +func xfiltrate() string { + letters := "BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz1234567891234567891234" + db := opendatabase() + for { + var x int64 + var b [16]byte + rand.Read(b[:]) + for i, c := range b { + b[i] = letters[c&63] + } + s := string(b[:]) + r := db.QueryRow("select honkid from honks where xid = ?", s) + err := r.Scan(&x) + if err == nil { + continue + } + if err != sql.ErrNoRows { + log.Printf("err picking xid: %s", err) + return "" + } + r = db.QueryRow("select fileid from files where name = ?", s) + err = r.Scan(&x) + if err == nil { + continue + } + if err != sql.ErrNoRows { + log.Printf("err picking xid: %s", err) + return "" + } + return s + } +} + +type Mention struct { + who string + where string +} + +var re_mentions = regexp.MustCompile(`@[[:alnum:]]+@[[:alnum:].]+`) + +func grapevine(s string) []string { + m := re_mentions.FindAllString(s, -1) + var mentions []string + for i := range m { + where := gofish(m[i]) + if where != "" { + mentions = append(mentions, where) + } + } + return mentions +} + +func bunchofgrapes(s string) []Mention { + m := re_mentions.FindAllString(s, -1) + var mentions []Mention + for i := range m { + where := gofish(m[i]) + if where != "" { + mentions = append(mentions, Mention{who: m[i], where: where}) + } + } + return mentions +} + +var re_link = regexp.MustCompile(`https?://[^\s"]+[\w/)]`) + +func obfusbreak(s string) string { + s = strings.TrimSpace(s) + s = strings.Replace(s, "\r", "", -1) + s = html.EscapeString(s) + linkfn := func(url string) string { + addparen := false + adddot := false + if strings.HasSuffix(url, ")") && strings.IndexByte(url, '(') == -1 { + url = url[:len(url)-1] + addparen = true + } + if strings.HasSuffix(url, ".") { + url = url[:len(url)-1] + adddot = true + } + url = fmt.Sprintf(`%s`, url, url) + if adddot { + url += "." + } + if addparen { + url += ")" + } + return url + } + s = re_link.ReplaceAllStringFunc(s, linkfn) + + s = strings.Replace(s, "\n", "
", -1) + s = re_mentions.ReplaceAllStringFunc(s, func(m string) string { + return fmt.Sprintf(`%s`, html.EscapeString(gofish(m)), + html.EscapeString(m)) + }) + return s +} + +func prepend(s string, x []string) []string { + return append([]string{s}, x...) +} + +func savebonk(w http.ResponseWriter, r *http.Request) { + xid := r.FormValue("xid") + + log.Printf("bonking %s", xid) + + xonk := getxonk("", xid) + if xonk == nil { + return + } + if xonk.Honker == "" { + xonk.XID = fmt.Sprintf("https://%s/u/%s/h/%s", serverName, xonk.Username, xonk.XID) + } + + userinfo := GetUserInfo(r) + + dt := time.Now().UTC() + bonk := Honk{ + UserID: userinfo.UserID, + Username: userinfo.Username, + Honker: xonk.Honker, + What: "bonk", + XID: xonk.XID, + Date: dt, + Noise: xonk.Noise, + Donks: xonk.Donks, + Audience: oneofakind(prepend(thewholeworld, xonk.Audience)), + } + + aud := strings.Join(bonk.Audience, " ") + res, err := stmtSaveHonk.Exec(userinfo.UserID, "bonk", "", xid, "", + dt.Format(dbtimeformat), "", aud, bonk.Noise) + if err != nil { + log.Printf("error saving bonk: %s", err) + return + } + bonk.ID, _ = res.LastInsertId() + for _, d := range bonk.Donks { + _, err = stmtSaveDonk.Exec(bonk.ID, d.FileID) + if err != nil { + log.Printf("err saving donk: %s", err) + return + } + } + + user, _ := butwhatabout(userinfo.Username) + + go honkworldwide(user, &bonk) + +} + +func savehonk(w http.ResponseWriter, r *http.Request) { + rid := r.FormValue("rid") + noise := r.FormValue("noise") + + userinfo := GetUserInfo(r) + + dt := time.Now().UTC() + xid := xfiltrate() + if xid == "" { + return + } + what := "honk" + if rid != "" { + what = "tonk" + } + honk := Honk{ + UserID: userinfo.UserID, + Username: userinfo.Username, + What: "honk", + XID: xid, + RID: rid, + Date: dt, + } + if noise[0] == '@' { + honk.Audience = append(grapevine(noise), thewholeworld) + } else { + honk.Audience = append([]string{thewholeworld}, grapevine(noise)...) + } + if rid != "" { + xonk := getxonk("", rid) + honk.Audience = append(honk.Audience, xonk.Audience...) + } + honk.Audience = oneofakind(honk.Audience) + noise = obfusbreak(noise) + honk.Noise = noise + + file, _, err := r.FormFile("donk") + if err == nil { + var buf bytes.Buffer + io.Copy(&buf, file) + file.Close() + data := buf.Bytes() + img, format, err := image.Decode(&buf) + if err != nil { + log.Printf("bad image: %s", err) + return + } + data, format, err = vacuumwrap(img, format) + if err != nil { + log.Printf("can't vacuum image: %s", err) + return + } + name := xfiltrate() + media := "image/" + format + if format == "jpeg" { + format = "jpg" + } + name = name + "." + format + url := fmt.Sprintf("https://%s/d/%s", serverName, name) + res, err := stmtSaveFile.Exec(name, name, url, media, data) + if err != nil { + log.Printf("unable to save image: %s", err) + return + } + var d Donk + d.FileID, _ = res.LastInsertId() + d.XID = name + d.Name = name + d.Media = media + d.URL = url + honk.Donks = append(honk.Donks, &d) + } + + aud := strings.Join(honk.Audience, " ") + res, err := stmtSaveHonk.Exec(userinfo.UserID, what, "", xid, rid, + dt.Format(dbtimeformat), "", aud, noise) + if err != nil { + log.Printf("error saving honk: %s", err) + return + } + honk.ID, _ = res.LastInsertId() + for _, d := range honk.Donks { + _, err = stmtSaveDonk.Exec(honk.ID, d.FileID) + if err != nil { + log.Printf("err saving donk: %s", err) + return + } + } + + user, _ := butwhatabout(userinfo.Username) + + go honkworldwide(user, &honk) + + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func showhonkers(w http.ResponseWriter, r *http.Request) { + userinfo := GetUserInfo(r) + templinfo := getInfo(r) + templinfo["Honkers"] = gethonkers(userinfo.UserID) + templinfo["HonkerCSRF"] = GetCSRF("savehonker", r) + err := readviews.ExecuteTemplate(w, "honkers.html", templinfo) + if err != nil { + log.Print(err) + } +} + +var handfull = make(map[string]string) +var handlock sync.Mutex + +func gofish(name string) string { + if name[0] == '@' { + name = name[1:] + } + m := strings.Split(name, "@") + if len(m) != 2 { + log.Printf("bad far name: %s", name) + return "" + } + handlock.Lock() + defer handlock.Unlock() + ref, ok := handfull[name] + if ok { + return ref + } + j, err := GetJunk(fmt.Sprintf("https://%s/.well-known/webfinger?resource=acct:%s", m[1], name)) + if err != nil { + log.Printf("failed to get far name: %s", err) + handfull[name] = "" + return "" + } + links, _ := jsongetarray(j, "links") + for _, l := range links { + href, _ := jsongetstring(l, "href") + rel, _ := jsongetstring(l, "rel") + t, _ := jsongetstring(l, "type") + if rel == "self" && friendorfoe(t) { + handfull[name] = href + return href + } + } + handfull[name] = "" + return "" +} + +func savehonker(w http.ResponseWriter, r *http.Request) { + name := r.FormValue("name") + url := r.FormValue("url") + peep := r.FormValue("peep") + flavor := "presub" + if peep == "peep" { + flavor = "peep" + } + + if url == "" { + return + } + if url[0] == '@' { + url = gofish(url) + } + if url == "" { + return + } + + u := GetUserInfo(r) + db := opendatabase() + _, err := db.Exec("insert into honkers (userid, name, xid, flavor) values (?, ?, ?, ?)", + u.UserID, name, url, flavor) + if err != nil { + log.Print(err) + } + if flavor == "presub" { + user, _ := butwhatabout(u.Username) + go subsub(user, url) + } + http.Redirect(w, r, "/honkers", http.StatusSeeOther) +} + +func avatate(w http.ResponseWriter, r *http.Request) { + n := r.FormValue("a") + a := avatar(n) + w.Header().Set("Cache-Control", "max-age=76000") + w.Write(a) +} + +func servecss(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "max-age=7776000") + http.ServeFile(w, r, "views"+r.URL.Path) +} +func servehtml(w http.ResponseWriter, r *http.Request) { + templinfo := getInfo(r) + err := readviews.ExecuteTemplate(w, r.URL.Path[1:]+".html", templinfo) + if err != nil { + log.Print(err) + } +} + +func servefile(w http.ResponseWriter, r *http.Request) { + xid := mux.Vars(r)["xid"] + row := stmtFileData.QueryRow(xid) + var data []byte + err := row.Scan(&data) + if err != nil { + log.Printf("error loading file: %s", err) + http.NotFound(w, r) + return + } + w.Header().Set("Cache-Control", "max-age=432000") + w.Write(data) +} + +func serve() { + db := opendatabase() + LoginInit(db) + + getconfig("servername", &serverName) + listener, err := openListener() + if err != nil { + log.Fatal(err) + } + debug := false + getconfig("debug", &debug) + readviews = ParseTemplates(debug, + "views/homepage.html", + "views/honkpage.html", + "views/honkers.html", + "views/honkform.html", + "views/honk.html", + "views/login.html", + "views/header.html", + ) + if !debug { + savedstyleparam = getstyleparam() + } + + mux := mux.NewRouter() + mux.Use(LoginChecker) + + posters := mux.Methods("POST").Subrouter() + getters := mux.Methods("GET").Subrouter() + + getters.HandleFunc("/", homepage) + getters.HandleFunc("/rss", showrss) + getters.HandleFunc("/u/{name:[[:alnum:]]+}", viewuser) + getters.HandleFunc("/u/{name:[[:alnum:]]+}/h/{xid:[[:alnum:]]+}", viewhonk) + getters.HandleFunc("/u/{name:[[:alnum:]]+}/rss", showrss) + posters.HandleFunc("/u/{name:[[:alnum:]]+}/inbox", inbox) + getters.HandleFunc("/u/{name:[[:alnum:]]+}/outbox", outbox) + getters.HandleFunc("/a", avatate) + getters.HandleFunc("/d/{xid:[[:alnum:].]+}", servefile) + getters.HandleFunc("/h/{name:[[:alnum:]]+}", viewhonker) + getters.HandleFunc("/.well-known/webfinger", fingerlicker) + + getters.HandleFunc("/style.css", servecss) + getters.HandleFunc("/login", servehtml) + posters.HandleFunc("/dologin", dologin) + getters.HandleFunc("/logout", dologout) + + loggedin := mux.NewRoute().Subrouter() + loggedin.Use(LoginRequired) + loggedin.Handle("/honk", CSRFWrap("honkhonk", http.HandlerFunc(savehonk))) + loggedin.Handle("/bonk", CSRFWrap("honkhonk", http.HandlerFunc(savebonk))) + loggedin.Handle("/saveuser", CSRFWrap("saveuser", http.HandlerFunc(saveuser))) + loggedin.HandleFunc("/honkers", showhonkers) + loggedin.Handle("/savehonker", CSRFWrap("savehonker", http.HandlerFunc(savehonker))) + + err = http.Serve(listener, mux) + if err != nil { + log.Fatal(err) + } +} + +var stmtHonkers, stmtDubbers, stmtOneHonk, stmtOneXonk, stmtHonks, stmtUserHonks *sql.Stmt +var stmtHonksForUser, stmtDeleteHonk, stmtSaveDub *sql.Stmt +var stmtHonksByHonker, stmtSaveHonk, stmtFileData, stmtWhatAbout *sql.Stmt +var stmtFindXonk, stmtSaveDonk, stmtFindFile, stmtSaveFile *sql.Stmt + +func prepareStatements(db *sql.DB) { + var err error + stmtHonkers, err = db.Prepare("select honkerid, userid, name, xid, flavor from honkers where userid = ? and flavor = 'sub' or flavor = 'peep'") + if err != nil { + log.Fatal(err) + } + stmtDubbers, err = db.Prepare("select honkerid, userid, name, xid, flavor from honkers where userid = ? and flavor = 'dub'") + if err != nil { + log.Fatal(err) + } + stmtOneHonk, err = db.Prepare("select honkid, honks.userid, users.username, what, honker, xid, rid, dt, url, audience, noise from honks join users on honks.userid = users.userid where honkid = ? limit 50") + if err != nil { + log.Fatal(err) + } + stmtOneXonk, err = db.Prepare("select honkid, honks.userid, users.username, what, honker, xid, rid, dt, url, audience, noise from honks join users on honks.userid = users.userid where xid = ?") + if err != nil { + log.Fatal(err) + } + stmtHonks, err = db.Prepare("select honkid, honks.userid, users.username, what, honker, xid, rid, dt, url, audience, noise from honks join users on honks.userid = users.userid where honker = '' order by honkid desc limit 50") + if err != nil { + log.Fatal(err) + } + stmtUserHonks, err = db.Prepare("select honkid, honks.userid, username, what, honker, xid, rid, dt, url, audience, noise from honks join users on honks.userid = users.userid where honker = '' and username = ? order by honkid desc limit 50") + if err != nil { + log.Fatal(err) + } + stmtHonksForUser, err = db.Prepare("select honkid, honks.userid, users.username, what, honker, xid, rid, dt, url, audience, noise from honks join users on honks.userid = users.userid where honks.userid = ? and honker <> '' and what <> 'zonk' order by honkid desc limit 150") + if err != nil { + log.Fatal(err) + } + stmtHonksByHonker, err = db.Prepare("select honkid, honks.userid, users.username, what, honker, honks.xid, rid, dt, url, audience, noise from honks join users on honks.userid = users.userid join honkers on honkers.xid = honks.honker where honks.userid = ? and honkers.name = ? order by honkid desc limit 50") + if err != nil { + log.Fatal(err) + } + stmtSaveHonk, err = db.Prepare("insert into honks (userid, what, honker, xid, rid, dt, url, audience, noise) values (?, ?, ?, ?, ?, ?, ?, ?, ?)") + if err != nil { + log.Fatal(err) + } + stmtFileData, err = db.Prepare("select content from files where xid = ?") + if err != nil { + log.Fatal(err) + } + stmtFindXonk, err = db.Prepare("select honkid from honks where userid = ? and xid = ? and what = ?") + if err != nil { + log.Fatal(err) + } + stmtSaveDonk, err = db.Prepare("insert into donks (honkid, fileid) values (?, ?)") + if err != nil { + log.Fatal(err) + } + stmtDeleteHonk, err = db.Prepare("update honks set what = 'zonk' where xid = ? and honker = ?") + if err != nil { + log.Fatal(err) + } + stmtFindFile, err = db.Prepare("select fileid from files where url = ?") + if err != nil { + log.Fatal(err) + } + stmtSaveFile, err = db.Prepare("insert into files (xid, name, url, media, content) values (?, ?, ?, ?, ?)") + if err != nil { + log.Fatal(err) + } + stmtWhatAbout, err = db.Prepare("select userid, username, displayname, about, pubkey from users where username = ?") + if err != nil { + log.Fatal(err) + } + stmtSaveDub, err = db.Prepare("insert into honkers (userid, name, xid, flavor) values (?, ?, ?, ?)") + if err != nil { + log.Fatal(err) + } +} + +func ElaborateUnitTests() { +} + +func finishusersetup() error { + db := opendatabase() + k, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return err + } + pubkey, err := zem(&k.PublicKey) + if err != nil { + return err + } + seckey, err := zem(k) + if err != nil { + return err + } + _, err = db.Exec("update users set displayname = username, about = ?, pubkey = ?, seckey = ? where userid = 1", "what about me?", pubkey, seckey) + if err != nil { + return err + } + return nil +} + +func main() { + cmd := "run" + if len(os.Args) > 1 { + cmd = os.Args[1] + } + if cmd != "init" { + db := opendatabase() + prepareStatements(db) + } + switch cmd { + case "peep": + peeppeep() + case "init": + initdb() + case "run": + serve() + case "test": + ElaborateUnitTests() + default: + log.Fatal("unknown command") + } +} diff --git a/html.go b/html.go new file mode 100644 index 0000000..24b18d4 --- /dev/null +++ b/html.go @@ -0,0 +1,191 @@ +// +// Copyright (c) 2019 Ted Unangst +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +package main + +import ( + "fmt" + "html/template" + "io" + "log" + "net/url" + "regexp" + "sort" + "strings" + + "golang.org/x/net/html" +) + +var permittedtags = []string{"div", "h1", "h2", "h3", "h4", "h5", "h6", + "table", "thead", "tbody", "th", "tr", "td", + "p", "br", "pre", "code", "blockquote", "strong", "em", "b", "i", "s", "sup", + "ol", "ul", "li"} +var permittedattr = []string{"colspan", "rowspan"} +var bannedtags = []string{"script", "style"} + +func init() { + sort.Strings(permittedtags) + sort.Strings(permittedattr) + sort.Strings(bannedtags) +} + +func contains(array []string, tag string) bool { + idx := sort.SearchStrings(array, tag) + return idx < len(array) && array[idx] == tag +} + +func getattr(node *html.Node, attr string) string { + for _, a := range node.Attr { + if a.Key == attr { + return a.Val + } + } + return "" +} + +func hasclass(node *html.Node, class string) bool { + return strings.Contains(" "+getattr(node, "class")+" ", " "+class+" ") +} + +func writetag(w io.Writer, node *html.Node) { + io.WriteString(w, "<") + io.WriteString(w, node.Data) + for _, attr := range node.Attr { + if contains(permittedattr, attr.Key) { + fmt.Fprintf(w, ` %s="%s"`, attr.Key, html.EscapeString(attr.Val)) + } + } + io.WriteString(w, ">") +} + +func render(w io.Writer, node *html.Node) { + switch node.Type { + case html.ElementNode: + tag := node.Data + switch { + case tag == "a": + href := getattr(node, "href") + hrefurl, err := url.Parse(href) + if err != nil { + href = "#BROKEN-" + href + } else { + href = hrefurl.String() + } + fmt.Fprintf(w, ``, html.EscapeString(href)) + case tag == "img": + div := replaceimg(node) + if div != "skip" { + io.WriteString(w, div) + } + case tag == "span": + case tag == "iframe": + src := html.EscapeString(getattr(node, "src")) + fmt.Fprintf(w, `<iframe src="%s">`, src, src) + case contains(permittedtags, tag): + writetag(w, node) + case contains(bannedtags, tag): + return + } + case html.TextNode: + io.WriteString(w, html.EscapeString(node.Data)) + } + for c := node.FirstChild; c != nil; c = c.NextSibling { + render(w, c) + } + if node.Type == html.ElementNode { + tag := node.Data + if tag == "a" || (contains(permittedtags, tag) && tag != "br") { + fmt.Fprintf(w, "", tag) + } + if tag == "p" || tag == "div" { + io.WriteString(w, "\n") + } + } +} + +func replaceimg(node *html.Node) string { + src := getattr(node, "src") + alt := getattr(node, "alt") + //title := getattr(node, "title") + if hasclass(node, "Emoji") && alt != "" { + return html.EscapeString(alt) + } + return html.EscapeString(fmt.Sprintf(``, src)) +} + +func cleannode(node *html.Node) template.HTML { + var buf strings.Builder + render(&buf, node) + return template.HTML(buf.String()) +} + +func cleanstring(shtml string) template.HTML { + reader := strings.NewReader(shtml) + body, err := html.Parse(reader) + if err != nil { + log.Printf("error parsing html: %s", err) + return "" + } + return cleannode(body) +} + +func textonly(w io.Writer, node *html.Node) { + switch node.Type { + case html.ElementNode: + tag := node.Data + switch { + case tag == "a": + href := getattr(node, "href") + fmt.Fprintf(w, ``, href) + case tag == "img": + io.WriteString(w, "") + case contains(bannedtags, tag): + return + } + case html.TextNode: + io.WriteString(w, node.Data) + } + for c := node.FirstChild; c != nil; c = c.NextSibling { + textonly(w, c) + } + if node.Type == html.ElementNode { + tag := node.Data + if tag == "a" { + fmt.Fprintf(w, "", tag) + } + if tag == "p" || tag == "div" { + io.WriteString(w, "\n") + } + } +} + +var re_whitespaceeater = regexp.MustCompile("[ \t\r]*\n[ \t\r]*") +var re_blanklineeater = regexp.MustCompile("\n\n+") +var re_tabeater = regexp.MustCompile("[ \t]+") + +func htmltotext(shtml template.HTML) string { + reader := strings.NewReader(string(shtml)) + body, _ := html.Parse(reader) + var buf strings.Builder + textonly(&buf, body) + rv := buf.String() + rv = re_whitespaceeater.ReplaceAllLiteralString(rv, "\n") + rv = re_blanklineeater.ReplaceAllLiteralString(rv, "\n\n") + rv = re_tabeater.ReplaceAllLiteralString(rv, " ") + for len(rv) > 0 && rv[0] == '\n' { + rv = rv[1:] + } + return rv +} diff --git a/image.go b/image.go new file mode 100644 index 0000000..9983d81 --- /dev/null +++ b/image.go @@ -0,0 +1,148 @@ +// +// Copyright (c) 2019 Ted Unangst +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +package main + +import ( + "bytes" + "fmt" + "image" + "image/jpeg" + "image/png" + "math" +) + +func lineate(s uint8) float64 { + x := float64(s) + x /= 255.0 + if x < 0.04045 { + x /= 12.92 + } else { + x += 0.055 + x /= 1.055 + x = math.Pow(x, 2.4) + } + return x +} + +func delineate(x float64) uint8 { + if x > 0.0031308 { + x = math.Pow(x, 1/2.4) + x *= 1.055 + x -= 0.055 + } else { + x *= 12.92 + } + x *= 255.0 + return uint8(x) +} + +func blend(d []byte, s1, s2, s3, s4 int) byte { + l1 := lineate(d[s1]) + l2 := lineate(d[s2]) + l3 := lineate(d[s3]) + l4 := lineate(d[s4]) + return delineate((l1 + l2 + l3 + l4) / 4.0) +} + +func squish(d []byte, s1, s2, s3, s4 int) byte { + return uint8((uint32(s1) + uint32(s2)) / 2) +} + +func vacuumwrap(img image.Image, format string) ([]byte, string, error) { + maxdimension := 2048 + for img.Bounds().Max.X > maxdimension || img.Bounds().Max.Y > maxdimension { + switch oldimg := img.(type) { + case *image.NRGBA: + w, h := oldimg.Rect.Max.X/2, oldimg.Rect.Max.Y/2 + newimg := image.NewNRGBA(image.Rectangle{Max: image.Point{X: w, Y: h}}) + for j := 0; j < h; j++ { + for i := 0; i < w; i++ { + p := newimg.Stride*j + i*4 + q1 := oldimg.Stride*(j*2+0) + i*4*2 + q2 := oldimg.Stride*(j*2+1) + i*4*2 + newimg.Pix[p+0] = blend(oldimg.Pix, q1+0, q1+4, q2+0, q2+4) + newimg.Pix[p+1] = blend(oldimg.Pix, q1+1, q1+5, q2+1, q2+5) + newimg.Pix[p+2] = blend(oldimg.Pix, q1+2, q1+6, q2+2, q2+6) + newimg.Pix[p+3] = squish(oldimg.Pix, q1+3, q1+7, q2+3, q2+7) + } + } + img = newimg + case *image.YCbCr: + w, h := oldimg.Rect.Max.X/2, oldimg.Rect.Max.Y/2 + newimg := image.NewYCbCr(image.Rectangle{Max: image.Point{X: w, Y: h}}, + oldimg.SubsampleRatio) + for j := 0; j < h; j++ { + for i := 0; i < w; i++ { + p := newimg.YStride*j + i + q1 := oldimg.YStride*(j*2+0) + i*2 + q2 := oldimg.YStride*(j*2+1) + i*2 + newimg.Y[p+0] = blend(oldimg.Y, q1+0, q1+1, q2+0, q2+1) + } + } + switch newimg.SubsampleRatio { + case image.YCbCrSubsampleRatio444: + w, h = w, h + case image.YCbCrSubsampleRatio422: + w, h = w/2, h + case image.YCbCrSubsampleRatio420: + w, h = w/2, h/2 + case image.YCbCrSubsampleRatio440: + w, h = w, h/2 + case image.YCbCrSubsampleRatio411: + w, h = w/4, h + case image.YCbCrSubsampleRatio410: + w, h = w/4, h/2 + } + for j := 0; j < h; j++ { + for i := 0; i < w; i++ { + p := newimg.CStride*j + i + q1 := oldimg.CStride*(j*2+0) + i*2 + q2 := oldimg.CStride*(j*2+1) + i*2 + newimg.Cb[p+0] = blend(oldimg.Cb, q1+0, q1+1, q2+0, q2+1) + newimg.Cr[p+0] = blend(oldimg.Cr, q1+0, q1+1, q2+0, q2+1) + } + } + img = newimg + default: + return nil, "", fmt.Errorf("can't support image format") + } + } + maxsize := 512 * 1024 + quality := 80 + var buf bytes.Buffer + for { + switch format { + case "png": + png.Encode(&buf, img) + case "jpeg": + jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}) + default: + return nil, "", fmt.Errorf("can't encode format: %s", format) + } + if buf.Len() > maxsize && quality > 30 { + switch format { + case "png": + format = "jpeg" + case "jpeg": + quality -= 10 + } + buf.Reset() + continue + } + break + } + return buf.Bytes(), format, nil +} diff --git a/login.go b/login.go new file mode 100644 index 0000000..318d4cf --- /dev/null +++ b/login.go @@ -0,0 +1,331 @@ +// +// Copyright (c) 2019 Ted Unangst +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +package main + +import ( + "context" + "crypto/rand" + "crypto/sha512" + "crypto/subtle" + "database/sql" + "fmt" + "hash" + "io" + "log" + "net/http" + "reflect" + "regexp" + "strings" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type keytype struct{} + +var thekey keytype + +func LoginChecker(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userinfo, ok := checkauthcookie(r) + if ok { + ctx := context.WithValue(r.Context(), thekey, userinfo) + r = r.WithContext(ctx) + } + handler.ServeHTTP(w, r) + }) +} + +func LoginRequired(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ok := GetUserInfo(r) != nil + if !ok { + loginredirect(w, r) + return + } + handler.ServeHTTP(w, r) + }) +} + +func GetUserInfo(r *http.Request) *UserInfo { + userinfo, ok := r.Context().Value(thekey).(*UserInfo) + if !ok { + return nil + } + return userinfo +} + +func calculateCSRF(salt, action, auth string) string { + hasher := sha512.New512_256() + zero := []byte{0} + hasher.Write(zero) + hasher.Write([]byte(auth)) + hasher.Write(zero) + hasher.Write([]byte(csrfkey)) + hasher.Write(zero) + hasher.Write([]byte(salt)) + hasher.Write(zero) + hasher.Write([]byte(action)) + hasher.Write(zero) + hash := hexsum(hasher) + + return salt + hash +} + +func GetCSRF(action string, r *http.Request) string { + auth := getauthcookie(r) + if auth == "" { + return "" + } + hasher := sha512.New512_256() + io.CopyN(hasher, rand.Reader, 32) + salt := hexsum(hasher) + + return calculateCSRF(salt, action, auth) +} + +func CheckCSRF(action string, r *http.Request) bool { + auth := getauthcookie(r) + if auth == "" { + return false + } + csrf := r.FormValue("CSRF") + if len(csrf) != authlen*2 { + return false + } + salt := csrf[0:authlen] + rv := calculateCSRF(salt, action, auth) + ok := subtle.ConstantTimeCompare([]byte(rv), []byte(csrf)) == 1 + return ok +} + +func CSRFWrap(action string, handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ok := CheckCSRF(action, r) + if !ok { + http.Error(w, "invalid csrf", 403) + return + } + handler.ServeHTTP(w, r) + }) +} + +func loginredirect(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: "auth", + Value: "", + MaxAge: -1, + Secure: securecookies, + HttpOnly: true, + }) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +var authregex = regexp.MustCompile("^[[:alnum:]]+$") +var authlen = 32 + +var stmtUserName, stmtUserAuth, stmtSaveAuth, stmtDeleteAuth *sql.Stmt +var csrfkey string +var securecookies bool + +func LoginInit(db *sql.DB) { + var err error + stmtUserName, err = db.Prepare("select userid, hash from users where username = ?") + if err != nil { + log.Fatal(err) + } + var userinfo UserInfo + t := reflect.TypeOf(userinfo) + var fields []string + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + fields = append(fields, strings.ToLower(f.Name)) + } + stmtUserAuth, err = db.Prepare(fmt.Sprintf("select %s from users where userid = (select userid from auth where hash = ?)", strings.Join(fields, ", "))) + if err != nil { + log.Fatal(err) + } + stmtSaveAuth, err = db.Prepare("insert into auth (userid, hash) values (?, ?)") + if err != nil { + log.Fatal(err) + } + stmtDeleteAuth, err = db.Prepare("delete from auth where userid = ?") + if err != nil { + log.Fatal(err) + } + debug := false + getconfig("debug", &debug) + securecookies = !debug + getconfig("csrfkey", &csrfkey) +} + +var authinprogress = make(map[string]bool) +var authprogressmtx sync.Mutex + +func rateandwait(username string) bool { + authprogressmtx.Lock() + defer authprogressmtx.Unlock() + if authinprogress[username] { + return false + } + authinprogress[username] = true + go func(name string) { + time.Sleep(1 * time.Second / 2) + authprogressmtx.Lock() + authinprogress[name] = false + authprogressmtx.Unlock() + }(username) + return true +} + +func getauthcookie(r *http.Request) string { + cookie, err := r.Cookie("auth") + if err != nil { + return "" + } + auth := cookie.Value + if !(len(auth) == authlen && authregex.MatchString(auth)) { + log.Printf("login: bad auth: %s", auth) + return "" + } + return auth +} + +func checkauthcookie(r *http.Request) (*UserInfo, bool) { + auth := getauthcookie(r) + if auth == "" { + return nil, false + } + hasher := sha512.New512_256() + hasher.Write([]byte(auth)) + authhash := hexsum(hasher) + row := stmtUserAuth.QueryRow(authhash) + var userinfo UserInfo + v := reflect.ValueOf(&userinfo).Elem() + var ptrs []interface{} + for i := 0; i < v.NumField(); i++ { + f := v.Field(i) + ptrs = append(ptrs, f.Addr().Interface()) + } + err := row.Scan(ptrs...) + if err != nil { + if err == sql.ErrNoRows { + log.Printf("login: no auth found") + } else { + log.Printf("login: error scanning auth row: %s", err) + } + return nil, false + } + return &userinfo, true +} + +func loaduser(username string) (int64, string, bool) { + row := stmtUserName.QueryRow(username) + var userid int64 + var hash string + err := row.Scan(&userid, &hash) + if err != nil { + if err == sql.ErrNoRows { + log.Printf("login: no username found") + } else { + log.Printf("login: error loading username: %s", err) + } + return -1, "", false + } + return userid, hash, true +} + +var userregex = regexp.MustCompile("^[[:alnum:]]+$") +var userlen = 32 +var passlen = 128 + +func hexsum(h hash.Hash) string { + return fmt.Sprintf("%x", h.Sum(nil))[0:authlen] +} + +func dologin(w http.ResponseWriter, r *http.Request) { + username := r.FormValue("username") + password := r.FormValue("password") + + if len(username) == 0 || len(username) > userlen || + !userregex.MatchString(username) || len(password) == 0 || + len(password) > passlen { + log.Printf("login: invalid password attempt") + loginredirect(w, r) + return + } + userid, hash, ok := loaduser(username) + if !ok { + loginredirect(w, r) + return + } + + if !rateandwait(username) { + loginredirect(w, r) + return + } + + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + if err != nil { + log.Printf("login: incorrect password") + loginredirect(w, r) + return + } + hasher := sha512.New512_256() + io.CopyN(hasher, rand.Reader, 32) + hash = hexsum(hasher) + + http.SetCookie(w, &http.Cookie{ + Name: "auth", + Value: hash, + MaxAge: 3600 * 24 * 30, + Secure: securecookies, + HttpOnly: true, + }) + + hasher.Reset() + hasher.Write([]byte(hash)) + authhash := hexsum(hasher) + + _, err = stmtSaveAuth.Exec(userid, authhash) + if err != nil { + log.Printf("error saving auth: %s", err) + } + + log.Printf("login: successful login") + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func dologout(w http.ResponseWriter, r *http.Request) { + userinfo, ok := checkauthcookie(r) + if ok && CheckCSRF("logout", r) { + _, err := stmtDeleteAuth.Exec(userinfo.UserID) + if err != nil { + log.Printf("login: error deleting old auth: %s", err) + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "auth", + Value: "", + MaxAge: -1, + Secure: securecookies, + HttpOnly: true, + }) + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} diff --git a/rss.go b/rss.go new file mode 100644 index 0000000..8b64d54 --- /dev/null +++ b/rss.go @@ -0,0 +1,65 @@ +// +// Copyright (c) 2018 Ted Unangst +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +package main + +import ( + "encoding/xml" + "io" +) + +type Rss struct { + XMLName xml.Name `xml:"rss"` + Version string `xml:"version,attr"` + Feed *RssFeed +} + +type RssFeed struct { + XMLName xml.Name `xml:"channel"` + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + FeedImage *RssFeedImage + Items []*RssItem +} + +type RssFeedImage struct { + XMLName xml.Name `xml:"image"` + URL string `xml:"url"` + Title string `xml:"title"` + Link string `xml:"link"` +} + +type RssItem struct { + XMLName xml.Name `xml:"item"` + Title string `xml:"title"` + Description RssCData `xml:"description"` + Link string `xml:"link"` + PubDate string `xml:"pubDate"` +} + +type RssCData struct { + Data string `xml:",cdata"` +} + +func (fd *RssFeed) Write(w io.Writer) error { + r := Rss{Version: "2.0", Feed: fd} + io.WriteString(w, xml.Header) + enc := xml.NewEncoder(w) + enc.Indent("", " ") + err := enc.Encode(r) + io.WriteString(w, "\n") + return err +} diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..98aaf0f --- /dev/null +++ b/schema.sql @@ -0,0 +1,20 @@ + +CREATE TABLE honks (honkid integer primary key, userid integer, what text, honker text, xid text, rid text, dt text, url text, noise text); +CREATE TABLE donks (honkid integer, fileid integer); +CREATE TABLE files(fileid integer primary key, xid text, name text, url text, media text, content blob); +CREATE TABLE honkers (honkerid integer primary key, userid integer, name text, xid text, flavor text, pubkey text); + +create index idx_honksxid on honks(xid); +create index idx_honkshonker on honks(honker); +create index idx_honkerxid on honkers(xid); +create index idx_filesxid on files(xid); +create index idx_filesurl on files(url); + +CREATE TABLE config (key text, value text); + +CREATE TABLE users (userid integer primary key, username text, hash text, displayname text, about text, pubkey text, seckey text); +CREATE TABLE auth (authid integer primary key, userid integer, hash text); +CREATE INDEX idxusers_username on users(username); +CREATE INDEX idxauth_userid on auth(userid); +CREATE INDEX idxauth_hash on auth(hash); + diff --git a/template.go b/template.go new file mode 100644 index 0000000..f90847b --- /dev/null +++ b/template.go @@ -0,0 +1,81 @@ +// +// Copyright (c) 2019 Ted Unangst +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +package main + +import ( + "errors" + "html/template" + "io" + "log" +) + +type Template struct { + names []string + templates *template.Template + reload bool +} + +func mapmaker(values ...interface{}) (map[string]interface{}, error) { + if len(values)%2 != 0 { + return nil, errors.New("need arguments in pairs") + } + dict := make(map[string]interface{}, len(values)/2) + for i := 0; i < len(values); i += 2 { + key, ok := values[i].(string) + if !ok { + return nil, errors.New("key must be string") + } + dict[key] = values[i+1] + } + return dict, nil +} + +func loadtemplates(filenames ...string) (*template.Template, error) { + templates := template.New("") + templates.Funcs(template.FuncMap{ + "map": mapmaker, + }) + templates, err := templates.ParseFiles(filenames...) + if err != nil { + return nil, err + } + return templates, nil +} + +func (t *Template) ExecuteTemplate(w io.Writer, name string, data interface{}) error { + if t.reload { + templates, err := loadtemplates(t.names...) + if err != nil { + return err + } + return templates.ExecuteTemplate(w, name, data) + } + return t.templates.ExecuteTemplate(w, name, data) +} + +func ParseTemplates(reload bool, filenames ...string) *Template { + t := new(Template) + t.names = filenames + t.reload = reload + templates, err := loadtemplates(filenames...) + if err != nil { + log.Panic(err) + } + if !reload { + t.templates = templates + } + return t +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..42e4c8a --- /dev/null +++ b/util.go @@ -0,0 +1,227 @@ +// +// Copyright (c) 2019 Ted Unangst +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +package main + +import ( + "bufio" + "crypto/rand" + "crypto/sha512" + "database/sql" + "fmt" + "io/ioutil" + "log" + "net" + "os" + "os/signal" + "strings" + + "golang.org/x/crypto/bcrypt" + _ "humungus.tedunangst.com/r/go-sqlite3" +) + +var savedstyleparam string + +func getstyleparam() string { + if savedstyleparam != "" { + return savedstyleparam + } + data, _ := ioutil.ReadFile("views/style.css") + hasher := sha512.New() + hasher.Write(data) + return fmt.Sprintf("?v=%.8x", hasher.Sum(nil)) +} + +var dbtimeformat = "2006-01-02 15:04:05" + +var alreadyopendb *sql.DB +var dbname = "honk.db" +var stmtConfig *sql.Stmt + +func initdb() { + schema, err := ioutil.ReadFile("schema.sql") + if err != nil { + log.Fatal(err) + } + _, err = os.Stat(dbname) + if err == nil { + log.Fatalf("%s already exists", dbname) + } + db, err := sql.Open("sqlite3", dbname) + if err != nil { + log.Fatal(err) + } + defer func() { + os.Remove(dbname) + os.Exit(1) + }() + c := make(chan os.Signal) + signal.Notify(c, os.Interrupt) + go func() { + <-c + fmt.Printf("\x1b[?12;25h\x1b[0m") + fmt.Printf("\n") + os.Remove(dbname) + os.Exit(1) + }() + for _, line := range strings.Split(string(schema), ";") { + _, err = db.Exec(line) + if err != nil { + log.Print(err) + return + } + } + defer db.Close() + r := bufio.NewReader(os.Stdin) + fmt.Printf("username: ") + name, err := r.ReadString('\n') + if err != nil { + log.Print(err) + return + } + name = name[:len(name)-1] + if len(name) < 1 { + log.Print("that's way too short") + return + } + fmt.Printf("password: \x1b[?25l\x1b[%d;%dm \x1b[16D", 30, 40) + pass, err := r.ReadString('\n') + fmt.Printf("\x1b[0m\x1b[?12;25h") + if err != nil { + log.Fatal(err) + return + } + pass = pass[:len(pass)-1] + if len(pass) < 6 { + log.Print("that's way too short") + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(pass), 12) + if err != nil { + log.Print(err) + return + } + _, err = db.Exec("insert into users (username, hash) values (?, ?)", name, hash) + if err != nil { + log.Print(err) + return + } + fmt.Printf("listen address: ") + addr, err := r.ReadString('\n') + if err != nil { + log.Print(err) + return + } + addr = addr[:len(addr)-1] + if len(addr) < 1 { + log.Print("that's way too short") + return + } + _, err = db.Exec("insert into config (key, value) values (?, ?)", "listenaddr", addr) + if err != nil { + log.Print(err) + return + } + fmt.Printf("server name: ") + addr, err = r.ReadString('\n') + if err != nil { + log.Print(err) + return + } + addr = addr[:len(addr)-1] + if len(addr) < 1 { + log.Print("that's way too short") + return + } + _, err = db.Exec("insert into config (key, value) values (?, ?)", "servername", addr) + if err != nil { + log.Print(err) + return + } + var randbytes [16]byte + rand.Read(randbytes[:]) + key := fmt.Sprintf("%x", randbytes) + _, err = db.Exec("insert into config (key, value) values (?, ?)", "csrfkey", key) + if err != nil { + log.Print(err) + return + } + err = finishusersetup() + if err != nil { + log.Print(err) + return + } + db.Exec("insert into config (key, value) values (?, ?)", "debug", 1) + db.Close() + fmt.Printf("done.\n") + os.Exit(0) +} + +func opendatabase() *sql.DB { + if alreadyopendb != nil { + return alreadyopendb + } + var err error + _, err = os.Stat(dbname) + if err != nil { + log.Fatalf("unable to open database: %s", err) + } + db, err := sql.Open("sqlite3", dbname) + if err != nil { + log.Fatalf("unable to open database: %s", err) + } + stmtConfig, err = db.Prepare("select value from config where key = ?") + if err != nil { + log.Fatal(err) + } + alreadyopendb = db + return db +} + +func getconfig(key string, value interface{}) error { + row := stmtConfig.QueryRow(key) + err := row.Scan(value) + if err == sql.ErrNoRows { + err = nil + } + return err +} + +func openListener() (net.Listener, error) { + var listenAddr string + err := getconfig("listenaddr", &listenAddr) + if err != nil { + return nil, err + } + if listenAddr == "" { + return nil, fmt.Errorf("must have listenaddr") + } + proto := "tcp" + if listenAddr[0] == '/' { + proto = "unix" + err := os.Remove(listenAddr) + if err != nil && !os.IsNotExist(err) { + log.Printf("unable to unlink socket: %s", err) + } + } + listener, err := net.Listen(proto, listenAddr) + if err != nil { + return nil, err + } + if proto == "unix" { + os.Chmod(listenAddr, 0777) + } + return listener, nil +} diff --git a/views/header.html b/views/header.html new file mode 100644 index 0000000..46575fe --- /dev/null +++ b/views/header.html @@ -0,0 +1,21 @@ + + + +honk + + + + +
+honk +{{ if .ShowRSS }} +rss +{{ end }} +{{ if .UserInfo }} +{{ .UserInfo.Username }} +honkers +logout +{{ else }} +login +{{ end }} +
diff --git a/views/homepage.html b/views/homepage.html new file mode 100644 index 0000000..cc06d94 --- /dev/null +++ b/views/homepage.html @@ -0,0 +1,27 @@ +{{ template "header.html" . }} +
+
+

{{ .ServerMessage }} +{{ if .HonkCSRF }} +{{ template "honkform.html" . }} +{{ end }} +

+
+{{ $BonkCSRF := .HonkCSRF }} +{{ range .Honks }} +{{ template "honk.html" map "Honk" . "Bonk" $BonkCSRF }} +{{ end }} +
+{{ if $BonkCSRF }} + +{{ end }} diff --git a/views/honk.html b/views/honk.html new file mode 100644 index 0000000..1f05052 --- /dev/null +++ b/views/honk.html @@ -0,0 +1,14 @@ +
+{{ with .Honk }} +
avatar

{{ .Username }} {{ .What }} {{ .Date.Format "02 Jan 2006 15:04" }} {{ .URL }}

+

{{ .HTML }}

+{{ range .Donks }} +

+{{ end }} +{{ end }} +{{ if .Bonk }} +

+ + +{{ end }} +

diff --git a/views/honkers.html b/views/honkers.html new file mode 100644 index 0000000..deaf909 --- /dev/null +++ b/views/honkers.html @@ -0,0 +1,25 @@ +{{ template "header.html" . }} +
+
+

+

+add new honker + +

- name +

- url +

+ +

+

+
+{{ range .Honkers }} +
+

+{{ .Name }} +

url: {{ .XID }} +

honks +

flavor: {{ .Flavor }} +

+{{ end }} +
+
diff --git a/views/honkform.html b/views/honkform.html new file mode 100644 index 0000000..18447c7 --- /dev/null +++ b/views/honkform.html @@ -0,0 +1,30 @@ +

+ +

+ diff --git a/views/honkpage.html b/views/honkpage.html new file mode 100644 index 0000000..9f9259c --- /dev/null +++ b/views/honkpage.html @@ -0,0 +1,24 @@ +{{ template "header.html" . }} +
+{{ if .Name }} +
+

{{ .Name }} rss +{{ if .HonkCSRF }} +

+
+ + +

+ +

+
+{{ else }} +

{{ .WhatAbout }} +{{ end }} +

+{{ end }} +
+{{ range .Honks }} +{{ template "honk.html" map "Honk" . }} +{{ end }} +
diff --git a/views/login.html b/views/login.html new file mode 100644 index 0000000..846274c --- /dev/null +++ b/views/login.html @@ -0,0 +1,11 @@ +{{ template "header.html" . }} +
+
+
+

login +

- username +

- password +

+

+
+
diff --git a/views/style.css b/views/style.css new file mode 100644 index 0000000..d3aaa29 --- /dev/null +++ b/views/style.css @@ -0,0 +1,146 @@ +body { + background: #305; + color: #dde; + font-size: 1em; + word-wrap: break-word; +} +a { + color: #dde; +} +form { + font-family: monospace; +} +p { + margin-top 1em; + margin-bottom: 1em; +} +input { + font-family: monospace; + background: #305; + color: #dde; + font-size: 1.0em; + line-height: 1.2em; + padding: 0.5em; +} +.header { + max-width: 1200px; + margin: 1em auto; + font-size: 1.5em; + text-align: right; +} +.header span { + margin-right: 2em; +} +.center { + max-width: 1200px; + margin: auto; + font-size: 1.5em; +} +.info { + background: #002; + border: 2px solid #dde; + margin-bottom: 1em; + padding: 0em 1em 0em 1em; +} +.info div { + margin-top 1em; + margin-bottom: 1em; +} +button, form input[type=submit] { + font-size: 0.8em; + font-family: monospace; + color: #dde; + background: #305; + border: 1px solid #dde; + padding: 0.5em; +} +button a { + text-decoration: none; +} +.info form { + margin-top: 1em; +} +.info textarea { + padding: 0.5em; + font-size: 1em; + background: #305; + color: #dde; + width: 700px; + height: 8em; + margin-bottom: 0.5em; + box-sizing: border-box; +} +@media screen and (max-width: 1024px) { + .info textarea { + width: 500px; + } +} +.info input[type="checkbox"] { + position: fixed; + top: -9999px; +} +.info input[type="checkbox"] + span:after { + content: "no"; +} +.info input[type="checkbox"]:checked + span:after { + content: "yes"; +} +.info input[type="checkbox"]:focus + span:after { + outline: 1px solid #dde; +} +.info input[type=file] { + display: none; +} +.info label { + border: 1px solid #dde; + font-size: 0.8em; + padding: 0.5em; + font-size: 0.8em; + background: #305; +} + +.honk { + width: 90%; + margin: auto; + background: #002; + border: 2px solid #dde; + border-radius: 1em; + margin-bottom: 1em; + padding-left: 1em; + padding-right: 1em; + padding-top: 0; +} +.tonk { +} +.tonk .noise { + color: #aab; + font-size: 0.8em; +} +.tonk .noise a { + color: #aab; +} +.honk a { + color: #dde; +} +.honk .title .clip a { + color: #88a; +} +.honk .title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.8em; + margin-top: 1em; +} +.honk .title img { + float: left; + margin-right: 1em; + width: 64px; + height: 64px; +} +.honk .title p { + margin-top: 0px; +} +img { + max-width: 100% +} diff --git a/zig.go b/zig.go new file mode 100644 index 0000000..393e868 --- /dev/null +++ b/zig.go @@ -0,0 +1,203 @@ +// +// Copyright (c) 2019 Ted Unangst +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +package main + +import ( + "bytes" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "time" +) + +func sb64(data []byte) string { + var sb strings.Builder + b64 := base64.NewEncoder(base64.StdEncoding, &sb) + b64.Write(data) + b64.Close() + return sb.String() + +} +func b64s(s string) []byte { + var buf bytes.Buffer + b64 := base64.NewDecoder(base64.StdEncoding, strings.NewReader(s)) + io.Copy(&buf, b64) + return buf.Bytes() +} +func sb64sha256(content []byte) string { + h := sha256.New() + h.Write(content) + return sb64(h.Sum(nil)) +} + +func zig(keyname string, key *rsa.PrivateKey, req *http.Request, content []byte) { + headers := []string{"(request-target)", "date", "host", "content-length", "digest"} + var stuff []string + for _, h := range headers { + var s string + switch h { + case "(request-target)": + s = strings.ToLower(req.Method) + " " + req.URL.RequestURI() + case "date": + s = req.Header.Get(h) + if s == "" { + s = time.Now().UTC().Format(http.TimeFormat) + req.Header.Set(h, s) + } + case "host": + s = req.Header.Get(h) + if s == "" { + s = req.URL.Hostname() + req.Header.Set(h, s) + } + case "content-length": + s = req.Header.Get(h) + if s == "" { + s = strconv.Itoa(len(content)) + req.Header.Set(h, s) + req.ContentLength = int64(len(content)) + } + case "digest": + s = req.Header.Get(h) + if s == "" { + s = "SHA-256=" + sb64sha256(content) + req.Header.Set(h, s) + } + } + stuff = append(stuff, h+": "+s) + } + + h := sha256.New() + h.Write([]byte(strings.Join(stuff, "\n"))) + sig, _ := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, h.Sum(nil)) + bsig := sb64(sig) + + sighdr := fmt.Sprintf(`keyId="%s",algorithm="%s",headers="%s",signature="%s"`, + keyname, "rsa-sha256", strings.Join(headers, " "), bsig) + req.Header.Set("Signature", sighdr) +} + +var re_sighdrval = regexp.MustCompile(`(.*)="(.*)"`) + +func zag(req *http.Request, content []byte) (string, error) { + sighdr := req.Header.Get("Signature") + + var keyname, algo, heads, bsig string + for _, v := range strings.Split(sighdr, ",") { + m := re_sighdrval.FindStringSubmatch(v) + if len(m) != 3 { + return "", fmt.Errorf("bad scan: %s from %s\n", v, sighdr) + } + switch m[1] { + case "keyId": + keyname = m[2] + case "algorithm": + algo = m[2] + case "headers": + heads = m[2] + case "signature": + bsig = m[2] + default: + return "", fmt.Errorf("bad sig val: %s", m[1]) + } + } + if keyname == "" || algo == "" || heads == "" || bsig == "" { + return "", fmt.Errorf("missing a sig value") + } + + key := zaggy(keyname) + if key == nil { + return "", fmt.Errorf("no key for %s", keyname) + } + headers := strings.Split(heads, " ") + var stuff []string + for _, h := range headers { + var s string + switch h { + case "(request-target)": + s = strings.ToLower(req.Method) + " " + req.URL.RequestURI() + case "host": + s = req.Host + default: + s = req.Header.Get(h) + } + stuff = append(stuff, h+": "+s) + } + + h := sha256.New() + h.Write([]byte(strings.Join(stuff, "\n"))) + sig := b64s(bsig) + err := rsa.VerifyPKCS1v15(key, crypto.SHA256, h.Sum(nil), sig) + if err != nil { + return "", err + } + return keyname, nil +} + +func pez(s string) (pri *rsa.PrivateKey, pub *rsa.PublicKey, err error) { + block, _ := pem.Decode([]byte(s)) + if block == nil { + err = fmt.Errorf("no pem data") + return + } + switch block.Type { + case "PUBLIC KEY": + var k interface{} + k, err = x509.ParsePKIXPublicKey(block.Bytes) + if k != nil { + pub, _ = k.(*rsa.PublicKey) + } + case "RSA PUBLIC KEY": + pub, err = x509.ParsePKCS1PublicKey(block.Bytes) + case "RSA PRIVATE KEY": + pri, err = x509.ParsePKCS1PrivateKey(block.Bytes) + if err == nil { + pub = &pri.PublicKey + } + default: + err = fmt.Errorf("unknown key type") + } + return +} + +func zem(i interface{}) (string, error) { + var b pem.Block + var err error + switch k := i.(type) { + case *rsa.PrivateKey: + b.Type = "RSA PRIVATE KEY" + b.Bytes = x509.MarshalPKCS1PrivateKey(k) + case *rsa.PublicKey: + b.Type = "PUBLIC KEY" + b.Bytes, err = x509.MarshalPKIXPublicKey(k) + default: + err = fmt.Errorf("unknown key type: %s", k) + } + if err != nil { + return "", err + } + return string(pem.EncodeToMemory(&b)), nil +}